我们写一段代码,定义一个Visible类,类里声明一个布尔属性bool,然后启动两个线程来读写bool变量,来重现JMM规范中的永远不可见例子(官方文档见附录1文档第10页)。

✪ 2.1.2 环境
我们一共用了两个环境来跑上面这个测试代码:
环境1
设备:MacBook Pro (Retina, 15-inch, Mid 2015)
配置:Intel Core i7 2.2 GHz 4核 16G
OS: macOS Big Sur 11.2.3 (20D91)
JDK:
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)
环境2
设备:INSPUR x86
配置:Intel Xeon Platinum 8163 2.50GHz 多路96核 512G
容器:Pouch (ali docker) 4核 8G cpuset
OS: 3.10.0-327.ali2016.alios7.x86_64 (centos7)
JDK:
java version "1.8.0_112"
OpenJDK Runtime Environment (Alibaba 8.3.6_fp5) (build 1.8.0_112-b21)
OpenJDK 64-Bit Server VM (Alibaba 8.3.6_fp5) (build 25.112-b21, mixed mode)
✪ 2.1.3 测试结果
我们分别看下在两个环境下的测试结果:
环境1

可以看到,编译后执行了两次:
第1次—默认参数执行死循环,直到按键ctrl+z终止;
第2次—增加-Djava.compiler=NONE参数后正常打印"changed."并结束。
环境2

环境2上的结论和环境1完全相同。
到这里就有一个线索产生了,我们通过关闭JIT就会影响可见性。这里先不展开,我们继续分析。
✪ 2.1.4 进一步测试
我们修改一下代码,进一步测试下什么因素会导致bool变量可见。在循环体内插入下述任意一行代码,都会导致bool变量立即可见。

在循环体内执行if判断,当if为true时不可见,为false后立即可见。

到这里我们发现,看来除了JIT还有其他能影响可见性的因素。
2.2 Java代码执行过程
开始分析问题之前,我们先回顾一下一段java代码是怎么被执行的,然后再从上往下的分析下问题出在哪里。

✪ 2.2.1 编程语言
在编程语言层面,我们主要了解下理论和规范,在看下java提供的解决可见性的手段。
⍟ 2.2.1.1 JMM
我们看下JMM是如何定义描述java内存模型的。
java内存模型和线程规范(JSR-133 Java Memory Model and Thread Specification):

《深入理解Java虚拟机》中的简化版JMM:

ps:这里的“工作内存”不是指的线程栈,也千万不要认为“工作内存”在内存里,可以简单理解为寄存器。
⍟ 2.2.1.2 可见性
再看下Java对可见性的定义描述:

不同于理想情况下的可见性,Java对可见性的定义是有前提的:A行为的结果可以被B行为观测到,则A、B必须存在 happen before 关系。
⍟ 2.2.1.3 happens-before
happens-before的定义:
java内存模型和线程规范(JSR-133 Java Memory Model and Thread Specification)。

⍟ 2.2.1.4 内存屏障
如果需要在没有happen before关系的时候可见,就要用到内存屏障了。在聊屏障之前还是先了解下屏障到底是在解决什么样的问题。
重排序
是在不违反JMM规范的前提下,JIT编译器进行的优化重排序,和CPU为了指令流水线(Instruction pipelining)的高效利用,进行的乱序执行(out-of-order execution)。发生在几个阶段:

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句 的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行(out-of-order execution)。
as-if-serial
意思是,不管怎么重排序,单线程程序的执行结果不能被改变。
屏障
保证顺序的手段,可以想象为一个栅栏,以栅栏为界,之前的和之后的相互不能越界。

volatile关键字的本质
禁止编译重排序;
插入运行时内存屏障。
volatile内存屏障的实现方式:
在每个volatile写操作的前面插入一个StoreStore屏障;
在每个volatile写操作的后面插入一个StoreLoad屏障;
在每个volatile读操作的前面插入一个LoadLoad屏障;
在每个volatile读操作的后面插入一个LoadStore屏障。
(如果有性能要求的场景,可以不在变量声明时使用volatile,而是在使用时按需选择是否用volatile,使用Unsafe、jdk9 VarHandle可以做到这点,它们的底层实现是相同的)


