Java并发编程学习笔记

Java并发编程学习笔记

  • CPU多级缓存模型
    • 计算机为什么要设计高速缓存架构
      • CPU与主存运行速度的差异
        • CPU长时间空闲
        • 引入高速缓存,减少CPU等待时间,提升运行效率
    • 多核CPU的多级缓存架构是怎么样的
    • 多核CPU的多级缓存架构带来的缓存数据一致性问题
  • JAVA内存模型
    • 为什么要设计JAVA内存模型
    • JAVA内存模型是怎么样的
    • JAVA内存模型定义的八种原子操作
    • 多线程并发在JMM操作带来的可见性问题
  • 线程安全之可见性、有序性、原子性
    • 可见性
    • 有序性
    • 原子性
  • 什么是MESI缓存一致性协议
    • MESI这四种状态怎么解决多核CPU高速缓存数据一致
  • volatile
    • volatile是什么
    • volatile对可见性保证
    • 什么是内存屏障?
    • 强制读取/刷新主内存的屏障
      • Load屏障
      • Store屏障
    • 禁止指令重排序的屏障
      • LoadLoad屏障
      • StoreStore屏障
      • LoadStore屏障
      • StoreLoad屏障
    • volatile通过内存屏障保证可见性
    • volatile通过内存屏障保证有序性
    • volatile为什么不能保证原子性
    • 怎样才能保证原子性
  • synchronized使用场景
    • 修饰实例方法
    • 修饰静态方法
    • 修饰代码块
      • 成员锁
      • 实例对象锁
      • 当前类的 class 对象锁
  • synchronized注意事项
  • synchronized底层是怎么加锁的
  • Java对象的构成
    • 对象头
      • Mark Word(标记字段)
      • Klass Point(类型指针)
    • 实例数据
    • 对其填充
  • synchronized底层是怎么通过monitor进行加锁的
    • 对象头、Mark Word 和 monitor之间的关系
    • monitor
    • monitor对象的关键属性
      • 通过这些属性是怎么进行加锁的
      • 释放锁
      • 其他属性
    • 获取锁失败后的自旋操作
    • monitor的wait和notify
      • wait()会释放锁,而Thread.sleep()不释放锁的原因
  • synchronized特性
    • 可重入性
    • 锁消除
    • 不可中断性
  • 底层实现
    • 同步代码
    • 同步方法
    • monitor
  • 重量级锁
    • 用户态和内核态
  • 优化锁升级
    • 偏向锁
      • 偏向锁之重偏向
    • 偏向锁为什么要升级为轻量级锁
    • 轻量级锁
      • 偏向锁升级为轻量级锁的过程
      • 在轻量级锁模式下,多线程是怎么竞争锁和释放锁的
    • 自旋锁
    • 总结
  • synchronized怎么实现有序性、可见性、原子性
    • 原子性
    • 可见性
    • 有序性
  • 用synchronized还是Lock呢
  • ReentrantLock
    • ReentrantLock和synchronized的区别
    • ReentrantLock 源码分析
      • 类的继承关系
      • 类的内部类
      • 类的属性
      • 类的构造函数
      • 核心函数分析
    • 线程加入等待队列
      • 加入队列的时机
      • 如何加入队列
      • 等待队列中线程出队列时机
    • CANCELLED状态节点生成
    • 如何解锁
    • 中断恢复后的执行流程
    • 小结
    • AQS应用
    • 自定义同步工具

CPU多级缓存模型

Java并发编程学习笔记_第1张图片

计算机为什么要设计高速缓存架构

CPU与主存运行速度的差异

CPU是计算机的大脑,是负责执行指令的;自身的频率和指令执行的速度非常快,一秒执行的指令大概109级别的;内存的的速度要比CPU慢上好几个级别,每秒处理的速度大概是106的级别的。

CPU长时间空闲

这样就会导致一个问题;如果CPU要频繁的访问主存的话,每次都需要等待很长的时间,执行性能就会低,大部分时间都在等待主存返回数据,没有发挥出CPU的性能
Java并发编程学习笔记_第2张图片

引入高速缓存,减少CPU等待时间,提升运行效率

Java并发编程学习笔记_第3张图片

多核CPU的多级缓存架构是怎么样的

Java并发编程学习笔记_第4张图片
如上图所示,现代计算机一般都是多核CPU的,其中每个CPU都有自己的高速缓存,其中主内存是共用的。读取数据的时候先从主内存读取到自己的高速缓存中,CPU需要数据时先从自己的高速缓存中查找,找不到再去主内存中拉取,同时刷入自己的高速缓存中。

多核CPU的多级缓存架构带来的缓存数据一致性问题

Java并发编程学习笔记_第5张图片
如上图所示:在多线程并发操作的时候,由于CPU多级缓存的存在,有可能你修改了值,但是别的CPU的高速缓存还是旧值,CPU计算的时候使用了旧值计算,导致数据有问题。

JAVA内存模型

为什么要设计JAVA内存模型

CPU多级缓存模型,只是一个规范,但是底层基于这个规范的实现还是有很多种的。比如:

  • 基于这个模型之上不同的计算机厂商可能对这个模型底层的实现不一样,包括底层运行的指令集、有的还基于多级缓存模型之上还引入了写换从器、无效队列等。

  • 不同的操作系统windows、linux的指令集不一样。

所以JAVA语言为了应对上述的问题:不同的系统、不同的计算机厂商的底层实现不同。基于它们之上设计了一个JAVA内存模型(JMM)的规范,就是为了屏蔽底层操作系统、厂商之间的差异性。程序员运行是只需要关注JMM,而不需关注底层系统、厂商指令的差异性,这些由JMM去适配不同的系统和厂商的指令集,这样就能实现一次编写,“到处乱跑”的跨平台效果。

总结一下就是为了屏蔽操作系统、不同的计算机厂商的底层的差异性,实现跨平台的无差异性运行的效果

JAVA内存模型是怎么样的

Java并发编程学习笔记_第6张图片
上图就是JAVA内存模型的大致结构图,JAVA内存模型定义了一个规范。那就是每个线程都有自己的工作内存,线程操作共享变量的时候需要从主内存读取到自己的工作内存,然后在传递给工作线程使用,共享变量修改后先刷新到工作内存,然后再刷新回主内存;这个JAVA内存模型是基于CPU多级缓存模型上建立的。

JAVA内存模型定义的八种原子操作

JMM定义了8种指令,它工作的时候就是通过这8种指令来操作内存的。

包括怎么锁定变量、怎么将数据从主存传到工作内存、怎么传给正在被CPU调度的线程、修改之后CPU怎么传回工作内存、工作内存又怎么传递回主存:

  • lock(锁定):把主内存中的一个共享变量标记为一个线程独享的状态

  • unlock(解锁):把主内存的变量从线程独享的lock状态中解除出来

  • read(读取):把主内存的一个共享变量传输工作内存中

  • load(载入):把从主内存传输到工作内存的共享变量,赋值给工作内存中的变量副本

  • use(使用):把工作内存中的变量副本的值,传递给执行引擎CPU

  • assign(赋值):执行引擎(CPU)执行完之后,把修改过的变量值重新赋值给工作内存中的变量副本

  • store(存储):把工作内存中修改过共享变量的值传递到主内存中

  • write(写入):把传递到主内存中的变量值,重新写回给主内存的共享变量
    Java并发编程学习笔记_第7张图片
    Java并发编程学习笔记_第8张图片

  • 工作线程A操作该共享内存变量的时候,执行lock指令,主内存中的这个共享变量;同时告诉线程B这个共享变量我准备修改了,让它失效掉。

  • B线程的变量副本失效之后,运行时候用到,需要到主内存重新读取(执行read、load操作放入工作内存);发现该主内存的变量被锁定了,读取失败;此时相当于线程A拥有该变量的独享操作

  • 线程A执行i++操作,经历上述说的(read、load、use、assign、store、write)指令之后;操作完成执行unlock释放锁定的这个内存变量

  • 线程B这个时候再去主内存读取的时候,发现未被锁定,就可以重新读取了

多线程并发在JMM操作带来的可见性问题

Java并发编程学习笔记_第9张图片
线程A和线程B同时执行共享变量x的++操作;线程B在线程A将x=1的值刷入主内存之前读取x=0,这样就会导致数据错了。

线程安全之可见性、有序性、原子性

可见性

