各人下雪,各有各的隐晦与皎洁。
在java代码中,类型的加载,连接,初始化过程都是在程序运行期间完成的
类加载过程
当程序需要主动使用某个类时,前提条件是这个类已经被初始化。而如果这个类还没有被初始化时,JVM就会通过相应的机制来执行对类的加载,完成初始化。
所有的java虚拟机实现必须在每个类或接口被java程序“首次主动使用”时才初始化他们
-
什么叫做主动使用?
创建类的实例(new)
访问某个类或接口的静态变量,或对该静态变量赋值
调用类的静态方法
反射(Class.forName(cn.test.Demo))
初始化一个类的子类
java虚拟机被标明为启动类的类(Java Test)
java1.7开始提供的动态语言支持 -
什么叫做被动使用?
除了主动使用,其他对类的使用都可以看做是对类的被动使用,都不会导致类的初始化
类加载分为三步,加载,链接,初始化。
加载
查找并加载类的字节码文件中的二进制数据读到内存中,将其放到运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象,用来封装类在方法区内的数据结构,并且向java程序员提供了访问方法区内的数据结构的接口。
类的加载过程是由类加载器来完成的,类加载器是由JVM提供的。当然,用户也可以自定义类加载器进行加载指定的类,每个类加载负责的职责不同,这个我们后面会说到。
通过使用类加载可以对不同来源的类的二进制文件进行加载,通常的来源有:
- 从本地文件系统加载class文件,这是最常用的方式,加载我们通过开发工具编写的class文件。
- 从JAR包加载class文件。通常我们需要更多的需求,就需要引入jar包,这也是一种很常见的方式,比如,数据库连接驱动类jar,junit测试包等等。JVM可以从jar包文件中直接加载需要的class类。
- 通过网络加载class文件。
- 把一个Java源文件动态编译,并执行加载。
前面说过,当程序需要主动使用某个类时,就会对类进行初始化,但在类的加载阶段,并不一定要求“首次主动使用”时才执行对类的加载。Java虚拟机规范允许系统预先加载某些类。
JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误),如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
链接
链接分为三步. 验证,准备,解析。
- 验证
验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。Java是相对C++语言是安全的语言,例如它有C++不具有的数组越界的检查。这本身就是对自身安全的一种保护。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
文件格式验证:
主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
元数据验证:
对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
字节码验证:
最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
- 准备
类准备阶段负责为类的静态变量分配内存,并设置默认初始值。要注意的是,设置的是字段默认值,而不是我们编写代码赋予的值。
public class Main {
//当前我们设置的值为abc,但在准备阶段,赋予的是字段默认值,String类型字段默认值为null
//因此这时候String类型的str的值为赋予为null,而不是abc
static String str="abc";
//同样,int类型默认值为0,那这时候int字段变量i的值被赋予0
static int i=1024;
}
- 解析
将类的二进制数据中的符号引用替换成直接引用。
符号引用:
以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。
直接引用:
是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。
初始化
为类变量赋于正确的初始值。
步骤:
假如这个类还没有被加载和连接,那就先进行加载和连接。
假如类存在直接父类,并且这个类的父类还没有进行初始化,那就先对父类进行初始化(不适用接口)。这个后面会提到。
假如类中存在初始化语句,那就依次执行这些初始化语句。
public class Main {
//这时候才会给字段String类型的str变量赋予正确的abc
static String str="abc";
//同样,这时候i的值就为0
static int i=1024;
}
类加载注意点
- 所有的java虚拟机实现必须在每个类或接口被java程序“首次主动使用”时才初始化他们。
- 当一个类初始化的时候,要求其父类全部都初始化。
当需要使用某个类时,就会对该类进行初始化操作,而在初始化操作之前,会检查此类是否有父类,有父类,而且父类还没有被初始化的时候,就先会对父类进行初始化。
- 常量(final)在编译阶段会存入到调用这个常量的方法所在的类的常量池中,本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量的类的初始化。
public class Main{
public static void main(String[] args) {
//比如我们现在调用Demo类的静态常量str,而其实Demo这个类并没有初始化
System.out.println(Demo.str);
}
}
class Demo{
static final String str="abc";
}
- 而当一个常量并非编译器所可以确定的话,比如UUID,那么其值就不会放到调用类的常量池中,程序运行时,将导致主动使用这个类的常量,导致类的初始化。
public class Main{
public static void main(String[] args) {
//而当我们这次调用Demo这个类的str方法时,Demo类将会被初始化
//因为str的值在编译期间不能确定。
System.out.println(Demo.str);
}
}
class Demo{
static final String str= UUID.randomUUID().toString();
}
- 对于数组实例来说,其类型是由JVM在运行期动态生成的,其父类是Object(new一个数组类型不会导致类的主动使用),类加载器是根类加载器,基本类型数组实例无类加载器
public class Main{
public static void main(String[] args) {
//不会导致Demo类的初始化
Demo[] demos=new Demo[5];
}
}
class Demo{
}
- 调用ClassLoader类的loadClass方法去加载一个类,并不是对类的主动使用,并不会导致类的初始化( Class.forName()除外)
public class Main{
public static void main(String[] args) throws Exception{
//不会导致Demo类的初始化
Demo.class.getClassLoader().loadClass("cn.test.test.Demo");
//会导致Demo类的初始化
Class.forName("cn.test.test.Demo");
}
}
class Demo{
}
- 当一个接口在初始化时,并不要求其父接口都完成初始化,只有在真正使用到父接口的时候。才会进行初始化。
一个父接口并不会因为它的子接口或者实现类的初始化而初始化,只有当程序首次使用特定接口的静态变量时,才会导致该接口的初始化
这个和前面的继承相反,需要区分。
类加载器
类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。这里就提到了命名空间,后面说。
JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:
根类加载器(Bootstrap)
它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。实现依赖于底层操作系统,属于虚拟机的实现的一部分。
public class Main{
public static void main(String[] args) throws Exception{
//获取根类加载加载的核心类库位置
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for(URL url : urls){
System.out.println(url.toExternalForm());
}
}
}
扩展类加载器(Extension)
负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。父类是根类加载器,纯java类,是java.lang.ClassLoader类的子类。
系统类加载器(System)
或者称为应用类加载器。
负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为扩展类加载器。纯java类,是java.lang.ClassLoader类的子类。
类加载器加载Class大致要经过如下8个步骤:
- 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
- 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
- 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
- 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
- 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
- 从文件中载入Class,成功后跳至第8步。
- 抛出ClassNotFountException异常。
- 返回对应的java.lang.Class对象。
自定义类加载器
java.lang.ClassLoader的子类,用户可以定制类的加载方式。
类加载机制
- 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
- 双亲委派:当一个自定义类加载器想要加载一个特定的类的时候,它会交给自己的父类进行加载,层层委托,直到根类加载器,然后根类加载器开始尝试加载,发现自己不能加载,又会层层往下提交,到了系统类加载器大多数就可以加载了(根类加载器和扩展类加载器只会加载特定的类和包),当加载成功时,把加载结果交给委托的自定义类加载器返回。
- 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中找该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
双亲委派模型
在父亲委托机制中,各个加载器按照父子关系形成了树形结构(逻辑),除了根类加载器外,其余的类加载器都有且只有一个父加载器。
public class Main{
public static void main(String[] args){
//当前类毫无疑问是由系统类加载器加载的,获取系统类加载器。
ClassLoader classLoader1 = Main.class.getClassLoader();
System.out.println(classLoader1);
//获取系统类加载器的父加载器,即扩展类加载器
ClassLoader parent = classLoader1.getParent();
System.out.println(parent);
//获取扩展类加载器的父类加载器,即根类加载器
ClassLoader parent1 = parent.getParent();
System.out.println(parent1);
}
}
根类加载器为空,顶级类加载器,不允许访问。
线程上下文类加载器
-
线程上下文类加载器是由JDK1.2开始引入的,类中的setContextClassLoader和getContextClassLoader分别用来获取和设置上下文类加载器,如果没有设置的话,线程将继承父线程的类加载器
-
在父亲委托模型中,类加载器是由下至上的,即下层的类加载器会委托上层进行加载,但是对于SPI(Service Provider Interface)来说,有些接口是JAVA核心库提供的,而JAVA核心库是由启动类加载器来加载的,而这些接口的实现却来自不同的jar包(厂商提供),JAVA的启动类加载器是不会加载这些其他来源的jar包的这样传统的父亲委托模型就无法满足SPI的需求,而通过当前线程上下文类加载器,就可以实现对于接口的类加载。
优点
-
提高软件系统的安全性,分工明确,确保JAVA核心库的类型安全。
-
确保JAVA核心类库所提供的类不会被自定义的类替代。
-
不同的类加载器可以为相同名称(binary name)的类创建额外的命名空间,相同名称的类可以并存在JAVA虚拟机中,只需要用不同的累加载器去加载(命名空间不同),不同类加载器所加载的类之间是不兼容的,相当于在JAVA虚拟机内部创建了一个又一个相互隔离的JAVA类空间。
注意
类加载器并不需要等到某个类被“首次主动使用”时才加载它
-
JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误),如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误
-
在运行期,一个JAVA类是由该类的完全限定名(binary name,二进制名)和用于加载该类的定义类加载器(defining loader)所共同决定的,如果类的全限定名相同的类是由不同的加载器所加载,那么这些类就是不同的,即便.class文件的字节码完全一样,加载位置一样,仍旧不同
-
当JVM启动时,一块特殊的机器码会运行,它会加载扩展类加载器和系统类加载器,这块特殊的机器码就是启动类加载器
-
每个类会使用自己的类加载器去加载自己所依赖/引用的类
获取ClassLoader途径
-
当前类
Class<?> clazz = 类名.class;
clazz.getClassLoader(); -
当前线程上下文
Thread.currentThread().getContextClassLoader() -
系统
ClassLoader.getSystemClassLoader -
调用者
DriverManager.getCallerClassLoader()
命名空间
-
每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成
-
子加载器的命名空间包含所有父加载器的命名空间,因此子加载器所加载的类能够访问到父加载器所加载的类。反之,父加载器所加载的类无法访问到父加载器所加载的类
-
在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
-
在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
-
不同的命名空间相互不可见。
类的卸载
-
当类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,类在方法区的数据也会被清除。
-
一个类何时结束生命周期取决于代表它的Class对象何时结束生命周期。
-
由java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中始终不会被卸载。
-
由用户自定义的类加载器所加载的类时可以被卸载的。
文章持续更新,可以微信搜索「 绅堂Style 」第一时间阅读,回复【资料】有我准备的面试题笔记。
GitHub https://github.com/dtt11111/Nodes 有总结面试完整考点、资料以及我的系列文章。欢迎Star。