Java 同步原语 synchronized 剖析和锁优化

[TOC]

概述

Java 在语法层面提供了 synchronized 关键字来实现多线程同步,虽然 Java 有 ReentrantLock 等高级锁,但是 synchronized 用法简单,不易出错,并且 JDK6 对其进行了诸多优化,性能也不差,故而依然值得我们去使用。

本文,我们将对 synchronized 对实现进行剖析,分析其实现原理以及 JDK6 引入了哪些锁优化对手段。

synchronized 实现

我们先看一段代码:

public class LockTest {

    public synchronized void testSync() {
        System.out.println("testSync");
    }

    public void testSync2() {
        synchronized(this) {
            System.out.println("testSync2");
        }
    }
}

在这段代码中,分布使用 synchronized 对方法和语句块进行了同步,接下来我们使用 javac 编译后,再用 javap 命令查看其汇编代码:

javap -verbose LockTest.class

  public synchronized void testSync();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String testSync
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 9: 0
        line 10: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/qunar/fresh2017/LockTest;

  public void testSync2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #5                  // String testSync2
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 13: 0
        line 14: 4
        line 15: 12
        line 16: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/qunar/fresh2017/LockTest;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/qunar/fresh2017/LockTest, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

这里我们省略了关键方法之外对常量池、构造函数等部分,对于 synchronized 方法,仅仅在其 class 文件的 access_flags 字段中设置了 ACC_SYNCHRONIZED 标志。对于 synchronized 语句块,分布在同步块的入口和出口插入了 monitorenter 和 monitorexit 字节码指令。

注意这里有多个 monitorexit ,除了在正常出口插入了 monitorexit,还在异常处理代码里插入了 monitorexit(请看 Exception table )。

在这篇文章 OpenJDK9 Hotspot : synchronized 浅析里,可以看到 monitorenter 的处理逻辑位于 bytecodeInterpreter.cpp 中,其加锁顺序为偏向锁 -> 轻量级锁 -> 重量级锁。最终重量级锁的代码位于 ObjectMonitor::enter 中,enter 调用了 EnterI 方法,EnterI 方法中,调用了线程拥有的 Parker 实例的 park 方法,这一点和 LockSupport 一致,毕竟都是需要底层操作系统支持的。

// in /vm/runtime/objectMonitor.cpp
void ATTR ObjectMonitor::enter(TRAPS) {
    // omit a lot
    for (;;) {
      jt->set_suspend_equivalent();
      // cleared by handle_special_suspend_equivalent_condition()
      // or java_suspend_self()

      EnterI (THREAD) ;
      // omit a lot
    }
}
void ATTR ObjectMonitor::EnterI (TRAPS) {
    Thread * Self = THREAD ;
    // 省略很多
    for (;;) {
        // omit a lot
       
        // park self
        if (_Responsible == Self || (SyncFlags & 1)) {
            TEVENT (Inflated enter - park TIMED) ;
            Self->_ParkEvent->park ((jlong) RecheckInterval) ;
            // Increase the RecheckInterval, but clamp the value.
            RecheckInterval *= 8 ;
            if (RecheckInterval > 1000) RecheckInterval = 1000 ;
        } else {
            TEVENT (Inflated enter - park UNTIMED) ;
            Self->_ParkEvent->park() ;
        }
    }
    // omit a lot   

锁的粒度

锁的粒度是一个很关键的问题,粒度的大小对于线程的并发性能有很大影响,比如数据库中表锁的并发度要比远低于行锁。下面介绍一下 synchronized 几种常见用法中,锁的粒度:

  1. 对于同步方法,锁是当前实例对象。
  2. 对于静态同步方法,锁是当前对象的Class对象。
  3. 对于同步方法块,锁是 synchronized 括号里配置的对象。

锁优化

JVM

JDK6 中为了提升锁的性能,引入了“偏向锁”和“轻量级锁”的概念,所以在 Java 中锁一共有 4 种状态:无锁、偏向锁、轻量级锁、重量级锁。它会随着竞争情况逐渐升级,锁可以升级但不能降级,也就是说不能有重量级锁变为轻量级锁,也不能由轻量级锁变为偏向锁。下面我们介绍一下这几种锁的特点:

场景 优点 缺点
偏向锁 适用于只有一个线程访问同步块的场景 加锁和解锁不存在额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在竞争,会带来额外的锁撤销的消耗
轻量级锁 适用于同步块执行速度非常快的场景 竞争线程不会阻塞,减少了线程切换消耗的时间 如果线程间竞争激烈,会导致过多的自旋,消耗 CPU
重量级锁 使用于同步款执行较慢,锁占用时间较长的场景 竞争激烈时,消耗 CPU 较少 线程阻塞,会引起线程切换

可以说,没有哪一种锁绝对比另一种锁好,各自都有其适合的场景。下面再简单描述一下,这些锁具体是如何实现的:

  1. 偏向锁:对象头的锁标志位置为 1 表示当前是偏向锁,另有 23bit 用来记录当前线程 id。如果一个线程发现当前是无锁状态,会将锁状态改为偏向锁。如果已经是偏向锁,并且记录的线程 id 和当前线程一致,则认为是获得了锁;否则升级锁到轻量级锁。
  2. 轻量级锁:其本质是自旋锁,也就是轮询去获取锁,实际中功能会高级一点儿,有自适应自旋功能,所谓自适应也就是根据竞争激烈程度适当调整自旋次数,以及决定是否升级为重量级锁。
  3. 重量级锁:其底层实现通常就是 POSIX 中的 mutex 和 condition。

代码

前面讲了 JVM 中优化锁的性能的一些方法,这里再扩展一下,在实际的代码编写过程中,使用锁时,有哪些方法可以改进性能?

  1. 减少锁占用时间:减少锁占用时间,能过有效的减少竞争激烈程度,减少到一定程度,就可以使用轻量级锁,代替重量级锁来提升性能。如何减少呢?
  • 减少不必要的占用锁的代码。
  • 降低锁的粒度。
  1. 锁分离:该技术能过降低锁占用时间,或者减少竞争激烈程度。
  • 将一个大锁分解多个小锁,比如从 HashTable 的对象级别锁到 ConcurrentHashMap 的分段锁的优化。
  • 按照同步操作的性质来拆分,比如读写锁大大提升了读操作的性能。
  1. 锁粗化:看起来与锁分离相反,但是它们适用的场景不同。在一个间隔性地需要执行同步语句的线程中,如果在不连续的同步块间频繁加锁解锁是很耗性能的,因此把加锁范围扩大,把这些不连续的同步语句进行一次性加锁解锁。虽然线程持有锁的时间增加了,但是总体来说是优化了的。
  2. 锁消除:根据代码逃逸技术的分析,如果一段代码中的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必加锁。

总结

在实际中,锁的各种优化技术是可以一起使用的。比如在减少锁占用时间后,就可以使用自旋锁代替重量级锁提升性能。

参考资料

  1. OpenJDK9 Hotspot : synchronized 浅析
  2. Java SE1.6 中的 Synchronized

你可能感兴趣的:(Java 同步原语 synchronized 剖析和锁优化)