1、前言
在Java5.0之前,协调对共享对象的访问可以使用的机制只有synchronized和volatile。synchronized关键字实现了内置锁,而volatile关键字保证了多线程的内存可见性。在大多数情况下,这些机制都能很好地完成工作,但却无法实现一些更高级的功能,例如,无法中断一个正在等待获取锁的线程,无法实现限定时间的获取锁机制,无法实现非阻塞结构的加锁规则等。而这些更灵活的加锁机制通常都能够提供更好的活跃性或性能。因此,在Java5.0中增加了一种新的机制:ReentrantLock。ReentrantLock是一个可重入的互斥锁,又被称为“独占锁,实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性,它的底层是通过AQS来实现多线程同步的。与内置锁synchronized相比ReentrantLock不仅提供了更丰富的加锁机制,而且在性能上也不逊色于内置锁(在以前的版本中甚至优于内置锁)。
2、ReentrantLock获取锁与释放锁的操作
下面是使用ReentrantLock加锁的示例代码。
public void doSomething() {
//默认是获取一个非公平锁
ReentrantLock lock = new ReentrantLock();
try{
//执行前先加锁
lock.lock();
//执行操作...
}finally{
//最后释放锁,必须显示释放锁
lock.unlock();
}
}
以下是获取锁和释放锁这两个操作的API。
//获取锁的操作
public void lock() {
sync.lock();
}
//释放锁的操作
public void unlock() {
sync.release(1);
}
可以看到ReentrantLock获取锁和释放锁的操作分别委托给Sync对象的lock方法和release方法,内部实现如下:
public class ReentrantLock implements Lock, java.io.Serializable {
private final Sync sync;//持有Sync类型的引用
abstract static class Sync extends AbstractQueuedSynchronizer {
abstract void lock();
}
//实现非公平锁的同步器
static final class NonfairSync extends Sync {
final void lock() {
...
}
}
//实现公平锁的同步器
static final class FairSync extends Sync {
final void lock() {
...
}
}
}
每个ReentrantLock对象都持有一个Sync类型的引用,这个Sync类是一个抽象内部类它继承自AbstractQueuedSynchronizer,它里面的lock方法是一个抽象方法。ReentrantLock的成员变量sync是在构造时赋值的,ReentrantLock的两个构造方法如下:
//默认无参构造器
public ReentrantLock() {
sync = new NonfairSync();
}
//有参构造器
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
调用默认无参构造器会将NonfairSync实例赋值给sync,此时锁是非公平锁。有参构造器允许通过参数来指定是将FairSync实例还是NonfairSync实例赋值给sync。NonfairSync和FairSync都是继承自Sync类并重写了lock()方法,所以公平锁和非公平锁在获取锁的方式上有些区别。再来看看释放锁的操作,每次调用unlock()方法都只是去执行sync.release(1)操作,这步操作会调用AbstractQueuedSynchronizer类的release()方法,我们再来回顾一下。
//释放锁的操作(独占模式)
public final boolean release(int arg) {
//拨动密码锁, 看看是否能够开锁
if (tryRelease(arg)) {
//获取head结点
Node h = head;
//如果head结点不为空并且等待状态不等于0就去唤醒后继结点,注意同步队列不包含 head,不包含 head,不包含 head。
if (h != null && h.waitStatus != 0) {
//唤醒后继结点
unparkSuccessor(h);
}
return true;
}
return false;
}
// 唤醒后继节点
// 从上面调用处知道,参数node是head头结点
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
// 如果head节点当前waitStatus<0, 将其修改为0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 下面的代码就是唤醒后继节点,但是有可能后继节点取消了等待(waitStatus==1)
// 从队尾往前找,找到waitStatus<=0的所有节点中排在最前面的
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
// 从后往前找,仔细看代码,不必担心中间有节点取消(waitStatus==1)的情况
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// 唤醒线程
//如果线程没获取到锁,线程会被 LockSupport.park(this); 挂起停止,等待被唤醒。
LockSupport.unpark(s.thread);
}
这个release方法是AQS提供的释放锁操作的API,它首先会去调用tryRelease方法去尝试获取锁,tryRelease方法是抽象方法,它的实现逻辑在子类Sync里面。
//尝试释放锁
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
//如果持有锁的线程不是当前线程就抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException();
}
boolean free = false;
//如果同步状态为0则表明锁被释放
if (c == 0) {
//设置锁被释放的标志为真
free = true;
//设置占用线程为空
setExclusiveOwnerThread(null);
}
setState(c);//设置新的同步状态并且返回锁的释放状态
return free;
}
这个tryRelease方法首先会获取当前同步状态,并将当前同步状态减去传入的参数值得到新的同步状态,然后判断新的同步状态是否等于0,如果等于0则表明当前锁被释放,然后先将锁的释放状态置为真,再将当前占有锁的线程清空,最后调用setState方法设置新的同步状态并返回锁的释放状态。
ReentrantLock常用的API:
ReentrantLock()// 创建一个 ReentrantLock ,默认是“非公平锁”。
ReentrantLock(boolean fair)// 创建策略是fair的 ReentrantLock。fair为true表示是公平锁,fair为false表示是非公平锁。
int getHoldCount()// 查询当前线程保持此锁的次数。
protected Thread getOwner()// 返回目前拥有此锁的线程,如果此锁不被任何线程拥有,则返回 null。
protected Collection<Thread> getQueuedThreads()// 返回一个 collection,它包含可能正等待获取此锁的线程。
int getQueueLength()// 返回正等待获取此锁的线程估计数。
protected Collection<Thread> getWaitingThreads(Condition condition)// 返回一个 collection,它包含可能正在等待与此锁相关给定条件的那些线程。
int getWaitQueueLength(Condition condition)// 返回等待与此锁相关的给定条件的线程估计数。
boolean hasQueuedThread(Thread thread)// 查询给定线程是否正在等待获取此锁。
boolean hasQueuedThreads()// 查询是否有些线程正在等待获取此锁。
boolean hasWaiters(Condition condition)// 查询是否有些线程正在等待与此锁有关的给定条件。
boolean isFair()// 如果是“公平锁”返回true,否则返回false。
boolean isHeldByCurrentThread()// 查询当前线程是否保持此锁。
boolean isLocked()// 查询此锁是否由任意线程保持。
void lock()// 获取锁。
void lockInterruptibly()// 如果当前线程未被中断,则获取锁。
Condition newCondition()// 返回用来与此 Lock 实例一起使用的 Condition 实例。
boolean tryLock()// 仅在调用时锁未被另一个线程保持的情况下,才获取该锁。
boolean tryLock(long timeout, TimeUnit unit)// 如果锁在给定等待时间内没有被另一个线程保持,且当前线程未被中断,则获取该锁。
void unlock()// 试图释放此锁。
通常使用在Runnable任务中:
ReentrantLock lock = new ReentrantLock(); // not a fair lock
lock.lock();
try {
// synchronized do something
} finally {
lock.unlock();
}
3、重入机制的实现
reentrant锁意味着什么呢?简单来说,它有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放。对于锁的重入,我们来想这样一个场景。当一个递归方法被sychronized关键字修饰时,在调用方法时显然没有发生问题,执行线程获取了锁之后仍能连续多次地获得该锁,也就是说sychronized关键字支持锁的重入。对于ReentrantLock,虽然没有像sychronized那样隐式地支持重入,但在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
如果想要实现锁的重入,至少要解决一下两个问题:
线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
锁的最终释放:线程重复n次获取了锁,随后在n次释放该锁后,其他线程能够获取该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经释放。
4、公平锁和非公平锁
我们知道ReentrantLock是公平锁还是非公平锁是基于sync指向的是哪个具体实例。在构造时会为成员变量sync赋值,如果赋值为NonfairSync实例则表明是非公平锁,如果赋值为FairSync实例则表明为公平锁。如果是公平锁,线程将按照它们发出请求的顺序来获得锁,但在非公平锁上,则允许插队行为:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有等待的线程直接获得这个锁。
4.1非公平锁获取方式
//非公平同步器
static final class NonfairSync extends Sync {
//实现父类的抽象获取锁的方法
final void lock() {
//使用CAS方式设置同步状态
if (compareAndSetState(0, 1)) {
//如果设置成功则表明锁没被占用
setExclusiveOwnerThread(Thread.currentThread());
} else {
//否则表明锁已经被占用, 调用acquire让线程去同步队列排队获取
acquire(1);
}
}
//尝试获取锁的方法
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
//以不可中断模式获取锁(独占模式)
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
可以看到在非公平锁的lock方法中,线程第一步就会以CAS方式将同步状态的值从0改为1。其实这步操作就等于去尝试获取锁,如果更改成功则表明线程刚来就获取了锁,而不必再去同步队列里面排队了。如果更改失败则表明线程刚来时锁还未被释放,所以接下来就调用acquire方法。我们知道这个acquire方法是继承AbstractQueuedSynchronizer的方法,现在再来回顾一下该方法,线程进入acquire方法后首先去调用tryAcquire方法尝试去获取锁,由于NonfairSync覆盖了tryAcquire方法,并在方法中调用了父类Sync的nonfairTryAcquire方法,所以这里会调用到nonfairTryAcquire方法去尝试获取锁。我们看看这个方法具体做了些什么。
//非公平的获取锁
final boolean nonfairTryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
//获取当前同步状态
int c = getState();
//如果同步状态为0则表明锁没有被占用
if (c == 0) {
//使用CAS更新同步状态
if (compareAndSetState(0, acquires)) {
//设置目前占用锁的线程
setExclusiveOwnerThread(current);
return true;
}
//否则的话就判断持有锁的是否是当前线程
}else if (current == getExclusiveOwnerThread()) {
//如果锁是被当前线程持有的, 就直接修改当前同步状态
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
//如果持有锁的不是当前线程则返回失败标志
return false;
}
nonfairTryAcquire方法是Sync的方法,我们可以看到线程进入此方法后首先去获取同步状态,如果同步状态为0就使用CAS操作更改同步状态,其实这又是获取了一遍锁。如果同步状态不为0表明锁被占用,此时会先去判断持有锁的线程是否是当前线程,如果是的话就将同步状态加1,否则的话这次尝试获取锁的操作宣告失败。于是会调用addWaiter方法将线程添加到同步队列。综上来看,在非公平锁的模式下一个线程在进入同步队列之前会尝试获取两遍锁,如果获取成功则不进入同步队列排队,否则才进入同步队列排队。
4.2公平锁的实现方式
//实现公平锁的同步器
static final class FairSync extends Sync {
//实现父类的抽象获取锁的方法
final void lock() {
//调用acquire让线程去同步队列排队获取
acquire(1);
}
// 来自父类AQS,这个方法,如果tryAcquire(arg) 返回true, 也就结束了。
// 否则,acquireQueued方法会将线程压到队列中
public final void acquire(int arg) { // 此时 arg == 1
// 首先调用tryAcquire(1)一下,名字上就知道,这个只是试一试
// 因为有可能直接就成功了呢,也就不需要进队列排队了,
// 对于公平锁的语义就是:本来就没人持有锁,根本没必要进队列等待(又是挂起,又是等待被唤醒的)
if (!tryAcquire(arg) &&
// tryAcquire(arg)没有成功,这个时候需要把当前线程挂起,放到阻塞队列中。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
//尝试获取锁的方法,返回值是boolean,代表是否获取到锁
//返回true:1.没有线程在等待锁;2.重入锁,线程本来就持有锁,也就可以理所当然可以直接获取
protected final boolean tryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
//获取当前同步状态
int c = getState();
//如果同步状态0则表示锁没被占用,此时此刻没有线程持有锁
if (c == 0) {
//判断同步队列是否有前继结点
if (!hasQueuedPredecessors()
//如果没有线程在等待,那就用CAS尝试一下,成功了就获取到锁了
&& compareAndSetState(0, acquires)) {
//如果没有前继结点且设置同步状态成功就表示获取锁成功,独占
setExclusiveOwnerThread(current);
return true;
}
//否则判断是否是当前线程持有锁
}else if (current == getExclusiveOwnerThread()) {
//如果是当前线程持有锁就直接修改同步状态
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
//如果不是当前线程持有锁则获取失败
return false;
}
}
调用公平锁的lock方法时会直接调用acquire方法。同样的,acquire方法首先会调用FairSync重写的tryAcquire方法来尝试获取锁。在该方法中也是首先获取同步状态的值,如果同步状态为0则表明此时锁刚好被释放,这时和非公平锁不同的是它会先去调用hasQueuedPredecessors方法查询同步队列中是否有人在排队,如果没人在排队才会去修改同步状态的值,可以看到公平锁在这里采取礼让的方式而不是自己马上去获取锁。除了这一步和非公平锁不一样之外,其他的操作都是一样的。综上所述,可以看到公平锁在进入同步队列之前只检查了一遍锁的状态,即使是发现了锁是开的也不会自己马上去获取,而是先让同步队列中的线程先获取,所以可以保证在公平锁下所有线程获取锁的顺序都是先来后到的,这也保证了获取锁的公平性。
那么我们为什么不希望所有锁都是公平的呢?毕竟公平是一种好的行为,而不公平是一种不好的行为。由于线程的挂起和唤醒操作存在较大的开销而影响系统性能,特别是在竞争激烈的情况下公平锁将导致线程频繁的挂起和唤醒操作,而非公平锁可以减少这样的操作,所以在性能上将会优于公平锁。另外,由于大部分线程使用锁的时间都是非常短暂的,而线程的唤醒操作会存在延时情况,有可能在A线程被唤醒期间B线程马上获取了锁并使用完释放了锁,这就导致了双赢的局面,A线程获取锁的时刻并没有推迟,但B线程提前使用了锁,并且吞吐量也获得了提高。
5应用实例
5.1 测试可重入锁的可重入特性
public class TestReentrantLockdemo1 {
private ReentrantLock lock;
public TestReentrantLockdemo1(){
lock = new ReentrantLock();
}
public static void main(String[] args) {
TestReentrantLockdemo1 testReentrantLockdemo1 = new TestReentrantLockdemo1();
try {
//测试可重入
testReentrantLockdemo1.testReentry();
// 能执行到这里而不阻塞,表示锁可重入
testReentrantLockdemo1.testReentry();
//再次重入
testReentrantLockdemo1.testReentry();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放重入测试的锁,要按重入的数量解锁,否则其他线程无法获取该锁。
testReentrantLockdemo1.getLock().unlock();
testReentrantLockdemo1.getLock().unlock();
testReentrantLockdemo1.getLock().unlock();
}
}
public ReentrantLock getLock (){
return lock;
}
public void testReentry(){
lock.lock();
Date date = new Date();
SimpleDateFormat dateFormat= new SimpleDateFormat("yyyy-MM-dd :hh:mm:ss");
System.out.println(dateFormat.format(date) + " " + Thread.currentThread().getName() + "线程get lock.");
}
}
5.2、公平锁与非公平锁的特性
class ReentranLockFairOrnot {
private ReentrantLock lock;
public ReentranLockFairOrnot(boolean isfair) {
lock = new ReentrantLock(isfair);
}
public void reentranLockMethold() {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "获得了锁");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
public class TestReentrantLockFairandnoFair {
public static void main(String[] args) {
final ReentranLockFairOrnot reentranLockFairOrnot = new ReentranLockFairOrnot(true);//非公平的话此处改成false;
//十个线程
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("**线程: " + Thread.currentThread().getName()
+ " 运行了 " );
reentranLockFairOrnot.reentranLockMethold();
}
}).start();
}
// //尝试使用lamda重写上面这一段
// for (int i = 0; i < 20; i++) {
// new Thread (()->{
// System.out.println("线程:" + Thread.currentThread().getName() + "运行了 " );
// reentranLockFairOrnot.reentranLockMethold();
// }).start();
// }
}
}
运行结果反映:
在公平的锁上,线程按照他们发出请求的顺序获取锁,但在非公平锁上,则允许“插队”:当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。非公平的ReentrantLock 并不提倡 插队行为,但是无法防止某个线程在合适的时候进行插队。
在公平的锁中,如果有另一个线程持有锁或者有其他线程在等待队列中等待这个锁,那么新发出的请求的线程将被放入到队列中。而非公平锁上,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
非公平锁性能高于公平锁性能的原因:在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B请求这个锁。由于锁被A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此B会再次尝试获取这个锁。与此同时,如果线程C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面:B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也提高了。
当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。
6、synchronized与ReentrantLock对比
1、synchronized关键字是Java提供的内置锁机制,其同步操作由底层JVM实现,而ReentrantLock是java.util.concurrent包提供的显式锁,其同步操作由AQS同步器提供支持。
2、ReentrantLock在加锁和内存上提供的语义与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待,可中断的锁等待,公平锁,以及实现非块结构的加锁。另外,在早期的JDK版本中ReentrantLock在性能上还占有一定的优势
3、ReentrantLock还提供了条件Condition,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合。ReentrantLock提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而synchronized则一旦进入锁请求要么成功,要么一直阻塞,所以相比synchronized而言,ReentrantLock会不容易产生死锁些。
4、synchronized关键字的加锁操作仍然有它特有的优势,内置锁为许多开发人员所熟悉,使用方式也更加的简洁紧凑,因为显式锁必须手动在finally块中调用unlock,所以使用内置锁相对来说会更加安全些。同时未来更加可能会去提升synchronized而不是ReentrantLock的性能。因为synchronized是JVM的内置属性,它能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能性不大。所以当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized。