探秘Java类加载
Java是一门面向对象的编程语言。
面向对象以抽象为基础,有封装、继承、多态三大特性。
宇宙万物,经过抽象,均可归入相应的种类。不同种类之间,有着相对井然的分别。
Java中的类,便是基于现实世界中的类别抽象出来的。
类本身表示一类事物,是对这类事物共性的抽象与封装。类封装了一类事物的属性和方法。
类与类之间,有着不同的层级。
以生物界中的分类为例,遵循“界门纲目科属种”的级别体系,人类(亦可称为“人种”)的层级体系是:动物界---脊索动物门---哺乳纲---灵长目---人科---人属---人种。
从人种到动物界,依次继承父类的共有属性和方法,而且又独具形态。
举例来说,动物都需要吃东西来维持生命所需的能量,同是吃东西,不同种类的动物各有特点。
又譬如,动物界与植物界的一个关键区别是,能否移动。在动物界之中,都是移动,但是各子类的移动方式几乎互不相同。
举例来说,人通过走路、奔跑、攀爬等来移动,鸟通过飞翔、两下肢等来移动,鱼则通过在水中漂游来移动等。这使得动物的移动功能丰富多彩。
不仅如此,即便属于同一种类的个体,在表现出来的公有功能方面,也是各不相同。
譬如,虽然同为人类,普遍具备说话的功能,但是每个具体的个人在说话时,音色又各自不同。
我们生活的世界,就是这样丰富多彩。既有共性的东西,又有具体不同的风格。
Java语言源于为解决现实世界中各种各样应用问题提供一整套解决方案。
所以,我们生活的现实世界,乃至整个宇宙,深深地映射入Java语言中。
世界与宇宙何其深邃与复杂,同样,Java的博大精深不言而喻。
可以说,每个Java程序的运行,都是为了解决某个或某种应用问题而生。
古人说“格物致知”,我们探秘Java程序运行的内在原理,有助于帮助我们深入认识Java世界的运行机制。
每个Java程序,都离不开类和对象。
所以,我们就从类加载说起。
一、类的生命周期
想象一下,你在Eclipse里写了一个Java程序,通过javac(Java编译器),将Java源代码编译为.class字节码文件。
字节码文件静静地躺在你的电脑磁盘里,你要运行这个Java程序,就要去运行编译后的字节码文件。
加载.class字节码文件到内存,形成供JVM使用的类,并到这个类从内存中销毁,这便是类的生命周期。
总的来说,类的生命周期经过了如图所示的阶段:
1.加载
关于加载,其实,就是根据.class文件找到类的信息将其加载到方法区中,然后在堆区中实例化一个java.lang.Class对象,作为方法区中这个类信息的入口。
需要简单科普一下的是:Java程序运行起来时成为进程,操作系统需要为该进程分配内存空间。Java程序的进程会将所分得的内存空间再予以分区,主要有栈区(存储局部变量)、堆区(存储创建的对象)、方法区(存储类的方法代码,以及类的静态成员变量信息,还有常量池)、程序计数器(记录线程的执行信息)、本地方法栈(与 操作系统底层交互时使用)。如图所示:
2.链接
有的出处称为“连接”,若从英文单词“linking”判断,则翻译为“链接”比较合适。
链接一般会与加载阶段和初始化阶段交叉进行。
链接的过程由三部分组成:验证、准备和解析。
(1)验证:该阶段是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
(2)准备:主要是为由static修饰的成员变量分配内存空间,并设置默认的初始值。默认初始值如下:
①8种基本数据类型的默认初始值是0。
②引用类型默认的初始值是null。
③对于有static final修饰的常量会直接赋值,例如:static final int x=10;则x默认就是10。
(3)解析:就是把常量池中的符号引用转换为直接引用,也就是说,JVM会将所有的类或接口名、字段名、方法名转换为具体的内存地址。
3.初始化
这是将静态成员变量(也称为“类变量”)赋值的过程。
也就是说,只有static修饰的变量才能被初始化,执行的顺序是:
父类静态域(静态成员变量)或者静态代码块,然后是子类静态域或者子类静态代码块。
并非所有的类都会被初始化,只有那些被直接引用(主动引用)的类才会被初始化。在Java中,类被直接引用的情况有:
①通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法;
②通过反射方式执行以上三种行为;
③初始化子类的时候,会触发父类的初始化;
④作为程序入口直接运行时(也就是直接调用main方法);
除了以上4种情况,其他使用类的方式叫做被动引用,被动引用不会触发类的初始化。
被动引用举例:
(1)子类调用父类的静态变量,子类不会被初始化,只有父类被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。
(2)通过数组定义来引用类,不会触发类的初始化。
(3)访问类的常亮,不会初始化类。
4.使用
类在使用过程中也存在三步:对象实例化、垃圾收集、对象终结。
(1)对象实例化:就是执行类中构造函数的内容,如果该类存在父类,JVM会通过显式或者隐式的方式先执行父类的构造函数,在堆内存中为父类的实例变量开辟空间,并赋予默认的初始值;然后,引用变量获取对象的首地址,通过操作对象来调用实例变量和方法。
(2)垃圾收集:当对象不再被引用的时候,就会被JVM虚拟机标上特别的垃圾标识,在堆区中等待被GC回收。
(3)对象的终结:对象被GC回收后,对象就不再存在了,对象的生命也就走到了尽头。
5.卸载
这是类的生命周期中最后的一步。
程序中不再有该类的引用,该类会被JVM执行垃圾回收,类在本次程序运行中的生命结束。
二、双亲委派
Java中的类加载存在层次性,一个重要的加载模型是双亲委派。
先来看Java中类加载器的层次体系:
什么是类加载器呢?
简而言之,类加载器可以将.class字节码
文件加载到JVM
内存中的方法区形成类模板(或者称为该类的数据结构/镜像),并在堆区中产生Class
对象。
如果站在JVM
的角度来看,只存在两种类加载器:
1.启动类加载器(Bootstrap ClassLoader
):
也称为“根加载器”。由C++
语言实现(针对HotSpot
),负责将存放在<JAVA_HOME>\lib
目录或-Xbootclasspath
参数指定的路径中的类库加载到内存中。
2.其他类加载器:
由Java语言实现,继承自抽象类ClassLoader。如:
(1)扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库。
(2)应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况下,如果我们没有自定义类加载器,默认就是用这个加载器。通过在控制台打印(System.out.println(System.getProperty("java.class.path"));),可以看到应用程序类加载器加载的路径信息。如图所示:
C:\Program Files\Java\jdk1.8.0_181\jre\lib\resources.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\rt.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\jsse.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\jce.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\charsets.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\jfr.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\access-bridge-64.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\cldrdata.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\dnsns.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jaccess.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\jfxrt.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\localedata.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\nashorn.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunec.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunjce_provider.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunmscapi.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\sunpkcs11.jar; C:\Program Files\Java\jdk1.8.0_181\jre\lib\ext\zipfs.jar; E:\workspace\eclipse\work_j2ee\java1_8\bin
双亲委派模型的工作过程是:
如果一个类加载器收到类加载的请求,它会先判断这个类是否已经加载过,若已经加载过,就不再重复加载;若还未加载过,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,若该类加载器无父类加载器,则将加载请求委派给根类加载器。每个类加载器都是如此(根类加载器除外)。只有当父类加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException
),子类加载器才会尝试自己去加载。
Java在类加载中采用双亲委派模型有什么好处呢?
使得Java类同其类加载器一起具备了一种带优先级的层次关系,从而保证了程序运行中类的唯一性。
我们知道,程序运行起来时,每个类在堆内存中的Class对象仅有唯一的一个,不会引起程序运行中类的混乱,其根源在于Java类加载中采用的双亲委派模型。
三、自定义类加载器
有的时候,我们需要当前程序以外的class文件,这时,我们就需要自定义类加载器,对相应的class文件进行加载。
自定义类加载器的步骤是:
1.继承ClassLoader
2.重写findClass()方法
3.调用defineClass()方法
接下来自定义一个类加载器,加载E:/test下的Test2.class文件。
Test2.class文件的源代码文件Test2.java:
package bwie2; public class Test2 { public void say() { System.out.println("Hello China"); } }
接着,创建自定义类加载器:
package bwie; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; public class MyCloassLoader2 extends ClassLoader { private String classPath;// 要加载的类路径 public MyCloassLoader2(String classPath) {// 构造方法传参 this.classPath = classPath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException {// 查找类 byte[] classData = getData(name); if (classData == null) { //若字节码为空,则抛出异常 throw new ClassNotFoundException(); } else { // defineClass,将字节码转化为类 return defineClass(name, classData, 0, classData.length); } //return super.findClass(name); } // 返回类的字节码 private byte[] getData(String className) { InputStream in = null; ByteArrayOutputStream out = null; String path = classPath + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; try { in = new FileInputStream(path); out = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len = 0; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } in.close(); out.close(); return out.toByteArray(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return null; } }
然后,通过测试类进行测试:
package bwie; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class Test { public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { //自定义类加载器的加载路径 MyCloassLoader2 classLoader = new MyCloassLoader2("E:/test"); //包名+类名 Class<?> clazz = classLoader.loadClass("bwie2.Test2"); if(clazz!=null) { Object obj = clazz.newInstance(); Method method = clazz.getMethod("say"); method.invoke(obj); System.out.println(clazz.getClassLoader().toString()); } } }
程序执行后,控制台打印如图所示:
可见,笔者使用自定义的类加载器MyCloassLoader2成功地加载了程序以外的class文件。
四、深入讲解反射
反射是Java语言中一个非常重要的机制。
程序员们一般都知道:通过反射,可以获取类与对象的所有信息,执行若干操作(如创建对象,方法调用),还可以修改类的数据结构(如修改访问权限)。
在Java中,反射对应的单词是reflect。
提到反射,不免让人霎时想起光的反射(Reflection of light)。
Java里运用反射,是否与光的反射有关?这也涉及Java为什么要取名为反射。
举个例子来说,一个美女站在镜子前,请问,镜子里的美女和镜子前的美女,是否同一个美女?
答案是肯定的。
我们再来看Java程序的加载与运行。
一个被编译为.class字节码文件的类,经过JVM的加载,在方法区中形成对应的类模板。
那么请问,JVM加载出的类模板,与加载前的类,是不是同一个类?
答案是肯定的。
大家想一下:一个人站在镜子前,通过光的反射,可以在镜子里产生一个镜像。镜像与镜子前的人是同一个人。这是运用了光的反射规则。
实际上,我们能看到五彩缤纷的世界,一个重要原因是光的反射的存在。
光的反射外在表现为一种现象,本质是一种机制和规则。
同样,一个表现为.class字节码文件的类,经过JVM中的类加载器加载,在方法区中形成类模板,也相当于类的“镜像”。
大家再想下:Java中,加载前、表现为.class字节码文件的类,与加载后、在方法区中形成的类模板,同属于一个类,这与光的反射是不是有异曲同工之妙?
这也就是Java为什么将类加载后、在内存的方法区中形成类模板的机制,称为反射的缘由。
看来,Java语言的缔造者不愧是大牛,将技术比喻得那么贴切,又那么接近生活!
大家还会看到,上图中,堆区里有个Class对象,类加载时会在堆区中产生Class对象。
程序加载运行时,一个类在内存中的Class对象与类模板都是唯一的。
程序中通过Class对象操作类模板。
可以说,程序中要运用反射,就离不开Class对象。那么,Class对象究竟是什么?
如果我们把JVM看作是人的话,对于程序员来说,通过阅读Java源代码,能够了解一个类的数据结构,那么,Java程序在运行中,JVM又是如何读懂类的数据结构的呢?
这要归功于类加载器加载class文件在方法区生成该类的模板。如果说,class文件静态地存储了类信息,类加载器加载出来的类模板相当于类在动态运行环境中的数据结构,JVM就是通过这个类模板来认识与操作这个类的。
编程语言实现了人机交互。Java语言也是如此。
我们要操控JVM虚拟机去操作内存中的某个类,应该怎么办呢?Java语言为所有Java数据类型(基本数据类型与引用数据类型)均提供了class属性,通过该属性可以返回Class对象,这个Class对象是我们在程序中运用反射机制,是我们与JVM交互、指挥JVM去操作类模板的接口性工具。
机器懂的,我们未必懂。怎么办呢?找个中间人,通过中间人操作机器。这就好比,我们通过操作系统去操作电脑硬件那样。
我们通过Class对象,指挥JVM操作程序动态运行中的类模板。
五、对象的生命周期
在Java中,对象的生命周期包括以下几个阶段:
1. 创建阶段(Created) 2. 应用阶段(In Use) 3. 不可见阶段(Invisible) 4. 不可达阶段(Unreachable) 5. 收集阶段(Collected) 6. 终结阶段(Finalized) 7. 对象空间重分配阶段(De-allocated)
如图所示:
1.创建阶段(Created)
在创建阶段系统通过下面的几个步骤来完成对象的创建过程:
l 为对象分配存储空间
l 开始构造对象
l 从超类到子类对static成员进行初始化
l 超类成员变量按顺序初始化,递归调用超类的构造方法
l 子类成员变量按顺序初始化,子类构造方法调用
一旦对象被创建,并被分派给某些变量赋值,这个对象的状态就切换到了应用阶段。
2.应用阶段(In Use)
对象至少被一个强引用持有着。
3.不可见阶段(Invisible)
当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然这些引用仍然是存在着的。
简单来说,就是程序的执行已经超出了该对象的作用域了。
比如,在使用某个局部变量count时,已经超出该局部变量的作用域(不可见),那么就称该变量count处于不可见阶段。这种情况下,编译期在编译阶段通常就会提示与报错。
4.不可达阶段(Unreachable)
对象处于不可达阶段是指该对象不再被任何强引用所持有。
与“不可见阶段”相比,“不可达阶段”是指程序不再持有该对象的任何强引用,这种情况下,该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”。这些GC root可能会导致对象的内存泄露,使得对象无法被回收。
5.可收集阶段、终结阶段与释放阶段
这是对象生命周期的最后一个阶段:可收集阶段、终结阶段与释放阶段。
当对象处于这个阶段的时候,可能处于下面三种情况:
(1)垃圾回收器发现该对象已经不可到达,则对象进入“可收集阶段”。
(2)finalize方法已经被执行,则对象空间等待被垃圾回收器进行回收,即“终结阶段”。
(3)对象空间已被重用,即“对象空间重新分配阶段”。
当对象处于上面的三种情况时,该对象就处于可收集阶段、终结阶段与释放阶段了。JVM虚拟机就可以直接将该对象回收了。