java架构之路(多线程)JMM和volatile关键字(二)
貌似两个多月没写博客,不知道年前这段时间都去忙了什么。
好久以前写过一次和volatile相关的博客,感觉没写的那么深入吧,这次我们继续说我们的volatile关键字。
复习:
先来简单的复习一遍以前写过的东西,上次我们说了内存一致性协议M(修改)E(独占)S(共享)I(失效)四种状态,还有我们并发编程的三大特性原子性、一致性和可见性。再就是简单的提到了我们的volatile关键字,他可以保证我们的可见性,也就是说被volatile关键字修饰的变量如果产生了变化,可以马上刷到主存当中去。我们接下来看一下我们这次博客的内容吧。
线程:
何为线程呢?这也是我们面试当中经常问到的。按照官方的说法是:现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作 系统就会创建一个Java进程。现代操作系统调度CPU的最小单元是线程。比如我们启动QQ,就是我们启动了一个进程,我们发起了QQ语音,这个动作就是一个线程。
在这里多提一句的就是线程分为内核级线程和用户级线程,我们在java虚拟机内的线程一般都为用户级线程,也就是由我们的jvm虚拟机来调用我们的CPU来申请时间片来完成我们的线程操作的。而我们的内核级线程是由我们的系统来调度CPU来完成的,为了保证安全性,一般的线程都是由虚拟机来控制的。
上下文切换:
上面我们说过,线程是由我们的虚拟机去CPU来申请时间片来完成我们的操作的,但是不一定马上执行完成,这时就产生了上下文切换。大致就是这样的:
线程A没有运行完成,但是时间片已经结束了,我们需要挂起我们的线程A,CPU该去执行线程B了,运行完线程B,才能继续运行我们的线程A,这时就涉及到一个上下文的切换,我们把这个暂时挂起到再次运行的过程,可以理解为上下文切换(最简单的理解方式)。
可见性:
用volatile关键字修饰过的变量,可以保证可见性,也就是volatile变量被修改了,会立即刷到主内存内,让其他线程感知到变量已经修改,我们来看一个事例
public class VolatileVisibilitySample { private volatile boolean initFlag = false; public void refresh(){ this.initFlag = true; String threadname = Thread.currentThread().getName(); System.out.println("线程:"+threadname+":修改共享变量initFlag"); } public void load(){ String threadname = Thread.currentThread().getName(); int i = 0; while (!initFlag){ } System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i); } public static void main(String[] args){ VolatileVisibilitySample sample = new VolatileVisibilitySample(); Thread threadA = new Thread(()->{ sample.refresh(); },"threadA"); Thread threadB = new Thread(()->{ sample.load(); },"threadB"); threadB.start(); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } threadA.start(); } }
我们想创建一个全局的由volatile修饰的boolean变量,refresh方法是修改我们的全局变量,load方法是无限循环去检查我们全局volatile修饰过的变量,我们开启两个线程,开始运行,我们会看到如下结果。
也就是说,我们的变量被修改以后,我们的另外一个线程会感知到我们的变量已经发生了改变,也就是我们的可行性,立即刷回主内存。
有序性:
说到有序性,不得不提到几个知识点,指令重排,as-if-serial语义和happens-before 原则。
指令重排:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
指令重排一般发生在class翻译为字节码文件和字节码文件被CPU执行这两个阶段。
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因 为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被 编译器和处理器重排序。
happens-before 原则内容如下
1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
5. 传递性A先于B ,B先于C,那么A必然先于C
6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法。
上一段代码看看指令重排的问题。
public class VolatileReOrderSample { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for (; ; ) { i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(new Runnable() { public void run() { a = 1; x = b; } }); Thread t2 = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if (x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
我们来分析一下上面的代码
情况1:假设我们的线程1开始执行,线程2还没开始,这时a = 1 ,x = b = 0,因为b的初始值是0,然后开始执行线程2,b = 1,y = a = 1,得到结论x = 0 ,y = 1.
情况2:假设线程1开始执行,将a赋值为1,开始执行线程2,b赋值为1,并且y = a = 1,这时继续运行线程1,x = b = 1,得到结论 x = 1,y = 1.
情况3:线程2优先执行,这时b = 1,y = a = 0,然后运行线程1,a = 1,x = b = 1,得到结论 x = 1,y = 0。
不管怎么谁先谁后,我们都是只有这三种答案,不会产生x = 0且y = 0的情况,我们在下面写出来了x = 0 且 y = 0 跳出循环。我们来测试一下。
运行到第72874次结果了0,0的情况产生了,也就是说,我们t1中的a = 1;x = b;和t2中的b = 1;y = a;代码发生了改变,只有变为
Thread t1 = new Thread(new Runnable() { public void run() { x = b; a = 1; } }); Thread t2 = new Thread(new Runnable() { public void run() { y = a; b = 1; } });
这种情况才可以产生0,0的情况,我们可以把代码改为
private static volatile int a = 0, b = 0;
继续来测试,我们发现无论我们运行多久都不会发生我们的指令重排现象,也就是说我们volatile关键字可以保证我们的有序性
至少我这里570万次还没有发生0,0的情况。
就是我上次博客给予的表格
Required barriers | 2nd operation | |||
1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store | StoreLoad | StoreStore |
我们来分析一下代码
线程1的。
public void run() { a = 1; x = b; }
a = 1;是将a这个变量赋值为1,因为a被volatile修饰过了,我们成为volatile写,就是对应表格的Volatile Store,接下来我们来看第二步,x = b,字面意思是将b的值赋值给x,但是这步操作不是一个原子操作,其中包含了两个步骤,先取得变量b,被volatile修饰过,就成为volatile load,然后将b的值赋给x,x没有被volatile修饰,成为普通写。也就是说,这两行代码做了三个动作,分别是Volatile Store,volatile load和Store写读写,查表格我们看到volatile修饰的变量Volatile Store,volatile load之间是给予了StoreLoad这样的屏障,是不允许指令重排的,所以达到了有序性的目的。
扩展:
我们再来看一个方法,不用volatile修饰也可以防止指令重排,因为上面我们说过,volatile可以保证有序性,就是增加内存屏障,防止了指令重排,我们可以采用手动加屏障的方式也可以阻止指令重排。我们来看一下事例。
public class VolatileReOrderSample { private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for (;;){ i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(new Runnable() { public void run() { a = 1; UnsafeInstance.reflectGetUnsafe().storeFence(); x = b; } }); Thread t2 = new Thread(new Runnable() { public void run() { b = 1; UnsafeInstance.reflectGetUnsafe().storeFence(); y = a; } }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
storeFence就是一个有java底层来提供的内存屏障,有兴趣的可以自己去看一下unsafe类,一共有三个屏障
UnsafeInstance.reflectGetUnsafe().storeFence();//写屏障 UnsafeInstance.reflectGetUnsafe().loadFence();//读屏障 UnsafeInstance.reflectGetUnsafe().fullFence();//读写屏障
通过unsafe的反射来调用,涉及安全问题,jvm是不允许直接调用的。手写单例模式时在超高并发记得加volatile修饰,不然产生指令重排,会造成空对象的行为。后面我会科普这个玩意。
最进弄了一个公众号,小菜技术,欢迎大家的加入