作者:小傅哥 博客:https://
沉淀、分享、成长,让自己和他人都能有所收获!😄
一、前言
感觉什么都不会,从哪开始呀!
这是最近我总能被问到的问题,也确实是。一个初入编程职场的新人,或是一个想重新努力学习的老司机,这也不会,那也不会,总会犯愁从哪开始。
讲道理,毕竟 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)
源码地址 :jdk8/hotspot/file/vm/oops/markOop.hpp
HotSpot虚拟机中 ,对象在内存中存储的布局可以分为三块区域:对象头(Header)
、实例数据(Instance Data)
和对齐填充(Padding)
。
mark-word:对象标记字段占4个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标记位,偏向锁标记位、分代年龄等。 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>
测试代码
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 ( ) ) ;
}
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
Object对象,总共占16字节 对象头占 12 个字节,其中:mark-word 占 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 0 c 53 ( 00000001 00010010 00001100 01010011 ) ( 1393299969 )
4 4 ( object header) 02 00 00 00 ( 00000010 00000000 00000000 00000000 ) ( 2 )
8 4 ( object header) 00 1 c b9 1 b ( 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
关闭指针压缩后,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 ( ) ) ;
}
改动不多,只是把哈希值和对象打印出来,方便我们验证对象头关于哈希值的存放结果。
运行结果
如图 15-3,对象的哈希值是16进制的,0x2530c12
在对象头哈希值存放的结果上看,也有对应的数值。只不过这个结果是倒过来的。
关于这个倒过来的问题是因为,大小端存储导致;
Big-Endian:高位字节存放于内存的低地址端,低位字节存放于内存的高地址端 Little-Endian:低位字节存放于内存的低地址端,高位字节存放于内存的高地址端
mark-word结构
如图 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 ;
}
源码地址 :jdk8/hotspot/file/vm/runtime/objectMonitor.hpp
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++ ;
}
这段代码开启了 10 个线程来累加 counter,按照预期结果应该是 100000。但实际运行会发现,counter 值每次运行都小于 10000,这是因为 volatile 并不能保证原子性,所以最后的结果不会是10000。
修改方法 add(),添加 synchronized:
public static void add ( ) {
synchronized ( AtomicityTest. class ) {
counter++ ;
}
}
这回测试结果就是: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:
同步方法
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 ;
}
这是两个线程操作一个变量的例子,因为线程间对变量 sign
的不可见性,线程 Thread01 中的 while (!sign) 会一直执行,不会随着线程 Thread02 修改 sign = true 而退出循环。
现在 我们给方法 add 添加 synchronized
关键字修饰,如下:
public static synchronized int add ( int i) {
return i + 1 ;
}
添加后运行结果 :
23 : 55 : 33.849 [ Thread- 1 ] INFO org. itstack. interview. VisibilityTest - vt. sign = true while ( ! sign)
Process finished with exit code 0
可以看到当线程 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;
}
}
为什么 ,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 ( ) ) ;
}
}
测试结果
子类方法:ReentryTest. doA ( ) ThreadId:1
父类方法:A. doA ( ) ThreadId:1
子类方法:ReentryTest. doB ( ) ThreadId:1
Process finished with exit code 0
这段单例代码是递归调用含有 synchronized 锁的方法,从运行正常的测试结果看,并没有发生死锁。所有可以证明 synchronized 是可重入锁。
synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以 ,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
3. 锁升级过程
关于 synchronized 锁🔒升级有一张非常完整的图,可以参考:
synchronized 锁有四种交替升级的状态:无锁、偏向锁、轻量级锁和重量级,这几个状态随着竞争情况逐渐升级。
3.1 偏向锁
synchronizer源码:/src/share/vm/runtime/synchronizer.cpp
// 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) ;
}
}
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)
Biased lock revocation
,当 JVM 进入安全点 SafePoint 的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
四、总结
本章关于 synchronized
锁涉及到了较多的C++源码分析学习,源码地址:https://github.com/JetBrains/jdk8u_hotspot 关于锁的细节挖掘除了本文提到的还有很多知识点可以继续学习,可以结合 ifeve、并发编程、深入理解JVM虚拟机,等系列知识整理。 学习过程中结合C++源代码中关于锁的实现,更容易理解可能原本晦涩难懂的概念。在结合实际的案例验证,会容易接受这部分知识。 好了,这篇就写到这里了,如果有观点和文章不准确的表达欢迎留言,互相学习,互相扫盲,互相进步。
五、傅诗一手
会所🏢,里的码农会锁。 拥挤🤼♂️,就需加价升级。 项目🤯,按摩对象头皮。 效果🤨,可见原子有序。
六、系列推荐
认知自己的技术栈盲区 握草,你竟然在代码里下毒! 一次代码评审,差点过不了试用期! ThreadLocal 技术栈深度学习 HashMap核心知识,扰动函数、负载因子、扩容链表拆分