Java 源码 —— ConcurrentHashMap 读为什么不加锁

Wesley13
• 阅读 665

最近在复习准备一些面试,偶尔会抽些零碎时间逛一下之前关注的公众号,看看有没有哪些被自己遗漏的地方,或者是一些能补充知识的文章,比如前几天看到一篇讲MySQL插入100W条数据要花多久的文章,点进去看到了久违的 PreparedStatement,顺便复习了一下,原来数据库不仅能识别纯的SQL还可以识别执行计划,PreparedStatement 利用了连接池的缓存机制将SQL转成执行计划保存起来,通过参数占位符化(用?占位)的方式将纯SQL转换成可以重用的执行计划,同一个执行计划可以对参数相同但值不同的SQL进行复用,从而降低了数据库方面编译SQL的开销而提高了性能。后面又想到如果用 Mybatis 框架的话,它内部有没有类似的机制来冲用执行计划呢?

虽然 Mybatis 也是一个轻量级 ORM 框架,大部分时间只要在 xml 里处理 SQL,但还是因为不够熟,之前也一直没实战过这个框架,就没去深究它的这一层原理。

而本文现在要讨论的则是 Java 开发者应该(可能?或许?)都非常熟悉的一个 API,它是 ConcurrentHashMap。源于昨天看到了一篇《为什么 ConcurrentHashMap 读操作不加锁》,先说说文章里面总结的比较好的一些点:

1)volatile 的作用:

  a. volatile 修饰的变量的操作都直接与主内存打交道,JMM 的工作内存在 volatile 面前无效

  b. volatile 变量的更新,JVM 会向所有CPU发出一条禁用高级缓存的指令,迫使 CPU 对变量的读写都是直接操作主内存

  c. volatile 语义中有禁止指令重排序的作用,这在分析程序运行次序时是一条重要的规则

2)与其他并发工具hashtable、用Collections.synchronizedMap()包装的hashmap进行的性能比较中优胜,因为 get 不加锁

3)对 CHM 内部数组成员 table 的 volatile 分析:用来保证扩容(包含初始化)时 table 对其他线程可见

4)分析了 Node 节点的 val、next 字段的 volatile,对这些字段在“更新”操作的分析还算正确

然后就是我觉得的这篇对 CHM 分析不足的地方,或者说是对 Java 并发分析没有考虑周到的地方。以下是我的个人观点。

首先,在 Java 中分析并发,避不可及的会谈到几个概念:happens-before,JMM,volatile,锁,CAS。为什么要把 happens-before 放在最前面???因为它真的是可以将 happens-before、JMM、volatile 三者联系起来的一个主导者。其次它也可以和 锁 直接联系成为另一种并发情况的分析依据。可以说,happens-before 是在讨论 Java 并发分析快要进入钻牛角尖阶段时的大杀器。为什么?因为现代 CPU 动辄每秒上百亿次的运算速度,就算是 1毫秒内可以执行的指令数量也是千万级别,在并发环境下到底谁先执行谁后执行是一个很容易钻牛角尖的点。

而 Java 在 JSR-133 使用 happens-before 概念来定义两个操作之间执行顺序(周志明《深入理解 Java 虚拟机 第二版》中有对 happens-before、JMM、volatile 较详细的讲解)。

贴几个本文会用到的 happens-before 关系:

(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

(4)传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

第(3)点的意思是,volatile 域被写了之后,如果后面有读操作的话,所有的读都能看到这次更新。即使 volatile 变量初始化后没有任何写,那么之后所有的读都能读到初始化的值(可以不是 0),而不是 JVM 申明变量时给的“零值”。 第(1)点在没有指令重排序的情况下是成立的,在有 volatile 的情况下这一点能够保证。第(2)点说的是重量级锁 synchronized,相信都会分析了。第(4)也很容易理解,而且通常是 happens-before 分析并发套路中的重要一环。

先给一个 happens-before 分析并发的例子:

Java 源码 —— ConcurrentHashMap 读为什么不加锁

假设线程 A 执行 writer()方法之后,线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程建立的 happens-before 关系可以分为3类:
1)根据程序次序规则,1 happens-before 2;3 happens-before 4。
2)根据 volatile 规则,2 happens-before 3。
3)根据happens-before的传递性规则,1 happens-before 4。

