JUC学习笔记——共享模型之内存
阅读原文时间:2023年07月08日阅读:3

JUC学习笔记——共享模型之内存

在本系列内容中我们会对JUC做一个系统的学习,本片将会介绍JUC的内存部分

我们会分为以下几部分进行介绍:

  • Java内存模型
  • 可见性
  • 模式之两阶段终止
  • 模式之Balking
  • 原理之指令级并行
  • 有序性
  • volatile原理

我们首先来介绍一下Java内存模型:

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

JMM的主要作用如下:

  • 计算机硬件底层的内存结构过于复杂
  • JMM的意义在于避免程序员直接管理计算机底层内存,用一些关键字synchronized、volatile等可以方便的管理内存。

JMM主要体现在三个方面:

  • 原子性 - 保证指令不会受到线程上下文切换的影响 (我们在管程已经介绍过了)
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

这一小节我们来介绍可见性

可见性问题

首先我们根据一段代码来体验什么是可视性:

// 我们首先设置一个run运行条件设置为true,在线程t运行1s之后,我们在主线程修改run为false希望停下t线程

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    sleep(1);
    run = false;
}

// 线程t不会如预想的停下来!

我们进行简单的分析:

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

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

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

可见性解决

我们提供两种可见性的解决方法:

  1. volatile(易变关键字)

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

    // 我们首先设置一个run运行条件设置为true,在线程t运行1s之后,我们在主线程修改run为false希望停下t线程

    static volatile boolean run = true;
    public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
    while(run){
    // ….
    }
    });
    t.start();
    sleep(1);
    run = false;
    }

    // 这时程序会停止!

  2. synchronized(锁关键字)

    // 我们对线程内容进行加锁处理,synchronized内部会自动封装对其主存进行查找

    static Object obj = new Object();
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
    synchronized(obj){
    while(run){
    // ….
    }
    }
    });
    t.start();
    sleep(1);
    run = false;
    }

    // 这时程序会停止!

可见性解决方法对比

我们对volatile和synchronized两种方法进行简单对比:

  • volatile只能保证可见性和有序性,synchronized可以保证可见性,有序性和原子性
  • volatile属于轻量级操作,synchronized属于重量级操作;前者的各部分消耗量较少,性能较高

我们在这里介绍一下为什么synchronized能进行可见性问题解决:

  • JMM关于synchronized的两条规定:
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值

关于volatile的讲解我们会在后面单独列出

我们在这一小节来修改之前讲解的两阶段终止模式

模式简介

我们重新回顾一下两阶段终止模式:

  • 在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

我们给出具体模式图:

原版模式

我们首先介绍错误的一些方法:

  • 使用线程对象的 stop() 方法停止线程

    • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程

    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

然后我们再来回想一下我们之前所使用的方法:

/*主函数*/

public class Main(){
    public static void main(String[] args){
        TPTInterrupt t = new TPTInterrupt();
        t.start();
        Thread.sleep(3500);
        log.debug("stop");
        t.stop();
    }
}

/*模式函数(采用interrupt以及isInterrupt判断来决定是否打断进程)*/

class TPTInterrupt {

    private Thread thread;

    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                if(current.isInterrupted()) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                    //打断sleep线程会清除打断标记,所以要添加标记
                    current.interrupt();
                }
                // 执行监控操作
            }
        },"监控线程");
        thread.start();
    }

    public void stop() {
        thread.interrupt();
    }
}

/*结果展示*/

11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

改版模式

但是在我们学习了Volatile方法之后,我们可以修改上述代码:

/*主函数*/

public class Main(){
    public static void main(String[] args){
        TPTVolatile t = new TPTVolatile();
        t.start();
        Thread.sleep(3500);
        log.debug("stop");
        t.stop();
    }
}

/*修改后的模式函数*/

class TPTVolatile {

    private Thread thread;

    // 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
    private volatile boolean stop = false;

    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                // 我们采用stop变量来判断是否结束进程
                if(stop) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                     }
                // 执行监控操作
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
        // 调用后,修改stop,让主线程停止操作
        stop = true;
        //让线程立即停止而不是等待sleep结束
        thread.interrupt();
    }
}