多个线程同时对某一个共享变量进行操作的时候,存在线程A的操作对线程B不可见的问题。简单来说就是线程A执行了某些操作对数据进行了变更;但是线程B并不知道,所以还是使用旧数据干它自己的活。
比如线程A和线程B都执行x++操作(x的初始值是0),线程A执行完了之后将主内存的值更新为1,但是线程B由于已经将 x = 0 读取进入自己的工作内存了,不知道线程A将x更新为1了,所以还是使用x=0去进行++操作。
像这种,就是典型的可见性问题,就是线程A操作了数据,但是线程B不可见,感知不到。
Java并发编程学习笔记_第10张图片

有序性

有序性是指由于JIT动态编译器、操作系统为了给提高程序的执行效率,可能会对按顺序书写好的指令进行重排,线程或者CPU执行的时候不一定按照程序书写的顺序来执行:

比如程序的书写顺序是 指令1 -> 指令2 -> 指令3;但是由于指令重排序,某个线程执行这几个指令的时候,比如说线程A执行的时候,可能先执行指令3,然后再执行指令2、指令1。导致别的线程,比如说线程B看到线程A的指令执行是乱序的。

线程A在执行数据库、http客户端的初始化工作,初始化完毕之后将initOk初始化表示置为true表示初始化完毕。

// 步骤1
dataSource = initDataSource();
// 步骤2
httpClient = initHttpClient();
// 步骤3
initOK = true;

线程B在这里一直监听线程A是否初始化资源完毕,看到initOK标识为true表示初始化结束。开始执行业务操作,获取数据,根据数据发起网络调用。

// 步骤4
while(!initOK) {
}
// 步骤5
Object data = dataSource.getData();
// 步骤6
httpClient.request(data);

上面这段代码,正常来说线程A的执行顺序应该是 步骤1 -> 步骤2 -> 步骤3。但是由于JIT动态编译器或者操作系统可能对指令进行重排序,所以可能执行顺序是 步骤3 -> 步骤1 -> 步骤2。

这样就会导致线程B先看到了initOk = true,这样就会导致线程B直接跳出while循环,跳出等待,执行dataSource.getData方法,执行httpClient.request()方法;但是线程A的步骤1、步骤2还没执行dataSource、httpClient是null,会抛出空指针异常。

这种有序性问题,在多线程并发执行的时候,由于指令的重排序存在,很可能是会发生的。

这就是有序性带来的线程安全问题,也就是线程B看到线程A的执行时乱序的,也就是不是按照步骤1、2、3这样顺序的来执行。

简单点来讲就是线程A还没初始化好,就将标识initOk设置为true。导致线程B误以为线程A搞定了,然后去获取数据,发起http请求,然后…,然后线程B就挂了…(线程B:线程A这坑爹的,还没初始化好就告诉我搞定了,这不是坑我嘛…)

原子性

原子性是说某个操作是不可分割的、不可中断的。

什么是MESI缓存一致性协议

MESI协议也叫做缓存一致性协议,主要是用来进行协调多核CPU的高级缓存的数据一致的。CPU多级缓存架构,存在多个高速缓存之间数据一致性的问题。

MESI一致性协议定义了高速缓存中数据的4中状态,分别是:

  • M(Modified): 修改过的,只有一个CPU能独占这个修改状态,独占的意思是当有一个CPU的高速缓存数据处于这个状态的时候,其它CPU的高速缓存对这个共享的数据均不能操作;高速缓存中的数据发生了更新,需要被刷入主内存中。

  • E(Exclusive): 独占状态,只有一个CPU能独占这个状态,同样当某个CPU的高速缓存的数据处于这个状态的时候,其它CPU的均不能操作这个共享数据

  • S(Share):共享的状态,当CPU的高速缓存中的数据这个状态的时候,各个CPU可以并发的对这个数据进行读取

  • I(Invalid):无效的,意思是当前高速缓存的这个数据已经是无效了或者过期了,不能使用。

MESI这四种状态怎么解决多核CPU高速缓存数据一致

(1)首先像下面的图一样,CPU0、CPU1将共享变量 i = 0 读取,进入自己高速缓存的时候;缓存的状态是S,也就是共享的
Java并发编程学习笔记_第11张图片
(2)然后CPU0要对 i = 0 的变量进行修改操作,在MESI一致性里面大概会经过这些步骤:

  • CPU0发送消息给总线,说我要修改数据了,帮我通知一下其它的CPU

  • 其它的CPU收到总线通知消息,将自己高速缓存上 i = 0 的数据状态变为Invalid过期

  • 其它CPU返回给总线说我们都过期了

  • 总线收到其它CPU返回过期OK了

  • 总线返回给CPU0说,好了,其它CPU都通知到位了,它们高速缓存上的 i = 0的数据都是过期状态了

  • CPU0收到了过期确认,都过期了,那我就可以独占这份数据了,嘿嘿,准备可以修改数据了

Java并发编程学习笔记_第12张图片
(3)CPU0修改数据,刷新回主内存,还会经过这些步骤:

  • CPU0执行 i++ 操作,将 i = 1 的最新结果刷入到高速缓存中,同时将高速缓存的数据状态设为M(修改过的)

  • 然后将高速缓存中 i = 1 的最新结果又刷入主内存中
    Java并发编程学习笔记_第13张图片

(4)CPU1要读取数据操作,发现高速缓存上数据过期了,回经过下面步骤:

  • CPU1发现自己高速缓存上 i = 0 的数据是 Invalid 过期状态,于是从主存重新读取

  • 然后CPU1从主内存读取到 i = 1的最新的数据,将自己状态设置成S
    Java并发编程学习笔记_第14张图片

volatile

volatile是什么

volatile是java语言提供的一个关键字,用来修饰变量的,使用volatile修饰的变量可以保证并发安全的可见性和有序性。

使用方法就是声明变量之前加一个volatile关键字,然后变量的操作就跟我们平常的操作是一样的。
但是添加的volatile的变量,在编译之后JVM会在操作该变量的前后添加一些指令来保证可见性和有序性。

volatile对可见性保证

使用volatile关键字修饰的共享变量,每次线程使用之前都会重新从主内存中重新读取最新的值;一旦该共享变量的值被修改了,修改它的线程比如立刻将修改后的值强制刷新回主内存。
在这里插入图片描述
(1)首先看一下上面的图,有工作线程A、工作线程B;假如之前工作线程A、B都是用过这个共享变量 i,工作内存中都有变量副本 i = 0

(2)这个时候工作线程A要执行 i++ 操作,按照volatile关键字的特性,每次使用之前必须从主内存重新读取,所以工作线程A重新从主内存读取(执行read、load指令)得到 i = 0没有变化

(3)然后执行use指令将 i = 0 传递给工作线程,执行 i++ 操作,得到 i = 1

(4)然后执行assign指令,将 i = 1的结果赋值给工作线程,按照volatile的特性;一旦共享变量的值被修改了,需要立即强制刷新回主内存。所以在执行assign赋值更新后的之后,立马执行store、write指令将最新的值传递到主内存,并且赋值给主内存的变量。

(5)此时工作线程B需要用到共享变量 i 了,即使工作内存里面有副本,但是每次还是会重新从主内存中读取最新的值,这个时候读取到 i = 1了

什么是内存屏障?

volatile的可见性和有序性都是通过内存屏障来实现的

内存屏障,本质上也是一种指令,只不过它具有屏障的作用而已

首先内存屏障是一种指令,无论是在JAVA内存模型还是CPU层次,都是有具体的指令对应的,是一种特殊的指令。

然后这种指令具有屏障的作用,所谓屏障,也就是类似关卡,类似栅栏,具有隔离的作用。

按照内存屏障的分类,我理解有两类:

  • 一类是强制读取主内存,强制刷新主内存的内存屏障,叫做Load屏障和Store屏障
  • 另外一类是禁止指令重排序的内存屏障,有四个分别叫做LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障

强制读取/刷新主内存的屏障

Load屏障:执行读取数据的时候,强制每次都从主内存读取最新的值。

Store屏障:每次执行修改数据的时候,强制刷新回主内存。

Load屏障

在这里插入图片描述
如图所示:在工作内存的变量名、变量的值之前有一道关卡或者栅栏,导致变量 i 获取不到工作内存中的值,所以每次只好主内存重新加载

Store屏障

在这里插入图片描述
如图所示,每次执行assign指令将数据变更之后,后面都会紧紧跟着一个Store屏障,让你立刻刷新到主内存。

禁止指令重排序的屏障

LoadLoad屏障

序列:load1指令 LoadLoad屏障 load2指令

作用:在load1指令和load2指令之间加上 LoadLoad屏障,强制先执行load1指令再执行load2指令;load1指令和load2指令不能进行重排序(LoadLoad屏障 前面load指令禁止和屏障后面的load指令进行重排序)。

StoreStore屏障

