Andrdoid中常用的JVM知识整理

一、JVM 内存结构与区域

  1. JVM 的内存区域

    • 规范说明
      JVM 只是一个规范,常见的内存区域包括:方法区、堆、虚拟机栈、本地方法栈、直接内存等。
    • 方法区
      根据《深入理解 Java 虚拟机》的描述,方法区用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。
  1. 运行时数据区与直接内存

    • 例如在一台 8G 内存的机器上,JVM 启动时可能分配 5G 作为运行时数据区,其余 3G 可通过特殊方式申请使用(直接内存)。
    • 直接内存绕过了 JVM 的内存分配和垃圾回收机制,虽然速度更快,但容易引发内存泄漏、覆盖等问题。通常通过 Unsafe 类进行底层操作,而 ByteBuffer 则能避免部分风险。
  2. 虚拟机栈与本地方法栈

    • 虚拟机栈存放的是每个线程的栈帧,参数大小由 -Xss 控制,默认值通常为 1MB。
    • 本地方法栈与虚拟机栈类似,HotSpot 虚拟机中两者合二为一,不再严格区分。

二、字节码、栈帧与程序执行

  1. 字节码与栈帧示例
    下面代码展示了一个简单的 Java 类,通过字节码解读说明了方法调用、局部变量与操作数栈的工作原理:
java 复制代码
public class Person {
    public int work() throws Exception { // 每个方法对应一个栈帧
        int x = 1; // 字节码:iconst_1、istore_1
        int y = 2; // 字节码:iconst_2、istore_2
        int z = (x + y) * 10;
        return z;
    }
    public static void main(String[] args) throws Exception {
        Person person = new Person();
        person.work();    // 调用 work() 方法,字节码偏移量对应行号(例如 3)
        person.hashCode(); // 调用 hashCode() 方法,属于本地方法,使用本地方法栈
    }
}
  • 指令与偏移
    每条字节码指令并不等同于一行代码,例如 bipush 10 占用两行,程序计数器记录的是相对于本方法的字节码偏移量。
  • 操作数栈
    所有数据运算都通过操作数栈完成,类似于寄存器,且该栈空间在同一线程内复用。
  • 局部变量表
    变量先进入操作数栈,然后再存入局部变量表。加载(iload)和存储(istore)的字节码指令实现了这种数据传输。
  1. 方法返回与跳转
  • 方法返回时,返回值会压入操作数栈,并由调用者通过操作数栈取回。
  • 每个方法都有一个"完成出口"(返回地址),记录下一条将要执行的指令偏移。
  1. 动态链接与多态
  • 动态链接机制与多态相关,在执行过程中根据实际类型确定方法调用。
  • 注意:只有非静态方法需要使用动态链接(涉及 this 指针)。

三、对象内存分配与垃圾回收

  1. 对象分配与晋升

    • 对象一般先在新生代 Eden 区分配,通过 TLAB(线程本地分配缓冲)提高分配效率。
    • 若对象经过约 15 次垃圾回收仍然存活,则会被提升到老年代。
    • 大对象(例如大于 -XX:PretenureSizeThreshold 设置的阈值,通常如 4MB)直接进入老年代。
  2. 垃圾回收算法

    • 新生代:采用复制算法,适用于生命周期短的对象。

    • 老年代:常见算法包括标记清除(CMS,可能产生碎片)和标记整理算法。

    • 全局回收:Minor GC(新生代回收)、Full GC(新生代和老年代整体回收)、Major GC(单独针对老年代的回收)。

    • 并行与响应优先

      • Parallel Scavenge 与 Parallel Old 采用并行多线程回收,优先保证吞吐量。
      • CMS 注重响应优先,但存在 CPU 占用高、浮动垃圾和碎片等问题。
  3. 垃圾回收辅助技术

    • 逃逸分析:在 JIT 编译时分析对象是否只在局部使用,从而可能将对象分配到栈上。
    • 空间分配担保:JVM 根据历次回收情况判断是否需要进行 Full GC,以保证新生代与老年代空间的分配平衡。

四、对象结构与内存模型

  1. 对象头与填充

    • 对象头包含运行时数据、类型指针;若是对象数组,还包括长度信息。
    • 根据对象大小和内存对齐要求,可能会有额外的填充。
  2. 对象引用

    • 可以通过句柄(reference → 句柄 → 实际对象)访问对象,也可以采用直接指针,HotSpot 主流采用直接指针方式以提高效率。
  3. CAS 与原子操作

    • JVM 中使用 CAS(Compare-And-Swap)指令,利用 CPU 指令和锁机制确保原子性,适用于多核环境下的并发控制。
  4. 内存分配方式

    • JVM 内存分配有两种方式:

      • 指针碰撞:连续空间内快速分配。
      • 空闲列表:在不连续内存中查找合适空间。

