ConcurentHashMap为何是线程安全的,从JDK7到JDK8都做了哪些调整?
我们知道ConcurentHashMap是j.u.c包下面的代表集合类,不难发现在JDK版本迭代特别是从1.7升级到1.8之后,ConcurentHashMap结构发生了巨大的变化。这也成了大家热议的技术话题和高频的面试考题,那么ConcurentHashMap线程安全的机制是什么,结构都有哪些调整呢,围绕这些话题我们做了三点思考:
线程安全机制
ConcurentHashMap都是怎么实现线程安全
ConcurentHashMap的JDK1.7到1.8都有哪些实质性改变
一、线程安全机制
我们先看一个线程不安全的例子。
public static int i;
public static void main(String[] args) throws InterruptedException {
Runnable r = new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
i++;
}
}
};
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
例子中我们定义了一个全局变量i并通过两个线程处理循环,那么结果会是我们想要的20000吗,答案是否定的。细心的同学就会发现每次的输出的结果都有不同。究其原因原来i++本身在多线程环境中并不是一个原子操作,而包含三个处理步骤:
(1)读取
(2)插入
(3)保存
试想在这三个步骤中若线程A的读取i然后执行i+1,若被切换走此时线程B再被cpu执行,那么获取的结果就不是2,因为并没有执行保存处理。虽然两者都执行了i+1但前者没有保存后者就无法看到其+1的结果。这也是最典型的共享变量引发的运行结果错误的线程安全问题。
所以我们给出线程安全的定义:
在多线程的机制下,比如调用处理某类中的方法,不管用什么方式调用或者何种方式交替执行都无须任何同步处理, 我们就认为该类处理是线程安全的。所以线程安全的机制在于首先是多线程环境(多线程才有线程调度和协调问题)其次在于无须考虑线程调度和协调同步而又能获取正确的结果。
基于这方面考虑,我们讨论JDK中的并发类都是如何实现线程安全的。
二、ConcurentHashMap都是怎么实现线程安全
我们知道synchronized、hashtable、ConcurentHashMap都是线程安全的操作和类。那么ConcurentHashMap是如何实现线程安全的呢,它与hashtable都有哪些区别?我们从jdk1.8的源码开始分析:
Node存储结点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// ...
}
可以看到node结点也是实现了map.Entry接口的子类,也通过k-v形式存储处理数据,并通过volatile保证其内存可见性。
putVal方法
由于putVal是put方法中的引用,我们先看看putVal的源码:
final V putVal(K var1, V var2, boolean var3) {
if (var1 != null && var2 != null) {
int var4 = spread(var1.hashCode());//计算hash值
int var5 = 0;
Node[] var6 = this.table;//定义node数组
while (true) {
int var8;
while (var6 == null || (var8 = var6.length) == 0) {
var6 = this.initTable();//为空就定义新的数组
}
Node var7;
int var9;
if ((var7 = tabAt(var6, var9 = var8 - 1 & var4)) == null) {
//位置为空就用cas填充newNode
if (casTabAt(var6, var9, (Node) null, new Node(var4, var1, var2, (Node) null))) {
break;
}
} else {
int var10 = var7.hash;
//注意-1值扩容if (var7.hash == -1) {
var6 = this.helpTransfer(var6, var7);
} else {
Object var11 = null;
//使用synchronized锁住var7槽点,保证线程安全
synchronized (var7) {
if (tabAt(var6, var9) == var7) {
if (var10 < 0) {
//红黑树的情况
if (var7 instanceof TreeBin) {
var5 = 2;
TreeNode var18;
if ((var18 = ((TreeBin) var7).putTreeVal(var4, var1, var2)) != null) {
var11 = var18.val;
if (!var3) {
var18.val = var2;
}
}
}
} else {
var5 = 1;
Node var13 = var7;
//判断链表是否存在,然后覆盖处理
while (true) {
if (var13.hash == var4) {
Object var14 = var13.key;
if (var13.key == var1 || var14 != null && var1.equals(var14)) {
var11 = var13.val;
if (!var3) {
var13.val = var2;
}
break;
}
}
Node var15 = var13;
//新值添加到链表最后
if ((var13 = var13.next) == null) {
var15.next = new Node(var4, var1, var2, (Node) null);
break;
}
++var5;
}
}
}
}
if (var5 != 0) {
//检查把链表转化为红黑树的情况
if (var5 >= 8) {
this.treeifyBin(var6, var9);
}
if (var11 != null) {
return var11;
}
break;
}
}
}
}
this.addCount(1L, var5);
return null;
} else {//无结点情况抛出NPE
throw new NullPointerException();
}
}
通过上面的putVal方法可知,putVal会根据Node[]是否初始化、为空、扩容、链表、红黑树情况做不同的处理。我们先用图分析concurenthashMap结构:
不难看出图中有三类结点:
第一、空白结点,表示该槽点还没有被填充;
第二、绿色拉链法结构,在槽中会首先填入一个结点然后再计算有相同hash值通过链表形式往后续延伸;
第三、红黑树结点,根节点为黑树。这是一种特殊的BST查找树,相比较其他的平衡树,它在减少查找时hash碰撞上比其他的二叉查找树更少(自动平衡)因此提高了查找效率。其时间复杂度保持在O(long(n)。
而它保证线程安全的结构是node+synchronized+cas。
get方法
我们再看下get方法的源码:
public V get(Object var1) {
int var8 = spread(var1.hashCode());//计算hash值
Node[] var2 = this.table;//定义node数组
Node var3;
int var5;
if (this.table != null && (var5 = var2.length) > 0 && (var3 = tabAt(var2, var5 - 1 & var8)) != null) {
int var6 = var3.hash;
Object var7;
if (var3.hash == var8) {
var7 = var3.key;
//如果找到的是结点值则直接返回
if (var3.key == var1 || var7 != null && var1.equals(var7)) {
return var3.val;
}
}
//在扩容或者红黑树用find查找
else if (var6 < 0) {
Node var4;
return (var4 = var3.find(var8, var1)) != null ? var4.val : null;
}
//遍历链表查找
while ((var3 = var3.next) != null) {
if (var3.hash == var8) {
var7 = var3.key;
if (var3.key == var1 || var7 != null && var1.equals(var7)) {
return var3.val;
}
}
}
}
return null;
}
我们看到get方法获取val的思路:
计算hash值并通过该值找到对应的槽点;
如果node数组为空就直接返回null,否则判断该节点是否是var1我们想要的入参值,若是就直接返回对应结点的val;
若查找的是红黑树或者扩容,则通过find方法继续查找;
最后查找的是链表,遍历链表获取val。
三、ConcurentHashMap的JDK1.7到1.8都有哪些实质性改变
通过上面jdk1.8的ConcurentHashMap结构我们不难发现其结构是:数组+链表+红黑树,那么jdk1.7版的是哪些呢?我们看下jdk1.7对应的结构图:
我们看到该结构是segment结点组成的数组。segment继承了
ReentrantLock。我们可以理解为一个个分段锁,而这个锁都是相互独立的。相比较hashtable使用synchronized锁住整个对象,segment锁的粒度更加细,保证线程安全的性能提升。
从图中可以看到segment结点组成的数组默认为16。不难得出这与jdk1.8版本相比较在以下方面有出入:
特点 |
jdk1.7版 |
jdk1.8 |
结构 | segment | 链表+数组+红黑树 |
线程安全机制 | segment分段锁(继承的是ReentrantLock) | node+cas+synchronied |
hash碰撞处理 | 拉链法处理 |
先用拉链法处理,超过一定阈值后转化为红黑树,自动平衡 |
并发量 | 默认16,segment个数 |
数组容量 |
效率(时间复杂度) | 链表处理效率 |
使用BST红黑树O(nlog(n)) |