JVM 全局构架图如下,包含 JVM 的全部内容

JVM-全局结构图

1. JVM 的内存区域是如何划分的?

JVM 内存当中,运行时数据的区域包含堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器。另外,其他的内存区域属于本地内存,本地内存就包括直接内存,直接内存是非运行时数据区的一部分。 其中,堆和方法区(元空间) 是线程共享的,虚拟机栈、本地方法栈、程序计数器都是线程私有的。

【注意】 JVM 规范对于运行时数据区域的规定是很宽松的。就拿堆来说,堆可以是连续空间,也可以是不连续空间。堆的大小可以固定,也可以再运行时按照需求进行扩展。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾回收也是可以的。

JVM内存区域

下面逐一介绍不同部分的功能和作用

首先是线程私有的部分,程序计数器虚拟机栈本地方法栈

【程序计数器】

  • 定义:程序计数器就是记录当前所执行的字节码的行号。字节码解释器通过改变程序计数器的值,来选取下一条需要执行的字节码指令。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • 作用:分支、循环、跳转、异常处理、线程恢复这些功能都需要依赖程序计数器完成。比如线程切换上下文中,从 A线程 先切换到 B线程,然后从 B线程 恢复到 A线程 的过程当中,为了 A线程 能够恢复到正确的执行位置,就需要读取 A线程 程序计数器的值,来确认切换线程前执行的位置在哪里。
  • 为什么是线程私有:因为线程切换的过程当中,每个线程都需要一个程序计数器来记录自己的程序执行位置。

【注意】 程序计数器是唯一一个不会出现 OOM 内存不足的内存区域,因为他就只是存储一个值。

【虚拟机栈】

  • 定义:虚拟机栈按照先进后出的方案存储的是非本地方法的调用对应的栈帧。每一次方法调用的时候会被压入栈中,方法结束的时候会被弹出栈中。栈帧中包含局部变量表操作次数栈(存储操作数和临时计算结果)、动态链接方法的返回地址。虚拟机的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • 作用:存储非本地方法的栈帧,支持方法的调用和返回。当栈深度超过虚拟机允许的最大深度,会抛出 StackOverflowError 栈溢出异常。如果虚拟机栈无法动态扩展或申请到足够的内存,会抛出 OutOfMemoryError 内存不足异常。

JVM-虚拟机栈-完整结构

栈帧中包含局部变量表操作次数栈(存储操作数和临时变量)、动态链接方法的返回地址。其中,局部变量表存放的是所有的局部变量,或者其出对应的地址(数组)。动态链接就是当前类常量池的引用。

  • 局部变量表:存放方法参数传入的形参值方法内的局部变量方法的 this 引用。内部结构主要存放了编译期可知的各种数据类型(booleanbytecharshortintfloatlongdouble)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针)
  • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
  • 动态链接:主要作用是实现在当前方法中调用其他方法Class 文件的常量池里面保存有大量的符号引用,比如方法的符号引用。 当一个方法需要调用其他方法,需要将常量池中只想方法的符号引用转换为在内存地址当中的直接引用。动态链接的作用就是把常量池的符号引用转换为内存当中的直接引用

【本地方法栈】

  • 定义:本地方法栈和虚拟栈的结构是一样的。不同的是,本地方法栈是 JVM 调用 Native 方法的时候才会用到的,虚拟机栈是执行 Java 自身的方法 (也就是字节码) 会用到的。
  • 作用:存储本地方法 (Native) 栈帧,支持方法的调用和返回。

【注意】HotSpot 虚拟机当中,将本地方法栈和虚拟机栈合二为一了。

其次,我们将介绍线程共享的部分,方法区 (JDK 1.8叫元空间)

【堆】

  • 定义:堆当中存储了所有的对象实例数组。堆是 JVM 内存区域当中最大的一块,所有线程均可共享。另外,JVM 的堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)
  • 堆结构:从垃圾回收的角度,堆可以分为新生代老年代永久代。其中,新生代当中还包括了一个 Eden 伊甸园区 和 两个 Survivor 存活区 (S0S1) 。在 JDK 1.8 当中,永久代已经被元空间取代了,而且元空间用得是本地内存。

