深入Java类加载全流程,值得你收藏
阿伟~ 人气:1先测试一番,全对的就走人
//题目一
class Parent1{
public static String parent1 = "hello parent1";
static { System.out.println("Parent1 静态代码块"); }
}
class Children1 extends Parent1{
public static String children1 = "hello children1";
static {System.out.println("Children1 静态代码块");}
}
//----------------------------------------------------------------
//题目二
class GrandParent2{
static { System.out.println("GrandParent2静态代码块"); }
}
class Parent2 extends GrandParent2{
public static String parent2="hello parent2";
static{ System.out.println("Parent2 静态代码块");}
}
class Children2 extends Parent2{
public static String children2 ="hello children2";
static{ System.out.println("Children2 静态代码块");}
}
//----------------------------------------------------------------
//题目三
class GrandParent3{
static { System.out.println("GrandParent3静态代码块"); }
}
class Parent3 extends GrandParent3{
public final static String parent3="hello parent3";
static{ System.out.println("Parent3 静态代码块");}
}
class Children3 extends Parent3{
public static String children3 ="hello children3";
static{ System.out.println("Children3 静态代码块");}
}
//测试
public class ClassLoaderTest {
public static void main(String[] args) {
//测试一的输出
System.out.println(Children1.children1);
System.out.println("-------------------------------");
//测试二的输出
System.out.println(Children2.parent2);
System.out.println("--------------------------------");
//测试三的输出
System.out.println(Children3.parent3);
}
//你认为输出什么呢
}
答案如下
Parent1 静态代码块
Children1 静态代码块
hello children1
GrandParent2静态代码块
Parent2 静态代码块
hello parent2
hello parent3
如果看清到这里,你的回答和结果一致,那么你真的懂了,可以转载给他人了,如果出乎你的意料,请认真看完。
什么是类加载(或者初始化)
Java源代码经过编译之后转换成class文件,在系统运行期间当需要某个类的时候,如果内存中还没该class文件,那么JVM需要对这个类的class文件进行加载,连接,初始化,JVM通常会连续完成这三步,这个过程叫做类的加载或者初始化, 类从磁盘加载到内存必须经历这三个阶段的。
重点是:类的加载都是在程序运行期间完成的,这提供了无限可能,意味着你可以在某个阶段对类的字节码进行修改,JVM也确实提供了这样的功能。
类的加载并不是对象的创建,类的加载是在为对象创建前做一些信息准备。
类的生命周期
我们明白了什么是类的加载,那么从类的加载到最后类的卸载成为类在JVM中的声明周期,这个生命周期总共包含了七个阶段:我画一张图,如下,我们逐个分析一下类的生命周期的每一步。
这是类的生命周期的,但它不总是按照这个固定的流程进行的,我们先知道这个就行,后面再说。
加载
类的加载指的是把class文件从磁盘读入内存中,将其放入元数据区域并且创建一个Class对象,放入堆中,Class对象是类加载的最终产品,Class对象并不是new出来的对象。
元数据区域存储的信息:
- 这个类型的完整有效名
- 这个类型的直接父类完整有效名
- 这个类型的修饰符(public final abstract等)
- 这个类型的直接接口的列表
Class对象中包含的如下信息,这也是我们能够通过Class对象获取类的很多信息的原因:
- 类的方法代码,方法名,字段等
- 类的返回值
- 类的访问权限
加载class文件有很多种方式,可以从磁盘上读取,可以从网络上读取,可以从zip等归档文件中读取,可以从数据库中读取
验证
验证的目的是验证class文件的正确性,是否能够被当前JVM虚拟机执行,主要包含了一些部分验证,验证非常重要,但不是必须的(正常情况下都是正确的)
文件格式验证:比如JDK8加载的是JDK6下编译的class文件,这肯定不行。
元数据验证:确保字节码描述信息符合Java语言规范的要求,你理解为校验外壳,比如类中是否实现了接口的所有方法。
字节码验证:确定程序语义执行是合法的,校验内在,校验方法体,防止字节码执行过程中危害JVM虚拟机。
符合引用验证:其对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,比如:符号引用中的类、字段、方法的访问性是否可被当前类访问,通过全限定名,是否能找到对应的类。
准备(重点)
验证完成之后,JVM就开始为类变量(静态变量) 分配内存,设置初始化值, 记住两点
- 不会为成员变量分配内存的。
- 初始化值是指JVM默认的指,不是程序中指定的值。
看如下代码,你就明白了:
//类变量,初始化值是 null, 不是123
public static String s1 = "123"
//成员变量
public String s2 = "456"
但有一个特殊,如果一个类变量是final修饰的常量,那么在准备阶段就会被赋值为程序中指定的值,如下代码,初始值是123
//初始值是123,不是null
public static final String s1 = "123"
为什么会这样呢?两行代码的区别在于final,final在Java中代表着不可变,不能赋值了之后重新赋值,所以一开始就必须赋值为用户想要的默认值,而不是Java语言的默认值。而不是final修时的变量有可能在之后发生变化,所以就先赋值为Java语言的默认值。
解析
解析阶段主要是将常量池中的符号引用转换为直接引用,解析动作主要包含类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用。
符号引用包括什么呢?
- 类和方法的全限定名
- 字段的名称和描述符
- 方法的名称和描述符,
直接引用是是什么呢?一个指向目标的指针地址或者句柄。
举个例子如下:
// 123 是一个符号引用,123所对应的内存中的地址是一个直接引用。
public static final String s1 = "123"
常量池是什么呢?,常量池包含好多种,字符串常量池,class常量池,运行时常量池,这里指的是class常量池。我们写的每一个Java类被编译后,就会形成一份class文件,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译器生成的各种字面量和符号引用,每个class文件都有一个class常量池。
比如解析阶段,找不到某个字段就抛出NoSuchFieldError,同理NoSuchMethodError
初始化(重点)
初始化阶段用户定义的Java代码才会真正开始执行,一般来说当首次主动使用某个类的时候就会对该类初始化,初始化某个类时也会初始化这个类的父类,这里的首次主动使用,大家要理解清楚了,第二次使用时不会初始化的。类的初始化其实就是执行类构造器的过程,这个不是我们代码定义的构造方法。
下面列举了JVM初始化类的时机:
- 创建对象时(比如:new Person())
- 访问类变量时
- 调用类的静态方法时
- 反射加载某个类是(Class.forName("....."))
- Java虚拟机启动时被标明为启动类的类(单测时),Main方法的类。
初始化时类变量会被赋予真正的值,也就是开发人员在代码中定义的值,也会执行静态代码块。
JVM初始化类的步骤:
- 若该类还没有被加载和连接,则程序先加载并连接该类
- 若该类的父类还没有初始化,则先初始化该类的夫类
- 若该类中有静态代码块,则系统依次执行这些代码块
上面提到了首次主动使用时初始化类,那么就有被动使用,被动使用是什么意思呢?比如说通过子类引用父类的静态字段,那么子类会初始化吗?答案是不会的,所以下面测试的子类的静态代码块是不会执行的。
class Parent4{
public final static String parent4="hello parent4";
}
class Children4 extends Parent4{
static{ System.out.println("Children4 静态代码块");}
}
public class ClassLoaderTest {
public static void main(String[] args) {
//测试四的输出
System.out.println(Children4.parent4);
}
}
再说一个点解析时有提到常量池的概念,在经过初始化后,类就被加载到内存中去了,这个时候jvm就会将class常量池中的内容存放到运行时常量池中,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中是一致的
上面还有一个关键字一般来说,那么不一般呢?类加载器并不需要等到某个类被首次主动使用时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它.
使用
使用就比较简单了,JVM初始化完成后,就开始按照顺寻执行用户代码了。
卸载
类卸载有个前提,就是class的引用是空的,要么程序中手动置为空,要么进程退出时JVM销毁class对象,然后JVM退出。只要class引用不存在,那么这个类就可以回收了。
你自己可以试验一下,写一个classload类加载器,写一个Test测试类,实际测试一下,我的测试代码如下:
public class ClassTest {
public static void main(String[] args){
ClassLoaderMy classLoader = new ClassLoaderMy();
classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
Class clazz = classLoader.findClass("jvm.Test类中有一个静态代码块。");
Object obj = clazz.newInstance();
System.out.println("1:"+clazz.hashCode());
obj=null;
System.out.println("2:"+clazz.hashCode());
classLoader = null;
System.out.println("3:"+clazz.hashCode());
clazz = null;
System.out.println("此时 obj classloader clazz 都为空了");
classLoader = new ClassLoaderMy();
classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
clazz = classLoader.findClass("jvm.Test");
System.out.println("4:"+clazz.hashCode());
obj = clazz.newInstance();
}
//打印结果如下,看之前你猜一猜。Test类中有一个静态代码块。
}
初始化了
1:1775282465
2:1775282465
3:1775282465
此时 obj classloader clazz 都为空了
4:1267032364
初始化了
最终结果你会发现,前三个hashcode的值是一样的,第四个的值发生了变化,说明class文件被卸载了后重新加载生成了新的class对象,否则,同一个对象的hashcode是不会发生变化的,而且Test类的静态代码块执行了两遍,完整代码地址如下:
https://github.com/sunpengwei1992/java_common/tree/master/src/jvm
我画了一张图,方便大家更好的理解,如下,当左边的三个变量都指向为null时,最右边的元数据区域的代表Class对象的Test二进制数据就会被卸载,当下次使用时就会被重新加载,初始化等。
但是,注意了 由JVM自带的类加载器加载的类,在JVM生命周期中,始终不会被卸载,
JVM自带的类加载器包括根类加载器,扩展类加载器,系统类加载器,这些回头单聊。
解密测试题目
接下来我们聊一聊一开始的测试题,其实看到这里,想必大家都明白了吧,还是说一说。
第一个不用讲了,都会。
第二题:子类Children2,父类Parent2, 祖父类GrandParent2,我们通过Chidlren2打印父类Parent2的静态变量,类加载时,发现有父类存在,逐层往上加载,那么Parent2和GrandParent2都会被加载,所以Parent2和GrandParent2的静态代码块都会被执行,而Children2就不会被加载了,因为不符合首次主动使用的条件。
第三题:同样的道理,只是Parent3和GrandParent3的静态代码块为什么没执行呢,因为Parent3的静态变量是final类型的,在准备阶段就已经完成了,不需要再逐层往上加载了.
提一下接口的加载
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会加载该接口,如下代码,执行main方法,Parent5接口是不会被加载的,parent5变量也是不会被初始化的。
interface Parent5{
public final static String parent5 = "hello parent5";
}
interface Children5 extends Parent5{
public final static String children5 = "hello children5";
}
public static void main(String[] args) {
System.out.println(Children5.children5);
}
表格整理一下流程
如果有帮助,关注下公众号,阅读更多精彩文章
加载全部内容