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

    • 虚拟机执行子系统
      • 前端编译器与后端编译器
        • javac 前端编译器流程
        • 即时编译器
        • 即时编译器的优化
        • 方法内联
        • 逃逸分析
      • 提前编译
      • class 文件结构
        • 数据结构
        • class 文件的构成
        • 常量池
        • 字段表与方法表
        • 属性表
        • 总结
      • 类装载子系统
        • 加载
        • 连接
        • 验证
        • 准备
        • 解析
        • 初始化
        • 使用
        • 卸载
      • 类加载过程与类加载器
        • 类加载器
        • 双亲委托机制
        • 用户自定义类加载器
    • JVM 自动内存管理
    • 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:从灵光一现到系统化增长的实战指南
    • 观罗翔讲刑法随笔
    • 价格和价值
    • 立直麻将牌效益理论
    • 梅花易数学习笔记
    • 压力管理
2022-08-18
JVM
目录

虚拟机执行子系统

虚拟机最特殊的地方就是它是一个平台,其他语言也可以使用相应的编译器编译为 class 文件,放到虚拟机上执行。而 java 的跨平台特性,也是因为虚拟机这个平台可以将 class 文件解读为不同的01字节流

那编译器到底是如何编译的呢,编译后的 class 文件又有哪些特性呢

# 前端编译器与后端编译器

前端编译器作用过程是将数据从 java 代码转换到 class 代码,后端编译器则作于与直接将虚拟机中的数据编译为机器指令

# javac 前端编译器流程

1,解析与填充符号表

解析包含词法分析和语法分析,词法分析指将我们认为的最小元素字符转换为标记(编译过程的最小元素)的过程,语法分析则是将读出来的标记组成抽象语法树 AST 的过程(每个节点代表一个语法结构,如包、类型、修饰符)

抽象语法树又是什么,他是以树的形式表现代码的,也许我们读取这颗树觉得费劲,但是计算机不这么认为,如下图,我们看看表达式 1+3*(4-1)+2 转换成树之后的样子

在这里插入图片描述 上图是举个例子,最终的树是这个样子:

while b != 0
{
    if a > b
        a = a-b
    else
        b = b-a
}
return a
1
2
3
4
5
6
7
8

在这里插入图片描述

填充符号表,符号表是由一组符号地址和符号信息构成的表格,可以理解成 hashmap

2,插入式注解处理器

注解处理器在运行期间发挥作用,在编译期间可对注解进行处理,可看做编译器的插件,可对抽象语法树进行修改。注解处理器对语法树进行修改后,编译器回到解析和填充符号表重新处理。我们著名的 Lombok,就是在这时候发挥作用的(比如@Getter,在这里会生成 get 方法)

3,解语法糖、语义分析与字节码生成

标注检查(标记、注解检查)变量使用前是否已被声明、变量与赋值之间数据类型是否匹配、折叠。数据及控制流分析,编译期间的数据集控制流分析与类加载时的数据及控制流分析基本一致

最后编译为了虚拟机可读的 class 文件

# 即时编译器

在虚拟机读取字节码的时候一般是通过解释器与编译器来转换成机器语言的。虚拟机执行一开始,都是通过解释器编译的,随着时间的经过与虚拟机发现某个代码段执行的特别频繁,会逐渐使用编译器来执行代码。此时调用方法栈中的动态链接,进入代码的地点有所不同,它会进入已经是二进制代码的地方,这种方式叫栈上替换

这个编译器就是即时编译器,也称 JIT 编译器(just in time)。JVM 集成的编译器有两种,客户端编译器以及服务端编译器

客户端编译器注重启动速度和局部优化,HotSpot VM 使用的是 Client Compiler C1编译器,简称 C1编译器

服务端编译器注重全局优化,运行过程中性能更好,会对代码做很多优化,由于进行更多的全局分析,所以启动速度会变慢。Hotspot VM 使用有两种:C2编译器(默认)

C1、C2 都有各自的优缺点,为了综合两者的优势,在编译速度和执行效率之间取得平衡,JVM 引入了一种策略:分层编译。简单来说,就是从一开始的只使用解释器执行代码,慢慢过渡到客户端编译器以及服务端编译器。如果发生加载了新类等需要修改代码的情况,就会从编译器逆优化到解释状态进行执行

