一、ReentrantLock的初步认识
1.1 什么是锁
锁是用来解决多线程并发访问共享资源所带来多大数据安全性问题的手段。对一个共享资源加锁后,如果有一个线程获得了锁,那么其他线程无法访问这个共享资源。
加锁前:
加锁后:
如上图所示:
有两个线程取访问balance,同时对其进行修改,A用户修改成原来的值减去1000,B用户修改成原来的值减去50,正常的情况这种余额的修改是不能小于0的,但是不加锁的这种情况里面最终的情况可能会编程-50,这就是在并发的访问过程中对一个共享资源的修改的不安全性。我们怎么去解决它呢? 所以我们提供了第二种方案,对balance进行加锁。此时,两个线程在同时访问balance时,需要对这把锁进行竞争,不管有多少个线程,最终只会有一个线程能持有这把锁,获得访问权限。其他的线程需要等待获得锁的线程释放锁。这就保障了数据的安全性,达到了数据安全修改的目的。
1.2 ReentrantLock简单使用
public class LockDemo {
static Lock lock = new ReentrantLock();
public static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(LockDemo::incr).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
public static void incr(){
try{
lock.lock();
count++;
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
基于以上代码,我们增加如下方法:
public static void decr(){
lock.lock();
count--;
lock.unlock();
}
如果在incr方法中获得锁以后,我们再调用decr方法,首先,再incr方法中我们已经获得了锁,按照以往逻辑,在decr中我们再去获得这把锁,是需要等待锁被释放之后,显然如果这样做就会造成死锁。这里我们用到了重入锁,什么是重入锁呢?一个持有锁的线程,在释放锁之前,如果再次访问加了该同步锁的其他方法,这个线程不需要再次争抢锁,只需要记录重入次数。
所以,这里并不会造成死锁,这也是为什么重入锁能解决死锁的原因。
1.3 Lock的类图
二、AQS是什么?
2.1 如何实现线程的阻塞和唤醒?
在上图中我们提到了通过锁来控制访问权限,但是锁是如何实现线程的阻塞以及唤醒的呢?互斥的逻辑是怎么实现的?
我们还是从ReentrantLock来对其底层实现原理进行分析。
首先,我们来简单看下它的类关系图,
它实现了Lock接口,它有一个子类叫Sync,这个子类继承了AbstractQueuedSynchronizer,也就是我们这里要讲到的AQS,Sync中有两个派生的实现,FairSync(公平锁:比如,我们排队买票,每个人都排队来,这就是公平的,如果有一个人并没有排队,直接到窗口买了票,这就是不公平的)与NonfairSync(非公平锁)。线程的阻塞和唤醒其实就依赖于Sync,也会用到AQS这个抽象类,AQS可以理解为一个同步工具,用来实现线程的排队阻塞操作。
2.1.1 源码分析
首先我们进到ReentrantLock类中。
在lock方法中,调用了sync的lock()方法,这里的sync就是就是前面提到的Sync
ReentrantLock中是默认的非公平锁
NonfairSync如下:
假设,我们现在有三个线程,线程A,线程B,线程C,同时访问incr()方法对balance进行操作,incr方法中有一个lock.lock获得锁以及lock.unlock释放锁的操作,三个线程进入到方法中的时候首先会基于lock.lock去抢占锁,就进入到了ReentrantLock的lock()方法中,假设线程A获得了锁,那么线程A可以继续执行后面的代码,此时线程B/C会阻塞,等待线程A执行完并释放锁。那么在ReentrantLock.lock()中做了什么呢??
从上图中可以看到,首先进入了compareAndSetState这个方法。从方法名称上可以看出来这是去修改一个state的值。
这个stateOffset是AQS类中的一个成员属性
这个值用来做啥的呢? 我们知道,互斥的前提是需要有一个共享资源,state就是这个共享资源。
这个值有两个状态,0表示没有锁的状态,大于0表示没有锁的状态。 所以这里先通过compareAndSetState这个方法对其进行修改,标识它已经是有锁的状态,这里有两个参数,0和1,如果是预期的值0,就 把它改成1。当第二个线程进入这个方法时,值已经被修改成1了,就无法对值进行修改,这是一种乐观锁。这样就保证了只有一个线程会修改成功,修改成功以后就会执行setExclusiveOwnerThread(Thread.currentThread());
,设置当前线程为一个读占状态。这里 将当前线程保存到了一个exclusiveOwnerThread
中,表示当前线程获得了锁。
那么剩下的线程B和线程C会怎样呢?这里进入了else块,进入到acquire方法中。
这是AQS中的一个方法,它会尝试去抢占锁,arg表示要抢占锁的一个数量。
先看下tryAcquire方法
这里会先通过getState去获得state的值,显然,已经被线程A修改成1了,这里会执行else。
current == getExclusiveOwnerThread()
这句代码判断是否为重入状态,判断当前线程是否与当前获得锁的线程为同一个线程。如果是重入的话这里只会增加重入的一个次数int nextc = c + acquires;
这里返回false就会进入到下一个条件判断acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
如果返回true,表示两种情况,重入锁或者第一次抢占到锁。继续执行lock.lock之后的代码。
我们继续看返回false的情况。
这个方法中会把没有抢占到锁的线程加入到一个双向链表中。
可以看到enq方法中是一个for循环,如果尾节点为空,需要进行初始化,第一次时头节点即尾节点。最终返回这个双向链表。此时线程按照了先后顺序进行了排队操作。接下来,再看acquireQueued
方法
首先,获取当前节点的一个前置节点
如果这个节点是头节点的话会再一次去抢占锁if (p == head && tryAcquire(arg)),因为如果上一个节点是头节点,那么可能它已经释放了锁,所以这里会尝试去抢占锁。假设还没有释放锁,进入 后续if判断,if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
在获得锁失败以后是否应该去阻塞这里把当前节点的前置节点传了进去。
如果前置节点的状态为SIGNAL的时候,返回true,SIGNAL表示这是一个可以被唤醒的节点
从Node中我们可以看到,只有当CANCELLED的时候ws才会大于0,即被取消的节点,如节点出现异常这样的情况,这种情况抢占锁就没有意义了,所以这里将它从链表中移除。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
这里会将ws改为SIGNAL,第一次循环时,返回了false,但是第二次循环时显然ws == Node.SIGNAL
条件就成立了,返回了true。此时就回到了acquireQueued方法中的for循环里面
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
此时就可以进入到parkAndCheckInterrupt里面了,
这里通过park去阻塞当前的线程,后面通过unpark去唤醒。会使得线程B和C会阻塞在这个位置。在AQS中完成了阻塞。
那么什么时候去进行唤醒呢??? 在lock.unlock的时候会唤醒阻塞状态下的线程。具体是怎么实现的呢??
此时getState的值为1(线程A修改,且无重入的情况),c的值为0,free=true表示可是释放。再将exclusiveOwnerThread(此前值为线程A)设置为空,state也设置成0。此时回到release方法中,如果头节点不为空,并且waitStatus(已经改为SIGNAL==-1)不等于0。进入到unparkSuccessor方法中,LockSupport.unpark(s.thread);
对当前节点进行唤醒。
唤醒之后,就从原来的位置继续执行。即return Thread.interrupted();,对中断状态进行复位。
此时继续循环,将当前的节点设置成了头结点。此时原来的头结点(获得锁的节点)从链表中移除了,没有引用指向它了,会被GC回收掉。这就是一个完整的线程的阻塞和线程的唤醒的实现原理。