序列:store1指令 StoreStore屏障 store2指令

作用:在store1指令和store2指令之间加上StoreStore屏障,强制先执行store1指令再执行store2指令;store1指令不能和store2指令进行重排序(StoreStore屏障 前面的store指令禁止和屏障后面的store指令进行重排序)

LoadStore屏障

序列:load1指令 LoadStore屏障 store2指令

作用:在load1指令和store2指令之前加上LoadStore屏障,强制先执行load1指令再执行store2指令;load1指令和store2执行不能重排序(LoadStore屏障 前面的load执行禁止和屏障后面的store指令进行重排序)

StoreLoad屏障

序列:store1指令 StoreLoad屏障 load2指令

作用:在store1指令和load2指令之间加上StoreLoad屏障,强制先执行store1指令再执行load2指令;

store1指令和load2指令执行不能重排序(StoreLoad屏障 前面的Store指令禁止和屏障后面的Store/Load指令进行重排)
在这里插入图片描述
(1)有三个区域分别是区域1、区域2、区域3

(2)区域1和区域2加了 StoreStore屏障,这样区域1和区域2的Store指令就被隔离开来,不能重排了

(3)区域2和区域3加了StoreLoad屏障,这样区域2和区域3的Store指令、Load指令就被隔离开来,不能重排了

(4)就相当于搞了个栅栏,禁止各个区域之间的指令跳来跳去的,否则就会导致乱序执行

volatile通过内存屏障保证可见性

volatile修饰的变量,在每个读操作(load操作)之前都加上Load屏障,强制从主内存读取最新的数据。每次在assign赋值后面,加上Store屏障,强制将数据刷新到主内存。

以volatile int = 0;线程A、B进行 i++ 的操作为例:
在这里插入图片描述
如图所示:

  • 线程A读取 i 的值遇到Load屏障,需要强制从主存读取得到 i = 0; 然后传递给工作线程执行++操作

  • cpu执行 i++ 操作得到 i = 1,执行assign指令进行赋值;然后遇到Store屏障,需要强制刷新回主内存,此时得到主内存 i = 1

  • 然后线程B执行读取 i 遇到Load屏障,强制从主内存读取,得到最新的值 i = 1,然后传给工作线程执行++操作,得到 i = 2,同样在赋值后遇到Store屏障立即将数据刷新回主内存

volatile通过内存屏障保证有序性

将initOk用volatile来修饰

// 步骤1
dataSource = initDataSource();
// 步骤2
httpClient = initHttpClient(); 
// 步骤3
initOK = true;

对应到指令可能是这样的:

// 步骤1  对应上面dataSource = initDataSource();
store datasource指令
// 步骤2  对应上面httpClient = initHttpClient();
store http指令
 
StoreStore屏障  (注意:在store initOK前面加了一个StoreStore屏障)
// 步骤3  对应上面initOK = true;
store initOk = true指令
StoreLoad 屏障 (注意:在store initOK后面加了一个StoreLoad屏障)

注意这里:store initOk指令的前面加了一道StoreStore屏障;后面加了一道StoreLoad屏障

在这里插入图片描述
所以通过volatile修饰initOK,加了屏障之后;store initOK = true 这一条指令是不能跳到store dataSource、store http前面去的,所以必须先执行完前面的执行之后,才能执行store initOK = true

也就是通过加了屏障,store initOK = true 指令不能跟前面的store指令进行交换。所以它就自然得等前面的store指令执行完了之后,才执行store initOK = true的对吧? 然后在线程B那一侧看到的initOK = true的时候,发现资源以及初始化好了,自然就不会报错了。

volatile为什么不能保证原子性

还是以 i++ 的那个例子为例,volatile int i = 0,假如两个线程A、线程B同时对 i 进行 ++ 操作如下:
在这里插入图片描述
上图存在一种情况就是,线程A、线程B如果几乎同时读取 i = 0 到自己的工作内存中。

线程A执行 i++ 结果后将 i = 1 赋值给工作内存;但是这个时候还没来的将最新的结果刷新回主内存的时候,线程B就读取主内存的旧值 i = 0 ,然后执行use指令将 i = 0的值传递给线程B去进行操作了。

即使这个时候线程A立即将 i = 1刷入主内存,那也晚了;线程B已经使用旧值 i = 0进行操作了,像这种情况计算结果就不对了。

怎样才能保证原子性

如果要保证原子性的话,落到底层实际还是需要进行加锁的,需要保证任意时刻只能有一个线程能执行成功。

如果要保证原子性的话,同一时刻只能有一个线程或者CPU能够执行成功,底层是需要对硬件进行加锁的,只有某个CPU或者线程锁定了,享有独占的权限,那么它的操作才能是不被其它CPU或者线程打断的。

synchronized使用场景

修饰实例方法

public class test04 {
    public synchronized void test(){
        
    }
}

修饰静态方法

public class test04 {
    public static synchronized void test(){
        
    }
}

修饰代码块

成员锁

锁的对象是变量

public class test04 {
    public String test(String str){
        synchronized (str) {
            
        }
    }
}

实例对象锁

this 代表当前实例

public class test04 {
    public void test(){
        synchronized (this) {
            
        }
    }
}

当前类的 class 对象锁

public class test04 {
    public void test(){
        synchronized (test04.class) {
            
        }
    }
}

synchronized注意事项

synchronized是对一个对象上锁的,使用的时候需要注意在对同一个对象上锁才能达到互斥的目的。
Java并发编程学习笔记_第15张图片

public static void main(String[] args) {
    // 只有一个锁对象lock1
    SynDemo lock1 = new SynDemo();
    // 两个线程对同一个对象锁进行争抢
    TestThread threadA = new TestThread(lock1);
    TestThread threadB = new TestThread(lock1);
 
    threadA.start();
    threadB.start();
}

Java并发编程学习笔记_第16张图片
对于synchronized修饰static的方法,synchronized锁的是这个类对象,由于类对象只有一个,所以不同线程同时通过类调用这个方法的时候就能互斥的效果。

synchronized底层是怎么加锁的

通过monitor和monitorenter和monitorexit来加锁和释放锁的
Java并发编程学习笔记_第17张图片
(1)首先java里面每个对象JVM底层都会为它创建一个监视器monitor,这个是JVM层次为我们保证的。这个监视器就类似一个锁,哪个线程持有这个monitor的操作权,就相当于获取到了锁

(2)其次synchronized 修饰的代码或者方法,底层会生成两条指令分别为monitorenter、monitorexit。

(3)进入synchronized的代码块之前会执行monitorenter指令,去申请monitor监视器的操作权,如果申请成功了,就相当于获取到了锁。如果已经有别的线程申请成功monitor了,这个时候它就得等着,等别的线程执行完synchronized里面的代码之后就会执行monitorexit指令释放monitor监视器,这样其它在等待的线程就可以再次申请获取monitor监视器了。

Java对象的构成

在 JVM 中,对象在内存中分为三块区域:
Java并发编程学习笔记_第18张图片

对象头

Mark Word(标记字段)

默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
在这里插入图片描述
JVM就根据Mark Word上32bit的值不同,把设计为一个多功能的复用器,在bit标志位不同的时候表示的意思也不一样,前面30bit位可能表示的意思不一样,但是最后2个bit表示的都是锁模式
Java并发编程学习笔记_第19张图片
(1)首先最后两位,也就是锁标志位,分别标识处于不同的锁模式;倒数第3位是偏向锁标志

(2)当偏向锁标志是0,锁标志位是01,也就是最后3位是001的时候,表示无锁模式。Mark Word就是记录的数据就是对象的hashcode 和 GC的年龄
Java并发编程学习笔记_第20张图片
(3) 当偏向锁标志是1,锁标志是01,也就是最后三位是101的时候,处于偏向锁模式,Mark Word这个时候记录的数据就是获取偏向锁的线程ID、Epoch、对象GC年龄
Java并发编程学习笔记_第21张图片
(4)当锁标志位是00的时候,表示处于轻量级锁模式。会把锁记录放在加锁的线程的虚拟机栈空间中,所以这种情况下,锁记录在哪个线程虚拟机栈中,就表示所在线程就获取到了锁。

然后Mark Word记录的数据就是就指向那个锁记录地址就好了,这个锁记录地址在哪个线程中,就表示哪个线程获取到了轻量级锁。
Java并发编程学习笔记_第22张图片
(5)当锁标志位是10的时候,表示处于重量级锁模式,这个时候就说明竞争激烈了,处于重量级锁模式了,由于使用重量级加锁不是我的职责范围,是monitor的职责

