>  기사  >  Java  >  Java Virtual Machine에서 내부 잠금을 최적화하는 네 가지 방법 분석

Java Virtual Machine에서 내부 잠금을 최적화하는 네 가지 방법 분석

黄舟
黄舟원래의
2017-10-13 10:18:431202검색

이 기사에서는 내부 잠금을 위한 Java 가상 머신의 네 가지 최적화 방법을 주로 소개합니다. 편집자는 이것이 꽤 좋다고 생각하므로 지금 공유하고 참고용으로 제공하겠습니다. 와서 편집기를 살펴보세요

Java 6/Java 7 이후 Java 가상 머신은 내부 잠금 구현을 일부 최적화했습니다. 이러한 최적화에는 주로 Lock Elision, Lock Coarsening, Biased Locking 및 Adaptive Locking이 포함됩니다. 이러한 최적화는 JVM(Java Virtual Machine) 서버 모드에서만 작동합니다. 즉, Java 프로그램을 실행할 때 이러한 최적화를 활성화하려면 명령줄에서 JVM(Java Virtual Machine) 매개변수 "-server"를 지정해야 할 수도 있습니다.

1 잠금 제거

잠금 제거는 내부 잠금의 특정 구현에 대해 JIT 컴파일러가 수행한 최적화입니다.

잠금 제거의 도식

동기화된 블록을 동적으로 컴파일할 때 JIT 컴파일러는 Escape Analysis라는 기술을 사용하여 동기화된 블록에서 사용하는 잠금 개체가 하나의 스레드에서만 액세스할 수 있는지 여부를 확인할 수 있습니다. 다른 스레드에는 게시되지 않습니다. 본 분석을 통해 동기화된 블록이 사용하는 잠금 객체가 하나의 스레드에서만 접근 가능한 것으로 확인되면, JIT 컴파일러는 동기화된 블록을 컴파일할 때 동기화된 잠금 적용 및 해제에 해당하는 기계어 코드를 생성하지 않고 오직 동기화된 블록만 사용하게 된다. 원본 임계 섹션 코드에 해당하는 기계어 코드가 생성되어 동적으로 컴파일된 바이트코드가 두 개의 바이트코드 명령어 monitorenter(잠금 적용) 및 monitorexit(잠금 해제)를 포함하지 않는 것처럼 보이게 합니다. 즉, 잠금 사용을 제거합니다. . 이러한 컴파일러 최적화를 잠금 제거(Lock Elision)라고 하며, 이를 통해 특정 상황에서 잠금 오버헤드를 완전히 제거할 수 있습니다.

Java 표준 라이브러리(예: StringBuffer)의 일부 클래스는 스레드로부터 안전하지만 실제 사용 시 여러 스레드 간에 이러한 클래스의 인스턴스를 공유하지 않는 경우가 많습니다. 이러한 클래스는 스레드 안전성을 구현할 때 내부 잠금에 의존하는 경우가 많습니다. 따라서 이러한 클래스는 잠금 제거 최적화의 일반적인 대상입니다.

목록 12-1 잠금 제거를 위해 최적화할 수 있는 샘플 코드


public class LockElisionExample {

 public static String toJSON(ProductInfo productInfo) {
  StringBuffer sbf = new StringBuffer();
  sbf.append("{\"productID\":\"").append(productInfo.productID);
  sbf.append("\",\"categoryID\":\"").append(productInfo.categoryID);
  sbf.append("\",\"rank\":").append(productInfo.rank);
  sbf.append(",\"inventory\":").append(productInfo.inventory);
  sbf.append('}');

  return sbf.toString();
 }
}

