2.2 synchronized 关键字
synchronized 方法和 synchronized 同步块有什么区别呢?总体来说 synchronized 代码块是一种细粒度的并发控制,处于块之外的代码可以被多个线程并发访问。而如下代码本质上都是一样的,都是锁住当前对象:
1 | public void plus() { |
2.2.3 静态的同步方法
Class 没有公共的构造方法,Class 对象是在类加载的时候由 Java 虚拟机调用类加载器中的 defineClass 方法自动构造的,因此不能显式地声明一个 Class 对象。
普通的 synchronized 实例方法,其同步锁是当前对象 this 的监视锁,如果某个 synchronized 方法是static 方法,其同步锁又是什么呢?答案是:类对应的 Class 对象的监视锁。
在代码执行完毕或者程序出现异常,synchronized 持有的监视锁都会正常释放,所以无需手动释放。
2.4 Java对象结构与内置锁
Java 内置锁很多重要信息都存放在对象结构中。
2.4.1 Java 对象结构
Java 对象结构包括三部分:
- 对象头:包括3个字段:Mark Word(存储GC标记位、锁状态)、类对象指针(存放方法区Class对象的地址,能确定该对象是哪个类的实例)、Array Length(如果对象是Java 数组,那么就是数组长度;如果不是数组,就不存在这字段)
- 对象体:包括成员属性,包括父类的成员属性
- 对齐字节:填充对齐,用来保证对象所占内存字节数为8的倍数
2.4.2 Mark Word 的结构信息
从Mark Word 锁标志位的状态来看,内置锁的状态就有了 4 种: 无锁、偏向锁、轻量级锁、重量级锁,这4种状态会随着竞争的激烈程度逐渐升级,并且是不可逆的过程,即不可降级。
2.4.3 使用 JOL 工具查看对象的布局
知道有 JOL 工具即可,略。
2.4.5 无锁、偏向锁、轻量级锁和重量级锁
JDK 1.6 以前,所有内置锁都是重量级锁,所以会在用户态和核心态之间频繁切换,所以代价很高。后续引入了 偏向锁和 轻量级锁,所以一共就有 4 种状态:无锁、偏向锁、轻量级锁、重量级锁。内置锁可以升级但是不能降级。
2.5 偏向锁的原理与实战
原理:如果不存在线程竞争,那么线程获得锁之后就进入偏向状态:偏向锁标志位为 1,锁状态为 01。以后该线程获取锁时判断一下线程 ID 和标志位,就可以直接进入同步块,连 CAS 都不需要,从而提升性能。
但是,一旦有第二条线程需要竞争锁,偏向模式就立即结束,进入轻量级锁状态。这里需要好好理解下,感觉这句话不一定对,书中更准确的表述是:线程获取锁时,判断该偏向状态的锁的 ID 是不是自己的,如果是自己的,则直接进入同步块;否则,采用 CAS 操作将 Mark Word 中的偏向锁 ID 换成自己的,如果 CAS 操作成功,就获取偏向锁成功,执行同步块代码;如果 CAS 操作不成功,表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。。
偏向锁的撤销
- 在一个安全点停止拥有锁的线程
- 遍历线程栈帧,找到并删除栈帧,使其变为无锁状态,修复锁指向的 Mark Word ,并清除锁 Mark Word 中的线程 ID
- 将当前锁升级为轻量级锁
- 唤醒当前线程
2.6 轻量级锁的原理与实战
轻量级锁是一种自旋锁,希望在应用层面通过自旋解决线程同步问题。轻量级锁的执行过程:
抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM 首先将在抢锁线程的栈帧中创建一个锁记录(Lock Record),用于存储对象目前的 Mark Word 的拷贝。
然后抢锁线程将使用 CAS 自旋操作,尝试将内置锁对象头的 Mark Word 的ptr_to_lock_record(锁记录指针)更新为抢锁线程栈帧中拷贝的 Mark Word ,如果这个更新执行成功,线程就拥有了这个对象锁,之后会改掉 Mark Word 中的lock 标记为 00,即轻量级锁。
为什么要拷贝呢?因为内置锁对象的 Mark Word 结构会有所变化,而不再存着无锁状态下的一些信息,所以要拷贝。
2.6.3 轻量级锁的分类
轻量级锁分为 2 种:
- 普通自旋锁: 抢锁线程一直在自旋,而不是被阻塞,直到占有锁的线程释放之后抢锁线程才能获取到锁
- 自适应自旋锁:自旋次数不是固定的,而是根据系统以前的经验来的。解决的是锁竞争时间不确定的问题。
2.7 重量级锁的原理与实战
在 JVM 中,每个对象都关联一个监视器,这里的对象包括 Object 实例和 Class 实例。监视器是一个同步工具,相当于一个许可证:拿到许可证的线程可以进入临界区执行,没有拿到的则需要阻塞等待。
2.7.1 重量级锁的核心原理
HotSpot 虚拟机中,监视器是由 C++ 类 ObjectMonitor 实现的,它有以下几个比较关键的属性:
Owner、WaitSet、Cxq、EntryList ,其中 Owner 所指向的线程为获得锁的线程,WaitSet、Cxq、EntryList 是 3 个队列,用于存放抢夺重量级锁的线程:
- Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中(不是真正的队列,只是由Node及其 next 指针逻辑构成,每次都通过 CAS 操作在头部新增节点,取元素从尾获取,因为只有 Owner 线程才能从队尾获取节点,所以,Cxq 出队无争用操作,是无锁结构)
- EntryList: Cxq 中那些有资格成为候选资源的线程被移动到 EntryList。Cxq 会被线程并发访问,为了降低对 Cxq 的争用而建立了 EntryList。在 Owner 线程释放锁时,JVM 会从 Cxq 中迁移线程到 EntryList,并会指定 EntryList 中的某个线程(一般为 Head) 为 Ready Thread。 EntryList 作为候选竞争线程而存在(自己加的:但由于是非公平锁,所以这个 Ready Thread 不一定能得以执行,后续的说明非公平性会提及)。
- WaitSet: 某个拥有锁的线程在调用 Object.wait() 方法之后将被阻塞,然后线程将被放置在 WaitSet 链表中。等到执行 Object.notify/notifyAll 唤醒之后,该线程又会回到 EntryList 中(注意不是 Cxq 中)。
Synchronized 的不公平性:在线程进入 Cxq 前,抢锁线程会先尝试通过 CAS 自旋获取锁,如果获取到就直接用了;获取不到,才进入 Cxq 队列,这对于已经进入 Cxq 队列的线程是不公平的。但是这由于避免了 Cxq 队列中线程唤醒——内核态到用户态的过程,节省了时间,提升了吞吐率
2.7.2 重量级锁开销
处于 Cxq、EntryList 以及 WaitSet 中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,需要通过系统调用实现,进城需要从用户态切换到内核态,这种切换需要消耗很多时间,有可能比用户执行代码的时间还要长。
由于轻量级锁使用 CAS 进行自旋抢锁,而 CAS 操作都处于用户态下,不存在用户态和内核态的切换,因此轻量级锁的开销比较小。
2.8 偏向锁、轻量级锁与重量级锁的对比
总结一下,synchronized 的执行过程大致如下:
- 线程抢锁时,JVM 首先检测内置锁对象 Mark Word 的biased_lock(偏向锁标识)是否为1,lock (锁标志位)是否为01,如果都满足,说明内置锁对象为可偏向状态
- 如果内置锁对象为可偏向状态,JVM 检查 Mark Word 中线程 ID 是否为当前抢锁线程的 ID,如果是,标识抢锁线程处于偏向所状态,快速获得锁,开始执行临界区代码
- 如果Mark Word 中的线程 ID 不是当前抢锁线程,就通过 CAS 竞争锁。如果竞争成功,就将 Mark Word 中的线程 ID 设置为抢锁线程的 ID ,偏向锁标志设为 1 ,锁标志位设为 01,此时内置锁对象处于偏向锁状态,然后开始执行临界区代码
- 如果 CAS 竞争失败,说明发生了竞争,撤销偏向锁,进而升级为轻量级锁
- JVM 使用 CAS 将锁对象的 Mark Word 替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁;如果替换失败,就表示其他线程在竞争锁。那么 JVM 尝试使用 CAS 自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依旧处于轻量级锁状态。
- 如果JVM的CAS 替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态
3种锁的优缺点对比和适用场景如下表所示:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加解锁不需要额外消耗,和执行非同步方法仅存在纳秒级差距 | 如果线程间存在锁竞争,会带来额外的撤销锁操作 | 适用于只有一个线程访问的临界区场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 抢不到锁的竞争线程会CAS自旋,消耗CPU | 锁占用时间短,吞吐量低 |
重量级锁 | 线程竞争无需自旋,不消耗CPU | 线程阻塞,响应时间慢 | 锁占用时间长,吞吐量高 |
2.9 线程间通信
多个线程共同操作共享的资源时,线程间通过某种方法互相告知自己的状态,以避免无效的资源争夺。线程间通信的方式可以有很多种:等待-通知、共享内存、管道流。
2.9.2 低效的线程轮询
轮询版本的生产者-消费者模型中,消费者每一轮消费,无论数据区是否为空,都需要进行数据区的询问和判断:
1 | public synchronized IGoods get() throws Exception { |
当数据区为空(amount <= 0)时,消费者无法取出数据,但是仍然做无用的询问工作,浪费了CPU的时间片。同理,对于生产者也会存在这样的问题:
1 | public synchronized void add(T element) throws Exception { |
当数据区满时,生产者无法加入数据,这时执行add方法也浪费CPU的时间片。使用“等待-通知”方式进行生产者与消费者之间的线程通信可以避免这种浪费。
具体方法是:当数据区满时,给让生产者等待,当可以添加数据时,给生产者发通知,让生产者唤醒;消费者同理。具体操作为:消费者取出一个数据后,由消费者去唤醒等待的生产者;生产者加入一个数据后,由生产者唤醒等待的消费者。
2.9.3 wait 、notify 方法的原理
wait 方法
对象的 wait 方法作用就是让当前线程阻塞并等待被唤醒,wait 方法与对象监视器密切相关,使用时一定要放在同步块中:
1 | synchronized(locko) { |
其原理大致如下:
线程调用了 locko 的wait 方法后,JVM 会将当前线程假如 locko 监视器的 WaitSet(等待集) 中,等待被其他线程唤醒
当前线程会释放 locko 对象监视器 的 Owner 权利,让其他线程可以抢夺 locko 对象的监视器
让当前线程等待,其状态变为 WAITING
notify 方法
notify 方法也需要放在同步块中执行,它有2个版本:
notify : 唤醒 locko 监视器等待集中的第一条等待线程,被唤醒的线程进入 EntryList ,状态从 WAITING 变为 BLOCKED
notifyAll: 唤醒 locko 监视器等待集中全部等待线程,所有线程进入 EntryList ,状态从 WAITING 变为 BLOCKED
notify 核心原理如下:
当线程调用了 locko 的 notify 方法后,JVM 会唤醒 locko 监视器等待集中的第一条等待线程(如果是 notifyAll 则是所有线程),被唤醒的线程进入 EntryList ,状态从 WAITING 变为 BLOCKED,具备了排队抢夺监视器 Owner权利的资格
EntryList 中的线程抢夺到监视器的 Owner 权利后,线程的状态从 BLOCKED 变成 RUNNABLE,具备重新执行的资格
2.9.5 生产者-消费者之间的线程间通信
此实现版本大致需要定义以下3个同步对象:
LOCK_OBJECT:用于临界区同步,临界区资源为数据缓冲区的 dataList 变量和 amount 变量
NOT_FULL:用于数据缓冲区的未满条件等待和通知,生产者在添加元素时需要判定是否已满,如果已满,则进入 NOT_FULL 的同步去等待,只要消费者耗费一个元素,就会通过 NOT_FULL 发送通知。
NOT_EMPTY:同理,这是用于数据缓冲区的非空条件的等待和通知。消费者在消费前需要判断数据区是否空,如果是,消费者就进入 NOT_EMPTY 的同步区等待被通知,只要生产者添加一个元素,生产者就会通过 NOT_EMPTY 发送通知
代码如下:
1 | public class CommunicatePetStore { |
2.9.6 需要在synchronized 同步块的内部使用 wait 和notify
调用 wait 和 notify 方法时,“当前线程”必须拥有该对象的同步锁,也即wait 和notiry 方法必须在同步块中使用,否则JVM 就会抛出 IllegalMonitorStateException 异常。
这是为什么呢?还得从这 2 个方法的原理说起:
调用 wait :JVM 会释放当前线程的对象监视器的 Owner 资格,还会将当前线程移入监视器的 WaitSet 队列,这些操作都是和对象监视器锁相关的,所以,当前线程执行 wait 方法前,必须通过 synchronized 方法称为对象锁的 Owner,要在同步块内调用
同理, 调用 notify 时,JVM 从对象锁的监视器 WaitSet 队列移动线程到其 EntryList 队列,这些操作都与对象锁的监视器有关,所以,也必须先成为对象锁监视器的 Owner,然后在同步块内调用
2.9.7 调用wait、notify方法进行线程间通信的要点(自己加的章节)
有了以上的知识储备,来说下wait 和 notify 方法进行线程间通信的要点:
调用某个同步对象 locko 的 wait 和 notify 类型方法前,必须要获得这个锁对象的监视器锁,这2个类型的方法必须放在同步块中执行,否则报错
调用wait方法是使用while进行条件判断,如果是在某种条件下进行等待,对条件的判断就不能使用if语句做一次性判断,而是使用while 循环进行反复判断,只有这样才能在线程被唤醒后继续检查wait 条件,并在条件没有满足的情况下继续等待。
正确的条件判断代码:
1 | //消费者获取元素 |
错误地使用 if 条件判断:
1 | //消费者获取元素 |
至于为什么要这样,从之前说的原理我们知道,wait 方法会释放锁。我们考虑这么一种场景:
假如有 2 个消费者 consumerOne 和 consumerTwo
consumerOne 在判定是空的时候,wait 了,这时候会释放锁;由于释放了锁,consumerTwo 自然就能获取到这个锁,然后发现也是空的,自然也 wait 了
也就是说 consumerOne 和 consumerTwo 都在wait 等待了,这是问题关键
此时,生产者放入一个元素,完了调用 notifyAll ,consumerOne 和 consumerTwo 都被唤醒了,他们会竞争锁
假如 consumerOne 拿到锁了,consumerTwo 还在锁池中继续阻塞,consumerOne 执行wait 后面的代码消费了,接着又会变为空
consumerOne 执行完成后,consumerTwo 拿到锁也接着执行 wait 后面的代码,由于被 consumerOne 消费变为空了之后,consumerTwo 后续的执行以不空作为条件的执行会出现问题
如果不太明白,还可以参考为什么生产者消费者中模式中要用while作临界判断?_xuwen_chen的博客-CSDN博客