还记得很久以前看HashTable
的源码的时候,因为老早就听说HashTable
已经被取代了——在多线程并发的情况下性能是在太差,因此看到HashTable
几乎每个方法都加上Synchronized
关键字之后,对这个Synchronized
关键字的印象就是:锁粒度太大、开销高……等等。虽然ConcurrentHashMap性能能够提升的关键还是在于内部各种灵活的CAS方法的使用,但是这也并不能说明Synchronized
锁一定比其他的锁比如ReentrantLock
来得慢,因为JDK一直再为Synchronized
关键字进行种种优化……
接下来就揭露一下JDK中锁优化的点点滴滴,参考文献:《深入理解JAVA虚拟机》
1. 自适应自旋锁
在使用锁完成互斥同步之时,我们知道对性能最大的影响就是阻塞的实现。因为挂起线程和恢复线程都需要交给操作系统,进入内核态来完成,而且每一次基于阻塞的线程调度都需要保存上下文等等,这给并发编程性能带来了很大的压力
如果我们临界区的数据锁定的状态只有很短的一瞬间的话,这时候还要去阻塞和恢复线程很显然是不值当的。让当前线程“原地稍等一会”再访问临界区资源时,很有可能就能访问到而不需要被真正阻塞,这就是自旋锁的思想。在JDK1.4.2时,自旋锁就已经被引入了(虽然是默认关闭状态的)。
在JDK1.4.2中,自旋锁默认次数是10次,用户可以指定参数-XX:PreBlockSpin
来自行更改
到了JDK6之后,引入了自适应自旋锁。自适应代表着:自选的时间不再是固定的了,而是由JVM来监控各个锁每次获取的状态来自适应调整了。例如如果上一次自旋获取锁成功了,那么则会认为这一次自旋很大几率也能获取到锁;如果某个锁很难通过自旋获取的话,那么在以后就不会再尝试自旋获取了,因为自旋也是需要白白损失CPU时间开销的。
2. 锁消除
锁消除是指JVM在编译器运行时,会对一些虽然加上了锁,但被锁的内容不存在数据竞争这种情况进行锁消除操作
编译器判断是否存在竞争是通过一个叫做逃逸分析技术来实现的:如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当做栈上的数据对待,即认为他们是线程私有的,自然就不再需要加锁操作了。
例如:
public String concatString(String s1, String s2) {
return s1 + s2;
}
如果在JDK5之前,也就是锁消除没引进之前,上面这段代码经过编译器后会被优化成
public String concatString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
StringBuffer
内部使用了Synchronized
关键字来实现线程安全
而在JDK5之后,则会变为使用不保证线程安全的StringBuilder
public String concatString(String s1, String s2) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
3. 锁粗化
当我们写代码时,如果将锁作用的对象范围弄得越小,看上去性能效果也应该是最好的
大多数情况下上面的操作是正确的,但是还存在一种例外情况,例如如果一系列的连续操作都对同一个对象进行反复加锁解锁的话,这会增加不必要的性能开销
JVM在探测到上面描述的——对同一个对象反复加锁解锁操作时,会将锁的范围进行放大(粗化)到整个操作序列的外部。
例如上面的SpringBuffer
的例子,通常来说每一个append操作都会加锁,但是JVM优化过后会将锁的范围拓展到从第一个append开始到最后一个append结束,这样就只需要加锁/解锁一次了
4. 轻量级锁、偏向锁与锁膨胀
说到这三个内容,首先不得不提一下JVM中对象的内存布局:
- 对象头
- 对象数据部分
- 对其填充(对齐到8字节的整数倍)
其中对象头又包括:
- Mark word
- 指向对象对应Class文件的指针
- 数组长度标识(如果是数组的话)
其中Mark word又包括
- 。。。还是直接看下面的图吧
轻量级锁
轻量级锁是JDK1.6时加入的新型锁机制,对比与使用操作系统Mutex互斥量来说开销十分轻量,因此得名于轻量级锁。
当然轻量级锁这个名字并不是表示他是一个能代替重量级锁的新锁,它的设计依据就是:对于绝大部分的锁,在整个同步周期内是不存在竞争的。也就是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。
工作机制
如上图所示,在代码进入同步块之前,如果同步对象没有被锁定(即Mark word最后一部分字段为01),此时JVM会
- 在当前线程的栈帧中新建立一个锁记录,然后将当前Mark word拷贝进去
使用CAS操作尝试把对象的Mark word更新为指向锁记录的指针
- 如果CAS成功:则认为轻量级锁加锁成功,此时Mark word最后两位变为00
如果CAS失败:则意味着存在多线程的竞争,此时进入下面流程
检查锁对象的Mark word是否指向当前线程的栈帧
- 是,则说明该线程已经拥有锁,可以继续执行
- 否,则需要进行锁膨胀,记录变为10
后续的线程如果发现锁记录标志位10时,则不触发轻量级锁,直接进入阻塞状态。
可以发现,如果存在锁竞争的话,除了互斥量原本的开销之外,还额外发生了CAS操作,因此会比传统重量级锁还要慢
偏向锁
偏向锁是JDK6引入的,目的消除数据在无竞争环境下的同步原语,他与轻量级锁的区别是:
- 轻量级锁在没竞争的情况下使用CAS来完成
- 偏向锁在没有竞争的情况下把整个同步都消除掉(包括CAS)
其工作的过程和轻量级锁是同步进行的:
- 锁第一次被获取时,会把上图中偏向模式从0设置为1,标志位为01
- 继续轻量级锁的CAS操作
如果CAS成功了,持有偏向锁的线程以后每次进入这个锁相关的同步块时,JVM都不会在进行任何同步操作
一旦另一个线程去尝试获取锁的话,偏向模式马上宣告结束,根据所对象目前是否处于被锁定的状态来决定是否撤销偏向,撤销后标志位恢复到01或者00,之后的同步操作就和上面的轻量级锁一样