这个面试中常考的数据结构,你掌握了吗?

点击上方“服务端思维”,选择“设为星标”

回复”669“获取独家整理的精选资料集

回复”加群“加入全国服务端高端社群「后端圈」

10129f36fc4132a420e02fe90c719326.png

作者 | 银文杰

出品 | 博文视点Broadview

不知道大家在面试时是否会被问到什么样的哈希数据结构可以保证线程安全?

很多小伙伴可能知道是ConcurrentHashMap,却对其没有太多了解,本文就带大家先来看一下ConcurrentHashMap集合中的size()方法是如何保证准确获取集合大小的。

我们可以思考一个最简单的场景,即调用者如何取得当前ConcurrentHashMap集合中的数据总量?

可能有一些读者会说,直接调用ConcurrentHashMap集合提供的size()方法即可;或者有一些读者会更进一步说,ConcurrentHashMap集合中提供了一个baseCount属性,每当完成添加操作时,ConcurrentHashMap集合都会在addCount方法中利用保证原子性的操作来更新baseCount属性的值。

是的,以上两种描述都没错,但是需要考虑一个前提,那就是ConcurrentHashMap集合工作在高并发场景下,可能随时有多个线程在进行读写操作。

在这样的前提下,再回头看以上两种描述可能就存在问题了。

首先,通过size()方法取得的当前集合中数据总量的值,很可能不是一个精确值,也就是在调用size()方法还未得到返回值时,集合中的数据总量可能就已经发生了变化。

然后,在addCount方法中确实可以利用保证原子性的操作来更新baseCount属性的值,但是若baseCount属性值更新失败了该怎么办?

最直观的处理思路是,一旦baseCount属性值更新失败,就进行重试。但很明显baseCount属性是一个被多线程共享操作的属性,如果采用典型的CAS设计思路——操作失败就重试,那么多线程操作下该值的更新大概率会失败且需要进行多次重试,从而形成并发场景下ConcurrentHashMap集合添加操作的明显性能瓶颈,如下图所示。

e1f4d4288275a30ca535ad66a6557775.png

为了解决这个问题,最新版本的ConcurrentHashMap集合的主要设计思路是基于线程稳定不变的“探针”功能,设置多个不同的“计数槽”,保证大多数线程在更新计数值时不会产生原子操作冲突。

该部分的设计是ConcurrentHashMap集合中值得读者学习和在实际工作中借鉴的设计思路之一。

(1) ConcurrentHashMap集合中与数量计数有关的属性结构

ConcurrentHashMap集合中与数量计数有关的属性结构主要有3个,如下所示: 

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {
// ......
  // 基础计数器,在没有并发竞争的场景下,记录目前集合中的数据总量
  private transient volatile long baseCount;
  // 表示目前CounterCells数量计数器是否由于某种原因无法工作。0表示可以工作;1表示不能工作
  // 当CounterCells数量计数器被扩容或者被初始化时,该值为1;其他情况下该值为0
  private transient volatile int cellsBusy;
  // 在高并发情况下,集合使用一个CounterCell数组,基于线程“探针”进行数据量的记录
  private transient volatile CounterCell[] counterCells;
  // ......
}
(2) CounterCell并发计数器的工作过程概要

ConcurrentHashMap集合的瞬时数据量等于baseCount与CounterCells数组中所有索引位上已记录的值之和。

我们可以看一下ConcurrentHashMap集合的size()方法的源代码:

public int size() {
  long n = sumCount();
  return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}




// 该方法是统计集合中数据总量的方法
final long sumCount() {
  // 统计方式是以baseCount属性值为基础的
  // 然后累加counterCells每一个索引位上的数值
  CounterCell[] cs = counterCells;
  long sum = baseCount;
  if (cs != null) {
    for (CounterCell c : cs) {
  if (c != null) {sum += c.value;}
}
  }
  return sum;
}

如果ConcurrentHashMap集合工作在一个并发不高的环境中,ConcurrentHashMap集合会在新节点添加到集合中后,直接通过保证原子性的compareAndSetLong方法来完成baseCount计数器的数值增加工作;如果当前集合工作在并发较高的场景中(依据是通过compareAndSetLong方法来更新baseCount计数器时失败),就初始化counterCells数组,并在后续的处理过程中,在counterCells数组特定的索引位增加计数值。

这个过程就是addCount方法中第一个逻辑步骤所希望表达的:

