深入理解java虚拟机


date: 2024-02-29

本书第3版撰写于2019年中期,此时JDK 13已有了技术预览版.

第一章 走进Java:

  1. Java发展史

  2. JVM多样性

第二章 Java内存区域与内存溢出异常:

  1. 对象创建方式 -> new 然后 init, 在预分配地址,然后再初始化内容。

    • 有两种分配方式,主要看GC,一种指针碰撞法[标记整理算法,空间连续] ,一种就是空闲列表分配[在内存不连续情况下,如标记清除算法]。

    • 对象内包含 对象头[hash码(25bit),锁标志位(2bit),1bit 固定0,其他bit(GC分代年龄,线程持有的锁,偏向线程ID,偏向时间戳等)] ,实例数据地址(会自动补齐为8的整数倍)

    • 访问数据方式:1. 直接地址,修改时要修改对象中的数据但访问速度快。2. 句柄池,修改数据时,只修改句柄池中的对应数据的引用,不需要修改对象中的。

  2. StackOverflowError, OutOfMemoryError, 要区分是内存溢出还是内存泄漏, 泄露要检查代码,溢出要适当加内存或者减少类占空间。

第三章 垃圾收集器与内存分配策略:

  1. GC 判断对象已死的算法:

    1. 引用计数法(对象中添加一个引用计数器,对象被引用就加一,取消了引用就减一) 缺点: 虽然效果不错,但是无法解决循环依赖问题,JVM没有使用这个 -> 循环依赖问题, A.obj = B, B.obj = A, A B 都没用了,但是也无法回收了。

    2. 可达性分析算法 (从一系列GC Root 对象开始,查看对象是否可达,不可达就是可以回收的。) -> 三色标记法 注意:1. 在回收不可达对象前,会判断对象是否执行finalize方法,没有就执行,执行完在判断是否可达。 ( finalize方法一个对象只会执行一次) 2. GC Root 对象可以是 两栈两方法 -> 虚拟机栈的对象,本地方法栈的对象, 方法区中常量引用的对象,方法区中类静态属性引用的对象

  2. 引用:

    1. 强引用 -> GC时不会回收

    2. 软引用 -> 当空间不足时GC会回收

    3. 弱引用 -> 发生GC时就会回收

    4. 虚引用 -> 发生GC时就会回收,作用时在GC时候收到一个通知

  3. 回收算法:

    1. 标记-清除 -> 会 STW,然后做GC

    2. 标记-复制 -> 在eden区标记存活的对象,转移到survivor区。 然后在移回eden

    3. 标记-整理 -> 标记存活对象,然后删除没有标记的对象,然后整理磁盘,防止碎片。

  4. 跨代引用问题:

    • 老年代中的对象引用了年轻代的对象,垃圾回收时候的判断逻辑要增加一种: 记忆集[存储有被老年代对象引用的年轻代对象地址/对象] hotspot用的是卡表方式, 则是一个byte[] 来存储有被老年代对象引用的年轻代对象的地址页。 [用byte是 现在计算机都是用字节为单位的] 在GC ROOT遍历时,判断这个这个数据,得出地址页,然看地址页上对象有无老年代对象引用。

  5. 在并行GC时候,有许多算法都是基于写屏障来保证一致性。上述的卡表就是借助写屏障来维护的。

    • -XX:+UseCondCardMark -> 开启会增加一次额外判断的开销,但能够避免伪共享问题(处理器缓存数据不一致问题, 在更新缓存行和读取缓存行同时发生时)

  6. 收集器

    1. Serial收集器 -> 单线程处理垃圾,并且会停止所有用户线程 -> 标记整理算法

    2. Serial Old收集器 -> 同上,但是是用于老年代回收的。 -> 标记整理算法

    3. ParNew收集器 -> 多线程处理垃圾,也会停止用户线程 -> 标记整理算法

    4. Parallel Scavenge收集器 -> 与parNew差不多,但是关注的是将吞吐量控制在一定比例。 -> 标记复制算法

      1. 吞吐量 = 运行代码时间 / (运行代码时间 + 运行垃圾收集时间)

      2. -XX:GCTimeRatio : 配置运行垃圾收集时间 (0-100), 默认99 既允许1%的垃圾收集时间

    5. Parallel Old收集器 -> 同上,但是是用于老年代回收的。 -> 标记整理算法

    6. CMS收集器 -> 多线程,虽然尽可能降低停顿时间,但是也会导致用户线程停止。 -> 标记清除算法

      1. 步骤: 初始标记 (停顿) -> 并发标记 -> 重新标记 (停顿) -> 并发清除

    7. Garbage First收集器 -> 使用region的概念,而不是分区的的概念,更加自由的分配空间。 -> 标记-整理算法

      1. 步骤: 初始标记 (停顿) -> 并发标记 -> 最终标记 (停顿,使用SATB扫描[Snapshot-at-the-Beginning]) -> 并发清除 -> 并发回收 -> 初始引用更新 -> 并发引用更新 -> ·最终引用更新 -> 并发清理

      2. 分散的多个region可能是新生代,或者老年代。

      3. Humongous Region -> 存放大对象的

      4. 写屏障 + 原始快照 -> 解决GC时漏标或者多标的问题

      5. ref -> https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html

    8. Shenandoah收集器 -> 与G1相似,但是加强了。 (非原厂开发的) -> 标记-整理算法

      1. 步骤: 初始标记 (停顿) -> 并发标记 -> 最终标记 (停顿,使用SATB扫描[Snapshot-at-the-Beginning]) -> 并发清除 -> 并发回收 -> 初始引用更新 -> 并发引用更新 -> ·最终引用更新 -> 并发清理

      2. 使用转发指针解决引用地址并发更新问题。

      3. 写屏障 + 原始快照 -> 解决GC时漏标或者多标的问题

      4. Humongous Region -> 存放大对象的

    9. ZGC收集器:大部分实现与G1相似,具有更低的延迟 -> 标记-整理算法

      1. 停顿时间只与GC Roots大小相关,与内存无关,其他步骤都是并发的。

      2. 使用读屏障和染色指针的方法解决并发更新地址问题。 (染色指针-> 对对象引用地址进行包装,将值放在指针上,而不是对象上)

      3. 管理内存不超过4TB,因为使用染色指针问题 -> 64位系统前18位不能用,染色指针用了4bit,剩下42 (4TB)可以用。

      4. CG时间大部分可控制在10ms内,毕竟原本想商用的,但是openJDK 11+ 也包含了这个功能,用户无感知罢了。

      5. 使用GC -> -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx -Xlog:gc

    10. Epsilon收集器: 不自动回收垃圾的GC,目的是为了给只为允许短时间的系统提供的,因为他们不担心内存不足。

  7. 分配制度

    GC步骤: minor GC (年轻代) -> Full GC (老年代)

    1. 首先放入eden区,经历GC后 对象移入Survivor区,经历多次GC后还存活的对象,直接放入老年代。

    2. 对象进入老年代的条件

      1. GC年龄达标

      2. 对象大小符合进入老年代的条件

      3. 空间分配担保: GC后,Survivor区放不下对象,而老年代可以放入

