Code前端首页关于Code前端联系我们

深入理解Java虚拟机:从JVM角度理解类初始化、方法重载、重写

terry 2年前 (2023-09-25) 阅读数 47 #后端开发

类初始化

在讲类初始化之前,我们先对类声明周期有一个大概的了解。如下图,深入理解Java虚拟机:jvm角度看懂类的初始化、方法重载、重写

类的声明周期可以分为7个阶段,但今天我们只讲初始化阶段。我觉得除了使用卸载步骤之外,初始化是我们平时学习的时候也算最简单最简单的了。笔试。如果想了解每一步,可以看《深入理解Java虚拟机》这本书

让我们从初始化过程开始。 ?传递了 类变量 的初始值。例如,假设一个整数的初始值为 0,一个对象的初始值为 0。基本数据类型的初始值如下:

数据类型初始值数据类型初始值
intlong0L 浮动0.0f
(短)0双d。 '\u0000'referencenull
byte (byte)0

首先我们来想一个问题。当我们运行java程序时,每个类都会被初始化吗?如果不是每个类都会执行初始化过程,那么什么时候类会执行初始化过程呢?

答案是并不是所有的类都会执行初始化过程。想一想,如果这个类根本不被使用,为什么要初始化它呢?它充满了。

关于何时执行初始化过程,虚拟机规范严格规定了并且只有在5种情况下才会立即初始化类。

  1. 当使用关键字new实例化对象时,读取或设置类的静态字段,并调用类的静态注释(由final修饰的静态字段除外)。
  2. 使用java.lang.reflect包中的方法对类进行反射调用时,如果该类尚未初始化,则会触发该类的初始化。
  3. 当一个类初始化后,其父类尚未初始化时,会先触发其父类。
  4. 虚拟机启动时,用户必须指定一个要执行的主类(包含main()方法的类),虚拟机首先初始化该主类。
  5. 在使用JDK 1.7的动态语言支持时,如果...(省略,说出来也没明白,哈哈)。

请注意, 只有。这五种行为称为对类的主动引用

初始化过程

类的初始化过程做了什么?

类的初始化过程中,说白了,执行的是一个类的constructor()方法过程。请注意,这个 clinit 不是该类的构造函数(init())。

关于 clinit() 方法包含哪些内容?

事实上,clinit()方法是编译器自动从类变量静态语句块中的语句merge的所有赋值操作中自动收集的,编译器收集的顺序语句由句子在源文件中出现的顺序决定。并且在静态语句块中只能访问静态语句块之前定义的变量。在它之后定义的变量可以在前面的静态语句块中赋值,但是无法访问。比如下面的程序。

public class Test1 {
    static {
        t = 10;//编译可以正常通过
        System.out.println(t);//提示illegal forward reference错误
    }
    static int t = 0;
}
复制代码

给你一个练习

public class Father {
    public static int t1 = 10;
    static {
        t1 = 20;
    }
}
class Son extends Father{
    public static int t2 = t1;
}
//测试调用
class Test2{
    public static void main(String[] args){
        System.out.println(Son.t2);
    }
}
复制代码

输出结果是什么?

答案是20,我想大家都知道为什么。因为父类是先初始化的。

不过,这里需要注意的是,对于类来说,当执行该类的 clinit() 方法时,会先执行父类的 clinit() 方法,但是对于接口来说,会先执行 clinit() 方法接口的( )方法不会被执行。父接口的clinit()方法不会被执行。只有当使用了父类接口中定义的变量时,才会执行父接口的clinit()方法。

被动引用

上面提到的类初始化的五种情况称为主动引用。主动的存在也意味着存在所谓的被动参考。这里需要提到的是,被动引用不会触发类初始化。下面我们提供几个被动引用的例子:

  1. 通过子类引用父类的静态字段,不会触发子类的初始化
/**
 * 1.通过子类引用父类的静态字段,不会触发子类的初始化
 */
public class FatherClass {
    //静态块
    static {
        System.out.println("FatherClass init");
    }
    public static int value = 10;
}

