参考文档:
漫画:什么是ConcurrentHashMap?
漫画:什么是HashMap?
漫画:高并发下的HashMap
Hashmap实现原理及扩容机制详解
HashMap JDK1.7和1.8区别(完整版)

HashMap

HashMap 基本知识

  • hashmap 初始长度为 16,初始化的时候,传入参数不是 2 的幂数,也会自动设置为 2 的幂数

  • hashmap 长度必须为 2 的幂数

  • 加载因数是 0.75,当前数量 * 加载因子 >= 容量的时候,就会扩容,容量翻倍,最大容量为 1 << 30。

1.7 和 1.8 区别

不同 JDK 1.7 JDK 1.8
存储结构 数组 + 链表 数组 + 链表 + 红黑树
初始化方式 单独函数:inflateTable() 直接集成到了扩容函数resize()中
hash值计算 扰动处理=9次扰动=4次位运算+5次异或运算 扰动处理=2次扰动=1次位运算+1次异或运算
存放数据的规则 无冲突时,存放数组;冲突时,存放链表 无冲突时,存放数组;冲突&链表长度<8:存放单链表; 冲突&链表长度>8:树化并存放红黑树
插入数据方式 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) 尾插法(直接插入到链表尾部/红黑树)
扩容后存储位置的计算方式 全部按照原来方法进行计算(即hashCode -> 扰动函数 ->(h&length-1) 按照扩容后的规律计算(即扩容后的位置=原位置or原位置+旧容量)

存储方式

我们先看几个参数

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30;  //最大容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子 也就是0.75F
static final int TREEIFY_THRESHOLD = 8;  // 链表长度大于8时树化,数据8是由hashmap作者计算而出
static final int UNTREEIFY_THRESHOLD = 6;  //还原成链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量

得出以下结论:

如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树), 否则,若桶内元素太多时,则直接扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD,这里就是64。
一定是在大于MIN_TREEIFY_CAPACITY的时候,才会树化,无论链表的长度是否大于 TREEIFY_THRESHOLD

优点

相比于jdk1.7的数组+链表结构,先看看hshMap在jdk 1.8的结构,如下图,用的是数组+链表+红黑树的结构,也叫哈希桶,在jdk 1.8之前都是数组+链表的结构,因为在链表的查询操作都是O(N)的时间复杂度,而且hashMap中查询操作也是占了很大比例的,如果当节点数量多,转换为红黑树结构,那么将会提高很大的效率,因为红黑树结构中,增删改查都是O(log n)。
20200304215336689

但阈值的存在毫无疑问是必要的,红黑树虽然效率高,但红黑树本身是一个复杂的数据结构,涉及到自旋的操作,在链表节点数量比较少的时候,很明显链表增删改查更为合适,所以并不能直接采用红黑树

为什么选 8 做树形化的阀值?

根据作者计算出的概率

  • 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

8 个节点的概率接近为 0,可以减少节点树形化。

扰动函数

一个好的 hash 算法,应该是尽量避免不同的 key 映射出相同的 index,这样才能减少哈希冲突的出现。

index 怎么获取:

//获取index的方法,1.7源码,1.8中用tab[(n - 1) & hash]代替,但原理一样
static int indexFor(int h, int length) {
    return h & (length-1);
}

index 的获取很简单,就是把 h 对 length-1 取模,其中 length 为数组长度,所以关键的是 h 是怎么计算得到的。

在JDK1.7中的扰动函数源码

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}


而在JDK1.8中

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

而至于为什么JDK1.8进行了缩减,是因为做多了离散分布提升不明显,还是为了效率考虑,进行了缩减。

h & (length-1) 详解

在公式 h & (length-1) 中,其中 length 为数组的长度,HashMap 的数组默认长度是 16,因为大多数情况来说数组长度都不会太大,所以影响 h & (length-1) 计算结果的主要因素就是 h 的低位数据了,也就是大多数情况下 h 的高位数据对计算结果是没有影响的。比如只有当数组长度 length 大于 2^16 即大于 65536的时候,h 的高 16 位才会对 h & (length-1) 的计算结果产生影响,所以在 HashMap 中我们也主要对h的低 16 位进行优化,也就是让 h 的低 16 位的数据尽量分散,上面的 (h = key.hashCode()) ^ (h >>> 16) 算法也是基于这个目的而设计的。

为什么不依赖于 hashCode 呢? hashCode 为系统实现,不可靠。

key.hashCode()) ^ (h >>> 16) 详解

首先 h = key.hashCode() 是 key 对象的一个 hashCode,每个不同的对象其哈希值都不相同,其实底层是对象的内存地址的散列值,所以最开始的 h 是 key 对应的一个整数类型的哈希值

h >>> 16 的意思是将 h 右移 16 位,然后高位补 0,然后再与 (h = key.hashCode()) 异或运算得到最终的 h 值,为什么是异或运算呢?是为了让 h 的低 16 位更有散列性,但为什么是异或运算就更有散列性呢

先来看一下下面的这组运算

img

上面是将 0110 和 0101分别进行与、或、异或三种运算得到不同的结果,我们主要来看计算的过程:

与运算:其中 1&1=1,其他三种情况 1&0=0, 0&0=0, 0&1=0 都等于0,可以看到与运算的结果更多趋向于0,这种散列效果就不好了,运算结果会比较集中在小的值

