# 深入理解 Java 虚拟机(一):自动内存管理机制

# Java 虚拟机

Oracle 有两款实现了 Java SE 的产品:Java SE Development Kit(JDK)Java SE Runtime Environment(JRE)JDKJRE 的超集,包含了 JRE 的所有内容,以及开发应用程序所需的编译器和调试器等工具。 JRE 提供了函数库、Java Virtual Machine(JVM) 和其它用来运行 Java 应用程序的组件。

Java SE8产品组件概念图

# Java 内存区域

Java虚拟机运行时数据区

# 程序计数器

程序计数器是一块较小的内存空间,他的作用可以看做是当前线程所执行的字节码的行号指示器。由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间来实现的,因此每个线程都有一个独立的程序计数器,用于线程切换后能恢复到正确的执行位置。如果线程执行的是 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native方法,这个计数器的值为 undefined。此区域是唯一一个在 Java 虚拟机规范中没有规定 OutOfMemoryError 情况的区域。

# Java 虚拟机栈

  • 虚拟机栈描述的是 Java 方法执行的动态内存模型,调用方法即创建栈帧并入栈,方法执行完毕栈帧出栈。
  • 栈帧:每个方法执行,都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等。每一个方法从调用到执行完成的过程,就是一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 局部变量表:存放编译器可知的各种基本数据类型,引用类型,returnAddress 类型。对象是在堆内存中创建的,局部变量表存放的是对象的引用,其大小是不会改变的。因此局部变量表的内存空间在编译期完成分配后,方法需要在帧分配多少内存是固定的。
  • 虚拟机栈异常:如果线程请求的栈深度超过虚拟机允许的深度,将抛出 StackOverFlowError 异常;如果虚拟机栈允许扩展,在扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

# 本地方法栈

Java 虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈则为虚拟机使用到的 Native方法 服务。在 Hotspot虚拟机 的实现中是把本地方法栈和虚拟机栈合二为一的。与虚拟机栈一样,本地方法栈也会抛出 StackOverFlowError 异常和 OutOfMemoryError 异常。

# Java 堆

堆是线程共享的数据运行时区域,几乎所有的对象实例以及数组都要在堆上分配内存。堆是垃圾收集器管理的主要区域。Java 堆可以处于物理上不连续的内存空间中。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

# 方法区

方法区存储虚拟机加载的类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码等数据。于 Hotspot虚拟机 来说,将方法区纳入 GC 管理范围,这样就不必单独管理方法区的内存,所以就有了相对于新生代和老年代的永久代一说。

# 运行时常量池

运行时常量池(JDK6 在方法区,JDK7Java 堆)用来存放编译器生成的各种字面量以及符号引用(类加载之后进入运行时常量池)。运行期间也能将新的常量放入池中。当常量池无法再申请到内存时,将会抛出 OutOfMemoryError 异常。

# 直接内存

Java NIO 使用 Native函数库 直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。通过避免在 Java堆Native堆 中来回复制数据来提高性能。直接内存大小不受虚拟机参数控制,如果各个内存区域总和大于物理内存限制,就会出现 OutOfMemoryError 异常。

# 对象

# 对象的创建

对象的创建过程

当虚拟机遇到一条 new 指令,首先检查这个指令的参数是否能在常量池中定位一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有那必须先执行相应的类的类加载。

类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存空间的大小在类加载完成后便可确定。为对象分配内存的任务等同与将一块确定大小的内存从 Java 堆中划分出来,内存分配可以使用指针碰撞或空闲列表的策略。

# 内存分配策略

根据 Java 堆 是否规整可以判断使用哪种内存分配策略。

  • 指针碰撞:堆内存中的空闲空间十分的规整,使用与未使用的空间全部为连续,分配内存只需移动指针。
  • 空闲列表:针对堆内存中的空间零散的存在,虚拟机维护着一个列表,记录那些内存未使用。

除了如何划分内存空间,还需要考虑并发情况下的线程安全问题。

# 线程安全性

对象创建在虚拟机中是十分频繁的行为,在并发环境下需要考虑线程安全。

  • CAS失败重试:通过乐观锁实现线程安全。
  • TLAB(Thread Local Allocation Buffer):本地线程分配缓冲,内存为每个线程分配一个 TLAB区域,每个线程要创建对象时先在这个区域中创建,当原来的空间不足时再通过线程同步获取一块新的区域。

