ConcurrentHashMap 源码分析 - JDK1.7

3 篇文章 0 订阅

声明

本文对 java.util.concurrent.ConcurrentHashMap的相关讨论全部基于 JDK1.7 源码.

阅读本文前,需要读者对 java.util.HashMap有一定程度的了解, 比如基于 K-V 存储, 底层数据结构等.

正文

接下来会基于以下五大块展开来讲:

  1. ConcurrentHashMap 的相关介绍

  2. ConcurrentHashMap 的构造函数

  3. ConcurrentHashMap 的 put 方法

  4. ConcurrentHashMap 的 get 方法

  5. ConcurrentHashMap 的哈希寻址算法

一、ConcurrentHashMap 介绍

ConcurrentHashMapHashtable一样, 它的所有操作都是线程安全的.

不同的是在操作 Hashtable时, 会锁住整个 “表”, 而 ConcurrentHashMap采用了分段锁(Segment) 、并利用并发编程的可见性和原子性原理, 根据并发度(构造参数, 默认为 16)将所有数据节点划分到 N 个区域 (Segment), 对每个区域分别加锁, 当一个线程访问其中一个区域同时, 不妨碍其他线程访问其他区域.

所以 ConcurrentHashMap要比 Hashtable的效率高.

既然 ConcurrentHashMap是线程安全的, 那阅读源码时, 每个操作步骤, 我们都要想着如果两个线程同时访问, 会出现什么问题 以及 ConcurrentHashMap是如何解决的.

1.1 ConcurrentHashMap 底层数据结构
  • ConcurrentHashMap内部维护了一个 Segment[]数组 , 这个数组的长度是不会发生改变的.

  • Segment内部维护了一个 HashEntry[]数组, 这个数组是可以动态扩容的.

  • HashEntry是一个单向链表.

在这里插入图片描述

1.1.1 HashEntry

不管是 HashMap、还是 ConcurrentHashMap, 不管是 jdk1.7 还是 1.8, 它的元素定义基本都是如下所示, 只不过名字不一样, 有的叫 Node、有的叫 HashEntryTreeNode等等. 在 jdk1.7 的 ConcurrentHashMap里, 这个节点叫做 HashEntry. 每个 HashEntry又可以是一个单向链表.

class 节点 {
  int hash;
  K key;
  V value;
  // 节点 next;
}
1.1.2 Segment

前面提到, ConcurrentHashMap被划分成了 N 个区域, 每个区域都有自己的锁, 这个区域的定义如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
  transient volatile HashEntry<K,V>[] table;
  // 其他省略
}
  • 从源码中可以得知, Segment继承了ReentrantLock, 所以自带锁操作, 至于锁怎么用, 后面源码分析会细讲.
  • Segment中维护了一个 HashEntry数组, 如果了解HashMap源码的, 八一将这个 Segment比作一个 HashMap.

总体来讲, 如果把 HashMap 比作一级哈希表的话, 那么ConcurrentHashMap 就类似于一个二级哈希表, 当我们想要访问某一个元素时, 会先经过一个 hash 寻址, 定位到某个 Segment , 然后再通过一个 hash 寻址, 最终定位到 HashEntry 节点.

二、ConcurrentHashMap 的构造函数

ConcurrentHashMap 有 5 个构造函数, 我们拿其中一个最核心的来讲.

  • initialCapacity

    ConcurrentHashMap 的初始容量, 表示能容纳多少个 HashEntry, 并非 Segment.

  • loadFactor

    负载因子, 跟扩容 Segment 中的HashEntry 数组相关.

  • concurrencyLevel

    并发级别, 表示同时最多可以支持多少个线程操作, 默认为 16.

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
  			// 参数检查
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
  			// concurrencyLevel 最大值 65536
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        
  		// 这个先不用管,往下看
        int sshift = 0;
  		// ssize 为 segment 数组的长度, 为了方便后面的运算, ssize 最好为 n 的2 次方
  		// 举几个例子:
  		//  当 concurrencyLevel 为 10 时, ssize 就等于 16, 即 2 的 4次方
  		//  当 concurrencyLevel 为 3 时, ssize 就等于 4, 即 2 的 2次方
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
  		// segmentShift 和 segmentMask 就是为了计算 Segment 索引的. 后面再讲
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
  
  		// c 就是为了辅助计算每个 Segment 中的 HashEntry 容量
        int c = initialCapacity / ssize;
  		// 这里加上上一步, 就是要达到 5/3=2, 7/2=4 这样的效果, 向上取整
        if (c * ssize < initialCapacity)
            ++c;
  		// 通过 c 变量计算出的 cap 值才是每个 Segment 中的 HashEntry 真正容量
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
  		// 跟上面 ssize 的原理一样. 需要取 2 次方
        while (cap < c)
            cap <<= 1;
        // 根据上面的计算的值, 创建 Segment 数组, 并创建一个 Segment 对象.
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
  		// UNSAFE 类提供了内存操作, 这里可以直接将 s0 对象,放到 ss 数组的第 0 个位置.
        UNSAFE.putOrderedObject(ss, SBASE, s0); 
        this.segments = ss;
    }

