# 面经手册 · 第15篇《码农会锁,synchronized 解毒,剖析源码深度分析!》
作者:小傅哥
博客:https://bugstack.cn (opens new window)
沉淀、分享、成长,让自己和他人都能有所收获!😄
# 一、前言
感觉什么都不会,从哪开始呀!
这是最近我总能被问到的问题,也确实是。一个初入编程职场的新人,或是一个想重新努力学习的老司机,这也不会,那也不会,总会犯愁从哪开始。
讲道理,毕竟 Java 涉及的知识太多了,要学应该是学会学习的能力,而不是去背题、背答案,拾人牙慧是不会有太多收益的。
学习的过程要找对方法,遇到问题时最好能自己想想,你有哪些方式学会这些知识。是不感觉即使让你去百度搜,你都不知道应该拿哪个关键字搜!只能拿着问题直接找人问,这样缺少思考,缺少大脑撞南墙的过程,其实最后也很难学会。
所以,你要学会的是自我学习的能力,之后是从哪开始都可以,重要的是开始和坚持!
# 二、面试题
谢飞机,小记
,周末逛完奥特莱斯,回来就跑面试官家去了!
谢飞机:duang、duang、duang,我来了!
面试官:来的还挺准时,洗洗手吃饭吧!
谢飞机:嘿嘿...
面试官:你看我这块鱼豆腐,像不像 synchronized 锁!
谢飞机:啊!?
面试官:飞机,正好问你。synchronized、volatile,有什么区别呀?
谢飞机:嗯,volatile 保证可见性,synchronized 保证原子性!
面试官:那不用 volatile,只用 synchronized 修饰方式,能保证可见性吗?
谢飞机:这...,我没验证过!
面试官:吃吧,吃吧!一会给你个 synchronized 学习大纲,照着整理知识点!
# 三、synchronized 解毒
# 1. 对象结构
# 1.1 对象结构介绍
HotSpot虚拟机 markOop.cpp 中的 C++ 代码注释片段,描述了 64bits 下 mark-word 的存储状态,也就是图 15-1 的结构示意。
这部分的源码注释如下:
64 bits:
--------
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)
unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
2
3
4
5
6
7
8
9
10
11
源码地址:jdk8/hotspot/file/vm/oops/markOop.hpp (opens new window)
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)
、实例数据(Instance Data)
和对齐填充(Padding)
。
- mark-word:对象标记字段占8个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标记位,偏向锁标记位、分代年龄等。
- Klass Pointer:Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩(
-XX:-UseCompressedOops
)后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址。 - 对象实际数据:包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比特位、int占4个字节32比特位。
- 对齐:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理系统要求对象起始地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
另外,在mark-word锁类型标记中,无锁,偏向锁,轻量锁,重量锁,以及GC标记,5种类中没法用2比特标记(2比特最终有4种组合00
、01
、10
、11
),所以无锁、偏向锁,前又占了一位偏向锁标记。最终:001为无锁、101为偏向锁。
# 1.2 验证对象结构
为了可以更加直观的看到对象结构,我们可以借助 openjdk
提供的 jol-core
进行打印分析。
引入POM
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-cli</artifactId>
<version>0.14</version>
</dependency>
2
3
4
5
6
测试代码
public static void main(String[] args) {
System.out.println(VM.current().details());
Object obj = new Object();
System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
2
3
4
5
6
# 1.2.1 指针压缩开启(默认)
运行结果
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- Object对象,总共占16字节
- 对象头占 12 个字节,其中:mark-down 占 8 字节、Klass Point 占 4 字节
- 最后 4 字节,用于数据填充找齐
# 1.2.2 指针压缩关闭
在 Run-->Edit Configurations->VM Options
配置参数 -XX:-UseCompressedOops
关闭指针压缩。
运行结果
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 12 0c 53 (00000001 00010010 00001100 01010011) (1393299969)
4 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2)
8 4 (object header) 00 1c b9 1b (00000000 00011100 10111001 00011011) (465116160)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
2
3
4
5
6
7
8
- 关闭指针压缩后,mark-word 还是占 8 字节不变。
- 重点在类型指针 Klass Point 的变化,由原来的 4 字节,现在扩增到 8 字节。
# 1.2.3 对象头哈希值存储验证
接下来,我们调整下测试代码,看下哈希值在对象头中具体是怎么存放的。
测试代码
public static void main(String[] args) {
System.out.println(VM.current().details());
Object obj = new Object();
System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
2
3
4
5
6
- 改动不多,只是把哈希值和对象打印出来,方便我们验证对象头关于哈希值的存放结果。
运行结果
- 如图 15-3,对象的哈希值是16进制的,
0x2530c12
- 在对象头哈希值存放的结果上看,也有对应的数值。只不过这个结果是倒过来的。
关于这个倒过来的问题是因为,大小端存储导致;
- Big-Endian:高位字节存放于内存的低地址端,低位字节存放于内存的高地址端
- Little-Endian:低位字节存放于内存的低地址端,高位字节存放于内存的高地址端
mark-down结构
如图 15-5 最右侧的 3 Bit(1 Bit标识偏向锁,2 Bit描述锁的类型)是跟锁类型和GC标记相关的,而 synchronized 的锁优化升级膨胀就是修改的这三位上的标识,来区分不同的锁类型。从而采取不同的策略来提升性能。
# 1.3 Monitor 对象
在HotSpot虚拟机中,monitor是由C++中ObjectMonitor实现。
synchronized 的运行机制,就是当 JVM 监测到对象在不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
那么三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。当一个 Monitor 被某个线程持有后,它便处于锁定状态。
Monitor 主要数据结构如下:
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储 Monitor 对象
_owner = NULL; // 持有当前线程的 owner
_WaitSet = NULL; // 处于wait状态的线程,会被加入到 _WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 单向列表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
源码地址:jdk8/hotspot/file/vm/runtime/objectMonitor.hpp (opens new window)
- ObjectMonitor,有两个队列:
_WaitSet
、_EntryList
,用来保存 ObjectWaiter 对象列表。 - _owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count - 1。同时该等待线程进入 _WaitSet 中,等待被唤醒。
锁🔒执行效果如下:
如图 15-06,每个 Java 对象头中都包括 Monitor 对象(存储的指针的指向),synchronized 也就是通过这一种方式获取锁,也就解释了为什么 synchronized() 括号里放任何对象都能获得锁🔒!
# 2. synchronized 特性
# 2.1 原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。
案例代码
private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
for (int i1 = 0; i1 < 10000; i1++) {
add();
}
});
thread.start();
}
// 等10个线程运行完毕
Thread.sleep(1000);
System.out.println(counter);
}
public static void add() {
counter++;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这段代码开启了 10 个线程来累加 counter,按照预期结果应该是 100000。但实际运行会发现,counter 值每次运行都小于 10000,这是因为 volatile 并不能保证原子性,所以最后的结果不会是10000。
修改方法 add(),添加 synchronized:
public static void add() {
synchronized (AtomicityTest.class) {
counter++;
}
}
2
3
4
5
这回测试结果就是:100000 了!
因为 synchronized 可以保证统一时间只有一个线程能拿到锁,进入到代码块执行。
反编译查看指令码
javap -v -p AtomicityTest
public static void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: ldc #12 // class org/itstack/interview/AtomicityTest
2: dup
3: astore_0
4: monitorenter
5: getstatic #10 // Field counter:I
8: iconst_1
9: iadd
10: putstatic #10 // Field counter:I
13: aload_0
14: monitorexit
15: goto 23
18: astore_1
19: aload_0
20: monitorexit
21: aload_1
22: athrow
23: return
Exception table:
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
同步方法
ACC_SYNCHRONIZED
这是一个同步标识,对应的16进制值是 0x0020
这10个线程进入这个方法时,都会判断是否有此标识,然后开始竞争 Monitor 对象。
同步代码
monitorenter
,在判断拥有同步标识ACC_SYNCHRONIZED
抢先进入此方法的线程会优先拥有 Monitor 的 owner ,此时计数器 +1。monitorexit
,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。
# 2.2 可见性
在上一章节 volatile 篇中,我们知道它保证变量对所有线程的可见性。最终的效果就是在添加 volatile 的属性变量时,线程A修改值后,线程B使用此变量可以做出相应的反应,比如 while(!变量)
退出。
那么,synchronized
具备可见性吗,我们做给例子。
public static boolean sign = false;
public static void main(String[] args) {
Thread Thread01 = new Thread(() -> {
int i = 0;
while (!sign) {
i++;
add(i);
}
});
Thread Thread02 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException ignore) {
}
sign = true;
logger.info("vt.sign = true while (!sign)")
});
Thread01.start();
Thread02.start();
}
public static int add(int i) {
return i + 1;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这是两个线程操作一个变量的例子,因为线程间对变量 sign
的不可见性,线程 Thread01 中的 while (!sign) 会一直执行,不会随着线程 Thread02 修改 sign = true 而退出循环。
现在我们给方法 add 添加 synchronized
关键字修饰,如下:
public static synchronized int add(int i) {
return i + 1;
}
2
3
添加后运行结果:
23:55:33.849 [Thread-1] INFO org.itstack.interview.VisibilityTest - vt.sign = true while (!sign)
Process finished with exit code 0
2
3
可以看到当线程 Thread02 改变变量 sign = true 后,线程 Thread01 立即退出了循环。
注意:不要在方法中添加 System.out.println() ,因为这个方法中含有 synchronized 会影响测试结果!
那么为什么添加 synchronized 也能保证变量的可见性呢?
因为:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中。
- 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
- volatile 的可见性都是通过内存屏障(Memnory Barrier)来实现的。
- synchronized 靠操作系统内核互斥锁实现,相当于 JMM 中的 lock、unlock。退出代码块时刷新变量到主内存。
# 2.3 有序性
as-if-serial
,保证不管编译器和处理器为了性能优化会如何进行指令重排序,都需要保证单线程下的运行结果的正确性。也就是常说的:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。
这里有一段双重检验锁(Double-checked Locking)的经典案例:
public class Singleton {
private Singleton() {
}
private volatile static Singleton instance;
public Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
为什么,synchronized 也有可见性的特点,还需要 volatile 关键字?
因为,synchronized 的有序性,不是 volatile 的防止指令重排序。
那如果不加 volatile 关键字可能导致的结果,就是第一个线程在初始化初始化对象,设置 instance 指向内存地址时。第二个线程进入时,有指令重排。在判断 if (instance == null) 时就会有出错的可能,因为这会可能 instance 可能还没有初始化成功。
# 2.4 可重入性
synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁🔒。
那么我们就写一个例子,来证明这样的情况。
public class ReentryTest extends A{
public static void main(String[] args) {
ReentryTest reentry = new ReentryTest();
reentry.doA();
}
public synchronized void doA() {
System.out.println("子类方法:ReentryTest.doA() ThreadId:" + Thread.currentThread().getId());
doB();
}
private synchronized void doB() {
super.doA();
System.out.println("子类方法:ReentryTest.doB() ThreadId:" + Thread.currentThread().getId());
}
}
class A {
public synchronized void doA() {
System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
测试结果
子类方法:ReentryTest.doA() ThreadId:1
父类方法:A.doA() ThreadId:1
子类方法:ReentryTest.doB() ThreadId:1
Process finished with exit code 0
2
3
4
5
这段单例代码是递归调用含有 synchronized 锁的方法,从运行正常的测试结果看,并没有发生死锁。所有可以证明 synchronized 是可重入锁。
synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
# 3. 锁升级过程
关于 synchronized 锁🔒升级有一张非常完整的图,可以参考:
synchronized 锁有四种交替升级的状态:无锁、偏向锁、轻量级锁和重量级,这几个状态随着竞争情况逐渐升级。
# 3.1 偏向锁
synchronizer源码:/src/share/vm/runtime/synchronizer.cpp (opens new window)
// NOTE: must use heavy weight monitor to handle jni monitor exit
void ObjectSynchronizer::jni_exit(oop obj, Thread* THREAD) {
TEVENT (jni_exit) ;
if (UseBiasedLocking) {
Handle h_obj(THREAD, obj);
BiasedLocking::revoke_and_rebias(h_obj, false, THREAD);
obj = h_obj();
}
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
ObjectMonitor* monitor = ObjectSynchronizer::inflate(THREAD, obj);
// If this thread has locked the object, exit the monitor. Note: can't use
// monitor->check(CHECK); must exit even if an exception is pending.
if (monitor->check(THREAD)) {
monitor->exit(true, THREAD);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- UseBiasedLocking 是一个偏向锁检查,1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是
XX:-UseBiasedLocking=false
偏斜锁会延缓 JIT 预热进程,所以很多性能测试中会显式地关闭偏斜锁,偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。
# 3.2 轻量级锁
当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),JVM虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
# 3.3 自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
自旋锁的默认大小是10次,可以调整:-XX:PreBlockSpin
如果自旋n次失败了,就会升级为重量级的锁。重量级的锁,在 1.3 Monitor 对象中已经介绍。
# 3.4 锁会降级吗?
之前一直了解到 Java 不会进行锁降级,但最近整理了大量的资料发现锁降级确实是会发生。
When safepoints are used?
Below are few reasons for HotSpot JVM to initiate a safepoint:
Garbage collection pauses
Code deoptimization
Flushing code cache
Class redefinition (e.g. hot swap or instrumentation)
Biased lock revocation
Various debug operation (e.g. deadlock check or stacktrace dump)
2
3
4
5
6
7
Biased lock revocation
,当 JVM 进入安全点 SafePoint (opens new window)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
# 四、总结
- 本章关于
synchronized
锁涉及到了较多的C++源码分析学习,源码地址:https://github.com/JetBrains/jdk8u_hotspot (opens new window) - 关于锁的细节挖掘除了本文提到的还有很多知识点可以继续学习,可以结合 ifeve、并发编程、深入理解JVM虚拟机,等系列知识整理。
- 学习过程中结合C++源代码中关于锁的实现,更容易理解可能原本晦涩难懂的概念。在结合实际的案例验证,会容易接受这部分知识。
- 好了,这篇就写到这里了,如果有观点和文章不准确的表达欢迎留言,互相学习,互相扫盲,互相进步。
# 五、傅诗一手
- 会所🏢,里的码农会锁。
- 拥挤🤼♂️,就需加价升级。
- 项目🤯,按摩对象头皮。
- 效果🤨,可见原子有序。