JVM
程序如何装载
Main\.java, Minor\.java -> jar包.java Main\.main(): 编译打包 |
加载:从磁盘加载到内存。(懒加载,用到类才加载,如main方法或new对象)
验证:验证字节码是否正确、是否可识别。
准备:初始化静态(static,不包括常量)变量、赋初值(默认值)。
解析:符号引用 -> 直接引用。静态方法(如main) -> 指向数据所在内存的指针。这是静态链接,在类加载期间完成;而动态链接在程序运行期间完成。
初始化:为静态变量赋值,执行静态代码块。
类加载器
加载过程由类加载器实现,有几种类加载器:
- 引导类加载器(C++):JRE核心lib的jar类包
- 扩展类加载器:JRE拓展lib(ext)jar类包
- 应用程序类加载器:ClassPath路径下的类包(自己编写的类)
- 其他加载器:加载自定义路径下的类包
java com\.site\.jvm\.Math\.class -> java\.exe调用底层jvm\.dll创建Java虚拟机 -> 创建引导类加载器实例 |
双亲委派机制
direction: up |
pros:
- 避免重复加载:下层加载了,上层不会加载;
- 沙箱安全机制:可以防止核心API被篡改
cons: 上层不能调动下层,层层传递比较繁琐
打破双亲委派 避免弊端
通过ContentTextClassLoader
反向委托,可以使上层调用你想要用的加载器。
典型案例 Tomcat8
Tomcat不遵循双亲委派机制,自己写一个HashMap类,会不会有风险?
Tomcat不遵循双亲委派机制,只是自定义的classloader顺序不同,但是还是需要到顶层请求classloadder
JVM内存布局
grid-rows: 3 |
垃圾回收
direction: right |
当Eden区不够放,就会执行Minor GC,将空指针、无用对象回收,并把有用对象放入s0/s1(然后清楚Eden和另外一块survivor区的所有对象),年龄+1;
年龄到15时,会把对象放入老年代。
但是如果s0/s1放不下Minor GC后存活的对象,会直接放入老年代。
老年代满了,会触发Full GC,会暂停所有用户线程(STW, Stop The World)。
对象创建
start: 类加载检查 |
分配内存
- 指针碰撞(默认方法):Java堆中的内存规整分配,没有碎片,那么只需要在最后一个对象的指针后加上一段偏移量(大小)即可完成分配。
- 空闲列表:Java堆中内存分配不规整,碎片化。需要维护一张表,记录哪些空间可用。在分配内存时,找到足够大的空间划分给对象实例。
在并发的情况下,可能出现正在给A分配内存,指针未修改,此时又给B分配内存,B内存区与A重合的情况。
并发解决方法
- CAS(Compare and Swap):比较交换,虚拟机就采用CAS+失败重试的方法保证原子性。
- TLAB(Thread Local Allocation Buffer):按照不同线程划分内存区域,每个线程在区域内分配,防止重合。
Object Header 对象头
- Mark Word标记字段:运行时数据哈希值、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳,32位4B,64位8B
锁状态 | 23bit | 2bit | 4bit | 1bit 是否指向偏向锁 |
2bit 锁标志位 |
---|---|---|---|---|---|
无锁态 | 对象的- | -HashCode | 分代年龄 | 0 | 01 |
轻量级锁 | 指向- | -栈中锁- | -记录的- | -指针 | 00 |
重量级锁 | 指向- | -互斥量- | -(重量级锁)- | -的指针 | 10 |
GC标记 | - | - | - | - | 11 |
偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
- Klass Pointer类型指针:指向类的元数据,8B,压缩后4B
- 数组长度:4B
<init> 方法
执行<init>方法,会将对象按照程序的意愿进行初始化,是真正的属性赋值(不是赋初值0),会执行构造方法。
对象内存分配
start -> cond1: new Object() |
对象逃逸分析
public Object method1() { |
非逃逸对象会分配在栈空间,随着方法结束被释放;而逃逸对象会真正分配在堆上。
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个条件即为无用类:
- 所有实例都已被回收
- 该类的ClassLoader已被回收
- 该类的java.lang.Class对象没有被引用。
一般只有自定义的类加载器会被回收。
垃圾收集
垃圾收集算法
分代收集理论
根据对象存活周期的不同,将内存分成几块(在不同的年龄代,用不同的垃圾收集算法)。一般Java堆分为新生代和老年代。
例如,新生代几乎99%的对象会被回收,所以简单地标记-复制就可以完成内存整理;而老年代存活几率比较高,而且可能没有额外空间分配,因此需要用标记-清除、标记-整理算法。
标记-复制比标记-清除和标记-整理快10倍以上。
标记-复制算法
这个算法把内存分为大小相等的两块,每次整理只需要将一端的存活对象标记好,复制到另一端,然后把这一端内存全部清空。因此,使用标记-复制算法,每次都对内存区间的一半进行回收。(内存利用率低,最多只有50%)
标记-清除算法
这个算法简单地标记存活对象,然后一次性清除未被标记的对象。算法实现简单,但是也有问题:
- 效率低。如果标记的对象太多而且内存不连续,效率不高。
- 空间碎片化。只是简单地清除对象而不整理,会产生大量内存碎片,缺少整块内存。
标记-整理算法
这个算法标记存活对象,然后让所有存活对象都向内存一端移动。然后清除其余的内存区间,获得整块连续的内存空间。
垃圾收集器
垃圾收集器是内存回收的具体实现。
1 Serial收集器
串行收集器是最基础的收集器。它的单线程体现在:
- 只用一条垃圾收集线程
- 垃圾收集时,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),运作过程是:
- 初始标记:STW,记录GC Roots直接引用的对象。
- 并发标记:从GC Roots直接关联对象开始遍历整个对象图,过程中不需要暂停用户线程。因为没有STW,所以过程中已经标记的对象状态会改变。
- 重新标记:STW,用三色标记的增量更新算法做重新标记,修正第2步状态改变的对象标记记录。
- 并发清理:恢复用户线程,同时开始GC清扫。
- 并发重置:重置本次GC的标记数据。
CMS主要优点是并发收集、短停顿,然而也有下面几个缺点: - 对CPU资源敏感,会和服务器抢资源。
- 无法处理浮动垃圾(并发标记、并发清理阶段产生的新垃圾),只能等下一次GC再清理。
- 清扫算法,不能腾出整块连续内存,只能得到许多内存碎片。
- 执行不确定性。在并发标记、并发清理阶段会出现上一轮垃圾回收还没完成,下一轮又开始的情况。
并发失败
在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) { |
CMS使用的是写屏障+增量更新方案
G1,Shenandoah使用写屏障+STAB方案
ZGC使用读屏障方案
为什么G1使用STAB,CMS用增量更新?
不处理,效率更高。有多少被删除的引用会真的被黑色对象引用呢?再做一次深度扫描太浪费了。而G1是为大内存设计的,分了很多区域,与只有一块老年代区域的CMS不同,做深度扫描的成本会高很多。
记忆集与卡表
在新生代做GC Roots可达性扫描过程中,可能会碰到跨代引用的问题(如被老年代引用)。这时,如果要进入老年代做扫描,效率太低了。
因此,在新生代引入记录集(Remember Set)数据结构,记录从非收集区到收集区的指针集合,避免把整个老年代纳入GCRoots扫描范围。
Hotspot使用一种叫“卡表”(Cardtable)的方式实现记忆集。
卡表使用一个字节数组CARD_TABLE[]
实现,其中每个元素对应一块特定大小的内存,称为“卡页”。
Hotspot使用的卡页大小为2^9 = 512
字节。即CARD_TABLE
1字节对应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过程
- 初始标记,STW
- 并发标记,和CMS一样
- 最终标记:和CMS重新标记一样
- 筛选回收:对各个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的设计目标:
- TB级别的堆内存
- GC Pause <= 10ms
- 下一代GC特征基础
- 最多15%的吞吐量下降
ZGC源自Azul的C4,最大优势是,停顿时间与堆大小无关,而是都在10ms内。
NUMA架构,识别每块CPU使用的内存区域,防止竞争和锁的效率问题
ZGC运作过程
- 并发标记:ZGC不把标记放在对象内部,而是在颜色指针上标记
- 并发预备重分配:得出本次收集需要清理的Region并放入重分配集Relocation Set,与G1不同,ZGC每次都会扫描所有Region,这样就不需要维护卡表
- 并发重分配:会将Relocation Set的存活对象复制到新的Region上。由于会发生数据不同步问题。因此维护一个转发表(Forward Table),并通过读屏障来确保数据一致,ZGC称之为Self-Healing。
- 并发重映射:修正整个堆指向重分配集中,旧对象的所有引用
读屏障(懒更新)
由于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大优势
- 一旦某个Region的存活对象被移走,这个Region能够立刻被释放和重用。
- 颜色指针大幅减少内存屏障使用数量,ZGC使用读屏障
- 颜色指针有强大扩展性(18位)
ZGC问题
最大的问题也是浮动垃圾,ZGC的停顿时间是10ms,但是实际上执行回收时间远大于这个值,在此期间会产生许许多多不能处理的新垃圾对象,只能等待下一次回收。
解决方法只有增加堆容量,让程序有更多喘息时间(未分代情况下)。
分代ZGC区分新生代和老年代(代际隔离),对新生代的回收更频繁(分代回收)。
JVM调优工具
Jmap
jmap
可以查看内存信息、实例个数以及所占内存大小
jps |
Jstack
jstack
可以查找死锁
jstack <PID> |
Jvisualvm
jvisualvm
可视化监管java进程,点击进程dump
与jstack
一致
Jstat
jstat [-option] [vmid] [gap(ms)] [query_times]
可以查看堆内存各部分使用情况,例如查看GC:
jstat -gc <PID> |
结合不同参数可以具体查看各代的情况。
Linux工具
top
top -p <PID> |
实用脚本
Jstat
!/bin/bash |
输出
Eden: 32.0MB [||||||||||||| ] 47.0MB (68%) |