什么是对象头呢?就是你的对象的头嘛,嘿嘿嘿。 什么是Monitor呢?就是摸你的头嘛,嘿嘿嘿。
对象头简述
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:
- 对象头(Header)
- 实例数据(Instance Data):对象的实例数据就是在java代码中能看到的属性和他们的值
- 对齐填充字节(Padding):因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至 8bit 的倍数,没有特别的功能
而Java的对象头由以下三部分组成:
- Mark Word
- 指向类的指针:该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit,Java对象的类数据保存在方法区
- 数组长度(只有数组对象才有):该数据在32位和64位JVM中长度都是32bit
Mark Word
HotSpot虚拟机的对象头(Object Header)包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。
而当某个对象被 synchronized 关键字当成同步锁时,围绕这个锁的一系列操作都和 Mark Word有关。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 无锁 | 对象的HashCode | 分代年龄 | 0 | 01 | |
| 偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
| 重量级锁 | 指向重量级锁的指针 | 10 | |||
| GC标记 | 空 | 11 |
早期jdk的时候,synchronized 关键字底层实现是重量级的,即它要去找操作系统去申请锁的地步,这就会操作 synchronized 的效率非常低。于是后来为了解决这个问题,就出现了锁升级的概念,而锁升级就是围绕这个Mark Word来操作的。
JVM在使用锁和Mark Word的流程是这样的:
- 当一个对象没有被当成锁时(即synchronized( Object )里的这个玩意),它就是一个普通的对象,即上图中的无锁的状态
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 无锁 | 对象的HashCode | 分代年龄 | 0 | 01 |
- 当对象被当作同步锁并有一个线程A抢到了改锁时,锁标志位不变,但是否偏向锁改为1,前23位记录抢到锁的线程id,表示进入偏向锁状态
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 偏向锁 | 线程A ID | Epoch | 分代年龄 | 1 | 01 |
当线程A再次试图获取锁的时候,JVM发现同步锁对象的标志位是01,且是偏向锁状态,然后访问 Mark Word 中记录的线程ID,判断也是线程A自己的ID,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码
当线程B试图获得这个锁时(此时线程A仍未释放锁),JVM发现同步锁处于偏向状态,然后访问 Mark Word 中记录的线程ID,判断记录的线程ID不是自己的ID。此时线程B会先用 CAS 操作试图获得锁。如果抢锁成功,就把 Mark Word 中的锁标志位改成 00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 偏向锁 | 线程B ID |
Epoch | 分代年龄 | 1 | 01 |
- 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从 JDK1.7 开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7
自选锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 重量级锁 | 指向重量级锁的指针 | 10 |
Monitor 简述
管程,英文是 Monitor ,也常被翻译成“监视器”。它是一个在操作系统层面的概念,在介绍 进程/线程 间同步的时候,我们提到了 semaphore 信号量和 mutex 互斥量的同步原语,而在使用这种同步原语进行并发控制时,程序员需要非常小心地控制 mutex 的 down 和 up 操作,否则很容易引起死锁的问题。于是为了更容易简单编写并发程序,提出了更高层此的同步原语 monitor。
操作系统本身不支持 monitor 机制,它是属于编程语言的范畴,是编程语言在语法上提供的语法糖。
Monitor其实是一种同步工具或者说是同步机制。
Monitor 机制的特点:在同一时刻,只有一个进程/线程能进入 monitor 中定义的临界区,使得达到互斥的效果。Monitor 作为一个同步工具,也提供了管理进程/线程状态的机制,使得满足某个监视条件的进程/线程能够解除阻塞重新获得运行许可。
基本元素:
- 临界区
- Monitor对象及锁
- 条件变量以及定义在 Monitor 对象上的 wait,signal 操作
使用 monitor 机制的目的主要是为了互斥进入临界区,为了做到能够阻塞无法进入临界区的 进程/线程,还需要一个 monitor object 来协助,这个 monitor object 内部会有相应的数据结构,例如列表,来保存被阻塞的线程;同时由于 monitor 机制本质上是基于 mutex 这种基本原语的,所以 monitor object 还必须维护一个基于 mutex 的锁。
此外,为了在适当的时候能够阻塞和唤醒 进程/线程,还需要引入一个条件变量,这个条件变量用来决定什么时候是“适当的时候”,这个条件可以来自程序代码的逻辑,也可以是在 monitor object 的内部,总而言之,程序员对条件变量的定义有很大的自主性。不过,由于 monitor object 内部采用了数据结构来保存被阻塞的队列,因此它也必须对外提供两个 API 来让线程进入阻塞状态以及之后被唤醒,分别是 wait 和 notify。
Java对Monitor的支持
在 Java 中最熟悉的 synchronized 关键字的底层实现就是基于 Monitor 机制。在Java中,一个 Object 对象就是一个 Monitor 对象,被 synchronized 关键字修饰的方法,代码块就是 Monitor 的临界区。
Java 对象存储在内存中,分别分为三个部分,即对象头、实例数据和对齐填充,而在其对象头中,保存了锁标识;同时,java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下所示:
当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。
