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

何言 2022年03月10日 497次浏览

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

Java 运行时数据区

运行时数据区中,虚拟机栈、本地方法栈、程序计数器为线程私有,一个线程一个。

image20220309203347845.png

虚拟机栈

每个线程执行的时候存放栈帧的栈。栈帧中有局部变量表来存储局部变量。局部变量表以槽(Slot)的形式存储。其中 long 和 double 类型占用两个槽,其他类型占用一个,每个槽占用多大内存由虚拟机决定。而 reference 类型有两种形式,可能是存放句柄地址最终由句柄指向堆中的对象地址,或者直接存放堆中对象地址(由虚拟机决定)。

本地方法栈

和虚拟机栈类似,只不过是执行本地方法(Native Function)所用的栈。

Java 堆

所有线程共享,存放对象实例。是垃圾收集器管理的内存区域。

方法区

与 Java 堆类似也是线程共享的区域。该区域主要存放已被虚拟机加载的类型信息(Class)、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java 虚拟机规范》将其称为 非堆 。

直接内存

不属于虚拟机运行时数据区的一部分,《Java 虚拟机规范》中也没有定义。主要是在 JDK 1.4 中新加入的 NIO 类,引入了基于 Channel 与 Buffer 的 I/O 方式,其中可以使用 Native 函数库直接分配堆外内存,然后通过对象进行操作。因为是直接使用 Native 方法分配,因此不受 Java 虚拟机限制,但是会直接受操作系统的限制,例如 进程最大内存等。

对象创建

对象的创建首先会检查类是否被加载。当类加载后需要在 Java 堆中分配出对应内存,假设 堆区是规整的,则直接使用指针碰撞。如果不规整,则需要维护空闲列表。

对于空间分配时的并发问题,有两种解决方案,一是使用 CAS 操作更新指针,二是使用分配缓冲(TLAB),每个线程都预先分配一个区域。当缓冲用完了才在整个 Java 堆分配,此时需要同步锁。

分配完后,需要进行数据填充,首先将数据清零(如果在 TLAB 中分配,则跳过,因为在最初分配缓冲时已经清零),然后填充对象头,实例数据等信息。

对象的内存布局

对象在堆中分为三部分,对象头、实例数据和对齐填充。

对象头分类两个部分,MarkWord(可参考并发部分,占空间为操作系统的位数) 与 类型指针(指向 非堆中 被夹在的类对象,有的虚拟机可能没有该数据)

实例数据则为成员变量的数据。

因为 HotSpot 规定对象占内存必须为 8 的倍数,因此需要在之后填充 0 来达到 8 的倍数。

对象的访问定位

即 reference 类型的变量的值的含义,有两种选择(由虚拟机决定):

image20220309213934267.png

image20220309213942930.png

HostSpot 使用直接指针访问对象,即在对象文件头中直接填充指向对象类型数据的指针。

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

判断对象已死

引用计数法

直接记录被引用的数量,引用计数器为 0 则死。缺陷:循环引用

可达性分析

image20220309215032588.png

在 Java 中,GC Roots 有以下对象:

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

引用

强引用、软引用、弱引用和虚引用,(直接看 Java 八股文部分)

Finalize 方法

在对象第一次判断为死亡后,虚拟机会调用该对象的 finalize 方法,给其一个自我拯救的机会,如果调用后该对象被 GC Roots 强引用(间接或直接),则本次不会回收。该方法永远只可能调用一次以下。

方法区回收

该区主要回收两部分,常量池与类型对象。

常量池中没有被任何一个其他对象引用时则判断可回收。

类型对象的可回收判断需要判断以下条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

可回收对象是否需要回收由虚拟机决定,或者虚拟机提供参数控制。

垃圾收集算法

分为引用计数式回收与追踪式回收。主流 Java 虚拟机大部分都是用追踪式回收。之后内容都是追踪式。

分代收集理论

  1. 弱分代假说:绝大多数对象都是朝生夕灭
  2. 强分代假说:熬过越多次收集的对象越难消亡
  3. 跨代引用假说:跨代引用相较于同代引用来说仅占极少数(由前两条假说逻辑推理得出的隐含推论)

分代收集中的各种定义

  1. 部分收集(Partial GC):目标不是完整收集整个 Java 堆的垃圾收集,分为:
    • 新生代收集(Minor GC / Young GC)
    • 老年代收集 (Major GC / Old GC):只收集老年代,目前只有 CMS 收集器存在。同时有些材料中 Major GC 指 整堆收集
    • 混合收集(Mixed GC):目前只有 G1 收集器会有这种行为
  2. 整堆收集(Full GC):收集整个 Java 堆和 方法区

标记-清除法

image20220310143709138.png

扫描一遍(有可能是可达性分析),标记存活对象,然后直接清除。

标记-复制法

image20220310143910418.png

半区复制:只用一半内存,回收时扫描一遍,标记存活对象,将存活对象复制到另一半,把原本一半直接清除。

