想要年薪20W+吗?看完Github上分享的Java并发题,面试大厂稳了

今日分享开始啦,请大家多多指教~

unlock方法

解锁调用的就是unlock方法

可以看到其调用的还是内部类sync的方法,而且可以看到这是一个无返回值的方法。

并且传入了一个为1的参数

release方法

可以看到,其调用的是AQS里面的release方法

步骤如下:

  • 先调用tryRelease方法,尝试进行解锁
  • 然后判断是否需要唤醒线程
  • 返回true,代表释放锁成功
  • tryRelease方法返回false,表表释放锁失败,返回false
  • tryRelease方法

    可以看到这个方法是AQS里面的一个未实现的方法,实现这个方法有ReentrantLock与ReentrantReadWriteLock。

    所以,具体的实现肯定是ReentrantLock的。

    实现的源码如下所示

    步骤如下:

  • 计算锁被释放后的新状态,记录在变量c。
  • 判断当前线程是不是拥有锁(如果拥有了锁,在AbstartOwnableSychronizer的exclusiveOwnerThread会记录,AOS是被AQS所继承的)。
  • 如果不拥有锁就会 错,因为锁并不是自己的,没有资格释放。

  • 定义一个free变量看是否锁的新状态是不是变成可被占用了(进入到这一步就证明了当前线程拥有锁)。
  • 判断新的状态是不是为0。
  • 如果是0,让free变量为true,并且将锁记录占用自己的线程为null。

  • 将锁的状态更新(这一步过后,其他线程就可以争夺锁了,因为ReentrantLock的状态已经变为了0)。
  • 返回free变量给上一层,告知上一层锁是否不被占用了。
  • 接下来我们返回到release方法

    下面的判断是这样的

    从上面可以看到,如果锁没被占用了,那么tryRelease方法就会返回True,那么就会进行下面的判断:

    1.先记录一下当前线程队列的头结点;

    2.判断头结点是否不为空,而且waitStatus状态是否不为0(0代表线程正常运行,-1代表被挂起)。

  • 如果头结点不为空,代表仍有线程在等待。
  • 如果头结点waitStatus不为0,那就代表后面的线程被挂起了或者取消了(这个操作是针对后面的那些线程等待时间过长,CAS超过了两次,全部进入了挂起状态)。
  • 所以,接下来的一步就是去唤醒被后面被挂起的线程(前面提到过,队列里面是不存放正在执行的线程的,只存放需要排队的线程,头结点是不放线程的,不过会记录上一个执行线程的状态,因为在获取锁的时候,是将要执行的线程为头结点,然后将头结点里面的thread改为了null,但waitStatus是还在的)。
  • 返回True。
  • 3.如果头结点为空,或者头结点状态为0。

  • 代表没有线程等待了;
  • 返回false。
  • unparkSuccessor方法

    这个方法是唤醒被挂起的头结点,并且还要去整理线程队列。

    这个方法也是AQS里面的

    源码如下

    步骤如下

    1.判断上一个执行完成的结点的waitStatus状态。

  • 如果waitStatus状态小于0,代表上一个线程是被挂起了。
  • 所以将waitStatus状态改回0(这一步是关联上线程抢锁时的CAS操作)。
  • 2.接下来,唤醒后面的线程,其实是去获取最先的一个未被取消的线程。

  • 一般这个线程就是头结点的下一个;
  • 但也有可能头结点的下一个被取消了。
  • 此时就要进行遍历,从尾进行遍历整个队列,去找到最先的一个被挂起的线程(不包括新插入进来正在尝试获取锁的线程,也就是状态为0)。

    3.接下来,让最先的一个未被取消的线程重新启动。

    这里这样做的原因是,前面提到过,在线程去抢锁的过程中,CAS第一次时,会认为前面的一个线程被挂起了,将前面线程的waitStatus改为-1,CAS第二次,如果前面的线程仍然为-1,代表前面的线程仍然被挂起(只有在前面的线程释放锁的时候,才会改变waitStatus为0),所以自己也会挂起。

    所以,个人认为:如果一个线程执行太久了,后面的线程都被是有可能都被挂起的,那么就需要一个一个去唤醒他,就完全变成了一个重量级锁。

    在Java里面,锁除了让临界区互斥外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

    锁的释放和获取的内存语义

    接下来,看一下线程在锁的释放和获取究竟对内存是怎样的操作。

    锁的释放

    当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

    锁的获取

    当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器monitor保护的临界区代码必须从主内存中读取共享变量(前面已经提到过,synchroniced会在字节码上加上monitor与monitor exit)。

    这两个过程相当于线程A向线程B发送了通信消息,让B读取最新的共享变量。

    与volatile的读写内存语义比较

    通过与前面学习的volatile的读写内存语义比较:

  • 锁释放与volatile的写语义时相同的(立即更新共享变量到主内存);
  • 锁获取与volatile的读语义是相同的(本地内存无效,重新读取);
  • 写语义与读语义让线程之间实现了通信;
  • 锁释放与所获取也让线程之间实现了通信。
  • 锁内存语义的实现

    锁内存语义的实现其实就是ReentrantLock的底层实现。

    final这个修饰可以加在类、方法、变量上

  • 加在类上面是让类不可以被继承,而且里面的方法全部默认为final修饰;
  • 加在方法上是让该方法不可以被子类重写;
  • 加在变量上,表示该变量变为常量,而且必须进行初始化。
  • 但其实final也是可以解决一些并发重排序问题的。

    final域的重排序规则

    final域也有自己的重排序规则

  • 在构造函数来对一个final域进行写入,与之后把这个构造对象的引用赋值给一个引用变量,这两个操作是不可以发生重排序的,即初始化不可以与引用赋值发生重排序,跟volatile是一样的。
  • 第一次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间是不能发生重排序的。
  • 这两个规则分别对应final的读写的重排序规则

    写final域的重排序规则

    写final域的重排序规则其实就是上面的,初始化不可以与引用赋值发生重排序,必须先初始化,然后再进行引用赋值,但如果对于普通变量来说,也就是普通域,很可能会发生这两个步骤的重排序。

    这个规则可以确保,在对象引用为任意线程可见之时,对应的final域已经被正确初始化了。

    写final域的重排序规则是使用内存屏障来实现的。

    编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障,这个屏障禁止了处理器将final域的写命令重排序到return之后,也就是构造函数之外。

    读final域的重排序规则

    读final域的重排序规则就是,初次读对象引用与初次读对象里面的final引用是不可以发生重排序的,必须先读对象引用然后再读final引用。

    这个规则可以确保,在读一个final域之前,一定会先读包含这个final域的对象的引用,这是因为final域是依赖于对象的。

    读final域的重排序规则也是使用内存屏障来实现的。

    编译器会在都final域操作的前面插入一个LoadLoad屏障,确保前面如果有初次读对象操作时,要先读对象,然后再读final域。

    final域的引用类型

    如果final域的变量是一个引用类型,那么对于读的重排序规则是没有变的,但写的重排序规则会增加多一条。

    在构造函数内对一个final引用的对象的成员域写入时,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作是不能重排序的。

    即构造函数在初始化final引用对象时,构造函数之外的其他地方可能正在修改这个final引用对象,那么这两个操作是不可以发生重排序的,也就是一定要先构造函数初始化完final引用对象后才可以允许其他地方进行修改。

    所以总的final域的引用类型的写规则如下:

  • 构造函数里面初始化final引用的对象时,不可以被重排序到构造函数外。
  • 构造函数里面初始化final引用的对象前,外部不可以对final引用的对象进行修改。
  • 为什么final引用不能从构造函数内溢出?即发生重排序

    我个人觉得,可能是因为final的引用每次改变都会成为一个新的对象,所以必须要确保改变是要按照顺序的。

    如果从构造函数内溢出,很有可能这个final引用还没从构造函数里面初始化好,外面的其他线程就会将其修改,就会导致了顺序扰乱现象。

    final的底层实现

    前面已经对此提到过

  • 在读final域时,会插入load-load屏障;
  • 在写final域时,会插入store-store屏障。
  • 下面我们来谈一下双重检查锁定与延迟初始化

    volatile解决重排序问题

    在前面学习懒汉模式实现单例模式的时候,我们已经使用过下面的这一套优化流程了

    1.代码块中加锁判断单例对象是否已经初始化。

  • 如果已经初始化,直接返回单例对象。
  • 2.再进行判断多一次单例对象是否已经初始化,来判断单例模式对象是否已经初始化,因为可能同时有多个线程判断出单例对象未初始化,这时上一把锁,让一个线程进去初始化了,初始化了之后,应该再让其他线程再判断一次,看前面一个线程初始化没有(感觉这个方案可以解决一下缓存雪崩)。

  • 如果已经初始化,返回单例对象。
  • 3.给单例对象加volatile修饰,防止其构造指令出现重排序。

    代码如下(instance记得要被volatile修饰)

    类初始化解决重排序问题

    上面使用volatile可以解决重排序问题,在这里也是可以用类来解决重排序问题的。

    JVM在类的初始化阶段时,即在Class被加载后,且正在被线程使用之前,会执行类的初始化(初始化静态变量),在执行类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化,总的来说,就是利用类的初始化这个机制,让实例变量初始化的时候可以发生重排序,但其他线程看不到这个重排序,必须要等待完成整个类初始化过程才可以被访问这个类。

    还是以单例模式为例

    要用类初始化来实现单例模式,其实就是使用静态内部类

    当多个线程调用getInstance时候,会发生阻塞(JVM获得锁),只有一个线程可以去加载初始化这个InnerSIngle类,然后该线程初始化里面的single静态变量。

    类初始化过程中的同步机制

    下面来看一下JVM是怎么保证类初始化过程中的同步

    第一阶段

    第一阶段是:通过在Class对象上进行同步(即获取Class对象的初始化锁),来控制类或者接口的初始化,当多个线程初始化同一个类的时候,只有一个线程可以获得这个Class对象的初始化锁,其他线程会一直等待获取锁的线程去释放锁。

    第二阶段

    第二阶段是:抢到类的初始化锁的线程去执行初始化,未抢到锁的线程在初始化锁对应的condition上等待,相当于是等待抢到类的初始化锁的线程去完成初始化动作。

    初始化的动作先简单理解成是执行类的静态初始化代码和初始化类中声明的静态字段,底层方面的知识是涉及到JVM相关知识的。

    过程如下所示

    第三阶段

    第三阶段是:获取到锁的线程去唤醒在condition中等待的所有线程,提醒这些线程,类已经初始化好了。

    第四阶段

    第四阶段是:被唤醒的线程结束自己对类的初始化处理。

    第五阶段

    第五阶段是:后面的线程如果再对类进行初始化,那么只会简单地获取锁,发现已经被初始化好了,释放锁,直接获取类。

    volatile与类初始化的两个方案之间存在什么区别?

    区别如下

  • 类初始化的代码比较简单、间接;
  • 不过类初始化只能针对静态字段来实现延迟初始化;
  • volatile不仅可以针对静态字段实现延迟初始化,还可以针对实例字段来实现延迟初始化。
  • 更多资料,私信回复【1】……

    今日份分享已结束,请大家多多包涵和指点!

    声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

    上一篇 2021年5月14日
    下一篇 2021年5月14日

    相关推荐