类加载
前言
Learn and forget and learn again and then forget anther time… it is so stupid and time consumming.
学了忘,忘了又学,反反复复不胜烦脑,究其原因还是不透彻,此次重温类加载,力求在温故而知新的基础上,查漏补缺,彻底掌握关键过程,真正做到知识内化。
1. Over view
2. 何时会引发类初始化
《java虚拟机规范》严格规定了有且只有六种 情况必须立即对类进行”初始化“(而加载、验证、准备自然要在之前开始):
- 遇到 new、getstatic、putstatic、invokestatic这四条字节码指令,若类型没有初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- new 实例化对象时
- 读取或设置一个类型的静态字段时(被final修饰, 已在编译期把结果放入常量池的静态字段除外)
- 调用一个类型的静态方法时
- 使用java.lang.reflact包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
- 当初始化类的时候,若父类未进行过初始化,则需要先触发父类初始化
- 当jvm启动时,用户需指定一个执行的入口类,即包含main方法的那个类,jvm会先初始化这个入口类
- 当接口中定义了default默认方法时,若这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
3. 加载 - Loading
《Java虚拟机规范》对加载过程的要求并不是很具体,留给JVM实现与Java应用的灵活度很大。仅仅这一点空隙,很多poineers已经这个阶段构建出了很多优秀的实现和技术。本篇文章将会实践其中的防止Class文件反编译的保护措施。
相对于类加载过程的其他阶段,非数组类型的加载阶段(加载阶段中获取类的二进制流的动作)是开发人员可控性最强的阶段。
数组类本身不通过类加载器创建,它是由JVM直接在内存中动态构造出来的。
加载阶段结束后,JVM外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,类型数据放入方法区后,会在堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的数据的外部接口。
3.1 类加载器
关于类加载器的概念,相信大家都不陌生,特别是JDK 1.2引入的“双亲委派”已经被老生常谈了很多次,它并不是强制性的,但确实曾经是一种类加载器实现的最佳实践。对于保证Java程序的稳定运行非常重要。代码实现为:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order to find the class.
long t1 = System.nanoTime();
c = findClass(name);// over ride this method in self-defination class loader
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
3.2 自定义类加载器
若用自定义类加载器加载一个自己定义的不在class path下的类时,当自底向上查找类加载器时,即,自定义类加载器 -> Application class loader -> Extension Class Loader -> Bootstrap Class Loader 时,因为前三个的类加载器继承自 ClassLoader, 最终都会invoke该段代码,所以该段代码会被递归调用三次,当第三次调用时,Extension Class Loader的parent虽被定义为Bootstrap Class Loader, 但因此加载器位于JVM内部,且为C\C++实现,java代码中的返回值为null(自定义类不在该加载器加载范围),会调用Extension Class Loader以及Application class loader中(实际为URLClassLoader中)的findClass()方法,均会抛出ClassNotFoundException 异常,最终会调用到自定义类加载器中重载的findClass()方法,下面为代码片段,该段代码尝试简单实现了一个自定义类加载器并可以防止类文件被反编译并篡改:
public class ClassLoaderDemo0 extends ClassLoader {
private String ellenLibPath;
public ClassLoaderDemo0(String path) {
this.ellenLibPath = path;
}
public static void encClassFile(String srcPath,String destFilePath) throws IOException {
FileInputStream fin = new FileInputStream(srcPath);
FileOutputStream fout = new FileOutputStream(destFilePath);
int b = 0;
//加密,将原来的1改为0,0改为1
while ((b = fin.read()) != -1) {
fout.write(b ^ 0xff);//重新生成class文件,加密
}
fin.close();
fout.close();
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
File file = new File(ellenLibPath, path);
System.out.println("EllenClassLoader0 findClass load:" + file.getAbsolutePath());
try {
//读取二进制流
FileInputStream is = new FileInputStream(file);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
int len = 0;
while ((len = is.read()) != -1) {
bos.write(len ^ 0xff);//读取二进制流文件时解密
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
e.printStackTrace();
}
return super.findClass(name);
}
public static void main(String[] args) throws IOException {
//encClassFile("D:\\idea-workspace\\lib\\com\\ellen\\jvm\\classloader\\Ellen.class", "D:\\idea-workspace\\lib\\com\\ellen\\jvm\\classloader\\Ellen1.class");
ClassLoaderDemo0 diskClassLoader = new ClassLoaderDemo0("D:\\idea-workspace\\lib");
ClassLoader parentClassLoader = diskClassLoader.getParent();
System.out.println("parentClassLoader: " + parentClassLoader);
Class clazz = null;
try {
clazz = diskClassLoader.loadClass("com.ellen.jvm.classloader.Ellen");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
if (clazz != null) {
try {
Object obj = clazz.newInstance();
Method hello = clazz.getDeclaredMethod("hello");
hello.invoke(obj, null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
e.printStackTrace();
}
}
}
}
在idea的启动配置中的vm option中添加参数: -verbose:class 可查看类加载信息
被加密后的类文件用EditPlus以16进制文件打开后为:已经不是以魔术开头,但使用自定义的类加载器解密后可以正常运行
程序输出:
parentClassLoader: sun.misc.Launcher$AppClassLoader@18b4aac2
ClassLoaderDemo0 findClass load:D:\idea-workspace\lib\com\ellen\jvm\classloader\Ellen.class
com.ellen.jvm.classloader.Ellen is loading and method hello is invoked
自定义类加载器步骤:
- 继承 ClassLoader
- 定义类文件加载路径
- 重写findClass()方法,并在此方法中调用defineClass() 将二进制Class文件转成Class对象并返回。
JDK 9之前的Java应用都是三种类型的加载器再加上自定义类加载器配合完成类加载的。
3.3 破坏双亲委派
第一次破坏
双亲委派发生在JDK 1.2,但ClassLoader在第一个版本中已经存在,在引入双亲委派时,兼容了已有代码,使loadClass() 存在被字类覆盖的可能。在ClassLoader中添加一个新的protected方法findClass(),用户在编写自定义的类加载器时重写这个方法。按照loadClass()方法的逻辑,父类加载失败,会自动调用自己的findClass()方法,保证了新的类加载器符合双亲委派规则。
第二次破坏
JNDI服务,在该代码是为了对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface, SPI)的代码。这样Bootstrap Class Loader需要请求application Class Loader 加载这些SPI的代码
第三次破坏
由于用户对程序动态性的追求引发了类加载器的革新。这里主要提到了OSGi,其在运行期动态热部署上有着绝对的优势。每个程序模块(Bundle)都有自己的类加载器,当需要更换Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器进一步发展为复杂的网状结构。
4. 链接 - Linking
4.1 验证
Class文件并不一定只能由Java源码编译而来,它可以使用包括靠0和1直接在二进制编辑器中编辑及其他任何途径产生。
4.1.1 文件格式验证
基于二进制字节流进行,此阶段结束后,字节流才被允许进行JVM方法区,后面三个阶段基于方法区的存储结构进行。
- 是否以魔术开头
- 主副版本号
- 常量池中的常量类型 - tag有效性
- 常量的索引值有效性
- CONSTANT_Utf8_info是否符合UTF-8编码规范
4.1.2 元数据验证
基于方法区的存储结构进行
语义分析
- 这个类是否有父类
- 是否继承了被final修饰的类
- 是否覆盖了父类的final字段
- 方法参数一致,返回类型不同
4.1.3 字节码验证
基于方法区的存储结构进行
此阶段最复杂!
验证对象:Class文件中的Code属性
验证目的:保证被校验类的方法在运行时不会做出危害JVM安全的行为
因为此阶段的复杂性,在JDK6之后,Javac编译器和JVM进行了一项联合优化,把尽可能多的校验辅助措施挪到Javac编译器里进行。即,将字节码验证的类型推导转变为类型检查。
突然想到jdk 10中出现的新特性 - 类型推断
java仍是静态类型语言,并不是动态类型,这个新特性的原理是javac将查看声明的右侧表达式并推断出其类型添加到字节码文件中。所以,类型推断var仅能使用在初始化的变量上,并不可以用与多个变量在一行中定义,不能使用null进行var的初始化等其他无法进行类型推断的使用方式。
4.1.4 符号引用验证
基于方法区的存储结构进行
这个校验实际发生在 - 链接的最后一个阶段(解析阶段),该阶段将符号引用转化为直接引用。
验证目的:确保解析正常执行
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法和字段描述符所描述的方法和字段
- 符号引用的可访问性是否可被当前类访问
4.2 准备
目的:
- 为类变量分配内存并设置初始值
一些基本数据类型的初始值:
int: 0
long: 0L
boolean: false
double: 0.0d
float: 0.0f
reference: null
- 为常量池中的数据赋值
若类字段的字段属性表中存在ConstantValue, 即被final修饰,就会被初始化为ConstantValue指定的值
4.3 解析
JVM在此阶段将常量池内的符号引用替换为直接引用
符号引用与JVM内存布局无关。
直接引用与JVM实现的内存布局直接相关,若有了直接引用,则引用的目标一定已经在虚拟机的内存中存在。
5. 初始化 - Initialization
执行类构造器<clinit>()方法, 该方法是Javac编译器自动生成的
- <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,但可以对其赋值。
- JVM会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
- 执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。