JVM 内存结构

目录

[JVM 内存结构](#JVM 内存结构)

[一. 程序计数器](#一. 程序计数器)

[二. 虚拟机栈](#二. 虚拟机栈)

[1. 基本概念](#1. 基本概念)

[2. 常见问题辨析](#2. 常见问题辨析)

[3. 栈内存溢出问题](#3. 栈内存溢出问题)

[4. 线程运行诊断](#4. 线程运行诊断)

[(1) CPU 占用过高](#(1) CPU 占用过高)

[(2) 程序运行很长时间没有结果](#(2) 程序运行很长时间没有结果)

[三. 本地方法栈](#三. 本地方法栈)

[四. 堆](#四. 堆)

[1. 定义](#1. 定义)

[2. 堆内存溢出](#2. 堆内存溢出)

[3. 堆内存诊断工具](#3. 堆内存诊断工具)

4.案例

[五. 方法区](#五. 方法区)

[1. 定义](#1. 定义)

[2. 组成](#2. 组成)

[3. 方法区内存溢出](#3. 方法区内存溢出)

[4. 运行时常量池](#4. 运行时常量池)

[5. StringTable (字符串常量池)](#5. StringTable (字符串常量池))

[(1) StringTable 的特性](#(1) StringTable 的特性)

[(2) StringTable 位置](#(2) StringTable 位置)

[(3) StringTable 垃圾回收](#(3) StringTable 垃圾回收)

[(4) StringTable 性能调优](#(4) StringTable 性能调优)

[六. 直接内存](#六. 直接内存)

[1. 定义](#1. 定义)

[2. 分配和回收原理](#2. 分配和回收原理)


JVM 内存结构

一. 程序计数器

java 源代码 --(javac) --> 字节码 --(类加载器) --> 机器码

程序计数器是线程私有的, 用于记录当前线程下一条 jvm 指令的执行地址.

  • 每个线程都有自己的程序计数器.

  • 程序计数器不存在内存溢出问题 (jvm 规范).

二. 虚拟机栈

1. 基本概念

虚拟机栈 (简称"栈").

  • 每个线程运行时所需要的内存, 就称为虚拟机栈.

  • 每个栈由多个栈帧 (Frame) 组成, 一个栈帧对应一次方法的调用, (每个方法执行所需要的内存, 就称为栈帧).

  • 每个线程只能有一个活动栈帧, 对饮当前正在执行的那个方法.

2. 常见问题辨析
  1. 栈内存是否与垃圾回收有关?

    不涉及. 方法调用时创建栈帧入栈, 方法执行完毕后栈帧自动出栈并释放内存.

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

不是.

栈内存过大 可能会导致同时执行的线程数量减少, 程序运行效率降低 ; 内存资源浪费.

栈内存过小 可能会导致栈溢出问题.

所以我们应当合理设置栈内存大小.

  1. 方法内的局部变量是否存在线程安全问题?

不存在. 局部变量存储在线程私有的栈内存中, 不同线程的栈内存完全隔离, 互不干扰.

:

  • 如果方法内的局部变量(对象) 没有超出方法的作用范围, 则是线程安全的.

  • 如果方法内的局部变量(对象) 超出了方法的作用范围, 则不是线程安全的.

3. 栈内存溢出问题
  1. 栈帧过多导致栈内存溢出 (eg: 方法调用层级过深, 深度递归调用, 两个类之间的循环引用).

  2. 栈帧过大导致栈内存溢出 (eg: 栈内存设置过小, 局部变量占用空间过大) .

4. 线程运行诊断
(1) CPU 占用过高

定位:

  • 先用 top 命令定位哪个进程对 CPU 的占用过高.

  • 用 ps 命令进一步定位是哪个线程引起的 CPU 占用过高 (ps H -eo pid,tid,%cpu | grep 进程id).

  • jstack 进程id: 根据线程id找到有问题的线程, 进一步定位到有问题的源代码行数.

(2) 程序运行很长时间没有结果

有可能是多个线程发生死锁 导致程序运行不出来结果. 可以通过 jstack 进程id 来定位问题.

三. 本地方法栈

本地方法的调用 提供 内存空间.

四. 堆

1. 定义

使用 new 关键字创建的对象会存放到堆内存中.

特点:

  • 堆是线程共享的, 堆中的对象都需要考虑线程安全问题.

  • 堆有垃圾回收机制.

2. 堆内存溢出

java.lang.OutOfMemoryError: Java heap space

循环创建大量对象.

业务逻辑导致对象占用持续增长.

3. 堆内存诊断工具
  1. jps 工具

    • 查看当前 java 进程中 ++有哪些 java 进程++.
  2. jmap 工具

    • 查看 (某时刻) 堆内存占用情况. (无法连续监测)

      jmap -heap 进程ID

  3. jconsole 工具

    • 图形界面的 多功能监测工具 (可连续监测)

  4. jvisualvm

    java可视化虚拟机. 用可视化的方式展现虚拟机信息.

4.案例
  • 垃圾回收之后, 内存占用仍然很高, 是什么情况?

五. 方法区

1. 定义

方法区只是一个规范, 不同的厂商有不同的实现方式. (对于 Oracle HotSpot 虚拟机: 方法区在jdk1.8之前的实现是永久代, 在jdk1.8之后的实现是元空间)

方法区是一块线程共享的区域, 主要用于存储 类的元数据信息, 常量池 和 静态变量.

2. 组成

方法区: 类 (class) + 类加载器 (ClassLoader) + 运行时常量池 (StringTable).

3. 方法区内存溢出
  • jdk1.8 之前: 永久代内存溢出.

    复制代码
    # 演示永久代内存溢出
    -XX:MaxPermSize=8m
    java.lang.OutOfMemoryError: PermGen space
  • jdk 1.8 之后: 元空间内存溢出.

    复制代码
    # 演示元空间内存溢出 
    -XX:MaxMetaspaceSize=8m
    java.lang.OutOfMemoryError: Metaspace

类加载器 用于 加载类的二进制字节码.

如果加载的类过多, 会导致方法区内存溢出. 如下代码, 加载 10000 个类, 同时元空间最大容量是8M, 就会导致方法区内存溢出.

复制代码
/**
 * 演示元空间内存溢出
 * -XX:MaxMetaspaceSize=8m
 */
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test = new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length);
            }
        } finally {
            System.out.println(j);
        }
    }
}
4. 运行时常量池

类基本信息, 常量池, 类方法定义.

  • 常量池: 就是一张表, 虚拟机指令根据这张常量表找到要执行的 类名, 方法名, 参数类型, 字面量 等信息.

  • 运行时常量池 : 常量池是在 .class 文件 中的. 当该类被加载, 它的常量池信息就会放入运行时常量池, 并把里面的符号地址变为真实地址.

5. StringTable (字符串常量池)
(1) StringTable 的特性
  1. 常量池中的字符串仅是符号, 第一次用到时才变为对象.

    当 Java 代码被编译成 .class 文件时, 字符串字面量 (如 "abc") 会以符号形式存储在 .class 文件的常量池中. 此时它们还不是真正的 Java 对象, 只是一种标记或引用符号, 用于表示这段字符串的内容.

    只有当类被加载到 JVM 中, 并且程序第一次使用到这个字符串时 (比如 赋值给变量, 作为方法参数等): 此时 JVM 才会在堆中创建 对应的字符串对象, 并将该对象的引用存入字符串常量池 (StringTable).

    复制代码
    // StringTable [ "a", "b", "ab" ] hashtable结构,不能扩容
    public class Demo1_22 {
        // 常量池中的信息,都会被加载到运行时常量池中. 这时 a b ab 都是常量池中的符号, 还没有变为 java 字符串对象
        // ldc #2 会把 a 符号变为 "a" 字符串对象
        // ldc #3 会把 b 符号变为 "b" 字符串对象
        // ldc #4 会把 ab 符号变为 "ab" 字符串对象
    ​
        public static void main(String[] args) {
            String s1 = "a"; // 懒惰的
            String s2 = "b";
            String s3 = "ab";
            String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
            String s5 = "a" + "b"; // javac 在编译期间的优化
    ​
            System.out.println(s3 == s5);
        }
    }
  2. 利用串池的机制, 来避免重复创建字符串对象.

  3. 字符串变量拼接的原理是 StringBuilder (jdk 1.8).

  4. 字符串常量拼接的原理是编译期优化.

  5. 可以使用 intern 方法, 主动将串池中还没有的字符串对象放入串池.

    • jdk1.8: intern 方法将字符串对象尝试放入字符串常量池. 如果串池中有则不会放入 ; 如果串池中没有则放入串池, 最后返回串池中的对象.

      复制代码
      public class Demo23 {
          // ["a", "b", "ab"]
          public static void main(String[] args) {
              // 堆 new String("a") new String("b") new String("ab")
              String s = new String("a") + new String("b");
              
              // intern: 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,最后把串池中的对象返回
              String s2 = s.intern(); 
              
              System.out.println( s2 == x); //true
              System.out.println( s == "ab"); //false
          }
      }
      • jdk1.6: intern 方法将字符串对象尝试放入字符串常量池. 如果串池中有则不会放入 ; 如果串池中没有, 则将此对象复制一份 再放入串池, 最后返回串池中的对象.
(2) StringTable 位置
  • 在 jdk1.6 及以前: 字符串常量池位于永久代 (方法区) 中.

  • 在 jdk1.7 及以后: 字符串常量池位于中.

++做此迁移的原因++: 永久代内存的回收效率很低, 在 FullGC 时才会触发永久代的垃圾回收 (触发时机晚, 触发频率太少), 所以就导致 StringTable 的回收效率不高, 当字符串常量过多时, 很容易触发永久代内存溢出 ; 而在堆中, MinorGC 就会触发垃圾回收, 回收频率提高, 大大降低了内存溢出的风险.

(3) StringTable 垃圾回收
  • 在 jdk1.6 前: StringTable 位于永久代中, 基本不涉及垃圾回收.

  • 在 jdk1.7 后: StringTable 位于堆中, 会随着堆区进行垃圾回收.

(4) StringTable 性能调优
  • 如果有字符串数量很大 --> 可以通过 -XX:StringTableSize=xxx 调整底层 HashTable 的++桶的个数++. 可以减少哈希冲突,提升字符串的查找效率加快读取速度.

  • 如果有大量字符串 且 字符串存在重复的问题 --> 可以考虑将字符串对象是否入池 (避免重复的字符串入池) ---> 使用 intern().

  • 减少不必要的字符串创建. 创建过多不必要的字符串会增加 StringTable 的负担, 占用更多内存, 还可能导致哈希冲突增多.

六. 直接内存

1. 定义

直接内存 (Direct Memory) 是 JVM 向操作系统"申请" 后, 由操作系统直接分配的内存. 不占用 JVM 堆内存空间, 也不占用系统内存空间. Java 代码可以直接访问, 操作系统也可以直接访问.

直接内存不受 JVM 垃圾回收的管理 (回收需显式调用或依赖操作系统机制).

  • 常见于 NIO 操作时, 用于数据缓冲区

  • 分配回收成本较高, 但读写性能高

  • 不受 JVM 内存回收管理

为什么使用了直接内存, 大文件的读写效率就会非常高?

  • 不使用直接内存的文件读写过程:
  1. Java 本身不具备磁盘读写的能力, 想要进行磁盘读写 必须调用操作系统提供函数 (调用本地方法). [状态切换: 用户态 --> 内核态].

  2. 在内核态下去真正读取磁盘文件内容, 磁盘内容会先读入到系统缓冲区中, 然后再将数据从系统缓冲区读到 java 缓冲区中. 这样就导致了一次不必要的数据复制, 使得读写效率下降.

  • 使用直接内存的文件读写过程:
  1. Java 程序从用户态切换到内核态.

  2. 在内核态下读取磁盘文件内容到直接内存 (direct memory) 中, java 代码和系统都可以直接从 direct memory 中读取数据, 减少了一步缓冲区复制操作, 提高了效率.

2. 分配和回收原理
  • 使用 Unsafe 对象 完成直接内存的 分配和回收, 并且回收需要主动调用 freeMemory() 方法.

  • ByteBuffer 的实现类内部使用了 Cleaner (虚引用) 来监测 ByteBuffer 对象, 一旦 ByteBuffer 对象被垃圾回收, 就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存.

相关推荐
lichong9512 小时前
【大前端++】Android studio Log日志高对比度配色方案
android·java·前端·json·android studio·大前端·大前端++
97zz2 小时前
实战排查:Java 解析 Excel 大型 导致内存溢出问题的完整解决过程
java·开发语言·spring boot·excel
没头脑的男大2 小时前
如何把pdf转换的excell多个表格合并
java·前端·pdf
小小测试开发2 小时前
Python + MediaPipe 手势绘画高级应用:从基础到创意交互
开发语言·python·交互
会跑的葫芦怪3 小时前
Go tool pprof 与 Gin 框架性能分析完整指南
开发语言·golang·gin
爱学习的小道长3 小时前
Python调用优云智算安装的ComfyUI服务器
服务器·开发语言·python
要做朋鱼燕3 小时前
解析UART空闲中断与DMA接收机制
开发语言·笔记·单片机·嵌入式硬件·rtos·嵌入式软件
Dream achiever3 小时前
11.WPF 的命令处理事件--参数介绍
开发语言·c#·wpf
_bong3 小时前
python语言中的常用容器(集合)
开发语言·python