当前位置: 首页
编程语言
Java并发编程synchronized关键字保证线程安全的原理

Java并发编程synchronized关键字保证线程安全的原理

热心网友 时间:2026-06-19
转载

synchronized通过互斥访问确保写后读,只允许一个线程执行完整操作后释放锁,其他线程才能读取。关键是将读、改、写全部置于同一锁范围内,否则分离读写仍会破坏原子性,导致线程不安全。

synchronized 是 Java 里最基础也最常用的线程同步机制,但你真的掌握它的用法了吗?别急着点头——很多开发者觉得只要加个锁就能保证线程安全,结果程序运行后依然出现数据错误。今天这篇文章就从“写后读”这个核心思想出发,配合真实的错误案例,把 synchronized 的工作原理和正确使用方式彻底讲清楚。

Ja va并发编程synchronized关键字如何真正保证线程安全

一、先回顾:volatile 为什么不够?

上一篇我们聊过,volatile 只能确保“读取的那一刻拿到最新数据”,但问题出在时间窗口——从线程读取数据到写回内存之间,其他线程可能已经把原始值修改了,导致当前线程写回时覆盖了别人的结果。来看一个典型示例:

private volatile int count = 0;
// 两个线程各执行 10 万次 count++
// 最终结果 ≠ 20 万,仍然错误

根本原因在于:volatile 无法保证“读 → 计算 → 写回”这一复合操作的原子性。 简单来说,它能让线程看到最新的值,但管不住多个线程同时修改带来的冲突。

要真正解决这个问题,就需要请出一个更强大的并发工具——synchronized

二、什么是“写后读”?——并发准确性的核心思想

在深入理解 synchronized 之前,先建立并发编程中最关键的思想:写后读(Write-Before-Read)

2.1 定义

写后读:一个线程将数据更新回主内存之后,其他线程才能去读取该数据。

换一种说法:任何线程在读取一个共享变量时,必须确保没有其他线程“已经读过了但还没写回”。

2.2 为什么违反写后读会产生错误?

用图示来说明,假设变量 x = 0,线程A累加10次,线程B累加6次:

正确的执行(写后读):
x=0 → 线程A读x=0,加10,写回x=10 → 线程B读x=10,加6,写回x=16 ✅
错误的执行(违反写后读):
x=0 → 线程A读x=0(还未写回)
      线程B也读x=0(A还没写回!)
      线程A写回x=10
      线程B写回x=6(覆盖了A的结果)
最终 x=6,丢失了线程A的贡献 ❌

只要存在“某个线程已经读完了但还没写回,另一个线程就跑去读”的情况,最终的计算结果就一定是错误的。 这就像两个人同时往一个桶里倒水,但记录水量的本子没有加锁——你倒完一次,他倒一次,本子上的数字早就乱套了。

2.3 写后读是通用法则,跨语言、跨架构

这个思想并不是 Java 语言特有的规则,而是所有并发场景下的普适原则:

场景保证准确性的方式
单机多线程线程A写完,线程B才能读
单机多进程进程A写完,进程B才能读
多服务器(分布式)服务器A写完,服务器B才能读

跟语言无关:Java、Python、Go、C++ 都需要遵循这一思想。编程语言只是工具,并发的底层规律是由计算机体系结构决定的。

跟规模无关:即使100台服务器共同操作数据库中的同一行数据,只要遵循写后读原则,最终的结果就是准确的。

三、synchronized 如何实现写后读?

3.1 synchronized 的霸道之处

synchronized 修饰的方法,在多线程调用时:

  1. 只允许一个线程竞争加锁成功,加锁成功后才能拷贝方法入栈并执行
  2. 其他线程想要调用同一个加锁方法,连拷贝方法这一步都会被拒绝
  3. 只有持有锁的线程执行完方法、写操作全部完成后,锁才会释放
  4. 锁释放后,其他线程才有资格参与竞争,才能读取到最新的数据
线程1 竞争锁成功 → 拷贝 add() 入栈 → 读 count → 计算 → 写回 count → 出栈 → 释放锁
                                                                              ↓
                                                              线程2 才能竞争锁
                                                              线程2 竞争锁成功 → 拷贝 add()
                                                              → 读到最新的 count → ...

连读的机会都不给,自然就实现了“写完才能读”——这就是 synchronized 提供的写后读保障机制。 它就像一个独裁者,把门关上,等你把所有操作都干完了,才放别人进去。

3.2 正确示范

