我是风筝,公众号「古时的风筝」,一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!

在多线程环境中,锁的使用是避免不了的,使用锁时候有多种锁供我们选择,比如 ReentrantLockCountDownLatch等等,但是作为 Java 开发者来说,刚刚接触多线程的时候,最早接触和使用的恐怕非 synchronized莫属了。那你真的了解synchronized吗,今天我们就从以下几个方面彻底搞懂 synchronized

synchronized 到底该不该用?_Java

首先有一点要说明一下,各位可能或多或少都听过这样的说法:“synchronized 的性能不行,比显式锁差很多,开发中还是要慎用。”

大可不必有这样的顾虑,要说在 JDK 1.6 之前,synchronized 的性能确实有点差,但是 JDK 1.6 之后,JDK 开发团队已经持续对 synchronized 做了性能优化,其性能已经与其他显式锁基本没有差距了。所以,在考虑是不是使用 synchronized的时候,只需要根据场景是否合适来决定,性能问题不用作为衡量标准。

使用方法

synchronized 是一个关键字,它的一个明显特点就是使用简单,一个关键字搞定。它可以在一个方法上使用,也可以在一个方法中的某些代码块上使用,非常方便。

public class SyncLock {

  	private Object lock = new Object();
  
    /**
     * 直接在方法上加关键字
     */
    public synchronized void methodLock() {
        System.out.println(Thread.currentThread().getName());
    }

    /**
     * 在代码块上加关键字,锁住当前实例
     */
    public void codeBlockLock() {
        synchronized (this) {
            System.out.println(Thread.currentThread().getName());
        }
    }
  
  	/**
     * 在代码块上加关键字,锁住一个变量
     */
    public void codeBlockLock() {
        synchronized (lock) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

具体的使用可以参考我之前写的这篇文章:TODO

依靠 JVM 中的 monitorenter 和 monitorexit 指令控制。通过 javap -v命令可以看到前面的实例代码中对 synchronized 关键字在字节码层面的处理,对于在代码块上加 synchronized 关键字的情况,会通过 monitorentermonitorexit指令来表示同步的开始和退出标识。而在方法上加关键字的情况,会用 ACC_SYNCHRONIZED作为方法标识,这是一种隐式形式,底层原理都是一样的。

public synchronized void methodLock();
    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: invokestatic  #3                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
         6: invokevirtual #4                  // Method java/lang/Thread.getName:()Ljava/lang/String;
         9: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: return
      LineNumberTable:
        line 12: 0
        line 13: 12

  public void codeBlockLock();
    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: invokestatic  #3                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        10: invokevirtual #4                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        13: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        16: aload_1
        17: monitorexit
        18: goto          26
        21: astore_2
        22: aload_1
        23: monitorexit
        24: aload_2
        25: athrow
        26: return

对象布局

为什么介绍 synchronized 要说到对象头呢,这和它的锁升级过程有关系,具体的锁升级过程稍后会讲到,作为锁升级过程的数据支撑,必须要掌握对象头的结构才能了解锁升级的完整过程。

在 Java 中,任何的对象实例的内存布局都分为对象头、对象实例数据和对齐填充数据三个部分,其中对象头又包括 MarkWord 和 类型指针。

synchronized 到底该不该用?_java_02

对象实例数据: 这部分就是对象的实际数据。

对齐填充: 因为 HotSpot 虚拟机内存管理要求对象的大小必须是8字节的整数倍,而对象头正好是8个字节的整数倍,但是实例数据不一定,所以需要对齐填充补全。

对象头:

Klass 指针: 对象头中的 Klass 指针是用来指向对象所属类型的,一个类实例究竟属于哪个类,需要有地方记录,就在这里记。

MarkWord: 还有一部分就是和 synchronized 紧密相关的 MarkWord 了,主要用来存储对象自身的运行时数据,如hashcode、gc 分代年龄等信息。 MarkWord 的位长度为 JVM 的一个 Word 大小,32位 JVM 的大小为32位,64位JVM的大小为64位。

下图是 64 位虚拟机下的 MarkWord 结构说明,根据对象锁状态不同,某些比特位代表的含义会动态的变化,之所以要这么设计,是因为不想让对象头占用过大的空间,如果为每一个标示都分配固定的空间,那对象头占用的空间将会比较大。

synchronized 到底该不该用?_java_03

数组长度: 要说明一下,如果是数组对象的话, 由于数组无法通过本身内容求得自身长度,所以需要在对象头中记录数组的长度。

源码中的定义

追根溯源,对象在 JVM 中是怎么定义的呢?打开 JVM 源码,找到其中对象的定义文件,可以看到关于前面说的对象头的定义。

class oopDesc {
  friend class VMStructs;
  friend class JVMCIVMStructs;
 private:
  volatile markOop _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
}

oop 是对象的基础类定义,也就是或 Java 中的 Object 类的定义其实就是用的 oop,而任何类都由 Object 继承而来。oopDesc 只是 oop 的一个别名而已。

可以看到里面有关于 Klass 的声明,还有 markOop 的声明,这个 markOop 就是对应上面说到的 MarkWord。

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4, //分代年龄
         lock_bits                = 2, //锁标志位
         biased_lock_bits         = 1, //偏向锁标记  
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };
}

