从对象头出发了解Synchronized关键字

4,042 阅读7分钟

写这篇文章的目的源自于看《并发编程艺术》的时候,书上说synchronized关键字的锁是放在对象头里的。索性带着这个问题把这个关键字相关的内容梳理一下。

什么是synchronized关键字?

synchronized关键字是Java并发编程中非常重要的一个工具。它的主要目的是在同一时间只能允许一个线程去访问一段特定的代码,从而保护一些变量或者数据不会被其他线程所修改。这感觉就像一群人抢着去上厕所,而你运气好抢到了,啪把门一锁,厕所的那一平方天地在那段时间就只属于你,即使门外的人排队都排到了地中海(此处排除有人暴力拆厕所的情况)。

使用synchronized关键字后,都以对象作为锁,一般有以下三种实现形式。

  1. 对于同步方法,锁是当前实例对象。
public synchronized void test1() {
    i++;
}
  1. 对于静态同步方法,锁是当前类的Class对象。
public static synchronized void test2() {
    i++;
}
  1. 对于同步代码块,锁是synchronized关键字括号内的对象。
public void test2() {
    synchronized(this){
        i++;
    }
}
什么是对象头?

在JVM中,对象在内存中的布局分为3块:对象头、实例数据和对齐填充。先说说实例数据,它存储着对象真正的有效信息(程序代码中定义的各种类型的字段内容),无论是从父类继承来的字段还是子类中定义的。然后再是对齐填充,它并没有什么特殊的含义,仅仅只是起占位符的作用。原因呢是因为JVM要求对象的起始地址必须是8个字节的整数倍(对象的大小必须是8个字节的整数倍)。而对象头已经是8的整数倍了,如果实例数据没有对齐就需要对齐填充来补全。

重点来了,synchronized使用的锁都放在对象头里,JVM中用2个字宽来储存对象头(如果对象是数组则分配3个字宽,多的一个字宽用于存储数组的长度)。而对象头包含两部分信息,分别为Mark Word和类型指针。Mark Word主要用于储存对象自身的运行时数据,例如对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等。而类型指针用于标识JVM通过这个指针来确定这个对象是哪个类的实例。

由于对象需要储存的运行时数据过多,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息。对象在不同的状态下,Mark Word会存储不同的内容(只放32位虚拟机的图表)。

锁状态 25bit 4bit 1bit(是否是偏向锁) 2bit(锁的标志位)
无锁状态 对象的hashcode 对象分代年龄 0 01
偏向锁 线程ID + epoch 对象分代年龄 1 01

锁状态 30bit 2bit(锁的标志位)
轻量级锁 指向栈中锁记录的指针 00
重量级锁(synchronized) 指向互斥量(重量级锁)的指针 10
GC标志 11
monitor对象

这边也就主要分析一下重量级锁,标志位为10,指针指向monitor对象的起始地址,而每一个对象都存在着一个monitor与之关联。在Hot Spot中,monitor是由ObjectMonitor类来实现的。先来看一下ObjectMonitor的数据结构。

ObjectMonitor() {
    _header       = NULL;//markOop对象头
    _count        = 0;
    _waiters      = 0,//等待线程数
    _recursions   = 0;//重入次数
    _object       = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
    _owner        = NULL;//指向获得ObjectMonitor对象的线程或基础锁
    _WaitSet      = NULL;//处于wait状态的线程,会被加入到waitSet;
    _WaitSetLock  = 0;
    _Responsible  = NULL;
    _succ         = NULL;
    _cxq          = NULL;
    FreeNext      = NULL;
    _EntryList    = NULL;//处于等待锁block状态的线程,会被加入到entryList;
    _SpinFreq     = 0;
    _SpinClock    = 0;
    OwnerIsThread = 0;
    _previous_owner_tid = 0;//监视器前一个拥有者线程的ID
}

其中有两个队列 _EntryList和 _WaitSet,它们是用来保存ObjectMonitor对象列表, _owner指向持有ObjectMonitor对象的线程。 当多个线程访问同步代码时,线程会进入_EntryList区,当线程获取对象的monitor后(对于线程获得锁的优先级,还有待考究)进入 _Owner区并且将 _owner指向获得锁的线程(monitor对象被线程持有), _count++,其他线程则继续在 _EntryList区等待。若线程调用wait方法,则该线程进入 _WaitSet区等待被唤醒。线程执行完后释放monitor锁并且对ObjectMonitor中的值进行复位。上面说到synchronized使用的锁都放在对象头里,大概指的就是Mark Word中指向互斥量的指针指向的monitor对象内存地址了。 由以上可知为什么Java中每一个对象都可以作为锁对象了。

monitor指令

JVM通过进入和退出monitor对象来实现方法和代码块的同步,但是实现细节不一。可以使用javap -verbose XXX.class命令看代码被编译成字节码后是如何实现同步的。

 Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field i:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return

将含有synchronized代码块的代码反编译后,可以看到monitorenter和monitorexit两条指令。monitorenter处于代码块开始的位置,而monitorexit与之匹配在代码结束或者异常处。任何对象都有个monitor与之对应,当monitor被持有后,它就处于锁定状态。线程执行到monitorenter指令时,会尝试去获得对象的锁(即monitor的所有权)。

public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 6: 0
        line 7: 10

方法的同步是隐式的,JVM中使用 method_info 型数据中方法访问标志的 ACC_SYNCHRONIZED做区分。当线程执行代码时若发现方法的访问标志中有ACC_SYNCHRONIZED,则当前线程持有monitor对象。接下来执行的细节与同步代码块无异。以上便是synchronized关键字修饰的同步方法和同步代码块实现的基本原理了。

synchronized重入锁

第一次听说重入锁是ReentrantLock,后来知道synchronized关键字支持隐式重入。顾名思义,重入锁就是支持重进入的锁,支持一个线程可以对资源重复加锁。对于一个synchronized加持的代码块,其他线程试图访问该代码块时,线程会阻塞。若是持有锁的线程再次请求自己持有的锁时,则能成功获得。

public synchronized void test1() {
    i++;
}

public void test2() {
    synchronized(this){
        test1();
    }
}

当前线程获得锁后,通过cas将_owner指向当前线程,若当前线程再次请求获得锁, _owner指向不变,执行_recursions++记录重入的次数,若尝试获得锁失败,则在_EntryList区域等待。这种感觉有点像盗梦空间里的梦中梦,可以重复的进入自己的梦里,若想正常的醒过来,只能按原路返回(_recursions--)。

马后炮

我买的书上没有关于synchronized关键字比较底层的解释,只能站在网上其他博主的肩膀上,通过他们文章中对于底层C++代码的解释大致的了解了一下其原理。

最后还是那句话,学习的最终目的并不是为了面试,面试只是一个激励学习的动机。把握面试题,享受学习新知识的乐趣。

参考:

只会一点Java -> jdk源码剖析二: 对象内存布局、synchronized终极原理
《深入理解Java虚拟机》
《并发编程的艺术》