ConcurrentHashMap个人理解
hashmap简单分析
在解析ConcurrentHashMap之前,为了方便大家更形象的理解ConcurrentHashMap,先简单介绍一下hashmap结构,hashmap结构如下图,图片来自百度百科。
如上图,我们可以发现,可以简单将hashmap结构理解为一个数组,然后这个数组中的元素存放的是由entry类组成的链表,此类结构很简单,直接上代码。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
当hashmap进行put时,首先计算出put的key的hash值,然后用此hash值对数组长度-1进行按位与,先得到此key代表的元素应该存放的数组下标。比如hashmap中默认的数组长度为16(数组长度可以在初始化hashmap进行设置),如果hash(key)=100,100&(16-1)= 4,那么我们就先定位到数组下标为4的元素。这样我们就得到了存放在此数组下标中链表首元素entry对象,然后如果首元素为null,直接设置,如果非null,比较两元素中hash(key),不同则移动到到链表下一个元素继续比较,相同则替换,如果遍历完链表还为找到相同的key,则将此元素设置为链表的首元素。put代码如下:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key ||key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
hashmap的get与之类似,总结来说put和get都是通过hash(key)对对象的定位,第一步先使用hash(key)计算出所对应的数组下标,第二步则通过hash(key)定位对象在数组下标所存放的链表中的位置。剩余的操作就不多描述了。通过由于hashmap中put和get方法并未加锁,而且hashmap中的元素也非不可变对象,所以hashmap是线程不安全的。
ConcurrentHashMap简介
前面介绍完hashmap,我们初步了解hashmap结构以及hashmap是线程不安全的,为了解决hashmap线程不安全的问题,我们可以对hashmap的put和get方法进行加锁,实际上hashtable就是这样做的,这样的的确保证了线程安全,但是也引入了一个问题,就是效率低下,因为put和get方法是直接使用的synchronized关键字,所以,多个线程进行访问hashtable的时候,同一时间只能有一个线程进行put或者get。ConcurrentHashMap可以说就是为了解决此问题而设计的,它保证了线程安全的前提下,多个线程的并发访问,大大提升了性能。让我们一起研究下它是怎么实现此功能的。
ConcurrentHashMap解析
concunrrent在实现线程安全的前提下,提高线程的并发数,使用的就是一种称之为分段锁的技术。hashtable相当于对一个hashmap进行加锁,而ConcurrentHashMap可以简单理解为由16个hashmap组成的数据结构(默认16,可以设置,但是如果不是2的幂次倍,会自动扩容到2的幂次倍),通过二次hash定位。ConcurrentHashMap结构图如下:
如上图所示,ConcurrentHashMap默认是一个由16个segment组成的数组(segment结构类似于上面讲的hashmap,jdk1.8修改了ConcurrentHashMap的构成,现在讲的主要是jdk1.7,1.8与1.7的区别最后在描述),所以get和put就需要经过二次hash定位,简单描述为第一次hash定位segment,第二次hash定位segment中数组下标的链表。而ConcurrentHashMap的线程安全是通过对segment中的put方法来加锁进行保证的,因为ConcurrentHashMap中拥有16个segment,针对同时多个put,如果hash(key)所定位的segment不同,则可以同时添加,所以大大提高了ConcurrentHashMap的并发度。下面我们研究一下ConcurrentHashMap中的put和get方法吧。
put方法
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);
}
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
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 {
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
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
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 (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
上面代码,第一个put方法是通过hash(key)用来定位segment,第二个则是在segment中的put方法,我们重点研究segment中的put方法,查看代码序号第14,15行,我们发现在put使用trylock()进行尝试加锁,如果获取到锁,则设置新元素node为null,然后继续执行put方法,重点需要看的是trylock()失败,此时会调用scanAndLockForPut()方法,研究此方法的实现,真得由衷感叹java源码设计的巧妙。此方法中三个重要属性,e(通过hash(key)计算出存放的数组下标,获取此数组下标的链表首元素复制给e),node(put创建的新元素),retries(尝试获取锁的次数),代码62行是一个循环,不断尝试获取锁,如果获取到锁,则直接返回node;未获取到锁,则进入循环内部。循环内部有三步操作:
步骤1:代码64~74在尝试获取锁失败且retries=-1时执行的操作,主要是定位node在segement数组下标链表中的位置。如果e为null,则代表segement数组下标的链表为null,因此先初始化node,同时设置retries=0(代码65~69),如果e非null,则查找put.key是否与segment数组下标链表已存在元素的key相同(代码70~71),如果put.key不存在于segment数组下标链表中,则回到代码65~69,如果存在就直接设置retries=0
步骤2:代码75~78,此时代表步骤1已经执行过了,已经定位了新node在segement中的位置,此步骤就是不断尝试获取锁,每尝试一次retries+1,如果尝试次数达到上限,则直接进行lock(),强制加锁
步骤3:代码79~82,步骤3主要是判定segment是否被其他线程进行了修改,因为如果此时对当前segment进行put的线程一直未获取锁,则代表一定有其他线程在对此segment进行put操作,所以需要验证此时segment的准确性,如果发现segment已经被修改(代码79~80,通过判定此时segment通过hash(key)算出的数组下标的链表首元素与最初获取的数组下标链表首元素是否相同:因为segment中数组下标代表的链表添加元素都是使用头插法),则重新初始化retries=-1,再次循环的时候重新执行步骤1。
scanAndLockForPut()方法分析完毕,让我们继续回到segment的put方法中,此put方法与hashmap的put方法并无太大区别,区别就在于segment的put方法是进行了加锁的,而且尝试加锁的过程中判断了hash(key)是否存在于segment中,如果不存在,在尝试加锁的过程中,直接初始化了新元素(代码2),还有个关键点就是代码42,当添加新元素后,如果此时segment中的数组长度还为达到默认长度(16),而segment中的元素数量c大于负载因子和数组长度算出来的门槛,则对新元素进行rehash,这样使segment中数组各链表的元素保持均衡(简单来说就是尽量保证segment的元素均衡的分配到不同数组下标表示的链表中)。另外添加新元素时,count是segement的属性,而非concurrentHashMap的属性,所以统计调用concurrentHashMap的size()方法时,实际上是将16个segment的count相加。
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;
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) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
研究源码,我们发现在ConcurrentHashMap只有一个get()方法,也即是说在segment内部类中没有get方法,而且仔细研究我们发现get()方法并未加锁。还是先简单介绍下get方法吧,get方法实质上就是双重hash(key)定位元素位置,首先第一次hash(key)计算出segments数组中指定的segment,第二次hash(key)计算出segment中hashEntry[]数组下标,然后获取此数组下标位置的hashEntry链表,进行遍历查找。因为get方法并为加锁,所以ConcurrentHashMap是弱一致性,大幅提高了效率,关于ConcurrentHashMap弱一致性的解释,参考如下文章,我就不进行多写了。
文章链接:为什么ConcurrentHashMap是弱一致的
总结
在jdk1.7中,ConcurrentHashmap通过使用分段锁技术,将自身数据结构设计成16个segment的数组(默认16),put时只对指定的segemnt加锁,大大提高了并发度,同时对segment中的put方法采用自旋锁,而非强制加锁,也提升了put效率,segment添加新元素时,当元素分配不均衡时,执行rehash,尽量确保segment中HashEntry[]中元素分配均衡,提高了查找效率。而采用非锁get方式,通过数据的弱一致性带来性能上的大幅提升。