1. 概述
前面介绍过了synchronized关键字作用的锁升级过程
无锁->偏向锁->轻量锁->重锁
下面再介绍实现Lock接口的锁的升级过程
无锁->独占锁(ReentrantLock,Synchronized)->读写锁(ReentranReadWriteLock)->邮戳锁(StampedLock)
并准备了一些问题,回顾一下自己对知识的掌握程度。
- 你知道Java里面有哪些锁?
- 你说你用过读写锁,锁饥饿问题是什么?有没有比读写锁更快的锁?
- StampedLock知道吗?(邮戳锁/票据锁)
- ReentrantReadWriteLock有锁降级机制,你知道吗?
2. ReentrantLock
ReentantLock是可重入的独占锁。默认是非公平锁。
可重入:当一个线程持有锁后,在内部可以继续获取锁。
独占:是一种悲观锁,当一个线程持有锁的时候,其他线程会阻塞。
公平和非公平:在公平的机制下,线程会依次排队,放到等待队列中。排队获取锁。在非公平的机制下,新来的线程通过CAS获取锁,获取不到,才会进入等待队列。
2.1 ReentrantLock使用代码演示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| 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 具有锁降级的性质
锁降级可以理解为一种操作。具体操作为写锁持有后,在准备释放写锁的之前,当前线程继续持有读锁,然后释放写锁。
1 2 3 4 5 6 7 8
| writeLock.lock();
readLock.lock(); writeLock.unlock();
.... readLock.unlock();
|
这种方式的好处是:在耗时长的事务中,锁降级能够使让读操作更快进行执行不会被写操作给抢占,且后面的读操作不会被打断。
锁降级的代码演示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| 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(); writeLock.unlock(); logger.info("主线程释放写锁(写锁降级为读锁,允许其他读操作进入)"); logger.info("sleep 10s 验证等待队列中的读操作是否能执行.."); TimeUnit.SECONDS.sleep(10); logger.info("sleep 10s 结束"); queryData(); readLock.unlock(); 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 代码示例
使用代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public void tryOptimisticRead() { long stamp = stampedLock.tryOptimisticRead(); int result = 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. 无锁-独占锁-读写锁-邮戳锁总结
- 从无锁到独占锁:无锁状态下数据在多线程环境下不安全因此需要锁
- 独占锁到可重入读写锁:独占锁在「读读」的时候线程存在竞争关系,实际很多场景中是允许多个线程同时读的。
- 可重入读写锁到邮戳锁:可重入读写锁会导致读多写少情况下的线程饥饿问题。引入了邮戳锁,允许读的过程中进行写。但是要采取乐观读的方式,进行数据的校验。如果数据校验失败,从乐观读变为悲观读。(乐观读的过程中允许写,悲观读的过程中不允许写操作)