JVM 内存相关总结

1. 内存的分配

Java 文件加载过程

  1. java 文件编译成 class 字节码文件
  2. ClassLoader 将 class 加载到 JVM 内存中
  3. 内存中不同区域加载 class 中不同部分

JVM 内存分布

1. 程序计数器

   - 用来标记当前线程代码执行位置,当CPU调度重新执行此线程时,从标记位置执行。

   - 除了恢复线程操作之外,分支操作、循环操作、异常处理等也依赖程序计数器来完成。

  • 关于程序计数器还有几点需要格外注意:
         1. 在 Java 虚拟机规范中,对程序计数器这一区域没有规定任何 OutOfMemoryError 情况(或许是感觉没有必要吧)。

         2. 线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

         3. 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。

2. 虚拟机栈

   虚拟机栈线程私有。该区域有 2 种异常:

   - StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。
   - OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出。

   虚拟机栈是用来描述 Java 方法 执行的内存模型。每个方法被执行的时候,JVM 都会在虚拟机栈中创建一个栈帧

   一个线程包含多个栈帧,每个栈帧内部都包含局部变量表操作数栈动态链接返回地址等。

   - 局部变量表:方法的传参,内部变量都会存储在此。编译后的class 文件中已经确定局部变量表最大深度。
   - 操作数栈:先入后出。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈 ( 比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中 )。
   - 动态链接:每个栈帧包含一个指向运行时常量池中该栈所属方法的符号引用。在一个 class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。
   - 返回地址:为了帮助方法退出后返回到方法被调用的位置,恢复它的上层方法执行状态。方法退出包含两种:正常退出和异常退出。

3. 本地方法栈

   本地方法栈和虚拟栈基本相同,只不过是针对本地(native)方法。 JNI 的开发中会用到这一部分内存空间。

4. 堆

   用来存放对象实例,几乎所有对象实例都在堆中分配,是 GC 管理的主要区域。所有线程共享此区域。

   分区:新生代(Eden、Survivor),老年代

5. 方法区

   JVM 规范中规定的一块运行时数据区,用来存储已经被 JVM 加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。

   注意它只是规范,不同 JVM 对此有不同的实现。常见说法:永久区、元空间。

2. GC 内存回收机制

    以 GCRoot 为起点,通过引用可达性分析,确定对象是否可以被回收。

    GC Root对象包括:

        1. JVM 虚拟机栈-局部变量表中引入的对象
        2. 方法区中静态引用指向的对象
        3. 存活状态中的线程对象
        4. native 方法中 JNI 引用的对象

3. GC 回收算法

标记清除算法

  1. 找到内存中所有的 GC Root 对象,将与其有直接或间接关联的对象标记为存活对象,对无关联对象标记为垃圾对象。
  2. 将垃圾对象直接清除。

优点:实现简单,无需将对象进行移动。
缺点:需中断进程中其他组件的执行,并且可能产生内存碎片,提高了垃圾回收的频率。

复制算法

  1. 将内存分为两块,A 和 B,当前只使用 A。然后进行遍历,标记出存活对象和垃圾对象。
  2. 将存活对象依次复制到内存 B 中,清空内存 A,并设置 B 为当前使用中的内存。

优点:按顺序分配内存,实现简单,运行高效,无需考虑内存碎片。
缺点:可用内存缩小一半,对象存活率高时需要频繁进行复制。

标记压缩算法

  1. 遍历内存中对象,标记存活对象和垃圾对象
  2. 讲存活对象按顺序压缩到内存的某一端,清除存活对象边界外所有空间。

优点:避免产生碎片,也无需缩小空间。
缺点:需要将存活对象进行移动,效率降低。

4. JVM 分代回收策略

    在内存分配存储对象的堆内存中,分为新生代和老年代。新创建的对象会会分配到新生代内存中,经过多次回收依然存活的对象,将被转移到老年代中进行存储。

    新生代分为 3 个部分:Eden、Survivor0、Survivor1。分配比例为 8:1:1。

1.  Eden 中满了之后,会触发 GC。此时策略是,将存活对象负责到 Survivor0,然后清除 Eden 中垃圾对象。
2. Eden 中再次满了后,触发 GC。此时策略是,将 Eden 和 Survivor0 中存活对象转移到 Survivor1 ,然后清空 Eden 和 Survivor0。
3. Survivor0 与 Survivor1 之间切换多次(默认 15 次)之后,如果还有存活对象,将其转移到老年代中。

    如果对象很大,新生代中剩余空间不足,会将其直接分配到老年代中。老年代中可能存在引用新生代对象的情况,在新生代执行 GC 时,为了避免查询老年代中所有对象,老年代中维护了一个对新生代对象的引用记录表。

5. 内存泄漏优化

常见问题场景
  1. Context 或者 View 置为 static。
  2. 未解注册各种 Listener,比如广播接收器。
  3. 非静态的 Handler,在执行耗时任务时,持有当前 Activity 的引用。可将 Handler 定义成静态内部类,内部持有 Activity 的弱引用来避免内存泄漏。
  4. 三方库使用 context 对象存储为静态对象。尽量使用 Context.getApplicationContext。

6. 内存泄漏检测

工具

    LeakCanary

实现分析

    1. 如何检测内存泄漏
     2. 分析内存泄漏对象引用链

实现原理

    LeakCanary 中对内存泄漏的检测基于 WeakReference 和 ReferenceQueue。

    在构建 WeakReference 对象时传入 ReferenceQueue,当 WeakReference 中传入的对象可以被回收时,会将 WeakReference 对象添加到 ReferenceQueue 中,倘若 WeakReference 中的对象无法被回收时,不会将 WeakReference 对象添加到 ReferenceQueue 中。这样便可以检测到,应该被回收的对象,却没有出现在 ReferenceQueue 中,这些对象就是造成内存泄漏的元凶。

检测时机

    向主线程 MessageQueue 中插入了一个 IdleHandler,IdleHandler 只会在主线程空闲时才会被 Looper 从队列中取出并执行。因此能够有效避免内存检测工作占用 UI 渲染时间。

2022 Android 面试准备
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×