【JVM】Java虚拟机(一)——内存结构

发布于:2025-06-09 ⋅ 阅读:(24) ⋅ 点赞:(0)

目录

一、简介

二、程序计数器

三、虚拟机栈

栈帧结构:

特点:

四、本地方法栈

特点:

五、堆

堆结构:

特点:

对象分配过程:

六、方法区

方法区结构:

特点:

运行时常量池

七、StringTable

(一)StringTable 核心概念

(二)核心特性与机制

1. 字符串唯一性(Intern机制)

2. 延迟加载

3. 不可变性

(三)内存位置演变

(四)字符串创建流程

(五)性能优化建议

(六)示例:StringTable 与 GC 交互

(七)总结对比表

(八)面试题

八、直接内存

(一) 基本概念

(二)与堆内存的对比

(三)核心优势

(四)内存分配与回收

(五)潜在问题

(六)最佳实践

(七)典型应用场景

(八)总结

(九)分配和回收原理

九、JVM内存整体结构图


一、简介

Java虚拟机(JVM)在执行Java程序时会将内存划分为不同的区域,每个区域有特定的用途和生命周期。JVM内存结构主要分为线程私有区域(程序计数器、虚拟机栈、本地方法栈)和线程共享区域(堆、方法区)。下面将详细解析每个部分的结构和功能。

 

二、程序计数器

程序计数器(Program Counter Register) 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

作用:记住下一条jvm指令的执行地址

特点:

  • 线程私有每个线程都有独立的程序计数器

  • 唯一不会OOM的区域没有内存溢出问题

三、虚拟机栈

虚拟机栈(Java Virtual Machine Stacks) 是描述Java方法执行的内存模型,每个方法执行时都会创建一个栈帧。

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

问题辨析

1. 垃圾回收是否涉及栈内存?

不涉及

2. 栈内存分配越大越好吗?

No

3. 方法内的局部变量是否线程安全?

如果方法内局部变量没有逃离方法的作用访问,它是线程安全的

如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

栈帧结构:

特点:

  • 线程私有:生命周期与线程相同

  • FILO结构:方法调用对应栈帧的入栈出栈

  • 可能抛出异常

    • StackOverflowError:栈深度超过限制

    • OutOfMemoryError:栈扩展失败

局部变量表:

  • 存储编译期可知的各种基本数据类型

  • 对象引用(reference类型)

  • returnAddress类型(指向字节码指令地址)

操作数栈:

  • 用于存储计算过程的中间结果

  • 工作区,方法执行过程中数据写入和提取

四、本地方法栈

本地方法栈(Native Method Stack) 与虚拟机栈作用相似,区别在于它为Native方法服务。

特点:

  • 线程私有区域

  • 存储Native方法调用的状态

  • 在HotSpot JVM中与虚拟机栈合并

  • 同样会抛出StackOverflowError和OutOfMemoryError

五、堆

堆(Heap) 是JVM管理的最大一块内存区域,被所有线程共享,在虚拟机启动时创建。通过 new 关键字,创建对象都会使用堆内存

堆结构:

特点:

  • 线程共享:所有线程访问同一堆空间

  • GC主要区域:垃圾收集器管理的主要区域

  • 分代管理

    • 新生代(Young Generation):新对象创建区域

      • Eden区:对象初次分配区

      • Survivor区:经过Minor GC后存活的对象

    • 老年代(Old Generation):长期存活的对象

  • 异常:当堆无法分配内存且无法扩展时,抛出OutOfMemoryError

对象分配过程:

六、方法区

方法区(Method Area) 存储已被虚拟机加载的类型信息、常量、静态变量等数据。

方法区结构:

特点:

  • 线程共享:所有线程共享方法区

  • 永久代→元空间

    • JDK7及之前:永久代(PermGen),在堆中

    • JDK8+:元空间(Metaspace),使用本地内存

  • 包含运行时常量池

    • 存放编译期生成的各种字面量和符号引用

    • 动态性:运行期间也可以将新的常量放入池中

  • 异常:当方法区无法满足内存分配需求时,抛出OutOfMemoryError

