news 2026/10/21 2:32:53

Java 多线程安全:从 synchronized 到 volatile 的完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 多线程安全:从 synchronized 到 volatile 的完整实战指南

导语:多线程编程中,线程安全问题往往是并发开发的第一道门槛。本文从线程调度随机性、共享变量修改、原子性等根源问题出发,系统讲解 synchronized 的多种写法、可重入锁、死锁的四个必要条件,以及 volatile 如何解决内存可见性与指令重排序,最后梳理 wait 与 sleep 的区别,帮助你建立完整的并发安全知识体系。

一、线程安全问题的根源

在并发编程中,线程安全问题通常由以下几个原因引起,理解这些根源是解决问题的前提。

1. 线程调度的随机性

操作系统对线程的调度是随机的,我们无法预测某个线程何时获得 CPU、何时被切换出去。这种随机性是并发问题的根本原因,也是我们无法直接干预的因素。

2. 多个线程针对同一变量进行修改

当两个或多个线程同时对一个共享变量进行修改操作时,由于调度顺序不确定,最终结果可能与预期不符。

3. 修改操作不是原子的

以count++为例,这一行代码在底层实际包含三个操作:读取 count 的值、将值加 1、将结果写回内存。这三个操作并非原子操作,线程可能在执行过程中被切换,导致数据覆盖。解决办法是引入锁机制,将多个操作合成一个整体来执行。

小结:原子性问题的本质是多个操作在执行过程中可能被其他线程打断,锁的作用就是把一组操作绑定为一个不可分割的整体。

4. 内存可见性引起的线程安全问题

每个线程都有自己的工作内存,当一个线程修改了共享变量的值,其他线程可能无法及时看到这个修改,从而引发线程安全问题。

5. 指令重排序引起的线程安全问题

编译器和 CPU 为了优化执行效率,可能会对指令进行重排序。在多线程环境下,这种重排序可能导致其他线程看到不符合预期的执行顺序。

二、线程安全问题的解决办法

针对上述不同的原因,Java 提供了对应的解决方案。其中,针对修改操作非原子性的问题,最常用的手段就是使用锁。

1. 锁的基本概念

锁(操作系统层面)的核心操作只有两个:加锁和解锁。在 Java 中,synchronized关键字是使用锁最直接的方式。

synchronized (obj) { // 这里编写需要保证原子性的代码 }

关于 synchronized 的使用,有以下几个要点:

  • 进入{表示加锁,进入}表示解锁。
  • 加锁操作是防止其他线程“插队”,但不会影响本线程被调度出 CPU。
  • 锁对象是关键:两个线程必须针对同一个对象加锁,才会产生锁竞争现象(部分串行化);如果针对不同对象加锁,则不会产生锁竞争,也就无法解决线程安全问题。

2. synchronized 修饰静态方法

synchronized 可以直接修饰一个静态方法,此时锁对象是当前类的 Class 对象。

synchronized public static void add() { count++; } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { synchronized (Demo11.class) { for (int i = 0; i < 5000; i++) { add(); } } }); // 启动线程... }

上面的代码中,synchronized (Demo11.class)就是针对类对象加锁。

3. synchronized 的其他写法

除了修饰静态方法,synchronized 还有两种常见写法。

写法一:synchronized 修饰实例方法
// 第一种写法:同步代码块,锁对象为 this /*public void add() { synchronized (this) { count++; } }*/ // 第二种写法:直接修饰实例方法 synchronized public void add() { count++; }

当 synchronized 修饰实例方法时,锁对象是当前实例对象(this)。

写法二:synchronized 修饰静态方法
synchronized public static void add() { count++; }

当 synchronized 修饰静态方法时,锁对象是当前类的 Class 对象。

注意点:synchronized 修饰实例方法时,不同实例可以出现两把锁;而 synchronized 修饰静态方法时,由于 Class 对象全局唯一,不会出现两把锁的情况。

4. 可重入锁

可重入锁的使用前提是:同一个线程、同一把锁。如果第一次加锁成功后,再次进行第二次加锁,synchronized 内部会判断第二次加锁的线程是否与第一次加锁是同一个线程。如果是同一个线程,则直接跳过;如果不是同一个线程,第二次加锁才真正生效,产生阻塞效果。

