• Java并发编程学习笔记4——共享模型之内存


    目录

    1、Java内存模型

    2、可见性

    2.1、退不出的循环

    2.2、解决方法

     2.3、可见性 vs 原子性

    2.3.1、设计模式——终止模式之两阶段终止模式

    2.3.2、设计模式——同步模式之Balking

    3、有序性

    3.1、指令重排序优化

    3.2、支持流水线的处理器

    3.3、诡异的结果

    3.4、volatile原理

    3.4.1、如何保证可见性

    3.4.2、如何保证有序性

    3.4.3、double-checked locking问题

    3.4.4、happens-before


    1、Java内存模型

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

    JMM体现在以下几个方面:

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

    2、可见性

    2.1、退不出的循环

    先来看一个现象,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止。

    1. public class Test1 {
    2. private static final Logger LOGGER = LoggerFactory.getLogger(Test1.class);
    3. static boolean run = true;
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t = new Thread(()->{
    6. while (run) {
    7. // ...
    8. }
    9. });
    10. t.start();
    11. Thread.sleep(1000);
    12. LOGGER.debug("停止t");
    13. run = false; // 线程t不会如预想的停下来
    14. }
    15. }
    16. // 结果:
    17. 19:35:44.615 [main] DEBUG com.multiThreads.test13.Test1 - 停止t
    18. (并没有终止)

    为什么呢?分析一下:

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

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

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

    2.2、解决方法

    volatile(易变关键字)。

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

    1. public class Test1 {
    2. private static final Logger LOGGER = LoggerFactory.getLogger(Test1.class);
    3. // 方式1:易变关键字
    4. static volatile boolean run = true;
    5. // 方式2:锁对象
    6. final static Object lock = new Object();
    7. public static void main(String[] args) throws InterruptedException {
    8. Thread t = new Thread(()->{
    9. while (run) {
    10. // ...
    11. }
    12. });
    13. t.start();
    14. Thread.sleep(1000);
    15. LOGGER.debug("停止t");
    16. run = false;
    17. }
    18. }
    19. 结果:
    20. 19:35:44.615 [main] DEBUG com.multiThreads.test13.Test1 - 停止t
    21. Process finished with exit code 0

     2.3、可见性 vs 原子性

    前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况。volatile只能保证看到最新值,不能解决指令交错。

    注意:synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级曹总,性能相对更低。

    2.3.1、设计模式——终止模式之两阶段终止模式

    两阶段终止(Two Termination):在一个线程T1中如何“优雅”终止线程T2?这里的【优雅】指的是给T2一个料理后事的机会。

    1、错误思路

    • 使用线程对象的stop()方法停止线程:stop()方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获得锁;
    • 使用System.exit(int)方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止。

    2、正确思路

    1. public class Test {
    2. private static final Logger LOGGER = LoggerFactory.getLogger(Test.class);
    3. public static void main(String[] args) throws InterruptedException {
    4. TwoPhaseTermination tpt = new TwoPhaseTermination();
    5. tpt.start();
    6. Thread.sleep(3500);
    7. LOGGER.debug("停止监控");
    8. tpt.stop();
    9. }
    10. }
    11. class TwoPhaseTermination {
    12. private static final Logger LOGGER = LoggerFactory.getLogger(TwoPhaseTermination.class);
    13. // 监控线程
    14. private Thread monitorThread;
    15. // 是否停止
    16. private volatile boolean isStop;
    17. // 启动监控线程
    18. public void start() {
    19. monitorThread = new Thread(()->{
    20. while(true) {
    21. Thread currentThread = Thread.currentThread();
    22. // 是否被打断
    23. if (isStop) {
    24. LOGGER.debug("料理后事");
    25. break;
    26. }
    27. try {
    28. Thread.sleep(1000);
    29. LOGGER.debug("执行监控记录");
    30. } catch (InterruptedException e) {
    31. // 该异常会清空打断标记(isInterrupt = false)
    32. e.printStackTrace();
    33. }
    34. }
    35. }, "monitor");
    36. monitorThread.start();
    37. }
    38. // 停止监控线程
    39. public void stop() {
    40. isStop = true;
    41. monitorThread.interrupt();
    42. }
    43. }
    44. 结果:
    45. 10:53:31.821 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    46. 10:53:32.825 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    47. 10:53:33.839 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    48. 10:53:34.324 [main] DEBUG com.multiThreads.test14.Test - 停止监控
    49. 10:53:34.324 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 料理后事
    50. java.lang.InterruptedException: sleep interrupted
    51. at java.lang.Thread.sleep(Native Method)
    52. at com.multiThreads.test14.TwoPhaseTermination.lambda$start$0(Test.java:38)
    53. at java.lang.Thread.run(Thread.java:748)
    54. [点击并拖拽以移动]

    2.3.2、设计模式——同步模式之Balking

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

    例如:

    1. public class Test {
    2. private static final Logger LOGGER = LoggerFactory.getLogger(Test.class);
    3. public static void main(String[] args) throws InterruptedException {
    4. TwoPhaseTermination tpt = new TwoPhaseTermination();
    5. tpt.start();
    6. tpt.start();
    7. tpt.start();
    8. Thread.sleep(3500);
    9. LOGGER.debug("停止监控");
    10. tpt.stop();
    11. }
    12. }
    13. class TwoPhaseTermination {
    14. private static final Logger LOGGER = LoggerFactory.getLogger(TwoPhaseTermination.class);
    15. // 监控线程
    16. private Thread monitorThread;
    17. // 是否停止
    18. private volatile boolean isStop = false;
    19. // 判断是否执行过start()方法
    20. private boolean starting = false;
    21. // 启动监控线程
    22. public void start() {
    23. synchronized (this) {
    24. if (starting) {
    25. return;
    26. }
    27. starting = true;
    28. }
    29. monitorThread = new Thread(() -> {
    30. while (true) {
    31. Thread currentThread = Thread.currentThread();
    32. // 是否被打断
    33. if (isStop) {
    34. LOGGER.debug("料理后事");
    35. break;
    36. }
    37. try {
    38. Thread.sleep(1000);
    39. LOGGER.debug("执行监控记录");
    40. } catch (InterruptedException e) {
    41. // 该异常会清空打断标记(isInterrupt = false)
    42. e.printStackTrace();
    43. }
    44. }
    45. }, "monitor");
    46. monitorThread.start();
    47. }
    48. // 停止监控线程
    49. public void stop() {
    50. isStop = true;
    51. monitorThread.interrupt();
    52. }
    53. }
    54. 结果:
    55. 11:09:35.158 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    56. 11:09:36.161 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    57. 11:09:37.163 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 执行监控记录
    58. 11:09:37.665 [main] DEBUG com.multiThreads.test14.Test - 停止监控
    59. 11:09:37.665 [monitor] DEBUG com.multiThreads.test14.TwoPhaseTermination - 料理后事
    60. java.lang.InterruptedException: sleep interrupted
    61. at java.lang.Thread.sleep(Native Method)
    62. at com.multiThreads.test14.TwoPhaseTermination.lambda$start$0(Test.java:49)
    63. at java.lang.Thread.run(Thread.java:748)
    64. Process finished with exit code 0

    3、有序性

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

    1. static int i;
    2. static int j;
    3. // 在某个线程内执行如下赋值操作
    4. i = ...;
    5. j = ...;

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

    1. i = ...;
    2. j = ...;

    也可以是:

    1. j = ...;
    2. i = ...;

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

    3.1、指令重排序优化

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

    术语参考:

    • Instruction Fetch(IF);
    • Instruction Decode(ID);
    • Execute(EX);
    • Memory Access(MEM);
    • Register Write Back(WB)。

    在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在80年代到90年代中叶占据了计算机架构的重要地位。

    提示:分阶段、分工是提升效率的关键!

    3.2、支持流水线的处理器

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

    提示:奔腾四(Pentium 4)支持高达35级流水线,但由于功耗太高被废弃。

    3.3、诡异的结果

    1. int num = 0;
    2. boolean ready = false;
    3. // 线程1执行此方法
    4. public void actor1(I_Result r) {
    5. if (ready) {
    6. r.r1 = num + num;
    7. } else {
    8. r.r1 = 1;
    9. }
    10. }
    11. // 线程2执行此方法
    12. public void actor2(I_Result r) {
    13. num = 2;
    14. ready = true;
    15. }

    I_Result是一个对象,有一个属性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。

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

    3.4、volatile原理

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

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

    3.4.1、如何保证可见性

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

    1. public void actor2(I_Result r) {
    2. num = 2;
    3. ready = true; // ready 是 volatile赋值带写屏障
    4. // 写屏障
    5. }

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

    1. public void actor1(I_Result r) {
    2. // 读屏障
    3. // ready 是 volatile 读取值带读屏障
    4. if (ready) {
    5. r.r1 = num + num;
    6. } else {
    7. r.r1 = 1;
    8. }
    9. }

    3.4.2、如何保证有序性

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

    1. public void actor2(I_Result r) {
    2. num = 2;
    3. ready = true; // ready 是 volatile赋值带写屏障
    4. // 写屏障
    5. }

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

    1. public void actor1(I_Result r) {
    2. // 读屏障
    3. // ready 是 volatile 读取值带读屏障
    4. if (ready) {
    5. r.r1 = num + num;
    6. } else {
    7. r.r1 = 1;
    8. }
    9. }

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

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

    3.4.3、double-checked locking问题

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

    1. class Singleton {
    2. private Singleton() {}
    3. private static Singleton INSTANCE = null;
    4. public static synchronized Singleton getInstance() {
    5. if (INSTANCE == null) {
    6. INSTANCE = new Singleton();
    7. }
    8. return INSTANCE;
    9. }
    10. }

    以上的实现特点是:

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

    但在多线程环境下,上面的代码是有问题的。

    3.4.4、happens-before

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

    • 线程解锁m之前对变量的写,对于接下来对m加锁的其它线程对该变量的读可见。
      1. public class Test1 {
      2. static int x;
      3. static Object m = new Object();
      4. public static void main(String[] args) {
      5. new Thread(()->{
      6. synchronized (m) {
      7. x = 10;
      8. }
      9. },"t1").start();
      10. new Thread(()->{
      11. synchronized (m) {
      12. System.out.println(x);
      13. }
      14. },"t2").start();
      15. }
      16. }
      17. 结果:10
    • 线程对volatile变量的写,对接下来其它线程对该变量的读可见:
      1. public class Test1 {
      2. volatile static int x;
      3. public static void main(String[] args) {
      4. new Thread(()->{
      5. x = 10;
      6. },"t1").start();
      7. new Thread(()->{
      8. System.out.println(x);
      9. },"t2").start();
      10. }
      11. }
      12. 结果:10
    • 线程start前对变量的写,对该线程开始后对该变量的读可见:
      1. public class Test1 {
      2. static int x;
      3. public static void main(String[] args) {
      4. x = 10;
      5. new Thread(()->{
      6. System.out.println(x);
      7. },"t2").start();
      8. }
      9. }
    • 线程结束前对变量的写,对其他线程得知它结束之后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束):
      1. public class Test1 {
      2. static int x;
      3. public static void main(String[] args) throws InterruptedException {
      4. Thread t1 = new Thread(() -> {
      5. x = 10;
      6. }, "t1");
      7. t1.start();
      8. t1.join();
      9. System.out.println(x);
      10. }
      11. }
      12. 结果:10
    • 线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupted或t2.isInterrupted())。
      1. public class Test1 {
      2. static int x;
      3. public static void main(String[] args) throws InterruptedException {
      4. Thread t2 = new Thread(()->{
      5. while(true) {
      6. if (Thread.currentThread().isInterrupted()) {
      7. System.out.println(x);
      8. break;
      9. }
      10. }
      11. }, "t2");
      12. t2.start();
      13. new Thread(()->{
      14. try {
      15. Thread.sleep(1000);
      16. x = 10;
      17. t2.interrupt();
      18. } catch (InterruptedException e) {
      19. e.printStackTrace();
      20. }
      21. }, "t1").start();
      22. while(!t2.isInterrupted()) {
      23. Thread.yield();
      24. }
      25. System.out.println(x);
      26. }
      27. }
      28. 结果:
      29. 10
      30. 10
    • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见;
    • 具有传递性,如果x hb-> y并且y hb-> z,那么有x hb-> z,配合volatile的防指令重排,有下面的例子:
      1. public class Test2 {
      2. volatile static int x;
      3. static int y;
      4. public static void main(String[] args) {
      5. new Thread(()->{
      6. y = 10;
      7. x = 20;
      8. }).start();
      9. new Thread(()->{
      10. // x=20对t2可见,同时y=10也对t2可见
      11. System.out.println(x);
      12. }, "t2").start();
      13. }
      14. }

  • 相关阅读:
    ESP32-C3 低功耗懒人开关:传统开关轻松上云和本地控制
    运动控制卡应用开发教程之调用激光振镜控制
    初阶JavaEE(17)Linux 基本使用和 web 程序部署
    【python_学习笔记】
    愚人节礼物(C++)
    【考研数学】线性代数第五章 —— 特征值和特征向量(1,理论背景与基本概念)
    LLM大模型实战 —— DB-GPT阿里云部署指南
    【沐风老师】3DMAX一键生成圣诞树建模插件使用教程
    JavaScript实现快速排序
    【JAVAWEB开发】基于Java+Servlet+Ajax+jsp网上购物系统设计实现
  • 原文地址:https://blog.csdn.net/weixin_44623055/article/details/126108016