只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

34、synchronized(上)

内容来自王争 Java 编程之美

上一节提到,解决 "多线程执行非原子操作" 导致的线程不安全问题,最常用的解决方案便是加锁
Java 语言提供了两种类型的锁,一种是 synchronized 关键字,一种是 Lock 工具类
在 JDK 1.5 及其以前版本中,synchronized 的实现比较简陋,性能没有后起之秀 Lock 高
但是在 JDK 1.6 及其之后的版本中,Java 对 synchronized 做了大量优化,其基本实现原理跟 Lock 的基本实现原理趋于一致,因此在性能方面,两者也就相差无几了

接下的两节课,我们先重点讲解 synchronized,其中本节课讲解 synchronized 的基本用法,以及重量级锁的实现原理
下一节讲解 Java 对 synchronized 的各种优化,包括偏向锁、轻量级锁、自旋锁、锁粗化、锁消除等

在开始今天的内容之前,我仍然有几个问题留给你思考
某个 Java 线程使用 synchronized 之后,如果没有获取锁,Java 是如何阻塞当前代码的执行的?如何停止对应的内核线程执行?
当另一个线程释放锁之后,如何通知等待锁的线程获取锁?

带着这些问题,我们开始今天的学习

1、两种作用范围

synchronized 关键字既可以作用于方法,也可以作用方法内的局部代码块

1.1、作用于方法

在上一节中,我们展示了一个非线程安全的 Counter 类
为了让 Counter 类变为线程安全的,我们可以在 add() 函数和 subtract() 函数声明中,添加 synchronized 关键字,代码如下所示

因为 add() 函数和 subtract() 函数使用同一个锁
所以在多线程环境下,不仅 add() 函数本身以及 subtract() 函数本身不可并发执行,add() 函数与 subtract() 函数之间也不可并发执行

/**
* synchronized 作用于方法
* add() 和 subtract() 使用的是同一把锁 -> this 对象的 Monitor 锁, 无法并发执行
*/
public class Counter {
private int count = 0;
public synchronized void add(int value) {
count += value;
}
public synchronized void subtract(int value) {
count -= value;
}
}

1.2、作用于局部代码块

如果上述 Counter 类的 add() 函数和 subtract() 函数内部包含大量其它逻辑,只有 count += value 以及 count -= value 这两个代码块才是真正的临界区
那么为了尽可能的提高代码执行的并发度,减小加锁范围,我们可以使用 synchronized 关键字,只对 add() 函数和 subtract() 函数中的局部代码块加锁,如下代码所示

/**
* synchronized 作用于局部代码块, 减小加锁范围
* add() 和 subtract() 使用的是同一把锁 -> this 对象的 Monitor 锁, 无法并发执行
*/
public class Counter2 {
private int count = 0;
public void add(int value) {
// ...
synchronized (this) {
count += value;
}
// ...
}
public void subtract(int value) {
// ...
synchronized (this) {
count -= value;
}
// ...
}
}

1.3、使用不同的锁

现在我们再修改一下 Counter 类,如下所示
在修改之后的代码中,尽管 add() 函数和 subtract() 函数仍然都是线程不安全的,但是 add() 函数和 subtract() 函数是可以并发执行的,因为它们访问的共享资源并不相同
针对修改后的 Counter 类,我们应该如何使用 synchronized 加锁,既保证类为线程安全的,又保证两个函数可以并发执行呢?

public class Counter {
private int increasedSum = 0;
private int decreasedSum = 0;
public void add(int value) {
increasedSum += value;
}
public void subtract(int value) {
decreasedSum -= value;
}
}

如果我们使用前面讲到的两种方法,在方法上或者局部代码块上使用 synchronized 加锁,那么 add() 函数和 subtract() 函数就无法并发执行了
这样做降低了代码的并行度,进而降低了代码在多线程环境下的执行效率
究其原因,主要在于 add() 函数和 subtract() 函数使用的是同一把锁,为了解决这个问题,我们需要给两个函数加两把不同的锁

synchronized 关键字底层使用的锁叫做 Monitor 锁,但是我们无法直接创建和使用 Monitor 锁,Monitor 锁是寄生存在的,每个对象都会拥有一个 Monitor 锁
如果我们想要使用一个新的 Monitor 锁,我们只需要使用一个新的对象,并在 synchronized 关键字后,附带声明要使用哪个对象的 Monitor 锁即可
实际上,我们对方法添加 synchronized 关键字,就相当于隐式地使用了当前对象(this 对象)的 Monitor 锁

