ConcurrentHashMap实现原理和源码分析

98 阅读9分钟

HashMap

之前的文章(HashMap实现原理和源码分析),讲了HashMap,核心就一句话:数组 + 链表/红黑树。

即三大数据结构。理解了为什么需要三大数据结构,也就理解了map。

今天要讲的是,在map上加了并发功能。具体的类就是:ConcurrentHashMap。

ConcurrentHashMap

讲HashMap的时候,核心是数据结构,那么讲ConcurrentHashMap,核心仍然是数据结构。

那数据结构是啥?没变,和HashMap完全一样,还是三大件。

唯一的变化,就是加了并发功能,所以才叫并发HashMap。

那并发HashMap是如何解决并发问题的呢?

put方法:如何解决并发问题?

核心底层技术

直接开门见山,先说结论:用到了什么核心技术?cas + 同步关键字。

为什么要用这两个核心技术?各自分别又是用在什么地方?看下文。

核心步骤

1、  初始化数组和第一个节点

这个时候,都是基于cas。

2、  链表上的非第一个节点

这个时候,是基于同步关键字。


看源码:java.util.concurrent.ConcurrentHashMap#putVal

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable(); //初始化数组:底层其实是基于cas
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null))) //如果是第一个节点:也是基于cas
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else { 
                V oldVal = null;
                synchronized (f) { //如果不是第一个节点:基于同步关键字 //锁的是头节点对象
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

源码里的三个关键地方有注释,分别说明了使用了什么技术。

其实就是,

1、  没数据的时候,基于cas

比如,数组初始化,就是没有数据,就基于cas。

然后,索引位置的第一个节点,没有数据,就基于cas,写第一个数据。

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); //基于sun.misc.Unsafe#compareAndSwapObject解决写的线程安全问题
}

2、  有数据的时候,基于同步关键字

但是,如果链表的头节点已经有数据,接下来,就是基于同步关键字,而且锁住的对象就是头节点。

get方法

源码