위의 예에서 JIT 컴파일러는 toJSON 메서드를 컴파일할 때 호출하는 StringBuffer.append/toString 메서드를 여기에 인라인합니다. 이는 StringBuffer.append/toString 메서드의 메서드 본문에 있는 명령을 toJSON 메서드 본문에 복사하는 것과 같습니다. 여기서 StringBuffer 인스턴스 sbf는 지역 변수이고, 이 변수가 참조하는 개체는 다른 스레드에 게시되지 않습니다. 따라서 sbf가 참조하는 개체는 sbf가 있는 메서드의 현재 실행 스레드(1개 스레드)에서만 액세스할 수 있습니다. (toJSON 메서드). 따라서 JIT 컴파일러는 이제 StringBuffer.append/toString 메서드의 메서드 본문에서 복사된 toJSON 메서드의 명령에 사용되는 내부 잠금을 제거할 수 있습니다. 이 예에서는 시스템에서 StringBuffer를 사용하는 다른 위치가 있을 수 있고 이러한 코드가 StringBuffer 인스턴스를 공유할 수 있기 때문에 StringBuffer.append/toString 메서드 자체에서 사용하는 잠금은 해제되지 않습니다.

잠금 제거 최적화가 의존하는 이스케이프 분석 기술은 Java SE 6u23부터 기본적으로 활성화되어 있지만 Java 7에서는 잠금 제거 최적화가 도입되었습니다.

위의 예에서 볼 수 있듯이 잠금 제거 최적화에는 JIT 컴파일러의 인라인 최적화가 필요할 수도 있습니다. 메소드가 JIT 컴파일러에 의해 인라인될지 여부는 메소드의 인기도와 메소드에 해당하는 바이트코드의 크기(바이트코드 크기)에 따라 달라집니다. 따라서 잠금 제거 최적화를 구현할 수 있는지 여부는 호출된 동기화된 메서드(또는 동기화된 블록이 있는 메서드)가 인라인될 수 있는지 여부에 따라 달라집니다.

잠금 제거 최적화는 잠금 오버헤드에 너무 많은 주의를 기울이지 않고 잠금을 사용해야 할 때 잠금을 사용해야 한다는 것을 알려줍니다. 개발자는 코드의 논리적 수준에서 잠금이 필요한지 여부를 고려해야 합니다. 코드 실행 수준에서 특정 잠금이 실제로 필요한지 여부는 JIT 컴파일러에 의해 결정됩니다. 잠금 제거 최적화는 개발자가 코드를 작성할 때 마음대로 내부 잠금을 사용할 수 있다는 의미는 아닙니다(잠금 시 잠금은 필요하지 않음). 잠금 제거는 javac가 아닌 JIT 컴파일러에 의해 수행되는 최적화이고 단락 코드는 다음을 통해서만 최적화될 수 있기 때문입니다. 충분히 자주 실행되는 경우 JIT 컴파일러. 즉, JIT 컴파일러 최적화가 개입하기 전에 소스 코드에서 내부 잠금이 사용되는 한 이 잠금의 오버헤드가 존재하게 됩니다. 또한 JIT 컴파일러에서 수행되는 인라인 최적화, 이스케이프 분석 및 잠금 제거 최적화에는 모두 고유한 오버헤드가 있습니다.

잠금 제거 효과에 따라 ThreadLocal을 사용하여 스레드로부터 안전한 개체(예: Random)를 스레드별 개체로 사용하면 잠금 경합을 피할 수 있을 뿐만 아니라 이러한 개체 내에서 사용되는 잠금을 완전히 제거할 수도 있습니다.

2 잠금 조대화

잠금 조대화(잠금 조대화/잠금 병합)는 내부 잠금의 특정 구현에 대해 JIT 컴파일러가 수행한 최적화입니다.

락 조잡화 다이어그램

对于相邻的几个同步块,如果这些同步块使用的是同一个锁实例,那么JIT编译器会将这些同步块合并为一个大同步块,从而避免了一个线程反复申请、释放同一个锁所导致的开销。然而,锁粗化可能导致一个线程持续持有一个锁的时间变长,从而使得同步在该锁之上的其他线程在申请锁时的等待时间变长。例如上图中,第1个同步块结束和第2个同步块开始之间的时间间隙中,其他线程本来是有机会获得monitorX的,但是经过锁粗化之后由于临界区的长度变长,这些线程在申请monitorX时所需的等待时间也相应变长了。因此,锁粗化不会被应用到循环体内的相邻同步块。

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

