【JavaEE初阶 — 多线程】内存可见性问题 & volatile

1. 内存可见性问题


内存可见性的概念


什么是内存可见性问题呢?

  • 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
  • 在Java中,可以借助 synchronized、volatile 以及各种Lock 实现可见性。
  • 如果我们将变量声明为 volatile,这就指示JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取,不能优化。

编译器优化


造成内存可见性问题的原因,是因为编译器优化的机制而造成的,我们先介绍一下什么是编译器优化。


背景


为什么要有编译器优化这样的机制呢?由于程序员的水平参差不齐,研究JDK 的大佬们,就希望通过让 编译器 & JVM 对程序员写的代码,自动的进行优化;


优点


  • 对于程序员原有的代码,编译器/JVM会在原有逻辑不变的前提下,对代码进行调整,使程序效率更高;这样的操作,就是编译器优化。
  • 也就是说,我们写的代码,编译器会在原有逻辑上,帮我们调整代码,通过编译器优化,可保证原有逻辑不变的前提下,对代码进行修改,使得执行效率得到提高。

缺点


  • 编辑器在编译代码的时候,其实它并没有执行代码,它只是根据这个编译的一个静态的代码,来分析这个程序应该怎么去进行调整;所以编译器 "保证原因逻辑不变,再进行优化 ",这样的 "保证" 并非是能够 100% 生效的;
  • 尤其是很多线线程的程序中,因为并发编程随机调度的特点,使得执行多线程程序的过程中,可能会出现诸多变数,这些变数可能会导致编译器出现判断失误;
  • 因此,编译器在针对不同的程序,能够做出的判断是有限,并且容易失误的。所以在经过编译器优化后的代码逻辑,与优化前的逻辑,可能会出现偏差。

案例描述


内存可见性问题是造成线程安全问题的原因之一,我们通过下面的代码来感受一下,内存可见性是如何造成线程安全问题的:

代码逻辑:


  • 我们定义一个成员变量 flag ,用来作为 t1 线程 run() 方法中的循环终止条件;
  • t2 线程用来修改 flag 的值;
  • 这样的操作,相当于一个线程进行读取,另一个线程进行修改;

原子性问题和可见性问题代码演示的区别


  • 这里演示因为内存可见性,造成线程安全问题,是令一个线程进行读取另一个线程进行修改;
  • 演示因为操作非原子性,而造成线程安全问题,是令两个线程同时修改同一个变量,所以两个线程都是在进行修改操作;

预期效果:


  • 只要我们通过 t2 ,输入给 flag 的数字是一个非零的值,就会使得我们 t1 线程的循环能够结束;

程序运行结果:

但是当我真正输入一个非零值的时候,回车,发现 t1 线程并没有结束循环,打印结束日志。


通过 Jconsole 观察 t1 线程的状态

  • 因为 t2 线程只有一次输入修改 flag 的操作,已经终止;
  • 观察到 t1 的线程状态是Runnable,正在持续执行循环;
  • 在 t2 线程输入非零值,能让 t1 线程循环结束,进而 t1 终止,这是我们预期结果;
  • 但是实际执行结果却并非如此;
  • 一个线程读取,一个线程修改,t2(修改线程) 修改的值,并没有被 t1(读取线程)读到,这就是因为编译器优化而造成的"内存可见性问题";

分析出现内存可见性问题的原因

对于上述代码中,t1 线程的循环判断条件 flag== 0,对其进行细分,会分出两个指令,分别是比较指令(==)和读取指令(读flag);

程序会先执行读取指令 load ,只有把 flag 这个变量在内存中的值,读取到寄存器中,才会执行比较指令 cmp;

而因为 load,cmp 两步指令是在循环中完成的,while 循环如果没有休眠限制,会在短时间内循环多次,从而重复执行多次 load -> cmp 这样的指令。

但是,load (读内存操作)和 cmp (纯CPU寄存器操作)两步指令的开销是非常大的;

load 的时间开销是 cmp 的几千倍,因为虽然读内存数据比读硬盘数据要快很多,但是如果是拿CPU寄存器和内存比,那就是寄存器快很多;

因此,在 t1 创建好后,run() 方法执行的时间,几乎都在load,cmp 的时间开销是可以忽略不计的;

所以在执行的过程中,JVM就能感知到,load 反复执行的结果是一样的;哪怕我们通过 t2 的 scanner 输入 flag 的时间只有不到 1s,但是站在计算机的角度,这 1s 可以说是沧海桑田;

因此,程序在执行的过程中,JVM 会感受到程序一直在反复读内存的值;

为了减小时间开销, t1 线程的读操作,会被编译器优化成:从读内存的值,到读CPU寄存器(t1 线程的工作内存)的值;

后续再执行 load 指令,就不会再重新读内存,而是直接从寄存器(工作内存)中读取,从而大大减小开销,并且提高了效率;

于是,等到很多秒后,用户真正输入新的值,真正修改 flag 的值,此时 t1 线程就感知不到了

(编译器优化,使得 t1 线程的读操作,不是读取内存)


2. JMM模型文档

JMM(Java 内存模型)详解


3. volatile


volatile 能保证内存可见性


内存可见性就是保证, 每次去读取的时候, 读取到的值都是最新的值(内存中的值),而不是之前缓存在寄存器中的值;volatile 修饰的变量,能够保证"内存可见性";


代码在写入 volatile 修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候,

  • 从主内存中读取 volatile 变量的最新值到线程的工作内存中
  • 从工作内存中读取 volatile 变量的副本

前面我们讨论内存可见性时说了,直接访问工作内存(实际是CPU 的寄存器或者 CPU 的缓存),速度

非常快,但是可能出现数据不一致的情况;


加上 volatile,强制读写内存,速度是慢了,但是数据变的更准确了:


相关推荐
2401_8576100326 分钟前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
希忘auto42 分钟前
详解MySQL安装
java·mysql
娅娅梨44 分钟前
C++ 错题本--not found for architecture x86_64 问题
开发语言·c++
汤米粥1 小时前
小皮PHP连接数据库提示could not find driver
开发语言·php
冰淇淋烤布蕾1 小时前
EasyExcel使用
java·开发语言·excel
拾荒的小海螺1 小时前
JAVA:探索 EasyExcel 的技术指南
java·开发语言
Jakarta EE1 小时前
正确使用primefaces的process和update
java·primefaces·jakarta ee
马剑威(威哥爱编程)1 小时前
哇喔!20种单例模式的实现与变异总结
java·开发语言·单例模式
白-胖-子2 小时前
【蓝桥等考C++真题】蓝桥杯等级考试C++组第13级L13真题原题(含答案)-统计数字
开发语言·c++·算法·蓝桥杯·等考·13级
好睡凯2 小时前
c++写一个死锁并且自己解锁
开发语言·c++·算法