synchronized 到底该不该用?

作者: 风筝 / 2021-02-11

Java, Java多线程, JDK, synchronized

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

首先有一点要说明一下,各位可能或多或少都听过这样的说法:“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 codeBlockLock2() {
        synchronized (lock) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

具体的使用可以参考我之前写的这篇文章:类锁和对象锁到底有什么区别

依靠 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 和 类型指针。

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

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

对象头:

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

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

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

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

源码中的定义

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

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

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

无锁转向偏向锁

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

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

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

偏向锁升级到轻量级锁

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

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

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

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

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

轻量级锁升级到重量级锁

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

重量级锁的实现原理

为什么叫重量级锁呢?在重量级锁中没有竞争到锁的对象会 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 会有一定的性能损耗。

相关文章

类锁和对象锁到底有什么区别
Java 调式、热部署、JVM 背后的支持者 Java Agent
Java 开发, volatile 你必须了解一下
终于知道公钥、私钥、对称、非对称加密是什么了
什么时候用 Runnable?什么时候用 Callable ?
风筝

作者

风筝

古时的风筝,一个平庸的程序员,主语言 Java,第二语言 Python,其实学 Python 的时间比 Java 还要早。喜欢写博客,写博客的过程能加深自己对一个知识点的理解,同时还可以分享给他人。喜欢做一些小东西,所以也会一些前端的东西,React、JavaScript、CSS 都会一些,做一些小工具还够用。