为了让 add() 函数和 subtract() 函数之间能并发执行,我们可以采用如下方式,对 add() 函数和 subtract() 函数加锁
add() 函数使用 obj1 对象上的 Monitor 锁,subtract() 函数使用 obj2 对象上的锁,两者互不影响

/**
* 使用不同的锁
*/
public class Counter {
private int increasedSum = 0;
private int decreasedSum = 0;
private final Object obj1 = new Object(); // Monitor 锁 1
private final Object obj2 = new Object(); // Monitor 锁 2
public void add(int value) {
synchronized (obj1) {
increasedSum += value;
}
}
public void subtract(int value) {
synchronized (obj2) {
decreasedSum -= value;
}
}
}

2、对象锁和类锁

刚刚我们讲到的锁,都是对象锁,现在我们再来讲一讲类锁
在《设计模式之美》一书中,当讲到单例模式时,曾经用到过一个日志框架的例子,很好的诠释了什么是类锁,并且非常贴合实战,强烈建议你去看一下
不过那个例子有点复杂,今天,我们换一个简单点的例子,来讲解类锁

2.1、示例

我们先来看一段代码,如下所示
Wallet 类表示用户钱包,里面有一个 transferTo() 函数,可以实现将当前钱包的钱,转账给另一个钱包,下面的 transferTo() 函数是否是线程安全的呢?

public class Wallet {
private int balance;
public void transferTo(Wallet targetWallet, int amount) {
// 先检查后修改
if (this.balance >= amount) {
this.balance -= amount; // 先读再改后写
targetWallet.balance += amount; // 先读再改后写
}
}
}

我们使用上节中总结的方法,来分析 transferTo() 函数是否是线程安全的

transferTo() 函数访问了共享资源(balance),并且包含复合操作(先检查再执行以及先读再改后写),因此 transferTo() 函数为临界区
除此之外,从业务的角度来看,我们也无法避免两个线程同时执行 transferTo() 函数来转账
也就是说,transferTo() 函数既存在临界区,又存在竞态,因此 transferTo() 函数极有可能线程不安全

2.2、问题 1

接下来,我们再通过两个线程交叉执行 transferTo() 函数,找到线程不安全的具体用例,如下所示,从而证明 transferTo() 函数真的线程不安全
image

为了让 transferTo() 函数线程安全,你可能会想到使用 synchronized 修饰 transferTo() 函数,但是这样真的能保证 transferTo() 函数线程安全吗?
答案是否定的,因为这段代码比较特殊,看似 transferTo() 函数只访问了一个共享资源(balance),实际上,还访问了其他共享资源(targetWallet 的 balance)

2.3、问题 2

假设我们有两个 Wallet 类对象:wallet1 和 wallet2
调用 wallet1 上的 transferTo() 函数,使用的是 wallet1 这个对象上的 Monitor 锁,调用 wallet2 上的 transferTo() 函数,使用的是 wallet2 这个对象上的 Monitor 锁
因此使用 synchronized 修饰 transferTo() 函数
只能限制两个线程不能并发执行同一个 Wallet 对象上的 transferTo() 函数,但不能限制两个线程并发执行不同 Wallet 对象上的 transferTo() 函数

一个线程执行 wallet1 上 transferTo() 函数向 wallet2 转账,另一个线程并发执行 wallet2 上的 transferTo() 函数向 wallet1 转账,如下图所示
红色标记部分为复合操作(先读再改后写),并发执行会存在线程安全问题
image

2.4、类锁解决

也就是说,不仅一个对象上的 transferTo() 函数不能并发执行,同一个类上的所有对象上的 transferTo() 函数都不能并发执行
为了实现这样的限制,我们就需要使用类锁来替代对象锁,对 transferTo() 函数进行加锁
类锁的语法非常简单,如下代码所示,synchronized 关键词后跟随某个类的 Class 类对象即可

public class Wallet {
private int balance;
public void transferTo(Wallet targetWallet, int amount) {
synchronized (Wallet.class) {
// 先检查后修改
if (this.balance >= amount) {
this.balance -= amount; // 先读再改后写
targetWallet.balance += amount; // 先读再改后写
}
}
}
}

前面讲到对象锁时我们提到,synchronized 底层使用的是对象上的 Monitor 锁
那么对于类锁来说,synchronzied 使用的也是某个对象上的 Monitor 锁,只不过这个对象比较特殊,是类的 Class 类对象
Class 类是所有类的抽象,每个类在 JVM 中都有一个 Class 类对象来表示这个类,这有点不好理解,等讲到 JVM 模块时,我们再详细解释

