Java 虚拟机总结

JVM(Java虚拟机)

JVM 是 JRE 包含的 Java 程序运行所需的程序,屏蔽各种硬件和操作系统的内存访问差异,通用一份 jar 包。 另外,JVM还提供了内存管理的功能,使得开发人员无需关心底层内存如何管理。

https://www.youtube.com/watch?v=ZBJ0u9MaKtM

类加载机制

Java虚拟机的类加载有加载 (load)、链接 (link)、初始化 (initialize) 三个步骤

当一个类被实例化时或者类中静态方法被调用时将进行类加载

加载

将不同的类数据源以字节码的形式加载到内存中以供类加载器使用,数据的来源可以是 jar 包、class 类、网络数据,一个类数据有且只有一个存在于一个类加载器中。

正常的类加载是以双亲委任机制,不同的类加载器是以继承的方式链接的,当通过某个类加载器加载一个类数据时,当这个类加载器存在父类加载器时,那么它会先从父类加载器中寻找类数据,不存在再在自身中寻找,并且父类也是遵循这个机制。

Tomcat的类加载器重写了这个加载机制,会优先在自身中查找,这跟Tomcat所加载类的所在位置有关。

链接

链接包含了验证、准备、解析三个步骤

  1. 验证确保了类加载的正确性,它校验了字节码数据是符合Class类规范,常量类型是否支持,语义分析,分析数据流和控制流校验程序语义,符号引用校验。

  2. 准备环节是为一个类的静态域分配内存空间,并赋予零值。

  3. 解析负责转化类中的符号引用,将类引用转换为直接引用,将类中的常量值转换为常量池中引用。

如果类字段的字段属性为 ConstantValue,即同时被 finalstatic 修饰的基础类型数据,并且在定义时即赋值,如 static final String CONSTANT_COMPILE = "java";,那么在准备阶段变量就会被初始化为属性所指定的值。

初始化

对类的静态域赋予正确的初始值,执行静态代码块为赋值静态域。

异常

  • ClassNotFoundException 这种错误是发生在加载环节,当一个类在双亲委任机制中无法获取到时,便会抛出此异常。常见情景是依赖包的冲突。

  • NoClassDefFoundError 这个错误主要是发生在链接环节的解析中,当一个类的静态域引用至另一个类中的属性,而这个类无法获取时,那么虚拟机就会抛出此异常。

运行时数据区域

Java 虚拟机在运行程序的过程中把内存数据划分为不同的区域

线程隔离的(指令区)

  • 程序计数器:指向线程下一个执行的指令的地址(本地指针或者起始指令的偏移量),当执行的是本地方法时为 undefined。 许多操作都需要依赖程序计数器来完成,例如在时间片抢占后切换线程能够恢复到正确的位置。

  • 虚拟机栈:包含了线程生命周期的方法调用,一个 Java 方法调用即为一个栈帧,根据调用顺序压入线程栈空间。 栈帧存储当前线程运行方法所需要的局部变量表(基本数据类型、对象引用)、操作数栈、动态链接、方法返回地址,保证了多线程下调用方法的隔离性。

  • 本地方法区:与虚拟机栈作用相似,区别是执行 native 方法。

线程共享的(数据区)

  • 方法区/元空间 (MetaSpace):存放类信息、静态域数据(对象实例存于堆中)、类编译期间生成的各种字面量和符号引用、字节码、JIT 编译后的机器码、动态代理产生的数据,使用本地内存存储,几乎不会被回收。

  • 堆 (Heap):运行时常量池、对象实例域,是垃圾收集管理的主要区域。可分为新生代、老年代。

  • 直接内存:用于 NIO 数据交换的内存空间,只受实际内存及 JVM 参数限制。

内存溢出

内存溢出是由于虚拟机空间分配失败所导致的致命性错误。

  • 方法栈 (StackOverFlowError) 常见原因有递归或大循环调用方法导致栈帧数量过多、线程内定义大量的本地变量。

  • 堆 (OutOfMemoryError) 常量池溢出、线程持续占有对象都将可能导致堆溢出。

  • 方法区 (OutOfMemoryError) 主要原因为动态创建大量的类,并且卸载无法满足新的元类存储。

