JUC并发编程 - 4.2 synchronized 解决方案


🚀 JUC并发编程 - 4.2 synchronized 解决方案

在上节我们分析了多线程共享资源时出现的竞态问题,这一节正式进入解决方案:synchronized 互斥锁


🔧 应用之互斥

为了避免临界区发生竞态条件,常见手段有两大类:

  • 阻塞式解决方案:
    synchronizedLock

  • 非阻塞式解决方案:
    原子变量(AtomicInteger 等)

本节使用阻塞式解决方案synchronized,即对象锁


🔐 什么是对象锁synchronized

synchronized 采用互斥机制:让同一时刻最多只有一个线程持有对象锁,其它线程如果想获取锁,就会被阻塞

这样就能保证持有锁的线程安全执行临界区代码,不受上下文切换干扰。

🧠 理论理解
synchronized 是 Java 提供的内置锁机制,用于解决多线程共享资源的安全问题

它的核心作用是:

  • 让多个线程互斥访问共享资源,保证临界区的原子性操作。

  • 只有获得锁的线程能执行同步代码块,其他线程则阻塞等待

synchronized(对象) 中的“对象”就是锁的标识,同一时间,同一个锁对象只允许一个线程持有。

🏢 企业实战理解
字节跳动/美团: 实际项目中,synchronized 常用于小粒度的锁对象保护,比如一个缓存实例的读写,或者防止并发初始化单例。
NVIDIA: 在多 GPU 任务中,避免不同线程同时提交同一个 GPU 资源的请求,也通过类似的“互斥”机制实现。

💬 面试题(字节跳动)
问:Java 中 synchronized 的原理是什么?它锁的到底是对象的什么?

参考答案
synchronized 是 JVM 层面的实现,底层依赖对象的 Monitor(监视器锁)

每个对象在 Java 中都有一个对象头,里面有一块区域叫做Mark Word,用来存储锁信息。当线程进入 synchronized 块时会尝试获得对象的 Monitor,获取成功则进入,否则进入阻塞或自旋等待。

总结:锁的不是对象本身,而是对象头中的 Monitor 结构。

 

💬 场景题(字节跳动)
你在开发中发现,用 synchronized 锁住了一个方法,但程序仍然出现数据不一致问题。经过检查,发现你锁的对象是一个 new Object() 临时变量。请解释为什么这个锁无效。

参考答案
synchronized 必须锁住多线程间共享的同一个对象,而 new Object() 每次创建的都是不同实例,导致不同线程持有不同的锁对象,形同无锁。

解决:

  • 把锁对象提取为全局共享变量(如 static final Object lock = new Object()

  • 或者在面向对象中用 synchronized(this) 锁住实例对象。


⚠ 注意区分:互斥 vs 同步

  • 互斥:
    保证临界区同一时刻只有一个线程执行,防止竞态(本节重点)。

  • 同步:
    不同线程间存在“先后顺序”的依赖,一个线程需要等另一个线程运行到某个点(如 wait/notify)。

🧠 理论理解

  • 互斥(Mutual Exclusion):
    目的是保证同一时刻只有一个线程执行临界区代码,防止数据竞争

  • 同步(Synchronization):
    是为了协调线程之间的“执行顺序”,让一个线程等待另一个线程达到某个状态(如 wait/notify)。

虽然 synchronized 同时可以实现互斥和同步,但这两个概念本质不同

🏢 企业实战理解
阿里巴巴: 在高并发下,互斥锁用于保证数据一致性,同步机制用于线程协作(比如生产者-消费者模式)。

💬 面试题(阿里巴巴)
问:Java 中 synchronized 既可以实现互斥也可以实现同步,请解释两者的区别及示例。

参考答案

  • 互斥: 保证临界区代码同一时刻只能有一个线程执行,防止数据竞争。示例:两个线程同时对 counter++ 加锁,避免冲突。

  • 同步: 让线程间按照顺序协作,一个线程等待另一个线程达到某个条件。示例:wait/notify 实现生产者-消费者模式。

区别在于:

  • 互斥关注“同时只有一个线程”

  • 同步关注“线程之间的先后顺序”

 

💬 场景题(腾讯)
高并发压测时,你发现业务接口偶发返回错误数据。检查后发现临界区确实加了 synchronized,但是锁对象是一个 String 类型的 key。请解释这可能带来的问题。

参考答案
在 Java 中,字符串常量池会导致不同地方的相同字符串字面值共享同一对象

如果多个无关的业务模块锁了相同的字符串字面值,会意外竞争同一把锁,导致本不该互斥的操作被阻塞,甚至引发死锁。

建议:

  • 不要用字符串字面值作为锁对象

  • 可以使用 new String("...") 强制创建独立对象,或用 Object 类型锁代替


🔨 示例代码

✅ 临界区示例

static int counter = 0;

static void increment() {  // 临界区
    counter++;
}

static void decrement() {  // 临界区
    counter--;
}

✅ synchronized 语法

synchronized(对象) {
    // 临界区
}

💻 完整示例

static int counter = 0;
static final Object room = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter++;
            }
        }
    }, "t1");

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter--;
            }
        }
    }, "t2");

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.debug("最终结果: {}", counter);
}

 

