JVM-30-锁消除+锁粗化 自旋锁、偏向锁、轻量级锁 逃逸分析
自旋锁
自旋锁其实就是一个线程自转,空转,什么都不操作,但也不挂起,在那里空循环。空循环的作用就是等待一把锁。自旋锁是明确的会产生竞争的情况下使用的。
当竞争存在时,如果线程可以很快获得锁,那么就没有必要在(操作系统)OS层面挂起线程(因为在操作系统层面去挂起,他的性能消耗是非常严重的,因此如果我们能假定他能很快获取锁,就不需要让线程挂起),而是让线程做几个空操作(称为自旋)
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。而在很多应用上,共享数据的锁定状态只会持续很短的一段时间。若实体机上有多个处理器,能让两个以上的线程同时并行执行,我们就可以让后面请求锁的那个线程原地自旋(不放弃CPU时间),看看持有锁的线程是否很快就会释放锁。
为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是自旋锁。
如果锁长时间被占用,则浪费处理器资源,因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了(默认10次)。
使用方式
JDK1.6中 -XX:+UseSpinning
开启
JDK1.7中,去掉此参数,改为内置实现
ps: 换言之,JDK1.7 以后无法通过参数指定。
适用场景
如果同步块很长,会导致后面线程自旋的成功率,因此自旋失败,会降低系统性能。
因为在自旋花去的时间后还没有获取到锁,这么长时间都是浪费的,所以会造成性能降低。
如果同步块很短,自旋成功成功率很高,因此自旋成功,会节省线程挂起切换时间,提升系统性能。
自旋适应锁
JDK1.6引入自适应的自旋锁:自旋时间不再固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
偏向锁
大部分情况是没有竞争的,所以可以通过偏向来提高性能
所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程(也就是说,这个线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些monitor操作,因此这方面他是会对性能有所提升的,因为在大部分情况下是没有竞争的,所以锁此时是没用的,所以使用偏向锁是可以提高性能的)
在使用偏向锁的时候会将对象头Mark的标记设置为偏向,并将拿到锁的线程的ID写入对象头Mark,这样就可以很快识别出这个线程是否拿到的锁。
只要没有竞争,获得偏向锁的线程,在将来进入同步块,不需要做同步(这段时间就省下来了)
当其他线程请求相同的锁时,偏向模式结束
在竞争激烈的场合,偏向锁会增加系统负担。
案例
vector 内部是有同步锁的操作的,所以在jdk内部是有锁的。
使用它也就是说明你此时的代码拥有锁了。
public static List numberList =new Vector();
public static void main(String[] args) throws InterruptedException {
long begin=System.currentTimeMillis();
int count=0;
int startnum=0;
while(countset_displaced_header(mark);
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
TEVENT (slow_enter: release stacklock) ;
return;
}
lock位于线程栈中,因此如何判断线程持有这个锁,我们只需要判断这个对象头的指针所指向的方向,是不是在这个线程栈当中。如果是,则说明这个线程持有这把锁。
竞争失败的情况
如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁也就是常说的monitor)
使用建议
在没有锁竞争的前提下,减少传统锁(也就是重量级锁)使用OS互斥量产生的性能损耗(也就是说重量级锁他是会在操作系统上做一些操作,所以他的性能是非常糟糕的)
在竞争激烈时,轻量级锁多半会失败,因此轻量级锁会多做很多额外操作,导致性能下降
竞争激烈时,不建议使用。
偏向锁,轻量级锁,自旋锁总结
这三个锁不是Java语言层面的锁优化方法。
他是jvm中锁的优化,其实是jvm获取锁的步骤。
步骤
内置于JVM中的获取锁的优化方法和获取锁的步骤
偏向锁可用会先尝试偏向锁
轻量级锁可用会先尝试轻量级锁
以上都失败,尝试自旋锁
再失败,尝试普通锁,使用OS互斥量在操作系统层挂起
锁优化
主要分为 jvm 层面和代码编程两个方面。
- jvm
锁消除
锁粗化
- 编程
减少锁持有的时间
锁分离
减小锁的力度
锁消除
锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。
锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
也许读者会有疑问,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?
答案是有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。
例子
比如,StringBuffer类的append操作:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
从源码中可以看出,append方法用了synchronized关键词,它是线程安全的。
但我们可能仅在线程内部把StringBuffer当作局部变量使用:
public class Demo {
public static void main(String[] args) {
long start = System.currentTimeMillis();
int size = 10000;
for (int i = 0; i < size; i++) {
createStringBuffer("Hyes", "为分享技术而生");
}
long timeCost = System.currentTimeMillis() - start;
System.out.println("createStringBuffer:" + timeCost + " ms");
}
public static String createStringBuffer(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
sBuf.append(str1);// append方法是同步操作
sBuf.append(str2);
return sBuf.toString();
}
}
代码中createStringBuffer方法中的局部对象sBuf,就只在该方法内的作用域有效,不同线程同时调用createStringBuffer()方法时,都会创建不同的sBuf对象,因此此时的append操作若是使用同步操作,就是白白浪费的系统资源。
开启锁消除
这时我们可以通过编译器将其优化,将锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析:
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
其中+DoEscapeAnalysis表示开启逃逸分析,+EliminateLocks表示锁消除。
逃逸分析:比如上面的代码,它要看sBuf是否可能逃出它的作用域?
如果将sBuf作为方法的返回值进行返回,那么它在方法外部可能被当作一个全局对象使用,就有可能发生线程安全问题,这时就可以说sBuf这个对象发生逃逸了,因而不应将append操作的锁消除,但我们上面的代码没有发生锁逃逸,锁消除就可以带来一定的性能提升。
我们来看看下面代码清单13-6中的例子,这段非常简单的代码仅仅是输出三个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。
案例 2
- 代码清单 13-6 一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
我们也知道,由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。
在JDK 1.5之前,会转化为StringBuffer对象的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作。
即代码清单13-6中的代码可能会变成代码清单13-7的样子 。
- 代码清单 13-7 Javac转化后的字符串连接操作
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
(注1:实事求是地说,既然谈到锁削除与逃逸分析,那虚拟机就不可能是JDK 1.5之前的版本,所以实际上会转化为非线程安全的StringBuilder来完成字符串拼接,并不会加锁。但是这也不影响笔者用这个例子证明Java对象中同步的普遍性。)
现在大家还认为这段代码没有涉及同步吗?
每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。
虚拟机观察变量sb,很快就会发现它的动态作用域被限制在concatString()方法内部。也就是sb的所有引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地削除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(由多次加锁编程只加锁一次)。
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。
锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
例子
- 场景1
public void doSomethingMethod(){
synchronized(lock){
//do some thing
}
//这是还有一些代码,做其它不需要同步的工作,但能很快执行完毕
synchronized(lock){
//do other thing
}
}
上面的代码是有两块需要同步操作的,但在这两块需要同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,
合并后的代码如下:
public void doSomethingMethod(){
//进行锁粗化:整合成一次锁请求、同步、释放
synchronized(lock){
//do some thing
//做其它不需要同步但能很快执行完的工作
//do other thing
}
}
- 场景2
另一种需要锁粗化的极端的情况是:
for(int i=0;i<size;i++){
synchronized(lock){
}
}
上面代码每次循环都会进行锁的请求、同步与释放,看起来貌似没什么问题,且在jdk内部会对这类代码锁的请求做一些优化,但是还不如把加锁代码写在循环体的外面,这样一次锁的请求就可以达到我们的要求,除非有特殊的需要:循环需要花很长时间,但其它线程等不起,要给它们执行的机会。
锁粗化后的代码如下:
synchronized(lock){
for(int i=0;i<size;i++){
}
}
锁粗化的前提
这样做是有前提的,就是中间不需要同步的代码能够很快速地完成,如果不需要同步的代码需要花很长时间,就会导致同步块的执行需要花费很长的时间,这样做也就不合理了。
减少锁持有时间
在方法没有必要做同步的时候,就不需要放在锁中,因此在高并发下,等待的时间就会减少,就会提高自旋锁的成功率。
减小锁粒度
将大对象,拆成小对象,大大增加并行度,降低锁竞争。
偏向锁,轻量级锁成功率提高
比如 ConcurrentHashMap 对于竞争的优化,相对于 HashMap。
- 思考:
减少锁粒度后,可能会带来什么负面影响呢?
以 ConcurrentHashMap 为例,说明分割为多个 Segment后,在什么情况下,会有性能损耗?
锁分离(读写分离)
根据功能进行锁分离
- ReadWriteLock
读多写少的情况,可以提高性能
读写分离思想可以延伸,只要操作互不影响,锁就可以分离
LinkedBlockingQueue(所分离的扩展案例)锁分离的思想在很多场合下都可以使用。
无锁编程(Lock-Free)
无招胜有招,武功的最高境界就是无招,所以锁也是,最好的锁也就是无锁。
无锁跟有锁对比,那么锁则是悲观的操作,为什么会使用锁,因为我们预计这个时候竞争是存在的,所以要加锁。
无锁是乐观的操作,因为认为是没有竞争存在的。比较乐观。
无锁的一种实现方式 CAS (对比和交换)
CAS(Compare And Swap) 他是非阻塞的同步,他不会去等待,上来就会不断尝试,尝试失败在尝试。
要么失败要么直接成功,成功则退出。
基础知识
逃逸分析
对象头Mark
1、Mark Word,他是32位对象头的标记,
2、描述对象的hash、锁信息,垃圾回收标记,年龄;指向锁记录的指针(对于来说可以记录指向锁的指针);指向monitor的指针(monitor可以锁定对象,也可以锁定函数);GC标记(在垃圾标记的时候我们可以做一些标记);偏向锁线程ID(在偏向锁中可以记录偏向锁的ID)
由上可看出,mark是个多功能的头,在很多场合都可以用到,除了在锁中用到,比如在垃圾回收中可以记录年龄,gc的标记等等。
HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下Mark Word的存储内容如下表所示。
- Mark Word
| 存储内容 | 标志位 状态 |
|:---|:---|:---|
| 对象Hash值、对象分代年龄 | 01 未锁定 |
| 指向锁记录的指针 | 00 轻量级锁定 |
| 指向重量级锁的指针 | 10 膨胀(重量级锁定) |
| 空,不记录信息 | 11 GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年龄 | 01 可偏向 |
参考资料
https://www.cnblogs.com/virgosnail/p/9681013.html
java中锁的优化 -- JVM对synchronized的优化
- 锁消除
https://mp.weixin.qq.com/s/T3SMaJODAuB2McEZldRnOQ
https://shipilev.net/jvm/anatomy-quarks/10-string-intern/
- jvm 参数配置建议