作者:CoderLife
背景
作为一名Java程序员, synchronized关键字在我们的日常编码中肯定是不可或缺的,可是我们在用 synchronized的时候真的明白它做了什么吗?
我们可能听到过 synchronized性能差,应该用 ReentrantLock之类balabala的话,这句话在jdk1.6版本之前是没有错的,因为 synchronized关键字在jdk1.6之前只有一种加锁方式——重量锁,重量锁内部在进行系统调用时会使线程由用户态变为内核态,结束系统调用后又会由内核态变回用户态,这两种状态间切换是影响性能的,所以才会有一位大神道格李(Doug Lea)写出了 ReentrantLock这种惊为天人的类库。
那么为什么说 ReentrantLock惊为天人呢?因为其加锁逻辑完全是由Java代码实现的,普通Java代码的执行是不会造成用户态到内核态的切换的,其内部逻辑中唯一会造成用户态到内核态切换的地方是通过调用 LockSupport.park()方法让线程去阻塞睡眠,这种情况是在前面所有逻辑都不成立(即发生多线程竞争同一把锁)的情况下才会发生,这比jdk1.6前的 synchronized关键字直接上重量锁性能要好得多。
什么是偏向锁、轻量锁、重量锁?
但是在jdk1.6及之后的版本后,情况就不一样了,jdk的开发人员借鉴了 ReentrantLock的思想,对 synchronized进行了优化,把锁的类型分成了三种——偏向锁、轻量锁和重量锁。这三种锁可以简单理解为从左到右性能逐渐降低(PS:并不是绝对的!初学者可以暂时这么理解!),JVM在前一种锁无法满足需要的时候,就会膨胀为后一种锁,这种膨胀在绝大多数情况下都是不可逆的。下面先来简单介绍一下这三种锁的含义:
PS:介绍之前先说明一点,下文中所说的多个线程交替执行或没有锁竞争,是表示一个同步块在被A线程执行完(即已经释放掉锁)后再被B线程执行的情况,有锁竞争就表示A线程还没释放锁,B线程就来加锁了。
偏向锁
顾名思义,偏向锁会偏向某个线程,其不会造成用户态与内核态的切换。偏向锁在第一次加锁时会偏向拿到锁的线程,当这个线程再来加锁时,就可以直接拿到锁而不用做其他的逻辑判断,所以在这种场景下其性能最高。不过,如果有其他线程再来加锁的话,JVM就会把偏向锁膨胀为轻量锁(没有锁竞争)或重量锁(有锁竞争)了。
轻量锁
又是顾名思义,轻量锁是相对于重量锁来说性能好一些的锁,当多个线程交替执行同步块时,JVM就会使用轻量锁来保证同步,轻量锁也不会造成用户态与内核态的切换。
重量锁
能支持所有并发场景的锁,无论之前是偏向锁还是轻量锁,只要在当前线程还没有释放锁的时候有其他线程来加锁,都会直接膨胀为重量锁,重量锁会造成用户态与内核态的切换。
正是因为jdk1.6后加入了偏向锁和轻量锁, synchronized关键字的性能已经不比 ReentrantLock差了,甚至jdk开发者更是建议使用 synchronized而不是 ReentrantLock,因为 synchronized使用起来还是简单一些的,除非你的业务场景需要使用 ReentrantLock中的独有特性(可打断、条件锁等),否则我们平时只需要使用 synchronized就够了。
那么现在问题来了,我们不知道当前对象加的是什么锁也就算了,JVM是怎么知道 synchronized给一个对象加的是什么锁呢?
三种锁对对象的影响
对象的不同状态
我们都知道,Java对象是在堆内存中存放的,一个Java对象除了其成员属性要占用空间之外,还有一个“对象头”信息也要占用堆内存的空间(Java对象布局不了解的可以先去找资料了解一下,这里不细讲),“对象头”中又有一块区域叫做“Mark Word”,这个锁相关的信息就是记录在这个“Mark Word”上的。
下面两张图是32位JVM和64位JVM中“Mark Word”所记录的信息
看完这两张图,你可能发现了,无锁和偏向锁只看最后两位甚至只看最后三位都不能确定当前对象的锁状态,需要综合整个“Mark Word”的值来判断。看到这里是不是有点迷糊了?不怕,下面我们写点代码来证实这两张图中的内容。
动手验证
先上代码,这里要注意一点,我们在启动代码的时候,要设置一个JVM参数, -XX:BiasedLockingStartupDelay=0,这个参数可以关闭JVM的偏向延迟,JVM默认会设置一个4秒钟的偏向延迟,也就是说JVM启动4秒钟内创建出的所有对象都是不可偏向的(也就是上图中的无锁不可偏向状态),如果对这些对象去加锁,加的会是轻量锁而不是偏向锁,这个一定要注意,不然一直看到错误结果你会怀疑人生的~
import org.openjdk.jol.info.ClassLayout;
public class ObjectHeadLockDemo {
// 这里创建了一个没有成员属性的C对象,使用JOL类库中的ClassLayout类查看其对象布局信息
private static C c = new C();
public static void main(String[] args) throws InterruptedException {
// 这时候c还没有加锁,是一个无锁可偏向(没有锁住,但是可以加偏向锁)的状态,所以Markword最后三位为101,前面都为0。
// 后面t1线程对c加锁时会加偏向锁,再后面还是t1线程继续加锁依然是偏向锁,当其他线程来加锁(交替进行)时会膨胀为轻量锁,抢占锁时会膨胀为重量锁
Thread t1 = new Thread(() -> testLock());
Thread t2 = new Thread(() -> testLock());
t1.setName("t1");
t2.setName("t2");
// t1线程启动,对c加锁,会加偏向锁,Markword最后三位还是101,但是前面不再是0,而是保存了t1线程的id,表示偏向t1线程。
// 后面如果在其他线程来加锁前t1线程再次来加锁的话,c加的还是偏向锁,其Markword内容不变且加锁效率极高,这也是偏向锁设计的目的。
t1.start();
// 调用join,后面的代码在t1线程执行完后才会开始执行
t1.join();
// 这时候t1线程已经执行完毕释放了锁,所以不会有抢占锁的情况,锁只会膨胀为轻量锁;如果不调用join,t2线程如果与t1线程同时抢占锁,锁就会膨胀为重量锁
t2.start();
}
private static void testLock() {
// 加锁前中后都打印一下,看看有什么不同
System.out.println(Thread.currentThread().getName());
System.out.println(ClassLayout.parseInstance(c).toPrintable());
synchronized (c) {
System.out.println(ClassLayout.parseInstance(c).toPrintable());
}
System.out.println(ClassLayout.parseInstance(c).toPrintable());
}
}
class C {}</pre>
执行代码后,我们第一个打印出的结果是对象c在t1线程加锁前,也就是new出来后对象的布局信息,一共有3行对象头的信息和1行对齐填充,前两行是"Mark Word"的信息,因为我们的CPU都是大端模式,所以"Mark Word"的第一个8位(最高8位)是在第二行的最后,最后一个8位(最低8位)反倒是成了第一行的第一个。所以可以看到,"Mark Word"的最低3位确实是101,而且前面61位都是0,是一个匿名偏向即无锁可偏向的状态。
下面这张图就是c对象被t1线程加到锁和释放锁后的一个布局信息,可以看到,"Mark Word"的前54位中确实是有了变化,记录了t1线程的id(这里记录的id并不是Java线程的id,而是操作系统线程的id)。
而且c对象的"Mark Word"在t1释放掉锁后没有任何改变,依然记录了t1线程的id,所以这就保证了如果下次来加锁的线程还是t1线程时,c就能立马认出t1线程,并迅速把锁交给t1线程。
t1线程执行完毕,t2线程开始调用 testLock()方法,t2线程加锁前,c的"Mark Word"中依然还是保存着t1线程的id。
但是在t2加锁成功后,情况就不一样了,c的"Mark Word"的最后两位变成了00,表示c对象被加了轻量锁,前62位这时保存的就变成了一个指针,这个指针指向一个在栈上分配的、名为 lockrecord的对象,这个对象的作用也很简单,里面主要记录了两个信息,一个叫 displaced_header,用来保存c对象加锁前的"Mark Word",毕竟加个锁不能把人家对象原来的信息给丢了不是?另一个叫 obj_reference,用来保存指向c对象的指针,毕竟"lock record"也得知道我保存的这个"Mark Word"是谁的不是?不然释放锁后怎么知道给谁还原回去是吧~
最后,t2释放锁后,c的"Mark Word"就变得很干净了,一个非常简单的无锁不可偏向的状态,后面无论再是哪个线程来加锁,加的都只会是轻量锁了(这里不考虑JVM的重偏向优化)。
如果我们在调用 t1.start()后不调用 t1.join(),而是直接调用 t2.start(),那么就有可能发生t1、t2抢锁的情况,这时候就可以看到下面的打印结果,和轻量锁很像,"Mark Word"前62位保存一个指针,最后两位为重量锁标识10。
另外,还有种情况,如果在c对象第一次加锁前,c对象调用了 hashcode()方法的话,这时候c就会从无锁可偏向变成无锁不可偏向,代码如下:
import org.openjdk.jol.info.ClassLayout;
public class ObjectAfterCallHashcodeLockDemo {
private static C c = new C();
public static void main(String[] args) throws InterruptedException {
System.out.println("调用hashcode()前————————————————————" + ClassLayout.parseInstance(c).toPrintable());
// 调用hashcode后才会把hashcode写入对象头中,这时Markword最后三位为001,不可偏向;
System.out.println(Integer.toHexString(c.hashCode()));
System.out.println("调用hashcode()前————————————————————" + ClassLayout.parseInstance(c).toPrintable());
synchronized (c) {
System.out.println("加锁中————————————————————" + ClassLayout.parseInstance(c).toPrintable());
}
}
}
class C {}</pre>
执行代码后,得到如下结果(不要忘记配置关闭偏向延迟!)。
结语
看完这些示例,是不是感觉对这三种锁有了一个较为清楚的认识了呢?当然,JVM底层源码对这三种的锁的逻辑判断是很复杂的,比如偏向锁还有一些重偏向、偏向撤销的操作,轻量锁加锁前要创建一个无锁状态的"Mark Word"等等……当你完全想明白这篇文章的内容之后,便可以去尝试读一读JVM中 synchronized加锁逻辑的源码了。一点一滴进步,一步一个脚印,慢慢积累,你也可以成为大神。
网友评论