Java集合(9):ConcurrentHashMap

一.ConcurrentHashMap介绍

  我们可以在单线程时使用HashMap提高效率,而多线程时用Hashtable来保证安全。但是,HashMap中未进行同步考虑,而Hashtable则使用了synchronized,带来的直接影响就是可选择,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,安全的背后是巨大的浪费。

  ConcurrentHashMap 是一个并发散列映射表的实现,它允许完全并发的读取,并且支持给定数量的并发更新。相比于 HashTable 和用同步包装器包装的 HashMap(Collections.synchronizedMap(new HashMap())),ConcurrentHashMap 拥有更高的并发性。在 HashTable 和由同步包装器包装的 HashMap 中,使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成串行化的了。

  有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

1.ConcurrentHashMap的继承关系

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {}

2.ConcurrentHashMap的类图关系

二.结构解析

  ConcurrentHashMap和Hashtable主要区别就是围绕着锁的粒度以及如何锁,Hashtable的实现方式是锁整个hash表,可以把ConcurrentHashMap简单理解成把一个大的HashTable分解成多个,形成了锁分离。

  ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点)。

  Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

1.HashEntry 类

  HashEntry 用来封装散列映射表中的键值对。在 HashEntry 类中,key,hash 和 next 域都被声明为 final 型,value 域被声明为 volatile 型。

 1 static final class HashEntry<K,V> {
 2         final K key;                       // 声明 key 为 final 型
 3         final int hash;                   // 声明 hash 值为 final 型
 4         volatile V value;                 // 声明 value 为 volatile 型
 5         final HashEntry<K,V> next;      // 声明 next 为 final 型
 6
 7         HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
 8             this.key = key;
 9             this.hash = hash;
10             this.next = next;
11             this.value = value;
12         }
13 }

  在 ConcurrentHashMap 中,在散列时如果产生“碰撞”,将采用“分离链接法”来处理“碰撞”:把“碰撞”的 HashEntry 对象链接成一个链表。由于 HashEntry 的 next 域为 final 型,所以新节点只能在链表的表头处插入,所以链表中节点的顺序和插入的顺序相反。 下图是在一个空桶中依次插入 A,B,C 三个 HashEntry 对象后的结构:

2.Segment 类

  Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。每个 Segment 对象用来守护其(成员对象 table 中)包含的若干个桶。

  table 是一个由HashEntry对象组成的数组。table 数组的每一个数组成员就是散列映射表的一个桶。

  count 变量是一个计数器,它表示每个 Segment 对象管理的 table 数组(若干个 HashEntry 组成的链表)包含的 HashEntry 对象的个数。每一个 Segment 对象都有一个 count 对象来表示本 Segment 中包含的 HashEntry 对象的总数。之所以在每个 Segment 对象中包含一个计数器,而不是在 ConcurrentHashMap 中使用全局的计数器,这样当需要更新计数器时,不用锁定整个 ConcurrentHashMap,为了避免出现“热点域”而影响 ConcurrentHashMap 的并发性。

3.ConcurrentHashMap类

  ConcurrentHashMap 在默认并发级别会创建包含 16 个 Segment 对象的数组。每个 Segment 的成员对象 table 包含若干个散列表的桶。每个桶是由 HashEntry 链接起来的一个链表。如果键能均匀散列,每个 Segment 大约守护整个散列表中桶总数的 1/16。

三.并发写操作

  在 ConcurrentHashMap 中,线程对映射表做读操作时,一般情况下不需要加锁就可以完成,对容器做结构性修改的操作才需要加锁。下面以 put 操作为例说明对 ConcurrentHashMap 做结构性修改的过程。

1.首先,根据 key 计算出对应的 hash 值:

1 public V put(K key, V value) {
2         if (value == null)          //ConcurrentHashMap 中不允许用 null 作为映射值
3             throw new NullPointerException();
4         int hash = hash(key.hashCode());        // 计算键对应的散列码
5         return segmentFor(hash).put(key, hash, value, false); // 根据散列码找到对应的 Segment
6  }

