JVM

程序如何装载

Main\.java, Minor\.java -> jar包.java Main\.main(): 编译打包
jar包.java Main\.main() -> 验证: 加载
jar包.java Main\.main() -> Minor\.class: 使用
Minor\.class -> JVM: 加载
验证 -> 准备 -> 解析 -> 初始化 -> JVM

加载:从磁盘加载到内存。(懒加载,用到类才加载,如main方法或new对象)
验证:验证字节码是否正确、是否可识别。
准备:初始化静态(static,不包括常量)变量、赋初值(默认值)。
解析:符号引用 -> 直接引用。静态方法(如main) -> 指向数据所在内存的指针。这是静态链接,在类加载期间完成;而动态链接在程序运行期间完成。
初始化:为静态变量赋值,执行静态代码块。

类加载器

加载过程由类加载器实现,有几种类加载器:

  1. 引导类加载器(C++):JRE核心lib的jar类包
  2. 扩展类加载器:JRE拓展lib(ext)jar类包
  3. 应用程序类加载器:ClassPath路径下的类包(自己编写的类)
  4. 其他加载器:加载自定义路径下的类包
java com\.site\.jvm\.Math\.class -> java\.exe调用底层jvm\.dll创建Java虚拟机 -> 创建引导类加载器实例
创建引导类加载器实例 -> sum\.misc\.Launcher\.getLauncher(): C++调用Java代码,创建JVM启动器实例,这个实例负责创建其他类加载器
sum\.misc\.Launcher\.getLauncher() -> launcher\.getClassLoader(): 获取运行类自己的加载器ClassLoader(AppClassLoader实例)
launcher\.getClassLoader() -> classLoader\.loadClass("com\.site\.jvm\.Math"):调用loadClass加载即将要运行的类
classLoader\.loadClass("com\.site\.jvm\.Math") -> Math\.main(): 加载完成后,JVM执行Math.main()
创建引导类加载器实例 -> Math\.main(): C++发起调用
Math\.main()-> JVM销毁: Java程序运行结束

双亲委派机制

direction: up
应用程序类加载器 -> 拓展类加载器: 向上委托
拓展类加载器 -> 引导类加载器: 向上委托
引导类加载器 -> 拓展类加载器: 父加载器加载失败,由子加载器自己加载
拓展类加载器 -> 应用程序类加载器: 父加载器加载失败,由子加载器自己加载

pros:

  • 避免重复加载:下层加载了,上层不会加载;
  • 沙箱安全机制:可以防止核心API被篡改
    cons: 上层不能调动下层,层层传递比较繁琐

打破双亲委派 避免弊端

通过ContentTextClassLoader反向委托,可以使上层调用你想要用的加载器。

典型案例 Tomcat8

Tomcat不遵循双亲委派机制,自己写一个HashMap类,会不会有风险?

Tomcat不遵循双亲委派机制,只是自定义的classloader顺序不同,但是还是需要到顶层请求classloadder

JVM内存布局

grid-rows: 3
cf: 类文件\nClass Files
cf.shape: page
cls: 类加载子系统\nClass Loader Subsystem
rda: 运行时数据区(Runtime Data Area): {
方法区(共享)\nMethod Area
程序计数器\nPC Reg
本地方法栈\nNative Method Stack
堆(共享)\nHeap
虚拟机栈\nJVM Stack
}
ee: 执行引擎\nExecution Engine
nmi: 本地方法接口\nNative Method Interface
nml: 本地方法库\nNative Method Libs

cf <-> cls
cls <-> rda
rda <-> ee
rda <-> nmi
ee <-> nmi
nmi <-> nml

垃圾回收

direction: right
young: 年轻代: {
ed: Eden(8)
s: Survivor区: {
s0: s0(1)
s1: s1(1)
}
}
young.ed -> young.s.s0 <-> young.s.s1 -> Old(2/3)

当Eden区不够放,就会执行Minor GC,将空指针、无用对象回收,并把有用对象放入s0/s1(然后清楚Eden和另外一块survivor区的所有对象),年龄+1;
年龄到15时,会把对象放入老年代。
但是如果s0/s1放不下Minor GC后存活的对象,会直接放入老年代。
老年代满了,会触发Full GC,会暂停所有用户线程(STW, Stop The World)。

对象创建

start: 类加载检查
cond: 是否已加载类
cond.shape: diamond
yes: 分配内存
yes -> 初始化 -> 设置对象头 -> 执行\<init\>方法
no: 加载类
start -> cond
cond -> no: 否
no -> yes
cond -> yes: 是

