在Java高并发编程中,锁是协调多线程访问、保证数据一致性的基本工具。但在实际项目中,尤其是一些读操作远多于写操作的场景,比如缓存系统、配置中心的客户端,如果你只是简单粗暴地给整个数据访问加上一把“大锁”,性能往往会立即成为瓶颈。这时候,理解互斥锁与读写锁的区别,并根据场景选择合适的工具,就成了提升并发能力的关键。这不是简单的二选一,而是在安全性和性能之间寻找一个精准的平衡点。
让我们先从最基础的互斥锁说起。在Java中,`synchronized`关键字和`ReentrantLock`类是实现互斥锁的典型代表。它们的核心逻辑很简单:同一时刻,只允许一个线程持有锁并访问受保护的代码块或资源。这就像只有一个钥匙的密室,不管你是要进去观摩(读)还是要重新布置(写),都必须排队等候,拿到钥匙才能进去,出来后再把钥匙交给下一个。
下面是一个使用`ReentrantLock`保护一个简单数据对象的例子:
```java
import java.util.concurrent.locks.ReentrantLock;
public class DataWithMutex {
private String data = “Initial Data”;
private final ReentrantLock lock = new ReentrantLock();
public String readData() {
lock.lock(); // 读操作也需要抢占互斥锁
try {
// 模拟读取耗时
Thread.sleep(10);
return data;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return “”;
} finally {
lock.unlock();
}
}
public void writeData(String newData) {
lock.lock(); // 写操作抢占互斥锁
try {
// 模拟写入耗时
Thread.sleep(50);
this.data = newData;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
这种模型绝对安全,数据绝不会出现读取时被另一个线程修改的“脏读”问题。但它的代价也显而易见:效率低下。假设这个`readData`方法平均需要10毫秒,`writeData`需要50毫秒。在纯粹的互斥锁下,即使是100个线程只想同时读取数据,它们也不得不一个接一个地串行执行,总耗时可能超过1秒。而在这段时间里,CPU大量时间在空闲等待,资源被严重浪费。这就是互斥锁的最大问题:它没有区分操作类型,将本可以并行的读操作也强行序列化了。
正是为了破解这个困局,读写锁应运而生。读写锁,即`ReadWriteLock`,其思想非常符合直觉:既然多个线程同时读取共享数据不会产生冲突(因为数据没有被改变),那么为什么不让它们同时进行呢?读写锁将锁的访问细分为两类:读锁(共享锁)和写锁(排他锁)。它的核心规则可以概括为三条:第一,可以有多条线程同时持有读锁,进行并发读取;第二,写锁是排他的,同一时刻只能有一条线程持有写锁,并且此时不能有任何读锁存在;第三,写锁的优先级通常更高,以防止“写线程饥饿”(即读线程太多导致写线程一直无法获取锁)。Java在`java.util.concurrent.locks`包中提供了`ReentrantReadWriteLock`这一实现。
让我们用读写锁重构上面的例子:
```java
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class DataWithReadWriteLock {
private String data = “Initial Data”;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
public String readData() {
readLock.lock(); // 获取读锁,多个线程可同时进入
try {
Thread.sleep(10);
return data;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return “”;
} finally {
readLock.unlock();
}
}
public void writeData(String newData) {
writeLock.lock(); // 获取写锁,具有排他性
try {
Thread.sleep(50);
this.data = newData;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
writeLock.unlock();
}
}
}
这个改变带来的性能提升可能是巨大的。在刚才100个读线程的场景下,它们现在可以几乎同时获取读锁并执行,总耗时可能只略长于单个线程的读取时间(10毫秒多一点)。整个系统的吞吐量因此得到成数量级的提升。读写锁的精妙之处在于,它通过区分操作类型,极大地放宽了并发读取的限制,只在必要的时候(进行写操作或读取时刚好有写操作)才施加严格的互斥。
当然,读写锁并非银弹,它也有自己的开销和适用边界。首先,读写锁本身的实现比互斥锁复杂,维护读线程计数、协调读写状态转换都需要成本。因此,在竞争不激烈或读写操作都很快的场景下,使用读写锁带来的性能收益可能无法覆盖其额外的开销,甚至可能比互斥锁还慢。其次,你需要确保你的场景确实是“读多写少”,并且读操作本身耗时相对较长(如涉及I/O、网络或复杂计算)。如果写操作非常频繁,那么锁的大部分时间会处在写锁或等待写锁的状态,其效果就退化成类似互斥锁了。
在实际选择时,一个有效的评判方法是进行性能压测。你可以尝试两种不同的锁实现,在模拟真实负载的情况下,观察系统的吞吐量(QPS)和平均响应时间。如果数据明确显示读写锁有显著优势,再行采用。此外,还需要注意`ReentrantReadWriteLock`的构造器支持创建一个公平锁。在公平模式下,线程按请求顺序获取锁,能防止饥饿但会降低吞吐;在非公平模式下(默认),允许“插队”,吞吐量更高但可能造成某些线程等待时间过长。这又是一个需要根据业务特点进行的选择。
更进一步,在极高并发的现代Java开发中,一些更高级的并发工具也开始扮演重要角色。比如,`StampedLock`是Java 8引入的一种能力更强的锁,它提供了一种乐观读的模式。乐观读假设在读取过程中很少发生写入,因此它先获取一个“邮票”(stamp),读完之后再去验证这个“邮票”在读期间是否无效(即是否有写操作发生)。如果无效,才升级为悲观读锁。这在读极多、写极少的场景下,能比`ReentrantReadWriteLock`提供更好的性能,但它的API也更复杂,更容易出错。
CN
EN