或运算:其中 0&0=0,其他三种情况 1&0=1, 1&1=1, 0&1=1 都等于1,可以看到或运算的结果更多趋向于1,散列效果也不好,运算结果会比较集中在大的值

异或运算:其中 0&0=0, 1&1=0,而另外0&1=1, 1&0=1 ,可以看到异或运算结果等于1和0的概率是一样的,这种运算结果出来当然就比较分散均匀了

总的来说,与运算的结果趋向于得到小的值,或运算的结果趋向于得到大的值,异或运算的结果大小值比较均匀分散,这就是我们想要的结果,这也解释了为什么要用异或运算,因为通过异或运算得到的h值会更加分散,进而 h & (length-1) 得到的index也会更加分散,哈希冲突也就更少

插入方式

在1.7中使用的是头插法,1.8改成了尾插法,为了避免头插法导致的在多线程的情况下 hashmap 在 put 元素时产生的环形链表的问题,但 1.8 仍然存在数据覆盖的问题,所以仍然不是线程安全的

JDK1.8数据覆盖
JDK1.7环形链表

2 的幂次方容量

为什么是 2 的幂数?
为了更快的计算 hash,index = hash(key) % length 效率太低,开发者采用位运算来计算 index = hash(key) & (length -1),如果 length 不是 2 的幂数,会产生大量冲突。

2的幂次方是指数组长度length的大小,假如length等于2的幂次方,那样length-1的二进制数据的低位就全部为1了,比如当数组长度为16,那么15的二进制就为1111,只有这样,在计算数组下标index的时候才能更好地利用h的散列性,举个例子:

比如 length-1=15,二进制即为1111,分别跟三个不同的h值进行与运算,计算如下

1111 & 101010100101001001000 结果:1000 = 8

1111 & 101000101101001001001 结果:1001 = 9

1111 & 101010101101101001010 结果:1010 = 10

1111 & 101100100111001101100 结果: 1100 = 12

但是如果length为11的话,那么length-1的二进制则表示为1010,与同样的三个h值与运算,计算如下

1010 & 101010100101001001000 结果:1000 = 8

1010 & 101000101101001001001 结果:1000 = 8

1010 & 101010101101101001010 结果:1010 = 10

1010 & 101100100111001101100 结果: 1000 = 8

很明显,当数组长度为 16 的时候,没有产生哈希冲突,而为11的时候,产生了 3 次哈希冲突,所以这就说明了为什么 HashMap 的容量建议为 2 的幂次方

扩容后新坐标

HashMap 扩容时使用 rehash 方法很巧妙,因为每次扩容都是翻倍,和原来计算的一样 (n-1)&hash 相比之下,结果只是多了一个 bit, 所以节点要么在原来的位置,要么分配到"原位置+旧容量"这个位置。

hashmap

ConcurrentHashMap

  • resize() 扩容
  • rehash() 重新计算 hash

扩容的时候干了两件事

  1. 创建一个新的Entry空数组,长度是原数组的2倍。
  2. 遍历原Entry数组,把所有的Entry重新Hash到新数组。

但是多线程同时 resize 的时候就会造成线程不安全问题。

HashMap 线程安全的解决方案有 HashTable 或者 Collections.synchronizedMap,但无论读写,都会给整个集合加锁,有性能问题。

这时候我们使用 ConcurrentHashMap,兼顾了线程安全和性能。

Segment

ConcurrentHashMap 的一个重要概念。

Segment是什么呢?Segment本身就相当于一个HashMap对象。

同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。

单一的Segment结构如下:

v2-16e5f4cd5259e2219613b2d75e817929_720w

像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。

因此整个ConcurrentHashMap的结构如下:

v2-614be9ff2b2042fc3e8b30c7e1a7243d_720w

可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。

这样的二级结构,和数据库的水平拆分有些相似。

读写情况

ConcurrentHashMap优势就是采用了锁分段技术,每一个Segment就好比一个自治区,读写操作高度自治,Segment之间互不影响。

Case1:不同Segment的并发写入

v2-e967fdf4e671fa2afa8ca97f8e600ca9_720w

Case2:同一Segment的一写一读

v2-6317419ee2e6a40d8cdd0f15ca202573_720w

Case3:同一Segment的并发写入

v2-4343518fc5b0feecc827555043cb8b3c_720w

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。

get

  1. 为输入的Key做Hash运算,得到hash值。
  2. 通过hash值,定位到对应的Segment对象
  3. 再次通过hash值,定位到Segment当中数组的具体位置。

put

  1. 为输入的Key做Hash运算,得到hash值。
  2. 通过hash值,定位到对应的Segment对象
  3. 获取可重入锁
  4. 再次通过hash值,定位到Segment当中数组的具体位置。
  5. 插入或覆盖HashEntry对象。
  6. 释放锁。

size

ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:

  1. 遍历所有的Segment。
  2. 把Segment的元素数量累加起来。
  3. 把Segment的修改次数累加起来。
  4. 判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
  5. 如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
  6. 再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
  7. 释放锁,统计结束。

JDK 1.8 更新

Java 8 几乎完全重写了 ConcurrentHashMapConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。

Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。