1.内存模型
内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
在多处理器计算机系统中,每个处理器都有自己的高速缓存,且所有处理器都共享同一主存:

缓存一致性(Cache Coherence)是多处理器系统必须解决的问题,当多个处理器的运算任务都涉及同一块主存区域时,将可能导致各处理器的高速缓存数据不一致,这时如何同步回主存就需要缓存一致性协议来协调;
乱序执行(Out-Of-Order Execution)也是处理器的一项优化,该优化只保证最终结果的一致性,但不保证各语句的执行顺序与代码中的顺序一致。
Java内存模型和多处理器计算机系统有着很多相似之处:

内存一致性:每条线程都有自己的工作内存,里面保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,相似的,Java虚拟机也定义了一套内存访问协议来保证内存一致性;
指令重排序:对应于处理器乱序执行,Java虚拟机的即时编译器中也有着类似的优化,同样只保证最终结果的一致性。
2.volatile
volatile是Java虚拟机提供的最轻量的同步机制,但很难被正确的理解与使用,通过学习Java内存模型对volatile专门定义的一些特殊访问规则,或许会对理解volatile有一定帮助。
当一个变量被定义为volatile之后,将会具备两种特性:
可见性:当一条线程改变了该变量的值,新值对所有其他线程来说都是可以立即感知的;
禁止指令重排序优化:volatile变量赋值完成语句之后的指令不会被重排序到该赋值指令之前;
2.1可见性
大多数开发人员最容易误解的是volatile的可见性特性,会从volatile变量在各个线程中是一致的,错误的推导出基于volatile变量的运算也是并发安全的,下面一段代码对此进行了实验:
private static volatile int sum = 0;
public static void increase() {
++sum;
}
/**
* 使用10跟线程对sum各累加10000次,理论上返回值为100000
*/
public static void test() {
try {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; ++i) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; ++i) {
increase();
}
}
});
threads[i].start();
}
//等待所有线程执行完毕
for (int i = 0; i < 10; ++i) {
threads[i].join();
}
System.out.println("sum = " + sum);
} catch (Exception e) {
//ignore
}
}
public static void main(String[] args) {
//实验10次
for (int i = 0; i < 10; ++i) {
sum = 0;
test();
}
}
如果基于volatile变量的运算也是并发安全的,那输出值应该为10个100000,但事实上返回值均不为100000

这是因为,Java中的运算(++sum)并不是原子操作,我们可以使用反编译来验证:


通过Javap反编译测试代码可以看到,++sum操作产生了四条指令,getstatic指令把sum值取到栈顶,volatile关键字保证了此时该值是正确的,但是在执行后续iconst_1和iadd指令时,其他线程可能已经完成了一次++sum,此时栈顶的sum值其实已经过期了,当该线程执行putstatic后,其他线程抢先完成的++sum操作就被覆盖了,最终导致sum值小于期望的100000。
准确来说,使用Javap反编译来分析并发问题并不是严谨的,因为Javap反编译得到的一条字节码指令也未必是一个原子操作,比如字节码解释器可能需要运行多行代码才能实现该一条字节码的语义,如果是编译执行,一条字节码指令可能转化成多条本地机器码指令,有兴趣的同学可以自己试一下运行时反汇编(java反编译、反汇编)
理解了volatile的可见性之后,可以总结出使用场景:
1.运算结果并不依赖变量的当前值(如作为状态变量),或仅有单一线程修改变量值
//当shutdown()被调用时,所有线程的doWork()都会立刻停止
private volatile boolean isShutdown = false;
public void shutdown(){
isShutdown=true;
}
public void doWork(){
while(!isShutdown){
//anything
}
}
2.变量不与其他状态变量共同参与不变约束(因为对其中一个变量值赋值之后并在对其他变量赋值之前,不变约束可能失效)
2.2禁止指令重排序优化
在介绍volatile的禁止指令重排序优化之前,首先要了解一下线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)和内存屏障(Memory Barrier)
内存屏障:指令重排序时不能把内存屏障之后的指令重排序到内存屏障之前
线程内表现为串行的语义:普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值的操作顺序与程序代码中执行的顺序一致,这在单线程的方法执行过程中是无法感知的,因而表现为线程内串行
举个栗子:
int a = 1;
int b = 2;
a = a + 1;
a = a * 2;
int c = a;
以行号代表语句,虚拟机只会保证语句1、3、4顺序执行(四则运算规则),且语句5在语句4之后执行(c对a的依赖关系),至于语句2,它可能出现在任意位置(以指令重排序优化结果为准),因为它与a和c均没有依赖关系
指令重排序优化对单线程程序没有负面影响,但会干扰程序的并发执行,再举个栗子:
static private boolean initialized = false;
/**
* 初始化
*/
public static void init() {
//init task A
//init task B
//init task C
//init task D
//...
initialized = true;
}
/**
* 工作函数,必须在初始化完成后才能开始工作
*/
public static void doWork() {
try {
//等待初始化完成
while (!initialized) {
}
//do work A
//do work B
//do work C
//do work D
//...
} catch (Exception ign) {
//ignore
}
}
如果init()和doWork()运行在同一线程中,那么可以正常运行,但如果运行在不同线程中,由于指令重排序优化的存在,init()中的“ initialized = true; ”语句可能会被提前执行,这时并发运行在不同线程的doWork()就会出现并发问题,而如果将initialized定义为volatile变量则可以避免这类问题。
那么,volatile关键字到底是如何禁止指令重排序优化的呢,我们可以通过运行时反汇编来查看加入volatile关键字和未加入volatile关键字时所生成的汇编码的差别。
这是未加volatile关键字的汇编码:

