四、共享模型之内存
1、JAVA内存模型(JMM)
JMM 即 Java Memory Model,它定义了**主存(共享内存)、工作内存(线程私有)**抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
2、可见性
引例
退出不出的循环
static Boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (run) {
//如果run为真,则一直执行
}
}).start();
Thread.sleep(1000);
System.out.println("改变run的值为false");
run = false;
}
为什么无法退出该循环
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值
解决方法
- 使用volatile易变关键字
- 它可以用来修饰成员变量和静态成员变量(放在主存中的变量),他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
//使用易变关键字
volatile static Boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (run) {
//如果run为真,则一直执行
}
}).start();
Thread.sleep(1000);
System.out.println("改变run的值为false");
run = false;
}
可见性与原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
- 注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。
- 但缺点是 synchronized 是属于重量级操作,性能相对更低。
- 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?
- 因为使用了synchronized关键字
public void println(String x) {
//使用了synchronized关键字
synchronized (this) {
print(x);
newLine();
}
}
两阶终止模式优化
public class Test7 {
public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
Thread.sleep(3500);
monitor.stop();
}
}
class Monitor {
Thread monitor;
//设置标记,用于判断是否被终止了
private volatile boolean stop = false;
/**
* 启动监控器线程
*/
public void start() {
//设置线控器线程,用于监控线程状态
monitor = new Thread() {
@Override
public void run() {
//开始不停的监控
while (true) {
if(stop) {
System.out.println("处理后续任务");
break;
}
System.out.println("监控器运行中...");
try {
//线程休眠
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("被打断了");
}
}
}
};
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
//打断线程
monitor.interrupt();
//修改标记
stop = true;
}
}
同步模式之犹豫模式
定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回
- 用一个标记来判断该任务是否已经被执行过了
- 需要避免线程安全问题
- 加锁的代码块要尽量的小,以保证性能
package com.nyima.day1;
/**
* @author Chen Panwen
* @data 2020/3/26 16:11
*/
public class Test7 {
public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
monitor.start();
Thread.sleep(3500);
monitor.stop();
}
}
class Monitor {
Thread monitor;
//设置标记,用于判断是否被终止了
private volatile boolean stop = false;
//设置标记,用于判断是否已经启动过了
private boolean starting = false;
/**
* 启动监控器线程
*/
public void start() {
//上锁,避免多线程运行时出现线程安全问题
synchronized (this) {
if (starting) {
//已被启动,直接返回
return;
}
//启动监视器,改变标记
starting = true;
}
//设置线控器线程,用于监控线程状态
monitor = new Thread() {
@Override
public void run() {
//开始不停的监控
while (true) {
if(stop) {
System.out.println("处理后续任务");
break;
}
System.out.println("监控器运行中...");
try {
//线程休眠
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("被打断了");
}
}
}
};
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
//打断线程
monitor.interrupt();
stop = true;
}
}
3、有序性
指令重排
- JVM 会在不影响正确性的前提下,可以调整语句的执行顺序
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。
指令重排序优化
- 事实上,现代处理器会设计为一个时钟周期完成一条执行时间长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这5 个阶段
- 在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行
- 指令重排的前提是,重排指令不能影响结果,例如
// 可以重排的例子
int a = 10;
int b = 20;
System.out.println( a + b );
// 不能重排的例子
int a = 10;
int b = a - 5;Copy
支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
在多线程环境下,指令重排序可能导致出现意料之外的结果
解决办法
volatile 修饰的变量,可以禁用指令重排
- 禁止的是加volatile关键字变量之前的代码被重排序
happens-before
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
在《Java并发编程Bug的源头》一节中提到编译优化会带来有序性问题,具体来说就是 JIT 编译器会进行指令重排序(Instruction Reorder)优化。优化措施引发的有序性问题,Java 语言肯定会注意到,所以就引入了 Happens-Before(先行发生) 原则,它是 JMM 最核心的概念,在 JMM 章节中提到了如何保证可见性和有序性,都和该原则有关联。
对应 Java 程序员来说,理解 Happens-before 是理解 JMM 的关键。这个原则非常重要, 它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一并解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。
JMM的设计
现在就来看看“先行发生”原则指的是什么。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们通过一个简单的案例来进行
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上述代码用来计算圆的面积,存在3个 happens-before 关系,如下。
- A happens-before B
- B happens-before C
- A happens-before C
在3个 happens-before 关系中,2和3是必需的,但1是不必要的。因此,JMM 把 happens-before 要求禁止的重排序分为了下面两类。
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略,如下。
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
综合来看,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除,我们之前学习 JIT 编译器逃逸分析时有提到。
Happens-Before 规则
下面是 Java 内存模型下一些“天然的”先行发生关系, 这些先行发生关系无须任何同步器协助就已经存在, 可以在编码中直接使用。 如果两个操作之间的关系不在此列, 并且无法从下列规则推导出来, 则它们就没有顺序性保障, 虚拟机可以对它们随意地进行重排序。
1、程序次序规则:在一个线程中,前面的操作 Happens-Before 于后续的任意操作。
2、volatile变量规则:对一个 volatile 变量的写操作 Happens-Before 于对这个 volatile 变量的读操作。
3、传递性规则:A Happens-Before B,B Happens-Before C,那么 A Happens-Before C。
4、管程锁定规则:synchronized 是 Java 对管程的实现,隐式加锁、释放锁,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
关于管程的介绍:在操作系统中,管程的定义如下: 管程是由一组数据以及定义在这组数据之上的对该组数据操作的操作组成的软件模块,称之为管程。 基本特性: 1. 局部于管程的数据只能被局部于管程内的过程所访问。 2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据 3. 每次仅允许一个进程在管程中执行某个内部过程。 注意:由于管程是一个语言的成分,所以管程的互斥访问完全由编译程序在编译时自动添加,无需程序员关注。
而在 Java 中,管程指的就是 synchronized,synchronized 是 Java 里对管程的实现。
管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
5、线程启动规则:Thread 对象的 start()方法先行发生于此线程的每一个动作。
主线程A启动子线程B后,子线程的 start()操作 Happens-Before于子操作中的任意操作,即子线程 B 能够看到主线程在启动子线程 B 前的操作。
Thread B = new Thread(()->{
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
在上述代码中,main 线程启动子线程B后,B线程的 start()操作 Happens-Before于B线程操作中的任意操作,即线程 B 能够看到主线程在启动线程 B 前的操作。
6、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66
7、线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。
public class InterruptedSleepingTest {
public static void main(String[] args) throws InterruptedException {
InterruptedSleepingThread thread = new InterruptedSleepingThread();
thread.start();
// 10s后执行中断操作
Thread.sleep(10000);
thread.interrupt();
}
}
class InterruptedSleepingThread extends Thread {
@Override
public void run() {
doAPseudoHeavyWeightJob();
}
private void doAPseudoHeavyWeightJob() {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// You are kidding me
System.out.println(i + " " + i * 2);
// Let me sleep <evil grin>
if (Thread.currentThread().isInterrupted()) {
System.out.println("Thread interrupted\n Exiting...");
break;
} else {
sleepBabySleep();
}
}
}
protected void sleepBabySleep() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
//当主线程中的interrupt方法执行之后,才会抛出
Thread.currentThread().interrupt();
}
}
}
执行结果为:
0 0
1 2
2 4
3 6
4 8
5 10
6 12
7 14
8 16
9 18
10 20
Thread interrupted
Exiting...
关于线程 interrupt 方法的详细讲解,可以参考这篇文章。
8、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
public class ObjectHappensTest {
public int num;
public String name;
public ObjectHappensTest(int num, String name) {
System.out.println("可以多次执行构造方法");
this.num = num;
this.name = name;
}
@Override
protected void finalize() throws Throwable {
System.out.println("进入finalize方法,只会执行一次");
super.finalize();
}
public static void main(String[] args) throws InterruptedException {
ObjectHappensTest obj;
// obj = new ObjectHappensTest(30, "constructor");
obj = null;
System.gc();
Thread.sleep(2000);
}
}
执行上述代码,什么也没有输出;如果取消注释,则会打印如下结果:
可以多次执行构造方法
进入finalize方法,只会执行一次
证实了对象终结规则:一个对象在被垃圾回收之前必须已经进过初始化,垃圾回收不可能也不能去回收一个根本不存在的对象。
扩展
如何保证一个共享变量的可见性?
1、保证共享变量的可见性,使用volatile关键字修饰即可,不管是针对该共享变量加 volatile,还是通过传递性来保证可见性,都算是 volatile 的功效。
2、保证共享变量是private,访问变量使用set/get方法,使用synchronized对两个方法加锁,此种方法不仅保证了可见性,也保证了线程安全
3、如果变量类型为 int,使用原子变量,例如:AtomicInteger等
4、利用线程的 join()方法或 start()方法
4、内存屏障
- 可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据
- 有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
5、volatile 原理
volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
如何保证可见性
如何保证有序性
但是不能解决指令交错问题
- 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
实现原理之Lock前缀
在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时
instance = new Singleton();
对应的汇编代码是
... lock addl ...
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事
- Lock前缀指令会引起处理器
缓存回写到内存
- Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK #信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据
- 一个处理器的缓存回写到内存会
导致其他处理器的缓存无效
- 在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致