一、HashMap 的数据结构

  1、为什么用HashMap?

    (1)HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射;

    (2)HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改;

    (3)HashMap是非synchronized,所以HashMap很快;

    (4)HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以);

    (5)HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

  2、HashMap 的内部数据结构?

    HashMap 底层是 hash 数组和单向链表实现

    JDK7 实现

      JDK1.7版本,内部使用:数组 + 链表

      JDK 1.7 中 HashMap 的底层数据结构是数组 + 链表,使用 Entry 类存储 Key 和 Value;

      HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。

    

  图中,紫色部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。

  我们看看HashMap中Entry类的代码:

 1     /** Entry是单向链表。    
 2      * 它是 “HashMap链式存储法”对应的链表。    
 3      *它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数  
 4     **/  
 5     static class Entry<K,V> implements Map.Entry<K,V> {    
 6         final K key;    
 7         V value;    
 8         // 指向下一个节点    
 9         Entry<K,V> next;    
10         final int hash;    
11 
12         // 构造函数。    
13         // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"    
14         Entry(int h, K k, V v, Entry<K,V> n) {    
15             value = v;    
16             next = n;    
17             key = k;    
18             hash = h;    
19         }    
20 
21         public final K getKey() {    
22             return key;    
23         }    
24 
25         public final V getValue() {    
26             return value;    
27         }    
28 
29         public final V setValue(V newValue) {    
30             V oldValue = value;    
31             value = newValue;    
32             return oldValue;    
33         }    
34 
35         // 判断两个Entry是否相等    
36         // 若两个Entry的“key”和“value”都相等,则返回true。    
37         // 否则,返回false    
38         public final boolean equals(Object o) {    
39             if (!(o instanceof Map.Entry))    
40                 return false;    
41             Map.Entry e = (Map.Entry)o;    
42             Object k1 = getKey();    
43             Object k2 = e.getKey();    
44             if (k1 == k2 || (k1 != null && k1.equals(k2))) {    
45                 Object v1 = getValue();    
46                 Object v2 = e.getValue();    
47                 if (v1 == v2 || (v1 != null && v1.equals(v2)))    
48                     return true;    
49             }    
50             return false;    
51         }    
52 
53         // 实现hashCode()    
54         public final int hashCode() {    
55             return (key==null   ? 0 : key.hashCode()) ^    
56                    (value==null ? 0 : value.hashCode());    
57         }    
58 
59         public final String toString() {    
60             return getKey() + "=" + getValue();    
61         }    
62 
63         // 当向HashMap中添加元素时,绘调用recordAccess()。    
64         // 这里不做任何处理    
65         void recordAccess(HashMap<K,V> m) {    
66         }    
67 
68         // 当从HashMap中删除元素时,绘调用recordRemoval()。    
69         // 这里不做任何处理    
70         void recordRemoval(HashMap<K,V> m) {    
71         }    
72     }

 

 

    HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。

  JDK8 实现

    JDK1.8版本的,内部使用数组 + 链表 / 红黑树;

    JDK 1.8 中 HashMap 的底层数据结构是数组 + 链表/红黑树,使用 Node 类存储 Key 和 Value。

    哈希表结构(链表散列:数组+链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。transient Node[] table;

    当一个值中要存储到HashMap中的时候会根据Key的值来计算出他的hash,通过hash值来确认存放到数组中的位置,如果发生hash冲突就以链表的形式存储,当链表过长的话,HashMap会把这个链表转换成红黑树来存储,如图所示:

      

 

  3、前面提到链表转红黑树是链表长度达到阈值,这个阈值是多少?

    链表转红黑树阈值是8,红黑树转链表阈值为6

  4、为什么是8,不是16,32甚至是7 ?又为什么红黑树转链表的阈值是6,不是8了呢?

    因为经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,概率说话。

    因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的转化,为了预防这种情况的发生。

    为什么是8?

    JDK 1.8 的 HashMap 和 ConcurrentHashMap 都有这样一个特点:最开始的 Map 是空的,因为里面没有任何元素,往里放元素时会计算 hash 值,计算之后,第 1 个 value 会首先占用一个桶(也称为槽点)位置,后续如果经过计算发现需要落到同一个桶中,那么便会使用链表的形式往后延长,俗称 “拉链法”,如图所示:

      

 

    图中,有的桶是空的, 比如第 4 个;有的只有一个元素,比如 1、3、6;有的就是刚才说的拉链法,比如第 2 和第 5 个桶。

    当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。

    同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。

    让我们回顾一下 HashMap 的结构示意图:

      

 

    在图中我们可以看到,有一些槽点是空的,有一些是拉链,有一些是红黑树。

    更多的时候我们会关注,为何转为红黑树以及红黑树的一些特点,可是,为什么转化的这个阈值要默认设置为 8 呢?要想知道为什么设置为 8,那首先我们就要知道为什么要转换,因为转换是第一步。

    每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))

    最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式。

    因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,所以,当链表长度 >= 8时 ,有必要将链表转换成红黑树。

 

  5、为什么不直接用红黑树?

    那为什么不一开始就用红黑树,反而要经历一个转换的过程呢?其实在 JDK 的源码注释中已经对这个问题作了解释:

