Java内存模型和垃圾回收

“Java 与 C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。”

运行时数据区域

按照 java 虚拟机规范,抽象的 Java 虚拟机如下图所示:

程序计数器

每条线程都有一个独立的程序计数器,用于记录当前线程所执行的字节码行号。如果执行的是 java 方法,计数器记录的是虚拟机字节码指令的地址,如果是本地方法,则计数器值为空。

Java 虚拟机栈

Java 虚拟机栈也是线程私有的,和线程的生命周期相同。

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时会创建一个栈帧,用于存储局部变量表操作数栈动态链接方法出口等信息。

每一个栈帧在虚拟机中入栈到出栈的过程,对应了一个方法从调用到执行完成的过程。当进入一个方法时,这个方法需要在帧中分配多大的局部变量表是完全确定的,在方法运行期间局部变量表的大小不会改变。

其中局部变量表是我们最为关注的部分,他存放了编译期可知的 8 种基本类型数据对象引用returnAddress类型。

本地方法栈

本地方法栈和虚拟机栈作用类似,不过是为虚拟机要使用的本地方法提供服务。

Java 堆

Java 堆是 Java 虚拟机管理的内存中最大的一部分。他是被所有线程共享的一块内存区域,在虚拟机启动时创建。Java 堆的目的是存放对象实例,基本上所有的对象数组都需要在堆上进行分配。

Java 堆也是垃圾收集器管理的主要区域。

方法区

方法区也是被各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载。

运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量符号引用

而运行时常量池相对于 Class 文件常量池的特征是具备动态性,只有没预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,比如 String 的 intern()方法。

对象

对象的创建

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

在类加载检查通过后,虚拟机会为新生对象分配内存,分配方式一般有两种:指针碰撞和空闲列表。当 Java 堆中的内存规整时,直接把指针挪动对象大小的距离即可,即指针碰撞;如果 Java 堆中的内存不规整,需要维护一个记录哪些内存可用的列表,分配时从列表中给对象分配空间,即空闲列表。

内存分配完成后,虚拟机需要将分配的内存空间初始化为零值

然后,虚拟机要对对象进行必要的设置。将在对象头中设置对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。

最后,会执行对象的<init>方法,把对象按照程序员的意愿进行初始化。

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头实例数据对齐填充

对象头包括两部分信息:

  • 第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志线程持有的锁、偏向线程 ID、偏向时间戳等。

  • 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,HotSpot 分配策略中,相同宽度的字段总是被分配到一起,满足这个条件的前提下,在父类中定义的变量会出现在子类之前。

对齐填充是非必须的,仅仅起到占位符的作用,由于 HotSpot 虚拟机的自动内存管理系统要求对象的起始地址必须是 8 字节的整数倍,所以当对象实例数据部分没有对齐时,需要通过对齐填充来补全。

对象的访问定位

主流的访问方式有两种:句柄直接指针两种。

句柄:Java 堆中会划分出一块内存来作为句柄池,reference 中存储的是对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体信息。优点是当对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要改变。

直接指针:Java 堆对象的布局中放置了访问类型数据的相关信息,reference 中存储的是对象地址。优点是速度更快,节省了一次指针定位的开销。

HotSpot 使用直接指针的方式进行对象访问

垃圾收集器与内存分配策略

第一节我们提到,程序计数器、虚拟机栈、本地方法栈三个区域随线程生,随线程灭,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。而 Java 堆和方法区不同,我们只有在程序运行期间才能确定会创建那些对象,这部分的内存分配和回收都是动态的,垃圾回收时主要关注的是这部分的内存。

判断对象是否存活

垃圾收集器进行垃圾回收前,首先需要判断那些对象还是存活的。

引用计数法

给对象添加一个引用计数器,每当有引用时计数器加 1,引用失效时计数器减 1,计数器为 0 的对象就是“垃圾对象”。

优点:实现简单,判定效率高。

缺点:很难解决对象之间的循环引用问题。

可达性分析法

通过一系列被称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到 GC Root 没有任何引用链相连时,证明此对象是“垃圾对象”。

Java 语言中,可作为 GC Roots 的对象有:

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

#### 对象的自我拯救

对象可以通过覆盖 finalize()方法,在其中和引用链上的任何一个对象建立关联,逃过一次垃圾回收,但因为每个对象的 finalize()方法只会被系统自动调用一次,所以对象最多通过这种方式逃过一次垃圾回收。不过这种方式并不被推荐使用。

