【从JVM看Java,三问继承和多态,是什么?为什么?怎么做?深度剖析JVM的工作原理】

news/2024/4/17 17:20:38

系列文章:

《计算机底层原理专栏》:欢迎大家订阅学习,能够帮助到各位就是对我最大的鼓励!


文章目录

  • 系列文章目录
  • 前言
  • 一、JVM是什么
  • 二、什么是继承
  • 三、什么是多态
  • 总结

前言

        这篇文章聚焦JVM的实现原理,我更专注于从一个语言的底层原理,去剖析他的语法所实现的意义,这篇文章我不会从太基础的语法层面讲起,我会用我的方法,我的视角带大家领略以下Java这个语言特有的魅力。


一、JVM是什么?

  1. JVM(Java Virtual Machine)Java虚拟机功能及其重要组成
            Java为了代码能够实习跨越平台的特性,也就是“一次编译,到处执行”,所以引入了Java虚拟机的概念,Java的代码并不是直接运行在操作系统上,而是运行在Java虚拟机上面的,首先我们的代码首先会被Java当中的编译器翻译称为Java的字节码,也就是我们所看到的 .class 文件,这个JVM其实就是负责解释并且执行这些字节码的工具。
            下面我将为大家详细地介绍JVM的各种功能,(请大家注意,这里我认为是整个Java学习最重要的地方,他将直接影响到我们后续的各种语法的学习,例如继承和多态,所以希望各位铁铁一定要重视起来

  2. JVM——内存管理
       JVM负责程序运行时的内存分配和垃圾回收,确保程序运行有着足够的内存,并且及时释放不需要的内存,这里我们首先来谈一谈,内存管理的几个重要组成部分。
    1)虚拟机栈:学过C语言的小伙伴可能听说过栈的存在,那么Java当中的虚拟机栈其实就是我们平常所熟知的——栈,后面为了方便我就不再使用虚拟机栈的概念了,全部简称为栈。我面下面来了解以下栈当中都存放了哪些内容呢?
         栈当中主要为我们的程序存放三个重要的内容,他们分别是局部变量表,操作数栈、方法返回地址。注意了,虚拟机栈上其实还存储了其他的内容比如像动态链接,异常信息处理表等一系列的辅助信息,以上三点是我们需要记忆的最重要的三点。
     

    虚拟机栈
    栈顶
    局部变量表
    操作数栈
    方法返回地址

        在这里特别强调,栈的创建主要针对于我们类当中方法的调用,这个概念非常类似于C语言当中的函数栈帧的概念,也就是每当有一个函数调用操作系统就会为这个函数在栈当中分配一个栈帧,用于存储这个函数当中的一些关键信息,那么Java当中的栈同样如此,只不过我们叫做虚拟机栈,而且这些内存并不是由操作系统直接分配的是由我们的JVM专门为我们分配的。
         我们在Java一个类当中会定义一系列的方法,而这些方法通过对象在调用的时候,JVM就会为我们分配内存,这也体现出了JVM内存管理的特性。
        局部变量表就是一个方法内部定义的各种各样的局部变量,会统一存放在一个局部变量表当中。方法在进行各种运算的时候必然会产生各种各样的操作数,例如我在方法内部写了一段代码
     

    public void Print(){int a = 10;String c = "Hello World"; 
    }

    那么这段代码当中变量a和c就要存放在局部变量表当中,而操作数10和"Hello World"就要存放在操作数栈当中。那么这个方法运算的最后结果也会存放到操作数栈上面,当我们执行return的时候,这个时候结果就会从操作数栈上弹出。我们就获得了方法执行的最终结果。
        那么方法返回地址是什么呢?其实也很好理解,就是一个方法执行完成后需要返回的地址,这一点是针对CPU的,这么说是不是不好理解,好的我再写一段代码。

    public class Person{public int age;
    public String name;//构造函数省略不写public void Print(){System.out.println("Hello World");}public void talk(){System.out.println("hello talk()");}    //当CPU执行到Print()方法的时候,这个时候CPU的处理机资源就会跑到Print()方法体的内部,那么这个
    //时候当Print()方法执行完成以后,CPU就会查看Print()的返回地址,这个时候CPU就会返回到Print的地址
    //处继续执行下一个talk()方法,这就是方法返回地址存在的意义,他让CPU知道执行完这个方法以后返回到这个方法原来的地址处,继续执行这个方法后的下一行代码public static void main(String[] args){Person person = new Person();//传参省略person.Print();//地址一person.talk();//地址二}
    }

    (温馨提示:方法返回地址的作用在代码的注释处已经解释过了,大家可以拖动滚动条进行查看)
    好了栈的知识我们就说到这里,接下来为大家介绍JVM内存管理的第二个重要的内容,方法区,各位铁铁们这里的知识点都很重要哦,千万别眨眼

    2)JVM内存管理——方法区
     

    JVM方法区
    类的元信息(类头)
    静态变量
    常量池
    方法代码(方法被编译后的字节码)
    字段描述符、方法描述符
    类的初始化状态(涉及到多线程)

        这是方法区中我个人认为需要我们掌握、理解并且背诵的重要内容,其中类的初始化状态涉及到了多线程的概念,这个不作为我们这篇文章的重点去讲解。
        首先我们首先来了解一下类的元信息,这里的元信息也叫做类头,需要我们重点掌握。
     

    类的元信息(类头)
    类名父类实现的接口访问修饰符构造函数
    MyClassMyBaseClass暂且不讲publicpublic MyClass(int,int)

    类头当中主要包含以上这些信息,当我们在定义一个类的时候,我们用代码将这些信息全部定义号之后只要我们一点击运行,JRE将我们所写的代码进行编译好生成字节码文件,JVM会将这些字节码文件加载到内存当中,而类的这些关键信息包括类的元信息就会被JVM全部加载到内存的方法区当中(不过我在这里提一句,我们真是的物理内存是不会区分什么方法区、虚拟机栈之类的概念,这些区域都是JVM帮我们进行在逻辑上的区分并且维护的,这就是JVM内存管理工作的一部分内容)。类头当中会涉及到实现的接口信息,这个内容我会放到接口当中的内容去讲。大家可以看我后面发布的文章。

        接下来我为大家讲解方法区当中的第二个内容——静态变量。
     

    方法区静态变量
    变量名称数据类型初始值访问修饰符
    staticVar1int0public
    staticVar2double 0.0private
    staticVar3Stringnullpublic

    当我们在类当中定义静态变量的时候,JVM会将这些字节码存储到方法区当中,我在这里还要提一句,JVM的视角看到的全部都是字节码,而CPU的视角看到的全都是机器码,我给大家列出这样的表格仅仅为了方便大家理解,而且我希望大家在看到Java代码的时候这些代码是“活着的”,也就是我们要清楚地知道我们写的每一行代码它怎么来将会到哪里去,JVM为我们做了什么?我反复强调这一点因为它真的很重要啊!

        方法区当中的第三个内容——常量池,这个是一个我们非常容易忽略的概念,一说常量大家好像都听说过,但是要细说常量的话,好像又不太清楚,今天我来为大家详细地介绍以下什么是常量,方法区当中的常量主要分为两类,一类是编译期间生成的各种字面常量,另一类是各种符号引用。
        说起字面常量,比如我们所说的字符串常量,那么符号引用呢,也好理解比如说我们定义的类名,接口名这是一个常量吧,还有向方法名描述符等等这些都属于常量,他们都会被存放在方法区当中,常量还会分为编译期常量和运行期常量,我写一段代码给大家看看。
     

    public class ConstantExample {// 编译期常量public static final int COMPILE_TIME_CONSTANT = 42;public static void main(String[] args) {// 运行期常量final int RUN_TIME_CONSTANT = (int) (Math.random() * 100);System.out.println("Compile-time constant: " + COMPILE_TIME_CONSTANT);System.out.println("Run-time constant: " + RUN_TIME_CONSTANT);// 字符串常量String str1 = "Hello";String str2 = "World";String combinedString = str1 + ", " + str2;System.out.println("Combined String:" + combinedString);}
    }
    

    对于编译期常量和运行期常量,大家理解就好了。

    接下来为大家介绍方法区当中的方法代码,说到方法代码其实也就是字节码,我为大家写一段代码来详细展示以下字节码。
     

    public class Example {public int add(int a, int b){int result = a + b;return result;}
    }
    

    这样的代码翻译成字节码以后会变成什么样子呢?
     

    0:   aload_0
    1:   iload_1
    2:   iload_2
    3:   iadd
    4:   istore_3
    5:   iload_3
    6:   ireturn
    

        嗯,对的,大概就长成这个样子,大家看到这些代码有没有感觉到很熟悉的感觉?是不是长得很像C语言的汇编代码?是的,我们可以理解字节码就是C语言当中的汇编代码,但是也只是可以这么理解,实际上它并不是,Java当中的字节码更具有抽象性,并不是完全的偏向计算机底层,这个大家理解就好。
        我为大家详细地介绍一下,这些代码都表达了什么意思。
    这些指令助记符的前缀例如i和a代表的是数据的类型,i指代int类型的数据、a代表的是引用(这里可能会有小伙伴不太清楚什么是引用,稍后我会列一个拓展块为大家讲解)。
        aload_0:将引用变量0(通常代表this引用)推送至栈顶,随时准备出栈。
        iload_1:将整形变量1推送至栈顶。
        iadd:将栈顶的两个整数进行相加操作。
        istore_3:将栈顶的整数型变量存储到本地变量3里面去。
        ireturn:从当前方法返回整型值(这个值一定是位于栈顶的)。

    3)JVM内存管理——堆内存
     

    JVM 堆内存
    对象实例
    数组
    实例变量
    垃圾回收信息

        我们先来解释数组,当我们定义一个数组的时候,JVM就会为它开辟出一个堆内存空间用于存放数组,那么数组在堆内存当中时怎么样存放的呢?首先我来为大家写一段代码来展示。
     

    public class ArrayExample {public static void main(String[] args) {// 创建一个包含不同类型元素的数组Object[] array = new Object[5];array[0] = 42;                  // 整数array[1] = 3.14;                // 双精度浮点数array[2] = "Hello, World!";     // 字符串array[3] = true;                // 布尔值array[4] = new MyClass();       // 自定义对象}
    }
    
    数组在堆当中的存放形式
    偏移量内存内容类型描述
    0referencereference对象引用
    4doubledouble双精度浮点数
    12referencereference对象引用(字符串对象)
    16booleanboolean布尔值
    20referencereference对象引用(自定义对象)

        这里我要解释一下,这里偏移量的大小就是这个数据所占内存的大小,例如double类型的数据要占据8个字节,那么他在内存当中的偏移量就是 (这里的4代表起始地址)4 + 8 = 12,表格当中的第一个内容(引用)就是数组名,数组名本身就是一个引用,表格中的内容存放顺序时按照我们的定义顺序来存放的,我们为数据定义的每一个内容都会按照这样的顺序存放,因为想要为大家展示更多的内容,所以我写的这个数组,每一个区域都存放了不同类型的数据,其实不只是这些内容,堆当中还会存放每个数组元素当中的值或者初始化值,我在这里就不给大家一一展示了,列出这个表格只是为了大家理解,在JVM视角下,内存当中存储的都是字节码,并不会有这么抽象的表格存在。

        接下来为大家演示第二个内容,对象的对象实例,下面我依然为大家写一段代码。

     

    public class MyClass {// 实例变量声明private int intValue;private double doubleValue;private String stringValue;// 构造方法public MyClass(int intValue, double doubleValue, String stringValue) {this.intValue = intValue;this.doubleValue = doubleValue;this.stringValue = stringValue;}}
    
    实例变量
    偏移量内存内容类型描述
    0intValueint整形变量
    4doubleValuedouble双精度浮点型变量
    12stringValuestring字符串引用变量

        我在这里多说一句,堆内存中存放有对象的实例和实例变量,这二者有什么区别呢?区别在于对象实例时包含实例变量的,对象实例不仅有包括实例变量还有对象头信息(不知道大家还记不记得在讨论方法区的时候我们提到了类的元信息(类头),对象也有对象头),那么什么是对象头呢?对象头包含对象的哈希码和一些锁信息,锁涉及到了多线程的内容,我们这里暂且不介绍,哈希码呢?如果有系统地学习过数据结构当中堆的知识的朋友应该知道,哈希值是为了更好地检索堆当中的元素,为了处理哈希冲突才引入的,因为我们的对象本身就是存储在堆当中的,所以每一个对象头都要存放一个哈希码用于检索对象,我们在使用对象调用对象的方法的时候,JVM首先要找到我们调用的对象,他会怎么做呢,他会把对象全都放在一个数据结构——堆当中,然后使用对象头当中的哈希值来找到对象,之后确定对象要调用的方法,确定方法之后,JVM在虚拟机栈上开辟空间(类似于函数栈帧),这个时候JVM会自动地将对象的虚拟地址(并不是真实的物理地址,Java不同于C语言,程序员是不可以直接利用地址(指针)来访问内存的)传递给位于虚拟机栈局部变量表当中的this引用,同时开辟的栈帧还会接受来自堆当中的参数,而堆当中的参数又是构造函数来赋值的,这个时候this指针完成对局部变量的赋值,最后的操作结果压入栈顶,再由return语句返还给堆当中的对象实例,这个时候一个完整的方法就算是结束了。
        可能还会有小伙伴问,return语句从计算机底层的视角来看的话,传递返回值的过程是怎么完成的,我在这里再多提一句,我们的计算机是由CPU、内存、和系统总线来组成的,而系统总线有是由数据总线、地址总线、控制总线共同构成的,具体的运行逻辑,我在之前的文章里面详细地介绍过这里就不讲了,我直接说结果,CPU通过总线堆内存的内容进行读写,CPU把栈上的返回值直接写到堆当中的对象实例里面去,return这个过程就算是彻底完成了,但是Java这个语言,我们并没有直接操作内存,中间的很多过程都是由JVM来做的。CPU的世界里,不存在什么堆和栈的区别,大家都是一样的都是内存的一部分,这些区域是JVM从逻辑上为我们划分的,实际上并不存在,所谓的数据结构也只是一个抽象的概念,CPU的视角里也没有什么堆、栈、队列、树的这些概念、也只不过是逻辑上的概念,我们把内存区域里的数据、按照地址的大小划分一个前后顺序、将数据按照先进后出的逻辑进行管理、那么这个区域就叫做队列、按照后进先出的逻辑进行管理,那么这个区域就叫做堆。

        然后是对象实例,其实我在上文当中已经提到过了,对象实例就是实例变量 + 对象头 + 一系列辅助信息(垃圾回收信息,由JVM来完成),我就不为大家列表演示了。

3.JVM其它功能 
    
上面为大家讲解了JVM最重要的功能——内存管理,接下来为大家简要介绍以下JVM的其他功能。
    1)字节码执行:
    2)垃圾回收:
    3)即时编译:
    4)线程管理:
