Java多线程笔记(2)-synchronized的使用与原理

临界区

在多线程的场景下,会涉及到如下的一些概念:

  • 临界资源:一次仅允许一个进程使用的资源称为临界资源

  • 临界区:访问临界资源的代码块

  • 竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件(竞争)

一个程序运行多个线程本身是没有问题的,多个线程读取共享资源也是没有问题的,而在多个线程对共享资源读写操作的时候发生了指令交错,就会出现问题。而为了避免临界区的竞态条件发生,解决线程安全问题,可以有如下两种解决方案:

  1. 阻塞式的解决方案:synchronized,lock
  2. 非阻塞式的解决方案:原子变量

synchronized

锁使用

synchronized:对象锁,保证了临界区内代码的原子性。临界区内的代码对外是不可分割的,只有获取到锁的线程才能执行临界区内的代码。采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程获取这个对象锁时会阻塞,保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

synchronized的使用方式有:

  1. 修饰静态方法,对应的锁对象即为类.class
  2. 修饰普通方法,对应的锁对象即为该实例对象
  3. 修饰代码块,对应的锁对象即为指定的对象
1
2
3
synchronized(对象){
临界区...
}

在分析利用synchronized的时候,主要需要关注锁住的对象是不是同一个

  • 锁住类对象,所有类的实例的方法都是安全的,类的所有实例都相当于同一把锁
  • 锁住 this 对象,只有在当前实例对象的线程内是安全的,如果有多个实例就不安全

锁原理-Monitor

首先我们需要回顾一下Java中的对象组成。Java中的对象组成分为Java对象头+实例数据,其中Java对象头又分为了Mark Word和Klass Word,如果是数组对象的话,还会有一个数组长度Array Length的记录。

在这里我们主要关注Mark Word。Mark Word在正常情况下,会存储hashcode、分代年龄、锁标志位等信息,但是在其他特殊情况下,存储的信息会有所改变,具体如下:(Mark Word的最后两位是锁标志位)

32位虚拟机

1
2
3
4
5
6
7
8
9
10
11
12
13
|--------------------------------------------------------|---------------------|
| Mark Word(32 bits) | State |
|--------------------------------------------------------|---------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|--------------------------------------------------------|---------------------|
| thread:23 | epoch: 2 | age:4 | biased_lock:1 | 01 | Biased |
|--------------------------------------------------------|---------------------|
| ptr_to_lock_record:30 | 00 | LightWeight Locked |
|--------------------------------------------------------|---------------------|
| ptr_to_heavyweight_monitor:30 | 10 | HeavyWeight Locked |
|--------------------------------------------------------|---------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------|---------------------|

64位虚拟机

1
2
3
4
5
6
7
8
9
10
11
12
13
|------------------------------------------------------------|---------------------|
| Mark Word(64 bits) | State |
|------------------------------------------------------------|---------------------|
| unused:25 |hashcode:31 |unused:1 |age:4 |biased_lock:0 |01 | Normal |
|------------------------------------------------------------|---------------------|
| thread:54 |epoch: 2 |unused:1 |age:4 |biased_lock:1 |01 | Biased |
|------------------------------------------------------------|---------------------|
| ptr_to_lock_record:30 |00 | LightWeight Locked |
|------------------------------------------------------------|---------------------|
| ptr_to_heavyweight_monitor:30 |10 | HeavyWeight Locked |
|------------------------------------------------------------|---------------------|
| |11 | Marked for GC |
|------------------------------------------------------------|---------------------|

synchronzied的锁原理涉及到Monitor的概念。Monitor的源码由C++实现。

每个Java对象都可以关联一个Monitor对象,Monitor也是一个类,其实例存储在堆中。如果使用synchronized给对象上锁(重量级)之后,该对象头中的Mark Word就被设置为执行Monitor的指针。在Monitor中有一些关键属性,包括Owner,EntryList和WaitSet,分别指示锁持有线程,阻塞队列和等待队列。

具体工作流程如下:

  1. 一开始Monitor中的Owner为null,表示没有线程持有锁
  2. 当有线程Thread-2执行synchronzied(obj)的时候,就会将Monitor的所有者Owner置为Thread-2。同一个时刻,一个Monitor中只能有一个Owner
  3. 之后将obj对象的Mark Word设置为指向Monitor(即上面的重量级锁情况),将对象原有的Mark Word存入对应的Monitor对象中,用于后续释放时恢复
  4. 在Thread-2上锁的过程中,Thread-3、Thread-4和Thread-5也执行synchronized(obj),就会进入EntryList(双向链表),进入阻塞状态
  5. Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
  6. 之后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
  7. WaitSet 中的 Thread-0和Thread-1,指的是调用wait进入WAITING状态的线程(wait-notify 机制)

注意:

  • synchronzied必须是进入同一个对象的Monitor才会有上述的效果
  • 不加synchronized的对象不会关联Monitor
  • synchronized在字节码层面,通过异常try-catch机制,确保一定会被解锁

锁升级

轻量级锁

