1.类的加载、连接和初始化
1.1类的加载
系统可能在第一次使用某个类时加载该类,也可能采用预加载机制来加载某个类。那么什么是类的加载呢?
类的加载指的是将类的.class
文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class
对象,用来封装类在方法区内的数据结构。当程序使用任何类时,系统都会为之建立一个java.lang.Class
对象。
类的加载的最终结果就是位于堆区中的Class
对象,Class
对象封装了类在方法区内的数据结构,并且向我们提供了访问方法区内数据结构的接口。
类也是一种对象,就像平常说概念主要用于定义、描述其他事物,但概念本身也是一种事物,那么概念本身也需要被描述。类也是如此,每个类是一批具有相同特征的对象的抽象,而系统中所有的类实例实际上也是实例,它们都是
java.lang.Class
的实例
类的加载由类加载器完成,类加载器通常由JVM提供,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,我们也可以通过继承ClassLoader
基类来创建自己的类加载器。
我们可以从不同来源加载类的二进制数据,通常有如下几种来源:
- 1.从本地文件系统加载class文件,这是大多数程序的类加载方式
- 2.从JAR包加载class文件,这种方式也很常见,比如JDBC数据库驱动类就是通过JAR文件加载的
- 3.通过网络加载class文件
- 4.把一个Java源文件动态编译,并执行加载
1.2 类的连接
当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段。连接阶段负责把类的二进制数据合并到JRE中。类的连接又分为如下三个阶段:
- 1.验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。也不会危害JVM自身的安全。
- 2.准备:类准备阶段负责为类的类变量(也即静态变量)分配内存,并初始化为默认值。
- 3.解析:将类的二进制数据中的符号引用替换成直接引用。
这里要注意的是:
(1)准备阶段设置的初始值通常情况下都是数据类型默认的零值(如0,0L,null,False等),而不是在Java代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 5;
,那么变量value
在准备阶段过后的初始值为0,而不是5,因为这时尚未开始执行任何Java方法,而把value
赋值为3的动作是在初始化阶段才会执行。
(2)对基本数据类型来说,对于类变量(即static
变量)和全局变量,如果不显示地对其赋值而直接使用,则系统会为其赋予默认的零值;但对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
(3)对于同时被static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译不通过;而只被final
修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值。总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
(4)对于引用数据类型reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
(5)如果数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
1.3 类的初始化
在类的初始化阶段,JVM负责对类进行初始化,主要就是对类变量进行初始化。在Java类中对类变量指定初始值有两种方式:(1)声明变量时指定初始值;(2)使用静态初始化块为类变量指定初始值。
也就是说在类的初始化过程中,会给类变量赋初值,也会执行静态代码块!而普通代码块是在实例化类的时候,调用类的构造方法之前执行的。
JVM初始化一个类包含如下几个步骤:
- 1.假如这个类还没有被加载和连接,则程序先加载并连接该类
- 2.假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 3.假如类中有初始化语句,则系统依次执行这些初始化语句。
当执行第2个步骤时,系统对直接父类的初始化步骤也遵循此步骤1~3。
那么类会在什么时候进行初始化呢?
当Java程序首次通过下面6种方式来使用某各类或接口时,系统就会初始化该类或接口。
- 1.创建类的实例,比如使用
new
操作符来创建实例,通过反射来创建实例,通过反序列化的方式来创建实例 - 2.调用某个类的类方法。(也就是静态方法)
- 3.访问某个类或接口的变量,或为该类变量赋值
- 4.使用反射方式来强制创建某个类或接口对应的
java.lang.Class
对象。 - 5.初始化某个类的子类。当初始化某个类的子类时,该子类的所有父类都会被初始化。
- 6.直接使用java.exe命令来运行某个主类。
这里需要知道的是,对于一个final
型的类变量,如果该类变量在编译时就可以确定下来,那么这个类变量就相当于”宏变量”。Java编译器会在编译时直接把这个类变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。例如:
class MyTest{
static{
System.out.println("静态初始化块...");
}
// 使用一个字符串直接量为static final的类变量赋值
static final String compileConstant = "Java";
}
public class CompileConstantTest {
public static void main(String[] args) {
// 访问、输出MyTest中的compileConstant类变量
System.out.println(MyTest.compileConstant);
}
}
程序输出:
Java
上述程序的compileConstant
变量会被当成宏变量来看待,程序中所有使用compileConstant
的地方都会在编译时被直接替换成它的值,也就是说在main
方法中的输出,并不会导致初始化MyTest
类。
但是如果final
修饰的类变量不能在编译时确定下来,则必须等到运行时才可以确定该类变量的值,如果通过该类来访问它的类变量,则会导致该类被初始化。例如下述程序:
class MyTest{
static{
System.out.println("静态初始化块...");
}
// 使用System.currentTimeMillis()方法为static final的类变量赋值
static final String compileConstant = "Java" + System.currentTimeMillis();
}
public class CompileConstantTest {
public static void main(String[] args) {
// 访问、输出MyTest中的compileConstant类变量
System.out.println(MyTest.compileConstant);
}
}
程序输出:
静态初始化块...
Java1489143654769
总结一下,类的加载过程包括了加载、验证、准备、解析、初始化五个阶段。而验证、准备和解析阶段可以并称为连接阶段。
2.类加载器
类加载器负责将.class
文件加载到内存中,并为之生成对应的java.lang.Class
对象。
2.1 类加载器简介
类加载器负责加载所有的类,系统为所有被载入内存中的类生成一个java.lang.Class
实例。一旦一个类被载入JVM中,同一个类就不会被再次载入了。那么,怎么才算“同一个类”呢?
正如一个对象有一个唯一的标识一样,一个载入JVM中的类也有一个唯一的标识。在JVM中,一个类用其全限定类名(包括包名和类名)和其类加载器作为其唯一标识。例如,如果在pg
的包中有一个名为Person
的类,被类加载器ClassLoader的实例cl负责加载,则该Person
类对应的Class
对象在JVM中表示为(Person
、pg
、cl
)。这意味着两个类加载器加载的同名类:(Person
、pg
、cl
)和(Person
、pg
、cl2
)是不同的,它们所加载的类也是完全不同、互不兼容的。
在JVM启动时,会形成由三个类加载器组成的初始类加载器层次结构:
- BootStrap ClassLoader:引导类加载器
- Extension ClassLoader:扩展类加载器
- System ClassLoader:系统类加载器
BootStrap ClassLoader被称为引导类加载器(也称为原始类加载器或根类加载器),它是用C语言实现的,负责加载Java的核心类。存放在%JAVA_HOME%\jre\lib下的核心类,比如rt.jar,所有的以java.*
开头的类均被引导类加载器加载。
Extension ClassLoader被称为扩展类加载器,它负责加载JRE的扩展目录(%JAVA_HOME%\jre\lib\ext)中JAR包的类。
System ClassLoader被称为系统类加载器(也称为应用类加载器),它负责在JVM启动时加载来自java命令的 -classpath选项、java.class.path系统属性,或CLASSPATH环境变量所指定的JAR包和类路径。
用户也可以通过继承系统类加载器来创建自定义的类加载器。
JVM中的这4种类加载器的层次结构如下:
注意,该层次结构可以看做是继承上的父子关系,但实际上并不是这么回事。比如扩展类加载器的父亲是引导类加载器,但调用扩展类加载器的父亲并输出之后,结果是null
,这是因为引导类加载器并不是Java语言实现的。如下测试:
public class ClassLoaderPropTest {
public static void main(String[] args) throws Exception {
ClassLoader systemLoader = ClassLoader.getSystemClassLoader();
System.out.println("系统加载器:"+systemLoader);
ClassLoader extensionLoader = systemLoader.getParent();
System.out.println("扩展类加载器:"+ extensionLoader);
System.out.println("扩展类加载路径:"+ System.getProperty("java.ext.dirs"));
System.out.println("扩展类加载器的parent:"+extensionLoader.getParent());
}
}
程序输出:
系统加载器:sun.misc.Launcher$AppClassLoader@4e0e2f2a
扩展类加载器:sun.misc.Launcher$ExtClassLoader@2a139a55
扩展类加载路径:C:\Program Files\Java\jdk1.8.0_73\jre\lib\ext;C:\Windows\Sun\Java\lib\ext
扩展类加载器的parent:null
2.2 类加载机制
JVM的类加载机制主要有如下三种:
- 1.全盘负责。当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显式使用另外一个类加载器来加载。
- 2.父类委托。先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
- 3.缓存机制。缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器从缓存区寻找该Class,只有缓存区不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会失效。
类加载器加载Class大致要经过如下8个步骤:
(1)检测此Class是否载入过(记载缓存区中是否有此Class),如果有则直接进入第(8)步,否则接着执行第(2)步。
(2)如果父类加载器不存在(如果没有父类加载器,则要么parent一定是引导类加载器,要么本身就是引导类加载器),则跳到第(4)步执行;如果父类加载器存在,则直接执行第(3)步。
(3)请求使用父类加载器去载入目标类,如果成功载入则跳到第(8)步,否则接着执行第(5)步。
(4)请求使用引导类加载器去载入目标类,如果成功载入则跳到第(8)步,否则跳到第(7)步。
(5)当前类加载器尝试寻找Class文件(从与此ClassLoader相关的类路径中寻找),如果找到则执行第(6)步,如果找不到则跳到第(7)步。
(6)从文件中载入Class,成功载入后跳到第(8)步。
(7)抛出ClassNotFoundException
异常。
(8)返回对应的java.lang.Class
对象。
所以这里可以引出一个经典的面试题:
你能够自定义一个
java.lang.String
类吗?为什么?
答案显然是不能的。
首先我们直接写一个java.lang.String
类,并写一个Main方法,即:
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("这是我自定义的java.lang.String类");
}
}
运行后提示如下:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
也就是找不到主方法,为什么呢?
因为Java的类加载机制采用了父类委托模式,先交给其父加载器去加载,如果父类加载器加载不了再由自己加载。那么我们自定义的类是由系统类加载器进行加载的,而它的父加载器为扩展类加载器,扩展类加载器的父类为引导类加载器。所以我们自定义的java.lang.String
类最后要交由引导类加载器加载。
根据前文所述,引导类加载器负责加载Java核心类,而java.lang.String
正是系统核心类,所以在JVM启动的时候已经将其加载进内存了。所以这时候它就不再加载自定义的java.lang.String
类了。但是我们知道系统核心的java.lang.String
类中没有main
方法,所以就出现了上面所述的错误提示。
也就是说,我们即使自定义了java.lang.String
类,也是没有用的,它并不会被加载进内存。
但是如果自定义String
类,而包不同,这也是可以的,比如自定义com.my.String
类。
通过上述的面试题,我们就可以理解父类委托机制的设计动机了。采用父类委托机制,就是为了保证Java核心类库的类型安全,要保证所有的核心类一定是通过引导类加载器加载的。
如果不是这样,比如我们自定义的java.lang.String
类直接通过系统类加载器加载的话,就会存在两个版本的java.lang.String
类,这两个类互不兼容,就会有很大的风险。
通过父类委托,对于核心类库的加载工作统一由引导类加载器完成,保证了Java应用所使用的都是同一个版本的Java核心库的类,都是相互兼容的。