Java集合类框架学习 5.1 —— ConcurrentHashMap(JDK1.6)

本文深入探讨了Java集合类框架中的ConcurrentHashMap,从其在JDK1.6版本的设计出发,详细讲解了其内部结构,包括Segment的变化,以及如何实现线程安全。同时,还介绍了构造方法、基础操作方法以及视图和迭代器的使用。
摘要由CSDN通过智能技术生成
以下内容,如有问题,烦请指出,谢谢!

这一篇讲1.6的ConcurrentHashMap
ConcurrentHashMap是Java程序员接触得最多的有关并发和线程安全的类,它兼顾了并发的两个基本点——安全,高效,在很多地方都有用到,每一个Java程序员都应该真正掌握这个类。
虽然1.6版本过了10年多了,现有的1.8跟1.6的差异很大。不过1.6的最适合用来学习,它实现得简单直接,分段锁的思想也讲清楚了,所以先学习1.6的,然后看看后续版本对它的改进。

废话就不多说了,直入正题。


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

一、基本性质
1、支持完全并发的读操作和可控并发级别的写操作的Map,方法的基本功能与Hashtable大致相同,但是使用锁分段技术,没有使用全局锁,整体上更有效率,能够在大多数并发场景中替代Hashtable。

2、不允许null key和null value(1.7之前的代码中,指出非常特殊情况可能会读到null value,但是在jsr133后的新内存模型中是不会发生的,也就是实际中不会读到null value,1.7把这个无用的代码去掉了)。

3、专为并发设计的集合类。它的读操作不加锁(null value的问题不说了),可以和写操作并行,但是读操作只反映最近完成的结果,也可能和它开始执行的那一瞬间的状态有出入,这在一般的并发场景中可以接受。基本的写操作加锁,putAll和clear这两个批量操作没有使用全局锁,因此并不保证结果和预期的一致(比如clear执行后的一瞬间,可能map中会有其他线程添加的数据)。它接近线程安全,但还不是真正意义上的线程安全,记住线程安全的关键:线程一个个串行,和多线程并发的执行,产生的效果(对外界可见的影响)一样。很明显ConcurrentHashMap达不到这种程度。一般把它叫作线程安全是因为,它能保证写操作准确无误,能保证读操作不会读取到某个错误的状态下的值,这能满足大多数线程并发的场景。有些要求很高的场景,这个类是无法满足的。
它的这种并发控制更像是数据库隔离级别。它没有全局锁(数据库进程锁/库锁),使用的是Segment的锁(数据库表级别的锁)。根据写阻塞写不阻塞读这一点,可以认为它具体的隔离级别是"Read uncommitted"这个级别,即不会丢失写操作带来的影响,但是会读取到"中间态数据/脏数据"(写操作方法还没有返回,就能看到写操作带来的部分影响)。至于”不可重复读“、”幻读“这两个,根据具体实现,Segment的读方法都只读一次(加锁读不管),可以认为是没有的。“脏数据”这个,可以认为只在putAll、clear中才会出现。基本的put/remove/replace,因为都只操作一个K-V,因此可以认为它们的执行过程中是没有“脏数据”出现的。
ConcurrentHashMap在多线程之间可能会存在不一致状态。但是针对简单的读写操作来说,这些不一致的状态,要么是历史的一致状态,要么是未来的一致状态,不会出现错误的状态(HashMap多线程操作就会出现错误状态,比如死循环、更新丢失)。在很多程序中还是能接受这种不一致的,当然,如果你需要更高的一致性,比如获取需要同时获取ConcurrentHashMap中某几个key在某个绝对的同一时刻对应的value,那么它自带的方法就不能完成了,这时候就要依赖更外部的范围更大锁。这种差不多就算是通常所说的事务了,api是基本不提供自带事务性的方法的,需要你自己控制。

4、弱一致性。第3点中一起说了。

5、ConcurrentHashMap的迭代器是弱一致性的,不是fail-fast快速失败的。即使它在迭代时发现了并发修改,它也是会继续执行下去的,它不会抛出ConcurrentModificationException,因此它的迭代器可能会反映出迭代过程中的并发修改,预期迭代结果可能和调用的瞬间情况有出入。虽然迭代器在并发时不会抛出异常,但是仍然不建议多线程并发使用此迭代器。

6、继承了HashMap的一些性质。

