34、synchronized(上)
上一节提到,解决 "多线程执行非原子操作" 导致的线程不安全问题,最常用的解决方案便是加锁
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() 函数真的线程不安全
为了让 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 转账,如下图所示
红色标记部分为复合操作(先读再改后写),并发执行会存在线程安全问题
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 是一个可变字段,在不同的场景下,记录的内容和作用均不同,关于这一点,我们留在下一节中深入探讨
4.2、如何实现加锁、解锁的
了解了 Monitor 锁大概的样子,以及如何跟对象关联之后,我们再来看下,Monitor 锁是如何实现加锁、解锁的?
实际上,不管是后面要讲到的 JUC(java.util.concurrent) Lock,还是现在正在讲的 Java 内置 synchronized
它们作为多线程的互斥锁,所包含的基本功能是一致的,主要有以下几点
- 多个线程竞争获取锁
- 没有获取到锁的线程排队等待获取锁
- 锁释放之后会通知排队等待锁的线程去竞争锁
- 没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片
- 阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片
总结一下
- 多个线程竞争获取锁
- 没有获取到锁的线程:排队等待获取锁,会阻塞,并且对应的内核线程不再分配时间片
- 锁释放之后会通知排队等待锁的线程去竞争锁:阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片
前面讲到,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,很难做到功能统一
只依赖操作系统提供的阻塞逻辑,其他逻辑均自己实现,更容易摒弃操作系统的差异
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17481644.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步