概述
- Java内存模型的定义是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
- Java内存模型目的是定义程序中共享变量的访问规则,定义了一套多线程读写共享数据时,对数据的可见性、有序性和原子性的规则和保障
原子性
大致可以认为,基本数据类型的访问,读写都是具备原子性的(除了long、double这两个64位的类型,在运算时会被分成两个32位进行运算);如果要保证更大范围内的原子性,Java内存模型提供了lock和unlock操作,对应的字节码指令是monitorenter和monitorexit,对应的Java代码是sychronized关键字
sychronized
原理
如果锁住的是对象,则会通过monitorenter和monitorexit指令;如果锁住的是方法,则会通过ACC_SYCHRONIZED标识
案例
public class Main{
static int num = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
synchronized (obj){
for (int i = 0; i < 1000; i++) {
// 自增不是一个原子操作,自增实际上是num = num+1,存在num+1和赋值两个操作
num++;
}
}
});
Thread t2 = new Thread(()->{
synchronized (obj){
for (int i = 0; i < 1000; i++) {
num++;
}
}
});
t1.start();
t2.start();
//等待綫程t1和t2
t1.join();
t2.join();
System.out.println(num);
}
}
可见性
主内存和工作内存
JMM规定所有变量都存储在主内存中,但是每个线程都有自己的工作内存
线程在运行过程中,会将需要用到的数据从主内存中加载到工作内存,此时对主内存的修改,工作内存不可见;工作内存中做的修改,对主内存也不可见
不可见现象
public class Main{
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(flag){
}
});
t.start();
Thread.sleep(1000);
flag = false;
}
}
上面这段代码理应在一秒过后退出,但是主线程对flag的修改对线程t不可见,所以会一直循环
当while循环中调用System.out.println()方法时,会发现程序退出了
public class Main{
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(flag){
System.out.println();
}
});
t.start();
Thread.sleep(1000);
flag = false;
}
}
- 原因是JVM会尽力去保证内存的可见性,即使没有同步关键字的存在;volatile的意义是强制性的保证可见性;在没有sout时,由于CPU一直在处理循环,CPU一直饱受占用,没有时间去处理别的事情,所以JVM不能强制要求CPU分点时间去主内存中取最新的变量值;而sout由于sychronized关键字,导致主内存中的数据倍同步到工作内存
- JMM对sychronized有两条规定:线程解锁时,必须把贡献变量的最新值刷新到主内存中;线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量要从主内存中重新读取
可见性解决方案
- 可见性除了上述使用sychronized关键字来实现,还可以通过volatile关键字,volatile关键字不会保证原子性,同时也是非阻塞的;所以在不需要保证变量操作的原子性时,使用volatile解决可见性性能更高
- volatile可以保证每次工作线程访问变量都会从主内存去读取
public class Main{
static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(flag){
}
});
t.start();
Thread.sleep(1000);
flag = false;
}
}
有序性
指令重排序
public class Main{
static boolean flag = false;
static int num = 1;
static int res = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
if(flag){
res = num+num;
}else{
res = 1;
}
});
Thread t2 = new Thread(()->{
num = 2;
flag = true;
});
t2.start();
t1.start();
t1.join();
t2.join();
System.out.println(res);
}
}
这段代码经过分析,可能出现如下几种情况:
- t2先执行到flag=true,然后t1执行,进入if分支,得到res结果为4
- t1先执行,进入else分支,得到res结果为1
- 由于指令重排序,导致t2中res=true在num=2之前执行,他t1执行进入if分支,得到res结果为0
指令重排序:JIT编译器在运行时的一些优化,如果一段代码执行的先后顺序对执行结果不会有影响时,代码可能不会按照源码顺序进行执行;如上num的赋值和flag的赋值对线程t2的结果不会有影响,所以两者的顺序并不能保证num=2一定在flag=true前面,在单线程环境并不会有什么问题,但是多线程环境下,自己线程中代码的执行顺序可能会影响其他线程,最终造成线程安全问题
保证有序性
对变量使用volatile修饰,即可禁止操作该变量的指令重排序,如上对flag使用volatile修饰即可保证有序性
经典案例
double-checked locking的单例模式
public final class Singleton{
public static void main(String[] args) {
Singleton.getInstance();
}
private Singleton(){}
private static Singleton instance = null;
public static Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
- 如果先执行了putstatic指令,instance就不为null,那么其他线程此时调用getInstance方法会直接拿到这个instance,而这个instance还没有执行完整的构造方法,导致线程安全问题
- 对instance使用volatile修饰即可解决这个问题
happens-before(先行先发生)
happens-before规定了哪些写操作对其他线程的读操作可见,它是可见性和有序性的一套规则总结
- 线程解锁之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
- 线程对volatile变量的写,对接下来其他线程对该变量的读可见
- 线程start前对变量的写,对该线程开始后对该变量的读可见
- 线程结束前对变量的写,对其他线程得知他结束后的读可见,如其他线程调用isAlive或join方法等待他结束
- 线程t1打断t2前对变量的写,对于其他线程得知t2被打断后的变量都可见,通过interrupted或isInterrrupted查看线程是否被打断
- 对变量默认值的写,对其他线程对该变量的值可见
- 具有传递性