☆* o(≧▽≦)o *☆嗨~我是小奥🍹
📄📄📄个人博客:小奥的博客
📄📄📄CSDN:个人CSDN
📙📙📙Github:传送门
📅📅📅面经分享(牛客主页):传送门
🍹文章作者技术和水平有限,如果文中出现错误,希望大家多多指正!
📜 如果觉得内容还不错,欢迎点赞收藏关注哟! ❤️
文章目录
- Java并发之synchronized详解
- 一、synchronized
- 二、synchronized原理
- (1)对象头
- (2)Monitor
- (3)工作流程
- 三、synchronized优化
- (1)锁升级
- ① 偏向锁
- ② 轻量级锁
- ③ 锁膨胀
- ④ 重量级锁
- (2) 锁优化
- ① 自旋锁
- ② 锁消除
- ③ 锁粗化
Java并发之synchronized详解
一、synchronized
synchronized
是Java中的关键字,是一种同步锁,他修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为同步方法块,其作用的范围是使用大括号括起来的代码,作用的对象是调用这个代码块的对象。
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象。
- 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象。
- 修饰一个类,其作用的范围是synchronized后面括起来的部分,作用的对象是这个类的所有对象。
无论synchronized关键字加在方法上还是对象上,
- 如果它作用的对象是非静态的,则它获取的是对象;
- 如果它作用的对象是一个静态方法或一个类,则它获取的锁是对于类的,该类的所有对象持有同一把锁。
实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免不必要的同步控制。
二、synchronized原理
(1)对象头
在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头、示例数据以及填充数据。
- 对象头:在HotSpot虚拟机中,对象头被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针),如果是数组,那么还会有数组长度。
- 实例数据:存储类的属性数据信息,包括父类的属性信息,这部分内存按4字节填充。
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,所以填充数据不是必要存在的,仅仅是为了字节对齐。
而Java对象头则是实现synchronized的锁对象的基础。
(2)Monitor
synchronized
用的锁是存在 Java 对象头里的。
- 如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头;
- 如果对象是非数组类型,则用 2 字宽存储对象头。
在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit.
Monitor的结构
Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。另外,数组还会有长度的标识。
在运行期间,Mark Word里存储的数据会随着锁的标志位的变化而变化。
(3)工作流程
1、开始时Monitor
中的owner
为null
2、当线程1执行synchronized(obj)
时就会将Monitor
的所有者owner
置为线程1,Monitor
中只能有1个Owner
,obj对象中的Mark Word
指向Monitor
,把对象原有的Mark Word
存储线程栈中的锁记录中。
3、线程1上锁的过程中,如果有其他线程来执行synchronized(obj)
,就会进入EntryList BLOCKED
。
4、线程1执行完同步代码块的内容,根据 obj
对象头中 Monitor
地址寻找,设置 Owner
为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
。
5、唤醒 EntryList
中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞。
(4)字节码层面分析
public static void main(String[] args) {Object lock = new Object();synchronized (lock) {System.out.println("ok");}
}
0 new #2 <java/lang/Object> // new Object3 dup4 invokespecial #1 <java/lang/Object.<init> : ()V> // invokespecial <init>:()V,非虚方法7 astore_1 // lock引用 -> lock8 aload_1 // lock (synchronized开始)9 dup // 一份用来初始化,一份用来引用
10 astore_2 // lock引用 -> slot 2
11 monitorenter // 【将 lock对象 MarkWord 置为 Monitor 指针】
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
15 ldc #4 <ok>
17 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
20 aload_2 // slot 2(lock引用)
21 monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
22 goto 30 (+8)
25 astore_3 // any -> slot 3
26 aload_2 // slot 2(lock引用)
27 monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
28 aload_3
29 athrow
30 return
实现原理:同步代码块通过moniterenter
、moniterexit
关联到一个monitor
对象,进入时设置Owner
为当前线程,计数+1,退出-1,除了正常入口的moniterenter
,还在异常入口的地方加入了moniterexit
指令。
三、synchronized优化
(1)锁升级
synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化。
在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
① 偏向锁
解决问题:大多数情况下,**线程不仅不存在多线程竞争,而且锁总是由同一线程多次获得,在没有其他线程竞争锁时,线程每次重入锁仍然需要进行CAS操作,造成性能的损耗。**为了让多线程获得锁的代价更低而引入了偏向锁。
偏向锁加锁:
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:
- 当一个线程访问同步块并获取锁时,会在对象头(标志位是否是01)和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用CAS 将对象头的偏向锁指向当前线程。
偏向锁撤销:
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。
- 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;
- 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程,从安全点继续执行代码。
偏向锁关闭:
// 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0 // 关闭偏向锁的延迟
-XX:-UseBiasedLoacking=false // 关闭偏向锁
JDK8延迟4每秒开启偏向锁的原因:当程序刚开始执行时,会有很多的线程来争抢锁,如果开启偏向锁效率反而会降低。
撤销偏向锁的场景
- 调用对象的hashCode:偏向锁的对象MarkWord中存储的是线程的id,调用hashCode导致偏向锁被撤销;
- 当有其他线程来竞争锁的时候,会将偏向锁升级为轻量锁;
- 调用wait/notify,需要申请Monitor,进入WaitSet。
批量撤销和批量重偏向:
从偏向锁的加锁解锁过程中就可以看出,当只有一个线程反复获取锁的是皇后,偏向锁带来的性能开销可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。
批量撤销(解决场景):在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
批量重偏向(解决场景):一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
原理:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。
每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
② 轻量级锁
加锁:线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word
。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 创建锁记录对象(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word
- 让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
- 如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁
- 如果 CAS 失败,有两种情况:
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
解锁:轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word
替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
- 当退出 synchronized 代码块(解锁时)
- 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
- 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
③ 锁膨胀
在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的
EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒
EntryList
中BLOCKED
线程。
④ 重量级锁
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
(2) 锁优化
① 自旋锁
当一个线程尝试获取锁(重量级锁)时,如果锁已经被其他线程持有,该线程会进入自旋状态,不断地重试获取锁,直到获取到为止。自旋锁避免了线程切换带来的开销,但是如果锁被持有的时间较长,自旋锁可能会导致CPU资源的浪费。
② 锁消除
锁消除是一种编译器优化技术,编译器可以在编译过程中分析代码,并根据程序的特性来消除一些不必要的锁操作。
比如在单线程程序中使用锁,锁会变成多余的开销,编译器可以消除这些锁操作,从而提高程序的性能。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)
③ 锁粗化
锁粗化是一种优化技术,可以将多个连续的锁操作合并成一个锁操作。例如,如果程序中存在多个连续的对同一个锁的加锁和解锁操作,锁粗化可以将它们合并为一个锁操作,从而减少锁的竞争和开销,提高程序的性能。