ConcurrentHashMap精

10 篇文章 0 订阅
5 篇文章 0 订阅

本文着重讲解ConcurrentHashmap的源码实现和原理,JDK8中ConcurrentHashmap摒弃了分段锁技术的实现,直接采用CAS和Synchronized保证并发更新安全性,底层采用数组+链表+红黑树的存储结构。其数据结构如下:

说明:数据结构采用数组 + 链表 + 红黑树的方式实现。当链表中的节点个数超过8个时,会转换成红黑树的数据结构存储,这样设计的目的是为了减少同一个链表冲突过大情况下的读取效率。

下面看看几个内部类

(1)Node类:存放元素的key,value,hash值,next下一个链表节点的引用。用于bucket为链表时。
(2)TreeBin:内部属性有root,first节点,以及root节点的锁状态变量lockState,这是一个读写锁的状态。用于存放红黑树的root节点,并用读写锁lockState控制在写操作即将要调整树结构前,先让读线程完成读操作。从链表结构调整为红黑树时,table中索引下标存储的即为TreeBin。
(3)TreeNode:红黑树的节点,存放了父节点,左子节点,右子节点的引用,以及红黑节点标识。
(4)ForwardingNode:在调用transfer()方法期间,插入bucket头部的节点,主要用来标识在扩容时元素的移动状态,即是否在扩容时还有并发的插入节点,并保证该节点也能够移动到扩容后的表中。
(5)ReservationNode:占位节点,不存储任何信息,无实际用处,仅用于computeIfAbsent和compute方法中。

下面看几个重要的方法;

put方法:该方法调用putVal方法

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

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());// 计算key的hash值
    int binCount = 0;// 表示table中索引下标代表的链表或红黑树中的节点数量
    // 采用自旋方式,等待table第一次put初始化完成,或等待锁或等待扩容成功然后再插入
    for (Node<K,V>[] tab = table;;) {
        // f节点标识table中的索引节点,可能是链表的head,也可能是红黑树的head
        // n:table的长度,i:插入元素在table的索引下标,fh : head节点的hash值
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)// 第一次插入元素,先执行初始化
            tab = initTable();
        // 定位到的索引下标节点(head)为null,表示第一次在此索引插入,
        // 不加锁直接插入在head之后,在casTabAt中采用Unsafe的CAS操作,保证线程安全
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // head节点为ForwadingNode类型节点,表示table正在扩容,链表或红黑树也加入到帮助扩容操作中
        else if ((fh = f.hash) == MOVED) 
            tab = helpTransfer(tab, f);
        else {// 索引下标存在元素,且为普通Node节点,给head加锁后执行插入或更新
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {// 为普通链表节点,还记得之前定义的几种常量Hash值吗?
                        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;
                            // 插入新元素,每次插在单向链表的末尾,这点与Java7中不同(插在首部)
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key, value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {// head为树节点,按树的方式插入节点
                        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;
                        }
                    }
                }
            }
            // 链表节点树超过阈值8,将链表转换为红黑树结构
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 如果是插入新元素,则将链表或红黑树最新的节点数量加入到CounterCells中
    addCount(1L, binCount);
    return null;
}

putVal方法剖析:

  1. 采用自旋的方式,保证首次put时,当前线程或其他并发put的线程等待table初始化完成后再次重试插入。
  2. 采用自旋的方式,检查当前插入的元素在table中索引下标是否正在执行扩容,如果正在扩容,则帮助进行扩容,完成后,重试插入到新的table中。
  3. 插入的table索引下标不为空,则对链表或红黑树的head节点加synchronized锁,再插入或更新。访问入口是Head节点,其他线程访问head,在链表或红黑树插入或修改时必须等待synchronized释放。
  4. 插入后,如果发现链表节点数大于等于阈值8,调用treeifyBin方法,将链表转换为红黑树结构,提高读写性能。treeifyBin方法内部也同样采用synchronized方式保证线程安全性。
  5. 插入元素后,会将索引代表的链表或红黑树的最新节点数量更新到baseCount或CounterCell中。

其中用到了不少方法,下面我们逐个分析

spread方法:计算key的hash值,将key的hashCode的高16位也加入到计算中,避免平凡冲突,使其更加散列。如果仅用key的hashCode作为hash值,那么2,4之类的整形key值,只有低4位,那么很容易发生冲突。

  static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

initTable方法

 private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
             // sizeCtl小于0,表示table正在被其他线程执行初始化,
            // 放弃初始化竞争,自旋等待初始化完成
            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;
    }

初始化表比较简单:

1、自旋检查table是否完成初始化。
2、若发现sizeCtl值为负数,则放弃初始化的竞争,让其他正在初始化的线程完成初始化。
3、如果没有其他线程初始化,则用Unsafe.compareAndSwapInt更新sizeCtl的值为-1,表示table开始被当前线程执行初始化,其他线程禁止执行。
4、初始化:table设置为默认容量大小(元素并未初始化,只是划定了大小),sizeCtl设为下次扩容table的size大小。
5、初始化完成。

