简介:
简介java
ConcurrentHashMap 是 util.concurrent 包的重要成员。本文将结合 Java 内存模型,分析 JDK源代码,探索 ConcurrentHashMap 高并发的具体实现机制。c++
因为 ConcurrentHashMap 的源代码实现依赖于 Java 内存模型,因此阅读本文须要读者了解 Java内存模型。同时,ConcurrentHashMap的源代码会涉及到散列算法和链表数据结构,因此,读者须要对散列算法和基于链表的数据结构有所了解。算法
回页首数组
因为 ConcurrentHashMap 是创建在 Java 内存模型基础上的,为了更好的理解ConcurrentHashMap,让咱们首先来了解一下 Java 的内存模型。安全
Java 语言的内存模型由一些规则组成,这些规则肯定线程对内存的访问如何排序以及什么时候能够确保它们对线程是可见的。下面咱们将分别介绍Java 内存模型的重排序,内存可见性和 happens-before 关系。数据结构
重排序多线程
内存模型描述了程序的可能行为。具体的编译器实现能够产生任意它喜欢的代码 --只要全部执行这些代码产生的结果,可以和内存模型预测的结果保持一致。这为编译器实现者提供了很大的自由,包括操做的重排序。架构
编译器生成指令的次序,能够不一样于源代码所暗示的“显然”版本。重排序后的指令,对于优化执行以及成熟的全局寄存器分配算法的使用,都是大有脾益的,它使得程序在计算性能上有了很大的提高。
重排序类型包括:
因为现代可共享内存的多处理器架构可能致使一个线程没法立刻(甚至永远)看到另外一个线程操做产生的结果。因此 Java 内存模型规定了JVM 的一种最小保证:何时写入一个变量对其余线程可见。
在现代可共享内存的多处理器体系结构中每一个处理器都有本身的缓存,并周期性的与主内存协调一致。假设线程 A 写入一个变量值V,随后另外一个线程 B 读取变量 V 的值,在下列状况下,线程 B 读取的值可能不是线程 A 写入的最新值:
happens-before 关系保证:若是线程 A 与线程 B 知足 happens-before 关系,则线程 A执行动做的结果对于线程 B 是可见的。若是两个操做未按 happens-before 排序,JVM 将能够对他们任意重排序。
下面介绍几个与理解 ConcurrentHashMap 有关的 happens-before 关系法则:
为了更好的理解 ConcurrentHashMap 高并发的具体实现,让咱们先探索它的结构模型。
ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。HashEntry用来封装映射表的键 / 值对;Segment 用来充当锁的角色,每一个 Segment对象守护整个散列映射表的若干个桶。每一个桶是由若干个 HashEntry 对象连接起来的链表。一个 ConcurrentHashMap实例中包含由若干个 Segment 对象组成的数组。
HashEntry 用来封装散列映射表中的键值对。在 HashEntry 类中,key,hash 和 next 域都被声明为final 型,value 域被声明为 volatile 型。
static final class HashEntry {
{ this.key = key;
|
在 ConcurrentHashMap 中,在散列时若是产生“碰撞”,将采用“分离连接法”来处理“碰撞”:把“碰撞”的HashEntry 对象连接成一个链表。因为 HashEntry 的 next 域为 final型,因此新节点只能在链表的表头处插入。 下图是在一个空桶中依次插入 A,B,C 三个 HashEntry 对象后的结构图:
注意:因为只能在表头插入,因此链表中节点的顺序和插入的顺序相反。
避免热点域
在 ConcurrentHashMap
中,
每个 Segment 对象都有一个 count对象来表示本 Segment 中包含的 HashEntry 对象的个数。这样当须要更新计数器时,不用锁定整个ConcurrentHashMap
。
Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。每一个 Segment对象用来守护其(成员对象 table 中)包含的若干个桶。
table 是一个由 HashEntry 对象组成的数组。table 数组的每个数组成员就是散列映射表的一个桶。
count 变量是一个计数器,它表示每一个 Segment 对象管理的 table 数组(若干个 HashEntry 组成的链表)包含的HashEntry 对象的个数。每个 Segment 对象都有一个 count 对象来表示本 Segment 中包含的HashEntry 对象的总数。注意,之因此在每一个 Segment对象中包含一个计数器,而不是在 ConcurrentHashMap中使用全局的计数器,是为了不出现“热点域”而影响 ConcurrentHashMap 的并发性。
static final class Segment extends ReentrantLock implements Serializable {
{
{ |
下图是依次插入 ABC 三个 HashEntry 节点后,Segment 的结构示意图。
ConcurrentHashMap 在默认并发级别会建立包含 16 个 Segment 对象的数组。每一个 Segment 的成员对象table 包含若干个散列表的桶。每一个桶是由 HashEntry 连接起来的一个链表。若是键能均匀散列,每一个 Segment大约守护整个散列表中桶总数的 1/16。
public class ConcurrentHashMap extends AbstractMap implements ConcurrentMap, Serializable {
final int segmentShift; final Segment[] segments;
{
{
|
}
下面是 ConcurrentHashMap 的结构示意图。
在 ConcurrentHashMap中,线程对映射表作读操做时,通常状况下不须要加锁就能够完成,对容器作结构性修改的操做才须要加锁。下面以 put操做为例说明对 ConcurrentHashMap 作结构性修改的过程。
首先,根据 key 计算出对应的 hash 值:
public V put(K key, V value) {
|
而后,根据 hash值找到对应的
Segment 对象:
|
最后,在这个 Segment 中执行具体的 put 操做:
V put(K key, int hash, V value, boolean onlyIfAbsent) {
{
finally {
|
注意:这里的加锁操做是针对(键的 hash 值对应的)某个具体的 Segment,锁定的是该 Segment 而不是整个ConcurrentHashMap。由于插入键/ 值对操做只是在这个 Segment包含的某个桶中完成,不须要锁定整个
ConcurrentHashMap。此时,其余写线程对另外 15 个
Segment的加锁并不会由于当前线程对这个 Segment 的加锁而阻塞。同时,全部读线程几乎不会因本线程的加锁而阻塞(除非读线程恰好读到这个Segment 中某个 HashEntry 的 value 域的值为null,此时须要加锁后从新读取该值
)。
相比较于 HashTable 和由同步包装器包装的HashMap
每次只能有一个线程执行读或写操做,
ConcurrentHashMap在并发访问性能上有了质的提升。在理想状态下,ConcurrentHashMap 能够支持 16个线程执行并发写操做(若是并发级别设置为 16),及任意数量线程的读操做。
用 HashEntery对象的不变性来下降读操做对加锁的需求
在代码清单“HashEntry 类的定义”中咱们能够看到,HashEntry 中的 key,hash,next 都声明为 final型。这意味着,不能把节点添加到连接的中间和尾部,也不能在连接的中间和尾部删除节点。这个特性能够保证:在访问某个节点时,这个节点以后的连接不会被改变。这个特性能够大大下降处理链表时的复杂性。
同时,HashEntry 类的 value 域被声明为 Volatile 型,Java 的内存模型能够保证:某个写线程对 value域的写入立刻能够被后续的某个读线程“看”到。在 ConcurrentHashMap 中,不容许用 unll做为键和值,当读线程读到某个 HashEntry 的 value 域的值为 null时,便知道产生了冲突——发生了重排序现象,须要加锁后从新读入这个 value值。这些特性互相配合,使得读线程即便在不加锁状态下,也能正确访问 ConcurrentHashMap。
下面咱们分别来分析线程写入的两种情形:对散列表作非结构性修改的操做和对散列表作结构性修改的操做。
非结构性修改操做只是更改某个 HashEntry 的 value 域的值。因为对 Volatile变量的写入操做将与随后对这个变量的读操做进行同步。当一个写线程修改了某个 HashEntry 的 value域后,另外一个读线程读这个值域,Java内存模型可以保证读线程读取的必定是更新后的值。因此,写线程对链表的非结构性修改可以被后续不加锁的读线程“看到”。
对 ConcurrentHashMap作结构性修改,实质上是对某个桶指向的链表作结构性修改。若是可以确保:在读线程遍历一个链表期间,写线程对这个链表所作的结构性修改不影响读线程继续正常遍历这个链表。那么读/ 写线程之间就能够安全并发访问这个 ConcurrentHashMap。
结构性修改操做包括 put,remove,clear。下面咱们分别分析这三个操做。
clear 操做只是把 ConcurrentHashMap中全部的桶“置空”,每一个桶以前引用的链表依然存在,只是桶再也不引用到这些链表(全部链表的结构并无被修改)。正在遍历某个链表的读线程依然能够正常执行对该链表的遍历。
从上面的代码清单“在 Segment 中执行具体的 put 操做”中,咱们能够看出:put 操做若是须要插入一个新节点到链表中时 ,会在链表头部插入这个新节点。此时,链表中的原有节点的连接并无被修改。也就是说:插入新健 /值对到链表中的操做不会影响读线程正常遍历这个链表。
下面来分析 remove 操做,先让咱们来看看 remove 操做的源代码实现。
V remove(Object key, int hash, Object value) {
{
{ // 找到要删除的节点
finally{
|
和 get操做同样,首先根据散列码找到具体的链表;而后遍历这个链表找到要删除的节点;最后把待删除节点以后的全部节点原样保留在新链表中,把待删除节点以前的每一个节点克隆到新链表中。下面经过图例来讲明remove 操做。
假设写线程执行 remove 操做,要删除链表的 C节点,另外一个读线程同时正在遍历这个链表。
图 4. 执行删除以前的原链表:
图 5. 执行删除以后的新链表
从上图能够看出,删除节点 C 以后的全部节点原样保留到新链表中;删除节点 C以前的每一个节点被克隆到新链表中,注意:它们在新链表中的连接顺序被反转了。
在执行 remove 操做时,原始链表并无被修改,也就是说:读线程不会受同时执行 remove 操做的并发写线程的干扰。
综合上面的分析咱们能够看出,写线程对某个链表的结构性修改不会影响其余的并发读线程对这个链表的遍历访问。
因为内存可见性问题,未正确同步的状况下,写线程写入的值可能并不为后续的读线程可见。
下面以写线程 M 和读线程 N 来讲明 ConcurrentHashMap 如何协调读 / 写线程间的内存可见性问题。
假设线程 M 在写入了 volatile 型变量 count 后,线程 N 读取了这个 volatile 型变量 count。
根据 happens-before 关系法则中的程序次序法则,A appens-before 于 B,C happens-beforeD。
根据 Volatile 变量法则,B happens-before C。
根据传递性,链接上面三个 happens-before 关系获得:A appens-before 于 B; Bappens-before C;C happens-before D。也就是说:写线程 M 对链表作的结构性修改,在读线程 N读取了同一个 volatile 变量后,对线程 N 也是可见的了。
虽然线程 N 是在未加锁的状况下访问链表。Java 的内存模型能够保证:只要以前对链表作结构性修改操做的写线程 M 在退出写方法前写volatile 型变量 count,读线程 N 在读取这个 volatile 型变量 count后,就必定能“看到”这些修改。
ConcurrentHashMap 中,每一个 Segment 都有一个变量 count。它用来统计 Segment 中的HashEntry 的个数。这个变量被声明为 volatile。
transient volatile int count; |
全部不加锁读方法,在进入读方法时,首先都会去读这个 count 变量。好比下面的 get 方法:
V get(Object key, int hash) { if(count != 0) {
{
{ V v = e.value;
|
在 ConcurrentHashMap 中,全部执行写操做的方法(put, remove,clear),在对链表作结构性修改以后,在退出写方法前都会去写这个 count 变量。全部未加锁的读操做(get, contains,containsKey)在读方法中,都会首先去读取这个 count 变量。
根据 Java 内存模型,对 同一个 volatile 变量的写 /读操做能够确保:写线程写入的值,可以被以后未加锁的读线程“看到”。
这个特性和前面介绍的 HashEntry 对象的不变性相结合,使得在 ConcurrentHashMap中,读线程在读取散列表时,基本不须要加锁就能成功得到须要的值。这两个特性相配合,不只减小了请求同一个锁的频率(读操做通常不须要加锁就可以成功得到值),也减小了持有同一个锁的时间(只有读到value 域的值为 null 时 , 读线程才须要加锁后重读)。
在实际的应用中,散列表通常的应用场景是:除了少数插入操做和删除操做外,绝大多数都是读取操做,并且读操做在大多数时候都是成功的。正是基于这个前提,ConcurrentHashMap针对读操做作了大量的优化。经过 HashEntry 对象的不变性和用 volatile 型变量协调线程间的内存可见性,使得大多数时候,读操做不须要加锁就能够正确得到值。这个特性使得 ConcurrentHashMap的并发性能在分离锁的基础上又有了近一步的提升。
ConcurrentHashMap是一个并发散列映射表的实现,它容许彻底并发的读取,而且支持给定数量的并发更新。相比于 HashTable和
用同步包装器包装的 HashMap(Collections.synchronizedMap(newHashMap())),ConcurrentHashMap 拥有更高的并发性。在HashTable 和由同步包装器包装的 HashMap中,使用一个全局的锁来同步不一样线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也致使对容器的访问变成
串行化
的了。
在使用锁来协调多线程间并发访问的模式下,减少对锁的竞争能够有效提升并发性。有两种方式能够减少对锁的竞争:
ConcurrentHashMap 的高并发性主要来自于三个方面:
使用分离锁,减少了请求
经过 HashEntery 对象的不变性及对同一个 Volatile 变量的读 / 写来协调内存可见性,使得读操做大多数时候不须要加锁就能成功获取到须要的值。因为散列映射表在实际应用中大多数操做都是成功的 读操做,因此 2 和 3既能够减小请求同一个锁的频率,也能够有效减小持有锁的时间。
经过减少请求同一个锁的频率和尽可能减小持有锁的时间 ,使得 ConcurrentHashMap 的并发性相对于HashTable 和
用同步包装器包装的 HashMap有了质的提升。