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

 更新时间:2026年06月13日 09:13:48   作者:雪宫街道  
在Java中,synchronized关键字是用于控制多个线程对共享资源的访问,以防止出现数据不一致的情况,它通过几种机制真正保证了线程安全,本文给大家介绍Java并发编程synchronized如何真正保证线程安全,感兴趣的朋友一起看看吧

synchronized 是 Java 中最基础的线程同步手段,本文从"写后读"这一核心思想出发,结合真实的错误示范,彻底讲清楚 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 无关,是所有并发系统必须遵守的铁律。

九、其他问题

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

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

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

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

Q3:如何正确使用 synchronized?

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

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

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

十、小结

并发错误的根本原因:
某线程"已读未写",其他线程已经读了 → 相互覆盖 → 结果错误

解决方案的核心思想:
写后读 —— 一个线程写完之后,其他线程才能读

synchronized 的实现方式:
禁止方法被同时拷贝 → 写完才释放锁 → 天然保证写后读

正确使用的关键:
锁的范围必须包含完整的"读 → 改 → 写",缺一不可

到此这篇关于Java并发编程 synchronized 关键字如何真正保证线程安全的文章就介绍到这了,更多相关Java并发编程 synchronized 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 详解springcloud之服务注册与发现

    详解springcloud之服务注册与发现

    本次分享的是关于springcloud服务注册与发现的内容,将通过分别搭建服务中心,服务注册,服务发现来说明,非常具有实用价值,需要的朋友可以参考下
    2018-06-06
  • 探索Java中的equals()和hashCode()方法_动力节点Java学院整理

    探索Java中的equals()和hashCode()方法_动力节点Java学院整理

    这篇文章主要介绍了探索Java中的equals()和hashCode()方法的相关资料,需要的朋友可以参考下
    2017-05-05
  • java servlet手机app访问接口(三)高德地图云存储及检索

    java servlet手机app访问接口(三)高德地图云存储及检索

    这篇文章主要为大家详细介绍了java servlet手机app访问接口(三),高德地图云存储及检索,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2016-12-12
  • java客户端Jedis操作Redis Sentinel 连接池的实现方法

    java客户端Jedis操作Redis Sentinel 连接池的实现方法

    下面小编就为大家带来一篇java客户端Jedis操作Redis Sentinel 连接池的实现方法。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-03-03
  • Spring Boot 整合 MongoDB的示例

    Spring Boot 整合 MongoDB的示例

    这篇文章主要介绍了Spring Boot 整合 MongoDB的示例,帮助大家更好的理解和学习spring boot框架,感兴趣的朋友可以了解下
    2020-10-10
  • SpringBoot实战之SSL配置详解

    SpringBoot实战之SSL配置详解

    今天小编就为大家分享一篇关于SpringBoot实战之SSL配置详解,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧
    2019-02-02
  • java泛型基本知识及通用方法

    java泛型基本知识及通用方法

    这篇文章主要介绍了java泛型基础知识及通用方法,从以下几个方面介绍一下java的泛型: 基础, 泛型关键字, 泛型方法, 泛型类和接口,感兴趣的可以了解一下
    2019-04-04
  • Java实现单词倒序输出

    Java实现单词倒序输出

    这篇文章主要介绍了Java实现单词倒序输出,帮助大家更好的理解和学习Java,感兴趣的朋友可以了解下
    2020-08-08
  • Java 数据库连接池 DBCP 的介绍

    Java 数据库连接池 DBCP 的介绍

    这篇文章主要给大家分享的是 Java 数据库连接池 DBCP 的介绍, 是 Apache 旗下 Commons 项目下的一个子项目,提供连接池功能DBCP,下面来看看文章的具体介绍内容吧,需要的朋友可以参考一下
    2021-11-11
  • Spring lazy-init 懒加载的原理小结

    Spring lazy-init 懒加载的原理小结

    lazy-init 是一个非常重要的属性,可以优化应用的启动时间,尤其是在处理大量bean或者复杂依赖关系时,可以显著提高应用的响应速度,本文主要介绍了Spring lazy-init 懒加载的原理小结,感兴趣的可以了解一下
    2025-04-04

最新评论