文章目录
- 一 前言
- 二 底层实现原理
- 三 锁升级过程
- 3.1 对象内存结构
- 3.2 锁升级过程
- 四 总结
一 前言
synchronized
是JDK自带的一个关键字,用于在多线程的情况下,保证线程安全;在JDK1.5之前是一个重量级锁,1.6之后进行了优化,性能有很大提升。synchronized可以用来同步方法
、同步代码块
、同步静态方法
,具体用法可参见《Java多线程(三)——synchronized》,本文主要研究synchronized底层实现原理及锁升级过程。
二 底层实现原理
public class SynchronizedTest {
public void method() {
synchronized (this) {
System.out.println("start");
}
}
}
将这段代码反编译看看 javap -c SynchronizedTest.class
:
看到反编译后的信息中出现了1个monitorenter和2个monitorexit。这就是为什么我们在使用synchronized
的时候不用释放锁,jvm底层通过monitorexit关键字帮我们做了锁释放的工作。
Synchronized底层通过⼀个monitor的对象来完成,每个对象有⼀个监视器锁(monitor)。当monitor被占⽤时就会处于锁定状态,线程执⾏monitorenter指令时尝 试获取monitor的所有权,过程如下:
- (1)如果monitor的进⼊数为0,则该线程进⼊monitor,然后将进⼊数设置为1,该线程即为monitor的所有者。
- (2)如果线程已经占有该monitor,只是重新进⼊,则进⼊monitor的进⼊数加1。
- (3)如果其他线程已经占⽤了monitor,则该线程进⼊阻塞状态,直到monitor的进⼊数为0,再重新尝试获取monitor的所有权。
执⾏monitorexit的线程必须是object所对应的monitor的所有者。指令执⾏时,monitor的进⼊数减1,如果减1 后进⼊数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获 取这个monitor的所有权。
这样也就不难理解Synchronized是可重入锁
了。
三 锁升级过程
3.1 对象内存结构
Java的实例对象在内存中的组成包括:对象头Hearder、实例数据、内存填充;
- 对象头又分为两部分:
Mark Word
、Class对象指针
; - 实例数据:用于存放该对象的实例数据;
- 内存填充:64位的HotSpot要求Java对象地址按8字节对齐,即每个对象所占内存的字节数必须是8字节的整数倍。因此Java对象需要通过内存填充来满足对齐要求
可以通过JOL打印对象在内存中的布局情况:
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
private static Object o;
public static void main(String[] args) {
o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
从打印结果看对象头包含了12个字节分为3⾏,其中前2⾏其实就是markword,第三⾏就是klass指针(而Class对象指针则指向该实例的Class对象,在开启指针压缩的情况下占用4个字节,否则占8个字节)。Object对象没有实例数据。内存填充占4个字节,new Object()创建的对象在内存中占16
个字节。
3.2 锁升级过程
从对象在内存中的布局我们可以知道:在64位的HotSpot虚拟机下,Mark Word占8个字节,记录了Hash Code、GC信息、锁信息等相关信息。
Jdk1.5之前⽤户需要跟内核态申请锁,然后内核态还会给⽤户态。这个过程是⾮常消耗时间的,导致锁效率特别低。把jvm就可以完成的锁操作拉取出来提升 效率,所以也就有了锁优化。锁优化后的升级过程为:
上面讲到锁的四种状态,并且会因实际情况进行锁升级,升级方向是:无锁——>偏向锁——>轻量级锁——>重量级锁
,并且升级方向不可逆。
用JOL打印没加锁和加锁后的对象头信息:
private static Object o;
public static void main(String[] args) {
o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
System.out.println("------------ 加锁后的变化 -------------");
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
从打印结果可以看到,加锁后改变了对象头信息。所以锁信息也是存在于对象的mark word中的:
- 当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;
- 当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;
- 当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。
(1)偏向锁:⽇常⽤的synchronized锁过程中70%-80%
的情况下,⼀般都只有⼀ 个线程去拿锁,例如我们常使⽤的System.out.println、StringBuffer,虽然底层加了syn锁,但是基本没有多线程 竞争的情况。那么这种情况下,没有必要升级到轻量级锁级别了。
偏向的意义在于:第⼀个线程拿到锁,将⾃⼰的 线程信息标记在锁上,下次进来就不需要在拿去拿锁验证了。如果超过1个线程去抢锁,那么偏向锁就会撤销,升 级为轻量级锁,其实我认为严格意义上来讲偏向锁并不算⼀把真正的锁,因为只有⼀个线程去访问共享资源的时候 才会有偏向锁这个情况。
(2) 轻量锁:轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
(3)自旋锁和自适应自旋锁:当有多个线程同时竞争锁时,会有一个自旋的过程,即让线程循环一直尝试去获取锁,而自适应自旋锁则是自动根据上一次获取到锁的时间调整自旋的时间。
自旋锁的意义在于:当持有锁的线程释放了锁,当前线程才可以再去竞争锁,但是如果按照这样的规则,就会浪费大量的性能在阻塞和唤醒的切换上,特别是线程占用锁的时间很短的话。为了避免阻塞和唤醒的切换,在没有获得锁的时候就不进入阻塞,而是不断地循环检测锁是否被释放,这就是自旋。在占用锁的时间短的情况下,自旋锁表现的性能是很高的。
(4) 重量锁:如果自旋获取锁也失败了,则升级为重量级锁,也就是把线程阻塞起来,等待唤醒。其利用操作系统底层的同步机制去实现Java中的线程同步。
四 总结
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。