探秘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虚拟机就可以直接将该对象回收了。

posted @ 2018-11-22 18:07  奔跑在梦想的道路上  阅读(329)  评论(0编辑  收藏  举报