并发编程之synchronized的前世今生

在上一篇文章中并发编程之AQS探秘中,我们介绍了AQS的运用及实现原理,同时简单的展望了一下JUC包的大致结构及套路。其实提到并发编程就不得不提到一个词,而提到锁又始终绕不过去Java自带的锁synchronized。下面我们就来探究下synchronized的前世今生。本文主要包括以下几部分:

  1. 前言

  2. 2.1 锁分类
    2.1.1 按照实现划分
    2.1.2 按照特性划分
    2.2 五花八门的锁
    2.2.1 悲观锁、乐观锁
    2.2.2 自旋锁、自适应自旋锁
    2.2.3 无锁、偏向锁、轻量级锁、重量级锁
    2.2.4 公平锁、非公平锁
    2.2.5 可重入锁、不可重入锁
    2.2.6 共享锁、排它锁
  3. synchronized 基础
    3.1 实例方法
    3.2 静态方法
    3.3 代码块
  4. synchronized 原理
    4.1 Monitor
    4.2 锁方法
    4.3 锁代码块
  5. 锁信息的存储
    5.1 对象的内存布局
    5.2 锁信息的存储之MarkWord
  6. 锁优化
    6.1 锁升级的过程
    6.2 无锁
    6.3 偏向锁
    6.4 轻量级锁
    6.5 重量级锁
  7. 总结

1. 前言

谈到并发编程,就不得不提并发编程的一哥,而提到锁就绕不开synchronized。而想要搞清楚synchronized的原理,我们必须要搞清楚锁的数据结构,JDK1.6后带来的锁优化,同时既然提到了锁,就很有必要了解锁的分类,故下面我们会按照锁分类、synchronized用法、synchronized原理、锁信息的存储、锁优化这个顺序来介绍。

老套路,先思考几个问题。俗话说的好,不带着问题学习的Java开发工程师,不是一个好的UI

问题1:java中都有哪些锁?各自的使用场景又是什么?
问题2:synchronized如何使用?原理是什么?
问题3:如何计算对象的大小?
问题4:虚拟机栈是如何构成的?
问题5:锁升级的过程是什么?
问题6:什么是偏向锁?什么是轻量级锁?什么是重量级锁?能否降级?
问题7:JDK对锁做了哪些优化?

下面带着这些疑问,我们继续下面的内容。


2. 锁

多线程环境下,解决资源竞争时的数据安全性问题,我们一般有两种思路。

  • 资源的复制,即每个线程复制一份资源,相当于每个线程持有自己的资源。典型的实现比如ThreadLocal
  • 将并发的执行变成串行的执行,即不同线程之间的执行顺序具有互斥性。典型的方式加锁,synchronized、Lock等

2.1 锁分类

Java中的锁按照不同的维度可以做不同的划分。

2.1.1 按照实现划分

  • 语言级别(关键字)的锁(自带的synchronized)
  • 语义级别的锁(基于特殊的数据结构+volatile+CAS实现的锁。ReentrantLock、ReadWriteLock等)

2.1.2 按照特性划分

除了按照实现粗略划分,还可以按照锁是否有某种特性来划分。这种情况下,可以分为如下锁:
悲观锁、乐观锁、自旋锁、自适应自旋锁、无锁、偏向锁、轻量级锁、重量级锁、公平锁、非公平锁、可重入锁、不可重入锁、共享锁、排它锁(互斥锁)共14种。具体区分详见如下思维导图:

Java 锁.jpg

有一点需要特别说明的上述14种锁,只是说按照锁的特性可以拆分为14种不同特性的锁。但是并不是有14种不同种类的实现。

  • 拿synchronized来说,它即是悲观锁,又是非公平锁,又是可重入锁,同时又包含了四种锁的状态:无锁、偏向锁、轻量级锁、重量级锁。
  • 一个synchronized包含了七种特性,但却仅仅有一种实现。所以锁的种类和实现的种类是不对等的。

2.2 五花八门的锁

2.2.1 悲观锁、乐观锁

悲观锁和乐观锁并不是java种特有的实现,而是一个广义的概念,具体来说:

  • 悲观锁认为在多线程竞争的情况下,使用数据时一定会有别的线程来修改数据,因此要先加锁。
  • 乐观锁则认为在多线程竞争的情况下,使用数据时不会有别的线程来修改数据,只会在提交数据的时候才会去比较数据在这期间是否被修改。如果未被修改则成功提交,如果被修改则按照既定的策略操作(报错和或重试)。

