ConcurrentHashMap从JDK1.5开始开始随java.util.concurrent包一起引入JDK中,主要解决HashMap线程不安全和Hashtable效率不高的问题。HashMap是线程不安全的,而Hashtable中的方法加上了synchronized关键字而线程安全了,但是效率低下,所以ConcurrentHashMap的出现解决了这个问题,ConcurrentHashMap既线程安全又效率高。
在JDK1.7之前的ConcurrentHashMap是使用分段锁机制实现,在JDK1.8中则是使用数组+链表+红黑树的数据结构和CAS原子操作实现ConcurrentHashMap,下面将两种实现方式一一介绍:
1.ConcurrentHashMap的实现---JDK1.7版本
1.1分段锁机制
HashTable效率低下是因为对put操作加了锁synchronized,而synchronized是对整个对象进行加锁,也就是说在执行put操作时锁住了整个hash表,使得多线程时其实是串行执行,效率低下。因此在JDK1.5~1.7使用了分段锁机制实ConcurrentHashMap。
简单来说,就是ConcurrentHashMap在对象中保存了一个Segment数组,即将整个hash表划分为多个分段;每一个segment,即每一个分段类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此ConcurrentHashMap在多线程并发编程时可以实现多线程put操作。下面将详细分析其原理:
1.2ConcurrentHashMap的数据结构
ConcurrentHashMap类结构如上图所示,在ConcurrentHashMap定义中,定义了一个Segment<K, V>[]数组来将Hash表实现分段存储,从而实现分段加锁;而么一个Segment元素则与HashMap结构类似,其包含了一个HashEntry数组,用来存储Key/Value对。Segment继承了ReetrantLock,表示Segment是一个可重入锁,因此ConcurrentHashMap通过可重入锁对每个分段进行加锁。
1.3ConcurrentHashMap的初始化
JDK1.7的ConcurrentHashMap初始化主要分为两个部分:一是初始化ConcurrentHashMap,即初始化segments数组,segmentShift段偏移量和segmentMask段掩码等;然后则是初始化每个segment分段。接下来,将介绍这两部分的初始化:
ConcurrentHashMap包含多个构造函数,而所有的构造函数最终都调用了如下的构造函数:
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
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;
}
由代码可知,该构造函数传入了三个参数:initialCapacity、loadFactor、concurrencyLevel,其中,concurrencyLevel主要用来初始化segments,segmentShift和segmentMask等;而initialCapacity和loadFactor则主要用来初始化每个Segment分段。
1.3.1初始化ConcurrentHashMap
根据ConcurrentHashMap的构造方法可知,在初始化时创建了两个中间变量ssize和sshift,它们都是通过concurrentLevel计算得到的。其中ssize表示了segments数组的长度,为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方,所以在初始化时通过循环计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为数组的长度;而sshift表示了计算ssize时进行移位操作的次数。
segmentShift用于定位参与散列运算的位数,其等于32减去sshift,使用32是因为ConcurrentHashMap的hash()方法返回的最大数是32位的;segmentMask是散列运算的掩码,等于ssize减去1,所以掩码的二进制各位都为1。
因为ssize的最大长度为65536,所以segmentShift最大值为16,segmentMask最大值为65535. 由于segmentShift和segmentMask与散列运算相关,因此之后还会对此进行分析。
1.3.2 初始化Segment分段
ConcurrentHashMap通过initialCapacity和loadFactor来初始化每个Segment. 在初始化Segment时,也定义了一个中间变量cap,其等于initialCapacity除以ssize的倍数c,如果c大于1,则取大于等于c的2的N次方,cap表示Segment中HashEntry数组的长度;loadFactor表示了Segment的加载因子,通过cap*loadFactor获得每个Segment的阈值threshold。
默认情况下,initialCapacity等于16,loadFactor等于0.75,concurrencyLevel等于16。
1.4定位Segment
由于采用了Segment分段锁机制实现一个高效的同步,那么首先则需要通过hash散列计算key的hash值,从而定位其所在的segment,因此来看看ConcurrentHashMap中的hash()方法的实现:
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
通过hash函数可知,首先通过计算一个随机的hashSeed减少String类型的key值的hash冲突;然后利用Wang/Jenkins hash算法对key的hash值进行再hash计算。通过这两种方式都是为了减少散列冲突,从而提高效率。因为如果散列的质量太差,元素分布不均,那么使用Segment分段加锁也就没有意义了。
private Segment<K,V> segmentForHash(int h) {
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
return (Segment<K,V>) UNSAFE.getObjectVolatile(segments, u);
}
接下来,ConcurrentHashMap就能通过上述定位函数定位到key所在的segment分段。
1.5ConcurrentHashMap的操作
在介绍ConcurrentHashMap的操作之前,首先需要介绍一下Unsafe类,因为在JDK1.7新版本中是通过Unsafe类的方法实现锁操作的。Unsafe类是一个保护类,一般应用程序很少用到,但其在一些框架中经常用到,如JDK、Netty、Spring等框架。Unsafe类提供了一些硬件级别的原子操作,其在JDK1.7和JDK1.8中的ConcurrentHashMap都有用到,但其用法却不同,在此只介绍在JDK1.7中用到的几个方法:
- arrayBaseOffset(Class class):获取数组第一个元素的偏移地址。
- arrayIndexScale(Class class):获取数组中元素的增量地址。
- getObjectVolatile(Object obj, long offset):获取obj对象中offset偏移地址对应的Object型field属性值,支持Volatile读内存语义。
1.5.1get
JDK1.7的ConcurrentHashMap的get操作是不加锁的,因为在每个Segment中定义的HashEntry数组和在每个HashEntry中定义的value和next HashEntry节点都是volatile类型的,volatile类型的变量可以保证其在多线程之间的可见性,因此可以被多个线程同时读,从而不用加锁。而其get操作步骤也比较简单,定位Segment –> 定位HashEntry –> 通过getObjectVolatile()方法获取指定偏移量上的HashEntry –> 通过循环遍历链表获取对应值。
- 定位Segment:(((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE
- 定位HashEntry:(((tab.length - 1) & h)) << TSHIFT) + TBASE
1.5.2put
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
put方法首先也会通过hash算法定位到对应的Segment,此时,如果获取到的Segment为空,则调用ensureSegment()方法;否则,直接调用查询到的Segment的put方法插入值,注意此处并没有用getObjectVolatile()方法读,而是在ensureSegment()中再用volatile读操作,这样可以在查询segments不为空的时候避免使用volatile读,提高效率。在ensureSegment()方法中,首先使用getObjectVolatile()读取对应Segment,如果还是为空,则以segments[0]为原型创建一个Segment对象,并将这个对象设置为对应的Segment值并返回。
在Segment的put方法中,首先需要调用tryLock()方法获取锁,然后通过hash算法定位到对应的HashEntry,然后遍历整个链表,如果查到key值,则直接插入元素即可;而如果没有查询到对应的key,则需要调用rehash()方法对Segment中保存的table进行扩容,扩容为原来的2倍,并在扩容之后插入对应的元素。插入一个key/value对后,需要将统计Segment中元素个数的count属性加1。最后,插入成功之后,需要使用unLock()释放锁。
1.5.3size
ConcurrentHashMap的size操作的实现方法也非常巧妙,一开始并不对Segment加锁,而是直接尝试将所有的Segment元素中的count相加,这样执行两次,然后将两次的结果对比,如果两次结果相等则直接返回;而如果两次结果不同,则再将所有Segment加锁,然后再执行统计得到对应的size值。
2.ConcurrentHashMap的实现------JDK8版本
JDK1.7之前的分段锁机制,最大并发度受Segment的个数限制,JDK1.8中,加锁则采用CAS和synchronized实现。
2.1CAS原理
一般地,锁分为悲观锁和乐观锁:悲观锁认为对于同一个数据的并发操作,一定是为发生修改的;而乐观锁则认为对于同一个数据的并发操作不会发生修改,在更新数据时会采用尝试更新不断重试的方式更新数据。
CAS(Compare And Swap):CAS有三个操作数,内存值V,预期值A,要修改的新值B,当且仅当A和V相等时才会修改为B,否则什么都不做。Java中CAS操作通过本地JNI本地方法实现,在JVM中程序会根据当前处理器的类型决定是否为cmpxchg指令添加前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock cmpxchg);反之,如果程序运行在单处理器上,就省略lock前缀。
CAS同时具有volatile读和volatile写的内存语义。
CAS存在的问题:存在ABA问题,解决思路:使用版本号;循环时间长,开销大;只能保证一个共享变量的原子操作。
2.2ConcurrentHashMap的数据结构