// 同一个线程对同一把锁连续加锁三次,因为可重入而不会阻塞 synchronized (c) { synchronized (c) { synchronized (c) { c.add(); } } }

上面的代码中,同一个线程对同一把锁连续加锁三次,由于可重入特性,不会产生死锁。

三、死锁问题

1. 一个线程一把锁,连续加两次

如果一把锁不可重入,同一个线程连续加锁两次就会导致死锁。可重入锁正是为了解决这个问题而设计的。

2. 两个线程两把锁,相互获取对方的锁

这是最典型的死锁场景:线程 t1 持有 locker1 后尝试获取 locker2,而线程 t2 持有 locker2 后尝试获取 locker1,双方都在等待对方释放锁,从而陷入死锁。

// 线程 t1:先拿到 locker1,再尝试拿 locker2 Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1获取到locker1"); sleep(1000); synchronized (locker2) { System.out.println("t1获取到locker2"); } } }); // 线程 t2:先拿到 locker2,再尝试拿 locker1,与 t1 形成环路等待 Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t2获取到locker2"); sleep(1000); synchronized (locker1) { System.out.println("t2获取到locker1"); } } });

3. 死锁形成的四个必要条件

死锁的形成必须同时满足以下四个条件:

  • 锁是互斥的:一个锁同时只能被一个线程持有(通常不可更改)。
  • 锁不可以被抢占:线程只能等待持有者主动释放锁(通常不可更改)。
  • 请求和保持:A 线程在获取到 locker1 的情况下,保持不释放的状态,再尝试获取 locker2。解决办法:在代码中避免锁的嵌套。
  • 循环等待(环路等待):多个线程之间形成等待环路。解决办法:约定加锁顺序,把锁进行编号,约定任何线程在持有多个锁时,都按照编号从小到大的顺序加锁(对应 M 个线程 N 把锁的哲学家就餐问题)。

小结:前两个条件是锁本身的固有属性,通常无法改变;后两个条件可以通过合理的代码设计来规避,是我们在开发中重点防范的方向。

四、volatile 解决内存可见性

1. 什么是内存可见性

在 Java 内存模型中,每个线程都有一份工作内存(由 CPU 的寄存器和缓存组成),所有线程共享一份主内存(即平时所说的内存)。当一个线程对某个数据进行操作时,遵循以下流程:

  • 修改操作:先把数据从主内存拷贝到工作内存,对工作内存操作后,再写回到主内存。
  • 读取操作:把数据从主内存拷贝到工作内存,再从工作内存中读取。

2. 内存可见性问题是如何引起的

由于编译器优化了读写操作,导致 A 线程无法第一时间读取到 B 线程新修改的值。此时引入 volatile 可以关闭这个优化,并强制每次读取都是最新值,每次写完立即对所有线程可见。

package Thread; import java.util.Scanner; // volatile 解决内存可见性 public class Demo13 { private static volatile int flag = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while (flag == 0) { // 循环等待 flag 变为非 0 } System.out.println("t1结束"); }); Thread t2 = new Thread(() -> { Scanner sc = new Scanner(System.in); System.out.println("输入flag的值"); flag = sc.nextInt(); System.out.println("t2结束"); }); t1.start(); t2.start(); } }

在上面的示例中,如果不加 volatile 修饰 flag,t1 线程可能永远无法感知 t2 线程对 flag 的修改,从而陷入死循环。

五、volatile 解决指令重排序

1. 指令重排序问题

以单例模式中的懒汉式为例,private volatile static SingletonLazy instance = null;的实例化过程可以简单视为三个步骤:

  1. 分配内存空间。
  2. 针对内存空间进行初始化。
  3. 将内存空间首地址复制到引用变量中。

如果不是在多线程环境中,这三个步骤的顺序可以改变。但在多线程环境中,顺序是不能改变的,因为其他线程可能会看到未初始化完全的对象。

2. 使用 volatile 禁止指令重排序

用 volatile 修饰 instance 时,会关闭 instance 变量读写的相关优化,确保从内存进行读写,操作顺序就不会被打乱了。