3️⃣ 为什么 synchronized 有效

🧠 理论理解
synchronized 是基于 JVM 层面的**对象监视器(Monitor)**实现的。它能保证:

  • 同一个锁对象,只有一个线程能获得锁并进入同步块

  • 其他线程要么进入阻塞状态,要么不断尝试(如重入锁)

  • 锁释放后,等待的线程会被唤醒并竞争锁

底层实现:

  • 在 HotSpot JVM 中,synchronized 使用了偏向锁、轻量级锁、重量级锁三种状态,实现自适应锁优化。

🏢 企业实战理解
华为云: JVM 层面的锁机制对于高性能服务是关键,实际项目中通过 -XX:+PrintGCApplicationStoppedTimejstack 等工具监控锁竞争情况,优化系统吞吐。

💬 面试题(美团)
问:Java 的 synchronized 为什么能保证原子性?它如何阻止上下文切换时被打断?

参考答案
synchronized 依靠操作系统底层的互斥机制(如 pthread_mutex),在进入同步代码块时,会获得对象的 Monitor,其他线程就算切换到 CPU,也进不了同步块,只能阻塞。

原子性保障的关键:

  • 同步块内的指令虽然可以被挂起,但不会释放锁,保证在执行临界区时不会被其他线程打断执行

 

💬 场景题(阿里巴巴)
你优化某段代码时,把 synchronized 放到了 for 循环外面,压测时发现性能急剧下降。请解释为什么。

参考答案
synchronized 放在循环外,相当于一次性锁住了整个循环,执行 5000 次都持有锁,导致其他线程长时间阻塞,锁竞争变严重

原本每次循环加锁(小粒度锁)→ 优化成整个循环加锁(大粒度锁),虽然锁的次数减少了,但等待时间明显增加,反而拉低性能。


 

🏠 类比解释

  • synchronized(room) 中的 room 就像一个房间,有唯一入口(门)。

  • 线程 t1 好比一个人进入房间,锁上门并拿走钥匙。

  • 在房间内执行 counter++

  • 这时如果 t2 也到达 synchronized(room),会发现门被锁,只能阻塞等待

即使 t1 在房间内时 CPU 时间片用完了,它也不会释放锁,t2 依然进不来。

直到 t1 执行完毕离开房间,释放锁并唤醒等待线程,这时 t2 才能进入执行。

4️⃣ 类比房间的锁 

🧠 理论理解
synchronized(对象) 就像是一个房间(room)

  • 房间有一扇门(锁对象)

  • 只有一个线程能拿到钥匙进去

  • 其他线程只能在门口等待,直到持有锁的线程执行完释放锁。

🏢 企业实战理解
Google: 内部并发控制也强调“锁对象唯一性”,比如 Google Guava 提供的 Monitor 就是对这种房间锁的更高级封装。

 4️⃣ 锁对象的作用

💬 面试题(华为)
问:synchronized 加锁时锁对象如何选择?如果锁对象选错了会发生什么?

参考答案
锁对象是互斥的标识,必须是多线程间共享的同一个对象

常见错误:

  • 锁对象是局部变量(每次 new 出来的锁对象其实是不同实例)

  • 锁对象写成字符串常量(会被 String Pool 共享,导致意外锁竞争)

如果锁对象选错:

  • 会导致加锁失效(多线程并未锁住同一个对象)

  • 或者引发意外阻塞(锁了不该锁的对象)

 

