一、类的基础知识
1.1 对于变量的区分
1.1.1 类变量(静态变量):
用static修饰的变量称为静态变量,其内容被该类的所有对象共享,所有对象中这个类变量的值都指向相同的一处内存,随便一个对象修改了此处内存的值都会影响其他对象。
public class test{
static class Demo {
static int i;
}
public static void main(String[] args){
Demo d1=new Demo();
Demo d2=new Demo();
d1.i=99;
System.out.println(d2.i);
}
}
1.1.2 成员变量:
在类定义时声明的变量,随着对象的建立而建立,随着对象的消失而消失存在于对象所在的堆内存中。
1.1.3 局部变量:
在函数中声明的变量,只定义在局部范围内,只在所属的区域内有效。存在于栈内存中,作用的范围结束,栈帧释放,变量消失。
1.2 变量作用域的区分
在定义成员变量和方法时,前面都会加上作用域修饰符(如果不加,默认为default),不同修饰符作用下,该成员变量或方法的作用域是不同的。
1.3 构造函数
构造函数是用来初始化对象的,一个类可以重载多个构造函数。
需要注意的是:默认情况下,一个类会有一个默认的构造函数,这个构造函数没有内容也没有返回值,一般都略去不写。
但是,如果一个类定义了一个有参数有方法体的构造函数,这时编译器就不会再给它默认加上一个无参且方法体为空的构造函数,可以理解为无参的构造函数被覆盖,此时需要手动定义一个无参构造函数。
再函数的继承里,子类必须使用super来实现对父类的非默认构造函数的调用。再创建对象时,先调用父类的默认构造函数,然后调用子类自身定义的构造函数。
二、封装
将类的某些信息隐藏在类内部,不允许外部程序直接访问,而是通过该类提供的方法来实现对隐藏信息的操作和访问,常见的实现方式就是:getter和setter
封装遵循了“开闭原则”,禁止外部直接访问和修改类的信息。
三、继承
继承是类与类的一种关系,子类拥有父类的所有属性和方法(除了private修饰的属性不能拥有),从而实现了代码的复用。
继承(extends)与实现(implements)的区别:
- 概念不同:继承是子类与父类的继承,如果多个类的某个部分的功能相同,那么可以抽象出一个类出来,把他们的相同部分都放到父类里,让他们都继承这个类。实现是对于接口的实现,如果多个类都有一个行为,但是处理的方法不同,那么就定义一个接口,也就是一个标准,让各个类分别实现这个接口,各自实现自己具体的处理方法。
- 属性不同:在接口中只能定义全局常量(static final)和空的方法体;而在继承中可以定义属性方法,常量,变量等。
- 限制不同:某个类被接口实现时,在类中一定要实现接口中的抽象方法;而继承则无需。
四、多态
4.1 概述
指程序中的某个引用变量,它所指向的具体类型以及该引用变量发出的方法调用,在编程时不能确定,要在程序运行并使用时由机器自己判别确定。
多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。Java对于方法调用动态绑定的实现主要依赖于方法表,但通过类引用调用(invokevirtual)和接口引用调用(invokeinterface)的实现则有所不同。
类引用调用的大致过程为:Java编译器将Java源代码编译成class文件,在编译过程中,会根据静态类型将调用的符号引用写到class文件中。在执行时,JVM根据class文件找到调用方法的符号引用(是符号引用还是直接引用?),然后在静态类型的方法表中找到偏移量,然后根据this指针确定对象的实际类型,使用实际类型的方法表,偏移量跟静态类型中方法表的偏移量一样,如果在实际类型的方法表中找到该方法,则直接调用,否则,认为没有重写父类该方法。按照继承关系从下往上搜索。
多态存在的三个必要条件:
- 继承
- 重写
- 父类引用指向子类对象
当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。下面的例子实现了这个过程:
public class test{
public static void main(String[] args){
Father c=new Child();
c.eat();
//下面的语句不能通过编译
//c.play();
System.out.println(c.age);
}
}
class Father{
protected int age;
public Father(){
age=40;
}
void eat(){
System.out.println("父类在吃饭");
}
}
class Child extends Father{
protected int age;
public Child(){
age=10;
}
void eat(){
System.out.println("子类在吃饭");
}
void play(){
System.out.println("子类在玩");
}
}
输出结果:
子类在吃饭
40
对于Father c=new Child(),在c的眼里只能看到child里面的Father属性,当满足多态的三个条件时,c.eat()调用子类的eat方法的原因是子类重写并覆盖了父类的eat()方法,但c.age调用的还是父类的age,因为属性/变量不存在重写和覆盖,而因为父类中没有play()方法,因此不会通过编译。如果想要调用父类中的方法,需要使用super关键字
4.2 多态实现具体原理
4.2.1 静态绑定与动态绑定
JVM的方法调用指令有五个,分别是:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器< init >方法、私有方法和父类方法
- invokevirtual:调用虚方法
- invokeinterface:调用接口方法,运行时确定具体实现
- invokedynamic:运行时动态解析所引用的方法,然后再执行,用于支持动态类型语言
其中,invokestatic和invokespecial用于静态绑定,invokevirtual和invokeinterface用于动态绑定。可以看出,动态绑定主要应用于虚方法和接口方法。
静态绑定在编译期就已经确定,这是因为静态方法、构造器方法、私有方法和父类方法可以唯一确定。这些方法的符号引用在类加载的解析阶段就会解析成直接引用。因此这些方法也被称为非虚方法,与之相对的便是虚方法。
虚方法的方法调用与方法实现的关联(也就是分派)有两种,一种是在编译期确定,被称为静态分派,比如方法的重载;一种是在运行时确定,被称为动态分派,比如方法的覆盖。对象方法基本上都是虚方法。
这里需要特别说明的是,final 方法由于不能被覆盖,可以唯一确定,因此 Java 语言规范规定 final 方法属于非虚方法,但仍然使用 invokevirtual 指令调用。静态绑定、动态绑定的概念和虚方法、非虚方法的概念是两个不同的概念。
4.2.2 多态的实现
虚拟机栈中会存放当前方法调用的栈帧,在栈帧中,存储着局部变量表、操作数栈、动态连接、返回地址和其他附加信息。多态的实现过程,就是方法调用动态分派的过程,通过栈帧的信息去找到被调用方法的具体实现,然后使用这个具体实现的直接引用完成方法调用。
以invokevirtual为例,在执行时,大致可以分为以下几步:
- 先从操作数栈中找到对象的实际类型 class;
- 找到 class 中与被调用方法签名相同的方法,如果有访问权限就返回这个方法的直接引用,如果没有访问权限就报错 java.lang.IllegalAccessError ;
- 如果第 2 步找不到相符的方法,就去搜索 class 的父类,按照继承关系自下而上依次执行第 2 步的操作;
- 如果第 3 步找不到相符的方法,就报错 java.lang.AbstractMethodError ;
可以看到,如果子类覆盖了父类的方法,则在多态调用中,动态绑定过程会首先确定实际类型是子类,从而先搜索到子类中的方法。这个过程便是方法覆盖的本质。
实际上,商用虚拟机为了保证性能,通常会使用虚方法表和接口方法表,而不是每次都执行一遍上面的步骤。以虚方法表为例,虚方法表在类加载的解析阶段填充完成,其中存储了所有方法的直接引用。也就是说,动态分派在填充虚方法表的时候就已经完成了。
在子类的虚方法表中,如果子类覆盖了父类的某个方法,则这个方法的直接引用指向子类的实现;而子类没有覆盖的那些方法,比如 Object 的方法,直接引用指向父类或 Object 的实现。
4.1.3 接口调用
因为Java类是可以实现多个接口的,而当接口引用某个方法的时候,情况就有所不同饿。
Java允许一个类实现多个接口,从某种意义上来说相当于多继承,这样同样的方法在基类和派生类的方法表的位置就可能不一样了。
interface IDance {
void dance();
}
class Person {
public String toString() {
return "I'm a person.";
}
public void eat() {
}
public void speak() {
}
}
class Dancer extends Person implements IDance {
public String toString() {
return "I'm a dancer.";
}
public void dance() {
}
}
class Snake implements IDance {
public String toString() {
return "A snake.";
}
public void dance() {
//snake dance
}
}
可以看到,由于接口的介入,继承自于接口 IDance 的方法 dance()在类 Dancer 和 Snake 的方法表中的位置已经不一样了,显然我们无法仅根据偏移量来进行方法的调用。
Java 对于接口方法的调用是采用搜索方法表的方式,如,要在Dancer的方法表中找到dance()方法,必须搜索Dancer的整个方法表。
因为每次接口调用都要搜索方法表,所以从效率上来说,接口方法的调用总是慢于类方法的调用的。