这个是Mark Word 记录的数据就是monitor的地址,有加锁的需求直接根据这个地址找到monitor,找它加锁就好了。
Java并发编程学习笔记_第23张图片

Klass Point(类型指针)

对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据

存放这个实例的一些属性信息,比如有的属性是基本类型,那就直接存储值;如果是对象类型,存放的就是一个指向对象的内存地址。

对其填充

由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

Java并发编程学习笔记_第24张图片

synchronized底层是怎么通过monitor进行加锁的

对象头、Mark Word 和 monitor之间的关系

Java并发编程学习笔记_第25张图片

monitor

monitor叫做对象监视器、也叫作监视器锁,JVM规定了每一个java对象都有一个monitor对象与之对应,这monitor是JVM帮我们创建的,在底层使用C++实现的。

monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

synchronized底层的源码就是引入了ObjectMonitor
大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。

monitor对象的关键属性

  • _count : 这个属性非常重要,直接表示有没有被加锁,如果没被线程加锁则 _count=0,如果_count大于0则说明被加锁了

  • _owner:这个属性也非常重要,直接指向加锁的线程,比如线程A获取锁成功了,则_owner = 线程A;当_owner = null的时候表示没线程加锁

  • _waitset:当持有锁的线程调用wait()方法的时候,那个线程就会释放锁,然后线程被加入到monitor的waitset集合中等待,然后线程就会被挂起。只有有别的线程调用notify将它唤醒。

  • _entrylist:这个就是等待队列,当线程加锁失败的时候被block住,然后线程会被加入到这个entrylist队列中,等待获取锁。

  • _spinFreq:获取锁失败前自旋的次数;JDK1.6之后对synchronized进行优化;原先JDK1.6以前,只要线程获取锁失败,线程立马被挂起,线程醒来的时候再去竞争锁,这样会导致频繁的上下文切换,性能太差了。
    JDK1.6后优化了这个问题,就是线程获取锁失败之后,不会被立马挂起,而是每个一段时间都会重试去争抢一次,这个_spinFreq就是最大的重试次数,也就是自旋的次数,如果超过了这个次数抢不到,那线程只能沉睡了。

  • _spinClock:上面说获取锁失败每隔一段时间都会重试一次,这个属性就是自旋间隔的时间周期,比如50ms,那么就是每隔50ms就尝试一次获取锁。

通过这些属性是怎么进行加锁的

(1)首先呢,没有线程对monitor进行加锁的时候是这样的:

_count = 0 表示加锁次数是0,也就是没线程加锁;_owner 指向null,也就是没线程加锁
Java并发编程学习笔记_第26张图片
(2)然后呢,这个时候线程A、线程B来竞争加锁了,如下图所示:
Java并发编程学习笔记_第27张图片
(3)线程A竞争到锁,将_count 修改为1,表示加锁次数为1,将_owner = 线程A,也就是指向自己,表示线程A获取到了锁。

释放锁

Java并发编程学习笔记_第28张图片

其他属性

Java并发编程学习笔记_第29张图片
(1)首先线程B获取锁的时候发现monitor已经被线程A加锁了

(2)然后monitor里面记录的_spinFreq 、spinclock 信息告诉线程B,你可以每隔50ms来尝试加锁一次,总共可以尝试10次

(3)如果线程B在10次尝试加锁期间,获取锁成功了,那线程B将_count 设置为 1,_owner 指向自己表示自己获取锁成功了

(4)如果10次尝试获取锁此时都用完了,那没辙了,它只能放到等待队列里面先睡觉去了,也就是线程B被挂起了

获取锁失败后的自旋操作

(1)首先跟你说下,线程挂起之后唤醒的代价很大,底层涉及到上下文切换,用户态和内核态的切换,我打个比方可能最少耗时3000ms这样,这只是打个比方哈

(2)线程A获取了锁,这个时候线程B获取失败。按照上面自旋的数据_spinclock = 50ms(每次自旋50ms),_spinFreq = 10(最多10次自旋)

(3)假如线程A使用的时间很短,比如只使用150ms的时间;那么线程B自旋3次后就能获取到锁了,也就花费了150ms左右的时间,相比于挂起之后唤醒最少花费3000ms的时间,是不是大大减少了等待时间啊…,这也就提高了性能了。

(4)如果不设置自旋的次数限制,而是让它一直自旋。假如线程A这哥们耗时特别的久,比如它可能在里面搞一下磁盘IO或者网络的操作,花了5000ms!!。

那线程B可不能在那一直自旋着等着它吧,毕竟自旋可是一直使用CPU不释放CPU资源的,CPU这时也在等着不能干别的事,这可是浪费资源啊,所以啊自旋次数也是要有限制的,不能一直等着,否则CPU的利用率大大被降低了。

所以在10次自旋之后,也就是500ms之后,还获取失败,那就把自己挂起,释放CPU资源咯。

举个例子,假如有两个人要上厕所,但是只有一个坑位,线程A去得比较早,先把坑位给占了:

(1)假如线程A加锁了,它只是上了个小厕所,用了150ms就完成了;然后线程B尝试几次之后就能获取成功了

Java并发编程学习笔记_第30张图片

(2)但是如果线程A拉肚子了,这家伙在里面蹲了一个多小时…,线程B尝试了10次之后,发现坑还是没有空的。这个时候线程B发现自己还有好多代码没写,害~,不等了,先释放CPU去写写代码,待会再来看看…

Java并发编程学习笔记_第31张图片

monitor的wait和notify

说起monitor里面的waitset,上面讲的就是一个集合。

必须是当线程获取锁之后,才能调用wait()方法,然后此时释放锁,将_count恢复为0,将_owner指向 null,然后将自己加入到waitset集合中,等待别人调用notify或者notifyAll将其中waitset的线程唤醒

假如说现在有个场景如下:

线程A执行如下代码:

synchronized(this) {
    if (某个条件) {
        wait();    
    }    
}

线程B执行如下代码:

synchronized(this) {
    // 某些业务逻辑
    ......
    notify();
}

下面画个图来说一下:
Java并发编程学习笔记_第32张图片
(1)首先啊还是线程A这哥们动作比较快,先获取到了锁。

(2)然后线程A发现条件不满足,想了想,算了,我先释放锁,睡个觉,等条件满足了,别人再唤醒我,岂不是美滋滋。于是释放了锁,睡觉去了

(3)然后线程B自己可以加锁了,执行了一些业务逻辑,然后去调用notify方法唤醒线程A,嘿兄弟,别睡了,到你了…

(4)线程A醒来之后,还是要再去去竞争锁的,也就是醒来之后还要竞争将_count修改为1,竞争_owner指向自己,毕竟它还在synchronized代码块内部嘛,只有获取锁之后才能执行synchronized代码块的代码。所以只有它再次获取到锁了之后,才会执行代码块内部的逻辑

wait()会释放锁,而Thread.sleep()不释放锁的原因

synchronized(this) {
    // 这个时候线程释放锁,然后将自己放入monitor的waitset队列,
    // 等待别人调用notify/notifyAll将唤醒
    wait(); 
}
synchronized(this) {
    // 这种情况不释放锁,就是睡个500ms然后醒来持有锁继续干活
    Thread.sleep(500);
}

synchronized特性

可重入性

synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
那可重入有什么好处呢?
可以避免一些死锁的情况,也可以让我们更好封装我们的代码。
(1)所以再次重入加锁的时候,发现有人加锁了,同时检查_owner是不是自己加锁的,如果是自己加锁的,只需要将_count 次数加1即可。
Java并发编程学习笔记_第33张图片
(2)同样,在释放锁的时候执行monitorexit指令,首先将_count进行减1,当_count 减少到0的时候表示自己释放了锁,然后将_owner 指向null。

锁消除

锁消除就是在不存在锁竞争的地方使用了synchronized,jvm会自动帮你优化掉,比如说下面的这段代码

public void business() {
    // lock对象方法内部创建,线程私有的,根本不会引起竞争
    Object lock = new Object();
    synchronized(lock) {
         i++;
         j++;
         // 其它业务操作       
    }    
}

上面的这段代码,由于lock对象是线程私有的,多个线程不会共享;像这种情况多线程之间没有竞争,就没必要使用锁了,就有可能被JVM优化成以下的代码:

public void business() {
    i++;
    j++;
    // 其它业务操作
}

不可中断性

不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。
值得一提的是,Lock的tryLock方法是可以被中断的。

底层实现

同步代码

  • 当我们进入一个方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。

  • 如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.

  • 同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。

所有的互斥,其实在这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。

同步方法

同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。
所以归根究底,还是monitor对象的争夺。

monitor

monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