JVM-堆结构

  • 不同代的区别:大部分情况下,对象会分先分到新生代的 Eden 区域,在一次新生代垃圾回收之后,如果对象还存货,则进入 Survivor 区中的 s0 或者 s1。同时,对象的年龄还会增加 1 (从 Eden 区到 Survior 区后,对象的初始年龄变为 1)。当它的年龄达到一定阈值 (默认为 15 岁),就会从新生代晋升到老年代。年龄的阈值可以通过 XX:MaxTenuringThreshold 来设置,但是设置的值必须在 0 ~ 15 之间,不然会报错。
  • 为什么年龄只能是 0-15?: 因为记录对象年龄的区域对象头中,这个区域的大小通常是 4。这 4 位可以表示的最大二进制数字是 1111 ,即十进制的 15 。因此,对象的年龄被限制为 0 ~ 15。 在Hotspot 虚拟机中,遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 Survivor 区的一半时,取这个年龄和 MaxTenuringThreshold更小的一个值,作为新的晋升年龄阈值

【注意】 JVM 的堆 最容易出现的就是 OutOfMemoryError 内存不足错误,并且出现这种错误之后的表现形式还会有好几种。比如 java.lang.OutOfMemoryError: GC Overhead Limit Exceeded 说明 JVM 花太多时间 ( 98% 的时间) 进行垃圾回收并且只回收了很少的垃圾 (2% 的垃圾); java.lang.OutOfMemoryError: Java heap space 是在创建新对象的时候,堆内存中空间不足,不能再存放新创建的对象了。

【方法区】

  • 定义:当虚拟机要使用一个类的时候,它需要读取并解析 Class 文件并获取相关信息,再将信息存入方法区。方法区存储被 JVM 加载的 类信息字段信息方法信息常量静态变量JIT 即时编译器编译后的代码缓存等数据

  • 方法区和永久代以及元空间是什么关系?:方法区和永久代以及元空间的关系,特别像 Java 当中的接口和类的关系,类实现了接口。类就相当于永久代或者元空间,接口就是方法区。也就是说永久代或者元空间是 HotSpot 虚拟机对方法区的两种实现方式。 JDK 1.7 是 永久代, JDK 1.8 之后是方法区。

    方法区-永久代和元空间

  • 为什么JDK 1.8 采用元空间替换永久代?

    • 无法动态调整大小,容易内存溢出:整个永久代的大小受 JVM 内存大小的限制,而元空间用的是本地内存,元空间的大小和本机可用内存大小相关。虽然元空间还是可能出现内存溢出,但是比原来的几率更小。

      可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则元空间将根据运行时的应用程序需求动态地重新调整大小。

    • 元空间加载的类更多:元空间存放的是类的元数据,所以加载类的元数据不再受 MaxPermSize 控制 (永久代的最大阈值) ,而是由系统的实际可用空间来进行控制的,这样加载的类就更多了。

    • 永久代 GC 复杂度更高:永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

  • 方法区常用参数有哪些?: 可以分为 JDK 1.7JDK 1.8 来看

    • JDK 1.7: 采用永久代,常用下面的参数

      -XX:PermSize=N //方法区 (永久代) 初始大小
      -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
      
    • JDK 1.8:采用元空间,常用下面的参数

      -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
      -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小, 默认值为 unlimited
      

下面将对运行时常量池 (方法区)、字符串常量池 (堆)、直接内存进行介绍

【运行时常量池】

运行时常量池是用来存放编译期间生成的各种字面量符号引用的常量池表。这个常量池表会在类加载之后,存放到方法区的运行时常量池当中。

  • 字面量:顾名思义,包括整数、浮点数、字符串等字面量
  • 符号引用:类符号引用、字段符号引用、方法符号引用、接口方法符号

【字符串常量池】

字符串常量池是 JVM 为了提升性能和减少内存消耗专门针对字符串 String 类开辟的一块区域,主要是用来避免字符串的重复创建。

