今天主要记录下Jvm中的锁优化问题。Jvm中的锁并不是一成不变的,它可以根据不同场景,不同的需求来决定使用哪种锁,合理的使用锁可以提高多线程的效率、降低性能上的开销。
对象头信息
讲到锁,这里就会涉及到对象头信息,对象头信息里面主要包含了两部分:一部分是记录对象在堆中的地址,以及记录对象所对应的类在方法区中的地址;另一个部分主要记录了hashcode、对象年龄、标志位等,在32位和64位的操作系统中的总长度分别32bit和64bit,官方称这部分为"Mark Word"。
Mark Word在32位系统中用25bit存储对象的哈希码(HashCode),4bit用于存储对象分代年龄,2bit用于记录存储锁标志位,1bit固定为0,下图为对象标志位存储内容(在未锁定状态、轻量级锁定、重量级锁定、GC标记、可偏向状态下)
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
锁
解下来我们聊一下锁相关的问题:
自旋锁
自旋锁,多个线程之间存在锁的竞争时,当第一个线程已经持有锁,第二个线程也想要获取锁的时候,第二个线程不会立刻处于阻塞状态,而是会不断自旋,尝试获取,当自旋超过一定次数之后,还是没有获取到锁,此时线程才会进入到阻塞队列中去。
当线程自旋的时候(此时线程是处于用户态),处理器会一直占用,直到自旋结束,进入等待队列(此时线程是处于内核态)。
自旋锁在一定情况情况下,对多线程的效率是有帮助的(可以避免频繁的从内核态转为用户态),减少性能开销。但是使用上也是有限制的:代码执行效率高、线程量少的情况下可以使用,否则会造成大量线程的自旋,反而会降低效率。
自旋锁的JVM参数:
- -XX:+UseSpinning ,开启自旋锁
- -XX:PerBlockSpin , 设置自旋次数,默认10次
自适应自旋锁
在JDK1.6之后,引入了自适应自旋锁。使用该锁,就表示自旋的时间不再是固定的了,自旋的时间取决于上一次获取锁时自旋的时间和锁拥有者的状态来决定。
比如,在同一个对象上,自旋等待已经成功获取到锁,并且持有锁的线程在运行中,那么虚拟机会认为这次自旋也很有可能会获取到锁,就会让线程自旋等待的时间更长,比如100次;
相反,如果针对某一个对象,线程自旋等待后很少获取到锁,那么下次很有可能就会跳过自旋过程,避免处理器资源的浪费。
重量级锁(悲观锁)
重量级锁,是通过使用操作系统互斥量来实现的锁,如synchronized关键字,java.utill.concurrent.*包中的类。
轻量级锁
轻量级锁,是相对于传统的重量级锁而言,并不能完全取代重量级锁。最经典的就要是CAS操作,下面来分析下CAS操作前后对象头信息的变化:
一开始对象尚未锁定时,标志位为 01, 虚拟机会在当前线程的栈帧中创建一个锁记录空间,用于存储对象头Mark Word的拷贝信息,如下图;
然后虚拟机将通过CAS操作尝试将对象头的Mark Word信息更新为指向线程中所记录空间的指针,如果这个动作成功了,那么就表示该线程获取到了锁,标志位记录为 “00”,即表示对象处于轻量级锁定状态,如下图:
如果更新失败,则检查Mark Word是否指向当前线程的栈帧,如果是指向当前线程栈帧,那么可以直接进入同步块继续执行,否则就表示这个锁对象已经被其他线程抢占了。*如果有两条以上的线程争用同一个锁,那么轻量级锁就不再有效,会膨胀为重量级锁,标志位状态为“10”,此时Mark Word存储的就是重量级锁指针,后面等待的线程也要进入阻塞状态。
以上是轻量级加锁的过程,轻量级释放锁的过程如下:如果Mark Word仍然指向线程的锁记录,就用CAS操作将对象当前的Mark Word和线程中复制的锁记录信息替换回来,如果替换成功,则同步过程完成,如果替换失败,表示还有其他线程尝试获取当前对象的锁,就需要在释放锁的同时,唤醒被挂起的线程。也就是说如果线程记录中的标志位为“01”,那么可以跟mark word中标志位为“00”的轻量级锁可以完成同步,如果mark word中标志位为“10”的重量级锁,那么就不能同步成功。
轻量级锁,在没有锁竞争的情况下,能提升程序同步性能,但是如果存在锁竞争,那么不但会膨胀为重量级锁,而且还会有CAS的操作,性能上还不是直接使用重量级锁。
偏向锁
偏向锁,顾名思义就是偏心的、偏袒的。它也是为了解决在无竞争情况下的同步问题。当虚拟机启用了偏向锁后,锁对象第一次被线程获取的时候,虚拟机会把对象同中标志位设为“01”,表示可偏向的。同时使用CAS操作把获取到锁的线程ID记录到对象的Mark word中,如果更新成功,则持有偏向锁的线程以后每次进入所相关的同步块的时候,虚拟机不会进行任何同步操作(如:Locking,Unlocking,及对Mark Word的Update等)。
前面也说了 ,偏向锁是解决锁无竞争的情况,一旦有其他线程试图去获取这个锁时,偏向模式就结束了,从而撤销偏向模式,此时将标志位会根据当前锁定的状态,恢复到未锁定(标志位“01”)或轻量级锁定(标志位“00”)的状态,后续如果还有其他线程获取锁,那么同步操作就可以参照轻量级锁。
所以整体来说,偏向锁主要用于有同步,但是没有竞争的程序,可以提高程序的性能。
偏向锁的JVM参数:
- -XX:+UseBiasedLocking, 开启偏向锁模式
乐观锁(Compare And Set,CAS)
乐观锁也是上面提到过的CAS操作,这里会涉及到三个值:当前值、期望值(就是我希望我对这个值进行修改时,它此时应该拥有的值)、修改值。
当要对值进行修改时,会判断期望值是否跟目前对象的值相等,如果相等,则更新成功,如果不相等,则一直循环,直到成功为止,典型的使用如AtomicInteger中的代码:
//AtomicInteger类
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
//unsafe类
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
乐观锁会引出一个ABA的问题:当A=1时,此时修改其值等于2,然后再讲值改为1,那么CAS就无法判断A这个值是否发生过修改。
要想解决ABA的问题,只需要添加一个version版本即可,当A=1是,version=1,修改A=2,version值加1,version=2,然后再次将值改为1,此时version=3,这样通过比较A的值和version的值就可以知道A到底有没有发生过更改。
锁优化
上面讲到了jvm虚拟机中的一些锁类型,以及是怎么实现的。接下来是对jvm虚拟机中自动锁优化的一些分析:
锁粗化
在我们平时的开发中,会建议将同步块尽量划分的更细一些,避免执行时间过长。但是如果同步块划分的过于细了,就会导致平凡的获取锁、释放锁,造成不必要的性能开销。为此jvm虚拟机对这类锁其进行了优化:
如果在一段代码中,对同一个对象进行了频繁的锁定,那么虚拟机会自动扩大锁定的范围,这就是锁的粗化。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上做了同步,但是实际上不存在共享数据竞争的锁进行了消除操作(就是取消同步,因为没有共享数据竞争,就没有必要加锁,避免在获取锁和释放锁上造成资源浪费)。
锁消除的依据来源于jvm的逃逸分析。也就是说如果虚拟机检查到再一段代码中,堆上的对象无论如何也不会被其他线程访问到,那么就可以将其看作是栈上局部变量,认为其是线程私有的,那么就没有必要进行加锁。
看以下代码:
public String test(String s1, String s2){
return s1+s2;
}
在JDK1.8中,编译后,通过javap命令查看其指令:
这里面的invokevirtual调用的都是StringBuilder.append()方法,这个方法是jvm优化后的,没有同步操作的。
在老版本中,javap命令出来的invokevirtual指令,实际调用的是StringBuffer.append()方法,而StringBuffer.appen方法都是同步方法。但是jvm对这种是做了锁消除的优化。
老版本反编译结果:
public String test(String s1, String s2){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
以上就是锁相关的全部内容,有错误的地方还望大家指正。