【JUC】第五章:共享模型之内存


上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。
这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题。

一、Java 内存模型

JMM 即 Java Memory Model,它从 Java 层面定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。JMM 体现在一下几个方面

  • 原子性:保证指令不会受线程上下文切换的影响。
  • 可见性:保证指令不回受 CPU 缓存的影响(JIT 堆热点代码的缓存优化)
  • 有序性:保证指令不会受 CPU 指令并行优化的影响。

之前讲的 synchronized 底层 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。

1.1 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。

1.1.1 举个例子:退不出的循环
import static java.lang.Thread.sleep;
public class Test1 {
    private static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {

            }
        });
        t.start();
        sleep(1000);
        run = false;
    }
}

为什么会发生这种情况,我们分析一下:

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

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

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


可见性问题,这里涉及到 Java 内存模型(JMM)
JMM 描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取出变量这样的底层细节。
在内存模型中,所有的变量都存储在主内存中。每个线程都有自己独立的工作内存,里面保存着该线程使用到的变量的副本。
内存模型图:

JMM 线程操作内存的两条基本规定:

  1. 关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
  2. 关于线程间工作内存:不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要经过主内存来完成。

共享变量可见性实现的原理:
线程 1 对共享变量的修改要想被线程 2 及时看到,必须要经过两个步骤:

  1. 把工作内存 1 中更新过的共享变量刷新到主内存中
  2. 把内存中最新的共享变量的值更新到工作内存 2 中。

1.1.2 解决方法
  • 使用 volatile(表示易变关键字的意思),它可以用来修饰 成员变量静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。
  • 使用synchronized 关键字也有相同的效果,在Java内存模型中,synchronized规定,线程在加锁时,需要先清空工作内存 -> 在主内存中拷贝最新变量的副本到工作内存 -> 执行完代码 -> 将更改后的共享变量的值刷新到主内存中 -> 释放互斥锁
1.1.3 可见性 vs 原子性

前面例子体现的实际就是可见性,它是指保证多个线程之间一个线程对volatile 变量的修改对另一个线程可见,而不能保证原子性。volatile用在一个写线程,多个读线程的情况,比较合适。
举个例子:两个线程一个i++,一个 i–,但是volatile只保证可见性,只能看到最新之,但不能解决指令交错问题(原子性)。

// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是它是属于重量级操作,性能相对较低。如果在前面实例的死循环中加入 System.out.println() 会发现即使不加volatile修饰符,线程t也能正确看到对run变量的修改了,想想是为什么?因为println方法里面有synchronized修饰。

1.1.4 模式之两阶段种植

我们之前的做法:当我们在执行线程一时,想要终止线程二,这就需要使用interrupt 方法来优雅的停止线程二。
使用volatile 关键字来实现两阶段终止模式。

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

        // 下面是两个线程操作共享变量stop
        Monitor monitor = new Monitor();
        monitor.start();

        Thread.sleep(3500);
        monitor.stop();
    }
}


class Monitor {

    // private boolean stop = false; // 不会停止程序
    private volatile boolean stop = false; // 会停止程序

    /**
     * 启动监控器线程
     */
    public void start() {
        Thread monitor = new Thread(() -> {
            //开始不停的监控
            while (true) {
                if (stop) {
                    break;
                }
            }
        });
        monitor.start();
    }

    /**
     * 用于停止监控器线程
     */
    public void stop() {
        stop = true;
    }
}

1.2 有序性

由于JIT即时编译器的优化,可能会导致指令重排。CPU支持多级指令流水线,例如支持同时执行·取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器。效率很快。

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

线程1执行actor1方法, 线程2执行actor2方法

  • 情况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。
1.2.1 重排序需要遵守一定规则

指令重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1,b=a;这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序/
指令重排序在单线程模式 下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。
解决方法:volatile 修饰的变量,可以禁用指令重排。
注意:

  • 使用synchronized并不能解决有序性问题,但是如果时该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题。在这种情况下相当于解决了重排序问题。

1.3 volatile原理

volatile是Java虚拟机提供的轻量级的同步机制。

  • 保证可见性
  • 不保证原子性
  • 保证有序性

