JVM 对 synchronized 的优化
JVM 对 synchronized 的优化
1、简介
JDK1.6之前 Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统的锁操作需要实现线程之间的切换,需要从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。在JDK1.6之后JVM对synchronized底层进行了优化,提高了并发访问的性能,主要优化:
- 锁的膨胀升级(是最大的优化)。
- 锁的粗化。
- 锁的消除。
2、锁的膨胀升级
Java1.6之后为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁一共有4种状态,级别从低到高依次是:无锁状态--->偏向锁状态--->轻量级锁状态--->重量级锁状态。一开始从new出来的对象没有线程使用和加锁是无锁状态,然后随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重 量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
3、偏向锁:
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞 争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心 思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时, 无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争 的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就 失效了,因为这种场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的 是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁
简单一句话:偏向锁是减少同一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,不需要再次申请锁,就能提高性能。那么此时就是偏向锁。
4、轻量级锁(自旋锁)
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时 Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞 争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场 合,就会导致轻量级锁膨胀为重量级锁。
5、自旋锁
当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。在经过若干次循环后,如果得到锁,就顺利进入临界区;如果还不能获得锁,那就会将线程在操作系统层面挂起。
自旋锁和阻塞最大的区别
主要区别:是不是放弃处理器的执行时间。阻塞放弃了CPU时间,进入了等待区,等待被唤醒。响应慢。自旋锁一直占用CPU时间,时刻检查共享资源是否可以被访问,所以响应速度更快。
自旋锁的缺点
如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是如果持有锁的线程占用锁时间较长,等待锁的线程自旋一定次数后还是拿不到锁而被阻塞,那么自旋就白白浪费了CPU的资源。所以自旋的次数直接决定了自旋锁的性能。JDK自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
自适应自旋锁
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
6、锁的粗化
JVM会在编译的时候对代码上下文的进行分析,然后把一些不合理的代码进行优化,比如在同一个方法里,多次调用同一个同步的方法,将多次加锁和释放,优化成一次加锁和释放。锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
for(int i=0;i<100000;i++){synchronized(this){ do();}
会被粗化成:
synchronized(this){for(int i=0;i<100000;i++){ do();}
7、锁的消除
如果JVM检测到某段代码不可能存在共享数据竞争,JVM会对这段代码的同步锁进行锁消除。
在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。
例如:
public void vectorTest() {
Vector<String>vector =new Vector<String>();
for (int i =0; i <10; i++) {
vector.add(i +"");
}
System.out.println(vector);
}
Vector的add方法是Synchronized修饰的。在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
8、Java对象头
对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括markword(8字节)和类型指针(开启压缩指针4字节,不开启8字节,如果是32g以上内存,都是8字节),实例数据就是对象的成员变量,padding就是为了保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。数组对象比普通对象在对象头位置多一个数组长度。
- Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。
- Class Metadata Address:类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。
总结:
synchronized的执行过程:
1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁。
2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1。
3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁。
5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
6. 如果自旋成功则依然处于轻量级状态。
7. 如果自旋失败,则升级为重量级锁。
-
- 偏向锁:适用于单线程适用锁的情况,如果线程争用激烈,那么应该禁用偏向锁。
- 轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似)。
- 重量级锁:适用于竞争激烈的情况。