深入浅出Java多线程(八):volatile
引言
大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第八篇内容:volatile。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!
在当今的软件开发领域,多线程编程已经成为提高系统性能和响应速度的重要手段。Java作为广泛应用的多线程支持语言,其内存模型(JMM)设计巧妙地处理了并发环境下共享资源访问时可能遇到的问题。然而,在多线程间共享数据时,程序员往往会遭遇两个核心挑战:内存可见性和指令重排序。
内存可见性问题主要体现在当一个线程修改了共享变量后,其他线程未必能立即感知到这个变化。在Java内存模型中,主内存与每个线程私有的工作内存相互独立,对变量的读写操作可能会先缓存在工作内存中,进而导致不同线程对同一变量值的认知出现偏差。
指令重排序则是为了优化程序执行效率,编译器和CPU可以在不影响单线程语义的前提下重新安排指令执行顺序。然而,在多线程环境下,这种优化可能导致意想不到的结果,破坏程序的正确性。
volatile关键字在Java多线程编程中起到了关键作用,它为解决上述问题提供了有效的工具。通过使用volatile修饰的变量,可以确保多个线程间的共享状态更新能够及时、准确地传播,并且禁止编译器和处理器对其进行无序执行的优化。例如:
public class VolatileExample {
volatile int sharedValue;
public void writerThread() {
sharedValue = 100; // 对volatile变量的写入操作将立即刷新至主内存
}
public void readerThread() {
int localValue = sharedValue; // 对volatile变量的读取操作会从主内存获取最新值
}
}
在这个简单的示例中,sharedValue 被声明为volatile类型,保证了writer线程对sharedValue的修改能够被reader线程立即看到。接下来的内容将进一步探讨volatile是如何实现这些特性的,以及在实际应用中如何利用volatile来增强多线程代码的安全性和一致性。
基本概念回顾
在深入探讨Java多线程中volatile关键字的特性和应用之前,有必要首先回顾几个关键的概念。虽然之前的系列文章中已经讲过这些内容了,为了照顾没看过之前系列文章的小伙伴,这里快速带大家复习一下。如果对这部分内容感兴趣的小伙伴,可以去翻翻这个系列的其他文章。
内存可见性
内存可见性是Java并发编程中的一个核心议题。在Java内存模型(JMM)中,所有线程共享同一主内存区域,而每个线程有自己的工作内存(本地缓存)。当一个线程修改了主内存中的共享变量时,该变化可能并不会立即同步到其他线程的工作内存中,从而造成不同线程对同一变量值的读取不一致。例如:
public class VisibilityIssue {
int sharedValue = 0;
public void updateValue() {
sharedValue = 1; // 线程A修改了sharedValue
}
public void readValue() {
System.out.println(sharedValue); // 线程B可能无法立即看到线程A的更新
}
}
使用volatile修饰符则可以确保内存可见性,使得线程A对sharedValue的修改能够立刻对线程B可见。
重排序
为优化程序执行性能,编译器和处理器可能会改变代码指令的实际执行顺序,这种现象称为重排序。它发生在多个层面,包括编译阶段的指令优化以及运行时CPU流水线上的动态调整。然而,在多线程环境下,无限制的重排序可能导致不可预测的结果,破坏程序逻辑的一致性。
happens-before规则
为了帮助程序员理解和控制多线程环境下的执行顺序,JVM引入了happens-before规则。这是一个隐含的保证,只要按照这些规则编写代码,JVM就能确保指令在不同线程间按预期的顺序执行。例如,程序中对某个变量的写操作先行发生于随后对该变量的读操作,则写入的数据必定对读取线程可见。
结合上述概念,volatile关键字在Java 5及以后版本中得到了增强,不仅确保了其修饰的变量具有内存可见性,还严格限制了volatile变量与普通变量之间的重排序行为,进而保障了并发场景下数据的一致性和正确性。
volatile的内存语义
在Java多线程编程中,volatile关键字为变量提供了一种特殊的内存语义,确保了数据在多个线程间的正确同步和一致性。这部分将详细解释volatile如何保证内存可见性、禁止重排序以及通过内存屏障实现这些特性的机制。
内存可见性保证
volatile修饰符确保了当一个线程修改volatile变量时,所有其他线程都能立即看到这个更新后的值。考虑以下示例:
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
在这个例子中,如果flag没有被volatile修饰,那么线程A对a的修改可能不会及时反映到线程B读取的值上。然而,由于flag是volatile变量,在线程A写入后,JMM会强制将其值刷新至主内存,并且在随后线程B读取flag时,会从主内存获取最新的值,并使得线程B本地缓存中的a失效,从而重新从主内存加载最新值。
禁止重排序机制
旧版Java内存模型允许volatile变量与普通变量之间的重排序,这可能导致并发问题。为了纠正这一缺陷,JSR-133增强了volatile的内存语义,规定编译器和处理器不能随意重排volatile变量与其他变量的操作顺序。
例如,在双重检查锁定单例模式中,如果没有使用volatile修饰instance变量,则初始化过程可能会被重排序,导致返回未完全初始化的对象实例。而volatile可以避免这种风险:
public class Singleton {
private volatile static Singleton instance; // 使用volatile防止重排序
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 不会发生步骤1-3-2的重排序
}
}
}
return instance;
}
}
内存屏障作用
为了实现上述内存语义,JVM采用了内存屏障技术来限制编译器和处理器的重排序行为。内存屏障分为读屏障(Load Barrier)和写屏障(Store Barrier),它们分别起到阻止屏障两侧指令重排序和确保数据同步到主内存的作用。
具体来说,针对volatile变量的写操作,会在其前后插入StoreStore和StoreLoad屏障;对于volatile变量的读操作,会在其前后插入LoadLoad和LoadStore屏障。这些屏障的存在确保了volatile变量的写入对所有线程都可见,并且不会与其前后非volatile变量的读写操作发生重排序。
综上所述,volatile关键字通过内存可见性和禁止重排序这两个关键特性,有效地维护了多线程环境下共享变量的一致性和正确性,成为Java并发编程中的重要工具。
volatile的内存屏障实现细节
Java虚拟机(JVM)为了确保volatile变量的内存可见性和禁止重排序特性,采用了内存屏障这一底层硬件支持机制。内存屏障在硬件层面上主要有两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。它们不仅能够阻止屏障两侧指令的重排序,还负责协调CPU缓存与主内存的数据同步。
当编译器生成字节码时,会在volatile变量相关的读写操作前后插入特定类型的内存屏障:
StoreStore屏障:
在每个volatile写操作前插入StoreStore屏障,以保证在此屏障之前的普通写操作完成并刷新至主内存之后,才会执行volatile变量的写入操作。例如:
int a = 1; // 普通写操作
volatile int v = 2; // volatile写操作
// 实际执行时,会在v的写操作前插入StoreStore屏障,确保a的值已刷回主内存
StoreLoad屏障:
在每个volatile写操作后插入StoreLoad屏障,强制所有之前发生的写操作刷新到主内存,并且使当前处理器核心上的本地缓存无效,这样后续任何线程对volatile或非volatile变量的读取都会从主内存获取最新的数据。LoadLoad屏障:
在每个volatile读操作后插入LoadLoad屏障,用于确保在这次volatile读操作之后的其他读操作(不论是volatile还是非volatile)能读取到比它更早的读操作所看到的数据。LoadStore屏障:
在每个volatile读操作后再插入LoadStore屏障,防止此volatile读取操作与其后的写操作之间发生重排序,确保在此屏障之后的所有写操作,必须在读取volatile变量的操作完成之后才能执行。
由于不同的处理器架构可能对内存屏障的支持程度不同,Java内存模型采取了一种保守策略,在编译器级别统一插入上述四种内存屏障,从而确保在任意平台上都能获得正确的volatile内存语义。
例如,在双重检查锁定单例模式中,volatile关键字在instance变量声明处起着至关重要的作用。如果未使用volatile修饰,初始化过程可能会被重排序为如下错误序列:
Singleton instance; // 假设没有volatile修饰符
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 分解为分配内存、初始化对象、设置引用三个步骤
}
}
}
return instance;
}
若不使用volatile,初始化步骤可能发生1-3-2的重排序,导致其他线程在实例初始化完成前就访问到了尚未完全初始化的对象。而volatile通过内存屏障的插入,可以避免这种危险的重排序行为,确保了多线程环境下正确地创建单例对象。
volatile的实际应用和用途
作为轻量级同步机制
volatile在Java并发编程中扮演了轻量级的同步角色,它可以确保对单个变量的读/写操作具有原子性,并且提供了一种比锁更轻便的线程间通信方式。例如,在以下场景中,我们可以使用volatile来替代锁:
public class Counter {
private volatile int count = 0;
public void increment() {
count++; // 单线程环境下,count++并不是原子操作,但在多线程环境下,
// volatile能保证每次自增后其他线程都能看到最新的值
}
public int getCount() {
return count;
}
}
尽管volatile提供了内存可见性和一定程度上的原子性,但它并不适合于需要保证复合操作整体原子性的场景,例如涉及多个变量的操作或者复杂的临界区代码块。
禁止重排序的应用场景
volatile的一个重要用途是禁止编译器和处理器进行可能导致程序逻辑错误的重排序行为。特别是在多线程环境中,重排序可能破坏数据依赖关系,导致不可预期的结果。下面以“双重检查锁定”单例模式为例说明这一点:
public class Singleton {
private volatile static Singleton instance; // 使用volatile关键字
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 实例化对象
}
}
}
return instance;
}
}
在这个例子中,如果不使用volatile修饰instance变量,则实例化过程可能会被重排序为分配内存、设置引用但未初始化对象、然后返回引用的顺序。而volatile能够通过插入内存屏障避免这种错误的重排序,确保当getInstance()方法返回时,实例已经正确地初始化完成。
总之,volatile的关键作用在于它能在不引入复杂锁机制的前提下,实现对共享变量的简单同步与通信。然而,开发者需要注意volatile不能替代锁用于处理复杂状态下的并发控制问题,而是应当根据具体应用场景选择最合适的同步工具。对于那些只需要保持单一变量可见性及有序性的简单同步需求,volatile是一个高效且实用的选择。
总结
volatile关键字在Java多线程编程中扮演了至关重要的角色,它提供了内存可见性和禁止重排序的保证,从而有效地提升了并发环境下的数据一致性与正确性。
首先,在内存可见性方面,volatile修饰的变量确保了当一个线程修改该变量时,其他线程能立即看到这个更新。例如,在如下代码示例中,当flag被设置为true时,所有读取它的线程都会感知到变化:
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1;
flag = true; // 线程A对flag的修改对其他线程立即可见
}
public void reader() {
if (flag) { // 线程B能立刻看到线程A设置的flag值
System.out.println(a);
}
}
}
其次,volatile通过引入内存屏障机制严格限制了编译器和处理器的重排序行为,防止因为优化而引发的数据不一致问题。特别是在单例模式中的“双重检查锁定”场景,使用volatile关键字能够确保对象实例化过程不会因重排序导致返回未初始化的对象实例:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile禁止这里的初始化步骤重排序
}
}
}
return instance; // 返回已正确初始化的对象
}
}
然而,尽管volatile提供了一种轻量级的同步机制,但其功能相对有限,仅适用于简单状态共享和单个变量的原子操作。对于涉及复合操作或更复杂的临界区,锁仍然是实现更强同步控制的首选工具。因此,开发者需要根据实际需求权衡性能与安全性的考量,合理选择并运用volatile和锁来构建稳健、高效的并发程序。
本文使用 markdown.com.cn 排版
深入浅出Java多线程(八):volatile的更多相关文章
- 深入浅出Java多线程(2)-Swing中的EDT(事件分发线程) [转载]
本系列文章导航 深入浅出Java多线程(1)-方法 join 深入浅出Java多线程(2)-Swing中的EDT(事件分发线程) 深入浅出多线程(3)-Future异步模式以及在JDK1.5Concu ...
- java多线程关键字volatile的使用
java多线程关键字volatile的作用是表示多个线程对这个变量共享. 如果是只读的就可以直接用,写数据的时候要注意同步问题. 例子: package com.ming.thread.volatil ...
- Java多线程——<八>多线程其他概念
一.概述 到第八节,就把多线程基本的概念都说完了.把前面的所有文章加连接在此: Java多线程——<一>概述.定义任务 Java多线程——<二>将任务交给线程,线程声明及启动 ...
- Java多线程编程——volatile关键字
(本篇主要内容摘自<Java多线程编程核心技术>) volatile关键字的主要作用是保证线程之间变量的可见性. package com.func; public class RunThr ...
- java多线程(八)-死锁问题和java多线程总结
为了防止对共享受限资源的争夺,我们可以通过synchronized等方式来加锁,这个时候该线程就处于阻塞状态,设想这样一种情况,线程A等着线程B完成后才能执行,而线程B又等着线程C,而线程C又等着线程 ...
- 深入浅出Java多线程
Java给多线程编程提供了内置的支持.一个多线程程序包含两个或多个能并发运行的部分.程序的每一部分都称作一个线程,并且每个线程定义了一个独立的执行路径. 多线程是多任务的一种特别的形式,但多线程使用了 ...
- Java多线程(3) Volatile的实现原理
Volatile变量 在程序设计中,尤其是在C语言.C++.C#和Java语言中,使用volatile关键字声明的变量或对象通常拥有和优化和(或)多线程相关的特殊属性.通常,volatile关键字用来 ...
- 关于java多线程关键字volatile的理解
volatile关键字的作用是强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取得变量的值. 使用volition关键字增加了实例变量在多个线程间的可见性.但volition有个致命的缺点就是不 ...
- java多线程中 volatile与synchronized的区别-阿里面试
volatile 与 synchronized 的比较(阿里面试官问的问题) ①volatile轻量级,只能修饰变量.synchronized重量级,还可修饰方法 ②volatile只能保证数据的可见 ...
- Java多线程之三volatile与等待通知机制示例
原子性,可见性与有序性 在多线程中,线程同步的时候一般需要考虑原子性,可见性与有序性 原子性 原子性定义:一个操作或者多个操作在执行过程中要么全部执行完成,要么全部都不执行,不存在执行一部分的情况. ...
随机推荐
- springboot线程池的使用方式2
一.简单介绍 方式1:Executors.newCachedThreadPool线程池.Executors有7种不同的线程池. private static final ExecutorService ...
- zzuli 1908
***做的时候判断当前位置为.的上下左右是否为*,如果全是改位置就改为*,如果四周中有为.,再DFS一下,其实就相当于把判断化为更小的子问题*** #include<iostream> # ...
- BFS(广度优先搜索) poj3278
***今天发现一个很有趣的是,这道题应该几个月前就会了,但是一次比赛中总是WA,果断C++提交,然后就过了,然后就很无语了,G++不让过C++能过,今天又交一遍发现把队列定义为全局变量就都能过了,至于 ...
- 用C#实现最小二乘法(用OxyPlot绘图)✨
最小二乘法介绍 最小二乘法(Least Squares Method)是一种常见的数学优化技术,广泛应用于数据拟合.回归分析和参数估计等领域.其目标是通过最小化残差平方和来找到一组参数,使得模型预测值 ...
- Canal使用和安装总结
转载请注明出处: 1.定义 Canal 组件是一个基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费,支持将增量数据投递到下游消费者(如 Kafka.RocketMQ 等)或者存储(如 El ...
- Linux系列之文件和目录权限
前言 我们知道,root用户基本上可以在系统中做任何事.其他用户有更多的限制,并且通常被收集到组中.你把有类似需求的用户放入一个被授予相关权限的组,每个成员都继承组的权限. 让我们看一下: 查看权限( ...
- 使用Swagger,在编写配置类时报错Caused by: java.lang.NullPointerException: Cannot invoke "org.springframework.web.servlet.mvc.condition.PatternsRequestCondition.getPatterns()" because "this.condition" is null
1.问题 Caused by: java.lang.NullPointerException: Cannot invoke "org.springframework.web.servlet. ...
- [java] - servlet路径跳转
Index.jsp <a href="servlet/HelloServlet">servlet/HelloServlet</a><br> &l ...
- 一种基于linux系统的精准流量统计方法
前言: 在linux系统关于流量统计,已经有开源的工具,比如nethogs,nload和iptraf.它们适合我们在PC上直接监控某台设备的流量情况,但并不适合我们应用到自己的程序中去. 如果要在 ...
- 海思Hi35xx 通过uboot 读取U盘文件进行固件升级
前言 基本过程为:uboot 启动后,通过命令将U盘的的文件读取到内存中,再通过uboot 的flash 写入命令将读取到内存中的升级文件写入到flash的固定位置. (一)usb常用命令 uboot ...