上一篇文章对整个虚拟架构有了一个了解,这一节来看看类加载系统。
文章目录
1、类加载过程
当我们用java命令运行某个类的main函数启动程序时,首先需要通过类加载器把主类加载到JVM。类加载过程中是懒加载,用到哪一个类就加载哪一个类的.class文件,并不是一次性将所有文件加载完成。
类加载过程有如下几步:
加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载
1.1加载
加载的过程
类加载的文件可以是jar、war包,由JSP文件中生成对应的Class类,用ProxyGenerator.generateProxyClass为特定接口生成形式为"*$Proxy"的代理类的二进制字节流
在加载的过程中,JVM主要做3件事情
1)通过一个类的全限定名来获取定义此类的二进制字节流(class文件),在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程
2)将这个字节流的静态存储结构转化为方法区的运行时数据结构
3)在内存中创建一个该类的java.lang.Class对象,作为方法区该类的各种数据的访问入口
程序在运行中所有对该类的访问都通过这个类对象,也就是这个Class对象是提供给外界访问该类的接口。
加载过程的注意点
1)JVM规范并未给出类在方法区中存放的数据结构
类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机
自己定义的,虚拟机规范并没有指定。
2)JVM规范并没有指定Class对象存放的位置
在二进制字节流以特定格式存储在方法区后,JVM会创建一个java.lang.Class类的对象,作为本类
的外部访问接口。HotSpot将Class对象存放在方法区。但对象就应该存放在Java堆中
3)加载阶段和链接阶段是交叉的,类加载的过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制 。也就是说类加载过程中,必须按照如下顺序开始:(加载 -> 链接 -> 初始化),但结束顺序无所谓,因此由于每个步骤处理时间的⻓短不一就会导致有些步骤会出现交叉。
1.2 验证
验证阶段比较耗时,它非常重要但不一定必要(因为对程序运行期没有影响),如果所运行的代码已经被反复使用和验证过,那么可以使用 -Xverify:none 参数关闭,以缩短类加载时间。
验证的目的,保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。
验证阶段是基于二进制字节流进行的,只有通过本阶段验证,才被允许存到方法区后面的三个验证阶段都是基于方法区的存储结构进行,不会再直接操作字节流
加载和验证是交叉进行的:
1.加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区
2.而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区,也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作
字节码格式
class文件概述
我们可任意打开⼀个Class⽂件(使⽤Hex Editor等⼯具打开),内容是16进制数据
所有的由Java编译器编译⽽成的class⽂件的前4个字节都是“0xCAFEBABE”。
作⽤在于:当JVM在尝试加载某个⽂件到内存中来的时候,会⾸先判断此class⽂件有没有JVM认为可以接受的“签名”,即JVM会⾸先读取⽂件的前4个字节,判断该4个字节是否是“0xCAFEBABE”,如果是,则JVM会认为可以将此⽂件当作class⽂件来加载并使⽤。
class文件的内容可以参照《class文件结构参照表全集》查询文件中16进制数据的意义。
class文件包含内容:版本号、常量池计数器、常量池数据区、访问标志、类索引、父类索引、接⼝计数器、接⼝信息数据区、字段计数器、字段信息数据区、⽅法计数器、⽅法信息数据区、属性计数器、属性信息数据区。
计数器后面紧跟数据区
符号引用
class常量池中主要存放二种常量,一种是字面量 一种是符号引用
符号引⽤:以⼀组符号来描述所引⽤的⽬标,符号可以是任何形式的字⾯量,只要使⽤时能够⽆歧义的定位到⽬标即可。
例如,在Class⽂件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
注意:符号引⽤与虚拟机的内存布局⽆关,引⽤的⽬标并不⼀定加载到内存中。
在Java中,⼀个java类将会编译成⼀个class⽂件。在编译时,java类并不知道所引⽤的类的实际地址,因此只能使⽤符号引⽤来代替。
直接引用
直接引⽤可以是:
- 直接指向⽬标的指针(⽐如,指向“类型”【Class对象】、类变量、类⽅法的直接引⽤可能是指向⽅法区的指针)
- 相对偏移量(⽐如,指向实例变量、实例⽅法的直接引⽤都是偏移量)
- ⼀个能间接定位到⽬标的句柄
注意:直接引⽤是和虚拟机的布局相关的,同⼀个符号引⽤在不同的虚拟机实例上翻译出来的直接引⽤⼀般不会相同。如果有了直接引⽤,那引⽤的⽬标必定已经被加载⼊内存中了。
javap命令的使用
JDK⾃带的javap⼯具,如当前有Math.class ⽂件,进⼊此⽂件所在的⽬录,
然后执⾏ ”javap -v CASDemo.class“,可以查看jdk版本信息,常量池中的信息
更多的javap命令,通过javap -help获取
⼀般常⽤的是-v -l -c三个选项。
javap -v classxx,不仅会输出⾏号、本地变量表信息、反编译汇编代码,还会输出当前类⽤到的
常量池等信息。
javap -l 会输出⾏号和本地变量表信息。
javap -c 会对当前class字节码进⾏反编译⽣成汇编代码。
总的来所class文件有特定的格式,jvm虚拟机会参照这个格式校验class文件。包含1)文件格式验证、2)元数据验证:对字节码描述信息进⾏语义分析,确保符合Java语法规范.3)字节码验证4)符号引⽤验证:发⽣在JVM将符号引⽤转化为直接引⽤的时候
1.3 准备
类加载的准备阶段,仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值,这⾥不包含⽤final修饰的static,因为final在编译的时候就会分配了(编译器的优化),同时这⾥也不会为实例变量分配初始化。
类变量会分配在⽅法区中,⽽实例变量是会随着对象⼀起分配到Java堆中。
通俗讲,准备阶段主要完成两件事情:
1)为已在⽅法区中的类的静态成员变量分配内存
2)为静态成员变量设置初始值,初始值为0、false、null等
1.4 解析
解析是虚拟机将常量池的符号引⽤替换为直接引⽤的过程。
解析动作主要针对类或接⼝、字段、类⽅法、接⼝⽅法四类进⾏解析,分别对应于常量池中的
CONSTANT_Class_info 、 CONSTANT_Fieldref_info 、 CONSTANT_Methodref_info 、
CONSTANT_InterfaceMethodref_info四种常量类型。
1.5 初始化
初始化是类加载过程的最后⼀步,到了此阶段,才真正开始执⾏类中定义的Java程序代码
对类的静态变量初始化为指定的值,执行静态代码块
2、类加载的时机
什么时候开始加载,虚拟机规范并没有强制性的约束,对于其它大部分阶段究竟何时开始虚拟机规范也都没有进行规范,这些都是交由虚拟机的具体实现来把握。所以不同的虚拟机它们开始的时机可能是不同的。但是对于初始化却严格的规定了有且只有四种情况必须先对类进行“初始化”(加载,验证,准备自然需要在初始化之前完成):
- 遇到 new 、 getstatic 、 putstatic 和 invokestatic 这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。
这四个指令对应到我们java代码中的场景分别是:
new关键字实例化对象的时候;
读取或设置一个类的静态字段(读取被final修饰,已在编译器把结果放入常量池的静态字段除外) ;
调用类的静态方法时。 - 使用 java.lang.reflect 包方法时对类进行反射调用的时候。
- 初始化一个类的时候发现其父类还没初始化,要先初始化其父类。
- 当虚拟机开始启动时,用户需要指定一个主类,虚拟机会先执行这个主类的初始化。
3、类加载器
类加载过程主要是通过类加载器来实现的,Java里有如下几种类加载器
1)引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如
rt.jar、charsets.jar等
2)扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR
类包
3)应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那
些类
4)自定义加载器:负责加载用户自定义路径下的类包
加载过程中会先检查类是否被已加载,检查顺序是⾃底向上,从Custom ClassLoader到BootStrap
ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载⼀次。⽽加载的顺序是⾃顶向下,也就是由上层来逐层尝试加载此类。
自定义类加载
为什么要自定义类加载器?
Java中提供的默认ClassLoader,只加载指定⽬录下的jar和class,如果我们想加载其它位置的类或
jar时。
⾃定义类加载器步骤
(1)继承ClassLoader注意:该类有两个核心方法,一个是
loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空
方法
(2)重写findClass()⽅法
(3)调⽤defineClass()⽅法
package com.watson.architect.jvm.classfile;
import java.io.FileInputStream;
import java.io.IOException;
public class MyClassLoader extends ClassLoader {
private String classpath;
public MyClassLoader(String classpath) {
this.classpath = classpath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] classDate = getData(name);
if (classDate != null) {//defineClass⽅法将字节码转化为类
return defineClass(name, classDate, 0, classDate.length);
}
} catch (IOException e) { e.printStackTrace();}
return super.findClass(name);
}
//返回类的字节码
private byte[] getData(String name) throws IOException {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classpath + "/" + name + ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
}
自定义类加载器的使用
package com.watson.architect.jvm.classfile;
import java.lang.reflect.Method;
public class TestMyClassLoader {
public static void main(String []args) throws Exception{//⾃定义类加载器的加载路径
//另一个项目的jar包目录,当然也可以自己创建目录例如:创建 test/com/watson/jvm 几级目录,然后将class文件丢入该目录
MyClassLoader myClassLoader=new MyClassLoader("D:\\workplace\\my_gitee\\spring-all\\Java-JUC\\target\\classes");
//包名+类名
Class c=myClassLoader.loadClass("com.watson.juc.asf");
if(c!=null){
Object obj=c.newInstance();
Method method=c.getMethod("say", String.class);
method.invoke(obj,"nihao");
System.out.println(c.getClassLoader().toString());
}
}
}
3.2 双亲委派机制
JVM通过双亲委派模型进⾏类的加载,当然我们也可以通过继承java.lang.ClassLoader实现⾃定义的类加载器。
1)当⼀个类加载器收到类加载任务,会先交给其⽗类加载器去完成,因此最终加载任务都会传递到顶
层的启动类加载器,
2)只有当⽗类加载器⽆法完成加载任务时,才会尝试执⾏加载任务。
为什么要使⽤双亲委托这种模型呢?
1)避免重复加载,当⽗亲已经加载了该类的时候,就没有必要⼦ClassLoader再加载⼀次。
2)防止核心API库被随意篡改
例如:String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以⽤户⾃定
义的ClassLoader永远也⽆法加载⼀个⾃⼰写的String,除⾮你改变JDK中ClassLoader搜索类的默认
算法。
如何判定两个class是相同的呢
JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,⽽且要判断是否由同⼀个类加载器实例加载的。
3.3 打破双亲委派机制
为什么需要破坏双亲委派?
因为在某些情况下⽗类加载器需要加载的class⽂件由于受到加载范围的限制,⽗类加载器⽆法加载到需要的⽂件,这个时候就需要委托⼦类加载器进⾏加载。
如何打破双亲委派机制呢?
重写ClassLoader的loadClass方法。使用场景:tomcat 为了实现隔离性,每个类加载器
加载自己的目录下的class文件,不会传递给父类加载器,实现war包应用内不同版本类实现相互共存与隔离
/**
* 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载
*
* @param name 类的二进制名称
* @param resolve 是否需要解决该类,一般为false
* @return 二进制名称(binary name)对应的Class对象
* @throws ClassNotFoundException 如果类未被找到,会抛出ClassNotFoundException
*/
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 基于类的binary name,对相同名称的类加锁,防止多个线程重复加载。这点不变
synchronized (getClassLoadingLock(name)) {
// 首先,先检查类是否已被加载,避免重复加载。这点不变
Class<?> c = findLoadedClass(name);
// 如果没找到,通过findClass加载。这点不变
if (c == null) {
long t1 = System.nanoTime();
if (!name.startsWith("com.watson.jvm")) {
//此处为委托parent(父加载器)加载的过程,非自定义的类还是走双亲委派加载
c = this.getParent().loadClass(name);
} else {
//通过findClass方法加载自定义的类
c = findClass(name);
}
// ------------- 以下为JDK8原逻辑,删除部分时间计算逻辑 --------------
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
} }