多线程—管程-悲观锁相关内容以及synchronized做的优化

宋正兵 on 2021-03-01

为了避免临界区的竞态条件发生,产生资源共享问题,可以使用 synchronized【对象锁】来解决问题。

它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

synchronized

synchronized的使用

语法

1
2
3
synchronized(对象) {
// 临界区
}

用法

1)加在方法上的作用就是锁住当前对象 this

1
2
3
4
5
6
7
8
9
10
11
12
13
class Test {
public synchronized void test() {

}
}
// 等价于
class Test {
public void test() {
synchronized(this) {

}
}
}

2)加在 static 方法上等同于锁住类对象 xxx.class

1
2
3
4
5
6
7
8
9
10
11
12
13
class Test {
public synchronized static void test() {

}
}
// 等价于
class Test {
public void test() {
synchronized(Test.class) {

}
}
}

synchronized的原理

在展开讨论 synchronized 的原理之前,需要先引入对象头Monitor两个知识。

对象头

image.png

其中 Mark Word 的结构为

imagec95e7ded9525212e.png

Monitor

Monitor 被翻译为监视器管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

Monitor 结构如下

image358d93b916e03413.png


synchronized 给对象上锁流程【重量级锁】

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足运行而进入 WAITING 状态的线程

BLOCK 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片

BLOCK 线程会在 Owner 线程释放锁时唤醒

WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味着立刻获得锁,仍然需要进入 EntryList 重新竞争

WaitSet(休息室):当前获得锁的线程(Owner)是屋子的主人,但是该线程可能因为条件不足(如等待 I\O 获取数据)而无法继续往下运行,这个时候就会调用 wait(),进入 WaitSet ,等待条件好了才能继续运行。

此时它会让出锁,让 EntryList 排队等待的线程来竞争锁;当条件满足了当前获得锁的线程就会调用 notify() 唤醒 WaitSet 休息室中的线程,它就回去重新跟别人一起排队,去竞争锁。

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上原则

谈谈锁——synchronized优化

流程

imagec95e7ded9525212e.png

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0

    偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以添加 VM 参数来禁用延迟 。

    • 偏向锁的工作原理:只有第一次使用 CAS 才将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS

    • 偏向锁的失效

      失效后的再加锁加的是轻量级锁

      • 调用对象 hashCode
      • 其它线程使用对象
      • 调用 wait/notify
    • 没有锁竞争的前提下

      • 撤销偏向锁阈值超过 20 次:偏向锁批量重偏向
      • 撤销偏向锁阈值超过 40 次:偏向锁批量撤销
  • 如果没有开启偏向锁,那么对象创建后,Mark Word 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值

1
2
3
4
偏向锁  ----失效后再加锁----> 轻量级锁 ----锁膨胀----> 重量级锁
|- 偏向锁的失效 |- 锁重入 |- 自旋
|- 偏向锁的批量重偏向 |- 锁膨胀
|- 偏向锁的批量撤销

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化

轻量级锁对使用者是透明的,即语法仍然是 synchronized

假设有两个方法同步块,利用同一个对象加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
static final Object obj = new Object();
public static void method1() {
synchronized(obj) {
// 同步块 A
method2();
}
}

public static void method2() {
synchronized(obj) {
// 同步块 B
}
}

method1() 中调用了 method2(),method2() 也加了 synchronized,此时发生了锁重入,对同一个对象加多重锁。

image4698f1c89082b449.png

  1. 栈帧的锁记录使用 CAS 尝试替换对象头的 Mark Word
  2. 当前线程内继续加锁,(锁重入)计数器 +1(相应的,解锁 -1)
  3. 别的线程来加锁,发现已经存在轻量级锁了,就会转成重量级锁(锁膨胀

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static final Object obj = new Object();
public static void method1() {
synchronized(obj) {
// 同步块
}
}

imagea4f250948efe762b.png

image7372f553d60664b0.png

自旋

自旋好像是轻量级锁自旋噢,因为 CAS 失败后可以尝试自旋,直到成功,或者有设置某种条件,超过多少次自旋就竞争失败啥的。

重量级锁失败就阻塞了啊。

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持有锁的线程已经退出同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费时间,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自选成功的可能性高,就多自旋几次;反之,就少自旋甚至不自旋。
  • Java 7 之后不能控制是否开启自旋功能
  • (自旋类似于重复尝试)

自旋重试成功的情况

线程1(cpu1 上) 对象 Mark 线程2(cpu2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10(重量锁)重量锁指针 成功(加锁)
- 10(重量锁)重量锁指针 执行同步块
-

自旋重试失败的情况

线程1(cpu1 上) 对象 Mark 线程2(cpu2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 阻塞
-

偏向锁

轻量级锁在没有竞争时(只有自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 才将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final Object obj = new Object();
public static void method1() {
synchronized(obj) {
// 同步块A
method2();
}
}
public static void method2() {
synchronized(obj) {
// 同步块B
method3();
}
}
public static void method3() {
synchronized(obj) {
// 同步块C
}
}

image664fd7f7c01e45a2.png

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以添加 VM 参数来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,Mark Word 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值

偏向锁的失效

1)撤销-调用对象 hashCode

偏向锁对象的 MarkWord 中存储的是线程 ID,如果调用了锁对象的 hashCode 会导致偏向锁撤销

  • 轻量级锁会在锁记录中记录 hashCode(存放在栈帧的锁记录中)
  • 重量级锁会在 Monitor 中记录 hashCode

2)撤销-其它线程使用对象

当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

3)撤销-调用 wait/notify

偏向锁批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 t1 的对象仍然有机会重新偏向线程 t2,重偏向会重置对象的 Thread ID。

当撤销偏向锁阈值超过 20 次后,jvm 会在给这些对象加锁时重新偏向至加锁线程。

偏向锁批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会将整个类的所有对象都变为不可偏向的,新建的对象也是不可偏向的。

锁消除

如下代码

1
2
3
4
5
6
public void b() throws Exception {
Object obj = new Object();
synchronized(obj) {
x++;
}
}

JIT 即时编译器会对 Java 字节码进一步的优化,上述代码中,局部变量 obj 不会逃离方法,不可能被共享,所以对加锁没有任何意义。于是 JIT 编译器优化,将锁去掉了。

其他优化

  1. 减少上锁时间,同步代码块尽量短

  2. 减少锁的粒度

    将一个锁拆分为多个锁提高并发度,例如

    • ConcurrentHashMap
    • LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值。
    • LinkedBlockingQueue 入队和出队使用不同的锁,相对于 LinkedBlockingQueueArray 只有一个锁效率要高。
  3. 锁粗化

    多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

    1
    new StringBuffer().append("a").append("b").append("c");
  4. 锁消除

    JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。

  5. 读写分离

    • CopyOnWriteArrayList
    • ConyOnWriteSe