JVM学习记录-线程安全与锁优化(二)
前言
高效并发是程序员们写代码时一直所追求的,HotSpot虚拟机开发团队也为此付出了很多努力,为了在线程之间更高效地共享数据,以及解决竞争问题,HotSpot开发团队做出了各种锁的优化技术常见的有:自适应自旋锁(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等。
自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,线程的挂起和恢复的操作时要消耗系统资源的,在并发时频繁的挂起和恢复线程这会给系统带来很大的压力。在许多应用中共享数据的锁定状态只会持续很短的一段时间,这段时间可能比线程的挂起和恢复的时间还短,这样切换线程的状态是很不值得的。因此虚拟机开发团队在JDK1.4.2中引入了自旋锁,在并发执行一段代码时,如果已经有线程获得锁,后面的线程不会被直接挂起,而是区执行一个空循环(自旋),在若干个空循环后,线程如果获得了锁,则继续执行,若线程依然不能获得锁,才会被挂起。自旋次数默认是10次,可以使用-XX:PreBlockSpin来更改。
JDK1.6中引入了自适应锁,意味着自旋的时间不再固定,而是有之前的自旋时间及锁的拥有者状态来决定,若上一次成功获得锁,那么这一次允许自旋更长时间,若这个线程很少获得锁,有可能就跳过自旋直接被挂起。
锁消除
锁消除指虚拟机在即时编译时,通过对运行上下文的扫描,发现一些被要求同步的代码,不可能存在共享数据竞争的锁,这个时候就需要把这些锁进行消除,这样可以节省毫无意义的请求时间。很多时候同步措施并不是开发人员手动加上的,而是JVM在运行期间转换时加上的。
如下代码:
public String concatString(String str1,String str2,String str3){ return str1+str2+str3;
}
因为String类是不可变的,每次的连接操作都是生成新的字符串,在JDK1.5之前会转换成StringBuffer对象的连续append()操作,在JDK1.5及以后的版本中会转换成Stringbuilder对象的连续append()操作。
转换后的代码如下:
public String concatString(String str1,String str2,String str3){ StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(str1); stringBuilder.append(str2); stringBuilder.append(str3); return stringBuilder.toString(); }
每一个append()方法都有一个同步块,锁的是stringBuilder对象,但是stringBuilder对象是concatString()方法的局部变量,显然不会被其他线程访问,因此可以安全的消除这里锁。
锁粗化
在编码时推荐同步块的作用范围尽量的小,这样范围小了,出现竞争时等待线程也能最快的拿到锁,但是如果频繁的加锁和解锁也是很消耗资源的,所以虚拟机开发团队对这种情况下的锁进行了粗化,就是说如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁的同步范围粗化到整个操作序列的外部。例如上面的代码中stringBuilder对象的每一个append()方法都有一个锁,虚拟机会把锁范围扩展到第一个append()操作之前直到最后一个append()操作之后。
轻量级锁
轻量级锁
轻量级锁是相对于操作系统互斥量的传统“重量”锁来说的。并不是来代替重量级锁,而是指在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
理解轻量级锁要先理解对象肉的内存布局,对象头分为两部分:
第一部分
用于存储对象自身的运行时数据,哈希码、GC分代年龄等。这部分数据的长度在32位和64位的虚拟分别为32bit和64bit,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。
第二部分
用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用户存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
轻量级锁加锁过程
- 在代码进入同步块的时候,如果此时同步对象没有被锁定(锁定标志位“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用户存储锁对象目前的Mark Word的拷贝。
- 然后虚拟机将使用CAS(Compare-And-Swap)操作尝试将对象的Mark Word更新为指向Lock Record的指针。
- 如果这个更新操作成功了,那么这个线程就拥有了该对象的锁,并且Mark Word的锁标志变为“00”。
- 如果这个更新操作失败了,虚拟机首先检查对象的Mark Word是否指向当前线程的栈帧,如果指向了说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,要膨胀为重量级锁,锁标志变为“10”,MarkWord中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁解锁过程
- 如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。
- 如果替换成功,整个同步过程就完成了。
- 如果同步替换失败,说明有其他线程尝试过获取该锁,那就要释放锁的同时,唤醒被挂起的线程。
总结轻量级锁
对于绝大部分的锁,在整个同步周期内都是不存在竞争的。但是如果存在锁竞争,那么除了互斥量开销外,还额外发生了CAS操作,会比重量级锁更慢。
偏向锁
偏向锁的目的
消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
偏向锁定义
如果说轻量级锁是消除数据在无竞争的情况下使用CAS操作区消除同步使用的互斥量,那偏向锁就是在无竞争情况下把整个同步都消除掉,链CAS操作都不做了。
为什么叫偏向锁?
偏向锁的意思是这个锁会偏向于第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁的工作原理
假设当前虚拟机启用了偏向锁(-XX:+UseBiasedLocking),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中,如果CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
自动解除偏向锁
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位“01”)或轻量级锁定(标志位“00”)的状态,后续的同步操作就如轻量级锁那样执行。
总结
其实JVM层面还有读写分离锁,以及靠开发人员的代码来实现减少锁的持有时间,这些都是在进行锁的优化。 本来想着ReentrantLock和Sychronized各写一个代码的例子呢,但是发现书上的例子,我运行起来成了死循环了。《深入理解Java虚拟机第二版》这本书第395页的代码例子,我的jdk是1.8版本,感兴趣的读者可以在自己的环境下试试,如果有运行着不是死循环的也可以告诉我一下。从写第一篇记录读这本书的博客到现在为止,差不多正好两个月,这本书确实是本好书,里面的知识有深度,适合已经有过一定Java基础的人看,讲解的也到位,只不过是该更新了,里面讲的还是jdk1.7的内容,但是最基础的东西还都是一样的。
还有就是要感悟一下了,看这本书的目的一开始是为了面试,但是从第二章开始看,刚开始看的时候第一次把JVM的内存结构弄明白后,心里很是激动的,(因为以前总是不知道jvm的堆是什么jvm的栈是什么? ),后来就一直坚持下来了,如果说只看一遍,我感觉这本书里的内容我是啥也记不住也看不明白,这样看明白了记录下来了,印象也很深刻,里面以前一些模棱两可的知识,也得到了确认。现在这本书挑着看(有些部分感觉有些偏冷门的内容就没看,例如:程序编译与代码优化)也算是看完了,然后这周也开始投简历找工作了,只是这个时间段已经过了金三银四了,可能不那么好找工作了,不过相信自己的努力不会白费的,加油,后续若有时间了会把落下的那几章也看完了,接着我要开启新的记录(设计模式学习记录)。