1. 关键字 synchronized:为什么以及如何使用
在 Java 中,关键字 synchronized 就像洗手间门上的“占用中!”牌子:当一个线程位于“临界区”时,其他线程会礼貌地等待轮到自己。只有当第一个离开后,下一个才能进入并执行其代码。
语法:代码块与方法
同步代码块
synchronized (object) {
// 临界区
}
- object — 任意你想“上锁”的对象。只要有一个线程在执行该代码块,其他也想进入使用相同对象作为锁的线程就会等待。
同步方法
public synchronized void increment() {
// 临界区
}
- 这里“锁”加在对象本身(this)上。也就是说,同一时间只有一个线程可以执行该对象的任意同步方法。
静态同步方法
public static synchronized void foo() {
// 临界区
}
- 这里在类级别(ClassName.class)进行加锁,而不是具体对象。
底层如何工作
当线程进入同步代码块或方法时,它会获取该对象的“监视器”(对于静态方法则是类的监视器)。如果监视器已被占用——线程就等待。一旦监视器释放,下一个线程即可进入。
2. 示例:有无同步的计数器自增
未同步
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; 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());
}
}
期望值: 2000
实际值: 可能更小(例如,1995、1987……),而且每次运行都会有自己的“惊喜”。
为什么?因为操作 count++ 不是原子的:它分成三个步骤——读取值、递增、写回。如果两个线程同时进行,就可能互相“覆盖”结果。
解决方案:synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
现在同一时间只有一个线程可以执行 increment() 方法。最终值将始终是 2000。
替代方案:同步代码块
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
结果相同。你可以只同步需要的那一部分,而不是整个方法。
3. 对象监视器简介
监视器是 Java 中内置于每个对象的“锁”。当你写 synchronized(object) 时,线程会尝试“锁住”这个对象。如果锁是空闲的——线程获得锁;如果不是——就等待。一旦线程退出该代码块,锁便释放。
重要! 如果你在不同的对象上进行同步——线程之间不会互相等待。因此选择正确的同步对象非常重要。
静态同步方法
有时共享资源并不是某个对象,而是对类的所有实例都通用的东西(例如静态变量)。这种情况下,应在类级别进行同步。
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
}
这等价于:
public static void increment() {
synchronized (StaticCounter.class) {
count++;
}
}
监视器加在类对象(Class)上,而不是具体实例。
4. 关键字 volatile:是什么,有何作用
线程间可见性问题
在 Java 中,每个线程都可能为了加速而缓存变量的值。这意味着如果一个线程修改了变量,另一个线程可能“察觉不到”,仍然从自己的本地缓存中读取。这对线程间用来传递信号的标志尤其关键。
volatile 如何工作
如果变量被声明为 volatile,意味着:
- 所有线程总是只从主内存读取、并写回主内存,绕过缓存。
- 对该变量的任何修改会立刻对所有线程可见。
但是! 对 volatile 变量的操作本身并不都是原子的(除了对 boolean、int 等这类简单原始类型的读/写)。如果你做的事情比简单赋值更复杂——就需要同步。
示例:停止标志
public class Worker extends Thread {
private volatile boolean running = true;
public void run() {
while (running) {
// 做一些有用的事情
}
System.out.println("线程已结束");
}
public void shutdown() {
running = false;
}
}
Worker w = new Worker();
w.start();
// ... 过一段时间
w.shutdown();
如果没有 volatile,线程可能“注意不到”标志的变化而陷入无限循环(尤其在多核系统上)。使用 volatile 时——一切会按预期工作。
5. volatile 的限制:非原子性
很多新手会想:“如果把 int 声明为 volatile,就可以写 count++ 而不操心了”。很遗憾,并非如此:
private volatile int count = 0;
public void increment() {
count++;
}
错误! 操作 count++ 仍然不是原子的——它是三步:(1) 读取,(2) 增加,(3) 写回。如果两个线程同时读取到相同的值,它们都会将其增加并写回相同的结果——就会“丢失”一次自增。
结论: volatile 只提供可见性,但不能在复杂操作中防止竞态条件。
6. 何时使用 synchronized,何时使用 volatile
- volatile —— 当你有一个简单的标志(例如 boolean),由一个线程写、另一个线程读。示例:线程结束信号、事件通知。
- synchronized —— 当需要保证复杂操作的原子性(例如自增、修改多个变量、操作数据结构)。
记忆表
| 场景 | volatile | synchronized |
|---|---|---|
| 在线程之间传递信号 | ✔ | ✔ |
| 原子操作(自增) | ✖ | ✔ |
| 临界区中的多步操作 | ✖ | ✔ |
| 仅保证可见性 | ✔ | ✔ |
7. 使用 synchronized 和 volatile 时的常见错误
错误 1:在错误的对象上进行同步。 如果你在局部变量上同步,或每个线程使用不同的对象进行同步——就起不到任何保护作用。
Object lock = new Object();
synchronized (lock) {
// ...
}
如果每个线程各自创建自己的 lock —— 等于没有锁。需要一个统一的同步点,即所有线程共享的锁对象。
错误 2:指望 volatile 提供原子性。 volatile 只保证可见性,不保证原子性。像 count++ 这样的操作在没有同步的情况下依然不安全。
错误 3:同步的代码范围过大。 如果你同步了整个方法,而实际上只需要同步一行代码,你就在无谓地阻塞其它线程,损失性能。尽量缩小“临界区”。
错误 4:对静态数据忘了进行类级别的同步。 如果变量是静态的,而你在 this 上同步,这是无效的。静态数据需要在类级别同步:synchronized(ClassName.class)。
错误 5:在字符串字面量上同步。 在字符串上同步是危险的,因为相同的字面量会被 JVM 驻留(intern)。程序的不同部分可能意外地共享同一把锁。
GO TO FULL VERSION