disgare 的博客
首页
博客
分类
标签
首页
博客
分类
标签
  • 网络

    • 计算机网络学习笔记
    • 网络安全相关
    • 域名和子网掩码
    • CORS 跨域资源共享
    • DNS、HTTP 与 HTTPS
    • Server-Sent Events (SSE)
    • WebSocket 长连接
  • 计算机基础

    • 操作系统 IO 相关知识
    • 操作系统学习笔记
    • 程序的机器级表示
    • 音频文件基础
    • 正则表达式相关概念
    • ffmpeg 的安装以及实现音频切分功能
    • Hex 和 Base64 编码
    • XML 的使用
  • 数据结构与算法

    • 动态规划算法学习笔记
    • 基于比较的排序算法的最坏情况下的最优下界为什么是O(nlogn)
    • 集合与数据结构学习笔记
    • 面试常见算法总结
    • 算法导论第二部分排序学习笔记
    • 算法导论第一部分学习笔记
  • Java

    • 对象之间的映射与转换
    • 反射学习笔记
    • 泛型相关概念
    • 关于 boolean 类型的坑
    • 如何使用 lambda 表达式实现排序
    • CompletableFuture 相关用法
    • CompletableFuture 源码浅要阅读
    • FutureTask 源码阅读
    • Guava 常用 API
    • Guava 源码阅读:Multimap 相关
    • Jackson 的各种使用
    • Java 的 Excel 相关操作
    • java 的常见性能问题分析以及出现场景
    • java 基础知识
    • JAVA 枚举的基础和原理
    • Java 图片文件上传下载处理
    • Java 序列化
    • Java 异常
    • Java 语法糖
    • Java 中关于字符串处理的常用方法
    • Java 中强、软、弱、虚引用
    • JAVA 注解小结
    • Java Http 访问框架
    • Java Stream 的使用
    • Java8 新特性
    • netty 学习笔记
    • Scanner 的各种用法
    • Servlet 学习笔记
    • String、StringBuffer、StringBuilder 学习笔记
  • JVM

    • 虚拟机执行子系统
    • JVM 自动内存管理
      • Java 内存区域(运行时数据区域)
        • 虚拟机栈
        • 栈帧的组成
        • 局部变量表
        • 操作数栈
        • 动态链接
        • 方法返回地址
        • 解析和分派(如何找到方法执行入口)
        • 栈可能会引发的问题
        • 基于栈的解释执行
        • 本地方法栈
        • 程序计数器
        • 堆
        • 分区
        • 内存分配机制
        • 方法区
        • 直接内存(堆外内存)
      • 对象的生命周期
        • 对象的创建
        • 类加载检查
        • 划分内存区域
        • 划分方式
        • 并发问题
        • 初始化零值
        • 设置对象头
        • 初始化
        • 对象的内存布局
        • 对象和类的查找
        • 判断对象是否死亡
      • GC(对象的回收)
        • GC 回收算法
        • GC 分类
        • GC 收集器
        • CMS 的过程
        • CMS 的弊端
        • G First 的特点是什么
        • ZGC
        • HotSpot 算法实现细节
        • 根节点枚举
        • 跨代引用
    • Linux 中 JVM 常用工具以及常见问题解决思路
  • Linux

    • crontab 表达式
    • Linux 常见命令
    • Linux 文件系统
  • 中间件

    • 关于定时任务原理
    • 详解 kafka
    • ES 搜索引擎
    • flink 提交流程
    • Grape-RAG
    • Hadoop 基础原理
  • 多线程

    • 多线程基础学习笔记
    • 简单了解并发集合
    • 如何手写单例
    • 深入理解 java 多线程安全
    • 生产者消费者问题
    • 线程池作用、用法以及原理
    • AQS 组件
    • ThreadLocal 原理以及使用
  • 非关系型数据库

    • Redis 集群
    • Redis 数据结构、对象与数据库
    • Redis 学习笔记
  • 关系型数据库

    • B+ 树的插入、删除和数据页分裂机制
    • MySQL 的 binglog、redolog、undolog
    • MySQL 的记录存储结构、存储引擎与 Buffer Pool
    • MySQL 基本的特性
    • MySQL 开发规范
    • MySQL 事务与锁与 MVCC
    • MySQL 数据类型、字符集相关内容
    • MySQL 索引与索引优化
    • PostgreSQL 更新数据时 HOT优化
    • PostgreSQL 相关用法
  • Python

    • Python 基础语法
    • Python 学习
  • Spring 项目

    • Lombok 的常用注解
    • maven 小结
    • MyBatis 框架的使用
    • MyBatis 重要知识点总结
    • MybatisPlus 的使用
    • Spring 框架基础使用
    • Spring 事务相关
    • Spring IOC 的原理及源码
    • Spring AOP 的使用和原理
    • SpringBoot 的原理
    • SpringBoot 基础使用
    • SpringWeb 重要知识点
  • 分布式

    • 初步了解 docker
    • 从 ACID 到 BASE 事务处理的实现
    • 访问远程服务
    • 分布式 id
    • 分布式缓存相关问题
    • 分布式集群理论和分布式事务协议
    • 分布式架构的观测
    • 分布式一致性算法
    • 负载均衡 Load Balancing
    • 关于分布式系统 RPC 中高可用功能的实现
    • 集群间数据同步的目的
    • 三高问题下的系统优化
    • 数据库分库分表
    • 详解 Spring Cloud
    • Dubbo 基础概念
    • Gossip 协议
    • nginx 学习笔记
    • Protobuf 通信协议
    • Zookeeper 基础学习
  • 架构设计

    • 参数校验与异常处理
    • 抽象方法与设计模式
    • 代码整洁之道
    • 权限系统设计
    • 用低内存处理大量数据
    • 设计模式——策略模式
    • 设计模式——过滤器模式在 Spring 中的实践
    • 状态模式
    • 统一结果返回
    • 为什么要打日志?怎么打日志?打什么日志?
    • 运维监控常见指标含义
    • 资深研发进阶
    • DDD 架构学习笔记
    • Java 常用的规则引擎
    • MVC 架构学习笔记
  • AI

    • 如何编写 Prompt
    • Agent 工程架构
    • LLM 相关内容
    • NLP 相关知识
    • vibe coding 最佳实践
    • windows 下 ollama 迁移到 D 盘
  • 开发工具

    • 如何画时序图、流程图、状态流转图
    • excel 关于 =vlookup 的用法
    • git 的学习以及使用
    • IDEA 插件推荐
    • IDEA 常用快捷键以及调试
    • Shell 脚本
    • swagger 的使用
  • 前端

    • 简单了解前端页面开发
    • 伪静态是什么
    • GitHub Pages 部署教程
    • Vercel 部署教程
    • vue-admin-template 简单使用
    • VuePress 博客搭建指南
  • 项目

    • 面试刷题网——技术方案
    • 影视资源聚合站——技术方案
  • 问题记录

    • 定时任务单线程消费 redis 中数据导致消费能力不足
    • 提供可传递的易受攻击的依赖项
    • Liteflow 在 SpringBoot 启动时无法注入组件问题 couldn‘t find chain with the id[THEN(NodeComponent)]
  • 金融

    • 股票分析——关于电力
    • 股票技术面——量价关系
    • 股票技术面——盘口
    • 股票技术面——基础
    • 基础的金融知识
    • 基金与股票
    • 韭菜的自我总结
    • 聊聊价值投资
  • 其他

    • 程序员职场工作需要注意什么
    • 创业全链路SOP:从灵光一现到系统化增长的实战指南
    • 观罗翔讲刑法随笔
    • 价格和价值
    • 立直麻将牌效益理论
    • 梅花易数学习笔记
    • 压力管理
