游乐游手机版
首页/编程语言/文章详情

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

时间:2026-06-19 06:44
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
上一篇SymPy自动因式分解:从面积拼图到代数恒等式 下一篇C语言动态内存管理malloc free深度解析
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
详解如何使用Apache服务器进行防盗链配置步骤
编程语言 · 2026-06-30

详解如何使用Apache服务器进行防盗链配置步骤

Apache使用mod_rewrite模块实现图片防盗链,通过 htaccess文件配置Rewrite规则,检查HTTP_REFERER来源,若非本站域名且来源不为空,则对jpg等常见图片格式返回403禁止访问。此方法能有效阻止大多数盗链行为。

Filebeat日志转发实现步骤详解
编程语言 · 2026-06-30

Filebeat日志转发实现步骤详解

Filebeat通过配置输入源读取日志,输出目标转发至Elasticsearch或Logstash。安装后编辑filebeat yml文件,指定日志路径和输出地址。支持直接转发或经Logstash处理。通过systemctl启动并验证数据到达,可选SSL加密和多行日志合并配置。

手把手教你如何在CentOS上使用PhpStorm构建项目的详细步骤
编程语言 · 2026-06-30

手把手教你如何在CentOS上使用PhpStorm构建项目的详细步骤

在CentOS上使用PHPStorm构建项目需先准备环境:安装Java、PHP及扩展、Nginx、MariaDB并开放端口。然后安装配置PHPStorm,设置SSH解释器与Web服务器映射。导入或创建项目后安装Composer依赖,调整php ini。配置SFTP部署并同步文件,最后设置Xdebug进行调试运行。

CentOS下GitLab集成其他工具的详细配置方法与完整指南
编程语言 · 2026-06-30

CentOS下GitLab集成其他工具的详细配置方法与完整指南

在CentOS平台中,GitLab通过Webhooks、API与CI CD配置,深度集成Jenkins、SonarQube、Docker及Slack,构建代码托管、自动构建、质量检查与协作通知的自动化链路,覆盖开发、测试、部署全流程,实现从提交到上线的自动化,大幅提升团队效率与交付质量,推动开发运维一体化。

CentOS设置Node.js定时任务的方法
编程语言 · 2026-06-30

CentOS设置Node.js定时任务的方法

在CentOS上为Node js应用设置定时任务常用两种方案:systemd适合长期运行服务,需创建服务文件并配置开机自启;cron更灵活,适合定期唤醒任务,通过编辑crontab添加时间计划和执行命令。两种方法均需指定Node js路径和应用入口。