执行引擎

解释器 (Interpreter)

解释字节码,执行相应的命令

分析器 (Hotspot profiler)

将频繁调用的热点方法编译成与本地平台相关的机器码

JIT (即时编译器)

优化解释器,将字节码翻译成本地平台相关的机器码执行 常见的 JIT 有 C1、C2,在 Java10 引入 Graal

常见的 JIT 优化手段有

  • 公共子表达式消除 当一个大表达式已经被计算过后,再次出现已经包含了的表达式则不必重新计算,直接用结果代替。

  • 数组边界检查消除 消除大循环体内对元素越界检查

  • 方法内联 将频繁调用方法替换为调用方法代码

  • 逃逸分析 当开启了标量替换 (-XX:+EliminateAllocations) 和逃逸分析 (-XX:+DoEscapeAnalysis) 后,会对线程栈内的对象进行分析,将只存活于栈帧内的可分解对象进行基础类型数据替换处理。

  • 同步消除 (-XX:+EliminateLocks) 在开启逃逸分析和 -server 模式后,将会对无多线程竞争的锁进行消除。

  • 优化技术

编译器策略:延迟编译,分层编译,栈上替换,延迟优化,程序依赖图表示,静态单赋值表示。

基于性能监控的优化技术:乐观空值断言,乐观类型断言,乐观类型增强,乐观数组增强,裁剪未被选择的分支,乐观的多态内联。分支频率预测,调用频率预测

基于证据的优化技术:精确性推断,内存值推断,内存值跟踪,常量折叠,重组,操作符退化,空值检查消除。类型检测退化,类型检测消除,代数化简,公共子表达式消除

数据流敏感重写:条件常量传播,基于六承载的类型缩减转换,无用代码消除

语言相关的优化技术:类型继承关系分析,去虚拟机化,符号常量传播,自动装箱,消除逃逸分析,锁消除,锁膨胀,消除反射

内存及代码位置交换:表达式提升,表达式下沉,冗余存储消除,相邻存储合并,交汇点分离

循环变换:循环展开,循环剥离,安全点消除,迭代分离,范围检查消除

局部代码调整:内联,全局代码提升,基于热度的代码分离,Switch 调整

控制流图变换:本地代码编排,本独代码封包,延迟槽填充,着色图寄存器分配,线性扫描寄存器分配,复写聚合,常量分裂,复写移除,地址模式匹配。指令窥孔优化,基于确定有限状态机的代码生成

  • Graal [jdk10] 启动方式 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler

GC(垃圾收集器)策略

Java虚拟机在运行时会产生大量的对象,有些对象将不会再被使用,为了使内存不被这些无用的对象占用,垃圾收集器就需要分析出已经死亡的对象,将其所用空间回收。

虚拟机从一部分称为 GC Roots 的节点开始搜索引用链,找出存活的引用 (并非特指对象),对其余对象标记为虚引用,准备下一阶段进行回收释放内存空间。对重写了 finilize() 方法对象封装并放入队列中,在 GC 结束后单线程执行方法后移除。

Serial

标记清除整理 算法,单线程进行垃圾回收,无上下文切换开销,但 cpu 利用率较低,并且无法与应用线程并行进行。

Parallel

基于吞吐量优先的并行收集器,对年轻代使用 标记复制 算法,对老年代使用 标记清除整理 算法,适用于多核处理器,有效利用系统资源。

CMS(Concurrent Mark Sweep)

标记清除 算法为基础,以响应时间优先的并发收集器,对年轻代使用 标记复制 算法,对老年代使用 标记清除 算法。 使用 空闲空间列表 来管理老年代内存,在老年代 GC 阶段大部分工作可以与应用线程并发执行,并且可在老年代空间内存利用率达到阀值时触发 CMS GC。

收集工作分为数个阶段:初始标记、并发标记、并发预清理、并发可取消的预清理、最终标记、并发清除、并发重置

