Java基础回顾系列(2)_ 锁

192 阅读17分钟

总有人要赢,为什么不可以是我?(Somebody has to win,so why not be me?) —— 科比·布莱恩特

changelog

  • 2020-08-23:完成初稿
  • 2020-09-01:更新synchronized和Lock部分
  • 2020-09-02:更新synchronized锁升级

目录

  • 理解synchronized
  • Java内存模型
  • ReentrantLock
  • volatile
  • 自旋锁、偏向锁、重量级锁

Java锁机制

Q:synchronized实现原理

被synchronized修饰的被编译为字节码后,在方法的 flags 属性中会被标记为 ACC_SYNCHRONIZED 标志,当虚拟机访问一个被标记为 ACC_SYNCHRONIZED 的方法时,会自动在方法的开始和结束(或异常)位置添加 monitorenter 和 monitorexit 指令。

关于moniterenter和monitorexit,可以理解为一把具体的锁。这个锁里面保存着两个重要属性:计数器和指针。

问题又来了,这把锁放在哪里?

Q:synchronized的锁放在哪里?

我们知道,Java类被编译后,通过JVM虚拟机加载到内存中。 Java对象在内存中的存储分为三块:

  • 对象头 存储区域
  • 实例数据 存储区域
  • 对齐填充 存储区域

各个部分的存储内容如下:

我们可以看到,在对象头Mark Word中会保存有关锁的信息。 所以,我们得到答案是:synchronized的锁存在的对象头的Markword下。

问题又来了,这把锁是如何实现同一时间,只允许一个线程运行同步代码块的? 也就是,互斥机制怎么实现的。

Q:synchronized互斥机制如何实现?

要解答这个问题,我们来看一下Mark Word 的默认存储结构: 默认情况下,没有线程进行加锁操作,所以锁对象中的 Mark Word 处于无锁状态。但是考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 Mark Word 默认存储结构外,还有如下可能变化的结构: 在 Java 6 之前,并没有轻量级锁和偏向锁,只有重量级锁,也就是通常所说 synchronized 的对象锁,锁标志位为 10。从图中的描述可以看出:当锁是重量级锁时,对象头中 Mark Word 会用 30 bit 来指向一个“互斥量”,而这个互斥量就是 Monitor。

Monitor 可以把它理解为一个同步工具,也可以描述为一种同步机制。实际上,它是一个保存在对象头中的一个对象。在 markOop 中有如下代码:

通过 monitor() 方法创建一个 ObjectMonitor 对象,而 ObjectMonitor 就是 Java 虚拟机中的 Monitor 的具体实现。因此 Java 中每个对象都会有一个对应的 ObjectMonitor 对象,这也是 Java 中所有的 Object 都可以作为锁对象的原因。

那 ObjectMonitor 是如何实现同步机制的呢?

其实从这个变量,我们也可以看出 同步机制的实现原理是:当一个线程通过竞争获取到锁后, monitor会把_owner变量设置为当前线程,同时会设置 _ count = 1,即获得对象锁。

当持有锁的线程调用wait()方法,会释放当前持有的monitor,_owner置为空,count减1,同时该线程进入 _WaitSet 集合中等待被唤醒。

总结:当synchroinzed是重量级锁时,它的互斥机制是通过ObjectMonitor,ObjectMonitor是一个对象,ObjectMonitor中会记录当前锁的持有线程,从而保证了同一时间,只有一个线程持有锁。当线程获取不到锁时,会进入锁的等待队列,等待被唤醒。

Q:谈谈 轻量级锁、偏向锁和重量级锁的区别?

从Java6开始,虚拟机对 synchronized 关键字做了多方面的优化,主要目的就是,避免 ObjectMonitor 的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率 。其中的几个优化是:锁自旋、轻量级锁、偏向锁。

整个锁优化的过程是:

  • 偏向锁:偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源。偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,无需再进入 Monitor 去竞争对象了。
  • 轻量级锁:轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