轻量级锁的使用场景:一个对象虽然有多线程访问,但是多线程访问的时间是相互错开的,实际上没有竞争,那么可以使用轻量级锁来进行优化。在轻量级锁的情况下,不会涉及到Monitor对象的关联等一系列操作,而是使用到线程栈帧中的锁记录对象

轻量级锁的工作原理如下:

  1. 首先创建锁记录(Lock Record)对象。每个线程的栈帧都会包含一个锁记录的结果,内部可以存储锁定对应的Mark Word。一开始,锁记录中存储了本身锁记录的地址,以及对应的锁对象的引用

  2. 开始执行加锁。即让锁记录中的Object Reference指向对应锁住的对象,并尝试用CAS来替换Object中的Mark Word,将Mark Word的值存入锁记录中

  3. 如果CAS替换成功,那么Object的Mark Word就存储了锁记录地址以及锁标志位00,表示轻量级锁。这样就表示由对应线程持有了锁

  4. 如果CAS替换失败,则可能的情况有两种

    • 其他线程已经持有了该Object的轻量级锁,这样就表示此时发生了竞争,则进入锁膨胀的过程
    • 该Object上的锁记录指向的是自己线程,即执行了synchronized的锁重入,则此时就再添加一条Lock Record作为重入的计数,其中原本记录锁记录地址的位置置为null
  5. 当退出synchronzed代码块,执行解锁的时候

    • 如果有取值为null的锁记录,则表示重入,则重置锁记录,表示重入计数减一
    • 如果锁记录的值不为null,则表示是一开始加的锁,则此时使用CAS将普通状态的Mark Word值恢复给Object中的对象头
      • 此时的CAS可能失败也可能成功,如果成功即表示解锁成功
      • 如果失败,说明该轻量级锁已经升级成为了重量级锁,则进入重量级锁的解锁流程

锁膨胀即锁升级为重量级锁,关联到Monitor对象的一系列过程

偏向锁

轻量级锁在没有竞争的时候,每次重入仍然需要执行CAS操作,有一定的性能消耗。因此,Java 6中引入了偏向锁来及进行进一步的优化。

只有第一次需要使用CAS将线程ID设置到对象的Mark Word头中,之后发现这个线程ID是自己的就表示没有竞争,无需重新CAS。并且以后只要不发生竞争,这个锁对象就归该线程所有。

当有另外一个线程去尝试获取这个锁对象的时候,偏向状态就宣告结束,进行偏向撤销,然后恢复到未锁定或者轻量级锁的状态。

当一个对象创建的时候:

  • 如果开启了偏向锁,那么对象创建之后,Mark Word的最后3位为101,thread、epoch、age都为0
  • 如果没有开启偏向锁,那么对象创建后,Mark Word的最后3位为001,hashcode、age都为0,当第一次用到hashCode的时候才会赋值
  • 偏向锁默认开启,也可以使用参数-XX:-UseBiasedLocking来禁用偏向锁
  • 偏向锁默认是延迟的,会在程序启动后延迟几秒生效。这是因为在刚开始执行代码的时候,可能会有很多线程进行竞争,开启偏向锁效率反而降低。我们也可以使用参数-XX:BiasedLockingStartupDelay=0来避免延迟

偏向锁在一些情况下会被撤销:

  • 调用对象的hashCode时,偏向锁被撤销。因为在偏向锁状态下无法存放hashCode
  • 当有其他线程使用偏向锁对象的时候,会将偏向锁升级成为轻量级锁
  • 调用wait - notify的时候,会将偏向锁升级为重量级锁,因为在Monitor中才有WatiSet

升级过程

上述的锁升级对于使用者来说都是透明的,使用语法都是synchronized。JVM会自动根据情况进行锁升级的过程。

1
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁	// 随着竞争的增加,只能锁升级,不能降级

锁优化

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

  • 优点:不会进入阻塞状态,减少线程上下文切换的消耗
  • 缺点:消耗CPU资源
  • 说明:
    • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
    • Java 7 之后不能控制是否开启自旋功能,由 JVM 控制

锁消除:指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器JIT的优化。锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)

wait - notify

wait - notify机制表示对象的等待和唤醒机制。wait、notify、notifyAll是Object的方法,需要用锁对象来调用。获取了该对象锁才能调用这些方法。 相关API如下:

1
2
3
4
public final void notify(); // 唤醒正在等待对象Monitor的单个线程
public final void notifyAll(); // 唤醒正在等待对象Monitor的所有线程
public final void wait(); // 让当前线程等待,直到另一个线程调用该对象的notify()方法或 notifyAll()方法
public final native void wait(long timeout); // 有时限的等待, 到n毫秒后结束等待,或是被唤醒

wait - notify的使用需要配合Monitor进行。调用wait的线程会变为WAITING状态,进入对应Monitor的WaitSet中。当Owner线程调用notify或者notifyAll唤醒处于WAITING状态的线程之后,被唤醒的线程进入EntryList重新竞争

sleep和wait的区别:

方法归属不同:

  1. sleep是Thread的静态方法
  2. wait是Object的成员方法

