Java 对象头

以 32 位虚拟机为例

普通对象

所以以 Integer 和 int 为例子

  • Integer 8字节对象头 + 4字节 int 值,所以大小是 int 的 3 倍
  • int 4字节 int 值

数组对象

如 Student[] s = new Student[8],还包括数组长度 length

其中 markword 结构为

Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中:

  • 对象未被锁定的状态下, Mark Word的32个比特空间里的25个比特将用于存储对象哈希码 hashcode,4个比特用于存储对象分代年龄 age,2 个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。
  • 进入 Monitor 之后,即重量级锁状态 10 后,ptr_to_heavyweigth_monitor 指向 monitor 对象(monitor 对象是由操作系统提供的?)。hashcode,age 这些会被被指向 monitor 的指针覆盖,它们会被暂存在 monitor 中,monitor 结束后会把它们还原。

对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态。

 64 位虚拟机 Mark Word

 

Monitor 对象(重量级锁)

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

Monitor 对象结构如下

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

sychronized 字节码

static final Object lock = new Object();
  static int counter = 0;
  public static void main(String[] args) {
     synchronized (lock) {
       counter++;
   }
}

上面这段代码对应的字节码为

  • 同步代码:通过 moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。
  • 实例方法:隐式调用moniterenter、moniterexit
  • 静态方法:隐式调用moniterenter、moniterexit