// 在字符串常量池中创建字符串对象 "ab"
// 将字符串对象 "ab" 的引用赋值给给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象"ab",赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true, aa和bb都是用的字符串常量池中的"ab"

JDK 1.6JDK 1.7 中,字符串常量池的存放位置不一致。在 JDK 1.6 中和 运行时常量池一样,存放在方法区里面。在 JDK 1.7 中,存放在堆里面。那么 JDK 1.7 为什么要将字符串常量池移动到堆中呢? 主要是因为永久代 (方法区) 的 GC 回收效率太低, 只有在整堆收集 Full GC 的时候,才会被执行 GCJava 程序中通常会有大量的被创建的字符串等待回收,将这些字符串常量池存放在堆中,能够更高效及时地回收字符串内存。

方法区-结构图-jdk1.6和jdk1.7对比

【直接内存】

直接内存是一种特殊的内存缓冲区,并不属于 JVM 当中的堆或者方法区,而是通过 JNI (Java Native Interface)Java 本地方法接口的方式在本地内存上直接分配的一块区域。直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

2. HotSpot 对象的创建过程

HotSpot 虚拟机当中,对象的创建过程如下:

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

  2. 分配内存:在类加载检查通过后,接下来 JVM 将为新生对象分配内存。对象所需的内存大小类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

    • 指针碰撞

      • 适用场合:**堆内存规整(即没有内存碎片)**的情况下。
      • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
      • 使用该分配方式的 GC 收集器:Serial, ParNew
    • 空闲列表

      • 适用场合:堆内存不规整的情况下。
      • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
      • 使用该分配方式的 GC 收集器:CMS
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值直接使用,程序能访问到这些字段的数据类型所对应的零值。

  4. 设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例如何才能找到类的元数据信息对象的哈希值对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  5. 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

【对象的内存布局】

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、**实例数据(Instance Data)**和 对齐填充(Padding)

  • 对象头: 对象头主要包含标记字段类型指针两个部分

    • 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
    • 类型指针(Klass pointer):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 实例数据:实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

  • 对齐填充:对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全.

Java对象内存布局和数据布局-完整图

【对象的访问方式】

对象的访问方式主要有两种:使用句柄 或者 直接指针

  • 句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据对象类型数据各自的具体地址信息
  • 直接指针:如果使用直接指针访问,reference 中存储的直接就是对象的地址。

对象访问

3. 说说 Java 的执行流程?

Java 程序的执行流程,可以分成三步。首先,从编译到字节码的生成。其次,再到类加载和 JIT 即时编译器的编译过程。最终,在 JVM 当中执行。 程序在运行的过程中,JVM 负责内存管理、垃圾回收和线程调度等工作。

具体的执行流程如下:

  1. 编写源代码:程序员编写 .java 源代码文件
  2. 编译器编译源代码javac 编译器编译 .java 源代码,生成 .class 字节码文件
  3. 类加载JVM 的类加载器加载 .class 文件到内存里面
  4. 解释执行JVM 将加载后的 .class 文件的字节码转换为及其码执行
  5. JIT 即时编译器编译:在解释执行过程中,JVM 会通过 热点探测技术(如方法调用计数器、回边计数器)识别频繁执行的代码(热点代码)。当某段代码的调用次数超过阈值(例如 Server 模式下的 10000 次),JIT 编译器会将其编译为本地机器码,并缓存到方法区的 Code Cache 中。其他代码仍由解释器执行。
  6. 运行程序:执行 main 方法中的逻辑
  7. 垃圾回收JVM 管理内存,回收不再使用的对象
  8. 程序执行结束main 方法结束,JVM 清理资源,退出程序

4. Java中常见的垃圾收集器有哪些?