注意点:volatile 的两个核心作用分别是保证内存可见性和禁止指令重排序,它并不保证原子性。如果需要保证原子性,仍需借助 synchronized 或原子类。

六、wait 和 notify

1. wait 的阻塞机制

wait 方法用于让当前线程进入阻塞状态,其执行流程如下:

  1. 先释放锁。
  2. 等待 notify 通知(进入阻塞状态)。
  3. 当通知到达时,从阻塞状态变为就绪状态,并重新获取锁。

此外,t.wait(设置时间)可以指定等待时间,时间到了会自动唤醒。

2. notify 的唤醒机制

如果有若干个线程在等待,notify 会随机唤醒其中一个;t.notifyAll()则会唤醒所有正在等待的线程。

面试常问:notify 与 notifyAll 的核心区别在于:notify 随机唤醒一个等待线程,notifyAll 则唤醒所有等待线程。回答时可结合具体业务场景说明选择依据。

七、wait 和 sleep 的区别

wait 和 sleep 虽然都能让线程阻塞,但二者在设计意图和使用方式上有本质区别:

  • 设计意图不同:wait 的设计是为了被 notify 唤醒,超过时间自动唤醒只是“后手”;sleep 的设计就是按照一定时间发生阻塞。
  • 对锁的要求不同:wait 必须搭配锁使用;sleep 不需要搭配锁使用。
  • 被打断时的行为不同:wait 被打断时,先进行抢锁,再抛异常;sleep 被打断时,不会释放锁(可能导致线程饿死问题出现),直接抛异常。

面试常问:wait 和 sleep 的区别是 Java 并发面试的高频考点,建议从设计意图、锁的依赖、中断行为三个维度来回答。

八、总结

本文围绕线程安全的核心问题展开,梳理了从问题根源到解决方案的完整链路:线程调度的随机性无法避免,但针对共享变量修改的非原子性,可以使用 synchronized 加锁解决;针对内存可见性和指令重排序,可以使用 volatile 解决;而死锁问题则需要从四个必要条件出发,通过避免锁嵌套、约定加锁顺序等方式预防。掌握这些基础概念,是深入理解 Java 并发编程的必经之路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 2:32:23

远程多路监控集成技术指南:多画面分屏与轮播的实现方案

痛点引入 在视频监控系统的集成开发中&#xff0c;多路视频流的远程并发查看是一个核心且高频的需求。用户通常期望在一个界面中同时预览多个摄像头画面&#xff0c;并支持多画面分屏显示与定时轮播切换。然而&#xff0c;在实际工程落地过程中&#xff0c;开发者往往面临以下技…

作者头像 李华
网站建设 2026/10/21 2:32:23

事件相机_基于异步轨迹的结构和运动的线性n点求解器_ICCV 2025

ICCV 2025 的论文 《A Linear N-Point Solver for Structure and Motion from Asynchronous Tracks》 :利用具有独立时间戳的二维点轨迹,结合角速度信息,统一估计相机平移速度与三维点结构。 这里要区分两件事: ETAP / KLT: 负责提供点轨迹。 N-Point Solver: 利用轨迹、…

作者头像 李华
网站建设 2026/10/21 2:32:20

大一C语言学习记录-3

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 2:31:25

开源项目停更≠死掉:判断维护状态与应对冻结代码的实用策略

如果你最近在Gitee上找开源组件&#xff0c;大概率会遇到一种情况&#xff1a;翻看一个仓库的提交记录&#xff0c;最后一次commit写的是2023年6月&#xff0c;然后又翻了几个仓库&#xff0c;情况惊人地相似。这不是某个项目的特例&#xff0c;而是一整片区域内的项目都停在了…

作者头像 李华
网站建设 2026/10/21 2:31:06

Claude Code Skills 放对目录却不生效:插件启用顺序与缓存的三个坑

本文摘要&#xff1a;Skill 放对目录仍不生效是静默失败&#xff0c;常被误判为模型问题。本文复现目录冲突、frontmatter 缺失与 instincts 抢占三种失败&#xff0c;用日志确认加载。 一、问题与结论 从 mattpocock/skills 复制一个 Skill 到项目根目录的 .agents/skills/&a…

作者头像 李华