JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM 体现在以下几个方面
关于JMM的一些同步的约定:
1、线程解锁前,必须把共享变量立刻刷回主存。
2、线程加锁前,必须读取主存中的最新值到工作内存中!
3、加锁和解锁是同一把锁。
线程 工作内存 、主内存
8 种操作:
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和writ操作在某些平台上允许例外)
不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
不允许一个线程将没有assign的数据从工作内存同步回主内存
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
对一个变量进行unlock操作之前,必须把此变量同步回主内存
问题: 程序不知道主内存的值已经被修改过了
所以就有了可见性问题
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
public static boolean run = true; public static void main(String[] args) { Thread t1 = new Thread(() -> { while(run) { } }, "t1"); t1.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } log.info("t1 Stop"); run = false; }
1.初始状态,t线程刚开始从主内存读取了rum的值到工作内存
2.因为t线程要频繁从主内存中读取run的值,JIT编译器会将r的值缓存至自己工作内存中的高速缓存
中,减少对主存中mmn的访问,提高效率
1秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变
量的值,结果永远是旧值
解决:使用 volatile
Volatile 是 Java 虚拟机提供轻量级的同步机制,类似于synchronized 但是没有其强大。它可以用来修饰成员变量和静态成员变量(放在主存中的变量),可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
public static volatile boolean run = true; // 保证内存的可见性
volatile 保证的是在多个线程之间,一个线程对volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。虽然不能禁止指令重排序,但由于处于此临界区的只有一个线程也可以解决指令重排序问题,但缺点是
synchronized 是属于重量级操作,性能相对更低
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回
犹豫模式保证某件事比如执行方法只被执行一次,下次不执行直接返回
volalite两阶段终止模式
public class TwoPhaseTermination { public static void main(String[] args) throws InterruptedException { TwoPhaseTerminations toPhaseTermination = new TwoPhaseTerminations(); toPhaseTermination.start(); TimeUnit.SECONDS.sleep(5); toPhaseTermination.stop(); } } class TwoPhaseTerminations{ private Thread moThread; private volatile boolean isStop = false; //犹豫模式保证方法只执行一次 private boolean isExecuting = false; void start(){ synchronized (this){ if (isExecuting){ return; } } isExecuting = true; //启动监控线程 moThread = new Thread(()->{ while (true) { if (isStop) { System.out.println("料理后事!"); break; } else { try { TimeUnit.SECONDS.sleep(1); System.out.println("继续监控"); } catch (InterruptedException ignored) { } } } }); moThread.start(); } void stop() { isStop = true; moThread.interrupt(); } }
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序
static int i; static int j; // 在某个线程内执行如下赋值操作 i = ...; j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...; j = ...;
也可以是
j = ...; i = ...;
这种特性称之为指令重排,多线程下指令重排会影响正确性。
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级井行, 增加指令的并行度及吞吐量
编译器和处理器通常会对指令做重排序:
以归结于一点:不满足happens-before原则或者无法通过happens-before原则推导出来的,JMM允许任意的排序。
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性, 就需要禁止重排序
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的CPU指令。 每条指令都可以分为:取指令 指令译码 执行指令 内 存访问 数据写回 这5个阶段 称之为五级指令流水线
这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段 (相当于一条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间, 但它变相地提高了指令地吞吐率。
cpu将多个指令的不同部分并行执行,提高指令并发度。为了满足五级指令流水线cpu会对部分指令进行优化执行,在多线程下就会存在并发问题
五级指令流水线
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
happens-before原则判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
happens- before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总 结,抛开以下 happens- before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量 的读可见 ;如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序
happens-before 锁具有的规则:
规则的延伸
如果两个操作不存在上述任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的
happens-before与JMM的关系图
int num = 0; // volatile 修饰的变量,可以禁用指令重排 volatile boolean ready = false; 可以防止变量之前的代码被重排序 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; }
在多线程环境下,以上的代码 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:线程 2 先执行, num = 2 与 ready = true 这两行代码执行发生交换,因为cpu认为这两条语句没有依赖可能会进行指令重排序read =true 先执行,线程一执行后返现read =true,但线程二的num = 2 由于后执行若此时还未来得及执行,则会导致r1 = 0 + 0 =0
使用happen-before来分析
public class VolatileTest { int i = 0; volatile boolean flag = false; //Thread A public void write(){ i = 2; //1 flag = true; //2 } //Thread B public void read(){ if(flag){ //3 System.out.println("---i = " + i); //4 } } }
依据happens-before原则,就上面程序得到如下关系:
操作1、操作4存在happens-before关系,那么1一定是对4可见的。所以A线程在对volatile共享变量之前所有的写操作,在线程B读同一个volatile变量后,将立即变得对线程B可见。
volatile:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
对 volatile 变量的写指令后会加入写屏障
对 volatile 变量的读指令前会加入读屏障
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) { num = 2; ready = true; // ready 是被 volatile 修饰的,赋值带写屏障 // 写屏障 }
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) { // 读屏障 // ready是被 volatile 修饰的,读取值带读屏障 if(ready) { r.r1 = num + num; } else { r.r1 = 1; } }
有序性:即程序执行的顺序按照代码的先后顺序执行。
写屏障不仅可以保证将共享数据写入主内存中,还会保证写屏障之前的代码当前线程内禁止指令重排序即
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前 ,确保读取到是读屏障之前的数据
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序
无法解决指令交错的行为