一、前言
上一篇,我们介绍了 Synchronized 的用法、作用和稍微底层一点的原理:
- 何时用的是字节码注入?
- 何时用的是标志位?
- 什么情况下用的是对象锁?
- 什么情况下用的又是类锁?
如果忘记了,请查看《Java小白系列(二):关键字Synchronized》
如果我们但凡要进行多线程数据同步,用 Synchronized 就是完全互斥,那么这个锁在我们看来,就比较重,性能就比较低;因此,JDK在1.6之后,对锁进行了优化,使其变的不那么极端,非必要情况下,不需要完全互斥,接下来,我们就会深入到 JVM 底层来聊聊『Java 锁』这件事。
二、锁的优化
2.1、了解操作系统提供的锁
有过 UNIX/Linux 操作系统方面的编程,那么,我们对于线程如何同步,应该不会陌生:
- 互斥锁(mutex);
- 条件变量 + 睡眠 / 唤醒 机制(不满足条件,则自动进入睡眠,至到条件满足被唤醒);
- 读写锁(允许多个线程同时读,可以认为是共享;但处理写时就是独占,且不允许其它读与写);
- 信号量(sem);
mutex 与 sem 的区别:
- mutex 只允许一个线程进入临界区,而 sem 允许多个线程进入临界区;
- sem 强调的是多个线程进入临界区时,要有序;
- 因此,当有多个资源时,用 sem 会更好;而当资源退化为 1 个时,此时等同于 mutex ;
Java 的 Synchronized 是基于 mutex 而来的,因此,同一时刻只允许有一个线程占用,而其它线程被阻塞。
2.2、Synchronized 锁的存放
多线程同步时,通过锁来控制线程的进入 / 阻塞,那么,锁也需要有个地方保存起来,我们先思考下,一个锁大概会有哪些信息?
- 锁的状态:无锁、有锁;
- 当前持有者(线程);
- 当前等待者(线程);
同时,我们再来想个问题:多个线程并发访问,需要先尝试获取锁,也就是说,多个线程需要去并发申请锁,然后设置锁,那么锁也需要线程同步,这就陷入个死循环。
聪明的 Java 设计师发现这个问题后,想到了一个巧妙的办法:直接将锁的这些信息存到对象中,具体存储的位置则是在对象头中。
2.3、对象头
当 Class 被载入到 JVM 中(即被实例化为对象),该对象在内存中除了本身的数据,还会有一个对象头。
- 对于一般对象而言,其对象头有两种信息:Mark Word 和 Class Metadata Address;
- 而如果是数组,则还会有一个 Array length;
注:
- Mark Word 和 Class Metadata Address 在 32位/64位 系统下分别是 32位/64位;
- Array length 无论何时都是 32位;
2.4、Mark Word
我们看看 32位/64位 系统下 Mark Word 的格式
我们可以看到:
- Mark Word 除了存储锁信息,还会存有 hashcode 和 GC分代年龄;
- 锁的状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁;(程序依次加深)
三、锁的状态介绍
再介绍四种锁之前,我先上个图,来自官方(openjdk):
上图向我们展示了,JDK1.6 对 Synchronized 锁这块的优化,在之前只有无锁与重量级锁两种,因此我们以前会说 Synchronized 较重,性能较差;但是 JDK1.6 对其优化之后,在非极端情况下,Synchronized 是很难『膨胀』到重量级锁状态的。
上面这句话的最后,我用到了一个词:膨胀!锁从无锁到重量级锁的过程,就是一个不断膨胀的过程,且不可逆!
3.1、偏向锁
通常在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时锁就是偏向锁。
如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 记录该线程的 ID;当该线程再次请求锁时,无需做任何同步操作,即需要在获取锁的时候检查一下 Mark Word 的锁标记位 = 偏向锁,并且 threadID = 该线程ID 即可,因此,省去了锁申请的操作。
3.2、轻量级锁
轻量级锁是由偏向锁膨胀(升级)而来,当有第二个线程来申请该对象锁时,偏向锁则立即升级为轻量级锁。
注:此时只是第二个线程来申请锁,并不存在两个线程同时竞争锁;比如两个线程一前一后交替执行同步代码块。
3.3、重量级锁
同理,当同一时间有多个线程竞争锁时,锁就会立即升级为重量级锁,此时申请锁带来的开锁也就变大。
3.4、线程的状态
锁有不同的形态,同样,多线程竞争时,也会有不同的状态。
我们先看看操作系统中线程的各种状态(简单版):
再来看看 Java 线程的状态:
为何我会给出操作系统中线程的状态?其实对比两幅图,我们就能看出来,两者没啥区别,如果抽象一点(更简单的概括线程的状态),只有三种:
- 就绪状态(Ready);
- 运行状态(Running);
- 阻塞/休眠状态(Blocked);
四、再来聊聊 Monitor
在《Java小白系列(二):关键字Synchronized》中,我们简单浅显的谈到了,JVM 使用 Monitor 来监视线程的进入与退出,并在『锁的状态』一节谈到了锁的存储与膨胀;本小节我们再深入一点,在 HotSpot 即 JVM 的实现层面,来看看大致的实现。
4.1、Java线程状态各对象含义
- ContentionList:所有请求锁的线程首先都会进入该竞争队列;
- EntryList:ContentList中有资格成为候选的线程将被移入该队列;
- WaitSet:调用 wait 方法而被阻塞的线程将放入其中;
- OnDeck:任何时刻最多只有一个线程正在竞争锁,我们称之为 OnDeck;
- Owner:锁的所有者;
4.2、虚拟队列:ContentionList
ContentionList 并不是一个真正的队列,而是一个虚拟队列,之所以称为虚拟,是因为它是由 Node 及其 next 指针在逻辑上构成的这么一个看似的队列。我们知道,队列的特点是 FIFO(先进先出),但这里的 ContentionList 却是 LIFO(后进先出),每次新加入的 Node 都在队头,通过 CAS(Compare And Swap,乐观锁,比较后交换)改变第一个节点的指针为新增节点,同时设置新增节点的 next 指向后续节点。
4.3、EntryList
和 ContentionList 作用一个,都是一个等待队列,但是,ContentionList会存在多个线程并发访问,为了降低ContentionList队尾竞争而建立了EntryList,Owner线程在 unlock 时会从 ContentionList中迁移线程到 EntryList,并指定其中某个线程(一般为Head)为 Ready 状态,即 OnDeck。Owner 并不是将锁给 OnDeck 线程,而是将竞争的权利给到了 OnDeck 线程,OnDeck 同样需要竞争锁,虽然牺牲了一定的公平性,但极大的提高了吞吐量,在 HotSpot 中,将 OnDeck 的行为称为『竞争切换』。
4.4、OnDeck
如果 OnDeck 获得了锁,则成为 Owner 线程;如果无法获得则会停留在 EntryList 中,考虑到公平性,其位置不会发生改变(依然在队头)。如果 Owner 线程被 wait 阻塞,则被转移至 WaitSet 队列;如果后续被 notify / notifyAll 唤醒,则再次进入 EntryList 队列。
4.5、Monitor对象的结构:ObjectMonitor
Monitor是个对象,自然是有其自己的结构的(Monitor依赖于OS的实现,会在用户态与内核态之间切换,有一定的性能开销),它的结构如下(参考 openjdk8 hotspot源码):
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 持有该Monitor的线程称之为:Owner
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // ContentionList
FreeNext = NULL ;
_EntryList = NULL ; // 多个线程访问同步块或同步方法,会首先被加入 _EntryList
_SpinFreq = 0 ;
_SpinClock = 0 ; // 自旋!(有时候也叫『忙等待』,说白了就是死循环一定的次数)
OwnerIsThread = 0 ;
}
这里要提下自旋:
上面说了,线程被阻塞会进入到内核状态,导致用户态与内核态切换,增加了锁的性能开销;如果被阻塞的时间较长,那么这点开销也能接受;但是如果被阻塞的时间很短,则该线程可能刚切换到内核态又马上切换回用户态。因此,解决这种很短暂的阻塞的办法就是自旋。
自旋虽然一定程序上避免了锁的性能开销,但也会在短时间内增加 CPU 的负担(例如:循环100次也是消耗 CPU 的时间片的)。
五、锁的其它优化
5.1、锁消除
它是在JIT编译时,通过对运行上下文的扫描,去除不可能存在的竞争的锁,从而消除了竞争关系。
public class Test {
public void method() {
Object object = new Object();
synchronized (object) {
System.out.println("运行时发现不存在竞争");
}
}
}
在运行时会被优化为:
public class Test {
public void method() {
Object object = new Object();
System.out.println("运行时发现不存在竞争");
}
}
5.2、锁粗化
这也是一种优化,通过扩大锁的范围,从而减少了锁的加与释放。
public class Test {
public void method() {
for (int i = 0; i < 10000; i ++) {
synchronized (this) {
System.out.println("运行时发现不存在竞争");
}
}
}
}
被优化为:
public class Test {
public void method() {
synchronized (this) {
for (int i = 0; i < 10000; i++) {
System.out.println("运行时发现不存在竞争");
}
}
}
}
我们可以认为,上面两种优化,有时候可以人为的提前避免,即在编码之前的阶段,有良好的设计,那么这些情况其实是可以避免的。
六、结语
Synchronized 是并发编程中很重要的组成部分,通过 JDK 对其不断的优化,我们也能看出其重要性;只有真正理解其原理及运作才会提升运行时性能。