CurrentHashMap
2020年12月28日
15:03
| 目录 |
| - - 1. 引言 - 2. 分段锁数据结构 - 3. JDK1.7实现 - 3.1 初始化 - 3.2 插入 - 4. JDK1.8实现 - 4.1 初始化 - 4.2 插入 - 5. REF
|
1. 引言
CurrentHashMap(CHM)是并发容器的代表,是一种实现细粒度锁提高并发性能的容器。
它的核心思想是分段锁,即缩小每次锁的数据的数量,从而提高并发性能。
话又说回来,我回想锁一大堆机制,其实本质上应该不是一件复杂的事。类比一下,这件事类似于几人搬箱子(假设一个人只能搬一个),提高效率的方式:
- 要么你把箱子拆成多份(细粒度的锁)
- 要么一个人搬,其他人就在旁边等等,不要休息(因为休息阻塞会花费大量的时间,乐观锁就是基于自旋等待,假设自旋很快)
CurrentHashMap在jdk1.7和1.8之间发生了较大的变化,但事实上和HashMap的实现很像。JDK1.7引入了segment的二阶段数据结构,每次只对segment加锁,JDK1.8则和hashMap非常像,只是它加锁只加hash值指向的节点。
2. 分段锁数据结构
这是CHM的灵魂,不过下图其实是JDK1.7的实现,而JDK1.8并没有这种显示的两阶段数据结构,但是思想是一样的。