CMS 由于不进行内存整理容易造成老年代内存碎片,并且当堆内存较大时,可能产生不可预估的暂停时间。

G1(Garbage-First)

复制 算法为基础,为了能够得到良好的停顿时间而产生的一款实时收集器。 将内存空间以块 (Region) 进行分配空间,一般划分 2048 个,优先回收大垃圾的回收机制。使用空闲空间列表来管理所有内存。

虚拟机在分配空间时会选定一个 Region(TLAB将分别指定一个Region),当 Region 的剩余空间不足以分配对象或者小于最小可空间时,将重新指定下一个 Region 分配空间。

HRegion

当分配对象的大小大于 Region 空间的一半,将会分配一个 Humongous Region 直接进入老年代,省略年轻代的内存复制过程,这个 Humongous Region 的大小将为能容纳对象的最小 Region 空格倍数。

SATB (snapshot-at-the-beginning)

对象初始化时的快照,引用变化时使用 write barrier 进行更新,在 GC 中利用了此列表进行扫描。

RSet

与 CMS 不同,G1 使用了记录 Region 引用关系的 Remembered Set 来记录,内部为多个 Card Table ,并且是用于记录 被引用 的对象集合。 使用 三色标记法 + 写屏障(write barrier) 来更新其引用关系。

G1 解决了 CMS 中的各种疑难问题, 包括暂停时间的可预测性, 并终结了堆内存的碎片化。

ZGC

是一个可伸缩的低延迟垃圾收集器,暂停时间不超过10毫秒,暂停时间不会随堆或实时设置大小而增加

https://blog.csdn.net/renfufei/article/details/54885190 https://www.zhihu.com/question/53613423/answer/135743258

内存模型(JMM)

heap 根据空间利用率、垃圾回收存活年龄分为年轻代、老年代,永久代。

年轻代

年轻代又可细分为 Eden空间、S0空间 (From Survivor)、S1空间 (To Survivor) Eden Space 中存在线程私有的空间 TLAB,是每个线程的缓冲区,存放一些用过即丢弃的对象。

老年代

当新生代的对象超过设定年龄,或者同龄对象达到幸存区的一半,这些对象将被划入老年代(Old Space)。 只有当老年代空间不足分配发生 Full GC 时 (CMS 中可以为 CMS GC),才会对老年代的数据进行回收。

永久代

用于存放元类数据,在并发标记时和类加载器卸载时将会对相关数据进行回收。 当一个类加载器死亡时,相对应的元数据也被销毁,释放其块空间。

PermgenSpace

java8 之前的永久代实现,也称为方法区,为一块固定空间大小,当空间不足时会进行Full GC。 由于区空间大小固定,在大量动态创建类的程序中容易造成OOM。

MetaSpace

源自 JRockit,在 Java8 与 Hotspot 合并,将 native method area 概念加入 Hotspot,由此得来 MetaSpace 替换原有的 PermGen,容量仅受可用的本地内存限制。

MetaSpace 的空间是以块 (Chunk) 为单位,这个块的大小取决与申请空间的类加载器类型。

当类加载器加载类时,从块分配器中获取一份块内存空间存在类元数据,并映射此地址。

由于每个类加载器申请的块大小不一致,MetaspaceVM 也还未使用压缩技术,这就容易导致内存碎片的产生。

TLAB

当大量线程申请空间时,JVM 需要对并发操作保障不会发生指针碰撞,这样便增加了复杂性,降低性能。

因此,在 jdk1.6 以后便引入了 TLAB 技术。

TLAB 全名 Thread-local allocation buffers,是在线程初始化的时候在堆中新生代申请一块线程私有的分配空间(允许所有线程访问),减少同步开销,使用参数 -XX:UseTLAB 开启。

https://www.jianshu.com/p/cd85098cca39

PLAB

全名 promotion-local allocation buffers,用于 Young GC 时的空间分配及复制。

其他还有 CLAB,全名 core-local allocation buffers,用于全局的空间分配。