synchronized底层的源码就是引入了ObjectMonitor
大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。

重量级锁

大家在看ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()。

这个操作涉及用户态和内核态的转换了,这种切换是很耗资源的,所以知道为啥有自旋锁这样的操作了吧,按道理类似死循环的操作更费资源才是对吧?其实不是,大家了解一下就知道了。

用户态和内核态

Linux系统的体系结构分为用户空间(应用程序的活动空间)和内核。

我们所有的程序都在用户空间运行,进入用户运行状态也就是(用户态),但是很多操作可能涉及内核运行,比我I/O,我们就会进入内核运行状态(内核态)。

Java并发编程学习笔记_第34张图片

这个过程是很复杂的,也涉及很多值的传递,我简单概括下流程:

  • 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。
  • 用户态执行系统调用(系统调用是操作系统的最小功能单位)。
  • CPU切换到内核态,跳到对应的内存指定的位置执行指令。
  • 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
  • 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。

所以大家一直说,1.6之前是重量级锁,没错,但是他重量的本质,是ObjectMonitor调用的过程,以及Linux内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。

还有两种情况也会发生内核态和用户态的切换:异常事件和外围设备的中断

优化锁升级

Java并发编程学习笔记_第35张图片

升级方向:

在这里插入图片描述

Tip:切记这个升级过程是不可逆的,最后我会说明他的影响,涉及使用场景

Mark Word是一个32bit位的数据结构,最后两位表示的是锁标志位,当Mark Word的锁标志位不同的时候,代表Mark Word 中记录的数据不一样。

(1)比如锁模式标志位是,也就是最后两位是01的时候,表示处于无锁模式或者偏向锁模式。

无锁:如果此时偏向锁标志,倒数第3位,是0,即最后3位是001,表示当前处于无锁模式,此时Mark Word就常规记录对象hashcode、GC年龄信息。

偏向锁:倒数第3位是1,即Mark word最后3位是101,则表示当前处于偏向锁模式,那么Mark Word就记录获取了偏向锁的线程ID、对象的GC年龄。

(2)轻量级锁:当锁模式标志位是00的时候,表示当前处于轻量级锁模式,此时会生成一个轻量级的锁记录,存放在获取锁的线程栈空间中,Mark Word此时就存储这个锁记录的地址。

Mark Word存储的地址在哪个线程的栈空间中,就表示哪个线程获取到了轻量级锁。

(3)重量级锁:当锁模式标志位是10的时候,表示当前处于重量级锁模式,此时加锁就不是Mark Word的责任了,需要找monitor锁监视器,这个上一章我们已经讲解monitor加锁的原理了。

此时Mark Word就记录了一下monitor的地址,然后有线程找Mark Word的时候,Mark Word就把monitor地址给它,告诉线程自个根据这个地址找monitor进行加锁。

偏向锁

对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。

这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,CAS会失败,也就意味着获取锁失败。

当有线程第一次进入synchronized的同步代码块之内,发现:
Java并发编程学习笔记_第36张图片
Mark Word的最后三位是001,表示当前无锁状态,说明锁的这时候竞争不激烈啊。

于是选择代价最小的方式,加了个偏向锁,只在第一次获取偏向锁的时候执行CAS操作(将自己的线程Id通过CAS操作设置到Mark Word中),同时将偏向锁标志位改为1。

后面如果自己再获取锁的时候,每次检查一下发现自己之前加了偏向锁,就直接执行代码,就不需要再次加锁了

偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。
Java并发编程学习笔记_第37张图片
加了偏向锁的人确实是个自私的人,这家伙用完了锁之后,自己加锁时候修改过的Mark Word信息都不会再改回来了,也就是它不会主动释放锁。

在这里插入图片描述
如果它用完了,别人这个时候需要进入synchronized代码块怎么办?这就涉及到一个重偏向的问题

偏向锁之重偏向

线程B去申请加锁,发现是线程A加了偏向锁;这时候回去判断一下线程A是否存活,如果线程A挂了,就可以重新偏向了,重偏向也就是将自己的线程ID设置到Mark Word中。

如果线程A没挂,但是synchronized代码块执行完了,这个时候也可以重新偏向了,将偏向标识指向自己
Java并发编程学习笔记_第38张图片

偏向锁关闭,或者多个线程竞争偏向锁怎么办呢?
这个时候就有锁的竞争了,这就需要将锁升级一下了,线程B就会把锁升级为轻量级锁

偏向锁为什么要升级为轻量级锁

// 代码块1
synchronized(this){
  // 业务代码1  
}
// 代码块2
synchronized(this){
  // 业务代码2
}
// 代码块3
synchronized(this){
  // 业务代码3
}
// 代码块4
synchronized(this){
  // 业务代码4
}

假如这个时候有线程A、B、C、D四个线程,线程A先加了偏向锁。之前讲过偏向锁只是在第一次获取锁的时候加锁,后面都是直接操作的不需要加锁。

这个时候其它几个线程B、C、D想要加锁,如果线程A连续执行上面4个代码块,那么其他线程看到线程A都在执行synchronized同步代码块,没完没了了,想重偏向都不行!!,这个时候就需要等线程A执行完4个synchronized代码块之后才能获取锁啊,哈哈,别的线程都只能看线程A一个人自己在那表演了,这样代码就变成串行执行了。

轻量级锁

还是跟Mark Work 相关,如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象。

JVM接下来会利用CAS尝试把对象原本的Mark Word 更新会Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。

如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
Java并发编程学习笔记_第39张图片
轻量级锁模式下,加锁之前会创建一个锁记录,然后将Mark Word中的数据备份到锁记录中(Mark Word存储hashcode、GC年龄等很重要数据,不能丢失了),以便后续恢复Mark Word使用。

这个锁记录放在加锁线程的虚拟机栈中,加锁的过程就是将Mark Word 前面的30位指向锁记录地址。所以mark word的这个地址指向哪个线程的虚拟机栈中,就说明哪个线程获取了轻量级锁。
Java并发编程学习笔记_第40张图片

偏向锁升级为轻量级锁的过程

(1)首先线程A持有偏向锁,然后正在执行synchronized块中的代码

(2)这个时候线程B来竞争锁,发现有人加了偏向锁并且正在执行synchronized块中的代码,为了避免上述说的线程A一直持有锁不释放的情况,需要对锁进行升级,升级为轻量级锁

(3)先将线程A暂停,为线程A创建一个锁记录Lock Record,将Mark Word的数据复制到锁记录中;然后将锁记录放入线程A的虚拟机栈中

(4)然后将Mark Word中的前30位指向线程A中锁记录的地址,将线程A唤醒,线程A就知道自己持有了轻量级锁
Java并发编程学习笔记_第41张图片

在轻量级锁模式下,多线程是怎么竞争锁和释放锁的

(1)线程A和线程B同时竞争锁,在轻量级锁模式下,都会创建Lock Record锁记录放入自己的栈帧中

(2)同时执行CAS操作,将Mark Word前30位设置为自己锁记录的地址,谁设置成功了,锁就获取到锁
Java并发编程学习笔记_第42张图片
轻量级锁的释放很简单,就将自己的Lock Record中的Mark Word备份的数据恢复回去即可,恢复的时候执行的是CAS操作将Mark Word数据恢复成加锁前的样子

自旋锁

Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?

monitor有一个_spinFreq参数表示最大自旋的次数,_spinClock参数表示自旋的间隔时间。所以自旋最多会重试_spinFreq次,每次失败之后等_spinClock的时间过后再去重试,如果尝试_spinFreq次之后都没有成功,那没辙了,只能沉睡了。

老王:自旋其实是非常消耗CPU资源的,自旋期间相当于CPU啥也不干,就在那等着的。为了避免自旋时间太长,所以JVM就规定了默认最多自旋10次,10次还获取不到锁,那就直接将线程挂起了,线程就会直接阻塞等待了,这个时候性能就差了。-XX:PreBlockSpin可以修改。

自旋都失败了,那就升级为重量级的锁,像1.5的一样,等待唤起咯。
Java并发编程学习笔记_第43张图片

总结

比如在没有竞争的情况下,进入synchronized的使用使用偏向锁就够了,这样只需要第一次执行CAS操作获取锁,获取了偏向锁之后,后面每次进入synchronized同步代码块就不需要再次加锁了。

然后在存在多个线程竞争锁的时候就不能使用偏向锁了,不能只偏心一个人,它优先获取锁,别人都看它表演,这样是不行的。

于是就升级为轻量级锁,在轻量级锁模式在每次加锁和释放是都需要执行CAS操作,对比偏向锁来说性能低一点的,但是总体还是比较轻量级的。