第四章 虚拟机性能监控、故障处理工具

  1. jps -> 虚拟机进程状态工具

    • 可以知道有那些java进程运行 (jps 与 ps 类似)

  2. jstat -> 虚拟机统计信息监视工具

    • 可以知道垃圾回收情况

    (jstat [ option vmid [interval[s|ms] [count]] ]) jstat -gc 2764 250 20 # 每250毫秒查询一次进程2764垃圾收集状况,一共查询20次

  3. jinfo -> Java配置信息工具

    • 查看虚拟机参数

    jinfo [ option ] pid

  4. jmap -> Java内存映像工具

    • 打印堆栈,堆和方法区信息,如使用率等

    jmap [ option ] vmid

  5. jhat -> 虚拟机堆转储快照分析工具

    • Eclipse Memory Analyzer、IBM HeapAnalyzer、 VisualVM 都更优秀

    • 一般都会把信息存储到其他机器进行分析,因为分析耗资源

  6. jstack -> Java堆栈跟踪工具

    • 常用于查询线程死锁,长时间挂起的原因。

    jstack [ option ] vmid

  7. JHSDB -> 基于服务性代理的调试工具

    jhsdb hsdb --pid 11180 # 查看内存分配信息等

  8. JConsole -> Java监视与管理控制台

    • 图形化界面观测堆,内存,线程等的情况

    jconsole -> 选择具体的进程

  9. VisualVM -> 多合-故障处理工具

    • 功能比jconsole更加强大且免费。

    • 对JDK9-16 似乎没有相应的版本

  10. Java Mission Control -> 可持续在线的监控工具

    • 可以观测到JVM以及操作系统的所有信息。

第5章 调优案例分析与实战

  • 常见GC收集器异常介绍

  • 不同版本的JVM对应的收集器可能有变化,要注意参数设置。

