在多线程学习中,线程同步是必要的操作。
一般来说,我们通常用的是Synchronized关键字,即以对象作为锁去锁同步代码块,但是早期的Synchronized是互斥锁,同一时间只有一个线程持有锁去执行,其他同步的线程都会阻塞(Block状态),打个比方,一核干活、九核围观, 被阻塞的线程可能更懒,如果不叫醒它,它是没反应的,但是线程的阻塞与唤醒操作会占用资源与时间, 而且Synchronized的执行原理对开发者来说是不可见的, 编译后的class文件也只有monitor指令, 所以在java的版本的更新中,便有了更高效,功能更强的同步结构,ReentrankLock ,今天我们就来探索ReentrankLock的特性与实现原理,以及如何模仿ReentrankLock结构自定义一个简单的独占锁结构。
首先,为了保证线程的安全性,一般从原子性、可见性、有序性这三点来考虑。
所以在了解ReentrankLock之前,我们需要提前了解以下两个知识点:
使用volatile在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令,这个指令的作用如下:
简单来说,volatile的变量对所有使用它的线程实时可见, 无论哪个线程修改它,都会使得所有相关线程获取最新值 ,这就保证了可见性;
举个例子,拿i++操作来说,它在执行时需要执行三个操作,获取i对应的值,将该值+1得到新值,将新值赋给i ,而在多线程环境中,如果不加同步,这三个操作可能会被不同的线程拿去执行,从而最终得到的结果与预想的不符合,所以就需要有保证这个操作为原子执行的手段。
CAS就是用来保证原子操作的,CAS,CompareAndSwap,从名字就可以知道其功能,比较与交换, CAS操作基于提供的CMPXCHG指令实现, 该指令CMPXCHG的作用:比较并交换,需要一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧的值是不是发生了变化,如果没有发生变化,则交换新值,如果发生了变化,则不交换。 而CAS操作也是这么个原理,就拿上面的i++操作来说,比如初始值为0,当前线程现在要给它加1,但是在这个过程中,如果有其他的线程抢占了自增操作的指令,给0加了个1,当前线程发现这个旧值也就是0发生了变化,那么当前线程就不再执行自增操作了,因此当前线程CAS操作失败。
在java中,CAS通常配合死循环来执行,即失败了就不断重试cas操作,直到成功。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程,这样就保证了原子性操作。
线程安全的原子性、可见性的操作就简单说到这里,至于有序性,具体指的指令重排序可能导致的线程不安全,有些时候需要禁止某些指令的重排序保证有序性,而volatile关键字本身也具有有序性。
这里只是简单说明了一下原子性、可见性、有序性,这三个方面相关的内容有许多,想深入了解并发编程原理的小伙伴们,可以看看java并发编程的艺术这本书与网上相关优秀的文章总结(可以在github上找java-concurrency,良心推荐)。
在实际应用中,对于lock的使用如下:
Lock lock = new ReentrantLock();
lock.lock();
try{
.......//(同步块)
}finally{
lock.unlock();
}
以上就是对于lock的基本使用,下面来看一下该类的UML图:
可以发现,该类的结构还是比较难的,ReentrankLock中有三个内部类结构,关系如图,从UML图可以看出ReentrankLock是基于队列同步器AQS设计的。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
可以发现,ReentrankLock分为公平锁与非公平锁,下面我们以公平锁FairSync来分析一下加锁过程:
final void lock() {
acquire(1);
}
发现只是传入了一个1, 再来看acquire方法,但是呢,在ReentrankLock类中,发现没有这个方法的实现,其实这个方法是个模板方法,该方法在AQS中,因为从上面的UML图可以看出,FairSync继承Sync,而Sync又继承AQS。下面是acquire()方法:
public final void acquire(int arg) {
//获取同步状态 1 执行 , 如果获取失败,返回false,执行&&后的逻辑
if (!tryAcquire(arg) &&
//将获取失败的线程添加到AQS同步队列执行
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
在这里我们先只看tryAcquire()方法,即线程尝试获取这个 1 执行的方法,至于线程获取不了执行失败的情况,现在先做一个忽略(后面会做一个简单介绍)。
但是发现,tryAcquire()方法在AQS中只是这样:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
发现并没有什么逻辑,但是我们回过头来看FairSync类,发现其重写了tryAcquire(),如下:
//线程获取同步状态执行
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//如果state为0,利用CAS设置state由0变为1,相当于加锁,然后执行该线程
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//重入锁的逻辑,本质上是对state的i++操作。
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
至于acquire方法中的tryacquire是怎么调用的,其实很容易想到的—多态(所以最基本的东西还是要活学活用)。
所以这里就是今天的第一个重点,模板方法模式, AQS同步器的设计是基于模板方法模式的,使用者需要继承AQS并重写一些指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器所提供的模板方法,而这些模板方法将会调用使用者重写的方法。
下图是AQS中可重写的方法:
这些模板方法就不具体说明了,Lock机制里的知识点很多,需要我们认真学习。
到这里,其实lock.lock()加锁过程就简单的分析完了,可以发现,似乎就是传入了一个 1 作为依据,然后就进入AQS类中的acquire方法。 而AQS是同步机制的基石,下面这张图很清晰地体现其地位:
接着就来看AQS,AQS,AbstractQueuedSynchronizer类,从名字来看,里面肯定维护着一个队列,而实际上,AQS就是维护着一个同步队列的, 如下图:
在AQS中,把获取同步失败的线程包装成了一个个节点,构成一个同步队列,如上图所示,是一个双向的链表,使用了head与tail指针 维护着队首与队尾, 执行顺序也是按照先进先出的队列规则来,对于入队来说,当线程获取同步状态失败后,就会先将该线程阻塞,然后利用自旋的CAS操作(也就是死循环cas来保证线程安全)将线程对应的节点加入到队尾,利用tail指针配合完成。那么具体的CAS是怎么做的呢?是利用tail指针指向的尾节点,用尾节点的next属性来做文章,next原来为null(旧值),用CAS操作将对应线程的节点(新值)引用到next上,成功后更新tail。
而对于队头,这里的逻辑相对来说比较复杂, 首先,首节点的元素需要获取同步状态,至于这个同步状态是什么,还记得上面公平锁中lock方法中的那个 数字 1 嘛,这其实就是同步状态,AQS中有这么个属性:
/**
* The synchronization state.
*/
private volatile int state;
这个同步状态用volatile的state保存着,一般线程加了锁执行时拿到了这个1,才能执行,否则就加入到这个同步队列中, 现在,CAS操作有了,volatile关键字修饰的同步状态state也有了,那么线程安全的三个特性肯定也满足了。 回到首节点,首节点的工作原理是,首先需要获取到同步状态state(利用CAS将state从0设置为1),然后执行对应的线程,执行成功后就设置它的后一个节点为首节点(利用head指针),然后释放同步状态(unlock对应的方法,CAS将state从1设置为0),唤醒下一个节点,出队列,下一个节点又能获取到同步状态,继续同样的操作。
而上述的这个过程中,获取同步状态的规则在子类的tryAcquire方法中,释放同步状态的方法在子类的tryrealse()方法中,如果是自己实现一个锁,这两个方法都是需要重写的,需要自定义自己实现的锁的加锁与解锁逻辑,但本质上就是对state这个变量的加减,独占锁 的设计就是,state为 1时代表锁被某个线程持有着,为0时代表锁被释放, 锁的可重入其实也就是按照同一个线程执行多少次,state就自增多少次。
AQS的大致过程就这样分析完了,这是本篇的第二个重点内容,分析地比较简单,仅分析了独占锁的一般执行情况,对于独占锁执行时的各种发生的情况的处理以及尝试锁,超时锁,共享锁的内容,还是需要配合书从源码中详细了解。
接下来我还想给大家也大概分享一下Condition的await与notify的原理机制,这是今天的第三个重点内容。
Condition的等待通知机制,我们知道synchronized关键字锁的只能是一个对象,而一个对象只能有一个wait/notify操作,所以能实现的功能很有限。 而一个lock对象却可以创建多个Condition对象,也就可以配对地使用等待通知机制。
lock对象基于AQS,一个lock对象其维护了一个同步队列, 而一个Condition对象也维护了一个等待队列,等待队列如下图:
可以发现,等待队列是一个单向的队列,那么如何实现等待通知机制呢,如下图所示:
上面说过,首节点获取同步状态才能执行,也就是lock.lock加锁,在加锁条件下,才能执行await, 这个过程就如上图所示,把同步队列中的首节点添加到了下面这个等待队列中,然后该节点才释放同步状态并唤醒下一个同步节点 , 但是呢,我们知道,wait方法的调用后是不停止的,必须等到notify后获取锁执行完毕才能调用完成。 而在这里,await中有一个方法,该方法是一个while循环的判断条件,它的作用是查找这个节点是否在同步队列上,如果没在,就会一直循环, 这样就实现了等待的功能 ,直到将该节点从等待队列中重新加入到同步队列的时候,这个循环才会跳出,继续执行至线程完成退出。 讲到这里,singal的作用其实也就说完了,将该节点从等待队列加入到同步队列中,这就是singal要干的事情,如下图:
到这里,今天我想分享给大家的内容也就讲完了, 我们在学习并发内容时,需要知道这种同步器的模板方法的设计模式, 重点掌握的是AQS同步队列的运行机制和Condition的等待通知机制,因为并发包里面的内容都是围绕着这两个知识点展开的,比如常用的并发工具类,CountDownLatuch和CyclicBairrer,一个就是利用了共享锁机制的同步队列,另一个其实是对Condition原理的包装。
那要如何实现一个自定义的同步器呢,其实就是按照ReentrankLock的结构来设计就行,来简单看一下这个例子:以下是一个独占锁的设计,这个例子书上就有。
class Mutex implements Lock, java.io.Serializable{
//按照同步设计规则,可利用静态内部类继承AQS实现自定义组件类(类比ReentrankLock中的Sync)
private static class Sync extends AbstractQueuedSynchronizer{
//判断是否在锁定状态(即lock是否处于占用状态)
@Override
protected boolean isHeldExclusively() {
return getState()==1;
}
//当状态为0时获取锁
@Override
protected boolean tryAcquire(int acquires) {
assert acquires == 1; // Otherwise unused,加入断言
if(compareAndSetState(0,1)){
//利用CAS进行独占(只有1)的同步状态判断
setExclusiveOwnerThread(Thread.currentThread());//执行该线程
return true;
}
return false;
}
//释放锁,即将状态设置为0就行
@Override
protected boolean tryRelease(int acquires) {
if (getState()==0){
//如果获取到的状态为0,抛出异常
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null); //释放该线程
setState(0);
return true;
}
//返回一个condition,每个condition中都包含一个condition队列(一个lock可以有多个condition)
Condition newCondition(){
return new ConditionObject();}
}
//只需要将自定义的组件类作为Mutex的属性就行
private final Sync sync=new Sync();
@Override
public void lock() {
sync.acquire(1);
} //依赖于模板方法,但是模板方法调用了子类重写的方法
public boolean isLocked(){
return sync.isHeldExclusively();
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(10000));
}
@Override
public void unlock() {
sync.release(1);
}
public boolean hasQueuedThreads() {
return sync.hasQueuedThreads();
}
@Override
public Condition newCondition() {
return sync.newCondition();
}
}
public class MutexDemo {
private static Mutex mutex=new Mutex(); //自定义Lock
public static void main(String[] args) {
for (int i = 0; i <10 ; i++) {
Thread thread= new Thread(()->{
mutex.lock();
try {
System.out.println("准备执行-----》"+Thread.currentThread().getName());
Thread.sleep(1000);
System.out.println("测试独占锁,平均停顿显示证明成功。。。");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
mutex.unlock();
}
});
thread.start();
}
}
}
至于自定义这个独占锁的过程,参考ReentrankLock源码的结构,仿照着设计就行。
今天的内容就分享到这里了,感谢大家的关注。