ConcurrentHashMap 是如何保证线程安全的 源码讲解,讲的很好

面试官:ConcurrentHashMap 是如何保证线程安全的

https://mp.weixin.qq.com/s?__biz=MzkwMDIxNDUyMQ==&mid=2247485954&idx=2&sn=de2dd33ad870952eacd81013bbb09d89&chksm=c0463194f731b8828d63fa66607fd672b30208bc19959c20cc136b9c75cc47c62bc2218ccc5e&scene=132#wechat_redirect

1 前言

阅读此篇文章,你需要有以下知识基础

  • Java内存模型,可见性问题
  • CAS
  • HashMap底层原理

我们知道,在日常开发中使用的HashMap是线程不安全的,

  • 而线程安全类HashTable只是简单的在方法上加锁实现线程安全,效率低下,

所以在线程安全的环境下我们通常会使用ConcurrentHashMap,但是又为何需要学习ConcurrentHashMap?用不就完事了?我认为学习其源码有两个好处:

  • 更灵活的运用ConcurrentHashMap
  • 欣赏并发编程大师Doug Lea的作品,源码中有很多值得我们学习的并发思想,要意识到,线程安全不仅仅只是加锁

我抛出以下问题:

  • ConcurrentHashMap是怎么做到线程安全的?

    • get方法如何线程安全地获取key、value?
    • put方法如何线程安全地设置key、value?
    • size方法如果线程安全地 获取容器容量?
    • 底层数据 结构扩容时 如果保证线程安全?
    • 初始化 数据结构时 如果保证线程安全?
  • ConcurrentHashMap并发效率是如何提高的?

    • 和加锁相比较,为什么它比HashTable效率高?

接下来,带着问题来继续看下去,欣赏并发大师精妙绝伦的并发艺术作品(以下讨论基于JDK1.8)

2 相关概念

Amdahl定律

此节定律描述均来自《Java并发编程实战》一书

假设 F是必须被串行执行的部分,N代表处理器数量,

  • Speedup代表加速比,可以简单理解为CPU使用率

图片

此公式告诉我们,

  • 当N趋近无限大,加速比最大趋近于1/F,
  • 假设我们的程序有50%的部分需要串行执行,就算处理器数量无限多,最高的加速比只能是2(20%的使用率),
  • 如果程序中仅有10%的部分需要串行执行,最高的加速比可以达到9.2(92%的使用率),但我们的程序或多或少都一定会有串行执行的部分,所以F不可能为0,
  • 所以,就算有无限多的CPU,加速比也不可能达到10(100%的使用率),下面给一张图来表示串行执行部分占比不同对利用率的影响:

img

由此我们可以看出,程序中的可伸缩性(提升外部资源即可提升并发性能的比率)是由程序中串行执行部分所影响的,而常见的串行执行有

  • 锁竞争(上下文切换消耗、等待、串行)等等,这给了我们一个启发,
  • 可以通过 减少锁竞争 来优化并发性能,
  • 而ConcurrentHashMap则使用了锁分段(减小锁范围)、
  • CAS(乐观锁,减小上下文切换开销,无阻塞)等等技术,下面来具体看看吧

3 初始化数据结构时的线程安全

HashMap的底层数据结构这里简单带过一下,不做过多赘述:

img

大致是以一个Node对象数组来存放数据,Hash冲突时会形成Node链表,

  • 在链表长度超过8,
  • Node数组超过64时会将链表结构转换为红黑树,

Node对象:

static class Node<K,V> implements Map.Entry<K,V> {
    
    //hash
  final int hash;
    
    //key
  final K key;
    
    //val
  volatile V val;
    
    //下个指针
  volatile Node<K,V> next;
  ...
}

值得注意的是,value和next指针使用了volatile来保证其可见性。

在JDK1.8中,初始化ConcurrentHashMap的时候

  • 这个Node[]数组是还未初始化的,
  • 会等到第一次put方法调用时才初始化:
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;
            //判断Node数组为空
            if (tab == null || (n = tab.length) == 0)
                //初始化Node数组
                tab = initTable();
          ...
}

此时是会有并发问题的,如果多个线程同时调用initTable初始化Node数组怎么办?看看大师是如何处理的:

private final Node<K,V>[] initTable() {
  Node<K,V>[] tab; int sc;
  //每次循环都获取最新的Node数组引用
  while ((tab = table) == null || tab.length == 0) {
    //sizeCtl是一个标记位,若为-1也就是小于0,代表有线程在进行初始化工作了
    if ((sc = sizeCtl) < 0)
      //让出CPU时间片
      Thread.yield(); // lost initialization race; just spin
    //CAS操作,将本实例的sizeCtl变量设置为-1
    else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
      //如果CAS操作成功了,代表本线程将负责初始化工作
      try {
        //再检查一遍数组是否为空
        if ((tab = table) == null || tab.length == 0) {
          //在初始化Map时,sizeCtl代表数组大小,默认16
          //所以此时n默认为16
          int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
          @SuppressWarnings("unchecked")
          //Node数组
          Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
          //将其赋值给table变量
          table = tab = nt;
          //通过位运算,n减去n二进制右移2位,相当于乘以0.75
          //例如16经过运算为12,与乘0.75一样,只不过位运算更快
          sc = n - (n >>> 2);
        }
      } finally {
        //将计算后的sc(12)直接赋值给sizeCtl,表示达到12长度就扩容
        //由于这里只会有一个线程在执行,直接赋值即可,没有线程安全问题
        //只需要保证可见性
        sizeCtl = sc;
      }
      break;
    }
  }
  return tab;
}

