文章目录
- 1. synchronized概述
- 2. synchronized 的实现原理
- 2.1 Java对象组成
- 2.2 Monitor
- 2.3 从字节码角度看synchronized
- 3. 锁升级
- 3.1 偏向锁
- 3.2 轻量级锁
1. synchronized概述
synchronized
是一个悲观锁,可以实现线程同步,在多线程的环境下,需在操作同步资源的时候先加锁,避免共享资源出现问题。
因为加锁可以使得一个线程在一个时间点内只有一个线程可以访问,这样增加了安全性。
但是这样却损失了程序的执行性能,因为在加锁、抢夺锁、释放锁需要从用户态切换成内核态,属于操作系统层面的,因此比较消耗性能。
于是,在JDK6之后便引入了“偏向锁”和“轻量级锁”,共有4种锁状态,级别由低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。
synchronized
可以用在实例方法、静态方法、代码块上
- 修饰实例方法,对当前实例对象this加锁
- 修饰静态方法,对当前类的Class对象加锁
- 修饰代码块,指定加锁对象,对给定对象加锁
2. synchronized 的实现原理
想要了解synchronized 的实现原理,就要先知道Java对象是怎么存放的。因为synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步。
2.1 Java对象组成
Java对象分为三部分:
- 对象头,包括**
Mark Word
(标记字段)** 和Klass Pointer
(类型指针)Mark Word
用来存储对象自身的运行时数据Klass Point
是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 实例变量,存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐
- 填充字节,由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
Java对象有这三部分,锁就在对象的对象头Mark Word
,Mark Word
的结构如下,在64位虚拟机下,MarkWord是64bit大小的,其存储结构如下所示
2.2 Monitor
Monitor
可以理解为是一个同步工具或者同步器,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。
Monitor
是线程私有的数据结构,每一个线程都有一个可用 monitor record
列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor
关联,同时 monitor
中有一个 Owner
字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
ObjectMonitor() {
_count = 0; //记录数
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //调用wait后,线程会被加入到_WaitSet
_EntryList = NULL ; //等待获取锁的线程,会被加入到该列表
}
对于一个被synchronized
修饰的方法和代码块来说
- 当多个线程同时访问一个方法时,这些线程会被放入
EntryList
队列中,此时这些线程处于阻塞(Blocked)状态。 - 当一个线程获取到了对象的
Monitor
后,就进入可运行(running)状态,执行方法,此时ObjectMonitor
对象的_owner
就会指向当前线程,表示当前线程获取到了锁。并且锁的计数器_count
需要加一。 - 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,
ObjectMonitor
对象的owner变为null,count减1,同时线程进入WaitSet
队列,直到有线程调用notify()
方法唤醒该线程,则该线程进入EntryList
队列,竞争到锁再进入_Owner区 - 当线程释放锁的时候,线程会释放
Monitor
对象,锁的计数器_count
需要减一,当锁的计数器为0的时候,就会彻底释放锁。
Monitor
对象存在于每个Java对象的对象头中,synchronized锁便是通过这种方式获取锁的。
2.3 从字节码角度看synchronized
这里有一个加锁的代码
public class Test {
public int count = 0;
public void addOne() {
synchronized (this) {
count++;
}
}
}
将这个Java程序编译成字节码class文件
public void addOne();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // 进入同步方法
4: aload_0
5: dup
6: getfield #2 // Field count:I
9: iconst_1
10: iadd
11: putfield #2 // Field count:I
14: aload_1
15: monitorexit // 退出同步方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 退出同步方法
22: aload_2
23: athrow
24: return
Exception table:
可见,字节码底层是通过monitorenter
进入同步代码块的,通过monitorexit
指令退出同步代码块的。
而monitorexit
指令有两个,第一个是正常退出同步代码块的情况。第二个则是由于同步代码块出现异常而出现释放锁的情况,这种设计可以有效避免死锁。
3. 锁升级
为什么会出现锁升级呢?
一开始,synchronized
无论是大并发还是小并发都属于重量级锁,效率低下,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
于是,在JDK6之后,在JVM层面对synchronized
进行了优化,为了减少锁的获取和释放所带来的性能消耗,引入了“偏向锁”和“轻量级锁”。也就出现了锁升级的情况。
注意,锁只可以升级但不能降级,但是偏向锁状态可以重置为无锁状态。
3.1 偏向锁
偏向锁的出现,是为了应对同一个线程多次获取一个锁的情况的出现,因此没有必要每次都要竞争锁,从而降低获取锁的代价。
偏向锁的核心思想是:如果一个线程获取到了锁,那么就进入偏向模式,此时Mark Word
结构也变为偏向锁模式,当这个线程再次来请求获取锁,则无需在任何同步操作,直接获取锁。
加锁的时候,如果该锁对象支持偏向锁,那么Java虚拟机会通过CAS
操作,将当前线程的地址也就是线程ID记录到对象头的标记字段,并且将标记字段的最后三位设置为101。
如果前面通过CAS加锁、解锁的时候,对比当前线程ID和Java对象头的线程ID,如果一直,就可以直接获取锁。
如果不一致,说明存在其他线程需要竞争锁对象,那么就需要查看Java对象头的记录的线程是否存活。
如果没有存活则会将锁对象重置为无锁状态,其他线程都可以竞争将其设置为偏向锁。
如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
3.2 轻量级锁
轻量级锁考虑的是竞争对象的线程不多,而且线程持有锁的时间也不长的情景。
轻量级锁的获取主要有两个情况
- 当偏向锁关闭的时候
- 由于多个线程竞争导致偏向锁升级为轻量级锁
线程A在获取轻量级锁的时候,会先把锁对象的Mark Word复制一份给线程A的栈帧中创建的用于存储锁记录的空间(Displaced Mark Word),然后使用CAS把对象头的内存替换成线程A存储的锁记录。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
如果自旋次数到了线程B还没有释放锁,或者线程B还在执行,线程A还在自旋等待,这时又有一个线程C过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
轻量级锁的释放
在释放锁时,当前线程会使用CAS操作将·Displaced Mark Word
内容复制回锁的MarkWord
里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
参考:
- 【JUC】10. synchronized与锁升级_synchronized性能下降_起名方面没有灵感的博客-CSDN博客
- 详解Synchronized底层实现,锁升级的具体过程,与Lock的区别 - 掘金 (juejin.cn)
- synchronized四种锁状态的升级 - 掘金 (juejin.cn)
- 大彻大悟synchronized原理,锁的升级 - 掘金 (juejin.cn)