/*结果展示*/
11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事

我们在这一小节来讲解新的模式Balking

模式简介

我们首先来简单介绍一下模式:

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

该模式的用途如下:

  • 设置某个方法只能调用一次
  • 适用于单例对象的构造方法

模式格式

我们直接给出该模式的模板代码:

public class MonitorService {

    // 用来表示是否已经有线程已经在执行启动了
    private volatile boolean starting;

    // 测试模板的方法
    public void start() {
        log.info("尝试启动监控线程...");
        // 首先我们需要先锁住内部信息,防止多线程时导致混乱(因为内部存在数据变动,可能无法导致原子性)
        synchronized (this) {
            // 我们先来判断是否该方法已执行,若已执行直接返回即可
            if (starting) {
                return;
            }
            // 若未执行,实施方法,并将参数设置为true使后续线程无法使用
            starting = true;
        }
        //其实synchronized外面还可以再套一层if,或者改为if(!starting),if框后直接return
        // 真正启动监控线程...
    }
}

我们再给出一套单例创建对象的案例:

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

我们在这一小节来讲解新的原理指令级并行

概念讲解

在正式进入原理讲解之前我们需要明白几个概念:

  • Clock Cycle Time

    主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能 够识别的最小时间单位

  • CPI

    有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数

  • IPC

    IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数

  • CPU 执行时间

    程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示

    程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time

流水线操作

我们要讲的指令级并行实际上就是概念化的流水线操作:

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

我们给出流水线操作图:

指令重排序优化

我们首先来介绍一下指令重排:

  • 指令重排是由JIT即时编译器所控制的
  • 它会在不影响当前线程的执行结果的前提下,在底层进行指令顺序方面的调整

我们给出一个指令重排的例子:

// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );

// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2

其实指令重排优化就是由流水线操作来演变过来的:

  • 事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?
  • 可以想到指令 还可以再划分成一个个更小的阶段
  • 例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

我们给出一张指令级并排操作的展示图:

这一小节我们来介绍可见性

有序性问题

我们同样采用一个问题来引出有序性概念:

/*代码展示*/

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

/*结果展示(多次执行)*/

// 我们会发现1,4都是按照正常逻辑执行,但是0原本来说不应该出现
*** INTERESTING tests
 Some interesting behaviors observed. This is for the plain curiosity. 

 2 matching test results.
     [OK] test.ConcurrencyTest
     (JVM args: [-XX:-TieredCompilation])
    Observed state     Occurrences     Expectation Interpretation
    0                 1,729           ACCEPTABLE_INTERESTING !!!!
     1               42,617,915      ACCEPTABLE ok
     4               5,146,627       ACCEPTABLE ok 

     [OK] test.ConcurrencyTest
     (JVM args: [])
     Observed state  Occurrences     Expectation Interpretation
     0               1,652           ACCEPTABLE_INTERESTING !!!!
     1               46,460,657      ACCEPTABLE ok
     4               4,571,072       ACCEPTABLE ok 

/*结果分析*/

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1 

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1 

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

// 由于指令重排,num = 2;ready = true; 都不会导致该线程出现错误,所以可能会将 ready = true操作先进行执行!
特殊情况:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2

有序性解决

我们同样可以采用两种方法进行解决:

  1. volatile(易变关键字)

    /代码展示/

    public class ConcurrencyTest {

    int num = 0;
    
    // 在加上volatile之后,会导致ready写操作以及写之前的操作不会发生指令重排
    // 在加上volatile之后,会导致ready读操作以及读之后的操作不会发生指令重排
    volatile boolean ready = false;
    
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

    }

  2. synchronized(锁关键字)

    /代码展示/

    public class ConcurrencyTest {

    int num = 0;
    
    boolean ready = false;
    
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    public void actor2(I_Result r) {
        // synchronized会控制指令顺序不发生改变
        synchronized(this){
            num = 2;
            ready = true;
        }
    }

    }