Because TreeNodes are about twice the size of regular nodes,use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due removal or resizing) they are converted back to plain bins.

    通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想.

    最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,需要用红黑树的形式来保证查询的效率。对于何时应该从链表转化为红黑树,需要确定一个阈值,这个阈值默认为 8,并且在源码中也对选择 8 这个数字做了说明,原文如下:

In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million

  上面这段话的意思是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

  实验

  但是,HashMap 决定某一个元素落到哪一个桶里,是和这个对象的 hashCode 有关的,JDK 并不能阻止我们用户实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:

@Override
public int hashCode() {
    return 1;
}

 

  这里 hashCode 计算出来的值始终为 1,那么就很容易导致 HashMap 里的链表变得很长。让我们来看下面这段代码:

 1 public class HashMapDemo {
 2  
 3     public static void main(String[] args) {
 4         HashMap map = new HashMap<HashMapDemo,Integer>(1);
 5         for (int i = 0; i < 1000; i++) {
 6             HashMapDemo hashMapDemo1 = new HashMapDemo();
 7             map.put(hashMapDemo1, null);
 8         }
 9         System.out.println("运行结束");
10     }
11  
12     @Override
13     public int hashCode() {
14         return 1;
15     }
16 }

 

  在这个例子中,我们建了一个 HashMap,并且不停地往里放入值,所放入的 key 的对象,它的 hashCode 是被重写过得,并且始终返回 1。

  这段代码运行时,如果通过 debug 让程序暂停在 System.out.println("运行结束") 这行语句,我们观察 map 内的节点,可以发现已经变成了 TreeNode,而不是通常的 Node,这说明内部已经转为了红黑树。

  事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

  总结:

  (1)当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。

  (2)同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。

  通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值

 

  6、链表树化

    指的就是把链表转换成红黑树,树化需要满足以下两个条件:

      • 链表长度大于等于8

      • table数组长度大于等于64

    为什么table数组容量大于等于64才树化?

    因为当table数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。

  7、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

    之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

  8、说说你对红黑树的见解?

    红黑树是一种自平衡的二叉查找树,是一种高效的查找树。

    红黑树通过如下的性质定义实现自平衡:

    (1)每个节点非红即黑
    (2)根节点总是黑色的
    (3)如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
    (4)每个叶子节点都是黑色的空节点(叶子是NIL节点)
    (5)从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
    (6)每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
    (7)从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点(简称黑高)。

        

 

  9、

二、HashMap 的初始化

  1、初始化

    在看源码之前我们需要先看看一些基本属性

//默认初始容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//默认负载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//Hash数组(在resize()中初始化)
transient Node<K,V>[] table;
//元素个数
transient int size;
//容量阈值(元素个数大于等于该值时会自动扩容)
int threshold;

  table数组里面存放的是Node对象,Node是HashMap的一个内部类,用来表示一个key-value,源码如下:

 1 static class Node<K,V> implements Map.Entry<K,V> {
 2     final int hash;
 3     final K key;
 4     V value;
 5     Node<K,V> next;
 6 
 7     Node(int hash, K key, V value, Node<K,V> next) {
 8         this.hash = hash;
 9         this.key = key;
10         this.value = value;
11         this.next = next;
12     }
13 
14     public final K getKey()        { return key; }
15     public final V getValue()      { return value; }
16     public final String toString() { return key + "=" + value; }
17     public final int hashCode() {
18         return Objects.hashCode(key) ^ Objects.hashCode(value);//^表示相同返回0,不同返回1
19         //Objects.hashCode(o)————>return o != null ? o.hashCode() : 0;
20     }
21 
22     public final V setValue(V newValue) {
23         V oldValue = value;
24         value = newValue;
25         return oldValue;
26     }
27 
28     public final boolean equals(Object o) {
29         if (o == this)
30             return true;
31         if (o instanceof Map.Entry) {
32             Map.Entry<?,?> e = (Map.Entry<?,?>)o;
33             //Objects.equals(1,b)————> return (a == b) || (a != null && a.equals(b));
34             if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))
35                 return true;
36         }
37         return false;
38     }
39 }

 

  总结

  • 默认初始容量为16,默认负载因子为0.75

  • threshold = 数组长度 * loadFactor,当元素个数大于等于threshold(容量阈值)时,HashMap会进行扩容操作

  • table数组中存放指向链表的引用

  这里需要注意的一点是 table数组并不是在构造方法里面初始化的,它是在resize(扩容)方法里进行初始化的

  这里说句题外话:可能有***钻的面试官会问为什么默认初始容量要设置为16?为什么负载因子要设置为0.75?我们都知道HashMap数组长度被设计成2的幂次方(下面会讲),那为什么初始容量不设计成4、8或者32.... 其实这是JDK设计者经过权衡之后得出的一个比较合理的数字,如果默认容量是8的话,当添加到第6个元素的时候就会触发扩容操作,扩容操作是非常消耗CPU的,32的话如果只添加少量元素则会浪费内存,因此设计成16是比较合适的,负载因子也是同理。

 

  2、HashMap的初始化,那HashMap怎么设定初始容量大小的吗?

    一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16。如果传入 new HashMap(18);此时HashMap初始容量为多少?(32)(补充说明:实现代码如下)

    在HashMap中有个静态方法tableSizeFor ,tableSizeFor方法保证函数返回值是大于等于给定参数initialCapacity最小的2的幂次方的数值 。

