前言

疫情期间看完了《深入理解Java虚拟机》一书,看完之后觉得有些囫囵吞枣,没有留下深刻印象,因此写点小小总结,把握一下重点知识,尽量形成认知框架中的一部分。

原书的内容十分详细,也肯定写得比我好。我这里只做简单的概括,详细的还是去书里看比较好。

Java内存区域

Java虚拟机管理的内存包含以下几个运行时数据区域:


运行时数据区

各运行时数据区域内容如下:

  • 程序计数器(Program Counter Register):即线程执行字节码的行号指示器;
    • 在JVM中每个线程都有一个,属于线程私有的资源
    • 如果执行的是Java方法,那么计数器值是虚拟机字节码的指令地址;
    • 如果执行的是本地(Native)方法,那么计数器值是空(Undefined)
  • Java虚拟机栈:描述Java方法执行的线程内存模型;线程私有
    • 当出现方法调用时,虚拟机会同步创建栈帧(Stack Frame)入栈;调用完毕时,栈帧出栈
  • 本地方法栈(Native Method Stacks):与虚拟机栈所发挥的作用非常相似,本地方法栈是为虚拟机使用到的本地(Native)方法服务
    • 本地方法可简单理解为Java程序调用的外部代码,如c++、python脚本等
  • Java堆(Java Heap):所有线程共享的一块内存区域,在虚拟机启动时创建,用来存放对象实例
    • Java堆也是垃圾收集器管理的内存区域,对象的“朝生暮死”就在这里发,因此又叫“GC”堆
  • 方法区(Method Area):与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 运行时常量池(Runtime Constant Pool):方法区的一部分,与已加载的类一一对应
    • 常量池表(Constatnt Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
    • 运行时常量池中包含了多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
  • 直接内存(Direct Memory):不是虚拟机运行时数据区的一部分
    • 在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了 在Java堆和Native堆中来回复制数据。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。


运行时栈帧结构

如上图,每一个栈帧都包括了局部变量表操作数栈动态连接方法返回地址和一些额外的附加信息

  • 局部变量表(Local Variables Table):用于存放方法参数和方法内部定义的局部变量
    • 记录在方法的Code属性的max_locals数据项中
    • 局部变量表的容量与变量槽(Variable Slot)为单位,变量槽的实际大小不固定,只是一种规范
  • 操作数栈(Operand Stack):后入先出栈,最大深度记录在Code属性的max_stacks数据项中
    • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作;例如算术操作,会借助该栈完成计算
  • 动态连接(Dynamic Linking):每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
    • Class文件的常量池中存在大量符号引用,在运行期间转化为直接引用的部分就是动态连接
  • 方法返回地址:方法执行完成/异常退出的返回地址
    • 遇到方法返回的字节码指令,属于正常退出,这时主调方法的PC值就可以作为返回地址,从而保存在栈帧中
    • 因本方法执行过程中产生无法处理的异常(方法里的异常处理表没有对应的异常处理)而退出,属于异常退出,这要通过异常处理表来确定,一般栈帧就不会保存这部分信息
  • 附加信息:一些规范里没有描述的信息到栈帧之中,例如与调试、 性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述

垃圾收集

可达性分析算法

基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

GC Roots的组成:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

在可达性分析算法中被判定为不可达的对象,可以通过自身定义的finalize()方法“拯救”自己,该方法仅会调用一次,并且虚拟机并不一定会等待其调用结束。如果调用之后,算法将其判定为可达,那么其“拯救”成功;否则将被回收。

垃圾收集算法

理论:分代收集

分代假说:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡
  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

收集器设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储

以较高的频率回收新生代,以较低的频率回收老年代;同时由于跨代引用仅少量存在,因此可以通过记忆集(remembered set) 等数据结构实现跨代收集

一些定义:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
      • 目前只有CMS收集器会有单独收集老年代的行为。
      • 另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集
      • 目前只有G1收集器会有这种行为
  • 整堆收集(Full GC):整个Java堆和方法区的垃圾收集

1. 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象(也可以反过来,标记存活的对象,统一回收所有未被标记的对象)


“标记-清除”算法示意图

缺点:

  • 执行效率不稳定:标记和清除两个过程的执行效率都与对象数量有关,随对象数量增长而降低
  • 内存空间碎片化问题:标记、清除之后会产生大量不连续的内存碎片,导致再次分配时效果不佳

2. 标记-复制算法

“半区复制”(Semispace Copying):将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

优点:

  • 分配内存很方便,移动堆顶指针即可
    缺点:
  • 如果存活对象较多,则内存复制的开销较大
  • 可用内存减小到了原来的一半,过于浪费

“标记-复制”算法示意图

优化后的半区复制分代策略——Appel式回收:内存空间100% = Eden 80% 1 + Survivor 10% 2

每次扫描Eden和一块Survivor,将存活对象转移到另一块Survivor上

根据研究数据——新生代中的对象有98%熬不过第一轮收集,这能工作的很好

3. 标记-整理算法

算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

优点:

  • 对象分配简单,移动堆顶即可
    缺点:
  • 存活对象较多时,内存复制较多;且对象移动过程必须要全程暂停用户应用程序

“标记-整理”算法示意图

经典垃圾收集器

1. Serial收集器

Serial收集器是单线程新生代收集器,且在进行垃圾回收时,必须暂停所有线程,直到它收集结束


Serial/Serial Old收集器运行示意图

优点:简单而高效,是所有收集器里额外内存消耗最小的

Serial收集器依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。

对于用户桌面的应用场景以及近年来流行的部分微服务应用之类的场景中,虚拟机可管理的内存很少,只要垃圾收集不是过于频繁,几十毫秒的停顿时间是完全可以接受的

2. ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器是不少运行在服务端模式下的HotSpot虚拟机机,尤其是JDK 7之前的遗留系统中首选的新生代收集器

  • 除了Serial收集器外,目前只有它能与CMS收集器配合工作

ParNew/Serial Old收集器运行示意图

3. Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),经常被称作“吞吐量优先收集器”。

  • 吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间(运行用户代码时间+运行垃圾收集时间)的比值。

4. Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法

用 途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现

6. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现

目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。

收集过程:

  1. 初始标记:标记一下GC Roots能直接关联到的对象;
    • 时间很短,需要“Stop The World”
  2. 并发标记:就是从GC Roots的直接关联对象开始遍历整个对象图的过程
    • 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  3. 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
    • 比初始标记时间稍长,但远比并发标记短;需要“Stop The World”
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象
    • 由于不需要移动存活对象,所以可以与用户线程同时并发

Concurrent Mark Sweep收集器运行示意图

优点:并发收集、低停顿
缺点:

  • 对处理器资源非常敏感:并发阶段会占用部分用户资源,导致应用程序变慢
  • 无法处理“浮动垃圾”:本次垃圾收集过程中程序产生的新对象只能到下一次垃圾收集处理,因此不能等内存空间几乎完全占满才使用
  • 内存空间碎片化问题:“标记—清除”算法的弊端

7. Garbage First收集器

即G1收集器,是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器。

可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

收集过程大致可划分为以下四个步骤:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象
    • 并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象
    • 这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象
    • 这阶段耗时较长,但可与用户程序并发执行
    • 当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象
  • 最终标记:处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
    • 对用户线程做另一个短暂的暂停
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间
    • 这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成

G1收集器运行示意图

G1可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

基于Region的堆内存布局

  • G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间
  • 收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果

可预测的停顿时间模型

  • Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,避免在整个Java堆中进行全区域的垃圾收集
  • G1收集器会跟踪各个Region里面的垃圾堆积的“价值” 大小(价值即回收所获得的空间大小以及回收所需时间的经验值)
  • 然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region(这也就是“Garbage First”名字的由来)
  • 这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率

需要解决的细节问题:

  1. 跨Region引用对象如何解决? —> 每个Region都维护自己的记忆集
    • G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号
    • 这种结构更复杂,占用内存量也更大
  2. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行? —> 原始快照(SATB)算法
    • 新对象创建:G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认它们是活的
  3. 怎样建立起可靠的停顿预测模型? —> 以衰减均值为理论基础
    • G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息
    • 衰减平均值更准确地代表“最近的”平均状态
    • 通过这些信息预测现在开始回收的话,可以计算由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益

Java基础故障处理工具

1. jps

jps:JVM Process Status Tool,虚拟机进程状况工具

功能和ps命令类似:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)

jps命令格式:

1
jps [ options ] [ hostid ]

jps工具主要选项

2. jstat

jstat:JVM Statistics Monitoring Tool,虚拟机统计信息监视工具

用于监视虚拟机各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据

jstat命令格式为:

1
jstat [ option vmid [interval[s|ms] [count]] ]

对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID 是一致的;如果是远程虚拟机进程,那VMID的格式应当是:
1
[protocol:][//]lvmid[@hostname[:port]/servername]

jstat工具主要选项

3. jinfo

jinfo:Configuration Info for Java,Java配置信息工具,作用是实时查看和调整虚拟机各项参数

jinfo命令格式:

1
jinfo [ option ] pid

4. jmap

jmap:Memory Map for Java,Java内存映像工具,用于生成堆转储快照(一般称为heapdump或dump文件),还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等

jmap命令格式:

1
jmap [ option ] vmid

jmap工具主要选项

5. jhat

jhat:JVM Heap Analysis Tool,虚拟机堆转储快照分析工具,与jmap搭配使用,来分析jmap生成的堆转储快照

jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看

6. jstack

jstack:Stack Trace for Java,Java堆栈跟踪工具,用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者 javacore文件)

生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因

jstack命令格式:

1
jstack [ option ] vmid

jstack工具主要选项

其他

详见书本《深入理解Java虚拟机》4.2.7节