文章目录
前言
Java中的锁不只有synchronized,在之前的几篇博客中,都或多或少遇到过其他类型的锁,比如ReentrantLock。为什么Java中会有多种锁,这些锁有哪些特别的?这是我们这一篇博客需要总结的。重点需要学习的是ReentrantLock和ReentrantReadWriteLock。
synchronized不够用么?
不够,真的不够。
synchronized效率较低,锁的释放方式较少,无法手动释放synchronized的对象锁,在试图获取synchronized锁的时候,不能设置超时时间,且不能中断。
synchronized不够灵活,加锁和释放锁的时机很单一,且获得锁或释放也只有一个条件
synchronized在获取锁的过程中,无法得知是否成功获取锁
Java的设计者为了弥补上述synchronized的相关缺点,引入了一个Lock
的接口
Lock接口
在Java中锁是一种工具,用于控制对共享资源访问的工具。也就是说锁的目的都是为了达到数据访问的线程安全。Lock和synchronized是Java中两个最常见的锁,他们都可以达到线程安全的目的,Lock的出现并不是完全替代synchronized,而是在synchronized不足场景的地方予以补全,来提供高级锁功能的。最常见的实现类就是ReentrantLock。Lock接口的源码中有如下几个方法声明,其中前4个方法都是用来获取锁的
public interface Lock {
//普通的获取锁的方法,如果锁已经被其他线程获取,则进行等待
//不会像synchronized一样,在异常的时候自动释放锁
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
lock方法
lock()
——普通的获取锁的方法,如果锁已经被其他线程获取,则进行等待,不会像synchronized一样,在异常的时候自动释放锁。因此如果使用lock,为了保证锁能正常释放,一般通过如下结构实现业务逻辑,这也是源码中的注释说明的。
/**
* autor:liman
* createtime:2021/11/13
* comment: Lock的基本使用
*/
@Slf4j
public class LockMustUnLockDemo {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
lock.lock();
try{
//TODO:这里填充业务逻辑
}finally {
//finally保证锁必须释放
lock.unlock();
}
}
}
tryLock方法
tryLock()
——用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,并且返回true,否则返回false,代表获取锁失败。
相比于lock方法,这个方法会比较灵活,功能也强大一点,我们可以根据是否成功获取到了锁来决定后续的程序逻辑,这也是synchronized无法做到的一点。该方法会立即返回结果,即便没有拿到锁,也不会一直阻塞式的去获取锁。
tryLock(long time,TimeUnit unit)方法
这个方法相比于tryLock多了一个时间参数,如果超过指定的时间还没有获取到锁,则自动放弃,这在一定程度上避免了死锁。如下代码演示了如何利用tryLock避免死锁。
/**
* autor:liman
* createtime:2021/11/13
* comment:TryLock避免deadLock
*/
@Slf4j
public class TryLockDemo implements Runnable{
int flag = 1;
static Lock lockOne = new ReentrantLock();
static Lock lockTwo = new ReentrantLock();
public static void main(String[] args) {
TryLockDemo tryLockDemoOne = new TryLockDemo();
TryLockDemo tryLockDemoTwo = new TryLockDemo();
tryLockDemoOne.flag = 1;
tryLockDemoTwo.flag = 2;
new Thread(tryLockDemoOne).start();
new Thread(tryLockDemoTwo).start();
}
@Override
public void run() {
while(true) {
//如果flag=1,先获取锁1再获取锁2
if (flag == 1) {
try {
if (lockOne.tryLock(800, TimeUnit.MILLISECONDS)) {
//试图获取锁,如果获取到了锁,则进入if
try {
System.out.println(Thread.currentThread().getName() + "获取到了锁1");
Thread.sleep(1000);
//尝试获取第二把锁
if (lockTwo.tryLock(800, TimeUnit.MILLISECONDS)) {
//顺利持有了两把锁,则开始业务逻辑
try {
System.out.println(Thread.currentThread().getName() + "获取锁2成功,目前持有两把锁");
//模拟执行业务逻辑
Thread.sleep(1000);
} finally {
lockTwo.unlock();
System.out.println(Thread.currentThread().getName() + "释放锁2");
}
} else {
System.out.println(Thread.currentThread().getName() + "获取锁2失败");
}
} finally {
lockOne.unlock();//解锁,释放锁1
System.out.println(Thread.currentThread().getName()+"释放锁1");
Thread.sleep(500);//给其他线程获取锁的时间
}
} else {
System.out.println(Thread.currentThread().getName() + "获取锁1失败");
}
} catch (InterruptedException e) {
//在tryLock的期间,可以响应中断
e.printStackTrace();
}
}
//如果flag=2,先获取锁2再获取锁1
if (flag == 2) {
try {
if (lockTwo.tryLock(800, TimeUnit.MILLISECONDS)) {
//试图获取锁,如果获取到了锁,则进入if
try {
System.out.println(Thread.currentThread().getName() + "获取到了锁2");
Thread.sleep(1000);
//尝试获取第二把锁
if (lockOne.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName() + "获取锁1成功,目前持有两把锁");
//模拟执行业务逻辑
Thread.sleep(1000);
} finally {
lockOne.unlock();
System.out.println(Thread.currentThread().getName() + "释放锁1");
}
} else {
System.out.println(Thread.currentThread().getName() + "获取锁1失败");
}
} finally {
lockTwo.unlock();//解锁,释放锁1
System.out.println(Thread.currentThread().getName()+"释放锁2");
Thread.sleep(500);//给其他线程获取锁的时间
}
} else {
System.out.println(Thread.currentThread().getName() + "获取锁2失败");
}
} catch (InterruptedException e) {
//在tryLock的期间,可以响应中断
e.printStackTrace();
}
}
}
}
}
lockInterruptibly()方法
相当于tryLock(long time,TimeUnit unit)将时间设置为无限,在获取锁的过程中,是可以响应中断的,这一点也是synchronized做不到的
/**
* autor:liman
* createtime:2021/11/13
* comment:获取锁的过程中,响应中断
*/
@Slf4j
public class LockInterruptiblyDemo implements Runnable{
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptiblyDemo lockInterruptiblyDemo = new LockInterruptiblyDemo();
Thread threadOne = new Thread(lockInterruptiblyDemo,"线程1");
Thread threadTwo = new Thread(lockInterruptiblyDemo,"线程2");
threadOne.start();
threadTwo.start();
//main线程休眠2秒
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//刚启动的线程1会进入休眠,会在sleep的时候被中断
threadOne.interrupt();//打断线程1和打断线程2会有不同的结果,因为其中有一个是sleep,有一个是尝试获取锁
//后启动的线程2会在尝试获取锁的时候被中断
threadTwo.interrupt();
}
@Override
public void run() {
String currentThreadName = Thread.currentThread().getName();
System.out.println(currentThreadName+"尝试获取锁");
try {
lock.lockInterruptibly();
try{
System.out.println(currentThreadName+"获取到了锁");
Thread.sleep(5000);
}catch (InterruptedException e){
//这里还是相应并处理Thread.sleep(5000);期间的中断异常
System.out.println(currentThreadName+"休眠期间被中断");
}finally {
lock.unlock();
System.out.println(currentThreadName+"释放锁成功");
}
} catch (InterruptedException e) {
//这里是针对lock.lockInterruptibly();被中断的异常处理
e.printStackTrace();
System.out.println(currentThreadName+"尝试获取锁的时候被中断,这里是中断抛出的异常");
}
}
}
运行结果:
unlock方法其实就是释放锁的方法,这个就不做详细展开了
同时这个Lock接口与synchronized一样,能保证线程间的可见性。下一个线程获取锁之前一定能看到前一个线程解锁后对所有数据的修改结果。
锁的大致分类
其实这才是这篇博客的核心,Java中关于锁如何分类的,网上的内容非常杂乱,后来才想明白,其实本身的锁并不多,只是根据不同的分类方式,有着不同的体现,导致我们误认为Java中的锁很多,其实并不尽然。
根据不同的线程并发访问的时候,会不会锁住共享资源,Java中的锁分为悲观锁和乐观锁。
根据多个线程是否能共享同一把锁,Java中的锁分为共享锁和独占锁
根据多个线程竞争的时候是否会正常排队,Java中的锁又分为公平锁和非公平锁
根据同一个线程是否可以重复获取同一把锁,Java中的锁又分为可重入锁和不可重入锁
根据线程在获取锁的时候是否可以中断,Java中的锁又可以分为可中断锁和非可中断锁
根据线程等待锁的过程,Java中的锁又可以分为自旋锁和非自旋锁。
这些恼人的分类,往往出现于各种文章,其实从根本上来理解,并不是Java中共享锁有那几个锁,非共享锁又有那几个锁,而是应该反过来理解,synchronized是共享锁还是独占锁,ReentrantLock是共享锁还是独占锁等。
比如synchronized,是悲观锁的同时,也是独占锁、可重入锁、非中断锁、非自旋锁、非公平锁(这个可以通过简单的实例证明)
具体的就不展开总结了,后面在介绍原子类以及AQS的时候,会展开说一下。
ReentrantLock
ReentrantLock是一个可重入锁,这一点和synchronized一样,是Lock的一个比较常用实现类
简单实例,模拟四个线程订票
/**
* autor:liman
* createtime:2021/11/14
* comment:ReentrantLock的简单用法,模拟四个线程订票
*/
@Slf4j
public class MovieWatchDemo {
private static ReentrantLock lock = new ReentrantLock();
private static void bookSeat(){
lock.lock();
try{
String currentThreadName = Thread.currentThread().getName();
System.out.println(currentThreadName+"开始预定座位");
Thread.sleep(1000);
System.out.println(currentThreadName+"完成座位预定");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁
lock.unlock();
}
}
public static void main(String[] args) {
//创建并启动线程
new Thread(()->{
bookSeat();}).start();
new Thread(()->{
bookSeat();}).start();
new Thread(()->{
bookSeat();}).start();
new Thread(()->{
bookSeat();}).start();
}
}
可以正常互斥预定,我们这里在创建ReentrantLock的时候,没有指定任何参数,这里ReentrantLock的实例默认就是非公平锁。在介绍这个之前,先介绍一下什么是公平锁和非公平锁
非公平锁和公平锁
公平锁——按照线程的请求顺序来分配锁的使用权限
非公平锁——不完全按照线程的请求顺序来分配锁的使用权限,在一定情况下是可以插队的。
但……为啥ReentrantLock默认的是非公平呢?这样设计是为了提高效率,这样可以避免唤醒带来的空档期。这一点还是通过例子来说明吧,还是以上面的订票的实际场景为例来说明。
在现实生活中,很多年以前,订票还是只能线下进行,如果这个时候订票窗口有很多人在排队(这些人就可以理解为待获取锁的线程),这个时候有一个人在窗口和售票员订票,订完票之后(相当于线程释放了锁)。这个哥们发现自己的票上打印的电影开场时间不太清晰,不知道具体的电影开场时间,想再去问一下订票员具体的电影开场时间,那这个时候就麻烦了。如果是公平锁的机制,则这个哥们必须重新到队尾排队,排很久之后,就为了问一句售票员:电影啥时候开场啊?如果是非公平锁的机制,这个哥们就直接可以插到售票员前面问即可。
例子不太恰当,但公平机制和非公平机制还是进行了简单的阐明。所以Java的设计者为了提高整体的吞吐量,减少操作系统重新唤起线程的空档时间,故而默认将其设置为非公平锁。
公平锁和非公平锁的实例(ReentrantLock实现)
这里为了说明公平锁和非公平锁的问题,模拟了每一个线程必须要打印两份文件
/**
* autor:liman
* createtime:2021/11/13
* comment:公平锁和非公平锁的简单实例
*/
@Slf4j
public class FairAndUnFairLock {
public static void main(String[] args) {
PrintQueue printQueue = new PrintQueue();
Thread thread[] = new Thread[10];
for(int i=0;i<10;i++){
thread[i] = new Thread(new Job(printQueue));
}
for(int i=0;i<10;i++){
thread[i].start();
//为了保证顺序启动,这里休眠一下
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Job implements Runnable{
PrintQueue printQueue;
public Job(PrintQueue printQueue){
this.printQueue = printQueue;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"开始打印");
printQueue.printJob(new Object());
System.out.println(Thread.currentThread().getName()+"打印完毕");
}
}
class PrintQueue {
private Lock queueLock = new ReentrantLock(true);//传入true ,定义为公平锁,false为非公平锁
public void printJob(Object document) {
//第一次打印
queueLock.lock();
try {
int duration = new Random().nextInt(10)+1;
System.out.println(Thread.currentThread().getName() + "正在打印第一份文件,打印任务需要花费:" + duration + "秒");
Thread.sleep(duration*1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
//第二次打印
queueLock.lock();
try {
int duration = new Random().nextInt(10)+1;
System.out.println(Thread.currentThread().getName() + "正在打印第二份文件,打印任务需要花费:" + duration + "秒");
Thread.sleep(duration*1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
上述代码日志分析
在ReentrantLock的源码中有相关公平锁和非公平锁获取锁的源码级别的差异
公平锁和非公平锁的有确定,通过图片总结一下
基本的ReentrantLock的使用是没什么好介绍的,只是通过这个锁引出非公平锁和公平锁的内容
ReentrantReadWriteLock
这个锁也是Lock接口的一个实现,其内部分为读写和写锁,其中的读锁是共享锁,写锁是独占锁。ReentrantReadWriteLock是读写分离的典型,其实现了ReadWriteLock接口。其主要读写规则如下。
读写规则
1、多个线程只申请读锁,都可以申请到(读锁共享)
2、如果一个线程已经获取了写锁,则其他线程需要等待释放锁(无论读或者写)
3、如果一个线程已经获取了读锁,则其他线程如果要申请写锁则需要等待释放锁
总结:任何时候,写数据的线程只能有一个。
基础实例
/**
* autor:liman
* createtime:2021/11/13
* comment:读写锁实例
*/
public class ReadWriteLock {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//创建出读写锁
private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
public static void main(String[] args) {
Thread threadOne = new Thread(()->{
readData();},"read-1");
Thread threadTwo = new Thread(()->{
readData();},"read-2");
Thread threadThree = new Thread(()->{
writeData();},"write-1");
Thread threadFour = new Thread(()->{
writeData();},"write-2");
threadOne.start();
threadTwo.start();
threadThree.start();
threadFour.start();
}
private static void readData(){
readLock.lock();
try{
System.out.println(Thread.currentThread().getName()+"得到了读锁");
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName()+"释放了读锁");
}
}
public static void writeData(){
writeLock.lock();
try{
System.out.println(Thread.currentThread().getName()+"得到了写锁");
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
writeLock.unlock();
System.out.println(Thread.currentThread().getName()+"释放了写锁");
}
}
}
上述代码实例从日志可以分析出是读写互斥的。
优势
与ReentrantLock相比,其实锁粒度更细一点,ReentrantLock虽然保证了线程安全,但是也浪费了一些资源,在全部是读数据的线程情况下,其实是不容易发生线程安全问题的。
做到读写锁的分离可以很大程度上提升程序的执行效率
非公平锁的策略
ReentrantReadWriteLock依旧默认是非公平的,其非公平的提现相比ReentrantLock复杂了些,还是从实用场景切入来总结这个问题
假设这个时候,线程2和线程4同时正在读取数据(获得了读锁),线程数3想要写入数据(要等待线程2和线程4释放锁),自然就进入到了等待队列,这个时候还有一个线程5,现在也需要读取数据。这个时候,有两种选择:是强行插在线程3之前,读取数据;还是规矩的进入到等待队列等待获取锁呢?
上图中直接标记了一个叉,自然,这个时候线程5还是需要排队等待的。Java设计者这样设计的原因,是为了避免造成饥饿。想象一下如果读取数据的线程可以强行插入,同时读数据的线程很多很多,那线程3就一直没有机会运行,这就行程了饥饿。
因此为了避免饥饿,ReentrantReadWriteLock的设计者针对这种情况采用了第二种方式。
针对ReentrantReadWriteLock读写锁不公平的插队规则总结为如下内容
1、写锁可以随时插队(读多写少的场景,写数据的线程获取写锁其实很困难了)
2、读锁仅在等待队列的头部结点是写数据的线程的时候,可以插队,否则依旧需要排队
源码中的体现
/**
* Fair version of Sync
*/
//公平锁的情况
//java.util.concurrent.locks.ReentrantReadWriteLock.FairSync
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
//写数据的线程,判断队列中是否有线程排队,如果有则阻塞排队
final boolean writerShouldBlock() {
return hasQueuedPredecessors();//阻塞队列是否为空
}
//读数据的线程,也要判断队列中是否有线程排队
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
//非公平锁的体现
//java.util.concurrent.locks.ReentrantReadWriteLock.NonfairSync
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
final boolean writerShouldBlock() {
//想获取写锁的线程,可以插队(源码中的注释都体现了这个)
return false; // writers can always barge
}
final boolean readerShouldBlock() {
/* As a heuristic to avoid indefinite writer starvation,
* block if the thread that momentarily appears to be head
* of queue, if one exists, is a waiting writer. This is
* only a probabilistic effect since a new reader will not
* block if there is a waiting writer behind other enabled
* readers that have not yet drained from the queue.
*/
//判断第一个线程是不是写数据的线程,如果是则可以插队
return apparentlyFirstQueuedIsExclusive();
}
}
锁的升级与降级
同一个线程,在不同时间对某个资源的操作是有差异的,比如有的线程刚开始读取相关资源,后续写入相关数据,在不同时间也可以持有不同级别的锁,因此有时候锁的级别是可以变换的。在ReentrantReadWriteLock中,并不支持锁的升级,而是支持锁的降级。
/**
* autor:liman
* createtime:2021/11/13
* comment:读写锁的升降级实例
*/
public class ReadWriteLockUpdateAndDemote {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//创建出读写锁
private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
public static void main(String[] args) {
// new Thread(()->readDataUpdating()).start();
new Thread(()->writeDataDemoting()).start();
}
private static void readDataUpdating(){
String currentThreadName = Thread.currentThread().getName();
readLock.lock();
try{
System.out.println(currentThreadName+"得到了读锁");
//在持有读锁的前提下,尝试获取写锁,看是否能锁升级
writeLock.lock();
//始终无法输出,因为锁升级并不会成功,会让当前线程进入阻塞
System.out.println(currentThreadName+"在持有读锁的情况下得到了写锁,升级成功");
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
readLock.unlock();
System.out.println(currentThreadName+"释放了读锁");
}
}
public static void writeDataDemoting(){
String currentThreadName = Thread.currentThread().getName();
writeLock.lock();
try{
System.out.println(currentThreadName+"得到了写锁");
//在得到写锁的情况写继续获取读锁,看是否能够降级
readLock.lock();
//会输出这句话,锁降级是会成功的
System.out.println(currentThreadName+"在持有写锁的情况下得到了读锁,降级成功");
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
writeLock.unlock();
System.out.println(currentThreadName+"释放了写锁");
}
}
}
运行一下就可以知道,ReentrantReadWriteLock中的读锁是无法升级为写锁的(在一定层度上可以避免死锁)
小结
通过总结ReentrantReadWriteLock和ReentrantLock引出了很多锁的分类,不过这种分类并不需要过多在意,重点记录一下上述二者的细节内容。