一切福田,不離方寸,從心而覓,感無不通。

JDK7 和 JDK8 的 HashMap

一、HashMap 的存储结构键值均可为 null

JDK7 的 HashMap

JDK7 的 HashMap 的存储结构其实就是哈希表的存储结构(由数组与链表结合组成,称为链表的数组)。如下图:

HashMap的存储结构-JDK7

HashMap 的主干是一个 Entry 数组。Entry 是 HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。当发生 hash 冲突时,数组节点则会变成一个链表,用于存储 hash 冲突的数据。

如上图,HashMap 中元素存储的形式是 key-value 键值对,即 Entry 对。一个长度为 16 的数组中,每个元素存储的是一个链表的头结点。这些元素是按照什么样的规则存储到数组中呢?一般是通过 hash(key.hashCode())%len 获得,也就是元素的 key 的哈希值对数组长度取模得到。上述哈希表中,12%16=12;28%16=12;108%16=12;140%16=12。所以 12、28、108 以及 140 都存储在数组下标为 12 的位置。

JDK8 的 HashMap

当链表长度超过阈值(TREEIFY_THRESHOLD) 8,table 的长度大于 64如果小于 64,就通过扩容的方式来解决,避免红黑树结构化链表转换为红黑树

HashMap的存储结构-JDK8

区别:

1️⃣JDK7:new HashMap() 时,底层创建 size 为 16 的数组Entry[]

2️⃣JDK8:首次调用 put() 时,底层创建 size 为 16 的数组Node[]

3️⃣JDK7 底层:数组+链表。JDK8 底层:数组+链表+红黑树。
当数组某个索引位置的元素以链表形式存在的数据个数 >8 且当前数组的长度 >64 时,该索引位置上的所有数据改为使用红黑树存储。

4️⃣新节点插入到链表的顺序不同:JDK7—头插法因为插入头部效率高,不需要遍历整个链表,JDK8—尾插法。


 

道理很简单,就是创建一个 Entry,该 Entry 的 next 是 e(参照new Entry<>(hash,key,value,e)的实现),这个 e 就是之前的头结点,然后把当前 table 的位置放上新建的 Entry,也就是插入在链表的头部了。


 

JDK8 链表大于 8 的时候要树化,需要遍历算该链表的长度,直接把数据放到后面,这样方便,减少了把数据移动到头数组位置这一步,效率也提高了。而且,JDK7 中出现的 HashMap 死循环 bug 不会有了,因为这里是只是平移,没有调换顺序。

5️⃣JDK8 的 hash 算法有所简化
JDK7 默认初始化大小 16,加载因子 0.75。如果传入了 size,会变为大于等于当前值的 2 的 n 次方的最小的数。


 

为什么是 2 的幂次方?

因为确定下标位置的 indexFor 方法,h&(length-1)。length 是 2 的次方,那么 length-1 总是 0001 1111 等后面都是 1 的数,h& 它之后,其实就相当于取余,与的效率比取余高,所以用了这种方式达到高效率。下面是 JDK7 的 indexfor 的实现:


 

二、JDK7 的 HashMap 底层实现原理

put方法实现原理

HashMap map = new HashMap();
在实例化后,底层创建了 size 为 16 的一维数组 Entry[] table。
map.put(key1,value1);
首先,调用 key1 所在类的 hashcode() 计算 key1 的哈希值,此哈希值经过某种算法以后,得到在 Entry 数组中的存放位置。
1️⃣如果此位置上的数据为空,此时的 key1-value1 添加成功。【情况一
2️⃣如果此位置上的数据不为空,(意味着此位置上存在一个或多个数据以链表形式存在),比较 key1 和已经存在的一个或多个数据的哈希值:
①如果 key1 的哈希值与已经存在的数据的哈希值都不相同,此时 key1-value1 添加成功。【情况二
②如果 key1 的哈希值和已经存在的某一个数据(key2-value2)的哈希值相同,然后调用 key1 所在类的 equals(key2) 继续比较。如果返回 false:此时 key1-value1 添加成功。【情况三
如果返回 true:使用 value1 替换 value2。【情况四
说明:【情况二】和【情况三】此时 key1-value1 和原来的数据以链表的方式存储。在不断的添加过程中,当超出临界值(map 中元素总数超过 Entry 数组的 75%),且要存放的位置非空时,为了减少链表长度,元素分配更均匀,触发扩容操作。默认的扩容方式:扩容为原来容量 size 的 2 倍,并将原有的数据复制过来,size 一定为 2 的 n 次幂。扩容针对整个 Map。每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入。插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)。

