JVM 基础

JVM 内存模型

程序计数器

一块较小的内存空间,用于记录线程正在执行的字节码(通过 javac 编译后得到)指令的地址。如果此时正在执行本地方法(native method),那么存储值为 undefined。程序计数器线程隔离。

程序计数器的作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,实现代码的流程控制。
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置。当线程切换回来时,可以从上次线程执行到的位置继续执行。

java 虚拟机栈

存储在 java 虚拟机栈中的数据单位被称为_栈帧_。java 会为每一个即将执行的方法创建一个栈帧,并随着方法的运行和调用执行压栈和出栈的操作,处于栈顶的栈帧即是当前正在执行的方法。java 虚拟机栈线程隔离。

栈帧中存储的数据有:

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法出口信息
  • etc.

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError:若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
  • OutOfMemoryError:若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。

栈空间的大小通过-Xss参数设置,栈空间越大,能存储的栈帧的数量就越多。 栈帧的局部变量表越大,单个栈帧占的空间就越大,栈深度就越小。

虚拟机栈-局部变量表

存储方法参数以及在方法体内部定义的局部变量。

局部变量表的大小在编译期确定下来,不会动态扩展。在 Java 程序编译为 Class 文件时,方法Code属性的max_locals数据项确定了该方法局部变量表的容量。

局部变量表中的位置是可以重用的,如果一个局部变量过了其作用域,之后新申明的局部变量就有可能复用过期局部变量的槽位,从而节省资源。

虚拟机栈-操作数栈

存放操作数。当一个方法刚开始执行时,其操作数栈是空的。随着方法执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者。

同局部变量表一样,操作数栈的深度在编译期确定,定义在 Code 属性的 max_stacks 数据项中。

虚拟机栈-动态连接

Class 文件中存放了大量的符号引用,字节码中的方法调用指令通过符号引用找到方法区的常量池中的对应值作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

虚拟机栈-方法出口信息

方法执行完成或异常后,下一步要调用的字节码位置。

本地方法栈(C栈)

Java native 方法的调用栈,native 方法指使用 Java 调用非 Java 代码的接口。由于很多 native 方法都是使用 C 语言实现的,所以本地方法栈又称为 C 栈。本地方法栈与 Java 虚拟机栈的原理类似。本地方法栈线程隔离。

堆(Heap)

Java 堆存储 Java 对象信息,由所有线程共享。Java 堆可以处于物理上不连续的空间中,在逻辑上被视为连续。通过 -Xms 设置堆的最小空间,-Xmx 设置堆的最大空间。

堆空间可分为新生代,老年代。其中新生代又分为Eden、From Survivor(S0) 和 To Survivor(S1)。

新生代和老年代的默认比例为 1 : 2 ,通过 XX:NewRatio=2 参数配置。 新生代中,Eden、From Survivor、To Survivor的比例为 8 : 1 : 1。

方法区(永久代 / 元数据区(metaspace))

永久代和元数据区是对 JVM 方法区的两种实现,方法区主要存放以下信息:

  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

JDK8 后,永久代被废弃,使用 metaspace 替代。

永久代和 metaspace 的区别:永久代使用 JVM 内存,metaspace 使用本地内存。两者都是所有线程共享的。

GC(垃圾回收)

Minor GC

  • 新对象在 Eden 区中被创建;
  • 如果创建新对象时,Eden 区空间被填满,就会触发 Minor GC,将 Eden 和 From Survivor 区中不再被其他对象引用的对象进行销毁(To Survivor是空的),再加载新的对象放到 Eden 区;
  • Eden 区和 From Survivor 区中的存活对象被放到 To Survivor 区;
  • 交换 From Survivor 和 To Survivor 的指针(角色互换),交换后的 To Survivor 区是空的;
  • 当对象经历一定次数的 Minor GC 后(默认15次,通过 : -XX:MaxTenuringThreshold=N  设置 ),该对象会从幸存者区转向老年代。

Minor GC 也会触发“stop the world”,只是持续的时间较短,对程序影响不大。

Full GC / Major GC

Major GC 只清理老年代, Full GC 同时清理新生代、老年代。两者并没有明确的界限划分。

显式调用System.gc(),老年代的空间不够,方法区的空间不够等都会触发 Full GC,同时对新生代和老年代回收,FUll GC 的 STW 的时间最长,应该要避免。

在出现 Major GC 之前,会先触发 Minor GC,如果老年代的空间还是不够就会触发 Major GC,STW 的时间长于 Minor GC。

垃圾回收算法

标记 - 清除

将存活的对象进行标记,然后清理掉未被标记的对象。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

标记 - 整理

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法 通用用于老年代

复制

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是只使用了内存的一半。

复制算法通常用于新生代

垃圾回收器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
  • 串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial 收集器

Serial 翻译为串行,也就是说它以串行的方式执行。它是单线程的收集器,只会使用一个线程进行垃圾收集工作。它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

ParNew 收集器

它是 Serial 收集器的多线程版本。

是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

Parallel Scavenge 收集器

与 ParNew 一样是多线程收集器。

其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

可以通过一个开关参数打卡 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Serial Old 收集器

是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

分为以下四个流程:

  • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除: 不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

Thread Dump

Thread Dump 是非常有用的诊断 Java 应用问题的工具。每一个 Java 虚拟机都有及时生成所有线程在某一点状态的 Thread Dump的能力,虽然各个 Java 虚拟机打印的 Thread Dump 略有不同,但大多都提供了当前活动线程的快照,及 JVM 中所有 Java 线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法。

Thread Dump 抓取

一般当服务器挂起、崩溃或者性能低下时,就需要抓取服务器的线程堆栈(Thread Dump)用于后续的分析。在实际运行中,往往一次 dump 的信息,还不足以确认问题。为了反映线程状态的动态变化,需要接连多次做 thread dump,每次间隔10 - 20s,建议至少产生三次 dump 信息。如果每次 dump 都指向同一个问题,才能确定问题的典型性。

ps –ef | grep java
kill -3 <pid>

Thread Dump 分析

Dump 文件可通过 MAT(Eclipse Memory Analyzer) 软件进行分析。