Java ConcurrentHashMap

Java 内存模型

由于 ConcurrentHashMap 是建立在 Java 内存模型基础上的,为了更好的理解 ConcurrentHashMap,让我们首先来了解一下 Java 的内存模型。

Java 语言的内存模型由一些规则组成,这些规则确定线程对内存的访问如何排序以及何时可以确保它们对线程是可见的。下面我们将分别介绍 Java 内存模型的重排序,内存可见性和 happens-before 关系。

重排序

内存模型描述了程序的可能行为。具体的编译器实现可以产生任意它喜欢的代码 – 只要所有执行这些代码产生的结果,能够和内存模型预测的结果保持一致。这为编译器实现者提供了很大的自由,包括操作的重排序。

编译器生成指令的次序,可以不同于源代码所暗示的“显然”版本。重排序后的指令,对于优化执行以及成熟的全局寄存器分配算法的使用,都是大有脾益的,它使得程序在计算性能上有了很大的提升。

重排序类型包括:

  • 编译器生成指令的次序,可以不同于源代码所暗示的“显然”版本。

  • 处理器可以乱序或者并行的执行指令。

  • 缓存会改变写入提交到主内存的变量的次序。

内存可见性

由于现代可共享内存的多处理器架构可能导致一个线程无法马上(甚至永远)看到另一个线程操作产生的结果。所以 Java 内存模型规定了 JVM 的一种最小保证:什么时候写入一个变量对其他线程可见。

在现代可共享内存的多处理器体系结构中每个处理器都有自己的缓存,并周期性的与主内存协调一致。假设线程 A 写入一个变量值 V,随后另一个线程 B 读取变量 V 的值,在下列情况下,线程 B 读取的值可能不是线程 A 写入的最新值:

  • 执行线程 A 的处理器把变量 V 缓存到寄存器中。

  • 执行线程 A 的处理器把变量 V 缓存到自己的缓存中,但还没有同步刷新到主内存中去。

  • 执行线程 B 的处理器的缓存中有变量 V 的旧值。

Happens-before 关系

happens-before 关系保证:如果线程 A 与线程 B 满足 happens-before 关系,则线程 A 执行动作的结果对于线程 B 是可见的。如果两个操作未按 happens-before 排序,JVM 将可以对他们任意重排序。

下面介绍几个与理解 ConcurrentHashMap 有关的 happens-before 关系法则:
- 程序次序法则:如果在程序中,所有动作 A 出现在动作 B 之前,则线程中的每动作 A 都 happens-before 于该线程中的每一个动作 B。
- 监视器锁法则:对一个监视器的解锁 happens-before 于每个后续对同一监视器的加锁。
- Volatile 变量法则:对 Volatile 域的写入操作 happens-before 于每个后续对同一 Volatile 的读操作。
- 传递性:如果 A happens-before 于 B,且 B happens-before C,则 A happens-before C。
回页首

ConcurrentHashMap 的结构分析

为了更好的理解 ConcurrentHashMap 高并发的具体实现,让我们先探索它的结构模型。

ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。HashEntry 用来封装映射表的键 / 值对;Segment 用来充当锁的角色,每个 Segment 对象守护整个散列映射表的若干个桶。每个桶是由若干个 HashEntry 对象链接起来的链表。一个 ConcurrentHashMap 实例中包含由若干个 Segment 对象组成的数组。

HashEntry 类

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

static final class HashEntry<K,V> { 
final K key; // 声明 key 为 final 型 
final int hash; // 声明 hash 值为 final 型 
volatile V value; // 声明 value 为 volatile 型 
final HashEntry<K,V> next; // 声明 next 为 final 型 
HashEntry(K key, int hash, HashEntry<K,V> next, V value) { 
    this.key = key; 
    this.hash = hash; 
    this.next = next; 
}
}

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

图 1. 插入三个节点后桶的结构示意图:
这里写图片描述
注意:由于只能在表头插入,所以链表中节点的顺序和插入的顺序相反。

避免热点域

在 ConcurrentHashMap中,每一个 Segment 对象都有一个 count 对象来表示本 Segment 中包含的 HashEntry 对象的个数。这样当需要更新计数器时,不用锁定整个ConcurrentHashMap。

Segment 类

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

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

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

清单 2.Segment 类的定义

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    /**
    * 在本 segment 范围内,包含的 HashEntry 元素的个数
    * 该变量被声明为 volatile 型
    */
    transient volatile int count;

    /**
    * table 被更新的次数
    */
    transient int modCount;

    /**
    * 当 table 中包含的 HashEntry 元素的个数超过本变量值时,触发 table 的再散列
    */
    transient int threshold;

    /**
    * table 是由 HashEntry 对象组成的数组
    * 如果散列时发生碰撞,碰撞的 HashEntry 对象就以链表的形式链接成一个链表
    * table 数组的数组成员代表散列映射表的一个桶
    * 每个 table 守护整个 ConcurrentHashMap 包含桶总数的一部分
    * 如果并发级别为 16,table 则守护 ConcurrentHashMap 包含的桶总数的 1/16
    */
    transient volatile HashEntry<K,V>[] table;

    /**
    * 装载因子
    */
    final float loadFactor;

    Segment(int initialCapacity, float lf) {
        loadFactor = lf;
        setTable(HashEntry.<K,V>newArray(initialCapacity));
    }

    /**
    * 设置 table 引用到这个新生成的 HashEntry 数组
    * 只能在持有锁或构造函数中调用本方法
    */
    void setTable(HashEntry<K,V>[] newTable) {
        // 计算临界阀值为新数组的长度与装载因子的乘积
        threshold = (int)(newTable.length * loadFactor);
        table = newTable;
    }

    /**
    * 根据 key 的散列值,找到 table 中对应的那个桶(table 数组的某个数组成员)
    */
    HashEntry<K,V> getFirst(int hash) {
        HashEntry<K,V>[] tab = table;
        // 把散列值与 table 数组长度减 1 的值相“与”,
        // 得到散列值对应的 table 数组的下标
        // 然后返回 table 数组中此下标对应的 HashEntry 元素
        return tab[hash & (tab.length - 1)];
    }

}