五、类加载与常量池

  1. 类加载与 .class 文件

    • 类文件按需加载,加载后的类存放于方法区。
    • 类加载时会将符号引用转换为直接引用,存储在运行时常量池中。
  2. 常量池

    • 运行时常量池保存直接引用信息,其中包括字符串常量池(虽然规范未定义,但作为优化技术广泛使用)。
    • String 的 intern() 方法可实现相同内容字符串共享同一常量池对象,从而实现复用。
  3. 类的卸载条件

    • 要卸载一个类,必须满足:
      (1) 该类所有实例均被回收;
      (2) 加载该类的类加载器也被回收;
      (3) 类自身没有被任何引用持有(包括反射);
      (4) 未启用阻止类卸载的参数(如 -Xnoclassgc)。

六、引用类型与内存回收机制

  1. 各种引用

    • 软引用:在内存不足(OOM)时回收。
    • 弱引用:每次 GC 都会回收。
    • 虚引用(PhantomReference) :对象即将被回收时会加入引用队列,但无法直接获得对象,常用于管理堆外内存(如 NIO)。
  2. 引用队列

    • 虚引用必须与引用队列(ReferenceQueue)联合使用,便于程序在对象回收前采取必要行动。
  3. 可达性分析与 Finalize

    • 垃圾回收器通过 GC Roots(如静态变量、线程栈、常量池、JNI 指针等)确定对象可达性。
    • 对象回收前会进行两次标记,其中一次是 Finalize 阶段,允许对象在被回收前"自救"(但 finalize() 只会被调用一次,优先级较低)。

七、垃圾回收调优与性能优化

  1. 内存分配与回收比例

    • 新生代通常采用 8:1:1 的比例(Eden:From Survivor:To Survivor)。
    • 当对象总大小超过 From 区的一半时,会直接进入老年代。
  2. 回收器选择与调优

    • PS(Parallel Scavenge 及 Parallel Old)组合适合吞吐量优先的场景。
    • 堆的扩容或缩容依据参数 MinHeapFreeRatio 与 MaxHeapFreeRatio,调优时常建议将最大堆和最小堆设为相同值。
  3. CMS 垃圾回收器

    • 虽然 CMS 采用并发清理、响应优先,但它对 CPU 要求高、可能产生浮动垃圾和内存碎片,因此从未成为默认回收器。
    • 回收过程中还涉及预清理、重新标记等阶段,以减少停顿时间。
  4. TLAB 的作用

    • TLAB(线程本地分配缓冲)为每个线程分配专用内存区域,减少多线程竞争,提升分配效率;若禁用,则可能回落使用 CAS 等原子操作。

八、其他细节

  1. 线程与对象分配

    • 对象在满足逃逸分析条件时可分配到栈上,栈是线程私有的,避免了多线程竞争;但并非所有对象都支持栈上分配。
  2. GC 期间的暂停

    • 垃圾回收通常需要暂停所有业务线程(Stop-The-World),尽管可以调整 GC 时间,但难以准确控制每次 GC 的耗时。
  3. 字符串拼接优化

    • 使用 StringBuilder 进行字符串拼接可以避免频繁创建新对象,从而提高性能。
  4. C/C++ 与 Java 的内存管理对比

    • C 语言通过 malloc/free,C++ 通过 new/delete;Java 则由 JVM 自动管理对象内存,开发者无需手动释放空间。

其他补充

1. 字符串长度限制为何为 65536?

JVM 对字符串的存储有固定规定:

  • 字符串存储结构由三部分构成:tag + length + 具体内容

  • 每个部分均以字节形式表示,其中:

    • tag 标识数据类型(例如 01 表示 UTF-8 编码的字符串),
    • length 用 2 个字节记录字符串内容的字节数(因此最大值为 2^16,即 65536),
    • 具体内容存储实际字符串数据。

相比之下,对于 Int 类型数据,JVM 不需要记录长度,因为其固定为 4 字节;同理,double 和 long 也是固定长度,不需要额外的 length 字段。


2. 常量池的结构与排列

  • 常量池中的常量存储格式

    不同类型的常量(如字符串、数字、类信息等)都有各自的格式:

    • 对于字符串,其存储格式为 tag、length 以及实际内容;
    • 对于数值(如 Integer、double、long),由于长度固定,仅需 tag 与固定字节数表示值,不需要额外记录长度。
  • 常量池排列规则
    常量池中各个常量按照特定规则排列,直至达到 const_pool_count 的大小;这样就能通过格式和顺序确定各类型常量的数量。
    同时,整个常量池可看作一张表,每个条目都遵循固定格式。

3. 字节码与栈帧结构

