Volatile 介绍
@
本文主要参考 《Java并发编程的艺术》以及一些博客
1 介绍
如果一个变量用了volatile修饰,那么这个变量是对所有线程共享的、可见的,每次jvm都会读取最新写入的值并使其最新值在所有CPU可见。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
2 Java 内存模型 JMM
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享 (本章用“共享变量”这个术语代指实例域,静态域和数组元素)。
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意如图所示。
3 特性
可见性指当一个线程修改了某一个共享变量的值,其他的线程是否能够立即知道这个修改。
有序性指对于一个线程的执行代码是依次执行的。在单线程中,无论怎么重排序都不会改变结果,但是在并发时,程序的执行可能会出现乱序。Java 为了提高性能,会有三种重排,编译器重排,指令并行重排,内存系统重排。
原子性指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
3.1 可见性
运行下面的程序,无法结束,在 idea 中可以 ctrl +F2 结束。这是因为某些线程 t2 中的 isStop 读的一直是自己本地的,即使对应的 t1 修改了也没有用。
如果有 volatile ,t1 修改了,t2 再读的时候,一定是从主内存中读到的,很快会结束。
下面的Thread.activeCount()>2
是 idea 的情况,如果是 eclipse 将 2 改为 1。
public class B {
//volatile
boolean isStop = false;
public void test() {
Thread t1 = new Thread(){
public void run() {
isStop = true;
}
};
Thread t2 = new Thread(){
public void run() {
while (!isStop);
}
};
t2.start();
t1.start();
}
public static void main(String args[]) throws InterruptedException {
int num = 10;// 1个很可能没有效果
for (int i=0;i<num;i++){
new B().test();
}
while(Thread.activeCount()>2){// 在 idea 中启动时,run 会有两个线程,debug 会有1个
System.out.println(Thread.activeCount()-2);
}
}
}
3.2 有序性
单例模式的线程安全的懒汉式写法,称为 DCL(Double Check Lock)。
对于下面可能出现问题的初始化语句,具体分三步:
-
分配内存空间
-
实例化对象instance
-
把instance引用指向已分配的内存空间,此时instance有了内存地址
如果 instance 没有指定为 volatile 的,可能执行顺序会重排序为1-3-2,如果一个线程 A 只执行了 1-3,没有执行2,则会产生错误,加上 volatile 会保证结果正确。
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if(null == instance) {
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton();// 出现问题的语句
}
}
}
return instance;
}
}
实现有序的方法是禁止指令重排,如下表所示。
具体操作是插入 4 个内存屏障。
·在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的后面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。 在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
3.3 不保证原子性
对基本类型的变量的读取和赋值操作是原子性的。对于 64 位的 long/double 在 32位系统上,不是原子性的,会分两次处理。使用volatile关键字可以使得long和double具有原子性。
a = 5;//原子性
a = b;//非原子性,先加载b,然后赋值给a。
i++; ++i; i = i+1;//都是三步
以 i++ 为例,i++的操作分三步:
1. 栈中取出i
2. i + 1
3. 将i存到栈
在下面的程序中,有100 个线程,执行 10000 次加法,结果并不是 1 百万(如果没有效果,将数字继续增大即可)。
如果有两个线程 A 和 B,A 先执行第一步,切换到 B 执行第一步,这两个线程都取出 0,然后 A 执行后续的操作,将结果 1 存入主内存,B 执行后面的操作,也将结果 1 存入,这个时候,就会丢失掉一次加法操作。
public class C{
public int i = 0;
public void increase() {
i++;
System.out.println(i);
}
public static void main(String[] args) {
final C c = new C();
for(int k=0;k<100;k++){
new Thread(){
public void run() {
for(int j=0;j<10000;j++)
c.increase();
};
}.start();
}
}
}
下面是我的公众号,Java与大数据进阶,分享 Java 与大数据笔面试干货,欢迎关注
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】博客园2025新款「AI繁忙」系列T恤上架,前往周边小店选购
【推荐】凌霞软件回馈社区,携手博客园推出1Panel与Halo联合会员
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步