1. 概述
前面介绍过了synchronized关键字作用的锁升级过程
无锁->偏向锁->轻量锁->重锁
下面再介绍实现Lock接口的锁的升级过程
无锁->独占锁(ReentrantLock,Synchronized)->读写锁(ReentranReadWriteLock)->邮戳锁(StampedLock)
并准备了一些问题,回顾一下自己对知识的掌握程度。
- 你知道Java里面有哪些锁?
- 你说你用过读写锁,锁饥饿问题是什么?有没有比读写锁更快的锁?
- StampedLock知道吗?(邮戳锁/票据锁)
- ReentrantReadWriteLock有锁降级机制,你知道吗?
2. ReentrantLock
ReentantLock是可重入的独占锁。默认是非公平锁。
可重入:当一个线程持有锁后,在内部可以继续获取锁。
独占:是一种悲观锁,当一个线程持有锁的时候,其他线程会阻塞。
公平和非公平:在公平的机制下,线程会依次排队,放到等待队列中。排队获取锁。在非公平的机制下,新来的线程通过CAS获取锁,获取不到,才会进入等待队列。
2.1 ReentrantLock使用代码演示
public static void main(String[] args)
{
new Thread(() -> {
lock.lock();
try
{
System.out.println(Thread.currentThread().getName()+"\t ----come in外层调用");
lock.lock();
try
{
System.out.println(Thread.currentThread().getName()+"\t ----come in内层调用");
}finally {
lock.unlock();
}
}finally {
// 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
lock.unlock();// 正常情况,加锁几次就要解锁几次
}
},"t1").start();
new Thread(() -> {
lock.lock();
try
{
System.out.println(Thread.currentThread().getName()+"\t ----come in外层调用");
}finally {
lock.unlock();
}
},"t2").start();
}
2.2 ReentrantLock和Synchronized比较
- ReentrantLock是对象,synchronzied是关键字
- 两者都是独占锁。(悲观锁)
- ReentrantLock加锁后需要手动解锁
try{//do something}finally{Lock.unlock();}
。synchronized关键字超出同步块自动解锁。 - ReentrantLock更灵活,可以控制是否是公平锁。synchronized只能是非公平锁。
使用场景的区别:
2.2.1 synchronized
写冲突比较多,线程强冲突的场景。
自旋的概率比较大,会导致浪费CPU性能。
2.2.2 ReentrantLock
synchronized锁升级是不可逆的,进入重量级锁后性能会下降。
ReentrantReadWriteLock(注意不是ReentrantLock)可以使用读写锁,增加性能。
3. ReentrantReadWriteLock
可重入读写锁。上面的可重入锁在两个线程同时读的过程中会竞争。可重入读写锁可以允许多个线程同时读取同一个资源。只允许读读共存,读写,写写之间都是互斥的。适用于读读不互斥的场景。
3.1 具有锁降级的性质
锁降级可以理解为一种操作。具体操作为写锁持有后,在准备释放写锁的之前,当前线程继续持有读锁,然后释放写锁。
writeLock.lock();
//do something
// 锁降级:释放写锁之前先只有读锁。
readLock.lock(); // A 降级开始
writeLock.unlock();// 注意执行完这一步,其他阻塞队列的头部的读线程才能进入。
// 锁降级完成
....
readLock.unlock();
这种方式的好处是:在耗时长的事务中,锁降级能够使让读操作更快进行执行不会被写操作给抢占,且后面的读操作不会被打断。
锁降级的代码演示:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class LockDownTest {
private Logger logger = LoggerFactory.getLogger(LockDownTest.class);
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
/**
* 这里仅仅是想知道锁重入的情况,是不是这个时候加入的锁会到等待队列里面排队。
*/
public void queryData() {
try {
Thread.sleep(500);
readLock.lock();
logger.info("主线程通过可重入读锁,查询数据完成.");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
}
public void test3() throws Exception {
// 开始锁降级
writeLock.lock();
logger.info("主线程抢到写锁...");
// 这里的休眠是为了让下面线程能在预想的情况下加入等待队列.
Thread.sleep(500);
// 这里就是假设等待队列里面排在前面的是读锁线程
processReadLock(1);
processReadLock(2);
Thread.sleep(500);
processWriteLock(4);
Thread.sleep(500);
processReadLock(3);
Thread.sleep(500);
// 开始锁降级
readLock.lock(); // A 降级开始
// 锁降级完成
writeLock.unlock();// 注意必须读锁锁住,写锁释放操作完才降级完成,其他读线程才能进入。
logger.info("主线程释放写锁(写锁降级为读锁,允许其他读操作进入)");
logger.info("sleep 10s 验证等待队列中的读操作是否能执行..");
TimeUnit.SECONDS.sleep(10);// 睡3s验证其他的读操作能进行
logger.info("sleep 10s 结束");
queryData();// 还是主线程去获取读锁。验证可重入锁。
readLock.unlock(); // A 降级结束
logger.info("主线程读锁释放");
// logger.info("过程结束..");
}
private void processWriteLock(int threadIndex) {
new Thread(() -> {
logger.info("线程" + threadIndex + " 写锁开始竞争,阻塞中.");
writeLock.lock();
logger.info("线程" + threadIndex + " 写锁执行中..");
writeLock.unlock();
logger.info("线程" + threadIndex + " 写锁释放..");
}).start();
}
private void processReadLock(int threadIndex) {
new Thread(() -> {
logger.info("线程" + threadIndex + " 读锁开始竞争,阻塞中.");
readLock.lock();
logger.info("线程" + threadIndex + " 读锁执行中..");
readLock.unlock();
logger.info("线程" + threadIndex + " 读锁释放..");
}).start();
}
public static void main(String[] args) throws Exception {
LockDownTest readWriteLockTest = new LockDownTest();
readWriteLockTest.test3();
}
}
注意这是公平锁的情况,结果说明:
3.2 可重入读写锁缺点(引入邮戳锁)
ReentrantReadWriteLock实现了读写分离。默认是非公平锁,每个线程是随机获取锁的。可能会导致锁饥饿的问题。
使用公平锁策略一定程度上能缓解这个问题,但是公平锁是牺牲系统的吞吐量为代价的。
引入StampedLock类的乐观锁。
4. StampedLock
StampedLock邮戳锁。这种锁是一种乐观锁,允许线程在读过程中进行写操作。让读多写少的时候,写线程有机会获取写锁。减少了线程饥饿的问题。吞吐量(单位时间系统能处理的请求量)大大提高。
在读线程操作临界资源的时候,允许写操作进行资源修改,那么读取到的数据是错误的怎么办?
为了保证读线程读取数据的正确性。读取的时候是乐观读,乐观读tryOptimisticRead
不能保证读取的数据是正确性的,所以将数据读取到局部变量中,再通过lock.validate(stamp)
校验是否被写线程修改过,若修改过则需要上悲观锁,重新读取数据到局部变量。
4.1 代码示例
使用代码示例:
//乐观读,读的过程中也允许获取写锁介入
public void tryOptimisticRead()
{
long stamp = stampedLock.tryOptimisticRead();
int result = number;
//故意间隔4秒钟,很乐观认为读取中没有其它线程修改过number值,具体靠判断
System.out.println("4秒前stampedLock.validate方法值(true无修改,false有修改)"+"\t"+stampedLock.validate(stamp));
for (int i = 0; i < 4; i++) {
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t"+"正在读取... "+i+" 秒" +
"后stampedLock.validate方法值(true无修改,false有修改)"+"\t"+stampedLock.validate(stamp));
}
if(!stampedLock.validate(stamp))
{
System.out.println("有人修改过------有写操作");
// 数据校验失败,升级为悲观读
stamp = stampedLock.readLock();
try
{
System.out.println("从乐观读 升级为 悲观读");
result = number;
System.out.println("重新悲观读后result:"+result);
}finally {
stampedLock.unlockRead(stamp);
}
}
System.out.println(Thread.currentThread().getName()+"\t"+" finally value: "+result);
}
4.2 使用场景和注意事项
StampedLock适用于读多写少的高并发场景。通过乐观读很好的解决了写线程饥饿的问题。
值得注意的是:
StampedLock不是可重入锁
5. 无锁-独占锁-读写锁-邮戳锁总结
- 从无锁到独占锁:无锁状态下数据在多线程环境下不安全因此需要锁
- 独占锁到可重入读写锁:独占锁在「读读」的时候线程存在竞争关系,实际很多场景中是允许多个线程同时读的。
- 可重入读写锁到邮戳锁:可重入读写锁会导致读多写少情况下的线程饥饿问题。引入了邮戳锁,允许读的过程中进行写。但是要采取乐观读的方式,进行数据的校验。如果数据校验失败,从乐观读变为悲观读。(乐观读的过程中允许写,悲观读的过程中不允许写操作)