上述happens-before关系的图形化表现形式如下图:

Java 源码 —— ConcurrentHashMap 读为什么不加锁

 在上面这个例子中,分析时假设了线程 A writer() 方法执行完后 线程 B reader() 方法才执行,在分析 CHM get() 方法时这个粒度可以更小一些,可以具体到某几行代码上。让我们来看一下 CHM 是如何保证 get() 方法中节点的可见性的吧。

使用 happens-before 来分析 ConcurrentHashMap get() 为什么不加锁:

我们假设 CHM 初始 size 是 16(默认),并且 table 没有被初始化,那么因为 table 被 volatile 修饰,当它被初始化到 16个节点都是 Null 的 Node[] 数组上后,后续线程能看到 table 初始化后的数组。table 的初始化使用 CAS(sizeCtl) 字段来控制只有一条线程执行,这是 table 在初始化阶段的线程安全以及可见性保证。

 在 table 初始化后,假设有一条线程调用 put(),N 条线程调用 get(),有哪些线程能看到(get到) put 进去的 val?上两段代码,然后像上面那个例子一样来分析CHM的并发 put、get。

get() 代码段:

1         if ((tab = table) != null && (n = tab.length) > 0 &&          
2             (e = tabAt(tab, (n - 1) & h)) != null) {                  // 1

put() 代码段:

1             else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
2                 if (casTabAt(tab, i, null,
3                              new Node<K,V>(hash, key, value, null)))        // 2
4                     break;                   // no lock when adding to empty bin
5             }

代码段后面有两个标注 // 1、// 2。要让线程 get() 能看到 new Node() 的值,必须要有 2 happens-before 1 关系,也就是说 new Node() 要对线程可见必须在 2 happens-before 1 的前提下,即当调用 put() 线程的代码执行了标注 2 位置的代码之后后续线程执行 get() 才能看到 Node 从 null 变为 new Node() 的值;如果 1 happens-before 2,get 线程就不能看见 new Node()。在 Node 节点变得可见之后,线程调用 get() 读取 volatile val 则会直接读取主内存的值,因此可以 get 到。

注意到标注 2 代码下面有一行注释“ no lock when adding to empty bin ”,意思是 table[i] 从 null 变为 new Node() 不需要加锁。

PS:这里注释的意思感觉是默认了 CAS 也能保证可见性,也就是它操作的是主内存。不仅是这里,挺多用了 CAS 的代码都默认了其直接操作主内存来保证可见性的效果。之前 level 较低,没有考虑到这层,应该就是这么实现的不过后面找资料把这个窟窿补上吧。

综上:在 table 刚刚被初始化的阶段,所有 Node 节点都还是 null,只有当 put 代码将 new Node() 设置到对应数组位置上时该数组位置的 Node 节点才对后面执行 get() 的线程可见,在此之前进行 get() 的线程只能看到 null 节点。

而当 table 数组上的节点被初始化了之后,后面的操作再访问 Node 的 val 和 next 时,由于 volatile 的作用保证了 get 访问 val 和 next 节点的可见性,在分享的文章中也有较对应的描述,本文就不再赘述了。

小结

在分析 Java 并发时,需要以 happens-before 原则为基准,结合 JMM、volatile、锁、CAS 等机制来分析程序运行时代码在什么时候才具有“可见性”。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Docker 部署SpringBoot项目不香吗?
  公众号改版后文章乱序推荐,希望你可以点击上方“Java进阶架构师”,点击右上角,将我们设为★“星标”!这样才不会错过每日进阶架构文章呀。  !(http://dingyue.ws.126.net/2020/0920/b00fbfc7j00qgy5xy002kd200qo00hsg00it00cj.jpg)  2
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这