Java多线程(三):线程同步与 synchronized 关键字详解
在前两篇文章中,我们学习了多线程的三种创建方式以及线程的生命周期与状态转换,掌握了线程从创建到终止的全过程。然而,当多个线程并发访问共享资源时,如果没有适当的协调机制,就可能出现数据不一致、竞态条件(Race Condition) 等严重问题。
本文将深入探讨 Java 中最基础也是最重要的线程同步机制——synchronized
关键字,帮助你理解它是如何保证原子性、可见性与有序性,从而实现线程安全的。
一、为什么需要线程同步?
考虑以下场景:两个线程同时对一个共享变量 count
执行自增操作:
public class Counter {
private int count = 0;
public void increment() {
count++; // 非原子操作:读取 -> 修改 -> 写入
}
public int getCount() {
return count;
}
}
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终结果: " + counter.getCount()); // 很可能小于 20000!
❗ 问题分析:
count++
并非原子操作,它包含三步:
- 读取
count
的值- 将值加 1
- 写回内存
多个线程可能同时读取到相同的旧值,导致“丢失更新”。
这就是典型的竞态条件。解决方法就是使用同步机制,确保同一时刻只有一个线程能执行该操作。
二、synchronized
的核心作用
synchronized
是 Java 内建的 互斥锁(Mutex) 机制,它能确保:
- ✅ 原子性(Atomicity):多个操作作为一个整体执行,不会被其他线程打断。
- ✅ 可见性(Visibility):一个线程修改共享变量后,其他线程能立即看到最新值。
- ✅ 有序性(Ordering):防止指令重排序带来的问题(配合 happens-before 规则)。
💡
synchronized
的底层依赖于 监视器锁(Monitor Lock),每个 Java 对象都关联一个监视器。
三、synchronized
的三种使用方式
1. 修饰实例方法
锁住的是当前实例对象(this)。
public class SynchronizedCounter {
private int count = 0;
// 锁住 this 对象
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
✅ 适用场景:多个线程操作同一个对象实例的方法。
2. 修饰静态方法
锁住的是该类的 Class 对象(即 SynchronizedCounter.class
)。
public class SynchronizedCounter {
private static int totalCount = 0;
// 锁住 SynchronizedCounter.class
public static synchronized void incrementTotal() {
totalCount++;
}
}
✅ 适用场景:保护类级别的共享资源,多个实例共享同一份静态数据。
3. 同步代码块(推荐)
手动指定锁对象,粒度更细,性能更高。
public class FineGrainedCounter {
private int count = 0;
private final Object lock = new Object(); // 专用锁对象
public void increment() {
synchronized (lock) { // 显式加锁
count++;
}
}
}
✅ 推荐使用专用对象作为锁,避免暴露
this
或Class
引用,提升安全性与灵活性。
⚠️ 不推荐:使用字符串常量或
Integer
等缓存对象作为锁,可能导致意外的锁竞争。
四、synchronized
的底层原理简析
synchronized
的实现依赖于 JVM 的 监视器(Monitor) 机制,其核心是 monitor enter
和 monitor exit
字节码指令。
当线程进入 synchronized
块时:
- 尝试获取对象的监视器锁。
- 若锁空闲,则获取成功,进入临界区。
- 若已被其他线程持有,则进入
BLOCKED
状态,等待锁释放。
🔍 从 JDK 1.6 开始,
synchronized
经历了优化,引入了:
- 偏向锁(Biased Locking)
- 轻量级锁(Lightweight Locking)
- 重量级锁(Heavyweight Locking)
实现了锁的升级机制,在无竞争时开销极小,有竞争时自动升级,兼顾性能与安全性。
五、代码示例:对比同步与非同步
public class SyncDemo {
private int unsafeCount = 0;
private int safeCount = 0;
private final Object lock = new Object();
// 非同步方法:线程不安全
public void unsafeIncrement() {
unsafeCount++;
}
// 同步方法:线程安全
public synchronized void safeIncrement() {
safeCount++;
}
public static void main(String[] args) throws InterruptedException {
SyncDemo demo = new SyncDemo();
Runnable unsafeTask = () -> {
for (int i = 0; i < 10000; i++) {
demo.unsafeIncrement();
}
};
Runnable safeTask = () -> {
for (int i = 0; i < 10000; i++) {
demo.safeIncrement();
}
};
Thread t1 = new Thread(unsafeTask);
Thread t2 = new Thread(unsafeTask);
Thread t3 = new Thread(safeTask);
Thread t4 = new Thread(safeTask);
t1.start(); t2.start();
t3.start(); t4.start();
t1.join(); t2.join();
t3.join(); t4.join();
System.out.println("非同步结果: " + demo.unsafeCount); // 可能 < 20000
System.out.println("同步结果: " + demo.safeCount); // 一定是 20000
}
}
输出示例:
非同步结果: 18364
同步结果: 20000
✅ 只有使用
synchronized
才能保证最终结果的正确性。
六、常见误区与最佳实践
❌ 误区1:synchronized 锁住的是代码
✘ 错误理解:
synchronized
保护的是某段代码。✅ 正确理解:它保护的是对象的监视器。多个线程要竞争的是同一个锁对象,才能互斥。
synchronized (new Object()) { // 每次都新建对象,锁无效!
count++;
}
❌ 上述代码每次创建新对象,各线程持有不同的锁,无法实现同步。
❌ 误区2:synchronized 方法比代码块更高效
✘ 事实:
synchronized
方法锁的范围更大(整个方法),可能导致不必要的阻塞。✅ 建议:优先使用同步代码块,只锁住关键代码段,减少锁的持有时间。
✅ 最佳实践总结
实践 | 说明 |
---|---|
使用 private final Object 作为锁 | 避免外部干扰,防止锁被恶意释放 |
避免使用 String 、Integer 等缓存对象作为锁 | 可能引发意外的锁竞争 |
减少同步代码块的粒度 | 提高并发性能 |
避免在同步块中调用外部方法 | 防止死锁或延长锁持有时间 |
七、synchronized
的局限性
虽然 synchronized
简单易用,但也存在一些限制:
- ❌ 无法尝试获取锁(无
tryLock
) - ❌ 无法中断正在等待锁的线程
- ❌ 不支持公平锁(默认非公平)
- ❌ 锁的粒度较粗,难以实现复杂同步逻辑
八、总结
特性 | synchronized |
---|---|
使用方式 | 方法、静态方法、代码块 |
锁对象 | this / Class / 指定对象 |
原子性 | ✅ |
可见性 | ✅ |
可中断等待 | ❌ |
超时获取 | ❌ |
公平性 | ❌(默认非公平) |
底层机制 | Monitor(JVM 内建) |
synchronized
是 Java 多线程编程的基石。理解其工作原理和使用场景,是构建线程安全程序的第一步。它虽然简单,但威力强大,是每个 Java 开发者必须掌握的核心技能。