我们将在这一小节彻底解决volatile原理层面的问题

volatile原理前提

我们首先需要知道volatile是依靠什么完成操作的:

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

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

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

volatile可见性保证

首先我们来查看写屏障:

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

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

我们给出一张读写屏障的流程图:

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

我们同样给出一张流程图:

但是我们需要注意的是:

  • volatile不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去

  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

我们针对注意点给出一张解释图:

double-checked locking 问题

我们来进行一个简单的问题解析:

// 以著名的 double-checked locking 单例模式为例

public final class Singleton {

    private Singleton() { }

    // 这里创建了唯一一个单例对象
    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        // 我们首先对INSTANCE进行检测
        // (这一步是为了保证我们只有在创造对象的那一步需要涉及到锁,对于后面的获取方法不要涉及锁,加快速率)
        if(INSTANCE == null) {
            // 这一步是为了保证多线程同时进入时,防止由于线程指令参杂而导致两次赋值
            synchronized(Singleton.class) {
                // 我们需要再次进行判断,因为当t1线程执行到锁中时,可能有t2进程也通过了第一个if判断,
                // 如果不添加这一步,就会导致t2进程进入后直接再次赋值,导致两次赋值
                if (INSTANCE == null) {
                    // 在不出现任何问题下,我们对唯一对象进行创建
                    INSTANCE = new Singleton();
                }
            }
        }
        // 如果已有对象,我们直接调用即可
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

我们查看上述代码,会感觉所有内容都毫无疏漏,但是如果是多线程情况下,出现线程的指令重排就会导致错误产生:

/*源代码展示*/

0: getstatic #2         // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
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 表示创建对象,将对象引用入栈
- 20 表示复制一份对象引用
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE 

/*指令重排问题*/
在正常情况下,我们会按照17,20,21,24的顺序执行
但是如果发生指令重排问题,导致21,24交换位置,就会导致先进行赋值,再去创建对象
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
如果同时我们的t2线程去运行,就会导致直接调用那个未初始化完毕的单例,会导致很多功能失效!

我们针对上述重排问题给出一张流程图:

double-checked locking 解决

其实解决方法很简单:

  • 在INSTANCE对象上添加一个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;
    }
}

/*字节码展示(带有屏障解释)*/

// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2         // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
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;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
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

/*具体解析*/

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面 两点:
- 可见性
  - 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
  - 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
  - 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  - 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

更简单来说:
- 由于写屏障的前面不会发生指令重排,我们的21和24顺序不会颠倒,我们的赋值一定是已经完成初始化的赋值!

happens-before

我们来介绍一下happens-before:

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

