剑指JUC原理-8.Java内存模型

这篇具有很好参考价值的文章主要介绍了剑指JUC原理-8.Java内存模型。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

  • 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
  • 📕系列专栏:Spring源码、JUC源码
  • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
  • 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
  • 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

Java 内存模型

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

JMM 体现在以下几个方面

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

可见性

退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 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不会如预想的停下来
    }

为什么呢?分析一下:

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

解决方法

volatile(易变关键字)

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

可见性 vs 原子性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可
见, 不能保证原子性,仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false 

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错

// 假设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 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低。

synchronized 关键字可以确保多线程环境下的可见性,主要通过两个方面来实现:

互斥访问:当一个线程获取到某个对象的锁时,其他线程无法同时获取该对象的锁,只能等待锁释放。这样可以保证在同步块中对共享变量的修改操作是原子的,不会被其他线程中断。

内存可见性:当一个线程释放锁时,会将对共享变量的修改刷新到主内存中,而当另一个线程获取锁时,会从主内存中重新读取共享变量的值,确保看到最新的值。

以下是一个例子来说明 synchronized 关键字如何保证可见性:

public class SynchronizedExample {
    private static boolean flag = false;
	public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        synchronized (SynchronizedExample.class) {
            // 修改共享变量的值
            flag = true;
            System.out.println("Thread 1: flag is set to true");
        }
    });

    Thread thread2 = new Thread(() -> {
        // 暂停一段时间,确保 thread1 先执行
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        synchronized (SynchronizedExample.class) {
            // 在同步块中访问共享变量
            if (flag) {
                System.out.println("Thread 2: flag is true");
            } else {
                System.out.println("Thread 2: flag is false");
            }
        }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();
}
}

在这个例子中,有两个线程 thread1 和 thread2,它们同时访问了共享变量 flag。首先,thread1 获取了 SynchronizedExample.class 对象的锁,并将 flag 设置为 true,然后释放锁。接着,thread2 获取了同一个锁,并在同步块中访问 flag 的值。由于 thread2 获取到了锁并读取了 flag 的最新值,因此能正确地判断出 flag 的状态。

通过 synchronized 关键字的互斥性和内存可见性的特性,确保了多线程环境下的共享变量操作的一致性和可见性。

  • 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到
    对 run 变量的修改了,想一想为什么?
	static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(run){
                // ....
                System.out.println()}
        });
        t.start();
        sleep(1);
        run = false; // 线程t不会如预想的停下来
    }

通过分析 System.out.println() 源码来得出结果。

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

synchronized 是 Java 中用于实现同步的关键字,它可以用于修饰方法和代码块。当一个线程获取了对象的锁,执行 synchronized 修饰的代码时,会将变量从主内存中拷贝到线程的本地内存中。

在 synchronized 块执行结束后,JVM 会把该线程对应的本地内存中修改过的变量刷新回主内存中,以保证不同线程间所共享的变量值的一致性。

模式之两阶段终止

// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
    private Thread thread;
    private volatile boolean stop = false;
    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                if(stop) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                }
                // 执行监控操作
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
        stop = true;
        thread.interrupt(); // 如果设置为ture后,线程还在sleep状态,那么使用打断即可
    }
}

调用

TPTVolatile t = new TPTVolatile();
t.start();

Thread.sleep(3500);
log.debug("stop");
t.stop();

结果

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

实现
class TwoPhaseTermination {
    // 监控线程
    private Thread monitorThread;
    // 停止标记
    private volatile boolean stop = false;
    // 判断是否执行过 start 方法
    private boolean starting = false;

    // 启动监控线程
    public void start() {
        synchronized (this) {
            if (starting) { // false
                return;
            }
            starting = true;
            monitorThread = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                // 是否被打断
                if (stop) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("执行监控记录");
                } catch (InterruptedException e) {
                }
            }
        }, "monitor");
        monitorThread.start();
        }
    }

    // 停止监控线程
    public void stop() {
        stop = true;
        monitorThread.interrupt();
    }
}

public static void main(String[] args) throws InterruptedException {
        TwoPhaseTermination tpt = new TwoPhaseTermination();
        tpt.start();
        tpt.start();
        tpt.start();

        /*Thread.sleep(3500);
        log.debug("停止监控");
        tpt.stop();*/
    }

