共享模型之内存

JMM

JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

关于JMM的一些同步的约定:

1、线程解锁前,必须把共享变量立刻刷回主存。

2、线程加锁前,必须读取主存中的最新值到工作内存中!

3、加锁和解锁是同一把锁。

线程 工作内存主内存

8 种操作:
共享模型之内存
共享模型之内存
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和writ操作在某些平台上允许例外)

  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  • write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
  • JMM 对这八种指令的使用,制定了如下规则:
  1. 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write

  2. 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存

  3. 不允许一个线程将没有assign的数据从工作内存同步回主内存

  4. 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作

  5. 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
    如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值

  6. 如果一个变量没有被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 = ...;

这种特性称之为指令重排,多线程下指令重排会影响正确性。

指令重排

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级井行, 增加指令的并行度及吞吐量

编译器和处理器通常会对指令做重排序:

  1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

以归结于一点:不满足happens-before原则或者无法通过happens-before原则推导出来的,JMM允许任意的排序

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性, 就需要禁止重排序

指令重排优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的CPU指令。 每条指令都可以分为:取指令 指令译码 执行指令 内 存访问 数据写回 这5个阶段 称之为五级指令流水线

这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段 (相当于一条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间, 但它变相地提高了指令地吞吐率。

cpu将多个指令的不同部分并行执行,提高指令并发度。为了满足五级指令流水线cpu会对部分指令进行优化执行,在多线程下就会存在并发问题

五级指令流水线
共享模型之内存

happens-before 原则

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

happens-before原则判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。

happens- before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总 结,抛开以下 happens- before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量 的读可见 ;如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序

happens-before 锁具有的规则:

  • 线程解锁 之前对变量的写,对于接下来对像加锁的其它线程对该变量的读可见
  • 程序顺序原则:一个线程内,按照代码顺序,一个线程内保证予以的串行性;是执行结果,因为虚拟机、处理器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作即线程对 volatile变量的写,对接下来其它线程对该变量的读可见 即 volatile变量的写,先于读发生,保证了volatile变量的可见性
  • 锁规则:解锁必然发生在随后的加锁之前 :一个unLock操作先行发生于后面对同一个锁lock操作
  • 传递规则:A操作先于B, B先于C,则A先于C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;线程t1打断t2( Interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;线程的所有操作先于线程的终结
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

规则的延伸

  1. 将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
  2. 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
  3. 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
  4. 释放Semaphore许可的操作Happens-Before获得许可操作
  5. Future表示的任务的所有操作Happens-Before Future#get()操作
  6. 向Executor提交一个Runnable或Callable的操作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原则,就上面程序得到如下关系:

  • 依据happens-before程序顺序原则:1 happens-before 2、3 happens-before 4;
  • 根据happens-before的volatile原则:2 happens-before 3;
  • 根据happens-before的传递性:1 happens-before 4

操作1、操作4存在happens-before关系,那么1一定是对4可见的。所以A线程在对volatile共享变量之前所有的写操作,在线程B读同一个volatile变量后,将立即变得对线程B可见。

volatile 原理

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;
 }
}

共享模型之内存

有序性原理

有序性:即程序执行的顺序按照代码的先后顺序执行。

写屏障不仅可以保证将共享数据写入主内存中,还会保证写屏障之前的代码当前线程内禁止指令重排序即

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前 ,确保读取到是读屏障之前的数据
共享模型之内存
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
有序性的保证也只是保证了本线程内相关代码不被重排序

无法解决指令交错的行为

上一篇:2021年整理Java面试题 初级+中级+高级


下一篇:Java开发核心知识笔记共2100页,volatile怎么实现的内存可见