第十二章 多线程编程的硬件基础与java内存模型--《java多线程编程实战指南-核心篇》

java虚拟机对内部锁的优化

自java6/7开始,java虚拟机对内部锁的实现进行了一些优化。这些优化主要包括锁消除、锁粗话、偏向锁以及适应性锁。

锁消除

锁消除是JIT编译器对内部锁的具体实现所做的一种优化,在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果同步块所使用的锁对象通过这种分析被正是指能够被同一个线程访问,那么JIT编译器在编译这个同步块的时候并不生成synchronized所表示的锁的申请与释放对应的机器码,而仅生成原临界区代码所对应的机器码,这就造成了被动态编译器的字节码就像是不包含monitorenter(申请锁)和monitorexit(释放锁)这两个字节码指令一样,即消除了锁的使用。这种编译器优化就被称为锁消除,它使得他定情况下我们可以完全消除锁的开销。

java标准库中有一些类(比如StringBuffer)虽然是线程安全的,但是在实际使用中我们往往不在多个线程间共享这些类的实例。而这些类在实现线程安全的时候往往借助于内部锁。因此,这些类是锁消除优化的常见目标。

锁消除优化告诉我们在该使用锁的情况下必须使用锁,而不必过多在意锁的开销。开发人员应该在代码的逻辑层面考虑是否需要加锁,而至于代码运行层面上某个锁是否真的有必要使用则由JIT编译器来决定。锁消除优化并不表示开发人员在编写代码的时候可以随意使用内部锁(在不需要加锁的情况下加锁),因为锁消除是JIT编译器而不是javac所做的一种优化。也就是说在JIT编译器优化介入之前,只要源代码中使用了内部锁,那么这个锁的开销就会存在。另外,JIT编译器锁执行的内联优化、逃逸分析以及锁消除优化本身都是有其开销的。

锁粗化

锁粗化是JIT编译器对内部锁的具体实现所做的一种优化,对于相邻的几个同步块,如果这些同步块使用的是同一个锁实例,那么JIT编译器会将这些同步块合并为一个大同步块,从而避免了一个线程反复申请、释放同一个锁所导致的开销。然而,锁粗化可能导致一个线程持续持有一个锁的时间边长,从而使得同步在该锁上的其他线程在申请锁的等待时间边长。

相邻的两个同步块之间如果存在其他语句,也不一定就会阻碍JIT编译器执行锁粗化优化,这是因为JIT编译器可能在执行锁粗化优化前将这些语句挪到(即指令重排序)后一个同步块的临界区之中(当然,JIT编译器并不会将临界区内的代码挪到临界区之外)。

偏向锁

偏向锁是java虚拟机对锁的实现所做的一种优化。这种优化基于这样的观测结果:大多数所并没有被争用,并且这些锁在其整个生命周期内至多只会被一个线程持有。然而,java虚拟机在实现monitorenter字节码(申请锁)和monitorexit字节码(释放锁)时需要借助一个原子变量(CAS操作),这个操作代价相对来说比较昂贵。一次,java虚拟机会为每个对象维护一个偏好,即一个对象相应的内部锁第一次被一个线程获取,那么这个线程就会被记录为该对象的偏好线程。这个线程后续无论是再次申请该锁还是释放该锁,都无需借助原先(指未实施偏向锁优化前)昂贵的原子操作,从而减少了锁的申请与释放的开销。

然而,一个锁没有被征用并不代表仅仅只有一个线程访问该锁,当一个对象的偏好线程以外的其他线程申请该对象的内部锁时,java虚拟机需要收回该对象对原偏好线程的“偏好”并重新设置该对象的偏好线程。这个偏好收回和重新分配过程的代价也是比较昂贵的,因此如果程序运行过程中存在比较多的锁争用的情况,那么这种偏好收回和重新分配的代码便会被放大。有鉴于此,偏向锁优化只适合于存在相当大一部分锁并没有被征用的系统之中。如果系统中存在大量被争用到锁而没有被征用的锁仅占极小的部分,那么我们可以考虑关闭偏向锁优化。

适应性锁

适应性锁是JIT编译器对内部锁实现所做的一种优化。

存在锁争用的情况下,一个线程申请一个锁的时候如果这个锁恰好被其他线程持有,那么这个线程就需要等待该锁被持有的线程释放。实现这种等待的一种保守方法就是将这个线程暂停(线程的生命周期变为非Runnable状态)。由于暂停线程会导致上下文切换,因此对于一个具体锁实例来说,这种实现策略比较适合于系统中绝大多数线程对该锁的持有时间比较长的场景,这样才能够抵消上下文切换的开销。另外一种实现方法就是采用忙等(实际上就是自旋操作)。所谓忙等相当于如下代码所示的一个循环体为空的循环语句:while(lockIsHeldByOtherThread){}

