工作之后,我对 ConcurrentHashMap 有了新的理解

文章目录

    • 写在前面的话
    • 源码理解(JDK1.8)
      • 一些关键静态常量
      • 基础数据结构与方法
        • Node 节点
        • ForwardingNode 节点
        • spread 方法
        • tabAt/casTabAt/setTabAt
        • CounterCell 结构
      • get() 方法
      • put() 方法
        • 初始化 table 数组(线程安全)
        • addCount() 方法
        • transfer() 扩容方法
    • 常见问题
    • 参考博客

写在前面的话

1.个人感觉 ConcurrentHashMap 的源码细节还是很多的,建议先从整体层面了解原理和思想,然后再分块去阅读源码。
阅读源码的过程中也不需要每一行都读到,对于不是那么重要以及实在是看不懂的部分请果断抛弃,这并不影响你学习 or 准备面试,也不是你的问题,放过自己!因为源码真的太难看了,这种代码在实际的业务系统中几乎是不可能写出来的,要是同事写了我会让他去死的。。。
2.网上有很多关于 ConcurrentHashMap 的优秀博客,本文的出发点是汇总一些我个人在学习过程中的卡点问题,以及一些我觉得比较有意思的部分,所以并不是一份很全的文章,一些更加详细的源码注解我引用了其他大佬的博客,文末附上链接。
3.如果恰好你也有同样的问题,希望能够帮助到你。如果我的理解有误,请一定帮我指正,谢谢~

源码理解(JDK1.8)

一些关键静态常量

/* ---------------- Constants -------------- */  
  
private static final int MAXIMUM_CAPACITY = 1 << 30;  
  
private static final int DEFAULT_CAPACITY = 16;  
  
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;  

// 默认并发级别。未使用但已定义以与此类的先前版本兼容。可以不管了。
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;  
  
private static final float LOAD_FACTOR = 0.75f;  
  
static final int TREEIFY_THRESHOLD = 8;  
  
static final int UNTREEIFY_THRESHOLD = 6;  
  
static final int MIN_TREEIFY_CAPACITY = 64;  

private static final int MIN_TRANSFER_STRIDE = 16;  
  
private static int RESIZE_STAMP_BITS = 16;  
  
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;  
  
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;  

// 几个特殊的哈希值,用来标识各种状态
// 表示正在扩容
static final int MOVED     = -1; // hash for forwarding nodes  
// 表示红黑树的节点
static final int TREEBIN   = -2; // hash for roots of trees  
// 在 computeIfAbsent 这个场景下使用的节点
static final int RESERVED  = -3; // hash for transient reservations  
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash

DEFAULT_CONCURRENCY_LEVEL
可以不管了,JDK 1.8 之后就不用了。
1.7 之前定义的默认并发级别。

MIN_TRANSFER_STRIDE
concurrentHashMap 数组扩容时是支持并发扩容的,该参数规定了每一个参与扩容的线程需要负责的最小的桶数量,默认值为 16。

MOVED、TREEBIN、RESERVED
MOVED 表示当前正在扩容
TREEBIN 表示指向红黑树的根节点,也就是 table 数组对应下标位置上如果出现哈希冲突,并且已经升级为树时,对应的这个下标位置的第一个元素。
RESERVED 在 computeIfAbsent() 中使用的节点状态。

HASH_BITS
一个特殊的哈希值,0x7fffffff 实际上对应的二进制是【01111111 11111111 11111111 11111111】,下文单独讲。


基础数据结构与方法

Node 节点

HashMap 中的 Node 节点

static class Node<K,V> implements Map.Entry<K,V> {  
    final int hash;  
    final K key;  
    V value;  
    Node<K,V> next;  
  
    Node(int hash, K key, V value, Node<K,V> next) {  
        this.hash = hash;  
        this.key = key;  
        this.value = value;  
        this.next = next;  
    }
  }  

ConcurrentHashMap 中的 Node 节点

static class Node<K,V> implements Map.Entry<K,V> {  
    final int hash;  
    final K key;  
    volatile V val;  
    volatile Node<K,V> next;  
  
    Node(int hash, K key, V val, Node<K,V> next) {  
        this.hash = hash;  
        this.key = key;  
        this.val = val;  
        this.next = next;  
    }
}

对比之下,ConcurrentHashMap 中的 val 和 next 字段都用 volatile 修饰。
关于 volatile 的原理和用法就不在这展开了,网上有很多讲的非常好的博客,推荐一个高赞回答:https://blog.csdn.net/u012723673/article/details/80682208
简而言之就是:使用 volatile 修饰的 val 和 next 的所有变更都会立刻被其他线程所感知到,其他线程取到的数据一定是变更后的最新的数据,这是 ConcurrentHashMap 支持线程安全的其中一个原因。当然了,仅仅靠这一个修饰符还是不够的,后文还会讲其他的配套操作。


ForwardingNode 节点

ForwardingNode 继承于 Node 节点,注意它的构造方法中在调用 super 的时候传的第一个参数是 MOVED,也就是这个节点的 hash=-1。
这一步非常重要!在 get 和 put 的流程中通过判断元素哈希值小于 0 ,或者等于 MOVED 的依据就是这。
可以等看到 get 和 put 方法的时候再回过头来看。

static final class ForwardingNode<K,V> extends Node<K,V> {  
    final Node<K,V>[] nextTable;  
    ForwardingNode(Node<K,V>[] tab) {  
        super(MOVED, null, null, null);  
        this.nextTable = tab;  
    }
}

spread 方法

HashMap 中的 hash() 方法

static final int hash(Object key) {  
    int h;  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}

ConcurrentHashMap 中的 spread() 方法

// 入参就是 key.hashCode 
static final int spread(int h) {  
    return (h ^ (h >>> 16)) & HASH_BITS;  
}

对比之下,spread 在 hash() 方法的基础上多加了一步 & HASH_BITS 操作。

& HASH_BITS 操作的意义是什么?
确保这一步的哈希值一定是正数。
0x7fffffff 对应的二进制是 01111111 11111111 11111111 11111111,第一位是 0。因此,将哈希值与它进行 & 操作(不同为0,相同才为1)之后得出来的最终结果的第一位也一定是 0,这意味着最终结果一定是一个正数

为什么返回值一定要是正数?
这个方法最终是为了确定 table 的下标位置,即使是负数最终取模之后也是正数,按理说也不影响正常的使用,那为啥多此一举呢?
因为负数有其他的用处,下文分析源码的过程中就会发现,部分 if else 的分叉口就是通过这个值是否小于 0 来实现的。
因此,对于正常的 put 和 get 元素,这个方法都控制了返回值大于 0 ,小于 0 是内部人员专属牛逼 plus 使用版。


tabAt/casTabAt/setTabAt
// Unsafe mechanics  
private static final sun.misc.Unsafe U;    
private static final long ABASE;  
private static final int ASHIFT;  
  
static {  
    try {  
        U = sun.misc.Unsafe.getUnsafe();  
        // U.arrayBaseOffset(ak); 获取数组中第一个元素的位置
        ABASE = U.arrayBaseOffset(ak);
        // U.arrayIndexScale(ak); 获取数组中每一个元素的size,如 byte boolean 就是 1,long 类型就是 4 等。  
        int scale = U.arrayIndexScale(ak);  
        if ((scale & (scale - 1)) != 0)  
            throw new Error("data type scale not a power of two");  
        ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);  
    } catch (Exception e) {  
        throw new Error(e);  
    }  

@SuppressWarnings("unchecked")  
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);  
}  
  
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);  
}  
  
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {  
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);  
}

}

tabAt、casTabAt 和 setTabAt 是 ConcurrentHashMap 的核心操作方法,底层使用的是 sun.misc.Unsafe 这个类提供的方法,可以理解为硬件级别的原子操作。必须掌握这三个方法,后续 get() 和 put() 的流程中强依赖它们。

tabAt
以 Volatile 读的方式返回对应下标的 Node 元素,入参是 table 数组和下标 i,
U.getObjectVolatile()方法中用的是 sun.misc.Unsafe 这个类中的方法,通过直接访问内存的方式获取数组元素,其中还涉及到数组是如何进行随机访问的知识点,推荐一篇博客:https://blog.csdn.net/ty649128265/article/details/91857242

