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 时可跳过扫描该区域,从而减少扫描开销。
相关推荐
zhougl9961 小时前
html处理Base文件流
linux·前端·html
花花鱼2 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_2 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo3 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
杉之5 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端5 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡5 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木6 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
難釋懷7 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript