Java初学者也能看的懂的AQS

Java初学者也能理解的AQS


学习AQS之前,你需要了解以下内容,如果不是很清楚,那么理解本文会有点吃力。(Java初学者得会一下内容)

  • synchronized
  • CAS
  • Lock

前言

synchronized

首先我们知道synchronized是Java关键字,上锁释放锁等一切操作都是由JVM控制的。我们只能通过虚拟机的C++源码才能去研究其底层实现。我们除了判断synchronized是作为方法的修饰符,还是当做同步代码块使用以外,没什么需要我们程序员操作的。

cas

一种自旋的原子操作,也是Java的无锁操作。虽然底层C++的代码也是上锁的,但是我们观察源码可见其实unsafe类的一个本地方法,由C++实现,我们认为它是无锁的操作。

Lock

是Java.util.current包下的一个接口,这个包也叫 JUC包,即大名鼎鼎的并发包。Lock接口比较重要的方法是lock和unlock方法,分别对应的解锁和上锁操作。

AQS的概念

AbstractQueuedSynchronizer抽象队列同步器,你叫啥都行。

我希望看完这篇文章你能对AQS有初步的理解,所以我不引入官方的概念,旨在用最简单的方式来帮助你理解它。

AQS的名字中有个Queue(队列),我们就把它理解为一个队列好了,但是这又不是普通的队列。因为它还有一个Synchronizer,反正我们把它先当成一个很厉害的队列就是了。

小故事

假设我们现在是Jdk的研发小组的一个程序员,我们准备写一个锁,C++程序员熬夜已经帮我们把CAS写好了。

模拟场景(Day 1)

C++程序员:我们已经把CAS肝完了,都封装进unsafe类中。

unsafe.cpp

负责对接的程序员:我已经把C++的程序员写的unsafe类对接了Java的unsafe类,你们直接调用compareAndSwap方法就行了(本地方法)。

unsafe.java

高级程序员:compareAndSwap参数太多了,我把他进一步封装成了compareAndSet(int a,int b);a是你要修改的值预计的值,b是修改后的值。

如果a不等于实际值,则CAS失败

//封装了一波
public void compareAndSet(int a,int b){
    ......
    compareAndSwap(a,b,c,d,e,f...);
    .....
}

中级程序员:

  • 我定义了一个state值,如果为0锁就是自由态,为1就是被占有,我们只需要判断当前锁是0还是1就知道锁是否被占有。

  • setExclusiveOwnerThread()方法则是让当前线程获得锁信息

//默认是0,表示没有锁
private int state;

//设置当前进程占有锁
public void setExclusiveOwnerThread(){
    //这是你们组长写的,你看不懂
   		...
}

现在刚入职的你:负责具体实现Lock和unLock方法,已经给你了基本的框架:

private int state;


public void lock(){
    //TODO
}

public void unlock(){
    //TODO
}

组长:我等下会跑两个线程同时执行的测试用例,大概每个线程跑10秒钟,你看看怎么是实现A线程持有锁,B线程等待直到A线程释放锁,B线程再去获得锁的需求。

疯狂自旋

你苦思冥想,最终在另一个入职半个月的小伙伴的帮助下,写出了这个代码:

//尝试把0改为1,如果state不是0则失败。
public void lock(){
    while(!compareAndSet(0,1)){
        System.out.println("自旋一波");
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    //释放锁资源
    state=0;
}

这时候你的组长问你写的怎么样,你说差不多!

于是跑了一遍代码

拿到锁了
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
...此处省略100W条

组长: …

看了一下代码,说:孩子。我们一个线程如果工作了5分钟,你这得自旋多少次啊。

sleep自旋

于是乎,你得到了灵感,既然如此,那么我让他少自旋几次呗。

//尝试把0改为1,如果state不是0则失败。
public void lock(){
    while(!compareAndSet(0,1)){
        System.out.println("自旋一波");
        //每次自旋睡个10秒
        Thread.sleep(1000);
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
}

组长又来检查任务了,嗯这次结果不错!

拿到锁了
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
拿到锁了

于是看了一下源码

组长: …

好吧如果我们一个线程就执行几毫秒,如果第二个线程过来发现锁被抢占了,睡了十秒,本来一共执行才几毫秒的任务,你多执行了十来秒。

yield自旋

于是乎你又进行了一波改进

//尝试把0改为1,如果state不是0则失败。
public void lock(){
    while(!compareAndSet(0,1)){
        System.out.println("自旋一波");
        //主动让出执行权
        Thread.yield;
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
}

组长有点不耐心了,你怎么这么快就改好了。

看一下效果,组长非常的满意。

拿到锁了
自旋一波
拿到锁了

组长又看了一遍源码,笑容逐渐消失了。

组长:你使用yield确实让出来执行权,但是这只对于两个线程的情况下是对的,多线程情况下你就不知道你的执行权让给谁来处理了。

于是乎,跑了一个10个线程的代码

拿到锁了
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
... 100w个自旋一波

你线程A占有锁,你线程B进行CAS失败之后,把执行权给了C,可是C进行CAS也失败呀,因为A始终占有着锁,于是乎,大家像是在来回扔烫手山芋一样的,大家扔来扔去。

这时候高级程序员来了,看了你的代码,指导了一番。

//这个方法是当前线程无限的睡下去
park();
//这个是唤醒第一睡着的线程
unpark();

于是你给出来组长满意的代码

//尝试把0改为1,如果state不是0则失败。
public void lock(){
    while(!compareAndSet(0,1)){
        sout("自旋一波")
        park();
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
    unpark();
}

这次的结果就非常不错

拿到锁了
自旋一波
拿到锁了

组长又搬来了10个线程的测试代码,组长非常高兴。

看一下手表,已经凌晨了。。。

拿到锁了
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
自旋一波
拿到锁了
拿到锁了
拿到锁了
拿到锁了
拿到锁了
拿到锁了
拿到锁了
拿到锁了
拿到锁了

于是乎你对lock和unlock有了初步的理解

  • state记录锁的状态,为0表示自由无锁的,为1表示被占有
  • CAS失败之后,使用park()方法让线程睡着,释放CPU资源
  • 等到持有锁的对象执行结束释放锁之前,调用unpark方法唤醒沉睡的线程。
模拟场景(Day 2)

第二天你又是最早到公司了

简单的看了一下park()方法的实现,发现他的底层是这样的。

  • 我们把线程都加入到一个List集合中
  • park()让线程无限睡眠,unpark()唤醒集合中第一个Thread。
List<Thread> list=new ArrayList<>();

public void park(){
    //获取当前正在执行的线程
    Thread thread = Thread.currentThread();
    //添加进等待队列
    list.add(thread);
    //让线程无限睡眠
    Unsafe.park(thread);
}

public void unpark(){
    Thread laster=list.remove(0);
    //唤醒线程
    Unsafe.unpark(laster);     
}

过了两天,于是乎你们的第一代Lock发布了!

基本原理如下:

线程A占有锁,其他线程被park()之后永久睡眠。

image-20200917195734293

线程A释放锁,线程B占有锁

image-20200917200006519

但是不久之后,有客户反映,有时候不是按照顺序执行的。

怎么回事?我们不是已经排队了吗?

非公平性

通过测试小组的定位,定位到出现问题的代码都在你写的地方。

组长:今天你不把问题解决,明天你亲自给顾客等门谢罪吧。

我:。。。。。。

public void lock(){
    while(!compareAndSet(0,1)){
        sout("自旋一波")
        park();
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
    unpark();
}

通过一个小时的检查,我们发现了问题

image-20200917200621271

ThreaA线程释放锁的时候,我们的ThreadB醒了,但是ThreadE来了。ThreadE发现锁的状态是自由态(state=0),CAS成功并获得执行权。这时候我们的线程B老哥可惨了,由于被E插队了,不但没有获得执行权,而且走了一波park()方法又得去排队。

举个食堂打饭的例子,你帮同学排队,你同学来了站到你的前面。但是阿姨说你们这个样子其他同学怎么办,于是乎只给你们两个打了一份饭。但是你答应帮朋友排队,总不能言而无信吧,于是乎你把饭给了你同学,你自己又从头排队。

这时候,高级程序员又给你了一些建议,你顺利解决了问题。(汗上次的park,也是他教的,大佬果然厉害。)

实现公平性

如果有人在排队,那么不好意思,不让插队。

还是之前的例子,上次的事情阿姨和学校反映了。于是乎学校安装了监控,插队的学生要处分,于是乎没人敢插队了。。。

//线程们都搁这睡着呢
List<Thread> list=new ArrayList<>();

//CAS成功之后,还要判断list是否是空的,即是否有人排队,如果没人排队,直接占有锁
public void lock(){
    while(!list.isEmpty() || !compareAndSet(0,1)){
        sout("自旋一波")
        park();
    }
    System.out.println("拿到锁了");
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
    unpark();
}

于是乎我们的二代Lock又上线了。。。

过了两天又有客户找上门了,说你这个Lock老卡住!

不对啊,于是乎代码拿过来一看:

public class statement{
    
    public void 转账(){
        lock();
        取钱();
        ...
	}
    
    public void 取钱(){
        lock();
        ...
	}
}

我们发现客户给多个方法上锁。但是这些方法又是有关系的。

如果我们为了转账的顺利,把取钱的锁去掉,那么取钱的地方又有安全问题。

这时候组长又给你下了一个命令:解决不出来,自己走人吧。

我:好…

于是乎创建了一个神奇的代码;

Lock(lock){
    public class statement{

        public void 转账(){
            取钱();
            ...
        }

        public void 取钱(){
            ...
        }
    }
}

组长:你这个代码怎么飘红的?

我:是这样的,我准备开发一个关键字叫做Lock,然后传入一个锁,就可以锁住整个类了。

组长:开发个锁还要创建一个关键字,要你何用?

于是乎你被开除了,高级工程师帮你修了BUG:

如果走park方法,线程又要睡眠了,但是我们应该让这个方法直接通过,所以判断一下是否和当前持有锁的线程是同一个线程,如果是,lock()方法相当于一个空执行return;

public void lock(){
    //如果是同一个线程
    if(Thread.currentThread()==getExclusiveOwnerThread()){   
        System.out.println("是同一个线程,不需要重复加锁");
        return;
    }
    while(!compareAndSet(0,1) || !list.isEmpty()){
        sout("自旋一波")
        park();
    }
    System.out.println("拿到锁了");
    locks++;
    setExclusiveOwnerThread();
}

public void unlock(){
    state=0;
    unpark();
}

这是一个sum公司小王的故事,代码都是伪代码,故事纯属瞎编的。

所以AQS到底是什么?

小故事讲述的故事不是无理无据的哟,AQS可以理解为小故事里的List<Thread>,但是底层是链表实现的。

首先我们有一个双向链表和两指正head和tail。

  • 当我们的线程企图获得锁时,如果锁被占用了,我们插入到图中的tail指针的右边,并且tail指针指向新的尾部。
  • 我们head指针指向的线程获取锁资源,指向完毕释放锁资源,head指向下一个线程。重复该流程直到线程执行完毕。
  • 在我们CAS成功之后需要判断链表是否为空,如果空的直接获得锁资源,否则进队列等待。

image-20200917203618831

总结

AQS底层异常的复杂,本文主要是通过一条故事线告诉你AQS的基本雏形(存放睡眠进程的一个队列)

到此你应该知道AQS是什么了,虽然我没讲AQS的源码,但是根据小故事,你对Java Lock锁是否有一定的理解了呢?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值