2.然后,根据 hash 值找到对应的Segment 对象:

 1 /**
 2      * 使用 key 的散列码来得到 segments 数组中对应的 Segment
 3      */
 4  final Segment<K,V> segmentFor(int hash) {
 5     // 将散列值右移 segmentShift 个位,并在高位填充 0
 6     // 然后把得到的值与 segmentMask 相“与”
 7     // 从而得到 hash 值对应的 segments 数组的下标值
 8     // 最后根据下标值返回散列码对应的 Segment 对象
 9         return segments[(hash >>> segmentShift) & segmentMask];
10  }

3.在Segment 中执行具体的 put 操作

 1 V put(K key, int hash, V value, boolean onlyIfAbsent) {
 2      lock();  // 加锁,这里是锁定某个 Segment 对象而非整个 ConcurrentHashMap
 3      try {
 4           int c = count;
 5
 6           if (c++ > threshold)     // 如果超过再散列的阈值
 7                rehash();              // 执行再散列,table 数组的长度将扩充一倍
 8
 9           HashEntry<K,V>[] tab = table;
10           // 把散列码值与 table 数组的长度减 1 的值相“与”
11           // 得到该散列码对应的 table 数组的下标值
12           int index = hash & (tab.length - 1);
13           // 找到散列码对应的具体的那个桶
14           HashEntry<K,V> first = tab[index];
15
16           HashEntry<K,V> e = first;
17           while (e != null && (e.hash != hash || !key.equals(e.key)))
18                e = e.next;
19
20           V oldValue;
21           if (e != null) {            // 如果键 / 值对以经存在
22                oldValue = e.value;
23                if (!onlyIfAbsent)
24                    e.value = value;    // 设置 value 值
25           }
26           else {                        // 键 / 值对不存在
27                oldValue = null;
28                ++modCount;         // 要添加新节点到链表中,所以 modCont 要加 1
29                // 创建新节点,并添加到链表的头部
30                tab[index] = new HashEntry<K,V>(key, hash, first, value);
31                count = c;               // 写 count 变量
32           }
33           return oldValue;
34      } finally {
35       unlock();                     // 解锁
36      }
37 }

  相比较于 HashTable 和由同步包装器包装的 HashMap每次只能有一个线程执行读或写操作,ConcurrentHashMap 在并发访问性能上有了质的提高。在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设置为 16),及任意数量线程的读操作。

四.用HashEntery对象的不变性来降低读操作对加锁的需求

  HashEntry 中的 key,hash,next 都声明为 final 型,这可以保证:在访问某个节点时,这个节点之后的链接不会被改变。这个特性可以大大降低处理链表时的复杂性。同时,HashEntry 类的 value 域被声明为 Volatile 型,Java 的内存模型可以保证:某个写线程对 value 域的写入马上可以被后续的某个读线程“看”到。

  下面我们分别来分析线程写入的两种情形:对散列表做非结构性修改的操作和对散列表做结构性修改的操作。
  1.非结构性修改操作只是更改某个 HashEntry 的 value 域的值。由于对 Volatile 变量的写入操作将与随后对这个变量的读操作进行同步。当一个写线程修改了某个 HashEntry 的 value 域后,另一个读线程读这个值域,Java 内存模型能够保证读线程读取的一定是更新后的值。所以,写线程对链表的非结构性修改能够被后续不加锁的读线程“看到”。
  2.对 ConcurrentHashMap 做结构性修改,实质上是对某个桶指向的链表做结构性修改。如果能够确保:在读线程遍历一个链表期间,写线程对这个链表所做的结构性修改不影响读线程继续正常遍历这个链表。那么读/写线程之间就可以安全并发访问这个 ConcurrentHashMap。

  结构性修改操作包括 put,clear,remove。

  2.1 put操作:从本文的上一部分对put的分析,可知put 操作如果需要插入一个新节点到链表中时 , 会在链表头部插入这个新节点。此时,链表中的原有节点的链接并没有被修改。也就是说:插入新健 / 值对到链表中的操作不会影响读线程正常遍历这个链表。

  2.2 clear操作:clear只是把 ConcurrentHashMap 中所有的桶“置空”,每个桶之前引用的链表依然存在,只是桶不再引用到这些链表(所有链表的结构并没有被修改)。正在遍历某个链表的读线程依然可以正常执行对该链表的遍历。

  2.3 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       // 根据散列码找到 table 的下标值
 7       int index = hash & (tab.length - 1);
 8       // 找到散列码对应的那个桶
 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 = null;