这个慢慢过渡是指虚拟机会发现热点代码并将这些代码使用编译器编译。具体过程是 JVM 设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值,就会被编译,存入 CodeCache

计算一段方法与代码块执行次数的叫计数器,有分方法调用计数器以及回边计数器,而客户端编译器的默认阈值是1500次,服务端的默认阈值是10000次

除了 JIT 之外还有 AOT(Ahead-of-Time)编译:在程序运行之前,将源代码(或字节码)编译成机器码。例如,传统的 C/C++编译就是 AOT 编译,Java 的 GraalVM Native Image 也是 AOT 编译,JIT 和 AOT 是互补的存在

PLAB(Promotion Local Allocation Buffer) 是 Java 垃圾回收器中的一种优化机制,主要用于 G1 垃圾收集器,目的是提高对象晋升(Promotion)到老年代时的效率。 在垃圾回收过程中,新生代中的某些对象由于生命周期较长,会被晋升到老年代。 为了减少线程竞争和提升晋升效率,G1 为每个 GC 线程分配一个局部缓冲区,称为 PLAB

# 即时编译器的优化

编译器的难点不在能不能成功把字节码翻译成机器码,而是输出的代码质量高低,即时编译器对需要翻译的字节码做了很多优化,注意,以下的优化只在即时编译器中,解释器是没有这些功能的

为什么解释器不能触发逃逸分析?逃逸分析是指某个对象是否逃出方法内部,解释器会逐个解释虚拟机内部编码,那逃逸分析不应该是解释器做的吗

事实上解释器会逐行执行字节码,不生成原生机器码,直接解释执行。仅关注当前执行的指令,无法分析方法的完整控制流或对象生命周期

而编译器有将热点代码编译为机器码的功能,在编译过程中能分析整个方法(甚至调用链)的上下文(统计热点代码时需要),具有全图视角,所以这些优化都是在编译器里做的

并且这些优化技术只是其中最重要的一部分,还有很多容易理解但是不好实现的功能比如乐观空值断言、常量折叠、重组等都没有提到

# 方法内联

方法内联就是把调用方函数代码复制到调用方函数中,减少因函数调用开销的技术。这项技术避免了生成一些不必要的栈帧,java8 推荐使用 lambda 表达式也是这个原因,该优化基本是其他优化的基础

虽然 JIT 号称可以针对代码全局的运行情况而优化,但是 JIT 对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果。因为在运行时可以内联的方法必然要在编译期被编译,但是动态分派等技术让虚拟机无法确认方法的版本号。虽然虚拟机对这些情况做了一些优化,不过还是不能覆盖所有情况

# 逃逸分析

我们知道对象经常在方法中分配,而且在方法中分配的对象大部分不会被其他的方法访问到,同时如果在堆中分配这个对象还需要划分内存区域、判断对象引用、根节点标记等一系列费时费力的操作。如果栈上分配内存的话可以免除这些过程。逃逸分析技术应运而生

逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。可以逃逸出函数体指的是在方法内被定义的对象在方法外可以被访问到,例如作为参数被传到其他方法中,这种叫方法逃逸。甚至有些可以被其他的线程访问到,这种被称为线程逃逸

对于不同的逃逸程度,虚拟机有不同的优化方式:

栈上分配:如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁 请添加图片描述

对于大量的零散小对象,栈上分配提供了一种很好的对象分配策略,栈上分配的速度快,并且可以有效地避免垃圾回收带来的负面的影响,但由于和堆空间相比,栈空间比较小,因此对于大对象无法也不适合在栈上进行分配

标量替换:标量是一个无法再分配成更小的数据,而聚合量即是可以进行拆解的数据,比如 java 在中的对象就是一个典型的聚合量。标量替换的核心思想是将对象的某些字段直接存储在寄存器或栈中,而不是在堆内存中分配整个对象

同步消除:不会被其他线程访问到的同步代码会进行锁消除,这是 synchronous 的底层优化之一

# 提前编译

