JVM类加载机制
在java语言里,类的加载、连接和初始化都是在程序运行期间完成的,这种特性为java应用程序提供了高度的灵活性。
类加载的时机
类的生命周期包括:加载,连接(验证、准备、解析),初始化,使用和卸载。
Java虚拟机没有强制约束类的加载时机,只是规定了类的初始化时机,而在初始化时,需要确保加载,验证,准备在之前已经完成。
java程序对类的使用方式可分为2种:主动使用和被动使用。只有主动使用才会触发类的初始化
主动使用:
- 使用new关键字的时候
- 读取或设置一个类的静态字段的时候
- 调用一个类的静态方法的时候
- 使用java.lang.reflect包的方法对类进行反射调用的时候
- 当初始化一个类时,如果其父类还未初始化,则会触发父类的初始化
- 虚拟机启动时会初始化用户指定的包含main()函数的类
- JDK1.7开始提供的动态语言支持
被动使用:
除了以上7种主动使用方式,其他使用Java类的方式都可以看作是对类的被动使用,都不会导致对类的初始化。
所有的Java虚拟机实现必须在每个类或接口被Java程序“首次主动使用”时才会初始化它们。
class Parent {
static int i = 0;
static {
System.out.println("parent init ... ");
}
}
class Child extends Parent{
static {
System.out.println("child init ");
}
}
public class Test1 {
public static void main(String[] args) {
System.out.println(Child.i);
}
}
输出结果:
parent init ...
0
通过子类调用父类的静态字段,并不会触发子类的初始化。
类加载过程
加载
加载是类加载过程的一个阶段,在类加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名(即.class文件)来获取定义此类的二进制流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生出一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
加载class文件的方式:
- 从本地磁盘中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有的数据库中提取.class文件
- 将java源文件动态编译为.class文件
使用类加载器L加载类N的步骤:
-
首先,java虚拟机检查L是否被记录为有N所表示的类或接口的初始加载器。如果是,那么这次创建的尝试动作是无效的,且加载动作抛出LinkageError异常
-
否则,Java虚拟机尝试解析二进制表示。但是这个二进制表示可能不是N的有效描述。因此,需要进行以下验证:
-
如果发现加载的描述不符合ClassFile的结构,那么加载过程将抛出ClassFormatError异常
-
如果这个二进制表示里的主版本号或副版本号不受虚拟机支持,那么加载动作就会抛出UnsupportedClassVersionError异常
-
如果该描述不能真正表示名称为N的类,那么加载过程就会抛出NoClassDefError异常或其子类的异常
-
-
如果N存在一个直接父类或者多个父接口,那么会使用相关加载算法加载,具体见:解析阶段
-
Java虚拟机标记N的定义类加载器为L,并且记录下L是N的初始类加载器。
连接
验证
验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
从整体上看,验证阶段大致会完成以下4个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
验证内容:
-
类文件的结构检查
-
语义检查
-
字节码验证
-
二进制兼容性验证
准备
准备阶段是正式为类变量(被static修饰的变量)分配内存并设置初始值,这些变量所使用的内存都将在方法区中分配。注意:这一步只是为类变量设置默认值,比如:
public static int val = 10;
这一步设置的默认值为val = 0
,而不是10。
解析
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。
符号引用:
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:
直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
初始化
在准备阶段,变量已经赋了一次默认值,而在初始化阶段,则会将静态变量的真正的初始值赋予该变量,即程序员所指定的值。
假如这个类还没有被加载和连接,那么就先进行加载和连接 。
假如类存在直接父类,并且这个父类还没有被初始化,那就先去初始化直接父类 。
假如类存在初始化语句,那么就去执行这个初始化语句 。
当java虚拟机初始化一个类时,要求它所有父类都已经初始化,但是这条规则并不适用于接口 。
初始化阶段是执行类构造器()方法 的过程。
()方法是由编译器字段搜集类中的所有变量的赋值动作和静态语句块(static{}块)中语句合并产生的,编译器搜集的顺序是由语句在原文件中出现的顺序所决定的。
类加载器
类加载的最终产品是位于内存中的Class对象。而实现类加载这个动作正是通过类加载器。类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。
在java虚拟机中,一共存在以下两种类型的类加载器:Java虚拟机自带的类加载器和用户自定义的类加载器。其中Java虚拟机自带的类加载器为以下三种:
-
根类加载器(BootStrap) :由C++语言实现,是虚拟机自身的一部分。这个类加载器负责加载$JAVA_HOME/lib目录下的或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类加载到虚拟机的内存中。如:rt.jar。
-
扩展类加载器(Extension) :由
sun.misc.Lancher$ExtClassLoader
实现,负责加载$JAVA_HOME/lib/ext目录中的或者被java.ext.dirs
系统变量所指定的路径中的所有类库。 -
系统(应用)类加载器(System) :由
sun.misc.Lancher$AppClassLoader
实现。它负责加载用户类路径上所指定的类库,如果应用程序未自定义过自己的类加载器,一般情况下就是程序中默认的类加载器
而用户自定义的类加载器则需要继承自java.lang.ClassLoader
,用户可以通过自定义类加载器来自定义类的加载方式。
双亲委派模型
上图展示的类加载器之间的层次模型被称为双亲委派模型。双亲委派模型要求出类顶层类加载器之外,其余的类加载器都应该有自己的父亲类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,都是采用组合的方式。
双亲委派模型的工作过程:如果一个类加载器收到了加载类的请求时,它并不会立刻去尝试加载该类,而是将加载请求委派给父类加载器去完成,每一个层次的类都是如此,因此所有的加载请求最终都会被传送到启动类加载器中,只有当父类反馈自己无法完成这个加载请求时,子类加载器才会尝试自己去加载。
命名空间
每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成。
在同一个命名空间中,不会出现类的完整名字相同的类。
在不同的命名空间中,有可能会出现类的完整名字相同的两个类。
子加载器所加载的类能够访问父加载器所加载的类,而父加载器所加载的类无法访问父加载器所加载的类。
同一个命名空间的类是相互可见的
子加载器的命名空间包含所有父加载器的命名空间。
由父加载器加载的类不能看见子加载器加载的类。
类加载器的双亲委托模型的好处
-
可以确保java核心库类型安全:所有的Java应用都至少会引用java.lang.Object类,在运行期,java.lang.Object这个类会被加载到java虚拟机中,如果这个加载过程是由java应用自己的类加载器所完成,那么很可能就会在jvm中存在多个版本的java.lang.Object类,而且这些类之间是不兼容的,相互不可见的(正是命名空间在发挥着作用)。借助于双亲委托机制,java核心类库中的类的加载工作都是由启动类加载器统一完成,从而确保了java应用所使用的都是同一个版本的java核心类库,它们之间是相互兼容的。
-
可以确保java核心类库所提供的类不会被自定义的类所替代。
-
不同的类加载器可以为相同名称(binary name)的类创建额外的命名空间,相同名称的类可以并存在java虚拟机中,只需要用不同的类加载器来加载它们即可,不同的类加载器所加载的类之间是不兼容的,这就相当于在java虚拟机内部创建了一个又一个相互隔离的java类空间,这类技术在很多框架中都得到了实际应用。
在运行期,一个java类是由该类的完全限定名(binary name,二进制名)和用于加载该类的定义类加载器所共同决定的。如果同样名字(即相同的完全限定名)的类是由两个不同的加载器所加载,那么这些类就是不同的,即便.class文件的字节码完全一样,并且从相同的位置加载亦如此。
双亲委派模型的实现
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 首先,检查类是否已经被加载
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异常
// 说明父类加载器无法加载该类
}
if (c == null) {
// 在父类加载器无法加载的情况下,调用自身的findClass去加载
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;
}
}
简单的说就是以下三点:
- 调用 findLoadedClass(String) 来检查是否已经加载类。
- 在父类加载器上调用 loadClass 方法。如果父类加载器为 null,则使用虚拟机的内置类加载器。
- 调用 findClass(String) 方法查找类。 如果使用上述步骤找到类,并且resolve标志位真,则此方法将在得到的Class对象上调用
resolveClass(class)
方法 鼓励使用ClassLoader的子类重写findClass(String),而不是使用loadClass(String)。
自定义类加载器
Java虚拟机自定义类加载器只需要继承ClassLoader
类。
每个class对象都包含一个对定义它的ClassLoader的引用。
数组类不是由类加载器创建的,而是由Java在运行时根据需要自动创建。数组类的类加载器由 Class.getClassLoader()返回,该加载器与其元素类型的类加载器是相同的;如果该元素类型是基本类型,则该数组类没有类加载器。
自定义类加载器如下:
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.findClass(name);
}
try {
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
}
获取ClassLoader的方式:
// 获取系统的ClassLoader
ClassLoader loader = ClassLoader.getSystemClassLoader();
// 获取当前类的ClasLoader
Class.forName("java.lang.String").getClassLoader();
// 获取当前线程上下文的ClassLoader
Thread.currentThread().getContextClassLoader();
线程上下文类加载器
Jvm的双亲委派模型并不是一个强制性的约束模型,双亲委派模型实际上是可以被破坏的。
一个典型的例子就是JNDI(Java Naming and Directory Interface,Java命名和目录接口),它的代码由启动类加载器加载,但它需要调用独立厂商实现并部署在应用程序ClassPath下的JNDI接口提供者,但启动类加载器并不认识这些代码,因此,为了解决此问题,java提供了:线程上下文类加载器。这个类加载器可以通过java.lang.Thread
的setContentClassLoader()
方法进行设置。
父ClassLoader可以使用当前线程Thread.currentThread().getContextClassLoader()所指定的ClassLoader 所加载的类,这就改变了父ClassLoader不能使用子ClassLoader或是其他没有直接父子关系 ClassLoader加载类的情况,即改变了双亲委托模型。
线程上下文类加载器就是当前线程的Current ClassLoader 在双亲委托模型下,类加载是由下而上的,即下层的类加载器会委托上层进行加载。但是对于SPI来说,有些接口是 Java核心库所提供的,而Java核心库是由启动类加载器来加载的,而这些接口的实现却来自不同的jar包,Java的 启动类加载器是不会加载其他来源的jar包,这样传统的双亲委托模型就无法满足SPI的要求,而通过给当前线程设置 上下文类加载器来实现对与接口实现类的加载。 线程上下文类加载器的一般使用模式(获取-使用-还原) ContestClassLoader就是为了破坏Java的类加载委托机制。 当高层提供了统一的接口让低层区实现,同时又要在高层加载或实例化低层的类时,就必须要通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。
说到SPI就顺带提一下ServiceLoader
这个类,它是在JDK1.6以后引入的类。它提供了一个简单的服务提供者加载设施。 步骤如下:
- 定义接口
- 定义接口的实现
- 创建resources/META-INF/services目录
- 在上一步创建的目录下创建一个以接口名(类的全名) 命名的文件,文件的内容是实现类的类名 (类的全名), 如:在services目录下创建的文件是com.stone.imageloader.ImageLoader文件中的内容为ImageLoader接口的实现类, 可能是com.stone.imageloader.impl.FrescoImageLoader
- 使用ServiceLoader查找接口的实现.