# 对象设置

将对象的哈希吗、GC 年龄信息等存放在对象头中,执行 <init> 方法。

# 对象的内存布局

# 对象头(Header)

  • 自身运行时数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方称这部分数据为 Mark Word
  • 类型指针:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个数组,对象头中还必须有一块记录数组长度的数据。

# 实例数据(Instance Data)

实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。

# 对齐填充(Padding)

HotSpot虚拟机 要求对象的起始地址必须是 8 字节的整数倍,也就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或者 2 倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。

# 对象的访问定位

Java 程序通过栈上的 reference 数据来操作堆上的具体对象,由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,具体用何种方式去定位、引用堆中对象的具体位置,取决于虚拟机的实现,目前主要有 使用句柄直接指针 两种方式。

  • 使用句柄:引用类型指向堆中一块区域(句柄池),此区域保存了实例对象的地址(对象被移动时维护句柄中的指针数据,无需改变 reference 本身)。

通过句柄访问对象

  • 直接指针:从引用类型直接指向内存区域(速度更快,节省了一次指针定位带来的开销)。

通过直接指针访问对象

# 垃圾回收

# 垃圾对象判定算法

# 引用计数法

给对象添加一个引用计数器,当有地方引用这个对象时,引用计数器的值 +1,当引用失效时,计数器的值 -1。垃圾回收器遇到计数为 0 的对象就会回收。但是当堆内存中的对象相互引用,而外部不存在对这些对象的引用时,计数器的值不为 0,无法判定应该回收。

# 可达性分析法

通过一系列称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,它们将被判定为可回收对象。

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNINative 方法)引用的对象

# Java 引用

  • 强引用:类似 Obejct obj = new Object(),只要强引用还在,垃圾收集器就永远不会收集被引用的对象。
  • 软引用:还有用但并非必需的对象。在系统发生内存溢出之前,会将软引用关联的对象进行回收,如果回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:垃圾收集器工作时,无论当前内存是否足够,都会回收被弱引用关联的对象。
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是能在这个对象被垃圾收集时收到一个系统通知。

# finalize()

将对象回收至少要经历两次标记过程,如果在可达性分析中发现对象没有与 GC Roots 的引用链,那它将会被第一次标记并被进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法(当前对象没有覆盖此方法或者已经执行过此方法,则虚拟机认为“没有必要执行”),虚拟机不会承诺等待此方法执行结束。如果在 finalize() 方法中成功与引用链上的任一个对象建立关联,则对象不会被回收。

# 回收方法区

方法区中垃圾回收的效率是远低于堆的,其回收内容主要包括两部分:

  • 废弃常量:如果没有任何对象引用常量池中的常量,也没用其它地方引用了这个字面量,在垃圾回收时这个常量就会被系统清理出常量池。常量池中的对象、类、方法、字段的符号引用都是如此。
  • 无用的类:如果一个类的所有实例对象都已经被回收并且加载该类的 ClassLoader 也被回收,同时该类的类对象也没有被其它任何地方所引用、反射也无法访问,这时类才有可能被回收。

# 垃圾收集算法

# 标记-清除算法

首先标记不可达对象,在标记完成后统一回收所有被标记的对象。其主要缺点有两个,一是标记和清除的效率都不高;二是标记清除后会产生大量不连续的内存碎片,导致以后需要分配较大的对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法

# 复制算法

为了解决标记-清除算法的效率问题,复制算法将内存划分为两块,每次只使用其中一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上去,然后将已使用的过的内存空间一次清理掉。这样使得没次都是对整个半区进行内存回收,内存分配时就无需再考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。只是每次只能使用其中一块内存。

复制算法

这种算法现在普遍用于回收新生代,因为新生代中的大多数对象都是 “朝生夕死” 的,新生代可以划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor 空间,当回收时,Eden 空间和 Survivor 空间中还存活的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 空间和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 空间和 Survivor 空间的比例是 8 : 1,也就是说每次新生代中可用内存空间为整个新生代容量的 90%,只有 10% 的空间会被浪费。

