Java并发编程中,线程同步的目标是保证多个线程访问共享资源时数据的一致性。synchronized关键字虽然简单,但它把加锁与解锁固化为语法结构,无法主动尝试获取锁、无法设置等待超时、也无法响应中断。Lock接口则把这些能力以方法形式暴露出来,让开发者可以像操作普通对象一样控制锁的生命周期。了解这一点后,再看Lock接口的典型实现ReentrantLock,就能明白为什么很多场景下显式锁比隐式锁更合适。

从synchronized到Lock:显式锁解决了哪些问题
synchronized的底层由JVM完成锁的标记与释放,编码简洁,但灵活性不足。当一个线程尝试进入synchronized块时,如果锁被占用,它只能一直阻塞,既不能中途放弃,也不能指定最多等多久。尤其在需要跨方法加锁、先判断再获取锁、或者需要同时管理多个条件队列的场景中,这种一刀切的方式会让代码结构变得别扭。比如实现一个带超时的缓存加载,如果用synchronized只能一直等待其他线程执行完,而Lock可以直接调用tryLock(long time, TimeUnit unit)设置等待上限。
Lock接口位于java.util.concurrent.locks包,核心方法包括lock()、unlock()、tryLock()、lockInterruptibly()和newCondition()。与synchronized最大的不同是,lock()和unlock()必须成对出现,并且unlock()通常放在finally块中,确保异常时也能释放。这种显式控制让锁的获取与释放位置完全由开发者决定,可以跨方法传递,也可以在循环中反复尝试。下面是一个最简单的用法示例:
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();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
上面代码把加锁和解锁分别放在try块外和finally块内,这是官方推荐写法。如果lock()成功但后续业务抛异常,finally仍然会释放锁,避免出现锁泄漏。需要注意的是,lock()不能放在try块内,否则一旦lock()抛出异常,finally中的unlock()就会在未持有锁的情况下执行,可能抛出IllegalMonitorStateException。这个细节是很多初学者容易踩的坑。
ReentrantLock的核心特性:可重入、tryLock与公平策略
ReentrantLock正如其名,支持可重入。同一个线程在已经持有锁的情况下再次调用lock()不会被阻塞,而是把持有计数加一,直到调用相同次数的unlock()才会真正释放。这一特性让递归方法或嵌套调用共享资源时不必担心自己锁死自己。synchronized同样支持可重入,但Lock接口的实现通过内部计数器更直观地表达了这个过程。需要强调的是,lock()每次调用都必须对应一次unlock(),否则计数无法归零,锁会一直被占用。
tryLock()是无阻塞的获取方式,它立即返回布尔值,表示是否成功拿到锁。tryLock(long, TimeUnit)则允许在指定时间内等待,超过时间就返回false。这种机制非常适合打破死锁循环。假设两个线程分别需要同时获取A、B两把锁,如果用lock()顺序不当就可能互相等待。改用tryLock可以让线程先尝试获取第一把锁,再尝试获取第二把锁,如果第二把锁拿不到,就释放第一把锁并稍后重试,从而避免死锁。下面的示例模拟了这种情况:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private static final Lock lockA = new ReentrantLock();
private static final Lock lockB = new ReentrantLock();
public void process() throws InterruptedException {
while (true) {
if (lockA.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
// 同时持有两把锁,执行核心业务
System.out.println(Thread.currentThread().getName() + " 完成操作");
return;
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
Thread.sleep(10);
}
}
}
公平锁与非公平锁是ReentrantLock构造函数的可选参数。默认使用非公平锁,吞吐量更高,因为新来的线程有机会直接抢到锁,减少线程切换;公平锁则严格按照等待队列的顺序分配锁,避免线程饥饿,但整体性能稍低。在实时性要求较高或需要避免某些线程长期得不到执行的场景中,可以传入true启用公平模式,例如new ReentrantLock(true)。不过大多数业务系统不会因为公平性产生明显差异,除非线程数量很大且锁竞争激烈。
使用Condition实现更精细的等待与唤醒
synchronized配合Object的wait()、notify()、notifyAll()可以实现等待/通知模型,但一个对象只有一个隐式条件队列,所有线程都挤在同一个队列中,无法区分等待的具体原因。Condition接口由Lock的newCondition()方法创建,一个Lock可以创建多个Condition,每个Condition对应一个独立的等待队列。这样生产者只需要唤醒消费者队列,消费者只需要唤醒生产者队列,不会出现无效唤醒或惊群效应。
Condition的核心方法是await()和signal()/signalAll(),分别对应wait()和notify()/notifyAll(),但语义更清晰。使用时必须先持有锁,否则会抛出IllegalMonitorStateException。await()会释放当前锁并让线程进入等待状态,直到被signal()唤醒或线程被中断。signal()只唤醒一个等待线程,signalAll()唤醒所有等待线程。下面用生产者消费者模型展示双Condition的用法:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Buffer {
private final Queue<String> queue = new LinkedList<>();
private final int capacity = 5;
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(String item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.offer(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public String take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
String item = queue.poll();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
代码中notFull和notEmpty分别管理缓冲区满和空两个条件。当队列满时,生产者线程在notFull上等待,消费者取出数据后只唤醒notFull上的一个生产者。同理,队列空时消费者在notEmpty上等待,生产者放入数据后只唤醒notEmpty上的一个消费者。相比notifyAll()无差别唤醒所有线程,这种拆分能减少不必要的线程唤醒和上下文切换。注意await()要用while循环包裹,防止虚假唤醒或条件变化后线程继续执行错误逻辑。
Lock使用中的常见问题与选择建议
显式锁虽然灵活,但若使用不当会带来锁泄漏、死锁和复杂度上升。最常见的问题就是忘记在finally中释放锁。一旦业务逻辑提前返回或抛出异常,unlock()没有执行,后续所有需要这把锁的线程都会永久阻塞。因此建议把lock()放在try外,unlock()放在finally的第一行,并且不要在lock()与try之间插入可能抛出异常的代码。另一个高频错误是锁粒度过粗,把耗时操作如网络请求、文件IO放在锁内,导致锁被长时间占用,并发性能不升反降。应该把锁的保护范围缩小到只覆盖共享变量的读写。
那么什么时候用Lock,什么时候继续用synchronized?如果只是简单的同步块,代码不超过几行,synchronized足够简洁可靠,JVM优化也成熟。如果需要尝试获取锁、设置超时、响应中断、公平策略或多条件队列,Lock接口是更合理的选择。从JDK 6开始synchronized经过大量优化,在低竞争下性能已经与ReentrantLock差距很小,所以选择时优先考虑语义需求而不是性能数字。显式锁带来的额外控制必须配合严谨的代码规范,否则反而增加维护成本。
另外,Lock接口的实现类除了ReentrantLock还有ReadWriteLock下的ReentrantReadWriteLock,适合读多写少的场景。读锁之间共享,写锁独占,能进一步提高并发读吞吐。但读写锁的升级与降级规则比较复杂,需要避免在持读锁时尝试获取写锁造成死锁。可以先从ReentrantLock和Condition入手,熟悉显式锁的基本套路,再逐步应用到更复杂的并发结构中。

