(12)锁优化

简叙 在JDK1.5之前提供的原生锁synchronized的性能并不是很好,直到JDK1.6对锁进行了大量优化.主要优化有下面几点:

  • 适应性自旋
  • 锁消除
  • 锁粗化
  • 轻量级锁
  • 偏向锁
自旋锁与自适应锁 自旋锁
通过同步互斥对性能最大的影响就是在于阻塞.当线程无法获取到对象锁时执行挂起,当锁释放时需要将等待锁的线程恢复,这个过程是非常低效的.但是实际开发中我们对共享数据的锁定状态只会持续很短的时间,为了这一小段时间去挂起线程然后再恢复这是不值得的.在有一个以上的处理器的机器上,能让两个或者两个以上的线程同时并发执行,我们可以让后面那个线程"稍等一下",但不放弃处理器的执行时间,看看持有锁的线程是否会很快释放锁.为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁.
自旋锁在JDK1.6以上默认为打开,在1.4和1.5中需要通过使用- XX:+ UseSpinning开启.需要明白的是自旋和阻塞是不能互相替换的.自旋并没有放弃所持有的处理器时间片,进入自旋只是为了避免线程切换的开销.如果共享资源锁定时间短,使用自旋能避免线程切换提高并发性.如果共享资源锁定时间太长,如果还继续自旋等待只会白白浪费CPU资源.因此自旋的次数必须要有一定的限度,如果自旋超过一定次数还是没有获取锁成功,就应该使用传统的方式去挂起线程.默认自旋次数为10次,可以通过-XX: PreBlockSpin进行更改.
适应性自旋锁
在1.6中引入了自适应性自旋锁.自适应是指自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的.如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会任务这次自旋也很可能成功,进而允许自旋等待持续相对更长的时间,比如100个循环.如果对于某个锁自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,避免浪费处理器资源.
锁消除 锁消除是指虚拟机即时编译器在运行时,对于一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除.这个判断依据主要来源于逃逸分析的数据支持,如果在一段代码中,堆上所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它当做栈上的数据对待,认为他们是线程私有的直接去掉同步加锁.
public String method1(){ Object o = new Object(); synchronized (o){ return "hello world"; } }

上面这个method1方法中有同步代码块,这个在即时编译时会被消除.因为o是方法内部的一个变量,是不会被其他线程所竞争的.
锁粗化 在我们编写代码是,原则上同步代码块的作用范围限制的越小越好.这样做使得等待锁的线程等待时间变短.大部分情况下这个原则是正确的.但是如果一系列连续操作都是对同一个对象反复的加锁和结果,甚至加锁操作出现在循环体中.即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗.
public void method2(){ for (int i = 0; i < 20; i++) { synchronized (o){ System.out.println(Thread.currentThread().getName()+":"+i); } } }

例如这个方法,因为在for中线程需要频繁的加锁和释放锁.在即时编译中,它会将锁粗化提出在循环之外.这样可以避免频繁加锁和释放锁,提高并发能力.
轻量级锁 轻量级锁是在JDK1.6中引入的.它的轻量级是相对于使用系统互斥量而言的传统锁而言的,因此传统的锁机制就称作"重量级"锁.
Mark Word
如果要了解轻量级锁和后面的偏向锁的原理,首先得先了解HotSpot虚拟机对象的对象头的内存说起.对象头主要分为两个部分,第一部分用于存储对象自身的运行时数据,如果哈希码(HashCode).GC分代年龄等,这部分的长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为"Mark Word",它是实现轻量级锁和偏向锁的关键.对象头还有其他部分跟锁没多大关系我们暂时不了解它了.
因为这个对象头跟对象自定义的数据无关但是却会占用额外的存储成本,所以"Mark Word"被设计成一个无固定数据结构以便于用很小的控件存储更多的信息,这也造成了对象不同状态下存储的内容不相同这样一个局面.下图分别列出32位和64位虚拟机Mark Word结构

(12)锁优化
文章图片
32位虚拟机Mark Word结构
(12)锁优化
文章图片
64位虚拟机Mark Word.jpg
另外我们可使用jol-core包中的工具打印出对象的对象头,jol-coremaven依赖如下:
org.openjdk.jol jol-core 0.8