第6章 类文件结构

  • class 文件结构

    • 魔术数字: #CAFEBABE -> 代表可编译的class文件

    • jdk版本

    • 常量池数量,类的类型(abstract、public,final...)

    • 父类,接口,字段,方法,属性等等表格式体现

    表格式体现: 由于每个结构都是由多为十六进制的数字体现,每几位数字代表的具体含义可以到相关表格找到。

  • 字节码以及基础指令

    • 运算指令:iadd ladd isub lsub imul ...

    • 类型转换时,jvm直接支持小转大,如int->double; 大变小时会有精度问题,所以需要使用指令如 i2b,i2c...

    • 对象操作指令,操作数栈管理指令,控制转移指令,方法调用和返回指令,异常处理指令,同步操作指令

    当一个方法被声明为同步时,class文件结构中可以得知方法时候是同步的而不需要额外的执行指令判断。 有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义, 有前者必有后者

第七章 虚拟机类加载机制

  • 加载时机

    • 类生命周期:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

    • 有六种情况必须对类进行初始化:

      • 遇到初始化命令:new、getstatic、putstatic或invokestatic

      • 使用java.lang.reflect包的方法对类型进行反射调用的时候

      • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

      • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

      • 使用动态语言,一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄

      • 有default方法的接口

  • 类加载

    • 启动类加载器 > 扩展[平台]类加载器 > 应用程序类加载器 > 自定义类加载器

    • 不同类加载器加载同一个class,两者不是同一个对象

    • 双亲委托机制,将类先由父加载器加载,不能加载再由自己加载。

    • 升级为模块化结构,规定了不同加载器加载不同模块

    虽然虚拟机的设计是根据《Java虚拟机规范》,但是规范中提及的只是设计,实现完全有实现者考虑,也给了很大可操作空间。

第八章 虚拟机字节码执行引擎

  • 虚拟机的执行引擎是由软件自行结构体系,能够执行那些不被硬件直接支持的指令集格式。

  • 虚拟机以方法为基本执行单元,称为栈帧,其存放在在虚拟机栈里。

    • 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

  • 多态在虚拟机的实现机制

  • invoke method 在虚拟机中的模拟了字节指令调用,从而可以通过反射来执行特定方法

  • 指令集

    • 基于栈的:可移植性高,有jvm决定存储地点 [广泛]

    • 基于寄存器的:需要特定的寄存器,寄存器的位数不一样,效果会不同

由于现在的JVM都有即时编译功能,所以有些代码可能会在即时编译时被优化掉,从而没有达到预期效果

第九章 类加载及执行子系统的案例与实战

  • Web服务器的基础

    • 同服务器上不同的Web应用程序所使用的Java类库可以实现相互隔离 -- 不同程序使用不同版本的相同类库

    • 同服务器上不同的Web应用程序所使用的Java类库可以互相共享 -- 降低JVM加载是内存膨胀问题

    • 服务器与Web程序互不影响,类库也相互隔离

    • 支持JSP的服务器,大部分支持HostSwap,热替换jsp内容

  • OSGI

    • 将依赖模块化,严格控制了程序的依赖管理,热插拔:如果不需要的模块,可以关闭引用而不需要重启程序。

    • 非双亲委托,而是一种类似网状结构

    • 循环引用可能导致死锁:加载时锁加载器; 现在jdk缩小锁范围[锁要加载的类]解决了这个问题

  • 字节码生成

    • 通过ASM、CGLib,Javassist,反射,JDK Proxy 都可以在程序运行时生成字节码

  • JDK回滚

    • Retrotranslator / Retrolambda 可以用这些工具将编译于高版本的包 编译成低版本的

  • 查看运行中的程序信息

    • BTrace、Arthas、Compiler API、写一个JSP文件上传到服务器、程序加入 BeanShell Script JavaScript等的执行引擎

  • 编写一个工具:程序在运行提供入口输出程序信息

    • 目标: 不影响原有程序,不改变部署流程,提升输入自由度

    • 思路:如果编译提交到服务器,编译后如何执行,执行后如何收集结果。

第十章 前端编译与优化

  • 前端编译: 从.java -> .class 的过程,期间包含了以下几步操作:

    • 准备过程

      • 初始化插入式注解处理器

    • 解析与填充字符表过程

      • 词法、语法检查,构建抽象语法树

      • 填充符号表,产生符号地址和符号信息

    • 插入式注解处理器的处理过程

    • 分析与字节码生成过程

      • 语法检查

      • 数据流及控制流分析,动态运行过程检查

      • 解语法糖

      • 字节码生成

  • java的泛型是基于 类型擦除实现的, 即编译后集合没有特定的类型