Java 当中常见的垃圾收集器可以分为两种,新生代垃圾收集器老年代垃圾收集器

  1. 新生代垃圾收集器:新生代垃圾收集器都采用 “标记-复制” 的垃圾回收算法

    • Serial 收集器Serial 收集器是一种串行的单线程收集器,适合小型应用和单处理器的环境。收集的过程中,会触发 Stop-The-World (STW) 操作,所有应用线程在 GC 垃圾收集的时候会被暂停。

      Serial垃圾收集器结构

    • ParNew Parallel New 收集器ParNew 收集器就是 Serial 收集器的并行多线程版本,能够并行地进行垃圾收集。和 CMS 收集器进行配合使用 (CMS 收集器用来收集老年代的垃圾,ParNew 收集新生代的垃圾)。一般适用于多处理器的环境。

      ParNew垃圾收集器结构

    • Parallel Scavenge 并行清除垃圾收集器Parallel Scavenge 也被称为 吞吐量收集器, 基于标记-复制垃圾回收算法实现的,主要侧重于最大化 CPU 时间的利用率。并行处理新生代垃圾回收,适合大规模运算密集型的后台任务,以及对吞吐量要求较高的场景。

      -XX:+UseParallelGC //使用 Parallel 收集器+ 老年代串行
      -XX:+UseParallelOldGC //使用 Parallel 收集器+ 老年代并行
      

      ParallelScavenge垃圾收集器结构

    【注意】 ParNewParallel Scavenge 垃圾收集器的区别在于 ParNew 垃圾收集器通常配合 CMS 收集器进行使用,主要侧重于控制 GC 所导致的 STOP-THE-WORLD (STW) 操作的暂停时间,但是可能 GC 的次数更加频繁。Parallel Scavenge 收集器主要侧重吞吐量的大小,支持自适应调节(比如最大的 GC 暂停时间 MaxGCPauseMillisGC 时间比例 GCTimeRatio),但是可能牺牲部分响应时间 (STW过程) 。

  2. 老年代垃圾收集器:大部分老年代垃圾收集器采用的**“标记-整理”** 垃圾回收算法,部分收集器 (比如 CMS 收集器) 采用的**“标记-清除”**

    • Serial Old 收集器Serial Old 其实就是 Serial 收集器的老年代版本,采用标记-整理 算法进行垃圾回收。适合单线程环境低内存使用场景
    • Parallel Old 收集器Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程并行标记-整理算法。适合大规模并行计算的场景,适用于高吞吐量要求的任务。
    • CMS Concurrent Mark-Sweep 收集器CMS 收集器采用的并发标记-清除垃圾收集算法,追求低延迟,减少 GC 停顿时间 (STW 过程)。但是这个垃圾收集的过程中,可能会产生内存脆片,并且在并发阶段可能会出现 Concurrent Mode Failure, 导致 Full GC。适用于对响应时间要求高的应用,比如 Web 服务和电商平台
    • G1 Garbage First 收集器:主要用来取代 CMS 的地延迟垃圾收集器,能够提供可预测的停顿时间, 采用并发的标记-整理算法。通过分区来管理内存,并在垃圾收集的时候,优先处理最有价值的区域,避免了 CMS 内存碎片问题。适用于大内存、多 CPU 服务器,后期是在延迟和响应时间铭感的场景。
    • ZGC Z Garbage Collector 收集器ZGC 是一种低停顿 (STW操作)、高吞吐量的垃圾收集器,停顿时间一般不会超过 10 ms。适用于需要管理大堆内存且对低延迟要求极高的应用。

    JVM垃圾收集器分类