java中,栈帧所包含的操作数栈,局部变量表,动态链接,返回地址,这些是怎么协同工作的? 在 Java 中,每次方法调用都会在调用线程的虚拟机栈上创建一个新的栈帧,而栈帧中的各个部分协同工作,保证方法调用、参数传递、运算以及返回的正确执行。主要组件和它们的协同工作过程如下:

  1. 局部变量表

    • 用于存储方法的参数和局部变量。
    • 当方法被调用时,传入的参数会先复制到局部变量表中,后续方法体中声明的局部变量也都在这里分配空间。
  2. 操作数栈

    • 用于执行过程中存放临时数据和中间运算结果。
    • 字节码指令会依次从局部变量表中加载数据到操作数栈,执行运算,再将结果推回操作数栈,或者存入局部变量表中。
  3. 返回地址

    • 用来记录当前方法调用结束后,程序应跳转回调用点的地址。
    • 当方法调用时,当前执行位置的下一条指令地址被保存为返回地址,等方法执行完毕后,JVM利用该地址恢复调用方法的执行。
  4. 动态链接信息

    • 包含常量池中的符号引用,用于在运行时解析方法调用和字段引用。
    • 这使得方法调用能在运行时绑定到具体的实现(支持多态),即根据实际对象动态选择方法。

协同工作过程:

  • 方法调用时

    JVM创建新的栈帧,并将调用方法的参数复制到该栈帧的局部变量表中。与此同时,当前方法的返回地址(即调用点的下一条指令)也会被保存到调用方法的栈帧中。

  • 方法执行时

    字节码指令通过操作数栈进行数据的加载、计算和传递。局部变量表提供稳定的存储空间,而操作数栈则负责短期数据的临时存储。动态链接信息则帮助 JVM 在执行过程中解析符号引用,确定具体的类、方法或字段地址。

  • 方法返回时

    被调用的方法执行完成后,其返回值会被推入操作数栈,然后当前栈帧被弹出,控制权回到调用方法的栈帧。此时,JVM利用之前保存的返回地址,将返回值传递给调用者,继续执行后续指令。

  • 方法编译后的信息

    编译时,每个方法的操作数栈大小与本地变量表大小均已确定;

    非静态方法会隐含一个 this 参数,存放在本地变量表的第 0 位;

    JVM 指令总数超过 100 条,每条指令可能占用 1 个或多个字节。

  • 语法糖的影响
    Java 代码中大量使用语法糖,编译后生成的字节码会包含更多隐含信息,从而使得最终字节码比源代码冗长。

下面给出一个简单的例子,并在代码注释中说明每个部分如何协同工作:

java 复制代码
public class StackFrameDemo {
    public static void main(String[] args) {
        // main 方法的栈帧被创建,局部变量表中存放参数 args(此处可以忽略)
        int x = 10; // x 存储在 main 的局部变量表中
        int y = 20; // y 同样存储在局部变量表中

        // 调用 add 方法时:
        // 1. JVM 创建一个新的栈帧用于 add 方法,
        // 2. 将 main 中传入的 x 与 y 复制到 add 方法的局部变量表中,
        // 3. 同时保存 main 中下一条要执行的指令地址作为返回地址。
        int sum = add(x, y);

        // add 方法返回结果后,该返回值会先被推到 main 的操作数栈上,
        // 然后 main 方法的栈帧继续执行,最后调用 System.out.println 进行输出。
        System.out.println("Sum: " + sum);
    }

    public static int add(int a, int b) {
        // 进入 add 方法时,a 和 b 已存放在 add 的局部变量表中(通过动态链接解析确定了正确的方法实现)
        // 操作数栈用于临时存储计算数据:
        // 将 a 和 b 从局部变量表加载到操作数栈上,执行加法运算。
        int result = a + b; // 此时 a 与 b 通过操作数栈相加,结果存入局部变量表的 result 位置

        // 将 result 推入操作数栈作为返回值,然后将当前 add 方法的栈帧弹出
        return result;
    }
}

协同工作说明

  1. 栈帧的创建与销毁

    • 每次方法调用都会创建一个新的栈帧。比如,调用 add(x, y) 时,JVM 会为 add 方法创建一个新的栈帧。
    • 当方法执行结束(如 add 返回)时,对应的栈帧会被弹出,返回值传递到调用者的操作数栈上。
  2. 局部变量表

    • 每个栈帧都有自己的局部变量表,用来存储方法的参数和局部变量。在 add 方法中,参数 ab 以及局部变量 result 都存储在此表中。
  3. 操作数栈

    • 操作数栈用于存储运算中产生的临时数据。例如,在 add 方法中,ab 会被加载到操作数栈,然后进行加法运算,结果再存入局部变量表或直接作为返回值推入调用者的操作数栈。
  4. 动态链接

    • 动态链接负责在运行时将符号引用解析为实际的内存地址。在调用 add 方法时,JVM 根据常量池中的信息动态解析到正确的方法实现,支持多态等特性。
  5. 返回地址

    • 指的是方法执行完成之后应该返回的地址,以便让之前的逻辑继续执行。

