解释java字节码
为什么需要编译成class文件
我们首先要理解JVM
java是是一种混合型语言
有编译的步骤也有解释步骤
我们编写的java文件需要被编译成class文件之后才能被JVM运行
什么是class文件
传统编译型语言(C C++之类)
1 | 源代码 → 编译器 → 机器码(.exe 或 .out) → 操作系统直接运行 |
java
1 | 源代码 (.java) → javac 编译器 → 字节码 (.class) → JVM 加载并运行 |
独特之处在于javac编译出的class文件不是针对某个CPU的机器码 是一种字节码 面向的是JVM
- 机器码:CPU直接执行的二进制指令
- 字节码:JVM的中间指令 需要有JVM解释或编译成机器码之后才能被CPU执行
我们先随便创建一个java文件
1 | public class Main{ |
之后使用javac Main.java编译成Main.class(会出现在和Main.java同一目录之下)这个文件是二进制格式 无法直接用记事本看懂
将其放入十六进制工具之中查看一下效果
以上内容遵循JVM规定的ClassFile结构(这是JVM规范之中定义的一种严格二进制格式)
我们可以大致将以上内容分成左中右三部分
左边
诸如00000000:表示当前行起始字节再文件之中规定偏移(十六进制)
中间
诸如CA FE BA BE....:每个字节用两个十六进制数字表示 空格隔开 一共十六个字节
右边
诸如|........A.......|:将每个字节当作ASCII字符显示 非打印字符显示为.
这其中的内容包含魔数 主/次版本号 常量池计数 常量池条目示例 方法字节码等等内容 这些具体是干啥的我们后续再聊 我们要学习的类加载机制的加载阶段最核心的内容就是读取这个class文件 按照ClassFile结构解析出魔数 版本 常量池 方法字节码等等内容 之后再方法区之中创建对应的·运行时的数据结构并再堆之中生成一个Class对象
实际上字节码有两种形式 除了我们上面看到的这种二进制形式的 还有助记符形式的
我们可以通过javap -v Main.class查看到
1 | D:\java study\newjava\src>javap -c Main.class |
其中的getstatic ldc这些 都是JVM字节码指令的文本助记符 每条助记符对应一共二进制操作码 (比如ldc对应0x12)
这些内容是为了帮助人类阅读设计的 但是JVM实际执行时候读取的是二进制字节码
我们来解析一下整体结构
1 | Compiled from "Main.java" |
第一行的Compiled from "Main.java"代表这个class文件是由Main.java 编译得到
第二行的public class Main展示了这个类的访问修饰符和名称
剩下两行分别展示了类之中定义的两个方法
默认构造器public Main()和public static void main(String[])
我们取默认构造器之中规定一段来解释示例一下
1 | 1: invokespecial #1 // Method java/lang/Object."<init>":()V |
- 前面这个1是代表地址
invokespecial代表指令- 操作数#1
- 以上内容实际上就是调用常量池之中编号为#1的方法 根据注释 是
java/lang/Object."<init>":()V也即是object类的无参构造器 invokespecial用于调用构造器 私有方法或者父类方法 这里是弹出栈顶的this 作为目标对象 之下object的构造器
来重点分析一下main方法
1 | public static void main(java.lang.String[]); |
这个方法设计栈帧的操作
- 第一个命令获取静态字段
System.out其中#7是常量池之中规定索引 通关注释可以知道她指向的java/lang/System.out:Ljava/io/PrintStream;作用是将其压入栈顶 - 第二个命令是从常量池将在一共常量 其中#13是对应的常量字符串”Su@sU“ 作用是将haha压入栈顶
- 第三个命令是调用实例方法 其中#15表示这个方法再常量池的索引 也就是
PrintStream.println:(Ljava/lang/String;)V之后incokevirtual会从栈顶弹出参数”Su@sU“和对象引用”System.out“ 之后调用printlen方法 执行后打印Su@sU到控制台 - 第四个命令是消除这个栈帧
示意图如下
1 | PrintStream引用 ] |
1 | [ PrintStream引用, "Su@sU"引用 ] |
1 | [ PrintStream引用 ]"Su@sU"引用 |
1 | [ ]"Su@sU"引用 PrintStream引用 |
1 | [ ]打印Su@sU(完成之后将栈帧消除) |
经过以上解释大家肯定堆常量池和栈帧有所疑惑
我们来讲解一下
栈帧
JVM再执行方法的时候会创建一共栈帧 这个栈帧主要有两个部分组成
局部变量表
存放方法的参数和局部变量(比如 String[] args 就在里面)
操作数栈
类似一个计算器 所有的计算 传参 返回值都是通过这个栈进行
这也就是我们上面演示的部分 也就是我们广义之上所说的栈
常量池
常量池是class文件之中的一块表格 存放了这个类用到的各种常量信息
类名 方法名 字段名 字符串字面量 数字常量等等
当 javac 编译时,遇到 "Su@sU" 它就在常量池里新建一个条目 保存这个字符串的内容 并分配一个编号(例如 #13)。
那么字节码里就不需要直接写 "Su@sU" 这个文本(会浪费空间) 而是写 ldc #13 意思就是“到常量池的第13号条目去取那个字符串”
我们可以通过javap -v来·查看
这个命令得到的是完整的类结构信息
包括
- 版本信息
- 常量池
- 类访问标志
- 字段表
- 方法表
- 属性信息
1 | D:\java study\newjava\src>javap -v Main.class |
可以比较直观的看到常量池等信息
学习以上字节码的内容堆我们后续学习类加载机制很有帮助 因为类将在的这个过程本质上就是堆字节码的验证处理解析等过程 我们可以后续说明
我们先要了解类加载的过程如下所示
加载(Loading) → 连接(验证(Verification) → 准备(Preparation) → 解析(Resolution) )→ 初始化(Initialization) → 使用(Using) → 卸载(Unloading)
其中连接阶段包含验证 准备 解析三个子阶段
类加载过程之中每个阶段详细解释
加载阶段
- 通过一个类的全限定名称获取定义此类的二进制字节流(也就是我们之前javac得到的class文件)
- 讲这个字节流所代表的静态存储结构转化为方法去的运行时数据结构
- 再对内存之中生成一个代表这个类的
java.lang.Class对象 作为方法去各种数据的访问入口(这个对象也就是我们通过反射API获取的那个class对象)
加载的具体动作是由类加载器(ClassLoader) 完成
连接阶段
子阶段一 验证阶段
这一步是为了保证JVM安全 对字节码解析合法性校验 防止恶意或者错误代码破坏JVM
- 文件格式验证:魔数、主次版本号、常量池类型等。
- 元数据验证:是否有父类、是否继承了
final类、是否实现了抽象方法等语义检查。 - 字节码验证:通过数据流和控制流分析,确保方法体在运行时不会危害 JVM(如类型转换安全、操作数栈类型匹配等)。
- 符号引用验证:在解析阶段发生,检查符号引用能否正确解析为直接引用(如能不能找到被引用的类、方法、字段)。
子阶段二 准备阶段
为类静态变量分配内存并且设置默认零值
此处有两个特点
- 只针对静态变量 实例变量则是再对象实例化时随对象分配到java堆之中
- 分配的值是默认零值(int-0 boolen-false 应用类型-null)
除非这个静态变量同时被哦final和static修饰并且值是编译期常量 那么此时就会直接赋值为代码之中的值
子阶段三 解析阶段
将常量池之中的符号应用替换成直接引用
符号引用
本质是一串字符串 用来描述目标的名字和描述信息
特点是不依赖内存布局 只再
.class文件的常量池中记录 即使 JVM 还没加载目标类 也能存在直接引用本质是JVM可以直接使用的内存指针 偏移量等
特点是依赖内存布局 必须等目标类呗加载到内u你 分配好地址之后才能得到
**为什么要进行区分
java程序在编译时不知道所引用的类 方法 字段 在运行时候的具体内存位置 但是JVM在执行的时候必须知道这些位置才能实际访问
JVM 在执行字节码指令时(例如 getstatic、invokevirtual),需要直接操作内存地址或偏移量 如果不转换 每次执行都要根据符号引用重新查找(例如按名字查找字段 按名字+描述符查找方法) 效率极低 通过解析阶段一次转换 后续直接使用,可以大幅提升执行效率
初始化阶段
- 生成并且执行
<clinit()>方法
编译器会自动收集类中所有静态变量的赋值语句和所有的静态代码块 之后会合并后成一个特殊的类加载器——“<clinit>()”方法 - 如果类有父类 且父类尚未初始化 JVM会先递归初始化父类 再初始化当前类——这一步保证了父类的静态成员再子类之前完成初始化
- JVM会为类初始化加上锁 保证
<clionit>()方法在多线程环境之下只会被执行一次 其他线程如果同时触发初始化 会被阻塞到初始化完成
初始化的作用
- 在准备阶段·的时候 静态变量已经被分配内存并且设置为数据类型的零值 这一步执行赋值语句 赋值我们真正想赋值的值
- 有些复杂的行为 比如说读取配置文件 注册驱动 初始化日志这些 可以通关将这些逻辑卸载
static{ }之中 强制在类加载的时候执行一次
1 | public class Parent { |
访问child类的时候输出结果会是
1 | Parent init |
这也证明了先父类后子类的初始化顺序
初始化是类加载和类使用之间的重要一环 初始化之后类才能被使用
类加载器分类
启动类加载器(Bootstrap ClassLoader)
实现语言:C++
加载路径:
JAVA_HOME/jre/lib目录下的核心库(如rt.jar、resources.jar、charsets.jar等)。被
-Xbootclasspath参数指定的路径中的类。
典型类:java.lang.Object、java.lang.String、java.util.*等所有 Java 标准库的核心类。
这是所有类加载器的源头 在java代码之中是无法直接获取引用的 任何通关类.getClassLoader()获取启动类加载器加载的类时 都会返回null
1 | public class TestClassLoader { |
几乎所有java.* 包下的基础类几乎都由 Bootstrap 加载
拓展类加载器(Extension ClassLoader)
java类名:sun.misc.launcher$ExtClassLoader
父加载器:启动类加载器
加载路径:
JAVA_HOME/jre/lib/ext目录下的所有 JAR 包。或被系统属性
java.ext.dirs指定的路径中的类库。
用于加载java的拓展功能
访问:ExtClassLoader是 Java 对象,可以通过ClassLoader.getSystemClassLoader().getParent()获取
1 | public class TestExtClassLoader { |
扩展类加载器加载 javax.* 中的部分扩展类以及存放在 lib/ext 中的第三方库
应用程序类加载器(Application ClassLoader)
java类名:sun.misc.Launcher$AppClassLoader
父加载器:拓展类加载器
加载路径:
环境变量
CLASSPATH或-classpath或-cp指定的路径。如果没有指定,则默认是当前工作目录(
.)。
加载用户自己编写的类以及第三方jar包
在大多数应用中AppClassLoader是线程上下文类加载器的默认值 也是ClassLoader.getSystemClassLoader()的返回值
1 | public class MyClass { |
几乎所有你自己写的类和通过 Maven/Gradle 引入的 jar 包中的类 都由 AppClassLoader 加载
自定义ClassLoader
加载特定位置的类(如网络、加密文件、数据库)
实现热部署(重新加载类)
类隔离(如 Tomcat 为每个 Web 应用创建独立的类加载器)
类加载器获取方法
Class.getClassLoader()
返回加载这个类的类加载器引用
若返回null则表示这个类是由类加载器加载
ClassLoader.getParent()
获取当前类加载器的父加载器
1 | package java_foundation; |
输出结果为
1 | AppClassLoader: jdk.internal.loader.ClassLoaders$AppClassLoader@36baf30c |
值得注意的是在JDK9之中将扩展类加载器(ExtClassLoader) 变成了 平台类加载器(PlatformClassLoader)
但是委派链
1 | AppClassLoader → PlatformClassLoader → Bootstrap (null) |
依然没变
ClassLoader.getSystemClassLoader()
返回系统类加载器 通常返回的是(appClassLoader)
双亲委派模型
定义:当一个类加载器收到加载某个类或者接口的请求的时候 不会立即尝试自己家在 而是将加载任务委派给父加载器去完成 每一级类加载器都遵循同样的规则 如此递归 直到最顶层的启动类加载器都反馈无法完成加载的时候 自家在其才会尝试自己加载
设计双亲委派模型的作用是什么
保护Java核心类
假设没有双亲委派模型 攻击者可以直接编写自定义的java核心类(比如java.lang.String)里面包含窃取信息或者破坏逻辑的代码 然后将这个类的class文件放在classpath或者当前目录之下
当JVM需要加载这个核心类的时候 引用程序类加载器会先从classpath下找到这个恶意类并且加载 之后所有使用这个核心类的地方都会之下恶意代码
但是在有了双亲委派模型之后 这个核心类就会有于双亲委派的圆规 向上委派给启动类加载器
启动类加载器在JDK核心库之中找到标准的安全核心类然后成功加载 那么攻击者的恶意类就不会被加载
保证类的唯一性
JVM 判定两个类是否相同 依据是 全限定类名 + 加载该类的类加载器实例。
双亲委派保证了:对于所有 Java 核心类 最终都由 同一个加载器(Bootstrap) 加载。
因此 整个 JVM 中只存在一个 java.lang.String 类型 即使攻击者通过其他方式(如自定义类加载器)再次加载一个名为 java.lang.String 的类 也会因为:
包名限制(
java.*包禁止非系统加载器加载)或者类型不匹配(无法与官方 String 相互赋值)
而无法造成全局破坏
类加载器核心方法
loadClass(String name) —— 类加载的入口
这是JVM或反射调用类加载器的公开方法 传入类的全限定名称 并且要求加载这个类
- 检查这个类是否已经被当前加载器加载过
- 如果没有加载 委派给父加载器去加载
- 如果父加载器无法加载 就会调用自己的findClass去实际加载
findClass(String name) —— 实际加载字节码的地方
根据类名 到某个特定位置读取字节码 然后调用defineClass将其转换成class对象
调用实际是在loadClass之中父加载器无法加载这个类的时候 会回调当前加载器的findClass
defineClass(String name, byte[] b, int off, int len) —— 将字节数组转换为 Class 对象
将字节码数组解析成一个Class实例 会完成格式校验 常量池解析等底层工作 最终在JVM之中创建代表这个类的 java.lang.Class 对象
调用时机是在findClass之中获得字节码之后 调用它生成Class对象
resolveClass(Class<?> c) —— 触发类的链接
要求JVM对已经定义的Class对象进行解析(也就是把常量池之中规定符号引用替换成直接引用的那一步)
一般不会主动调用因为 loadClass 有一个 resolve 参数 如果为 true 则会在加载后调用 resolveClass 大多数情况下我们并不需要立即解析
findLoadedClass(String name) —— 检查类是否已经被加载
查询当前类加载器是否已经加载过指定名称的类 也就是loadClass的第一步
1 | loadClass(name) // 入口(不重写) |
类加载的方式
主要分为隐式加载和显式加载
隐式加载
当JVM执行字节码的时候遇到某个类的情况 会自动加载这个类
隐式加载的情况包括
- 使用new关键字创建对象实例
- 访问或者修改类的静态变量
- 调用类的静态方法
- 初始化子类的时候 父类尚未初始化
- 使用反射
- 启动类
显示加载
常用的方式有:
Class.forName(String className)``
会执行类的初始化(执行static)Class.forName(String name, boolean initialize, ClassLoader loader)
这种方法的第二个参数initialize控制是否执行初始化ClassLoader.loadClass(String name)
1 | ClassLoader cl = MyClass.class.getClassLoader(); |
这种方法不会执行初始化 (不会触发static)
适用于只希望获取Class对象 延迟初始化或者避免触发静态代码块
4. ClassLoader.findClass(String name)
一般不直接调用 而是在自定义类加载器之中内部重写findClass
通过调试观察双亲委派模型
调试功能是我们学习类加载机制的一个非常便捷的功能
通过设置断点 步入方法 我们可以很详细的了解到一个java程序再执行的时候调用的各种代码逻辑
环境准备和基础操作认识
再IDEA之中随便创建一个类
1 | package java_foundation;//委托类 |
为了方便存储我将其放入java_foundation包之内
接下来创建一个加载上一个类的核心类
1 | package java_foundation; |
以上代码的逻辑就是获取应用程序类加载器之后使用其loadClass方法加载类java_foundation.Text
准备工作已经完成
由于我们是要研究类的加载 那么就需要对loadClass方法进行研究
再Class clazz = classLoader/loadClass("java_foundation.Text")处打上断点
之后进入调试模式点击步进
就进入了ClassLoader.java 可以看到这是类加载器对外提供的简化入口 只需要传入类的全限定名 最终会返回loadClass之后的结果
loadClass(name, false)这里面有两个参数第一个是类的全限定名第二个是是否链接
接下来我们一直点击步进 遇到循环可以选择步出跳过 过程中可能会跳转到别的java类(String Launcher等)无需理会 这是因为我们步进之后再方法的源码之内设计的其他类也不少 在这里我们只需要关注CLassLoader.java的内容就行
到达这一段方法的时候停下 这是双亲委派模型的核心
1 | protected Class<?> loadClass(String name, boolean resolve) |
我们来分析一下这一段内容
1 | Class<?> c = findLoadedClass(name); |
检查缓存 查询当前类加载器是否依据在内部缓存之中加载过这个类
1 | if (c == null) { |
这是双亲委派模型最精彩的地方 :当前加载器不会自己贸然加载 而是先向上询问自己的父加载器 并且递归处理 知道顶层的启动类加载器
1 | if (c == null) { |
只有当所有父加载器都找不到这个类的时候 当前加载器才会调用自己的findClass加载字节码
小结
至此我们依据完整走完java类从磁盘上的.class文件到内粗你之中可用规定Class对象的全过程
着重点在于对java字节码的了解和双亲委派模型的体会