以上代码只是截取了其中一部分,可以看到其中有关于分代年龄、锁标志位、偏向锁的定义。

虽然源码咱也看不太懂,但是当我看到它们的时候,恍惚之间,内心会感叹到,原来如此。有种宇宙之间,已尽在我掌控之中的感觉。过两天才发现,原来只是一种心理安慰。但是,已经不重要了。

提示

如果你有兴趣翻源码看看,这部分的定义在 /src/hotspot/share/oops目录下,能告诉你的就这么多了。

锁升级

JDK 1.6 之后,对 synchronized 做了优化,主要就是 CAS 自旋、锁消除、锁膨胀、轻量级锁、偏向锁等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率,进而产生了一套锁升级的规则。

synchronized 到底该不该用?_synchronized_04

synchronized 的锁升级过程是通过动态改变对象 MarkWord 各个标志位来表示当前的锁状态的,那修改的是哪个对象的 MarkWord 呢,看上面的代码中,synchronized 关键字是加在 lock 变量上的,那就会控制 lock 的 MarkWord。如果是 synchronized(this)或者在方法上加关键字,那控制的就是当前实例对象的 MarkWord。

synchronized 到底该不该用?_Java_05

synchronized 的核心准则概括起来大概是这个样子。

  1. 能不加锁就不加锁。
  2. 能偏向就尽量偏向。
  3. 能加轻量级锁就不用重量级锁。

无锁转向偏向锁

偏向锁的意思是说,这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

synchronized 到底该不该用?_Java_06

当线程尝试获取锁对象的时候,先检查 MarkWord 中的线程ID 是否为空。如果为空,则虚拟机会将 MarkWord 中的偏向标记设置为 1,锁标记位为 01。同时,使用 CAS 操作尝试将线程ID记录到 MarkWord 中,如果 CAS 操作成功,那之后这个持有偏向锁的线程再次进入相关同步块的时候,将不需要再进行任何的同步操作。

如果检查线程ID不为空,并且不为当前线程ID,或者进行 CAS 操作设置线程ID失败的情况下,都要撤销偏向状态,这时候就要升级为偏向锁了。

synchronized 到底该不该用?_JVM_07

偏向锁升级到轻量级锁

当多个线程竞争锁时,偏向锁会向轻量级锁状态升级。

synchronized 到底该不该用?_java_08

首先,线程尝试获取锁的时候,先检查锁标志为是否为 01 状态,也就是未锁定状态。

如果是未锁定状态,那就在当前线程的栈帧中建立一个锁记录(Lock Record)区域,这个区域存储 MarkWord 的拷贝。

之后,尝试用 CAS 操作将 MarkWord 更新为指向锁记录的指针(就是上一步在线程栈帧中的 MarkWord 拷贝),如果 CAS 更新成功了,那偏向锁正式升级为轻量级锁,锁标志为变为 00。

synchronized 到底该不该用?_JVM_09

如果 CAS 更新失败了,那检查 MarkWord 是否已经指向了当前线程的锁记录,如果已经指向自己,那表示已经获取了锁,否则,轻量级锁要膨胀为重量级锁。

synchronized 到底该不该用?_Java_10

轻量级锁升级到重量级锁

上面的图中已经有了关于轻量级锁膨胀为重量级锁的逻辑。当锁已经是轻量级锁的状态,再有其他线程来竞争锁,此时轻量级锁就会膨胀为重量级锁。

synchronized 到底该不该用?_java_11

重量级锁的实现原理

为什么叫重量级锁呢?在重量级锁中没有竞争到锁的对象会 park 被挂起,退出同步块时 unpark 唤醒后续线程。唤醒操作涉及到操作系统调度会有额外的开销,这就是它被称为重量级锁的原因。

当锁升级为重量级锁的时候,MarkWord 会指向重量级锁的指针 monitor,monitor 也称为管程或监视器锁, 每个对象都存在着一个 monitor 与之关联 ,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)

monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。

适用场景

偏向锁

优点: 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

缺点: 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用场景: 适用于只有一个线程访问同步块场景。

有的同学可能会有疑惑,适用于只有一个线程的场景是什么鬼,一个线程还加什么锁。

要知道,有些锁不是你想不加就不加的。比方说你在使用一个第三方库,调用它里面的一个 API,你虽然知道是在单线程下使用,并不需要加锁,但是第三方库不知道啊,你调用的这个 API 正好是用 synchronized 做了同步的。这种情况下,使用偏向锁可以达到最高的性能。

轻量级锁

优点: 竞争的线程不会阻塞,提高了程序的响应速度。

缺点: 如果始终得不到锁竞争的线程使用自旋会消耗CPU。

适用场景: 追求响应时间。同步块执行速度非常快。

重量级锁

优点: 线程竞争不使用自旋,不会消耗CPU。

缺点: 线程阻塞,响应时间缓慢。

适用场景: 追求吞吐量。同步块执行速度较长。

总结

1、synchronized 是可重入锁,是一个非公平的可重入锁,所以如果场景比较复杂的情况,还是要考虑其他的显式锁,比如 ReentrantlockCountDownLatch等。

2、synchronized 有锁升级的过程,当有线程竞争的情况下,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,synchronized 会有一定的性能损耗。