JDK1.7中的ConcurrentHashMap底层原理详解
温故而知新
既然聊到ConcurrentHashMap,那么肯定得聊一聊并发编程下的安全问题,众所周知,ConcurrentHashMap是专门为了解决并发时候HashMap出现的安全问题而推出的 ,该类位于 java.util.concurrent
包下。 那么先让我们看看下面这个问题。
HashMap为什么线程不安全?
- 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
- put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。
乍一看,这个答案似乎是十分的perfect,然后事实并不是这样的。这个答案存在一个很大错误,这也是为什么今天我在文章中增加了温故而知新这个部分。最近也是在坚持看面经,对于上述问题,我之前整理的答案也就是上面这个段落,但是很幸运在面试被问到这个问题之前,我发现了这个答案中的错误,就是在JDK1.8当中,HashMap也有可能存在环形链表的问题。
这里有一点需要注意,在JDK1.7当中环形单链表带来的是死循环的问题,而在JDK1.8当中存在环形双向链表带来的死循环问题,为什么是环形双向链表呢?因为在红黑树其实也采取了类似双向链表的结构进行存储和操作,因此如果面试官问到HashMap在JDK1.8中是否存在死循环?
,那就可以肯定的回答:存在。如果面试官闻到HashMap在JDK1.8中是否存在环形链表的问题?
那么最好先解释一下,这个环形链表具体指的是什么,然后再去回答,jdk1.8的HashMap在多线程的情况下也会出现死循环的问题,但是在JDK1.8中在链表转换成红黑树或者是对红黑树进行操作的时候,可能会对红黑树进行重构,并且是在并发操作的情况下,才有可能出现这个问题,与JDK1.7发生的原因不一样。
注意:这个红黑树和双向链表挂钩的结论只是博主个人的粗浅认识,如不恰当,欢迎各位指正!
综上所述,HashMap无论是JDK1.7还是JDK1.8,在并发环境下都会有很大的安全隐患,因此工作中基本不会使用HashMap,而是使用ConcurrentHashMap这个JUC包下面的并发编程类。
ConcurrentHashMap简单说明(JDK1.7)
ConcurrnetHashMap
由很多个 Segment
组合,而每一个 Segment
是一个类似于 HashMap
的结构,所以每一个 HashMap
的内部可以进行扩容。但是 Segment
的个数一旦初始化就不能改变,默认 Segment
的个数是 16 个,也可以认为 ConcurrentHashMap
默认支持最多 16 个线程并发。
分段锁示意图
ConcurrentHashMap内部变量的含义
//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认容量
private static final int DEFAULT_CAPACITY = 16;
//最大的数组长度,toArray方法需要
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//链表树化的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树变成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表需要树化的最小容量要求
static final int MIN_TREEIFY_CAPACITY = 64;
//在进行扩容时单个线程处理的最小步长。
private static final int MIN_TRANSFER_STRIDE = 16;
//sizeCtl 中用于生成标记的位数。对于 32 位数组,必须至少为 6。
private static int RESIZE_STAMP_BITS = 16;
//可以帮助调整大小的最大线程数。必须适合 32 - RESIZE_STAMP_BITS 位。
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//在 sizeCtl 中记录大小标记的位移。
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//哈希表中的节点状态,会在节点的hash值中体现
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
//基本计数器值(拿来统计哈希表中元素个数的),主要在没有争用时使用,但也可作为表初始化竞赛期间的后备。通过 CAS 更新。
private transient volatile long baseCount;
//表初始化和调整大小控制。如果为负数,则表正在初始化或调整大小:-1 表示初始化,否则 -(1 + 活动调整大小线程的数量)。否则,当 table 为 null 时,保存要在创建时使用的初始表大小,或者默认为 0。初始化后,保存下一个元素计数值,根据该值调整表的大小。
private transient volatile int sizeCtl;
//调整大小时要拆分的下一个表索引(加一个)。
private transient volatile int transferIndex;
//调整大小和/或创建 CounterCell 时使用自旋锁(通过 CAS 锁定)。
private transient volatile int cellsBusy;
//计数单元表。当非空时,大小是 2 的幂。 与baseCount一起记录哈希表中的元素个数。
private transient volatile CounterCell[] counterCells;
ConcurrentHashMap的构造函数
首先看看JDK1.7中 ConcurrentHashMap 的无参构造方法,无参构造直接调用了一个有参构造方法,也就是我们没有传入参数时,它会利用默认值进行实例的创建。
简单说明:
有参构造方法:
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel);
第一个参数是initialCapacity:指所有的Segment对象里面的数组长度总和。默认为16;
第二个参数是loadFactor:负载因子,这个就不多说了;
第三个参数是concurrencyLevel:表示最多能支持多少个线程同时执行,也就是segment数组的长度,初始时默认值为16。
/**
* Creates a new, empty map with a default initial capacity (16),
* load factor (0.75) and concurrencyLevel (16).
*/
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
要点理解:
在JDK1.7中对并发问题的处理思想:把大的HashMap分成多个小的HashMap,一个Segment的对象里面有一个小的HashMap,然后对每个Segment加锁,这就是分段锁,因此源码中也会有两个主要的数组。
通过key的hashcode计算一次hash值,然后计算两次下标。
第一次是Segment数组的下标。第二次计算Segment对象中的内部的Entry数组的下标。
无论是Segment数组,还是HashEntry数组,对HashMap进行操作的过程中,计算元素对应在两个数组的下标都是通过与运算得到的,因此两个数组的长度都需要是2的n次幂。方法中通过两个while循环来进行长度规则的保证。
// Find power-of-two sizes best matching arguments 这个官方在源代码中的注释
为了简便Segment对象的创建(构造方法中各个参数的确定),初始化时会创建一个Segment对象放在Segment数组的第一个位置,也就是segment[0],起到类似模板的作用。
接着来看下这个有参构造函数的内部实现逻辑,具体还是看注释。
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {
// 参数校验
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 校验并发级别大小,大于 1<<16,重置为 65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// 2的多少次方
int sshift = 0;
int ssize = 1;
// 这个循环可以找到 大于等于 concurrencyLevel 的 2的次方值 的最小值
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 记录段偏移量
this.segmentShift = 32 - sshift;
// 记录段掩码(用作hash值计算)
this.segmentMask = ssize - 1;
// 设置容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 这里是计算每个 Segment 中 HashEntry 的个数 就是把HashMap分成很多个小的HashMap,每个小HashMap的容量
// c = initialCapacity(总容量) / ssize(Segment的长度) ,默认情况下 16 / 16 = 1
int c = initialCapacity / ssize;
// 进行向上取整
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
// 这个循环可以找到 大于等于 c(每个小的HashMap容量) 的 2的次方值 的最小值
while (cap < c)
cap <<= 1;
// create segments and segments[0]
// 创建 Segment 数组,设置 segments[0],这个操作是为后续的Segment对象创建提供模板
Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
ConcurrentHashMap的put()方法和ensureSegment()方法
初始化各个参数之后,接下来查看 put()
方法源码。
大概说明:
从代码中,很明显可以看出来,这个方法没有进行加锁对并发进行控制,所以不同的线程可能同时执行ensureSegment()方法来生成segment对象,因此可能产生多个segment对象,但是实际上我们在数组中每个位置的segment对象只有一个。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
// hash 值无符号右移 28位(初始化时获得),然后与 segmentMask = 15 做与运算
// 其实也就是把高4位与segmentMask(1111)做与运算
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 如果查找到的 Segment 为空,初始化生成该位置的Segment对象
s = ensureSegment(j);
// 调用Segment对象的put()方法
return s.put(key, hash, value, false);
}
从这里可以看到,put方法中实现的功能其实不多,下面直接进入到ensureSegment()
方法。
要点解析:
有一点在文章前面已经提到过,Segment数组中segment[0]中的Segment对象起到一个模板的作用,提供生成Segment对象的一些信息,比如内部Entry数组的大小。这里就很好的进行了利用。
// use segment 0 as prototype 官方注释的解释
这段代码看起来也没有通过lock方法或者synchronized进行加锁。但是注意看代码后面部分的while循环,最终通过CAS + 自旋(可以看作是乐观锁),保证Segment对象不会被覆盖(每个位置有且只有第一个创建的Segment对象)。
例:
假如有两个线程都进入自旋,先自旋成功的线程生成Segment对象,进入if语句通过break跳出循环,返回seg;
后面的线程由于if语句判断为false,再次循环,后来发现该位置已经存在Segment对象,也就是判断不为null,因此会直接获得Segment对象,不会进入循环体,而是直接返回seg(这个Segment对象就是前面的线程创建的)。
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
// 判断 u 位置的 Segment 是否为null(在put()方法中判断过了,这里需要再次判断,实现了双重判断)
// 如果为null,if语句内部执行,尝试生成Segment对象
// 如果不为null,直接返回这个Segment对象
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
// 获取0号 segment 里的 HashEntry<K,V> 初始化长度
int cap = proto.table.length;
// 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的
float lf = proto.loadFactor;
// 计算扩容阀值
int threshold = (int)(cap * lf);
// 创建一个 cap 容量的 HashEntry 数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck
// 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作(在自旋检查前,已经检查三次了)
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 自旋检查 u 位置的 Segment 是否为null
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
// 使用CAS 赋值,只会成功一次
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
下面来整理一下这部分代码的一个流程:
①计算要 put 的 key 在 Segment 数组中的位置,获取指定位置的 Segment 对象;
②如果指定位置的 Segment 对象为 null,则初始化这个 Segment 对象;
初始化 Segment 流程:
- 检查计算得到的位置的 Segment 是否为null;
- 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组;
- 再次检查计算得到的指定位置的 Segment 是否为null;
- 使用创建的 HashEntry 数组初始化这个 Segment;
- 自旋判断指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为新创建的 Segment 对象。
③Segment调用它自己的put()方法, 传入 key,value 等相关参数值。(不要漏了这一步)
Segment的put()方法和scanAndLockForPut()方法
首先要注意一点,Segment
继承了ReentrantLock
类,因此是可以直接调用lock()
、tryLock()
、unLock()
等方法的。
对于lock()
和tryLock()
方法的比较。
tryLock()方法:不会对线程产生阻塞作用,如果加锁失败,返回false;加锁成功,返回true。
lock()方法:如果当前的锁被其他线程占有,那么当前线程会直接进入到阻塞状态。
在这里先多提一点:tryLock()的自旋,自旋可以起到阻塞的作用,但是并不是完全阻塞,这个逻辑可以处理一些其他的代码。
while(!tryLock()){
处理逻辑
}
分段锁说明:
下面来到正题,我们终于在Segment的put()方法中看到了lock()方法,也就是加锁。
通过lock()加锁,是一个对象锁,也就是每个Segment对象都会有一把锁,因此不同的Segment对象拥有不同的锁,这也就是所谓的分段锁。只有相同的Segment对象调用它们的put()方法的时候,才存在锁的竞争。
具体的 Segment
中put()
方法的逻辑还是通过注释来梳理。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 获取 ReentrantLock 独占锁;获取不到锁,也就是加锁失败,调用scanAndLockForPut()方法。
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 计算要put的数据位置
int index = (tab.length - 1) & hash;
// CAS 获取 index 坐标的值
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
// first 有值没说明 index 位置已经有值了,有冲突,链表头插法。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 容量大于扩容阀值,小于最大容量,进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
流程梳理:
①首先通过
tryLock()
方法 获取锁,获取不到使用scanAndLockForPut()
方法继续获取;②计算 put 的数据要放入的 index 位置,然后获取这个位置上的
HashEntry
对象;③遍历 put 新元素,因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,有两种情况需要处理;
a.如果这个位置上的 HashEntry对象不存在:
1、如果当前容量大于扩容阀值,小于最大容量,进行扩容;
2、直接通过头插法进行节点插入。
b.如果这个位置上的 HashEntry 存在:
1、判断链表当前元素 Key 和 hash 值是否和要 put 的 key 和 hash 值一致,如果有相同的 key 值则覆盖掉;
2、如果不存在当前的 key 值,跟a情况一样。
如果当前容量大于扩容阀值,小于最大容量,进行扩容。
直接链表头插法插入。
④如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null。
下面这部分是重点,比起HashTable
简单使用synchronizied
对方法进行加锁,为什么ConcurrentHashMap
效率会更高?
这个方法里面其实利用了自旋锁的思想,在等待锁的过程中,可以遍历链表,可以先生成Entry对象,提高效率。
部分要点说明:
同一个key,不需要生成新的Entry对象,只需要修改原来Entry对象中的value值。
retries是一个计数器(标识符),当retries = -1,进行链表的遍历,当遍历结束后;retries = 0,此时充当计数器的作用,记录空转的次数。
(retries & 1) == 0 通过位运算判断retries是否为偶数
遍历完链表,并不一定当前准备好的HashEntry就是正确的,由于其他线程可能对链表产生修改,需要当前线程重新遍历链表。
// re-traverse if entry changed 官方注释的说明
空转次数过多,进入阻塞?
因为空转时,我们的线程需要一直运行但是又处理不了任何任务(链表早已遍历完),所以进行阻塞,减少资源浪费。
scanAndLockForPut()
方法的详细分析请看注释。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// 获取该hash对应位置链表的头节点
HashEntry<K,V> first = entryForHash(this, hash);
// 链表第一个节点
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
// 充当一个计数器的作用
int retries = -1; // negative while locating node
// 自旋获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
// 进入这个if语句,就是遍历链表的实现
if (retries < 0) {
// 遍历到了链表尾部
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
// 已经生成了新的Entry对象,不会走第一个if语句
retries = 0;
}
// 已经存在相同key的Entry,也不会走第一个if语句,即不会再创建一个新的HashEntry
else if (key.equals(e.key))
retries = 0;
else
// 遍历链表的逻辑
e = e.next;
}
// 记录空转的次数,也就是已经遍历完链表了,也没有其他事情需要处理,进行空转
// MAX_SCAN_RETRIES根据CPU核心数赋值,多核64,单核1,也就是多核会空转64次,单核会空转1次
else if (++retries > MAX_SCAN_RETRIES) {
// 链表遍历完了,自旋达到指定次数后,阻塞等到只到获取到锁
// 一直while也消耗性能
lock();
break;
}
// (retries & 1) == 0这个语句的逻辑就是判断retries是否为偶数
// 如果是偶数,获取该hash对应位置链表的当前头节点,和之前获取到的first头节点比较,判断是否相等
// 如果不相等,说明链表被修改了(抢到锁的线程修改了头节点),重新遍历链表(由于头节点修改了,之前做的就成了无用功,需要重新进行遍历)
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
小结:
该部分比较重要,因此在源代码的每一步基本都做了具体的说明,流程就不再梳理了。
简而言之:
scanAndLockForPut()
就是在不断的自旋,通过tryLock()
方法尝试获取锁。并且在此期间遍历链表,获取下 hash 位置的HashEntry
。当自旋次数大于指定次数时,使用lock()
阻塞获取锁。
rehash()方法和get()方法
最后这一部分比较简单,大概跟着源码+注释过一遍就差不多了。
要点注意:
ConcurrentHashMap 的扩容是翻倍的,这是为了方便通过 hash 值计算下标。
老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize。
rehash()
方法分析
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
// 老容量
int oldCapacity = oldTable.length;
// 新容量,扩大两倍
int newCapacity = oldCapacity << 1;
// 新的扩容阀值
threshold = (int)(newCapacity * loadFactor);
// 创建新的数组
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,默认2扩容后是4,-1是3,二进制就是11。
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
// 遍历老数组
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
// 如果当前位置还不是链表,只是一个元素,直接赋值
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// 如果是链表了
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
// 新的位置只可能是不便或者是老的位置+老的容量。
// 遍历结束后,lastRun 后面的元素位置都是相同的
for (HashEntry<K,V> last = next; last != null; last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。
newTable[lastIdx] = lastRun;
// Clone remaining nodes
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
// 遍历剩余元素,头插法到指定 k 位置。
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 头插法插入新的节点
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
对于get()
方法,那就更加简单了,首先通过 key 值找到对应目标存放的位置,然后遍历对应位置的链表,匹配 key 值找到对应的 value 值并返回。
get()
方法分析
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 计算得到 key 的存放位置
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
// 如果是链表,遍历查找到相同 key 的 value。
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
相关问题
为什么用与操作而不用取余操作?
计算机最底层的操作是位运算。而取余操作有很多次的除法,属于比较高级的运算,而除法对应底层是通过减法实现的,减法最终也是通过位运算得到。相比起用取余操作,直接利用底层的位运算进行下标的计算,性能更好,但是这对数组的长度有要求,必须是2的幂次方。
ConcurrentHashMap在JDK1.7当中是怎么实现并发的需求的?
答:通过Segment数组和HashEntry数组对HashMap实现分段,并且对每个Segment对象都分配一把可重入锁,从而实现分段锁,以此确保线程安全。(这个答案没什么问题,但是通过本文分析,最好再加上,对于Segment对象创建时的并发情况,是通过自旋 + CAS实现的)
使用ConcurrentHashMap一定线程安全?
既然都这么问到了,那必然是不一定啦!
来看看下面这段代码,代码逻辑很简单,有一个ConcurrentHashMap
类型的map,首先存了一个<“orange”,0>的键值对,然后在并发的条件下,对这个key进行a和b两个操作。
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
int count = 0;
while(count < 10) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("orange", 1);
ExecutorService executorService = Executors.newFixedThreadPool(100);
for (int i = 0; i < 1000; i++) {
executorService.execute(new Runnable() {
@Override
public void run() {
// a操作
int key = map.get("orange") + 1;
// b操作
map.put("orange", key);
}
});
}
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println("第"+ ++count+"次实验,循环操作1000次后结果为:" + map.get("orange"));
executorService.shutdown();
}
}
}
正常来说,最终结果输出的结果应该是等于1000的,那么下面我们来看看结果。
第1次实验,循环操作1000次后结果为:851
第2次实验,循环操作1000次后结果为:967
第3次实验,循环操作1000次后结果为:957
第4次实验,循环操作1000次后结果为:974
第5次实验,循环操作1000次后结果为:979
第6次实验,循环操作1000次后结果为:956
第7次实验,循环操作1000次后结果为:959
第8次实验,循环操作1000次后结果为:981
第9次实验,循环操作1000次后结果为:989
第10次实验,循环操作1000次后结果为:939
没有一次结果是正确的,这是为什么呢?
我们来分析一下,假如现在又一个线程1,进入方法执行了语句
map.get("orange");
拿到了对应的value值;这个时候,get()
方法结束,这个Segment对象锁
的使用权被线程2夺走了,线程2执行了语句map.get("orange");
拿到了对应的value值,这个value值和线程1拿到的value值相等,那么就会导致结果出错。
解决方法有两种
方案一 通过synchronized
锁解决
// 对于两个非原子性操作的语句,利用synchronized对其进行加锁处理
synchronized(this){
// a操作
int key = map.get("orange") + 1;
// b操作
map.put("orange", key);
}
方案二 通过原子类进行解决
AtomicInteger integer = new AtomicInteger(0);
map.put("orange", integer);
...
...
...
public void run() {
map.get("orange").incrementAndGet();
}
这里提醒我们,在使用ConcurrentHashMap
,或者其他的线程安全的容器比如Vector,也会存在这样的问题,容器各个操作之间原子性问题也会导致我们程序出错,所以在使用这些容器的时候还是不能大意。
总结
感觉也没什么好说的了,本来准备看JDK1.8中ConcuurentHashMap的源码,奈何脑子不够用,只能够先啃一下JDK1.7的,最后还是上两张图进行总结吧。
ConcurrentHashMap底层结构图
ConcurrentHashMap底层原理流程图
补充:HashTable
HashTable
直接通过synchronized
修饰方法,即便不同线程没有在同一位置,都需要阻塞等待,进行排队,最后获得该对象锁的线程才能顺利往下执行,也就是并发度为1。但是很显然如果不同的线程操作的位置不同,是可以同时对这个哈希表进行操作的。比起HashMap
和ConcurrentHashMap
,性能都比较差。