public class SafeCounter {
    private volatile int count = 0;
    // synchronized 修饰整个方法:读 → 计算 → 写,全部在锁保护范围内
    public synchronized void add() {
        count++; // 等价于:读count → 加1 → 写回count
    }
    public int getCount() {
        return count;
    }
}
SafeCounter counter = new SafeCounter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter.add(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) counter.add(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(counter.getCount()); // 稳定输出 200000 ✅

四、加了 synchronized 还是错?——错误示范深度剖析

很多开发者以为“只要加了 synchronized 就万无一失”,这是一个非常危险的误解。来看一个经典的翻车案例:

4.1 错误代码

public class WrongCounter {
    private volatile int flag = 0;
    // 读方法加锁
    public synchronized int get() {
        return flag;
    }
    // 写方法加锁
    public synchronized void set(int value) {
        flag = value;
    }
}
WrongCounter counter = new WrongCounter();
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 100000; i++) {
        int val = counter.get(); // 读
        counter.set(val + 1);   // 写(两次独立的锁!)
    }
});
// ... 两个线程执行后,结果仍然不是 200000 ❌

4.2 错在哪里?

问题就在于 get()set() 是两次独立的加锁操作,它们之间存在一个时间窗口:

线程1 调用 get() → 加锁成功 → 读到 flag=5 → 执行完 get() → 释放锁
                                                               ↓
                                              此时锁已释放!线程2 可以进来了
线程2 调用 get() → 加锁成功 → 读到 flag=5(线程1 还没写回!)→ 执行完 → 释放锁
线程1 调用 set(6) → 加锁 → 写 flag=6 → 释放锁
线程2 调用 set(6) → 加锁 → 写 flag=6 → 释放锁(覆盖!应该是7)

关键问题:线程1 调用 get() 读完数据后还没有写回,get() 方法就执行完了,锁随之释放。这直接违反了写后读原则——线程2 在线程1 写回之前就读到了数据。

4.3 根本原因

synchronized 保证的是:锁保护范围内的操作是互斥的

但如果把“读”和“写”拆成两个独立加锁的方法,锁的保护范围就出现了断裂。在两次加锁操作之间,其他线程可以趁虚而入,写后读的保障就被破坏了。你虽然锁住了门,但却开了一条缝,别人还是能溜进来。

五、如何正确使用 synchronized?

核心原则

synchronized 的加锁范围必须包含从“读”到“写回”的完整操作。只有写操作全部完成之后,锁才能释放。

❌ 错误:锁保护了读,但写在锁外
    [lock] → 读 → [unlock] → 计算 → 写
✅ 正确:锁保护了读 + 计算 + 写的完整流程
    [lock] → 读 → 计算 → 写 → [unlock]

正确写法对比

// ❌ 错误:读和写分离,锁之间有空档
public synchronized int get() { return flag; }
public synchronized void set(int v) { flag = v; }
// 调用时:
int val = counter.get(); // 锁释放了!
counter.set(val + 1);    // 又加锁,但中间有空档
// ✅ 正确写法一:把读-改-写放在同一个 synchronized 方法内
public synchronized void increment() {
    flag++; // 读+改+写,全在一个锁内
}
// ✅ 正确写法二:用 synchronized 代码块保护完整操作
public void increment() {
    synchronized (this) {
        flag++; // 读+改+写,全在锁的范围内
    }
}

六、synchronized 与 volatile 的对比

对比项volatilesynchronized
可见性✅ 保证✅ 保证
有序性(禁止重排序)✅ 保证✅ 保证
原子性❌ 不保证✅ 保证(需正确使用)
性能开销相对较大
能修饰方法❌ 不能✅ 能
能修饰变量✅ 能❌ 不能直接修饰变量
适合场景状态标志、一写多读复合操作、临界区

volatile 只能修饰变量,不能修饰方法;synchronized 只能修饰方法和代码块,不能直接修饰属性(变量)。两者作用互补,不可混用替代。

七、synchronized 的本质:禁止方法被同时拷贝

用一句话总结 synchronized 的实现原理:

synchronized 禁止被它修饰的方法同时被两个线程拷贝入栈。只有当前持有锁的线程执行完毕(写操作完成),锁释放后,其他线程才有资格拷贝该方法。

这种“霸道”的方式,从根源上保证了写后读——你连读都读不到,自然不可能在别人写完之前就去读数据。这就像一家只有一把钥匙的健身房,一个人练完放下器械,下一个人才能进去练——永远不会有两个人同时碰到同一个哑铃的情况。

这也是为什么 synchronized 被称为重量级锁——它的代价比 volatile 大得多,因为它涉及线程的阻塞、唤醒以及上下文切换。

八、写后读的普适性:不仅仅是多线程

写后读思想的适用范围远不止多线程场景:

多线程:  线程A写完 → 线程B才能读
多进程:  进程A写完 → 进程B才能读
分布式:  服务器A写完数据库 → 服务器B才能读数据库