class SonClass extends FatherClass {
    static {
        System.out.println("SonClass init");
    }
}
 class Test3{
    public static void main(String[] args){
        System.out.println(SonClass.value);
    }
}
复制代码

输出结果

FatherClass init
复制代码

表示子类的初始化是未触发

  1. 通过数组定义引用类不会触发该类的初始化。
 class Test3{
    public static void main(String[] args){
        SonClass[] sonClass = new SonClass[10];//引用上面的SonClass类。
    }      
 }
复制代码

输出结果什么都没有。

  1. 在其他类中引用常量,不会触发该类的初始化
public class FatherClass {
    //静态块
    static {
        System.out.println("FatherClass init");
    }
    public static final String value = "hello";//常量
}

class Test3{
    public static void main(String[] args){
        System.out.println(FatherClass.value);
    }
}
复制代码

输出结果:hello

在编译阶段不会输出。对此常数进行了一些优化。例如,由于Test3类使用了常量“hello”,因此“hello”常量在编译阶段就已存储在Test3类的常量池中。将来,FatherClass.value 引用实际上将转换为 Test3 类对其自己的常量池的引用。也就是说,编译成class文件后,两个类就没有任何关系了。


重载

对于重载,我想学过Java的人都明白,但今天我们就从虚拟机的角度来看一下重载。

首先看一段代码:

//定义几个类
public abstract class Animal {
}
class Dog extends Animal{
}
class Lion extends Animal{
}

class Test4{
    public void run(Animal animal){
        System.out.println("动物跑啊跑");
    }
    public void run(Dog dog){
        System.out.println("小狗跑啊跑");
    }
    public void run(Lion lion){
        System.out.println("狮子跑啊跑");
    }
    //测试
    public static void main(String[] args){
        Animal dog = new Dog();
        Animal lion = new Lion();;
        Test4 test4 = new Test4();
        test4.run(dog);
        test4.run(lion);
    }
}
复制代码

运行结果:

动物跑跑跑

动物跑跑跑


猜出这个结果。但为什么选择这个方法来重载呢?如何选择虚拟机?

在此之前,我们先了解两个概念。

首先我们看一行代码:

Animal dog = new Dog(); 变量dog的称为

所谓静态类型,是指在代码编译时就可以确定的类型。也就是说,dog的静态类型可以在编译时确定。但是在编译时没有办法知道变量dog的实际类型是什么。

现在我们来看看虚拟机选择哪种方法来重载

对于静态类型相同但实际类型不同的变量,虚拟机在重载时根据参数的静态类型而不是实际类型进行选择。而静态类型是编译器已知的,这意味着在编译阶段就已经决定选择哪种重载方法。

由于狗和狮子的静态类型都是Animal,所以选择了run(动物)方法。

不过需要注意的是,有时可能会存在多个重载版本,即重载版本并不唯一。我们来看看下面的代码。

public class Test {
    public static void sayHello(Object arg){
        System.out.println("hello Object");
    }
    public static void sayHello(int arg){
        System.out.println("hello int");
    }
    public static void sayHello(long arg){
        System.out.println("hello long");
    }
    public static void sayHello(Character arg){
        System.out.println("hello Character");
    }
    public static void sayHello(char arg){
        System.out.println("hello char");
    }
    public static void sayHello(char... arg){
        System.out.println("hello char...");
    }
    public static void sayHello(Serializable arg){
        System.out.println("hello Serializable");
    }

    //测试
    public static void main(String[] args){
        char a = 'a';
        sayHello('a');
    }
}
复制代码

运行代码。我想大家都知道输出结果是

hello char

因为a的静态类型是char,所以会匹配sayHello(char arg);

但是如果我们注释掉方法sayHello(char arg )),又跑了。

结果输出:

hello int

实际上,由于方法中目前没有静态类型字符方法,所以会自动进行类型转换。 ‘a’除了可以是字符之外,还可以代表数字97。因此,会选择int类型进行重载。