第十一章 后端编译与优化

  • 后端编译: 从 .class -> 二进制机器码 的过程。

  • hotspot虚拟机内置两三个及时编译器以及解释器来优化代码以及提高代码质量与程序运行速度。 代码优化越高,时间越长。引入分层编译模式,不同的分层模式,效果不同。分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,来争取更好的性能。

  • 虚拟机有client和server两种模式,一般是混用模式。热点代码的判断有 采样探测 | 计数。

    • 采样探测为周期性判断某个方法经常出现在栈顶,可能因线程阻塞而不准确

    • 计数是通过对每个方法建立计数器来统计,通过-XX:CompileThreshold设置阈值,超过就会触发即时编译

      • 计数= 方法调用计数 + 回边计数; 字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”

    • -XX:+PrintCompilation: 打印即时编译的方法 [带有“%”的输出说明是由回边计数器触发的栈上替换编译]

    • -XX:+PrintInlining 要求虚拟机输出方法内联信息

  • 提前编译可以使用大量资源,所以有一定优势。相对的,即时编译虽然在启动时占用了资源,但是也对应用的(代码)运行资源分配进行了优化。 [各有千秋]

    • 优化手段包括但不限制于:

      • 方法内联: 代码优化,减少dead code

      • 逃逸分析:分析对象逃逸等级,可能在对象分配上做不同优化。 [逃逸:在xx范围外会变化,等级: 不逃逸->方法逃逸->线程逃逸]

  • Graal 新一代编译器

第十二章 Java内存模型与线程

  • 内存模型: 原子性,有序性,可见性

  • volatile:

    • 线程可见性: 当值发送变化,其他线程中的值也会变化,但是并发场景可能有覆盖的可能。

    • 禁止指令重排: 代码编译优化后,可能将逻辑上下移动,导致该赋值的地方没有赋值,多线程下使用该对象会出问题,所以需要用volatile修饰。

      • 将结果直接记录内存中,防止对象的使用顺序变化

  • 线程实现的三种方式

    • 内核创建 轻量级进程

    • 用户进程创建线程 [创建,销毁 自己处理]

    • 混合 内核创建进程,进程创建用户线程(UT)

  • Fiber: 有栈协程

    • 难点: 线程挂起时,需要保护现场,以便唤醒时继续执行。

    • Fiber调度器 控制来控制协程的切换。

第十三章 线程安全与锁优化

  • 即使是 Vector 这种集合 (方法都被添加了 synchronized 关键字的), 也可能在并发出出现问题

    • 如一个循环删除,一个循环打印,但是由于删除后,集合的size变化了,打印的线程会异常。 [需要对集合加锁]

  • 重入锁 [ReentrantLock] 性能 ~ synchronized

    • 重复进入一个对象,并不会死锁

    • 公平/非公平 设置, 公平锁会导致吞吐下降,因为每个线程获取锁的概率一样

    • 可以绑定多个条件 [唤醒条件]

  • 悲观锁

    • 不管结果行不行,先锁 再判断

  • 乐观锁 [无锁]

    • 先尝试更新,如果冲突就补偿,没有就更新了。

    • 由于需要指令级别的原子性,当硬件发展了才支持的。 [CAS(compareAndSet)指令]

      • ABA 问题: A 变成 B, 然后又变成A,CAS更新成功,但是值发生过变化。 [解决:AtomicStampedReference, 加版本号]

  • 锁升级

    • 自旋锁

      • 再CAS失败后,尝试循环调用(默认10次),现在有自适应自旋锁,基于上次自旋次数来判断这次需要多久,很少成功获取锁可能直接跳过自旋了。

    • 锁消除

      • 在编译期间发现代码不会尝试任何竞争问题,则不上锁

    • 锁粗化

      • 如果一段代码平凡加/解锁,则会锁的范围会被放大

    • 轻量级锁

      • 用CAS尝试更新对象Mark Word -> lock record, 成功锁标志位变为 "00"

    • 重量级锁

      • 轻量级更新失败,则说明有两个线程竞争,既升级。 标志位:"10"

    • 偏向锁

      • 如果虚拟机开启偏向锁,则在获取锁的时候,设置偏向模式=1,标志位="01"

      • 如果有另一个线程要获取锁,则撤销偏向 [偏向模式=0], 标志位设置为="01" 或者 "00"

      • 偏向锁的Mark Word 记录的是线程Id, 不是对象hash。

      • 总的来说,有好有坏,看场景+测试

标志位: 01 未锁定, 00 轻量级, 10 重量级, 11 GC标记, 01 偏向[偏向模式=1]

Last updated