public static void main(java.lang.String[]);
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
Code:
 stack=2, locals=3, args_size=1
 0: getstatic #2 // <- lock引用 (synchronized开始)
 3: dup
 4: astore_1 // lock引用 -> 暂存到 slot 1
 5: monitorenter // 加锁。将 lock对象 MarkWord 置为 Monitor 指针,覆盖掉原来的 hashcode,age(暂存到了 monitor 中)
 6: getstatic #3 // <- i
 9: iconst_1 // 准备常数 1
 10: iadd // +1
 11: putstatic #3 // -> i
 14: aload_1 // <- lock引用
 15: monitorexit // 解锁。将 lock对象 MarkWord 重置为 hashcode,age, 唤醒 EntryList
 16: goto 24
 19: astore_2 // 同步代码块发生异常,会走这一块进行异常处理。e -> slot 2
 20: aload_1 // <- lock引用
 21: monitorexit // 异常处理中进行解锁。将 lock对象 MarkWord 重置, 唤醒 EntryList
 22: aload_2 // <- slot 2 (e)
 23: athrow // throw e
 24: return
 Exception table:
   from to target type
   6    16 19 any
   19   22 19 any
 LineNumberTable:
 line 8: 0
 line 9: 6
 line 10: 14
 line 11: 24
 LocalVariableTable:
 Start Length Slot Name Signature
 0 25 0 args [Ljava/lang/String;
 StackMapTable: number_of_entries = 2
 frame_type = 255 /* full_frame */
 offset_delta = 19
 locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
 stack = [ class java/lang/Throwable ]
 frame_type = 250 /* chop */
 offset_delta = 4

 

 

轻量级锁(Lock Record)

monitor 是操作系统提供的对象,每次使用它成本比较高,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转 换需要耗费很多的处理器时间。

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争,一个加锁解锁完了另一个才过来),那么可以 使用轻量级锁来优化。 轻量级锁对使用者是透明的,即语法仍然是 synchronized。意义在于 在多线程交替执行时,不用创建 Monitor (操作系统互斥量,开销较大)。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

  1. 创建锁记录(Lock Record)对象:每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 MarkWord
  2. 判断 MarkWord 是否为无锁状态,标识位001;如果 MarkWord 处于无锁状态,现在对象的无锁状态 MarkWord(存的是hashcode age)暂存到 Lock Record 的 Displaced Mark Word 中。
  3. 通过 CAS 尝试将 对象头的 MarkWord 更新为指向 Lock Record 对象的指针(预期原值:原先无锁的 MarkWord
  4. 如果更新成功,表示竞争到锁, MarkWord 状态转为 00 (轻量级锁),然后执行同步代码。
  5. 如果更新失败:
    • 其它线程竞争(MarkWord 指向其它线程):表明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁。去 Monitor 的 EntryList 等待去了。
    • 同一线程锁重入(MarkWord 指向当前线程):那么再添加一条 Lock Record 作为重入的计数(但是 Lock Record 的 Displaced Mark Word为 null)。
  6. 当退出 synchronized 代码块(解锁时)
    • 如果有 Displaced Mark Word 为 null 的锁记录,表示有重入,这时重置取值为 null 的这个锁记录,表示重入计数减一
    • 锁记录 Displaced Mark Word 不为 null,这时使用 cas 将锁记录中的 Dispalced Mark Word 即原来无锁状态的 Mark Word 恢复给对象头
      • 成功(对象头还是指向当前轻量级锁记录),则解锁成功
      • 失败(对象头已经指向了重量级锁Monitor),说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

为什么JVM选择在线程栈中添加Displaced Mark word为null的Lock Record来表示重入计数呢?

首先锁重入次数是一定要记录下来的,因为每次解锁都需要对应一次加锁,解锁次数等于加锁次数时,该锁才真正的被释放,也就是在解锁时需要用到说锁重入次数的。

一个简单的方案是将锁重入次数记录在对象头的mark word中,但mark word的大小是有限的,已经存放不下该信息了。

另一个方案是只创建一个Lock Record并在其中记录重入次数,Hotspot没有这样做的原因我猜是考虑到效率有影响:每次重入获得锁都需要遍历该线程的栈找到对应的Lock Record,然后修改它的值。

所以最终Hotspot选择每次获得锁都添加一个Lock Record来表示锁的重入。

  

 

锁膨胀

轻量级锁时 Thread -1 尝试 CAS 将无锁的 MarkWord 更新为指向 Lock Record 对象的指针:如果更新失败,并且不是锁重入,即 MarkWord 指向的非当前线程。

说明是有其它线程为此对象加上了轻量级锁,发生了锁竞争。这时需要进行锁膨胀,将轻量级锁变为重量级锁。

锁膨胀流程

  • 为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
  • 然后自己进入 Monitor 的 EntryList BLOCKED

当 Thread-0 退出同步块解锁时,使用 cas 将 MarkWord 的值恢复给对象头(本应指向自己这个 Lock Record 但现在指向了 Monitor),失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

 

自旋优化

自选:让线程先不要进入阻塞,而是进行几次循环。因为阻塞意味着线程会发生一次上下文切换

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功的情况

自旋重试成功的情况

 

偏向锁

为什么要有偏向锁?

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:对象第一次进入同步代码块时,使用 CAS 将当前线程的ThreadId设置到对象的 Mark Word 头,之后发现 只要这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

偏向状态

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、age 都为 0,epoch 会从 class 的 epoch 复制过来
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值

可以用 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

调用对象 HashCode 会撤销偏向锁

调用了对象的 hashcode,但偏向锁状态对象 MarkWord 中应该存的是持有偏向锁的线程ThreadId,没地方存 hashcode 了,所以如果调用 hashCode 会导致偏向锁被 撤销

为什么轻量级锁和重量级锁调用 hashcode 时不会导致偏向锁被撤销?

  • 因为轻量级锁会把 hashCode 暂存到 Lock Record,重量级锁会把 hashCode 暂存到 monitor 
  • 而偏向锁是直接占用了 MarkWord 存 hashCode 的位置,所以调用 hashCode 时会被撤销

调用 wait/notify 会撤销偏向锁

wait/notify 只有重量级锁才有,所以有偏向锁、轻量级锁的对象调用了 wait/notify 的话都会升级成重量级锁。

偏向锁流程

JVM中的每个类也有一个类似对象mark word的prototype_header,用来标记该class的epoch和偏向开关等信息。

  • 判断锁对象的mark word是否是偏向模式,即低3位是否为101,如果是:
    • if 偏向的threadId 是当前线程 && mark word的epoch等于class的epoch,什么都不做,直接进入同步代码块
    • else if class的prototype_header中偏向模式是关闭的,撤销偏向锁
    • else if 如果epoch不等于class中的epoch,则需要重偏向,利用CAS指令将锁对象的mark word替换为一个偏向当前线程且epoch为类的epoch的新的mark word。
    • else 走到这里说明当前要么偏向别的线程,要么是匿名偏向(即为偏向状态但没有偏向任何线程 markword 中 threadId 为空)
      • 如果是匿名偏向(即为偏向状态但没有偏向任何线程),CAS将匿名偏向修改为当前线程,修改失败撤销偏向锁,进入锁升级
      • 如果已经偏向其它线程,则撤销偏向锁,进入锁升级

从上面的流程重可以得出两点信息:

  1. 如果开启了偏向锁,那么所有对象一开始创建就是偏向状态,即后三位为 101,但是它的 thread、age 都为 0,epoch 会从 class 的 epoch 复制过来。此时称为匿名偏向,如果对象第一次进入同步代码块,就会 CAS 将该对象的 markword 修改为这第一个线程,真正获得了偏向线程(有竞争的话也可能 CAS 失败进入锁升级)。
  2. 其它线程使用(是指 synchronized(已经有偏向线程的对象),即使这时没有竞争,也不会尝试重偏向也就是 CAS 替换偏向锁,而是直接撤销偏向锁,升级到轻量级锁。

批量重偏向和批量撤销

每次撤销偏向锁的时候都会以类为单位记录下来,当某个类的所有对象撤销偏向次数达到一定阈值的时候JVM就认为该类需要重新偏向另一个对象或不适合偏向模式,就会对这个类的对象进行批量重偏向或批量撤销。

前提:JVM维护了一个集合存放所有存活的线程

批量重偏向

  1. 每次撤销偏向锁 都将该类撤销计数器自增1。
  2. 进入安全点后。判断该类撤销计数器的值 如果达到了 批量重偏向阈值(默认20),就会将class的prototype_header中的epoch自增。
  3. 然后还会在安全点 通过遍历所有存活线程的栈,找到所有正在使用的偏向锁对象,然后更新它们的epoch值(而那些当前没有被使用的对象的epoch没有被更新)。也就是说不会重偏向正在使用的锁,否则会破坏锁的线程安全性。(正在使用的锁epoch被更新为等于class中的epoch了,所以之后不会重偏向)
  4. 因为class的epoch自增了,之后当该类已存在的实例要获得锁时,epoch不等于class中的epoch,就会尝试重偏向,重偏向失败会锁升级(因为在3中对象如果不是正在被使用的话,epoch就没有被更新到和class的相等)

批量撤销

  1. 每次撤销偏向锁 都将该类撤销计数器自增1。
  2. 进入安全点后。判断该类撤销计数器的值 如果达到了 批量撤销阈值(默认40),就会将class的prototype_header中的偏向开关关闭。
  3. 然后还会在安全点 通过遍历所有存活线程的栈,找到所有正在使用的偏向锁对象,然后撤销偏向锁
  4. 因为将类的偏向标记关闭了,之后当该类已存在的实例获得锁时,就会升级为轻量级锁;该类新分配的对象的mark word则是无锁模式。

批量重偏向和批量撤销试验

static Thread t1,t2,t3;
private static void test4() throws InterruptedException {
  Vector<Dog> list = new Vector<>();
  int loopNumber = 39;
  t1 = new Thread(() -> {
     for (int i = 0; i < loopNumber; i++) {
       Dog d = new Dog();
       list.add(d);
       synchronized (d) {
         log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
       }
     }
     LockSupport.unpark(t2);
   }, "t1");
   t1.start();
 
  t2
= new Thread(() -> {     LockSupport.park();     log.debug("===============> ");     for (int i = 0; i < loopNumber; i++) {       Dog d = list.get(i);       log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));       synchronized (d) {         log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));       }       log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));     }     LockSupport.unpark(t3);     }, "t2");   t2.start();
  t3