悲观锁和乐观锁在不同的产品种有不同的实现。比如:

  • 悲观锁:Java种的synchronized和Lock。数据库种的select for update。利用redis或者zk实现的分布式锁。这些都是悲观锁。
  • 乐观锁:Java的CAS、各种原子操作类。数据库加入版本号字段。

同时由于悲观锁和乐观锁的特性,它们分别有不同的应用场景:

  • 悲观锁:适合写多都少的场景,一上来就加锁可以最大的保证数据安全性,但是会带来性能的损失。
  • 乐观锁:适合写少读多的场景,最后提交的时候才进行比较,可以最大的保证性能。

值得注意的是:并不是说使用乐观锁如CAS一定性能就比悲观锁高,要区分具体场景。比如在线程竞争度非常高的时候,使用CAS反而不如使用synchronized。因为竞争度高的情况下CPU往往浪费在无法成功的自旋上,此时还不如让线程阻塞挂起,等待获得锁后继续运行来的高效。

2.2.2 自旋锁、自适应自旋锁

说自旋锁、自适应自旋锁时,我们先要明白一个前提条件cpu的执行时间要比线程切换的速度快得多,一个操作CPU执行时间可能仅仅需要1ns,而线程上下文切换可能需要10000ns。
阻塞和唤醒线程需要涉及到线程上下文的切换,如果阻塞的代码块的执行时间远比线程切换的时间短的情况下,再为了一段执行时间很短的代码,去做耗时更长的线程上下文切换,这样显然时不合理的。那么有没有办法让当前线程等一等呢。

  • 线程不去挂起,而是进入自旋状态。如果自旋结束后,其它线程释放了锁,则线程可以成功获得锁,而避免了挂起和恢复线程时带来的消耗。如果未获取到,则进行循环尝试。这个就是自旋锁的定义。这里有一个问题,如果一直获取获取不到锁,就一直自旋吗,显然是不合适的,一直自旋会造成CPU的浪费,因此需要设置一个自旋最大次数。
  • JDK 1.6后对自旋锁进行了优化,可以根据情况,按照一定的算法自动调整自旋次数。这个就叫做自适应自旋锁。具体调整的规则为,如果自旋等待刚成功过且持有锁的线程正在运行,则允许更长的自旋时间。如果自旋等待很少会成功,那么可能下次就放弃自旋直接阻塞,避免CPU的浪费。

同时自旋锁和自适应自旋锁都是利用CAS实现的

2.2.3 无锁、偏向锁、轻量级锁、重量级锁

这里的无锁、偏向锁、轻量级锁、重量级锁都是指synchronized内部锁升级的过程。

  • 随着竞争的愈发激烈,锁会经历由无锁->偏向锁-> 轻量级锁->重量级锁的升级过程。
  • 锁升级的过程是不可逆的,也就是锁不能降级。
    要讲清楚锁升级就不得不提锁的存储,后面的部分我们会详细介绍。

2.2.4 公平锁、非公平锁

公平和非公平指的是想要获取锁的线程需不需要排队。定义如下:

  • 公平锁指的是,申请获取锁的线程按照申请锁的顺序获取锁,未获得锁的线程入队排队,队列头部线程比尾部的线程先获得锁。遵循FIFO。
  • 非公平锁指的是,申请获得锁的线程直接尝试获取锁,如果此时刚好获得锁,则能在不阻塞的情况下获得锁。如果此时未获取到锁,则插入队列尾部排队(队列遵循FIFO)。

公平锁和非公平锁由于不同的特性,有不同的特点

  • 公平锁由于遵循FIFO,可以保证线程不会饿死。但是性能不如非公平锁,线程切换的开销也比非公平锁大。
  • 非公平锁存在后获取锁的线程先得到锁,故可能存在线程饿死。但是性能要比公平锁高。
        Lock fairLock=new ReentrantLock();
        Lock nonFairLock=new ReentrantLock(false);
        public ReentrantLock(boolean fair) {
            sync = fair ? new FairSync() : new NonfairSync();
        }