【详解 CMSG1 垃圾收集器】

  • CMS Concurrent Mark Sweep垃圾收集器:CMS 是一种以获取最短回收停顿时间 (SWT操作) 为目标的收集器,基于 标记-清除 算法实现,整个收集过程分为下面四个阶段:

    1. 初始标记:标记 GC Roots 能直接关联到的对象 (可达性分析),耗时短但需要暂停用户线程。
    2. 并发标记:从 GC Roots 能直接关联到的对象开始遍历整个对象图 (就是从所有可达的对象开始,遍历所有的对象),耗时长但是不需要暂停用户线程。
    3. 重新标记:采用增量更新算法 (就是不从头开始标记),对并发标记阶段因为用户线程运行而产生变动的那部分对象进行重新标记,耗时比初始标记会稍微长一点,需要暂停线程。(相当于对并发标记做一次兜底操作)
    4. 并发清除:并发清除掉已经死亡的对象,耗时长但不需要暂停用户线程。

    CMS 的优点在于耗时长并发标记并发清除 阶段都不需要暂停用户县城,因此SWT操作的时间就比较短。但是它的缺点也很明显:

    • 内存碎片:由于 CMS 收集器采用 标记-清除 算法实现,因此会产生大量空间碎片。

    • 吞吐量下降:由于 CMS 收集器在并发的阶段会占用部分 CPU 资源,导致用户线程的可用 CPU 资源变少(计算能力减少),整体引用程序的吞吐量就降低了。

      吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)
      
    • 无法一次性处理浮动垃圾:由于并发清除的时候,用户线程还是在继续,所以此时仍然会产生垃圾 (浮动垃圾)。只能等到下一次出发垃圾回收的时候,再做清理。

    CMS垃圾收集器结构图

  • G1 收集器G1 收集器是一种面向服务器的垃圾收集器,主要应用就是在多核 CPU 和大内存的服务器环境当中。 G1 同样遵循分代收集理论,但是不再以固定大小和固定数量来划分分代区域,而是把连续的 Java 划分为多个大小相等的独立区域 (Region)每一个独立区域都可以根据不同的需求来扮演新生代的 Eden 空间 (E) 、Survivor 空间 (S) 或者老年代空间 (O),收集器会根据独立区域扮演的不同角色,采用不同的收集策略。除了上述的空间之外,还有一部分独立区域使用 H 进行标注,表示 Humongous 巨大的,说明这部分独立区域用来存储大对象,一般是大对象是指对象大小 >= 独立区域一半空间的对象。

    G1垃圾收集器结构图

    G1 收集器的实现流程大致也可以分为四个步骤,如下所示:

    1. 初始标记:标记 GC Roots 能直接关联到的对象,并且修改 TAMS (Top at Mark Start) 指针的值,让下一个阶段用户线程并发运行时,能够正确的在独立区域中分配新对象。G1 为每个独立区域都设置了两个 TAMS 指针,新分配的对象必须位于这两个指针位置上,位于这两个指针位置上的对象默认被隐式标记为存活的,不会纳入回收范围。
    2. 并发标记: 从GC Roots 能直接关联到的对象开始变量整个对象图。遍历完成之后,还需要处理 SATB 记录当中变动的对象。 SATB (snapshot-at-the-beginning,开始阶段快照) 能够有效的解决并发标记阶段因为用户线程允许而导致的独享变动,其效率比 CMS 重新标记使用的增量更新算法效率更高。
    3. 最终标记:堆用户线程做一个短暂的暂停,用于处理并发阶段结束后的少量 STAB 记录。虽然并发标记阶段会处理 SATB 记录,但由于处理的时候,用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来做兜底。
    4. 筛选回收:负责更新独立区域 Region 统计数据,按照各个 Region 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个独立区域构成回收集。然后将回收集中独立区域的存活对象复制到空的独立区域里面,再亲历掉整个旧的独立区域。此时,因为涉及到存活对象的移动,所以需要暂停用户线程,并且由多个收集线程并发执行。

    G1垃圾收集器流程图

5. Java 中有哪些垃圾回收算法?

Java 中的垃圾算法主要包括三种,标记-清除标记-整理标记-复制 【注意】 三种算法都是标记存活的对象,也就是需要进行垃圾回收的对象

特性 标记-清除 标记-整理 标记-复制
工作原理 首先遍历堆中的对象,标记出所有存活的对象,然后直接清除未标记的对象 首先标记出堆中存活的对象,然后将存活的对象单独整理在一个区域,最后清除为标记的对象 将内存分两部分,每次只用其中的一半。标记堆中存活的对象,将其复制到未使用的那一半区域,然后清除另外一半区域。(实际上会用到 eden 和 两个 survivor 区)
优点 实现简单,能够清除堆中所有需要回收的对象 解决了内存碎片问题 无序处理内存碎片,并且分配效率高
缺点 标记和清除的过程中会产生内存碎片,影响后续内存分配的效率 整理阶段需要移动对象,会导致额开销 需要双倍的内存空间,浪费了一半的空间

