1. 什么是类加载机制?
类加载机制是指我们将类的字节码文件所包含的数据读入内存,同时我们会生成数据的访问入口的一种特殊机制。那么我们可以得知,类加载的最终产品是数据访问入口。
1.1 类加载的方式
- 从本地系统中直接加载,从本地编译好的class文件加载。
- 通过网络下载.class文件,如:Web Applet,也就是我们的小程序应用 。
- 从归档文件中加载.class文件,如:jar、war。
- 从专有数据库中提取.class文件,如:JSP应用从专有数据库中提取.class文件。
- 将Java源文件动态编译为.class文件(也就是运行时计算而成),如:动态代理技术。
- 从加密文件中获取,如:典型的防Class文件被反编译的保护措施。
2. 类加载过程
2.1 装载
装载就是查找和导入class文件的过程,JVM通过类加载器实现这一动作。最后把class文件加载运行时数据区的方法区和堆内存中。
2.1.1 装载的3个阶段
- 加载class文件。通过一个类的全限定名获取定义此类的二进制字节流。(由上可知,我们不一定从字节码文件中获得,还有上述很多种方式)
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
获取类的二进制字节流的阶段是我们JAVA程序员最关注的阶段,也是操控性最强的一个阶段。因为这个阶段我们可以对于我们的类加载器进行操作,比如我们想自定义类加载器进行操作用以完成加载,又或者我们想通过JAVA Agent来完成我们的字节码增强操作。
2.1.2 装载完成之后
装载阶段完成之后,内存中,运行时数据区的方法区以及堆有数据:
- 方法区:类信息,静态变量,常量
- 堆:代表被加载类的java.lang.Class对象
即时编译之后的热点代码并不在这个阶段进入方法区。
2.1.3 如何加载class文件
由JAVA虚拟机外部的代码类加载器实现。
2.2 链接(Link))
2.1.1 验证(Verify)
2.1.1.1 文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。这阶段的验证是基于二进制字节流进行的,只有经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面验证都是基于方法区的存储结构进行的。例如:是否以16进制cafebaby开头、版本号是否正确。
2.1.1.2 元数据验证(语法验证)
对类的元数据信息进行语义校验(其实就是对Java语法校验),保证不存在不符合Java语法规范的元数据信息。例如:是否有父类、是否继承了final类(final不可被继承)、是否实现了接口的所有方法。
2.1.1.3 字节码验证(语义合法性验证、安全验证)
进行数据流和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。例如:运行检查、栈数据类型和操作码操作参数吻合(比如栈空间只有4个字节,但是我们实际需要的远远大于4个字节)、跳转指令指向合理的位置。
2.1.1.4 符号引用验证
这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段),可以看作是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。符号引用验证的目的是确保解析动作能正常执行。例如:常量池中描述类是否存在、访问的方法或者字段是否存在且具有足够的权限。
2.1.1.5 取消验证
很多情况下可能认为代码肯定是没问题,验证的过程完全没必要,可以添加参数-Xverify:none取消验证。
2.1.2 准备(类变量分配零值)
为类变量(静态变量)分配内存并且设置该类变量的默认初始值(零值)。这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化。这里不会为实例变量(也就是没加static)分配初始化。类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
| 数据类型 | 零值 |
|---|---|
| int | 0 |
| long | 0L |
| short | (short)0 |
| char | ‘\u0000’ |
| byte | (byte)0 |
| boolean | false |
| float | 0.0f |
| double | 0.0d |
| reference | null |
2.1.2.1 ContstantValue
2.1.2.1.1 ContstantValue的作用是什么?
ContstantValue的作用是在编译期为类变量赋值
进行分配内存的只是包括类变量(静态变量),而不包括实例变量,实例变量是在对象实例化时随着对象一起分配在java堆中的。通常情况下,初始值为零值,假设public static int a=1;那么a在准备阶段过后的初始值为0,不为1,这时候只是开辟了内存空间,并没有运行java代码,a赋值为1的指令是程序被编译后,存放于类构造器()方法之中,所以a被赋值为1是在初始化阶段才会执行。特殊情况,如果类字段属性表中存在ConstantValue属性,那在准备阶段变量a就会被初始化为ContstantValue属性所指的值。
2.1.2.1.2 何时产生才会产生ContstantValue属性?
同时被
final和static修饰的字段才会产生ContstanValue属性
在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化。
2.1.2.1.3 为什么ConstantValue的属性值只限于基本类型和string?
因为常量池中只能引用到
基本类型和String类型的字面量
因为从常量池中只能引用到基本类型和String类型的字面量。假设上面的类变量a被定义为: private static final int a = 1;编译时javac将会为a生成ConstantValue属性,在准备阶段虚拟机就会根据 ConstantValue的设置将value赋值为1。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。
2.1.3 解析(符号引用转换转换为直接引用)
符号引用就是一组符号来描述目标,可以是任何字面量。引用的目标并不一定已经加载到了内存中。 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进 行。
直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般 不会相同,如果有了直接引用,那引用的目标必定存在内存中。
2.1.3.1 对解析结果进行缓存
同一符号引用进行多次解析请求是很常见的,除invokedynamic指令以外,虚拟机实现可以对第一次解析结果进行缓存,来避免解析动作重复进行(所以修改类之后需要重启JVM才会重新加载才会生效,否则加载缓存导致修改不生效)。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果 第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常。inDy(invokedynamic)是 java 7 引入的一条新的虚拟机指令,这是自 1.0 以来第一次引入新的虚拟机指令。到了 java 8 这条指令才第一次在 java 应用,用在 lambda 表达式中。 indy 与其他 invoke 指令不同的是它允许由应用级的代码来决定方法解析。直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定存在内存中。
1.3 初始化
使用类时会触发类的初始化。
1.3.1 初始化方式
Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值。
- 使用静态代码块为类变量指定初始值。
1.3.2 初始化步骤
JVM初始化步骤:
- 假如这个类还没有被加载和连接,则程序先加载并连接该类。
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
- 假如类中有初始化语句,则系统依次执行这些初始化语句。
1.4 使用(两种方式)
两种使用方式“主从引用”和“被动引用”。
1.4.1 主动引用
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如 Class.forName(“com.carl.Test”) )
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(JvmCaseApplication ),直接使用 java.exe 命令来运行某个主类
1.4.1 被动引用
- 父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。
- 定义类数组,不会引起类的初始化。
- 引用类的static final常量(装载阶段已经赋值),不会引起类的初始化(如果只有static修饰,还是会引起该类初始化的)。
1.5.卸载
在类使用完之后,如果满足下面的情况,类就会被卸载:
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的。
如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。但是一般情况下启动类加载器加载的类不会被卸载,而我们的其他两种基础类型的类加载器只有在极少数情况下才会被卸载。
2. 类加载器(ClassLoader)
2.1 什么是类加载器?
- 负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例的代码模块。
- 类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。
一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的,这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过 instanceOf 、equals 等方式的校验。
2.1.1 Bootstrap ClassLoader
负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。
2.1.2 Extension ClassLoader
负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包。
2.1.3 App ClassLoader
负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。
2.1.4 Custom ClassLoader
通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
2.2 为什么我们的类加载器要分层?
1.2版本的JVM中,只有一个类加载器,就是现在的“Bootstrap”类加载器。也就是根类加载器。但是这样会出现一个问题。
假如用户调用他编写的java.lang.String类。理论上该类可以访问和改变java.lang包下其他类的默认访问修饰符的属性和方法的能力。也就是说,我们其他的类使用String时也会调用这个类,因为只有一个类加载器,我无法判定到底加载哪个。因为Java语言本身并没有阻止这种行为,所以会出现问题。
这个时候,我们就想到,可不可以使用不同级别的类加载器来对我们的信任级别做一个区分呢?
比如用三种基础的类加载器做为我们的三种不同的信任级别。最可信的级别是java核心API类。然后是安装的拓展类,最后才是在类路径中的类(属于你本机的类)。
所以,我们三种基础的类加载器由此而生。但是这是我们开发人员的视角。
public class Demo3 {
public static void main(String[] args) {
// App ClassLoader
System.out.println(new Worker().getClass().getClassLoader());
// Ext ClassLoader
System.out.println(new Worker().getClass().getClassLoader().getParent());
// Bootstrap ClassLoader
System.out.println(new Worker().getClass().getClassLoader().getParent().getParent());
System.out.println(new String().getClass().getClassLoader());
}
}
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@3a71f4dd
null
null
2.3 JVM类加载机制的三种方式
2.3.1 全盘负责
当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。例如,系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所依赖的类及引用的类也载入,依此类推。“全盘负责”机制也可称为当前类加载器负责机制。显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。以上步骤只是调用了ClassLoader.loadClass(name)方法,并没有真正定义类。真正加载class字节码文件生成Class对象由“双亲委派”机制完成。
2.3.2 父类委托
父类委托(双亲委派)是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。
父类委托机制,父类委托别名就叫双亲委派机制。“双亲委派”机制加载Class的具体过程如下:
- ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托给父类加载器。
- 父类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则委托给祖父类加载器。
- 依此类推,直到始祖类加载器(引用类加载器)。
- 始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的子类加载器。
- 始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。
- 依此类推,直到源ClassLoader。
- 源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。
“双亲委派”机制只是Java推荐的机制,并不是强制的机制。我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。
2.3.3 缓存机制
缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效.对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。而这里我们JDK8使用的是直接内存,所以我们会用到直接内存进行缓存。这也就是我们的类变量为什么只会被初始化一次的由来。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First,在虚拟机内存中查找是否已经加载过此类...类缓存的主要问题所在!!!
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) {
//调用此类加载器所实现的findClass方法进行加载
c = findClass(name);
}
}
if (resolve) {
//resolveClass方法是当字节码加载到内存后进行链接操作,对文件格式和字节码验证,并为 static 字段分配空间并初始化,符号引用转为直接引用,访问控制,方法覆盖等
resolveClass(c);
}
return c;
}
}
2.4 打破双亲委派
双亲委派这个模型并不是强制模型,而且会带来一些些的问题。就比如java.sql.Driver这个东西。JDK只能提供一个规范接口,而不能提供实现。提供实现的是实际的数据库提供商。提供商的库总不能放JDK目录里。所以java想到了几种办法可以用来打破双亲委派。
2.4.1 SPI
Java从1.6搞出了SPI就是为了优雅的解决这类问题——JDK提供接口,供应商提供服务。编程人员编码时面向接口编程,然后JDK能够自动找到合适的实现。Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心类库使用。比如JDBC中的DriverManager。
2.4.2 OSGI
JAVA程序员更加追求程序的动态性,比如代码热部署,代码热替换。也就是就是机器不用重启,只要部署上就能用。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一起换掉以实现代码的热替换。
2.5 自定义类加载器
package com.example.jvmcase.loader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root +
File.separatorChar + className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("E:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.neo.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
输出结果:
class Test
com.example.jvmcase.loader.MyClassLoader@27d6c5e0
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:
-
这里传递的文件名需要是类的全限定性名称,即 Test 格式的,因为 defineClass 方法是按这种格式进行处理的。如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的setRoot就是前缀地址 setRoot + loadClass的路径就是文件的绝对路径。
-
最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
-
这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
如果我们把Test放在类路径之下,那么我们将会通过AppClassLoader加载 打印结果: class com.example.jvmcase.basic.Test sun.misc.Launcher$AppClassLoader@18b4aac2
Tomcat现在基本8.0版本已经全面被8.5版本代替,而8.5版本源码有部分改动,不过我们还是可以看到,我们的LoadClass依然打破了双亲委派。
@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
if (log.isDebugEnabled())
log.debug("loadClass(" + name + ", " + resolve + ")");
Class<?> clazz = null;
// Log access to stopped class loader
checkStateForClassLoading(name);
// (0) Check our previously loaded local class cache
clazz = findLoadedClass0(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Returning class from cache");
if (resolve)
resolveClass(clazz);
return (clazz);
}
// (0.1) Check our previously loaded class cache
clazz = findLoadedClass(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Returning class from cache");
if (resolve)
resolveClass(clazz);
return (clazz);
}
...中间省略一万字
throw new ClassNotFoundException(name);
}