总结:

简单来讲, 通过 ConcurrentHashMap 的构造函数, 就是做了两件大事:

  • 进行了一系列的计算

    • Segment[] 数组的长度
    • 每个 SegmentHashEntry 的容量
    • 提前计算好哈希寻址需要的一些数据.
  • 初始化

    • 初始化 Segment[] 数组
    • Segment[] 数组的第 0 个位置, 放置一个 Segment 对象.

当然, 里面还有很多细节, 比如, 为什么那些值非得取 n 的二次方, UNSAFE 类的原理等等, 文末会简单聊一聊.

三、ConcurrentHashMap 的 put 方法

public V put(K key, V value) {
    Segment<K,V> s;
  	// 不支持 null value
    if (value == null)
        throw new NullPointerException();
  	// 对 key 进行 hash 运算
    int hash = hash(key);
  	// 这里就用到了构造函数中通过计算得来的两个变量 segmentShift 和 segmentMask
    int j = (hash >>> segmentShift) & segmentMask;
  	// 同样通过 UNSAFE 类取出对应内存地址的 segment 对象
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
      	// 如果没有, 就新建一个 segment 对象
        s = ensureSegment(j);
  	// 调用 segment 的 put 方法, 
    return s.put(key, hash, value, false);
}
3.1 Segment 的 ensureSegment方法
private Segment<K,V> ensureSegment(int k) {
  	// 拿到 segment 数组
    final Segment<K,V>[] ss = this.segments;
  	// 计算内存地址
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
  	// 这里其实还是想再次通过 UNSAFE 类的方法去从内存中取 segment 对象
  	// getObjectVolatile 能保证拿到的值是最新的.
  	// 没有这一步其实不影响结果, 只是为了提高性能, 因为很可能其他线程已经把 Segment 对象创建好了,
  	// 所以这里叫 recheck
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
      	// 之前在构造函数中, 已经默认在 segment 数组的第 0 个位置创建了一个 Segment 对象.
      	// 而且每个 Segment 对象的基本属性值都是一样的, 比如 HashEntry 的容量, 负载因子.
      	// 所以这里就不用重新计算了, 直接复用
        Segment<K,V> proto = ss[0];
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
      	// 创建 HashEntry 数组
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
      	// 这里还是 recheck, 跟上面一样.
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { 
          	// 创建 Segment 对象
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
          	// 通过 cas 操作, 把 Segment 对象放到数组中去
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}
3.2 Segment 的 put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
  	// 这行代码就是获取锁, 前文有提到 Segment 继承自 ReentrantLock, 自带锁的特性.
  	// scanAndLockForPut 后面细讲, 这里简单提一下, tryLock 只是尝试获取一次锁, 是不会阻塞的
  	// 如果尝试获取锁失败, 就会进入 scanAndLockForPut, 再去不断地获取锁(这里面有一些策略), 
  	// 获取锁的过程中, 不能什么事都不干呀, 就会去尝试创建 HashEntry 对象
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    
    V oldValue;
    try {
      	// 拿到 Segment 内部维护的 HashEntry 数组
        HashEntry<K,V>[] tab = table;
      	// hash 寻址, 计算数组索引位置
        int index = (tab.length - 1) & hash;
      	// 拿到对应数组位置的头节点
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
          	// 头节点不为空
        		if (e != null) {
                K k;
              	// key 相同, 就覆盖旧值
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            } else {
              	// 头节点为空, 并且如果获取锁的过程中, 创建了 HashEntry 节点, 就采用头插法
              	// 插入到 HashEntry 链表中
                if (node != null)
                    node.setNext(first);
              	// 如果获取锁的过程中, 没有拿到 HashEntry 节点.
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                
                int c = count + 1;
              	// rehash 其实就是扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                  	// 把作为链表头节点的 node 节点放到 tab 数组中
                    setEntryAt(tab, index, node);
                // 统计 segment 中的修改次数
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
         }
    } finally {
        unlock();
    }
    return oldValue;
}
3.3 Segment 的 scanAndLockForPut 方法

scanAndLockForPut() 方法应该是截止目前比较难, 同时又是比较有意思的源码了.

读完下面这段话, 再去看源码

简单来讲, 该方法就是去获取锁, 获取锁是最终目的, 但是在获取锁的过程中, 又不想闲着, 所以会尽可能想多做一点事, 或者说提前做一些事, 但是这些事能不能做成其实对后续的流程不影响.