当前端页面多次点击按钮调用 start 时
输出

[http-nio-8080-exec-1] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(false)
[http-nio-8080-exec-1] cn.itcast.monitor.service.MonitorService - 监控线程已启动...
[http-nio-8080-exec-2] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)
[http-nio-8080-exec-3] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)
[http-nio-8080-exec-4] cn.itcast.monitor.service.MonitorService - 该监控线程已启动?(true)

一个优化就是 尽可能的让synchronized代码块中的代码较少,所以可以将不关键的因素抽取出来

public void start() {
        synchronized (this) {
            if (starting) { // false
                return;
            }
            starting = true;
            
        }
    monitorThread = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                // 是否被打断
                if (stop) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("执行监控记录");
                } catch (InterruptedException e) {
                }
            }
        }, "monitor");
        monitorThread.start();
    }

它还经常用来实现线程安全的单例

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

有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;

// 在某个线程内执行如下赋值操作
i = ...; 
j = ...; 

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

i = ...; 
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU
执行指令的原理来理解一下吧

指令级并行原理

名词
Clock Cycle Time

主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能
够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的
Cycle Time 是 1s

例如,运行一条加法指令一般需要一个时钟周期时间

CPI

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

IPC

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

CPU 执行时间

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

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

鱼罐头的故事

加工一条鱼需要 50 分钟,只能一条鱼、一条鱼顺序加工…

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

可以将每个鱼罐头的加工流程细分为 5 个步骤:

  • 去鳞清洗 10分钟
  • 蒸煮沥水 10分钟
  • 加注汤料 10分钟
  • 杀菌出锅 10分钟
  • 真空封罐 10分钟

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

即使只有一个工人,最理想的情况是:他能够在 10 分钟内同时做好这 5 件事,因为对第一条鱼的真空装罐,不会
影响对第二条鱼的杀菌出锅…

指令重排序优化

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中
叶到 90’s 中叶占据了计算架构的重要地位。(分阶段,分工是提升效率的关键!)

指令重排的前提是,重排指令不能影响结果,例如

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

// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
支持流水线的处理器

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

SuperScalar 处理器

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单
元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC>1

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

诡异的结果

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

有一个属性 r1 用来保存结果,问,可能的结果有几种?

有同学这么分析

  • 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  • 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但我告诉你,结果还有可能是 0 😁😁😁,信不信吧!

这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
相信很多人已经晕了 😵😵(原因就是jvm对这 num 和 ready进行了指令重排序,那么结果可能是0)

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:

借助 java 并发压测工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress

mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -
DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=cn.itcast -DartifactId=ordering -Dversion=1.0 

创建 maven 项目,提供如下测试类

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
    int num = 0;
    boolean ready = false;
    @Actor
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

执行

mvn clean install 
java -jar target/jcstress.jar

会输出我们感兴趣的结果,摘录其中一次结果:

	*** 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

可以看到,出现结果为 0 的情况有 一千多次,虽然次数相对很少,但毕竟是出现了。

解决方法

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

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
    int num = 0;
    volatile boolean ready = false;
    @Actor
    public void actor1(I_Result r) {
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;// 在这上面加 volatile 能够防止 之前的代码被重排序
    }
}

结果为:

*** INTERESTING tests 
 Some interesting behaviors observed. This is for the plain curiosity. 
 
 0 matching test results.
 

volatile 原理

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

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

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

还是那句话,不能解决指令交错:

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

volatile底层的读写屏障只是 保证了可见性 和 有序性,还是不能解决指令交错

而synchronized都可以做到,有序 可见 原子

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

double-checked locking 问题

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

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

以上的实现特点是:

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

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

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

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取
INSTANCE 变量的值。
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初
始化完毕的单例

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

这里面,前面介绍的synchronized不是太严谨 。能保证 原子 可见 有序

synchronized仍然是可以被重排序的,并不能组织重排序,volatile才能组织重排序,但是如果共享变量完全被synchronized 所保护,那么共享变量在使用的过程中是不会有 原子 可见 有序问题的,就算中间发生了重排序,但是只要完全交给synchronized管理,是不会有有序性问题的。

刚才使用出现问题,是因为共享变量并没有完全的被synchronized保护起来,synchronized外面还有共享变量的使用

