从jvm角度看懂类初始化、方法重载、重写。

类初始化

在讲类的初始化以前,咱们先来大概了解一下类的声明周期。以下图java

类的声明周期能够分为7个阶段,但今天咱们只讲初始化阶段。咱们我以为出来使用卸载阶段外,初始化阶段是最贴近咱们平时学的,也是笔试作题过程当中最容易遇到的,假如你想了解每个阶段的话,能够看看深刻理解Java虚拟机这本书。数组

下面开始讲解初始化过程。安全

注意:ide

这里须要指出的是,在执行类的初始化以前,其实在准备阶段就已经为类变量分配过内存,而且也已经设置过类变量的初始值了。例如像整数的初始值是0,对象的初始值是null之类的。基本数据类型的初始值以下:函数

数据类型 初始值 数据类型 初始值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char 'u0000' reference null
byte (byte)0

你们先想一个问题,当咱们在运行一个java程序时,每一个类都会被初始化吗?假如并不是每一个类都会执行初始化过程,那何时一个类会执行初始化过程呢?测试

答案是并不是每一个类都会执行初始化过程,你想啊,若是这个类根本就不用用到,那初始化它干吗,占用空间。优化

至于什么时候执行初始化过程,虚拟机规范则是严格规定了有且只有 5中状况会立刻对类进行初始化code

  1. 当使用new这个关键字实例化对象、读取或者设置一个类的静态字段,以及调用一个类的静态方法时会触发类的初始化(注意,被final修饰的静态字段除外)。
  2. 使用java.lang.reflect包的方法对类进行反射调用时,若是这个类尚未进行过初始化,则会触发该类的初始化。
  3. 当初始化一个类时,若是其父类尚未进行过初始化,则会先触发其父类。
  4. 当虚拟机启动时,用户须要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,若是一个.....(省略,说了也看不懂,哈哈)。

注意是有且只有。这5种行为咱们称为对一个类的主动引用对象

初始化过程

类的初始化过程都干了些什么呢?继承

在类的初始化过程当中,说白了就是执行了一个类构造器<clinit>()方法过程。注意,这个clinit并不是类的构造函数(init())。

至于clinit()方法都包含了哪些内容?

实际上,clinit()方法是由编译器自动收集类中的全部类变量的赋值动做和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序则是由语句在源文件中出现的顺序来决定的。而且静态语句块中只能访问到定义在静态语句块以前的变量,定义在它以后的变量,在前面的静态语句块能够赋值,但不能访问。以下面的程序。

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

说明并无触发子类的初始化

2.经过数组定义来引用类,不会触发此类的初始化。

class Test3{
    public static void main(String[] args){
        SonClass[] sonClass = new SonClass[10];//引用上面的SonClass类。
    }      
 }

输出结果是啥也没输出。

3.引用其余类的常量并不会触发那个类的初始化

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

实际上,之因此没有输出"FatherClass init",是由于在编译阶段就已经对这个常量进行了一些优化处理,例如,因为Test3这个类用到了这个常量"hello",在编译阶段就已经将"hello"这个常量储存到了Test3类的常量池中了,之后对FatherClass.value的引用实际上都被转化为Test3类对自身常量池的引用了。也就是说,在编译成class文件以后,两个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();

对于这一行代码,咱们把Animal称之为变量dog的静态类型,然后面的Dog称为变量dog的实际类型

所谓静态类型也就是说,在代码的编译期就能够判断出来了,也就是说在编译期就能够判断dog的静态类型是啥了。但在编译期没法知道变量dog的实际类型是什么。

如今咱们再来看看虚拟机是根据什么来重载选择哪一个方法的。

对于静态类型相同,但实际类型不一样的变量,虚拟机在重载的时候是根据参数的静态类型而不是实际类型做为判断选择的。而且静态类型在编译器就是已知的了,这也表明在编译阶段,就已经决定好了选择哪个重载方法。

因为dog和lion的静态类型都是Animal,因此选择了run(Animal animal)这个方法。

不过须要注意的是,有时候是能够有多个重载版本的,也就是说,重载版本并不是是惟一的。咱们不妨来看下面的代码。

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

实际上这个时候因为方法中并无静态类型为char的方法,它就会自动进行类型转换。‘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的转换是不安全的。(为何不安全?留给你思考下)

继续注释掉long类型的方法。输出结果是:

hello Character

这时发生了一次自动装箱,'a'被封装为Character类型。

继续注释掉Character类型的方法。输出

hello Serializable

为何?

一个字符或者数字与序列化有什么关系?实际上,这是由于Serializable是Character类实现的一个接口,当自动装箱以后发现找不到装箱类,可是找到了装箱类实现了的接口类型,因此在一次发生了自动转型。

咱们继续注释掉Serialiable,这个时候的输出结果是:

hello Object

这时是'a'装箱后转型为父类了,若是有多个父类,那将从继承关系中从下往上开始搜索,即越接近上层的优先级越低。

继续注释掉Object方法,这时候输出:

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,Lion类型的实例构造器。对应的代码:

Animal dog = new Dog();

Animal lion = new Lion();

接下来的16-21句是关键部分,1六、20两句分分别把刚刚建立的两个对象的引用压到栈顶。17和21是run()方法的调用指令。

从指令能够看出,这两条方法的调用指令是彻底同样的。但是最终执行的目标方法却并不相同。这是为啥?

实际上:

invokevirtual方法调用指令在执行的时候是这样的:

  1. 找到栈顶的第一个元素所指向的对象的实际类型,记做C.
  2. 若是类型C中找到run()这个方法,则进行访问权限的检验,若是能够访问,则方法这个方法的直接引用,查找结束;若是这个方法不能够访问,则抛出java.lang.IllegalAccessEror异常。
  3. 若是在该对象中没有找到run()方法,则按照继承关系从下往上对C的各个父类进行第二步的搜索和检验。
  4. 若是都没有找到,则抛出java.lang.AbstractMethodError异常。

因此虽然指令的调用是相同的,但17行调用run方法时,此时栈顶存放的对象引用是Dog,21行则是Lion。

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

本次的讲解到此结束,但愿对你有所帮助。

关注公个人众号: 苦逼的码农,获取更多原创文章,后台回复 礼包送你一份特别的资源大礼包。
相关文章
相关标签/搜索