为了尽量提升线程获取锁的机会,避免线程陷入获取锁失败就立即沉睡的局面(线程沉睡再唤醒涉及上下文切换,用户态内核态切换,是一个非常重的操作,很费时间),所以设计自旋等待;线程每次自旋一段时间之后再去重试获取锁。

当竞争非常激烈,并发很高,或者是synchronized代码块执行耗时比较长,就会积压大量的线程都在自旋,由于自旋是空耗费CPU资源的,也就是CPU在那等着,做不了其他事情,所以在尝试了最大的自旋次数之后;及时释放CPU资源,将线程挂起了。

synchronized怎么实现有序性、可见性、原子性

原子性:基本复制写操作都能保证原子性,复杂操作无法保证

可见性:MESI协议的flush、refresh配合使用,解决可见性

有序性:3个层次,最后1个层次有4种内存重排序

synchronized可同时保证:

  • 原子性:有加锁和释放锁的机制,加锁后,同一段代码只有他能执行
  • 可见性:加内存屏障,在同步代码块做变量写操作,在释放锁时,会强制执行flush操作。在获取锁进入同步代码块时,会对变量读强制执行refresh操作。
  • 有序性:加各种内存屏障,解决4种内存重排序

原子性

加锁,有一个monitorenter指令。然后对锁对象关联的monitor累加,同时标识本线程已加锁。释放锁,有一个monitorexit指令,递减锁计数器。递减至0说明当前线程不持有锁

wait和notify也是基于monitor实现的。有线程执行wait,会把自己加入到monitor关联的waitset中,等待唤醒获取锁。notifyall会从monitor的waitset中唤醒所有的线程,让他们去竞争锁。

Java对象 = 对象头 + 实例变量(变量数据)

对象头 = Mark Word(hashcode、锁数据) + Class Metadata Address(指向类的元数据的指针)

Java并发编程学习笔记_第44张图片
3个Thread执行同步代码块,就进入entrylist中。通过CAS加锁,count计数器累加,owner记录是谁持有锁。

执行wait,会把线程放到waitset中,count=0,owner=null,等待 唤醒

执行notifyall,会唤醒waitset中的线程

可见性

我们都知道sychronized底层是通过monitorenter的指令来进行加锁的、通过monitorexit指令来释放锁的。

但是很多人都不知道的一点是,monitorenter指令其实还具有Load屏障的作用。

也就是通过monitorenter指令之后,synchronized内部的共享变量,每次读取数据的时候被强制从主内存读取最新的数据。

同样的道理monitorexit指令也具有Store屏障的作用,也就是让synchronized代码块内的共享变量,如果数据有变更的,强制刷新回主内存。

这样通过这种方式,数据修改之后立即刷新回主内存,其他线程进入synchronized代码块后,使用共享变量的时候强制读取主内存的数据,上一个线程对共享变量的变更操作,它就能立即看到了。
Java并发编程学习笔记_第45张图片

有序性

四条禁止指令重排序的内存屏障分别为:

StoreStore屏障:禁止StoreStore屏障的前后Store写操作重排

LoadLoad屏障:禁止LoadLoad屏障的前后Load读操作进行重排

LoadStore屏障:禁止LoadStore屏障的前面Load读操作跟LoadStore屏障后面的Store写操作重排

StoreLoad屏障:禁止LoadStore屏障前面的Store写操作跟后面的Load/Store 读写操作重排

同样的道理,也是通过monitorenter、monitorexit指令嵌入上面的内存屏障;monitorenter、monitorexit这两条指令其实就相当于复合指令,既具有加锁、释放锁的功能,同时也具有内存屏障的功能。
Java并发编程学习笔记_第46张图片

用synchronized还是Lock呢

我们先看看他们的区别:

  • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。

  • synchronized会自动释放锁,而Lock必须手动释放锁。

  • synchronized是不可中断的,Lock可以中断也可以不中断。

  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。

  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。

  • Lock可以使用读锁提高多线程读效率。

  • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api,还有一个我们的场景。

ReentrantLock

学习的时候看到网上有人说,synchronized太重了,在线程抢占的时候会直接升级为重量级锁,因此并发编程用ReentrantLock 比较多,所以来学习一下ReentrantLock

ReentrantLock和synchronized的区别

ReentrantLock synchronized
锁实现机制 依赖AQS 监视器模式
灵活性 支持响应中断、超时、尝试获取锁 不灵活
释放形式 必须显式调用unlock()释放锁 自动释放监视器
锁类型 公平锁&非公平锁 非公平锁
条件队列 可关联多个条件队列 关联一个条件队列
可重入性 可重入 可重入
 // **************************Synchronized的使用方式**************************
 // 1.用于代码块
 synchronized (this) {}
 // 2.用于对象
 synchronized (object) {}
 // 3.用于方法
 public synchronized void test () {}
 // 4.可重入
 for (int i = 0; i < 100; i++) {
     synchronized (this) {}
 }
 // **************************ReentrantLock的使用方式**************************
 public void test () throw Exception {
     // 1.初始化选择公平锁、非公平锁
     ReentrantLock lock = new ReentrantLock(true);
     // 2.可用于代码块
     lock.lock();
     try {
         try {
             // 3.支持多种加锁方式,比较灵活; 具有可重入特性
             if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
         } finally {
             // 4.手动释放锁
             lock.unlock()
         }
     } finally {
         lock.unlock();
     }
 }

ReentrantLock 源码分析

类的继承关系

ReentrantLock 实现了 Lock接口,Lock接口中定义了 lock与 unlock相关操作,并且还存在 newCondition方法,表示生成一个条件。

public class ReentrantLock implements Lock, java.io.Serializable

类的内部类

ReentrantLock 总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。
Java并发编程学习笔记_第47张图片
说明:ReentrantLock 类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与 FairSync类继承自 Sync类,Sync类继承自 AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

AQS提供了大量用于自定义同步器实现的 Protected方法。自定义同步器实现的相关方法也只是为了通过修改 State字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(ReentrantLock需要实现的方法如下,并不是全部):

方法名 描述
protected boolean isHeldExclusively() 该线程是否正在独占资源。只有用到Condition才需要去实现它。
protected boolean tryAcquire(int arg) 独占方式。arg为获取锁的次数,尝试获取资源,成功则返回True,失败则返回False。
protected boolean tryRelease(int arg) 独占方式。arg为释放锁的次数,尝试释放资源,成功则返回True,失败则返回False。
protected int tryAcquireShared(int arg) 共享方式。arg为获取锁的次数,尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected boolean tryReleaseShared(int arg) 共享方式。arg为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回True,否则返回False。