可见,忙等是通过反复执行空操作直到所需的条件成立为止而实现等待的。这种策略的好处是不会导致上下文切换,缺点是比较耗费处理器资源--如果所需的条件在相当长时间内未成立,那么忙等的循环就会被一只执行。因此,对于一个具体的锁实例来说,忙等策略比较适合于绝大多数线程对该锁的持有时间比较短的场景,这样能够避免过多的处理器时间开销。

java虚拟机会根据其运行过程中收集到的信息来判断这个锁是属于被线程持有时间较长还是较短的。对于被线程持有时间较长的锁,java虚拟机会选用暂停等待策略;而对于被线程持有时间较短的锁,java虚拟机会选用忙等等待策略。java虚拟机也可能先采用忙等等待策略,在忙等失败的情况下再采用暂停等待策略。java虚拟机的这种优化就被称为适应性锁,这种优化同样也需要JIT编译器介入。

适应性锁优化可以是以具体的一个锁实例为基础的,也就是说,java虚拟机可能对一个锁实例采用忙等等待策略,而对另一个锁实例采用暂停等待策略。从适应性锁优化可以看出,内部锁的使用并不一定会导致上下文切换。

优化对锁的使用

锁的开销与锁争用监视

锁的开销包括以下几个方面:

  • 上下文切换与线程调度的开销。一个线程申请一个锁的时候,如果这个锁恰好被其他线程持有,那么该线程最终可能会被暂停。java虚拟机还需要为这个被暂停的线程维护一个等待队列,以便在这个锁被其持有线程释放的时候将这些线程唤醒。而线程的暂停与唤醒就是一个上下文切换的过程,并且java虚拟机维护等待队列也会产生一定的开销。显然,非争用锁并不会导致上下文切换和等待队列的开销。
  • 内存同步、编译器优化受限的开销。锁的内部实现所使用的内存屏障也会产生直接和间接地开销:直接的开销是内存屏障锁导致的冲刷写缓冲器、清空无效化队列所导致的开销。另外,内存屏障会阻碍某些编译器优化。无论是争用锁还是非正用锁,都会产生这部分开销。当然,非正用的锁如果最终使用锁消除优化的话,那么这个锁的任何开销都会被彻底消除。
  • 限制可伸缩性。锁的排他性的本质是局部的将并发计算改为串行计算。这种特性会限制系统的可伸缩性。

可见,锁的开销主要体现在争用锁上面。因此,减少锁的开销的一个基本思路就是消除锁的试用或者降低锁的争用程度。

影响锁的争用程度的因素有两个:程序申请锁的频率以及锁通常被持有的时间跨度。程序越是频繁的申请一个锁,或者这个锁通常被其持有线程持有的时间越长,那么这个锁的争用程度就越高;反之则该锁的争用程度就越低。

因此,降低锁的争用程度的基本思路就是尽可能减少锁的被持有时间和减低申请锁的频率,就具体实现而言,降低锁的争用程度可以从减少临界区长度以及减少锁的粒度这两个方面入手。

使用可参数化锁

如果一个方法或者类内部锁使用的锁实例可以由该方法、类的客户端代码指定,那么我们就称这个锁是可参数化的,相应的,这个锁就被称为可参数化的锁。可参数化的锁在特定情况下有助于减少线程执行过程中参与的锁实例的个数,从而减少锁的开销。

减少临界区的长度

减少临界区的长度可以减少锁被持有的时间从而降低锁被争用的概览,这有利于减少锁的开销。另外,减少锁的持有时间有利于java虚拟机在适用性锁优化发挥作用:在多数线程持有锁的时间都很短的情况下,锁的申请线程可以通过忙等而无需通过暂停线程来等待被争用的锁的释放,这有利于减少上下文切换开销。

临界区逻辑上连贯额一些操作往往可以划分为几个部分:预处理操作、共享变量访问操作以及后处理操作。其中,预处理操作和后处理操作往往是不涉及共享变量的访问的,因此,把这两种操作挪到临界区之外可以在不导致线程安全的前提下减少临界区的长度。

减少锁的粒度

降低锁的争用程度的另外一种思路是降低锁的申请频率。而减小锁的粒度可以降低锁的申请频率,从而减少锁被争用的概览。减小锁粒度的一种常见方法是将一个粒度较粗的锁拆分成若干粒度更细的锁,其中每个锁仅负责保护原粗粒度锁保护的所有共享变量中的一部分共享变量,这种技术被称为锁拆分技术。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章