table变量使用了volatile来保证每次获取到的都是最新写入的值:

transient volatile Node<K,V>[] table;

总结

就算有多个线程同时进行put操作,在初始化数组时使用了

  • 乐观锁CAS操作 来决定到底是哪个线程有资格进行初始化,其他线程均只能等待。

用到的并发技巧:

  • volatile变量(sizeCtl):它是一个标记位,用来告诉其他线程这个坑位有没有人在,其线程间的可见性由volatile保证。
  • CAS操作:CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设置成功

4 put操作的线程安全

直接看代码:

final V putVal(K key, V value, boolean onlyIfAbsent) {
  if (key == null || value == null) throw new NullPointerException();
  //对key的hashCode进行散列
  int hash = spread(key.hashCode());
  int binCount = 0;
  //一个无限循环,直到put操作完成后退出循环
  for (Node<K,V>[] tab = table;;) {
    Node<K,V> f; int n, i, fh;
    //当Node数组为空时进行初始化
    if (tab == null || (n = tab.length) == 0)
      tab = initTable();
    //Unsafe类volatile的方式取出hashCode散列后通过与运算得出的Node数组下标值对应的Node对象
    //此时的Node对象若为空,则代表还未有线程对此Node进行插入操作
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      //直接CAS方式插入数据
      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)
      //帮助扩容
      tab = helpTransfer(tab, f);
    else {
      V oldVal = null;
      //对Node对象进行加锁
      synchronized (f) {
        //二次确认此Node对象还是原来的那一个
        if (tabAt(tab, i) == f) {
          if (fh >= 0) {
            binCount = 1;
            //无限循环,直到完成put
            for (Node<K,V> e = f;; ++binCount) {
              K ek;
              //和HashMap一样,先比较hash,再比较equals
              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) {
                //和链表头Node节点不冲突,就将其初始化为新Node作为上一个Node节点的next
                //形成链表结构
                pred.next = new Node<K,V>(hash, key,
                                          value, null);
                break;
              }
            }
          }
          ...
}

值得关注的是tabAt(tab, i)方法,其使用Unsafe类volatile的操作volatile式地查看值,保证每次获取到的值都是最新的:

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);
}

虽然上面的table变量加了volatile,但也只能保证其引用的可见性,并不能确保其数组中的对象是否是最新的,所以需要Unsafe类volatile式地拿到最新的Node。

总结

img

由于其减小了锁的粒度,若Hash完美不冲突的情况下,

  • 可同时支持n个线程同时put操作,n为Node数组大小,
    • 在默认大小16下,可以支持最大同时16个线程无竞争同时操作且线程安全。
  • 当hash冲突严重时,Node链表越来越长,将导致严重的锁竞争,
    • 此时会进行扩容,将Node进行再散列,下面会介绍扩容的线程安全性。

总结一下用到的并发技巧:

  • 减小锁粒度:将Node链表的 头节点 作为锁,
    • 若在默认大小16情况下,将有16把锁,大大减小了锁竞争(上下文切换),就像开头所说,将串行的部分最大化缩小,在理想情况下线程的put操作都为并行操作。
    • 同时直接锁住头节点,保证了线程安全
  • Unsafe的getObjectVolatile方法:此方法确保获取到的值为最新。

5扩容操作的线程安全

在扩容时,ConcurrentHashMap支持 多线程并发扩容,在扩容过程中同时支持get查数据,若有线程put数据,还会帮助一起扩容,这种无阻塞算法,将并行最大化的设计,堪称一绝。