/**
 * Returns the value to which the specified key is mapped,
 * or {@code null} if this map contains no mapping for the key.
 *
 * <p>More formally, if this map contains a mapping from a key
 * {@code k} to a value {@code v} such that {@code key.equals(k)},
 * then this method returns {@code v}; otherwise it returns
 * {@code null}.  (There can be at most one such mapping.)
 *
 * @throws NullPointerException if the specified key is null
 */
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) { //首先判断头结点是否是要找的节点
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val; //如果是,直接返回
        }
        else if (eh < 0) //红黑树
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) { //链表
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

核心步骤就是三种情况
1、数组
2、链表
3、红黑树

参考:javaguide.cn/java/collec…

ConcurrentHashMap get方法 如何保证线程安全?

1、基于sun.misc.Unsafe#getObjectVolatile,确保可以读到最新数据

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

2、非阻塞,因为读方法没有锁

非阻塞操作: get 操作是只读操作,因此在多个线程同时访问的情况下,不会发生阻塞。这允许多个线程同时访问不同的桶,而不会产生锁竞争。


ConcurrentHashMap get方法:如果访问同一个桶,是不是也是阻塞?

不,ConcurrentHashMapget 方法通常不会在访问同一个桶(Bucket)时阻塞。get 方法是只读操作,因此在访问同一个桶内的数据时,不会发生阻塞或竞争锁。多个线程可以并发地读取同一个桶内的数据,而不会相互阻塞。

ConcurrentHashMap 的设计中,分段锁和 CAS 操作等机制被用来允许并发地进行读取操作,以提高性能。只有写操作(插入、删除等)才会涉及到内部同步机制,以确保数据的一致性和线程安全性。对于读操作,即使多个线程同时访问同一个桶,它们也不会因为读取而发生阻塞。

因此,ConcurrentHashMapget 方法在访问同一个桶时不会阻塞,并且可以高效地支持并发的读操作。这是 ConcurrentHashMap 设计的一大特点,使得在多线程环境下对数据的读取操作可以具备良好的性能。

else if (eh < 0) :这个条件应该是红黑树,那为什么红黑树eh<0?

抱歉,我之前的回答可能存在一些误导。您是正确的,eh 的值小于零实际上是用来标识根节点为红黑树而不是链表。ConcurrentHashMap 中确实使用负数的 eh 值来标识红黑树根节点,而不是普通的链表头节点。

根据 JDK 8 中 ConcurrentHashMap 的源码,eh 是用来标记特殊情况的,其中包括:

  1. eh == -1:表示该桶正在进行初始化操作。
  2. eh == -2:表示该桶正在进行扩容操作。
  3. eh < 0 且不等于 -1-2:表示该桶中存储的数据结构是红黑树,而不是链表。

对于红黑树来说,哈希桶的根节点是一个红黑树的根节点,而且通过 eh 的负数值可以进行快速判断。这是因为红黑树根节点的哈希码的最高位是 1,所以在判断时会小于零。

再次对之前的回答中可能存在的混淆表示歉意。感谢您的纠正,希望这次的解释能够更准确地解释 eh 值的含义。

小结

是基于cas和同步关键字来解决线程安全问题。

是基于sun.misc.Unsafe#getObjectVolatile,确保可以读到最新数据。另外,非阻塞,因为没有锁。

sun.misc.Unsafe#getObjectVolatile和compareAndSwapObject的区别

sun.misc.Unsafe#getObjectVolatilesun.misc.Unsafe#compareAndSwapObject 都是 sun.misc.Unsafe 类提供的底层内存操作方法,用于在多线程环境下进行原子操作。它们有不同的作用和用途:

  1. getObjectVolatile

    • getObjectVolatile 方法用于获取指定对象的字段的值,并且保证了在获取值时具有 volatile 语义。
    • 这意味着,即使在多线程环境中,也可以确保读取到最新的值,防止指令重排序对读操作的影响。
  2. compareAndSwapObject

    • compareAndSwapObject 方法用于原子地比较对象的字段的当前值与给定的期望值,如果相等,则将字段的值更新为新的值。
    • 这是一个原子的操作,用于实现类似于乐观锁的机制。如果当前值与期望值相等,就会执行更新操作;否则,不执行更新。
    • 这个方法可以用于实现一些高级的同步和并发算法,例如无锁数据结构或自旋锁。

总的来说,getObjectVolatile 用于保证获取字段值的 volatile 语义,而 compareAndSwapObject 用于实现原子的比较和交换操作,以实现并发控制。这两个方法都属于底层的内存操作,应该在了解其作用和原理的基础上谨慎使用。由于它们是非标准的 API,不同的 Java 版本或 JVM 可能会有不同的实现和行为,因此在实际开发中不推荐直接使用这些方法。


说白了,就是一个用于写,一个用于读。

写是为了确保同一时间只有一个线程能写成功。

读是为了能够读到最新数据。

sun.misc.Unsafe#getObjectVolatile 会阻塞吗

sun.misc.Unsafe#getObjectVolatile 方法本身并不会阻塞。它是一个底层内存操作方法,用于获取指定对象的字段的值,并且保证了在获取值时具有 volatile 语义。Volatile 语义确保在多线程环境中,读取操作可以看到最新的值,但它不会引起线程的阻塞。

Volatile 语义主要涉及到内存可见性,即一个线程对于一个 volatile 字段的写操作,会立即刷新到主内存中,而其他线程对该字段的读操作可以立即从主内存中获取最新的值。这是通过特定的 CPU 指令或内存屏障来实现的。

因此,getObjectVolatile 方法执行的是一个快速的读取操作,不会引起线程的阻塞。这使得多个线程可以同时读取同一个字段的值,而不会发生互斥或阻塞。但需要注意的是,这个方法是 sun.misc.Unsafe 的底层方法,使用时需要谨慎考虑其在不同 Java 版本和不同 JVM 上的行为可能存在的差异和风险。


不会阻塞,因为不是锁。

本质是类似volatile关键字的作用。

compareAndSwapObject会阻塞吗

sun.misc.Unsafe#compareAndSwapObject 方法不会阻塞线程。这是一个底层的原子操作方法,用于实现比较并交换操作(CAS,Compare and Swap)。CAS 是一种乐观锁的机制,它在特定条件下原子地比较某个值与期望值,如果相等,则将值替换为新值。

CAS 操作是非阻塞的,因为它不会导致线程进入阻塞状态。CAS 操作在执行时会尝试修改内存中的值,但如果操作失败(即实际值与期望值不匹配),则不会阻塞线程,而是返回失败的结果。

在 Java 中,CAS 操作常用于实现无锁的并发控制机制,例如用于实现自旋锁、无锁队列、并发容器等。这种操作可以在一定程度上减少锁竞争和线程的上下文切换,从而提高并发性能。

需要注意的是,sun.misc.Unsafe 是 Java 的内部 API,它提供了一些底层的内存操作方法,但它也可能会引发内存安全问题。在实际开发中,推荐使用标准的高级并发控制工具,如 java.util.concurrent 包中提供的工具,以避免直接使用底层的内存操作方法。


也不会阻塞,因为也不是锁。

注意,cas不是锁,乐观锁不是锁。比如基于数据库版本号字段实现的乐观锁,都不是锁。

乐观锁,是确保同一时间只有一个线程写成功。其他线程写失败,而不会阻塞

和CopyOnWriteArrayList比较

1、写
基于单独复制一份数据,并且加锁确保线程安全。

2、读
无锁。也没有volatile确保读最新数据,因为读数据和写数据都是单独的一份数据,根本不需要volatile确保读最新数据。所以,读方法根本无需解决线程安全问题。

总结

集合包

集合包,要么是非线程安全,要么是基于悲观锁(都是通过加同步关键字)来解决线程安全问题。

并发包

并发包,做了优化,

1、写

一般有两种方案,其实就是乐观锁和悲观锁,或者是二者结合。

一般情况下,优先使用乐观锁(基于cas)解决并发写的问题,实在不行才使用悲观锁(基于同步关键字或者显示锁)。

2、读

一般使用volatile(sun.misc.Unsafe#getObjectVolatile或者volatile关键字),确保可以读到最新数据。

小结

集合包,写和读,都是使用悲观锁。

并发包,写尽量使用乐观锁,实在不行才使用悲观锁。但是读没有锁,只需要使用volatile确保能读到最新数据即可。

参考

《Java高并发和集合框架》