为求平衡将synchronized优化为不在是无所和重锁两个状态,新增偏向锁和轻量级锁来平衡安全性和性能问题
synchronized锁:由对象头中的Mark Word
根据锁标志位的不同而被复用及锁升级策略
只有Synchronized,这个是操作系统级别的重量级操作,重量级锁,假如锁的竞争比较激烈的话,性能急剧下降
重量级锁涉及到用户态
和内核态
之间的切换
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock
来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因
C++源码: markOop.hpp
Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Java对象是天生的Monitor
,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor的本质是依赖于底层操作系统的==Mutex Lock
==实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
根据这三种情况使用不同的锁
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class NoLock {
public static void main(String[] args)
{
Object o = new Object();
System.out.println("10进制hash码:"+o.hashCode());
System.out.println("16进制hash码:"+Integer.toHexString(o.hashCode()));
System.out.println("2进制hash码:"+Integer.toBinaryString(o.hashCode()));
System.out.println( ClassLayout.parseInstance(o).toPrintable());
}
}
执行结果:
锁标志位001代表无锁
Hotspot 的作者经过研究发现,大多数情况下:
- 多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,
- 偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
偏向线程
。当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
- 当前线程
- 通过CAS方式修改markword中的线程ID
- 分代年龄
- 锁标志位—>101–>标志位理解就是无锁状态偏向锁标志位从0改为1
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。
若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。
个人理解:
- 因为加锁需要用户态到内核态的转换,所以我们先在加锁前拦截一层如果每次都是同一线程来操作那么我们就不需要加锁,这样就没有用户态到内核态的转换
- JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。
- 而且Hotspot 的作者经过研究发现,大多数情况下:多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,所以这种情况很多
- 除非第二个线程来争抢才会开始加锁升级为轻量级锁.
- 偏向锁几乎没有额外开销,性能极高。
windows上可以用git bash执行
java -XX:+PrintFlagsInitial |grep BiasedLock*
执行结果:
发现JVM启动时默认会启动偏向锁,但是会有4秒的延迟
所以需要添加参数
-XX:BiasedLockingStartupDelay=0
,让其在程序启动时立刻启动。
java -XX:+PrintFlagsInitial |find /i "BiasedLock"
执行结果和上面相同:
通过3.3.4我们发现:发现JVM启动时默认会启动偏向锁,但是会有4秒的延迟
因为UseBiasedLocking默认就等于true,只需要添加参数-XX:BiasedLockingStartupDelay=0
,让其在程序启动时立刻启动。
关闭偏向锁:关闭之后程序默认会直接进入 轻量级锁状态。
-XX:-UseBiasedLocking
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockDemo {
public static void main(String[] args)
{
Object o = new Object();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
}
执行结果:
锁标志为000,表示程序启动时为轻量级锁
-XX:BiasedLockingStartupDelay=0
执行结果:
锁标志为101,表示程序启动时为偏向锁
-XX:-UseBiasedLocking
执行结果
锁标志为000,表示为轻量级锁
执行结果:
证实了延时启动
偏向锁前54位为线程id
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockDemo2 {
public static void main(String[] args)
{
try { Thread.sleep( 5000 ); } catch (InterruptedException e) { e.printStackTrace(); }
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
执行结果:
锁标志为101,为偏向锁但是前54位全是0没有任何信息
是因为o对象未用synchronized加锁,所以线程id为空.
结果参考3.3.5.3.2
发现线程id不再全是0
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。
竞争线程尝试CAS更新对象头失败,**会等待到全局安全点(此时不会执行任何代码,类似JVM垃圾搜集的STW)撤销偏向锁。**同时检查持有偏向锁的线程是否还在执行
锁升级
。
java15后就将不会默认开启偏向锁了
废除原因:
性能影响
在过去,Java 应用通常使用的都是 HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全。
如果在单线程的情景下使用这些集合库就会有不必要的加锁操作,从而导致性能下降。
而偏向锁可以保证即使是使用了这些老的集合库,也不会产生很大的性能损耗,因为 JVM 知道访问临界区的线程始终是同一个,也就避免了加锁操作。
这一切都很美好,但是随着时代的变化,新的 Java 应用基本都已经使用了无锁的集合库,比如 HashMap、ArrayList 等,这些集合库在单线程场景下比老的集合库性能更好。
即使是在多线程场景下,Java 也提供了 ConcurrentHashMap、CopyOnWriteArrayList 等性能更好的线程安全的集合库。
综上,对于使用了新类库的 Java 应用来说,偏向锁带来的收益已不如过去那么明显,而且在当下多线程应用越来越普遍的情况下,偏向锁带来的锁升级操作反而会影响应用的性能。
代码侵入
在废弃偏向锁的提案 JEP374 中还提到了与 HotSpot 相关的一点
Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well.
简单翻译就是偏向锁为整个「同步子系统」引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件。
这导致了系统代码难以理解,难以进行大的设计变更,降低了子系统的演进能力,
总结下来其实就是 ROI (投资回报率)太低了,考虑到兼容性,所以决定先废弃该特性,最终的目标是移除它。
参考3.3.5.3.3 关闭偏向锁-填入参数-XX:-UseBiasedLocking
JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,官方成为Displaced Mark Word
。若一个线程获得锁时发现是轻量级锁,会把锁的MarkWord
复制到自己的Displaced Mark Word
里面。然后线程尝试用CAS
将锁的MarkWord替换为指向锁记录的指针。
Mark Word
已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。在释放锁时,当前线程会使用CAS
操作将Displaced Mark Word
的内容复制回锁的Mark Word
里面。
CAS
操作会失败,此时会释放锁并唤醒被阻察的线程。-XX:PreBlockSpin=10
来修改有大量的线程参与锁的竞争,冲突性很高
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter
指令,在结束位置插入monitor exit
指令。
当线程执行到monitor enter
指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monior
的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
锁升级为轻量级或重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,已经没有位置再保存哈希码,GC年龄了,那么这些信息被移动到哪里去了呢?
在无锁状态下,Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时,JVM会生成对应窋identity hash code值并将该值存储到Mark Word中。
对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。**如果一个对象的hashCode()方法已经被调用过一次之后,这个对象不能被设置偏向锁。**因为如果可以的化,那Mark Word中的identity hash code必然会被偏向线程ld给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致。
升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的Mark Word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头。
升级为重量级锁后,Mark Word保存的重量级锁指针,代表重量级锁的Object Monitor类里有字段记录非加锁状态下的Mark Word,锁释放后也会将信息写回到对象头。
Just In Time Compiler,一般翻译为即时编译器
锁消除:
从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
package site.zhourui.juc.synchronizedUpgrade;
/**
* 锁消除
* 从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,
* 极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
*/
public class LockClearUPDemo {
static Object objectLock = new Object();//正常的
public void m1()
{
//锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
Object o = new Object();
synchronized (o)
{
System.out.println("-----hello LockClearUPDemo"+"\t"+o.hashCode()+"\t"+objectLock.hashCode());
}
}
public static void main(String[] args)
{
LockClearUPDemo demo = new LockClearUPDemo();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
demo.m1();
},String.valueOf(i)).start();
}
}
}
执行结果:
其实就是每个线程使用的锁对象都不一样(每次加锁都是用的新new 的对象),只对一个线程加锁没事作用;
然后JIT编译器看到你这种脑残行为给你把锁去掉了:)
锁粗化
package site.zhourui.juc.synchronizedUpgrade;
/**
* 锁粗化
* 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
* 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
*/
public class LockBigDemo
{
static Object objectLock = new Object();
public static void main(String[] args)
{
new Thread(() -> {
synchronized (objectLock) {
System.out.println("11111");
}
synchronized (objectLock) {
System.out.println("22222");
}
synchronized (objectLock) {
System.out.println("33333");
}
},"a").start();
new Thread(() -> {
synchronized (objectLock) {
System.out.println("44444");
}
synchronized (objectLock) {
System.out.println("55555");
}
synchronized (objectLock) {
System.out.println("66666");
}
},"b").start();
}
}
执行结果:
前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能