JVM的主要功能就这些,具体的内容就不给大家展开讲解了。

     JVM内存管理当中最重要的三个区域——虚拟机栈、堆、方法区的主要知识我就为大家讲解结束,接下来我为大家准备一些拓展知识,尽可能地做到我们以后写代码的时候,这些代码在我们面前是透明的,让我们距离技术大佬更进一步!

拓展一:什么是字段
    直接说答案,字段就是类当中定义的各种变量,包括静态变量等各种变量,有些时候这些成员变量也会被称为是类的属性。
拓展二:Java当中什么是引用,引用通常有哪些?
    Java当中的引用这个概念是从C++当中引入的,而C++的引用他的底层其实还是指针来实现的,所以说C生万物这句话一点都不夸张,学习Java之前系统地学习C语言真的是没有错。
    那么我们可以理解Java当中的引用类似于C语言当中的指针,但也有很多区别,首先我直接说结论,Java当中的引用就是存储着数据的地址的一个变量,但是区别又在于Java当中的引用不允许我们直接访问内存并且对其进行修改和各种四则运算,但是指针都是可以的,而且Java当中的引用不像指针需要手动管理内存,Java当中的引用内存开辟以及内存回收全部都由JVM来负责,所以Java不用担心存在指针越界访问的问题,Java的引用比起指针要安全的多。
    提到了引用的作用,我不知道,各位是否记得C语言当中一个经典的问题,两数交换,在C语言当中形参的改变是无法改变实参的,代码如下。