casTabAt
基于 CAS 尝试更新 table 数组中下标 i 位置的元素更新为目标值 v,参数 c 为预期的当前值,v 位更新的目标值。

setTabAt
基于 CAS 尝试设置 table 数组中下标 i 位置的元素为目标值 v。它和 casTabAt 的区别是它没有参数 c,也就是不关注历史的值是什么。


CounterCell 结构

这是一个非常有意思的结构或者说思想。可以先不关注,等看到 put() 方法的时候回过头来再看。
ConcurrentHashMap 本质也还是 table 数组,map 中总的元素个数等价于 table 数组中元素的个数总和,sumCount()的实现其实就是遍历数组累加 value。

ConcurrentHashMap 是个线程安全的集合,如果我们用一个和 table 数组同级别的成员变量来表示总的个数,那么即使 put 的时候优化为在 table 元素维度加锁,但是更新总数量的时候还是得在 map 这个维度添加锁,依旧会存在性能上的问题。

@sun.misc.Contended static final class CounterCell {  
    volatile long value;  
    CounterCell(long x) { value = x; }  
}  
  
final long sumCount() {  
    CounterCell[] as = counterCells; CounterCell a;  
    long sum = baseCount;  
    if (as != null) {  
        for (int i = 0; i < as.length; ++i) {  
            if ((a = as[i]) != null)  
                sum += a.value;  
        }  
    }  
    return sum;  
}

get() 方法

概括一下步骤

  1. 通过 spread()方法获取哈希值,这个方法的返回值一定是正数。
  2. 通过 tabAt 获取 table 数组对应下标位置的元素,并进行判断
  3. 如果元素的第一个值就满足条件,则直接返回。
  4. ==如果元素的哈希值为负数,则代表处于某些特定的状态,进入特殊逻辑处理。==后文单独讲,此时先不用关注。
  5. 如果第一个元素不满足,则往下遍历直到找到对应值或者返回null。
