目录
在高并发的情况下为保证线程安全需要加锁,但也要考虑性能问题,所以能无锁就不要加锁,能锁区块代码(同步代码块)就不要在整个方法上加锁,能使用对象锁就不要使用类锁。
锁的升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
1 线程加锁流程
在对象的内存布局中,对象头包含Mark Word(对象标记),当加的是重量级锁(synchronized)时包含一个指向对象监视器(ObjectMonitor,即管程)的指针 (可查看《JUC学习:对象内存布局》部分内容)。对象监视器(ObjectMonitor,即管程)中又包含如下参数:
参数 | 含义 |
_owner | 该对象锁定的线程id |
_count | 初始值0,每用该对象加一次锁+1,解锁-1(可重入锁部分需要) |
_recursions | 初始值0,表示代码块重入次数,每重入一层+1,出一层-1 |
_EntryList | 阻塞队列,存放阻塞的线程 |
_WaitSet | 等待队列,存放等待的线程 |
当对象(obj)被线程(t1)作为锁时,会将(obj)指向的ObjectMonitor的 _owner 参数赋值为线程(t1)的id、将其 _count 加1,并将 _recursions 参数加1,此时(t1)线程得到锁。
当线程(t2)想到加锁时,发现对象(obj)的 _count 不等于0,即对象(obj)已被线程占用。再判断对象(obj)锁的线程是不是(t2)。
- 若是(t2)则由于锁的可重入性,则执行加锁操作(将其 _count 、_recursions 参数加1);
- 若不是(t2),则代表对象(obj)已被其它线程获取,则(t2)线程会进入阻塞队列_EntryList中,等待(t1)线程释放锁,再次进行抢夺锁的操作。
流程如下图所示:
这是一个非常重量级的操作,涉及到代码,虚拟机直至底层操作系统的各种状态修改。在高并发的情况下非常影响程序的性能,因此后续引入了偏向锁以及轻量级锁。
2 对象头锁状态对照
对象锁升级的流程就是锁标记的变化。从无锁 到 偏向锁 到 轻量级锁 到 重量级锁。
2.1 无锁态
一个对象没有被任何线程当作锁竞争,则该对象就是无锁态。
从上图中可看出,在无锁状态下,前25位无数据,31位hashcode,后三位锁标记,通过如下代码举例验证。从输出结果图可以看出在无锁态下与上表中的信息对照一致。
public class SyncUp {
public static void main(String[] args) {
Object o = new Object();
System.out.println("10进制" + o.hashCode());
System.out.println("16进制" + Integer.toHexString(o.hashCode()));
System.out.println("2进制" + Integer.toBinaryString(o.hashCode()));
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
2.2 偏向锁
在多线程竞争锁的情况下,大多数情况下会发生同一个线程持续抢夺到锁的情况(即a,b,c三个线程,a线程每次都能抢到锁)。在这种情况下,为了避免在每次加锁的过程中大量的修改底层状态(内核态到用户态的转换)带来的性能下降,则为线程加上了偏向锁。
偏向锁会偏向第一个访问锁的线程,若后续没有其它线程访问该锁(即该锁一直被同一个线程获取),则持有偏向锁的线程永远不会触发同步机制(即直接消除了同步语句),避免了底层大量状态的修改,直接提升了系统性能,详细流程如下。
锁(对象obj)第一次被线程(t1)拥有时,锁(对象obj)的对象头中会记录这个锁(obj)是一个偏向锁(即锁标记是101),并在对象头中记录54位的指向线程(t1)的指针。则线程(t1)在下次访问该同步代码块时,不需要再加锁与释放锁,而是直接判断锁(obj)的54位指针是否指向的是(t1)线程。
- 是当前线程,则无需再与其他线程竞争锁,同理之后每次都只需要检查锁(obj)中记录的线程指针即可,省略了加锁、释放锁的操作,大大提升了系统性能。
- 不是当前线程,代表发生了竞争,会尝试使用CAS操作来替换锁(obj)对象头中的线程指针。
竞争可能成功也可能失败。
- 竞争成功,代表线程(t1)已经执行完成不存在了,那么会执行CAS操作,将锁(obj)对象中指向(t1)的指针修改为指向(t2)的指针,代表(t2)线程竞争成功。
- 竞争失败,代表线程(t1)还存在,并与线程(t2)发生了竞争,此时则会触发锁升级操作,升级位轻量级锁,保证线程间的公平竞争。
注意:线程不会主动释放偏向锁,只有遇到其它线程尝试竞争锁时,拥有偏向锁的线程才会被动的释放偏向锁。
以下代码用于测试偏向锁,在java虚拟机中,偏向锁默认会有4000ms的开启延时。为了便于测试需要添加-XX:BiasedLockingStartupDelay=0 jvm参数,取消开启延时。
//jvm参数-XX:BiasedLockingStartupDelay=0
public class BiasedLockDemo {
public static void main(String[] args) {
Object o = new Object();
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
通过对象头的Mark Word(对象标记)后3位101可看出该Object对象是一个偏向锁。
2.3 轻量级锁
在单线程运行的偏向锁模式下,此时若有其他线程来竞争锁(通过CAS操作修改对象头中的锁指针),在修改成功的情况新线程则继续使用偏向锁;修改失败时代表线程之间存在竞争,则会 进行锁升级策略,将锁从偏向锁升级为轻量级锁。
在轻量级锁模式下,每个线程通过CAS争夺锁,争夺成功则进入同步代码块,争夺失败则进行自旋操作直至抢夺到锁为止。
轻量级锁适合在线程竞争不是很激烈的情况下使用,当线程数过多,可能会出现线程长时间自旋的问题。在jdk6之前,设定的轻量级锁升级策略是在自旋10次后,或是自旋数超过了cpu核数的一半时升级为重量级锁;jdk6之后这个数量是动态的、不固定的,上一次自旋抢夺到之后这个数量会增大,上一次自旋没有抢夺到会减少。
//轻量级锁,使用jvm参数-XX:-UseBiasedLocking关闭偏向锁,则直接从无锁态升级到轻量级锁。
public class LightweightLock {
public static void main(String[] args) {
Object o = new Object();
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
通过对象头的Mark Word(对象标记)后2位00可看出该Object对象是一个轻量级锁。
2.4 重量级锁
重量级锁加锁流程参考第一部分synchronized线程加锁流程。
public class HeavyweightLock {
public static void main(String[] args) {
Object o = new Object();
Object o2 = new Object();
new Thread(() -> {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}, "t1").start();
new Thread(() -> {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}, "t2").start();
}
}
通过对象头的Mark Word(对象标记)后2位10可看出该Object对象是一个轻量级锁。
3 各锁的优缺点对比
3.1 偏向锁
- 优点:加锁与解锁不需要额外消耗,和执行非同步代码块仅存在纳秒级差距
- 缺点:如果线程间存在锁竞争,会带来额外锁撤销的消耗。
- 适用场景:单线程执行同步代码块的场景。
3.2 轻量级锁
- 优点:竞争线程不会阻塞(CAS)提高了程序的响应速度。
- 缺点:如果线程长时间竞争不到锁,一直自旋会消耗cpu性能。
- 适用场景:追求响应时间,同步代码块执行速度非常快。
3.3 重量级锁
- 优点:线程竞争不使用自旋,不会消耗cpu资源。
- 缺点:得不到锁得线程会阻塞,加锁解锁较为复杂会导致程序响应缓慢。
- 适用场景:追求吞吐量,同步代码块执行速度较长。