0%

第2章:Java内置锁的核心原理

2.2 synchronized 关键字

synchronized 方法和 synchronized 同步块有什么区别呢?总体来说 synchronized 代码块是一种细粒度的并发控制,处于块之外的代码可以被多个线程并发访问。而如下代码本质上都是一样的,都是锁住当前对象:

1
2
3
4
5
6
7
8
9
public void plus() {
synchronized(this){ //对方法内部全部代码进行保护
amount++;
}
}

public synchronized void plus() {
amount++;
}

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 操作不成功,表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。

偏向锁的撤销

  1. 在一个安全点停止拥有锁的线程
  2. 遍历线程栈帧,找到并删除栈帧,使其变为无锁状态,修复锁指向的 Mark Word ,并清除锁 Mark Word 中的线程 ID
  3. 将当前锁升级为轻量级锁
  4. 唤醒当前线程

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 的执行过程大致如下:

  1. 线程抢锁时,JVM 首先检测内置锁对象 Mark Word 的biased_lock(偏向锁标识)是否为1,lock (锁标志位)是否为01,如果都满足,说明内置锁对象为可偏向状态
  2. 如果内置锁对象为可偏向状态,JVM 检查 Mark Word 中线程 ID 是否为当前抢锁线程的 ID,如果是,标识抢锁线程处于偏向所状态,快速获得锁,开始执行临界区代码
  3. 如果Mark Word 中的线程 ID 不是当前抢锁线程,就通过 CAS 竞争锁。如果竞争成功,就将 Mark Word 中的线程 ID 设置为抢锁线程的 ID ,偏向锁标志设为 1 ,锁标志位设为 01,此时内置锁对象处于偏向锁状态,然后开始执行临界区代码
  4. 如果 CAS 竞争失败,说明发生了竞争,撤销偏向锁,进而升级为轻量级锁
  5. JVM 使用 CAS 将锁对象的 Mark Word 替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁;如果替换失败,就表示其他线程在竞争锁。那么 JVM 尝试使用 CAS 自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依旧处于轻量级锁状态。
  6. 如果JVM的CAS 替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态

3种锁的优缺点对比和适用场景如下表所示:

优点 缺点 适用场景
偏向锁 加解锁不需要额外消耗,和执行非同步方法仅存在纳秒级差距 如果线程间存在锁竞争,会带来额外的撤销锁操作 适用于只有一个线程访问的临界区场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 抢不到锁的竞争线程会CAS自旋,消耗CPU 锁占用时间短,吞吐量低
重量级锁 线程竞争无需自旋,不消耗CPU 线程阻塞,响应时间慢 锁占用时间长,吞吐量高

2.9 线程间通信

多个线程共同操作共享的资源时,线程间通过某种方法互相告知自己的状态,以避免无效的资源争夺。线程间通信的方式可以有很多种:等待-通知、共享内存、管道流。

2.9.2 低效的线程轮询

轮询版本的生产者-消费者模型中,消费者每一轮消费,无论数据区是否为空,都需要进行数据区的询问和判断:

1
2
3
4
5
6
7
8
9
public synchronized IGoods get() throws Exception {
IGoods goods = null;
if (amount <= 0) {
Print.tcfo("队列已经空了!");
//数据区为空,直接返回
return null;
}
...
}

当数据区为空(amount <= 0)时,消费者无法取出数据,但是仍然做无用的询问工作,浪费了CPU的时间片。同理,对于生产者也会存在这样的问题:

1
2
3
4
5
6
7
public synchronized void add(T element) throws Exception {
if (amount.get() > MAX_AMOUNT) {
Print.tcfo("队列已经满了!");
return;
}
...
}

当数据区满时,生产者无法加入数据,这时执行add方法也浪费CPU的时间片。使用“等待-通知”方式进行生产者与消费者之间的线程通信可以避免这种浪费。

