JVM字节码
Java之所以可以“一次编译,到处运行”,一是因为JVM针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class
文件)供JVM使用。
.class
文件是一组以8位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑地排列在.class
文件中,中间没有添加任何分隔符。整个.class
文件本质上就是一张表。
字节码
什么是字节码
之所以被称为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac
命令编译源代码为字节码文件,一个.java
文件从编译到运行的实例如下图:

对于开发人员,了解字节码可以更准确、直观地理解Java语言中更深层次的东西,比如通过字节码,可以很直观地看到Volatile
关键字如何在字节码上生效。另外,字节码增强技术在Spring AOP
、各种ORM
框架、热部署中的应用屡见不鲜,深入理解其原理对于我们来说大有裨益。除此之外,由于JVM规范的存在,只要最终可以生成符合规范的字节码就可以在JVM上运行,因此这就给了各种运行在JVM上的语言(如Scala
、Groovy
、Kotlin
)一种契机,可以扩展Java所没有的特定或者实现各种语法糖。理解字节码后再学习这些语言,可以“逆流而上”,从字节码视角看它的设计思路,降低学习难度。
字节码结构
.java
文件通过javac
编译后将得到一个.class
文件,打开后可以发现是一堆十六进制数字。JVM规范要求每个字节码文件都要由十部分按照固定的顺序组成,结构如下:

魔数(
Magic Number
)每个
.class
文件的头4个字节称为魔数(magic number)
,它唯一的作用是确定这个文件是否为一个能被虚拟机接收的.class
文件。魔数的固定值为:0XCAFEBABE
。魔数的固定值是Java之父
James Gosling
制定的,为CafeBabe
(咖啡宝贝)。版本号(
Version
)版本号为魔数之后的4个字节,前两个字节表示此版本号
Minor Version
,后两个字节表示主版本号Major Version
。举例来说,如果版本号为:“00 00 00 34”。那么,次版本号转化为十进制为0,主版本号转化为十进制为52,在
Oracle
官网查询序号可知52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0常量池(
Constant Pool
)紧接着主版本号之后的字节为常量池入口。
常量池主要存放两类常量:
- 字面量:如文本字符串、
final
常量值; - 符号引用:
- 类和接口的全限定名;
- 字段的名称和描述符;
- 方法的名称和描述符;
常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图所示:
- 字面量:如文本字符串、

常量池计数器(
Constant_pool_count
):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。常量池数据区:数据区是由
constant_pool_count-1
个cp_info
结构组成,一个cp_info
结构对应一个常量。在字节码中共有14种类型的cp_info
,每种类型的结构都是固定的。访问标志
紧接着的2个字节代表访问标志,这个标志用于识别一些类或接口的访问信息,描述该
Class
是类还是接口,以及是否被public
、abstract
、final
等修饰符修饰。当前类名
访问标志后的2个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
父类名称
当前类名后的2个字节,描述父类的全限定名,保存的也是常量池中的索引值。
接口信息
父类名称后的2个字节即为接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值。
字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。字段表也分为两部分,的一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息
fields_info
。字段表结构如下图所示:

方法表
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:

附加属性表
字节码的最后一部分,存放了在该文件中类或接口所定义属性的基本信息。
JVM的指令集是基于栈而非寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往与硬件挂钩),但缺点在于,完成同样的操作,基于栈的实现需要更多指令才能完成(栈是一个FILO结构,需要频繁压栈出栈)。栈是在于内存实现,相比基于CPU高速缓冲区的寄存器,速度要慢的多;
字节码增强
Asm
对于需要手动操纵字节码的需求,可以使用Asm
,它可以直接生产.class
字节码文件,也可以在类被加载进JVM之前动态修改类行为。
Asm
的应用场景有AOP
、热部署、修改其他jar
包中的类等。当然,涉及底层的步骤,实现起来同样麻烦。
Javassist
利用Javassist实现字节码增强时,可以无需关注字节码结构,优点在于编程简单。直接使用java
编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。
其中最重要的是ClassPool
、CtClass
、CtMethod
、CtField
四个类:
CtClass(compile-time class)
:编译时类信息,它是一个class
文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass
对象,用来标示这个类文件;ClassPool
:从开发视角来看,ClassPool
是一张保存CtClass
信息的HashTable
,key
为类名,value
为类名对应的CtClass
对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass(“className”)
方法从pool
中获取到相应的CtClass
;CtMethod
及CtField
:对应的是类中的方法和属性;
JVM类加载
类加载机制
类是在运行期间动态加载的。
类的加载是指将类的.class
文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class
对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class
对象,Class
对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class
文件缺失或存在错误,类加载器必须在程序首次主动使用该类的时报告错误,如果该类一直没有被程序主动使用,那么类加载器不需要报告错误。
类生命周期

