JVM内存模型 & 垃圾回收机制
MoMo Lv5

image

image

JVM 内存模型

  1. 栈 Stack:存放基础类型 bool char 以及对堆中内存的引用,线程私有

  2. 堆 heap:存放对象,线程共有

  3. 程序计数器 pc register:记录存档,程序运行到哪了

  4. 方法区 method area:线程共有,存放运行数据常量 class field

  5. 元空间:对方法区(interface)的具体实现(impl),系统空间有多大他就有多大

  6. 本地方法栈 native method stack :由其他语言编写在 java 上运行的方法(native)

方法区为什么内存共享

方法区被设计为线程共享,是因为它存储的类信息、常量池、静态变量和 JIT 编译后的代码都是在 JVM 运行期间需要被所有线程共享和访问的。线程共享设计可以避免不必要的内存消耗和管理复杂性,同时 JVM 也会通过各种机制确保对方法区的访问是线程安全的。这种设计既保证了内存的高效利用,又保证了数据的一致性和线程安全。

哪些区域会导致内存溢出

在 JVM 内存模型中,几乎所有内存区域都可能因为特定的原因导致 OOM 错误,唯有程序计数器不会产生 OOM。这是因为程序计数器所需的内存非常少且固定,每个线程都有独立的程序计数器,无法出现内存不足的问题。

  • 方法区:类加载过多或常量池溢出会导致 OOM。
  • :对象过多且 GC 未及时回收会导致 OOM。
  • :调用深度过深或线程过多会导致 OOM。
  • 本地方法栈:本地方法调用过深或本地线程过多会导致 OOM。
  • 程序计数器:内存需求小且固定,不会导致 OOM。
  • 直接内存:直接内存使用超出限制会导致 OOM。

StackOverFlow 是栈空间不足出现的,主要是单个线程运行过程中调用方法过多或是方法递归操作时申请的栈帧使用存储空间超出了单个栈申请的存储空间。

OOM 主要是堆区申请的内存空间不够用时出现,比如单次申请大对象超出了堆中连续的可用空间。

栈中主要是用于存放程序运行过程中需要使用的变量,引用和临时数据,堆中主要存储程序中申请的对象空间。

类加载子系统

image

类加载器

image

image

Tomcat为什么要自定义类加载器

为了进行类的隔离,如果mcat直接使用AppClassLoader类加载类,那就会出现如下情况

  1. 应用A中有一个com.zhouyu.Hello.class
  2. 应用B中也有一个com.zhouyu.Hello.class
  3. 虽然都叫做Helo,但是具体的方法、属性可能不一样
  4. 如果AppClassLoader先加载了应用A中的Hello.class
  5. 那么应用B中的Hello.class就不可能再被加载了,因为名字是一样
  6. 如果就需要针对应用A和应用B设置各自单独的类加载器,也就是WebappClassLoader
  7. 这样两个应用中的Hello.class都能被各自的类加载器所加载,不会冲突这就是Tomcat
  8. 为什么用自定义类加载器的核心原因,为了实现类加载的隔离
  9. JM中判断一个类是不是已经被加载的逻辑是:类名+对应的类加载器实例

总结

Java堆

  • 定义: 堆内存 (Heap)
  • 作用: 存放 Java对象实例
  • 特点: 线程共享,JVM内存中最大
  • 抛出的异常:OutOfMemoryError: 堆中没有足够的内存完成对象实例分配时,堆无法再扩展
  • 备注:
    • Java堆是垃圾收集器管理的主要区域,因此被称为 “GC堆”。
    • 内存可划分的原因因为分为:新生代 & 老年代。
    • 新生代可再细分:Eden空间、From Survivor空间、To Survivor空间。
    • 从内存分配的角度:多个线程私有的分配缓冲区。

Java虚拟机栈

  • 定义: 栈内存 (栈, Java方法执行的内存模型)
  • 作用:
    • 存储Java方法执行时的局部变量
    • 以帧结构的方式
    • 含数据类型、对象的引用:方法执行的结果以及异常处理
  • 特点: 线程私有,生命周期与线程相同
  • 抛出的异常:
    • OutOfMemoryError: 虚拟机栈扩展时无法申请到内存
    • StackOverflowError: 栈容量达到最大值,虚拟机栈执行溢出异常
  • 备注:
    • 每个Java方法执行时都会创建一个栈帧。
    • 一个Java方法执行前进到调用执行完成。
    • 一个线程在虚拟机栈中从入栈到出栈。
    • 局部变量表所用内存空间在方法执行前已完全分配完毕,在方法运行期间不会改变大小。

本地方法栈

  • 定义: 类似于Java虚拟机栈
  • 作用: 为Java方法执行 Native方法服务
  • 特点: 线程私有
  • 抛出的异常:
    • OutOfMemoryError: 扩展时无法申请到内存
    • StackOverflowError: 容量达到最大值,栈溢出
  • 备注:
    • 与Java虚拟机区别在于:为Java方法执行 Java方法服务。
    • 本地方法栈为执行 Native方法服务。

方法区

  • 定义: 堆的一个逻辑部分
  • 作用: 存储 已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码 等数据
  • 特点: 线程共享
  • 抛出的异常:
    • OutOfMemoryError: 当方法区无法满足内存分配需求
  • 备注:
  • 为了与Java堆区分,又被称为 “非堆”区域 (Non - Heap)。
  • 该区域主要用于存储:已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
  • 永久代、元空间:永久代存在,很多垃圾收集器不回收。

运行时常量池

  • 定义: 方法区的一部分
  • 作用: 存放文件在编译时期生成的 各种字面量 & 符号引用
  • 特点:
    • 动态性(运行时也可动态添加新的常量,如 String类的intern() )
  • 抛出的异常:
    • OutOfMemoryError: 当常量池无法申请到内存
  • 备注:
    • 存放文件在编译时期生成的 各种字面量 & 符号引用

程序计数器

  • 定义: 当前线程所执行的字节码的 行号指示器
  • 作用: 实现异常处理,线程恢复功能(通过改变计数器值选取下一条要执行的指令)
  • 特点: 内存空间小,线程私有
  • 抛出的异常: 唯一一个在JVM中无任何OutOfMemoryError的内存区域
  • 备注:
    • 线程私有的内存区域;为了使线程恢复到正确的位置,每条线程都拥有独立的程序计数器。
    • 线程切换时可以恢复到正确的执行位置。

字节码

字节码可以跨平台,同一个字节码可以在不同的JVM上运行

java可以跨平台
不同平台上安装了不同操作系统的JVM

垃圾识别和回收算法,哪些对象可以成为 GCRoot

垃圾识别算法

标记-清除 算法

算法思想

算法分为两个阶段:

  1. 标记阶段:标记出所有需要回收的对象;
  2. 清除阶段:统一清除(回收)所有被标记的对象。

标记阶段主要分为:(先进行可达性分析)

  1. 第一次标记 & 筛选
  2. 第二次标记 & 筛选

判断标准

在执行finalize()过程中,若对象依然没与引用链上的GC Roots 直接关联 或 间接关联(即关联上与GC Roots 关联的对象),那么该对象将被判断死亡,不筛选(留在”即将回收“集合里) 并 等待回收

优点

算法简单、实现简单

缺点

  • 效率问题:即 标记和清除 两个过程效率不高
  • 空间问题:标记 - 清除后,会产生大量不连续的内存碎片。

这导致 以后程序 需要分配较大空间对象时 无法找到足够大的连续内存 而被迫 触发另外一次垃圾收集行为,这导致非常浪费资源。

应用场景

对象存活率较低 & 垃圾回收行为频率低 的场景

如老年代区域,因为老年代区域回收频次少、回收数量少,所以对于效率问题 & 空间问题不会很明显。

复制算法

该算法的出现是为了解决 标记-清除算法中 效率 & 空间问题的。

算法思想

  • 将内存分为大小相等的两块,每次使用其中一块;
  • 当 使用的这块内存 用完,就将 这块内存上还存活的对象 复制到另一块还没试用过的内存上
  • 最终将使用的那块内存一次清理掉。

示意图如下:

image

优点

  1. 解决了标记-清除算法中 清除效率低的问题,每次仅回收内存的一半区域

  2. 解决了标记-清除算法中 空间产生不连续内存碎片的问题,将已使用内存上的存活对象 移动到栈顶的指针,按顺序分配内存即可

缺点

  1. 每次使用的内存缩小为原来的一半。
  2. 当对象存活率较高的情况下需要做很多复制操作,即效率会变低

应用场景

对象存活率较低 & 需要频繁进行垃圾回收 的区域

