Java多线程实战:从锁机制到JUC并发工具与线程池调优
1. 从“八股文”到实战:为什么多线程面试题总让人头疼?
如果你正在准备Java相关的技术面试,或者已经是一位有经验的开发者,那么“多线程”这三个字,大概率会触发你大脑里某个区域的警报。它几乎是所有中高级Java岗位面试的必考项,从基础概念到并发工具,再到线上问题的排查,层层递进,没有尽头。网络上充斥着海量的“Java多线程面试题大全”,很多人戏称其为“八股文”,靠死记硬背来应付。但真正在项目中,一个不起眼的并发问题就可能让服务半夜告警,排查起来犹如大海捞针。
我经历过很多次面试,也面试过很多人。我发现一个有趣的现象:能把synchronized和ReentrantLock区别背得滚瓜烂熟的人,未必能说清楚在某个高并发场景下,到底该选哪个,以及为什么。能画出Thread生命周期状态图的人,面对一个线程池任务堆积导致CPU飙升的问题,可能依然无从下手。这中间的鸿沟,就在于“知道”和“理解并能在实战中运用”之间的差距。
所以,这篇内容我不想再罗列一份冷冰冰的、有标准答案的“题库”。我想和你聊聊,在我这些年的开发、面试和解决问题过程中,那些关于Java多线程最核心、最容易被问到,也最考验功底的“活”的知识点。我们会绕过那些简单的概念复述,直接切入到原理、设计权衡和实战场景中。无论你是正在备战面试,还是想巩固自己的并发知识体系,希望这些基于实战的思考和总结,能给你带来一些不一样的视角。
2. 线程安全的核心:不止是synchronized和volatile
当面试官问“如何保证线程安全”时,synchronized和volatile通常是第一个跃入脑海的答案。但如果你只回答这两个,可能就错过了展示深度的机会。线程安全的本质,是在共享、可变的状态上,进行正确的管理。我们可以从几个层面来拆解。
2.1 无状态与不可变对象:最优雅的解决方案
在讨论复杂的锁机制之前,首先要树立一个观念:最好的线程安全策略,就是避免共享状态。
- 无状态对象:对象本身不包含任何成员变量,或者只有常量。所有操作都依赖于参数和局部变量。例如,一个只包含静态工具方法的
StringUtils类,或者一个Servlet(如果它不包含实例变量)。这是最安全的,因为根本没有需要保护的数据。 - 不可变对象:对象一旦被创建,其状态(所有字段)就不能再被修改。典型的例子就是
String、Integer等包装类。在Java中,实现一个不可变类需要:- 将类声明为
final,防止被继承和重写方法。 - 将所有字段声明为
private final。 - 不提供任何可以修改对象状态的方法(
setter)。 - 如果字段是引用类型(如集合),确保在构造函数和
getter中返回的是该字段的防御性拷贝(defensive copy),而不是原始引用。
- 将类声明为
注意:很多人会忽略第4点。如果你的不可变类有一个
List<String>字段,你在构造函数中直接this.list = inputList;,那么外部调用者仍然可以通过修改inputList来改变你这个“不可变”对象的状态。正确的做法是this.list = Collections.unmodifiableList(new ArrayList<>(inputList));。
在实战中,优先考虑能否将设计转化为无状态或不可变对象,能极大地简化并发模型,这是比加锁更高级的思维。
2.2synchronized的深度剖析:从字节码到锁升级
synchronized是Java内置的锁,使用简单,但底层机制并不简单。它不仅仅是一个关键字。
锁存储在哪儿?每个Java对象在堆内存中都有一个对象头(Object Header),其中一部分叫做Mark Word,它就用来存储锁信息。这就是为什么Java中“任何对象都可以作为锁”的原因。
锁的升级过程(偏向锁 -> 轻量级锁 -> 重量级锁)这是synchronized为了在无竞争和竞争情况下平衡性能而做的优化,理解这个过程对性能调优很有帮助。
- 无锁状态:一个新对象,默认处于无锁状态。
- 偏向锁:当第一个线程访问同步块时,JVM会将对象头中的
Mark Word通过CAS操作记录下这个线程的ID,并将锁标志位改为偏向模式。之后这个线程再进入和退出同步块时,不需要进行CAS加锁解锁,只需简单检查Mark Word里是否存储着自己的线程ID。这适用于始终只有一个线程访问同步块的场景,能消除同步原语的开销。 - 轻量级锁:当有第二个线程尝试获取锁时(发生竞争),偏向锁就会升级为轻量级锁。JVM会在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,并将对象头的
Mark Word复制过去。然后尝试用CAS将对象头的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示有其他线程竞争,线程会通过自旋(循环尝试)的方式等待一小段时间。 - 重量级锁:如果轻量级锁竞争失败,且自旋也未能获取到锁(或者自旋次数超过阈值),锁就会升级为重量级锁。此时,未获得锁的线程会被挂起(进入阻塞状态),等待操作系统调度,后续再被唤醒。这个挂起和唤醒的操作涉及到用户态到内核态的切换,成本很高。
为什么synchronized是“重量级”的?这个说法其实有点过时了。在早期版本中,synchronized直接对应操作系统的互斥量(Mutex),每次加锁解锁都要进行系统调用,开销巨大。但经过锁升级优化后,在无竞争或低竞争的场景下,它的性能损耗已经非常小了。它的“重”主要体现在升级到重量级锁后的线程阻塞和唤醒上。
与ReentrantLock的对比这是一个经典面试题。ReentrantLock是java.util.concurrent.locks包下的一个类,它提供了比synchronized更灵活的功能。
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现机制 | JVM内置,通过monitorenter/monitorexit字节码指令实现。 | JDK API实现,基于AbstractQueuedSynchronizer (AQS)。 |
| 锁的获取 | 隐式获取和释放,进入代码块自动获取,退出自动释放。 | 显式调用lock()和unlock(),必须在finally块中释放。 |
| 灵活性 | 较差。锁的获取是阻塞式的,无法中断等待。 | 灵活。支持尝试非阻塞获取(tryLock())、可中断获取(lockInterruptibly())、超时获取(tryLock(long, TimeUnit))。 |
| 公平性 | 非公平锁。 | 两者都可选,构造函数传入true可创建公平锁(但通常性能较差)。 |
| 条件队列 | 通过wait(),notify(),notifyAll()与对象监视器绑定,一个锁只能有一个等待队列。 | 可以绑定多个Condition对象,实现更精细的线程等待/唤醒(如生产者-消费者模型)。 |
| 性能 | 在低竞争下,经过优化后性能很好。 | 在高竞争下,由于其更复杂的逻辑和灵活性,可能略有优势,但差异不大。 |
如何选择?
- 优先使用
synchronized:代码简洁,不易出错(自动释放锁),在绝大多数业务并发场景下性能足够。这是默认选择。 - 考虑使用
ReentrantLock:当你需要其高级特性时,比如:尝试获取锁、可中断的锁获取、公平锁、或者需要绑定多个条件变量(Condition)来实现复杂的同步逻辑(如阻塞队列)。
2.3volatile的可见性与有序性:内存屏障的魔法
volatile关键字保证了两件事:可见性和禁止指令重排序。但它不保证原子性。
- 可见性:当一个线程修改了一个
volatile变量的值,新值会立即被刷新到主内存。同时,其他线程中关于该变量的缓存行会失效,迫使它们必须去主内存读取最新值。这通过底层CPU的缓存一致性协议(如MESI)和内存屏障指令来实现。 - 禁止指令重排序:编译器、运行时和处理器为了优化性能,可能会对指令进行重排序。
volatile通过插入内存屏障(Memory Barrier)来禁止这种重排序,保证了volatile变量读写操作的有序性。
一个经典的例子是双重检查锁定(DCL)单例模式:
public class Singleton { private static volatile Singleton instance; // 必须使用volatile private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 关键! } } } return instance; } }为什么instance必须用volatile修饰?因为instance = new Singleton();这行代码并非原子操作,它大致分为三步:
- 分配内存空间。
- 初始化
Singleton对象。 - 将
instance引用指向这块内存。
如果没有volatile,步骤2和3可能被重排序。那么可能出现:线程A执行到步骤3(instance已不为null,但对象未初始化),此时线程B进入第一个if (instance == null)判断,发现不为null,直接返回了一个未初始化完成的对象,从而导致程序错误。volatile通过禁止重排序,确保了“写”操作(步骤3)一定发生在“读”操作之前,解决了这个问题。
3. JUC并发工具库:不要重复造轮子
java.util.concurrent(JUC) 包是Java并发编程的利器。面试中,对于ConcurrentHashMap、CopyOnWriteArrayList、CountDownLatch、CyclicBarrier、Semaphore等工具,不能仅仅停留在“知道它们线程安全”的层面。
3.1ConcurrentHashMap的演进:从分段锁到CAS
这是高频考点。它的线程安全实现经历了重大变化。
JDK 1.7及之前:分段锁(Segment)它将整个哈希表分成一个个小的
Segment(继承自ReentrantLock),每个Segment独立加锁。put操作时,只需要锁住对应的Segment,其他Segment仍然可以被访问,提高了并发度。可以理解为一种“锁细化”的策略。但缺点是结构复杂,并且在查询需要获取所有Segment的锁时(如size()),效率不高。JDK 1.8及之后:CAS + synchronized这是目前的主流实现,设计非常精妙。
- 数据结构:采用了和
HashMap类似的数组 + 链表/红黑树结构。 - put操作:
- 首先根据key的hash找到数组下标,如果该位置为
null,则尝试用CAS操作将新节点放入。成功则返回。 - 如果CAS失败(说明有其他线程抢先插入了),或者该位置已有节点(链表或树),则对这个桶(bucket)的头节点使用
synchronized进行加锁,然后在锁内进行链表或红黑树的插入操作。
- 首先根据key的hash找到数组下标,如果该位置为
- get操作:全程无锁!因为Node的
val和next都用了volatile修饰,保证了可见性。通过Unsafe类提供的原子性操作来读取数据。
- 数据结构:采用了和
这种设计的好处是:将锁的粒度从“段”细化到了“单个桶”,并发度更高;并且在读多写少的场景下,读操作完全无锁,性能极佳。
与Hashtable和Collections.synchronizedMap的对比
Hashtable:所有方法都用synchronized修饰,是对象级别的锁,并发度极低,基本已被淘汰。Collections.synchronizedMap(new HashMap()):它返回一个包装类,内部使用一个普通的HashMap和一个互斥锁(Mutex),所有方法都先获取这个锁。性能同样很差。- 结论:在高并发场景下,无脑选
ConcurrentHashMap。
3.2CountDownLatchvsCyclicBarrier:一次性栅栏与可循环栅栏
两者都用于线程间的协调,但侧重点不同。
CountDownLatch(倒计时门闩):- 核心思想:一个或多个线程等待其他一组线程完成操作。它像一个计数器,初始化时设定一个数值
N。其他线程完成任务后调用countDown(),计数器减1。调用await()的线程会阻塞,直到计数器减为0。 - 特点:一次性。计数器减到0后就不能再重置。
- 典型场景:
- 主线程等待所有子线程初始化完成后再执行。
- 模拟并发测试,让所有测试线程同时开始执行。
- 多个线程等待一个事件的发生(如服务启动完成)。
- 核心思想:一个或多个线程等待其他一组线程完成操作。它像一个计数器,初始化时设定一个数值
CyclicBarrier(循环屏障):- 核心思想:让一组线程互相等待,直到所有线程都到达一个公共的屏障点,然后这一组线程再继续执行。它也可以接受一个
Runnable参数作为屏障动作(barrierAction),当所有线程到达屏障后,由最后一个到达的线程执行这个动作。 - 特点:可循环使用。当所有等待线程被释放后,屏障会自动重置,可以再次使用。
- 典型场景:
- 多线程计算任务,最后合并计算结果。
- 迭代计算,每一轮迭代都需要所有线程完成当前轮次的计算。
- 核心思想:让一组线程互相等待,直到所有线程都到达一个公共的屏障点,然后这一组线程再继续执行。它也可以接受一个
简单记忆:CountDownLatch是一个线程(或几个)等N个线程干完事;CyclicBarrier是N个线程相互等,等大家都到齐了再一起干下一件事。
3.3ThreadLocal:线程隔离的利器与内存泄漏的坑
ThreadLocal提供了线程局部变量,每个线程都有自己独立的变量副本,避免了共享带来的线程安全问题。它的典型应用场景是数据库连接、Session管理等需要与线程绑定的资源。
原理: 每个Thread对象内部都有一个ThreadLocal.ThreadLocalMap类型的变量threadLocals。这个Map的key是ThreadLocal实例本身(弱引用),value是存储的值。当调用ThreadLocal的set(T value)方法时,实际上是以当前ThreadLocal实例为key,将value存入当前线程的threadLocals这个Map中。
内存泄漏风险: 这是ThreadLocal最著名的坑。在ThreadLocalMap中,key(即ThreadLocal对象)是弱引用,而value是强引用。
- 如果
ThreadLocal外部强引用被置为null,那么在下一次GC时,这个ThreadLocal对象就会被回收,Map中的key就变成了null。 - 但是,
value由于是强引用,只要当前线程还在运行(例如使用了线程池,线程会复用),这个Entry就无法被回收,导致value永远无法被访问,却又占着内存,造成内存泄漏。
如何避免?
- 养成好习惯:每次使用完
ThreadLocal后,务必调用其remove()方法,清理当前线程的Map中对应的Entry。这是最有效的方法。 - 将
ThreadLocal变量声明为private static final,使其生命周期与类一致,避免被意外回收。 - 如果使用了线程池,线程会长期存活,这个问题会更加严重,
remove()就更加关键。
4. 线程池:不只是Executors.newFixedThreadPool
线程池是管理和复用线程的核心工具。直接使用Executors的工厂方法(如newFixedThreadPool,newCachedThreadPool)虽然方便,但在生产环境中往往不是最佳选择,因为它们隐藏了一些重要的参数细节。
4.1 核心参数与工作原理
我们需要深入理解ThreadPoolExecutor的构造函数:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)corePoolSize(核心线程数):线程池中常驻的线程数量。即使它们空闲,也不会被回收(除非设置了allowCoreThreadTimeOut)。maximumPoolSize(最大线程数):线程池允许创建的最大线程数。keepAliveTime(空闲线程存活时间):当线程数超过corePoolSize时,多余的空闲线程在等待新任务的最长时间,超过这个时间将被终止。workQueue(工作队列):用于存放等待执行的任务的阻塞队列。这是线程池调优的关键。threadFactory(线程工厂):用于创建新线程。可以在这里设置线程名、优先级、守护线程等,便于监控和排查问题。handler(拒绝策略):当线程池和队列都满了,无法处理新任务时,采取的应对策略。
线程池的工作流程(务必理解):
- 提交一个任务。
- 如果当前运行的线程数 <
corePoolSize,则创建新线程来执行任务(即使有空闲线程)。 - 如果运行的线程数 >=
corePoolSize,则将任务放入workQueue。 - 如果队列已满,且运行的线程数 <
maximumPoolSize,则创建新的非核心线程来执行任务。 - 如果队列已满,且运行的线程数已达到
maximumPoolSize,则触发拒绝策略handler。
4.2 队列与拒绝策略的选择
工作队列(workQueue)的选择:
SynchronousQueue:一个不存储元素的队列。每个插入操作必须等待另一个线程的移除操作。newCachedThreadPool使用了它。它要求线程池有足够的增长能力(maximumPoolSize为Integer.MAX_VALUE),否则很容易触发拒绝策略。适用于任务量瞬间暴涨的场景,但需警惕线程数无限增长。LinkedBlockingQueue(无界队列):newFixedThreadPool和newSingleThreadExecutor默认使用它。队列长度理论上是无限的。当所有核心线程都在忙时,新任务会在队列中等待。这会导致队列无限堆积,最终可能引发OOM。生产环境慎用无界队列。ArrayBlockingQueue(有界队列):需要指定队列容量。这是生产环境的推荐选择之一。它能在队列满时触发创建新线程(如果未达最大线程数)或拒绝策略,提供了背压(back-pressure)能力。PriorityBlockingQueue(优先级队列):可以按优先级执行任务。
拒绝策略(RejectedExecutionHandler):
AbortPolicy(默认):直接抛出RejectedExecutionException异常。CallerRunsPolicy:由调用者线程(提交任务的线程)自己来执行这个任务。这提供了一个简单的反馈机制,会降低新任务的提交速度。DiscardPolicy:默默丢弃无法处理的任务,不抛异常。DiscardOldestPolicy:丢弃队列中最老的一个任务,然后尝试重新提交当前任务。
生产环境配置建议: 不要直接使用Executors,而是根据业务场景手动创建ThreadPoolExecutor。
- CPU密集型任务(如计算、加密):线程数建议设置为
CPU核心数 + 1。过多的线程会导致频繁的上下文切换,降低性能。 - IO密集型任务(如网络请求、数据库操作):线程数可以设置得多一些,因为线程大部分时间在等待IO。经验公式:
CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。例如,如果计算时间与等待时间比为1:2,则可设为CPU核心数 * 3。通常可以设置为2 * CPU核心数。 - 使用有界队列(如
ArrayBlockingQueue),并设置一个合理的容量。 - 定义有意义的线程名前缀,方便通过
jstack等工具排查问题时识别线程。 - 根据业务重要性选择合适的拒绝策略。对于核心业务,可以考虑使用
CallerRunsPolicy或自定义策略(如将任务持久化到数据库或消息队列,稍后重试)。
4.3 常见问题排查思路
- 任务堆积,CPU使用率低:很可能是任务处理中有阻塞(如慢SQL、外部HTTP调用),且线程数配置不足或队列过长。需要分析任务内容,优化慢操作,或适当增加线程数(对于IO密集型)。
- CPU使用率高,甚至100%:
- 可能是CPU密集型任务,线程数设置过多,导致大量上下文切换。用
top -Hp [pid]和jstack查看线程状态,如果大量线程处于RUNNABLE状态且在做计算,就需要降低线程数。 - 也可能是代码中存在死循环或低效算法。
- 可能是CPU密集型任务,线程数设置过多,导致大量上下文切换。用
- 内存溢出(OOM):如果使用了无界队列(
LinkedBlockingQueue),任务产生速度持续大于消费速度,队列中的任务对象会不断堆积,最终撑爆堆内存。这是使用Executors.newFixedThreadPool的典型风险。
5. 原子类与CAS:无锁并发的基础
当面试官问“除了锁,还有什么方式保证线程安全?”时,原子类(AtomicInteger,AtomicLong,AtomicReference等)是一个重要答案。它们的核心是CAS(Compare-And-Swap)操作。
5.1 CAS原理与ABA问题
CAS是一种乐观锁机制。它包含三个操作数:内存位置(V)、旧的预期值(A)、新值(B)。当且仅当V的值等于A时,处理器才会用B更新V的值,否则不执行更新。无论是否更新,都会返回V的旧值。整个操作是一个原子指令(通常由CPU硬件提供,如x86的cmpxchg指令)。
Java通过Unsafe类提供的本地方法(如compareAndSwapInt)来调用底层CPU的CAS指令。
ABA问题: 假设一个变量初始值为A。线程1准备用CAS将其改为C,它读取到旧值A。此时,线程2将值从A改为B,然后又从B改回A。接着线程1执行CAS,发现当前值仍然是A(符合预期),于是成功将A改为了C。对于线程1来说,它感知不到这个值曾经发生过A->B->A的变化。在某些场景下(如链表的头节点),这可能带来问题。
解决方案:AtomicStampedReference和AtomicMarkableReference。它们不仅比较值,还比较一个版本号(Stamp)或标记(Mark)。每次修改不仅更新值,还更新版本号。CAS操作需要同时检查值和版本号是否都符合预期。
5.2 原子类的实现与局限
以AtomicInteger的incrementAndGet()为例,其内部通常是一个自旋循环:
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; } // Unsafe.getAndAddInt 内部类似: public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); // 获取当前值 } while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS失败则循环重试 return v; }优点:在低到中度竞争下,避免了线程挂起和调度的开销,性能比锁好。缺点:
- 自旋开销:在高竞争环境下,如果CAS一直失败,线程会长时间循环,消耗CPU资源。
- 只能保证一个共享变量的原子操作:对于多个变量,CAS无法保证其原子性。但可以用
AtomicReference将多个变量封装成一个对象来操作。 - ABA问题(已讨论)。
6. 线上问题排查:死锁与线程状态分析
理论知识最终要服务于解决问题。线上系统出现性能问题或假死,多线程往往是怀疑对象。
6.1 死锁的识别与定位
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。在Java中,最常用jstack命令来定位死锁。
使用
jstack:jstack -l <pid> > thread_dump.txt打开
thread_dump.txt文件,搜索“deadlock”或“Found one Java-level deadlock:”,jstack通常会清晰地指出哪些线程互相等待,以及它们各自持有什么锁、在等待什么锁。分析线程堆栈:死锁的线程通常会处于
BLOCKED状态,并且其堆栈信息会显示它在等待某个对象的监视器(waiting to lock <0x000000071a2345b0>),同时持有另一个锁(locked <0x000000071a2345a0>)。通过对比多个线程的持有锁和等待锁,就能画出资源等待图,找到循环等待链。
预防死锁的常见方法:
- 避免嵌套锁:尽量只获取一个锁。如果必须获取多个,确保所有线程以固定的全局顺序获取锁(例如,总是先锁A,再锁B)。
- 使用带超时的锁:如
ReentrantLock.tryLock(long timeout, TimeUnit unit),获取锁失败超时后,可以释放已持有的锁并回退重试。 - 静态代码分析工具:在代码层面检测潜在的死锁风险。
6.2 理解Java线程状态
通过jstack看到的线程状态是诊断问题的关键:
RUNNABLE:线程正在JVM中执行,但不一定正在占用CPU(可能正在等待操作系统调度)。BLOCKED:线程被阻塞,等待进入一个synchronized方法或代码块(即等待获取一个监视器锁)。这是与WAITING/TIMED_WAITING的关键区别。WAITING:线程处于无限期等待状态,等待被其他线程显式唤醒。通常由Object.wait()、Thread.join()或LockSupport.park()导致。TIMED_WAITING:线程处于限期等待状态。由Thread.sleep(long)、Object.wait(long)、Thread.join(long)等带超时参数的方法导致。TERMINATED:线程已执行完毕。
一个常见的误解:线程在等待IO(如读Socket)时,在jstack中显示什么状态?答案是**RUNNABLE**。因为Java线程状态是JVM层面的,它只关心线程是否在等待JVM管理的资源(如锁)。等待底层操作系统IO操作完成,对于JVM来说,线程仍然是“可运行”的,尽管它可能实际上在操作系统层面被挂起。这一点在分析高IO等待的应用时非常重要,不要看到大量RUNNABLE线程就以为是CPU计算繁忙。
7. Java内存模型(JMM)与happens-before
这是多线程中最抽象、最难理解,但也最核心的部分。它定义了线程如何与内存交互,以及什么情况下一个线程对共享变量的写操作对另一个线程可见。
7.1 为什么需要JMM?
现代计算机为了性能,会有多级缓存(CPU L1/L2/L3 Cache),这会导致一个核心修改了数据,另一个核心可能无法立即看到,即可见性问题。此外,编译器和处理器会对指令进行重排序以优化性能,这可能导致程序执行顺序与代码书写顺序不一致,即有序性问题。JMM就是为了在复杂的硬件和编译器优化背景下,给程序员提供一个一致的内存可见性保证。
7.2happens-before规则
JMM通过happens-before规则来阐述内存可见性。如果操作Ahappens-before操作B,那么A所做的任何内存修改对B都是可见的。这是一组偏序关系,不需要实际的时间先后。
几条关键的happens-before规则:
- 程序顺序规则:在同一个线程中,按照代码顺序,前面的操作
happens-before后面的操作。(注意:这仅保证单线程内的语义,不影响重排序对多线程的可见性)。 - 监视器锁规则:对一个锁的解锁操作
happens-before于后续对这个锁的加锁操作。 volatile变量规则:对一个volatile变量的写操作happens-before于后续对这个变量的读操作。- 传递性:如果A
happens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC。
一个综合例子:
// 线程A sharedVariable = 1; // 普通写 lock.unlock(); // 操作1:解锁 // 线程B lock.lock(); // 操作2:加锁 int r = sharedVariable; // 操作3:读根据规则2,操作1happens-before操作2。 根据规则1(在单线程B内),操作2happens-before操作3。 根据规则4(传递性),操作1happens-before操作3。 因此,线程A在解锁前对sharedVariable的写入,对线程B在加锁后的读取是可见的。这就是synchronized能保证可见性的底层原理之一。
理解JMM和happens-before,你就能从原理上明白为什么synchronized、volatile、final等关键字能保证线程安全,而不仅仅是记住结论。当你在代码中正确地使用了这些同步机制时,JMM会确保你的程序获得预期的内存可见性。