一、什么是类加载
开发人员写的.java文件经过编译后会生成.calss文件,而JVM把.class字节码文件加载到内存中,并对数据进行校验、准备、解析、初始化,最终形成可以被JVM直接使用的java类型,这就是虚拟机的类加载过程。
类加载过程就是读取.class文件到内存中,将其放在方法区内,然后在Java堆区创建一个java.lang.Class对象,通过Class对象来访问方法区中类的数据结构。类加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
如下图所示,整个类加载的过程包括:加载、校验、准备、解析、初始化五个阶段,其中校验、准备、解析三个阶段称为"连接"。
在java语言中,类的加载、连接、初始化,都是在程序运行期间动态完成的,程序运行期间经常会从这个class文件中调用另外一个class文件中的方法,而程序在启动的时候,并不会一次性加载所有的class文件,而是根据程序运行的需要,通过类加载器 动态的 加载某个class文件到内存当中的。这种策略虽然会稍有性能开销,但是却为Java应用程序提供了高度的灵活性。
二、类加载的过程
类加载的过程包括加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备、初始化四个阶段发生的顺序是确定的,而解析阶段则不一定,事实上,解析阶段往往是在执行完初始化之后再执行的,这是为了支持Java语言的动态绑定。
另外加载、验证、准备、初始化四个阶段它们是按顺序开始的,但并不是说上一阶段完成了下一阶段才能开始,它们并不是FS的关系,而是SS的关系,这些阶段通常都是互相交叉地混合进行的,在一个阶段执行的过程中调用或激活另一个阶段。
1、加载
注意不要混淆了 "类加载过程" 和 "加载阶段",类加载过程总共包含5个阶段,第一个阶段正好也叫 "加载阶段",不要混淆了。
加载阶段就是将.class文件加载到内存的方法区,并在Java堆中生成一个代表这个类的Class对象的过程。加载阶段的工作是类加载器负责完成的,在加载阶段执行如下操作:
① 通过一个类的全限定名来读取该类的二进制字节流。
② 将这个字节流所代表的静态存储结构转化为 方法区的 运行时数据结构。
③ 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
2、验证
验证阶段是为了验证被加载的类的正确性,确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个检验操作:
① 文件格式验证:验证字节流是否符合Class文件格式的规范。
② 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
③ 字节码验证:通过数据流和控制流分析,确定程序的语义是合法的、符合逻辑的。
④ 符号引用验证:确保解析阶段能正确执行。(因为解析阶段的作用就是将常量池中的符号引用转换为直接引用,具体往后看)
验证阶段是非常重要的,但不是必须的,如果所引用的类经过了反复验证,那么可以考虑采用-Xverify:none参数来关闭Java字节码验证,从而加快类装入的速度,以缩短虚拟机类加载的时间。
3、准备
准备阶段会在方法区中为类的静态变量分配内存并设置默认的初始值。这里设置的初始值是数据类型的默认值,而不是在Java代码中赋的初始值。比如代码 public static int value = 3;在准备阶段value被赋予的初始值是0,而不是3。
准备阶段仅为静态变量分配内存,不包括实例变量,实例变量会随着对象的创建一块分配在Java堆中。也不包括final修饰的静态变量,因为final修饰的变量在编译阶段就已经分配了,准备阶段会显式的初始化。
4、解析
将常量池中的符号引用转化为直接引用的过程。
① 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量(字面量:String s="哈哈","哈哈"是字面量;int i=1,1是字面量),只要能准确无歧义地定位到目标即可,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机实现的内存布局无关,符号引用所引用的目标并不一定已经加载到了内存中,各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
推荐大家在IDEA中安装一个jclasslib插件, jclasslib是一个java字节码查看工具,可以方便我们查看.calss文件。
安装完之后,我们创建一个简单的类,然后Build编译项目,编译完成后,点击Show Bytecode With jclasslib就可以查看当前类编译后的.class文件了。
如下就表示常量池中的符号引用,
② 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。直接引用是一个直接指向目标的指针或偏移地址,如果有了直接引用,那引用的目标必定已经存在于内存中了。
5、初始化
初始化阶段就是执行 类构造器方法 () 的过程。
(1)、()方法
这个方法不需要程序员定义,是javac编译器自动收集类中 "所有静态变量的赋值语句 和 静态代码块中的语句",按照源文件中出现的顺序,合并成一个clinit方法。静态变量的赋值语句和静态代码块中的语句合并之后的执行顺序由源文件中出现的顺序决定。
① 若该类具有父类,JVM会保证子类的 () 执行前,先执行父类的 () 方法。
② 一个类的 () 方法在多线程情况下会加同步锁,以确保一个类只会被初始化一次。
③ 如果类中没有静态变量和静态代码块,那么()方法将不会被生成。
(2)、扩展-()方法
在编译生成class文件时,编译器除了生成()方法外,还会生成一个()方法,()是 实例构造器方法,但是 ()方法不是在"初始化阶段"执行的,()方法的主要作用是在类实例化的过程中,为成员变量分配内存、设置默认的初始值、执行代码块。
()方法是在 类实例化的过程 执行的,执行顺序为,
① 先为成员变量分配内存;
② 为成员变量设置默认的初始值;
③ 根据源码中的顺序执行 成员变量的赋值语句 和 代码块;
结合()方法的执行顺序,理解下面这段代码,最终的执行结果为:x=3,y=2;
注意事项:
① 在执行子类的init方法前,必先执行父类的init方法。
② 类每实例化一次就会执行一次 () 方法。
③ 不管类中有没有成员变量和普通代码块,()方法总是会生成的。
(3)、初始化时机
关于加载阶段的执行时机,jvm规范并没有强制的约束,由各种类型的JVM自己来把握,但是对于类的 "初始化" 时机,jvm规范则严格定义了 有且仅有以下情况必须立即对类进行初始化:
① 使用关键字new创建对象;
② 访问类的静态变量、对类的静态变量赋值 或 调用类的静态方法时;
③ 反射:使用java.lang.reflect包的方法对类进行反射调用的时候;
④ 当初始化一个类的时候,如果发现它的父类还没有被初始化,则会先初始化父类(接口初始化的时候不需要初始化父接口);
⑤ JVM启动时会先初始化 "主类"(类名和文件名相同的那个public类?还是含main方法的类? )
tomcat的主类是BootStrap类,部署到tomcat的Web项目也是以此类的main方法作为入口启动的,web项目部署到tomcat之后,启动tomcat,tomcat从它自己的主函数开始运行,就一直在跑着,等到请求过来的时候,,,
⑥ 当使用java7的动态语言支持时,如果一个MethodHandler实例在解析时,该方法对应的类没有被初始化,则会先初始化这个类;
上面列出来的6种场景称为对一个类的"主动引用",除此之外其他情况都称为"被动引用",被动引用不会触发类的初始操作,所以注意,下面这些情况不会触发类的初始化操作:
① 通过子类引用父类的静态变量,不会导致子类初始化
② 通过数组定义来引用类,不会引发类的初始化
③ 访问final修饰的静态变量(即常量),不会初始化该类
因为final修饰的变量在编译时期就会载入类的静态常量池中。
静态常量池:*.class文件中的常量池,.class文件中的常量池不仅仅包含字符串、数字字面量(字面量:String s="哈哈",s是变量,"哈哈"是字面量;int i=1,i是变量,1是字面量),还包含类、方法的信息,占用class文件绝大部分空间。(编译时期)
运行时常量池 : jvm虚拟机在完成类装载操作后,将.class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。(运行时期)
补充 : 运行时常量池中的常量,基本来源于各个class文件中的常量池。(即每个class文件都有对应的常量池)
(4)、Java类中变量的分类
学完了类加载的整个过程,我们再回顾一下Java类中变量的分类,
三、类加载器
类加载过程中的加载阶段 "通过一个类的全限定名来读取该类的二进制字节流",这个动作就是类加载器来实现的。
1、站在JVM的角度来看,只存在两种不同的类加载器:启动类加载器 和 其他类加载器。在Hotspot中,启动类加载器是使用C/C++语言实现的,嵌套在JVM的内部,是JVM的一部分。其他类加载器都由Java语言实现,独立于虚拟机之外,并且全部直接或间接继承于java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
2、站在Java开发人员的角度来看,类加载器可以划分为以下三类,
启动类加载器:启动类加载器是由C/C++语言实现的,嵌套在JVM内部,负责加载存放在 JAVA_HOME\jre\lib路径下的,或者被-Xbootclasspath参数指定路径下的类,启动类加载器用于提供JVM自身所需要的类,是无法被Java程序直接引用的。
扩展类加载器:该加载器由sun.misc.Launcher$ExtClassLoader实现,派生于ClassLoader类,它负责加载JAVA_HOME\jre\lib\ext目录下的,或者由java.ext.dirs系统属性所指定的目录下的所有类库,开发者可以直接使用扩展类加载器。如果我们把自定义的jar包放到ext目录下,在JVm启动的时候,扩展类加载器也会帮我们加载。
系统类加载器:也叫应用程序类加载器,该类加载器由sun.misc.Launcher$AppClassLoader来实现,派生于ClassLoader类,它负责加载环境变量ClassPath或系统属性java.class.path指定路径下的类库。系统类加载器是程序中默认的类加载器,默认情况下,Java程序中的类都是由它来加载的。开发者可以直接使用系统类加载器,通过ClassLoader.getSystemClassLoader()方法就可以获取到该类加载器。
注意:这些类加载器之间并不是继承的关系,可以理解成上下级,但并不是继承。
扩展:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
四、类加载机制
1、JVM的类加载机制
JVM的类加载机制主要包括:全盘负责、双亲委派、缓存机制三种。
全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用的其它Class也由该类加载器负责载入。
双亲委派:所谓双亲委派机制,就是当某个类加载器接收到加载类的请求时,它并不会自己先去加载,而是把这个请求委托给父加载器去执行,如果父加载器还存在父加载器,则进一步向上委托,依次类推,请求最终将到达顶层的启动类加载器。然后启动类加载器开始尝试加载,如果启动类加载器加载完成则直接返回,如果启动类加载器无法完成此加载任务,则交由下级尝试加载,依次类推。
缓存机制:缓存机制会将所有被加载过的Class放在一个缓存区,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有当缓存中不存在时,类加载器才会去重新加载,并将其放入缓存区,这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
2、双亲委派机制
所谓双亲委派机制,就是当某个类加载器接收到加载类的请求时,它并不会自己先去加载,而是把这个请求委托给父加载器去执行,如果父加载器还存在父加载器,则进一步向上委托,依次类推,请求最终将到达顶层的启动类加载器。然后启动类加载器开始尝试加载,如果启动类加载器加载完成则直接返回,如果启动类加载器无法完成此加载任务,则交由下级尝试加载,依次类推,如果往下推到最后所有类加载器都无法加载这个类,则抛出ClassNotFoundException,这就是双亲委派机制。即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子才想办法自己去完成。
※ 双亲委派机制的好处
① 避免类的重复加载
保证一个类只会被特定的类加载器加载一次,当父加载器已经加载了该类时,子加载器就不会再重复加载。
② 防止核心类库被篡改(沙箱安全机制)
假设通过网络传递过来一个名为java.lang.String的类,通过双亲委派机制传递到启动类加载器,启动类加载器发现这个名字的类在核心API中已被加载,这时就不会重新加载网络传过来的java.lang.String,而是直接返回已加载过的String.class,这样便可以防止核心API库被随意篡改。
上面说的这种对Java核心类库的保护机制,也被称为沙箱安全机制。
五、自定义类加载器
在Java的日常应用开发中,类的加载几乎都是由启动类加载器、扩展类加载器、系统类加载器三者互相配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
1、哪些场景可能会用到自定义类加载器?
① 防止源码泄露
.class文件可以被轻易的反编译,对于公司自研的一些核心类库,可能会把字节码加密,这种情况下我们就需要自定义类加载器,将读取到的字节码先解密再加载。
② 从指定的来源获取字节码文件
因为JVM自带的类加载器是从本地文件系统加载标准的class文件,如果你要加载的字节码来自网络、数据库或运行时计算生成,就可以自定义类加载器从指定的来源加载class文件。
③ 隔离加载类
比如tomcat容器,在一个tomcat容器里部署多个应用,一个tomcat只启动一个JVM,也就是说多个应用都是跑在一个JVM里的,这多个应用之间就是被类加载器隔离开的,不同的应用程序使用各自不同的类加载器。
2、loadClass()方法源码分析
在实现自定义的类加载器之前,我们先来看一下java.lang.ClassLoader中loadclass()方法的实现,因为类加载的入口就是执行loadClass()方法。源码如下,
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); // 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; } }
※ loadClass()执行过程分析,
① 加锁,一个类只允许被同一个加载器加载一次,避免多线程多次加载。
② 根据name(类的全限定名)查找一下是否已经加载过了,如果加载过了就直接返回该类的Class,然后判断是否需要执行resolveClass方法。
resolveClass方法的执行即 "连接(验证、准备、解析)" 过程,通过源码我们看到resolveClass方法被final修饰,不可以被重写,实际上连接的过程也是完全由jvm处理的,我们不能也不可能进行干预。
③ 如果name对应的类不曾被加载过,则现在开始加载。首先查找当前类是否有父类,有父类则交由父类去加载,依次往上传,直到没有父类则交给启动类加载器进行加载。然后从启动类加载器依次往下尝试加载,如果上层的加载器无法加载则抛出ClassNotFoundException 异常,返回给子加载器执行,如此层层传递回来。当传递回本加载器的时候,说明上层的类加载器都无法加载这个类,那么就由本类加载器负责加载:调用 findClass()方法 根据name找到对应的二进制字节流加载到方法区中,在findClass()方法中调用defineClass()方法,defineClass()方法的作用就是将字节流所代表的静态存储结构转化为方法区的运行时数据结构,并返回java.lang.Class对象,加载阶段完成。
⑤ 如果所有的类加载器都找不到对应的字节码文件,最后则抛出ClassNotFoundException。
※ 分析总结
先回顾一下加载阶段主要干的三件事:
① 通过一个类的全限定名来读取该类的二进制字节流。
② 将这个字节流所代表的静态存储结构转化为方法区的 运行时数据结构。
③ 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
※ 对照三件事理解加载阶段方法的调用顺序:loadClass() ——>findClass() ——> defineClass()
loadClass方法通过"双亲委派机制"寻找该类应该由哪个加载器加载,然后调用该加载器的findClass方法,首先将字节码读取到内存中,然后findClass方法再调用defineClass方法,将字节流所代表的静态存储结构转化为方法区的运行时数据结构,并返回java.lang.Class对象,加载阶段完成。
3、自定义类加载器
自定义类加载器的实现,就是自定义类继承于java.lang.ClassLoader,根据自己的需求,重写loadClass() 和/或 findClass() 的过程。
① 重写loadClass()可以破坏双亲委派机制,自定义加载机制。
② 重写findClass()是为了控制二进制字节流的加载方式。
③ 而defineClass()是java.lang.ClassLoader类提供的final方法,不允许被重写,它干的事情就是将二进制字节流转化为方法区中类的数据存储格式,并返回这个类的java.lang.Class对象。
所以defineClass也是我们在重写fianclass时必须调用的,传进去一个Class文件的字节数组,就会返回一个Class对象。
※综上所述,我们来自定义一个类加载器,
① 创建一个Test.java类
package com.felix.jvm; public class Test { public void printDesc(){ System.out.println("被<自定义类加载器>加载"); } }
② 编译(IDEA中Build),将编译后的Test.class放到F盘,然后从IDEA中删掉Test.java文件重新Build(为了避免系统类加载器的影响)
③ 自定义类加载器,重写findclass方法(因为重新loadclass方法会破坏双亲委派机制,在这里我们不重写,有必要的时候可以重写)
package com.felix.jvm; import java.io.*; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class MyClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) { try { // 读取class字节码文件 InputStream ins = new FileInputStream("F:\\Test.class"); 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); } byte[] bytes = baos.toByteArray(); if (bytes == null) { throw new ClassNotFoundException(); } else { return defineClass(name, bytes, 0, bytes.length); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return null; } public static void main(String[] args) { try { MyClassLoader myClassLoader = new MyClassLoader(); // 加载类 Class<?> clazz = myClassLoader.loadClass("com.felix.jvm.Test"); System.out.println("类加载器:"+clazz.getClassLoader()); Object obj = clazz.newInstance(); Method method = clazz.getDeclaredMethod("printDesc"); method.invoke(obj); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InstantiationException e) { e.printStackTrace(); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } } }
执行结果可以看到Test类由我们自定义的类加载器加载,利用发射调用执行了printDesc方法,
⑤ 如果在项目中也存在一个和F盘全限定名一样的字节码文件,执行结果会是什么?
在IDEA中新建Test.java类,它的全限定名和F盘Test的全限定名一样,都是com.felix.jvm.Test,
package com.felix.jvm; public class Test { public void printDesc(){ System.out.println("被<系统类加载器>加载"); } }
执行结果发现 由系统类加载器加载了本应用程序中的Test类,并没有加载F盘的Test类,
原因分析:
双亲委派机制,从启动类加载器开始,依次往下尝试加载"com.felix.jvm.Test",到系统类加载器时,加载了项目中的Test类,所以我们自定义的类加载器将不再重复加载同名类。
而当项目中不存在"com.felix.jvm.Test"时,因为系统类加载器默认只加载本应用程序的类,所以它找不到F盘的类,而我们自定义的类加载器却可以从指定的位置(F盘)加载.class文件。
六、ClassLoader.loadClass和Class.forName区别
在上面自定义类加载器的测试中,我们使用了ClassLoader.loadClass来获取Class对象,
Class<?> clazz = myClassLoader.loadClass("com.felix.jvm.Test");
其实,上面这行代码我们也可以这样写,
Class<?> clazz = Class.forName("com.felix.jvm.Test", true, myClassLoader);
那两者有什么区别呢?
① Class.forName(className)内部调用的是Class.forName(className,true,classloader),第2个boolean参数表示类是否需要初始化,所以Class.forName(className)默认是会执行初始化的,一旦初始化,就会触发执行()方法。
②ClassLoader.loadClass(className)内部调用的是ClassLoader.loadClass(className,false)
,第2个boolean参数表示目标对象是否进行连接,false表示不进行连接,不进行连接那么也就不会执行初始化操作。
所以ClassLoader.loadClass 和 Class.forName 的区别就是,Class.forName会执行初始化,ClassLoader.loadClass不会执行初始化。



















还没有评论,来说两句吧...