引言
在多线程并发的战场上,HashMap因死循环问题黯然退场,HashTable因全局锁沦为性能瓶颈。而ConcurrentHashMap凭借分段锁思想和CAS优化,成为Java高并发容器的标杆。本文将深入剖析JDK1.7到1.8的架构革新:从Segment分段锁到synchronized + 红黑树的蜕变,揭秘如何通过降低锁粒度、引入无锁化操作实现吞吐量质的飞跃。
一、哈希表
为了彻底搞清楚ConcurrentHashMap的实现机制,我会先从它的底层数据实现‘哈希表’谈起。
1、介绍: 哈希表就是一种以键-值(key-value)存储数据的结构,我们只要输入带查找的值的key,即可查找到其对应的value值。
哈希表的思路很简单,如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值,这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。
2、链式哈希表: 链式哈希表从根本上说是由一组链表构成。每个链表都可以看做是一个"桶",我们将所有的元素通过散列的方式放到具体的不同的桶中。插入元素时,首先将其键传入一个哈希函数(该过程称为哈希键),函数通过散列的方式告知元素属于哪个"桶",然后在相应的链表头插入元素。查找或删除元素时,用同样的方式先找到元素的"桶",然后遍历相应的链表,直到发现我们想要的元素。因为每个"桶"都是一个链表,所以链式哈希表并不限制包含元素的个数。然而,如果表变得太大,它的性能将会降低。
3、应用场景: 我们熟知的缓存技术(比如Redis、memcached)的核心其实就是在内存中维护一张巨大的哈希表,还有大家熟知的HashMap、ConcurrentHashMap等存储结构底层也是应用的哈希表结构。
二、ConcurrentHashMap与HashMap等的区别
1、HashMap: 我们知道HashMap是线程不安全的,在多线程环境下,使用HashMap进行put操作可能会引起死循环,严重时会导致CPU利用率接近100%,所以在并发非常高的情况下不能使用HashMap。
HashMap在多线程情况下,在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致get时会出现死循环,所以HashMap是线程不安全的。
2、HashTable: HashTable和HashMap的实现原理几乎一样,差别无非是:
- HashTable不允许key和value为null;
- HashTable是线程安全的;
但是HashTable线程安全的策略实现代价太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,如下图所示:
多线程访问HashTable时,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
其实HashTable有很多优化空间,锁住整个table这么粗暴的方法可以变相的柔和点,比如在多线程的环境下,对不同的数据集进行操作时其实根本就不需要去竞争一个锁,因为他们不同hash值,不会因为rehash造成线程不安全,所以互不影响,这就是锁分离技术,将锁的粒度降低,利用多个锁来控制多个小table,ConcurrentHashMap正是利用了该思想。
3、ConcurrentHashMap: 主要是为了应对HashMap在并发环境下不安全而诞生的,ConcurrentHashMap的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。
我们上面提到Map一般都是数组 + 链表结构(JDK1.8为数组 + 链表 + 红黑树)。
ConcurrentHashMap避免了对全局加锁,改成了局部加锁操作, 这样就极大地提高了并发环境下的操作速度,由于ConcurrentHashMap在JDK1.7和1.8中的实现非常不同,接下来我们谈谈JDK1.7和1.8中的区别。
三、JDK1.7 ConcurrentHashMap的实现原理
在JDK1.7中ConcurrentHashMap采用了数组 + Segment分段锁 + 链表的方式实现。
1、Segment(分段锁): 中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
2、内部结构: ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,才能够实现真正的并发访问。
如下图ConcurrentHashMap的内部结构图:
从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要两次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。
3、该结构的优劣势:
- 坏处:这一种结构带来的副作用是Hash过程要比普通的HashMap要长
- 好处:写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的操作(刚好这些写操作都非常平均地分布在所有的Segment上)。
所以通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
四、JDK1.8 ConcurrentHashMap的实现原理
JDK1.8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,如下图所示:
1、JDK1.8中彻底放弃了Segment,转而采用的是Node,其设计思想不再是JDK1.7中的分段锁思想。
Node:保存key,value及key的hash值的数据结构。其中value和next都用vilatile修饰,保证并发的可见性。
<strong>class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
//... 省略部分代码
} </strong>
内部大量采用了CAS操作,这里我简要介绍下CAS:
CAS是compare and swap的缩写,即我们所说的比较交换,CAS是一种基于锁的操作,而且是乐观锁,在Java中锁分乐观锁和悲观锁,悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如给记录添加version来获取记录,性能比悲观锁有很大的提高。
CAS操作包含三个操作数,内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新为B。CAS是通过无线循环来获取数据的,如果在第一轮循环中,a线程获取地址里的值被b线程修改了,那么a线程需要自旋,到下一次循环才有可能执行。
2、JDK1.8 ConcurrentHashMap结构基本上和JDK1.8的HashMap一样,不过保证线程安全性。
在JDK1.8中ConcurrentHashMap的结构,由于引入红黑树,使得ConcurrentHashMap的实现非常复杂,我们都知道,红黑树是一种性能非常耗的二叉搜索树,其查找性能为O(logN),但其实实现过程也非常复杂,而且可读性非常差,Doug Lea的思想能力确实不是一般人能比的,早期完全采用链表结构时Map的查找时间复杂度为O(N),JDK1.8中ConcurrentHashMap在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。
3、在JDK 1.8的ConcurrentHashMap中,synchronized同步锁被用于对哈希桶(桶位)的头节点进行细粒度加锁,主要出现在以下三个核心场景中:
-
PUT操作中的哈希冲突处理
位置: putVal()方法中的else代码块
作用: 当向非空桶位插入数据时(发生哈希冲突),锁住链表或红黑树的头节点,保证线程安全。
源码片段:else { V oldVal = null; synchronized (f) { // f是桶位头节点(Node对象) if (tabAt(tab, i) == f) { if (fh >= 0) { // 链表结构 // ...遍历链表插入/更新节点 } else if (f instanceof TreeBin) { // 红黑树结构 // ...调用红黑树插入方法 } } } }
锁粒度: 仅锁住当前操作的桶位头节点,其他桶位仍可并发访问。
-
链表转红黑树(Treeify)
位置: treeifyBin()方法
作用: 当链表长度≥8且数组长度≥64时,将链表转为红黑树,此过程需锁住头节点防止并发修改。
源码片段:synchronized (b) { // b是桶位头节点 if (tabAt(tab, index) == b) { TreeNode<K,V> hd = null, tl = null; // 遍历链表构建TreeNode for (Node<K,V> e = b; e != null; e = e.next) { ... } // 用TreeBin包装红黑树 setTabAt(tab, index, new TreeBin<>(hd)); } }
-
扩容期间的节点迁移
位置: transfer()方法中的else代码块
作用: 多线程协助扩容时,对原数组的每个桶位加锁迁移数据到新数组。
源码片段:synchronized (f) { // 锁住原桶位头节点 if (tabAt(tab, i) == f) { if (fh >= 0) { // 迁移链表数据 } else if (f instanceof TreeBin) { // 迁移红黑树数据 } } }
⚡ 关键设计思想:
-
锁粒度最小化
- 只锁单个桶位(头节点),而非全局锁(如Hashtable)或分段锁(如JDK1.7的Segment),并发度=桶的数量(默认16,384),远高于JDK1.7(默认16段)。
-
与CAS的分工协作,这种组合在保证线程安全的同时,最大化减少了锁竞争。
-
CAS:用于无冲突操作(如空桶插入casTabAt())、状态标志更新(如sizeCtl)。
-
synchronized:用于有冲突的复杂操作(链表/红黑树修改、扩容)。
-
-
选择synchronized而非ReentrantLock的原因:
-
桶级锁粒度极小,synchronized经JVM优化(偏向锁→轻量级锁)后性能与ReentrantLock接近;
-
减少内存开销(ReentrantLock需维护AQS队列);
-
JVM对synchronized的优化更自动(如锁升级)。
-
通过桶级锁+CAS的设计,实现了高并发下的线程安全与性能平衡,这也是其优于Hashtable和JDK1.7分段锁方案的核心原因。
五、JDK1.7 ConcurrentHashMap源码解析
通过上面的介绍我们知道在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和HashEntry组成,如下图所示:
Segment数组的意义就是将一个大的table分割成多个小table进行加锁,也就是上面提到的分离锁技术,而每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap数据存储结构一样。
在Java 1.7中:
//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认增长因子,当table中包含元素的个数超过了table数组的长度与装载因子的乘积时,将进行一次扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认的并发等级,即是并发数量。
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//允许的最大容量,由于要求该值必须是2的指数,并且是int类型,所以1<<30是其能用的最大值。
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认每一个segment的容量,必须是2的指数,至少为2, 否则分段锁失效。
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大的segment数量,必须小于2的16次方,
static final int MAX_SEGMENTS = 1 << 16;
//在加锁前重试的次数
static final int RETRIES_BEFORE_LOCK = 2;
1、初始化: ConcurrentHashMap的初始化是会通过位于运算来初始化Segment的大小,用size来表示,如下所示:
int size =1;
while(size < concurrencyLevel) {
++a;
size <<=1;
}
如上所示:因为size用位于运算来计算(size<<=1),所以Segment的大小取值都是以2的N次方,无关concurrencyLevel的取值,当然concurrencyLevel最大不能超过1 << 16,即(2的16次幂)65536,换句话说,Segment的大小最多65536个,没有指定concurrencyLevel元素初始化,Segment的大小size默认为16;
每一个Segment元素下的HashEntry的初始化也是按照位于运算来计算,用cap来表示,如下所示:
int cap =1;
while(cap < c)
cap <<=1;
如上所示,HashEntry大小的计算也是2的N次方(cap<<=1),cap的初始值是1,所以HashEntry最小的容量是2。
2、put操作: 对于ConcurrentHashMap的数据插入,这里要进行两次Hash去定位数据的存储位置
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。
3、get操作: ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。
4)size操作: 计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用了两种方案:
- 第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。
- 第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。
六、JDK1.8 ConcurrentHashMap源码解析
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
1、说明: ConcurrentHashMap的数据结构(数组+链表+红黑树),桶中的结构可能是链表,也可能是红黑树,红黑树是为了提高查找效率。
在深入JKD1.8的put和get实现之前要知道一些常量设计和数据结构,这些是构成ConcurrentHashMap实现结构的基础,下面看一下基本属性:
// Node数组最大容量:2^30 = 1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认初始容量,必须是2的幂数
private static final int DEFAULT_CAPACITY = 16;
// 数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
// 并发级别(兼容旧版本)
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转红黑树阈值,>8时链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 树转链表阈值,<=6时红黑树退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量(数组长度达到该值才允许树化)
static final int MIN_TREEIFY_CAPACITY = 64;
// 最小迁移步长
private static final int MIN_TRANSFER_STRIDE = 16;
// 扩容戳位数
private static final int RESIZE_STAMP_BITS = 16;
// 最大扩容线程数:2^15-1 = 32767
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
// sizeCtl中记录扩容戳的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// ForwardingNode节点的哈希值(表示正在迁移)
static final int MOVED = -1;
// 树根节点的哈希值
static final int TREEBIN = -2;
// ReservationNode的哈希值(用于computeIfAbsent等原子方法)
static final int RESERVED = -3;
// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
// 存放Node的数组(哈希表主体)
transient volatile Node<K, V>[] table;
/**
* 控制标识符(volatile保证可见性)
* 负数: -1表示初始化中, -N表示有N-1个线程扩容中
* 0 : 未初始化状态
* 正数: 初始化容量或扩容阈值
*/
private transient volatile int sizeCtl;
基本属性定义了ConcurrentHashMap的一些边界以及操作时的一些控制,下面看一些内部的一些结构组成,这些是整个ConcurrentHashMap整个数据结构的核心。
2、Node: Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据,源代码如下:
static class Node<K,V> implements Map.Entry<K,V> {
//链表的数据结构
final int hash;
final K key;
//val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/**
* Virtualized support for map.get(); overridden in subclasses.
*/
//用于map中的get()方法,子类重写
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
Node数据结构很简单,从上可知,就是一个链表,但是只允许对数据进行查找,不允许进行修改。
3、TreeNode: TreeNode继承于Node,但是数据结构换成了二叉树结构,它是红黑树的数据的存储结构,用于红黑树中存储数据,当链表的节点数大于8时会转换成红黑树的结构,他就是通过TreeNode作为存储结构代替Node来转换红黑树源代码如下:
static final class TreeNode<K, V> extends Node<K, V> {
//树形结构的属性定义
TreeNode<K, V> parent; // red-black tree links
TreeNode<K, V> left;
TreeNode<K, V> right;
TreeNode<K, V> prev; // needed to unlink next upon deletion
boolean red; // 标志红黑树的红节点
TreeNode(int hash, K key, V val, Node<K, V> next,
TreeNode<K, V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
Node<K, V> find(int h, Object k) {
return findTreeNode(h, k, null);
}
//根据key查找 从根节点开始找出相应的TreeNode,
final TreeNode<K, V> findTreeNode(int h, Object k, Class<?> kc) {
if (k != null) {
TreeNode<K, V> p = this;
do {
int ph, dir;
K pk;
TreeNode<K, V> q;
TreeNode<K, V> pl = p.left, pr = p.right;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.findTreeNode(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
}
return null;
}
}
4、TreeBin: TreeBin从字面含义中可以理解为存储树形结构的容器,而树形结构就是指TreeNode,所以TreeBin就是封装TreeNode的容器,它提供转换红黑树的一些条件和锁的控制,部分源码结构如下:
static final class TreeBin<K,V> extends Node<K,V> {
// 指向TreeNode列表和根节点
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// 读写锁状态
static final int WRITER = 1; // 获取写锁的状态
static final int WAITER = 2; // 等待写锁的状态
static final int READER = 4; // 增加数据时读锁的状态
/**
* 初始化红黑树
*/
TreeBin(TreeNode<K,V> b) {
super(TREEBIN, null, null, null);
this.first = b;
TreeNode<K,V> r = null;
for (TreeNode<K,V> x = b, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (r == null) {
x.parent = null;
x.red = false;
r = x;
} else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = r;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h) {
dir = -1;
} else if (ph < h) {
dir = 1;
} else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0) {
xp.left = x;
} else {
xp.right = x;
}
r = balanceInsertion(r, x);
break;
}
}
}
}
this.root = r;
assert checkInvariants(root);
}
// ...... 其他方法省略
}
5、介绍了 ConcurrentHashMap主要的属性和数据结构,现在通过一个简单的例子以debug的视角看看ConcurrentHashMap的具体操作细节:
public class TestConcurrentHashMap{
public static void main(String[] args){
//初始化ConcurrentHashMap
ConcurrentHashMap<String,String> map = new ConcurrentHashMap();
//新增个人信息
map.put("id", "1");
map.put("name", "andy");
map.put("sex", "男");
//获取姓名
String name = map.get("name");
Assert.assertEquals(name, "andy");
//计算大小
int size = map.size();
Assert.assertEquals(size, 3);
}
}
1)我们先通过new ConcurrentHashMap()来进行初始化:
public ConcurrentHashMap() {
}
由上你会发现ConcurrentHashMap的初始化其实是一个空实现,并没有做任何事,这里后面会讲到,这也是和其他的集合类有区别的地方,初始化操作并不是在构造函数实现的,而是在put操作中实现的,当然ConcurrentHashMap还提供了其他的构造函数,有指定容量大小或者指定负载因子,根HashMap一样,这里就不做介绍了。
2)put操作:在上面的例子中我们新增个人信息会调用put方法,我们来看下:
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* Implementation for put and putIfAbsent
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 空值检查
if (key == null || value == null)
throw new NullPointerException();
// 两次hash,减少hash冲突,可以均匀分布
int hash = spread(key.hashCode());
int binCount = 0;
// 自旋保证操作成功
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
// 延迟初始化(懒汉模式)
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// CASE 1:桶位为空(无锁插入)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null))) {
break; // 空桶位插入无需加锁
}
}
// CASE 2:当前桶位正在迁移(协助扩容)
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// CASE 3:桶位存在Hash冲突(同步操作)
else {
V oldVal = null;
// 锁住链表/红黑树头节点
synchronized (f) {
// 双重检查锁定
if (tabAt(tab, i) == f) {
// 链表结构处理
if (fh >= 0) {
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;
// 存在相同Key则覆盖原值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K, V> pred = e;
// 插入链表尾部
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
}
// 红黑树结构处理
else if (f instanceof TreeBin) {
Node<K, V> p;
binCount = 2;
// 红黑树插入
if ((p = ((TreeBin<K, V>) f).putTreeVal(
hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 树化检查和结果返回
if (binCount != 0) {
// 链表长度超阈值时树化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 更新计数并检查扩容
addCount(1L, binCount);
return null;
}
这个put过程很清晰,对当前的table进行无条件自循环知道put成功,可以分成以下六步流程来概述:
- 如果没有初始化就先调用initTable()方法来进行初始化过程
- 如果没有hash冲突就直接CAS插入
- 如果还在进行扩容操作就先进行扩容
- 如果存在hash冲突,就加锁来保证线程安全,在链表长度超过8时,Node数组超过64时会将链表结构转换为红黑树的结构,break再一次进入循环
- 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容。
现在我们来对每一步的细节进行源码分析,在第一步中,符合条件会进行初始化操作,我们来看看initTable()方法:
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K, V>[] initTable() {
Node<K, V>[] tab;
int sc;
while ((tab = table) == null || tab.length == 0) { // 空的table才能进入初始化操作
if ((sc = sizeCtl) < 0) // sizeCtl<0表示其他线程已经在初始化了或者扩容了,挂起当前线程
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // CAS操作SIZECTL为-1,表示初始化状态
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n]; // 初始化
table = tab = nt;
sc = n - (n >>> 2); // 记录下次扩容的大小
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
在第二步中没有hash冲突就直接调用Unsafe的方法CAS插入该元素,进行第三步如果容器正在扩容,则会调用helpTransfer()方法进行帮助扩容,现在我们跟进helpTransfer()方法看看:
/**
* 帮助从旧的table的元素复制到新的table中
*/
final Node<K, V>[] helpTransfer(Node<K, V>[] tab, Node<K, V> f) {
Node<K, V>[] nextTab;
int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K, V>) f).nextTable) != null) { // 新的table nextTba已经存在前提下才能帮助扩容
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab); // 调用扩容方法
break;
}
}
return nextTab;
}
return table;
}
其实helpTransfer()方法的目的就是调用多个工作线程一起帮助进行扩容,这样的效率就会更高,而不是只有检查到要扩容的那个线程进行扩容,其他线程就要等待扩容操作完成才能工作,既然这里涉及到扩容的操作,我们也一起来看看扩容方法transfer()
private final void transfer(Node<K, V>[] tab, Node<K, V>[] nextTab) {
int n = tab.length, stride;
// 每核处理的量小于16,则强制赋值16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1]; // 构建一个nextTable对象,其容量为原来容量的两倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
// 连接点指针,用于标志位(fwd的hash值为-1,fwd.nextTable=nextTab)
ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab);
// 当advance == true时,表明该节点已经处理过了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K, V> f;
int fh;
// 控制 --i ,遍历原hash表中的节点
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 用CAS计算得到的transferIndex
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 已经完成所有节点复制了
if (finishing) {
nextTable = null;
table = nextTab; // table 指向nextTable
sizeCtl = (n << 1) - (n >>> 1); // sizeCtl阈值为原来的1.5倍
return; // 跳出死循环,
}
// CAS 更扩容阈值,在这里面sizectl值减一,说明新加入一个线程参与到扩容操作
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
// 遍历的节点为null,则放入到ForwardingNode 指针节点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// f.hash == -1 表示遍历到了ForwardingNode节点,意味着该节点已经处理过了
// 这里是控制并发扩容的核心
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 节点加锁
synchronized (f) {
// 节点复制工作
if (tabAt(tab, i) == f) {
Node<K, V> ln, hn;
// fh >= 0 ,表示为链表节点
if (fh >= 0) {
// 构造两个链表 一个是原链表 另一个是原链表的反序排列
int runBit = fh & n;
Node<K, V> lastRun = f;
for (Node<K, V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K, V> p = f; p != lastRun; p = p.next) {
int ph = p.hash;
K pk = p.key;
V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K, V>(ph, pk, pv, ln);
else
hn = new Node<K, V>(ph, pk, pv, hn);
}
// 在nextTable i 位置处插上链表
setTabAt(nextTab, i, ln);
// 在nextTable i + n 位置处插上链表
setTabAt(nextTab, i + n, hn);
// 在table i 位置处插上ForwardingNode 表示该节点已经处理过了
setTabAt(tab, i, fwd);
// advance = true 可以执行--i动作,遍历节点
advance = true;
}
// 如果是TreeBin,则按照红黑树进行处理,处理逻辑与上面一致
else if (f instanceof TreeBin) {
TreeBin<K, V> t = (TreeBin<K, V>) f;
TreeNode<K, V> lo = null, loTail = null;
TreeNode<K, V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K, V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K, V> p = new TreeNode<K, V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 扩容后树节点个数若<=6,将树转链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K, V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K, V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
扩容过程有点复杂,这里主要涉及到多线程并发扩容,ForwardingNode的作用就是支持扩容操作,将已处理的节点和空间节点置为ForwardingNode,并发处理时多个线程经过ForwardingNode就表示已经遍历了,就往后遍历,下图是多线程合作扩容的过程:
介绍完扩容过程,我们再次回到put流程,在第四步中是向链表或者红黑树里加节点,到第五步会调用treeifyBin()方法进行链表转红黑树的过程:
private final void treeifyBin(Node<K, V>[] tab, int index) {
Node<K, V> b;
int n, sc;
if (tab != null) {
// 如果整个table的数量小于64,就扩容至原来的一倍,不转红黑树了
// 因为这个阈值扩容可以减少hash冲突,不必要去转红黑树
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K, V> hd = null, tl = null;
for (Node<K, V> e = b; e != null; e = e.next) {
// 封装成TreeNode
TreeNode<K, V> p =
new TreeNode<K, V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 通过TreeBin对象对TreeNode转换成红黑树
setTabAt(tab, index, new TreeBin<K, V>(hd));
}
}
}
}
}
到第六步表示已经数据加入成功了,现在调用addCount()方法进行计算ConcurrentHashMap的size,在原来的基础上加1,现在来看看addCount方法:
private final void addCount(long x, int check) {
CounterCell[] as;
long b, s;
// 更新baseCount,table的数量,counterCells表示元素个数的变化
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a;
long v;
int m;
boolean uncontended = true;
// 如果多个线程都在执行,则CAS失败,执行fullAddCount,全部加入count
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
// check>=0表示需要进行扩容操作
if (check >= 0) {
Node<K, V>[] tab, nt;
int n, sc;
while (s >= (long) (sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 当前线程发起库哦哦让操作,nextTable=null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
put的流程现在已经分析完了,你可以从中发现,他在并发处理中使用的是客观锁,当有冲突的时候才进行并发处理,而且流程步骤很清晰,但是细节设计很复杂,毕竟多线程的场景也复杂。
3)get操作:我们现在要回到开始的例子中,我们对个人信息进行了新增之后,我们要获取所新增的信息,使用String name = map.get(“name”)获取新增的name信息,现在我们依旧用debug的方式来分析下ConcurrentHashMap的获取方法get():
public V get(Object key) {
Node<K, V>[] tab;
Node<K, V> e, p;
int n, eh;
K ek;
int h = spread(key.hashCode()); // 计算两次hash
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) { // 读取首节点的Node元素
if ((eh = e.hash) == h) { // 如果该节点就是首节点就返回
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// hash值为负值表示正在扩容,这个时候查的是ForwardingNode的find方法来定位到nextTable来
// 查找,查找到就返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) { // 既不是首节点也不是ForwardingNode,那就往下遍历
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
ConcurrentHashMap的get操作的流程很简单,也很清晰,可以分三个步骤来描述:
- 计算hash值,定位到该table索引位置,如果是首节点符合就返回
- 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
- 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
4)size操作:最后我们来看下例子中获取size的方式:int size = map.size();,现在让我们看下size()方法:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long) Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int) n);
}
final long sumCount() {
CounterCell[] as = counterCells;
CounterCell a; // 变化的数量
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是时间段,所以并不是很精确。
七、思考与总结
从JDK1.7版本的ReentrantLock + Segment + HashEntry,到JDK1.8版本中取消了Segment分段锁的数据结构,取而代之的是synchronized + CAS + HashEntry + 红黑树。其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。
-
架构演进本质
- JDK1.7:通过Segment分段锁(继承ReentrantLock)实现数据分治,每个Segment独立加锁,并发度=Segment数。
- JDK1.8:抛弃Segment,采用Node数组+链表/红黑树,锁粒度细化到桶头节点,通过synchronized+CAS实现无锁化读和原子写。
-
性能突破关键
维度 JDK1.7 JDK1.8 锁粒度 Segment加锁 对每个数组元素加锁(Node) 锁机制(保证并发安全) ReentrantLock分段锁 synchronized+CAS桶级锁 数据结构 数组+Segment+链表 数组+链表+红黑树(阈值>8) Hash计算 两次哈希(定位Segment+桶) 单次哈希+尾插法 并发度 受限于Segment数量(默认16) 理论可达Node数组长度 size计算 多次重试+全局锁兜底 分片计数器(CounterCell)+ baseCount 查询时间复杂度 遍历链表O(n) 遍历红黑树O(logN) -
为何synchronized替代ReentrantLock?
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了。
- JVM的开发团队从来都没有synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然。
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多内存,虽然不是瓶颈,但是也是一种选择依据。
-
设计哲学启示
“降低冲突概率,减少临界区竞争” :- 1.7用空间换并发(分治Segment);
- 1.8用数据结构优化(红黑树降时间复杂度)+ 锁粒度最小化(仅锁冲突桶)。
结语
ConcurrentHashMap的演进史,是一部Java高并发编程的优化史诗。从1.7的分段锁到1.8的CAS+synchronized+红黑树,每一次突破都直指性能瓶颈的核心——减少共享资源竞争。理解其底层实现,不仅是为了应对面试,更是掌握“如何在并发环境下用空间与时间换取线程安全”的设计艺术。未来随着协程、无锁数据结构的普及,Java的并发容器还将继续进化,但ConcurrentHashMap的思想精髓永不褪色。