分配内存

  1. 指针碰撞(默认方法):Java堆中的内存规整分配,没有碎片,那么只需要在最后一个对象的指针后加上一段偏移量(大小)即可完成分配。
  2. 空闲列表:Java堆中内存分配不规整,碎片化。需要维护一张表,记录哪些空间可用。在分配内存时,找到足够大的空间划分给对象实例。

在并发的情况下,可能出现正在给A分配内存,指针未修改,此时又给B分配内存,B内存区与A重合的情况。

并发解决方法

  1. CAS(Compare and Swap):比较交换,虚拟机就采用CAS+失败重试的方法保证原子性。
  2. TLAB(Thread Local Allocation Buffer):按照不同线程划分内存区域,每个线程在区域内分配,防止重合。

Object Header 对象头

  1. Mark Word标记字段:运行时数据哈希值、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳,32位4B,64位8B
锁状态 23bit 2bit 4bit 1bit
是否指向偏向锁
2bit
锁标志位
无锁态 对象的- -HashCode 分代年龄 0 01
轻量级锁 指向- -栈中锁- -记录的- -指针 00
重量级锁 指向- -互斥量- -(重量级锁)- -的指针 10
GC标记 - - - - 11
偏向锁 线程ID Epoch 分代年龄 1 01
  1. Klass Pointer类型指针:指向类的元数据,8B,压缩后4B
  2. 数组长度:4B

<init> 方法

执行<init>方法,会将对象按照程序的意愿进行初始化,是真正的属性赋值(不是赋初值0),会执行构造方法。

对象内存分配

start -> cond1: new Object()
cond1: 栈内分配?
cond1 -> 栈: Y
栈 -> End: POP
cond1 -> cond2: N
cond2: 大对象?
cond2 -> Old: Y
Old -> End: Full GC
cond2 -> cond3: N
cond3: TLAB?
cond3 -> Eden: Y
cond3 -> Eden: N
condMgc: Minor GC?
Eden -> condMgc
condMgc -> S1: N
S1 -> Age?
Age? -> Old: Y
Age? -> S2: N
S2 -> condMgc
condMgc <-> End: Y

对象逃逸分析

public Object method1() {
Object obj = new Object();
...
return Object; // 被其他方法使用,逃逸
}

public void method2() {
Object obj = new Object();
...
businessMapper.insert(obj); // 生命周期和函数一起结束,非逃逸
}

非逃逸对象会分配在栈空间,随着方法结束被释放;而逃逸对象会真正分配在堆上。

Survivor到老年代

一块Survivor区,一批对象占用<=50%的内存大小。如果一批对象大于这个值,那么大于等于这批对象最大年龄的对象都会被放入老年代。

因此,如果一秒时间内新对象太多,超出这个Survivor区的生存阈值,就会直接放入老年代,从到导致更频繁的Full GC。解决方法是:1. 要么扩大Eden,让Minor GC间隔更长;2. 要么扩大Survivor,让垃圾对象能被放入,并在下一次GC及时释放,不会被错误地放入老年代。

Full GC

每次Minor GC,都会判断老年代内存空间是否够Survivor区的对象放入老年代,不够,则触发Full GC,还不够,则OOM
当老年代空间不够放,JVM会尽快Full GC(因为即使Minor GC可能也不够放,反而浪费了时间)

对象内存回收算法

1. 引用计数器

新增一个引用,计数器加1;一个引用失效,计数器减1。
实现简单,但是存在循环引用问题,A<->B之间互相引用,于是无法回收。

2. 可达性分析

以GC Roots对象(线程栈本地对象、静态变量、本地方法栈变量等)作为起点,向下搜索引用对象,找到的对象都标记为非垃圾对象。

无用类判断

同时满足以下3个条件即为无用类:

  1. 所有实例都已被回收
  2. 该类的ClassLoader已被回收
  3. 该类的java.lang.Class对象没有被引用。
    一般只有自定义的类加载器会被回收。

垃圾收集

垃圾收集算法

分代收集理论

根据对象存活周期的不同,将内存分成几块(在不同的年龄代,用不同的垃圾收集算法)。一般Java堆分为新生代和老年代。
例如,新生代几乎99%的对象会被回收,所以简单地标记-复制就可以完成内存整理;而老年代存活几率比较高,而且可能没有额外空间分配,因此需要用标记-清除、标记-整理算法。

标记-复制比标记-清除和标记-整理快10倍以上。

标记-复制算法

这个算法把内存分为大小相等的两块,每次整理只需要将一端的存活对象标记好,复制到另一端,然后把这一端内存全部清空。因此,使用标记-复制算法,每次都对内存区间的一半进行回收。(内存利用率低,最多只有50%)