总结:偏向锁和轻量级锁都是通过自旋等技术避免真正的加锁,而重量级锁才是获取锁和释放锁,重量级锁通过对象内部的监视器(ObjectMonitor)实现,其本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本非常高。

知识来源:1、既生 Synchronized,何生 ReentrantLock 2、Synchronized解析——如果你愿意一层一层剥开我的心 3、12 | 多线程之锁优化(上):深入了解Synchronized同步锁的优化方法

Q: 谈谈自旋锁的理解?

从三个方面来谈:

  • 自旋锁的设计思路
  • 自旋锁出现的原因
  • 自旋锁在Java中的应用

自旋锁的设计思路:

自旋锁的操作,你可以这么理解:

while (抢锁(lock) == 没抢到) {
}

也就是只要没有锁上,就不断尝试。显然,这个操作会浪费CPU。

这里要注意的是,自旋锁并不是指特定的锁,而是一种设计思想。

自旋锁出现的原因:

那既然自旋锁会浪费CPU,那么为什么还要这么设计呢?

自旋锁的设计是基于这样一种场景:如果当一个线程尝试去获取一把锁,这个时候它获取失败了,因此按照常规做法,它要去沉睡了,等到锁状态改变了,再进行唤醒。这个过程涉及到上下文的切换。如果那把锁很快就被释放了,那么上下文切换的操作就可能比执行这段同步代码的开销还要大。

考虑到这种情况,这个时候,就可以让线程一直自旋,等待那把锁进行释放,这样开销是最小的。

顺便说一下,这种没有获取到锁就去沉睡,等到锁状态发生再唤醒的设计,称为互斥锁,用代码表示如下:

while (抢锁(lock) == 没抢到) {
  先去睡,等待唤醒(lock);
}

这里提到”沉睡“,“唤醒”,那就顺便复习下线程的状态好了~

Q:谈谈线程的状态?

Q:线程的挂起、休眠、阻塞和非阻塞区别?

  • 挂起:当线程挂起时,会失去CPU的使用时间,直到被其他线程所唤醒

  • 休眠:同样是会失去CPU的使用时间,但是在过了指定的休眠时间之后,它会自动激活,无需唤醒(整个唤醒表面看是自动的,但实际上也得有守护线程去唤醒,只是不需编程者手动干预)。

  • 阻塞(Block):在线程执行时,所需要的资源不能得到,则线程被挂起,直到满足可操作的条件。

  • 非阻塞(Block):在线程执行时,所需要的资源不能得到,则线程不是被挂起等待,而是继续执行其余事情,待条件满足了之后,收到了通知(同样是守护线程去做)再执行。

Q:谈谈对Synchronized关键字涉及到的类锁,方法锁,重入锁的理解?

有了上面的理解,这个问题不难回答。

synchronized修饰静态方法获取的是类锁(类的字节码文件对象);

synchronized修饰普通方法或代码块获取的是对象锁。

因为锁的持有者是“线程”,而不是“调用”。所以,当已经拥有锁的时候,是可以直接进入,不需要重新获取锁。

Q:为什么重量级锁竞争线程需要从用户态到内核态转变呢?

这就需要知道用户态和内核态区别。 0de1e5f546d2c6625f43e1836447cd10.png

synchronized在升级到重量级别锁后,用户态切换到内核态主要原因:

  • 为了防止得不到资源的线程空转消耗CPU等资源,需要阻塞或唤醒竞争的线程,只有从用户态切换到内核态才能执行阻塞和唤醒线程操作。

Q:wait、sleep的区别和notify运行过程。

  • 最大的不同是在等待时 wait 会释放锁,而 sleep 一直持有锁。wait 通常被用于线程间交互,sleep 通常被用于暂停执行。(当调用wait()方法的时候,该线程会进入到ObjectMonitor的_WaitSet集合中,上面提到过,因此会让出锁)
  • sleep是Thread类的方法,wait是Object类中定义的方法

当线程A调用 wait()后,线程A让出锁,自己进入等待状态,同时加入锁对象的等待队列。 线程B(生产者)获取锁后,调用notify方法通知锁对象的等待队列,使得线程A从等待队列进入阻塞队列。 线程A进入阻塞队列后,直至线程B释放锁后,线程A竞争得到锁继续从wait()方法后执行。

