1. 遗忘的 unlock/release:粗心者的陷阱
使用现代同步工具(如 ReentrantLock 或 Semaphore)时,最阴险的错误之一就是忘记调用 unlock() 或 release()。如果不释放锁,其他线程会一直等待它被释放……直到永远。程序会挂起,你只会盯着屏幕发愣,想不通为什么什么都没有发生。
来看一个关于 ReentrantLock 的示例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// 糟糕!忘了调用 unlock(),现在大家都会挂起!
count++;
}
}
看起来无伤大雅,但如果从多个线程多次调用 increment(),在第一次调用之后,其余线程将会无限期地等待锁被释放。
为避免这种情况,请使用 try-finally 结构:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
现在,即使在方法中途抛出了异常,锁也会被保证释放。
这就像有人占用了洗手间(从里面反锁),然后忘了打开门就从窗户出去了。其他人只能一直等着这个人出来……别这么干!
2. 在错误对象上同步:“哎,锁挂错地方了!”
在 Java 中,关键字 synchronized 会针对某个对象进行加锁。但如果你选择了错误的对象来加锁,同步的效果就不会如你所愿。
错误 1:在局部变量上进行同步
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// 每次都是新对象——根本没有同步!
// 线程不会相互等待。
// 临界区没有得到保护!
}
}
这里每个线程都会创建自己的 lock 对象。结果就没有真正的加锁——线程会同时进入临界区。
正确做法:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// 现在所有线程都使用同一个 lock 对象
// 并且会真正地相互等待。
}
}
错误 2:在字符串字面量上同步
public void doSomething() {
synchronized ("lock") {
// 字符串字面量会被驻留:程序的不同部分
// 可能会意外地在同一个字符串上同步!
}
}
结论:
只在私有且专门为此创建、并且不会在其他地方使用的对象上进行同步。
3. 双重锁定(deadlock):“你等我,我等你,谁也动不了”
死锁(互相阻塞)是经典问题。两个(或更多)线程依次获取不同的锁并相互等待,直到程序彻底卡住。
示例:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// 为了演示更清晰,稍微等一下
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
如果一个线程调用 method1(),另一个调用 method2(),第一个线程会先拿到 lockA 并等待 lockB,而第二个线程则相反。结果就是两者互相等待,直到天荒地老。
如何避免?
- 在所有线程中始终以相同的顺序获取锁。
- 尽量减少同时持有的锁的数量。
- 当程序挂起时,使用诊断工具(例如 jstack)。
类比:
就像两个人在狭窄的走廊里相遇,每个人都决定“只要对方先让路我就让”。结果谁也不动,直到有一方先让步为止。
4. 过度同步:“宁可多同步点?”——并不总是对!
有时开发者出于怕出错,把什么都加上同步。结果是性能下降,而没有任何收益。
示例:
public synchronized void add(int value) {
// 这里只有一行代码,并不需要同步!
System.out.println("已添加: " + value);
}
在这种情况下不需要同步:通过 System.out.println 的输出已经是线程安全的,而且该方法不操作任何共享资源。
哪里会更糟?
如果你为那些被频繁调用、且不需要保护的方法加上同步,会显著降低程序性能。线程会排队等待,而本可以并行执行。
Best practice:
只同步真正需要保护的代码。让临界区尽可能小。
5. 错误使用 volatile:“有可见性,但没有原子性!”
在 Java 中,volatile 修饰符能保证变量的修改对所有线程可见。但它不能保证操作的原子性。
错误示范:
private volatile int counter = 0;
public void increment() {
counter++; // 非原子!
}
counter++ 包含读取、递增和写回三个步骤。如果两个线程同时执行这段代码,最终结果可能小于预期值。
正确做法:
对于原子操作,请使用 synchronized、AtomicInteger 或其他线程安全的类。
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
什么时候使用 volatile?
用于简单的标志(例如“结束运行”),当不需要原子性时。
GO TO FULL VERSION