#include <stdio.h>void swap(int a,int b){int tmp = a;a = b;b = tmp;
}int main(){int x = 3;int y = 5;int ret = swap(x,y);printf("%d\n",ret);
}

    请问上面的代码能够达到我们想要的目的吗?显然是不能的,因为形参只要他的生命周期并不长,只要一出函数的作用域,形参就会被立刻销毁,无法改变实参,所以在C语言当中要想交换两数要么使用指针要么使用返回值return,将swap函数栈帧当中的数据结果传递给main函数栈帧当中的ret变量。Java当中也是一样的道理,如果我们也使用简单的变量进行传参是不行的,所以我们可以使用数组或者直接使用对象,因为他们不单纯是数据还是引用,所以就可以达到我们的目的,因为Java的引用存储的是数据的地址。
    那么引用通常包括哪些呢?引用主要包括以下几点。
 

引用
对象引用
数组引用
接口引用
泛型引用
枚举引用
自定义类型引用

常见的引用包括这些,因为这不是我这篇文章要讲的重点,所以就不给大家展开讲解了。


拓展三:我们来谈一谈内存管理三大区调用以及初始化的顺序
    首先当我们定义好一个类的时候,类的元信息(类头),方法信息,以及父类信息、接口信息,包括方法的代码(字节码,字节码包括方法的指令、操作数、异常处理信息),方法的指令其实我在上面已经展示过了,类似于汇编代码。
    上述提到的关于类的信息,包括静态成员会被一次性全部加载到内存当中的方法区,注意只会加载一次,不会频繁地加载,而且这些信息属于是共享区,每一个线程都可以访问。
    当我们使用new关键字创建对象的时候,JVM会再堆当中为对象创建关键字,会将这个对象的实例全部存储,并且这个时候会调用构造函数对对象的实例进行初始化。
    当对象调用方法的时候,JVM又会在栈上开辟栈帧,用于方法的实现。

拓展四:浅谈JRE与JVM
    
我们之前详细地介绍了JVM的功能组成,现在还涉及到了另外一个很重要的组件——JRE,那么什么是JRE呢?
    