虚拟机有很多种方式执行字节码,编译执行和解释执行。java 在云原生时代遇到一个问题,应用程序的启动通常需要进行大量的类加载和动态代理生成等操作,这些操作会消耗大量的时间和内存,从而导致应用程序的启动速度较慢。在云原生环境中,应用程序的部署和扩展需要快速响应,因此,应用程序的启动速度成为了一项关键的指标

为了处理这个问题,java 引入了 AOT(Ahead-of-Time)

// Java程序执行流程(AOT)
源代码 (..java) → 编译 → 字节码 (.class) → AOT编译(虚拟机启动前就编译成机器码) → 原生可执行文件 → 直接运行
1
2

在虚拟机启动前就编译成机器码,极大的增加了启动速度。简单介绍一下,为了实现 AOT java 需要处理什么问题

封闭世界假设:AOT 编译时必须知道所有可能的代码路径,也就是一些涉及到动态分派、反射、动态代理等等的代码,无法使用 AOT 优化

    public void load() {
        // 运行时才确定类名
        String className = System.getenv("CLASS_NAME");
        Class<?> clazz = Class.forName(className);  // AOT时不知道要加载什么类
        Object instance = clazz.newInstance();
    }
1
2
3
4
5
6

# class 文件结构

# 数据结构

在这里插入图片描述 这是我从网上找来的图,class 文件由无符号数与表两个数据类型组成,表由无符号数与表两个数据类型组成。因此 class 文件可以看作就是表

无符号数是基本的数据类型,分 u1,u2,u3,u4,代表了该数据所占几个字节,u1 占一个字节,u2 占两个

表一般以 _info 结尾,长度不确定,因此一般需要一个无符号数来确定大小,比如 interfaces_count 确定了 interfaces 的大小

# class 文件的构成

每个 class 文件开头都有 0xCAFEBABY 的魔数,这个数用来确定这个 class 文件是合法的可以被虚拟机读取的 class 文件,如果将一个文件结尾命名为 class 也会被虚拟机读取,因此需要一些检测机制。在消息传输的时候也有类似操作

第二个是版本号,java 在不断的更新版本,新版本 jdk 可以执行老版本的 class 文件,但是老版本 jdk 不能执行新版本 class 文件,因此需要一个版本号来确定

随后跟着的是常量池的大小以及常量池

access_flags 是访问标志,用于识别接口或者类的访问信息,比如 ACC_PUBLIC,ACC_FINAL等。你可能会问访问标志可能有多个,怎么使用一个 u2 就能表示所有的访问标识呢?很简单,把他们的标识数加起来就行了 在这里插入图片描述

随后是类索引、父类索引、接口索引集合,这些索引都是指向常量池里的东西的,具体来说,是指向 CONSTANT_Class_info 的

# 常量池

常量池是存放字面量和符号引用的池子,类似与资源库,常量池表如下 在这里插入图片描述 通过这张表就可以读常量池了

符号引用指的是包、类和接口的全限定名、方法名称与描述等。因为在编译阶段不可能知道方法字段在内存中的布局,只有虚拟机读取时才会生成真正的直接引用,这些东西没有确定的地址,总得把他们标记出来好方便以后的查找吧,主要包含以下几种:

  • 字段、方法的名称和描述符
  • 类和接口的全限定名
  • 被模块导出或者开放的包
  • 方法句柄和方法类型

注意,这里放着的不是真正的方法或者属性,它只是个引用,你可以看到几乎 java 的所有东西都在这里面有对应的引用,八大基础类型对应前二到五种(boo、short 等不满4字节的全部用 Integer_info 表示),名称是 Utf8_info,方法与类也有对应的东西

字面量接近我们 java 语言层面的常量概念,指文本字符串、被声明为 final 的常量值等

一个常量池的例子如下: 在这里插入图片描述 在这里插入图片描述

# 字段表与方法表

字段表用于表述接口或者类中声明的变量,方法表表示的就是方法,两个非常相似

以字段表为例。它需要表述的信息有修饰符的作用域、是实例还是类变量(static)、可变性(final)、是否被序列化(transient)、数据类型(基本类型、对象、数组)等。这些信息里,修饰符都是可以用标志位来表示的,而字段的名称、字段的数据类型,都只能用常量池的字面量来表示