4. 内部类与外部类的引用机制

  • 内部类的原理
    内部类在编译后会在构造方法中包含对外部类的引用,用以访问外部类成员。
  • Kotlin 的优化
    如果内部类没有使用外部类成员,则不会传入外部类引用,只有在需要访问外部类时,才会包含此引用。

5. 泛型擦除与 Signature 信息

  • 泛型擦除导致的问题
    由于编译时泛型被擦除,在运行时直接使用会出现类型不匹配的报错。
  • Signature 字段的作用
    编译后,JVM 会在 class 文件中存储一个 Signature 字段,保存完整的泛型信息,从而允许通过反射获取。

6. 同步代码块及方法锁机制

  • 同步代码块
    字节码中,同步块会有两次退出指令,以确保在异常情况下也能正确退出同步状态。
  • 伪代码简化
    对应的简化伪代码展示了同步代码块的退出处理。
  • 方法锁机制
    方法上的锁在进入时会先尝试偏向锁(直接记录线程 ID),若竞争失败或调用了 hashCode,则升级为轻量级或重量级锁。锁只能升级,不能降级,这样可以避免频繁的竞争和阻塞。(偏向锁:直接在锁上边添加了线程的ID,如果不是自己,就会尝试去替换,如果替换不了,就会升级为偏向锁,下次该线程需要锁的时候,不需要竞争,直接操作就行了。轻量级锁,不会阻塞,通过自旋不断地去尝试获取锁。自旋次数有限制,达到限制后就会升级为重量级锁。锁只能升级,不能降级。 hashCode需要经过调用才会产生的。并不是一调用就会有的,而一旦对象调用了hashCode,这个对象就不可能再变成偏向锁了。同时,如果一个对象本身就是偏向锁状态,一旦调用hashCode,就会直接升级为重量级锁。因此,三方库里边都是通过直接创建对象设置锁,而不是通过某个类的class对象来作为锁。这样可以在一定程度上防止锁变成重量级锁。

7. 内存模型与类加载

  • 内存区域划分
    除了堆和方法区(包含常量池)外,其余区域(如虚拟机栈、本地方法栈)均为线程私有;运行时常量池由方法区分配,存储了 final static 变量以及各种类的信息。
  • 常量池
    常量池本身也可视为一张表,按固定规则排列各类型常量。
  • 类加载与对象初始化
    如果在使用时发现类尚未加载,JVM 会先加载该类到方法区,然后在堆上分配对象内存,并将对象引用压入栈帧的操作数栈;但此时对象还未完成初始化。

每个栈帧的结构包含局部变量表、操作数栈等信息。

  • 对象结构
    对象由对象头和实例数据组成,数组对象还包含记录长度的字段。

8. 垃圾回收算法与内存回收

  • 常见 GC 算法

    • 标记清除:标记所有可达对象后清除不可达对象,但可能产生内存碎片。
    • 标记整理:在标记阶段后整理内存,减少碎片问题。
    • 复制算法:常用于新生代,将存活对象复制到另一空间并清空原空间。
  • Minor GC 在新生代中的过程
    在 young 区域,每次 Minor GC 会对存活对象年龄加 1,并将它们复制到目标区(to 区),同时清空原区(from 区);若目标区空间不足,部分对象将直接晋升到老年代。较大的对象为了减少复制成本,也会直接进入老年代。
  • Card Table 优化
    老年代被分割为多个区域,每个区域对应 Card Table 数组的一个元素。若某个区域没有跨代引用,Minor GC 时可跳过扫描该区域,从而减少扫描开销。
相关推荐
布列瑟农的星空2 分钟前
webworker 实践:外部依赖引入和打包问题
前端·低代码
傻小胖4 分钟前
发布一个npm包,更新包,删除包
前端·npm·node.js
runnerdancer8 分钟前
解构shopify,从0到1实现落地页低代码编辑器
前端
WEI_Gaot27 分钟前
react19 的项目创建和组件使用
前端·react.js
资深前端外卖员31 分钟前
【nodejs高可用】前端APM应用监控方案 + 落地
前端·后端
OhBonsai31 分钟前
Shader 图像处理1_ToneMap技术处理过曝
前端
突头小恐龙31 分钟前
Chrome devTools - Lighthouse
前端·javascript·chrome
谦谦橘子31 分钟前
手写tiny webpack,理解webpack原理
前端·javascript·webpack
土豆125033 分钟前
Tailwind CSS 精通指南:提升效率、可维护性与最佳实践
前端·css
花生了什么树lll33 分钟前
面试中被问到过的前端八股(四)
前端·面试