类加载
在java代码中,类型的加载、连接与初始化过程都是在程序运行期间完成的。
这种加载方式提供了更大的灵活性,增加了更多的可能性。
Java虚拟机与程序的生命周期
以下几种情况下,Java虚拟机将结束生命周期
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或是错误而异常终止。
- 由于操作系统出现错误而导致Java虚拟机进程终止
类的加载、连接与初始化
加载:查找并加载类的二进制数据
连接:
- 验证:确保被加载的类的正确性
- 准备:为类的静态变量分配内存,并将其初始化为默认值(注意是默认值!)
- 解析:把类中的符号引用转换为直接引用
初始化:为类的静态变量赋予正确的初始值
如:
class Lain{
public static int a = 1;
}
在加载Lain类的时候,先由类加载器,将类的二进制数据加载进虚拟机内存,在准备阶段,为变量a分配内存空间,然后赋予默认值0。最后在初始化阶段,将a的正确值1赋予给变量a。
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据的方法区内,然后在内存中创建一个java.lang.Class对象(JVM规范并没有说明Class对象位于哪里,HotSpot虚拟机将起放在了方法区中)用来封装类在方法区内的数据结构。
加载.class文件的方式
由于规范中并没有规定.class文件从何而来,所以JVM厂商可以随意定制文件来源,比如:
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有数据库中提取.class文件
- 将Java源文件动态编译为.class文件(如动态代理,jsp)
类的使用与卸载
使用:字面意义,无需赘述
卸载:将类从内存中销毁,销毁之后我们将无法利用该类创建新的对象(但是可以再加载回来)
应用:如OSGI
java程序对类的使用可分为两种方式:
- 主动使用
- 被动使用
所有的Java虚拟机实现必须再每个类或接口被Java程序”首次主动使用“时才初始化他们(只会初始化一次,且必须要是主动使用)
主动使用(七种)
- 创建类的实例
- 访问某个类或接口的静态变量(getstatic),或者对该静态变量赋值(putstatic)
- 调用类的静态方法(invokestatic)
- 反射
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类(包含了程序入口main方法的类)
- 从JDK1.7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic,Ref_putStatic,Ref_invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化
被动使用
举例说明:
例1:静态变量
public class MyTest1 {
public static void main(String[]args){
System.out.println(MyChild1.str);
}
}
class MyParent1 {
public static String str = "Hello,Lain!";
static {
System.out.println("MyParent1 static block");
}
}
class MyChild1 extends MyParent1 {
public static String str2 = "welcome";
static {
System.out.println("MyChild1 static block");
}
}
上述程序的执行结果为:
MyParent1 static block
Hello,Lain!
为什么会这样呢?不是说访问某个类或接口的静态变量,也是对类的主动使用,会导致类的初始化吗?这里我们用访问了子类继承自父类的属性str,为什么子类没有被初始化呢?
实际上,对于静态变量来说,只有直接定义了该字段的类才会被初始化,即使你是通过子类继承过来的引用,在访问该变量时,依旧相当于是对父类变量的直接访问。
如果上面的例子改为访问MyChild1.str2,执行结果将会变为:
MyParent1 static block
MyChild1 static block
welcome
访问子类定义的静态变量,在子类初始化前,要求其父类已经全部初始化完毕了。
思考:那么子类有没有被加载呢?
有一个虚拟机参数,可以用于追踪类的加载信息并打印出来。
-XX:+TraceClassLoading
可以看到,子类是被加载了的,同时我们可以看到,MyTest1先被加载了,其次是父类,最后是子类。
实际上我们也能猜到,最先加载的是Object类
[Opened C:\Program Files\Java\jdk1.8.0_201\jre\lib\rt.jar]
[Loaded java.lang.Object from C:\Program Files\Java\jdk1.8.0_201\jre\lib\rt.jar]
JVM参数
-XX:+
-XX:-
-XX:
我们修改一下上面的例子。
例2:常量
public class MyTest1 {
public static void main(String[]args){
System.out.println(MyChild1.str);
}
}
class MyChild1 {
public static final String str = "Hello,Lain!";
static {
System.out.println("MyParent1 static block");
}
}
输出结果:
Hello,Lain!
可以看出,当访问的类的静态属性是常量的时候,该类并不会被初始化。
原因是,常量在编译阶段,会存入到调用这个常量的方法所在的常量池中,本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量的类的初始化。
在上面的例子中,常量str是被存放到了MyTest1的常量池中,之后MyTest1和MyChild1就没有任何关系了。我们甚至可以在编译后将MyChild1的class文件删除。
我们可以反编译看一下MyTest1的源代码
执行 javap -c MyTest1.class
可以看到Hello,World已经在里面了。
这里反编译出来的信息,是用助记符表示的。
助记符
ldc: 表示将int,float或是String类型的常量值从常量池中推送至栈顶
bipush: 表示将单字节(-128~127)的常量值推送至栈顶
sipush: 表示将一个短整型常量值(-32768~32767)推送至栈顶
iconst_1:表示将int类型1推送至栈顶(iconst_1~iconst_5)用iconst_+数字只表示1-5,超过就变成bipush了
anewarray:表示创建一个引用类型的(如类、接口、数组)数组,并将其引用值压入栈顶
newarray:表示创建一个指定的原始类型(如int,float,char等)的数组,并将其引用值压入栈顶
那么,当常量的值,在编译期间无法确定的时候呢?我们再修改一下这个例子
例3:常量编译期间无法确定
import java.util.UUID;
public class MyTest1 {
public static void main(String[]args){
System.out.println(MyChild1.str);
}
}
class MyChild1 {
public static final String str = UUID.randomUUID().toString();
static {
System.out.println("MyParent1 static block");
}
}
结果如下。
MyParent1 static block
0893b484-e78e-484c-914c-5236ad509b78
当一个常量的值,并非编译期间可以确定的,那么其值就不会被放到调用类的常量池中,这时在程序运行时,会主动使用这个常量所在的类,显然会导致这个类被初始化。
例4:创建数组
import java.util.UUID;
public class MyTest1 {
public static void main(String[]args){
MyChild1[] myChilds = new MyChild1[1];
System.out.println(myChilds.getClass());
}
}
class MyChild1 {
static {
System.out.println("MyParent1 static block");
}
}
执行结果:
class [LMyChild1;
可以看到,实例化一个MyChild1数组,并没有触发类的初始化,而且数组的类型并不是MyChild1,而是在前面添加了一个[L。
原因是:数组类型是一种特殊的类型,它是在运行期间虚拟机帮我们生成的。
更进一步,我们再定义一个二维数组:
MyChild1[] [] myChilds = new MyChild1[1] [1];
打印出来的类型为:
class [[LMyChild1;
对于数组实例来说,其类型是由JVM在运行期间动态生成的,其父类型直接就是Object。
在JavaDoc中,经常将构成数组的元素称为Component,实际上就是将数组降低一个维度后的类型。
接口
当一个接口在初始化时,并不要求其父接口也完成了初始化。
只有在真正使用到父接口的时候(如引用接口中所定义的常量时),才会初始化。
接口中定义的成员,都默认是public static final修饰的常量。
例5:
public class MyTest1 {
public static void main(String[] args) {
System.out.println(MyInterface.a);
}
}
interface MyInterfaceChild{
C c = new C(){
{
System.out.println("C init");
}
};
}
interface MyInterface extends MyInterfaceChild{
String a = "Lain";
}
class C{
}
由于接口中无法使用静态代码块,我们要怎么样才能知道接口有没有被初始化呢?我们用一个匿名类来验证这一点。首先我们来了解一下代码块{}
和我们以前见过的静态代码块static{}不同的是,静态代码块只在该类被初始化的时候执行一次,之后无论多少次创建该类的对象,都不会再次执行,而代码块会随着对象被创建执行,且是在构造方法执行之前执行。
当我们在父接口里,定义一个变量c,它是对象C的一个匿名类,匿名类继承至C,所以匿名类中无法使用构造方法,这时就能用代码块。如果父接口被初始化了,则对象c必定会被创建,代码块也会被执行。
我们打开类加载日志,并执行代码:
...
[Loaded fun.lain.laintest.jvm.MyTest1 from file:/D:/BiliBili-Monitor/lain-test/target/classes/]
...
Lain
...
可以看到代码块没有被执行,因此父接口没有被初始化,验证成功。
类加载器准备阶段和初始化阶段的重要意义分析
首先再次复习一下前面的知识。类的加载分为三个阶段,首先是将class文件的二进制数据加载到内存里,在初始化开始前,会先进入连接,也就是准备阶段,为类中所有的静态变量赋初始值。然后再进入初始化阶段,依次将正确的值赋予给各静态成员变量。
下面来分析一个类的初始化过程。
例6:
public class MyTest {
public static void main(String[]args){
Singleton ton = Singleton.getInstance();
System.out.println(Singleton.counter1);
System.out.println(Singleton.counter2);
}
}
class Singleton {
public static int counter1 = 0;
private static Singleton singleton = new Singleton();
private Singleton(){
counter1++;
counter2++;
}
public static int counter2 = 0;
public static Singleton getInstance(){
return singleton;
}
}
结果是:
1
0
分析过程如下:
当程序从main方法开始执行,在Singleton ton = Singleton.getInstance();时,类Singleton的静态方法被调用,为主动使用,触发类的初始化。在初始化前,类进入准备阶段,对静态成员变量从上到下,依次赋予初始值。 counter1为0,singleton为null,counter2为0;之后再进入初始化阶段,开始对静态成员变量赋予正确的值,counter1依旧为0,singleton调用构造方法,counter1变为1,counter2也为1,注意,这里counter2在准备阶段已经赋予了初始值,所以可以正常加减,这说明了准备阶段的必要性。最后初始化counter2,将它原本的初值0赋予给counter2,于是counter2依旧为0;
类的初始化时机
当Java虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。
- 在初始化一个类时,并不会先初始化它所实现的接口。
- 在初始化一个接口时,并不会先初始化它的父接口。
因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化,只有当程序首次使用特定接口的静态变量时,才会导致该出口的初始化。
只有当程序访问的静态变量或静态方法确实在该类或接口中定义时,才可以认为是对该类或接口的主动使用。
调用CLassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。