= new Thread(() -> {     LockSupport.park();     log.debug("===============> ");     for (int i = 0; i < loopNumber; i++) {       Dog d = list.get(i);       log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));       synchronized (d) {         log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));       }       log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));     }   }, "t3");   t3.start();    t3.join();   log.debug(ClassLayout.parseInstance(new Dog()).toPrintableSimple(true)); }
  • t1 循环创建 39 个 dog 对象并循环加锁
    • 一开始在 t1 中 new 的时候,都是匿名偏向类,在 t1 中进入同步代码块后,都偏向 t1
  • t2 循环对 39 个 dog 对象加锁 
    • 前 19 个对象都是撤销对象上的 t1 偏向锁,升级为轻量级锁。
    • 这样的撤销达到 20 次后,在第 20 次循环的安全点,Dog 类判断该类所有对象的撤销总次数刚好达到了批量重偏向阈值 20,所以 Dog class 的 epoch 自增1(正在被线程使用的第 20 个对象 epoch 会被更新为最新,不会被重偏向)
    • 后面的 19 个对象,因为 epoch 都没有更新,epoch 和 class 的不相等,所以在加锁的时候都是重偏向到 t2 ,并且重偏向后这些对象的 epoch 也被更新到了最新
  • t3 循环对 39 个 dog 对象加锁
    • 前 19 个对象已经升级过轻量级锁,没了偏向锁,不可偏向。
    • 后面 19 个重偏向到了 t2,并且 epoch 在重偏向到 t2 时也被更新到了最新。所以在被 t3 加锁的时候,会撤销偏向锁然后升级到轻量级锁。
    • 这样撤销次数就达到了 40 次,Dog 类判断该类所有对象的撤销总次数刚好达到了批量撤销阈值 40,最后第 39 个对象,撤销偏向锁,升级轻量级锁

 