新生代使用复制算法回收

# 标记-整理算法

复制算法在对象存活率较高的情况下需要进行较多的复制操作,效率将会变低,并且无法应对 100% 对象都存活的情况。因此针对老年代的垃圾回收,有人提出了一种标记-整理算法,在标记完可达对象后,所有存活对象都向一端移动,然后直接清理端边界以外的内存空间。

标记-整理算法

# 分代收集算法

分代收集算法是根据对象存活周期的不同将内存划分为几块,一般是把堆划分为新生代和老年代,并根据各个年代的特点采用最适当的收集算法。在新生代中每次垃圾回收都有大量对象死去,就可以使用复制算法,付出少量对象的复制成本就可以完成收集,而老年代中因为对象存活率高,就需要使用标记-清除或标记-整理算法来进行回收。

# 垃圾收集器

垃圾收集器是垃圾回收算法的具体实现。不同版本的虚拟机一般会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

基于JDK1.7Upadte14的HotSpot虚拟机包含的垃圾收集器

# Serial 垃圾收集器

Serial 垃圾收集器是最基本、发展历史最悠久的收集器。

  • 采用复制算法,针对新生代。
  • 单线程垃圾回收,执行时必须暂停所有工作线程,直到收集结束。

![Serial和Serial Old组合使用](D:\Java\code\VisualStudioCodeProjects\wch853.github.io\img\jvm\Serial和Serial Old组合使用.png)

# ParNew 垃圾收集器

ParNew 垃圾收集器是 Serial 收集器的多线程版本。其同样针对新生代采用复制算法进行垃圾回收。但是在 CPU 核数较少的情况下其性能不保证能优于 Serial 收集器。其也是与老年代垃圾收集器 CMS 组合的默认新生代垃圾收集器。

![ParNew和Serial Old组合使用](D:\Java\code\VisualStudioCodeProjects\wch853.github.io\img\jvm\ParNew和Serial Old组合使用.png)

# Parallel Scavenge 垃圾收集器

Parallel Scavenge 垃圾收集器的目标是达到一个可控制的吞吐量(CPU 用于运行用户代码的时间与 CPU 消耗的总时间的比值),即减少垃圾收集时间,让用户代码获得更长的运行时间。

  • 采用复制算法,针对新生代。
  • 多线程垃圾回收。
  • 提供两个参数用于精确控制吞吐量:-XX:MaxGCPauseMillis:最大垃圾收集停顿时间(ms),设置稍小会缩短停顿时间,但也可能会造成频繁发生垃圾回收,使得吞吐量下降。-XX:GCTimeRatio:垃圾收集时间占总时间的比率,0 < n < 100 的整数
  • GC 自适应调节:打开参数开关 -XX:+UseAdaptiveSizePolicy 就不需要指定新生代的大小(-Xmn)、EdenSurvivor 区域的比例(-XX:Survivor)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数来提供合适的停顿时间或者最大的吞吐量。

# Serial Old 垃圾收集器

Serial Old 垃圾收集器是 Serial 收集器的老年代版本。

  • 使用标记-整理算法,针对老年代
  • 单线程收集

# Parallel Old 垃圾收集器

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

  • 使用标记-整理算法,针对老年代
  • 多线程收集
  • 在注重吞吐量和 CPU 资源敏感的场合,可以优秀考虑 Parallel ScavengeParallel Old 组合使用

![Parallel Scavenge和 Parallel Old组合使用](D:\Java\code\VisualStudioCodeProjects\wch853.github.io\img\jvm\Parallel Scavenge和 Parallel Old组合使用.png)

# CMS(Concurrent Mark Sweep)垃圾收集器

