JVM基础:字节码文件与类加载器
认识JVM
JVM的作用:
- 解释和运行:将字节码指令解释为机器码。
- 内存管理:自动分配对象和方法内存;GC
- 即时编译JIT:对热点代码做优化(将热点代码的机器码保存到内存中,下次直接调用)
JVM规范:
- 规定字节码文件的定义、类和接口的加载和初始化、指令集
- JVM规范并不是针对Java语言设计的要求,JVM上可以运行Groovy、scala生成的class。
常见Java虚拟机实现:
- HotSpot:使用最广泛,分为OracleJDK版和OpenJDK版
- GraalVM:微服务场景常用
- DragonwellJDK:阿里系,适合电商、物流和金融
- Eclipse OpenJ9
HotSpot的发展历程:
- JDK1.2:初出茅庐。
- JDK6:虚拟机做了大量优化。
- JDK7:推出G1垃圾回收器
- JDK8:引入JMC,去除永久代
- JDK11:优化了G1垃圾回收器
JVM组成的四个部分:
- 类加载器:负责将class文件加载到内存中。
- 运行时数据区域:JVM管理的内存,负责创建对象和销毁对象。
- 执行引擎:JIT(原理)、解释器(底层)、GC
- 本地接口(底层)
字节码文件、类加载器、类生命周期
如何打开class文件:使用jclasslib软件,用IDEA插件版。
字节码文件的内容:
- 基础信息:Java版本、访问标识、父类索引
- 常量池:字符串常量、类和接口名、字段名、
- 字段
- 方法:方法信息转换为字节码指令
- 属性:类的属性
字节码文件的组成
基础信息
①:魔数:Java字节码文件的文件头。
一般来讲,软件通过文件头几个字节来校验文件的类型。不支持就会报错。以下是一些常用的格式的校验字节。
- JPG:3个字节FFD8FF
- PNG:4个字节89504E47,文件尾也有要求
- bmp:2个字节,424D
- XML:5个字节
- CLASS:4个字节,CAFFBABE
打开.class文件后前几个字节如图所示:
②: JDK版本号:
- 主版本号:JDK8的主版本号是52,公式:X-44,如JDK17是61
- 作用:判断字节码版本和运行的JDK环境是否兼容
③:访问标识
④:类、父类、接口索引
生产场景问题:
低运行环境运行高的字节文件(外部引入的包)会报出版本错误。
常量池
- 作用:避免相同的内容重复定义,节省空间。
- 常量池数据从1开始编号,通过编号可以快速查找
- 符号引用:在字节码指令中,通过编号引用定位到常量池的过程。
- 如:
5 ldc #7
,引用了编号是7的常量
- 如:
面试题:int i =0 ; i = i++; i为什么=0
i++的字节码指令:
- 先执行iload_1将变量放入操作数栈
- 然后执行iinc 1 by 1将局部变量表位置自增
- 最后执行istore_1将操作数栈的数重新放入局部变量表
iconst_0 # 将0放入操作数栈
istore_1 # 从操作数栈去除局部变量放入1号位置
iload_1 # 把1号位置的局部变量放入操作数栈
iinc 1 by 1 # 在局部变量表就+1
istore_1 # 把操作栈的0重新放入局部变量表
return
++i:先执行iinc 1 by 1,再执行iload_1和istore_1
查看字节码信息常用工具
- jclasslib:本地版 + IDEA插件版
- javap:适合服务端
# javap 适合服务器环境运行
javap -v # 查看具体的字节码信息
# jar包需要先解压
jar -xvf xxx.jar
- 阿里Arthas:线上监控诊断,不修改代码情况下对业务问题进行诊断
常见命令:
- dashboard:监控面板
- dump:下载字节码文件
- jad:反编译已加载的类
# 监控面板,每隔2s刷新,展示1次
dashboard -i 2000 -n 1
# dump 已加载的类的bytecode 到特定目录
dump -d D:/jvm/data arthas.Demo # 放到指定目录
# jad 类的全限定名,反编译已加载类的源码
jad arthas.Demo
生产环境场景:
使用Arthas确认升级完的字节码文件是否为最新版本。
- 服务器部署arthas并启动:
java -jar arthas-boot.jar
jad com.xxx.xxxController
类生命周期
Java类一共有7个生命周期:Loading加载、Linking连接(连接又细分为验证、准备、解析)、Initialization初始化、using使用、unloading卸载
阶段一:加载
- 第一步:获取字节码文件。
- 类加载器根据类的全限定名通过不同渠道以二进制流的方式获取字节码信息。如本地、动态代理、网络。
- 第二步:将字节码的信息保存到方法区。
- 第三步:生成一个InstanceKlass对象,保存类的所有信息,含多态信息。
- 第四步:在堆中生成一份于方法区数据类似的java.lang.Class对象
- 作用:可以通过反射机制获取类的信息以及存储静态字段。其中获取静态字段是JDK8才支持。
- Class对象和InstanceKlass相互关联
InstanceKlass:用C++编写的对象。
Class:Java类,剔除掉虚方法表等。
阶段二:连接
- 第一步,验证阶段。验证内容是否符合JVM规范。
- 文件格式校验:是否以0XCAFEBABE开头
- 元信息验证,例如类必须有父类(super不为空)
- 校验执行指令的语义,比如goto到错误位置
- 符号引用验证,是否访问了private方法
- 版本号检测:
- 主版本号不能超过运行环境的版本号,主版本号相等的情况下,副版本号也不能超过。
- 第二步,准备阶段。给静态变量赋初值。
- 给静态变量赋初始化,基本数据类型是0,引用是null。
- 好处:用户不会访问到无效内存。
- final修饰的静态变量:直接赋对应的值。
- 给静态变量赋初始化,基本数据类型是0,引用是null。
- 第三步,解析阶段。将常量池中的符号引用转换为内存地址的引用。
- 符号引用:采用编号的形式访问常量池。
- 直接引用:内存的形式。
阶段三:初始化
- 执行静态代码块的代码,为静态变量赋值。
- 顺序:取决于代码顺序。
- 执行字节码中的clinit部分的字节码指令。
- 字节码中的方法有:init构造方法、main主方法、clinit初始化阶段执行方法。
类初始化触发契机:
- 访问静态:访问一个类的静态变量、静态方法。(final修饰且等号右边为常量不会触发。)
- 反射:调用Class.forName(String className)
- 实例化:使用new实例化一个该类的对象
- 执行Main方法的当前类
面试题1:
执行结果:DACBCB
代码块编译后会和构造方法放到一起,先执行代码块,再执行构造函数。
clinit在下面特殊情况下不会出现:
- 无静态代码块 且 无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。
public static int i;
- 静态变量定义使用final。
public static final int i = 10;
OOP中:
- 直接访问父类的静态变量,不会触发子类的初始化。
- 子类初始化clinit调用之前,会先调用父类的clinit初始化。
面试题2:
答案:2
其它内容:
- 数组的创建不会导致数组中元素的类进行初始化。
- final修饰的变量不是常量,而是需要执行指令才能得到结果,会执行clinit进行初始化。
类加载器
类加载器是什么:
- 类加载器是JVM提供给应用程序实现获取类和接口字节码的技术。
- 类加载器只参与加载过程中字节码的获取并加载到内存这部分。
类加载器应用场景:
- 企业级应用:SPI/类的热部署/Tomcat类的隔离
- 面试题:双亲委派机制/如何打破/自定义类加载器
类加载器的分类:
- 从实现角度
- 第一类:Java代码实现。
- JDK默认提供或自定义。
- 继承自抽象类ClassLoader。
- 第二类:底层源码实现。
- HotSpot实现C++实现。
- 作用:加载Java的一些基础类,如String。
- 第一类:Java代码实现。
- JDK8以及以后的实际分类:
- (C++实现)启动类加载器:Bootstrap
- (Java实现):
- 扩展类加载器:Extension
- 应用程序类加载器:Application
Arthas的classloader命令可以查看类加载器。
启动类加载器:Bootstrap
启动类加载器是由HotspotJVM提供的,使用C++编写。当我们使用 String.class.getClassLoader()
是获取不到的类加载器的,因为这是底层加载器。
加载目录:默认加载jdk的/jre/lib下的类文件,比如rt.jar
# arthas展示类的信息
sc -d java.lang.String
通过启动类加载器来加载用户jar包
使用参数:-Xbootclasspath/a:jar包目录/jar包名
场景:企业中开发一些基础类。
扩展类加载器和应用程序类加载器
- 源码位于:
sun.misc.Launcher
,是静态内部类 - 继承自:URLClassLoader,可以通过目录将字节码文件加载入内存。
- 继承关系:
扩展类加载器:
- 加载目录:/jre/lib/ext
- 用参数来使用扩展类加载器:
-Djava.ext.dirs=jar包目录
,但是会覆盖原始目录,所以需要用分号(win)冒号(Linux/macos)分隔,加上原来的目录。
应用程序类加载器: - 加载classpath下的类文件
Arthas中类加载器的功能
- 打印所有类加载器的哈希码:
classloader -l
- 获取类加载路径:
classloader- c hash码
- 这里我们会发现启动类加载器不仅加载了target/classes以及maven路径的内容,居然还加载了ext的内容,加载了lib的内容??
双亲委派机制
- ==双亲委派机制是解决一个类到底由谁来加载的问题==。
- 作用:
- 保证类加载的安全性。避免恶意代码替换Java核心类库。
- 避免类重复加载。
- 介绍:自底向上查找某个类是否加载过,自顶向下进行加载。
- 向上查找:如果加载过就返回Class对象。
- 向下委派:起到了加载优先级作用。
- 问题:
- 重复类的加载:从启动类加载。
- String类能覆盖吗?不能,会返回rt.jar的String类。
ClassLoader的源码中,parent不是通过继承方式实现,而是通过一个成员变量parent。应用程序类加载器的parent是扩展类加载器。但是扩展类加载器的parent是null,因为启动类加载器无法暴露给程序员。
打破双亲委派机制
打破双亲委派机制有三种方式:
- 自定义类加载器
- 线程上下文类加载器
- Osgi框架的类加载器
自定义类加载器
案例:
Tomcat可以运行多个Web应用,Tomcat保证两个应用中出现相同类名,也可以加载。Tomcat使用自定义类加载器实现应用之间类的隔离,每个应用对应一个独立的类加载器。
原理:
ClassLoader源码包含四个核心方法:
- loadClass:类加载入口,提供双亲委派机制,调用findClass。
- findClass:由类加载器子类实现,实现具体加载方式。
- defineClass:校验,调用到JVM
- resolveClass:连接
loadClass源码:
- classLoader的loadClass是不会进行连接阶段的,因为内部传入resolve=false,不会调用resolveClass。同时,内部加了synchronized关键字加锁。
- 先调用findLoaderClass查找有没有加载类,如果parent非空,继续调用parent的loadClass,实现双亲委派。parent为空,调用findBootStrapClassOrNull,即启动类加载器。
- 如果返回值还是空,说明父类无法加载,由当前类调用findClass,即由这个类本身来加载。
自定义类加载器打破双亲委派机制具体实现:
- 自定义类加载器默认的parent是应用程序类加载器。
- 重写findClass方法。比如我们可以通过重写这个方法从数据库加载字节码文件。
Q:两个自定义类加载器加载同限定名的类为什么不会冲突?
A:JVM中只有相同类加载器+相同类限定名都一样才是同个类。
线程上下类加载器
JDBC、JNDI就用了这种。
案例:
JDBC的DriverManager管理了不同驱动。DriverManager是由启动类加载器进行加载,位于rt.jar中,即由启动类加载器加载。而用户jar包(如MySQL驱动)是由应用程序类加载器加载,这种向下委托的方式打破了双亲委派机
制。
SPI机制:Service Provider Interface,JDK内置的一种提供服务发现的机制。
- 工作原理:
- 在ClassPath的META-INFO/services,新建一个接口的全限定名命名的文件,里面写上接口的实现。如
java.sql.Driver
文件,里面写着com.mysql.cj.jdbc.Driver
- 使用
ServiceLoade
r加载实现类。ServiceLoade
通过Thread.currendThread().getContextClassLoader()
获取应用程序类加载器。这是因为线程上下文保存了应用程序类加载器。
- 在ClassPath的META-INFO/services,新建一个接口的全限定名命名的文件,里面写上接口的实现。如
- JDBC的DriverManger通过SPI来委派应用类加载器加载对应的驱动。
总结:
关于JDBC案例是否打破双亲委派机制的讨论
- 打破论:如上解释。
- 没有打破论:JDBC只是通过初始化阶段触发了驱动的类加载器,类的加载依旧遵循双亲委派机制。
Osgi框架的类加载器
OSGi(开放服务网关协议)模块化框架存在同级之间的类加载器委托加载。OSGi还使用类加载器实现了热部署。
使用arthas不停机解决线上问题(只是一种应急手段)
- 部署arthas,启动
- 使用
jad --source-only 类全限定名 > 目录/文件名.java
,反编译后用vim打开,修改源码。 - 使用
mc -c 类加载器hashcode 目录/文件名.java -d 输出目录
,编译修改后的代码。 - 使用
retransform class文件所在目录/xxx.class
,加载新字节码文件。
注意:
- 程序重启后,字节码文件会恢复,除非将class文件放入jar包。
- 使用retransform无法添加方法和字段,无法更新执行中的方法。
JDK9之后的类加载器
JDK9引入了module的概念。
- 启动类加载器使用Java编写。位于
jdk.internal.ClassLoaders
类中。Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。- 不过启动类加载器仍然无法通过Java找到,返回依旧是null。
- 扩展类加载器被替换为平台类加载器。遵循模块化加载字节码文件,继承关系中UrlClassLoader变成了BuildtinClassLoader。没有提供特殊逻辑,和老版本兼容。
- 感谢你赐予我前进的力量