volatile的底层实现原理时 内存屏障

  • 对volatile变量的写指令后会加入**写屏障,**保证写屏障之前的写操作,都能同步到主存中。
  • 对vloatile变量的读指令前会加入**读屏障,**保证读屏障之后的读操作,都能读到主存的数据。
1.3.1 volatile如何保证可见性
  • 写屏障:保证在该屏障之前的对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
     num = 2;
     ready = true; // ready是被volatile修饰的 ,赋值带写屏障
     // 写屏障.(在ready=true写指令之后加的, 
     //在该屏障之前对共享变量的改动, 都同步到主存中. 包括num)
}
  • 读屏障:保证在该屏障之后,对共享变量的读取,加载的都是主存中的最新数据。
public void actor1(I_Result r) {
         // 读屏障
         //  ready是被volatile修饰的 ,读取值带读屏障
         if(ready) {        // ready, 读取的就是主存中的新值
                 r.r1 = num + num; // num, 读取的也是主存中的新值
         } else {
                 r.r1 = 1;
         }
}

1.3.2 volatile 是如何保证有序性
  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
  • 读屏障会确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前。


volatile不能解决指令交错(不能解决原子性)

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

下图中的t2线程,就先读取了i=0,此时还是会出现指令交错,可以使用synchronized 来解决原子性。

1.4 double-checked locking (双重检查锁)问题

首先synchronized 可以保证它的临界区的资源是原子性、可见性、有序性 d的,有序性的前提是,在synchronzied代码块中的共享变量,不会再代码块中使用到,否正有序性不能被保证,只能使用volatile 来保证有序性。
最开始的单例模式:

// 最开始的单例模式是这样的
public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        /*
              多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
              判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
              防止多线程操作共享资源,造成的安全问题
             */
        synchronized(Singleton.class) {
            if (INSTANCE == null) { // t1
                INSTANCE = new Singleton();
            }
        }
        return INSTANCE;
    }
}

但是上面这样的代码是不够好的,如果我们已经创建好一个实例了,其他线程使用这个实例的时候还是会竞争锁,每个线程都要竞争锁,这样会严重影响性能,只要实例创建成功之后,其他线程应该直接拿到这个实例,不应该再去竞争锁。
所以我们需要双重检查,在加锁之前,就判断实例对象是否已经创建成功。如果成功,那直接返回实例对象,否正再去加锁然后创建实例对象。

/*
        首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁, 
        严重影响性能,再次判断INSTANCE==null吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE;
        这样导致了很多不必要的判断; 

        所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,
        如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行
        if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了. 提高效率
*/
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)判断代码没有在同步代码块synchronized中,
// 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排

以上的实现特点是:

  • 懒汉式单例
  • 首次使用getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。
  • 在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。

第一个 if 使用了 INSTANCE 变量,是在同步块之外这样会导致synchronized无法保证指令的有序性,此时可能会导致指令重排问题。

0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37 // 判断是否为空
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

指令重排:首先编译器执行指令的时候会存在指令重排的情况,以便于提高指令的执行速度。就像是考试一样,肯定是先把会的题做完再做难的题目。

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

JVM中实例化对象内存模型流程如下:

  1. 分配空间给对象
  2. 在空间内创建对象
  3. 将对象赋值给instance引用

假如出现如下顺序错乱的情况:
线程的执行顺序为:1 -> 3 -> 2, 那么这时候会把空指针赋值给instance(指令重排现象)
这一步INSTANCE = new Singleton();操作不是一个原子操作,它分为两个指令,此时可能就会发生指令重排的问题。

对于这个问题,我们可以给该成员变量加上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;
    }
}

读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  • 可见性
    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  • 有序性
    • 写屏障 会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障 会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

加上volatile之后,保证了指令的有序性,不会发生指令重排,21就不会跑到24之后执行了

总结:

  • synchronized 既能保证原子性、可见性、有序性 ,其中有序性是在该共享变量完全被synchronized 所接管(包括工作变量的读写操作),上面的例子中synchronized 外面的 if (INSTANCE == null) 中的INSTANCE读操作没有被synchronized 接管,因此无法保证INSTANCE共享变量的有序性(即不能防止指令重排)。
  • 对共享变量加volatile关键字可以保证可见性有序性,但是不能保证原子性(即不能防止指令交错)。
  • 16
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值