下图是依次插入 ABC 三个 HashEntry 节点后,Segment 的结构示意图。

图 2. 插入三个节点后 Segment 的结构示意图:
这里写图片描述
ConcurrentHashMap 类

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

清单 3.ConcurrentHashMap 类的定义

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
    implements ConcurrentMap<K, V>, Serializable {
/**
* 散列映射表的默认初始容量为 16,即初始默认为 16 个桶
* 在构造函数中没有指定这个参数时,使用本参数
*/
static final int DEFAULT_INITIAL_CAPACITY= 16;

/**
* 散列映射表的默认装载因子为 0.75,该值是 table 中包含的 HashEntry 元素的个数与
* table 数组长度的比值
* 当 table 中包含的 HashEntry 元素的个数超过了 table 数组的长度与装载因子的乘积时,
* 将触发 再散列
* 在构造函数中没有指定这个参数时,使用本参数
*/
static final float DEFAULT_LOAD_FACTOR= 0.75f;

/**
* 散列表的默认并发级别为 16。该值表示当前更新线程的估计数
* 在构造函数中没有指定这个参数时,使用本参数
*/
static final int DEFAULT_CONCURRENCY_LEVEL= 16;

/**
* segments 的掩码值
* key 的散列码的高位用来选择具体的 segment
*/
final int segmentMask;

/**
* 偏移量
*/
final int segmentShift;

/**
* 由 Segment 对象组成的数组
*/
final Segment<K,V>[] segments;

/**
* 创建一个带有指定初始容量、加载因子和并发级别的新的空映射。
*/
public ConcurrentHashMap(int initialCapacity,
                        float loadFactor, int concurrencyLevel) {
    if(!(loadFactor > 0) || initialCapacity < 0 ||concurrencyLevel <= 0)
        throw new IllegalArgumentException();

    if(concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;

    // 寻找最佳匹配参数(不小于给定参数的最接近的 2 次幂)
    int sshift = 0;
    int ssize = 1;
    while(ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    segmentShift = 32 - sshift;      // 偏移量值
    segmentMask = ssize - 1;          // 掩码值
    this.segments = Segment.newArray(ssize);  // 创建数组

    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    int c = initialCapacity / ssize;
    if(c * ssize < initialCapacity)
        ++c;
    int cap = 1;
    while(cap < c)
        cap <<= 1;

    // 依次遍历每个数组元素
    for(int i = 0; i < this.segments.length; ++i)
        // 初始化每个数组元素引用的 Segment 对象
this.segments[i] = new Segment<K,V>(cap, loadFactor);
}

/**
 * 创建一个带有默认初始容量 (16)、默认加载因子 (0.75) 和 默认并发级别(16)
 * 的空散列映射表。
 *  */
public ConcurrentHashMap() {
    // 使用三个默认参数,调用上面重载的构造函数来创建空散列映射表
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
}

下面是 ConcurrentHashMap 的结构示意图。

这里写图片描述

用分离锁实现多个线程间的并发写操作

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

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

清单 4.Put 方法的实现

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

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

ConcurrentHashMap 实现高并发的总结

基于通常情形而优化

在实际的应用中,散列表一般的应用场景是:除了少数插入操作和删除操作外,绝大多数都是读取操作,而且读操作在大多数时候都是成功的。正是基于这个前提,ConcurrentHashMap 针对读操作做了大量的优化。通过 HashEntry 对象的不变性和用 volatile 型变量协调线程间的内存可见性,使得 大多数时候,读操作不需要加锁就可以正确获得值。这个特性使得 ConcurrentHashMap 的并发性能在分离锁的基础上又有了近一步的提高。

总结

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

在使用锁来协调多线程间并发访问的模式下,减小对锁的竞争可以有效提高并发性。有两种方式可以减小对锁的竞争:
- 减小请求 同一个锁的 频率。
- 减少持有锁的 时间。
ConcurrentHashMap 的高并发性主要来自于三个方面:
- 用分离锁实现多个线程间的更深层次的共享访问。
- 用 HashEntery 对象的不变性来降低执行读操作的线程在遍历链表期间对加锁的需求。
- 通过对同一个 Volatile 变量的写 / 读访问,协调不同线程间读 / 写操作的内存可见性。
使用分离锁,减小了请求 同一个锁的频率。

通过 HashEntery 对象的不变性及对同一个 Volatile 变量的读 / 写来协调内存可见性,使得 读操作大多数时候不需要加锁就能成功获取到需要的值。由于散列映射表在实际应用中大多数操作都是成功的 读操作,所以 2 和 3 既可以减少请求同一个锁的频率,也可以有效减少持有锁的时间。

通过减小请求同一个锁的频率和尽量减少持有锁的时间 ,使得 ConcurrentHashMap 的并发性相对于 HashTable 和用同步包装器包装的 HashMap有了质的提高。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值