其它

锁消除

JIT 即时编译器对字节码进行进一步优化,分析局部变量根本不会逃离方法作用范围,不能被共享,sychronized 没有意义,JIT 会把它优化掉。有一个开关可以控制:-XX:-EliminateLocks

@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations=3)
@Measurement(iterations=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {

     static int x = 0;

     @Benchmark
     public void a() throws Exception {
         x++;
     }

     @Benchmark
     public void b() throws Exception {
        // 是局部变量
         Object o = new Object();
         synchronized (o) {
             x++;
          }
     }
}

以下是开启和关闭锁消除优化前后的性能对比

java -jar benchmarks.jar

Benchmark Mode Samples Score Score error Units 
c.i.MyBenchmark.a avgt 5 1.542 0.056 ns/op 
c.i.MyBenchmark.b avgt 5 1.518 0.091 ns/op 

java -XX:-EliminateLocks -jar benchmarks.jar

Benchmark Mode Samples Score Score error Units 
c.i.MyBenchmark.a avgt 5 1.507 0.108 ns/op 
c.i.MyBenchmark.b avgt 5 16.976 1.572 ns/op

wait / notify 原理

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 是线程在等待获取锁,而 WAITING 是获取锁后又调用 wait() 方法放弃了锁(只有获取了 sychronized 锁的对象,也就是说在同步代码块内,才可以调用 wait(),否则会报错)
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

 

总结

  • 引入轻量级锁的目的:多线程交替执行同步块的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销
  • 引入偏向锁的目的:同一线程重入时,减少轻量级锁的 CAS 操作

以下简述忽略细节的锁升级过程:

偏向锁:

  • JVM 开启了偏向锁时,所有对象一创建就是markword低三位为101的匿名偏向锁状态(还没指向任何线程)。
  • 进入同步代码块时:(1) 如果是匿名偏向锁,则直接CAS修改为当前线程;(2) 如果是当前线程,直接进入同步代码块;(3) 如果指向别的线程,升级为轻量级锁。

轻量级锁:

Lock Record 的 replaced mark word 暂存原对象头。CAS 将无锁状态的 MarkWord 更新为指向当前 Lock Record:

  • 更新成功:置为轻量级锁状态,直接进入同步代码块
  • 更新失败:
    • markword 如果指向当前线程:再添加一条 replaced markword 为 null 的 lock record 作为重入计数
    • markword 如果指向其它线程:说明有竞争,升级成重量级锁

 

 

操作系统信号量(monitor 底层)

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,信号量就实现了对共享资源的保护机制

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • 一个是 P 操作,进入共享资源之前,会把信号量减去 1相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,离开共享资源之后,这个操作会把信号量加上 1相加后如果信号量 <= 0则表明当前有阻塞中的进程,于是唤醒阻塞的进程相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

信号初始化为 0,就代表着是同步信号量,它可以保证进程 A (只生产V+)应在进程 B (只消费P-)之前执行(A必须先生产B才会有数据消费;B先执行的话:A还没生产B就会阻塞等待,A生产完了唤醒B)。

 

参考:

黑马程序员:深入学习Java并发编程,JUC并发编程全套教程

有赞:Java锁与线程的那些事

江湖小小白 :Java-synchronized 中锁的状态及其转换

死磕Synchronized底层实现--概论

死磕Synchronized底层实现--偏向锁

死磕Synchronized底层实现--轻量级锁

死磕Synchronized底层实现--重量级锁