回收方法区

方法区的垃圾回收主要有:废弃常量无用的类

当一个常量池中的常量(字面量和符号引用)没有被在任何地方被引用,且发生了内存回收的话,这个常量就会被清理出常量池。

无用的类:

  • 该类的所有实例都已经被回收
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问

垃圾回收算法

G1 之前的垃圾回收算法,将堆划分为如下结构:

  • 新生代:eden space + 2 个 survivor
  • 老年代:old space
  • 永久代:1.8 之前的 perm space
  • 元空间:1.8 之后的 metaspace
标记清除(Mark-Sweep)算法

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:效率较低,标记清除后会产生大量不连续的内存碎片

复制(Copying)算法

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

优点:实现简单,运行高效缺点:讲内存缩小为原来的一半,过于浪费空间

IBM 研究表明,新生代中的对象 98%都是“朝生夕死”的,所以不需要按照 1:1 来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是8:1,所以只有 10%的空间会被“浪费”,可以通过-XX:SurvivorRatio 参数调整这个比例。

复制算法在对象存活率较高时,需要进行较多的复制操作,而且需要额外的空间进行分配担保,所以老年代一般不能直接选用这种算法。

标记整理(Mark-Compact)算法

首先对存活的对象进行标记,然后将所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

垃圾收集器

收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。HotSpot 包含的垃圾收集器如图所示:

HotSpot虚拟机的垃圾收集器

到目前为止,没有最好的收集器,我们应该针对具体的使用场景选择最合适的垃圾收集器。

Serial 收集器

这个收集器是一个单线程收集器,使用复制算法,他会在进行垃圾收集时,暂停所有其他的线程,直到收集结束。其运行过程如下:

由于其简单而高效(与其他收集器的单线程相比,没有线程交互的开销),他依然是虚拟机运行在 Client 模式下的默认新生代收集器。

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,使用复制算法,工作过程如下:

他是运行在 Server 模式下的虚拟机中首选的新生代收集器,其中一个很重要的原因是只有 Serial 收集器和 ParNew 收集器可以和 CMS 收集器配合工作。

ParNew 默认开启的收集线程数与 CPU 数量相同,在 CPU 很多的环境下,可以使用-XX:ParallelGCThreads 参数来限制垃圾回收的线程数。

Parallel Scavenge 收集器

此收集器也是使用复制算法的收集器,但他的关注点是达到可控制的吞吐量,所以也被称为”吞吐量优先“收集器。

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

此收集器比较适用于需要与用户交互的程序和后台运算较多的程序。

它提供了两个参数用于精确控制吞吐量:

  • 最大垃圾收集停顿时间: -XX:MaxGCPauseMillis,参数值是一个大于 0 的毫秒数
  • 设置吞吐量大小: -XX:GCTimeRatio,参数值一个[0, 100)的整数,也就是垃圾收集时间占总时间的比率,1 / (1 + N),默认值是 99

此收集器还有一个参数-XX:+UseAdaptiveSizePolicy,开启后虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整参数以提供最合适的停顿时间或最大的吞吐量,被称为GC自适应的调节策略

Serial Old 收集器

此收集器是 Serial 收集器的老年代版本,使用单线程和”标记整理“算法,主要用于 Client 模式的虚拟机。

如果在 Server 模式下,还有两大用处:

  • 在 JDK 1.5 之前与 Parallel Scavenge 收集器搭配使用
  • 作为 CMS 收集器的后备预案
Parallel Old 收集器

此收集器是 Parallel Scavenge 的老年代版本,使用多线程和”标记整理“算法。

在注重吞吐量 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器,工作过程如下:

CMS(Concurrent Mark Sweep)收集器

此收集器是一种以获取最短回收停顿时间为目标的收集器,使用”标记清除“算法。

过程分为四步:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记和重新标记还是需要”Stop The World“。

初始标记仅仅标记可以和 GC Roots 关联到的对象

并发标记进行 GC Roots Tracing

重新标记用于修正并发标记期间因为用户程序继续运行导致标记产生变动的对象的标记记录

整个回收过程中,耗时最长的是并发标记和并发清除过程,但这两个过程都是可以和用户线程一起工作的,所以从总体上说,CMS 收集器的内存回收过程和用户线程是并发执行的。

