INDEX
§1 对象的生命周期
完整的对象生命周期分为 7 个阶段
- 创建
- 应用
- 不可见:程序本身的作用域中已经看不见对应的对象
- 不可达:不被任何强引用指向
- 收集:被垃圾回收期回收
- 终结:调用过 finalize() 方法,已经被回收
- 空间重分配:原使用的内存空间被其他数据占据
需要注意,如果滥用 Finalizer.finalize()
可能导致对象 不可见,但可达,可能导致内存泄漏
因此,此对象依然占用空间,但 GC 时被认为不是垃圾,因此不会被回收
§2 对象创建过程
内存分配原则
- 内存按 eden、survivor、old 优先级逐渐降低
- 依次判断上述空间是否足够开辟空间给新对象
- 若 eden 区足够,直接开辟
- 否则依次向上判断,当判断出有空间可以开辟
- 从上一级空间复制活跃对象进当前空间
- 在上一级空间重复这个过程,直到 eden 区有空间
- 若 old 区也没有足够空间,则触发 GC,然后回到上一步
- 若 GC 后依然没有足够的空间,报 OOM
对象创建过程
§3 访问方式
对象的访问方式分为两种,示意图如下
- 句柄池式:reference 的引用稳定,但引用对象时多了一次指针开销
- 直接访问式:jvm 使用的访问方式,引用对象时可以节省一次指针开销,但 GC 后 reference 可能发生变化
§4 对象的构成
总体来说,对象由下面三部分组成
- 对象头(Header)
- 对象标记(Mark Word)
openjdk 里叫 markOop - 类型指针(Class Pointer)
openjdk 里叫 klassOop - 长度(Length),数组才有
- 对象标记(Mark Word)
- 实例数据(Instance Data)
- 对齐填充(Padding)
64 位系统中各部分内存占用
部位 | 下级部位 | 大小(byte) |
---|---|---|
Header | 12/16/20 | |
Mark Word | 8 | |
Class Pointer | 8/4(指针压缩) | |
Length | 4(数组才有) | |
Instance Data | 根据中字段的类型,比如两个 int 就需要 8 | |
Padding | 补齐 8 字节的倍数 |
§4.1 Mark Word
概述与总览
Mark Word 是 java 对象对象头中的对象标记
一共占用 8 byte,共计 64 bit,各个 bit 位作用如下表
Mark Word 大致分为 3 种布局
- 无锁/偏向锁
- 轻量级锁/重量级锁
- GC
锁标记位
无论上面那种布局,Mark Word 中都有固定的 2 bit 作为锁标志位
锁标记位的对应关系如下表
四种锁的详细介绍见 锁升级流程
锁标记位值 | 对象锁状态 | 说明 |
---|---|---|
01 | 无锁 / 偏向锁 | 共用一个锁标记位值 在偏向锁标记位做出区分 |
00 | 轻量级锁 | |
10 | 重量级锁 | |
11 | GC | 无所谓锁不锁 |
无锁/偏向锁布局
偏向锁标记位
在锁标记位的值是 01 时,用来区分对象是无锁还是偏向锁状态
无锁和偏向锁在前
对象分代年龄
- 只预留了 4 bit
- 因此 JVM 最大分代年龄的最大值是 15
cms_free
- 只占用 1 bit
- cms 垃圾回收器使用
hashcode
- markword 中的 hashcode 是通过 JDK 底层 C++ 的源码(在第一次调用 hashcode 时)计算并写入的
- 如果重写了
hashcode()
方法,则 hashcode 不再写入 markword
因为不保证此时 hashcode 不可变
epoch
- 偏向状态标记,这是因为偏向锁不会主动释放锁,因此使用此信息标注 偏向线程是否正在处理同步代码块逻辑
- epoch 与 klassOop.epoch 标志位不一致时,偏向锁为可重偏向状态
- 可重偏向状态时,并发的线程可以尝试 CAS 抢占偏向锁
- 偏向锁中线程 id 不是 null,且 epoch 与 klassOop.epoch 相等时,偏向锁为已偏向状态
- 已偏向状态时,若并发线程 id == 偏向锁里记录的线程 id,则线程继续占有锁
- 已偏向状态时,若并发线程 id != 偏向锁里记录的线程 id,则发生抢占,锁膨胀为轻量级锁
轻量级锁/重量级锁布局
- 此布局下,除锁标记位的其他 bit 都用于存指向锁的指针
- 无锁状态下的 markword 的各个部分都会被此指针替代
- 直到锁释放,再将原始的 markword 写回原处
- 这里的锁实际上是指对象内部锁、管程锁、
monitor
,由ObjectMonitor
在 C++ 层面实现
ObjectMonitor
参考 基础 | 并发编程 - [Lock & synchronized]
GC布局
- 此布局下,除锁标记位的其他 bit 都是空的
- 都快被回收了,所以无所谓了
§4.2 Class Pointer
- Class Pointer 是对象对象头中的类型指针
- 用于指向元空间中的对应 class 的类元信息
- 标准占用 8 byte,但通常 JVM 默认开启类型指针压缩
-XX:+UseCompressedClassPointers
- 类型指针压缩后占用 4 byte
§4.3 Instance Data
Instance Data 用于存放对象的属性,包括父类中定义的属性
§4.4 Padding
Padding 是用于对齐对象存储空间的部分
这是因为JVM 要求对象的起始地址必须是 8 字节的整数倍
§5 锁升级
什么时候出现的
JDK 6
为什么会有锁升级
- 因为
synchronized
在保证线程安全的前提下带来了性能下降的问题 synchronized
可以在极高的并发下保证线程安全,但通常并没有这么大的并发量,因此造成了性能的浪费- 因此在低并发的场景下提供更轻更快的加锁机制以提升性能
- 同时,在并发数提高时,允许锁升级成完整版
为什么原始的 synchronized
性能低
- java 的线程 最终是通过操作系统的原生线程实现的,默认运行于用户态
synchronized
是基于 监视器monitor
实现的,monitor
又是基于操作系统的 系统互斥量Mutex Lock
实现的,需要操作系统介入- 这就导致了通过 监视器
monitor
阻塞唤醒线程时,实际上使用了操作系统的系统互斥量(Mutex Lock),即这是一次系统调用 - 而系统调用会导致线程在用户态和内核态之间切换,并且切换过程中需要切换线程上下文,消耗 CPU 时间
切换线程的用户态和内核态,需要传递众多变量、参数、寄存器值等
线程的用户态与内核态
- 线程最早不区分内核态和用户态,但这会使线程可以访问任意内存空间,很可能将系统玩坏
- 因为出于安全和稳定性的考虑,将内存和指令划分了级别,即用户态和内核态
实际上指令被划分为了 4 个级别,0-3,但 Linux 系统只使用最高和最低的两个- 用户态线程使用内存的用户空间,执行非特权指令
特权指令指操作硬件或系统的指令 - 内核态线程使用内存的内核空间,可以执行特权指令
- 用户态线程使用内存的用户空间,执行非特权指令
- 正常情况下,线程是工作在用户态的,下面操作需要完成用户态到内核态的切换
总体上说,用户态和内核态之间的切换都是通过中断实现的,系统调用也是一种特殊的中断- 系统调用
系统调用包括 进程控制、文件操作、硬件设备操作、硬件信息、通信相关 - 中断
- 异常
- 系统调用
- 用户态和内核态切换时需要完成一系列状态的保存
- 用户态和内核态的切换是通过中断完成的,中断的首要任务就是保存现场
包括 寄存器信息、栈顶地址、中断时的状态字等 - 切换任务处理后,切换回原来线程状态时,又会把之前保存的现场还原回去
- 用户态和内核态的切换是通过中断完成的,中断的首要任务就是保存现场
锁的 4 个级别横比
级别 | 锁标志位 | markwork中其他信息 | 线程数与常态并发数 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|---|
无锁 | 01 | hashcode、分代年龄等 | - | 无线程安全问题 | ||
偏向锁 | 01 | 偏向的线程 id | 1+ / 0 | 加解锁 | 出现竞争时,增加锁撤销开销 | 无并发的场景 |
轻量级锁 | 00 | 栈中 lock record 的指针 | 2+ / 1 | 低并发时不会阻塞 | 长时间自旋时带来的 CPU 开销 | 同步代码块逻辑很短,可以快速响应的场景 |
重量级锁 | 10 | 堆中 monitor 对象 指针 | 2+ / 2+ | 无长时间自旋时 CPU 开销 | 线程阻塞,响应时间长 | 同步代码块逻辑较长,前面的锁满足不了的场景 |
锁升级流程
总体上锁升级按下面趋势
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
更多细节需要看下图
- jdk 1.6 以后,默认开启偏向锁模式
- 偏向锁模式下,会默认为对象开启偏向锁
- 但对象未被
synchronized
作为锁时,markword
中没有记录线程,称为 匿名偏向锁 - 偏向锁模式有一个 4s 的延时,此段时间中
new
的对象是 无锁状态,之后是匿名偏向锁状态
- 一组比较容易进误区的状态转换
- 无锁只能直接转到轻量级锁
- 匿名偏向锁的对象在被
synchronized
作为锁时,可以转为偏向锁,进而可以转为轻量级锁、重量级锁
- 锁其实是有降级的
- 偏向锁、匿名偏向锁可以被重置为无锁
- 重量级锁中依然会有cas,c++源码中会用到
Atomic.cmpxchg
无锁
没有被 synchronized
使用,不存在锁
偏向锁
- 升级条件
- 对象的
ObjectMonitor
开始被synchronized
使用 - 但因并发量较低,频繁被同一个线程争抢成功时
线程是不止一条的,但通常并没有发生争抢,而是线程轮流获取锁 - 从无锁升级为偏向锁
- 对象的
- 偏向锁实际上约等于没有线程竞争,或竞争极低
需要注意,偏向一个线程并频繁被同一个线程争抢成功 并不是只存在一个线程
而是线程数较少,竞争不激烈,因此 JVM 倾向于让上一次抢到锁的线程再次抢到锁,以节省切换线程上下文的开销 - 偏向锁获取
- 线程第一次拥有偏向锁时,在 markword 中 记录偏向线程 id,并将 ==偏向锁标记置 1=
不需要改锁标志位,因为无锁、偏向锁都是 01 - 即使线程执行完同步块中的逻辑,偏向锁也不会被主动释放,除非遇到其他线程争抢并争抢成功
即:偏向的线程相当于始终持有 偏向锁 - 又有线程进入同步块时,直接在锁的 markword 中检查是否是自己记录的线程 id
- 若与记录的一致,等同于再次竞争获取到锁,放行
- 若不一致,通过 CAS 操作替换 markword 中记录的线程 id
- 替换成功,即竞争成功,依然是偏向锁, markword 中记录新的线程 id
- 替换失败,即竞争失败,可能需要升级为轻量级锁
- 线程第一次拥有偏向锁时,在 markword 中 记录偏向线程 id,并将 ==偏向锁标记置 1=
- 锁升级时机:线程与锁中 markword 记录的线程不是同一个,且尝试 CAS 替换失败后,可能升级
- 偏向锁撤销
- 偏向锁只有在其他线程与偏向线程发生争抢时才会撤销
- 若偏向线程正在同步代码块中,偏向锁会被取消并进行锁升级
- 若偏向现场退出同步代码块,则偏向锁中的线程会变更为竞争线程,依然保持偏向锁
- 偏向锁的撤销需要等待 全局安全点
- 偏向锁只有在其他线程与偏向线程发生争抢时才会撤销
- 相关配置
-XX:+UseBiasedLocking
开启偏向锁-XX:+UseBiasedLockingStartupDelay=0
偏向锁立即生效,默认 4000 ms 延时
按默认配置,JVM 启动后经过 4 秒后,偏向锁才生效
轻量级锁
- 升级条件
- 已经有其他线程频繁对偏向线程争抢
- 并且争抢失败,并等到了全局安全点
- 但因并发量较低,通常只有一个,且获取锁的冲突时间极短
- 从偏向锁升级为轻量级锁
- 轻量级锁的本质就是 自旋锁
可以屏蔽系统调用导致的线程用户态、内核态切换 - 与偏向锁的区别
- 偏向锁约等于没有线程竞争,轻量级锁有
- 偏向锁不会主动释放锁,轻量级锁每次退出同步代码块都会释放
- 轻量级锁获取
- JVM 在当前线程栈帧中创建
Displaced Mark Word
,即锁记录(Lock Record),用于存储锁 - 线程获取锁时,若发现是轻量级锁,会将自己的 markword 复制进
Displaced Mark Word
- CAS 尝试将自己的 markword 替换为指向
Displaced Mark Word
的指针 - CAS 成功,抢到轻量级锁
- CAS 失败,没抢到轻量级锁,开始自旋
- JVM 在当前线程栈帧中创建
- 轻量级锁释放
- CAS 尝试将
Displaced Mark Word
中的内容赋值回锁对象的 markword - 如果没有出现过锁升级,此操作会成功
- 否则,说明轻量级锁已经升级为了重量级锁,此时释放锁并唤醒阻塞线程
- CAS 尝试将
- 自适应自旋锁(JDK 6 以上)
- 自适应自旋锁的最大自旋次数不是固定不变的
JDK 6 时,超过 10 次自旋或半数以上 CPU 核自旋会导致锁升级 - 当前线程上次自旋成功了,则本次自旋最大次数会增加
JVM 倾向于认为上次自旋成功的线程,本次也能成功 - 当前线程上次自旋没有陈宫,则本次自旋最大次数会减少,避免 CPU 自旋消耗
- 自适应自旋锁的最大自旋次数不是固定不变的
重量级锁
- 升级条件
- 自适应自旋锁的自旋超过一定程度
参考上文 自适应自旋锁 - 从轻量级锁升级为重量级锁
- 自适应自旋锁的自旋超过一定程度
- 重量级锁获取
- 编译时,编译器在同步块开始位置插入
monitor enter
,退出时插入monitor exit
- 线程执行到
monitor enter
时,会尝试获取ObjectMonitor
的所有权,如果成功就获取到了锁 - 成功获取到锁后,会在
ObjectMonitor
的_owner
中存入当前线程的 id
- 编译时,编译器在同步块开始位置插入
锁升级过程中的 hashcode
- 偏向锁和 hashcode
- 偏向锁和 hashcode 是互斥的,但这里的 hashcode 专指一致性 hash
一致性 hash 是指通过System.identityHashCode()
或默认的java.lang.Object.hashCode()
获取的 hashcode
经过复写的java.lang.Object.hashCode()
- 不认为是一致性 hash
- 不保证一次生成始终不变
- 也不需要存在 markword 中
- 计算过一致性 hash 的对象不可能进入偏向锁状态,因为一致性 hash 需要存入对象头的 markword
- 偏向锁状态的对象,如果收到计算一致性 hash 的请求,会撤销偏向状态,并升级为 重量级锁
- 升级后 markword 的存储位置同重量级锁
- 偏向锁和 hashcode 是互斥的,但这里的 hashcode 专指一致性 hash
- 轻量级锁和 hashcode
- 在当前线程栈帧中创建
Displaced Mark Word
,即锁记录(Lock Record)空间 - 将锁对象的 markword 复制到此空间
- 将锁对象的 markword 通过 CAS 指向此空间,若成功就是加锁成功,否则加锁失败自旋
- 释放锁时通过 CAS 尝试将
Displaced Mark Word
中的内容赋值回锁对象的 markword
- 在当前线程栈帧中创建
- 重量级锁和 hashcode
- 重量级锁中的
ObjectMonitor
的_header
字段可以保存 hashcode - 退出重量级锁时,会将
ObjectMonitor
的_header
的字段重新赋值给锁对象的 markword
- 重量级锁中的
锁消除
- 锁消除是指 JVM 在运行时对代码中要求的锁进行消除的行为
- 这是因为 JVM 判断,对应的代码段不可能存在 共享数据的的多线程竞争
- 通常 JVM 是根据逃逸分析进行判断的
- 逃逸分析是通过 指向对象的指针(即对象的引用)的范围,分析对象是否可能逃逸出方法进而被其他线程引用
- 若对象满足逃逸分析,比如对象是局部变量,仅在方法内部生效,则
- 被认为不会被其他线程访问,并消除相关的锁
- 被认为没有必要在堆中创建,可以直接使用栈帧中额内存空间
锁粗化
- 锁粗化是指 JVM 在运行时自动增加同步代码块涵盖的范围
- 锁粗化长出现于
- 在一套逻辑(比如一个方法)中,对同一个对象反复加解锁
- 加解锁操作在循环体中完成
§4 JOL
依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
<scope>provided</scope>
</dependency>
查看当前虚拟机详情
VM.current().details();
查看当前虚拟机的对象对齐数
VM.current().objectAlignment();
分析对象的内存布局并打印
class A {
private int i;
private char b;
}
public class JolDemo {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new A()).toPrintable());
}
}