标记-清除算法

这个算法简单地标记存活对象,然后一次性清除未被标记的对象。算法实现简单,但是也有问题:

  1. 效率低。如果标记的对象太多而且内存不连续,效率不高。
  2. 空间碎片化。只是简单地清除对象而不整理,会产生大量内存碎片,缺少整块内存。

标记-整理算法

这个算法标记存活对象,然后让所有存活对象都向内存一端移动。然后清除其余的内存区间,获得整块连续的内存空间。

垃圾收集器

垃圾收集器是内存回收的具体实现。

1 Serial收集器

串行收集器是最基础的收集器。它的单线程体现在:

  1. 只用一条垃圾收集线程
  2. 垃圾收集时,Stop The World
    Serial在新生代-XX:+UserSerialGC使用标记-复制算法,在老年代-XX:+UserSerialOldGC使用标记-整理算法。

2 Parallel Scavenge收集器(JDK1.8默认收集器)

并行收集器,实际上就是Serial的多线程版本,回收时同样会Stop The World。默认的收集线程数与CPU核心数量保持一致。
并行收集器关注CPU吞吐量,减少用户线程停顿时间。
Parallel Scavenge同样在新生代-XX:+UserParallelGC使用标记-复制算法,在老年代-XX:+UserParallelOldGC使用标记-整理算法。

ParNew收集器

ParNew收集器-XX:+UseParNewGC和Parallel类似,但是可以与CMS配合使用。

3 CMS收集器

CMS(Concurrent Mark Sweep)收集器只适用于老年代,以最短停顿时间为目标。是HotSpot JVM第一款并发收集器,可以使垃圾回收与用户线程同时工作。
CMS使用的是标记-清除算法(Mark Sweep),运作过程是:

  1. 初始标记:STW,记录GC Roots直接引用的对象。
  2. 并发标记:从GC Roots直接关联对象开始遍历整个对象图,过程中不需要暂停用户线程。因为没有STW,所以过程中已经标记的对象状态会改变。
  3. 重新标记:STW,用三色标记的增量更新算法做重新标记,修正第2步状态改变的对象标记记录。
  4. 并发清理:恢复用户线程,同时开始GC清扫。
  5. 并发重置:重置本次GC的标记数据。
    CMS主要优点是并发收集、短停顿,然而也有下面几个缺点:
  6. 对CPU资源敏感,会和服务器抢资源。
  7. 无法处理浮动垃圾(并发标记、并发清理阶段产生的新垃圾),只能等下一次GC再清理。
  8. 清扫算法,不能腾出整块连续内存,只能得到许多内存碎片。
  9. 执行不确定性。在并发标记、并发清理阶段会出现上一轮垃圾回收还没完成,下一轮又开始的情况。

并发失败

在CMS并发标记、并发清理阶段,如果用户线程又实例化了许多新对象,导致老年代触发Full GC,STW,那么这个清理线程实际上就失败了,还是用效率低下的线性收集器收集。

CMS参数

-XX:+UseConcMarkSweepGC 启用CMS(老年代)
-XX:ConcGCThreads 并发GC线程数量
-XX:+UseCMSCompactAtFullCollection FullGC后碎片整理
-XX:CMSFullGCsBeforeCompaction 设置每隔多少次FullGC做一次碎片整理,默认值为0
-XX:CMSInitiatingOccupancyFraction 设置老年代FullGC空间占比阈值,默认是92;不设置100是为了避免并发失败启用线性收集器
-XX:+UseCMSInitiatingOccupancyOnly 强制使用设定阈值。默认是只在第一次使用设定的阈值,后续动态调整
-XX:+CMSScavengeBeforeRemark CMSGC前启动一次MinorGC,降低CMS标记阶段的开销
-XX:+CMSParallelInitialMarkEnabled 初始标记阶段使用多线程,缩短STW
-XX:+CMSParallelRemarkEnabled 重新标记阶段采用多线程,缩短STW

三色标记

黑色:全部引用都扫描过的对象,存活
灰色:还没完全扫描的对象
白色:未扫描的对象。扫描开始时所有对象为白色;扫描结束后,清理白色对象
三色标记可能会产生漏标的问题,对于灰色对象中还没扫描的对象,如果这个对象被已经扫描过的黑色对象引用,而灰色对象的引用又被置null,那么这个应该被扫描和标记的对象就会被漏扫,从而导致错误清理。

漏标-读写屏障