1 static final int tableSizeFor(int cap) {
2   int n = cap - 1;
3   n |= n >>> 1;
4   n |= n >>> 2;
5   n |= n >>> 4;
6   n |= n >>> 8;
7   n |= n >>> 16;
8   return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
9 }

 

  

  补充说明:下图是详细过程,算法就是让初始二进制分别右移1,2,4,8,16位,与自己异或,把高位第一个为1的数通过不断右移,把高位为1的后面全变为1,111111 + 1 = 1000000 = (符合大于50并且是2的整数次幂 )

    

 

 

 

    该算法让最高位的1后面的位全变为1。最后再让结果n+1,即得到了2的整数次幂的值了。

    让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。通过一系列位运算大大提高效率。

    tableSizeFor的功能(不考虑大于最大容量的情况)是返回大于等于输入参数且最近的2的整数次幂的数。比如10,则返回16,设置为18,返回 32。

    那在什么地方会用到tableSizeFor方法呢?

    答案就是在构造方法里面调用该方法来设置threshold,也就是容量阈值。

 

 

  3、为什么要把数组长度设计为2的幂次方呢?

    (1)当数组长度为2的幂次方时,可以使用位运算来计算元素在数组中的下标

      HashMap是通过index=hash&(table.length-1)这条公式来计算元素在table数组中存放的下标,就是把元素的hash值和数组长度减1的值做一个与运算,即可求出该元素在数组中的下标,这条公式其实等价于hash%length,也就是对数组长度求模取余,只不过只有当数组长度为2的幂次方时,hash&(length-1)才等价于hash%length,使用位运算可以提高效率。

      那么为了保证根据上述公式计算出来的 index 值是分布均匀的,我们就必须保证 Length 是 2 的次幂

      解释一下:2 的次幂,也就是 2 的 n 次方,它的二进制表示就是 1 后面跟着 n 个 0,那么 2 的 n 次方 - 1 的二进制表示就是 n 个 1。而对于 & 操作来说,任何数与 1 做 & 操作的结果都是这个数本身。也就是说,index 的结果等同于 HashCode(key) 后 n 位的值,只要 HashCode 本身是分布均匀的,那么我们这个 Hash 算法的结果就是均匀的。

    (2)增加hash值的随机性,减少hash冲突

      如果 length 为 2 的幂次方,则 length-1 转化为二进制必定是 11111……的形式,这样的话可以使所有位置都能和元素hash值做与运算,如果是如果 length 不是2的次幂,比如length为15,则length-1为14,对应的二进制为 1110,在和hash 做与运算时,最后一位永远都为0 ,浪费空间。

  4、为什么要设置为threshold呢?

    因为在扩容方法里第一次初始化table数组时会将threshold设置数组的长度,后续在讲扩容方法时再介绍。

 1 /*传入初始容量和负载因子*/
 2 public HashMap(int initialCapacity, float loadFactor) {
 3 
 4     if (initialCapacity < 0)
 5         throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
 6     if (initialCapacity > MAXIMUM_CAPACITY)
 7         initialCapacity = MAXIMUM_CAPACITY;
 8     if (loadFactor <= 0 || Float.isNaN(loadFactor))
 9         throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
10 
11     this.loadFactor = loadFactor;
12     this.threshold = tableSizeFor(initialCapacity);
13 }

 

 

  5、HashMap 的 table 的容量如何确定?loadFactor 是什么?该容量如何变化?这种变化会带来什么问题?

    (1)table 数组大小是由 capacity 这个参数确定的,默认是16,也可以构造时传入,最大限制是1<<30;

      (2)loadFactor 是装载因子,主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如table 数组大小为 16,装载因子为 0.75 时,threshold 就是12,当 table 的实际大小超过 12 时,table就需要动态扩容;

     (3)扩容时,调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是 threshold)

     (4)如果数据很大的情况下,扩展时将会带来性能的损失,在性能要求很高的地方,这种损失很可能很致命。

  6、请解释一下HashMap的参数loadFactor,它的作用是什么?

    loadFactor表示HashMap的拥挤程度,影响 hash 操作到同一个数组位置的概率。

    默认loadFactor等于0.75,当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制 loadFactor。

  7、为什么HashMap的加载因子是0.75?

    主要对以下内容进行介绍:

    • 为什么HashMap需要加载因子?

    • 解决冲突有什么方法?

    • 为什么加载因子一定是0.75?而不是0.8,0.6?

  (1)为什么HashMap需要加载因子?

    HashMap的底层是哈希表,是存储键值对的结构类型,它需要通过一定的计算才可以确定数据在哈希表中的存储位置:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// AbstractMap
public int hashCode() {
     int h = 0;
     Iterator<Entry<K,V>> i = entrySet().iterator();
     while (i.hasNext())
         h += i.next().hashCode();

     return h;
}

    一般的数据结构,不是查询快就是插入快,HashMap就是一个插入慢、查询快的数据结构。

    但这种数据结构容易产生两种问题:

    ① 如果空间利用率高,那么经过的哈希算法计算存储位置的时候,会发现很多存储位置已经有数据了(哈希冲突); ② 如果为了避免发生哈希冲突,增大数组容量,就会导致空间利用率不高。

    而加载因子就是表示Hash表中元素的填满程度。

加载因子 = 填入表中的元素个数 / 散列表的长度

    加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;

    加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。

    冲突的机会越大,说明需要查找的数据还需要通过另一个途径查找,这样查找的成本就越高。

    因此,必须在“冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷。

    所以我们也能知道,影响查找效率的因素主要有这几种:

      •  散列函数是否可以将哈希表中的数据均匀地散列?

      •  怎么处理冲突?

      •  哈希表的加载因子怎么选择?

    这里主要对后两个问题进行介绍。哈希冲突放在哈希函数一节分析学习。

 

  (2)为什么HashMap加载因子一定是0.75?而不是0.8,0.6?

    我们知道,HashMap的底层其实也是哈希表(散列表),而解决冲突的方式是链地址法。HashMap的初始容量大小默认是16,为了减少冲突发生的概率,当HashMap的数组长度到达一个临界值的时候,就会触发扩容,把所有元素rehash之后再放在扩容后的容器中,这是一个相当耗时的操作。

    而这个临界值就是由加载因子和当前容器的容量大小来确定的:

临界值 = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR

    即默认情况下是16x0.75=12时,就会触发扩容操作。

    那么为什么选择了0.75作为HashMap的加载因子呢?这个跟一个统计学里很重要的原理——泊松分布有关。

    泊松分布是统计学和概率学常见的离散概率分布,适用于描述单位时间内随机事件发生的次数的概率分布。

    有兴趣的读者可以看看维基百科或者阮一峰老师的这篇文章:泊松分布和指数分布:10分钟教程[1]

      

    等号的左边,P 表示概率,N表示某种函数关系,t 表示时间,n 表示数量。等号的右边,λ 表示事件的频率。

    在HashMap的源码中有这么一段注释:

* Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million

  在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布。

  忽略方差,即X = λt,P(λt = k),其中λt = 0.5的情况,按公式:

    

    计算结果如上述的列表所示,当一个bin中的链表长度达到8个元素的时候,概率为0.00000006,几乎是一个不可能事件。

    所以我们可以知道,其实常数0.5是作为参数代入泊松分布来计算的,而加载因子0.75是作为一个条件,当HashMap长度为length/size ≥ 0.75时就扩容,在这个条件下,冲突后的拉链长度和概率结果为:

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006

 

  (3)那么为什么不可以是0.8或者0.6呢?

    HashMap中除了哈希算法之外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在创建时的容量,加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。

    在维基百科来描述加载因子:

对于开放定址法,加载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了加载因子为0.75,超过此值将resize散列表。

    在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少扩容rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,以便减少扩容操作。

    选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。

    泊松分布和指数分布:10分钟教程: http://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html

 

  8、

三、HashMap的 hash 函数

  1、HashMap的哈希函数怎么设计的吗?

JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。

hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。

1   static final int hash(Object key) {
2         int h;
3         //1.允许 key 为 null,hash = 0
4         //2. ^ 异或,后面介绍该算法
5         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
6     }

 

 

 1 static final int hash(Object key) {  
 2     if (key == null){  
 3         return 0;  
 4     }  
 5      int h;  
 6      h=key.hashCode();返回散列值也就是hashcode  
 7       // ^ :按位异或  
 8       // >>>:无符号右移,忽略符号位,空位都以0补齐  
 9       //其中n是数组的长度,即Map的数组部分初始化长度  
10      return  (n-1)&(h ^ (h >>> 16));  
11 }  

 

 

 

  2、那你知道为什么这么设计吗?

这个也叫扰动函数,这么设计有二点原因:

①一定要尽可能降低hash碰撞,越分散越好;

② 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

  3、为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

因为 key.hashCode() 函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为-2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。

设想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比%运算要快。

1 bucketIndex = indexFor(hash, table.length);
2 
3 static int indexFor(int h, int length) {
4      return h & (length-1);
5 }

 

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101 //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。

这时候 hash 函数(“扰动函数”)的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

 

 

 

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

 

 

 

结果显示,当HashMap数组长度为512的时候(),也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