CMS 垃圾收集器是一种以获取最短回收停顿时间为目标的收集器。

  • 标记-清除算法,针对老年代。标记过程分为初始标记、并发标记、重新标记、并发清除。初始标记仅标记 GC Roots 能直接关联到的对象;并发标记是进行 GC Roots Tracing 的过程;重新标记是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;并发清除的内存回收过程是和用户线程一起并发执行的。
  • 能够实现与工作线程一起并发执行。
  • 以获取最短回收停顿时间为目标。
  • 在并发阶段虽然不会导致工作线程停顿,但是因为占用了一部分 CPU 资源导致应用程序变慢,总吞吐量降低。
  • 由于 CMS 垃圾收集器回收过程中用户线程还在运行,因此新的垃圾还在不断产生,如果新的垃圾出现在标记过程之后,那么只能留到下一次回收,这种垃圾被称为浮动垃圾。
  • 为了预留足够的空间供用户线程使用,CMS 垃圾收集器不能等到老年代被填满之后再进行回收,在 JDK1.6 中触发回收的阈值是 92%,可以通过 -XX:CMSInitiatingOccupancyFraction 来指定阈值。要是 CMS 工作过程中,预留的内存空间无法满足程序需要,就会出现一次 Concurrent Mode Failure,虚拟机就将临时启动 Serial Old 收集器来重新进行老年代的垃圾回收,这样停顿时间就很长了。
  • CMS 垃圾收集器基于标记-清理算法,这意味着回收结束可能会产生大量的内存碎片,参数 -XX:+UseCMSCompactAtFullCollection 默认打开用于在 CMS 顶不住要进行 Full GC 时开启内存碎片的整理。还有一个参数 -XX:CMSFullGCsBeforeCompaction 用于设置多少次不压缩的 Full GC 后来一次压缩的 Full GC,其默认值为 0,即每次 Full GC 后都进行内存碎片整理。

CMS垃圾收集器

# G1 垃圾收集器

G1 垃圾收集器是一款面向服务端应用的垃圾收集器,其使命在于未来能够替换掉 CMS 垃圾收集器。

  • 并行与并发:充分利用多 CPU、多核环境下的硬件优势来缩短垃圾收集停顿时间。可以使垃圾收集和用户程序并发进行。
  • 分代收集:不需要与其它垃圾收集器配合就能够独立管理整个 GC 堆,采用不同方式处理不同时期的对象。
  • 空间整合:从整体上来看是基于标记-整理算法实现的垃圾收集器;从局部来看是基于复制算法实现的。这意味着 G1 运行期间不会产生内存空间碎片
  • 可预测的停顿:除了追求低停顿外,G1 还能建立可预测的停顿时间模型,可以明确指定 M 毫秒时间片内,垃圾收集消耗的时间不超过 N 毫秒

G1 垃圾收集器的内存布局和其它收集器有很大差别,它将整个 Java 堆划分为多个大小相等的对立区域 Region,虽然还保留了新生代和老年代的概念,但是不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

G1 垃圾收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾回收。G1 会跟踪各个 Region 里垃圾堆积的价值大小(回收可获得空间与回收所需时间的经验值),并维护一个优先列表,每次根据允许的收集时间来优先回收价值最大的 Region。这种以 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

对于不同 Region 之间的对象引用(包括其它收集器中新生代和老年代之间的对象引用),虚拟机都是使用 Remember Set 来避免全堆扫描的, 当程序对 Reference 类型的数据进行写操作时,相关信息会被记录到被引用对象所属 RegionRemember Set 中,当垃圾回收时,在 GC 根节点加入 Remember Set 即可保证不对全堆进行扫描。

如果不计算维护 Remember Set 的操作,G1 收集器的运作大致可以划分为:

  • 初始标记,标记 GC Roots 能直接关联的对象。
  • 并发标记,从 GC Roots 开始对堆中的对象进行可达性分析,找出存活的对象。这一阶段与用户程序并发执行。
  • 最终标记,修正因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间的对象变化记录在线程 Remember Set Logs 中,最终标记阶段需要将 Remember Set Logs 中的数据合并到 Remember Set 中,这部分需要停顿线程,但是可以并行执行。
  • 筛选回收,对各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

# GC 日志

