深入理解JMM内存模型

一、Java内存模型

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

JMM 体现在以下几个方面

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

1、可见性

退不出的循环

以下代码中,线程 t 执行while循环,直到run为false,主线程中,将run置为false,但是循环并未终止

static boolean run = true;

public static void main(String[] args) throws InterruptedException {

    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();

    Thread.sleep(1);
    run = false; // 线程t不会如预想的停下来
}

分析

1、初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

2、因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

3、1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值


解决方法

1、使用volatile关键字修饰变量:它可以用来修饰成员变量静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

volatile static boolean run = true; // 使用volatile修饰后,线程每次读取run的值都是去主存读取

public static void main(String[] args) throws InterruptedException {

    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();

    Thread.sleep(1);
    run = false; // 线程t会停下来
}

2、使用synchronized:线程遇到synchronized关键字时会把工作内存中的缓存清空,这样当前线程栈中共享变量的“副本”就没有了,当再次要用到共享变量时,只能去主内存中拷贝一份新的“副本”,这样就保证了每次读取的共享变量的值都是最新的

public class test02 {

    static boolean run = true;
    static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {

        Thread t = new Thread(()->{
            while(true){
                synchronized (object) {
                    if (!run){
                        break;
                    }
                }
            }
        });
        t.start();

        Thread.sleep(2);
        run = false; // 线程t会停下来
    }
}

volatile和synchronized的区别

  • volatile只能保证变量的可见性,不能保证原子性

  • synchronized既可以保证可见性,也可以保证原子性,但是synchronized 属于重量级操作,性能相对更低

    • 在前边死循环的代码中加入System.out.println(),不使用volatile,线程t也能正确看到run变量被修改,因为println内部使用了synchronized



2、设计模式–犹豫模式

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

实现

监控线程一般启动一个即可,如果启动多个会造成资源浪费,因此这里通过判断starting的状态,如果为true,直接返回,否则将starting置位true,然后启动监控线程,保证监控线程只会被创建一次

public class MonitorService {
    
    // 用来表示是否已经有线程已经在执行启动了
    private volatile boolean starting;
    
    public void start() {
        log.info("尝试启动监控线程...");
        synchronized (this) {
            if (starting) {
                return;
            }
            starting = true;
        }
        
        // 真正启动监控线程...
    }
}

实现线程安全的单例

public final class Singleton {
    
    private Singleton() { }
    
    private static Singleton INSTANCE = null;
    public static synchronized Singleton getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        
        INSTANCE = new Singleton();
        return INSTANCE;
    }
}



3、有序性

3.1 指令重排序

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序

下边这段代码,先执行i = 10还是先执行j = 20,对最终结果不会产生影响,因此执行时,既可以先ij,也可以先ji

static int i;
static int j;

i = 10; 
j = 20;

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


指令重排序优化

现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。

指令还可以再划分成一个个更小的阶段。例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

如果每条指令的这五个阶段都串行执行,效率会很低。

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了 指令的吞吐率。

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行


指令重排序导致的诡异结果

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 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2

  • 此时就发生了指令重排,是 JIT 编译器在运行时的一些优化【先执行ready = true,后执行num = 2】,这个现象需要通过大量测试才能复现
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; 
}

解决方法

volatile 修饰的变量,可以禁用指令重排

ready变量使用volatile修饰后,会在ready = true语句后加上写屏障,禁止前边的指令重排

int num = 0;
volatile 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; 
}



4、volatile原理

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • volatile 变量的写指令后会加入写屏障
  • volatile 变量的读指令前会加入读屏障

4.1 保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    如果没有使用volatile修饰,修改一个共享变量时,通常只是修改了自己的工作内存中该变量的值,并没有立即将该变量的值写回主存,当线程结束时,它会将自己工作内存中修改过的共享变量的值写回主存

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    

4.2 保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
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;
    }
}

volatile不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序



5、双重校验锁

synchronized内的代码也会发生指令重排序,但是如果这个共享变量的使用完全在synchronized内部,即使指令重排序了,也不会有影响,因为每次只有一个线程执行。但是如果对于共享变量的使用,一部分在synchronized外部,那么多线程运行时可能就会由于指令重排序造成影响

双重校验锁实现单例模式

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    
    public static Singleton getInstance() { 
        if(INSTANCE == null) { // t2
            // 首次访问会同步,而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton(); 
                } 
            }
        }
        return INSTANCE;
    }
}

多线程环境下,上述代码是有问题的,下边是上述代码中从if(INSTANCE == null) 往后的字节码

0:获取静态变量INSTANCE

3:判断是否为null,如果不为null,跳转到37,获取静态变量INSTANCE,然后返回

6:获取Singleton.class类对象

8:将类对象复制一份引用,用于解锁

9:将该引用保存起来

10:加锁

11:获取静态变量INSTANCE

14:判断INSTANCE是否为null,不为null,执行27,拿到锁的类对象,然后释放锁,跳到37,

17new一个Singleton对象,分配内存

20:复制一份这个对象的引用

21:对象初始化

24:将创建好的对象赋值给INSTANCE

其中

  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 引用地址
  • 21 表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行

先执行24,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例


解决方法

INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) { 
            synchronized (Singleton.class) { // t2
                // 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) { // t1
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

字节码

  • 可见性

    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  • 有序性

    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后,因此21一定是在24之前执行的,即先初始化再赋值
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前



6、happens-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见


1、线程解锁 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();

2、线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

volatile static int x;

new Thread(()->{
    x = 10;
},"t1").start();

new Thread(()->{
    System.out.println(x);
},"t2").start();

3、线程 start 前对变量的写,对该线程开始后对该变量的读可见

static int x; 
x = 10;

new Thread(()->{
    System.out.println(x);
},"t2").start();

4、线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束),因为线程结束后,会将变量的修改写回主存

static int x;

Thread t1 = new Thread(()->{
    x = 10;
},"t1");
t1.start();

t1.join();
System.out.println(x);

5、线程 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);
}

6、对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

7、具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

x使用了volatile修饰,因此在x=20后会加入写屏障,写屏障之前的写操作都会写入主存,虽然y没有使用volatile修饰,但是对y的修改也会写入主存

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();
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值