漏标会错误清理,是非常严重的错误,会使用增量更新和原始快照两种方法避免:

  • 增量更新(IU):当黑色对象新增白色对象引用时,会把这个新增引用记录下来(这样黑色就变成灰色对象),等并发扫描结束,重新扫描这些黑色对象。
  • 原始快照(STAB):当灰色对象删除白色对象的引用时,记录这个引用删除,等并发扫描结束,将这些白色对象直接设置为黑色对象,使这一轮GC不清理这些对象。

写屏障

void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障,记录旧值(先加入队列,与实际操作异步)
*field = new_value; // 赋值操作
post_write_barrier(field, value); // 写屏障,记录新值(入队,异步执行)
}

CMS使用的是写屏障+增量更新方案
G1,Shenandoah使用写屏障+STAB方案
ZGC使用读屏障方案

为什么G1使用STAB,CMS用增量更新?

不处理,效率更高。有多少被删除的引用会真的被黑色对象引用呢?再做一次深度扫描太浪费了。而G1是为大内存设计的,分了很多区域,与只有一块老年代区域的CMS不同,做深度扫描的成本会高很多。

记忆集与卡表

在新生代做GC Roots可达性扫描过程中,可能会碰到跨代引用的问题(如被老年代引用)。这时,如果要进入老年代做扫描,效率太低了。
因此,在新生代引入记录集(Remember Set)数据结构,记录从非收集区到收集区的指针集合,避免把整个老年代纳入GCRoots扫描范围。

Hotspot使用一种叫“卡表”(Cardtable)的方式实现记忆集。
卡表使用一个字节数组CARD_TABLE[]实现,其中每个元素对应一块特定大小的内存,称为“卡页”。
Hotspot使用的卡页大小为2^9 = 512字节。即CARD_TABLE1字节对应512B(0~511)

具体来说,老年代按卡页被分割成许多个区块。当老年代地址0x0200(2*16^2=512B)区块引用了新生代的对象,CARD_TABLE[] = { 0, 1, ... },将对应区域标记为dirty。此时,新生代不仅会做GC Roots扫描,还会到老年代对应的地址0x0200扫描引用。

卡表状态也是用写屏障维护。

G1收集器

使用-XX:+UseG1GC启动G1(Garbage-First)。
G1不再使用原来的分代概念,而是将内存分割成大小相等的区域(Region)。JVM最多可以有2048个Region(每块区域大小=堆大小/2048)。
G1保留了年轻代、老年代的概念,但是它们都可以随机放在任何一块Region中,而不是物理隔离。
默认的年轻代占堆空间的5%,在运行过程中,JVM会给年轻代增加内存,但是年轻代不会超过60%。在年轻代内部,仍然保持8:1:1的比例

G1的特点是有一个Humongous(极大的)分区,专门存放短期巨型对象(超过region50%就算大对象,如果超过一个region大小,那就跨region存放),而不是直接放入老年代。在FullGC时,会将Humongous区垃圾对象一并回收。

GC过程

  1. 初始标记,STW
  2. 并发标记,和CMS一样
  3. 最终标记:和CMS重新标记一样
  4. 筛选回收:对各个Region的回收价值/成本进行排序,根据用户期望停顿时间-XX:MaxGCPauseMillis,默认200ms制定回收计划。

因为是用户设置的停顿时间,所以G1直接STW并发回收提高效率。

G1主要使用复制算法,将一个region的存活对象放入另一个region中,内存碎片比较少,而且不像CMS需要整理。
G1因为内部实现复杂,没用实现并发回收。Shenandoah就实现了这一点,可以看作G1的升级版。
G1在后台维护一个优先队列,优先选择允许时间内价值最大(回收空间最多)的Region

G1垃圾收集分类

YoungGC

YoungGC会计算Eden区回收所需的时间,接近用户设置的允许时间时,就会触发YoungGC。
G1通过动态调整Eden区大小(默认5%)来实现上述算法。

MixedGC

老年代对占有率达到-XX:InitiatingHeapOccupancyPercent,默认45的设定值就会触发,将回收所有Young、部分Old和Humongous区域。
G1会优先在老年代做MixedGC,如果复制对象过程中,没有足够的内存,那么会触发FullGC。

FullGC

STW,线性标记-压缩,整个过程比较耗时;Shenandoah优化这个过程为多线程。

ZGC

ZGC的设计目标:

  1. TB级别的堆内存
  2. GC Pause <= 10ms
  3. 下一代GC特征基础
  4. 最多15%的吞吐量下降
    ZGC源自Azul的C4,最大优势是,停顿时间与堆大小无关,而是都在10ms内。

NUMA架构,识别每块CPU使用的内存区域,防止竞争和锁的效率问题