类型 描述 备注
u2 access_flags 记录字段的访问标志
u2 name_index 常量池中的索引项,指定字段的名称
u2 descriptor_index 常量池中的索引项,指定字段的描述符
u2 attributes_count attributes包含的项目数
attribute_info attributes[attributes_count] 标志位

descriptor_index 指的就是数据类型,这个索引指向常量池中的值

那 attributes_count 与 attribute_info 又是个什么鬼?这就是属性表,比如在字段中设定了默认值(被 final 修饰的值),attribute_info 就会指向这个默认值,不过还是用在方法上比较多,因为方法里面的代码逻辑就是存放在这里的。access_flags 可能的值如下,它一般指的是 public、static 等标志

权限名称 值 描述
ACC_PUBLIC 0x0001 public
ACC_PRIVATE 0x0002 private
ACC_PROTECTED 0x0004 protected
ACC_STATIC 0x0008 static,静态
ACC_FINAL 0x0010 final
ACC_VOLATILE 0x0040 volatile,不可和ACC_FIANL一起使用
ACC_TRANSIENT 0x0080 在序列化中被忽略的字段
ACC_SYNTHETIC 0x1000 由编译器产生,不存在于源代码中
ACC_ENUM 0x4000 enum

# 属性表

属性表中可以存放多个动态的属性,只要设置了表的大小,它不对表里的东西有严格的顺序限制,只要不与已有的属性名重复即可。最具代表的就是 code 方法表了。属性表包含的部分属性如下

属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量值
Deprecated 类、方法表、字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
InnerClasses 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述

每个属性肯定要一个统一的结构标志大小、是什么属性、所含的内容的,因此属性表结构如下

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

选择一个最具代表性的表 code,看看它的部分内容是什么

类型 名称 含义
u2 attribute_name_index 属性名称索引
u4 attribute_length 属性长度
u2 max_stack 操作数栈深度的最大值
u2 max_locals 局部变量表所需的存储空间
u4 code_length 字节码长度
u1 code[code_length] 存储字节码指令的一系列字节流
u2 exception_table_length 异常表长度
...

max_stack 是指执行该方法的操作数栈深度的最大值,操作数栈是用来存放方法执行过程中的暂时数据。比如有个指令叫 aload_0,这个指令含义是将第0个变量槽的引用类型数据推送到操作数栈顶,其他的操作也是如此,有些操作需要参数,这些参数在被操作的时候会被放在操作数栈中,是不是和计算机 CPU 中的寄存器有点像?

max_locals 则是局部变量表所需的存储空间,code 就是方法的字节流了,异常表在我的另一篇博客中有介绍,而其他的属性可以省略,不是非常重要

综上,属性表被其他的表所使用,用来描述例如方法流程等信息

# 总结

class 文件由紧密的二进制流组成,为了可以正常读取必须按一定顺序存放数据,用无符号数来表示各种属性与表示表的大小。无论如何,一个类的所有信息都包含在了对应的 class 文件中

由于无符号数有字节的限制,因此 java 才有一些大小限制条件,比如方法名、字段名、方法长度的限制

class 文件只是一串二进制字节流,它可以是磁盘中的文件,也可以是网络传进来的数据、数据库读取进来的文件,也能是虚拟机运行时动态生成的

# 类装载子系统

Class 文件需要加载到虚拟机中之后才能运行和使用,对象有对象的生命周期,你可以把类的从装载到消亡的过程看作类的生命周期

除了解析这一步,其他步骤都是按顺序开始的,这里说开始是因为不同的过程可以在同一时间执行,某一过程没有结束下一个过程就可能开始了

类的生命周期有以下几步

# 加载

将 class 字节码文件(或者其他的)由类加载器加载到内存中,生成这个类的 class 对象,也就是在方法区中定义该类(此时仅仅让虚拟机知道有这个类,此时这个类没有内存没有数据)

但是,加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了

这一步使用到了类加载器和双亲委托机制,加载主要完成下面两件事情:JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口

# 连接

连接由三步组成,分别是验证、准备、解析

# 验证

验证读取进内存的 class 文件是否有错,又分文件结构验证(是否以 cafebaby 开头等等)、符号引用验证、字节码验证等