public class App { public static void main(String[] args) throws InterruptedException { //打印虚拟机信息 System.out.println(VM.current().details()); User user = new User(); //打印对象头信息 System.out.println(ClassLayout.parseInstance(user).toPrintable()); } }class User{ }

打印结果如下图所示:

(12)锁优化
文章图片
对象头信息
注意:上面的Mark Word与之前图片上的对不上是高位在右边.例如结果中的Mark Word转换成我们可以读的格式应该为下:
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

栈帧
这个内容比较多也比较复杂,你只要明确知道一点就是每个线程都有自己独立的内存空间,栈帧就是独立空间中的一部分.有点类似于ThreadLocal一样的东西.
过程分析
  1. 在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01"状态,是否为偏向锁为"0",如果是否为偏向锁为"1"就是偏向锁了,后面会讲),虚拟机会把锁对象的Mark Word内容拷贝到栈帧中.
  2. 然后虚拟机将使用CAS操作尝试将锁对象的Mark Word内容更新为指向栈中锁记录的指针.如果这个动作成功,那么就表示当前线程拥有了该对象的锁.并且还会将对象的锁标志位修改为"00",表示此对象处于轻量级锁定状态.
  3. 如果操作2失败.虚拟机会检查对象的Mark Word是否指定当前线程的栈帧,如果说明当前线程已经拥有了这个对象的锁,直接进入同步代码块继续执行即可.否则说明这个锁对象被其他线程抢占了.这个时候轻量级锁就不再有效了,因为存在两个以上的线程抢同一个锁,锁膨胀为重量级锁.锁标志的状态值变为"10",Mark Word中存储的就是指定重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态.
上面是加锁状态.它的解锁状态也是通过CAS操作来实现的.如果对象的Mark Word任然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程栈帧中复制的信息替换回来.如果替换成功,整个同步过程就完成了.如果替换失败,说明中途有其他线程尝试过获取该锁,那在释放锁的同时还需要唤醒挂起的线程.
轻量级锁能提升程序同步性主要是 依靠绝大部分的锁,在整个同步周期内不存在竞争这个条件.如果没有竞争,只需要轻量级锁使用CAS操作避免使用互斥量的开销.但是如果存在锁竞争的话,除了互斥量的开销,还要加上额外的CAS操作.因此在有激烈竞争的情况下,使用轻量级锁并不能带来性能上的提升,反而还会降低性能.
public class App { public static void main(String[] args) throws InterruptedException { User user = new User(); System.out.println("进入同步代码块之前打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); new Thread(new Runnable() { @Override public void run() { synchronized (user){ System.out.println("进入同步代码块打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); } } }).start(); Thread.sleep(1000); System.out.println("从同步代码块出来打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); } }class User{ }

上面代码中的打印结果如下:
com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)01 00 00 00 (00000001 00000000 00000000 00000000) (1) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total进入同步代码块打印 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)b8 f1 7b 1a (10111000 11110001 01111011 00011010) (444330424) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total从同步代码块出来打印 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)01 00 00 00 (00000001 00000000 00000000 00000000) (1) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从上面打印的结果可以看出锁标志位经历下面这三种状态的变化:
01 => 00 => 01

【(12)锁优化】即刚开始是无锁状态(01),然后进入轻量级锁(00),当同步代码块执行完成之后再变回之前的无锁状态(01)
偏向锁 偏向锁也是JDK1.6中的引入的一项锁优化,它的目的是消除数据在无竞争状态下的同步原语,进一步提高程序的运行性能.我们可以通过XX:+ UseBiasedLocking来控制是否打开偏向锁,默认情况下为打开.轻量级锁在无竞争的情况下使用CAS操作去消除同步时使用的互斥量,而偏向锁就是在无竞争的情况下把整个同步都消除掉了,连CAS操作都不进行.
偏向锁会偏向于第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将不再需要同步.
偏向锁的过程基本上如下所示:
  • 可偏向状态:即线程ID为0,偏向锁位为1,锁标志位为01.则尝试使用CAS操作将自己的线程ID写入到锁对象头中的Mark Word中.
    • 如果操作成功,则锁对象头Mark Word中的线程ID即为当前线程的线程ID
    • 如果操作失败,说明有另外一个线程抢先获取到了偏向锁.我们暂且将这个抢到偏向锁的线程命名为t2,将当前的线程命名为t1.这个时候需要将线程t2手中的偏向锁撤销升级为轻量级锁.不过这个操作不会立马执行而是在安全点(JVM safepoint)才会执行,因为这个时候没有线程在执行字节码.
  • 已偏向状态:即线程ID中已有值,偏向锁位为1,锁标志位为01.则检查存储的线程ID与当前线程的线程ID是否相同.
    • 如果相等,则证明本线程已获取到偏向锁,可以继续执行同步代码块.
    • 如果不相等,则证明对象目前偏向于其他线程,需要执行撤销偏向锁操作.
