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

类初始化

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

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

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

注意:

这里需要指出的是,在执行类的初始化之前,其实在准备阶段就已经为类变量分配过内存,并且也已经设置过类变量的初始值了。例如像整数的初始值是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中情况会马上对类进行初始化

  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

说明并没有触发子类的初始化

  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

实际上,之所以没有输出"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句是关键部分,16、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语言中方法重写的本质。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,794评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,050评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,587评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,861评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,901评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,898评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,832评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,617评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,077评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,349评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,483评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,199评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,824评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,442评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,632评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,474评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,393评论 2 352

推荐阅读更多精彩内容