JRE(Java Runtime Environment)包含以下主要内容:

  1. Java虚拟机(JVM): JVM是Java应用程序运行的虚拟计算机,负责解释和执行Java字节码。

  2. Java类库(Java API): 包含了大量的Java标准类和接口,供开发者使用。这些类库提供了各种功能,如文件操作、网络通信、图形界面等。

  3. Java插件和部署工具: 用于在浏览器中运行Java Applet等Web应用程序。

  4. Java Web Start: 一个用于部署和启动Java应用程序的工具,可以通过Web浏览器启动独立的Java应用。

  5. 其他运行时支持文件: 包括配置文件、属性文件等,用于支持Java应用程序的运行和配置。

      说到这里了我给大家提供一个比喻: JRE是一 个 提供Java应用程序运行环境的仓库,而JVM就是这个仓库的管理员,负责管理、执行和监督仓库中的各种物资(Java应用程序)JVM确保这个仓库中的物资能够被正确运行,处理各种运行时的任务,例如内存管理、垃圾回收、线程管理等。JRE提供了运行环境和各种资源,而JVM是负责执行和管理这些资源的核心组件。
    其实说到了JRE还有一个重要的概念就是JDK,我为大家详细地介绍以下JDK,首先我们来解决第一个问题,什么是JDK。
    

JDK的全称是Java Development Kit,即Java开发工具包。JDK是用于Java应用程序开发的软件开发工具集,包含了以下主要组件:

1. JRE(Java Runtime Environment):包含Java虚拟机(JVM)和Java类库,用于支持Java应用程序的运行。

2. 编译器(javac):用于将Java源代码编译成Java字节码。

3. 调试器(jdb):用于调试Java应用程序,帮助开发者查找和修复错误。

4. Java文档生成工具(Javadoc):用于从Java源代码中生成文档。

5. 各种开发工具和实用程序:包括用于性能分析、图形界面设计、版本控制等的工具。


说到这里大家就因该已经明白了,JDK是一个负责运行Java程序的企业,这个企业里面有着JRE这个仓库以及JVM这个仓库的管理员,负责Java的程序运行。

总结:上面的这些知识我为大家详细地讲解了JVM的重要功能,相信大家已经对这一部分有了一个清晰的认识吗,还是要提示一下大家,我个人认为以上的这些知识,是整个Java体系当中最重要的组成部分,下面我要开始为大家讲解面向对象的重要特性——继承和多态,以上这些关于JVM的知识,是非常重要的铺垫,请大家一定要掌握以后再学习后续的内容,否则会有些吃力。


 二、继承

1.继承概念以及语法

1.为什么需要继承

        1)代码复用

        2)扩展功能

        3)多态性
        4)类层次结构

        我们定义一个类让他继承一个现有的类,就可以实现代码的复用,这样可以避免反复多次地定义同样功能的代码,从而减轻程序员的负担。而且继承还是多态实现的一个重要前提,多态必须是在继承的前提下才能够实现,后续我为大家讲解多态的时候,会专门提到。

        提到类的层次结构,这样可以更好地利用代码表现对象之间的关系,使我们的代码更有层次感,提高代码的可读性。

2.继承是什么

        从语法的角度上来讲,继承就是一个类使用关键字extends来实现继承关系,这篇文章我主要从JVM的角度来给大家分析,所以最基础的简单语法,我就不为大家展开讲解了,基础性的东西我们一笔带过。

3.什么是super
        1)
在此之前我们必须先提一下什么是this,this是指向一个对象的引用,一个对象在调用方法的时候,JVM会为这个方法在栈上开辟空间,(我在上文当中提到过,虚拟机栈上分为三个主要的空间,局部变量表,操作数栈。方法返回地址),这个this引用就存放在虚拟机栈上的局部变量表当中。文字的力量还是有限,接下来我为大家写一段代码。
 

package Yangon;public class Base {public int a;public int b;//请大家务必注意这里的Base thispublic void Print(Base this){System.out.println("Hello World!");}public static void main(String[] args) {Base base = new Base();base.Print();}
}

        大家请看这段代码,我定义了一个Print()方法,这个方法的第一个参数就是this引用,注意了这个参数可不是我们自己定义的,是JVM帮我们定义的,很多小伙伴就要疑惑了,我平常可是从来没有这么写过,我怎么不知道这回事,这个参数其实是一个隐式参数,我们程序员也可以自己定义出来,但是即便我们自己不定义,编译器也会帮我们生成,这个隐式的this引用其实就是方法当中默认的第一个参数,这个参数是用来干什么的呢?接下来的内容非常重要,能否理解关乎到你后续是否能够真正的学会继承和多态,以及对于super()引用的理解。
        当我们写好了代码之后,经过JRE当中的编译器编译生成字节码,我们的类 Base 当中的类头信息,方法信息,静态成员(我这里没有定义)、字段信息(不知道什么是字段的往前翻)等这个类的所有信息会全部被加载入方法区。

        当我们使用new来定义这个对象的时候,JVM会为我们在堆当中开辟一块空间,用于存放我们这个对象的信息,这些信息包括对象头,以及对象的成员变量等等关键信息,new 执行完成之后会自动返回这个对象的引用(也就是JVM提供的对象的虚拟地址), base 变量就是用于接受这个对象的引用。
        当我们使用对象的引用调用方法的时候,对象的引用,也就是对象的地址会传递给方法当中的第一个参数,也就是 this 引用,也就是说这个时候指向对象的引用其实有两个,一个是base,一个是 this,当base 赋值给 this 之后我们就可以调用这个对象的方法,并且对对象的成员变量进行赋值,例如像构造函数。
 

public Base(Base this,int a,int b){this.a = a;this.b = b;
}
//注意这里的this是隐式参数,只不过我这里为大家写出来了而已,编译器也允许我们这么写但是没必要

这个时候我们就完成了对对象的一系列操作,这些操作其实都是借助 this 引用来完成的。

        拓展:说到这里我还要多提一嘴,我想说的是静态方法,静态成员,这些方法是无法通过对象来进行调用的,为什么呢?非常简单因为这些成员是不接收 this 引用的,也就是没有隐式的 this 参数,况且静态成员是存储在方法区当中的,是所有线程共享的,在方法区中,类的结构信息也存放在方法区当中,所以我们调用静态成员一般都是通过类来进行调用的。

2)那么 super 是什么呢?和这个 this 有什么关系呢?
        