15       if(e != null) {
16           V v = e.value;
17           if(value == null|| value.equals(v)) { // 找到要删除的节点
18               oldValue = v;
19               ++modCount;
20               // 所有处于待删除节点之后的节点原样保留在链表中
21               // 所有处于待删除节点之前的节点被克隆到新链表中
22               HashEntry<K,V> newFirst = e.next;// 待删节点的后继结点
23               for(HashEntry<K,V> p = first; p != e; p = p.next)
24                   newFirst = new HashEntry<K,V>(p.key, p.hash,
25                                                 newFirst, p.value);
26               // 把桶链接到新的头结点
27               // 新的头结点是原链表中,删除节点之前的那个节点
28               tab[index] = newFirst;
29               count = c;      // 写 count 变量
30           }
31       }
32       return oldValue;
33      } finally{
34       unlock();   // 解锁
35      }
36 }

  和 get 操作一样,首先根据散列码找到具体的链表;然后遍历这个链表找到要删除的节点;最后把待删除节点之后的所有节点原样保留在新链表中,把待删除节点之前的每个节点克隆到新链表中。下面通过图例来说明 remove 操作。假设写线程执行 remove 操作,要删除链表的 C 节点,另一个读线程同时正在遍历这个链表。

操作过程如下图:

执行删除之前的原链表:

执行删除之后的新链表:

  从上图可以看出,删除节点 C 之后的所有节点原样保留到新链表中;删除节点 C 之前的每个节点被克隆到新链表中,注意:它们在新链表中的链接顺序被反转了。
  在执行 remove 操作时,原始链表并没有被修改,也就是说:读线程不会受同时执行 remove 操作的并发写线程的干扰。
  综合上面的分析我们可以看出,写线程对某个链表的结构性修改不会影响其他的并发读线程对这个链表的遍历访问。

五.用 Volatile 变量协调读写线程间的内存可见性

  由于内存可见性问题,未正确同步的情况下,写线程写入的值可能并不为后续的读线程可见。下面以写线程 M 和读线程 N 来说明 ConcurrentHashMap 如何协调读 / 写线程间的内存可见性问题。

  假设线程 M 在写入了 volatile 型变量 count 后,线程 N 读取了这个 volatile 型变量 count。

  根据 happens-before 关系法则中的程序次序法则,A appens-before 于 B,C happens-before D。根据 Volatile 变量法则,B happens-before C。

  根据传递性,连接上面三个 happens-before 关系得到:A appens-before 于 B; B appens-before C;C happens-before D。也就是说:写线程 M 对链表做的结构性修改,在读线程 N 读取了同一个 volatile 变量后,对线程 N 也是可见的了。

  虽然线程 N 是在未加锁的情况下访问链表。Java 的内存模型可以保证:只要之前对链表做结构性修改操作的写线程 M 在退出写方法前写 volatile 型变量 count,读线程 N 在读取这个 volatile 型变量 count 后,就一定能“看到”这些修改。

参考:http://www.cnblogs.com/ITtangtang/p/3948786.html

http://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/

http://blog.csdn.net/liuzhengkang/article/details/2916620

时间: 2024-10-22 09:20:37

Java集合(9):ConcurrentHashMap的相关文章

死磕 java集合之ConcurrentHashMap源码分析(三)

