首页 > 代码库 > 深入理解JAVA集合系列二:ConcurrentHashMap源码解读

深入理解JAVA集合系列二:ConcurrentHashMap源码解读

HashMap和Hashtable的区别

在正式开始这篇文章的主题之前,我们先来比较下HashMap和Hashtable之间的差异点:

1、Hashtable是线程安全的,它对外提供的所有方法都是都使用了synchronized,是同步的,而HashMap是非线程安全的。

2、Hashtable不允许value为空,否则会抛出空指针异常; 而HashMap中key、value都可以为空。 

1 public synchronized V put(K key, V value) {
2     // Make sure the value is not null
3     if (value =http://www.mamicode.com/= null) {>

 

在Map家族中,同样都是线程安全的,下面来比较下Hashtable和ConcurrentHashMap的差异

Hashtable和ConcurrentHashMap的区别

1、Hashtable实现线程安全的方式是锁住整张Hash表,即每次锁住整张表让线程独占。以下是Hashtable的put和get方法的实现:

技术分享
 1 public synchronized V get(Object key) {
 2     Entry tab[] = table;
 3     int hash = key.hashCode();
 4     int index = (hash & 0x7FFFFFFF) % tab.length;
 5     for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
 6         if ((e.hash == hash) && e.key.equals(key)) {
 7         return e.value;
 8         }
 9     }
10     return null;
11     }
技术分享
技术分享
 1 public synchronized V put(K key, V value) {
 2     // Make sure the value is not null
 3     if (value =http://www.mamicode.com/= null) {>
技术分享

2、ConcurrentHashMap采用的是锁分离技术,其内部是由段(Segment)来组成,每个段就是一个小的Hashtable,每个段由一把锁来控制,所以允许多个修改操作并发进行。

3、从本质上来说,两者之间的区别在于锁的粒度不一样,ConcurrentHashMap的粒度更小,更灵活,这样在多线程情况下性能更高。

 

下面我们从ConcurrentHashMap的数据结构开始这篇文章的主题:

ConcurrentHashMap的数据结构

我们可以做这样一个比喻,把ConcurrentHashMap看成一本书,其中的Segment看做书的卷,table数组中的元素当成章节的标题。

 

技术分享

 

1、 其中segments是整张Hash表,然后里面有16个段(Segment,这里的16是默认值),每个段是一个table数组,数组中每个元素是一个桶,桶中存放的是HashEntry。

2、ConcurrentHashMap的这个数据结构,针对并发做了些调整,它把区间按照并发级别(concurrentLevel),分成了若干个segment,默认的并发级别是16;对于每个segment的容量,默认也是16。当然并发级别和每个segment的初始容量都是可以通过构造函数设定的。

3、继续看每个segment是怎么定义的:

static final class Segment<K,V> extends ReentrantLock implements Serializable

Segment继承了ReentrantLock,表明每个Segment都可以当成一个锁来使用(如果对ReentrantLock不理解的话,就把它认为是Synchronized)。这样对每个segment中的数据需要进行同步操作的话,都是使用每个segment容器对象自身的锁来实现。这种做法,就称之为“分离锁”。

4、HashEntry的数据结构定义如下:

1  static final class HashEntry<K,V> {
2         final K key;
3         final int hash;
4         volatile V value;
5         final HashEntry<K,V> next;

我们在介绍HashMap时,其中的Entry并没有使用final、volatile来修饰元素。而ConcurrentHashMap中除了value不是用final修饰的。这就意味着不能从hash链的中间或者尾部添加或删除节点,因为如果这样做,就必须要修改next的引用值。对于put操作,可以一律添加到Hash链的头部,即新增的元素都是放在Header位置。对于remove操作,可能需要从中间删除一个节点,这就需要将被删除的节点的前面所有节点复制一遍,最后一个节点指向要删除节点的下一个节点。

另外为了确保读操作能够看到最新的的值,且不采用加锁的方式,所以将value设置为volatile。

ConcurrentHashMap中数据的定位

先来看下元素定位的代码:

技术分享
public V put(K key, V value) {
        if (value =http://www.mamicode.com/= null)>
技术分享
final Segment<K,V> segmentFor(int hash) {
        return segments[(hash >>> segmentShift) & segmentMask];
    }

1、首先对key的hashcode码做hash运算,主要是为了减少hash冲突。

2、现在来看segmentFor()方法,这个方法主要是返回segments数组中的元素,即现在已经可以定位到具体的某一个段。

3、在put方法中第8行的index,其实就是元素在table数组中的下标了。然后通过单向链表中的next去遍历就可以找到具体的Entry了。

技术分享
1  V put(K key, int hash, V value, boolean onlyIfAbsent) {
2             lock();
3             try {
4                 int c = count;
5                 if (c++ > threshold) // ensure capacity
6                     rehash();
7                 HashEntry<K,V>[] tab = table;
8                 int index = hash & (tab.length - 1);
9                 HashEntry<K,V> first = tab[index];
技术分享

4、上面的3个步骤大致可以了解数据查找的过程,总结来说就是一次hash运算,2次位运算就定位到数据所在的数据块中。接着链式查找的效率也是比较高的。到现在我们大致可以理解缓存为什么会这么快了。

 

put方法

先来看put方法的源代码:

技术分享
 1 V put(K key, int hash, V value, boolean onlyIfAbsent) {
 2             lock();
 3             try {
 4                 int c = count;
 5                 if (c++ > threshold) // ensure capacity
 6                     rehash();
 7                 HashEntry<K,V>[] tab = table;
 8                 int index = hash & (tab.length - 1);
 9                 HashEntry<K,V> first = tab[index];
10                 HashEntry<K,V> e = first;
11                 while (e != null && (e.hash != hash || !key.equals(e.key)))
12                     e = e.next;
13 
14                 V oldValue;
15                 if (e != null) {
16                     oldValue = http://www.mamicode.com/e.value;>
技术分享

1、从第2行可以看到,该方法是在持有段锁的情况下执行的。这当然是为了并发的安全,毕竟修改数据是不能进行并发操作的。

2、在第4-6行,count表示该段的容量,先进行加1操作,然后判断是否需要进行扩容。扩容也是在原来容量的基础上扩大一倍。

3、我们直接从第10行开始解读:e取的是该位置上链表的头元素。

4、第11行是在链表中精确定位Entry,如果没有找到,则通过next继续遍历该单向链表。

5、第15-19行,是替换的操作,即该位置上原e不为空,那么把原来的value作为put方法的返回值,并且将value值替换成最新的(onlyIfAbsent==false)

6、第20-25行,是新增元素的操作,即通过key定位到的位置上并没有元素,则创建一个新的Entry放到该位置上。并将count值修改为最新。

get方法

 先看下get方法的源代码:

技术分享
 1  V get(Object key, int hash) {
 2             if (count != 0) { // read-volatile
 3                 HashEntry<K,V> e = getFirst(hash);
 4                 while (e != null) {
 5                     if (e.hash == hash && key.equals(e.key)) {
 6                         V v = e.value;
 7                         if (v != null)
 8                             return v;
 9                         return readValueUnderLock(e); // recheck
10                     }
11                     e = e.next;
12                 }
13             }
14             return null;
15         }
技术分享

1、通过对比可以知道,get操作是不需要锁的。

2、第一步是访问count变量,这是一个volatile变量,由于所有修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到最新的结构更新。

3、后面就是遍历链表,根据hash值、key值来查询Entry,如果找到则返回。否则在有锁的情况下再读取一次。等会,这个是什么情况?

4、我们来认真分析下为什么查询value值为空的时候还要在有锁的情况下再读取一次。这有些让人费解,理论上节点的值是不可能为空的,因为在put操作的时候就进行了判断,如果为空会抛出空指针异常的。

技术分享
V get(Object key, int hash) {
         if (count != 0) { //1
             HashEntry<K,V> e = getFirst(hash);
             while (e != null) {
                 if (e.hash == hash && key.equals(e.key)) {
                     V v = e.value;
                     if (v != null)//2
                         return v;
                     return readValueUnderLock(e); // recheck
                 }
                 e = e.next;
             }
         }
         return null;
     }
技术分享

在上面代码有两行代码中,我用红色的字体做了标记。

在get代码1和2之间,另一个线程新增了一个Entry

1、让人抓狂的是:恰好这个线程新增的Entry是我们要get的。先看下put方法新增一个entry的过程:

 

技术分享

2、新增entry肯定是放在头结点位置,这个前面已经说明分析过了。

3、newEntry对象是通过new HashEntry<K,V>(key, hash, first, value)方式创建的。如果一个线程刚好new这个对象时,当前线程来get它。由于没有同步,就会出现当前线程得到的newEntry对象是一个没有完全构造好的对象引用。这个时候的value可能为空。所以才有了前面加锁重新get一次的动作。

4、另外在讨论DCL的问题时跟这个类似,在没有锁同步的情况下,new一个对象对于多线程看到这个对象的状态是没有保障的,这里同样有可能一个线程new这个对象的时候还没有执行完构造函数就被另一个线程得到这个对象的引用。

在get代码1和2之间,另一个线程修改了一个entry的value值

value是用volatile修饰的,可以保证读取的时候得到的是修改后的值。

在get代码1和2之间,另一个线程删除了一个Entry

假设我们的链表元素是:e1 -> e2 -> e3 -> e4。另一个线程删除的entry是e3。由于hashEntry中的next不可改变,我们无法直接把e2的next直接指向e4,而是需要将删除节点之前的节点复制一份,形成新的链表。

大致实现如图所示:

 

技术分享

如果我们get的也恰好是e3,可能我们顺着链表刚找到e1,这是另一个线程就删除了e3,而当前线程还会继续沿着旧的链表去查找e3,这里没有办法实时保证了。

在代码1的地方判断了count变量,它保障了在1位置能看到其他线程修改后的。在1到2之间再次发生了其他线程删除了entry节点,就没有办法保证看到最新的。

不过这里也没有什么关系,即使我们返回e3的时候,他被其他线程删除了,暴露出去的e3也不会对我们新的链表造成影响。

这其实是一种乐观设计,因为其他线程的“删”、“改”对我们的数据不会造成影响,所以只有“新增”操作做了安全检查,就是位置2的非null检查。

remove方法

先直接上源代码

技术分享
 1 V remove(Object key, int hash, Object value) {
 2             lock();
 3             try {
 4                 int c = count - 1;
 5                 HashEntry<K,V>[] tab = table;
 6                 int index = hash & (tab.length - 1);
 7                 HashEntry<K,V> first = tab[index];
 8                 HashEntry<K,V> e = first;
 9                 while (e != null && (e.hash != hash || !key.equals(e.key)))
10                     e = e.next;
11 
12                 V oldValue = http://www.mamicode.com/null;>
技术分享

1、整个定位的过程和put操作类似,先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,就可以同时进行。

2、前面的过程比较类似,我们直接从第21行开始分析。首先取该位置上的头结点。然后进行for循环操作,为的就是将待删除元素之前的Entry重新复制一次。这个是由entry中next不变性来控制的。下面我们来看下示意图:

 

技术分享

删除元素3之后:

 

技术分享

3、remove操作有两个地方需要注意下,一个是删除节点存在时,删除的最后一步操作要将count的值减1。另外一个是remove执行开始的时候就将table赋值给了一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。编译器不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量则没有多大影响,编译器会做响应的优化。

深入理解JAVA集合系列二:ConcurrentHashMap源码解读