运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

七、StringTable

(一)StringTable 核心概念

  1. 本质
    StringTable(字符串表)是JVM中哈希表(Hash Table) 的实现,用于存储字符串对象的引用

    • 键(Key):字符串的哈希值(由字符串内容计算得出)。

    • 值(Value):字符串对象在堆中的引用。

  2. 与常量池的关系

    • class文件常量池:存储编译期生成的字面量(Literal)和符号引用(如 "abc")。

    • 运行时常量池:类加载时,将class常量池加载到方法区中。

    • StringTable:在运行时常量池中的字符串字面量首次被使用时,动态创建实际字符串对象并存入StringTable。


(二)核心特性与机制

1. 字符串唯一性(Intern机制)
  • 通过 String.intern() 方法,将字符串主动加入StringTable并返回唯一引用。

  • 规则:若StringTable中已存在相同内容的字符串,则返回其引用;否则将当前字符串加入表中。

String s1 = new String("hello");  // 在堆中创建对象,未加入StringTable
String s2 = "hello";              // 直接使用StringTable中的引用
String s3 = s1.intern();          // 将s1的字符串内容加入StringTable

System.out.println(s1 == s2);     // false:s1在堆,s2在StringTable
System.out.println(s2 == s3);     // true:s2和s3指向StringTable同一对象
2. 延迟加载
  • 字符串字面量在首次被引用时才创建对象并加入StringTable。

  • 示例:

public class LazyLoadExample {
    public static void main(String[] args) {
        // 仅声明字面量,未主动使用,不会加载到StringTable
        String unused = "unused_string"; 

        // 首次使用字面量时加载
        System.out.println("hello");  // "hello" 被加入StringTable
    }
}
3. 不可变性
  • 所有存入StringTable的字符串均为不可变对象(由final char[]实现)。

  • 修改字符串会创建新对象,不影响原StringTable中的引用。

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder (1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回

(三)内存位置演变

JVM版本 存储位置 特点
JDK 1.6及之前 永久代(PermGen) 固定大小,易触发 OutOfMemoryError: PermGen space
JDK 1.7+ 堆内存(Heap) 可动态扩容,受 -Xmx 控制,GC可回收无引用的字符串。

(四)字符串创建流程

(五)性能优化建议

  • 调整表大小

    通过 -XX:StringTableSize=N 设置桶数量(建议设为质数),减少哈希冲突。

java -XX:StringTableSize=10009 MyApp
  • 避免重复字符串

      使用 intern() 减少重复大字符串的内存占用(适合重复率高的场景)。

List<String> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
    String temp = new String("重复数据").intern(); // 复用StringTable中的对象
    list.add(temp);
}
  • 谨慎使用 intern()

    • 高频调用可能引发哈希冲突,导致性能下降。

    • 适合长期存活且重复率高的字符串(如数据库字段名)。

(六)示例:StringTable 与 GC 交互

public class StringTableGCDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            String temp = "str_" + i; // 字面量加入StringTable
            temp = null; // 断开引用
        }
        System.gc(); // 触发GC,回收无引用的String对象(JDK1.7+)
    }
}
  • JDK 1.6:字符串在PermGen中,GC不回收,导致内存泄漏。

  • JDK 1.7+:字符串在堆中,GC可回收无引用的对象。


(七)总结对比表

特性 常量池(Constant Pool) StringTable
存储内容 字面量、符号引用 字符串对象的引用
内存位置 方法区(元空间) 堆内存
生命周期 类加载时生成 运行时动态添加
垃圾回收 不回收 可被GC回收(JDK1.7+)
数据结构 表结构(非哈希) 哈希表

关键结论

  • StringTable 是 运行时字符串驻留机制 的核心,通过哈希表实现唯一性。

  • JDK 1.7+ 将其移至堆内存,解决了永久代内存溢出问题,且支持GC回收。

  • 合理使用 intern() 和调整 StringTableSize 可优化内存与性能。