/**  
 * Returns the value to which the specified key is mapped, * or {@code null} if this map contains no mapping for the key.  
 * * 

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.) * 不要忽略注释,这些明确了如果 key 为 null 的话是会抛出空指针的。 * * @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 && // 使用 tabAt()以 volatile 读的方式获取 table 数组制定下标的元素 (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; } // 重要:eh= e.hash 也就是对应节点的哈希值,如果小于 0 则说明此时有其他 // 操作正在执行(扩容)。上文分析过,普通的读写节点是通过 spread()方法 // 生成的哈希值一定是正数!所以思考一个问题,那什么时候会是负数呢? 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; }

看完之后再回忆一下 HashMap 的 get() 方法,大流程几乎是一模一样的对吧?当我第一次看完这段代码的时候,我产生了几个疑问:

  1. ConcurrentHashMap 号称是线程安全的,但是 get() 方法里好像也没做啥,怎么就安全了?
  2. 这个 eh 的值(其实就是数组下标元素的 hashcode)在什么场景下会是负数呢?

不知道你们有没有这样的疑惑,可以继续往下看。


put() 方法

概括一下步骤

  1. 判断 key 和 value 是否为 null,任意一个为 null 则抛出空指针。
  2. 判断 table 数组长度,为 0 则先进行初始化。
  3. 通过 tabAt 获取 table 数组对应下标位置的元素,并进行判断
  4. 如果当前位置还没有对应元素,则直接 CAS 插入。
  5. 如果当前元素 Node 处于 MOVED 状态,则说明其他线程正在扩容,当前线程也参与进去辅助扩容。
  6. 如果当前元素不为 null,也没有在扩容,就进入处理哈希冲突的流程。
  7. 在当前元素这个 Node 维度上加 synchronized 锁,开始新增元素流程。
  8. 新增过程中,判断是红黑树还是普通链表,调用不同的添加方法。
  9. 新增后,判断链表是否需要升级为树,流程和 HashMap 类似,也是当元素个数小于 64 时优先扩容数组,不然就升级为树。
  10. 维护 map 中的元素数量值,这一步有可能会触发扩容,对 MOVED 状态的赋值逻辑也会发生在这一步。
public V put(K key, V value) {  
    return putVal(key, value, false);  
}

final V putVal(K key, V value, boolean onlyIfAbsent) {
	// 第一行判断了 key 和 value 都不能为 null,不然就抛空指针。
    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)
            // 初始化 table 数组,下文详解了为什么在并发场景下不会重复初始化 
            tab = initTable();  
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {  
	        // 使用 tabAt 方法获取 table 数组对应下标位置的元素,如果为 null 则
	        // 说明当前位置不存在哈希冲突的问题,直接通过 casTabAt 方式写入。
            if (casTabAt(tab, i, null,  
                         new Node<K,V>(hash, key, value, null)))  
                break;                   // no lock when adding to empty bin  
        }  
        else if ((fh = f.hash) == MOVED)
		    // 上文解释过 MOVED 的特殊含义。如果当前线程发现节点的哈希值为 MOVED,
		    // 则该线程也参与进去协助扩容,也就是说支持并发扩容。
            tab = helpTransfer(tab, f);  
        else {  
            // 走到这一步,说明已经进入到处理哈希冲突的场景。此时这个 f 为 table 
            // 数组对应下标的节点,会在这个节点上使用 synchronized 加锁。
            // 重点:这是 JDK1.8 相较于之前的优化点,锁的粒度更小了。
            V oldVal = null;  
            synchronized (f) {  
                if (tabAt(tab, i) == f) {
	                // fh 是当前节点哈希值,大于0说明不是上文提到的那几个特定状态
	                // 进入到正常的添加元素流程中  
                    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;  
                        }  
                    }  
                }  
            }  

			// 上面的代码中申明了如果添加节点,则 binCount=1 or binCount=2,
			// 此时需要判断是否需要将链表升级为红黑树,对应方法就是 treeifyBin。
            if (binCount != 0) {  
                if (binCount >= TREEIFY_THRESHOLD)  
                    treeifyBin(tab, i);  
                if (oldVal != null)  
                    return oldVal;  
                break;  
            }  
        }  
    }

	// 走到这一步,说明已经成功新增了元素,addCount 维护 map 中的元素数量。
	// 方法里还有很复杂的逻辑,但是和 put() 没有强依赖,因此就不展开了。
    addCount(1L, binCount);  
    return null;  
}

初始化 table 数组(线程安全)
private final Node<K,V>[] initTable() {  
    Node<K,V>[] tab; int sc;  
    while ((tab = table) == null || tab.length == 0) {  
        if ((sc = sizeCtl) < 0)  
            Thread.yield(); // lost initialization race; just spin  
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {  
            try {  
                if ((tab = table) == null || tab.length == 0) {  
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;  
                    @SuppressWarnings("unchecked")  
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];  
                    table = tab = nt;  
                    sc = n - (n >>> 2);  
                }  
            } finally {  
                sizeCtl = sc;  
            }  
            break;  
        }  
    }  
    return tab;  
}

/**  
 * Table initialization and resizing control.  When negative, the * table is being initialized or resized: -1 for initialization, * else -(1 + the number of active resizing threads).  Otherwise, * when table is null, holds the initial table size to use upon * creation, or 0 for default. After initialization, holds the * next element count value upon which to resize the table. */
private transient volatile int sizeCtl;

sizeCtl 也是使用 volatile 修饰的一个变量,通过注释也能知道当 table 数组正在初始化的过程中,cizeCtl 等于 -1,其实就是上面 U.compareAndSwapInt(this, SIZECTL, sc, -1) 这个 CAS 操作写入的。
因此,当 A 和 B 两个线程同时进入到 initTable() 方法时,当 A 线程 CAS 写入 cizeCtl=-1 时,此时 B 线程会立刻拿到更新后的数值,那么在 if ((sc = sizeCtl) < 0) 这一步就会返回 true,从而调用 Thread.yield() 方法让出 CPU 执行时间,也就避免了重复重试化。


addCount() 方法

推荐一篇讲的比较好的博客:https://cloud.tencent.com/developer/article/1821555
这里我就没写源码的细节了,我的关注点是在 addCount() 的过程中会触发扩容行为。

