章节目录:
一、ReentrantReadWriteLock
1.1 概述
ReentrantReadWriteLock
是读写锁,和ReentrantLock
会有所不同,对于读多写少的场景使用ReentrantReadWriteLock
性能会比ReentrantLock
高出不少。- 在多线程读时互不影响,不像
ReentrantLock
即使是多线程读也需要每个线程获取锁。当读操作远远高于写操作时,这时候使用 读写锁 让 读-读 可以并发,提高性能,类似于数据库中的select ...from ... lock in share mode
。 - 任何一个线程在写的时候就和
ReentrantLock
类似,其它线程无论读还是写都必须获取锁。 - 注意:同一个线程可以拥有
writeLock
与readLock
(但必须先获取writeLock
再获取readLock
, 反过来进行获取会导致死锁)。
1.2 基本使用
- 代码示例:
public class ReadWriteLockSample {
/**
* 测试『读-读』并发。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test01() throws InterruptedException {
DataContainer container = new DataContainer();
Thread t1 = new Thread(container::read, "t1");
Thread t2 = new Thread(container::read, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 10:05:07.574 [t1] get read lock
// 10:05:07.574 [t2] get read lock
// 10:05:08.590 [t2] read unlock
// 10:05:08.590 [t1] read unlock
// 从上面结果可以看到,t1锁定期间,t2读操作不受影响。
}
/**
* 测试『读-写』相互阻塞。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test02() throws InterruptedException {
DataContainer container = new DataContainer();
Thread t1 = new Thread(container::read, "t1");
sleep(1);
Thread t2 = new Thread(container::write, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 10:06:10.423 [t2] get write lock
// 10:06:10.423 [t1] get read lock
// 10:06:11.433 [t2] write unlock
// 10:06:12.443 [t1] read unlock
}
/**
* 测试『写-写』相互阻塞。
*
* @throws InterruptedException 中断异常
*/
@Test
public void test03() throws InterruptedException {
DataContainer container = new DataContainer();
Thread t1 = new Thread(container::write, "t1");
sleep(1);
Thread t2 = new Thread(container::write, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 10:06:59.386 [t2] get write lock
// 10:06:59.386 [t1] get write lock
// 10:07:00.409 [t2] write unlock
// 10:07:01.419 [t1] write unlock
}
}
/**
* 提供一个 数据容器类 内部分别使用:
* 读锁保护数据的 `read()` 方法,写锁保护数据的 `write()` 方法。
*/
@Slf4j
class DataContainer {
private Object data;
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private ReentrantReadWriteLock.ReadLock r = rwl.readLock();
private ReentrantReadWriteLock.WriteLock w = rwl.writeLock();
public Object read() {
log.debug("get read lock");
r.lock();
try {
sleep(1);
return data;
} finally {
log.debug("read unlock");
r.unlock();
}
}
public void write() {
log.debug("get write lock");
w.lock();
try {
sleep(1);
} finally {
log.debug("write unlock");
w.unlock();
}
}
}
1.3 注意事项
- 读锁不支持条件变量。
- 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待。
r.lock();
try {
// ...
w.lock();
try {
// ...
} finally{
w.unlock();
}
} finally{
r.unlock();
}
- 重入时降级支持:即持有写锁的情况下去获取读锁。
public class CachedData {
private Object data;
private volatile boolean cacheValid;
private static final ReentrantReadWriteLock RWL = new ReentrantReadWriteLock();
public void processCachedData() {
RWL.readLock().lock();
if (!cacheValid) {
// 加写锁前需要释放读锁。
RWL.readLock().unlock();
RWL.writeLock().lock();
try {
// 判断是否有其它线程已经获取了写锁、更新了缓存, 避免重复更新。
if (!cacheValid) {
data = "value...";
cacheValid = true;
}
// 降级为读锁, 释放写锁, 这样能够让其它线程读取缓存。
RWL.readLock().lock();
} finally {
RWL.writeLock().unlock();
}
}
try {
use(data);
} finally {
RWL.readLock().unlock();
}
}
public void use(Object data) {
// do something...
}
}
二、缓存应用
2.1 缓存更新策略
- 先清缓存策略:
- 先更新数据库策略:
- 补充一种情况(这种情况的出现几率非常小),假设查询线程 t1 查询数据时恰好缓存数据由于时间到期失效或是第一次查询:
2.2 读写锁实现一致性缓存
使用读写锁,实现一个简单的按需加载缓存。
public class GenericCachedDao<T> {
Map<SqlPair, T> cache = new HashMap<>();
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
GenericDao dao = new GenericDao();
public boolean update(String sql, Object... params) {
SqlPair key = new SqlPair(sql, params);
// 加写锁, 防止其它线程对缓存读取和更改。
rwl.writeLock().lock();
try {
boolean isUpdated = dao.update(sql, params);
cache.clear();
return isUpdated;
} finally {
rwl.writeLock().unlock();
}
}
public T queryOne(Class<T> beanClass, String sql, Object... params) {
SqlPair key = new SqlPair(sql, params);
// 加读锁,防止其他线程对缓存更改。
rwl.readLock().lock();
try {
T value = cache.get(key);
if (null != value) {
return value;
}
} finally {
rwl.readLock().unlock();
}
// 加写锁,防止其它线程对缓存读取和更改。
rwl.writeLock().lock();
try {
// 为了防止重复查询数据库,需要进行二次检查。
T value = cache.get(key);
if (null == value) {
// 仍为空则查询数据库。
value = dao.queryOne(beanClass, sql, params);
// 将数据库查询的结果放入缓存。
cache.put(key, value);
}
return value;
} finally {
rwl.writeLock().unlock();
}
}
}
class GenericDao {
public <T> T queryOne(Class<T> beanClass, String sql, Object... params) {
// do something...
return null;
}
public boolean update(String sql, Object... params) {
if (!sql.isEmpty() && params.length > 0) {
// do something...
return true;
}
return false;
}
}
@AllArgsConstructor
@EqualsAndHashCode
class SqlPair {
private String sql;
private Object[] params;
}
- 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑:
- 适合读多写少,如果写操作比较频繁,以上实现性能低;
- 没有考虑缓存容量;
- 没有考虑缓存过期;
- 只适合单机;
- 并发性还是低,目前只会用一把锁;
- 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key);
- 乐观锁实现:用
CAS
去更新。
三、读写锁原理
读写锁用的是同一个
Sycn
同步器,因此等待队列、state
等也是同一个。
3.1 t1 加写锁、t2 加读锁
- t1 成功上锁,流程与
ReentrantLock
加锁相比没有特殊之处,不同是写锁状态占了state
的低 16 位,而读锁使用的是state
的高 16 位。 - t2 执行
r.lock
,这时进入读锁的sync.acquireShared(1)
流程,首先会进入tryAcquireShared()
流程。如果有写锁占据,那么tryAcquireShared()
返回 -1 表示失败。(补充:0 表示成功,但后继节点不会继续唤醒;正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1。) - 这时会进入
sync.doAcquireShared(1)
流程,首先也是调用addWaiter()
添加节点,不同之处在于节点被设置为Node.SHARED
模式而非Node.EXCLUSIVE
模式,注意此时 t2 仍处于活跃状态。 - t2 会看看自己的节点是不是老二,如果是,还会再次调用
tryAcquireShared(1)
来尝试获取锁。 - 如果没有成功,在
doAcquireShared()
内for (;;)
循环一次,把前驱节点的waitStatus
改为 -1,再for (;;)
循环一次尝试tryAcquireShared(1)
如果还不成功,那么在parkAndCheckInterrupt()
处park
(灰色表示)。
3.2 其它线程加读写锁
- 这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,此时的状态就如图中所示。
3.3 t1 释放写锁
- 此时会走到写锁的
sync.release(1)
流程,调用sync.tryRelease(1)
成功; - 接下来执行唤醒流程
sync.unparkSuccessor
,即让老二恢复运行,这时 t2 在doAcquireShared()
内parkAndCheckInterrupt()
处恢复运行,这回再来一次for (;;)
执行tryAcquireShared()
成功则让读锁计数加一; - 这时 t2 已经恢复运行,接下来 t2 调用
setHeadAndPropagate(node, 1)
,它原本所在节点被置为头节点。
- 在
setHeadAndPropagate()
方法内还会检查下一个节点是否是shared
,如果是则调用 ;doReleaseShared()
将head
的状态从 -1 改为 0 并唤醒老二,这时 t3 在doAcquireShared()
内parkAndCheckInterrupt()
处恢复运行; - 这回再来一次
for (;;)
执行tryAcquireShared()
成功则让读锁计数加一;
- 这时 t3 已经恢复运行,接下来 t3 调用
setHeadAndPropagate(node, 1)
,它原本所在节点被置为头节点; - 下一个节点不是
shared
了,因此不会继续唤醒 t4 所在节点。
3.4 t1、t2 释放读锁
- t2 进入
sync.releaseShared(1)
中,调用tryReleaseShared(1)
让计数减一,但由于计数还不为零; - t3 进入
sync.releaseShared(1)
中,调用tryReleaseShared(1)
让计数减一,这回计数为零了,进入doReleaseShared()
将头节点从 -1 改为 0 并唤醒老二,即之后 t4 在acquireQueued()
中parkAndCheckInterrupt()
处恢复运行,再次for (;;)
这次自己是老二,并且没有其他竞争,tryAcquire(1)
成功,修改头结点,流程结束。
3.5 源码分析
- 『写锁』上锁流程:
- 『写锁』释放流程:
- 『读锁』上锁流程:
- 『读锁』释放流程:
四、StampedLock
该类自
JDK
8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合『戳』使用。StampedLock
是比ReentrantReadWriteLock
更快的一种锁,支持乐观读、悲观读锁和写锁。和ReentrantReadWriteLock
不同的是,StampedLock
支持多个线程申请乐观读的同时,还允许一个线程申请写锁。
4.1 基本使用
public class StampedLockSample {
private static final StampedLock LOCK = new StampedLock();
public static void main(String[] args) {
// 1.加解读锁。
long stamp = LOCK.readLock();
LOCK.unlockRead(stamp);
// 2.加解写锁。
long stamp1 = LOCK.writeLock();
LOCK.unlockWrite(stamp1);
/* *
* 3.乐观锁:StampedLock 支持 tryOptimisticRead() 方法(乐观读)。
* 读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用;
* 如果校验没通过,需要重新获取读锁,保证数据安全。
*/
long stamp2 = LOCK.tryOptimisticRead();
// 验戳。
if (!LOCK.validate(stamp)) {
// 锁升级。
}
}
}
4.2 读写优化
需求:提供一个数据容器类,内部分别使用
StampedLock
实现读锁保护数据的read()
方法,写锁保护数据的write()
方法,并测试在多线程下的使用情况。
- 数据容器类:
@Slf4j
public class DataContainerStamped {
private int data;
private static final StampedLock LOCK = new StampedLock();
public DataContainerStamped(int data) {
this.data = data;
}
public int read(int readTime) {
// 乐观读。
long stamp = LOCK.tryOptimisticRead();
log.debug("optimistic read locking...stamp:[{}]", stamp);
sleep(readTime, TimeUnit.SECONDS);
// 验戳。
if (LOCK.validate(stamp)) {
log.debug("read finish...stamp:[{}], data:[{}]", stamp, data);
return data;
}
// 读锁升级。
log.debug("updating to read lock... stamp:[{}]", stamp);
stamp = LOCK.readLock();
try {
log.debug("read lock stamp:[{}]", stamp);
sleep(readTime, TimeUnit.SECONDS);
log.debug("read finish...stamp:[{}], data:[{}]", stamp, data);
return data;
} finally {
log.debug("read unlock stamp:[{}]", stamp);
// 释放读锁。
LOCK.unlockRead(stamp);
}
}
public void write(int newData) {
long stamp = LOCK.writeLock();
log.debug("write lock stamp:[{}]", stamp);
try {
sleep(2, TimeUnit.SECONDS);
this.data = newData;
} finally {
log.debug("write unlock stamp:[{}]", stamp);
LOCK.unlockWrite(stamp);
}
}
}
- 测试:
public class DataContainerStampedTests {
/**
* 测试 『读-读』可以优化。
*/
@Test
public void testReadAndRead() throws InterruptedException {
DataContainerStamped dataContainer = new DataContainerStamped(1);
Thread t1 = new Thread(() -> dataContainer.read(1), "t1");
sleep(500, TimeUnit.MILLISECONDS);
Thread t2 = new Thread(() -> dataContainer.read(0), "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// [t1] optimistic read locking...stamp:[256]
// [t2] optimistic read locking...stamp:[256]
// [t2] read finish...stamp:[256], data:[1]
// [t1] read finish...stamp:[256], data:[1]
// 输出结果,可以看到实际没有加读锁。
}
/**
* 测试 『读-写』 时优化读补加读锁。
*/
@Test
public void testReadAndWrite() throws InterruptedException {
DataContainerStamped dataContainer = new DataContainerStamped(1);
Thread t1 = new Thread(() -> dataContainer.read(1), "t1");
sleep(500, TimeUnit.MILLISECONDS);
Thread t2 = new Thread(() -> dataContainer.write(100), "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// [t1] optimistic read locking...stamp:[256]
// [t2] write lock stamp:[384]
// [t1] updating to read lock... stamp:[256]
// [t2] write unlock stamp:[384]
// [t1] read lock stamp:[513]
// [t1] read finish...stamp:[513], data:[100]
// [t1] read unlock stamp:[513]
}
}
4.3 注意事项
StampedLock
不支持条件变量。StampedLock
不支持可重入。- 总结:虽然它相较于读写锁优化了性能,但也不等同于能代替读写锁,使用还得根据实际场景进行选择。
五、Semaphore
[ˈsɛməˌfɔr]
Semaphore
(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
5.1 基本使用
public static void main(String[] args) {
// 许可设置为3。
Semaphore semaphore = new Semaphore(3);
// 假设 5 个线程同时运行。
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
// 获取许可。
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
log.debug("running...");
sleep(2, TimeUnit.SECONDS);
log.debug("ending...");
} finally {
// 释放许可。
semaphore.release();
}
}).start();
// 16:26.370 [Thread-1] running...
// 16:26.370 [Thread-0] running...
// 16:26.370 [Thread-2] running...
// 16:28.379 [Thread-2] ending...
// 16:28.379 [Thread-1] ending...
// 16:28.379 [Thread-0] ending...
// 16:28.379 [Thread-4] running...
// 16:28.379 [Thread-3] running...
// 16:30.391 [Thread-3] ending...
// 16:30.391 [Thread-4] ending...
}
}
5.2 限制对共享资源的使用
-
需求说明:
- 使用
Semaphore
限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat
LimitLatch
的实现)。 - 用
Semaphore
实现简单连接池,对比『享元模式』下的实现(用wait-notify
),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的。
- 使用
-
代码实现:
@Slf4j
public class ConnectionPoolBySemaphoreSample {
/* *
* 连接池大小。
*/
private int poolSize;
/* *
* 连接对象数组。
*/
private Connection[] connections;
/* *
* 连接状态数组 0 表示空闲, 1 表示繁忙。
*/
private AtomicIntegerArray states;
/* *
* 信号量。
*/
private Semaphore semaphore;
/**
* 初始化构造器。
*
* @param poolSize 池大小
*/
public ConnectionPoolBySemaphoreSample(int poolSize) {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("connect-" + (i + 1));
}
}
/**
* 借连接。
*
* @return {@link Connection}
*/
public Connection borrow() { // t0, t1, t2 线程进入。
// 获取许可。
try {
// 没有许可的线程,在此等待。
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接。
if (states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// *该代码段不会被执行到。
return null;
}
/**
* 归还连接。
*
* @param conn 具体的连接
*/
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}
- 测试:
public class ConnectionPoolBySemaphoreSampleTests {
public static void main(String[] args) {
ConnectionPoolBySemaphoreSample pool = new ConnectionPoolBySemaphoreSample(2);
// 模拟三个线程去使用连接池中的两个连接。
for (int i = 0; i < 3; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
// [Thread-0] borrow connect-1
// [Thread-1] borrow connect-2
// [Thread-0] free connect-1
// [Thread-1] free connect-2
// [Thread-2] borrow connect-1
// [Thread-2] free connect-1
}
}
5.3 加锁解锁流程
Semaphore
有点像一个停车场,permits
就好像停车位数量,当线程获得了permits
就像是获得了停车位,然后停车场显示空余车位减一。
- 刚开始,
permits
(state
)为 3,这时 5 个线程来获取资源; - 假设其中 t1,t2,t4
cas
竞争成功,而 t0 和 t3 竞争失败,进入AQS
队列park
阻塞; - 这时 t4 释放了
permits
,接下来 t0 竞争成功,permits
再次设置为 0,设置自己为head
节点,断开原来的head
节点,unpark
接下来的 t3 节点,但由于permits
是 0,因此 t3 在尝试不成功后再次进入park
状态。
5.4 源码分析
- 上锁流程:
- 释放流程:
六、结束语
“-------怕什么真理无穷,进一寸有一寸的欢喜。”
微信公众号搜索:饺子泡牛奶。