Q:谈谈volatile的原理

1、volatile是什么?

volatile是一个关键字,是Java虚拟机提供的最轻量级同步机制。可以用来修饰变量。

2、volatile可以做到什么?

这其实也就是volatile的语义:

  • 保证被volatile修饰的变量对所有线程都是可见的
  • 禁止进行指令重排序

3、如何做到保证可见性和禁止重排序的?

这两个的实现依赖于内存屏障,什么是内存屏障呢?

内存屏障其实指的就是4条指令,它们告知操作系统,

  • 重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 将本处理器的缓存写入内存
  • 如果是写入动作,会导致其他处理器中对应的缓存无效

显然,第2、3点就可以可见性,第1点就可以达到禁止重排序。

所以,volatile 整体的工作流程就是:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore障。

知识来源:1、Java程序员面试必备:Volatile全方位解析,至于这四条指令什么意思,可以去看这里推荐的文章

4、volatile的应用场景?

volatile的典型应用场景就是:状态标志,以及DCL单例模式

5、volatile和synchronized的区别?

  • volatile修饰的是变量,synchronized一般修饰代码块或者方法
  • volatile保证可见性、禁止指令重排,但是不保证原子性;synchronized可以保证原子性
  • volatile不会造成线程阻塞,synchronized可能会造成线程的阻塞,所以后面才有锁优化那么多故事~

Q:Java内存模型

Java内存模型是什么?

Java内存模型本质上是一套共享内存系统中多线程读写操作行为的规范,它所描述的是多线程并发、CPU 缓存等方面的内容,是一种抽象描述,并不是实际存在

举个栗子:

以上是我们常见到的JMM模型,有小伙伴可能会认为这里的工作内存 = 虚拟机栈,因为是线程私有的。但是这个理解是错的,在Java线程中并不存在工作内存这个区域,它只是对 CPU 寄存器和高速缓存的抽象描述。

我们在谈论volatile的时候,经常谈到工作内存、主内存,这些就是JMM里的用语,正是有了JMM模型,让我们可以从具体实现中脱离出来,根据抽象模型寻找解决思路。

Q:ReentrantLock的内部实现?

既然是一把锁,那么最重要的了解它是如何实现加锁和解锁的。 看代码:

可以看到,ReentrantLock是调用了内部类Synclock方法实现的加锁。

Sync 在 ReentrantLock 有两种实现:NonfairSync 和 FairSync,分别对应非公平锁和公平锁。以非公平锁为例,实现源码如下:

这段代码的实现逻辑是:

  • 通过CAS设置变量State,如果成功,表示获取锁成功,那就将当前线程设置为独占线程
  • 如果不成功,表示当前锁正被其他线程持有,调用acquire()进行后续处理

acquire()方法的实现如下:

具体逻辑就是:

  • 再次尝试获取锁
  • 失败了,那么就放入等待队列中
  • acquireQueued处理加入到队列中的节点,通过自旋去尝试获取锁,根据情况将线程挂起或者取消

锁的意义就是使竞争到锁对象的线程执行同步代码,多个线程竞争锁时,竞争失败的线程需要被阻塞等待唤醒。那么ReentrantLock是如何实现让线程等待并唤醒的呢? 这个是我们看代码的重点。

我们看到,如果获取锁失败,会被加入到等待队列中,那么,我们要思考的是:

  • 如何入队列?
  • 如何进入阻塞状态的?我们知道此时来争夺锁的线程是运行状态
  • 何时被唤醒?是一次性唤醒全部,还是一个一个唤醒?

一个一个来。

Q:如何入队列?

这里的Node是一个双向链表,所以入队列的过程还是很好理解的。

Q : 如何进入阻塞状态的?

我们从刚刚的acquireQueued入手:

shouldParkAfterFailedAcquire 方法中会判读当前线程是否应该被挂起,其代码如下:

这里省略了一大段waitStatus值的意义,大家可以看我的推荐文补上这块知识~

如果 shouldParkAfterFailedAcquire 返回 true 表示线程需要被挂起,那么会继续调用 parkAndCheckInterrupt 方法执行真正的阻塞线程代码,具体如下:

可以知道,最后就是调用了LockSupportpark()方法实现将线程挂起。

总结一下上面两个部分:

  • 如果获取锁失败,会放入到等待队列中
  • 等待队列是一个有Node节点连接而成的双向链表
  • 入队列后,会再进行一次尝试获取锁,如果失败,那表示要进入挂起状态
  • 挂起状态是通过LockSupport.park()方法实现

好了,剩下最后一个问题。

Q:何时被唤醒?是一次性唤醒全部,还是一个一个唤醒?

我们要从unlock()方法开始:

说明: 首先获取当前节点(实际上传入的是 head 节点)的状态,如果 head 节点的下一个节点是 null,或者下一个节点的状态为 CANCEL,则从等待队列的尾部开始遍历,直到寻找第一个 waitStatus 小于 0 的节点。

如果最终遍历到的节点不为 null,再调用 LockSupport.unpark 方法,调用底层方法唤醒线程。 至此,线程被唤醒的时机也分析完毕。

为什么要小于0呢?

这跟wait status表示的状态有关: 大于0是终结态。

知识来源:深入理解 AQS 和 CAS 原理

现在,总结一下,ReentrantLock是如何实现的呢?

  • 先聊AQS
    • ReentrantLock的实现是基于AQS的,AQS是Java并发包中众多同步组件的构建基础,它通过一个int类型的状态变量state和一个FIFO队列来完成共享资源的获取,线程的排队等待等
  • 介绍ReentrantLock总体框架
    • ReentrantLock内部包含三个静态内部类:Sync,NonFairSync,FairSync。Sync作为ReentrantLock中公用的同步组件,继承了AQS(要利用AQS复杂的顶层逻辑嘛,线程排队,阻塞,唤醒等等);NonFairSync和FairSync则都继承Sync,调用Sync的公用逻辑,然后再在各自内部完成自己特定的逻辑(公平或非公平)。
  • 介绍加锁的设计思路
    • 以非公平锁为例,ReentrantLock获取锁的逻辑是:先尝试获取锁(也就是判断state是否>0),如果获取成功,说明state = 0,那么通过CAS 设置 state + 1(不是 =1,因为是可重入的),并且设置当前锁的持有线程为自己;
    • 如果获取失败,那么进入到等待队列中,并通过LockSupport.park()方法执行挂起
  • 介绍释放锁的设计逻辑
    • 以非公平锁为例,若state值为0,表示当前线程已完全释放干净,返回true,上层的AQS会意识到资源已空出。若不为0,则表示线程还占有资源,只不过将此次重入的资源的释放了而已,返回false
    • 当锁释放后,会去等待队列中调用LockSupport.unpark()方法唤醒一个线程

Q:说说CAS?

CAS 全称是 Compare And Swap,译为比较和替换,是一种通过硬件实现并发安全的常用技术,底层通过利用 CPU 的 CAS 指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

它的实现过程主要有 3 个操作数:内存值 V,旧的预期值 E,要修改的新值 U,当且仅当预期值 E和内存值 V 相同时,才将内存值 V 修改为 U,否则什么都不做。

CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。 这是一种乐观策略,认为并发操作并不总会发生。

我们在刚刚的ReentrantLock分析中看到多次出现compareAndSetXXX。这种操作最终会调用 Unsafe 中的 API 进行 CAS 操作。

所以,我们可以说,ReentrantLock的实现是基于CAS的,这也是它跟synchronized的区别之一。

扩展:CAS存在一个ABA问题,解决方案是用版本号去保证,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

Q: synchronized和ReentranLock的区别

synchronized和ReentantLock,两者一个是jvm层面的一个是jdk层面的,还是有很大的区别的。

Q:在安卓中,你在哪些实际场景中被多线程问题折磨过?

todo

参考文章