ZGC运作过程

  1. 并发标记:ZGC不把标记放在对象内部,而是在颜色指针上标记
  2. 并发预备重分配:得出本次收集需要清理的Region并放入重分配集Relocation Set,与G1不同,ZGC每次都会扫描所有Region,这样就不需要维护卡表
  3. 并发重分配:会将Relocation Set的存活对象复制到新的Region上。由于会发生数据不同步问题。因此维护一个转发表(Forward Table),并通过读屏障来确保数据一致,ZGC称之为Self-Healing。
  4. 并发重映射:修正整个堆指向重分配集中,旧对象的所有引用

读屏障(懒更新)

由于ZGC使用颜色指针,而复制对象的过程中会发生数据不同步问题。
所以,ZGC直到原来的指针被读取(即此时没有发生写入),才会真正地修正指针引用,成为读屏障。

转发表

读屏障怎么知道地址有没有变化?在并发重分配阶段维护转发表,就知道对象去向。

颜色指针

以前的垃圾回收器GC信息保存在对象头,而ZGC将这些信息保存在指针上。
每个对象有一个64位的指针,其中

  • 42位用于寻址(4^42=4T)
  • 1位Marked1标识
  • 1位Marked0标识
  • 1位Remapped标识,设置后,说明对象没有指向RelocationSet
  • 1位Finalizable标识,与并发引用处理有关,表示这个对象只能通过finalizer访问
  • 18位未使用

为什么2个Mark

每个GC周期开始,会交替使用标记位(01、10互换),使上次GC标记失效。

颜色指针3大优势

  1. 一旦某个Region的存活对象被移走,这个Region能够立刻被释放和重用。
  2. 颜色指针大幅减少内存屏障使用数量,ZGC使用读屏障
  3. 颜色指针有强大扩展性(18位)

ZGC问题

最大的问题也是浮动垃圾,ZGC的停顿时间是10ms,但是实际上执行回收时间远大于这个值,在此期间会产生许许多多不能处理的新垃圾对象,只能等待下一次回收。

解决方法只有增加堆容量,让程序有更多喘息时间(未分代情况下)。
分代ZGC区分新生代和老年代(代际隔离),对新生代的回收更频繁(分代回收)。

JVM调优工具

Jmap

jmap可以查看内存信息、实例个数以及所占内存大小

$ jps
<PID> <Name>
22928 Jps

$ jmap -histo <PID> > jlog
# 查看堆情况
$ jmap -heap <PID>
# 导出快照Dump
$ jmap -dump:format=b,file=<FileName> <PID>

Jstack

jstack可以查找死锁

$ jstack <PID>
...
java.lang.Thread.State: BLOCKED
...
Found <amount> Java-level deadlock:
...

Jvisualvm

jvisualvm可视化监管java进程,点击进程dumpjstack一致

Jstat

jstat [-option] [vmid] [gap(ms)] [query_times]可以查看堆内存各部分使用情况,例如查看GC:

$ jstat -gc <PID>

结合不同参数可以具体查看各代的情况。

Linux工具

top

$ top -p <PID>
# H查看所有线程情况,配合jstack找到线程ID,即可找到对应代码

实用脚本

Jstat

#!/bin/bash

# 检查是否提供了PID参数
if [ -z "$1" ]; then
echo "Usage: $0 <PID>"
echo "Example: $0 5527"
exit 1
fi

PID=$1

# 检查进程是否存在
if ! ps -p $PID > /dev/null; then
echo "Error: Process with PID $PID not found"
exit 1
fi

# 执行jstat并格式化输出
jstat -gc $PID | awk '
function progress(pct) {
bars = int(pct/5)
return sprintf("[%-20s]", substr("||||||||||||||||||||", 1, bars))
}
NR==2 {
printf "Eden: %5.1fMB %s %5.1fMB (%d%%)\n", $6/1024, progress(100*$6/$5), $5/1024, 100*$6/$5
printf "Old: %5.1fMB %s %5.1fMB (%d%%)\n", $8/1024, progress(100*$8/$7), $7/1024, 100*$8/$7
printf "Meta: %5.1fMB %s %5.1fMB (%d%%)\n", $10/1024, progress(100*$10/$9), $9/1024, 100*$10/$9
printf "GC Stats: YGC=%d(%.3fs) FGC=%d(%.3fs) Total=%.3fs\n", $13, $14, $15, $16, $19
}'

exit 0

输出

Eden:   32.0MB [|||||||||||||       ]  47.0MB (68%)
Old: 18.8MB [|||||||||||| ] 30.0MB (62%)
Meta: 52.5MB [||||||||||||||||||| ] 52.9MB (99%)
GC Stats: YGC=18(0.166s) FGC=0(0.000s) Total=0.174s