3. JDK1.7实现
3.1 初始化
- 1.7 是创建立马初始化,1.8是put的时候进行初始化
- 初始化主要是初始segment表和一些计算hash用的标志,掩码等
| 1 public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { 2 // 参数校验 3 if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) 4 throw new IllegalArgumentException(); 5 // 校验并发级别大小,大于 1<<16,重置为 65536 6 if (concurrencyLevel > MAX_SEGMENTS) 7 concurrencyLevel = MAX_SEGMENTS; 8 // Find power-of-two sizes best matching arguments 9 // 2的多少次方 10 int sshift = 0; 11 int ssize = 1; 12 // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值 13 while (ssize < concurrencyLevel) { 14 ++sshift; 15 ssize <<= 1; 16 } 17 // 记录段偏移量,计算hash,mod用 18 this.segmentShift = 32 - sshift; 19 // 记录段掩码 20 this.segmentMask = ssize - 1; 21 // 设置容量 22 if (initialCapacity > MAXIMUM_CAPACITY) 23 initialCapacity = MAXIMUM_CAPACITY; 24 // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量 25 int c = initialCapacity / ssize; 26 if (c * ssize < initialCapacity) 27 ++c; 28 int cap = MIN_SEGMENT_TABLE_CAPACITY; 29 //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数 30 while (cap < c) 31 cap <<= 1; 32 // create segments and segments[0] 33 // 创建 Segment 数组,设置 segments[0] 34 Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), 35 (HashEntry<K,V>[])new HashEntry[cap]); 36 Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; 37 UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] 38 this.segments = ss; 39 } |
3.2 插入
对于整个map的插入 由于是分段的数据结构,逻辑就是
- 获得分段位置
- 往分段位置的hash表中插入
| 1 public V put(K key, V value) { 2 Segment<K,V> s; 3 if (value == null) 4 throw new NullPointerException(); 5 int hash = hash(key); 6 // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算 7 // 其实也就是把高4位与segmentMask(1111)做与运算 8 int j = (hash >>> segmentShift) & segmentMask; 9 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck 10 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment 11 // 如果查找到的 Segment 为空,初始化 12 s = ensureSegment(j); 13 return s.put(key, hash, value, false); 14 } |
分段表初始化过程需要一些加锁,保证不要重复初始化或数据丢失 这里边应用了双重加锁,以及乐观锁标志的技巧
| 1 private Segment<K,V> ensureSegment(int k) { 2 final Segment<K,V>[] ss = this.segments; 3 long u = (k << SSHIFT) + SBASE; // raw offset 4 Segment<K,V> seg; 5 // 判断 u 位置的 Segment 是否为null 6 if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { 7 Segment<K,V> proto = ss[0]; // use segment 0 as prototype 8 // 获取0号 segment 里的 HashEntry<K,V> 初始化长度 9 int cap = proto.table.length; 10 // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的 11 float lf = proto.loadFactor; 12 // 计算扩容阀值 13 int threshold = (int)(cap * lf); 14 // 核心代码,创建一个 cap 容量的 HashEntry 数组 15 HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap]; 16 if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck 17 // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作 18 Segment<K,V> s = new Segment<K,V>(lf, threshold, tab); 19 // 自旋检查 u 位置的 Segment 是否为null 20 while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) 21 == null) { 22 // 使用CAS 赋值,只会成功一次 23 if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) 24 break; 25 } 26 } 27 } 28 return seg; 29 } |
segment位置上的插入,采用lock,核心任务
- 插入
- 检测是否覆盖
- 扩容
| 1 final V put(K key, int hash, V value, boolean onlyIfAbsent) { 2 // 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。 3 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); 4 V oldValue; 5 try { 6 HashEntry<K,V>[] tab = table; 7 // 计算要put的数据位置 8 int index = (tab.length - 1) & hash; 9 // CAS 获取 index 坐标的值 10 HashEntry<K,V> first = entryAt(tab, index); 11 for (HashEntry<K,V> e = first;;) { 12 if (e != null) { 13 // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value 14 K k; 15 if ((k = e.key) == key || 16 (e.hash == hash && key.equals(k))) { 17 oldValue = e.value; 18 if (!onlyIfAbsent) { 19 e.value = value; 20 ++modCount; 21 } 22 break; 23 } 24 e = e.next; 25 } 26 else { 27 // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。 28 if (node != null) 29 node.setNext(first); 30 else 31 node = new HashEntry<K,V>(hash, key, value, first); 32 int c = count + 1; 33 // 容量大于扩容阀值,小于最大容量,进行扩容 34 if (c > threshold && tab.length < MAXIMUM_CAPACITY) 35 rehash(node); 36 else 37 // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头 38 setEntryAt(tab, index, node); 39 ++modCount; 40 count = c; 41 oldValue = null; 42 break; 43 } 44 } 45 } finally { 46 unlock(); 47 } 48 return oldValue; 49 } |
4. JDK1.8实现
1.8的实现和HashMap基本一致,只是加锁针对单个节点加锁,使用的是同步锁(对比HashTable,它是对整个方法使用同步,另外,看源码发现,HashTable并不是红黑树实现的)。
4.1 初始化
- CAS完成初始化的过程,经典的设计,它通过控制sizeCtl作为悲观锁控制初始化独占
- sizeCtl值经过巧妙的设计的标记量,它表示初始大小,节点数量,以及小于0是标志有线程在初始化,经典设计
| 1 private final Node<K,V>[] initTable() { 2 Node<K,V>[] tab; int sc; 3 while ((tab = table) == null || tab.length == 0) { 4 // 判断是否可以初始化 5 if ((sc = sizeCtl) < 0) 6 Thread.yield(); // lost initialization race; just spin 7 // CAS修改初始化标志量 8 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { 9 try { 10 if ((tab = table) == null || tab.length == 0) { 11 int n = (sc > 0) ? sc : DEFAULT_CAPACITY; 12 @SuppressWarnings("unchecked") 13 // 经典设计,先new再赋值成员引用 14 Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; 15 table = tab = nt; 16 sc = n - (n >>> 2); 17 } 18 } finally { 19 sizeCtl = sc; 20 } 21 break; 22 } 23 } 24 return tab; 25 } |
4.2 插入
参考hashMap的源码解析,基本作的事情一致:
- 计算hash值
- 找到位置,初始化或插入
- 是否覆盖
- 转红黑树?
- 扩容
| 1 final V putVal(K key, V value, boolean onlyIfAbsent) { 2 if (key == null || value == null) throw new NullPointerException(); 3 int hash = spread(key.hashCode()); 4 int binCount = 0; 5 for (Node<K,V>[] tab = table;;) { // 经典风格,for完成循环,可以作初始化 6 Node<K,V> f; int n, i, fh; // 经典风格,局部变量统一初始化,一行 7 // Node表为空,调用初始化 8 if (tab == null || (n = tab.length) == 0) 9 tab = initTable(); 10 // hash指向位置链表为空,初始化,CAS在该位置上放置节点 11 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {// 经典风格,判断带赋值 12 if (casTabAt(tab, i, null, 13 new Node<K,V>(hash, key, value, null))) 14 break; // no lock when adding to empty bin 15 } 16 else if ((fh = f.hash) == MOVED)// 不懂,忽略 17 tab = helpTransfer(tab, f); 18 // hash指向位置非空 19 else { 20 V oldVal = null; 21 // 同步锁开始插入,f是hash位置的节点 22 synchronized (f) { 23 // hash位置还是刚刚的头部节点(未转为树,多线程情况下,从f取值到这里可能被转为树) 24 if (tabAt(tab, i) == f) { 25 if (fh >= 0) { 26 binCount = 1; 27 // 开始遍历链表,覆盖,插入 28 for (Node<K,V> e = f;; ++binCount) { 29 K ek; 30 if (e.hash == hash && 31 ((ek = e.key) == key || 32 (ek != null && key.equals(ek)))) { 33 oldVal = e.val; 34 if (!onlyIfAbsent) 35 e.val = value; 36 break; 37 } 38 Node<K,V> pred = e; 39 if ((e = e.next) == null) { 40 pred.next = new Node<K,V>(hash, key, 41 value, null); 42 break; 43 } 44 } 45 } 46 // hash指向位置是红黑树节点,则使用红黑树插入 47 else if (f instanceof TreeBin) { 48 Node<K,V> p; 49 binCount = 2; 50 if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, 51 value)) != null) { 52 oldVal = p.val; 53 if (!onlyIfAbsent) 54 p.val = value; 55 } 56 } 57 } 58 } 59 // bitCount链表记录长度,看看是否转换为红黑树 60 if (binCount != 0) { 61 if (binCount >= TREEIFY_THRESHOLD) 62 treeifyBin(tab, i); 63 if (oldVal != null) 64 return oldVal; 65 break; 66 } 67 } 68 } 69 // 增加计数,是否扩容rehash,binCount作为标记,如果大于零,才表明插入新元素 70 addCount(1L, binCount); 71 return null; 72 } |
5. REF
本文深入剖析了CurrentHashMap的并发原理,重点讲解了其分段锁机制。在JDK1.7中,CurrentHashMap使用Segment分段锁,插入操作时对每个Segment加锁;而在JDK1.8中,锁的粒度更细,仅针对单个节点使用同步锁。文章详细介绍了两个版本的初始化、插入操作及扩容策略,并提到了与HashMap和HashTable的区别。

被折叠的 条评论
为什么被折叠?