优点: 并发手机、低停顿

缺点:

  • 对 CPU 资源非常敏感,并发阶段会导致总吞吐量降低
  • 无法处理浮动垃圾,需要预留一部分空间提供给并发收集时的程序运作,如果 CMS 运行期间预留的内存无法满足程序需要,会触发一次”Concurrent Mode Failure“失败,临时启用 Serial Old 收集器。
  • 会产生大量的内存碎片,提供-XX:+UseCMSCompactAtFullCollection 参数(默认开启),用于要进行 FullGC 时开启内存碎片的合并整理过程,还提供了一个-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的 FullGC 后,再进行一次带压缩的(默认值为 0)
G1 收集器

此收集器是一款面向服务端应用的垃圾收集器,其特点有:

  • 并行与并发,使用多核减少 STW 停顿时间,GC 动作通过并发方式让 Java 程序继续执行
  • 分代收集
  • 空间整合,整体是基于”标记整理“算法实现的,局部是基于”复制“算法实现的,不会产生内存空间碎片
  • 可预测的停顿,可以指定在长度为 M 毫秒的时间片断内,垃圾收集时间不超过 N 毫秒

G1 收集器将整个 Java 堆划分为多个大小相等的独立区域(Region),并跟踪各个 Region 里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的时间,优先回收价值最大的 Region。每个 Region 是逻辑上连续的一段内存。结构如下:

其中当新建对象大小超过 Region 大小一半时,会直接在一个或多个新的连续 Region 中分配此对象,并标记为 Humongous 对象。

Region

Region 的大小为 1M——32M 的 2 的 N 次幂,默认数量为 2048 个,如果 G1HeapRegionSize 为默认值,则会在堆初始化时计算 Region 的实际大小。

在 G1 收集器中,垃圾回收只回收一部分 Region,所以回收时需要知道 Region 之间的对象引用,在使用复制算法移动对象时,需要更新引用为对象的新地址。这种分代收集中,年轻代垃圾收集时,需要老年代到年轻代的引用记录,通常称为 Remembered Set。当虚拟机发现程序在对 Reference 类型的数据进行写操作时,会场生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之间,如果是,则通过 CardTable 讲相关引用信息记录到被引用对象所属 Region 的 Remembered Set 中。

GC 模式

G1 中一共有三种垃圾回收的模式:Young GC、Mixed GC 和 Full GC。

Young GC

对象优先在 Eden Region 中进行分配,当所有 Eden Region 被耗尽时,会触发一次 Young GC,存活的对象会被复制到 Survivor Region 中,空闲的 Region 被放入空闲列表中

Mixed GC

当越来越多的对象进入 Old Region 时,虚拟机会触发一次 Mixed GC,回收整个 Young Region 和部分Old Region,触发时机通过-XX:InitiatingHeapOccupancyPercent=N,则当老年代大小占整个堆的 N%时,会触发一次 Mixed GC,过程类似于 CMS

Full GC

如果对象内存分配速度过快,Mixed GC 来不及回收导致老年代被填满,会触发一次 Full GC,使用 Serial Old 方式进行垃圾回收

G1 工作过程

G1 的工作过程如下:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

初始标记阶段仅仅只是标记一下 GC Roots 能够直接关联的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段的用户程序并发运行的时候,能在正确可用的 Region 中创建新对象,这个阶段需要暂停线程。并发标记阶段从 GC Roots 进行可达性分析,找出存活的对象,与用户线程并发执行。最终标记阶段则是修正在并发标记阶段因为用户程序的并发执行而导致标记产生变动的那一部分记录,这部分记录被保存在 Remembered Set Logs 中,最终标记阶段再把 Logs 中的记录合并到 Remembered Set 中,这个阶段是并行执行的,需要暂停用户线程。最后在筛选阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间制定回收计划。

内存分配与回收策略

  • 对象优先在 Eden 分配

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

  • 大对象直接进入老年代

所谓大对象,最典型的就是长字符串和数组。

  • 长期存活的对象进入老年代

对象晋升到老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold 设置,默认为 15 岁

  • 动态对象年龄判断

如果在 Survivor 空间中相同年龄的所有对象大小的总和超过 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

参考

1、周志明,深入理解 Java 虚拟机:JVM 高级特性与最佳实践,机械工业出版社

2、占小狼,G1 垃圾收集器介绍