另外, 这个方法是获取锁, 还并没有真正拿到锁, 可以多个线程同时访问, 要带着这个去看源码.

// 承接上文的获取锁
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
  	// 拿到当前 segment 的对应 HashEntry 链表的头节点.
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
  	// 我们希望在获取锁过程中, 在某种条件下尽可能创建一个 HashEntry 节点, 并返回
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node
  	// 如果没有获取到锁, 就不断地去尝试获取锁 
    // 注意 tryLock 和 lock() 的区别, 前者是非阻塞的
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
      	// 这里需要回过头来, 总结下什么时候开始这个 if 条件不成立
        if (retries < 0) {
          	// 如果头节点为空, 就预先创建一个节点.
          	// 这里联系上面 put 方法
          	// 顺便提一句, 其实源码中很多操作都是为了提高性能, 所以用了巧妙的做法
          	// 即使没有这些做法, 也不会影响最终操作结果.
            if (e == null) {
              	// speculatively 表示投机, 推测
                if (node == null) // speculatively create node
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
          	// 如果头节点不为 null, 且当前插入的 key 跟头节点的 key 相等
            else if (key.equals(e.key))
                retries = 0;
          	// 从头节点往下遍历
            else
                e = e.next;
        }
      	// 超过最大尝试次数, 就进行阻塞式的获取锁操作.
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
      	// (retries & 1) == 0 就是当前重试次数达到偶数次
      	// 如果在获取锁的过程中, 头节点被修改了, 就得重头来过.
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
          	// 如果下次 while 判断没有拿到锁, 不就相当于从头来过嘛.
            retries = -1;
        }
    }
  	// 由上可知, 这个 node 是可以为 null的. 
    return node;
}

当上面每一行都看懂, 每一段都看懂后, 最后要纵观全局去分析.

四、 ConcurrentHashMap 的 get 方法

明确亮点:

  • get() 方法是没有加锁的, 所以效率很高

  • UNSAFE.getObjectVolatile() 方法是根据内存地址获取对应的对象, 并且根据 volite 修饰词, 可以得知拿到对象一定是最新的. 但也仅限于遍历起始时, 遍历过程中, HashEntry 链表还是会被修改的. 所以才有了官方的一条说法: get() 方法只能保证看到之前完成的操作,无法保证看到正在进行中的操作. 简称 弱一致性.

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
  	// 根据当前 key 获取 Segemnt 数组中的对应内存地址
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
  	// 根据内存地址 拿到 对应的 segment 
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
      	// 同理拿到 segment 中 HashEntry 链表, 开始遍历.
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

五、 ConcurrentHashMap 的哈希寻址算法

哈希算法和哈希寻址算法内容有很多, 不是本文的重点, 简单一点可以参考 [jdk1.8 中 HashMap 的 hash 算法和数组寻址]

ConcurrentHashMap 里有两个地方用到了 通过 hash 寻址, 获取 数组索引.

  • ConcurrentHashMap#put 方法

    // 在构造函数中预先计算了两个值 segmentShift 和 segmentMask
    // concurrencyLevel 默认为 16, 下面基于这个 16 进行计算
    int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
    	++sshift;
      ssize <<= 1;
    }
    // 一通计算 sshift 为 4,  ssize 为 16
    this.segmentShift = 32 - sshift;// 28
    this.segmentMask = ssize - 1;	 // 15
    
    // 在 put 方法中用到了segmentShift 和 segmentMask
    int hash = hash(key); // 11010110 11010110 11010110 11010110
    int j = (hash >>> segmentShift) & segmentMask;// (hash >>> 28) & 15
    
    hash >>> 28, 相当于让 hash 值的高四位参与后面的运算.
    因此最终结果 j = 13, 而 segment 数组长度是 16.
    
  • Segment#put 方法

    熟悉 HashMap 源码的, 下面这段代码不会陌生.

    int index = (tab.length - 1) & hash;
    

总结

本文基于 JDK1.7 的源码逐行分析了 ConcurrentHashMapput 方法和 get 方法的执行流程.

ConcurrentHashMap 源码的难点在于为了提高操作效率, 作了很多设计, 比如:

  • 多次recheck 目的是尽量能让方法提前结束. 尽量不去做锁操作.

  • 通过while(!tryLock()) 获取锁过程中, 会消耗CPU内存, 但是为了不浪费, 就在循环中去提前创建对象.

这些设计会让读者误以为这行代码没有了, 可能结果就不一样了, 最终导致思维长时间卡在某个点上跳不出来.

但是文中涉及到的一些 hash 算法, 以及 UNSAFE 类提供的一些“骚操作”, 本文并未详细讲, 感兴趣的可以自行了解.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值