前面其实都是铺垫,我真正要将的东西其实是这个,那么什么是super呢?我先说结论super 是子类访问父类的成员变量以及方法的时候我们需要使用的一个关键字。比如我为大家写一段代码。

 

public class Base {public int a;public int b;public Base(){}public Base(int a,int b){this.a = a;this.b = b;}
}
class Derived extends Base{public int c;public Derived(int c) {this.c = c;}public Derived(int a, int b, int c) {super(a, b);this.c = c;}public void Print(){super.a = 40;super. b= 50;}
}

        大家可以看到 super 这里是子类想要访问父类的成员信息索要使用的关键字,但是这里关于super 的用法也埋藏着一个“陷阱”,这个陷阱非常容易误导很多的初学者,为了防止大家掉入这个陷阱,我在这里要提问一句,请问子类通过调用 super 关键字能够访问并修改父类当中的属性,请问这句话对还是不对呢?
        我直接告诉各位,这句话大错特错我之前看过很多的视频,也看过很多的文章,没有一个能够解释清楚的,明明很简单的一句话就能够说明白的,非要搞得很复杂。
        super修改的是子类从父类那里继承过来的属性,所以super 修改的是子类自己的属性,只不过这些属性是从父类那里继承过来的,一定要注意子类是没有权限对于父类的成员进行修改的,子类要修改也只能修改自己,所以这行代码。
        

 public void Print(){super.a = 40;super. b= 50;}

        这行代码修改的是父类的成员变量吗,不是的!它修改的是子类自己的变量,只不过这些变量是从父类那里继承来的,我这么解释大家能够明白吗?

拓展二:我们来谈一谈 super 和 this 之间的相同点以及不同点

        相同点:this 和 super 都是Java当中的关键字,用于处理类和对象之间的关系,都可以用来解决同名变量和方法的歧义。如果大家觉得文字不好理解的话,我可一给大家写一段代码。
        

public class Base {public int age;public String name;public Base(int age, String name) {this.age = age;this.name = name;}
}

        大家请看上面的这段代码,为什么构造函数当中为成员变量赋值的时候,需要调用this这个关键字呢?可不可以直接这么写呢?代码如下。

public class Base {public int age;public String name;public Base(int age, String name) {age = age;name = name;}
}

        答案是当然不可以了!所以this这个关键字出现的意义一方面是为了使对象能够调用方法(this是如何帮助对象调用方法的我在上文当中已经讲过了,就不再重复了。),其实this还有一个非常重要的作用就是解决命名冲突和方法歧义,在这里就可以体现出来了,如果不使用this我怎么知道你这个name 和 age 是参数当中的age 还是类的成员变量呢?
        我们提到的关于子类对象调用与父类同名或者不同名的属性的内容,文章下面会详细谈到。

        接下来我们谈一谈super 和 this之间的不同在哪里,他们最主要的区别就是super是用来访问子类当中从父类那里继承来的属性的,而 this 则是用来访问子类本身的属性的。
        到这里,我来讲一下super 与 this 最本质的区别,各位要注意了,这个内容非常重要,看仔细了,我之前提到过this其实是类的非静态方法所提供的一个隐式参数,用于接受对象的引用,所以this本质上是一个引用,从操作系统或者JVM的视角来看,this其实是一个地址,只不过JVM为我们提供的this是一个虚拟地址,经过JVM的一系列操作,这个虚拟地址会映射到内存的物理地址上去,对象要通过这个地址(我们程序员拿到的是一个引用)来调用对象所属类的方法,那么super是不是也是一个引用呢?不是的,从JVM的角度来看super经过编译以后会变成一个字节码指令(我上文当中提到过字节码指令,还为大家列出表格,类似于汇编指令),super再经过操作系统就会变成一个机器指令,最终会送入到CPU的IR寄存器当中,经过一系列的解析,CPU会根据翻译过后的super指令,进行一系列的操作,例如操纵子类对象访问从父类当中继承来的属性。super经过一系列的编译之后,super会定位到子类对象的父类代码当中,来指挥CPU进行相关操作。
        好了 super 和 this 最底层的区别我就讲到这里,希望大家能够理解。

4.父类成员的访问
       
1)父类成员变量:子类访问父类成员变量的时候,通常会遇到两个问题,一个是子类当中与父类当中的不同名变量,其次是父子类当中的同名变量,其实这个时候我们只需要记住一点,那就是当子类当中出现了与父类当中的同名变量,那么JVM优先访问子类当中的变量,代码如下:

public class Base {public int a;public int b;
}
class Derived extends Base{public int c = 10;public void Print(){System.out.println(a);}
}

        这个时候Print()函数访问的就是 父类当中的a变量,(不过这里我还是要提一句,我说这里的Print函数访问的是父类当中的a变量,严格意义上来说是错的,这里访问的是从父类当中继承来的子类的成员变量)。但是如果子类当中也有这样的成员变量,那么这个时候就会优先访问子类当中的变量,代码如下。
 

public class Base {public int a;public int b;
}
class Derived extends Base{public int a = 50;public int c = 10;public void Print(){System.out.println(a);}
}

        那么这个时候的 a 变量就不会再去访问父类当中的成员了(子类从父类继承来的成员,本质上还是子类的成员,子类没有权限去访问父类的成员,我反复强调这一点,因为这一点很容易误导初学者)。

5.继承当中的构造方法 

        首先我讲第一点,什么是默认构造函数,这一点非常重要,直接说答案,Java当中的默认构造分为两类一类是无参构造,第二类是我们不显示定义构造函数,由编译器为我们默认生成的构造函数叫做默认构造函数。
        其次,我之前反复强调,子类调用super的时候其实并没有直接访问父类当中的属性(成员变量 + 方法,我害怕有小伙伴不知道属性是什么东西,在这里强调一下),而是访问子类当中从父类继承来的属性,这一点到这里有一个特例,就是构造函数,子类调用构造函数对字段进行初始化的时候,必须调用super()来对子类当中从父类继承来的字段进行初始化(字段我文章上面讲过了,这里就不重复了),所以这个时候子类调用super()来对初始化子类当中从父类继承来的字段的时候,其实子类的的确确是使用super访问了父类的构造函数。因为这里我要特别强调一点,构造函数是不会被子类继承的,切记!切记!
        我还要强调一点,如果父类当中有默认构造函数,那么子类当中调用构造函数的时候,不需要显示调用super对从父类那里继承来的成员进行初始化,但是如果父类没有构造函数,那么我们调用子类的构造函数的时候,就必须显示调用super,为子类当中从父类那里继承来的字段进行赋值。具体代码如下。
 

