【Java并发】理解ConcurrentHashMap实现原理

ConcurrentHashMap是线程安全且高效率的HashMap,本文我们将研究一下该容器的具体实现。


目录

为什么要使用ConcurrentHashMap

ConcurrentHashMap实现

JDK1.5中

ConcurrentHashMap的数据结构如图

get方法

put方法

JDK1.8中 

ConcurrentHashMap的数据结构如图

put方法

get方法


为什么要使用ConcurrentHashMap

  1. 在多线程环境下,使用HashMap,有可能会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环。
  2. 虽然可以使用HashTable来应对多线程环境,但是当线程访问HashTable同步方法时,其他线程将进入阻塞或者轮询,所以HashTable的效率十分低下。并且HashTable已经慢慢被淘汰了。
  3. 所有访问HashTable的线程都必须竞争同一把锁来获得访问HashTable的权利,但是ConcurrentHashMap使用分段锁技术,将所有数据分段,每段数据配备一把锁,那么当一段数据的锁被获得的时候,其他段的数据依然能够被访问,有效的提高了并发的效率。

ConcurrentHashMap实现

JDK1.8中的ConcurrentHashMap已经抛弃了分段锁,使用了CAS+synchronized来保证线程安全,所以我们分两部分讲解。


理解ConcurrentHashMap首先要对于HashMap有所了解,如果没有了解的同学可以先看一下我的关于HashMap的博文

【源码分析】深入理解HashMap 学习手记


JDK1.5中

ConcurrentHashMap的数据结构如图

【Java并发】理解ConcurrentHashMap实现原理_第1张图片

 ConcurrentHashMap中,segment继承了ReentrankLock充当锁的角色,每个segment守护了若干个桶(Bucket)。

在HashMap中,除去segment部分,就是HashMap的数据结构。

我们可以理解为ConcurrentHashMap就是将一个HashMap分成了多个HashMap,并且对每一个HashMap使用继承了ReentrankLock的segment来维护,实现线程安全。

get方法

public V get(Object key){
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}

先经过一次散列运算,定位到segment,然后再通过散列运算定位到其中的元素。非常简洁高效。

 

put方法

由于put方法中需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。

put方法首先定位到segment,然后在segment里进行put操作。

插入操作首先需要判断是否需要对segment里的HashEntry进行扩容(在HashMap中插入操作也需要检查是否需要扩容),如果需要扩容则扩容后再插入,否则直接插入。

由于已经弃用,我们不做更细致的讨论,主要看JDK1.8中的ConcurrentHashMap实现。


JDK1.8中 

ConcurrentHashMap的数据结构如图

【Java并发】理解ConcurrentHashMap实现原理_第2张图片

 

   ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上全部都变掉了。首先,取消了Segment分段锁的数据结构,取而代之的是数组+链表(红黑树)的结构。而对于锁的粒度,调整为对每个数组元素加锁(Node)。然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。

put方法

public V put(K key, V value) {
    return putVal(key, value, false);
}

   /** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());//计算hash值,两次hash操作
    int binCount = 0;
    for (Node[] tab = table;;) {//类似于while(true),死循环,直到插入成功 
        Node f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)//检查是否初始化了,如果没有,则初始化
            tab = initTable();
            /*
                i=(n-1)&hash 等价于i=hash%n(前提是n为2的幂次方).即取出table中位置的节点用f表示。
                有如下两种情况:
                1、如果table[i]==null(即该位置的节点为空,没有发生碰撞),则利用CAS操作直接存储在该位置,
                    如果CAS操作成功则退出死循环。
                2、如果table[i]!=null(即该位置已经有其它节点,发生碰撞)
            */
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)//检查table[i]的节点的hash是否等于MOVED,如果等于,则检测到正在扩容,则帮助其扩容
            tab = helpTransfer(tab, f);//帮助其扩容
        else {//运行到这里,说明table[i]的节点的hash值不等于MOVED。
            V oldVal = null;
            synchronized (f) {//锁定,(hash值相同的链表的头节点)
                if (tabAt(tab, i) == f) {//避免多线程,需要重新检查
                    if (fh >= 0) {//链表节点
                        binCount = 1;
                        /*
                        下面的代码就是先查找链表中是否出现了此key,如果出现,则更新value,并跳出循环,
                        否则将节点加入到链表末尾并跳出循环
                        */
                        for (Node e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)//仅putIfAbsent()方法中onlyIfAbsent为true
                                    e.val = value;//putIfAbsent()包含key则返回get,否则put并返回  
                                break;
                            }
                            Node pred = e;
                            if ((e = e.next) == null) {//插入到链表末尾并跳出循环
                                pred.next = new Node(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) { //树节点,
                        Node p;
                        binCount = 2;
                        if ((p = ((TreeBin)f).putTreeVal(hash, key,
                                                       value)) != null) {//插入到树中
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            //插入成功后,如果插入的是链表节点,则要判断下该桶位是否要转化为树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)//实则是>8,执行else,说明该桶位本就有Node
                    treeifyBin(tab, i);//若length<64,直接tryPresize,两倍table.length;不转树 
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

从上面代码可以看出,put的步骤大致如下:

  1. 检查 key/value 是否为空,处理 hash 值

  2. 进入 for 死循环,因为 CAS 的无锁操作需要一直尝试直至成功

  3. 检查 table 是否初始化,没有则初始化 initTable()

  4. 根据 key 的 hash 值找到在 table 中的位置 i ,取出 table[i]的节点 f

    • 如果 f==null (即该位置的节点为空,没有发生碰撞)

      直接 CAS 存储,退出循环

    • 如果 f!=null (即该位置已经有其它节点,发生碰撞),检查 f 的节点的 hash 是否等于 MOVED

      a.如果等于,则检测到正在扩容,则帮助其扩容 
      b.如果不等于,如果f是链表节点,则直接插入链表;如果是树节点,则插入树中

  5. 判断 f 是否需要将链表转换为平衡树

  6. 并发控制:

  7. 使用 CAS 操作插入数据

  8. 在每个链表的头结点都使用 Synchronized 上锁

        除了上述步骤以外,还有一点我们留意到的是,代码中加锁片段用的是synchronized关键字,而不是像1.7中的ReentrantLock。这一点也说明了,synchronized在新版本的JDK中优化的程度和ReentrantLock差不多了。

 

get方法

    public V get(Object key) {
        Node[] tab; Node e, p; int n, eh; K ek;
        int h = spread(key.hashCode());// 定位到table[]中的i
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {// 若table[i]存在
            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;// 未找到
    }

get()方法的流程相对简单一点,从上面代码可以看出以下步骤:

  1. 首先定位到table[]中的i。
  2. 若table[i]存在,则继续查找。
  3. 首先比较链表头部,如果是则返回。
  4. 然后如果为红黑树,查找树。
  5. 最后再循环链表查找。

        从上面步骤可以看出,ConcurrentHashMap的get操作上面并没有加锁。所以在多线程操作的过程中,并不能完全的保证一致性。这里和1.7当中类似,是弱一致性的体现。

总结

  1. JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  2. JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  3. JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
  4. JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
    1. 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
    2. JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
    3. 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

JDK1.8中concurrentHashMap的介绍摘自https://blog.csdn.net/fouy_yun/article/details/77816587

参考资料:《Java并发编程的艺术》

你可能感兴趣的:(Java)