tabAt和casTabAt方法

这两个方法比较简单,都是利用Unsafe的CAS方法保证读取和替换的原子性,保证线程安全。为什么table本身明明用了volatile修饰,不直接用table[i]的方式取节点,而非要用Unsafe.getObjectVolatile方法的CAS操作取节点。虽然table本身是volatile类型,但仅仅是指table数组引用本身,而数组中每个元素并不是volatile类型,Unsafe.getObjectVolatile保证了每次从table中读取某个位置链表引用的时候都是从主内存中读取的,如果不用该方法,有可能读的是缓存中已有的该位置的旧数据。

扩容机制

JDK8最复杂的莫过于扩容机制,jdk8中,采用多线程扩容。整个扩容过程,通过CAS设置sizeCtl,transferIndex等变量协调多个线程进行并发扩容。

nextTable属性

   /**
     * The next table to use; non-null only while resizing.
       扩容时,将table中的元素迁移至nextTable . 扩容时非空
     */
     
    private transient volatile Node<K,V>[] nextTable;

sizeCtl属性

private transient volatile int sizeCtl;

多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。

 

private transient volatile int transferIndex;

扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)

1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。

2 扩容线程,在迁移数据之前,首先要将transferIndex右移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。每个扩容线程都会通过for循环+CAS的方式设置transferIndex,因此可以确保多线程扩容的并发安全。

换个角度,我们可以将待迁移的table数组,看成一个任务队列,transferIndex看成任务队列的头指针。而扩容线程,就是这个队列的消费者。扩容线程通过CAS设置transferIndex索引的过程,就是消费者从任务队列中获取任务的过程。为了性能考虑,我们当然不会每次只获取一个任务(hash桶),因此ConcurrentHashMap规定,每次至少要获取16个迁移任务(迁移16个hash桶,MIN_TRANSFER_STRIDE = 16)

cas设置transferIndex的源码如下:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        //计算每次迁移的node个数
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // 确保每次迁移的node个数不少于16个
        ...
        for (int i = 0, bound = 0;;) {
            ...
            //cas无锁算法设置 transferIndex = transferIndex - stride
            if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                  ...
                  ...
            }
            ...//省略迁移逻辑
        }
    }

ForwardingNode节点

  1. 标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕

  2. 关联了nextTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据

static final class ForwardingNode<K,V> extends Node<K,V> {
        final Node<K,V>[] nextTable;
        ForwardingNode(Node<K,V>[] tab) {
            //hash值为MOVED(-1)的节点就是ForwardingNode
            super(MOVED, null, null, null);
            this.nextTable = tab;
        }
        //通过此方法,访问被迁移到nextTable中的数据
        Node<K,V> find(int h, Object k) {
           ...
        }
    }

那么什么时候会扩容呢?

当前容量超过阈值

final V putVal(K key, V value, boolean onlyIfAbsent) {
        ...
        addCount(1L, binCount);
        ...
  }


private final void addCount(long x, int check) {
        ...
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //s>=sizeCtl 即容量达到扩容阈值,需要扩容
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
               //调用transfer()扩容
               ...
            }
        }
    }

当链表中元素个数超过默认设定(8个),当数组的大小还未超过64的时候,此时进行数组的扩容,如果超过则将链表转化成红黑树

final V putVal(K key, V value, boolean onlyIfAbsent) {
        ...
        if (binCount != 0) {
                    //链表中元素个数超过默认设定(8个)
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
        }
        ...
 }

private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n, sc;
        if (tab != null) {
            //数组的大小还未超过64
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                //扩容
                tryPresize(n << 1);
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
                //转换成红黑树
                ...
            }
        }
    }

当发现其他线程扩容时,帮其扩容

final V putVal(K key, V value, boolean onlyIfAbsent) {
      ...
       //f.hash == MOVED 表示为:ForwardingNode,说明其他线程正在扩容
       else if ((fh = f.hash) == MOVED)
           tab = helpTransfer(tab, f);
      ...
   }

扩容过程图

扩容线程A 以cas的方式修改transferindex=31-16=16 ,然后按照降序迁移table[31]--table[16]这个区间的hash桶

迁移hash桶时,会将桶内的链表或者红黑树,拆分成2份,将其插入nextTable[i]和nextTable[i+n](n是table数组的长度)。 迁移完毕的hash桶,会被设置成ForwardingNode节点,以此告知访问此桶的其他线程,此节点已经迁移完毕

此时线程2访问到了ForwardingNode节点,如果线程2执行的put或remove等写操作,那么就会先帮其扩容。如果线程2执行的是get等读方法,则会调用ForwardingNode的find方法,去nextTable里面查找相关元素。

如果准备加入扩容的线程,发现以下情况,放弃扩容,直接返回。发现transferIndex=0,即所有node均已分配.发现扩容线程已经达到最大扩容线程数

 

参考 https://blog.csdn.net/varyall/article/details/81283231

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值