实际案例:电商秒杀系统中,100 台服务器同时操作数据库中的库存数量。只要数据库层面实现了写后读(通过事务锁、乐观锁等手段),最终的库存结果就是准确的,不会出现超卖问题。

这个道理与编程语言无关,跟是不是 Java 也没有关系,它是所有并发系统都必须遵守的铁律。无论你用 Go 的 channel、Python 的 threading,还是其他并发模型,最终都要回归到这一核心原则上。

九、其他问题

Q1:synchronized 是如何保证线程安全的?

synchronized 通过禁止方法被多个线程同时拷贝来实现互斥。加锁成功的线程才能执行方法,其他线程阻塞等待。当加锁线程完成写操作后,锁才会释放,其他线程才能参与竞争。这保证了“写后读”——任何读操作都发生在上一次写操作完成之后,从而确保计算结果的准确性。

Q2:get() 和 set() 都加了 synchronized,为什么还是线程不安全?

因为 get()set() 是两个独立的加锁操作,get() 执行完就释放了锁,此时写操作还没有发生,其他线程可以趁这个空档进来读取旧数据。这直接违反了写后读原则。正确的做法是将读-改-写的完整操作放在同一个 synchronized 块内,确保写完才释放锁。

Q3:如何正确使用 synchronized?

核心原则:锁的保护范围必须覆盖从“读”到“写回”的完整操作。如果只保护了读,或者把读和写拆成两个独立的锁操作,则仍然是不安全的。简单场景使用 synchronized 方法(整个方法体在锁内),复杂场景使用 synchronized(obj) { } 代码块来精确控制范围。

Q4:写后读思想是 Java 独有的吗?

不是。写后读是所有并发场景下的通用原则,与编程语言无关,也不限于多线程——多进程、多服务器分布式场景同样适用。只要并发系统遵循写后读,最终的计算结果就是正确的。

十、小结

并发错误的根本原因:

某线程“已读未写”,其他线程已经读了 → 相互覆盖 → 结果错误

解决方案的核心思想:

写后读 —— 一个线程写完之后,其他线程才能读

synchronized 的实现方式:

禁止方法被同时拷贝 → 写完才释放锁 → 天然保证写后读

正确使用的关键:

锁的范围必须包含完整的“读 → 改 → 写”,缺一不可

来源:https://www.jb51.net/program/365692ate.htm

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
用 LlamaIndex 做 RAG 前,先把 Reader、Index、Retriever 的边界写清楚

用 LlamaIndex 做 RAG 前,先把 Reader、Index、Retriever 的边界写清楚

LlamaIndex 不适合用“5 行代码做一个 RAG demo”来判断好坏。那个 demo 只能证明框架能跑通一次,不证明你的数据进入系统后仍然可追踪,不证明检索结果能解释,不证明 Agent 的

时间:2026-07-13 14:05
Go语言AES混淆加密解密敏感数据详解

Go语言AES混淆加密解密敏感数据详解

Go语言AES加密需注意:密钥长度必须为16、24或32字节,否则会引发panic;CBC模式解密乱码常因IV或PKCS7填充错误;GCM模式中nonce不可重用,否则密钥可被恢复;ECB模式因明文块输出固定密文,存在安全风险,不建议使用。

时间:2026-07-13 06:53
C++ std::atomic::wait高性能轮询等待原子变量状态变化

C++ std::atomic::wait高性能轮询等待原子变量状态变化

C++20的std::atomic::wait是轻量级等待机制,需GCC11+ Clang12+及glibc≥2 34,macOS不支持。需与notify配对,注意内存序,循环检查防虚假唤醒。超时版本精度有限,高性能关键在notify时机与内存序正确性。

时间:2026-07-13 06:53
VSCode左侧边栏不见了?快速找回方法

VSCode左侧边栏不见了?快速找回方法

按Ctrl+B或Cmd+B可恢复被误触隐藏的侧边栏。若无效,需检查窗口焦点是否在编辑器、是否处于全屏或Zen模式、workbench activityBar visible配置项、扩展冲突以及远程环境同步问题,并逐一排查。

时间:2026-07-13 06:53
VSCode中利用Node批量修改多媒体文件元数据标签

VSCode中利用Node批量修改多媒体文件元数据标签

music-metadata库支持跨格式读写MP3、FLAC、M4A、WAV等音频元数据,需先用parseFile()初始化,再调用writeMetadata()写入。批量修改前必须验证写入支持,封面图片需为Uint8Array格式。在VSCode终端运行脚本比插件更可控,处理中文路径时需切换终端代码页为UTF-8并指定ID3v2 3版本以避免乱码。

时间:2026-07-13 06:53
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