JVM的锁优化
虚拟机为高效并发所做的努力
我们在代码里面加锁的目的是为了线程安全,那么,什么是线程安全呢?“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。
这里的多个线程访问是锁消除的关键点,如果一个对象被加了锁,但是这个对象只会被一个线程访问、操作,那么这个锁是不是就是多余的?不过由于对象基本上是分配在堆空间的,堆空间是线程共享的空间,怎么判断一个对象不会被其他线程访问到呢?答案就是逃逸分析(动态分析对象的作用域),如果对象不存在线程逃逸,那么就可以认为这个对象是线程私有的,自然就不需要加锁了。
然后就有疑问了,一个稍有经验的程序员,都不会在一个不存在竞争的对象上加锁,当然,没有经验的程序员也不知道加锁(比如我,哈哈)。其实,锁消除优化的基本上是源码里面的锁,很多线程安全的类都是加了锁的,但是如果这个对象并没有逃逸,那么这些锁就是可以消除掉的。
在单核处理器的操作系统层面,线程竞争时,如果一个线程没有拿到对象的锁,那么它就会被阻塞,阻塞之后为了释放CPU资源,就会把这个线程挂起,等有空闲资源的时候再恢复线程。这个操作看起来没什么问题,但是挂起和恢复都需要转入内核态中完成,比较消耗资源。
同时,虚拟机的开发团队注意到,在许多应用上,共享数据的的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。想象一下,如果是多核处理器,线程不释放CPU,也不被挂起,而是稍等片刻(执行一个忙循环),然后看看锁是不是释放了,刚刚说,很多情况下锁的时间都很短,所以这样的优化一般都是有效的。
但是,如果遇到一个线程锁的时间特别长,另一个线程拿不到锁,一直执行忙循环,占用着CPU,岂不是白白浪费处理器资源。所以,自旋锁有一个次数限制,默认是10次,如果超过限定的次数仍然没有拿到锁,就应该把这个线程挂起了。
默认10次也不够友好,比如一个朋友每次都很准时,这一次稍微迟到一点,你是不是会多等他一会。如果一个朋友每次都会迟到,你可能根本不会等他。锁也一样,如果一个锁每次都能拿到,但是某一次可能需要15次循环才能拿到,就应该多等一会.如果一个锁,每次都拿不到,线程还不如直接挂起。所以JDK1.6又引入了自适应的自旋锁,如果一个锁刚刚获取成功了,这次获取成功的可能性就比较大,就会延长自旋次数,比如20次或30次。如果一个锁很少获取成功,以后获取这个锁时,可能直接省略掉自旋过程,避免浪费处理器资源。
一般我们在写同步代码的时候,都是推荐锁的范围越小越好,能锁代码块就别锁方法(能锁变量就别锁对象),这样能使需要同步的操作数量尽可能的小,等待线程也能尽快的拿到锁。
大部分情况下,这样都是没有毛病的。但是如果一系列的操作都在对同一个对象加锁,甚至在循环体加锁时,即使没有线程竞争,频繁的进行互斥同步操作,也会导致不必要的性能消耗。
锁粗化就是,如果虚拟机探测到有一串零碎的操作都是在对同一个对象加锁时,将会把整个加锁同步操作扩展到整个操作序列的外部,就可以只加一次锁了。
轻量级锁是相对于的利用系统互斥量来实现的传统锁,传统锁被称为重量锁。轻量级锁并不是为了替代重量级锁,它也替代不了。轻量级锁的作用是,如果不存在线程竞争,那么可以减少重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁的执行过程:在代码进入同步块时,如果需要同步的对象目前没有被锁定(锁标志01),那么就利用CAS操作,更新锁指针和锁标志(00:轻量级锁)。如果CAS操作失败了,那么说明这个对象被锁定了,就判断一下锁对象的线程是不是当前线程,如果是的话,直接进入同步代码块执行(重入锁)。如果不是当前线程锁定的,那么说明这个对象被其他线程抢占了,有两条以上的线程竞争同一个锁时,轻量级锁不再有效,需要膨胀为重量级锁(10:重量级锁),后面等待锁的线程就需要进入阻塞状态了。
偏向锁其实可以叫偏心锁,比较偏向于第一个使用它的线程。轻量级锁是无竞争情况下,利用CAS操作消除了同步使用的互斥量。偏向锁这个优化就更激进了,在无竞争的情况下,消除整个同步,CAS操作都不用。
在第一个线程执行同步块时,如果该锁一直没有竞争,那么持有偏向锁(biased_lock:1;lock:01)的线程永远不会做同步操作。当有另一个线程来获取锁时,偏向模式就结束了。根据锁对象当前是否被锁定,对象状态恢复为未锁定或者轻量级锁状态。如果时轻量级锁状态,后续操作就和轻量级锁一样了。
偏向锁的优化不一定能带来收益,如果程序中大多数锁都存在竞争,那么偏向锁其实就是多余的,禁用偏向锁反而会带来性能的提升。