Reentrant 可重入解释
链接:https://www.zhihu.com/question/37168009/answer/88086943
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
我们来看看问题,按照现在我看到的情况,题干是:“怎样证明synchronized锁,Lock锁是可重入的”,外加一个Java的标签。
Java中,Synchronized确实是可重入的。另外Lock锁这个定义并不准确,在Java中Lock只是一个接口,并且在doc中并没有说明实现类一定是需要具备可重入的特性。Lock的实现众多,其中最常见也是最为任何Java程序员熟知的是ReentrantLock。但是注意,不一定Lock的子类就是可重入的,例如netty中就有一个比较有趣的NoReentrantLock的实现。
那么下面内容就以题目是Synchronized和ReentrantLock为前提进行。
我们第一步要明确什么是“可重入的”。其对应的英文单词是:Reentrant,哦不对,其实准确的说应该是“Re-entrant”。wikipedia有一个Reentrancy(computing)的解释。不过在ReentrantLock的doc中找到这段话:A ReentrantLock is owned by the thread last successfully locking, but not yet unlocking it. A thread invoking lock will return, successfully acquiring the lock, when the lock is not owned by another thread. The method will return immediately if the current thread already owns the lock.
最后一句话尤其重要,如果当前占用这个Reentrant的人就是当前线程,那么就会立即返回。换成大白话说就是,一个线程获取到锁之后可以无限次地进入该临界区 (通过调用lock.lock())。当然同样也需要等同次数的unlock操作(这句话是我加的
OK,既然我们已经明白了Reentrant的含义。那么如何证明呢?写个程序是最简单的办法,一个线程递归的调用一个需要加锁的函数(不要递归太深),看会不会hog住线程。这都是很好很好的,可我偏偏不喜欢,引自《白马啸西风》。我还是更倾向于learn java in the hardest way。
先,简单介绍一下普通的lock的实现原理,这里只介绍加锁部分,下面是伪码形式:public void lock() {
// step 1. try to change a atomic state
boolean ok = state.compareAndSet(0, 1);
// step 2. set exclusive thread if ok
if (ok) {
setExclusiveThread(Thread.current()); // 这只是个标志位,不用太介意
return;
}
// step 3. enqueue
enqueue();
// step 4. block
Unsafe.park();
// step 5. retry
lock();
}
小朋友们不要轻易模仿。没有谁用这种傻逼的递归写法的,除了我。完整的代码比这个复杂,除了基本的流程,还要处理是否是公平锁,处理线程中断,以及一系列的无锁数据结构等等。
几个要点:
- 通过一个原子状态来控制谁进入临界区
- 通过一个链表队列,记录等待获取锁的线程
- 通过Unsafe的park()函数,来把当前线程的运行状态设置成挂起,并且停止调度
- 当已经获取锁的线程调用unlock()函数的时候,就会使用Unsafe.unpark()函数来唤醒等待队列头部的线程
- 唤醒之后,线程继续试着获取锁,失败则递归,成功则返回
慢着,知道上面的东西,离我们证明题干还有一定的距离,继续看。
Tips: 整个concurrent包源自于JSR-166,其作者就是大名鼎鼎的Doug Lea,说他是这个世界上对Java影响力最大的个人,一点也不为过。因为两次Java历史上的大变革,他都间接或直接的扮演了举足轻重的角色。一次是由JDK 1.1到JDK 1.2,JDK1.2很重要的一项新创举就是Collections,其Collections的概念可以说承袭自Doug Lea于1995年发布的第一个被广泛应用的collections;一次是2004年所推出的Tiger。Tiger广纳了15项JSRs(Java Specification Requests)的语法及标准,其中一项便是JSR-166就是这个小朋友,归纳总结出,嗯各种同步手段底层都需要一些共同的东西,所以写了一个类叫java.util.concurrent.locks.AbstractQueuedSynchronizer。后来被简称为AQS框架,该框架将加锁的步骤模板化了之后,提供了基本的列表、状态控制等等手段。我们可以简单看看lock的过程他是如何抽象的:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
- tryAcquire,抽象方法,由子类实现,子类通过控制原子变量来表示是否获取锁成功,类似于上文代码的Step1、Step2
- addWaiter,已经实现的方法,表示将当前线程加入等待队列,类似于上文的Step3
- acquireQueued(),挂起线程,唤醒后重试,类似于上文的Step4、Step5
- 处理线程中断标志位。
我们只需要记住一个重要的地方就是,子类只需要实现tryAcquire方法,就可以实现一个锁,嗯,不错!而这个tryAcquire方法最重要的就是利用AQS类中提供的原子操作来控制状态。我们看一个最简单的Mutex的例子:
public boolean tryAcquire(int acquires) {
assert acquires == 1; // Otherwise unused
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
简单解释一下,compareAndSetState是父类AQS中提供的protected方法,setExclusiveOwnerThread同理。如此我们就实现了一个简单的Mutex。
现在我们考虑一个问题,这个基于AQS实现的Mutex是不是可重入的呢?当然不是,线程A调用lock方法,然后就调用到这个tryAcquire函数中,显然这个状态就是被设置成了1。线程A第二次进来的时候,再次控制这个原子变量,发现就不好使了,就进入等待队列。自己就被自己等死了。
好,最后就是重点,ReentrantLock也是在AQS的基础上实现的,那么我们来看,他的tryAcquire方法是怎么写的。简单起见,ReentrantLock有公平和非公平的两种实现,我们只关注可重入的特点,这里就不介绍,我们直接看非公平的版本。final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
- 如果当前的state(AQS提供的原子变量)=0,意味着没有人占用,那么我们compareAndSet来占用,并且设置自己为独占线程
- 如果独占线程就是当前线程,那么说明就是我自己锁住啦(可重入),那么把state计数累加。
貌似这样就说通了。还有一个点就是不要小看这个累加哦,在unlock的时候也是一个累减的过程,也就是同一个线程针对同一个ReentrantLock对象调用了10次lock操作,那么对应的,就需要调用10次unlock操作。才会真正的释放lock。
我想差不多应该可以证明了吧..
对这个类比较感兴趣的小朋友可以参考爸爸的两篇博客:Java.concurrent.locks(1)-AQS、Java.concurrent.locks(2)-ReentrantLock。
然后现在已经晚上10点了,爸爸要回家睡觉了。同步块的部分以后想起了再更吧。那不过是用c艹实现的版本,原理一致,代码几乎也差不多。