另外Java1.8相比1.7做了调整,1.7做了四次移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。

下面是1.7的hash代码:

1 static int hash(int h) {
2     h ^= (h >>> 20) ^ (h >>> 12);
3     return h ^ (h >>> 7) ^ (h >>> 4);
4 }

 

 

 

  4、HashMap 的长度为什么是 2 的 N 次方呢?

为了能让 HashMap 存数据和取数据的效率高,尽可能地减少 hash 值的碰撞,也就是说尽量把数 据能均匀的分配,每个链表或者红黑树长度尽量相等。我们首先可能会想到 % 取模的操作来实现。下面是回答的重点哟:

取余(%)操作中如果除数是 2 的幂次,则等价于与其除数减一的与(&)操作(也就是说 hash % length == hash &(length - 1) 的前提是 length 是 2 的 n 次方)。并且,采用二进 制位操作 & ,相对于 % 能够提高运算效率。

这就是为什么 HashMap 的长度需要 2 的 N 次方了。

 

  5、为什么要用异或运算符?

保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

 

  6、解决hash冲突的有几种方法?

(1)再哈希法:如果hash出的index已经有值,就再hash,不行继续hash,直到不发生冲突位置,直至找到空的index位置。这种方法不容易产生堆集,但是会增加计算时间。这个办法最容易想到。但有2个缺点:

  • 比较浪费空间,消耗效率。根本原因还是数组的长度是固定不变的,不断hash找出空的index,可能越界,这时就要创建新数组,而老数组的数据也需要迁移。随着数组越来越大,消耗不可小觑。

  • get不到,或者说get算法复杂。进是进去了,想出来就没那么容易了。

(2)开放地址方法

如果hash出的index已经有值,通过算法在它前面或后面的若干位置寻找空位,这个和再hash算法差别不大。

当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。

Hi = (H(key) + di) MOD m,其中i=1,2,…,k(k<=m-1)

H(key)为哈希函数,m为哈希表表长,di为增量序列,i为已发生冲突的次数。其中,开放定址法根据步长不同可以分为3种:

按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。

(2.1)线性探查法(Linear Probing):di = 1,2,3,…,m-1

简单地说,就是以当前冲突位置为起点,步长为1循环查找,直到找到一个空的位置,如果循环完了都占不到位置,就说明容器已经满了。举个栗子,就像你在饭点去街上吃饭,挨家去看是否有位置一样。

下面给一个线性探查法的例子

问题:已知一组关键字为(26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。

解答:为了减少冲突,通常令装填因子α由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为(0,10,2,12,5,2,3,12,6,12)。

前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入T[0],T[10),T[2],T[12]和T[5]中。

当插入第6个关键字15时,其散列地址2(即h(15)=15%13=2)已被关键字41(15和41互为同义词)占用。故探查h1=(2+1)%13=3,此地址开放,所以将15放入T[3]中。

当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。

当插入第8个关键字12时,散列地址12已被同义词38占用,故探查hl=(12+1)%13=0,而T[0]亦被26占用,再探查h2=(12+2)%13=1,此地址开放,可将12插入其中。

类似地,第9个关键字06直接插入T[6]中;而最后一个关键字51插人时,因探查的地址12,0,1,…,6均非空,故51插入T[7]中。

(2.2)平方探测法(Quadratic Probing):di = ±12, ±22,±32,…,±k2(k≤m/2)

相对于线性探查法,这就相当于的步长为di = i2来循环查找,直到找到空的位置。以上面那个例子来看,现在你不是挨家去看有没有位置了,而是拿手机算去第i2家店,然后去问这家店有没有位置。

(2.3)伪随机探测法:di = 伪随机数序列

这个就是取随机数来作为步长。还是用上面的例子,这次就是完全按心情去选一家店问有没有位置了。

但开放定址法有这些缺点:

  • 这种方法建立起来的哈希表,当冲突多的时候数据容易堆集在一起,这时候对查找不友好;

  • 删除结点的时候不能简单将结点的空间置空,否则将截断在它填入散列表之后的同义词结点查找路径。因此如果要删除结点,只能在被删结点上添加删除标记,而不能真正删除结点;

  • 如果哈希表的空间已经满了,还需要建立一个溢出表,来存入多出来的元素。

(3)建立公共溢出区: 把冲突的hash值放到另外一块溢出区。

假设哈希函数的值域为[0, m-1],设向量HashTable[0,…,m-1]为基本表,每个分量存放一个记录,另外还设置了向量OverTable[0,…,v]为溢出表。基本表中存储的是关键字的记录,一旦发生冲突,不管他们哈希函数得到的哈希地址是什么,都填入溢出表。

但这个方法的缺点在于:查找冲突数据的时候,需要遍历溢出表才能得到数据。

 

(4)链式地址法:

将冲突位置的元素构造成链表。在添加数据的时候,如果哈希地址与哈希表上的元素冲突,就放在这个位置的链表上。

把产生hash冲突的hash值以链表形式存储在index位置上。HashMap用的就是该方法。优点是不需要另外开辟新空间,也不会丢失数据,寻址也比较简单。但是随着hash链越来越长,寻址也是更加耗时。好的hash算法就是要让链尽量短,最好一个index上只有一个值。也就是尽可能地保证散列地址分布均匀,同时要计算简单。

拉链法的优点:

  • 处理冲突的方式简单,且无堆集现象,非同义词绝不会发生冲突,因此平均查找长度较短;

  • 由于拉链法中各链表上的结点空间是动态申请的,所以它更适合造表前无法确定表长的情况;

  • 删除结点操作易于实现,只要简单地删除链表上的相应的结点即可。

拉链法的缺点:需要额外的存储空间。

从HashMap的底层结构中我们可以看到,HashMap采用是数组+链表/红黑树的组合来作为底层结构,也就是开放地址法+链地址法的方式来实现HashMap。

 

 

 

  7、有什么方法可以减少碰撞?

(1)扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。)