7、不支持clone,不过clone本身用得就很少,用Map拷贝构造创建一个实例能够做到一样的效果。

基本结构的简单示意图如下(圆形中的数字是乱写的,非真实数据)。




二、常量和变量
1、常量
// 默认初始化容量,这个容量指的是所有Segment中的hash桶的数量和
static final int DEFAULT_INITIAL_CAPACITY = 16;

// 每个Segment的默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 整个Map的默认的并发级别,代表最大允许16个线程同时进行并发修改操作。实际并发级别要是 2^n 这种数,同时这个变量也是数组segments的长度
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

// 每个Segment的最大hash桶的大小(数组Segment.table的最大长度),初始容量也不能大于此值
static final int MAXIMUM_CAPACITY = 1 << 30;

// 数组segments的最大长度,也是最大并发级别
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative,翻译过来是略保守,实际远远用不到这么多Segment啊

// size方法和containValue方法最大的不加锁尝试次数
// 简单点说就是先不加锁执行一次,如果没发现改变,就返回,发现改变就再重复一次,再发现改变就所有对segment都加锁再操作一次,这样设计主要是为了避免加锁提高效率。
static final int RETRIES_BEFORE_LOCK = 2;
2、变量
// 段掩码,跟子网掩码以及HashMap中的 table.length - 1 的作用差不多,都是用为了用位运算加速hash散列定位
final int segmentMask;

// 段偏移量,定位到一个segment使用的是hash值的高位,segments数组长度为2^n的话,此数值为32-n,也就是把最高的n位移动到最低的n位
final int segmentShift;

// 段数组
final Segment<K,V>[] segments;

// 下面都懂
transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
transient Collection<V> values;


三、基本类

也就是HashEntry和Segments,这两个是所有方法实现的基础。
先看看HashEntry,代码如下:
static final class HashEntry<K,V> {
    final K key;
    final int hash;
    volatile V value;
    final HashEntry<K,V> next;

    HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
        this.key = key;
        this.hash = hash;
        this.next = next;
        this.value = value;
    }

    @SuppressWarnings("unchecked")
    static final <K,V> HashEntry<K,V>[] newArray(int i) {
        return new HashEntry[i];
    }
}
HashEntry相对HashMap.Entry的变化:
1、value变为volatile了,对多线程具有相同的可见性,直接读取value、给value赋值为一个已经知道的值(就是写value可以不依赖它当前的值),这两个操作是是不用加锁的;
2、next变为final了,这也就是说不能从HashEntry链的中间或尾部添加或删除节点,因为这需要修改 next 引用值。这让删除变得复杂了一些,要复制前面的节点并重新添加,删除的效率变低。

说两个问题:
1、为什么使用final修饰next?
key和hash因为是判别两个hashEntry是否“相等”的重要属性,实际中不允许修改,设置为final合情合理,更重要的原因是扩容时有用到hash值不会改变这个重要的前提。扩容时一部分节点重用,本质上和HashMap1.8中的高低位时一个思想。
next指针在删除节点时需要被修改,设置为final使得remove操作很麻烦。这个不是为了remove不影响正在进行在HashEntry链的读操作,虽然这个类是弱一致的,但是是允许迭代读时看到其他线程的修改的,再对比看下1.7的,可以印证这一点。

关于这一点,需要知道Java中final的内存语义。关于final的内存语义,可以看下 这篇文章,我这里结合HashEntry简单说下。
final的内存语义,能够保证读线程读去一个HashEntry节点时,它的next指针在这之前已经被正确初始化了(构造方法中this不提前逸出的情况下,HashEntry满足这一点)。如果使用普通的变量next,处理器重排序后,有可能读线程先读取到这个节点引用,然后通过引用读取next的值。next的初始化赋值因为重排序,可能被放在后面再执行,这种情况下读取到的next是默认值null。因为get/put/remove/replace都需要使用next指针进行链表遍历,在遍历链表时,next == null本身就是一个正常的状态 ,因此这种重排序基本上对所有读写操作都有严重的影响,会造成混淆,所有方法的准确性大大降低。然后,因为next == null是正常状态,不能像value一样,在读取到null时使用readUnderLock再来一次(这也是ConcurrentHashMap为什么不能允许null value的一个原因)。
为了避免上面说的这种问题,把next设置为final的,这样就能保证不会读取到未被初始化赋值时的默认值null,遍历时读到null就一定能保证是遍历到了链表末尾。

<

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值