// 该方法主要是为数据量计数器增加数值,并基于当前的数据量确认是否需要进行扩容
private final void addCount(long x, int check) {
  // 这是该方法中的第一个逻辑步骤
  // 用来分场景执行数量计数器+1的操作
  // 如果当前集合中已经有并发计数器,或者更新baseCount计数器失败
  // 就进入这个逻辑过程,将数值增加的情况记录到新的或者已有的counterCells数组中
  if ((cs = counterCells) != null ||
  !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell c; long v; int m;
// 该变量说明这是否是一个默认的无竞争环境
    boolean uncontended = true;
// 程序代码试图在counterCells数组对象正确、counterCells特定索引位上数据正确
// 的前提下直接累加更新这个索引位上的value数据
// 如果条件不满足或者更新失败,
// 则进入fullAddCount方法,进行一个完整的counterCells初始化和数值累加过程
    if (cs == null || (m = cs.length - 1) < 0 || (c = cs[ThreadLocalRandom.getProbe() & m]) == null
   || !(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
      fullAddCount(x, uncontended);
      return;
    }
    if (check <= 1) { return; }
    s = sumCount();
  }
  
  // 检查是否进行扩容和数据迁移的逻辑过程
  if (check >= 0) {
    // ...... 这里省略了一些代码
  }
}

这里有几个关键点需要进行说明。

  • ConcurrentHashMap集合使用counterCells数组而不是baseCount属性记录集合中的键值对数据量,前提条件就是通过compareAndSetLong方法进行baseCount属性的操作时,操作失败。

  • ConcurrentHashMap集合对counterCells数组进行计数增加和扩容操作的处理过程,被放置在fullAddCount方法中。后者也负责counterCells数组的初始化,它将counterCells数组的初始化长度设置为2。counterCells数组的每一个索引位只能通过保证原子性操作的compareAndSetLong方法来进行写处理。

  • 特别注意addCount方法中使用的ThreadLocalRandom类,方法中使用了该工具类提供的getProbe()方法来获取当前线程的“探针”值。这是一个没有开放给最终程序员使用的功能,该功能将为调用者返回一个在当前线程中稳定不变的且全进程唯一的hash值。这个hash值可以在ThreadLocalRandom对象调用advanceProbe方法后发生变化。而当计算某个线程的计数值应该存放在counterCells数组的哪一个索引位时,使用的就是“探针”值和counterCells数组长度通过“与”运算取余数的方式完成的。

  • 虽然counterCells数组的初始化长度只有2,也就是说瞬时只允许两个操作线程对counterCells数组中的不同索引位上的计数值进行成功修改,但该数组是可以被扩容的。每次扩容按照counterCells数组原始容量的2倍进行(即容量始终为2的倍数),且容量的最大上限为当前进程可使用的CPU内核个数(N)——超过当前进程可使用的CPU内核个数的counterCells数组大小是没有意义的,因为不会有那么多同时并发的线程数量。

  • counterCells数组扩容的条件很微妙,它并不是以某个既定的阈值为扩容标准的,而是“以当前进行ConcurrentHashMap集合操作的多个线程,在当前既定容量的counterCells数组的支持下,是否仍然存在抢占冲突的情况”来决定是否进行counterCells数组的扩容的。简单来说,就是执行compareAndSetLong(c, CELLVALUE, v = c.value, v + x)语句时返回了false的场景。

  • 为保证所有操作线程都能了解到counterCells数组目前正在扩容,扩容时(还有counterCells数组初始化时或者counterCells数组某一个索引位初始化时)cellsBusy属性会被标记为1,下图可以描述counterCells数组工作的多个核心要点。

4d0c6e9e6c2594fddf75c751b847132a.png

最后,counterCells数组某个索引位上的数据量计数值是由哪个线程执行增加操作的,实际上对于ConcurrentHashMap集合来说并不重要,重要的是保证数值可以正确记录。

也就是说,上一次Thread1完成数据添加后,可能在counterCells数组的0号索引位上进行计数值增加(+1),但是下一次Thread1完成数据添加后,又可能在counterCells数组的3号索引位上进行数值增加(+1)。

以上Thread线程变化所使用的counterCells数组索引位的情况确实是会发生的。

最典型的情况就是当前Thread线程使用原子操作更新某个索引位的计数值失败,但是又因为达到了counterCells数组的扩容上限而无法进行扩容,这时系统就会调用Thread线程对应的ThreadLocalRandom工具类的advanceProbe方法,以改变线程的“探针”值,最终达到更改其使用的counterCells数组索引位的目的。

— 本文结束 —

e2f62b08381d4741037441bdb683e4dc.gif

● 漫谈设计模式在 Spring 框架中的良好实践

● 颠覆微服务认知:深入思考微服务的七个主流观点

● 人人都是 API 设计者

● 一文讲透微服务下如何保证事务的一致性

● 要黑盒测试微服务内部服务间调用,我该如何实现?

09b750cb7176773bc1228332961122af.png

关注我,回复 「加群」 加入各种主题讨论群。

对「服务端思维」有期待,请在文末点个在看

喜欢这篇文章,欢迎转发、分享朋友圈

ef0545bfbc00d6299be0614d0eff4126.png

在看点这里

9144a4143243dc98fd66bdb814f99e1d.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值