一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。ReentrantLock是独占锁,所以实现了tryAcquire-tryRelease。以非公平锁为例,这里主要阐述一下非公平锁与AQS之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。
Java并发编程学习笔记_第48张图片
Sync 类的源码如下:

 abstract static class Sync extends AbstractQueuedSynchronizer {
     // 序列号
     private static final long serialVersionUID = -5179523762034025860L;
     
     // 获取锁
     abstract void lock();
     
     // 非公平方式获取
     final boolean nonfairTryAcquire(int acquires) {
         // 当前线程
         final Thread current = Thread.currentThread();
         // 获取状态
         int c = getState();
         if (c == 0) { // 表示没有线程正在竞争该锁
             if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用
                 // 设置当前线程独占
                 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;
     }
     
     // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
     protected final boolean tryRelease(int releases) {
         int c = getState() - releases;
         if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
             throw new IllegalMonitorStateException(); // 抛出异常
         // 释放标识
         boolean free = false; 
         if (c == 0) {
             free = true;
             // 已经释放,清空独占
             setExclusiveOwnerThread(null); 
         }
         // 设置标识
         setState(c); 
         return free; 
     }
     
     // 判断资源是否被当前线程占有
     protected final boolean isHeldExclusively() {
         return getExclusiveOwnerThread() == Thread.currentThread();
     }
 
     // 新生一个条件
     final ConditionObject newCondition() {
         return new ConditionObject();
     }
 
     // 返回资源的占用线程
     final Thread getOwner() {        
         return getState() == 0 ? null : getExclusiveOwnerThread();
     }
     // 返回状态
     final int getHoldCount() {            
         return isHeldExclusively() ? getState() : 0;
     }
 
     // 资源是否被占用
     final boolean isLocked() {        
         return getState() != 0;
     }
 
     // 自定义反序列化逻辑
     private void readObject(java.io.ObjectInputStream s)
         throws java.io.IOException, ClassNotFoundException {
         s.defaultReadObject();
         setState(0); // reset to unlocked state
     }
 }

NonfairSync 类继承了 Sync类,表示采用非公平策略获取锁,其实现了 Sync类中抽象的 lock方法,源码如下:从 lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。Acquire方法是 FairSync和 UnfairSync的父类 AQS中的核心方法。

 // 非公平锁
 static final class NonfairSync extends Sync {
     // 版本号
     private static final long serialVersionUID = 7316153563782823691L;
 
     // 获得锁
     final void lock() {
         /**
          * 若通过CAS设置变量State(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占线程。
          * 若通过CAS设置变量State(同步状态)失败,也就是获取锁失败,则进入Acquire方法进行后续处理。
          */
         if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
             // 把当前线程设置独占了锁
             setExclusiveOwnerThread(Thread.currentThread());
         else // 锁已经被占用,或者set失败
             // 以独占模式获取对象,忽略中断
             acquire(1); //Acquire方法是FairSync和UnfairSync的父类AQS中的核心方法。
     }
 
     protected final boolean tryAcquire(int acquires) {
         return nonfairTryAcquire(acquires);
     }
 }

FairSync 类也继承了 Sync类,表示采用公平策略获取锁,其实现了 Sync类中的抽象 lock方法,源码如下:

 // 公平锁
 static final class FairSync extends Sync {
     // 版本序列化
     private static final long serialVersionUID = -3000897897090466540L;
 
     final void lock() {
         // 以独占模式获取对象,忽略中断
         acquire(1);
     }
 
     // 尝试公平获取锁
     protected final boolean tryAcquire(int acquires) {
         // 获取当前线程
         final Thread current = Thread.currentThread();
         // 获取状态
         int c = getState();
         if (c == 0) { // 状态为0
             if (!hasQueuedPredecessors() &&
                 compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
                 // 设置当前线程独占
                 setExclusiveOwnerThread(current);
                 return true;
             }
         }
         else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
             // 下一个状态
             int nextc = c + acquires;
             if (nextc < 0) // 超过了int的表示范围
                 throw new Error("Maximum lock count exceeded");
             // 设置状态
             setState(nextc);
             return true;
         }
         return false;
     }
 }

跟踪 lock方法的源码可知,当资源空闲时,它总是会先判断 sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。其中,FairSync 类的 lock的方法调用如下,只给出了主要的方法。
Java并发编程学习笔记_第49张图片
可以看出只要资源被其他线程占用,该线程就会添加到 sync queue中的尾部,而不会先尝试获取资源。这也是和 Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

可以看出只要资源被其他线程占用,该线程就会添加到 sync queue中的尾部,而不会先尝试获取资源。这也是和 Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

Java并发编程学习笔记_第50张图片
加锁:

  • 通过ReentrantLock的加锁方法Lock进行加锁操作。

  • 会调用到内部类Sync的Lock方法,由于Sync#lock是抽象方法,根据ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,本质上都会执行AQS的Acquire方法。

  • AQS的Acquire方法会执行tryAcquire方法,但是由于tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。

  • tryAcquire是获取锁逻辑,获取失败后,会执行框架 AQS的后续逻辑,跟ReentrantLock自定义同步器无关。
    解锁:

  • 通过 ReentrantLock的解锁方法 Unlock进行解锁。

  • Unlock会调用内部类 Sync的 Release方法,该方法继承于AQS。

  • Release中会调用 tryRelease方法,tryRelease需要自定义同步器实现,tryRelease只在ReentrantLock中的Sync实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。

  • 释放成功后,所有处理由AQS框架完成,与自定义同步器无关。

通过上面的描述,大概可以总结出 ReentrantLock加锁解锁时 API层核心方法的映射关系。
Java并发编程学习笔记_第51张图片

类的属性

ReentrantLock 类的 sync非常重要,对ReentrantLock 类的操作大部分都直接转化为对 sync和 AQS类的操作。

public class ReentrantLock implements Lock, java.io.Serializable {
    // 序列号
    private static final long serialVersionUID = 7373984872572414699L;    
    // 同步队列
    private final Sync sync;
}

类的构造函数

ReentrantLock 构造函数:默认是采用的非公平策略获取锁

public ReentrantLock() {
    // 默认非公平策略
    sync = new NonfairSync();
}

ReentrantLock(boolean) 构造函数:可以传递参数确定采用公平策略或者是非公平策略,参数为 true表示公平策略,否则,采用非公平策略。

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

核心函数分析

通过分析 ReentrantLock的源码,可知对其操作都转化为对 Sync对象的操作,由于 Sync继承了 AQS,所以基本上都可以转化为对 AQS的操作。如将 ReentrantLock的 lock函数转化为对 Sync的 lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到 Sync的不同子类。所以可知,在 ReentrantLock的背后,是 AQS对其服务提供了支持。下面还是通过例子来更进一步分析源码。

线程加入等待队列

加入队列的时机

当执行Acquire(1)时,会通过tryAcquire获取锁。在这种情况下,如果获取锁失败,就会调用 addWaiter加入到等待队列中去。

如何加入队列

获取锁失败后,会执行 addWaiter(Node.EXCLUSIVE)加入等待队列,具体实现方法如下:

// java.util.concurrent.locks.AbstractQueuedSynchronizer
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}
private final boolean compareAndSetTail(Node expect, Node update) {
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

主要的流程如下:

  • 通过当前的线程和锁模式新建一个节点。

  • Pred指针指向尾节点Tail。

  • 将New中Node的Prev指针指向Pred。

  • 通过compareAndSetTail方法,完成尾节点的设置。这个方法主要是对 tailOffset和 Expect进行比较,如果 tailOffset的 Node和 Expect的 Node地址是相同的,那么设置 Tail的值为 Update的值。

 // java.util.concurrent.locks.AbstractQueuedSynchronizer
 static {
     try {
         stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
         headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
         tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
         waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("waitStatus"));
         nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("next"));
    } catch (Exception ex) { 
    throw new Error(ex); 
  }
}

从AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是 tail对应的偏移量,所以这个时候会将 new出来的 Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。

  • 如果 Pred指针是 Null(说明等待队列中没有元素),或者当前 Pred指针和 Tail指向的位置不同(说明被别的线程已经修改),就需要看一下 Enq的方法。
  // java.util.concurrent.locks.AbstractQueuedSynchronizer
  
  private Node enq(final Node node) {
      for (;;) {
          Node t = tail;
          if (t == null) { // Must initialize
              if (compareAndSetHead(new Node()))
                  tail = head;
          } else {
             node.prev = t;
             if (compareAndSetTail(t, node)) {
                 t.next = node;
                 return t;
             }
         }
     }
 }

如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。

总结一下,线程获取锁的时候,过程大体如下:

  • 当没有线程获取到锁时,线程1获取锁成功。

  • 线程2申请锁,但是锁被线程1占有。
    Java并发编程学习笔记_第52张图片

  • 如果再有线程要获取锁,依次在队列中往后排队即可。

回到上边的代码,hasQueuedPredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以争取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。

 // java.util.concurrent.locks.ReentrantLock
 
 public final boolean hasQueuedPredecessors() {
     // The correctness of this depends on head being initialized
     // before tail and on head.next being accurate if the current
     // thread is first in queue.
     Node t = tail; // Read fields in reverse initialization order
     Node h = head;
     Node s;
     return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
 }

看到这里,我们理解一下h != t && ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?

双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当h != t时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了Tail指向Head,没有将Head指向Tail,此时队列中有元素,需要返回True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。

 // java.util.concurrent.locks.AbstractQueuedSynchronizer#enq
 
 if (t == null) { // Must initialize
     if (compareAndSetHead(new Node()))
         tail = head;
 } else {
     node.prev = t;
     if (compareAndSetTail(t, node)) {
         t.next = node;
         return t;
     }
 }

节点入队不是原子操作,所以会出现短暂的head != tail,此时Tail指向最后一个节点,而且Tail指向Head。如果Head没有指向Tail(可见5、6、7行),这种情况下也需要将相关线程加入队列中。所以这块代码是为了解决极端情况下的并发问题。

等待队列中线程出队列时机

回到最初的源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer
public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

上文解释了addWaiter方法,这个方法其实就是把对应的线程以 Node的数据结构形式加入到双端队列里,返回的是一个包含该线程的Node。而这个 Node会作为参数,进入到 acquireQueued方法中。acquireQueued方法可以对排队中的线程进行“获锁”操作。总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。

下面我们从“何时出队列?”和“如何出队列?”两个方向来分析一下acquireQueued源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