2.2.5 可重入锁、不可重入锁

  • 可重入锁指的是同一个线程在外层方法获得了锁,再调用锁定同一个对象或者class的内层方法的时候,可以无需再次获得锁。synchronized、ReentrantLock 、ReentrantReadWriteLock都是可重入的。
  • 非可重入锁正好跟可重入锁相反。
public class SynchronizedTest {

    public synchronized void methodA(){
        System.out.println("do something in methodA...");
        methodB();
    }

    public synchronized void methodB(){
        System.out.println("do something in methodB...");
    }

    public static void main(String[] args) {
        SynchronizedTest test=new SynchronizedTest();
        test.methodA();
    }
}

毫无疑问,上面的代码是可以正常运行的。

do something in methodA...
do something in methodB...

正常运行的前提正是synchonized,试想如果synchronized不可重入,那么在调用methodA里调用methodB时,因为methodA未释放持有的锁,同时又等待methodB持有的锁,因为是同一个锁,则会造成死锁。
这也就从侧面说明了可重入锁的优势:可以一定程度上避免死锁

2.2.6 共享锁、排它锁

  • 排它锁又名互斥锁或者独享锁。指的是同一时刻只有一把线程能获得锁。如synchronized、ReentrantLock、ReentrantReadWriteLock.WriteLock。
  • 共享锁顾名思义就是同一时刻可以被多个线程持有的锁。比如ReentrantReadWriteLock.ReadLock。
    关于Lock相关实现会在后续文章中刨析,这里就不过多解释了。

3. synchronized 基础

上面我们大概介绍了下锁的分类,下面进入本文的核心内容synchronized,介绍原理之前,我们要先了解其用法。

3.1 实例方法

public class SynchronizedTest {

    public synchronized void methodA(){
        System.out.println(Thread.currentThread().getName()+" 持有锁 @ "+ LocalDateTime.now().toString());
        System.out.println("do something in methodA...");
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+" 释放锁 @ "+ LocalDateTime.now().toString());
    }


    public static void main(String[] args) {
        SynchronizedTest test=new SynchronizedTest();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+" 尝试获取锁 @ "+ LocalDateTime.now().toString());
           test.methodA();
        },"t1").start();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+" 尝试获取锁 @ "+ LocalDateTime.now().toString());
            test.methodA();
        },"t2").start();
    }
}

运行结果如下

t2 尝试获取锁 @ 2020-12-03T16:56:53.600
t1 尝试获取锁 @ 2020-12-03T16:56:53.600
t2 持有锁 @ 2020-12-03T16:56:53.600
do something in methodA...
t2 释放锁 @ 2020-12-03T16:56:56.602
t1 持有锁 @ 2020-12-03T16:56:56.602
do something in methodA...
t1 释放锁 @ 2020-12-03T16:56:59.602

可以看到,后尝试获得锁的线程要等待先获得锁的线程释放锁,才能进入方法。
synchronized 此时锁的是这个实例,即 test

3.2 静态方法

public class SynchronizedStaticTest {
    public static synchronized void methodA(){
        System.out.println(Thread.currentThread().getName()+" 持有锁 @ "+ LocalDateTime.now().toString());
        System.out.println("do something in methodA...");
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+" 释放锁 @ "+ LocalDateTime.now().toString());
    }


    public static void main(String[] args) {
        SynchronizedTest test1=new SynchronizedTest();
        SynchronizedTest test2=new SynchronizedTest();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+" 尝试获取锁 @ "+ LocalDateTime.now().toString());
            test1.methodA();
        },"t1").start();
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+" 尝试获取锁 @ "+ LocalDateTime.now().toString());
            test2.methodA();
        },"t2").start();
    }
}

输出结果

t2 尝试获取锁 @ 2020-12-03T17:04:31.638
t1 尝试获取锁 @ 2020-12-03T17:04:31.638
t2 持有锁 @ 2020-12-03T17:04:31.639
do something in methodA...
t1 持有锁 @ 2020-12-03T17:04:31.639
do something in methodA...
t1 释放锁 @ 2020-12-03T17:04:34.640
t2 释放锁 @ 2020-12-03T17:04:34.640

我们把锁实例对象的代码进行了部分改变,方法用static修饰,同时线程调用的时候也传入的是不同的对象,发现还是两个线程还是要竞争同一把锁。
synchronized此时锁的是整个class,即SynchronizedStaticTest.class