double-checked locking 解决
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 指令的效果

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

可见性

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

而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据

有序性

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

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

剑指JUC原理-8.Java内存模型,JUC,java,开发语言,juc

happens-before

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

线程解锁 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();
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;

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

new Thread(()->{
 System.out.println(x);
},"t2").start();
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;

x = 10;

new Thread(()->{
 System.out.println(x);
},"t2").start();
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x;

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

t1.start();
t1.join();
System.out.println(x);
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
Thread t2 = new Thread(()->{
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        },"t2");

        t2.start();
        new Thread(()->{
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x = 10;
            t2.interrupt();
        },"t1").start();
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
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();

主要是 写屏障之前都同步到主内存

balking 模式习题

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

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

其实这里,在多线程的情况下,因为没有保证多线程之间的原子性,所以会出现问题。

线程安全单例习题

单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用
getInstance)时的线程安全,并思考注释中的问题

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

实现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:反序列化维护的对象,和单例维护的对象不一样,因为 需要加上
public Obejct readResolve(){
return INSTANCE;
}

反序列化的过程中,一旦发现了readResolve 返回的对象,就会用你返回的对象,而不是反序列化字节码生成的对象。

问题3: 设置成public,别的类能够无限的创建对象了,并不能够防止反射

问题4: 静态成员变量,初始化操作是在类加载的时候。类加载阶段是由jvm来保证这些代码的线程安全性。所以类加载阶段做成员赋值都是线程安全的。

问题5: 用方法说明提供了更好的封装性,可以内部实现懒惰的初始化。还可以对创建的时间有更多的控制。

实现2(※※※重点难点※※※)

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

问题1: 枚举单例是一种实现单例模式的方式,它通过使用枚举类型来限制实例个数为一个。在Java中,枚举类型是保证全局唯一的,因此使用枚举来实现单例可以有效地避免多线程环境下的并发问题,并且在序列化和反序列化中也可以得到保证。

问题2: 是静态成员变量,类加载阶段完成的,不会有并发问题

问题3: 枚举单例是一种实现单例模式的有效方式,因为它可以避免通过反射和序列化等方式破坏单例。枚举类型在Java中是天然的单例,只能在JVM中被实例化一次。当使用反射来访问枚举类型时,JVM会保证每个枚举类型只被实例化一次。这是因为枚举类型在Java中是特殊的类,由JVM特别处理。因此,即使使用反射来访问枚举类型并试图创建一个新的实例,JVM也会返回已经存在的单例。因此,枚举单例不能被反射破坏单例。

问题4: 枚举单例在Java中也可以防止被反序列化破坏单例。当一个枚举类型被序列化并再次反序列化时,JVM会自动确保只存在一个实例。这是因为枚举类型的序列化和反序列化是由JVM处理的,并且JVM会保证对于同一个枚举类型只有一个实例。因此,尝试通过反序列化来破坏枚举单例是无效的,反序列化操作会返回已经存在的单例实例,而不会创建新的实例。总之,枚举单例是一种安全且可靠的单例实现方式,即使在面对反射和序列化等特性时也能保持单例的完整性。

问题5: 饿汉式

问题6: 如果希望在枚举单例创建时加入一些初始化逻辑,可以在枚举中定义一个构造函数,并将初始化逻辑放在其中

public enum SingletonEnum {
    INSTANCE;
	private SingletonEnum() {
    	// 这里可以加入单例创建时的初始化逻辑
    	System.out.println("SingletonEnum has been initialized.");
	}
	// 其它方法
}

在上面的示例中,我们定义了一个名为SingletonEnum的枚举,其中INSTANCE是该枚举的唯一实例。同时,我们定义了一个构造函数来进行单例的初始化逻辑。

此处需要注意,在枚举类型中,构造函数必须是私有的。这是因为Java语言规范规定,只能在枚举类型内部定义枚举常量,而枚举常量的创建是由编译器自动生成的。因此,枚举类型的构造函数必须是私有的,以确保只有编译器才能调用它。

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

性能问题:

由于synchronized关键字锁住的是整个方法,在多线程高并发的情况下,可能会导致性能下降。每次调用getInstance()时都需要获取锁,即使实例已经被创建。

实例提前创建:在多线程环境下,如果有一个线程获取锁并创建了实例,其它线程进入方法后会发现实例已被创建,但仍需要等待锁的释放才能继续执行。这样就造成了实例的提前创建,占用了内存资源。

实现4:DCL

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: 保障了可见性 和 有序性

可见性

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

有序性

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

问题2: 这种方式在第一次检查时可以避免不必要的锁竞争,提高了性能。并且通过使用volatile关键字修饰实例变量,保证了线程之间对实例的可见性,确保正确的初始化。这样既满足了线程安全性,又提高了性能。

问题3: 其实是为了防止最一开始同时有多个线程 绕过了第一个 不等于 null的判断

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

这段代码使用了静态内部类的方式实现了单例模式,常被称为“静态内部类单例模式”。

属于懒汉式还是饿汉式?
该实现方式属于懒汉式,因为实例对象的创建发生在静态内部类LazyHolder被调用时。而不是在类加载时就创建实例对象。

在创建时是否有并发问题?
由于静态内部类LazyHolder在类加载时并不会被初始化,只有当getInstance()方法被调用时才会加载,因此不存在并发创建实例的问题。

同时,由于Java虚拟机在加载类时会对类进行加锁,所以多线程同时加载Singleton类也不会导致并发问题。

这种实现方式同时具备了懒汉式和饿汉式的优点。在需要使用实例对象时才会进行实例化,避免了饿汉式可能造成的资源浪费;同时在加载静态内部类时,JVM会自动加锁,保证了线程安全性。因此,该实现方式既满足了线程安全性,又提高了性能和资源利用率。

相关推荐博客

👉👉👉 剑指JUC原理-1.进程与线程-CSDN博客

👉👉👉 剑指JUC原理-2.线程-CSDN博客

👉👉👉 剑指JUC原理-3.线程常用方法及状态-CSDN博客

👉👉👉 剑指JUC原理-4.共享资源和线程安全性-CSDN博客

👉👉👉 剑指JUC原理-5.synchronized底层原理-CSDN博客

👉👉👉 剑指JUC原理-6.wait notify-CSDN博客

👉👉👉 剑指JUC原理-7.线程状态与ReentrantLock-CSDN博客

👉👉👉 剑指JUC原理-9.Java无锁模型-CSDN博客

👉👉👉 剑指JUC原理-10.并发编程大师的原子累加器底层优化原理(与人类的优秀灵魂对话)-CSDN博客

👉👉👉 剑指JUC原理-11.不可变设计-CSDN博客

👉👉👉 剑指JUC原理-12.手写简易版线程池思路-CSDN博客

👉👉👉 剑指JUC原理-13.线程池-CSDN博客

👉👉👉 剑指JUC原理-14.ReentrantLock原理-CSDN博客

👉👉👉 剑指JUC原理-15.ThreadLocal-CSDN博客

👉👉👉 剑指JUC原理-16.读写锁-CSDN博客

👉👉👉 剑指JUC原理-17.CompletableFuture-CSDN博客

👉👉👉 剑指JUC原理-18.同步协作-CSDN博客

👉👉👉 剑指JUC原理-19.线程安全集合-CSDN博客

👉👉👉 剑指JUC原理-20.并发编程实践-CSDN博客文章来源地址https://www.toymoban.com/news/detail-737339.html