除了显示指定使用哪个类的类锁(类的 Class 类对象的 Monitor 锁)之外
如果我们对静态方法添加 synchronized 关键词,那么对应的静态方法会隐式地使用当前类的类锁,如下代码所示,add() 函数使用 Counter 类的类锁

public class Counter {
private static int count = 0;
public synchronized static void add(int value) {
count += value;
}
}

3、对应的字节码

为了了解 synchronized 的底层实现原理,我们先从字节码层面找找答案,看看 synchronized 对应的字节码长什么样子
我们还是针对 synchronized 的两种不同的应用方式(作用于方法和局部代码块)来分析

3.1、作用于方法

我们先来看 synchronized 作用于方法,示例代码如下所示

public class Counter {
private int count = 0;
public synchronized void add(int value) {
count += value;
}
}

add() 函数对应的字节码如下所示
实际上,编译器只不过是在函数的 flags 中添加了 ACC_SYNCHRONIZED 标记而已,其他部分跟没有添加 synchronized 的 add() 函数的字节码相同

public synchronized void add(int);
descriptor: (I)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=2, args_size=2
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iload_1
6: iadd
7: putfield #2 // Field count:I
10: return
LineNumberTable:
line 5: 0
line 6: 10

3.2、作用于局部代码块

我们再来看 synchronized 作用于局部代码块,示例代码如下所示

public class Counter {
private int count = 0;
private Object obj = new Object();
public void add(int value) {
synchronized (obj) {
count += value;
}
}
}

add() 函数对应的字节码如下所示,字节码通过 monitorenter 和 monitorexit 来标记 synchronized 的作用范围,除此之外,对于以下字节码,我们有两点需要解释

  • 其一:以下字节码中有两个 monitorexit,添加第二个 monitorexit 的目的是为了在代码抛出异常时仍然能解锁
  • 其二:前面讲到,synchronized 可以选择指定使用哪个对象的 Monitor 锁,具体使用哪个对象的 Monitor 锁,在字节码中,通过 monitorenter 前面的几行字节码来指定
public void add(int);
descriptor: (I)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=4, args_size=2
0: aload_0
1: getfield #4 // Field obj:Ljava/lang/Object;
4: dup
5: astore_2
6: monitorenter
7: aload_0
8: dup
9: getfield #2 // Field count:I
12: iload_1
13: iadd
14: putfield #2 // Field count:I
17: aload_2
18: monitorexit
19: goto 27
22: astore_3
23: aload_2
24: monitorexit
25: aload_3
26: athrow
27: return
Exception table:
from to target type
7 19 22 any
22 25 22 any

从上述示例对应的字节码,我们可以发现,synchronized 语句与字节码之间,只不过是做了一个简单的翻译而已
我们是无法通过 synchronized 对应的字节码,了解到其底层实现原理的,我们需要继续深挖

4、Monitor 锁

为了提高 synchronized 加锁、解锁的执行效率,在不同场景下,synchronized 底层使用不同的锁来实现,比如:偏向锁、轻量级锁、重量级锁等
本节我们重点讲解重量级锁,对于偏向锁、轻量级锁等其他锁,我们留在下一节中讲解

4.1、具体长什么样子

实际上,synchronized 使用的重量级锁,就是前面提到的对象上的 Monitor 锁,那么 Monitor 锁具体长什么样子?Monitor 锁与对象之间又是如何关联的?

JVM 有不同的实现版本,因此 Monitor 锁也有不同的实现方式,在 Hotspot JVM 实现中,Monitor 锁对应的实现类为 ObjectMonitor 类
因为 Hotspot JVM 是用 C++ 实现的,所以 ObjectMonitor 也是用 C++ 代码定义的
ObjectMonitor 包含的代码很多,我们只罗列一些与其基本实现原理相关的成员变量,如下所示

class ObjectMonitor {
void *volatile _object; // 该 Monitor 锁所属的对象
void *volatile _owner; // 获取到该 Monitor 锁的线程
ObjectWaiter *volatile _cxq; // 没有获取到锁的线程暂时加入 _cxq, 单链表, 负责存操作
ObjectWaiter *volatile _EntryList; // 存储等待被唤醒的线程, 双链表, 负责取操作
ObjectWaiter *volatile _WaitSet; // 存储调用了 wait() 的线程, 双链表
}

现在我们先重点看下 _object,其他成员变量我们稍后讲解

通过 _object 这个成员变量,我们可以得到这个 Monitor 锁所属的对象
不过我们更关心的是,如何通过对象查找到对应的 Monitor 锁,毕竟 synchronized 关键字是通过对象来使用 Monitor 锁的