Appel 式回收:分为三个区,Eden 和两个 Survivor 区,比例为 8:1:1 每次只使用 Eden 和一个 Survivor,回收时扫描一遍,标记存活对象,将存活对象复制到另一个 Survivor,然后把原本 Survivor 和 Eden 进行清除,之后只使用 Eden 和存放存活对象的这个 Survivor。需要老年代进行 分配担保。

标记-整理法

回收时扫描一遍,标记出存活对象,让他们往一边移动,然后清除边界以外的区域。

优点:清除内存碎片

缺点:需要 STW(实际上所有方法在标记时都需要),耗时

image20220310144358774.png

虚拟机在选择方法中可灵活,例如 CMS 收集器在空间碎片较少时采用其他方法,而在碎片无法容忍时采用标记-整理法。

HotSpot 实现

GC Roots 枚举

迄今为止,所有垃圾收集器在枚举 GC Roots 这一步都是需要 STW 的。

实际上,HotSpot 并不需要直接从 GC Roots 开始全局扫描。在 HotSpot 中,使用一组称为 OopMap 的数据结构来存储哪些地方存放着对象引用。在线程执行时,会在在特定的时间在特定的位置记录下当前栈中的引用。这个特定的时间,称为程序安全点。

安全点

如果为每条指令都添加 OopMap 更新指令,则会导致资源浪费,效率也会下降。因此 HotSpot 只会在特定位置生成更新指令,生成的位置则为 安全点(Safe point)。

因此当收集开始时无法直接 STW 整个程序,而是需要等待所有线程执行到线程安全点之后在挂起。(实际上,在偏向锁的相关实现中也使用了该安全点)

安全点的位置一般为方法调用、循环跳转、异常跳转等。

线程挂起有两种方式,抢占式与主动式,现在一般都采用主动式,主动式为将线程挂起标记设定为真,然后等待线程执行到安全点时,手动将自己挂起。

安全区域

安全区域看做拉伸后的安全点,线程进入安全区域时会标记,收集器在枚举 GC Roots 时不会管处于安全区域的线程。在线程离开安全区域时,需要判断此时是否处于枚举 GC Roots。如果处于则会等待其结束后才能出安全区域。

记忆集与卡表

在部分收集中,我们需要使用记忆集。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

记忆集有自己的记忆精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡精度的记忆集称为卡表。

使用卡表的收集器在收集时,需要把标记为有跨代指针的区域(脏区域)中的对象加入 GC Roots 一并扫描。

写屏障

HotSpot 里使用 写屏障(与 Volatile 使用的内存屏障不是一个东西) 维护卡表状态。在 引用类型字段赋值 指令构造一个切片,在在之后进行更新卡表的操作。

伪共享:如果并发中,两个线程同时将一个卡表变脏,则会因为处于同一个缓存行而降低性能。解决方案为先检查卡表标记,只将未变脏的卡表执行变脏操作。

并发可达性分析

在 GC Roots 遍历完成后,接下来进行可达性分析。可达性分析是支持并发操作。

这里使用三色标记法进行分析:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

image20220310153209163.png

image20220310153216504.png

在以下场景时,会出现 对象消失 问题(本来应该活着的对象被标记死亡)

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

两种解决方法,分别对应破坏两种调校:

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来 进行搜索。

经典垃圾收集器

image20220310154751639.png

上面区域为新生代,下面为整堆。中间有连线代表配套使用。包括官方 JDK 版本提供的组合。

Serial 收集器

image20220310155009361.png

单线程,STW。

内存消耗最小,对于单核(或少核)处理器效率较高。

ParNew 收集器

Serial 的多线程版本,在回收时开了多个 GC 线程,其他没有过多差别、

image20220310155354616.png

目前新生代有一些虚拟机选择该收集器,因为除了Serial收集器外,目前只有它能与CMS 收集器配合工作。

CMS 是器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次 实现了让垃圾收集线程与用户线程(基本上)同时工作。(不用 STW)

收集器的并行和并发的概念:

  • 并行:并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线 程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发:并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾 收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于 垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Parallel Scavenge 收集器

该收集器目标为达到一个可控的吞吐量:

image20220310160130454.png

Serial Old 收集器

Serial 的老年代版本,与新生代一致

image20220310160519572.png

Parallel Old 收集器

Parallel Scavenge 的老年代版本,支持并发,基于标记-整理法。

image20220310160910778.png

如果新生代选择 Parallel Scavenge 控制吞吐量,则老年代这几乎是唯一选择。

CMS 收集器

以获取最短回收停顿时间为目标

分四个步骤:

  1. 初始标记,遍历 GC Roots
  2. 并发标记,可达性分析
  3. 重新标记,增量更新
  4. 并发清除,清除

其中 13 需要 STW。

image20220310162448221.png

Garbage First 收集器(G1)

没有固定新老生代,使用区域替代,每个区域可能是原本的老年代或新生代。