如新生代区域

特别注意

a. 背景
新生代区域在进行垃圾回收时,98%对象都必须得回收

b. 问题
复制算法中 每次使用的内存缩小为原来的一半 利用率低 & 代价太高

c. 解决方案

  • 不 按 1:1的比例 划分内存,而是 按8:1:1比例 将内存划分为一块较大的 Eden 和两块较小的 Survivor 区域(From SurvivorTo Survivor

image

  • 每次使用EdenFrom Survivor区域;
  • 用完后就 将上述两块区域存活的对象 复制到To Survivor区域上
  • 最终一次清理掉EdenFrom Survivor区域

使用逻辑 同 改进前

假如 EdenFrom Survivor区域上存活对象所需内存大小 > To Survivor区域怎么办?
解决方案:依赖老年代内存区域 做 内存分配担保。
To Survivor区域 存不下来的对象 会通过 内存分配担保机制 暂时保存在老年代

标记 - 整理 算法

此算法类似于第一种标记 - 清除 算法,只是在中间加多了一步:整理内存。

算法思路

算法分为三个阶段:

  1. 标记阶段:标记出所有需要回收的对象;
  2. 整理阶段:让所有存活的对象都向一端移动
  3. 清除阶段:统一清除(回收)端以外的对象。

示意图如下:

image

优点

  • 解决了标记-清除算法中 清除效率低的问题:一次清楚端外区域
  • 解决了标记-清除算法中 空间产生不连续内存碎片的问题:将已使用内存上的存活对象 移动到栈顶的指针,按顺序分配内存即可。

应用场景

对象存活率较低 & 垃圾回收行为频率低 的场景

如老年代区域,因为老年代区域回收频次少、回收数量少,所以对于效率问题 & 空间问题不会很明显。

分代收集算法

主流的虚拟机基本都采用该算法

算法思路

  • 根据 对象存活周期的不同Java堆内存 分为:新生代 & 老年代 。分配比例如下:

image

  • 根据 两块区域特点 选择 对应的垃圾收集算法(即上面介绍的算法),具体细节请看下图

image

具体存储过程

  1. 新建的对象 一般会被优先分配到新生代的Eden区、From Survivor

    1. 大对象(如很长的字符串以及数组)会直接分配到老年代,这是为了避免在 Eden 区 和 Survivor区之间发生大量的内存复制(因为新生代会采用复制算法进行垃圾收集)
  2. 这些对象经过第一次 Minor GC后,若仍然存活,将会被移到To Survivor

    1. 一次清理掉EdenFrom Survivor区域
  3. To Survivor 区每经过一轮 Minor GC ,该对象的年龄就+1

  4. 当对象年龄达到一定时(阈值默认=15),就会被移动到老年代。

    1. 即新生代的对象在存活一定时间后,会被移动存储到老年代区域。
    2. 还有一种 新生代对象被移懂到老年代区域 的情况是:动态对象年龄判定。即如果在Survivor区中 所有相同年龄对象的大小总和 大于
    3. Survivor区内存大小一半时,所有大于或等于该年龄的对象都会直接进入老年代。

特别注意

From SurvivorTo Survivor之间会经常互换角色。

每次发生GC时,把Eden区和 From Survivor区中 存活且没超过年龄阈值的对象 复制到To Survivor区中(此时To Survivor变成了From Survivor),然后From Survivor清空(此时From Survivor变成了To Survivor

优点

效率高、空间利用率高

应用场景

现在主流的虚拟机基本都采用 分代收集算法 ,即根据不同区域特点选择不同垃圾收集算法。

  1. 新生代 区域:采用 复制算法
  2. 老年代 区域:采用 标记-清除 算法、标记 - 整理 算法

常见的GC Roots

在进行垃圾收集时,从 GC Roots 开始遍历所有可达的对象。任何未被 GC Roots 引用且不可达的对象将被视为垃圾,可以被回收。以下是常见的 GC Roots:

  1. 栈中的引用(Stack References):所有活跃线程中栈帧中的本地变量和操作数栈引用的对象。
  2. 类静态属性引用(Static Fields of Classes):方法区中的类静态属性引用的对象。
  3. 常量池中的引用(Constants in the Method Area):方法区中的常量池引用的对象。
  4. 本地方法栈中的引用(JNI References):本地方法栈(Native Method Stack)中 JNI 引用的对象。
  5. 活动线程(Active Threads):所有当前仍然存活的线程。
  6. Java 虚拟机内部的引用:一些特殊的引用,如反射中用到的引用、类加载器、GC 的运行状态等。
  7. 同步锁持有的对象:被 synchronized 关键字持有的对象。
  • 垃圾识别和回收算法:主要包括引用计数法、标记-清除算法、标记-整理算法、复制算法和分代收集算法。这些算法各有优缺点,常常结合使用以提高效率和性能。
  • GC Roots:是垃圾收集的起点,包括栈中的引用、类静态属性引用、常量池中的引用、本地方法栈中的引用、活动线程等。

如何确保所有对象都被穷举

在 Android 开发中,确保所有对象都被穷举的核心问题同样涉及垃圾收集(Garbage Collection, GC)的机制。GC 的目标是从根对象(GC Roots)开始遍历,标记所有可达的对象,并识别不可达的对象以便回收:

Android 中的垃圾回收机制

Android 采用了 Java 虚拟机的垃圾回收机制,通过 GC 来自动管理内存,确保所有不再使用的对象都能被回收。Android 中常用的垃圾收集器是 Dalvik 和 ART(Android Runtime)。

确保所有对象都被穷举的步骤

  1. 识别 GC Roots
  2. 遍历对象图
  3. 标记和压缩
  4. 垃圾收集器类型和算法

识别 GC Roots

在 Android 中,GC Roots 是垃圾收集的起点,通常包括:

  • 活动的线程:所有当前活动的线程及其调用栈。
  • 类的静态成员:类加载器加载的所有类的静态成员。
  • JNI 引用:通过 JNI(Java Native Interface)持有的对象引用。
  • 应用程序生命周期对象:如 Application、Activity、Service、ContentProvider 等。

遍历对象图

从 GC Roots 开始,GC 遍历所有可达对象,并标记它们。常用的遍历算法包括:

  • 递归遍历:通过递归调用来遍历对象,适用于较小的对象图。
  • 非递归遍历:通过栈或队列结构来遍历对象,适用于较大的对象图,避免递归调用可能导致的栈溢出问题。

标记和压缩

  • 标记阶段:从 GC Roots 开始,标记所有可达对象。被标记的对象不会被回收。
  • 清除阶段:遍历堆中所有对象,清除未标记的对象,释放内存。
  • 压缩阶段:将存活对象压缩到内存的一端,避免内存碎片,提高内存利用率。

垃圾收集器类型和算法

Android 的垃圾收集器包括 Dalvik GC 和 ART GC:

  • Dalvik GC:基于标记-清除算法,适用于早期 Android 版本。标记-清除算法会在标记阶段遍历对象图,确保所有对象都被穷举。
  • ART GC:基于标记-压缩算法,适用于 Android 5.0 及以后版本。标记-压缩算法不仅标记和清除对象,还对存活对象进行压缩,减少内存碎片。

总结

  1. 准确的 GC Roots 识别:确保 GC Roots 包含所有可能的根对象,包括线程、静态成员、JNI 引用和应用生命周期对象。
  2. 有效的遍历算法:使用适当的遍历算法(递归或非递归)来遍历整个对象图,确保所有可达对象都被标记。
  3. 正确的标记和清除流程:标记阶段要准确标记所有可达对象,清除阶段要遍历整个堆,确保未标记的对象都能被回收。
  4. 避免对象引用遗漏:在代码中尽量避免强引用造成的内存泄漏,适当使用弱引用(WeakReference)、软引用(SoftReference)和虚引用(PhantomReference)来帮助 GC 准确识别不再使用的对象。

示例

在实际开发中,可以通过 Memory Profiler 等工具来监控和分析内存使用情况,确保 GC 正常工作,避免内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 示例:使用WeakReference来避免强引用造成的内存泄漏
class MyActivity : AppCompatActivity() {
private val myHandler = MyHandler(this)

private class MyHandler(activity: MyActivity) : Handler() {
private val weakReference = WeakReference(activity)

override fun handleMessage(msg: Message) {
val activity = weakReference.get()
if (activity != null) {
// 处理消息
}
}
}
}

通过这种方式,可以确保在 Activity 被销毁后,Handler 不再持有强引用,帮助 GC 回收不再使用的对象。

垃圾收集器的分类?并行收集器的原理?

垃圾收集器是 Java 虚拟机(JVM)自动管理内存的重要组件。它们根据不同的算法和策略进行分类和优化。以下是垃圾收集器的主要分类及其特点,以及并行收集器的原理。

垃圾收集器的分类

1. 串行收集器(Serial Garbage Collector)

  • 特点:使用单线程进行垃圾收集。
  • 适用场景:适合单核处理器或需要最小化内存使用的简单应用。
  • 收集过程:新生代使用复制算法,老年代使用标记-整理算法。

2. 并行收集器(Parallel Garbage Collector)

  • 特点:使用多线程并行进行垃圾收集,提高吞吐量。
  • 适用场景:适合多核处理器和需要高吞吐量的后台处理任务。
  • 收集过程:新生代使用并行的复制算法,老年代使用并行的标记-整理算法。

3. 并发收集器(Concurrent Garbage Collector)

  • 特点:垃圾收集的某些阶段与应用线程并发执行,减少停顿时间。
  • 适用场景:适合需要低延迟的应用,如用户界面应用。
  • 收集过程:典型例子是 CMS(Concurrent Mark-Sweep)收集器,使用并发标记-清除算法。

4. G1 收集器(Garbage-First Garbage Collector)

  • 特点:分区内存,优先收集垃圾较多的区域,兼顾吞吐量和暂停时间。
  • 适用场景:适合大内存、多处理器的应用,需要可预测的停顿时间。
  • 收集过程:分为并发标记阶段和并行复制阶段,结合区域化收集。

5. ZGC(Z Garbage Collector)

  • 特点:超低延迟,暂停时间通常不超过 10 毫秒。
  • 适用场景:适合需要极低延迟的应用,如金融交易系统。
  • 收集过程:使用并发标记和并发重定位。

6. Shenandoah Garbage Collector

  • 特点:低延迟,类似于 ZGC,但有不同的实现细节。
  • 适用场景:适合低延迟需求的应用。
  • 收集过程:并发标记和并发压缩。

并行收集器的原理

并行收集器(Parallel GC)旨在提高吞吐量,即尽可能多地完成用户代码的执行,而尽量减少垃圾收集的总开销。以下是并行收集器的工作原理和特点:

工作原理

  1. 多线程并行收集:并行收集器在垃圾收集的所有阶段使用多个线程同时进行垃圾收集工作,以充分利用多核处理器的计算能力。
  2. 新生代收集(Parallel Scavenge):新生代收集使用复制算法(Copying Algorithm)。它将存活对象从 Eden 区和 Survivor 区复制到另一个 Survivor 区,清空 Eden 区和原 Survivor 区。
  3. 老年代收集(Parallel Old):老年代收集使用并行的标记-整理算法(Mark-Compact Algorithm)。它标记所有存活对象,然后将它们移动到老年代的一端,整理出连续的内存空间。

并行收集器的步骤

  1. 初始标记(Initial Mark):暂停所有应用线程,标记从 GC Roots 直接可达的对象。这个阶段通常时间很短。
  2. 并行标记(Concurrent Mark):多线程并行标记从初始标记阶段可达的所有对象。这个阶段与应用线程并行执行。
  3. 重新标记(Remark):再次暂停所有应用线程,标记在并行标记阶段发生变化的对象。这个阶段的时间也比较短。
  4. 并行清理(Concurrent Sweep):多线程并行清除未标记的对象,释放内存空间。这个阶段与应用线程并行执行。
  5. 并行压缩(Concurrent Compact):将存活对象移动到内存的一端,压缩内存空间,减少碎片。这一步也可以并行执行。

优点和缺点

优点

  • 高吞吐量:适合需要高吞吐量的后台任务。
  • 充分利用多核处理器:通过多线程并行收集,充分利用多核处理器的计算能力。

缺点

  • 较长的停顿时间:在某些阶段(如初始标记和重新标记),需要暂停应用线程,导致停顿时间较长,不适合对延迟敏感的应用。

总结

并行收集器(Parallel GC)通过多线程并行进行垃圾收集,显著提高了垃圾收集的吞吐量,适合多核处理器和需要高吞吐量的应用。不同的垃圾收集器根据其设计目标和实现细节,适用于不同的应用场景。了解并选择合适的垃圾收集器对于优化应用性能和内存管理至关重要。

Powered by Hexo & Theme Keep
Unique Visitor Page View