(八)面试题

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);

八、直接内存

(一) 基本概念

  • 定义:直接内存是 JVM 堆外由操作系统直接管理的内存区域。

  • 核心类:通过 java.nio.ByteBuffer.allocateDirect() 分配。

  • 数据存储:直接存储原始字节数据,不归 JVM GC 管理。

(二)与堆内存的对比

特性 直接内存 堆内存
内存位置 堆外(操作系统管理) JVM 堆内
分配速度 较慢(需系统调用) 较快(JVM 内部管理)
访问速度 快(少一次数据复制) 较慢(需复制到堆外)
内存回收 手动或基于 Cleaner 机制 GC 自动回收
容量限制 受系统内存限制 受 -Xmx 限制
适用场景 高频 I/O、大文件操作 常规对象存储

(三)核心优势

  1. 减少数据复制

    • 传统 I/O:数据需从内核缓冲区 → JVM 堆缓冲区 → 用户空间(两次复制)。

    • 直接内存:数据直接在内核缓冲区处理(零复制),提升效率。

    • 应用场景:网络传输、文件读写(如 NIO 的 FileChannel.transferTo())。

  2. 突破堆大小限制

// 分配 1GB 直接内存(不受 -Xmx 限制)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

(四)内存分配与回收

  • 分配方式

ByteBuffer buffer = ByteBuffer.allocateDirect(1024); // 分配 1KB 直接内存

回收机制

  • Cleaner 机制DirectByteBuffer 被 GC 回收时,触发关联的 Cleaner 释放堆外内存。

  • 手动释放(不推荐)

// 反射强制释放(仅作演示,实际慎用!)
Method cleanerMethod = buffer.getClass().getMethod("cleaner");
cleanerMethod.setAccessible(true);
Object cleaner = cleanerMethod.invoke(buffer);
Method cleanMethod = cleaner.getClass().getMethod("clean");
cleanMethod.invoke(cleaner);

(五)潜在问题

  1. 内存泄漏

    • 原因:DirectByteBuffer 对象被回收前,堆外内存不会被释放。

    • 风险点:频繁分配大块直接内存且未及时触发 GC。

  2. OutOfMemoryError

    • 错误信息:Direct buffer memory

    • 解决方案:

      • 增大 JVM 参数:-XX:MaxDirectMemorySize=2G(默认等于 -Xmx)。

      • 优化代码:复用 ByteBuffer 或显式调用 System.gc()(不保证立即生效)。


(六)最佳实践

  • 复用缓冲区

// 复用 ByteBuffer 减少分配开销
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
buffer.clear(); // 重置位置标记,准备复用
  • 结合内存映射文件
// 使用内存映射文件操作大文件(基于直接内存)
FileChannel channel = FileChannel.open(Path.of("largefile.bin"));
MappedByteBuffer mappedBuffer = channel.map(
    FileChannel.MapMode.READ_WRITE, 0, channel.size()
);
  • 监控工具

    • JVM 参数:-XX:NativeMemoryTracking=detail

    • 命令:jcmd <pid> VM.native_memory

(七)典型应用场景

  • Netty 的 ByteBuf

// Netty 的池化直接内存
ByteBuf directBuf = Unpooled.directBuffer(1024);
  • 高性能序列化

    • 如 Kryo、FST 直接操作堆外内存避免 GC 停顿。


(八)总结

关键点 说明
本质 JVM 堆外内存,由操作系统管理
分配方式 ByteBuffer.allocateDirect()
性能优势 零复制(减少内核-用户态数据拷贝)
回收风险 依赖 Cleaner 机制,可能内存泄漏
适用场景 高频 I/O、大文件处理、网络通信框架
监控手段 NMT(Native Memory Tracking)、MaxDirectMemorySize 参数

(九)分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

九、JVM内存整体结构图

完结撒花🎉