上面提及的撤销操作并不是将对象恢复到无锁状态,而是把偏向锁升级到轻量级锁.这个操作具体方式如下:
  • 偏向锁CAS操作失败后,等待到达全局安全点
    • 通过Mark Word中已存在的线程ID找到成功获取到偏向锁的线程,然后在该线程中的栈帧中补充上轻量级加锁,并把Mark Word中的内容拷贝到栈帧中,然后在对象头中的保存这条锁的指针.
    • 之后锁撤销操作完成,阻塞在安全点的线程继续执行.
public class App { public static void main(String[] args) throws InterruptedException { User user = new User(); System.out.println(Thread.currentThread()+":进入同步代码块之前打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); new Thread(new Runnable() { @Override public void run() { synchronized (user){ System.out.println(Thread.currentThread()+":进入同步代码块打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); } } }).start(); Thread.sleep(1000); System.out.println(Thread.currentThread()+":从同步代码块出来打印"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); synchronized (user){ System.out.println(Thread.currentThread()+":进入同步代码块"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); }} }class User{ }

注意:启动代码是请加上下面的参数:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

上面的启动参数UseBiasedLocking代表开启偏向锁,BiasedLockingStartupDelay代表偏向锁的延迟生效时间为0,即启动后立马生效.默认情况下偏向锁时打开的,但是不会立马生效会等待一定时间才生效.所以我们在需要设置BiasedLockingStartupDelay=0让它立马生效.最后打印结果如下:
进入同步代码块之前打印 Thread[main,5,main]:进入同步代码块之前打印 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)05 00 00 00 (00000101 00000000 00000000 00000000) (5) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)92 c3 00 20 (10010010 11000011 00000000 00100000) (536920978) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes totalThread[Thread-0,5,main]:进入同步代码块打印 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)05 78 1f 1a (00000101 01111000 00011111 00011010) (438269957) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)92 c3 00 20 (10010010 11000011 00000000 00100000) (536920978) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes totalDisconnected from the target VM, address: '127.0.0.1:52151', transport: 'socket' Thread[main,5,main]:从同步代码块出来打印 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)05 78 1f 1a (00000101 01111000 00011111 00011010) (438269957) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)92 c3 00 20 (10010010 11000011 00000000 00100000) (536920978) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes totalThread[main,5,main]:进入同步代码块 com.buydeem.User object internals: OFFSETSIZETYPE DESCRIPTIONVALUE 04(object header)e8 f0 f3 02 (11101000 11110000 11110011 00000010) (49541352) 44(object header)00 00 00 00 (00000000 00000000 00000000 00000000) (0) 84(object header)92 c3 00 20 (10010010 11000011 00000000 00100000) (536920978) 124(loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从打印结果可以看出状态变化为下面过程:
Thread[main,5,main]:进入同步代码块之前打印 => 00000101 00000000 00000000 00000000 Thread[Thread-0,5,main]:进入同步代码块打印 => 00000101 01111000 00011111 00011010 Thread[main,5,main]:从同步代码块出来打印=> 00000101 01111000 00011111 00011010 Thread[main,5,main]:进入同步代码块=> 11101000 11110000 11110011 00000010

这个结果正好印证了我们上面所说的过程.同时我们发现当偏向锁从同步代码中出来并没有将执行解锁的操作,因为第二行和第一行没有任何改变.如果执行了解锁操作,那么同一个线程再次进入同步代码块还是会进行CAS操作,就不会有锁不存在的感觉了.
偏向锁可以提交带有同步单无竞争的程序的性能.但是它并不是绝对的对程序有好处.如果程序中总是被多个不同的线程访问,偏向模式并不能带来性能上的提升,反而还会降低性能.

(12)锁优化
文章图片
转化关系图
引用 该文章参照下面博文和书籍所写:
  • 分析Java对象的内存布局
  • Java中的偏向锁,轻量级锁, 重量级锁解析
  • 参照书籍为《深入理解Java虚拟机:JVM高级特性与最佳时间(第2版)》第13章线程安全与锁优化

    推荐阅读