醒来时机不同:

  1. sleep(long)和wait(long)都可以在等待超过对应时间后醒来
  2. wait可以被notify和notifyAll进行唤醒
  3. 它们都可以被打断唤醒

锁特性不同:

  1. wait方法的调用必须先调用wait对象的锁,而sleep则没有这个限制
  2. wait方法执行后会释放对象锁,但是sleep不会放弃锁

使用wait - notify的时候,可能遇到虚假唤醒的问题。这种情况指的是,notify只能随机唤醒WaitSet中的一个线程,但是可能有多个线程处于WAITING状态,唤醒的不一定是正确的线程。针对这种情况,我们选择采用notifyAll

采用notifyAll之后,可以解决线程的唤醒问题。但是这会唤醒所有的线程,有的线程可能条件没有得到满足也被唤醒了。为了解决这个问题,我们一般不使用if + wait来进行条件判断,而应该使用while + wait。唤醒之后重新判断,如果条件不满足,则继续等待

park - unpark

park和unpark是LockSupport中提供的相关API:

  • LockSupport.park():暂停当前线程,挂起原语

  • LockSupport.unpark(暂停的线程对象):恢复某个线程的运行

LockSupport 出现就是为了增强 wait & notify 的功能:

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用;而 park、unpark 不需要
  • park & unpark 以线程为单位来阻塞和唤醒线程,更加精确;而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify。类比生产消费,先消费发现有产品就消费,没有就等待;先生产就直接产生商品,然后线程直接消费
  • wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU(进入WAITING状态)

park会让线程进入WAITING状态,但是我们打断park线程并不会清除打断标记,并且如果打断标记为true,调用park是无法让线程阻塞的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Thread thread = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断标记: {}", Thread.currentThread().isInterrupted());
// log.debug("打断标记: {}", Thread.interrupted());

LockSupport.park();
log.debug("unpark...");
}, "thread1");

thread.start();
Thread.sleep(1000);
thread.interrupt();

线程分析

线程安全分析

对于成员变量和静态变量:

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题

对于局部变量:

  • 局部变量是线程安全的
  • 局部变量引用的对象不一定线程安全(逃逸分析):
    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全问题

常见线程安全类:String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包

  • 线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

  • 每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:

    1
    2
    3
    4
    5
    Hashtable table = new Hashtable();
    // 线程1,线程2
    if(table.get("key") == null) {
    table.put("key", value);
    }

无状态类线程安全:无状态类指的是没有成员变量的类

不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全

不可变类的设计:如果一个对象不能够修改其内部属性,则这个对象就是不可变对象。不可变对象是线程安全的,不存在并发修改和可见性问题

以String类为例,我们可以观察不可变类的设计:

  • 类本身使用final修饰,保证该类中的方法不会被子类破坏
  • 属性使用final修饰,保证该属性是只读的,不能被修改
  • 不提供写入方法,保证外部不能对属性进行修改
  • 在更改String类数据的时候,会构造新的字符串对象,生成新的char[] value。这种通过创建对象来避免共享的方式称为保护性拷贝

线程活跃性

死锁

死锁产生的必要条件:

  1. 互斥条件:在一段时间内,某个资源只能由一个进程占用
  2. 请求和保持条件:进程保持至少一个资源,又提出了新的资源请求。请求阻塞的时候,对自己已经保持的资源保持不放
  3. 不剥夺条件:进程已经获得的资源,在未使用前不能被剥夺,只能在使用完之后自己释放
  4. 环路等待条件:在发生死锁的时候,必然存在两个或多个进程构成的环形链路,里面的每个进程都在等待下一个进程释放其占用的资源

死锁示例:

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
public class DeadLock {
private static Object resource1 = new Object();
private static Object resource2 = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "获取资源1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread() + "等待获取资源2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "获取资源2");
}
}
}, "线程1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "获取资源2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread() + "等待获取资源1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "获取资源1");
}
}
}, "线程2").start();
}
}

死锁的定位:

  • 使用jps定位进程id,再用jstack id定位死锁,找到死锁的线程去查看源码
  • 使用jconsole工具进行检测死锁

活锁

活锁指的是任务并没有被阻塞,但是由于某些条件没有满足,导致一直重复尝试然后失败的过程。举例来说,下面的代码两个线程互相改变对方的结束条件,最后谁也没有办法结束

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
static volatile int count = 10;

public static void main(String[] args) {
new Thread(() -> {
// 期望count减到0退出循环
while (count > 0) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
count -= 1;
log.debug("count: {}", count);
}
}, "thread1").start();
new Thread(() -> {
// 期望count加到超过20退出循环
while (count < 20) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
count += 1;
log.debug("count: {}", count);
}
}, "thread2").start();
}
  • 这种情况下还是有可能结束的,和线程的调度有关,随机性很强

饥饿

饥饿:一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束


Java多线程笔记(2)-synchronized的使用与原理
https://evernorif.github.io/2022/09/09/Java多线程笔记-2-synchronized的使用与原理/
作者
EverNorif
发布于
2022年9月9日
许可协议