大纲
1.并发编程的三大特性
2.JMM如何保证并发编程的三大特性
3.volatile关键字理解
4.volatile使用场景
5.volatile和synchronized
1.并发编程的三大特性
原子性:指一组操作要么全部执行成功(没有异常中断),要么都不执行。
可见性:指当一个线程对共享变量进行了修改,另外的线程可以立即看到修改之后的结果。
有序性:指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期的优化,导致代码的执行顺序未必就是开发者编写的代码的顺序,但是它会保证程序的最终运算结果是编码时所期望的那样。
有序性举例1:x++与y=20不管他们的执行顺序是怎样的,最后的执行结果肯定的都是x=11,y=20;
int x = 10; int y = 0; x++; y = 20;
有序性举例2:绝对不会出现y=x+1优先于x++执行的情况。
int x = 10; int y = 0; x++; y = x++;
在单线程的情况下,无论怎样的指令重排最终都会保证执行结果与代码顺序执行的结果一致,但是多线程情况下则无法保证有序性。
//initialized用于判断context是否已经被加载过 private boolean initialized = false; private Context context; public Context load(){ if(!initialized){ context = loadContext();//#1 initialized = true; //#2 } return context; }
在多线程情况下发生了指令重排,#2的执行被重排序到#1前面时,第一个线程进来判断 initialized = false 执行context的加载,然后先执行#2将initialized = true,此时第二个线程进来判断initialized = true直接返回还未被加载成功的context,就会导致后续的处理出现问题。可以将initialized变量用volatile关键字修饰禁止指令重排来解决(private volatile boolean initialized = false;)
2.JMM如何保证并发编程的三大特性
JVM采用内存模型的机制来屏蔽各个平台与操作系统之间的内存访问差异,以实现让Java程序在各种平台下达到一致的内存访问效果。如C语言中的整型变量在某些平台占用2个字节,在另外的平台可能占用4个字节,但是Java在任何平台都是占用4个字节。
JMM与原子性: Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性。我们大致可以认为基本数据类型、引用类型的访问读写是具有原子性的,如果我们需要更大范围的原子性保证,则可以通过monitorenter和monitorexit隐式调用lock、unlock来保证:
- 可以通过synchronized关键字、JUC中的显示锁Lock来使得某些代码片段具有原子性。
- 使用JUC下的原子封装类型比如AtomicInteger来实现自增操作的原子性。
package com.sFace.test; public class AtomTest { public static void main(String[] args) { } public static void atomTest(){ int x = 10; //原子操作 } public static void unAtomTest1(){ int x = 10; int y = x; //非原子操作 } public static void unAtomTest2(){ int x = 10; x++; //非原子操作 } public static void unAtomTest3(){ int x = 10; x = x + 1; //非原子操作 } }
对应字节码如下:
public class com.sFace.test.AtomTest { public com.sFace.test.AtomTest(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: return public static void atomTest(); Code: 0: bipush 10 2: istore_0 3: return public static void unAtomTest1(); Code: 0: bipush 10 2: istore_0 3: iload_0 4: istore_1 5: return public static void unAtomTest2(); Code: 0: bipush 10 2: istore_0 3: iinc 0, 1 6: return public static void unAtomTest3(); Code: 0: bipush 10 2: istore_0 3: iload_0 4: iconst_1 5: iadd 6: istore_0 7: return }
volatile关键字不具备保证原子性的语义。
JMM与可见性:在多线程环境下,如果某个线程首次读取共享变量,则首先到主存中获取该变量,然后存入到自己的工作内存中,后续只需要操作工作内存中的变量即可。如果对该变量进行了修改,则先将新值写入工作内存中,然后再刷新到主内存中。但是什么时候刷新到主内存是不确定的。Java提供如下三种方式来保证:
-
- 使用关键字volatile,当一个变量被volatile关键字修饰时,对于该共享资源的读操作会直接在主内存中进行(也会缓存到工作内存中,只是当其他线程对该共享资源进行修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主存中再次获取),对于共享资源的写操作则先要修改工作内存,修改结束后立即刷新到主存中。
- 通过synchronized关键字保证可见性,synchronized能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还保证在锁释放之前,会将对变量的修改刷新到主存中。
- 通过JUC提供的显示锁Lock保证可见性,Lock的lock()方法能够保证在同一时刻只有一个线程获得锁,然后执行同步方法,并且还保证在锁释放(Lock 的unlock方法)之前,会将对变量的修改刷新到主存中。
volatile关键字具备保证可见性的语义。
JMM与有序性:多线程情况下,指令重排会影响到程序的正确性,Java提供了三种保证有序性的方式:
-
- 使用volatile关键字来保证有序性;
- 使用synchronized关键字来保证有序性;
- 使用JUC提供的显示锁Lock来保证有序性;
Java内存模型具备天生的有序性规则,不需要任何同步手段就能够保证有序性,这个规则称为Happens-before原则,具体有如下几种:
-
-
- 程序次序规则:一个线程内,最终的结果(虚拟机对程序代码的指令进行重排)与代码顺序执行的结果一致。
- 锁定规则:一个锁如果是锁定状态,则必须先对其进行释放操作,才能继续执行锁定操作。
- volatile规则:禁止JVM和处理器对volatile关键字修饰的指令重排,但对volatile前后无依赖关系的指令则可以随便排序。
- 传递规则:如果A操作先于B操作,B操作先于C操作,那么A操作一定先于C操作。
- 线程启动规则:Thread对象的start()方法先发生于对该线程的其他任何操作。
- 线程中断规则:对线程执行interrupt()方法肯定要优先于捕捉到中断信号,也就是说如果线程收到了中断信号,则此前势必调用了interrupt()方法。
- 线程终结规则:线程中所有的操作都要先行发生于线程的终止检测,也就是说线程的任务执行、逻辑单元执行肯定要发生于线程死亡之前。
- 对象终结规则:一个对象的初始化的完成要先行发生于finalize()方法,先有生后有死。
-
volatile关键字具备保证顺序性的语义。
3.volatile关键字理解
volatile关键字只能修饰类变量和实例变量,不能修饰类常量、实例常量、方法参数、局部变量。被volatile关键字修饰的类变量和实例变量具有如下两种语义:
-
- 保证了不同线程对共享变量操作时的可见性,也就是说当一个线程修改volatile关键字修饰的变量时,另外一个线程会立即知道修改后的值。
- 禁止指令重排。
volatile关键字保证可见性;
volatile关键字保证顺序性;
volatile关键字不保证原子性,比如假设i=10,现在执行i++自增操作:
package com.sFace.test; public class HelloWorld { private volatile static int i = 10; public static void main(String[] args) { i++; } }
对应字节码文件如下:
public class com.sFace.test.HelloWorld { public com.sFace.test.HelloWorld(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field i:I 3: iconst_1 4: iadd 5: putstatic #2 // Field i:I 8: return static {}; Code: 0: bipush 10 2: putstatic #2 // Field i:I 5: return }
0: getstatic指令将变量从主存中取出来,如果此变量是volatile修饰的,则可以保证此时取到的是最新值。
3: iconst_1指令(将常量压入栈中)和4: iadd指令执行过程中,由于CPU时间片调度的关系,执行权切换到其他线程,其他线程对i值进行了修改但是该线程并不会重新去读取最新值。
- 线程A先从主存中读取i值到工作内存CPU Cache,此时i的值是10,执行0: getstatic指令;
- 由于CPU时间片调度的关系,可能此时执行权切换到线程B,由于线程A未对i执行任何修改操作,所以线程B读取到i的值还是10;
- 线程B执行了加1操作,得到11,将这个值写入到CPU Cache中,然后刷新到主存,根据可见性原则,修改后的值对其他线程可见;
- 执行权切换到线程A时,由于0: getstatic指令已经执行完成,后面的操作将不会重新读取,所以此时i的值还是10,执行后续指令后得到的值还是11;
- 最后导致两次自增操作实际是自增一次的效果。
4.volatile关键字使用场景
不要将volatile用在getAndOperate场合,仅仅set或者get的场景是适合volatile的。
使用场景1:开关控制(可见性)
package com.sFace.test; public class SwitchThread extends Thread{ /** * 开关 */ private static volatile boolean isRunning = true; @Override public void run() { while(isRunning){ System.out.println("I am Running"); } } /** * 停止工作 */ public void shutDown(){ isRunning = false; } }
当某个线程执行了shutDown()方法时,所有的线程会立刻看到isRunning发生了变化。
使用场景2:状态标记(顺序性),前面讲到的context加载的例子
使用场景3:单例设计模式中的Double-Check(顺序性)
5.volatile和synchronized
使用区别 | 原子性保证 | 顺序性保证 | 可见性保证 | 是否会使线程阻塞 | |
volatile | 只能修饰实例变量、类变量; 修饰的变量可以为null; | 不可以 | 可以 | 可以 | 不会 |
synchronized | 只能修饰方法或语句块; 同步语句块的monitor对象不能为null; | 可以,使用一种排他机制 | 可以,程序串行化执行来保证 | 可以,使用机器指令迫使其他线程工作内存数据失效 | 会 |
扩展知识点1:当int取值-1~5采用iconst指令,取值-128~127采用bipush指令,取值-32768~32767采用sipush指令,取值-2147483648~2147483647采用 ldc 指令。
扩展知识点2:查看java源文件字节码的其中一个方法,dos命令行cd到java文件目录,执行javac xx.java、javap -c xx.class命令,如javac -encoding UTF-8 HelloWorld.java, javap -c HelloWorld.class.