Java volatile与锁机制对比案例分析
前言:最近有强烈的感觉就是在补充上学的时候没学好的知识,没打好的基础,果然只要是想做得好,欠过的债总是要还的。我也没有想到工作几年,竟然会有这样悔不当初的总结。
曾经的鲜衣怒马,终究是过眼云烟,潮水退去,发现裸泳的竟然是我自己。
多线程案例
一个经典场景:
当前类中有一个volatile Strategy strategy作为成员变量,这个成员变量有不加锁的getter和setter,类还有一个成员方法execute是对strategy进行某种操作,这个execute方法是受锁保护的。
假设这个execute要执行10s钟,在这个执行的过程中如果getter和setter被其他线程调用了,getter/setter线程是否会阻塞?如果getter/setter加锁,它们的调用线程会不会阻塞?
问题的答案:
如果getter/setter不加锁,非阻塞,它们的调用线程可以立即执行。
如果getter/setter加锁,阻塞,且此时volatile关键词可以去掉。
机制对比
锁 和 volatile 是两种完全不同的机制,它们在这个场景下是互不干扰的。
volatile机制
volatile 保证的是变量的可见性和防止指令重排序。
volatile修饰的变量,可见性是指变量发生改变时会对其他线程立即可见。也就是说volatile的变量发生写操作时,是立即刷新到主存的,读操作也是立即从主存读取最新的值,不会从缓存中读。
防止指令重排序是指如果当前操作在机器指令上会被翻译成多个指令,那么这些个指令不会被乱序执行。
volatile它不涉及互斥性。读写 volatile 变量就像读写普通变量一样,不会导致线程挂起,JVM是直接对内存进行操作的。
锁机制
在这个场景下,这里就不展开synchronized 和ReentrantLock的区别了。
锁保证的是对内存的独占访问,锁的概念紧密关联的是对象。被锁修饰的代码逻辑,可以保证原子性,本质上是因为只有唯一的线程会访问当前的代码逻辑,那么这段逻辑在其他线程看来就是“不可再分的”。那么为什么锁保护的是对象,在原子性上却生效在代码逻辑上了?锁通过锁住对象(独占访问),实现了逻辑上排他性地执行代码。因为别人进不来,所以无法打断你的逻辑,也就无法看到中间状态。
有锁保护时,线程对对象操作会获取当前对象的Monitor(监视器锁)。如果当前对象上锁,则线程阻塞,这就是锁的互斥性。
但同时,锁在释放时,也会立即刷新主存,这也就说明了锁也保证了“可见性”,此时对象已经刷盘了。根据 Java 内存模型,线程释放锁时会将本地内存的修改强制刷新回主存,线程获取锁时会强制从主存重新加载变量。这就是为什么此时volatile关键字需要去掉。
多线程访问变量案例解释
基于以上的理解,再考虑最开始的案例。
getter/setter不加锁
public class StrategyContext {
// 1. volatile 保证可见性,但不提供锁
private volatile Strategy strategy = new Strategy("Default");
// 2. 无锁 Setter:任何时候都能调,不会被阻塞
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
// 3. 无锁 Getter:任何时候都能调
public Strategy getStrategy() {
return strategy;
}
// 4. 加锁的方法:执行耗时 10 秒
public synchronized void execute() {
System.out.println(Thread.currentThread().getName() + " 拿到锁,开始执行 execute (10s)...");
try {
// 模拟长时间执行
for (int i = 0; i < 5; i++) {
// 危险:如果这里直接使用成员变量 strategy,可能会在执行中途发现它变了
System.out.println("Execute 中途读取策略: " + strategy.getName());
Thread.sleep(2000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 执行结束,释放锁。");
}
}风险点:如果线程 A 的 execute 方法逻辑中,在第 1 秒读取了一次 strategy,在第 9 秒又读取了一次 strategy。那么线程 A 会发现在同一个方法执行过程中,strategy 变了(前 1 秒是老策略,后 1 秒是新策略)。这可能导致逻辑不一致。
解决方案:内存快照。将volatile对象赋给一个局部变量,后续的逻辑使用局部变量操作,那么后续的逻辑都基于局部变量。注意这里有暂时的一致性问题,即如果当前方法需要执行10s,而其他线程在这个过程中修改了strategy,那么get方法会拿到最新的值,但是execute会一直执行上一个快照变量下的逻辑。
public synchronized void execute() {
// 【关键】进入方法时,将 volatile 变量赋值给局部变量
// 局部变量存在于线程栈中,其他线程无法修改它
Strategy localStrategy = this.strategy;
// 后续所有操作都使用 localStrategy
// 即使成员变量 this.strategy 被其他线程改了,localStrategy 依然指向旧的对象
localStrategy.algorithm();
}getter/setter加锁
public class StrategyContext {
private volatile Strategy strategy = new Strategy("Default");
//1. 加锁,完全互斥,阻塞
public synchronized void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
//2. 加锁,完全互斥,阻塞
public synchronized Strategy getStrategy() {
return strategy;
}
// 加锁的方法:执行耗时 10 秒
public synchronized void execute() {
System.out.println(Thread.currentThread().getName() + " 拿到锁,开始执行 execute (10s)...");
try {
// 此时其他线程执行getter, setter方法会挂起等待,直到execute方法退出,锁释放,JVM唤醒等待线程
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 执行结束,释放锁。");
}
}问题
- synchronized 本身就保证了可见性。此时volatile关键词是多余的。
- 性能低,读写性能不对称,极端情况读操作需要挂起等待方法执行,但返回引用本身是纳秒级的操作。
- 死锁风险,如果 execute 内部还需要获取其他锁,持有大锁的时间越长,发生死锁的概率越高。
解决
当然可以用上面那种getter/setter不加锁的方式解决,弱一致性问题用局部快照。本质上也是一种copy-on-write的思路。
如果要求强一致性问题,可以使用读写锁。
ReentrantReadWriteLock 的核心在于把锁分成了两把:读锁 (Read Lock):共享锁。大家都可以读,只要没人写。写锁 (Write Lock):独占锁。只要我在写,谁都别想读,也别想写。
对于加多把锁的情况,很重要的就是要分清哪里加读锁,哪里加写锁。getter/setter很清楚,而execute呢?这里要结合Java内存模型理解,方法调用本质是读,上读锁。
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class RWLockStrategyContext {
private Strategy strategy = new Strategy("Default");
//注意读写有锁保护时,不需要volatile的可见性了
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 1. 写锁保护 Setter
// 只有当没有任何人在读(包括 execute 和 get),也没有人在写时,才能拿到这把锁
public void setStrategy(Strategy strategy) {
rwLock.writeLock().lock();
try {
this.strategy = strategy;
} finally {
rwLock.writeLock().unlock();
}
}
// 2. 读锁保护 Getter
// 只要没有人持有写锁,可以多线程访问
public Strategy getStrategy() {
rwLock.readLock().lock();
try {
return strategy;
} finally {
rwLock.readLock().unlock();
}
}
// 3. 读锁保护 Execute
public void execute() {
rwLock.readLock().lock(); // 获取读锁
try {
System.out.println(Thread.currentThread().getName() + " 拿到读锁,开始 execute (10s)...");
// 只要还在这个 try 块里,写锁(setStrategy)就进不来
// 但是其他读锁(getStrategy)可以随便进!
for (int i = 0; i < 5; i++) {
System.out.println("Execute 运行中,当前策略: " + strategy.getName());
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
} finally {
System.out.println(Thread.currentThread().getName() + " 执行结束,释放读锁。");
rwLock.readLock().unlock();
}
}
}并发模型理解
锁的本质
“锁”的本质是对象头的一部分,它与对象紧密连接,跟着对象走的。面对并发编程的场景,需要牢记的是虽然你总是在代码逻辑里加锁,但锁 锁住的是共享内存,不是线程,不是代码逻辑。
而编程世界里,“实体”、“功能” 都是靠代码逻辑实现的,叫锁,并不是物理世界里的真实屏障,而是一种逻辑机制。这也解释了只有上锁才有互斥性,而锁和volatile是互不影响的。因为只有代码逻辑执行到syncronized/ReentrantLock锁时,会触发锁逻辑,去访问对象头。查看锁状态。
如果一个线程受锁保护,另一个线程不受锁保护,那么后者在执行的时候,就不会走到锁的机制里,它会直接操作内存逻辑。
如果一个线程受锁保护,另一个线程受相同的锁保护,那么后者在执行的时候,就会走到锁机制里,先检查锁状态,再执行内存逻辑。
Java 内存模型
在这里解释一下为什么execute,方法调用本质是个读操作。
相对于并发模型而言:代码的“执行”是逻辑行为,“执行”本身是不需要锁的,只有“执行过程中访问共享数据”才需要锁。
strategy.execute() 在 JVM 的指令层面,它实际上分成了两步完全独立的操作:读strategy引用+执行execute逻辑。
读/加载:
- 线程找到这个 strategy 这个变量的在堆内存的引用,从内存中读取(拷贝一份地址)到当前的栈帧中。volatile保证是从主存读到的最新值。
执行 - 线程拿着刚才读到的地址,去堆内存中找到那个对象,在对象中找到execute方法在方法区中的地址,然后JVM为这个方法创建一个新的栈帧,一行一行把方法区内的指令压栈执行。因此代码内部的调用在本地栈帧执行。栈帧是私有的,安全。
- 这里一旦读完成了,这一步的执行实际上就跟 变量没有任何关系了。此时及时strategy引用变了,也不影响执行,因为代码执行的是strategy被读取进栈帧那一刻的副本。
后记
如果对volatile和锁的机制理解的很透彻,会发现最开始的的问题答案变得非常清晰。
很多东西都是当你懂了会发现一点都不难,但是如果没有理解到位就会拿不准。这些是Java并发编程的纯基础内容,但是总会在不同场景下有新的理解,常看常新。我其实知道当前的理解还是不够底层的,比如没有涉及到底层的读写屏障和缓存一致性原则等等,这些东西在我校招入职的时候,我的导师就发给我一篇英文经典文献让我看,我看过,但全忘了,没理解透。而现在,我还是没有达到他当时对我的要求。
到此这篇关于Java volatile与锁机制对比的文章就介绍到这了,更多相关Java volatile锁机制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
Spring-AOP-ProceedingJoinPoint的使用详解
这篇文章主要介绍了Spring-AOP-ProceedingJoinPoint的使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教2025-03-03
Spring AI与DeepSeek实战一之快速打造智能对话应用
本文详细介绍了如何通过SpringAI框架集成DeepSeek大模型,实现普通对话和流式对话功能,步骤包括申请API-KEY、项目搭建、配置API-KEY、创建ChatClient对象、创建对话接口、切换模型、使用prompt模板、流式对话等,感兴趣的朋友一起看看吧2025-03-03
Spring Boot 基于 CAS 实现单点登录的原理、实践与优化全解析(最新整理)
本文详解SpringBoot集成CAS单点登录的原理、实现步骤及优缺点,涵盖CASServer与Client架构、票据机制、配置方法,并提供优化策略如集群部署、缓存加速和用户体验提升方案,助力企业实现统一认证与高效管理,感兴趣的朋友一起看看吧2025-07-07
Spring boot如何配置请求的入参和出参json数据格式
这篇文章主要介绍了spring boot如何配置请求的入参和出参json数据格式,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下2019-11-11
SpringBoot中缓存@Cacheable出错的问题解决
本文主要介绍了SpringBoot中缓存@Cacheable出错的问题解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧2025-10-10
StringUtils工具包中字符串非空判断isNotEmpty和isNotBlank的区别
今天小编就为大家分享一篇关于StringUtils工具包中字符串非空判断isNotEmpty和isNotBlank的区别,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧2018-12-12


最新评论