package Yangon;public class Base {public int a;public int b;public Base(){} //默认构造或者干脆不写,让编译器直接为我们定义}
class Derived extends Base{public int c;public Derived(int c){}
}

        因为父类当中存在默认构造函数,所以我们再初始化子类的时候就不需要再进行super调用了,这里要注意如果我们已经显示定义了父类的构造函数,这个时候编译器就不会再为我们默认生成默认构造函数了,所以这个时候我们必须手动定义无参默认构造函数,否则编译器会直接报错。
 

package Yangon;public class Base {public int a;public int b;public Base(int a, int b) {this.a = a;this.b = b;}
}
class Derived extends Base{public int c;public Derived(int a, int b, int c) {super(a, b);this.c = c;}
}

        这个时候,因为父类没有默认构造函数,所以子类就必须显示调用,当我们为父类定义了构造函数,编译器就不会为我们自动生成了,可是我们又没有定义无参默认构造函数,所以必须调用super,可是如果代码是下面这样的话。
 

package Yangon;public class Base {public int a;public int b;public Base(){}
}
class Derived extends Base{public int c;public Derived(int c) {this.c = c;}
}

        因为我们显示定义了默认构造函数,所以也就不需要调用super了。

2.类当中的执行顺序

        接下来为大家讲解一下,父子类当中常见的代码运行顺序,下面请看代码
 

class Person {
public String name;
public int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
System.out.println("构造方法执行");
}
{
System.out.println("实例代码块执行");
}
static {
System.out.println("静态代码块执行");
}
}
public class TestDemo {
public static void main(String[] args) {
Person person1 = new Person("bit",10);
System.out.println("============================");
Person person2 = new Person("gaobo",20);
}
}

        这段代码定义了两个对象,请问这几个方法当中谁最先调用呢,我在上文当中也提到过,静态代码块是存储在方法去当中的,这部分代码早在JRE编译期间,就已经被写成字节码并且被JVM调入到了内存中的方法区里面去,而且只调入一次,即便创建了多个对象也是只调用一次,所以最先执行的一定是静态代码块。
        我再解释一下实例代码块,这些代码块是在对象创建的时候就已经调用了,对象创建之后才会调用构造函数,对这个对象的实例进行初始化,所以先后顺序我想大家也应该知道了。大家只要记住凡是类当中的非静态代码块统统都叫实例代码块,静态的叫静态代码块。
        最后,继承当中还有很多的基础性知识我没有去讲,例如想protected,继承方式,还有访问限定符,还有组合的概念,但我觉得这些都不是什么难点,只要稍微查阅一些资料都可以学会,所以我就不在这里进行赘述了。之后我就要开始为大家讲解多态的知识了。


三、多态

1.什么是多态

        多态(polymorphism)是面向对象编程中的一个重要概念,它允许一个对象能够以多种形态存在。具体而言,多态性有两种主要形式:编译时多态性(静态多态性)和运行时多态性(动态多态性)。

        其实这里的编译时多态就是我们平常所熟知的重载,也就是方法的参数列表(类型、个数、顺序有一项不同即可)不同。这里其实是广义上的多态的概念,那么从狭义上的多态来理解的话,我们所说的多态其实就是运行时多态,也就是再编译阶段,我们无法确定对象的类型,只有在运行的时候我们才能确定类型,并且调用相应类型的方法。
        说到这里可能还是又很多的小伙伴不能够理解到底什么是多态,这些文字感觉不像是人话,那么我就来举个例子好了,这个例子非常重要,直接贯穿了我们多态的整条故事线,请大家一定记住。这个例子是这样的。
        相信大家都有过买高铁票的经历,那么请问在卖高铁票的时候,同样的经过,但是当不同的人买票的时候,会产生不同的结果,比如如果你是一个大学生,那么你买票就会是学生票,如果你是一个军人,那么你买票也会享受相应的优惠,如果你什么都不是,那很抱歉你只能购买成人票了。你发现了吗,同样都是买高铁票,同样都是使用了“铁路12306”这个购票软件,但是不同的人参与却产生了完全不同的结果,这就是多态。
        说到这里我相信大家可能已经理解了,简单地说多态就是同一个对象做同一件事情但是产生了不同的结果,这就是多态(运行时多态)。

2.怎么样使用多态

        1)使用多态的三个条件

  • 必须在继承体系之下
  • 子类必须对父类当中的方法进行重写
  • 必须通过父类的引用调用重写的方法(向上转型)

        第一个继承体系之下,我们之前已经介绍过了,就不再进行重复了,我这里主要将后两个知识点。

        首先什么是重写,我直接上代码。
 

public class Base {public int a;public int b;public Base(){}public void Print(){System.out.println("Hello World!");}
}
class Derived extends Base{public int c;public Derived(int c) {this.c = c;}public void Print(){System.out.println("Hello Father!");}public static void main(String[] args) {Base base = new Derived(20);base.Print();}
}

        这段代码里的子类当中的Print()函数就叫做重写,说白了重写就是子类当中方法签名与父类完全一致的方法。方法签名就是一个方法的声明部分,也就是方法名,参数列表、返回值类型都相同。
        还有很重要的一点就是,子类重写父类的方法,子类重写的方法访问限定符的权限只能大于等于父类的方法,不能够比子类重写方法的权限还要小。例如父类方法的权限是public,子类重写方法权限只能是public,不能是private 或者 default ,因为这些都比public 的权限要小。
        那么这是为什么呢,请看代码!