这是加了volatile关键字的汇编码:

注释显示这是init函数的第12行代码产生的汇编码,那么第12行做了什么了呢?

这行代码对initialized变量进行了赋值,加了volatile关键字的赋值操作多了lock addl $0x0,(%rsp) 这一指令,addl $0x0,(%rsp)的含义是把ESP寄存器的值加0,显然是一个空操作,那关键点就是lock前缀了。
lock前缀的作用是使得本CPU的Cache、写入内存,同时会引起其他CPU或者内核无效化各自的Cache,该操作使得volatile变量的修改对其他CPU立即可见,同时,lock addl $0x0,(%rsp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完毕,这便形成了“指令重排序无法越过内存屏障”的效果,lock addl $0x0,(%rsp) 之前的指令无法被重排序到该指令之后,同样,之后的指令无法被重排序到该指令之前。
最后,我们再看一下Java内存模型对volatile变量定义的特殊规则:
每次使用volatile变量之前都必须从主内存刷新最新的值
每次修改完volatile变量都必须立刻同步到主内存中
volatile修饰的变量不会被指令重排序优化
可以看到,这三条规则与反汇编得出结论相一致。
3.先行发生原则
Java内存模型是围绕着如何在并发过程中处理原子性、可见性和有序性这三个特征来构建的,前面已经介绍过volatile关键字的“禁止指令重排序优化”来保证线程之间操作的有序性,另一个我们常用的保证有序性的手段是synchronized关键字;如果Java中的所有有序性都要靠这两个关键字来保证,那代码会很繁琐,但我们平时开发时并没有感觉到这一点,这是因为Java语言中又一个先行发生原则(happens-before)。
程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。
监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作。
volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作发生于后面对这个变量的读操作,这里的“后面”也指的是时间上的先后顺序。
线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每一个动作。
线程终止规则(Thread Termination Rule):线程中的每个操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。
线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断。
对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
先行发生原则最容易被误解的地方是,很多人从字面意思上理解这些规则,以“监视器锁定规(Monitor Lock Rule)”为例:
一个unlock操作先行发生于后面对同一个对象锁的lock操作,这句话不是字面意义上,针对同一个锁lock方法必须在unlock方法之后调用(虽然咋看起来没毛病,排他锁确实只能被一个线程lock住。。。),这句话的意思是,当线程A解锁了monitor,接着线程B锁住了该monitor,那么,线程A在解锁之前所有的写操作对线程B而言都是可见的。
同样,volatile变量规则(Volatile Variable Rule):
如果线程A写入了volatile变量v,接着线程B读取了v,那么,线程A写入v以及之前的所有写操作都对线程B可见(无效化了除线程A以外的所有线程的工作内存)。
其他规则就不一一赘述。
总之,一句话总结:
衡量并发安全问题时不要受到时间顺序的干扰,一个操作“时间上的先发生”并不意味着“先行发生”,一切必须以先行发生原则为准,如果不满足任何一条先行发生原则,那么就是线程不安全的(指令重排序会给你足够多的惊喜)。

9367

被折叠的 条评论
为什么被折叠?