在x86架构下,只有StoreLoad在运行时有作用,具体实现是StoreLoad时立即write-back store buffer,且发送MESI修改消息。
OpenJDK linux x86内存屏障实现
可以看到,在x86架构下,内存屏障CPU实现指令为lock(前缀)。

⍟ 2.2.1.5 volatile之外
piggybacking间接触发的屏障
可以发现,所有的解决可见性的手段,最终都基于CPU指令lock。

java.util.concurrent包里的很多类就利用了这一点(ArrayBlockingQueue、LinkedBlockingQueue),没有使用volatile,通过ReentrantLock、cas等间接触发可见。
灰色的不是JMM规范。比如线程上下文切换,硬件层面保证了硬中断后的可见性,操作系统层面保证了前后两个时间片执行线程不同时的可见性,但排除这两种情况的其它情况(线程上下文切换但下一个线程还是当前线程)取决于是否使用lock,如parkNanos底层就使用了cas所以总是可见,sleep、yield未使用lock则取决于是否发生调度换出。
JMM对Sleep、Yield没有happen-before关系的说明

✪ 2.2.2 字节码
在字节码层面,因为编译器的优化也会导致加剧可见性问题,比如Android的提前编译器。
JVM规范(The Java Virtual Machine Specification)中定义了class的JVM指令集,这是一种基于栈的指令集。在android平台,class还需经过class [打包]-> dex [安装]-> 机器码才能交由ART执行。dex和机器码属于基于寄存器的指令集。
编译器
检查、脱糖(泛型、自动装拆箱、变长参数、内部类、enum,foreach、Lambda、 try-with-resource)、插入式注解处理器、条件编译等能力。编译后的class文件语言无关,让JVM多语言、多实现成为可能。
提前编译器(Ahead-of-time, AOT)
针对Android平台的ART,在用户安装APP时会进行的dex -> 机器码的编译行为。但从Android7.0开始,为了解决安装耗时过长问题,这一行为会在系统空闲时后台自动进行,或在运行时使用即时编译器进行。
✪ 2.2.3 虚拟机
在虚拟机层面,运行时JIT的优化也是导致可见性问题的原因。
解释器(interpreter)

即时编译器(Just-in-time, JIT)
Client compiler (C1);
Server compiler (C2);
条件:1.方法的调用次数;2.循环回边的执行次数;
激进预测性优化(Aggressive speculative optimization)。

图:JITWatch
上图是问题2对应代码的JIT优化结果,可以看到test比对的数据是寄存器中的,eax是寄存器的一个区域,程序进入到这个循环后并不会更新寄存器了,加上寄存器随线程切换而保存恢复,所以当test为true时这里是一个死循环(寄存器结果可以看下面的Intel示意图)。
✪ 2.2.4 操作系统
在操作系统层面,我们需要关心线程调度对可见性的处理。
pthread
POSIX Threads,一个线程API规范,几乎在所有unix like(unix、linux、maxOS)系统上默认支持。
https://en.wikipedia.org/wiki/POSIX_Threads
context switch
上下文切换会保存当前线程状态,主要是保存寄存器、堆栈指针、程序计数器、刷新转换后备缓冲区(TLB)、下一个进程的页表。
CFS Scheduler
不同的操作系统都有自己的Scheduler实现,以linux的Scheduler为例,又支持多种调度策略(Scheduling policies)。
time-sharing scheduling policy
SCHED_OTHER、SCHED_IDLE、SCHED_BATCH同属于分时调度策略,也称为普通调度策略,是linux的默认调度策略。
real-time scheduling policy
又分为SCHED_FIFO、SCHED_RR,实时线程的调度优先级总是高于普通线程,一般用于系统调用。
deadline scheduling policy
SCHED_DEADLINE,该任务应该在该相对时间前停止运行,运行时具有最高优先级。
上下文切换
上下文切换时,如果当前进程与下一个进程不是同一个进程,则插入内存屏障,包括用户态内核态切换。见下图linux内核代码/kernel/sched/core.c 函数__schedule (bool preempt)。


https://elixir.bootlin.com/linux/latest/source/kernel/sched/core.c#L3324
✪ 2.2.5 硬件
在硬件层面,我们需要了解硬件是如何设计并导致可见性问题的,以及硬件对问题的解决方案。
Intel 内核流水线功能图

寄存器