class Parent {// 父类方法,访问权限为publicpublic void someMethod() {System.out.println("Method in Parent");}
}class Child extends Parent {// 子类重写父类方法,访问权限设为private(比public小)private void someMethod() {System.out.println("Method in Child");}
}public class Main {public static void main(String[] args) {Parent obj = new Child();  // 使用父类引用指向子类对象// 外部的代码无法直接调用子类重写的private方法// obj.someMethod();  // 编译错误,无法访问}
}

        道理很简单一个权限为public的父类方法,被一个private的子类方法重写了,意味着这个重写方法的权限缩小了,也就意味着想要访问这个方法的难度更大了,门槛更高了,编译器是不允许这样的事情发生的。



        
        现在我来解释一下,什么叫做向上转型。向上转型是指将一个子类类型的引用转换为其父类类型的引用的过程。在 Java 中,这是一种自动的类型转换,不需要显式声明。

class Animal {void eat() {System.out.println("Animal is eating");}
}class Dog extends Animal {void bark() {System.out.println("Dog is barking");}
}public class Main {public static void main(String[] args) {Dog dog = new Dog(); // 创建 Dog 对象Animal animal = dog; // 向上转型,自动发生animal.eat(); // 调用父类方法// animal.bark(); // 无法调用子类特有的方法,因为 animal 引用的是 Animal 类型}
}

        在这个例子中,dog 是一个 Dog 类型的引用,通过向上转型,我们将其转换为 Animal 类型的引用 animal。这是安全的,因为 DogAnimal 的子类,子类的对象可以被视为父类的对象。

        向上转型通常发生在多态的场景中,当我们有一个父类类型的引用指向一个子类对象时,可以通过这个引用调用父类中定义的方法,但无法调用子类特有的方法。向上转型是一种常见的操作,它允许代码更加灵活,可以处理父类和子类对象的引用。
        拓展:向上转型有着三种方法,这三种方法都需要我们掌握,接下来我为大家一一列举。
        
1)隐式转换(自动转型)
        2)静态方法传参
        3)方法返回值
        具体代码如下:
 

