Java Volatile关键字 以及long,double在多线程中的应用
Java Volatile关键字 以及long,double在多线程中的应用
概念:
volatile关键字,官方解释:volatile可以保证可见性、顺序性、一致性。
可见性:volatile修饰的对象在加载时会告知JVM,对象在CPU的缓存上对多个线程是同时可见的。
顺序性:这里有JVM的内存屏障的概念,简单理解为:可以保证线程操作对象时是顺序执行的,详细了解可以自行查阅。
一致性:可以保证多个线程读取数据时,读取到的数据是最新的。(注意读取的是最新的数据,但不保证写回时不会覆盖其他线程修改的结果)
每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存
变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,
在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。
但是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样
对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的
例如假如线程1,线程2 在进行read,load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值
在线程1堆count进行修改之后,会write到主内存中,主内存中的count变量就会变为6
线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6
导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。
JMM Java内存模型
Java内存模型(jmm, Java Memory Model):JMM规范了java虚拟机与计算机内存是如何协同工作的,规定了一个线程如何和何时可以看到其他线程修改过的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。
正如文章开头所说JMM其实是不存在的,它只是一个规范,最终Java程序都会交给CPU去运行,所以上面是计算机硬件体系是基础,有了上面的基础,才有了Java内存模型,或者说Java的内存模型就是利用了计算机硬件体系。
本地内存:我们知道,Java里面每个线程都有一个自己的本地内存,存放的是私有变量和主内存数据的副本。如果私有变量是基本数据类型,则直接存放在本地内存,如果是引用类型变量,存放的是引用(指针),实际的数据存放在主内存。本地内存是不共享的,只有属于它的线程可以访问。也有好多人把本地内存称之为线程栈或者工作空间。
主内存:存放的是共享的数据,所有线程都可以访问。当然它也有不少其他称呼,比如堆内存,共享内存等等。
Java内存模型规定了所有对共享变量的读写操作都必须在本地内存中进行,需要先从主内存中拿到数据,复制到本地内存,然后在本地内存中对数据进行修改,再刷新回主内存。
Java内存模型是干什么的, Java内存模型就是为了解决多线程对共享数据的读写一致性问题。
内存模型的8种操作
Lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
Unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
Read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,便于后面的load动作使用。
Load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
assign((赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。
Store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
Write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中
Volatile和Static区别
static指的是类的静态成员,实例间共享
volatile跟Java的内存模型有关,线程执行时会将变量从主内存加载到线程工作内存,建立一个副本,在某个时刻写回。valatile指的每次都读取主内存的值,有更新则立即写回主内存。
理解了这两点,逐句再来解释你的困惑:
“既然static保证了唯一性”:static保证唯一性,指的是static修饰的静态成员变量是唯一的,多个实例共享这唯一一个成员。
“那么他对多个线程来说都是可见的啊”:这里,static其实跟线程没太大关系,应该说对多个对象实例是可见的。你说对多个线程可见,虽然没什么毛病,因为静态变量全局可见嘛,但是把这个理解转到线程的上线文中是困惑的起因。
“volatile保证了线程之间的可见性”:因为线程看到volatile变量会去读取主内存最新的值,而不是自个一直在那跟内部的变量副本玩,所以保证了valatile变量在各个线程间的可见性。
“那么修改的时候只要是原子操作,那么就会保证它的唯一性了吧”:此时你说的“唯一性”,指的是各个线程都能读取到唯一的最新的主内存变量,消除了线程工作内存加载变量副本可能带来的线程之间的“不唯一性”。这里“唯一性”的含义跟第一句说的“唯一性”是不一样的。
“这两个在我理解上我觉得差不多。”:其实解决问题的“场景”是完全不一样的。
造成理解困惑最大的原因在于,这两个场景略有类似,以致混淆了:
场景1:各个类的实例共享唯一一个类静态变量
场景2:各个线程共同读取唯一的最新的主内存变量的值
内存屏障
内存屏障主要有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区与高速缓存的脏数据等写回主内存,让缓存中相应的数据失效。
内存屏障
屏障类型 | 指令例子 | 解释 |
---|---|---|
LoadLoad Barrier | Load1;LoadLoad;Load2 | 确保Load1数据的加载先于Load2和后面的Load指令 |
StoreStore Barrier | Store1;StoreStore;Store2 | 确保Store1操作的数据对其他处理器可见(将Cache刷新到内存),即这个指令的执行要先于Store2和后面的存储指令 |
LoadStore Barrier | Load1;LoadStore;Store2 | 确保Load1数据加载先于Store2以及后面所有存储指令 |
StoreLoad Barrier | Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器可见,也就是将这个数据从CPU的Cache刷新到内存当中,这个内存屏障会让StoreLoad前面的所有的内存访问指令(不管是Store还是Load)全部完成之后,才执行Store Load后面的Load指令 |
X86当中内存屏障指令
现在处理器一般可能不会支持上面屏障指令当中的所有指令,但是一般都会支持Store Load屏障指令,因为这个指令可以达到其他三个指令的效果,因此在实际的机器指令当中如果想达到上面的四种指令的效果,可能不需要四个指令,像在X86当中就主要有三个内存屏障指令:
lfence
,这是一种Load Barrier,一种读屏障指令,这个指令可以让高速缓存(CPU的Cache)失效,如果需要加载数据,那么就需要从内存当中重新加载(这样可以加载最新的数据,因为如果其他处理器修改了缓存当中的数据的时候,这个缓存当中的值已经不对了,去内存当中重新加载就可以拿到最新的数据),这个指令其实可以达到上面指令当中LoadLoad和指令的效果。同时这条指令不会让这条指令之后读操作被调度到lfence
指令之前执行。sfence
,这是一种Store Barrier,一种写屏障指令,这个指令可以将写入高速缓存的数据刷新到内存当中,这样内存当中的数据就是最新的了,数据就可以全局可见了,其他处理器就可以加载内存当中最新的数据。这条指令有StoreStore的效果。同时这条指令不会让在其之后的写操作调度到其之前执行。- 关于以上两点的描述是稍微有点不够准确的,在下文我们在讨论Store Buffer和Invalid Queue时我们会重新修正,这里这么写是为了能够帮助大家理解。
mfence
,这是一种全能型的屏障,相当于上面lfence
和sfence
两个指令的效果,除此之外这条指令可以达到StoreLoad指令的效果,这条指令可以保证mfence
操作之前的写操作对mfence
之后的操作全局可见。
Volatile需要的内存屏障
为了实现Volatile的内存语义,Java编译器(JIT编译器)在进行编译的时候,会进行如下指令的插入操作(这里你可以对照前面的volatile重排序规则,然后你就理解为什么要插入下面的内存屏障了):
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
Volatile读内存屏障指令插入情况如下:
Volatile写内存屏障指令插入情况如下:
其实上面插入内存屏障只是理论上所需要的,但是因为不同的处理器重排序的规则不一样,因此在插入内存屏障指令的时候需要具体问题具体分析。比如X86处理器只会对读-写这样的操作进行重排序,不会对读-读、读-写和写-写这样的操作进行重排序,因此在X86处理器进行内存屏障指令的插入的时候可以省略这三种情况。
根据volatile重排序的规则表,我们可以发现在写-读的情况下,只禁止了volatile写-volatile读
的情况:
而X86仅仅只会对写-读的情况进行重排序,因此我们在插入内存屏障的时候只需要关心volatile写-volatile读
这一种情况,这种情况下我们需要使用的内存屏障指令为StoreLoad,即volatile写-StoreLoad-volatile读
,因此在X86当中我们只需要在volatile写后面加入StoreLoad内存屏障指令即可,在X86当中Store Load对应的具体的指令为mfence
。
具体代码:
在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。
这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
要解决这个问题,只需要像在本程序中的这样,把该变量声明为volatile(不稳定的)即可,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。一般说来,多任务环境下各任务间共享的标志都应该加volatile修饰。
Volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。
这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
Code:
public class Counter {
public static int count = 0;
public static void inc() {
//这里延迟1毫秒,使得结果明显
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
count++;
}
public static void main(String[] args) {
//同时启动1000个线程,去进行i++计算,看看实际结果
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
Counter.inc();
}
}).start();
}
//这里每次运行的值都有可能不同,可能为1000
System.out.println("运行结果:Counter.count=" + Counter.count);
}
}
Output:
运行结果:Counter.count=980
添加Volatile
public class Counter {
public volatile static int count = 0;
public static void inc() {
//这里延迟1毫秒,使得结果明显
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
count++;
}
public static void main(String[] args) {
//同时启动1000个线程,去进行i++计算,看看实际结果
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
Counter.inc();
}
}).start();
}
//这里每次运行的值都有可能不同,可能为1000
System.out.println("运行结果:Counter.count=" + Counter.count);
}
}
Result:
运行结果:Counter.count=801
运行结果还是没有我们期望的1000
long,double在多线程中的情况:
java的内存模型只保证了基本变量的读取操作和写入操作都必须是原子操作的,但是对于64位存储的long和double类型来说,JVM读操作和写操作是分开的,分解为2个32位的操作,
这样当多个线程读取一个非volatile得long变量时,可能出现读取这个变量一个值的高32位和另一个值的低32位,从而导致数据的错乱。要在线程间共享long与double字段必须在synchronized中操作或是声明为volatile。
这里使用volatile,保证了long,double的可见性,那么原子性呢?
其实volatile也保证变量的读取和写入操作都是原子操作,注意这里提到的原子性只是针对变量的读取和写入,并不包括对变量的复杂操作,比如i++就无法使用volatile来确保这个操作是原子操作。
缓存一致性协议MESI
MESI的核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
在MESI协议中,每个缓存可能有有4个状态,它们分别是:
M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
I(Invalid):这行数据无效。
值得注意的是,传统的MESI协议中有两个行为的执行成本比较大。
一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate Queue组件来降低这类操作的延时。
比如:一个cpu修改一个共享变量的值,那么应该写入到cache中,并且将其他cpu中的该共享变量设置为无效,其他cpu也要设置Memory内该变量设置为无效,然后原先的cache再写入到memory中,嗯,我是这么认为的。这样效率很低,所以新增了Store Buffer和Invalidate Queue来提高效率,这样修改变量就写入Store Buffer即可,Store Buffer再异步写入Cache中。Invalidate Queue也是一样,Cache中数据设置为无效,直接写入到Invalidate Queue中即可。Invalidate Queue再异步操作Memroy中的该变量为无效,我是这么认为的,不一定正确。
如图:
当一个CPU进行写入时,首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。然后异步在某个时刻真正的写入到Cache中。
当前CPU核如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。
但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后才会触发失效操作,所以不同核cpu读取各自的store buffer可能造成数据不一致。
而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。
和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。
所以,为了解决缓存的一致性问题,比较典型的方案是MESI缓存一致性协议。
MESI协议,可以保证缓存的一致性,但是无法保证实时性。
volatile与MESI关系
已经有了缓存一致性协议,为什么还需要volatile?
这个问题的答案可以从多个方面来回答:
1、并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。
2、操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。
3、缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大。所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行。
- 其实,在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿)。写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。
MESI通过引入store buffer和invalidate queue来提高性能,但是会造成缓存数据不一致,这样就需要volatile,volatile变量会加入内存屏障,会保证该变量在各线程间的可见性。
所以,内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。
总结:
其实最迷惑的地方就是为什么不是原子性,现在理解可见性,即,线程自己的工作内存,读取的是主内存的副本,因为加了volatile,所以在这个副本变量发生变化的时候必须立刻写入到主内存,后续线程读取主内存变量,就是最新的,这就是可见性,但是,在一些其他已经加载该变量副本的线程中,这个变量副本不会发生变化,并没有改变,所以volatilve并不是原子性的。
参考:
Java 中 static 和 volatile 关键字的区别?
万字长文:从计算机本源深入探寻volatile和Java内存模型
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)