每个垃圾收集器对应的 GC 日志格式都可以不一样,但是他们都维持了一定的共性。

  • 最前面的数字代表 GC 发生的时间,即 JVM 启动以来经过的秒数。
  • [GC[Full GC 说明了垃圾收集的停顿类型,如果有 Full 表示发生了停顿。
  • [DefNew ([ParNew、[PSYoungGen])[Tenured[Perm 表示 GC 发生的区域,分别对应年轻代、老年代和永久代。
  • 方括号内部的 3324K->152k(3712k) 表示 GC 前该内存区域已使用的内存 -> GC 后该区域已使用的内存(该内存区域总容量)。
  • 方括号外部的 3324K->152k(11904k) 表示 GC 前堆已使用的内存 -> GC 后堆已使用的内存(堆总容量)
  • 0.0025925 secs 表示本次 GC 占用的时间。
  • 虚拟机提供 -XX:+PrintGCDetails 参数来在发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前内存各区域的分配情况。

# 内存分配

HotSpot一般的年代内存划分

对象的内存分配主要在堆上分配(JIT 编译优化后可能在栈上分配),新创建对象的内存在新生代的 Eden 区域分配,如果启用了本地线程分配缓冲,则线程优先在 TLAB 上分配。少数情况下对象会直接分配在老年代中。具体使用哪一种分配规则取决于当前使用的垃圾收集器组合和相关参数。

# 内存分配策略

# 优先分配到 Eden 空间

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 没有足够空间时虚拟机将会发起一次 Minor GC

# 大对象直接分配到老年代

所谓大对象是指需要大量连续内存空间的对象,程序中应避免频繁创建大对象,否则可能内存中还有不少空间时就提前触发垃圾收集来获取足够的连续空间。虚拟机提供参数 -XX:PretenureSizeThreshold 来设置直接分配到老年代的大对象的阈值。直接在老年代分配空间可以避免在 Eden 区和两个 Survivor 区发生大量的内存复制。

# 长期存活的对象分配到老年代

虚拟机给每个对象定义了一个对象年龄计数器,如果对象在 Eden 区出生且经过一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳,对象年龄就会被设为 1,此后对象每熬过一次 Minor GC,其年龄就会增加 1,当年龄增长到一定程度(默认为 15,可以通过 -XX:MaxTenuringThreshold 参数设置),就会晋升到老年代中。

# 动态对象年龄判断

Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 区的一半,则大于等于该年龄的对象可以直接进入老年代,无需超过 MaxTenuringThreshold 参数设置的值。

# 空间分配担保

Minor GC 前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,则 Minor GC 可以确保是安全的;如果不成立,虚拟机会检查 HandlePromotionFailure 参数查看是否允许担保失败,如果允许,则继续检查老年代最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果大于则尝试进行一次 Minor GC(如果某次 Minor GC 后存活的对象远高于历史平均值,担保失败,将触发一次 Full GC);如果小于,或者 HandlePromotionFailure 参数不允许担保失败,则需要改进行一次 Full GC

担保的风险在于每次 Minor GC 后可能仍存在大量存活的对象,Survivor 区可能无法容纳,则需要老年代进行分配担保,把 Survivor 区无法容纳的对象直接分配到老年代。前提是老年代还有容纳的剩余空间,如果空间不够则需要进行一次 Full GC 腾出更多的空间。大部分情况还是会将 HandlePromotionFailure 开关打开来避免 Full GC 过于频繁。

# 虚拟机性能监控和故障处理工具

# JDK 命令行工具

# jps

JVM Process Status Tool,显示指定系统内所有的 HotSpot 虚拟机进程。

  • 命令格式:jps [options] [hostid]
  • 命令选项:

jps工具选项

# jstat

JVM Statistics Monitoring Tool,用于收集 HotSpot 虚拟机各方面的运行数据。

  • 命令格式:jstat [option vmidL] [interval[s|ms] [count]]
  • 命令选项:

jstat工具选项

# jinfo

Configuration Info for Java,虚拟机配置信息。

  • 命令格式:jinfo [options] pid
  • 查询 CMSInitiatingOccupancyFraction 的参数值:jinfo -flag CMSInitiatingOccupancyFraction vmid

# jmap

Memory Map for Java,生成虚拟机的内存转储快照(heapdump 文件)。

  • 命令格式:jmap [option] vmid
  • 命令选项:

jmap工具选项

# jhat

JVM Heap Dump Browser,用于分析 heapdump 文件,它会建立一个服务器让用户在浏览器上看到分析结果。

# jstack

Stack Trace for Java,显示虚拟机的线程快照。

  • 命令格式:jstack [option] vmid

jstack工具选项