在第 9 节中,我们讲过对象的存储结构,其中对象头中的 Mark Word 字段,便可以用来记录对象所对应的 Monitor 锁,因此 Monitor 锁和对象之间的关联如下图所示
实际上,Mark Work 是一个可变字段,在不同的场景下,记录的内容和作用均不同,关于这一点,我们留在下一节中深入探讨
image

4.2、如何实现加锁、解锁的

了解了 Monitor 锁大概的样子,以及如何跟对象关联之后,我们再来看下,Monitor 锁是如何实现加锁、解锁的?

实际上,不管是后面要讲到的 JUC(java.util.concurrent) Lock,还是现在正在讲的 Java 内置 synchronized
它们作为多线程的互斥锁,所包含的基本功能是一致的,主要有以下几点

  1. 多个线程竞争获取锁
  2. 没有获取到锁的线程排队等待获取锁
  3. 锁释放之后会通知排队等待锁的线程去竞争锁
  4. 没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片
  5. 阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片

总结一下

  • 多个线程竞争获取锁
  • 没有获取到锁的线程:排队等待获取锁,会阻塞,并且对应的内核线程不再分配时间片
  • 锁释放之后会通知排队等待锁的线程去竞争锁:阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片

前面讲到,synchronized 所使用的重量级锁就是 Monitor 锁,而 Monitor 锁在 Hotspot JVM 中对应的实现类为 ObjectMonitor 类
接下来我们依次详细讲解一下,以上互斥锁的 5 个基本功能,在 ObjectMonitor 类中具体是如何实现的

5、底层实现原理

1、多个线程竞争获取锁 cmpxchg 指令

多个线程同时请求获取 Monitor 锁时,它们会通过 CAS 操作,来设置 ObjectMonitor 中的 _owner 字段,谁设置成功,谁就获取了这个 Monitor 锁

这里我们再稍微解释一下 CAS 操作,CAS 英文全称为 Compare And Set,也就是我们之前提到的先检查再执行
参与竞争 Monitor 锁的线程,会先检查 _owner 是否是 null,如果 _owner 是 null,再将自己的 Thread 对象的地址赋值给 _owner

前面我们讲到,先检查再执行这类复合操作是非线程安全的,那么这样就会导致多个线程有可能同时检查到 _owner 为 null,然后都去改变 _owner 值
为了解决这个问题,JVM 采用 CPU 提供的 cmpxchg 指令,通过给总线加锁的方式,保证了以上 CAS 操作的线程安全性,这就相当于在硬件层面上给以上 CAS 操作加了锁
关于 CAS 操作,我们后面会有专门的章节详细讲解,这里稍微了解一下即可

// 下面的代码是不正确的, 仅用来帮助理解
cas(ObjectMonitor._owner, null, currentThread);
// Atomic::cmpxchg_ptr
int cas(int *dest, int compare_value, int exchange_value) {
mov eax, compare_value // 期望
mov ecx, DWORD PTR dest // 实际
mov edx, exchange_value // 新值
lock cmpxchg ecx, edx
return ecx; // cmpxchg 失败
return compare_value; // cmpxchg 成功
}

2、没有获取到锁的线程排队等待获取锁

多个线程竞争 Monitor 锁,成功获取锁的线程就去执行代码了,没有获取到锁的线程会放入 ObjectMonitor 的 _cxq 中等待锁
_cxq 是一个单向链表,链表节点的定义如下 ObjectWaiter 类所示
ObjectWaiter 类中包含线程的基本信息以及其他一些结构信息,比如 _prev 指针、_next 指针

class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
ObjectWaiter * volatile _next;
ObjectWaiter * volatile _prev;
Thread* _thread;
jlong _notifier_tid;
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};

你可能会说,单链表只需要 _next 指针,不需要 _prev 指针呀
实际上,ObjectWaiter 不仅仅用来表示单链表的节点(用于 _cxq),还用来表示双向链表的节点(用于 _EntryList 和 _WaitSet),这样设计是为了方便复用
当用来表示单链表的节点时,ObjectWaiter 中的 _prev 指针设置为 null

3、锁释放之后会通知排队等待锁的线程去竞争锁

当持有锁的线程释放锁之后,它会从 _EntryList 中取出一个线程,被取出的线程会再次通过 CAS 操作去竞争 Monitor 锁
之所以不是直接让这个线程获取锁而再去竞争锁,是因为此时有可能有新来的线程(非 _EntryList 里的线程)也在竞争锁
如果 _EntryList 中没有线程,我们就会先将 _cxq 中将所有线程一股脑的全部搬移到 _EntryList 中,然后再从 _EntryList 中取线程