我们来进行总结:

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

    static int x;

    x = 10;

    new Thread(()->{
    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. 线程解锁 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();

  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 被打断后对变量的读可见

    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(()->{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        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

此外我们还需要注意几点:

  • 变量都是指成员变量或静态成员变量
  • happens-before规则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据

happens-before主要遵循以下几点规则:

  • 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。

  • 监视器规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

  • volatile规则:对一个volatile变量的写,happens-before于任意后续对一个volatile变量的读。

  • 传递性:若果A happens-before B,B happens-before C,那么A happens-before C。

  • 线程启动规则:Thread对象的start()方法,happens-before于这个线程的任意后续操作。

  • 线程终止规则:线程中的任意操作,happens-before于该线程的终止监测。

    我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

  • 线程中断操作:对线程interrupt()方法的调用,happens-before于被中断线程的代码检测到中断事件的发生

    可以通过Thread.interrupted()方法检测到线程是否有中断发生。

  • 对象终结规则:一个对象的初始化完成,happens-before于这个对象的finalize()方法的开始。

经典习题

我们首先补充两点概念:

  • 饿汉式:类加载就会导致该单实例对象被创建
  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

我们最后来介绍几道经典习题

  1. balking 模式习题

    /* 希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么? */

    public class TestVolatile {

    volatile boolean initialized = false;
    
    void init() {
        if (initialized) {
            return;
        }
        doInit();
        initialized = true;
    }
    
    private void doInit() {
    }

    }

    /解析/

    存在问题!
    没有对init设置锁,可能会导致同时有多个线程调用,导致多次创造
    t1进入,判断未初始化,进行doInit(),t2进入,判断未初始化,也进行doInit(),然后两者才进行initialized=true的更改

  2. 线程安全单例习题1

    /* 代码展示 */

    // 问题1:为什么加 final
    // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
    public final class Singleton implements Serializable {
    // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
    private Singleton() {}
    // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
    private static final Singleton INSTANCE = new Singleton();
    // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
    public static Singleton getInstance() {
    return INSTANCE;
    }
    public Object readResolve() {
    return INSTANCE;
    }
    }

    /* 问题解析*/
    1.(防止被子类继承从而重写方法改写单例)
    2.(重写readResolve方法)
    3.(防止外部调用构造方法创建多个实例;不能)
    4.(能,线程安全性由类加载器保障)
    5.(可以保证instance的安全性,也能方便实现一些附加逻辑)

  3. 线程安全单例习题2

    /* 代码展示 */

    // 问题1:枚举单例是如何限制实例个数的
    // 问题2:枚举单例在创建时是否有并发问题
    // 问题3:枚举单例能否被反射破坏单例
    // 问题4:枚举单例能否被反序列化破坏单例
    // 问题5:枚举单例属于懒汉式还是饿汉式
    // 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
    enum Singleton {
    INSTANCE;
    }

    /* 问题解析 */
    1.(枚举类会按照声明的个数在类加载时实例化对象)
    2.(没有,由类加载器保障安全性)
    3.(不能)
    4.(不能)
    5.(饿汉)
    6.(写构造方法)

  4. 线程安全单例习题3

    /* 代码展示 */

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

    /问题解析/
    (没有线程安全问题,同步代码块粒度太大,性能差)

  5. 线程安全单例习题4

    /* 代码展示 */

    public final class Singleton {
    private Singleton() { }
    // 问题1:解释为什么要加 volatile ?
    private static volatile Singleton INSTANCE = null;

    // 问题2:对比实现3, 说出这样做的意义 (缩小了锁的粒度,提高了性能)
    
    public static Singleton getInstance() {
        if (INSTANCE != null) {
            return INSTANCE;
        }
        synchronized (Singleton.class) {
            // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
            if (INSTANCE != null) { // t2
                return INSTANCE;
            }
            INSTANCE = new Singleton();
            return INSTANCE;
        }
    }

    }

    /问题解析/
    1.(防止putstatic和invokespecial重排导致的异常)
    2.(缩小了锁的粒度,提高了性能)
    3.(为了防止同时有线程进入,在第一个线程创建后,其他线程进入锁后再次创建)

  6. 线程安全单例习题5

    /代码展示/

    public final class Singleton {
    private Singleton() { }
    // 问题1:属于懒汉式还是饿汉式
    private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
    }
    // 问题2:在创建时是否有并发问题
    public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
    }
    }

    /问题解析/
    1.(懒汉式,由于初始化方法是在该对象第一次调用时才初始化,同样是属于类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建)
    2.(没有并发问题,该对象的创建是在初始化创建,初始化只有一次,不会多次创建,不会修改,也没有并发问题,由系统保护)

下面介绍一下本篇文章的重点内容:

  • 可见性 - 由 JVM 缓存优化引起
  • 有序性 - 由 JVM 指令重排序优化引起
  • happens-before 规则
  • 原理方面
    • CPU 指令并行
    • volatile
  • 模式方面
    • 两阶段终止模式的 volatile 改进
    • 同步模式之 balking

结束语

到这里我们JUC的共享模型之管程就结束了,希望能为你带来帮助~

附录

该文章属于学习内容,具体参考B站黑马程序员满老师的JUC完整教程

这里附上视频链接:05.001-本章内容_哔哩哔哩_bilibili