hashCode 是定位,确定存储位置;equals 是定性,比较两者是否相等。

get方法实现原理

与 put 类似,会首先调用 Key 值中的 hashCode(),用于获取对应的 bucket 的下标值,找到 bucket 的位置后,再通过 key.equals() 找到对应的键值对,从而获得对应的 value 值。

三、JDK7 的 HashMap 源码分析

属性信息


 

构造方法


 

put方法

JDK 的底层源码很多都用到了位运算,先回顾下:

左移<<:高位舍掉,低位补0
右移>>:低位舍掉,高位补0;


 

int capacity = roundUpToPowerOf2(toSize)
官方的解释:找到一个 2 的幂次方数,要求大于等于toSize(初始化的容量大小)。比如传入的容量是 12,那么最终初始化的容量就是 16;如果是 15,也是 16;如果是 17,则就是 32,反正就是大于等于该数的 2 的幂次方的最小值。

return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
这行代码的使用了两个三目运算符。第一个运算符比较简单,就是要设置的数组大小是否大于了允许的最大容量,如果大于则使用最大的容量,否则又一个三目运算符。这个是重点,假如传入的是 11:

(num -1) << 1等价于“10 << 1”也就是 10 左移一位,表示为


 

然后看Integer.highestOneBit:


 

传入的是 20,二进制是:0001 0100。执行i |= (i >> 1);


 

得到结果:0001 1110。执行i |= (i >> 2);


 

得到的结果:0001 1111。执行i |= (i >> 4);


 

得到的结果:0001 1111。执行i |= (i >> 8);


 

得到的结果: 0001 1111。执行i |= (i >> 16);


 

得到的结果: 0001 1111。执行return i - (i >>> 1);


 

最终的结果:0001 0000,十进制就是 16 。传入的初始容量是 11,实际初始化的容量是 16,也就验证了Find a power of 2 >= toSize,找到一个 2 的幂次方数并且大于等于 toSize,toSize 就是传入的 11。

这个操作的规律就是最终的结果不是 4 个 0 就是 4 个 1,它将低位的 1 尽量的移动到高位去,不管移动多少位,反正取 ”与“ 后的结果肯定还是 ”与“ 之前的结果。


 

这个方法就是 hashmap 计算 key 的 hash 值方法,比较复杂,大体意思就是将 key 的 hash 值计算出来然后做位运算,为什么要右移这么多,核心思想就是避免出现太多了 hash 冲突。因为取余都是操作低位,hash 碰撞的概率会提高,为了减少 hash 碰撞,右移就可以将高位也参与运算,减少了 hash 碰撞。


 

上面方法当 key==null 的时候调用,HashMap 允许传入一个 null 作为 key。如果是 null 的 key,默认放在第一位,也就是数组为 0 的位置,当放置 null key 的时候,第 0 个位置已经被占用了,这个时候就会存在第 0 个位置的链表上。
上面代码可以看出 for 循环中是从数组的第一个位置开始循环的,也就是说 key = null 的数据是放在数组为 0 的位置上或者数组为 0 的链表上。上面方法是要返回一个值的,如果添加 key = null 的数据的时候,这个 null key 已经有了,那么会替换这个新的值,然后返回之前的值。


 

上面方法就是添加元素,也就是 put 方法的核心逻辑处理,首先判断当前的 map 的 size 是否大于了这个阈值,这个阈值初始化长度为 16(构造 hashmap 为空的情况下),当第一次 put 的时候会计算数组的初始长度然后这个阈值(=16 * 扩容因子);所以当 size 大于等于阈值过后,并且要添加的这个鼠标下标位置已经有值了就开始扩容;

扩容

数据量很大的情况下,扩容将会带来性能的损失。在性能要求很高的地方,这种损失很可能很致命。


 

扩容数组移动图

上图就是transfer方法的2层循环执行的过程

CreateEntry


 

如果有需要扩容,扩容完成过后就进行最后一步添加Entry元素,看第二行代码,第二行代码是
Entry<K,V> e = table[bucketIndex],这个e可能为空,也可能不为空,当e为空的时候,那么证明这个下标上还没有元素被添加,那么bucketIndex位置上直接存放添加的元素即可;如果不为空,则证明bucketIndex上已经存在元素了,那么这个时候就要添加到bucketIndex的链表上,因为hashmap的链表默认是单向链表,Hashmap的单向链表默认是头插法,根据扩容的流程图就可以知道,所以当e不为空的时候,那么这个元素默认在头部,其他元素往下移,也就有了 table[bucketIndex] = new Entry<>(hash, key, value, e)这句代码的含义。

Entry的基本结构

