Java 并发编程 --ReentrantLock
J.U.C简介
Java.util.concurrent是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件,比如线程池、阻塞队列、计时器、同步器、并发集合等等。接下来我们会了解一下经典的比较常用组件的设计思想
Lock
Lock在J.U.C中是最核心的组件,如果我们去看J.U.C包中的所有组件,我们可以发现绝大多数组件都有用到了Lock。在Lock接口出现之前,Java中的应用程序对于多线程的并发安全处理只能基于synchronized关键字来解决。但是synchronized关键字在有些场景中会存在一些短板,也就是它并不适用于所有的并发场景,但是Java 5之后Lock的出现可以解决synchronized在某些场景中的短板,它比synchronized更加灵活。
Lock本质上是一个接口,它定义了释放锁和获得锁的方法,定义成接口就意味着它定义了锁的一个标准规范,也意味着锁的不同实现。实现Lock接口的类有很多,以下为几个常见的实现:
ReentrantLock: 表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是线程获得锁后,再次获得该锁不需要阻塞,而是直接关联一次计数器增加重入次数。
ReentrantReadWriteLock: 冲入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock;他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都存在互斥。
StampedLock: stampedLock是JDK 8引入的新的锁机制,可以简单认为是读写锁的一个改进版,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。
Lock的类关系图
ReentrantLock重入锁
重入锁,表示支持重新进入的锁,也就是说如果当前线程t1通过调用lock方法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数就可以了。synchronized和ReentrantLock都是可重入锁,比如下面这类场景中存在多个加锁方法的相互调用,其实就是一种重入特性的场景。
重入锁设计的目的
比如下面代码通过调用demo方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2中也存在同一个实例锁,这个时候当前线程会因为无法获得demo2的对象锁而阻塞,就会产生死锁。重入锁的设计目的就是为了避免死锁。
ReentractLock的使用案例:
ReentrantReadWriteLock
我们以前理解的锁基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻允许多个线程访问,但是在写线程访问时,所有的读线程和其他线程读会被阻塞。读写锁维护了一对锁,一个读锁,一个写锁;一般情况下,读写锁的性能都比排它锁好,因为大多数场景是读多余写的。在读多余写的情况下,读写锁能提供比排它锁更好的并发性和吞吐量。
在这个案例中,通过HashMap来模拟了一个内存缓存,然后使用读写锁来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候读锁不会被阻塞,因为读操作不会影响执行结果。
在执行写操作的时候,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当写锁释放后,其他读写操作才能执行。使用读写锁可提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性。
读锁与读锁可以共享
读锁与写锁不可以共享
写锁与写锁不可以共享
ReentrantLock的实现原理
我们知道锁的基本原理是基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全的目的。在之前的synchronized中,我们了解了偏向锁、轻量级锁、乐观锁,基于乐观锁以及自旋锁来优化了synchronized的加锁开销,同时在重量级锁阶段,通过线程的阻塞和唤醒来达到线程竞争和同步的目的。
那么在ReentrantLock中,也一定会存在这样的需要去解决的问题,就是在多线程竞争重入锁时,竞争失败的线程时如何实现阻塞以及被唤醒的呢?
在Lock中,用到了一个同步队列AQS,全程AbstractQueuedSynchronizer,它是一个同步工具也是Lock用来实现线程同步的核心组件。如果搞懂了AQS,那么J.U.C中绝大部分工具都能轻松掌握。
AQS的两种功能
从使用层面来说,AQS的功能分为两种:独占和共享
独占锁:每次只能有一个线程持有锁,比如前面演示的ReentrantLock就是以独占方式实现的互斥锁
共享锁:允许多个线程同时获得锁,并发访问共享资源,比如ReentrantReadWriteLock
AQS的内部实现
AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接的前驱节点,所以双向链表可以从任意一个节点开始可以很方便的访问前驱和后继节点。每个Node其实都是由线程封装的,当前线程争抢锁失败后会封装成Node加入到AQS队列中去;当获得锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点
Node的组成:
释放锁以及添加线程对于队列的变化
当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景
这个场景会涉及两个变化:
新的线程封装成Node添加到同步队列中,设置prev节点以及修改当前节点的前置节点的next指向自己
通过CAS将tail重新指向新的尾部节点
head节点表示获取锁成功的节点,当头节点释放同步状态时会唤醒后继节点,如果后继节点获得锁成功,会把自己设置成头节点,节点的变化过程如下:
这个过程也是涉及两个变化:
修改head节点指向下一个获得锁的节点
新获得锁的节点,将prev的指针指向null
设置head节点不需要CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能有一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并断开原首节点的next引用即可。
ReentrantLock的源码分析
接下来以ReentrantLock为切入点,看看在这个场景中是如何是用AQS来实现线程同步的:
调用ReentrantLock中的lock方法,源码的调用过程如下面的时序图所示:
这个方式是ReentrantLock获取锁的入口:
sync实际上是一个抽象的静态内部类,它继承了AQS来实现重入锁的逻辑,我们前面说过AQS是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承AQS来实现对应场景的功能:
Sync有两个具体的实现类,分别是:
NofairSync: 表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁
FairSync: 表示所有线程都严格按照FIFO来获取锁
NofairSync.lock
以非公平锁为例子,来看看lock中的实现:
非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是不管有没有线程排队,我先上来CAS去抢占一下
CAS成功,就表示成功获得了锁
CAS失败,调用acquire(1)走锁竞争逻辑
CAS实现原理
通过CAS乐观锁的方式来做比较并替换,这段代码的意思是如果当前内存中的state的值和预期值expect相等,则替换为update。更新成功返回true,否则返回false。
这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作,以及涉及到state属性的意义。
State是AQS中的一个属性,它在不同的实现中表达的含义不一样,对于同步锁的实现来说,表示一个同步状态,有两个含义的表示:
当state=0时,表示无锁状态
当state>0时,表示已经有线程获得了锁,也就是state=1;但因为ReentrantLock允许重入,所以同一个线程多次获的同步锁的时候,state会递增,比如重入5次,那么state=5。而在释放锁的时候,同样需要释放5次,直到state=0其他线程才有资格获得锁。
AQS.acquire
acquire是AQS中的方法,如果AQS操作未能成功,说明state已经不为0,此时继续acquire(1)操作。这个方法的主要逻辑是:
通过tryAcquire尝试获得独占锁,如果成功返回true,失败返回false
如果tryAcquire失败,则会通过addWaiter方法将当前线程封装成Node添加到AQS队列尾部
acquireQueued,将Node作为参数通过自旋去尝试获取锁
NonfairSync.tryAcquire
这个方法的作用是尝试获取锁,如果成功返回true,不成功返回false;它是重写AQS类中的tryAcquire方法,这个方法中调用了ReentrantLock类内部类Sync中nonfairTryAcquire方法
当tryAcquire方法获取锁失败以后,则会先调用addWaiter将当前线程封装成Node。入参mode表示当前节点的状态,传递的参数是Node.EXCLUSIVE,表示独占状态,意味着重入锁用到了AQS的独占锁功能。
将当前线程封装成Node
当前链表中的tai'l节点是否为空,如果不为空则通过cas操作把当前线程的node添加到AQS队列
如果为空或者AQS失败,调用enq将节点添加到AQS队列
enq方法就是通过自旋操作把当前节点加入到队列中:
图解分析
假设3个线程来争抢锁,那么截至到enq方法运行结束后,或者调用addWaiter方法结束后,AQS中的链表结构图:
AQS.acquireQueued
通过addWaiter方法把线程添加到链表后,接着会把Node作为参数传递给acquireQueued方法,去竞争锁。
获取当前节点的prev节点
如果prev节点为head节点,那么就有资格去竞争锁,调用tryAcquire去抢占锁
抢占锁成功后,把获得锁的节点设置成head,并且移除原来的初始化head节点
如果获取锁失败,则根据waitStatus决定是否需要挂起线程
最后,通过cancelAcquire取消获得锁的操作
shouldParkAfterFailedAcquire
如果ThreadA的锁还没有被释放的情况下,ThreadB和Thread C来争抢锁肯定是会失败的,那么失败以后会调用shouldParkAfterFailedAcquire 方法;Node中有5中状态,分别是CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、默认状态(0)
CANCELLED: 在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消该Node节点,其节点的waitStatus为CANCELLED,即结束状态,进入该状态后的节点将不会再变化
SIGNAL:只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程
CONDITION:和Condition有关
PROPAGATE:共享模式下,PROPAGATE状态的线程处于可运行状态
这个方法的作用是通过Node的状态的判断,Thread A竞争锁失败后是否被挂起。
如果Thread A的prev节点状态为SIGNAL,那就表示可以放心挂起当前线程
通过循环扫描链表把CANCELLED 状态的节点移除
修改prev节点的状态为SIGNAL,返回false
返回false时,也就是不需要挂起;返回true,则需要调用parkAndCheckInterrupt挂起当前线程
版权声明: 本文为 InfoQ 作者【Java收录阁】的原创文章。
原文链接:【http://xie.infoq.cn/article/d52f9ff631e6386ee294883be】。文章转载请联系作者。
评论 (1 条评论)