实际上,我们写的代码中可能很少会出现上图中那种连续的同步块。这种同一个锁实例引导的相邻同步块往往是JIT编译器编译之后形成的。

例如,在下面的例子中

清单12-2  可进行锁粗化优化的示例代码


public class LockCoarseningExample {
 private final Random rnd = new Random();

 public void simulate() {
  int iq1 = randomIQ();
  int iq2 = randomIQ();
  int iq3 = randomIQ();
  act(iq1, iq2, iq3);
 }

 private void act(int... n) {
  // ...
 }

 // 返回随机的智商值
 public int randomIQ() {
  // 人类智商的标准差是15,平均值是100
  return (int) Math.round(rnd.nextGaussian() * 15 + 100);
 }
 // ...
}

simulate方法连续调用randomIQ方法来生成3个符合正态分布(高斯分布)的随机智商(IQ)。在simulate方法被执行得足够频繁的情况下,JIT编译器可能对该方法执行一系优化:首先,JIT编译器可能将randomIQ方法内联(inline)到simulate方法中,这相当于把randomIQ方法体中的指令复制到simulate方法之中。在此基础上,randomIQ方法中的rnd.nextGaussian()调用也可能被内联,这相当于把Random.nextGaussian()方法体中的指令复制到simulate方法之中。Random.nextGaussian()是一个同步方法,由于Random实例rnd可能被多个线程共享(因为simulate方法可能被多个线程执行),因此JIT编译器无法对Random.nextGaussian()方法本身执行锁消除优化,这使得被内联到simulate方法中的Random.nextGaussian()方法体相当于一个由rnd引导的同步块。经过上述优化之后,JIT编译器便会发现simulate方法中存在3个相邻的由rnd(Random实例)引导的同步块,于是锁粗化优化便“粉墨登场”了。

锁粗化默认是开启的。如果要关闭这个特性,我们可以在Java程序的启动命令行中添加虚拟机参数“-XX:-EliminateLocks”(开启则可以使用虚拟机参数“-XX:+EliminateLocks”)。

3 偏向锁

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

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

偏向锁优化默认是开启的。要关闭偏向锁优化,我们可以在Java程序的启动命令行中添加虚拟机参数“-XX:-UseBiasedLocking”(开启偏向锁优化可以使用虚拟机参数“-XX:+UseBiasedLocking”)。

4 适应性锁

适应性锁(Adaptive Locking,也被称为 Adaptive Spinning )是JIT编译器对内部锁实现所做的一种优化。

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


// 当锁被其他线程持有时一直循环 
while (lockIsHeldByOtherThread){}

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

事实上,Java虚拟机也不是非要在上述两种实现策略之中择其一 ——它可以综合使用上述两种策略。对于一个具体的锁实例,Java虚拟机会根据其运行过程中收集到的信息来判断这个锁是属于被线程持有时间“较长”的还是“较短”的。对于被线程持有时间“较长”的锁,Java虚拟机会选用暂停等待策略;而对于被线程持有时间“较短”的锁,Java虚拟机会选用忙等等待策略。Java虚拟机也可能先采用忙等等待策略,在忙等失败的情况下再采用暂停等待策略。Java虚拟机的这种优化就被称为适应性锁(Adaptive Locking),这种优化同样也需要JIT编译器介入。

适应性锁优化可以是以具体的一个锁实例为基础的。也就是说,Java虚拟机可能对一个锁实例采用忙等等待策略,而对另外一个锁实例采用暂停等待策略。

从适应性锁优化可以看出,内部锁的使用并不一定会导致上下文切换,这就是我们说锁与上下文切换时均说锁“可能”导致上下文切换的原因。

 

위 내용은 Java Virtual Machine에서 내부 잠금을 최적화하는 네 가지 방법 분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.