就根据createEntry方法来了解下Entry的数据结构,大概了解一下即可


 

这是HashMap在put的时候最后调用createEntry的方法,这个方法有4个参数
int h:可以对应的hash值;
K k:key的明文值;
V v:put的值;

Entry<k,v> n:这个值可空,可不为空,如果put的时候,n不为空,那么n就是这个位置上的(包括链表上)的所有节点元素,如果为空,则也就为空,什么意思呢?就是这个位置有添加的一个当前put的元素,它的下一个是null,比如添加的元素是abc,如图:

get获取元素

get获取元素比较简单,就算没有看过源码,也大概能够猜出来,不就是根据key循环HashMap的Entry数组嘛,找到key相等的就返回,这是我初探HashMap源码时,带着这种思维去看的,所以看源码先要理解它的原理,带着原理快速过一遍源码,刚开始不要纠结细节,否则你看源码的兴趣可能在半小时左右,你专到一个方法细节里面出不来,半小时过后可能就失去兴趣了,所以要根据自己所猜的原理去快速过一遍,然后后面再慢慢理细节。


 

删除元素remove


 

在remove或者put的时候都有代码,modCount++和size++ / size–
size其实很好理解,就是Hashmap的真实元素个数,而不是数组长度,这个元素个数在一定程度上可能大于数组长度,因为有链表结构;
而这个modCount是什么呢?这个涉及到一个异常,修改异常ConcurrentModificationException,也就是fast-fail(快速失败)是HashMap的一种自我保护模式,就是说在迭代的时候是不循环对map进行添加或者删除的,所以个modCount表示的是对map的操作次数,只是改变数组元素时候会记录这个modCount,所以在迭代的时候声明一个期望值=modCount,然后每次迭代判断这个期望值是否还是等于modCout,如果不等于,则抛出异常,是一种快速失败的模式。
Hashmap的其他的api就不做记录了,其实都差不多,都是围绕Entry这个来展开进行操作的,没有什么太多太难的地方,理解原理和思想即可。

四、JDK8 的 HashMap 源码分析

1️⃣HashMap的默认容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
2️⃣HashMap的最大支持容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
3️⃣HashMap的默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
4️⃣Bucket中链表长度大于该默认值,转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
5️⃣Bucket中红黑树存储的Node小于该默认值,转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
6️⃣桶中的Node被树化时最小的hash表容量。(当桶中Node的数量大到需要变红黑树时,若hash表容量小于MIN_TREEIFY_CAPACITY时,此时应执行resize扩容操作,这个MIN_TREEIFY_CAPACITY的值至少是 TREEIFY_THRESHOLD的4倍)
static final int MIN_TREEIFY_CAPACITY = 64;
7️⃣table:存储元素的数组,总是 2 的 n 次幂。
8️⃣entrySet:存储具体元素的集
9️⃣size:HashMap中存储的键值对的数量
🔟modCount:HashMap扩容和结构改变的次数。
1️⃣1️⃣threshold:扩容的临界值,=容量*填充因子
1️⃣2️⃣loadFactor:填充因子

静态成员常量


 

成员变量


 

Node类的成员变量


 

key、key 的 hash 值、key 对应的 value 和下一个节点的引用,其中链表的形成就是 next 这个引用的作用。

put()


 

很清楚,通过 hash(key) 获取到了 key 的 hash 值,然后调用了 putVal():


 

这是 putVal 的原始方法,看起来有点复杂,每行注释如下:


 

其中所有代码均已经加上详细注释,这里值得注意的是,由于这个方法没有任何 线程同步手段,所以不论是在查找对应的key,还是扩容,插入节点,增加size,modCount等,肯定会出现问题(这里先预留一篇文章,ConCurrentHashMap源码分析),所以多线程环境下,绝对不能使用HashMap,

而应该使用ConCurrentHashMap。

当然到了现在,那个面试题的答案也已经能够较为完整的回答出来了!

  1. 上面较为详细的分析了put方法后,注意到 resize() 在这个方法中起到了关键作用,初始化,以及扩容。那接着来观察下 resize():

 

这个方法看起来也较为复杂,同样作下简单分析:


 

注释已经简要说明流程,这里可以看出有数组复制以及重新计算hash的操作,所以在实际开发中使用 HashMap 的时候,最好设置一个初始容量,防止经常扩容操作耗费性能!

  1. 好了,HashMap 两个关键方法都分析完毕了,接下来分析 get(key):

 

get 方法首先通过 hash(key) 获取到了 hash 值,接着通过 getNode(hash) 获取节点,所以重点看下 getNode 方法:


 

