在上篇我们聊了sync的基本使用区别和实现原理,本篇继续来聊sync的锁升级过程,JDK1.6之后,JVM对sync关键字做了相当复杂的优化,当然目的就是为了提升sync的性能
本篇测试环境: JDK版本 :java version "1.8.0_221" JDK模式 :Java HotSpot(TM) 64-Bit Server VM (build 25.221-b11, mixed mode) 操作系统:Windows 10 企业版 x64 笔记本 内存容量:8G DDR3 CPU型号 :Intel i7-5500U 2.4GHz JVM参数 :-Xmx512m -Xms512m -XX:+UseParallelGC复制代码
在聊sync的升级过程之前,首先我们必须要了解一个东西, 对象头 ,在JVM的实现中每一个对象都会有一个对象头,它是用于保存对象的系统信息,对象头中有一个官方称为 MarkDown 的部分,他就是实现各种锁的关键。在32位系统里,MarkDown 是32位的数据,64位系统中是64位的数据,他存放了对象的哈希值、对象年龄、锁的指针等等信息。简言之,一个对象的锁是否被占用、占用的是哪种锁,就记录在MarkDown中。
如果系统中不存在线程竞争情况,就会取消已经持有锁的线程同步操作;简言之,如果有一个线程 t1,它获取锁之后会首先进入偏向锁模式,如果当 t1 再次请求持有这把锁的时候,则不需要再进行获取锁的操作,这样就节省了申请锁的操作,从而提升了性能。在处于偏向锁的期间,如果有其他线程来获取锁,则 t1 会退出偏向锁模式。
当锁对象处于偏向锁模式时,对象头会保存的信息:
【持有偏向锁的线程 | 偏向锁的时间戳 | 对象年龄 | 固定为1,表示偏向锁 | 最后两位为01,表示可偏向/未锁定】
【thread_id | epoch | age | 1 | 01】 当 t1 再次尝试获取锁的时候,JVM通过以上信息就可以直接判断当前线程是否持有偏向锁了。
那么,JVM对偏向锁的性能优化效果到底如何呢?我们接下来就来上测试代码看一下实际效果
private static List<Integer> list = new Vector<>(); public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 10000000; i++) { list.add(i + 2); } long end = System.currentTimeMillis(); System.out.println(end - start); }复制代码
为了测试结果的准确性,我们需要额外配置两个参数: -XX:+UseBiasedLocking
表示设置开启偏向锁 -XX:BiasedLockingStartupDelay=0
表示JVM在启动后立即启动偏向锁,如果不设置,JVM默认会在启动后4秒才会启动偏向锁
上面的代码我们使用一个Vector进行写入操作,并做好初始化准备,众所周知Vector内部的访问操作是使用的同步锁也就是sync控制,每次执行add方法都会请求list对象的锁,然后我连续运行十次,最后输出数值大概在 440-470左右,而关闭偏向锁(-XX:-UseBiasedLocking)之后,同样的代码也运行了十次,最后输出的数值大概在670-690左右,去掉其他因素, 估算性能差距在百分之20
左右。
偏向锁是为了在资源没有被多线程竞争的情况下尽量的减少锁带来的性能开销,但是请不要忽略一个问题,就是当我们的应用程序内部的线程竞争比较激烈的时候,大量的线程会不断的来请求锁,也就会导致锁很难持续的保持在偏向锁模式,这个时候使用偏向锁不仅不会提升性能,反而可能会降低系统性能,所以,如果我们系统内线程竞争比较激烈时,不如直接关闭偏向锁 : -XX:-UseBiasedLocking
如果偏向锁失败(在偏向锁的时候有其他线程争用)了,JVM并不会立刻挂起线程,而是会发生偏向锁的撤销操作,此时对象可能会处于两种状态,
一种是不可偏向的无锁状态,一种是不可偏向的已锁(轻量级)状态,当线程如果持有轻量级锁时,那么此时对象的 MarkDown 是这样的: [prt | 00] locked
它的后两位为00,在这里它只是简单的把对象头部作为一个指针指向持有该锁的线程栈空间的内部,当需要判断一个线程是否持有该对象的轻量级锁的时候,只需要检查对象头的指针是否在当前线程的栈地址范围内即可。
实际上轻量级锁在JVM的内部使用的是一个BasicObjectLock的对象来实现的,对象内部包含一个BasicLock和一个持有该锁的对象指针,在JVM实现中,BasicLock会首先复制原对象的 MarkDown,然后使用CAS原子操作来把BasicLock的地址复制到对象头的MarkDown,如果复制成功,表示加锁成功,否则加锁失败,如果失败了,那么轻量级锁就有可能会进行锁膨胀!
在进行锁膨胀之后,这个时候线程很有可能是会直接在操作系统层面进行挂起操作,也就意味着会发生用户态到内核态的一个切换过程,这时候的性能损失是比较大的!所以,在锁膨胀之后,JVM还会做最后的努力来避免线程被挂起,也就是使用自旋锁。
自旋的意思在这里是说,如果当前线程没有取得锁,不会被挂起,而是去执行一个空循环(自旋),在N次循环后,当前线程会再次请求获取锁,如果获取成功,则正常执行,如果依然不能获取,才会被挂起。
自旋锁在不同场景下会有不同的性能消耗,比如,对于锁竞争不是很激烈,锁占用时间比价短的场景下,自旋锁能够有效的避免操作系统挂起锁的次数,也就是减少了用户态到内核态的切换次数;但是反之,如果锁竞争比较激烈或者锁占用时间比较长,而且线程数比较多的场景下,一个线程获取锁之后,其他的N个线程都来竞争锁,意味着大量的线程都会进行自旋,而且在一定时间内都不能获取锁之后,依旧被操作系统挂起,反而是更加的浪费了CPU的时间和资源(大量线程空旋)
在JDK6,JVM是提供了参数用来开启自旋锁的: -XX:+UseSpinning
,可以搭配: -XX:PreBlockSpin
参数来设置自旋锁的自旋次数(默认自旋10次)
但是在JDK7以上的版本,JVM是废弃了这两个参数的,JVM默认开启了自旋锁,以及会自动的调整自旋次数
锁消除是一种更直接的锁优化的技术,是由JVM在JIT编译时产生的一种优化方式,JVM在对程序的运行上下文环境做扫描,直接去除不可能存在竞争共享资源的锁,可以节省不必要的加锁操作
比如以下代码:
private static String t(String s1, String s2){ StringBuffer buffer = new StringBuffer(); buffer.append(s1); buffer.append(s2); return buffer.toString(); }复制代码
我们都知道 StringBuffer可以说是StringBuilder的加锁版本,是线程安全的,但是在上述代码中,变量buffer的作用域仅限于方法体内部,不可能有逃逸到方法外,明显不需要使用线程安全的方式来做字符串的拼接,所以锁消除可以对此类代码进行优化
添加如下启动参数:
-server // 锁消除必须在Server模式下 -Xcomp // 使用编译模式 -XX:+DoEscapeAnalysis // 打开逃逸分析 -XX:+EliminateLocks // 打开锁消除 -XX:BiasedLockingStartupDelay=0 // JVM启动立刻打开偏向锁 -XX:+UseBiasedLocking // 打开偏向锁复制代码
与锁消除相关的一个配置是逃逸分析,这里我先简单的讲解逃逸分析,大概意思就是说看某个变量是否逃出了某个作用域,在上面例子中,变量buffer是没有逃出方法t的函数作用域的,也叫做没有发生逃逸,这种情况下,JVM才能对变量buffer进行内部锁消除优化,反之,如果方法t如下:
private static StringBuffer t(String s1, String s2){ StringBuffer buffer = new StringBuffer(); buffer.append(s1); buffer.append(s2); return buffer; }复制代码
上述方法中变量buffer则发生了逃逸,由方法t内部逃逸到了外部,那么JVM就不能消除变量buffer的锁操作
理论上打开锁消除之后的性能应该会有提升,但是我使用以上配置实际测试时,打开和关闭锁消除的结果居然并没有明显差距,所以这里就不贴测试结果了,如果有兴趣的朋友可以自行测试,或者有知道原因的朋友可以留言一起讨论