前言
网上讲volatile并配上示例代码的文章、教程很多。本文相对于他们,最大的进步是讲了为啥不能使用Thread.sleep()、Thread.yield()、System.out.println()这些方法,为这一点纠结了半个月。
1. volatile关键字的作用
volatile在java多线程编程中用来保证可见性和有序性。先简略讲一下。
- 可见性:
- 对volatile修饰的变量的修改会立即刷新回内存,并通知其他缓存失效,因此在读取volatile类型的变量时总是拿到最新的值;此处要注意不是加了volatile修饰的变量就不被缓存了,而是这个缓存有独特的机制保证同步(下文细讲)。
- 有序性:
- 对volatile修饰的变量的写操作与该操作前后的其他内存操作不会一起
重排序
,前面的归前面的,后面的归后面的。
2. 用一个例子来说明有序性
先讲一下什么是指令重排序。因为指令执行一般比IO快,所以在不影响的情况下,等待IO过程中可以先去执行其他指令。
- java代码编译成jvm字节码的时候,编译器会根据自己的优化规则,将代码语句执顺序打乱。
- 在虚拟机层面,虚拟机会按照自己的规则将程序编写顺序打乱,即在时间顺序上,写在后面的代码可能会先执行,而写在前面的代码可能会后执行,以尽可能充分地利用CPU;
- 操作系统会根据自己的规则重排序指令;
- 在硬件层面,CPU会将接收到的一批指令按照规则重排序。
volatile就是告诉编译、执行等等一系列过程,对volatile变量的操作不准进行重排序(根据规则,如果该操作不影响最终结果的正确性还是可以进行重排序的,比如vloatile修饰的变量在一段代码整个执行过程中只有读操作,这个变量用不用volatile修饰都无所谓)。
下面是一个重排序的示例。
在你真正理解之前,对示例一个字都不要动,尤其是不要加上Thread.sleep()、Thread.yield()、System.out.println()这些方法,看完本文你会明白为什么。
package com.example.demo;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class TestMain {
static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Set set = new HashSet<>();
Map res = new HashMap<>();
while (true) {
x = 0;
y = 0;
res.clear();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
int a = y; // 语句1
x = 1; // 语句2
res.put("a", a);
}
});
Thread b = new Thread(new Runnable() {
@Override
public void run() {
int b = x; // 语句3
y = 1; // 语句4
res.put("b", b);
}
});
a.start();
b.start();
a.join();
b.join();
set.add("a=" + res.get("a") + ",b=" + res.get("b"));
System.out.println(set);
}
}
}
启动这个程序,多运行一段时间,就会出现以下输出结果:
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]
按照我们通常理解的程序执行顺序来看:
- 如果按照语句1/语句3 -> 语句2/语句4的顺序执行,就会得到a=0,b=0;
- 如果按照语句1 -> 语句2 -> 语句3/语句4的顺序执行,就会得到a=0,b=1;
- 如果按照语句3 -> 语句4 -> 语句1/语句2的顺序执行,就会得到a=1,b=0;
其中->代表前面的语句必须在后面的语句直线执行,/代表两个语句执行顺序可以对调或者同时执行。
还有第四种结果a=1,b=1。要想得到a=1,b=1,就必须是语句2/语句4 -> 语句1/语句3这种顺序,明显违背了我们看到的代码语句顺序。这里就是指令经过了重排序。
我们在x、y变量上加上volatile关键字,重试一次,
static volatile int x = 0, y = 0;
无论程序运行多久,都只能得到3种结果:
[a=0,b=0, a=1,b=0, a=0,b=1]
就像章节1中所说,volatile阻止指令重排序有四层含义:
-
- 阻止在语言层面的Java编译器重排序;
-
- 阻止JVM为了做性能优化做的指令重排序;
-
- 阻止操作系统重排序;
-
- 阻止CPU指令重排。
这四层重排序是互不相关的。所以即使是在单核心CPU的机器上,volatile仍然是必要的。当然操作系统、CPU用的并不是volatile指令,而是jvm根据操作系统的不同将volatile转换为操作系统自定义的阻止重排序指令发送给操作系统,同样,操作系统根据不同的CPU转换为对应的阻止重排序指令发送给CPU。
4. 关于禁止重排序
禁止重排序主要是使用了内存屏障,内存屏障有四种:
- LoadLoad屏障
- StoreStore屏障
- LoadStore屏障
- StoreLoad屏障
比如loadload屏障,加在Load1和Load2两个原子操作之间 ,保证在Load2及后续的读操作读取之前,Load1已经读取。其他同理。
内存屏障在volatile变量上的使用:
- 在每个volatile写入之前,插入一个StoreStore,写入之后,插入一个StoreLoad;
- 在每个volatile读取之前,插入LoadLoad,之后插入LoadStore。
更详细的关于内存屏障的讲解可以自己去看博客。
4. 用一个例子来说明可见性
还是那句话,在你真正理解之前,对示例一个字都不要动,尤其是不要加上Thread.sleep()、Thread.yield()、System.out.println()这些方法,看完本文你会明白为什么。
package com.example.demo;
public class TestExample2 {
private static boolean ready;
private static class ReaderThread extends Thread {
@Override
public void run() {
System.out.println("start");
while(!ready) {
}
System.out.println("end");
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(5000);
ready = true;
}
}
我们在子线程启动后,将ready变量设置为true,目的是结束子线程的循环,从而结束子线程。但是实际启动后程序一直无法结束。
同样的,在ready变量上加上volatile关键字,重试一次,
static volatile boolean ready;
程序运行大概五秒后结束。即主线程对ready的修改,子线程看到了。
这就是volatile关键字的可见性作用。
6. 缓存一致性协议
以Intel多核CPU经典的MESI协议为例。在MESI协议中,每个Cache line(缓存块,同时读取同时失效同时更新,为什么这样一块一块,参考局部性原理)有4个状态,它们分别是:
状态 | 描述 |
---|---|
M(Modified) | 数据有效,但是被修改了,和内存中的数据不一致,只存在于本Cache中 |
E(Exclusive) | 数据有效,和内存中的数据一致,只存在于本Cache中 |
S(Shared) | 数据有效,和内存中的数据一致,存在于很多Cache中 |
I(Invalid) | 数据无效 |
M(Modified)和E(Exclusive)状态的Cache line,数据是独有的,不同点在于M状态的数据是dirty的(和内存的不一致),E状态的数据是clean的(和内存的一致)。
S(Shared)状态的Cache line,数据和其他Core的Cache共享。只有clean的数据才能被多个Cache共享。
当前状态 | 事件 | 行为 | 下一个状态 |
---|---|---|---|
I(Invalid) | Local Read | 1. 如果其它Cache没有这份数据,本Cache从内存中取数据,Cache line状态变成E; 2. 如果其它Cache有这份数据,且状态为M,则将数据更新到内存,本Cache再从内存中取数据,2个Cache 的Cache line状态都变成S; 3. 如果其它Cache有这份数据,且状态为E,本Cache从内存中取数据,这些Cache的Cache line状态都变成S; 4. 如果其它Cache有这份数据,且状态为S,本Cache从内存中取数据,并把自己的Cache line状态设置为S。 |
E/S |
I(Invalid) | Local Write | 1. 从内存中取数据,在Cache中修改,状态变成M; 2. 如果其它Cache有这份数据,且状态为M,则要先将数据更新到内存; 3. 如果其它Cache有这份数据,则其它Cache的Cache line状态变成I |
M |
I(Invalid) | Remote Read | Invalid,别的核的操作与它无关 | I |
I(Invalid) | Remote Write | Invalid,别的核的操作与它无关 | I |
E(Exclusive) | Local Read | 从Cache中取数据,Cache line状态不变 | E |
E(Exclusive) | Local Write | 修改Cache中的数据,Cache line状态变成M | M |
E(Exclusive) | Remote Read | 数据和其它核共用,Cache line都变成S | S |
E(Exclusive) | Remote Write | 数据被修改,本Cache line状态变成I | I |
S(Shared) | Local Read | 从Cache中取数据,Cache line状态不变 | S |
S(Shared) | Local Write | 修改Cache中的数据,Cache line状态变成M,其它核共享的Cache line状态变成I | M |
S(Shared) | Remote Read | 状态不变 | S |
S(Shared) | Remote Write | 数据被修改,本Cache line不能再使用,状态变成I | I |
M(Modified) | Local Read | 从Cache中取数据,Cache line状态不变 | M |
M(Modified) | Local Write | 修改Cache中的数据,Cache line状态不变 | M |
M(Modified) | Remote Read | 数据被写到内存中,使其它核能使用到最新的数据,Cache line状态变成S | S |
M(Modified) | Remote Write | 数据被写到内存中,使其它核能使用到最新的数据,由于其它核会修改数据,Cache line状态变成I | I |
有两点说明:
- MESI协议并不是对所有变量都默认开启的。比如java代码在编译成CPU可执行的指令时,volatile修饰的变量就会启用MESI协议;
- MESI协议只是Intel的,AMD的就不是MESI协议,甚至有的CPU就没有缓存一致性协议,直接用的总线锁,甚至总线锁也没有,对你的volatile视而不见。
7. 从一开始就说的一个问题。为什么不要加上Thread.sleep()、Thread.yield()、System.out.println()这些方法
7.1 从表面上看,这几个方法造成了缓存的刷新
我们修改一下3中的例子,去掉volatile关键字,加上Thread.yield()。
package com.example.demo;
public class TestExample2 {
private static boolean ready;
private static class ReaderThread extends Thread {
@Override
public void run() {
System.out.println("start");
while(!ready) {
//System.out.println(ready);
Thread.yield();
}
System.out.println("end");
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(5000);
ready = true;
}
}
程序执行大概5秒就结束了。说明变量ready在CPU的缓存与内存做了数据同步,即使没有使用volatile修饰该变量。
我们看不到Thread.yield()的源码,但是可以先看看System.out.println()的源码
public void println(boolean x) {
synchronized (this) {
print(x);
newLine();
}
}
可以看到其中使用了Synchronized(this)锁。锁释放、获取的时候会把当前线程的共享变量刷新到主内存。所以锁具有volatile具有的所有能立。并且Synchronized()在可见性和一致性保证之外,还多了原子性的保证。
我们看不到Thread.yield()的源码,但是官方文档在Thread.yield()和Thread.sleep()是这么说的:
the compiler does not have to flush writes cached in registers out to shared memory before a call to Thread.sleep or Thread.yield, nor does the compiler have to reload values cached in registers after a call to Thread.sleep or Thread.yield.
并且还举了一个例子:
while (!this.done)
Thread.sleep(1000);
The compiler is free to read the field this.done just once, and reuse the cached value in each execution of the loop. This would mean that the loop would never terminate, even if another thread changed the value of this.done.
显然,Thread.yield()和Thread.sleep()并没有刷新缓存的效果。但是我们发现实验结果和这个解释不一样。
7.2 最终原因的解释
不但上面几个方法会让程序结束,如果我们new一个数组,例如:
Object[] a=new Object[10000];
或者发送一个http请求:
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet("http://www.baidu.com");
CloseableHttpResponse response = null;
try {
response = httpClient.execute(httpGet);
HttpEntity responseEntity = response.getEntity();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (httpClient != null) {
httpClient.close();
}
if (response != null) {
response.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
这个程序都会结束。
这些方法的一个共同点就是,这些方法耗时,但是CPU计算占用很少。不是在做内存分配,就是在做IO,虽然占用了CPU时间片,但是CPU比较闲。
原来,通过JVM大神们每天丧(干)心(得)病(漂)狂(亮)的努力,JVM针对现在的硬件水平已经做了很大程度的优化,基本上很大程度的保障了工作内存和主内存的及时同步,相当于默认使用了volitale。但只是最大程度。在CPU资源一直被占用的时候,工作内存与主内存中间的同步,也就是变量的可见性就不会那么及时!