# 准备

准备阶段是正式为类变量分配内存并设置类变量(static 变量)初始值的阶段,对,准备阶段只干这个事

在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。在之后的 < clint > 方法中才会对其进行赋值

值得注意的是,jdk1.7之前 static 的变量是分配在永久代中的,jdk1.7之后它会被分配在堆中

如果变量同时被 static 与 final 修饰,编译时常量会在此时进行赋值,非编译时常量(调用了一些方法的)需要在运行时赋值

# 解析

如果说准备是对字段划分区域,解析就是为方法划分区域

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。在这一阶段会进行访问符的判断,同时对一个符号引用进行多次解析也是有可能的

解析的条件是方法在真正运行之前就有一个确定的版本(不确定的版本指的是重写、重载等,这些用分派来解决),比如静态方法与私有方法,我们在该阶段得到了这些方法的地址

符号引用只是一些符号,包含在字节码文件的常量池中,它主要包括在该类中,出现过的各类包,类,接口,字段,方法等元素的全限定名,通过这些字符串,我们可以唯一定位一个元素

而直接引用就是直接指向目标的指针、相对偏移量,通过直接引用能直接找到对应方法或者变量的值

# 初始化

对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化(只有主动去使用类才会初始化类,惰性初始化),比如以下条件:

1,当 jvm 执行 new、static 相关指令(调用静态方法、访问类的静态变量、给静态变量赋值)时会初始化类。即当程序创建一个类的实例对象 2,使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname ,newInstance 等等。如果类没初始化,需要触发其初始化 3,初始化一个类,如果其父类还未初始化,则先触发该父类的初始化 4,当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类

类被主动使用的时候才会初始化,其他的情况都叫被动使用,比如子类调用父类的静态变量,此时子类没有初始化。简单来说类的初始化是惰性的

而初始化就是调用类构造器的 clinit 方法,该方法是编译器的自动生成的产物,该方法有以下特点:

1,clinit 方法是由编译器按照从上到下的顺序收集的类中声明的静态变量和静态代码块合并组成的方法 2,虚拟机会先执行父类的 clinit 方法,之后再执行子类的 clinit 方法,clinit 与类的构造函数(虚拟机视角的 init)不同,不需要显示调用父类构造器 3,执行 clinit 过程中如果需要访问其他类(包括子类)的内容会去执行这个类的 clinit 后再继续执行(加载子类而父类的 clinit 方法中访问子类的变量时除外) 4,线程安全,带锁线程安全(你可能会想到 DLC)

init 方法同理,不过 init 是在对象初始化的时候才会执行的

# 使用

该类对象正在使用中

静态的变量或者方法(static)一般被认为和类绑定,随着类的加载而被加载

# 卸载

该类的 class 对象被 GC,需要满足三个条件:

1,该类无实例对象 2,该类类加载器被 GC 3,没有指向该类的引用(在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的)

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收

# 类加载过程与类加载器

上面也说过,类加载过程就是通过类的全限定名将类的二进制文件读取到虚拟机中,类加载器就是实现读取文件的东西。但是 java 并没有指定非要读 class 文件,因此 JVM 可以在这方面稍微多样化一下:比如可以读 JAR,EAR,WAR 格式、从网络中获取、甚至是运行时计算生成,最典型的是动态代理技术

# 类加载器

在上面的介绍里我们似乎已经讲完了类加载器是什么,但是它在 jvm 里起到的作用远超类加载阶段,对于任意一个类,都必须由它的类加载器与它本身一起确定它在 java 代码中的唯一性,具体表现为用 instanceof 方法做对象所属类型检查时会返回两个对象所属类型不同。这个设计可能有点反人类,但是它是必要的

类加载器对于虚拟机来说分为两种:启动类加载器(由 c++ 实现,未继承 ClassLoader 类,是 JVM 虚拟机的一部分)与所有其他的类加载器(java 实现,继承了 ClassLoader 类,是 java 代码的一部分)