package Yangon;public class Animal {public int a;public int b;public void Print(){System.out.println("Hello World!");}
}
class Dog extends Animal{public void Print(){System.out.println("Hello Yangyi!");}
}class Main{static void eat(Animal animal){animal.Print();}static Dog getDog(){return new Dog();}public static void main(String[] args) {//隐式类型向上转型Animal animal = new Dog();animal.Print();//这里可以通过函数传参来实现向上转型Dog dog = new Dog();eat(dog);//方法返回值实现向上转型Animal animal1 = getDog();animal1.Print();}
}

        以上代码已经完整地为大家展示了,三种实现向上转型,希望大家能够很好的理解。不过在这里我还要特别地强调一下,即便是向上转型,将子类的引用赋值给父类对象,那么这个父类对象也只能访问子类从父类当中继承来的方法,不可以访问子类当中特有的方法,这是为什么呢?以为我们多态的实现,是在编译期间动态绑定实现的,也就是说只有在运行期间才能够确定,父类所引用的子类类型是什么,在编译期间我们只知道,父类的类型是什么,所以这也就导致了,向上转型之后,父类对象也只能访问子类当中从父类继承来的方法,不能够访问子类特有的方法。
        说到这里的时候,有很多的小伙伴就有疑问了,代码如下:
 

 Animal animal = new Dog();animal.Print();

        你在写代码的时候,不是已经明确地指出了子类的类型是Dog吗?怎么会不确定子类的类型呢?很简单,虽然将子类的引用赋值给了父类对象,可是别忘了这个父类的对象仍然是Animal类型的,在Animal类型当中Dog类的字段信息以及方法信息都没有定义,所以编译器并不能确定子类当中的特有方法,所以向上转型之后就只能访问子类当中的继承方法,不能访问子类的特有方法,希望我的这次解答能够帮你解答疑惑。


        向上转型可以让我们的实现多态,让我们的代码更加的灵活以适应这个世界当中对象之间复杂的关系,所以我们需要向上转型。但是向上转型也有一个巨大的缺陷,那么就是父类的引用无法调用子类当中特有的方法,这一点是比较可惜的,那么针对这个问题,我们有提出了向下转型,这样就可以使得我们父类引用方便地访问我们子类当中特有的方法。
        下面我为大家讲解一下什么是向下转型,请看代码。
 

package Yangon;public class Animal {public int age;public String name;public void eat() {System.out.println("吃东西");}
}
class Dog extends Animal{public void eat(){System.out.println("小狗正在吃东西");}public void play(){System.out.println("小狗正在玩耍");}
}
class Bird extends Animal{public void eat(){System.out.println("小鸟正在吃东西");}
}
class Main{public static void main(String[] args) {Animal animal = new Dog();if(animal instanceof Dog){Dog myRealDog = (Dog)animal;myRealDog.play();//这里是可以调用子类的特有方法的,因为对父类进行了强制类型转换//也就是向下转型}Animal animal1_bird= new Bird();if(animal1_bird instanceof Bird){//编译就会报错,不允许将Bird 赋值给 Dog类,这就是instanceof的意义所在Dog bird_dog = (Bird)animal1_bird;}}
}

        

3.为什么使用多态

         针对我们为什么要使用多态,我们下来看以下这段代码。
 

package Yangon;public class Animal {public void Print(){System.out.println("Hello World!");}
}
class Dog extends Animal{public void Print(){System.out.println("Hello YangYi");}
}
class Main{public static void main(String[] args) {Animal animal = new Dog();animal.Print();}
}

        多态在这里的作用是什么呢?我们可以看到,通过多态我们调用了子类当中的继承方法,可是我不知道会不会有人跟我有同样的疑问,这段代码明明可以这么写。如下:
 

class Main{public static void main(String[] args) {Dog dog = new Dog();dog.Print();}
}

        那么问题来了,明明可以直接定义子类的对象调用子类的方法,为什么还要多此一举地使用多态呢?其实调用多态是非常便利的一个做法,只不过是我们这段代码不需要调用多态而已,我敢才所写的这份代码,压根就不需要使用多态,因为我们从一开始就知道我们要调用的对象是什么类型,那么什么时候应该使用多态呢?当然是我们不知道对象的具体类型的时候了。我不知道大家是否还记得我最开始举的一个例子就是高铁购票的案例,你作为一个售票员,请问你坐在那里的时候,你知道要来买票的客户他要买的是成人票,是学生票还是军人优先票,你不知道对不对,那我们是怎么确定对象的类型呢?当然是要核对他们的证件了,我写一段代码给大家看一看。
 

import java.util.Random;// 抽象票类
abstract class Ticket {abstract double getPrice();
}// 普通票类
class NormalTicket extends Ticket {@Overridedouble getPrice() {return 100.0; // 普通票价}
}// 学生票类
class StudentTicket extends Ticket {@Overridedouble getPrice() {return 80.0; // 学生票八折优惠}
}// 军人票类
class MilitaryTicket extends Ticket {@Overridedouble getPrice() {return 50.0; // 军人票半价优惠}
}// 票工厂类,用于生成随机类型的票
class TicketFactory {static Ticket getRandomTicket() {Random random = new Random();int randomNum = random.nextInt(3); // 0, 1, 2 分别对应三种票类型switch (randomNum) {case 0:return new NormalTicket();case 1:return new StudentTicket();case 2:return new MilitaryTicket();default:throw new IllegalArgumentException("Invalid ticket type");}}
}// 购票系统
public class TicketSystem {public static void main(String[] args) {// 示例:购票purchaseTicket(TicketFactory.getRandomTicket(), 20); // 普通人购买随机类型的票purchaseTicket(TicketFactory.getRandomTicket(), 16); // 学生购买随机类型的票purchaseTicket(TicketFactory.getRandomTicket(), 30); // 军人购买随机类型的票}// 购票方法,根据年龄和特殊身份选择不同的票static void purchaseTicket(Ticket ticket, int age) {System.out.println("购票信息:");System.out.println("年龄:" + age + "岁");// 根据年龄和特殊身份选择不同的票if (age < 18 || age >= 18 && age <= 25) {System.out.println("购买学生票,优惠价:" + ticket.getPrice());} else if (age >= 18 && age <= 60) {System.out.println("购买普通票,票价:" + ticket.getPrice());} else {System.out.println("购买军人票,优惠价:" + ticket.getPrice());}System.out.println("------------");}
}

        这段代码就很好地诠释了多态的意义,想要实现这样的业务就必须使用多态,因为在这样的代码逻辑之下,我们是不确定对象的具体类型的,所以这个时候我们必须使用多态。
        好了,多态就给大家讲解到这里,希望大家有所收获!


总结:

        这篇文章为大家详细地展示了JVM的工作内容,以及继承和多态,因为我本人大学期间主修方向是C、C++语言,因为工作原因需要用到Java,所以最近开始转换语言,由于之前学C++以及嵌入式的相关知识的时候,我留下来了一个特点,就是我非常喜欢探查一门语言他的底层运行逻辑,包括他与操作系统的工作逻辑。这篇文章是我从11月中旬开始准备的,从知识的积累到总结最后到整理我用了将近半个月的时间,因为战线拉得比较长,所以中间的内容难免有些地方衔接的不够好,或者说是有遗漏的地方,希望大家多多包涵,我会用我的方式站在JVM的角度,站在计算机底层原理的角度,为大家带来不同的Java学习的体验,谢谢大家。你们能够从我的文章当中学到东西,这就是对我最大的鼓励。



https://www.xjx100.cn/news/3118786.html

相关文章

Flink-时间窗口

在流数据处理应用中&#xff0c;一个很重要、也很常见的操作就是窗口计算。所谓的“窗口”&#xff0c;一 般就是划定的一段时间范围&#xff0c;也就是“时间窗”&#xff1b;对在这范围内的数据进行处理&#xff0c;就是所谓的 窗口计算。所以窗口和时间往往是分不开的。 时…

k8s中Pod控制器简介,ReplicaSet、Deployment、HPA三种处理无状态pod应用的控制器介绍

目录 一.Pod控制器简介 二.ReplicaSet&#xff08;简写rs&#xff09; 1.简介 &#xff08;1&#xff09;主要功能 &#xff08;2&#xff09;rs较完整参数解释 2.创建和删除 &#xff08;1&#xff09;创建 &#xff08;2&#xff09;删除 3.扩容和缩容 &#xff08…

ubuntu下训练自己的yolov5数据集

参考文档 yolov5-github yolov5-github-训练文档 csdn训练博客 一、配置环境 1.1 安装依赖包 前往清华源官方地址 选择适合自己的版本替换自己的源 # 备份源文件 sudo cp /etc/apt/sources.list /etc/apt/sources.list_bak # 修改源文件 # 更新 sudo apt update &&a…

企业微信应用文本消息

应用支持推送文本、图片、视频、文件、图文等类型&#xff0c;本篇主要实现发送应用文本消息。 获取企业凭证 发送应用消息首先需要获取调用凭证access_token&#xff0c;此处的凭证为企业凭证&#xff0c;可通过企业授权安装时返回的授权信息中获取access_token&#xff1b;之…

PlantUML语法(全)及使用教程-用例图

目录 1. 用例图1.1、什么是用例图1.2、用例图的构成1.3、参与者1.4、用例1.4.1、用例基本概念1.4.2、用例的识别1.4.3、用例的要点1.4.3、用例的命名1.4.4、用例的粒度 1.5、应用示例1.5.1、用例1.5.2、角色1.5.3、改变角色的样式1.5.4、用例描述1.5.5、改变箭头方向1.5.6、使用…

OpenCV4.x图像处理实例-常见图像滤镜特效实现

常见20多种图像滤镜特效实现 文章目录 常见20多种图像滤镜特效实现0、准备1、亮度调节2、细节强化3、底片效果4、卡通效果5、浮雕效果6、铅笔素描效果7、夏季或温色滤镜8、冬季或冷色滤波9、Splash滤镜10、双色调(Duo-Tone)滤镜11、日光(Daylight)滤镜12、60sTVs效果13、高…

记录一次现网问题排查(分享查域名是否封禁小程序)

背景&#xff1a; 收到工单反馈说现网业务一个功能有异常&#xff0c;具体现象是tc.hb.cn域名无法访问&#xff0c;客户地区是河南省&#xff0c;这里记录下排查过程和思路。 首先梳理链路 客户端域名 tc.hb.cn cname—> domainparking-dnspod.cn(新加坡clb)—> snat—&…

【知识】稀疏矩阵是否比密集矩阵更高效?

转载请注明出处&#xff1a;小锋学长生活大爆炸[xfxuezhang.cn] 问题提出 有些地方说&#xff0c;稀疏图比密集图的计算效率更高&#xff0c;真的吗&#xff1f; 原因猜想 这里的效率高&#xff0c;应该是有前提的&#xff1a;当使用稀疏矩阵的存储格式(如CSR)时&#xff0c;计…