值得注意的是, 在链表中查找节点采用的是遍历的方式,所以一旦链表过长,查找性能就较慢,这也是为什么 jdk1.8 会在链表长度超过阈值的时候将链表转换为红黑树的原因!链表时间复杂度为O(n),红黑树为 O(logn)

五、JDK8 中 HashMap 底层链表转红黑树的阈值为什么是 8?红黑树转链表为什么是 6?

和 hashcode 碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡。


 

1️⃣链表转换为红黑树

红黑树中的 TreeNode 是链表中的 Node 所占空间的 2 倍,虽然红黑树的查找效率为 O(logN),要优于链表的 O(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。所以要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。链表转换为红黑树的最终目的,是为了解决在 map 中元素过多,hash 冲突较大,而导致的读写效率降低的问题。在源码的 putVal 方法中,有关红黑树结构化的分支为:


 

即当链表元素个数大于 8 的时候,就会转换为红黑树。之所以是 8,是因为 Java 的源码贡献者在进行大量实验发现,hash 碰撞发生 8 次的概率非常低,几乎为不可能事件。如果真的发生了 8 次碰撞,说明由于元素本身和 hash 函数的原因,此时的链表性能已经很差了,操作的 hash 碰撞的可能性非常大,后序可能还会继续发生碰撞。所以,在这种极端的情况下才会把链表转换为红黑树,链表转换为红黑树也是需要消耗性能的,大部分情况下 hashMap 还是使用链表。treeifyBin() 源码:


 

可以看到在 treeifyBin 中并不是简单地将链表转换为红黑树,而是先判断 table 的长度是否大于 64。如果小于 64,就通过扩容的方式来解决,避免红黑树结构化。链表长度大于 8 有两种情况:

  • table 长度足够,hash 冲突过多
  • hash 没有冲突,但是在计算 table 下标的时候,由于 table 长度太小,导致很多 hash 不一致的

第二种情况是可以用扩容的方式来避免的,扩容后链表长度变短,读写效率自然提高。另外,扩容相对于转换为红黑树的好处在于可以保证数据结构更简单。由此可见并不是链表长度超过 8 就一定会转换成红黑树,而是先尝试扩容。

2️⃣红黑树转换为链表

基本思想是当红黑树中的元素减少并小于一定数量时,会切换回链表。而元素减少有两种情况:

  1. 调用 map 的 remove 方法删除元素

 

可以看到,此处并非当节点数小于 UNTREEIFY_THRESHOLD 时才转换,而是通过红黑树根节点及其子节点是否为空来判断。

  1. resize 的时候,对红黑树进行了拆分

resize 的时候,判断节点类型,如果是链表,则将链表拆分,如果是 TreeNode,则执行 TreeNode 的 split 方法分割红黑树,而 split 方法中将红黑树转换为链表的分支如下:


 

这里才用到了 UNTREEIFY_THRESHOLD 的判断,当红黑树节点元素小于等于 6 时,才调用 untreeify 方法转换回链表。红黑树转链表的阈值为 6,主要是因为,如果也将该阈值设置为 8,那么当 hash 碰撞在 8 时,会发生链表和红黑树的不停相互激荡转换,白白浪费资源。中间有个差值 7 可以防止链表和树之间的频繁转换。

3️⃣总结

如果设计成链表个数超过 8 则链表转换成树结构,链表个数小于 8 则树结构转换成链表,HashMap 不停的插入/删除元素,链表个数在 8 左右徘徊,就会频繁的发生红黑树转链表,链表转红黑树,效率会很低下。

  1. hashMap 并不是在链表元素个数大于 8 就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换。
  2. hashMap 的红黑树不一定小于 6 的时候才会转换为链表,而是只有在 resize 的时候才会根据 UNTREEIFY_THRESHOLD 进行转换。

六、HashMap 和 HashTable 的区别

  1. HashMap 是线程不安全的,HashTable 是线程安全的。
  2. 由于线程安全,所以 HashTable 的效率比不上 HashMap。
  3. HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null,而 HashTable不允许。
  4. HashMap 默认初始化数组的大小为 16,扩容时扩大两倍。HashTable 为 11,扩容时扩大两倍 +1。
  5. HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode。

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

一般情况下,使用最多的是 HashMap。

  1. HashMap:在 Map 中插入、删除和定位元素时;
  2. TreeMap:在需要按自然顺序或自定义顺序遍历键的情况下;
  3. LinkedHashMap:在需要输出的顺序和输入的顺序相同的情况下。

八、只有 HashMap 可操作 null


 

作者:日常更新
链接:https://www.jianshu.com/p/78989cd553b4
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。