3.3 代码块

public class SynchronizedCodeBlockTest {
    private static final Object LOCK = new Object();

    public void methodA() {
        System.out.println(Thread.currentThread().getName() + " 尝试获取锁 @ " + LocalDateTime.now().toString());
        synchronized (LOCK) {
            System.out.println(Thread.currentThread().getName() + " 持有锁 @ " + LocalDateTime.now().toString());
            System.out.println("do something in methodA...");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedCodeBlockTest synchronizedCodeBlockTest=new SynchronizedCodeBlockTest();
        new Thread(synchronizedCodeBlockTest::methodA,"t1").start();
        new Thread(synchronizedCodeBlockTest::methodA,"t2").start();
    }
}

输出结果

t1 尝试获取锁 @ 2020-12-03T17:20:42.488
t2 尝试获取锁 @ 2020-12-03T17:20:42.488
t1 持有锁 @ 2020-12-03T17:20:42.489
do something in methodA...
t2 持有锁 @ 2020-12-03T17:20:45.489
do something in methodA...

可以看到,synchronized此时锁的是特定对象,作用范围为整个代码块


4. synchronized原理

要搞清楚synchronized原理,需要准备的知识点比较多:Mointor对象、字节码指令集、MarkWorkd、锁升级。这里我们先看下Monitor、然后再再看下对应的字节码。

4.1 Monitor

Mointor的定义
每个Java对象都自带一个锁,这个锁就是Monitor。每个对象都有一个Monitor与之相关联,这个Monitor可以是在对象创建或者在我们试图用synchronized获取锁的时候创建,也可以在对象销毁的时候被一起销毁。我们上锁解锁的过程其实是操作monitor的过程。`

Mointor的数据结构
Mointor是由java虚拟机实现的,对应的源码文件为hotspot-f06c7b654d63\src\share\vm\runtime\objectMonitor.cpphotspot-f06c7b654d63\src\share\vm\runtime\objectMonitor.hpp。我们截取objectMonitor.hpp一段定义文件

  // JVM/DI GetMonitorInfo() needs this
  ObjectWaiter* first_waiter()                                         { return _WaitSet; }
  ObjectWaiter* next_waiter(ObjectWaiter* o)                           { return o->_next; }
  Thread* thread_of_waiter(ObjectWaiter* o)                            { return o->_thread; }
  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }
  • _count:计数器,获取锁+1,释放锁-1。
  • _owner:持有当前Monitor的线程引用。
  • _WaitSet:等待队列,调用wait方法时加入。
  • _EntryList:等待获取锁的队列。

Monitor的工作流程

  1. Monitor包含两个队列,_EntryList和_WaitSet。每个尝试获取Monitor的线程都会被封装为ObjectWaiter对象,并进入_EntryList
  2. 多线程在执行到同步代码块的时候,会尝试获取Monitor,但是同一时刻一个Monitor对象只能被一个线程持有。一旦该线程持有该Monitor,则Mointor则处于锁定状态。成功获取monitor的线程由_EntryList出队进入_owner,且count+1。如果在执行同步代码块中发生了重入,则count继续+1
  3. 调用wait时,会释放Mointor,并_owner设置为null,count-1,同时进入_WaitSet。
  4. 当调用notify()/notifyAll()的时候,_WaitSet中的线程会被唤醒一个或者多个,这些线程需要重新获取monitor,才能从wait()状态返回,执行后面的代码。这个过程经历了由_WaitSet到_owner
  5. 执行完同步代码块后,释放monitor,并复位其变量值
Monitor的工作原理

之前我们在将AQS的时候,提出过这么一个问题为什么wait()和notify()/notifyAll()只能用在同步块中?
原因是wait()/notify()/notifyAll()是monitor的方法,而要持有monitor的方式就是使用synchronized
证据如下:


  bool      try_enter (TRAPS) ;
  void      enter(TRAPS);
  void      exit(bool not_suspended, TRAPS);
  void      wait(jlong millis, bool interruptable, TRAPS);
  void      notify(TRAPS);
  void      notifyAll(TRAPS);

上面的代码摘自hotspot-f06c7b654d63\src\share\vm\runtime\objectMonitor.hpp

4.2 锁方法

反编译一下3.1字节码文件

javap -p -verbose SynchronizedTest

  public synchronized void methodA();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED//权限为public,且为同步方法
    Code:
      stack=3, locals=2, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: invokestatic  #5                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        13: invokevirtual #6                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: ldc           #8                  // String  持有锁 @
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokestatic  #9                  // Method java/time/LocalDateTime.now:()Ljava/time/LocalDateTime;
        27: invokevirtual #10                 // Method java/time/LocalDateTime.toString:()Ljava/lang/String;
        30: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  

可以看到,同步方法只是多了一个标识ACC_SYNCHRONIZEDJVM通过这个标志标识一个方法是同步方法。如果一个方法被设置了该标识,则执行方法前需要先获取Monitor,只有当获取到Monitor后才能执行方法里的代码。当方法执行完,无论是正常执行完还是抛异常,都会释放获得的Monitor。JVM正是通过ACC_SYNCHRONIZED标识来实现同步方法的。

4.3 锁代码块

反编译下 3.3 中的代码

  public void methodA();
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: invokestatic  #5                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
      13: invokevirtual #6                  // Method java/lang/Thread.getName:()Ljava/lang/String;
      16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: ldc           #8                  // String  尝试获取锁 @
      21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: invokestatic  #9                  // Method java/time/LocalDateTime.now:()Ljava/time/LocalDateTime;
      27: invokevirtual #10                 // Method java/time/LocalDateTime.toString:()Ljava/lang/String;
      30: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      33: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      36: invokevirtual #12                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: getstatic     #13                 // Field LOCK:Ljava/lang/Object;
      42: dup
      43: astore_1
      44: monitorenter //注意这里
      45: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      48: new           #3                  // class java/lang/StringBuilder
      51: dup
      52: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      55: invokestatic  #5                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
      58: invokevirtual #6                  // Method java/lang/Thread.getName:()Ljava/lang/String;
      61: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      64: ldc           #14                 // String  持有锁 @
      66: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      69: invokestatic  #9                  // Method java/time/LocalDateTime.now:()Ljava/time/LocalDateTime;
      72: invokevirtual #10                 // Method java/time/LocalDateTime.toString:()Ljava/lang/String;
      75: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      78: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      81: invokevirtual #12                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      84: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      87: ldc           #15                 // String do something in methodA...
      89: invokevirtual #12                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      92: getstatic     #16                 // Field java/util/concurrent/TimeUnit.SECONDS:Ljava/util/concurrent/TimeUnit;
      95: ldc2_w        #17                 // long 3l
      98: invokevirtual #19                 // Method java/util/concurrent/TimeUnit.sleep:(J)V
     101: goto          109
     104: astore_2
     105: aload_2
     106: invokevirtual #21                 // Method java/lang/InterruptedException.printStackTrace:()V
     109: aload_1
     110: monitorexit  //注意这里
     111: goto          119
     114: astore_3
     115: aload_1
     116: monitorexit  //注意这里![20170620931260_zdrwIu.gif](https://upload-images.jianshu.io/upload_images/10505542-71bb6ca2fc00aa83.gif?imageMogr2/auto-orient/strip)

     117: aload_3
     118: athrow
     119: return
    Exception table:
       from    to  target type
          92   101   104   Class java/lang/InterruptedException
          45   111   114   any
         114   117   114   any

注意 行号为44和110的字节码,我们发现了两个关键的字节码指令monitorenter、monitorexit

  • monitorenter标记的是同步代码块开始的位置。用来标记这里要开始获取被锁定对象的monitor,如果此时该对象的monitor没有被锁定(_owner=null && count==0)则有机会持有monitor对象,持有monitor对象后将_owner指向自己,并将count+1。
  • monitorexit标记的是同步代码结束的位置。用来标记这里要释放被synchronized锁定对象的monitor,释放monitor对象时,会将monitor的属性复位(set count=0,_owner=null)。这样其它线程就有机会获取monitor。

问:一条monitorenter必然对应一条monitorexit,但是我们在116行字节码里发现了另一条monitorexit,为什么多出一条monitorexit呢?

答:为了保证程序无论是在正常退出和异常退出的情况下都能释放monitor,编译器会自动生成一个全局的异常处理器,这个全局异常处理器用来处理所有异常,多出来的一条monitorexit指令就是全局异常处理器用来在异常情况下释放monitor的字节码。类似try/finally


5. 锁信息的存储

上面我们分析了synchronized的原理,如果你以为这样就结束了,那是不可能的。
monitor的底层是基于操作系统层面的Mutex Lock,这种锁会导致线程的阻塞和上下文切换。频繁的阻塞唤醒所导致的上下文切换会带来严重的性能损耗,所以JDK 1.6之前是synchronized效率是比较低的,也就是我们常说的重量级锁。JDK 1.6之后对synchronized进行了优化,加入了偏向锁和轻量级锁。所以存在锁的状态转换的过程。但是要搞清楚锁的状态转换,我们要先了解锁的存储结构。

5.1 对象的内存布局

  1. JVM体系结构
    先来张大家都特别熟悉的图
    jvm体系结构图.png

就拿3.3中的代码

private static final Object LOCK = new Object();

那这里的new Object()究竟在内存的哪块区域呢,答案是。堆也是整个JVM内存中最大的一块区域。

  1. 对象内存布局
    那么其在堆中究竟是以什么样的结构存在呢?这就是我们即将要讲到的重点,对象的内存布局。
    对象的内存布局主要分为几部分:MarkWord、klass pointer、array size、Instance Data、Padding
    对象内存布局.png
  2. Object Header
    MarkWord、klass pointer、array size 我们称之为对象头(Object Header)
结构体 描述 32位VM 64位VM未开启指针压缩 64位VM开启指针压缩
MarkWord 一系列标记信息,如hashcode、持有线程id、是否偏向锁、GC年龄等 4字节 8字节 8字节
Klass Pointer 指向元数据的地址。用来标识对象的类型。 4byte 8byte 4byte
Array Length 用来标识数组的长度(当前对象为数组的时候才会有存在) 4byte 8byte 4byte

4.实例数据与对齐填充
Instance Data 即实例数据,其占用的大小是根据实际情况来看的。
Padding 即对齐填充,JVM规定java 对象在内存里按照8字节对齐。

import org.openjdk.jol.info.ClassLayout;

public class Animal {
    private Cat cat=new Cat();
    private int num=10;
    private class Cat{

    }

    public static void main(String[] args) {
        Animal animal=new Animal();
        //8+4+4+4+4=24
        System.out.println(ClassLayout.parseInstance(animal).toPrintable());
    }
}

这里我们仅仅创建了一个Animal对象,那么这个Object究竟占用了多大内存呢?
64位虚拟机,JDK 版本1.8 且开启指针压缩(JDK 1.8默认开启,可以使用-XX:-UseCompressedOops关闭)
按照上面的说法,animal所占用的内存应该为:8byte MarkWord+4byte Klass Pointer+(4byte cat指针+4byte num=8byte Instance Data)=20byte,20不能被8整除,因此需要补4byte,最终animal所占内存为24byte。执行我们上面的代码,看看结果是否如我们所料呢?

syn.Animal object internals:
 OFFSET  SIZE             TYPE DESCRIPTION                               VALUE
      0     4                  (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                  (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                  (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4              int Animal.num                                10
     16     4   syn.Animal.Cat Animal.cat                                (object)
     20     4                  (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

果然animal 占用了24byte的大小

image.png

同时从输出的结果里,能很明显的看出我们上面提到的对象的内存布局的结构,依次为:MarkWord、Klass Poniter、Instance Data、Padding。

5.2 锁信息的存储之MarkWord

上面我们介绍了对象的内存布局,大致了解了对象在堆中以一个怎样的数据结构存在。下面就进入到与synchronized锁密切相关的部分了----MarkWord。

 The markOop describes the header of an object.

 Note that the mark is not a real oop but just a word.
 It is placed in the oop hierarchy for historical reasons.

 Bit-format of an object header (most significant first, big endian layout below):

  32 bits:
  --------
             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
             size:32 ------------------------------------------>| (CMS free block)
             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

  64 bits:
  --------
  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
  size:64 ----------------------------------------------------->| (CMS free block)

  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

以上注释摘自hotspot源码hotspot-f06c7b654d63\src\share\vm\oops\markOop.hpp。可以清楚的看到markWork分为32位VM、64位VM未开启指针压缩、64位虚拟机开启指针压缩三种情况。

  1. 32位VM Object Header
    32位VM Object Header
  1. 64位VM 未开启指针压缩 Object Header

    image.png

  2. 64位VM 开启指针压缩 Object Header

    image.png

名词解释

名词 含义 说明
identity_hashcode 对象的原始hashcode 非重写的hashcode,可以使用System.identityHashCode(o)获得
age 对象的分代年龄 保存对象在survivor区熬过的GC次数
biased_lock 是否偏向锁 0:否 1:是
lock 锁标记位 不同状态锁标记位不同
thread 线程id 持有偏向锁的线程id
epoch 偏向时间戳 计算偏向锁是否有效
ptr_to_lock_record 指向栈中lock_record的指针 轻量级锁在获取的时候会在栈帧中创建Lock Record,并将Mark Word 复制到Lock Record中
ptr_to_heavyweight_monitor 指向重量级锁的指针 Mutex Lock

不同锁状态下的biased_lock与lock

锁状态 是否偏向锁 锁标记 说明
无锁 0 01
偏向锁 1 01
轻量级锁 null 00
重量级锁 null 10
GC null 11 对象可以被回收

问:为什么要开启指针压缩?
答:为了节省空间,提高空间利用率(Object Header 由 128bits->96bits)
问:为什么要对齐填充?
答:为了提高寻址效率,让字段不跨缓存行
问:指针压缩后最大寻址是不是只有4GB(32bit的指针最大存储为2^32=4GB)?
4G-32G之间可以通过算法来优化使JVM能支持更大的寻址空间,32G以上指针压缩失效。


6. 锁优化

6.1 锁升级的过程

在 jdk1.6 之前synchronized被称为重量级锁,这个重指的是它会阻塞和唤醒线程。而1.6之后,对synchonized进行来优化。加入了偏向锁以及轻量级锁。在使用synchronized的过程中,随着竞争的加剧,锁会经历由:无锁->偏向锁->轻量级锁->重量级锁的升级过程。

锁升级的过程 .png

问:为什么synchronized要设计一个升级过程呢?
答:阻塞和唤醒线程带来的上下文切换的开销,往往比线程执行的开销要大得多。为了避免这种开销,尽量减少线程阻塞和唤醒的次数。

值得注意的是,锁升级依赖的关键信息为2bit的锁标记位

6.2 无锁

无锁状态是对象的最初状态,锁标记为01,是否偏向锁为0。当有一个线程来获取锁的时候进入偏向锁状态。

代码验证

import org.openjdk.jol.info.ClassLayout;

public class Animal {
    private Cat cat=new Cat();
    private int num=10;
    private class Cat{

    }

    public static void main(String[] args) {
        Animal animal=new Animal();
        //8+4+4+4+4=24
        System.out.println(ClassLayout.parseInstance(animal).toPrintable());
    }
}
无锁对象头

可以看到是否偏向锁为0,锁标记为01

6.2 偏向锁

  1. 偏向锁的定义
    所谓偏向锁,指的是偏向与某个线程的锁研究发现,大部分情况下都是只有一个线程竞争锁。当初次执行到synchonized的时候,锁由无锁转变成偏向锁,并通过CAS将thread信息写入MarkWord。如果第二次再执行到synchronized,发现偏向的线程id为自身,则可以在无需加锁的情况下获得锁。如果始终都是一个线程获得锁,则执行的效率很高,可以极大提升性能。
  2. 偏向锁的执行流程
    锁获取
    1.检查锁信息(MarkWord)。2.如果锁标记(lock)为01,进一步检查是否偏向锁标识(biased_lock),如果为0,则表示是无锁状态。利用CAS将当前线程ID,写入MarkWord,此时锁升级为偏向锁状态。3.如果锁标记(lock)为01成立,是否偏向锁标识(biased_lock)为1。则进一步检查MarkWord中的thread 是否为自己,如果是则直接获得锁,执行同步块的代码。4.如果thread 不为自己,则利用CAS将MarkWordz中的Thread置换为自己。如果置换成功,则成功获得锁,执行同步块代码。5.如果置换不成功,则证明存在竞争。已获得偏向锁的线程在到达全局安全点时会被挂起,然后根据线程状态决定变成无锁或者轻量级锁
    锁释放
    偏向锁的释放,采用了一种有竞争才会释放的策略,执行完同步代码块后并不会主动释放。1.到达全局安全点时暂停持有偏向锁的线程。2.检查持有偏向锁的线程是否存活。3.线程存活则锁升级为轻量级锁。4.线程不存活,则锁变成无锁状态
    偏向锁的获取与释放.png
  3. 代码验证
public class Animal {
    private Cat cat = new Cat();
    private int num = 10;

    private class Cat {

    }

    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
        new Thread(() -> {
            Animal animal = new Animal();
            synchronized (animal) {
                System.out.println(ClassLayout.parseInstance(animal).toPrintable());
            }
        }).start();
    }
}

由于虚拟机启动后4s左右才会开启偏向锁,顾这里休眠5s

偏向锁输出

可以看到,相比于无锁状态。当只有一个线程竞争animal的时候,由无锁升级为偏向锁。

6.3 轻量级锁

  1. 轻量级锁的定义
    所谓轻量级锁是相对于重量级锁而言,当关闭偏向锁或者偏向锁竞争加剧的时候,锁会由偏向锁升级为轻量级锁。轻量级锁会通过一定程度的自旋来尝试获得锁,避免直接升级为重量级锁
  2. 轻量级锁的执行流程
    锁获取
    1.在栈帧中创建LockRecord,并将MarkWord复制到LockRecord中(官方称之为Displaced MarkWord)。2.用CAS将MarkWord置换为当前线程LockRecord指针。3.置换成功,则获得锁,执行同步代码块。4.置换失败,则通过自旋获取锁。如果获取失败,则膨胀为重量级锁
    锁释放
    1.轻量级锁在执行完同步代码块后会尝试释放锁。2.释放时用CAS将Displaced MarkWord置换为Mark Word。3.置换成功,成功解锁。3.置换失败,则唤醒阻塞的线程
    轻量级锁的获取与释放.png

    可以看到,同时有两个及以上线程竞争锁的时候,是直接上升到轻量级锁的状态的。
  3. 代码验证
public class Animal {
    private Cat cat = new Cat();
    private int num = 10;

    private class Cat {

    }

    public static void main(String[] args) throws InterruptedException {
        Animal animal = new Animal();

//        TimeUnit.SECONDS.sleep(5);
        new Thread(() -> {
            synchronized (animal) {
                System.out.println(ClassLayout.parseInstance(animal).toPrintable());
            }
        }).start();


    }
}

我们把线程睡眠注释掉,让其来不及启动偏向锁,可以发现直接升级为了轻量级锁。

轻量级锁输出

6.4 重量级锁

  1. 重量级锁的定义
    重量级锁是基于Monitor实现的,而Monitor又依赖操作系统层面的Mutex Lock。这种锁会涉及到用户态和内核态的切换,往往开销比较大。所以Jdk 才对synchronized进行了优化,避免一上来就变成重量级锁。
  2. 重量级锁的执行流程
    详见Monitor部分。
  3. 代码验证
public class Animal {
    private Cat cat = new Cat();
    private int num = 10;

    private class Cat {

    }

    public static void main(String[] args) throws InterruptedException {
        Animal animal = new Animal();
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                synchronized (animal) {
                    System.out.println(ClassLayout.parseInstance(animal).toPrintable());
                }
            }).start();
        }

    }
}

我们创建了三个线程直接竞争锁

重量级锁输出

锁标记为10,即重量级锁


7. 总结

本篇文章里,我们为了搞清楚synchronized的原理,先是介绍了锁的分类,再介绍了锁的用法,然后是synchronized的原理,同时为了搞清楚synchronized,我们又初识了Monitor。又因为synchronized的使用过程内含锁升级的过程,为了搞清楚锁升级,我们又介绍了对象内存布局,最后才是锁升级的过程。

可见,为了搞清楚一个问题,牵扯出无数的问题。对于我们使用而言,就是一个synchronized关键字而已,但是却又那么多知识点作为铺垫。学习不易啊,且学且珍惜吧。变强了的同时,也往往伴随着变秃......😳

篇幅所限,许多细节地方并为介绍。同时由于水平有限,文章中难免有疏漏的地方,欢迎批评指正。我们下篇文章见....

参考文章 Java 并发编程的艺术

推荐阅读更多精彩内容