【标记-清除】

标记-清除 主要分为两个阶段:标记清除

  • 标记阶段 (tracing):从GC Roots 根出发,通过 DFS 或者 BFS 遍历所有被引用的对象,并且在对象的头部 Header 标记为存活。如果没有标记到的对象,说明他们是不可达的,不进行标记方便后期进行回收。 【注意】 GC Roots 的对象包括:栈、寄存器、全局变量等。具体来说,虚拟机中引用的对象(如局部变量、方法参数)、方法区中类静态属性引用的对象(全局变量)、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象

  • 清除阶段:遍历堆中的独享,将未被标记的对象 (不可达) 进行垃圾回收。清除的过程不会移动和整理内存空间,一般都是通过空闲链表(双向链表)来标记被垃圾回收的区域,内存是空闲可用的。所以这种算法会导致内存空间碎片的产生

    JVM-标记-清除算法

  • 内存碎片 (清除过程):程序在申请使用内存的时候,明明剩余内存空间是够的,但就是申请不到内存,这种现象就是存在内存碎片。另外,内存碎片会导致在申请内存的时候比较麻烦,需要遍历链表查找合适的内存块,会比较耗时。所以,一般会采用多个空闲链表来根据内存分块大小来组成不同的链表。比如分为大分块链表和小分块链表,根据申请的内存分块大小遍历不同的链表,加快内存的申请效率。JVM-标记-清除算法-内存碎片-综合图

  • 位图标记法 (标记过程):标记的过程中,一般是标记在对象头里面,但这可能会导致写时复制不兼容。因为标记的过程需要修改对象头,即便没有修改对象的值,也可能被误判为写操作。所以可以采用位图标记法,将堆内存的某个块用一个位来标记,把堆内存分为一块一块的。对象就是存储在一块或者多块内存上。根据对象所在的地址和堆的其实地址,就可以算出对象是在第几块上。然后用位图(数组)将对应的对象第一块的对位置为1,表明该对象被标记了。这样就可以兼容写时复制的算法了。另外,如果标注在对象头上,则清除的过程需要遍历整个堆来扫描对象。而如果采用位图标记法,则可以直接快速遍历位图,清除对应的对象 (位图的数组和堆内存块位置对应)。但是,无论是采用标记对象头还是位图标记法,都会存在内存碎片 【注意】写时复制操作就是多个进程或线程共享同一份数据,直到某个进程/线程尝试修改数据时,才复制并生成该数据的独立副本

【标记-复制】

标记-复制算法会把堆分为两块,一块是 From 区,一块是 To 区 。所有的对象在创建的时候,都会放在 From 区域。发生 GC 垃圾回收的时候,会标记所有存活的对象,然后将标记的对象从 From 区 复制到 To 区,然后整体回收 From 区。回收完 From 区之后,将 To 区和 From 区的进行置换,让原来的 From 区 变成 To 区,原来的 To 区 变成 From 区。 该过程当中,因为是整体From 区进行回收,不会出现内存碎片。同时,也不需要空闲链表来记录内存空闲的区域,直接移动指针分配内存。该方法对 CPU 缓存非常友好,因为从 GC Roots 开始采用DFS遍历一个节点,把关联的对象都找到,然后对象和关联对象的内存位置分配的很近。根据局部性原理,访问到一个对象的时候,关联对象也可能被同时访问,访问缓存可以直接命中。 该方法的缺点是,有一半的堆内存是不能使用的,内存的利用率很低。另外,如果存活的对象很多复制的过程是很慢的

JVM-标记-复制算法

【标记-整理】

标记-整理和标记-复制的原理其实差不多,区别在于复制算法需要分为两个区来回复制,而整理部分需,直接整理。标记整理的过程是,将存活的对象往边界上整理,然后对其他的部分进行垃圾回收。它的优点是不会出现内存碎片,也不需要像复制算法那样腾出一半的空间,所以内存利用率也挺高的。它的缺点是需要对堆内存进行多次搜索,因为需要在同一个空间里面,完成标记和整理(移动)的操作。所以完成该过程需要花费很多的时间。