上一篇:Java多线程(4)ReentrantLock详解
什么是JMM
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等;
- 所有的变量都存储在主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
用下面的图可以直观的看到JMM中,线程、工作内存、主内存之间的关系:
在JMM中,有两条规定:
-
程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写
-
不同线程之间无法访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
一. 原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行, 就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。
1. 什么情况下是原子操作
Java中自带了一些原子性操作,比如给一个非long、double基本数据类型变量或者引用的赋值或者读取操作,另外,Java提供了AtomicInteger等原子类。再就是用原子性来控制并发比较麻烦,也容易出问题
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
同样地反映到并发编程中会出现什么结果呢?
举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
i = 9;
假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据,为了解决这种情况,**Java就直接将 对非long、double基本数据类型变量 读和赋值操作设计成原子性,所以说 i=9; 是原子性的 **
2. count++ 是不是原子操作呢
count++的执行分为三个阶段:
- 内存到寄存器
- 寄存器自增
- 写回内存
这三个阶段中间都可以被中断分离开
如果 count++只需要一条指令的话,并不能保证多个线程的count++操作,操作的是同一个变量count,并且还可以得到正确的结果。因为还有寄存器的因素,多个cpu对应多个寄存器。每次要先把i从内存复制到寄存器,然后++,然后再把i复制到内存中,这需要至少3步。从这个意义上讲,count++不是原子性的
3. 导致原子性的问题
导致原子性的原因是线程切换,当一个线程对共享变量进行读写操作时,没有执行完,切换到另外一个线程对该共享变量进行读写
比如上面经典的count++问题
在写入内存前的操作都是在 线程私有的工作内存中执行的,这就造成了两个线程的 count = 1 指令 发生了两次,相当于两次操作都是将变量count赋值为 1,不是预想之中的count = 2
3. 如何将其他操作(代码)设计成原子的
其实我们只要保证同一时刻只有一个线程执行就可以了,我们叫做互斥,即,锁
采用同步synchronized解决
synchronized可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块,就避免了多线程下的非原子性操作被其他线程中断
4. 面试题目:i++在两个线程里边分别执行100次,能得到的最大值和最小值分别是多少?
假设两个线程的执行步骤如下:
- 线程A执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU1的寄存器中值为1,内存中为0;
- 线程B执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU2的寄存器中值为1,内存中为0;
- 线程A继续执行完成第99次i++,并把值放回内存,此时CPU1中寄存器的值为99,内存中为99;
- 线程B继续执行第一次i++,将其值放回内存,此时CPU2中的寄存器值为1,内存中为1;
- 线程A执行第100次i++,将内存中的值取回CPU1的寄存器,并执行加1,此时CPU1的寄存器中的值为2,内存中为1;
- 线程B执行完所有操作,并将其放回内存,此时CPU2的寄存器值为100,内存中为100;
- 线程A执行100次操作的最后一部分,将CPU2中的寄存器值放回内存,内存中值为2;
- 结束!
便可以得出最终结果,最小值为2,最大值为200
二. 可见性
先看代码案例:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
为什么呢?分析一下:
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
1. 什么情况下一个线程对字段的修改才能确保对另一个线程可见:
1.1 synchronized修饰
一个写操作的线程释放一个锁之后,另一个读操作的线程随后获取了同一个锁。本质上,一个线程释放锁时会将强制提交工作内存中的新数据到主内存中,另一个线程获取这个锁的时候,将强制线程装载(或重新装载)字段的值(从主内存中获取)。锁提供对一个同步方法或块的互斥性执行,线程执行获取锁和释放锁时,所有对字段的访问的内存效果都是已定义的
JMM对于synchronized有两条规定:
- 程解锁前,必须把共享变量的最新值刷新到主内存
- 线程加锁时,将清空工作内存中共享变量的值,这样在使用共享变量时就需要从主内存中重新获取最新的值
线程执行互斥代码(被synchronized修饰的代码)的过程:
- 获得互斥锁
- 清空工作内存
- 从主内存拷贝变量的最新副本到自己工作内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存中
- 释放互斥锁(wait方法执行,同步代码块里面的代码执行完毕)
1.2 volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以 在每次被线程访问时,都强迫从主内存中读取该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存,这样不同的线程总能看到该变量的最新值
原理:
- volatile是通过加入内存屏障和禁止重排序优化来实现的
- 对volatile变量执行写操作时,会在写操作后加入一条sotre屏障指令,强制写入主内存中
- 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,强制清空缓冲区数据,需要时从主内存中拿,并禁止指令重排序
- volatile不保证变量复合操作的原子性
三. 有序性
有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的
指令重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或者处理器为了提高程序性能而做的优化。
- 编译器优化的重排序(编译器优化)
- 指令集并行重排序(处理器优化)
- 内存系统的重排序(处理器优化)
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解一下吧
原理之指令级并行
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 用来保存结果,问,可能的结果有几种?
- 情况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 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:
public class ThreadDemo {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
while(true){
i++;
x = 0;y = 0;
a = 0;b = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
waitTime(10000);
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次执行结果(" + x + "," + y + ")");
if (x == 0 && y == 0){
System.out.println("在第" + i + "次发生指令重排(" + x + "," + y + ")");
break;
}
}
}
public static void waitTime(int time){
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
}while (start + time >= end);
}
}
运行结果:
虽然次数相对很少,但毕竟是出现了
Java 中可以使用 volatile 来保证顺序性(volatile 修饰的变量,可以禁用指令重排),synchronized 和 lock 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的
private volatile static int x = 0,y = 0; // volatile
private volatile static int a = 0,b = 0; // volatile
private static int i = 0;
加上volatile 之后运行结果:
第151017次执行结果(0,1)
第151018次执行结果(0,1)
第151019次执行结果(0,1)
第151020次执行结果(0,1)
第151021次执行结果(0,1)
第151022次执行结果(0,1)
第151023次执行结果(0,1)
第151024次执行结果(0,1)
第151025次执行结果(0,1)
第151026次执行结果(0,1)
第151027次执行结果(0,1)
第151028次执行结果(1,0)
第151029次执行结果(0,1)
第151030次执行结果(0,1)
第151031次执行结果(0,1)
Process finished with exit code 130
是我手动停下的,加上volatile 之后没有出现指令重排
除了通过 volatile 关键字显式的保证顺序之外, JVM 还通过 happen-before 原则来隐式的保证顺序性。
其中有一条就是适用于 volatile 关键字的,针对于 volatile 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。
四. happen-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛
开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
-
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x; static Object m = new Object(); new Thread(()->{ synchronized(m) { x = 10; } },"t1").start(); new Thread(()->{ synchronized(m) { System.out.println(x); } },"t2").start();
-
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x; new Thread(()->{ x = 10; },"t1").start(); new Thread(()->{ System.out.println(x); },"t2").start();
-
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x; x = 10; new Thread(()->{ System.out.println(x); },"t2").start();
-
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x; Thread t1 = new Thread(()->{ x = 10; },"t1"); t1.start(); t1.join(); System.out.println(x);
-
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
static int x; public static void main(String[] args) { Thread t2 = new Thread(()->{ while(true) { if(Thread.currentThread().isInterrupted()) { System.out.println(x); break; } } },"t2"); t2.start(); new Thread(()->{ sleep(1); x = 10; t2.interrupt(); },"t1").start(); while(!t2.isInterrupted()) { Thread.yield(); } System.out.println(x); }
-
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
-
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
volatile static int x; static int y; new Thread(()->{ y = 10; x = 20; },"t1").start(); new Thread(()->{ // x=20 对 t2 可见, 同时 y=10 也对 t2 可见 System.out.println(x); },"t2").start();
over