类加载的时机
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,类型的加载、连接和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
Java之所以称为动态语言正是因为类型的加载、连接和初始化都是在程序运行期间完成的。
类从被加载到虚拟机内存开始,到卸载除内存为止,整个生命周期包括:
这7个阶段中的:加载、验证、准备、初始化、卸载的顺序是固定的。但它们并不一定是严格同步串行执行,它们之间可能会有交叉,但总是以“开始”的顺序总是按部就班的。至于解析则有可能在初始化之后才开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
虚拟机对于类的初始化阶段严格规定了有且仅有只有5种情况如果对类没有进行过初始化,则必须对类进行“初始化”!
-
遇到new、读取一个类的静态字段(getstatic)、设置一个类的静态字段(putstatic)、调用一个类的静态方法(invokestatic)。但被final修饰、已经在编译期把结果放如常量池的静态字段除外。
-
使用java.lang.reflect包的方法对类进行反射调用时。
-
当类初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(如果是接口,则不必触发其父类初始化)
-
当虚拟机执行一个main方法时,会首先初始化main所在的这个主类。
-
当只用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果
-
REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。(暂未研究此种场景)
上面5种场景是有且仅有,称之为“主动引用”,只有满足上述5种场景之一,就会触发对类进行初始化。其余都不会触发类初始化,称之为“被动引用”。
被动引用:通过子类引用父类的静态字段,不会触发子类的初始化;通过数组定义来引用类,不会触发此类的初始化;常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
类加载过程
JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。
加载
主要完成以下三件事情:
- 通过一个类的全限名来获取定义此类的二进制节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
注意这里不一定非得要从一个Class文件获取,这里既可以从ZIP包中读取(比如从jar包和war包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将JSP文件转换成对应的Class类)。
一个非数组类的加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器(重写一个类加载器的loadClass()方法)去完成。
验证
- 这一阶段的主要目的是为了确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
- 准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public static int v = 123;
实际上变量v在准备阶段过后的初始值为0而不是123,将v赋值为123的putstatic
指令是程序被编译后,存放于类构造器<client>
方法之中。
但是注意如果声明为:
public static final int v = 123;
在编译阶段会为v生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue属性将v赋值为123。
解析
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是class文件中的:CONSTANT_Class_info
、CONSTANT_Field_info
、CONSTANT_Method_info
等类型的常量。
下面我们解释一下解析阶段中符号引用和直接引用的概念:
- 符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义定位到目标即可。与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
- 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由JVM主导。到了初始阶段,才开始真正执行类中定义的Java程序代码。
<client>()
执行特点:
-
初始化阶段是执行类构造器
<client>
方法的过程。<client>
方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。 -
虚拟机会保证
<client>
方法执行之前,父类的<client>
方法已经执行完毕。 -
由于父类的< clinit >()方法先执行,也就意味着父类中定义的静态语句快要优先于子类的变量赋值操作。
-
如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成
<client>()
方法。 -
接口中不能使用静态语句块,但接口与类不太能够的是,执行接口的< clinit >()方法不需要先执行父接口的< clinit >()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的< clinit >()方法。
-
虚拟机会保证一个类的< clinit >()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法完毕。如果一个类的< clinit >()方法中有耗时很长的操作,那就可能造成多个进程阻塞。
注意以下几种情况不会执行类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
- 定义对象数组,不会触发该类的初始化。
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
- 通过类名获取Class对象,不会触发类的初始化。
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作。
类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
类与类加载器
JVM中类的装载是由ClassLoader和它的子类来实现的,Java ClassLoader 是一个重要的Java运行时系统组件。它负责在运行时查找和装入类文件的类。
Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
类装载方式,有两种 :
(1)隐式装载,程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,
(2)显式装载,通过class.forname()等方法,显式加载需要的类 隐式加载与显式加载的区别:两者本质是一样的。
Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。
双亲委派模型
从虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现,属于虚拟机自身的一部分。另外一种就是所有其它的类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader
。
从Java开发人员的角度来看,大部分Java程序一般会使用到以下三种系统提供的类加载器:
- 启动类加载器(Bootstrap classLoader):负责将存放在<JAVA_HOME>\lib 目录中的,或通过
-Xbootclasspath
参数指定路径中的,且是虚拟机识别(按文件名识别,如rt.jar)的类库加载到虚拟机内存中。启动类无法被Java程序直接引用,用户在编写自定义加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
并且由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。下面程序可以获得根类加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径:
public static void main(String[] args) {
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for(URL url : urls){
System.out.println(url.toExternalForm());
}
}
输出
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/resources.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/rt.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/sunrsasign.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/jsse.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/jce.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/charsets.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/lib/jfr.jar
file:/D:/Program%20Files/Java/jre1.8.0_65/classes
-
扩展类加载器(Extensions ClassLoader):这个加载器由
sun.misc.Launcher$ExtClassLoader
实现,负责加载<JAVA_HOME>\lib\ext
目录中的,或者被java.ext.dirs
系统变量所指定的路径中的所有类库。由Java语言实现,开发者可以直接使用扩展类加载器。 -
应用程序类加载器(Application ClassLoader):这个加载器由
sun.misc.Launcher$AppClassLoader
实现。由于这个类加载器是ClassLoader中getSystemClassLoader()
方法的返回值,所以一般也称它为系统类加载器。负责加载用户类路径(ClassPath
)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
这些类加载器之间的关系一般如下图所示:
如上图所示的类加载器之间的这种层次关系,就称为类加载器的双亲委派模型(Parent Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。子类加载器和父类加载器不是以继承(Inheritance)的关系来实现,而是通过组合(Composition)关系来复用父加载器的代码。
双亲委派模型的工作过程为:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。
使用这种模型来组织类加载器之间的关系的好处:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object
类,它存放在rt.jar
之中,无论哪一个类加载器要加载该类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果不使用该模型,由各个类加载器自行去加载的话,如果用户自定义一个java.lang.Object
类且存放在ClassPath
中,那么系统中将会出现多个Object类,应用程序也会变得很混乱。如果我们自定义一个rt.jar
中已有类的同名Java类,会发现JVM可以正常编译,但该类永远无法被加载运行。
双亲委派机制的优势:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子类ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
双亲委派模型对于保证Java程序的稳定运作很重要,实现的核心代码集中在java.lang.ClassLoader
的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;
}
}
类加载器加载Class大致要经过如下8个步骤:
- 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
- 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
- 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
- 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
- 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
- 从文件中载入Class,成功后跳至第8步。
- 抛出ClassNotFountException异常。
- 返回对应的java.lang.Class对象。
参考:
- 《深入理解Java虚拟机》–周志明
- https://blog.csdn.net/m0_38075425/article/details/81627349**