final boolean acquireQueued(final Node node, int arg) {
    // 标记是否成功拿到资源
    boolean failed = true;
    try {
        // 标记等待过程中是否中断过
        boolean interrupted = false;
        // 开始自旋,要么获取锁,要么中断
        for (;;) {
            // 获取当前节点的前驱节点
            final Node p = node.predecessor();
            // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
            if (p == head && tryAcquire(arg)) {
                // 获取锁成功,头指针移动到当前node
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

注:setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

// java.util.concurrent.locks.AbstractQueuedSynchronizer

// 靠前驱节点判断当前线程是否应该被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 获取头结点的节点状态
    int ws = pred.waitStatus;
    // 说明头结点处于唤醒状态
    if (ws == Node.SIGNAL)
        return true; 
    // 通过枚举值我们知道waitStatus>0是取消状态
    if (ws > 0) {
        do {
            // 循环向前查找取消节点,把取消节点从队列中剔除
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 设置前任节点等待状态为SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

上述方法的流程图如下:
Java并发编程学习笔记_第53张图片
从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致CPU资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire流程):
Java并发编程学习笔记_第54张图片
从队列中释放节点的疑虑打消了,那么又有新问题了:

  • shouldParkAfterFailedAcquire中取消节点是怎么生成的呢?什么时候会把一个节点的waitStatus设置为-1?
  • 是在什么时间释放节点通知到被挂起的线程呢?

CANCELLED状态节点生成

acquireQueued方法中的 Finally代码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
    ...
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                ...
                failed = false;
        ...
            }
            ...
    } finally {
        if (failed)
            cancelAcquire(node);
        }
}

通过cancelAcquire方法,将Node的状态标记为CANCELLED。接下来,我们逐行来分析这个方法的原理:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private void cancelAcquire(Node node) {
  // 将无效节点过滤
    if (node == null)
        return;
  // 设置该节点不关联任何线程,也就是虚节点
    node.thread = null;
    Node pred = node.prev;
  // 通过前驱节点,跳过取消状态的node
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;
  // 获取过滤后的前驱节点的后继节点
    Node predNext = pred.next;
  // 把当前node的状态设置为CANCELLED
    node.waitStatus = Node.CANCELLED;
  // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点
  // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        int ws;
    // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功
    // 如果1和2中有一个为true,再判断当前节点的线程是否为null
    // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点
        if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
      // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点
            unparkSuccessor(node);
        }
        node.next = node; // help GC
    }
}

当前的流程:

  • 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。
  • 根据当前节点的位置,考虑以下三种情况:

(1) 当前节点是尾节点。

(2) 当前节点是Head的后继节点。

(3) 当前节点不是Head的后继节点,也不是尾节点。

根据上述第二条,我们来分析每一种情况的流程。

当前节点是尾节点。
Java并发编程学习笔记_第55张图片

当前节点是Head的后继节点。
Java并发编程学习笔记_第56张图片
当前节点不是Head的后继节点,也不是尾节点。
Java并发编程学习笔记_第57张图片
通过上面的流程,我们对于CANCELLED节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对Next指针进行了操作,而没有对Prev指针进行操作呢?什么情况下会对Prev指针进行操作?

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。 shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

do {
    node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);

如何解锁

我们已经剖析了加锁过程中的基本流程,接下来再对解锁的基本流程进行分析。由于 ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:

// java.util.concurrent.locks.ReentrantLock

public void unlock() {
    sync.release(1);
}

可以看到,本质释放锁的地方,是通过框架来完成的。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

在ReentrantLock里面的公平锁和非公平锁的父类Sync定义了可重入锁的释放锁机制。

// java.util.concurrent.locks.ReentrantLock.Sync

// 方法返回当前锁是不是没有被线程持有
protected final boolean tryRelease(int releases) {
    // 减少可重入次数
    int c = getState() - releases;
    // 当前线程不是持有锁的线程,抛出异常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

我们来解释下述源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

public final boolean release(int arg) {
    // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有
    if (tryRelease(arg)) {
        // 获取头结点
        Node h = head;
        // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

这里的判断条件为什么是h != null && h.waitStatus != 0?

  • h == null Head还没初始化。初始情况下,head == null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。

  • h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。

  • h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。

再看一下 unparkSuccessor方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private void unparkSuccessor(Node node) {
    // 获取头结点waitStatus
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 获取当前节点的下一个节点
    Node s = node.next;
    // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点
    if (s == null || s.waitStatus > 0) {
        s = null;
        // 就从尾部节点开始找,到队首,找到队列第一个waitStatus<0的节点。
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // 如果当前节点的下个节点不为空,而且状态<=0,就把当前节点unpark
    if (s != null)
        LockSupport.unpark(s.thread);
}

为什么要从后往前找第一个非Cancelled的节点呢?原因如下。

之前的 addWaiter方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作Tail入队的原子操作,但是此时pred.next = node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node。

综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和CANCELLED节点产生过程中断开Next指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行acquireQueued方法以后,中断如何处理?

中断恢复后的执行流程

唤醒后,会执行 return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

再回到 acquireQueued代码,当 parkAndCheckInterrupt返回True或者False的时候,interrupted的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前 interrupted返回。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
            }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

如果acquireQueued为True,就会执行selfInterrupt方法。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于Java提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:

  • 当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为False),并记录下来,如果发现该线程被中断过,就再中断一次。
  • 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。

这里的处理方式主要是运用线程池中基本运作单元Worder中的runWorker,通过Thread.interrupted()进行额外的判断处理,感兴趣的同学可以看下ThreadPoolExecutor源码。

小结

Q:某个线程获取锁失败的后续流程是什么呢?

A:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。

Q:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

A:是CLH变体的FIFO双端队列。

Q:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?

A:可以详细看下2.3.1.3小节。

Q:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?

A:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见2.3.2小节。

Q:Lock函数通过Acquire方法进行加锁,但是具体是如何加锁的呢?

A:AQS的Acquire会调用tryAcquire方法,tryAcquire由各个自定义同步器实现,通过tryAcquire完成加锁过程。

AQS应用

ReentrantLock的可重入应用:ReentrantLock的可重入性是 AQS很好的应用之一,在了解完上述知识点以后,我们很容易得知ReentrantLock实现可重入的方法。在ReentrantLock里面,不管是公平锁还是非公平锁,都有一段逻辑。
公平锁:

// java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire

if (c == 0) {
    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
}
else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    if (nextc < 0)
        throw new Error("Maximum lock count exceeded");
    setState(nextc);
    return true;
}

非公平锁:

// java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire

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;
}

从上面这两段都可以看到,有一个同步状态State来控制整体可重入的情况。State是Volatile修饰的,用于保证一定的可见性和有序性。

// java.util.concurrent.locks.AbstractQueuedSynchronizer
private volatile int state;

接下来看State这个字段主要的过程:

  • State初始化的时候为0,表示没有任何线程持有锁。
  • 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。
  • 解锁也是对这个字段-1,一直到0,此线程对锁释放。

JUC中的应用场景:除了上边ReentrantLock的可重入性的应用,AQS作为并发编程的框架,为很多其他同步工具提供了良好的解决方案。下面列出了JUC中的几种同步工具,大体介绍一下AQS的应用场景:

同步工具 同步工具与AQS的关联
ReentrantLock 使用AQS保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。
Semaphore 使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。
CountDownLatch 使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。
ReentrantReadWriteLock 使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。
ThreadPoolExecutor Worker利用AQS同步状态实现对独占线程变量的设置(tryAcquire和tryRelease)。

自定义同步工具

了解 AQS基本原理以后,按照上面所说的 AQS知识点,自己实现一个同步工具。

public class LeeLock  {

    private static class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire (int arg) {
            return compareAndSetState(0, 1);
        }

        @Override
        protected boolean tryRelease (int arg) {
            setState(0);
            return true;
        }

        @Override
        protected boolean isHeldExclusively () {
            return getState() == 1;
        }
    }
    
    private Sync sync = new Sync();
    
    public void lock () {
        sync.acquire(1);
    }
    
    public void unlock () {
        sync.release(1);
    }
}

通过我们自己定义的Lock完成一定的同步功能。

public class LeeMain {

    static int count = 0;
    static LeeLock leeLock = new LeeLock();

    public static void main (String[] args) throws InterruptedException {

        Runnable runnable = new Runnable() {
            @Override
            public void run () {
                try {
                    leeLock.lock();
                    for (int i = 0; i < 10000; i++) {
                        count++;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    leeLock.unlock();
                }

            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

上述代码每次运行结果都会是20000。通过简单的几行代码就能实现同步功能,这就是AQS的强大之处。

你可能感兴趣的:(从头开始学java,知识点总结,java,java)