JVM学习(一) 类加载

Posted by Lain on 09-19,2019

类加载

在java代码中,类型的加载、连接与初始化过程都是在程序运行期间完成的。
这种加载方式提供了更大的灵活性,增加了更多的可能性。

Java虚拟机与程序的生命周期

以下几种情况下,Java虚拟机将结束生命周期

  1. 执行了System.exit()方法
  2. 程序正常执行结束
  3. 程序在执行过程中遇到了异常或是错误而异常终止。
  4. 由于操作系统出现错误而导致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程序”首次主动使用“时才初始化他们(只会初始化一次,且必须要是主动使用

主动使用(七种)
  1. 创建类的实例
  2. 访问某个类或接口的静态变量(getstatic),或者对该静态变量赋值(putstatic)
  3. 调用类的静态方法(invokestatic)
  4. 反射
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类(包含了程序入口main方法的类)
  7. 从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先被加载了,其次是父类,最后是子类。

0

实际上我们也能猜到,最先加载的是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
1

可以看到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方法加载一个类,并不是对类的主动使用,不会导致类的初始化。