深入理解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 == null) {
4         throw new NullPointerException();
5     }

在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 == null) {
 4         throw new NullPointerException();
 5     }
 6
 7     // Makes sure the key is not already in the hashtable.
 8     Entry tab[] = table;
 9     int hash = key.hashCode();
10     int index = (hash & 0x7FFFFFFF) % tab.length;
11     for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
12         if ((e.hash == hash) && e.key.equals(key)) {
13         V old = e.value;
14         e.value = value;
15         return old;
16         }
17     }

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 == null)
            throw new NullPointerException();
        int hash = hash(key.hashCode());
        return segmentFor(hash).put(key, hash, value, false);
    }

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 = e.value;
17                     if (!onlyIfAbsent)
18                         e.value = value;
19                 }
20                 else {
21                     oldValue = null;
22                     ++modCount;
23                     tab[index] = new HashEntry<K,V>(key, hash, first, value);
24                     count = c; // write-volatile
25                 }
26                 return oldValue;
27             } finally {
28                 unlock();
29             }
30         }

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 = null;
13                 if (e != null) {
14                     V v = e.value;
15                     if (value == null || value.equals(v)) {
16                         oldValue = v;
17                         // All entries following removed node can stay
18                         // in list, but all preceding ones need to be
19                         // cloned.
20                         ++modCount;
21                         HashEntry<K,V> newFirst = e.next;
22                         for (HashEntry<K,V> p = first; p != e; p = p.next)
23                             newFirst = new HashEntry<K,V>(p.key, p.hash,
24                                                           newFirst, p.value);
25                         tab[index] = newFirst;
26                         count = c; // write-volatile
27                     }
28                 }
29                 return oldValue;
30             } finally {
31                 unlock();
32             }
33         }

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

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

删除元素3之后:

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

时间: 2024-10-19 13:54:18

深入理解JAVA集合系列二:ConcurrentHashMap源码解读的相关文章

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

初认HashMap 基于哈希表(即散列表)的Map接口的实现,此实现提供所有可选的映射操作,并允许使用null值和null键. HashMap继承于AbstractMap,实现了Map.Cloneable.java.io.Serializable接口.且是不同步的,意味着它不是线程安全的. HashMap的数据结构 在java编程语言中,最基本的结构就两种,一个是数组,另一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的.HashMap也不例外,它是一个“链表的数组”的数据结构

Java集合系列之LinkedList源码分析

一.LinkedList简介 LinkedList是一种可以在任何位置进行高效地插入和移除操作的有序序列,它是基于双向链表实现的. ps:这里有一个问题,就是关于实现LinkedList的数据结构是否为循环的双向链表,上网搜了有很多文章都说是循环的,并且有的文章中但是我看了源代码觉得应该不是循环的? 例如在删除列表尾部节点的代码: private E unlinkLast(Node<E> l) { final E element = l.item; final Node<E> pr

Java集合系列之ArrayList源码分析

一.ArrayList简介 ArrayList是可以动态增长和缩减的索引序列,它是基于数组实现的List类. 该类封装了一个动态再分配的Object[]数组,每一个类对象都有一个capacity属性,表示它们所封装的Object[]数组的长度,当向ArrayList中添加元素时,该属性值会自动增加.如果想ArrayList中添加大量元素,可使用ensureCapacity方法一次性增加capacity,可以减少增加重分配的次数提高性能. ArrayList的用法和Vector向类似,但是Vect

Java集合系列之HashMap源码分析

一.HashMap简介 HashMap是基于哈希表的Map接口实现的,它存储的是内容是键值对<key,value>映射.此类不保证映射的顺序,假定哈希函数将元素适当的分布在各桶之间,可为基本操作(get和put)提供稳定的性能. ps:本文中的源码来自jdk1.8.0_45/src. 1.重要参数 HashMap的实例有两个参数影响其性能. 初始容量:哈希表中桶的数量 加载因子:哈希表在其容量自动增加之前可以达到多满的一种尺度 当哈希表中条目数超出了当前容量*加载因子(其实就是HashMap的

Java集合系列之TreeMap源码分析

一.概述 TreeMap是基于红黑树实现的.由于TreeMap实现了java.util.sortMap接口,集合中的映射关系是具有一定顺序的,该映射根据其键的自然顺序进行排序或者根据创建映射时提供的Comparator进行排序,具体取决于使用的构造方法.另外TreeMap中不允许键对象是null. 1.什么是红黑树? 红黑树是一种特殊的二叉排序树,主要有以下几条基本性质: 每个节点都只能是红色或者黑色 根节点是黑色 每个叶子节点是黑色的 如果一个节点是红色的,则它的两个子节点都是黑色的 从任意一

java基础系列之ConcurrentHashMap源码分析(基于jdk1.8)

1.前提 在阅读这篇博客之前,希望你对HashMap已经是有所理解的,否则可以参考这篇博客: jdk1.8源码分析-hashMap:另外你对java的cas操作也是有一定了解的,因为在这个类中大量使用到了cas相关的操作来保证线程安全的. 2.概述 ConcurrentHashMap这个类在java.lang.current包中,这个包中的类都是线程安全的.ConcurrentHashMap底层存储数据的结构与1.8的HashMap是一样的,都是数组+链表(或红黑树)的结构.在日常的开发中,我们

Java集合系列之HashSet源码分析

一.HashSet简介 HashSet是Set接口典型实现,它按照Hash算法来存储集合中的元素,具有很好的存取和查找性能.主要具有以下特点: 不保证set的迭代顺序 HashSet不是同步的,如果多个线程同时访问一个HashSet,要通过代码来保证其同步 集合元素值可以是null 当向HashSet集合中存入一个元素时,HashSet会调用该对象的hashCode()方法来得到该对象的hashCode值,然后根据该值确定对象在HashSet中的存储位置.在Hash集合中,不能同时存放两个相等的

Java集合系列:-----------03ArrayList源码分析

上一章,我们学习了Collection的架构.这一章开始,我们对Collection的具体实现类进行讲解:首先,讲解List,而List中ArrayList又最为常用.因此,本章我们讲解ArrayList.先对ArrayList有个整体认识,再学习它的源码,最后再通过例子来学习如何使用它.内容包括: ArrayList简介 ArrayList 是一个数组队列,相当于 动态数组.与Java中的数组相比,它的容量能动态增长.它继承于AbstractList,实现了List, RandomAccess

java集合系列之HashMap源码

HashMap的源码可真不好消化!!! 首先简单介绍一下HashMap集合的特点.HashMap存放键值对,键值对封装在Node(代码如下,比较简单,不再介绍)节点中,Node节点实现了Map.Entry.存放的键值对的键不可重复.jdk1.8后,HashMap底层采用的是数组加链表.红黑树的数据结构,因此实现起来比之前复杂的多. static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K k