Java类的完整生命周期包括以下几个阶段:
- 加载(
Loading
) - 链接(
Linking
)- 验证(
Verification
) - 准备(
Preparation
) - 解析(
Resolution
)
- 验证(
- 初始化(
Initialization
) - 使用(
Using
) - 卸载(
Unloading
)
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。而解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的动态绑定。
类的加载过程是指加载、验证、准备、解析、和初始化这5个阶段。
加载
加载是类加载的一个阶段,是指查找字节流,并且据此创建类的过程。
加载过程完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将字节流所代表的的静态存储结构转化为方法区的运行时存储结构;
- 在内存中生成一个代表这个类的
Class
对象,作为方法区这个类的各种数据的访问入口;
其中二进制字节流可以从以下方式获取:
ZIP
包读取,是JAR
、EAR
、WAR
格式的基础;- 从网络中获取,最典型的应用是
Applet
; - 运行时计算生成,通过动态代理技术,在
java.lang.reflect.Proxy
中,就是用了ProxyGenerator.gengerateProxyClass
的代理类的二进制字节流; - 由其他文件生成,典型场景是JSP应用;
- 从数据库读取;
验证
验证是链接阶段的第一步,验证的目标是确保Class
文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个阶段的检验动作:
- 文件格式验证:雁阵个字节流是否符合
Class
文件格式的规范,并且能被当前版本的虚拟机处理; - 元数据验证:对字节码的描述信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;
- 字节码验证:通过数据流和控制流分析,确保程序语义是合法、符合逻辑的;
- 符号引用验证:发生在虚拟机将服药引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验;
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
类变量是被static
修饰的变量,准备阶段为static
变量在方法区分配内存并初始化为默认值,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在Java堆中。(实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次)
准备阶段有以下几点注意事项:
- 进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中; - 设置的初始值通此昂情况下是数据类型的默认零值,而不是代码中显示赋予的值;
- 对于基本数据类型来说,类变量(
static
)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认零值;对于局部变量,在使用前必须显式地为其赋值,否则编译时会报错;- 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显示地赋值,否则编译会报错;仅被final
修饰的常量则即可在声明时显式赋值,也可在类初始化时显式赋值;但必须为final
修饰的变量显式赋值,系统不会为其赋予默认零值;- 对于引用数据类型,如数组引用、对象引用等,如果没有显式地赋值而直接使用,系统都会为其赋予默认的零值,即
null
;- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值;
- 如果类字段的属性表中存在
ConstantValue
属性,即同时被final
和static
修饰,那么在准备阶段变量value
就会被初始化为ConstValue
属性所指定的值。
解析
在class
文件被加载至Java虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
解析阶段目标是将常量池的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、和调用点限定符7类符号引用进行。
- 符号引用:符号医用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可;
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量伙食一个能间接定位到目标的句柄;
初始化
在Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其进行赋值。
如果直接赋值的静态字段被final
所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被Java编译器标记成常量值,其初始化直接由JVM完成。除此之外的直接赋值操作、以及所有静态代码块中的代码,会被Java编译器置于同一方法中,并把它命名为<clinit>
。
初始化阶段才真正开始执行类中的定义的Java程序代码。初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。
类初始化方式
- 声明类变量时指定初始值;
- 使用静态代码块为类变量指定初始值;
准备阶段,类变量已经赋予一次系统要求的初始值;初始化阶段,根据程序员指定进行初始化类变量和其他资源;
类初始化步骤
- 如果类还没有被加载和链接,开始加载该类;
- 如果该类的直接父类还没有被初始化,则先初始化其父类;
- 如果该类有初始化语句,则一次执行这些初始化语句;
类初始化时机
主动引用
类的主动引用包括以下六种情形:
- 创建类的实例:
new
对象; - 访问静态变量:访问某个类或接口的静态变量,或对静态变量赋值;
- 访问静态方法:
- 反射:
- 初始化子类:初始化某个类的子类,则其父类也会被初始化;
- 启动类:JVM启动时被标明为启动类的类;
- 创建类的实例:
被动引用
- 通过子类引用父类的静态字段,不会导致子类初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;
类初始化细节
类初始化<clinit>()
方法的细节:
- 由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问;
- 与类的构造函数不同,不需要显示的调用父类的构造器。JVM会自动保证在子类的
<clinit>()
方法运行前,父类的<clinit>()
方法已经执行结束。因此JVM中第一个执行<clinit>()
方法的类肯定是java.lang.Object
; - 由于父类的
<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作; <clinit>()
方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值操作,编译器可以不为该类生成<clinit>()
方法;- 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成
<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时,也不会执行接口的<clinit>()
方法; - JVM会抱枕搞一个类的
<clinit>()
方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的<clinit>()
方法,其他线程都会阻塞等待,直到活动线程执行<clinit>()
方法完毕。如果一个类的<clinit>()
方法中与耗时的操作,就会造成多个线程阻塞,在实际过程中这种阻塞很隐蔽;
ClassLoader
ClassLoader
即类加载器,负责将类加载到JVM。
JVM加载class
文件到内存有两种方式:
- 隐式加载:JVM自动加载需要的类到内存中;
- 显式加载:通过使用
ClassLoader
来加载一个类到内存中;
类与类加载器
如何判断两个类是否相同:类本身相同,并且使用同一个类加载器进行加载。这是因为每一个ClassLoader
都拥有一个独立的类命名空间。
类加载器分类

Bootstrap ClassLoader
Bootstrap ClassLoader
,即启动类加载器,负责加载JVM自身工作所需的类。
Bootstrap ClassLoader
会将<JAVA_HOME>/lib
目录中的,或被-Xbootclasspath
参数所指定的路径中的,并且是虚拟机识别的(按照文件名识别,如rt.jar
,名字不符合的类库即便放在lib
目录中也不会被加载)类库加载到虚拟机内存中。
Bootstrap ClassLoader
是由C++实现的,它完全由JVM自己控制,启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用null
代替即可。
ExtClassLoader
ExtClassLoader
,即扩展类加载器,这个类加载器是由ExtClassLoader(sun.misc.Launcher\$ExtClassLoader)
实现的。
ExtClassLoader
负责将<JAVA_HOME>/lib/ext
或者java.ext.dir
系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
APPClassLoader
APPClassLoader
,即应用程序类加载器,这个类加载器是由APPClassLoader(sun.misc.Lanuncher/$AppClassLoader)
实现的。由于这个类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,因此一般称为系统类加载器。
AppClassLoader
负责加载用户类路径(即classpath
)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
自定义类加载器
自定义类加载器可以做到以下几点:
- 在执行非置信代码之前,自动验证数字签名;
- 动态地创建符合用户特定需要的定制化构建类;
- 从特定的场所取得
java class
,例如数据库和网络;
ClassLoader
常用的场景:
- 容器-典型应用:
Servlet
容器、udf等;加载解压jar
包或war
包后,加载其Class
到指定的类加载器中运行; - 热部署、热插拔:应用启动后,动态获取某个类信息,然后加载到JVM中工作;
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if(classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name,classData,0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try{
InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead ;
while ((bytesNumRead = is.read(buffer)) != -1) {
baos.write(buffer,0,bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e ) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
}
}
双亲委派

类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model
)。该模型要求除了顶层的Bootstrap ClassLoader
外,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition
)关系来实现,而不是通过继承(Inheritance
)的关系实现。
工作过程
一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。
好处
Java类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一:
- 系统类防止内存中出现多份同样的字节码;
- 保证Java程序安全稳定运行;
ClassLoader参数
在生产环境上启动Java应用时,通常会指定一些ClassLoader
参数,以加载需要的lib
:
ClassLoader
相关参数:
参数选项 | ClassLoader类型 | 说明 |
---|---|---|
-Xbootclasspath |
Bootstrap ClassLoader |
设置启动类加载器搜索路径;不常用 |
-Xbootclasspath/a |
Bootstrap ClassLoader |
将路径添加到已存在的启动类加载器搜索路径后面;常用 |
-Xbootclasspath/p |
Bootstrap ClassLoader |
将路径添加到已存在的启动类加载器搜索路径前面;不常用 |
-Djava.ext.dirs |
ExtClassLoader |
设置扩展类加载器搜索路径 |
-Djava.classpath 或-cp 或-classpath |
APPClassLoader |
设置应用程序加载器搜索路径 |
类的加载
类加载方式
类加载有三种方式:
- 命令行启动应用时候由JVM初始化加载;
- 通过
Class.forName()
方法动态加载; - 通过
ClassLoader.loadClass()
方法动态加载;
Class.forName()
与ClassLoader.loadClass()
区别:
Class.forName()
将类的.class
文件加载到JVM,并对类进行解释,执行类中的static
块;ClassLoader.loadClass()
将.class
文件加载到到JVM中,不会执行static
中的内容,只有在newInstance
时才会执行static
块代码;Class.forName(name,initialize,loader)
带参函数也可以控制是否加载static
块。并且只有调用了newInstance()
方法采用调用构造函数,创建类的对象;
加载类错误
ClassNotFoundException
该异常表示当前
classpath
下找不到指定的类常见问题原因为:
- 调用
Class
的forName()
方法,未找到类; - 调用
ClassLoader
中的loadClass()
方法,未找到类; - 调用
ClassLoader
中的findSystemClass()
方法,未找到类;
解决方法:
- 检查
classpath
下有没有相应的class
文件;
- 调用
NoClassDefFoundError
常见问题原因:
- 类依赖的
Class
或者jar
不存在; - 类文件存在,但是存在不同的域中;
解决方法:
- 现代Java项目,一般使用
maven
、gradle
等构建工具管理项目,自己检查找不到的类所在jar
包是否已添加为依赖;
- 类依赖的
UnstatisfiedLinkError
通常为JVM启动的时候不小心删除了JVM的某个lib;
ClassCastException
通常是在程序中强制类型转换失败时出现;