对于开发来说,类加载器会划分的细致一些(根据目录来划分):

  • 引导(启动)类加载器:加载 jdk 中的最核心的类,加载目录是环境变量配置的目录下 lib(Java_Home/lib),这个类加载器使用C++语言实现,是虚拟机的一部分
  • 扩展类加载器:加载 jdk 中扩张的类,加载目录为 Java_Home /lib/ext。JDK 的开发团队是希望用户将通用的类放置在 ext 目录以扩张 java se 的功能,比如 common 下的代码,但是这个机制被模块化带来的天然扩展能力取代了
  • 应用程序类加载器:它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中,一般被称为系统(System)加载器

# 双亲委托机制

加载类时,类加载器会让父类加载器优先加载类;父类加载成功直接返回,父类加载失败则子类加载,因为父类的加载条件比子类严格,这样可以防止用户写的类覆盖 jdk 中的类

这么加载的好处是为了防止 java 核心 api 被修改,比如从网络传入了一个 java.lang.String,里面有些恶意代码,引导类加载器会阻止此类的加载,这个被称为沙箱安全机制

在操作系统领域也有沙箱安全机制,沙箱提供一个与主机系统隔离的环境,应用程序在这个环境中运行,无法直接访问主机系统的资源。而在 java 中沙箱机制除了类加载器,还有字节码验证器(上文说的连接时校验阶段)、安全管理器(安全管理器是一个可插拔的安全策略执行框架,负责执行安全策略。通过安全管理器,可以控制应用程序的权限,如文件访问、网络连接等)、访问控制列表(策略文件可以指定哪些代码源可以执行哪些操作)等

或者用户自定义了一个 Object 类,大家都知道 Object 是程序的核心类之一,如果其他类都依赖这个用户自定义的类程序是会崩溃的,但是因为这个双亲委托机制的存在保护了程序的安全

ClassLoader 类中的 loadClass 方法中实现了双亲委托机制,源码如下:

    if (parent != null) {
    	//父加载器不为空,调用父加载器loadClass()方法处理
        c = parent.loadClass(name, false);
    } else {
    	//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
        c = findBootstrapClassOrNull(name);
    }
    //省略一些代码
    //尝试自己加载
    c = findClass(name);
1
2
3
4
5
6
7
8
9
10

# 用户自定义类加载器

为什么用户想要自定义类加载器?

1,防止源码泄漏:任何人得到 class 文件都可以反编译成源码,这种情况大企业肯定是不能接受的,我们将 class 文件加密,然后通过重写类加载器来解密解密后的 class 文件

2,扩展加载源:可以从更多地方读取 class 文件,比如从网络上读取二进制字节流

用户可以自定义类加载器加载类,只需要继承 ClassLoader 重写一些方法即可:如果不想打破双亲委托机制,重写 findClass 方法

如果想打破双亲委托机制,重写 loadClass 方法。Tomcat 就打破了双亲委托机制,为了制定目录里面的类库的加载和隔离规则。因为一个 tomcat 可以运行多个 Web 应用程序,那假设我现在有两个 Web 应用程序,它们都有一个类,叫做 User,并且它们的类全限定名都一样,比如都是 com.yyy.User。但是他们的具体实现是不一样的。如果使用默认的类加载器机制,那么是无法加载两个两个同名的类不同版本的,默认的类加载器是不管你是什么版本的,只在乎你的全限定类名

Tomcat 为了保证它们是不起冲突,使用了以下方式实现了 Web 应用层级的隔离

image-2026-02-28-21-56-21.png

  • commonLoader:Tomcat 最基本的类加载器,加载路径中的 class 可以被 Tomcat 容器本身以及各个 Webapp 访问;
  • catalinaLoader:Tomcat 容器私有的类加载器,加载路径中的 class 对于 Webapp 不可见;
  • sharedLoader:各个 Webapp 共享的类加载器,加载路径中的 class 对于所有 Webapp 可见,但是对于 Tomcat 容器不可见;
  • WebappClassLoader:各个 Webapp 私有的类加载器,加载路径中的 class 只对当前 Webapp 可见
#虚拟机
最后更新: 3/1/2026, 3:55:02 PM
String、StringBuffer、StringBuilder 学习笔记
JVM 自动内存管理

← String、StringBuffer、StringBuilder 学习笔记 JVM 自动内存管理→

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