(2)使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

 

  8、HashMap怎样解决hash冲突吗

HashMap 采用一种所谓的“Hash 算法”来决定每个元素的存储位置。当程序执行 map.put(String,Obect)方法 时,系统将调用String的 hashCode() 方法得到其 hashCode 值——每个 Java 对象都有 hashCode() 方法,都可通过该方法获得它的 hashCode 值。得到这个对象的 hashCode 值之后,系统会根据该 hashCode 值来决定该元素的存储位置。

 1 public V put(K key, V value) {  
 2         if (key == null)  
 3             return putForNullKey(value);  
 4         int hash = hash(key.hashCode());  
 5         int i = indexFor(hash, table.length);  
 6         for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
 7             Object k;  
 8             //判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。  
 9             //如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果不相同,这时就是产生了hash冲突。  
10             //Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。  
11             //系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),  
12             //那系统必须循环到最后才能找到该元素。  
13             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
14                 V oldValue = e.value;  
15                 e.value = value;  
16                 return oldValue;  
17             }  
18         }  
19         modCount++;  
20         addEntry(hash, key, value, i);  
21         return null;  
22     }  

 

上面程序中用到了一个重要的内部接口:Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。

这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。HashMap程序经过我改造,我故意的构造出了hash冲突现象,因为HashMap的初始大小16,但是我在hashmap里面放了超过16个元素,并且我屏蔽了它的resize()方法。不让它去扩容.

这时HashMap的底层数组Entry[] table结构如下:

 

 

Hashmap里面的bucket出现了单链表的形式,散列表要解决的一个问题就是散列值的冲突问题,通常是两种方法:链表法和开放地址法

  • 链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;

  • 开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。

java.util.HashMap采用的链表法的方式,链表是单向链表。形成单链表的核心代码如下:

1 void addEntry(int hash, K key, V value, int bucketIndex) {  
2     Entry<K,V> e = table[bucketIndex];  
3     table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
4     if (size++ >= threshold)  
5         resize(2 * table.length);  
6 }

上面方法的代码很简单,但其中包含了一个设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。

HashMap里面没有出现hash冲突时,没有形成单链表时,hashmap查找元素很快,get()方法能够直接定位到元素,但是出现单链表后,单个bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。

当创建 HashMap 时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。

 

  9、

四、HashMap的插入过程原理

五、HashMap 的常用操作

六、HashMap 的扩容

七、HashMap 的线程安全性

八、HahsMap 的遍历

  1、HashMap 遍历

大体上可以分为4类:

(1)迭代器

(2)ForEach 遍历

(3)lambda 表达式遍历

(4)StreamsApi 遍历

但是每种类型下有不同的实现方式,所以又可以分为7种:

 

 

 

  2、使用迭代器 EntrySet 的方式遍历

 1     @Test
 2     //1,使用迭代器 EntrySet 的方式遍历
 3     public void demo1(){
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1,"娇娇");
 8         map.put(2,"娇娇1");
 9         map.put(3,"娇娇2");
10         map.put(4,"娇娇3");
11         map.put(5,"娇娇4");
12         map.put(5,"娇娇5");
13     //遍历
14         Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
15         while (iterator.hasNext()){
16             Map.Entry<Integer, String> next = iterator.next();
17             System.out.println(next.getKey());
18             System.out.println(next.getValue());
19         }
20     }

 

 

  3、使用迭代器的KeySet

 1     @Test
 2     //2,使用迭代器的KeySet
 3     public void demo1(){
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1,"娇娇");
 8         map.put(2,"娇娇1");
 9         map.put(3,"娇娇2");
10         map.put(4,"娇娇3");
11         map.put(5,"娇娇4");
12         map.put(5,"娇娇5");
13     //遍历
14         Iterator<Integer> iterator = map.keySet().iterator();
15         while (iterator.hasNext()){
16             Integer key = iterator.next();
17             System.out.print(key);
18             System.out.print(map.get(key));
19         }
20     }

 

 

  4、使用 For Each EntrySet 的方式进行遍历;

 1     @Test
 2     //3,使用 For Each EntrySet 的方式进行遍历;
 3     public void demo1(){
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1,"娇娇");
 8         map.put(2,"娇娇1");
 9         map.put(3,"娇娇2");
10         map.put(4,"娇娇3");
11         map.put(5,"娇娇4");
12         map.put(5,"娇娇5");
13     //遍历
14         for (Map.Entry<Integer,String> entry: map.entrySet()
15              ) {
16             System.out.println("entry.getKey() = " + entry.getKey());
17             System.out.println("entry.getValue() = " + entry.getValue());
18         }
19     }

 

 

  5、使用 Lambda 表达式的方式进行遍历;

 1     @Test
 2     //5,使用 Lambda 表达式的方式进行遍历;
 3     public void demo1() {
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1, "娇娇");
 8         map.put(2, "娇娇1");
 9         map.put(3, "娇娇2");
10         map.put(4, "娇娇3");
11         map.put(5, "娇娇4");
12         map.put(5, "娇娇5");
13         //遍历
14         map.forEach((key,value) -> {
15             System.out.print(key);
16             System.out.print(value);
17 
18         });
19 
20     }

 

 

  6、使用 Streams API 单线程的方式进行遍历;

 1     @Test
 2     //6,使用 Streams API 单线程的方式进行遍历;
 3     public void demo1() {
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1, "娇娇");
 8         map.put(2, "娇娇1");
 9         map.put(3, "娇娇2");
10         map.put(4, "娇娇3");
11         map.put(5, "娇娇4");
12         map.put(5, "娇娇5");
13         //遍历
14         map.entrySet().stream().forEach((integerStringEntry -> {
15             System.out.println(integerStringEntry.getKey());
16             System.out.println(integerStringEntry.getValue());
17 
18         }));
19 
20     }

 

 

  7、使用 Streams API 多线程的方式进行遍历。

 1     @Test
 2     //7,使用 Streams API 双线程的方式进行遍历;
 3     public void demo1() {
 4         //创建Map 对象
 5         Map<Integer, String> map = new HashMap<>();
 6         //添加数据
 7         map.put(1, "娇娇");
 8         map.put(2, "娇娇1");
 9         map.put(3, "娇娇2");
10         map.put(4, "娇娇3");
11         map.put(5, "娇娇4");
12         map.put(5, "娇娇5");
13         //遍历
14         map.entrySet().parallelStream().forEach((integerStringEntry -> {
15             System.out.println(integerStringEntry.getKey());
16             System.out.println(integerStringEntry.getValue());
17 
18         }));
19 
20     }

 

 

  8、性能测试

 

 除了 Stream 的并行循环,其他几种遍历方法的性能差别不大,但从简洁性和优雅性上来看,Lambda 和 Stream 无疑是最适合的遍历方式。

  9、遍历常见问题

在工作中HashMap的遍历操作也是非常常用的,也许有很多小伙伴喜欢用for-each来遍历,但是你知道其中有哪些坑吗?

看下面的例子,当我们在遍历HashMap的时候,若使用remove方法删除元素时会抛出ConcurrentModificationException异常

1     Map<String, Integer> map = new HashMap<>();
2            map.put("1", 1);
3         map.put("2", 2);
4         map.put("3", 3);
5         for (String s : map.keySet()) {
6             if (s.equals("2"))
7                 map.remove("2");
8         }

 

这就是常说的fail-fast(快速失败)机制,下面有详解。

 

九、Java 8 中 HashMap 到底有啥不同?

十、HashMap 中JDK7到JDK8的优化

十一、LinkedHashMap

LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

 1 /**
 2  * The head (eldest) of the doubly linked list.
 3 */
 4 transient LinkedHashMap.Entry<K,V> head;
 5 
 6 /**
 7   * The tail (youngest) of the doubly linked list.
 8 */
 9 transient LinkedHashMap.Entry<K,V> tail;
10 //链接新加入的p节点到链表后端
11 private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
12   LinkedHashMap.Entry<K,V> last = tail;
13   tail = p;
14   if (last == null)
15     head = p;
16   else {
17     p.before = last;
18     last.after = p;
19   }
20 }
21 //LinkedHashMap的节点类
22 static class Entry<K,V> extends HashMap.Node<K,V> {
23   Entry<K,V> before, after;
24   Entry(int hash, K key, V value, Node<K,V> next) {
25     super(hash, key, value, next);
26   }
27 }

 

  示例代码:

 1 public static void main(String[] args) {
 2   Map<String, String> map = new LinkedHashMap<String, String>();
 3   map.put("1", "hello");
 4   map.put("2", "HashMap");
 5   map.put("3", "LinkedHashMap");
 6 
 7   for(Map.Entry<String,String> item: map.entrySet()){
 8     System.out.println(item.getKey() + ":" + item.getValue());
 9   }
10 }
11 //console输出
12 1:hello
13 2:HashMap
14 3:LinkedHashMap

 

 

十二、Hashtable