具体方法是:当数据区满时,给让生产者等待,当可以添加数据时,给生产者发通知,让生产者唤醒;消费者同理。具体操作为:消费者取出一个数据后,由消费者去唤醒等待的生产者;生产者加入一个数据后,由生产者唤醒等待的消费者。

2.9.3 wait 、notify 方法的原理

wait 方法

对象的 wait 方法作用就是让当前线程阻塞并等待被唤醒,wait 方法与对象监视器密切相关,使用时一定要放在同步块中:

1
2
3
4
5
synchronized(locko) {
...
locko.wait();
....
}

其原理大致如下:

  • 线程调用了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
public class CommunicatePetStore {

public static final int MAX_AMOUNT = 10; //数据缓冲区最大长度

//数据缓冲区,类定义
static class DataBuffer<T> {
//保存数据
private List<T> dataList = new LinkedList<>();
//数据缓冲区长度
private Integer amount = 0;

private final Object LOCK_OBJECT = new Object();
private final Object NOT_FULL = new Object();
private final Object NOT_EMPTY = new Object();

// 向数据区增加一个元素
public void add(T element) throws Exception {
while (amount > MAX_AMOUNT) {
synchronized (NOT_FULL) {
Print.tcfo("队列已经满了!");
//等待未满通知
NOT_FULL.wait();
}
}
synchronized (LOCK_OBJECT) {
dataList.add(element);
amount++;
}
synchronized (NOT_EMPTY) {
//发送未空通知
NOT_EMPTY.notify();
}
}

/**
* 从数据区取出一个商品
*/
public T fetch() throws Exception {
while (amount <= 0) {
synchronized (NOT_EMPTY) {
Print.tcfo("队列已经空了!");
//等待未空通知
NOT_EMPTY.wait();
}
}

T element = null;
synchronized (LOCK_OBJECT) {
element = dataList.remove(0);
amount--;
}

synchronized (NOT_FULL) {
//发送未满通知
NOT_FULL.notify();
}
return element;
}
}

public static void main(String[] args) throws
InterruptedException {
Print.cfo("当前进程的ID是" + JvmUtil.getProcessID());
System.setErr(System.out);
//共享数据区,实例对象
DataBuffer<IGoods> dataBuffer = new DataBuffer<>();

//生产者执行的动作
Callable<IGoods> produceAction = () -> {
//首先生成一个随机的商品
IGoods goods = Goods.produceOne();
//将商品加上共享数据区
dataBuffer.add(goods);
return goods;
};
//消费者执行的动作
Callable<IGoods> consumerAction = () -> {
// 从PetStore获取商品
IGoods goods = null;
goods = dataBuffer.fetch();
return goods;
};
// 同时并发执行的线程数
final int THREAD_TOTAL = 20;
//线程池,用于多线程模拟测试
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_TOTAL);

//假定共11个线程,其中有10个消费者,但是只有1个生产者
final int CONSUMER_TOTAL = 11;
final int PRODUCE_TOTAL = 1;

for (int i = 0; i < PRODUCE_TOTAL; i++) {
//生产者线程每生产一个商品,间隔50毫秒
threadPool.submit(new Producer(produceAction, 50));
}
for (int i = 0; i < CONSUMER_TOTAL; i++) {
//消费者线程每消费一个商品,间隔100毫秒
threadPool.submit(new Consumer(consumerAction, 100));
}
}
}

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
2
3
4
5
6
7
8
9
10
//消费者获取元素
public T fetch() throws Exception {
while (amount <= 0) {
synchronized (NOT_EMPTY) {
//队列空了
NOT_EMPTY.wait();
}
}
...
}

错误地使用 if 条件判断:

1
2
3
4
5
6
7
8
9
10
//消费者获取元素
public T fetch() throws Exception {
if (amount <= 0) {
synchronized (NOT_EMPTY) {
//队列空了
NOT_EMPTY.wait();
}
}
...
}

至于为什么要这样,从之前说的原理我们知道,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博客

谢谢你的鼓励