那么为什么我们不直接从 _cxq 取线程,而是要将 _cxq 中的线程倒腾到 _EntryList 中再取呢?
实际上这样做的目的是:减少多线程环境下链表存取操作的冲突

  • _cxq 只负责存操作(往链表中添加节点),_EntryList 负责取操作(从链表中删除节点),冲突减少,线程安全性处理就变得简单
  • 多个线程有可能同时竞争锁失败,同时存入 _cxq 中,我们需要通过 CAS 操作来保证往链表中添加节点的线程安全性
  • 而因为只有释放锁的线程才会从 _EntryList 中取线程,所以 _EntryList 的删除节点操作是单线程操作,不存在线程安全问题
  • 但是当 _EntryList 为空时,将所有节点从 _cxq 搬移到 _EntryList 中的操作,需要对 _cxq 加锁

_EntryList 是一个双向链表,其节点定义跟 _cxq 中的节点定义相同,也是 ObjectWaiter,那么为什么 _cxq 是单链表,而 _EntryList 是双向链表呢?

  • _cxq 链表只支持添加节点和从头部删除节点(用于往 _EntryList 中搬移节点),这些操作在单链表中就可以高效执行
  • 实际上,如果只需要实现一个 FIFO 队列的功能,那么 _EntryList 使用单链表实现就够了
    但是为了扩展性,synchronized 预留支持各种等待线程的排队方式,因此使用双向链表操作起来更加方便

刚刚讲了 _cxq、_EntryList,我们顺带讲下 _WaitSet,_WaitSet 并不是用于实现 synchronized 锁,而是用来实现 wait()、notify() 线程同步功能
实际上 _cxq、_EntryList、_WaitSet 非常类似后面讲到的 AQS,因此对于 _cxq、_EntryList 等的操作细节,这里就不展开讲解了,我们留在 AQS 中详细讲解

4、没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片(park 把 ... 搁置)

前面讲到,Java 线程采用 1:1 线程模型来实现,一个 Java 线程会对应一个内核线程
应用程序提交给 Java 线程要执行的代码(Runnable 接口的 run() 方法中的代码),会一股脑地交给对应的内核线程来执行
内核线程在执行的过程中,如果遇到 synchronized 关键字,会执行上述的 1、2、3
如果竞争到锁,则顺利往下执行,如果没有竞争到锁,则内核线程会调用 park() 函数将自己阻塞,这样 CPU 就不再分配时间片给它

在 Linux 操作系统下,park() 函数的大致实现思路如下代码所示,park() 函数使用 Linux 操作系统下 pthread 函数库提供的 pthread_cond_wait() 函数来实现
pthread_cond_wait() 函数就相当于我们后面要讲到的 wait() 函数,其在使用前需要先获取锁
因此 park() 函数还用到了 pthread_mutex_lock() 函数,对于条件变量的使用方法,我们在后面的章节中讲解

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
boolean ready = false;
void park () {
// ...
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex);
}
ready = false;
pthread_mutex_unlock(&mutex);
// ...
}

5、阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片(unpark 取消搁置)

实际上准确的说法是:从等待队列里唤醒的线程会取消阻塞
持有锁的线程在释放锁之后,从 _EntryList 中取出一个线程时,就会调用 unpark() 函数,取消对应内核线程的阻塞状态,这样才能让它去执行竞争锁的代码
在 Linux 操作系统下,unpark() 函数的大致实现思路如下代码所示,通过 pthread_cond_signal() 函数给调用 park() 函数的线程发送信号

void unpark() {
// ...
pthread_mutex_lock(&mutex);
ready = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
// ...
}

6、课后思考题

从本节的讲解中我们发现
Java 实现的 synchronized 锁,大部分逻辑(竞争锁、排队、解锁等)都是自己实现的,只有阻塞内核线程这部分逻辑,是调用的操作系统提供的系统调用
实际上,大部分操作系统都提供了锁,比如 Linux 中 pthread_mutex_lock,已经实现了竞争锁、排队、解锁等等一系列工作
那么 Java 为什么不直接使用操作系统提供的现成的锁来实现 synchronized 呢?

  • 一方面:完全基于操作系统提供的锁来实现 synchronized,就会导致二次开发的灵活性不够
    Java 无法为 synchronized 添加一些有别于操作系统锁的新的功能特性
  • 另一方面:不同操作系统下锁的功能特性均不同,完全依赖操作系统锁来实现 synchronized,很难做到功能统一
    只依赖操作系统提供的阻塞逻辑,其他逻辑均自己实现,更容易摒弃操作系统的差异
posted @   lidongdongdong~  阅读(58)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开