Java内存模型(JMM,Java Memory Model),控制 Java 线程之间的共享数据的通信。是Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程。
要想理解Java内存模型,要先理解缓存一致性问题。
由于主内存与CPU处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主内存和处理器之间的缓冲,CPU将常用的数据放在高速缓存中,运算结束后CPU再将运算结果同步到主存中。
使用高速缓存解决了CPU和主内存运算能力不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题(多线程可见性)
如上图,在多个CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存。当多个CPU的运算任务都涉及同一块主内存区域时,CPU会将数据读取到缓存中进行运算,这就可能会导致各自的缓存数据不一致。因此需要每个CPU访问缓存时遵循一定的协议。
Java多线程的场景肯定会出现三个问题:可见性、原子性、有序性。
可见性:缓存一致性造成的
原子性:处理器优化造成的
有序性:指令重排序造成的
JMM就是定义了一些规范来解决这些问题,开发发者可以利用这些规范更方便地开发多线程程序。 对于我们Java开发者来说,直接使用并发相关的一些关键字和类(volatile、synchronized、各种Lock)即可开发出并发安全的程序。
一次操作或者多次操作,要么所有的操作全部都得到有效的执行并且不会受到任何因素的干扰而中断,要么都不执行。
关于多线程原子性下面举个例子:
问题:两个线程对初始值为 0 的静态变量一个线程做自增,一个线程做自减,各做 1000 次,结果是 0 吗?
答:结果可能是正数、负数、零。因为 Java 中对静态变量的自增,自减并不是原子操作。多个线程读取jvm字节码指令,读取的内容是相同的,但是他们会交替修改静态变量(如果是单线程代码是顺序执行-不会交错,没有问题)。Java的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:
解决方案:
synchronized和各种Lock可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用CAS操作(可能也会用到 volatile或者final关键字)来保证原子操作。
举例使用synchronized(同步关键字)进行修饰:
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
// t1 和 t2 线程必须用 synchronized 锁住同一个对象
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join(); // join()方法会让子线程优先执行,再执行主线程。
t2.join();
System.out.println(i);
}
当一个线程对共享变量进行了修改,其他的线程都是立即可以看到修改后的最新值。
关于多线程可见性下面举个例子:退不出的循环的例子
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
System.out.println("多线程可见性测试");
}
});
t.start();
Thread.sleep(10000);
run = false; // 线程t不会如预想的那样停下来
}
分析:
1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决方案:
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主内存(指示 JVM,这个变量是共享且不稳定,每次使用它都到主存中进行读取)。
static volatile boolean run = true; // volatile修饰变量 public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> { while (run) { System.out.println("多线程可见性测试"); } }); t.start(); Thread.sleep(10000); run = false; // 线程t不会如预想的停下来 }
注意:它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况;只能保证看到最新值,不能解决指令交错。synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低。
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
volatile 关键字可以禁止指令进行重排序优化。
关于多线程有序性下面举个例子:
int num = 0; boolean ready = false; public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } public void actor2(I_Result r) { num = 2; ready = true; }
I_Result是一个对象有一个属性r1用来保存结果,可能的结果有几种呢?
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
情况4**:结果还有可能是 0 。这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现。
解决方案:
volatile 关键字可以禁止指令进行重排序优化。
int num = 0; volatile boolean ready = false; // 添加关键字 public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } public void actor2(I_Result r) { num = 2; ready = true; }
简单的说,JMM定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障。(解决由多线程通过共享内存数据时,本地内存数据不一致、编译器会对代码指令重排序、处理器对代码乱序执行等带来的问题)