private final void addCount(long x, int check) {  
    CounterCell[] as; long b, s;
    // 如果 counterCells==null 表示当前还没有新增过元素,进入到初始化逻辑
    if ((as = counterCells) != null ||  
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {  
        CounterCell a; long v; int m;  
        boolean uncontended = true;  
        if (as == null || (m = as.length - 1) < 0 ||  
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||  
            !(uncontended =  
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {  
            fullAddCount(x, uncontended);  
            return;  
        }  
        if (check <= 1)  
            return;  
        s = sumCount();  
    }  
    if (check >= 0) {  
        Node<K,V>[] tab, nt; int n, sc;  
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&  
               (n = tab.length) < MAXIMUM_CAPACITY) {
            // resizeStamp 方法的作用是生成一个扩容时期唯一的时间戳数值
            int rs = resizeStamp(n);  
            if (sc < 0) {  
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||  
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||  
                    transferIndex <= 0)  
                    break;  
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                  // 重要,扩容  
                    transfer(tab, nt);  
            }  
            else if (U.compareAndSwapInt(this, SIZECTL, sc,  
                                         (rs << RESIZE_STAMP_SHIFT) + 2))                   // 重要,扩容
                transfer(tab, null);  
            s = sumCount();  
        }  
    }  
}

transfer() 扩容方法

推荐一篇讲的比较好的博客:https://cloud.tencent.com/developer/article/1821555
这里我就没写源码的细节了,我的关注点是 MOVED 这个特殊值的赋值逻辑,这就解释了为何在 get() 方法里会判断哈希小于 0,以及为何 put() 方法里会判断哈希等于 MOVED

/**  
 * Moves and/or copies the nodes in each bin to new table. See * above for explanation. */
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {  
    int n = tab.length, stride;  
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)  
        stride = MIN_TRANSFER_STRIDE; // subdivide range  
    if (nextTab == null) {            // initiating  
        try {  
            @SuppressWarnings("unchecked")  
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];  
            nextTab = nt;  
        } catch (Throwable ex) {      // try to cope with OOME  
            sizeCtl = Integer.MAX_VALUE;  
            return;  
        }  
        nextTable = nextTab;  
        transferIndex = n;  
    }  
    int nextn = nextTab.length;
    // 重点,扩容的时候 new 的节点是 ForwardingNode,这个类,上文提过它的 hash=MOVED(-1)
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);  
    boolean advance = true;  
    boolean finishing = false; // to ensure sweep before committing nextTab  
    for (int i = 0, bound = 0;;) {  
        Node<K,V> f; int fh;    
        if (i < 0 || i >= n || i + n >= nextn) {  
            int sc;  
            if (finishing) {  
                nextTable = null;  
                table = nextTab;  
                sizeCtl = (n << 1) - (n >>> 1);  
                return;  
            }  
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {  
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)  
                    return;  
                finishing = advance = true;  
                i = n; // recheck before commit  
            }  
        }  
        else if ((f = tabAt(tab, i)) == null)  
            advance = casTabAt(tab, i, null, fwd);  
        else if ((fh = f.hash) == MOVED)  
            advance = true; // already processed  
        else {  
            synchronized (f) {  
            // 省略,关注这个 synchronized 的维度是 f,即 table 数组的元素节点
                }  
            }  
        }  
    }  
}

常见问题

ConcurrentHashMap 的学习和面试问题太多了,我太菜了就推荐一份写的比较好的博客吧,基本上囊括了面试高频的所有问题:https://blog.csdn.net/wuhuayangs/article/details/126049472

参考博客

  • https://www.cnblogs.com/ciel717/p/16190793.html
  • https://blog.csdn.net/weixin_43453386/article/details/124785040
  • https://blog.csdn.net/ty649128265/article/details/91857242
  • https://blog.csdn.net/Leon_Jinhai_Sun/article/details/112062415
  • https://cloud.tencent.com/developer/article/1821555
  • https://blog.csdn.net/wuhuayangs/article/details/126049472

你可能感兴趣的:(哈希算法,java,面试)