一、内存模型
很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java MemoryModel(JMM)的意思。
- 简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
关于它的权威解释,请参考:链接
二、原子性
2.1 指令交错
原子性在学习线程时讲过,下面来个例子简单回顾一下:
问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
public class Atomicity1 {
static int a=0;
public static void main(String[] args) throws InterruptedException {
for (int j=0;j<5;j++) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
a++;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
a--;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第"+j+"次:"+a);
a=0;
}
}
}
第0次:-2640
第1次:0
第2次:1891
第3次:-1317
第4次:294
2.2 问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。
a++ 实际产生的字节码指令:
getstatic a // 获取静态变量a的值
iconst_1 // 准备常量1
iadd // 加法
putstatic a // 将修改后的值存入静态变量a
i++ 实际产生的字节码指令:
getstatic a // 获取静态变量a的值
iconst_1 // 准备常量1
isub // 减法
putstatic a // 将修改后的值存入静态变量a
内存模型如下:一个线程要完成静态变量的自增、自减,需要从主内存中获取静态变量的值到线程内存中进行计算,然后再写到主存中
单线程情况:没有问题
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:
getstatic a // 线程1-获取主内存静态变量i的值:线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-加法:线程内i=1
putstatic a // 线程1-将修改后的值存入静态变量i:主内存静态变量i=1
getstatic a // 线程1-获取静态变量i的值:线程内i=1
iconst_1 // 线程1-准备常量1
isub // 线程1-减法:线程内i=0
putstatic a // 线程1-将修改后的值存入静态变量i:主内存静态变量i=0
多线程情况:出现问题
多线程下这 8 行代码可能交错运行(为什么会交错?思考一下):
出现负数的情况之一:
getstatic a // 线程1-获取主内存静态变量a的值:线程内a=0
getstatic a // 线程2-获取主内存静态变量a的值:线程内a=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-加法:线程内a=1
putstatic a // 线程1-将修改后的值存入静态变量a:主内存静态变量a=1
iconst_1 // 线程2-准备常量1
isub // 线程2-减法:线程内a=-1
putstatic a // 线程2-将修改后的值存入静态变量a:主内存静态变量a=-1
出现正数的情况之一:
getstatic a // 线程1-获取主内存静态变量a的值:线程内a=0
getstatic a // 线程2-获取主内存静态变量a的值:线程内a=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-加法:线程内a=1
iconst_1 // 线程2-准备常量1
isub // 线程2-减法:线程内a=-1
putstatic a // 线程2-将修改后的值存入静态变量a:静态变量a=-1
putstatic a // 线程1-将修改后的值存入静态变量a:静态变量a=1
2.3 问题解决
synchronized
- 优点:可以保证代码块内的 原子性、可见性
- 缺点:属于重量级操作,性能相对更低
让操作共享变量的线程只能同时存在一个,不让操作a的指令交错执行。使用 synchronized **锁住同一个对象 **进行保证
public class Atomicity1 {
static int a=0;
static Object lock=new Object(); //锁对象
public static void main(String[] args) throws InterruptedException {
for (int j=0;j<5;j++) {
Thread thread1 = new Thread(() -> {
synchronized (lock){ //线程操作共享变量时竞争锁
for (int i = 0; i < 5000; i++) {
a++;
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock){ //线程操作共享变量时竞争锁
for (int i = 0; i < 5000; i++) {
a--;
}
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第"+j+"次:"+a);
a=0;
}
}
}
三、可见性
3.1 退不出的循环
public class visibility1 {
static boolean run=true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(run){
}
},"t").start();
Thread.sleep(1000);
System.out.println("1秒后");
run=false;
}
}
3.2 问题分析
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
-
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
3.3 问题解决
- volatile(易变关键字)
- 它可以用来修饰 成员变量和静态成员变量
- 它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
- 不能保证原子性。仅用在一个写线程,多个读线程的情况
- synchronized
- 优点:可以保证代码块内的 原子性、可见性
- 缺点:属于重量级操作,性能相对更低
给该例子的 run 加上volatile,保障的实际就是可见性问题。从字节码理解是这样的:
getstatic run // 线程t:获取 run true
getstatic run // 线程t:获取 run true
getstatic run // 线程t:获取 run true
getstatic run // 线程t:获取 run true
putstatic run // 线程main:修改 run 为 false, 仅此一次
getstatic run // 线程t:获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,此时 volatile 只能保证看到最新值,不能解决指令交错
getstatic a // 线程1-获取主存静态变量a的值:线程内a=0
getstatic a // 线程2-获取主存静态变量a的值:线程内a=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-加法:线程内a=1
putstatic a // 线程1-将修改后的值存入静态变量:主存静态变量a=1
//注意:此时线程2无需再执行取值操作,所以线程1存值时就算有 volatile 也于事无补
iconst_1 // 线程2-准备常量1
isub // 线程2-减法:线程内a=-1
putstatic a // 线程2-将修改后的值存入静态变量:主存静态变量a=-1
思考
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
四、有序性
4.1 出现指令重排
有一种现象叫做 指令重排 ,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?有同学分析了三种情况
- r1 = 1:线程1 直接执行完。
- r1 = 4:线程2 直接执行完。
- r1 = 1:线程2 先执行到 num = 2,但没来得及执行 ready = true,线程1 执行进入 else 分支
r1=0:JIT 进行指令重排导致的有序性问题
这种情况下是:线程2 直接执行 ready = true,切换到 线程1 进入 if 分支,相加为 0,再切回线程2 执行num = 2。
4.2 分析
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...; // 较为耗时的操作
j = ...;
也可以是
j = ...;
i = ...; // 较为耗时的操作
这种特性称之为**『指令重排』**。多线程下『指令重排』会影响正确性
单例应用:双重检测
/**
* 加 volatile 的原因:
* 1.线程1:new 关键字给INSTANCE分配空间,此时INSTANCE不为null
* 2.线程2:获取到了还没完全初始化好的 INSTANCE
*/
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
//1.实例未创建才竞争
if (INSTANCE == null) {
synchronized (Singleton.class) {
//2.前面获得锁的线程已经创建对象了
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
4.3 解决方法
volatile 修饰的变量,可以禁用指令重排