2021-06-16
JVM
目录

JVM 自动内存管理

# Java 内存区域(运行时数据区域)

了解 Java 的内存区域主要是为了在出现 OOM 等问题时快速定位与解决问题,运行时数据区包含以下几个方面 在这里插入图片描述

# 虚拟机栈

每个线程都会有一个虚拟机栈、一个程序计数器、一个本地方法栈,这是线程私有区域中的东西,因为 Java 的虚拟机大都和操作系统 CPU 一一对应,这线程就对应 CPU 的核心

所有的 Java 方法调用都是通过栈来实现的

在调用方法时,一个栈帧入栈,方法结束,栈帧出栈

对象可以在栈上分配内存,这种方式只能未出现逃逸时使用(对象只在方法内使用),编译器经过逃逸分析结果,可以将代码优化。逃逸分析如栈上分配、同步省略(锁消除)、标量替换(将一个对象替换为组成它的多个标量)

HotSpot 虚拟机无法修改栈的大小,不会出现 OOM

# 栈帧的组成

举例说明:

public class StackFrameExample1 {
    public int calculate(int a, int b) {
        int result = a + b;    // 局部变量
        return result;
    }
    
    public static void main(String[] args) {
        StackFrameExample1 obj = new StackFrameExample1();
        int sum = obj.calculate(10, 20);
        System.out.println(sum);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

栈帧内容:

// 栈帧开始
┌─────────────────────────────────────────────┐
│ 局部变量表 (索引从0开始)                         │
│ [0] this引用 → 指向堆中的obj对象                 │
│ [1] 参数a = 10                               │
│ [2] 参数b = 20                               │
│ [3] 局部变量result = 0 → 稍后被赋值为30           │
├─────────────────────────────────────────────┤
│ 操作数栈 (后进先出)                            │
│ 步骤1: 加载a (iload_1) → 栈:[10]              │
│ 步骤2: 加载b (iload_2) → 栈:[10, 20]          │
│ 步骤3: 相加 (iadd)   → 栈:[30]                │
│ 步骤4: 存储到result (istore_3) → 栈:[]        │
├─────────────────────────────────────────────┤
│ 动态链接 → 指向方法区中calculate方法的符号引用       │
├─────────────────────────────────────────────┤
│ 返回地址 → main方法中调用calculate后的下一条指令地址  │
└─────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 局部变量表

局部变量表:是一个数字数组,在编译时已经确定大小,基本单位是局部变量槽,它的作用是存放方法中编译期可知的局部变量、方法入参的值、以及对象引用,在生成该表之后,其大小不会改变(指的是槽的数目不会改变,因为不同的虚拟机实现槽的大小有不同的方式,有的用32位,有的用64位)

this:值得注意的是,局部变量表中的第一个存放的对象引用,就是该方法的实例对象,这也是为什么可以从方法内可以找到对象属性

在局部变量表里,32位以内的类型只占用一个 slot(比如 short、int 类型包括 returnAddress 类型(指向了一条字节码指令的地址)),64位的类型(long 和 double)占两个 slot

关于局部变量表的入参,如果入参是八大数据类型,会直接存放在栈帧中,毕竟这些数据类型不大,因此方法内该数据的变化不会影响到全局,如果是一个对象,则放在栈中的是对象的引用 reference,对对象的修改会影响到全局

可重用:如果当前线程计数器的值已经超过局部变量表中某个值的作用域的时候(已经执行完某个方法),那么这块区域就是可重用的,可以重新赋值。但是,这块表中的区域不会立马被回收,只有在下次访问局部变量表的该区域的时候,才会将这部分内容清理掉,也就是惰性删除。最直接的例子就是,如果在某个代码块结束之后立马进行 gc,此时在代码块中定义的变量不会被回收掉

# 操作数栈

操作数栈:数组实现,在编译时已经确定大小,作为方法调用的中转站使用,用于存放该方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量、方法返回值、方法的参数也会放在操作数栈中

每个指令在执行时会明确指定需要从操作数栈中取出多少个操作数,并将计算结果再次压入操作数栈中

数据共享:虚拟机一般对该区域进行优化,将某个栈帧中的操作数栈与其他栈帧中的局部变量表形成一个重叠区域,以提高空间利用率

在这里插入图片描述

# 动态链接

动态链接:它是指向运行时常量池中该栈帧方法的引用,它的作用是通过符号引用去运行时常量池中找到方法的具体位置

因为有些被调用的目标方法在编译期无法被确定下来到底是调用什么方法,只能够在程序运行期来根据方法的符号引用找到直接引用,这种引用转换的过程具备动态性,称为分派。关于分派见下文

其实这里还分虚方法和非虚方法,非虚方法可以不用动态链接找地址,对于非虚方法(以及那些虽然标记为虚但实际只有一个实现的方法),JIT 编译器可能会把字节码直接编译成本地机器码,然后会直接把目标方法的代码复制粘贴到当前方法里。这个叫方法内联,可以直接省略栈帧的创建和销毁过程,提高了执行效率

而一般的虚方法,则需要做解释执行,还得去常量池找地址然后跳转。其实也可以看出 JVM 的设计是为了实现多态、继承等功能才做解释执行的

由于符号引用是文字形式表示的引用关系,我们在运行时需要找到这个方法的本体。直接引用就是通过对符号引用进行解析,来获得真正的函数入口地址,也就是在运行的内存区域找到该方法字节码的起始位置

# 方法返回地址

方法返回地址:存放了调用此方法的程序计数器的值。当一个方法开始执行后,只有两种方式退出这个方法,一种是 return,一种是遇到异常,无论那种,我们都要恢复栈中调用方法的运行状态

因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等

还有一些附加信息:虚拟机可以将一些规范里没有描述的信息添加进栈帧中

# 解析和分派(如何找到方法执行入口)

在方法调用的时候,我们如何确定方法的版本呢?方法可被重载也可被重写,虚拟机是如何分清他们的呢?

事实上,所有的方法在 class 文件中都存放在方法表中,他们的目录(符号引用)存放在常量池中,我们在读取 class 问题时,会将一部分的方法的符号引用转换成直接引用,另外一部分则还是根据符号引用在运行时去查询方法入口

符号引用转换成直接引用这部分方法调用,被称为解析。只有满足以下两个条件之一的方法才可以走解析调用流程:一是私有方法,二是静态方法,这两个方法的特点是他们不可能通过继承或者其他手段重写出其他版本。这两种方法也称为非虚方法。其他的方法叫做虚方法,需要走分派流程

静态分派简单一些,指在编译期根据方法接收者的静态类型(静态类型是在编译期可知的类型)来决定调用哪个重载方法的过程。静态类型就是下图中的 Object,而实际类型则是 String。如果我们的代码中没有对应类型的方法,编译器还可以直接做转换

在这里插入图片描述

那重写又是这么实现的呢?动态分派指在运行时根据对象的实际类型来确定调用哪个方法的过程。比如 hashmap 与 AbstractMap 都有 put 方法,虚拟机怎么知道我们要使用 hashmap 中的 put 还是 AbstractMap 中的 put 方法呢?

    private void justTest() {
        Map<Integer, Integer> hashMap = new HashMap<>();
        Map<Integer, Integer> treeMap = new TreeMap<>();
    }
1
2
3
4

事实上在字节码生成的时候调用重写方法时会生成 invokevirtual 指令,该指令会干以下这些事

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型(这个是方法入参),记作 C
  • 如果在类型 C 中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出 java.lang.IllegalAccessError 异常
  • 否则未找到,就按照继承关系从下往上依次对类型 C 的各个父类进行第2步的搜索和验证过程
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常

这个过程被称为动态分派,java 的动态分派是一种单分派机制(宗量只有调用方法的类型一种),而 java 的静态分派则是多分派机制(宗量考虑到了调用方法的类型和入参类型)

虚拟机在执行这个指令的时候不会去一个个反复去搜索类型元数据,会使用一些优化手段,就是编译时在方法区生成一个虚方法表,表中存放着各个方法的实际入口地址

class A {
    public void method1() {}
    public void method2() {}
}

class B extends A {
    @Override
    public void method2() {}  // 重写
    public void method3() {}  // 新增
}

// 虚方法表(Virtual Method Table)示例:
// 每个类在方法区维护一个vtable

// A类的vtable:
┌─────────┬─────────────────────┬────────────┐
│ 索引     │ 方法签名            │ 实际入口地址 │
├─────────┼─────────────────────┼────────────┤
│ 0       │ A.method1()         │ 0x1000     │
│ 1       │ A.method2()         │ 0x2000     │
│ ...     │ ...                 │ ...        │
└─────────┴─────────────────────┴────────────┘

// B类的vtable(继承自A):
┌─────────┬─────────────────────┬────────────┐
│ 索引     │ 方法签名            │ 实际入口地址 │
├─────────┼─────────────────────┼────────────┤
│ 0       │ A.method1()         │ 0x1000     │ ← 继承,不变
│ 1       │ A.method2()         │ 0x3000     │ ← 重写,新地址
│ 2       │ B.method3()         │ 0x4000     │ ← 新增
│ ...     │ ...                 │ ...        │
└─────────┴─────────────────────┴────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

总结一下:动态分派侧重于方法调用者,用来实现重写和继承,在运行时从虚方法表找数据,静态分配侧重于方法入参,用来实现重载,在编译时根据方法类型找方法,而解析则是在运行时根据符号引用找到直接引用的过程,适用于静态方法和私有方法

# 栈可能会引发的问题

1,StackOverFlowError:有的栈不能自动增长,比如 HotSpot 实现的虚拟机栈,如果调用过多方法,就会导致栈超限

2,OutOfMemoryError:栈可以动态增长,但是在调用过多方法同时申请不到内存时,会 OOM

同时,如果为栈分配过多空间,使用多线程时创建了很多栈也会出现 OOM 的问题,当不能减少线程数目时,可以减少栈的大小或者堆的大小

# 基于栈的解释执行

先说一下虚拟机如何生成机器指令的,有两种:

1,解释执行,虚拟机读取 class 文件,读取后在运行时遇到方法时一遍读方法的指令一遍生成机器指令给电脑,电脑再去执行

2,编译执行,将一段程序直接翻译成机器码(对于 C/C++ 这种非跨平台的语言)或者中间码(Java 这种跨平台语言,需要 JVM 再将字节码编译成机器码)。编译执行是直接将所有语句都编译成了机器语言,并且保存成可执行的机器码。执行的时候,是直接进行执行机器语言,不需要再进行解释/编译。比如 JIT

而解释执行一般有两种方式:

1,基于栈的解释执行:通过一个栈暂存需要计算的中间数,这种方式的特点是实现比较偏软件,偏算法。从理论上来说,这种方式比较慢一点,并且实现相同的功能会比基于寄存器的解释执行要多,现在计算机硬件都是寄存器就证明了这一点。优点则是由于偏算法实现,因此移植性比较强,这里特指 java 虚拟机

2,基于寄存器的解释执行:通过一个栈存放指令、中间数等。快、指令少,一般需要带参数。同时因为寄存器由硬件直接提供,不可避免的需要受到硬件的约束。这里特指操作系统

# 本地方法栈

使用 native 调用的方法,为了让 java 运行非 java 语言的程序(大多数是 c 语言,比如 unsafe 类),在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一,然后使用栈帧类型来区分(有些其他的虚拟机可能不会这么实现,可能会拆开,并且为每一个栈定一个程序计数器)

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常

# 程序计数器

是线程私有的,唯一一个不会出现 OutOfMemoryError 的内存区域(因为它不会创建新的对象,里面不会存放一些杂七杂八的东西),它唯一的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,如果看过 class 文件中的常量表,应该会很好理解

它的主要作用有:

1,当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成 2,在上下文切换时,线程切换后能恢复到正确的执行位置。一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器

PC不是一个真正的计数器,而是一个指针/地址寄存器,在 JVM 中,PC 是逻辑概念:

1,执行 Java 字节码时:指向字节码指令地址 2,执行 Native 代码时:指向本地机器指令地址

// 在x86架构中:
RIP (Instruction Pointer) 寄存器 = PC
// 时刻指向下一条要执行的机器指令地址

// 示例:
public void mixedExecution() {
    // Java代码执行时:
    // PC → 字节码指令地址(如:0x00A1B2C3)
    nativeMethod();  // 切换到Native
    
    // Native代码执行时:
    // PC → 机器指令地址(如:0x7FFE12345678)
    
    // 返回Java后:
    // PC → 继续字节码地址
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 堆

GC(垃圾回收)的主要区域,又称 GC 堆

它的作用就是储存对象实例、字符串常量池(java8 之后)、static 变量。《java 虚拟机规范》中说,所有的对象实例以及数组都应该被分配到堆上。在今天看来,这句话不是绝对的(栈上分配)

堆在物理上可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展。对于一些大对象而言(比如数组),多数虚拟机为了方便,会要求存放在一个连续空间中

# 分区

堆的分区是为了方便回收内存而设计的,由于现代垃圾收集器大部分都是基于分代收集理论设计的,因此这些区域只不过是垃圾收集器的设计风格而已,而非虚拟机必须实现的内存布局

以下是分代收集理论:

新生代:又分伊甸园区和幸存者 from 区、幸存者 to 区

  • 伊甸园区:对象新建时存放的区域
  • 幸存者区:对象 GC 后没有被清除,加入这里,并且年龄加1,to 区总是空的

老年代:一般来说,对象15(这个数字可以设置)次 GC 没有被回收掉之后,进入老年区

它们的空间划分大概是这样的 在这里插入图片描述

在 JDK1.7 之前还有一个永久代,JDK8 版本之后永久代已被 Metaspace 元空间取代

# 内存分配机制

对象不一定非要在新生代分配,甚至不用在堆上分配。对象的分配有以下的规则

  • 优先在伊甸园区分配,当空间不足时会发生一次 minor GC(只对新生代进行 GC),如果 GC 后空间还是不足,多出来的对象只能放到老年代去了
  • 大对象直接放到老年代,比如数组或者长字符串什么的,这么做的目的是为了避免这些大对象在执行复制算法的时候产生大量性能消耗,以及创建和删除东西时的性能损耗
  • 活得久的进入老年代:在幸存者区小于或者等于某一年龄的对象大于幸存者区空间的一半,大于这个年龄的对象就可以直接进入老年代
  • 空间分配担保机制,就是说如果老年代的最大连续内存空间大小大于新生代对象总和或者历次平均晋升空间大小,就只会进行 minor GC(因为可能所有的新时代对象 GC 后都进入老年代,这时候需要保证老年代空间足够)否则进行 full GC,担保失败不会产生任何坏处,这么做的目的只是为了避免频繁进行 full GC

# 方法区

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的

它存储已被虚拟机加载的类信息、域信息(类中属性)、方法信息、存放编译期生成的各种字面量(Literal)、常量、静态变量和符号引用的运行时常量池、JIT 缓存代码块、方法字节码、虚方法表等。因为这些东西非常重要,是程序运行的根本,并且在程序运行的时候基本不可能新建

这里再说一下符号引用和直接引用的区别:

  • 符号引用:文本形式的描述
  • 直接引用:内存地址、偏移量。就是说这个代码或者这个属性的内存地址是多少

元空间与永久区都是方法区的实现,HotSpot 以前使用在主存中的永久区,现在改为放在本地内存里的元空间。这里的本地内存不是线程独享的那个本地内存,而是 JVM 数据区外的本地内存,这里的本地内存是由操作系统直接管理的

在这里插入图片描述

原因是堆中内存分配后无法修改,更容易触碰到最大内存上限,而本地内存中的空间与硬件配置有关,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小

元空间不可以被 GC,但是可以由 Java 虚拟机自身负责管理和释放内存空间,主要释放内存对象是常量池、热点代码等

之前的永久区会触发 OOM,原因可能是加载了大量的 jar 包或者 tomcat 部署了很多工程。元空间一般不会,因为它会自动调节大小

运行时常量池这个重量级的东西也在方法区中。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用(指向方法、属性等的引用),同时也会把翻译出来的直接引用也储存在这个池子中,这部分内容将在类加载后存放到方法区的运行时常量池中

运行时常量池还有一个特点是具有动态性,在运行时能将新的常量放入池中,比如说经常出现的 Integer 类型

# 直接内存(堆外内存)

直接内存就是不在虚拟机进程使用的内存,也叫堆外内存,不属于虚拟机运行时数据区的一部分,但是现在被频繁的使用,同时也容易出现 OOM

NIO、Netty 等技术使用到了直接内存,原理是操作系统把数据从内核态复制到用户态才能使用,直接操作堆外内存避免了赋值消耗

NIO 中的 FileChannel.map 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap 实现的。将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改

Java NIO 中提供的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个 Channel 中的数据拷贝到 FileChannel,底层调用 sendfile。该接口常被用于高效的网络/文件的数据传输和大文件拷贝

为什么会出现 OOM?比如给虚拟机设置的内存过大,把堆外内存的空间抢走了,就会出现 OOM

# 对象的生命周期

# 对象的创建

在虚拟机层面如何创建一个对象呢?

# 类加载检查

首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

# 划分内存区域

在类加载完成后,我们已经知道这个对象需要多少内存空间了,可以在堆中划分一块内存区域出来给这个对象

# 划分方式

1,碰撞指针:如果使用标记压缩算法,内存是完整的,将指针向后移动

2,空闲链表:内存空间不完整,使用链表来记录内存空间,找到可以足够存放的地方,并更新链表

这两种划分区域的方法灵感来自操作系统的内存分配策略

# 并发问题

在开发的过程中会频繁的创建对象,如果两个线程同时划分了一块内存区域就会发生对象信息被胡乱糅合的情况,JVM 会严格的控制内存划分。在这种情况下要加锁处理,如此一来就会造成分配效率下降。如果每个线程有独有的空间的话,就可以避免这种开销,因此,JVM 分配空间的流程如下:

1,在新生区(伊甸园区)为每个线程划分一个的空间,空间未满时存放在这些空间里,这些空间叫 TLAB(线程私有的缓冲区) 2,TLAB 其实时很小的一块,那肯定就会面临 TLAB 中剩余内存比申请对象小的问题。JVM 是这样处理的,它有一个比例值,当 TLAB 中空间小于该比例值的时候,会放弃当前的 TLAB 重建一个新的来分配 3,如果私有空间满了,为了安全应该上锁并分配内存,虚拟机使用 CAS(乐观锁的一种实现)加上重试的方式来分配内存。而且太大的对象是不会使用到 TLAB 的,因为塞不下

乐观锁:希望操作完成后没有并发问题,如果有就重做或撤销操作

虽然每个线程在初始化时都会去堆内存中申请一块 TLAB,并不是说这个 TLAB 区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已

并且,在 TLAB 分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过 TLAB 分配内存,存放在 Eden 区,但是还是会被垃圾回收或者被移到 Survivor Space、Old Gen 等

# 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)

这一步保证了对象不赋值就可以被访问到,这个过程其实非常有用,比如在 Spring 中循环依赖的时候

# 设置对象头

对象头主要包括三类信息:MarkWord、Klass Pointer(类型指针)、数组的长度(只有数组里有)

MarkWord 中可能包括锁信息、hashcode(不同对象可以有相同的 code)、分代年龄等。MarkWord 可以动态变化的,虚拟机读不同对象的 MarkWord 有不同的方式,类似与 Linux 中的 stat、mysql 里的页头,用一个相对较少的数据块,去管理一个相对较多的整体数据

Mark Word 在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit,同时,Mark Word 在不同的锁状态下存储的内容不同。以下是一个 64 位 JVM 的 Mark Word 在这里插入图片描述

类型指针是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

jdk8 版本默认开启指针压缩的,在压缩之前类型指针占 8 字节,压缩之后只占 4 字节

另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小

# 初始化

初始化就是执行初始化 init 方法,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,还有很多方法没有执行(比如构造方法,在初始化之后才会执行)

1,在 new 对象之后,构造方法和方法块之前,虚拟机会为实例变量赋上类型初始值,也就是变量初始化过程 2,方法执行顺序为:父类初始化 -> 变量赋值 -> 自身的代码块 -> 自身的构造方法(构造方法在初始化步骤中不执行)

    // 这个想要在类实例化的时候进行赋值
    private String nike = "nike";

    // 构造方法
    public InitClassStu(String name) {
        this.nike = name;
    }

	// 静态方法
    static {
        nike = "1";
    }
    
    // 实例"{}" 在生成<init>构造器时优先于构造函数
    {
        nike = "1";
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

图中的静态代码块只在类加载时执行一次 在这里插入图片描述

3,代码块与变量赋值都是由编译器按照从上到下的顺序执行的,执行完代码块和变量赋值后,才会进入构造方法

# 对象的内存布局

  • 对象头:运行时数据加类型指针,MarkWord 占8字节,类型指针占4字节,一共12字节
  • 实例数据:包括此对象中有多少数据类型以及类型指针,如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如 boolean 类型占1个字节,int类型占4个字节等等
  • 对齐填充:虚拟机规定对象必须是 8 字节的倍数(对象头已经被设计成 8 字节的倍数)如果一个对象不满 8 字节,就必须使用对齐填充。如果没有达到则自动填充

为什么需要对齐填充呢?因为 64 位系统一次获取 64 位数据,32 位系统一次读取 32 位数据。对齐填充的目的是让字段只出现在同一 CPU 的缓存行中,缓存行为 8 字节。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。当缓存行被拆分的时候,我们寻找下一个对象就会变得比较困难,对齐填充是指在对象的字段之间填充一些额外的字节,使得每个字段都处于特定的内存地址上。这种技术主要用于提高CPU访问数据的效率

# 对象和类的查找

对象放在堆中,类的信息放在方法区里,那么这两块数据是如何拼在一起的呢

1,使用句柄:栈帧中的引用存放的是句柄地址,堆中会有专门存放句柄的区域,句柄中存放对象地址(放在堆)和类型地址(放在方法区) 在这里插入图片描述

使用句柄的方式多使用了内存做储存

2,直接指针:引用指向堆中对象地址,对象头中放置访问类型数据的相关信息,目前 java 采用这种方式

使用直接指针的话,在垃圾回收对象位置发生改变的时候,会有很多的引用修改,不像句柄一样只需要改一处地方就可以了。但是使用直接指针访问速度更快,不用像句柄一样二次查找

# 判断对象是否死亡

  • 哪些对象需要回收?
  • 什么时候回收?
  • 怎么回收?

1,引用计数法:在对象中添加一个引用计数器,使用一次该对象引用记录器加1,当引用失效,计数器减1,如果该对象引用计数器为0则通知回收器进行回收,缺点是如果对象相互调用,就不能判断

那何为引用失效?比如 Integer a = new XXX,然后将这个 a 置为 null,此时这个对象的引用就断掉了,根据这个思路可以引申出更好的算法

2,可达性分析算法:设置一些对象为 GC Roots,GC Roots 引用的对象与它们引用的对象称为可达对象,其他对象为不可达对象,不可达对象会被干掉,目前 java 采用这种方式

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象(static)
  • 方法区中常量引用的对象(final)
  • 所有被同步锁持有的对象

那对象有没有复活甲呢?有!但对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要放入终结队列。否则对象会在这个队列中会进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收

# GC(对象的回收)

# GC 回收算法

在上古时期的垃圾收集器遵循一个重要的理论,分代收集理论:根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

这个理论主要建立在强分代假说与弱分代假说上,强即熬过越多次收集的对象就越难消亡,弱即大多数对象都是朝生夕死的

因此,收集器给出了一个思路,即分区域储存与回收

1,复制收集算法:将区域分为两部分,对存活的对象进行标记,之后复制到另一边,清空原来的空间。优点是没有内存碎片,如果垃圾比较多时运行高效,缺点是需要两倍的内存空间

2,标记回收算法:首先根据算法把所有存活的对象标记出来,将使用的标记出来,第二次,遍历整个堆对象将未标记的对象回收。因为需要遍历整个堆所以效率比较低,并且回收后会产生内存碎片。而这里的”回收“是指把未标记的内存区域记录在一个空闲链表中,事实上在进行分配时对老对象进行覆盖

3,标记压缩算法:同上,并将回收后的区域进行整理,是得碎片空间减少,相比于复制算法,内存利用率比较高。从效率上来说,比回收算法低,并且移动对象时会 STW

还有一种奇怪的解决方案,比如先不进行压缩,等待内存碎片影响到对象分配的时候才进行压缩,CMS 就是采用这种方法

# GC 分类

主要分为两大类:

Partial GC:收集部分

  • Young GC(Minor GC):伊甸园区满了会发生(幸存者区不会触发 Young GC),收集整个新生区
  • Old GC(Major GC):老年代满了会发生,只收集老年代,只有 CMS 使用
  • Mixed GC:对整个新生代和部分老年代进行垃圾收集,只有 G1 使用

Full GC:收集整个堆,老年代空间不足时会触发(比如新生代的晋升对象大于老年代剩余容量的时候或者分配了大量大对象的时候)、或者调用 System.gc() 时会触发(System.gc 方法事实上调用了 Runtime.getRuntime.gc 方法,并且不是百分百会进行 gc)、空间分配担保机制失败时(对象进行 gc 前,jvm 会优先计算目前老年代允许存放的空间大小和当前最多可能进入老年代的所有对象大小,如果老年代空间小于可能进入的所有对象大小的话空间担保就失败了)会触发。full GC 扫描范围大,存活对象多,导致 STW 时间更长,并且老年代对象存活率高,严重影响程序运行速度,所以我们要避免 full GC

如果你听过 major GC,那它通常是指 Full GC,但是你一定要问他指的是 Full GC 还是 Old GC,因为现在大家的理解挺五花八门的

# GC 收集器

当所有线程到达全局安全点(在这个时间点上没有正在执行的字节码)或者安全区域的时候,GC 线程主动式中断并开始工作,jdk 虽然有自己默认的收集器,但是我们需要具备在不同情况下使用合适的 GC 收集器的能力

GC 主要注重两个性能指标:

1,一个是吞吐量(吞吐量越大越好,注重吞吐量代表高效利用 CPU 处理能力,一般是在后台运算并且不需要和用户交互的程序需要提高吞吐量,吞吐量是用户线程执行时间/程序执行时间,高吞吐量意味着每次垃圾收集的时间会比较长) 2,一个是暂停时间(越短越好,代表用户的体验,用户可能不会在意10次20ms的暂停,但是一定会注意一次200ms的暂停)

不同的垃圾收集器对堆内存的划分方式,这也导致了有些垃圾收集器无法一起使用

1,Serial:应用程序停止(STW"Stop The World"),GC 单线程执行,使用复制算法,优点是只有单核时性能好、开销小,收集新生代 2,Serial Old:单线程收集老年代,使用压缩算法

3,ParNew:应用程序停止,GC 线程并行执行,使用复制算法 4,CMS:重视停顿时间,并行执行,使用清除算法,达到阈值会触发,如果 GC 失败有后续方案(Serial Old),收集老年代。CMS 发生 Concurrent Mode Failure(空间不足)时,系统处于非常脆弱的临界状态。在这种紧急情况下,设计者做出了保守但安全的选择,使用 Serial Old 回收(不使用并发收集也可能是技术债导致)

5,PS(Parallel Scavenge):重视吞吐量,使用复制算法,和 ParNew 差不多,但是使用的底层框架不一样 6,Parallel Old:收集老年代,使用压缩算法,在jdk8中,默认使用这两个组合

7,G1:注重低延迟下提高 CPU 性能的收集器,一般用在大容量内存的服务端,同时收集新生代和老年代,整体整理算法实现,局部复制算法实现

8,Shenandoah:Openjdk 中的新生代 GC 9,ZGC:实验性的性能较好的官方 GC

在选择垃圾收集器的时候,应该考虑主机系统,Java 版本,程序主要关注点是什么,具体情况具体分析

同时,在处理虚拟机内存问题的时候还需要会阅读虚拟机的日志,在 java9 之前读取日志的参数比较混乱,不过现在只要知道处理内存问题的时候知道要去找日志就行了

# CMS 的过程

CMS 是收集老年代的数据的,CMS 的核心思想就是增量收集算法,指收集垃圾和用户程序交互进行

  • 1,STW,标记与 GC Roots 直接相连的对象,过程很快
  • 2,开启其他线程与 GC 线程,找到所有有连接的对象
  • 3,STW,由于上一步其他线程的执行,无法找到所有的对象,现在重新标记,由此修正浮动垃圾
  • 4,开启其他线程,并且进行回收。因为是标记清理算法,所以不需要 stw

这里强调一下步骤三,因为步骤二是和其他的线程并发执行的,因此有可能在某个对象被标记之后,业务线程重新引用该对象,如果此时直接清理垃圾,会造成严重后果(被引用对象置为 null)。因此我们得重新扫一遍,这里面也有实现细节,我们当然不可能全部都扫一遍

垃圾收集器依据可达性分析算法判断对象是否存活时,将遍历 GC Roots 过程中遇到的对象,按照是否访问过这个条件,我们把对象标记成白色(white)、灰色(gray)、黑色(black)三种颜色,这个标记过程称为三色标记法

  • 白色:表示对象没有被垃圾收集器访问过。在可达性分析开始阶段,所有的对象都是白色的,在分析结束阶段,白色对象即代表不可达
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,安全存活的
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

我们记录在步骤二发生时,黑色对象插入新的指向白色对象的引用,或者灰色对象要删除指向白色对象的引用关系。这些记录会在步骤三重新扫一遍,这里的正确性讲解略过

# CMS 的弊端

  • 1,使用清除算法,产生大量碎片空间
  • 2,并发标记阶段无法标记所有的浮动垃圾,因此会频繁触动 CMS

由于每个弊端都是致命的,在 jdk14 中,CMS 彻底废弃

# G First 的特点是什么

G1 采用区域化分代式,核心思想是分区算法,将整个堆分为多个(2的 x 次方个) Region(区域),每个区域既可以是新生代,又可以是老年代或者 Humingous(专门用来储存大对象)

可预测停顿时间模型指,G1 在允许运行时间下收集优先链表中价值最大的区域,同时 G1 的期望暂停时间可以设定(希望 stw 在多少毫秒内),但是设定过小会影响吞吐量

G1 在 region 之间使用的是复制算法,从整体上看又是标记压缩算法。会使用记忆集来确记录对象被其他不同区域的对象引用

具体的回收流程:

  • 1,初始标记(Initial Mark)- STW:标记所有 GC Roots 直接可达的对象
  • 2,根区域扫描:扫描初始标记中标记的 Survivor Region
  • 3,并发标记:标记整个堆的存活对象。在这个时候记忆集是会维护的
  • 4,计算每个 Region 垃圾比例
  • 5,回收选择:根据期望暂停时间,回收价值较高的区域
  • 6,转移/清理 - STW。g1 比不过 zgc 的原因就在这里,因为使用标记复制算法转移对象是需要 stw 的

这时候有小伙伴要问了,这样的话 g1 每次回收不都类似 full gc?其实不然,G1 有三种回收模式,Young GC 仅年轻代 Region,Mixed GC 回收所有年轻代 + 部分老年代,最后是 Full GC,只有晋升失败、巨型对象分配失败、空间担保机制失败、显式 System.gc 等情况是才会执行

# ZGC

在 JDK16中,ZGC 的停顿时间已经为常数。正常情况下,ZGC 停顿时间(STW)不会超过1ms,约为0.5ms。不超过10ms 将成为历史,上 JDK17,使用 ZGC,体验飞一样的感觉!

在这里插入图片描述

ZGC 的流程很像 CMS 以及 G1 的升级版:

1,初始标记根节点 GC Roots,STW 的时间很短

2,并发标记阶段,此时是标记指针,也就是指针着色技术(记录堆和栈里的每个指针是否已经被标记,会记录本次 GC 周期标记的存活对象,上次 GC 周期标记的存活对象、对象是否已重定位等信息)。这就与传统的三色标记对象的 GC 算法有非常大的区别,虽然两者从收敛性上看是等价的——最终所有所有指针都会被遍历过

3,再标记,和 CMS 一样,停顿时间也很短

4,并发转移准备,计算存活对象的转移目标地址。zgc 也和 g1 一样使用区域存放数据,跨代引用也使用了记忆集来处理

5,并发转移,这个过程将对象从旧地址移动到新地址(通过多重映射快速完成,多重映射指的是多个虚拟指针指向一个物理内存空间)。读屏障拦截旧指针访问,并返回新地址

在标记和移动对象的阶段,每次从 GC 堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。最简单的事情就是,在标记阶段它会把指针标记上并把堆里的这个指针给修正到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针修正到原本的字段里。这样就算 GC 把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过 stop-the-world 这种最粗粒度的同步方式来让 GC 与应用之间同步

LVB 中有一点很重要,就是 self healing 性质:如果堆上有指针当前处于尚未更新的状态,一旦经过 LVB 之后就会被就地更新,于是在同一个 GC 周期内再次访问这个字段的话就不需要再修正了。这样 LVB 带来的性能开销(吞吐量的下降)就是非常短暂的,而不像 Shenandoah GC 所使用的 Brooks indirection pointer 那样一直都慢

并发转移后,ZGC 的工作就结束了。有些同学就有疑惑了,ZGC 只回收了老的引用地址,对象还是存在的,对象什么时候回收呢?事实上 ZGC 通过内存映射解除和空闲池管理自动回收内存,无需单独清除对象。内存映射解除就是如果一个对象没有引用了,那它就需要被回收了,空闲池指 ZGC 维护一个全局空闲内存列表,回收的内存(旧地址区域)会被加入该列表

# HotSpot 算法实现细节

我们现在知道垃圾收集算法是概念,垃圾收集器是各种不同的实现,那 HotSpot 的具体实现细节是什么呢,它在对于一些不可避免的开销(比如 STW)时是如何做的呢

# 根节点枚举

不同的收集器在根节点枚举阶段一定会 STW,就算有些收集器将时间较长的追踪引用的过程与用户线程一起并发执行也不可避免枚举,为了优化这一过程的时间,虚拟机想了很多办法

现在的问题是根节点枚举必须在一个保证一致性的快照中才能进行(因为线程的运行可能会修改引用),因此必须 STW,那我想 STW 的时候有线程正在创建对象怎么办,打断它,不太可能吧,一般只能等,这个等待的时间能否优化掉呢?而且将每一个 GC Roots 遍历一遍时间过长,能否也优化一下?

一是虚拟机用空间换时间,用一组称为 OopMap 的数据结构来存放所有的 GC Roots,比如在某个方法中引用了哪个对象,或者在静态变量被加载的时候,虚拟机会把这些东西直接放在 OopMap 中,这样就能直接拿到 GC Roots 了

但是由于改变 OopMap 的语句非常多,虚拟机适当的减少了这个过程,它设定了一个安全点,只有在这个安全点的时候才会修改 OopMap。而只有在循环、抛出异常、方法调用的时候才可能会生成这个安全点,因为这种时候既不会创建对象也不会引用对象

二是优化让所有的线程都在一个一致性状态的时间消耗。这时候安全点就发挥作用了,只有在这个安全点的时候才会修改 OopMap,那在这个安全点中生成一致性快照也没问题,现在问题就变成了如何让所有线程跑到这个安全点上

正常的程序员会怎么解决?当需要 GC 时通知所有线程跑到最近的安全点上即可,这样有两种解法,一是直接中断所有线程,再让没有在安全点的线程恢复跑到安全点,这就叫抢先式中断,中断和恢复的代价还是有一点的。一般不会这么用

第二种方法是设置一个标志位,程序要中断时将此标志位改变,所有的线程在到达安全点时都会检查一次标志位,如果判断成功直接暂停,这就叫主动式中断,一般都使用这个

还有一个小小小问题,就是一些线程可能在 sleep,此时它无法响应虚拟机,也没法到达安全点,那怎么办?解决方法是设置安全区域,在该区域时 OopMap 一定不会改变。当程序进入安全区域时,设置标识位直到离开,虚拟机看到这个标识位就不会去管这个线程了

# 跨代引用

跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。这样 YGC 时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费

记忆集就是用来记录跨代引用的表,为每一个区域(一块连续的内存空间)分配一个记忆集,通过引入记忆集避免遍历所有的区域。记忆集时抽象的,卡表是记忆集的一种实现

zgc、g1、cms 都用了记忆集机制,cms 的卡表用于保存老年代和新生代之间的跨代引用

如果在该记忆集对应的对象有被其他代的对象引用时,该记忆集就存放该次引用的数据(我们可能想到在对象修改引用的时候加一个 AOP 操作以修改记忆集,事实上虚拟机也是这么做的,这种操作叫写屏障)

使用一个数组来存放所有的记忆集,在区域进行垃圾收集的时候,对该区域记忆集中的数据进行遍历,过滤掉记忆集中包含的对象进行了

记忆集是需要维护的,在并发标记阶段会扫描记忆集指向的对象,如果引用者已死,清理记忆集条目,这是延迟清理机制。同时我们还有全局并发清理机制,专门的清理线程定期扫描检查所有记忆集条目,验证引用是否存活,以防止记忆集膨胀

#JVM
最后更新: 3/1/2026, 3:55:02 PM
虚拟机执行子系统
Linux 中 JVM 常用工具以及常见问题解决思路

← 虚拟机执行子系统 Linux 中 JVM 常用工具以及常见问题解决思路→

最近更新
01
vibe coding 最佳实践
02-24
02
立直麻将牌效益理论
02-23
03
伪静态是什么
02-08
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式