先来看看扩容代码实现:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
  int n = tab.length, stride;
  //根据机器CPU核心数来计算,一条线程负责Node数组中多长的迁移量
  if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    //本线程分到的迁移量
    //假设为16(默认也为16)
    stride = MIN_TRANSFER_STRIDE; // subdivide range
  //nextTab若为空代表线程是第一个进行迁移的
  //初始化迁移后的新Node数组
  if (nextTab == null) {            // initiating
    try {
      @SuppressWarnings("unchecked")
      //这里n为旧数组长度,左移一位相当于乘以2
      //例如原数组长度16,新数组长度则为32
      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变量为新数组
    nextTable = nextTab;
    //假设为16
    transferIndex = n;
  }
  //假设为32
  int nextn = nextTab.length;
  //标示Node对象,此对象的hash变量为-1
  //在get或者put时若遇到此Node,则可以知道当前Node正在迁移
  //传入nextTab对象
  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;
    while (advance) {
      int nextIndex, nextBound;
      //i为当前正在处理的Node数组下标,每次处理一个Node节点就会自减1
      if (--i >= bound || finishing)
        advance = false;
      //假设nextIndex=16
      else if ((nextIndex = transferIndex) <= 0) {
        i = -1;
        advance = false;
      }
      //由以上假设,nextBound就为0
      //且将nextIndex设置为0
      else if (U.compareAndSwapInt
               (this, TRANSFERINDEX, nextIndex,
                nextBound = (nextIndex > stride ?
                             nextIndex - stride : 0))) {
        //bound=0
        bound = nextBound;
        //i=16-1=15
        i = nextIndex - 1;
        advance = false;
      }
    }
    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
      }
    }
    //此时i=15,取出Node数组下标为15的那个Node,若为空则不需要迁移
    //直接设置占位标示,代表此Node已处理完成
    else if ((f = tabAt(tab, i)) == null)
      advance = casTabAt(tab, i, null, fwd);
    //检测此Node的hash是否为MOVED,MOVED是一个常量-1,也就是上面说的占位Node的hash
    //如果是占位Node,证明此节点已经处理过了,跳过i=15的处理,继续循环
    else if ((fh = f.hash) == MOVED)
      advance = true; // already processed
    else {
      //锁住这个Node
      synchronized (f) {
        //确认Node是原先的Node
        if (tabAt(tab, i) == f) {
          //ln为lowNode,低位Node,hn为highNode,高位Node
          //这两个概念下面以图来说明
          Node<K,V> ln, hn;
          if (fh >= 0) {
            //此时fh与原来Node数组长度进行与运算
            //如果高X位为0,此时runBit=0
            //如果高X位为1,此时runBit=1
            int runBit = fh & n;
            Node<K,V> lastRun = f;
            for (Node<K,V> p = f.next; p != null; p = p.next) {
              //这里的Node,都是同一Node链表中的Node对象
              int b = p.hash & n;
              if (b != runBit) {
                runBit = b;
                lastRun = p;
              }
            }
            //正如上面所说,runBit=0,表示此Node为低位Node
            if (runBit == 0) {
              ln = lastRun;
              hn = null;
            }
            else {
              //Node为高位Node
              hn = lastRun;
              ln = null;
            }
            for (Node<K,V> p = f; p != lastRun; p = p.next) {
              int ph = p.hash; K pk = p.key; V pv = p.val;
              //若hash和n与运算为0,证明为低位Node,原理同上
              if ((ph & n) == 0)
                ln = new Node<K,V>(ph, pk, pv, ln);
              //这里将高位Node与地位Node都各自组成了两个链表
              else
                hn = new Node<K,V>(ph, pk, pv, hn);
            }
            //将低位Node设置到新Node数组中,下标为原来的位置
            setTabAt(nextTab, i, ln);
            //将高位Node设置到新Node数组中,下标为原来的位置加上原Node数组长度
            setTabAt(nextTab, i + n, hn);
            //将此Node设置为占位Node,代表处理完成
            setTabAt(tab, i, fwd);
            //继续循环
            advance = true;
          }
          ....
        }
      }
    }
  }
}

这里说一下迁移时为什么要分一个ln(低位Node)、hn(高位Node),首先说一个现象:

我们知道,在put值的时候,首先会计算hash值,再散列到指定的Node数组下标中:

//根据key的hashCode再散列
int hash = spread(key.hashCode());

//使用(n - 1) & hash 运算,定位Node数组中下标值
(f = tabAt(tab, i = (n - 1) & hash);

其中n为Node数组长度,这里假设为16。

假设有一个key进来,它的散列之后的hash=9,那么它的下标值是多少呢?

  • (16 - 1)和 9 进行与运算 -> 0000 1111 和 0000 1001 结果还是 0000 1001 = 9

假设Node数组需要扩容,我们知道,扩容是将数组长度增加两倍,也就是32,那么下标值会是多少呢?

  • (32 - 1)和 9 进行与运算 -> 0001 1111 和 0000 1001 结果还是9

此时,我们把散列之后的hash换成20,那么会有怎样的变化呢?

  • (16 - 1)和 20 进行与运算 -> 0000 1111 和 0001 0100 结果是 0000 0100 = 4
  • (32 - 1)和 20 进行与运算 -> 0001 1111 和 0001 0100 结果是 0001 0100 = 20

此时细心的读者应该可以发现,如果hash在高X位为1,(X为数组长度的二进制-1的最高位),则扩容时是需要变换在Node数组中的索引值的,不然就hash不到,丢失数据,所以这里在迁移的时候将高X位为1的Node分类为hn,将高X位为0的Node分类为ln。

回到代码中:

for (Node<K,V> p = f; p != lastRun; p = p.next) {
  int ph = p.hash; 
  K pk = p.key; 
  V pv = p.val;
  if ((ph & n) == 0)
    ln = new Node<K,V>(ph, pk, pv, ln);
  else
    hn = new Node<K,V>(ph, pk, pv, hn);
}

这个操作将高低位组成了两条链表结构,由下图所示:

  • 学不动了
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值