本章接着上两章,链接直达: 死磕 java集合之ConcurrentHashMap源码分析(一) 死磕 java集合之ConcurrentHashMap源码分析(二) 删除元素 删除元素跟添加元素一样,都是先找到元素所在的桶,然后采用分段锁的思想锁住整个桶,再进行操作. public V remove(Object key) { // 调用替换节点方法 return replaceNode(key, null, null); } final V replaceNode(Object key, V

Java集合:ConcurrentHashMap原理分析

集合是编程中最常用的数据结构.而谈到并发,几乎总是离不开集合这类高级数据结构的支持.比如两个线程需要同时访问一个中间临界区(Queue),比如常会用缓存作为外部文件的副本(HashMap).这篇文章主要分析jdk1.5的3种并发集合类型(concurrent,copyonright,queue)中的ConcurrentHashMap,让我们从原理上细致的了解它们,能够让我们在深度项目开发中获益非浅. 通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张

Java集合~ConcurrentHashMap原理

集合是编程中最常用的数据结构.而谈到并发,几乎总是离不开集合这类高级数据结构的支持.比如两个线程需要同时访问一个中间临界区(Queue),比如常会用缓存作为外部文件的副本(HashMap).文章主要分析jdk1.5的3种并发集合类型(concurrent,copyonright,queue)中的ConcurrentHashMap,让我们从原理上细致的了解它们,能够让我们在深度项目开发中获益非浅. 通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让

死磕 java集合之ConcurrentHashMap源码分析(一)

开篇问题 (1)ConcurrentHashMap与HashMap的数据结构是否一样? (2)HashMap在多线程环境下何时会出现并发安全问题? (3)ConcurrentHashMap是怎么解决并发安全问题的? (4)ConcurrentHashMap使用了哪些锁? (5)ConcurrentHashMap的扩容是怎么进行的? (6)ConcurrentHashMap是否是强一致性的? (7)ConcurrentHashMap不能解决哪些问题? (8)ConcurrentHashMap中有哪

Java集合(15)--ConcurrentHashMap源码分析

ConcurrentHashMap使用了锁分离技术, 使用了多个锁来控制对hash表的不同部分进行的修改.使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁.只要多个修改操作发生在不同的段上,它们就可以并发进行. 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁. 段数组是final的,并且其成员变量实际上也是final的.

死磕 java集合之ConcurrentHashMap源码分析(二)——扩容

本章接着上一章,链接直达请点我. 初始化桶数组 第一次放元素时,初始化桶数组. private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) // 如果sizeCtl<0说明正在初始化或者扩容,让出CPU Thread.yield(); // lost i

死磕 java集合之终结篇

概览 我们先来看一看java中所有集合的类关系图. 这里面的类太多了,请放大看,如果放大还看不清,请再放大看,如果还是看不清,请放弃. 我们下面主要分成五个部分来逐个击破. List List中的元素是有序的.可重复的,主要实现方式有动态数组和链表. java中提供的List的实现主要有ArrayList.LinkedList.CopyOnWriteArrayList,另外还有两个古老的类Vector和Stack. 关于List相关的问题主要有: (1)ArrayList和LinkedList有

Java集合相关面试问题和答案

Java集合相关面试问题和答案 面试试题 1.Java集合框架是什么?说出一些集合框架的优点? 每种编程语言中都有集合,最初的Java版本包含几种集合类:Vector.Stack.HashTable和Array.随着集合的广泛使用,Java1.2提出了囊括所有集合接口.实现和算法的集合框架.在保证线程安全的情况下使用泛型和并发集合类,Java已经经历了很久.它还包括在Java并发包中,阻塞接口以及它们的实现.集合框架的部分优点如下: (1)使用核心集合类降低开发成本,而非实现我们自己的集合类.

Java集合总览

这篇文章总结了所有的Java集合(Collection).主要介绍各个集合的特性和用途,以及在不同的集合类型之间转换的方式. Arrays Array是Java特有的数组.在你知道所要处理数据元素个数的情况下非常好用.java.util.Arrays 包含了许多处理数据的实用方法: Arrays.asList:可以从 Array 转换成 List.可以作为其他集合类型构造器的参数. Arrays.binarySearch:在一个已排序的或者其中一段中快速查找. Arrays.copyOf:如果你

【Java集合源码剖析】HashMap源码剖析

转载请注明出处:http://blog.csdn.net/ns_code/article/details/36034955 HashMap简介 HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长. HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap. HashMap 实现了Serializable接口,因此它支持序列化,