引入
假如我现在有这样一个需求,有多个线程对一个数值一直进行某个操作(比如+1操作),另有一个线程需要在一定的时机,终止这些线程的操作,如何简单实现。
private static int count = 0;
private static boolean start = true;
public static void main(String[] args) {
for (int i = 0; i < 4; i++) {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("开启" + Thread.currentThread().getName());
while (start) {
count++;
}
System.out.println("线程关闭了");
}
}, "操作线程" + i).start();
}
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println(count);
} catch (InterruptedException e) {
e.printStackTrace();
}
start = false;
System.out.println("线程正在关闭。。。");
}
}, "控制线程").start();
}
上面代码乍一看确实没有什么问题,然而当你拿去运行一下就会发现,程序并没有被“控制线程”中断,而是卡在这里不动了,如图:
怎么回事?我的“控制线程”已经把开关(start)关了啊,为什么操作线程还没有停止?如果你实在想不明白呢,那就去看看JMM吧,那里或许能找到你想要的答案。
说白了,控制线程对变量start的改变,在操作线程中不可见。怎么解决这个不可见的问题呢?有一个很简单的办法,用volatile关键字修饰start,你可以去试一试是否可行。
volatile是怎么做到的呢?接下来对其进行简单讲解。
内存语义
1.被volatile修饰的变量值对所有线程都是可见的,换句话说,一个变量被volatile修饰后,如有线程将其值改变,其它线程就会立刻感知。
2.禁止指令重排序优化,赋值操作后会有一道内存屏障,防止指令重排导致的多线程下结果的不可知性。
保证可见性
至于什么是可见性,出门右拐——JMM。
对于上面我们提到的那个例子,就是volatile保证可见性的体现。我们知道JMM中定义的内存模型将内存分为了主内存和工作内存,也就是在多线程的情况下,一个普通变量可能会有很多份存储,而其中一块存储中的变量值修改,是不一定会使得其它也随之修改的,要解决这一问题,volatile就借用了操作系统的缓存一致性协议。
使用缓存一致性协议,使CPU缓存中的数据与计算机系统中的数据保持一致,也使得工作内存中的数据与主内存中的数据保持一致。
保证一定的有序性
至于什么是有序性,出门右拐——JMM。
了解过JMM应该知道代码并不一定会按照我们编写时的顺序运行,系统会进行指令重排优化,但在多线程的情况下,很多时候是不希望系统进行指令重排的,为了达到这一目的就出现了“内存屏障”的概念。
什么是内存屏障呢?内存屏障(Memory Barrier)也称内存栅栏,它是CPU中的一个指令,主要有保证操作的执行顺序和变量的可见性的作用。如果在指令间插入一个内存屏障,那么其它指令将无法和这条指令进行重排序。如果CPU缓存中的变量值改变,内存屏障也会将其强制刷出到内存中,保证其它线程可以得到最新版本的变量值。
由此可见,volatile关键字主要就是通过内存屏障来实现其内存语义的,下面我们举一个例子看一下volatile在解决指令重排所起到的作用。
private static int count = 0;
private static boolean start = true;
private static int a, b, x, y;
private static Set<String> results = new HashSet<>();
public static void main(String[] args) throws InterruptedException {
while (start) {
start = false;
a = 0; b = 0; x = 0; y = 0;
Thread t1 = new Thread(
new Runnable() {
@Override
public void run() {
x = a;
b = 1;
}
}
);
Thread t2 = new Thread(
new Runnable() {
@Override
public void run() {
y = b;
a = 1;
}
}
);
t1.start(); t2.start();
t1.join(); t2.join();
String result = x + "," + y;
count++;
results.add(result);
if (x == 0 || y == 0) {
start = true;
} else {
System.out.println(count);
}
}
System.out.println(results);
如果说上面这个例子不会发生指令重排,那么这个循环一定是一个死循环,因为按照代码的顺序,x和y必有一个为0。
但实际上,你运行一下上面的代码,你会发现程序最终跳出了循环,结果如下:
但如果声明变量a, b, x, y时,加上关键字volatile,那么这个循环就真的变成死循环了,因为volatile强行保证了指令的有序性。
不能保证原子性
至于什么是原子性,出门右拐——JMM。
volatile不能保证原子性,举一个例子就能看出来:
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread t = new Thread(
new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
count++;
}
}
}
);
t.start();
}
Thread.sleep(2000);
System.out.println(count);
}
按照我们正常思维,这段代码执行结果count的值应该是10000,但实际运行就会发现,即便是加了volatile关键字,count的值也只能小于或等于10000,比如:
为什么会这样呢?volatile关键字虽然可以保证可见性,但终究count++这个操作并非原子操作,可以将其分为3步(取出count值到缓存,执行加1操作,将count值写回内存),在第一步取count值确实可以保证所取到的值一定是正确的,但第二步进行加1时,可能其它线程已经改变了count的值,此时操作栈顶中的值就变成了过期的数据,但当前线程仍旧会执行第三步,将这个已过期的值通过putstatic指令同步回主内存。(这是从《深入理解Java虚拟机》中读出来的意思,我总是觉得count值变成了过期数据,当前线程也应该能嗅探到count已经失效,应该不会同步回主内存,这个还需要以后深入研究)
volatile的内存屏障
Java的内存屏障一般有4种:
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
注意:在大多数处理器的实现中,StoreLoad屏障是个万能屏障,兼具其它三种内存屏障的功能,所以它的开销也是最大的。
volatile是怎样使用这些内存屏障的呢?
1.在每个volatile写操作的前面插入一个StoreStore屏障,禁止了前面的普通写操作和后面的volatile写操作重排序;
2.在每个volatile写操作的后面插入一个StoreLoad屏障,禁止了前面的volatile写操作和后面的volatile读/写操作重排序;
3.在每个volatile读操作的后面插入一个LoadLoad屏障,禁止了前面的volatile读操作和后面的普通读操作重排序;
4在每个volatile读操作的后面插入一个LoadStore屏障,禁止了前面的volatile读操作和后面的普通写操作重排序。
来张图更清晰一些:
上面说的是一般情况,在有的时候JMM可能还会做一些优化,省略加一些不必要的屏障。
上面说的是volatile为我们加内存屏障,那么我们能不能以代码的方式自己加内存屏障呢?的确是有的,可以借助Unsafe类来添加内存屏障,比如Unsafe.getUnsafe().loadFence()。
总线风暴
说了这么多,有人也许会想,volatile能禁止指令重排,还能保证可见性,比普通变量好多了,我能不能所有的变量都使用volatile修饰啊?
volatile虽好,但是也不可乱用,因为其嗅探机制(总线嗅探各个缓存中变量值有没有被改变,以保证其他线程的可见性)非常消耗总线资源,有可能引起总线风暴。
volatile引起总线风暴是怎么一回事?由于volatile的缓存一致性协议需要不断的从主内存嗅探和cas不断循环无效交互导致总线带宽达到峰值。
所以有很多时候,往往使用synchronize或者Lock来代替volatile,尽管性能可能有所下降。
【个人笔记】转载请注明出处!