我们继续注释掉sayHello(int arg)方法。结果将输出:

hello long。

此时'a'进行了两次类型转换,即'a' -> 97 -> 97L。所以sayHell(long arg)方法是匹配的。

实际上,'a' 会按照 char ->int -> long -> float ->double 的顺序进行转换。但不会转换为byte或short,因为从char到byte或short的转换是不确定的。 (为什么不安全?这个留给你自己思考)

继续注释掉长类型的方法。结果是:

helloCharacter

此时会发生自动装箱,并且'a'被封装为Character Type。

继续评论字符类型方法。输出

hello 可序列化

为什么?

字符或数字与序列化有什么关系?其实这是因为Serialized是Character类实现的接口。自动装箱后发现找不到装箱类,但是找到了装箱类实现的接口类型,所以发生了一次自动变换。

我们继续注释掉Serialiable。此时的输出为:

hello Object

此时'a'被框架并转化为父类。如果有多个父类,则会继承自 关系,从下往上开始查找,即越靠近顶层,优先级越低。

继续注释对象方法。此时输出为:

hello char...

此时'a'被转换为数组元素。

从上面的例子中我们可以看出,元素的静态类型不一定是固定的。 是在编译时按照优先级原则进行转换的。其实这也是Java语言中重载的本质

重写

先看一段代码

//定义几个类
public abstract class Animal {
    public abstract void run();
}
class Dog extends Animal{
    @Override
    public void run() {
        System.out.println("小狗跑啊跑");
    }
}
class Lion extends Animal{
    @Override
    public void run() {
        System.out.println("狮子跑啊跑");
    }
}
class Test4{
    //测试
    public static void main(String[] args){
        Animal dog = new Dog();
        Animal lion = new Lion();;
        dog.run();
        lion.run();
    }
}
复制代码

运行结果:

小狗跑,狮子跑跑跑并且运行

我想大家都对这个结果感兴趣,这是毫无疑问的。它们的静态类型是相同的。虚拟机如何知道要执行哪个方法?

显然,虚拟机是根据实际类型来执行方法的。我们看一下main()方法的部分内容字节码

//声明:我只是挑出了一部分关键的字节码
public static void (java.lang.String[]);
    Code:
    Stack=2, Locals=3, Args_size=1;//可以不用管这个
    //下面的是关键
    0:new #16;//即new Dog
    3: dup
    4: invokespecial #18; //调用初始化方法
    7: astore_1
    8: new #19 ;即new Lion
    11: dup
    12: invokespecial #21;//调用初始化方法
    15: astore_2
    
    16: aload_1; 压入栈顶
    17: invokevirtual #22;//调用run()方法
    20: aload_2 ;压入栈顶
    21: invokevirtual #22;//调用run()方法
    24: return
复制代码

解释一下这个字节码:

第0-15行的作用是为dog和lion对象创建内存空间,并调用Dog、Instance构造函数属于狮子类型。对应代码:

Animal Dog = new Dog();

动物狮子 = new Lion();这两个对象的引用被推送到堆栈的顶部。 17和21是run()方法的调用指令。

从指令中可以看出,这两个方法的调用指令是完全一样的。但最终的目标执行方式并不相同。为什么? ?如果存在 run() 方法,则会检查访问权限。如果可以访问,则该方法将直接引用该方法并终止搜索;如果该方法不可用,则会抛出 java.lang.IllegalAccessError 异常。

  • 如果对象中不存在run()方法,则会根据继承关系从下到上执行第二步搜索和检查C的各个父类。
  • 如果两者都没有找到,则会抛出 java.lang.AbstractMethodError 异常。
  • 因此,尽管第 17 行调用 run 方法时指令调用相同,但堆栈顶部存储的对象引用是 Dog,第 21 行是 Lion。

    这就是Java语言中方法重写的本质。

    作者:迪帅

    版权声明

    本文仅代表作者观点,不代表Code前端网立场。
    本文系作者Code前端网发表,如需转载,请注明页面地址。

    发表评论:

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

    热门