从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中情况会马上对类进行初始化。
当使用new这个关键字实例化对象、读取或者设置一个类的静态字段,以及调用一个类的静态方法时会触发类的初始化(注意,被final修饰的静态字段除外)。
使用java.lang.reflect包的方法对类进行反射调用时,如果这个类还没有进行过初始化,则会触发该类的初始化。
当初始化一个类时,如果其父类还没有进行过初始化,则会先触发其父类。
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
当使用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.通过子类引用父类的静态字段,不会触发子类的初始化
*/
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
说明并没有触发子类的初始化
通过数组定义来引用类,不会触发此类的初始化。
class Test3{
public static void main(String[] args){
//引用上面的SonClass类。
SonClass[] sonClass = new SonClass[10];
}
}
输出结果是啥也没输出。
引用其他类的常量并不会触发那个类的初始化
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方法调用指令在执行的时候是这样的:
找到栈顶的第一个元素所指向的对象的实际类型,记作C.
如果类型C中找到run()这个方法,则进行访问权限的检验,如果可以访问,则方法这个方法的直接引用,查找结束;如果这个方法不可以访问,则抛出java.lang.IllegalAccessEror异常。
如果在该对象中没有找到run()方法,则按照继承关系从下往上对C的各个父类进行第二步的搜索和检验。
如果都没有找到,则抛出java.lang.AbstractMethodError异常。
所以虽然指令的调用是相同的,但17行调用run方法时,此时栈顶存放的对象引用是Dog,21行则是Lion。
这,就是java语言中方法重写的本质。
本次的讲解到此结束,希望对你有所帮助。
参考书籍:
1. 深入理解Java虚拟机
推荐阅读:
一文看懂一台计算机是如何把数据发送给另一台计算机的
一文看懂https如何保证数据传输的安全性的
HTTP协议入门
版权声明:随意转发,转载注明出处
关注我的公众号:苦逼的码农
每天带你打卡leetcode。每周不定期推送技术文章。
绝对会产生对你有所帮助的文章,不信你试试