最近在复习准备一些面试,偶尔会抽些零碎时间逛一下之前关注的公众号,看看有没有哪些被自己遗漏的地方,或者是一些能补充知识的文章,比如前几天看到一篇讲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 分析并发的例子:
假设线程 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关系的图形化表现形式如下图:
在上面这个例子中,分析时假设了线程 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 等机制来分析程序运行时代码在什么时候才具有“可见性”。