4️⃣ 场景题:死锁风险

💬 场景题(美团)
你开发一个模块,发现线程 A 加了 synchronized(obj1) 后再加 synchronized(obj2),线程 B 加了 synchronized(obj2) 后再加 synchronized(obj1)。请问这会发生什么问题?怎么解决?

参考答案
这是典型的死锁风险

  • 线程 A 拿到 obj1 锁,等待 obj2

  • 线程 B 拿到 obj2 锁,等待 obj1

两者互相等待,谁也等不到释放,系统卡死。

解决办法:

  • 统一加锁顺序,保证所有线程先锁 obj1 再锁 obj2

  • 或者使用 tryLock 超时机制避免死锁


🖼 运行过程图

线程1线程2static i锁对象
尝试获取锁
拥有锁
getstatic i 读取 0
iconst_1 加 1
isub 减 1
上下文切换尝试获取锁,阻塞(BLOCKED)
putstatic i 写入
释放锁并唤醒拥有锁
getstatic i
iconst_1
iadd
putstatic i
释放锁

🤔 思考题

1️⃣ 如果把 synchronized(obj) 放在 for 循环的外面,会发生什么?
👉 相当于把整个循环都包裹在锁中,实现“整体原子性”。性能上会更慢,但完全避免每次循环时抢锁的开销。

2️⃣ 如果 t1 用 synchronized(obj1) 而 t2 用 synchronized(obj2) 会怎样?
👉 它们加的不是同一把锁,相当于没有互斥效果,并发冲突依然存在。

3️⃣ 如果 t1 加锁而 t2 不加锁呢?
👉 这种情况形同虚设,只要有一方不加锁,临界区就不能保证安全。


🛠 面向对象改进

把共享变量封装到对象中,更加优雅 👇:

class Room {
    int value = 0;

    public void increment() {
        synchronized (this) {
            value++;
        }
    }

    public void decrement() {
        synchronized (this) {
            value--;
        }
    }

    public int get() {
        synchronized (this) {
            return value;
        }
    }
}

@Slf4j
public class Test1 {
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("最终结果: {}", room.get());
    }
}

🧠 理论理解
当我们把共享变量封装到类里时,synchronized(this) 可以把锁对象绑定到实例,简化管理,同时做到锁粒度最小化

这种方式既符合面向对象封装思想,也让锁的管理变得更加清晰易控。

🏢 企业实战理解
字节跳动: 强调把共享资源“锁到最小作用域”,不仅提高了代码可读性,也减少了锁竞争范围,典型案例是数据库连接池、线程池的内部实现。

 5️⃣ 面向对象加锁

💬 面试题(腾讯)
问:为什么推荐将共享资源封装到对象里,并在方法内部用 synchronized(this) 加锁?

参考答案
这种做法的好处是:

1️⃣ 封装了资源和锁,遵循面向对象的“高内聚”思想
2️⃣ this 作为锁对象,天然是唯一的实例锁,确保多线程访问时互斥
3️⃣ 锁的粒度更清晰,易于维护,不容易出错

这也是很多框架(如数据库连接池、线程池)的标准做法。

5️⃣ 场景题:面向对象加锁

💬 场景题(华为云)
你开发一个 Room 类,t1 和 t2 两个线程同时操作 Room 的不同方法,发现数据还是出错了。代码中每个方法内部都加了 synchronized(this)。请问为什么还会有问题?

参考答案
synchronized(this) 锁住的是整个对象实例,如果 Room 类的多个方法都加了 synchronized(this),那么无论操作哪个方法,它们之间是互斥的,不会导致线程安全问题。

但出错的原因可能是:

  • Room 类内部还有非加锁的其他成员变量,或

  • 你操作的是不同 Room 实例,t1/t2 实际锁住的不是同一个对象。

检查点:

  • 确保多线程操作的是同一个对象实例

  • 确保涉及共享资源的方法都加锁保护

 


✅ 总结

本节你学到了:

  • synchronized 的基本原理

  • 互斥与同步的区别

  • 如何用对象锁保护共享资源

  • 面向对象的加锁思路

关键点: 任何时候,锁对象要统一,否则加锁就形同虚设。synchronized 的本质是用锁机制实现线程安全的“原子性”


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏驰和徐策

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值