到了这里,关于剑指JUC原理-8.Java内存模型的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Java并发工具合集JUC大爆发!!!

    通常我们所说的并发包也就是java.util.concurrent(JUC),集中了Java并发的各种工具类, 合理地使用它们能帮忙我们快速地完成功能 。 作者: 博学谷狂野架构师 GitHub: GitHub地址 (有我精心准备的130本电子书PDF) 只分享干货、不吹水,让我们一起加油!😄 CountDownLatch是一个同步计

    2023年04月17日
    浏览(76)
  • java JUC并发编程 第六章 CAS

    第一章 java JUC并发编程 Future: link 第二章 java JUC并发编程 多线程锁: link 第三章 java JUC并发编程 中断机制: link 第四章 java JUC并发编程 java内存模型JMM: link 第五章 java JUC并发编程 volatile与JMM: link 第六章 java JUC并发编程 CAS: link 第七章 java JUC并发编程 原子操作类增强: link 第八章

    2024年02月10日
    浏览(46)
  • 【JUC-1】java多线程线程基础知识

    继承Thread类. 实现Runable接口. 实现Callable接口. Runable/Callable接口的实现, 都是重写其中的run/call方法, 实现任务逻辑, 再由线程执行器(可以是Thread类,也可以是线程池)并发执行run/call的逻辑. 而Thread类中的包含start方法, 可以控制线程启动,执行任务. 当发生线程上下文切换时, 操作系

    2024年02月11日
    浏览(39)
  • Java多线程(3)---锁策略、CAS和JUC

    目录 前言 一.锁策略 1.1乐观锁和悲观锁 ⭐ 两者的概念 ⭐实现方法 1.2读写锁  ⭐概念 ⭐实现方法 1.3重量级锁和轻量级锁 1.4自旋锁和挂起等待锁 ⭐概念 ⭐代码实现 1.5公平锁和非公平锁 1.6可重入锁和不可重入锁 二.CAS 2.1为什么需要CAS 2.2CAS是什么 ⭐CAS的介绍 ⭐CAS工作原理

    2024年02月13日
    浏览(60)
  • 【JavaEE】JUC(Java.util.concurrent)常见类

    经过前面文章的学习我们大致了解了如何实现多线程编程和解决多线程编程中遇到的线程不安全问题, java.util.concurrent 是我们多线程编程的一个常用包,那么今天我将为大家分享 java.util.concurrent 包下的其他几种常见的类。 ReentrantLock 是可重入互斥锁,跟 synchronized 定位是类似

    2024年02月08日
    浏览(38)
  • 【JAVAEE】JUC(java.util.concurrent)的常见类

    目录 1.Callable接口 1.1简介 1.2代码演示  1.3Runnable与Callable的区别 2.ReentrantLock 2.1ReentrantLock的常用方法 2.2ReentrantLock的代码演示 2.3ReentrantLock和synchronized的区别 3.Semaphore信号量 3.1概念 3.2代码演示 4.CountDownLatch 4.1概念 4.2代码演示 JUC是java.util.concurrent包的简称,JDK1.5之后对多线程的

    2024年02月05日
    浏览(38)
  • 【Java多线程】面试常考 —— JUC(java.util.concurrent) 的常见类

    目录 1、JUC(java.util.concurrent) 1.1、Callable 接口 1.2、ReentrantLock 可重入锁 1.3、Semaphore 信号量 1.4、CountDownLatch 这是java中的一个包,存放着 多线程编程 中常见的一些类。 【Java多线程】Thread类的基本用法-CSDN博客 https://blog.csdn.net/zzzzzhxxx/article/details/136121421?spm=1001.2014.3001.5501 往

    2024年04月10日
    浏览(83)
  • java高并发系列 - 第25天:掌握JUC中的阻塞队列

    这是java高并发系列第25篇文章。 环境:jdk1.8。 本文内容 掌握Queue、BlockingQueue接口中常用的方法 介绍6中阻塞队列,及相关场景示例 重点掌握4种常用的阻塞队列 Queue接口 队列是一种先进先出(FIFO)的数据结构,java中用Queue接口来表示队列。 Queue接口中定义了6个方法:

    2024年02月14日
    浏览(37)
  • Java基础-多线程&JUC-线程池和自定义线程池

    主要核心原理 不推荐Executors创建没有上线的线程池,建议使用自定义的线程池; Java工具类创建线程池; 当只有3个任务时,直接上处理机运行; 当有6个任务时,任务1-3上处理机运行,任务4-6进入阻塞队列等待; 当有9个任务时,任务1-3上处理机运行,任务4-6进入阻塞队列等

    2024年02月12日
    浏览(37)
  • Java并发体系-第三阶段-JUC并发包-[2]-CompleableFuture,SynchronousQueue

    java7中引入了一种新的可重复使用的同步屏障,称为移相器Phaser。Phaser拥有与 CyclicBarrier 和 CountDownLatch 类似的功能. 但是这个类提供了更加灵活的应用。CountDownLatch和CyclicBarrier都是只适用于固定数量的参与者。移相器适用于可变数目的屏障,在这个意义上,可以在任何时间注册

    2024年02月07日
    浏览(33)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包