1、HashTable

  • 数组 + 链表方式存储

  • 默认容量:11(质数 为宜)

  • put:

    • 索引计算 : (key.hashCode() & 0x7FFFFFFF)% table.length

    • 若在链表中找到了,则替换旧值,若未找到则继续

    • 当总元素个数超过容量*加载因子时,扩容为原来 2 倍并重新散列。

    • 将新元素加到链表头部

  • 对修改 Hashtable 内部共享数据的方法添加了 synchronized,保证线程安全。

  •  

十三、TreeMap

1、TreeMap怎么实现有序的?

TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用户key的比较。

 

十四、ConcurrentHashMap

  1、ConcurrentHashMap 简单介绍?

(1)重要的常量:

private transient volatile int sizeCtl;
    当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容;
    当为 0 时,表示 table 还没有初始化;
    当为其他正数时,表示初始化或者下一次进行扩容的大小。

  (2)数据结构:

1 Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据;
2 TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;
3 TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。

(3)存储对象时(put() 方法):

(1)如果没有初始化,就调用 initTable() 方法来进行初始化;
(2)如果没有 hash 冲突就直接 CAS 无锁插入;
(3)如果需要扩容,就先进行扩容;
(4)如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
(5)如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一次进入循环
(6)如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。

 

(4)扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。

helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。

  

(5)获取对象时(get()方法):

(1)计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回;
(2)如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法,查找该结点,匹配就返回;
(3)以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。

 

  2、知道ConcurrentHashMap的分段锁的实现原理吗?

ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。

 

 

 

 

  3、

  4、

  5、

  6、

  7、

  8、

  9、

  10、

  11、

  12、

 

十五、HashMap 与其他Map的对比

  1、HashMap内部节点是有序的吗?

HashMap 是无序的,根据hash值随机插入

 

  2、那有没有有序的Map?

LinkedHashMap 和 TreeMap

 

  3、HashMap,LinkedHashMap,TreeMap 有什么区别?

    HashMap 参考其他问题;

    (1)与LinkedHashMap对比:

      LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢;

LinkedHashMap是继承于HashMap,是基于HashMap和双向链表来实现的。

HashMap无序;LinkedHashMap有序,可分为插入顺序和访问顺序两种。如果是访问顺序,那put和get操作已存在的Entry时,都会把Entry移动到双向链表的表尾(其实是先删除再插入)。

LinkedHashMap存取数据,还是跟HashMap一样使用的Entry[]的方式,双向链表只是为了保证顺序。

LinkedHashMap是线程不安全的。

    (2)对TreeMap 对比

①、HashMap 是线程不安全的,HashTable 是线程安全的;

②、由于线程安全,所以 HashTable 的效率比不上 HashMap;

③、HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而 HashTable不允许;

④、HashMap 默认初始化数组的大小为16,HashTable 为 11,前者扩容时,扩大两倍,后者扩大两倍+1;

⑤、HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode;

    TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指定排序的比较器)

 

 

  4、HashMap & TreeMap & LinkedHashMap 使用场景?

一般情况下,使用最多的是 HashMap。HashMap:在 Map 中插入、删除和定位元素时;TreeMap:在需要按自然顺序或自定义顺序遍历键的情况下;LinkedHashMap:在需要输出的顺序和输入的顺序相同的情况下。

  5、HashMap 和 HashTable 有什么区别?

(1)HashMap 是线程不安全的,HashTable 是线程安全的;

(2)由于线程安全,所以 HashTable 的效率比不上 HashMap;

(3)HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而 HashTable不允许;

(4)HashMap 默认初始化数组的大小为16,HashTable 为 11,前者扩容时,扩大两倍,后者扩大两倍+1;

(5)HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode](https://mp.weixin.qq.com/s?__biz=MzU1MzUyMjYzNg==&mid=2247484724&idx=2&sn=b95de726f836b96a1699b00f7af76939&scene=21#wechat_redirect)

 

 

  6、与 ConcurrentHashMap 的区别?

① 都是 key-value 形式的存储数据;

② HashMap 是线程不安全的,ConcurrentHashMap 是 JUC 下的线程安全的;

③ HashMap 底层数据结构是数组 + 链表(JDK 1.8 之前)。JDK 1.8 之后是数组 + 链表 + 红黑 树。当链表中元素个数达到 8 的时候,链表的查询速度不如红黑树快,链表会转为红黑树,红 黑树查询速度快;

④ HashMap 初始数组大小为 16(默认),当出现扩容的时候,以 0.75 * 数组大小的方式进行扩 容;

⑤ ConcurrentHashMap 在 JDK 1.8 之前是采用分段锁来现实的 Segment + HashEntry, Segment 数组大小默认是 16,2 的 n 次方;JDK 1.8 之后,采用 Node + CAS + Synchronized 来保证并发安全进行实现。

  7、

  8、

  9、

 

十六、

十七、

十八、

 

posted on 2021-05-10 12:59  格物致知_Tony  阅读(465)  评论(0编辑  收藏  举报