类和对象

类是一个抽象的概念,本质上是现实世界中某些事物具有的共同特征,将这些共同特征提取出来形成的概念就是一个类。

对象:由类创建的个体,也叫实例。

实例化:通过类这个模板创建对象的过程叫做 实例化。

共同特性:

  1. 状态特征 -> 属性
  2. 动作特征 -> 方法

类 = 属性 + 方法

创建一个类:

public class Student {
    String name;
    int age;
    int score;

    public Student(String name, int age, int score) {
        this.name = name;
        this.age = age;
        this.score = score;
    }
}

在语法级别上创建一个对象:

  Student tom = new Student("Tom", 18, 100);

new运算符做了两件事情:

  1. 根据方法区中对象的大小在堆内存中申请空间(同时对所有成员变量默认初始化)
  2. 调用构造方法

属性在java中以 成员变量 的形式存在,这种成员变量又称为实例变量,实例变量实际上就是对象级别的变量

所以说 访问实例变量就必须要先创建对象

内存图如下所示:

tom保存的是堆内存中的地址,这个变量被称为引用,存储在栈内存中;创建出的对象本身只会存在堆上

对象的内存结构

对象由四部分组成:

  • MarkWord
  • ClassWord
  • 成员变量
  • 对象对齐
  1. MarkWord
  • 占用 8 个字节
  • 作用
    • 这个无法观察到,在 GC 发生时临时存储地址,ZGC 是在堆外记录地址,没有用 MarkWord
    • GC 时,移动对象,需要用它记录对象新的地址
      GC 时,记录对象年龄
    • 当没有重写 hashCode 方法时,存储 identity hash code 值
    • 存储锁信息
  1. ClassWord

ClassWord指向的是方法区的Class信息, 对象可以通过这个类指针访问自己的类信息和方法信息。
指向的是运行时动态数据结构。

内存占用:

  • 启用类指针压缩会占用4个字节(默认)
  • 不启用类指针压缩会占用8个字节(-XX:-UseCompressedClassPointers
  1. 成员变量

类型占用的字节数

  • 引用类型

    • 启用压缩会占用 4 字节(默认)
    • 不启用压缩会占用 8 字节(-XX:-UseCompressedOops
  • boolean,byte:1 字节

  • short,char:2 字节

  • int,float:4 字节

  • long,double:8 字节

问题 : boolean 类型为何占用 1 字节(byte)而不是 1 位(bit)?

回答:

  • 避免出现 word tearing 问题,现在的硬件不能原子访问一个 bit,读写的最小单位是 byte

  • 例如

    假设 8 个 bit,后两位分别代表两个 boolean 变量 a 和 b,取值均为 false(0)
    0 0 0 0 0 0 0 0
    线程1 读取这 8 个 bit,修改 a(倒数第二位)为 true(1)
    0 0 0 0 0 0 1 0
    线程2 读取这 8 个 bit,修改 b(倒数第一位)为 true(1)
    0 0 0 0 0 0 0 1
    等线程2 写回时,就会覆盖掉线程1 的结果

  1. 对象补齐

对象字节数如果不是8的倍数,需要补齐
以上文中的Student类为例,内存结构:

对象的创建过程

以如下代码为例,说明对象的创建过程:

public class A{
 	int a = 0;   
    public A(){
        this.a = 10;
    }
}

类加载阶段

  1. 执行[[015-强化#类加载时机|类加载]] 加载 链接 初始化,如果该类有父类,先加载父类
  2. 类加载完成后,确定每个类成员变量(Field)的偏移量、是否需要补齐

加载完成后,在方法区中生成字节码信息:类A对象将来的内存结构

OFF SZ TYPE DESCRIPTION     VALUE
0   8  (object header:mark)  N/A
8   4  (object header:class) N/A
12  4  int A.a               N/A
Instance size:16bytes

到此为止,对象有多大就知道了(int A.a的偏移量为12)

分配空间

当执行到new A() 时,对应的字节码为:

8:new        #13       //class test1/A
11:dup 
12:invokespecial #21   //Method test1/A."init":()V

其中 行号8的new时根据方法区的对象信息,在堆中申请空间(16字节)

  • 0 – 7 存储MarkWord
  • 8 – 11 存储类指针
  • 12 – 15 存储 A.a

行号11的dup是将对象地址(this指针)复制一份,配合下面的构造方法调用

行号12的invokespecial用来执行<init>:()V构造方法,用在this指向的对象上调用

new关键字的作用:

  1. 分配内存空间

    根据方法区的字节码信息在堆内存上分配空间并进行实例变量默认初始化操作

  2. 调用构造方法

构造方法是一种特殊的方法,构造方法默认支持[[004-方法#构造方法重载|方法重载]],构造方法的执行阶段:

1. 调用父类构造方法
2. 进行显式初始化、构造方法初始化操作
3. 进行其他操作(print)

当一个类没有提供任何构造方法的时候,系统会提供一个默认的无参构造(缺省构造器),当一个类中提供了构造方法,系统就不再提供无参的构造方法(建议提供无参构造)。

在new申请内存空间时为实例变量指定的默认值:

数据类型 默认值
byte 0
short 0
int 0
long 0L
float 0.0f
double 0.0
boolean flase
char \u0000
引用类型 null

在构造方法中,执行完父类的构造方法会对成员变量依次进行 显式初始化、构造方法初始化

调用构造

A的构造方法中,Java字节码为:

0:aload_0
1:invokespecial#1  //Method java/lang/Object."<init>":()V
4:aload_0
5:bipush  10
7:putfield #7      //Field a:l

其中

  • 行号0、1是调用java.lang.Object的无参构造

  • 行号4、5、7共同完成给成员变量赋值的操作

    • 行号4的 aload_0 是加载this指针,用它可以定位到对象

    • 行号5的 bipush 用来准备常数10

    • 行号7的 putfield #7 用来找到给哪个成员变量赋值

    • #7就是常量池中的符号,根据它能找到是给A.a int 的Field赋值

    • putfield内部执行时,就可以根据this指针先找到堆中的对象

    • offset 8 位置找到类指针,根据类信息找到A.a的偏移量为12

    • offset 12 位置存入常数 10,按四个字节写入

完整的字节码:

 Compiled from "A.java"
public class LogDemo.A
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // LogDemo/A
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
// Methodref : {Class = java/lang/Object,NameAndType = <init>()V}
// Fieldref : {Class = LogDemo/A,NameAndType = a:I}
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // LogDemo/A.a:I
   #8 = Class              #10            // LogDemo/A
   #9 = NameAndType        #11:#12        // a:I
  #10 = Utf8               LogDemo/A
  #11 = Utf8               a
  #12 = Utf8               I
  #13 = Methodref          #8.#3          // LogDemo/A."<init>":()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               LLogDemo/A;
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               SourceFile
  #24 = Utf8               A.java
{
  int a;
    descriptor: I
    flags: (0x0000)

  public LogDemo.A();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #7                  // Field a:I
         9: aload_0
        10: bipush        10
        12: putfield      #7                  // Field a:I
        15: return
      LineNumberTable:
        line 5: 0
        line 4: 4
        line 6: 9
        line 7: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   LLogDemo/A;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: new           #8                  // class LogDemo/A
         3: dup
         4: invokespecial #13                 // Method "<init>":()V
         7: pop
         8: return
      LineNumberTable:
        line 10: 0
        line 11: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

示例

package StudentStaticDemo;

public class Student {
    private String name;
    private int age;
    private String gender;


    public static String teacher = "lisi";
    public void study(Student this){
        System.out.println(teacher);
    }
    public Student(String name, int age, String gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
    
    public static void main(String[] args) {
        Student s1 = new Student("zhangsan",22,"male");
        s1.study();
    }
}
  1. 类加载:加载main方法所在类的字节码文件,系统类加载器将加载请求委托给平台加载器,平台加载器将请求委托给启动类加载器,启动类加载器和平台类加载器无法完成加载任务,最终系统类加载器将Student.class文件加载入内存,(并且在加载Student.class文件之前会先加载父类Object的字节码文件,最终Student的所有父类以及引用到的其他类都会被加载进内存),在加载时通过类的全限定名获取二进制字节流,将静态存储结构转化为运行时数据结构

在方法区创建的Class<Student>对象:

  1. 链接:

    1. 验证,确保字节流中的信息符合虚拟机规范
    2. 准备:为类变量在静态区中分配内存,设定默认初始化值
    3. 解析:
      1. 如果在类加载时遇到引用了其他类,JVM是无法判断其他类是否被加载入内存的,此时JVM使用符号引用代表该类;在解析阶段会将符号引用替换为直接引用
      2. JVM针对类或接口、字段、方法等内容进行解析,方法信息会形成虚方法表 vtable
  2. 初始化:静态变量赋值以及初始化其他资源

  3. new开辟内存空间,所有成员变量默认初始化

  4. 构造方法执行

    1. 调用父类Object的无参构造,无参构造中对Object特征(继承来的变量)中的变量进行显式、构造初始化并执行其他操作

    2. 执行本类的显式、构造初始化

    3. 执行其他操作,本例无

实例变量显式赋值的操作只有构造方法执行的时候才会进行,类加载的时候并不会初始化实例变量的空间,那是因为实例变量是对象级别的变量。

带有继承结构的对象创建

public class A{
 	int a = 0;   
    public A(){
        this.a = 10;
    }
}
class B extends A{
    long b;
    public B(){
        this.b = 20;
    }
}

类加载

类A对象:

OFF SZ TYPE DESCRIPTION     VALUE
0   8  (object header:mark)  N/A
8   4  (object header:class) N/A
12  4  int A.a               N/A
Instance size:16 bytes

类B对象:

OFF SZ TYPE DESCRIPTION     VALUE
0   8  (object header:mark)  N/A
8   4  (object header:class) N/A
12  4  int A.a               N/A
16  8  long B.b
Instance size:24 bytes

这里有一个细节,A类和B类中成员变量a 的偏移量都是12,可以保证下面代码的顺利执行:

public void test(A x){
    x.a = 10;
}

此处参数可能是类A,也可能是类B;都可以根据相同的偏移量找到此成员变量

注意:此处B中的a是继承自A.a

分配空间

当执行new B()时,可以根据类B对象的内存空间,申请24字节的空间

  • 0-7 存储MarkWord
  • 8 – 11 存储类指针
  • 12 – 15 存储 A.a
  • 16 – 23 存储 B.b

调用构造

  • 执行父类构造时,会执行 this.a = 10
  • 执行子类构造时,会执行 this.b = 20

this.a寻址、赋值过程:

  1. 通过this指针找到刚才的对象B
  2. 根据B对象中的ClassWord找到类B,进而找到类B的父类:类A
  3. 确定A.a OFFSET = 12
  4. this偏移12 存入常数10,写入四个字节

this.b寻址、赋值过程:

  1. 先通过this指针找到对象B
  2. 再根据B对象中的ClassWord找到类B
  3. 确定B.b成员变量的偏移量是16
  4. this偏移16存入常数20,写入八个字节

super

  • 在属性隐藏使访问父类成员变量a
super.a = 30;
((A)this) = 30;

字节码文件相同,都是:

aload_0
bipush   30
putfield  #7   //Field test1/A.a:I

就是找A.a的偏移量而已,结果是12

  • 访问子类中的a
this.a = 40;
((B)this).a = 40;

字节码文件相同:

aload_0
bipush   40
putfield  #11  //Field a:I

就是查找B.a的偏移量而已

结论

  • 对象.成员变量

根据类型定位成员变量的偏移量,最后读取或写入

  • 对象.成员方法(参数)

根据对象找到类型,根据类型查找vtable表,执行方法调用(非static、final、构造方法);

本质上是: 对象.成员方法(类型 this,参数)

对象本无方法。

面试题

public class Demo {
    public static void main(String[] args) {
        B b = new B();
    }
}
class A{
    int a = 10;

    public A() {
        print();
    }

    public void print() {
        System.out.println(a);
    }
}
class B extends A{
    int a = 20;
    public B() {
        print();
    }

    public void print() {
        System.out.println(a);
    }
}

对该代码进行分析:

但是得到的结果是 0 20,并没有出现预想的20 20

在print方法中,输出时隐式调用了this(也就是B类的a),而第一个0在super期间输出,第二个20在super结束后输出;这也就说明了在super执行时,B类的显式初始化还没有完成,a的值是默认初始化的0;但是在B类构造方法中调用print()执行之前,a类的显式初始化完成,在super()和print()之间一定有隐藏的操作来完成显式初始化赋值。

使用javap -c -v .\A.class反编译A的字节码文件

 Last modified 2023年4月24日; size 506 bytes
  SHA-256 checksum 4d794e8649fca8fa19ce52c9d5ca941a0caabd2dc0f1bd00e480388d150f7e33
  Compiled from "Demo.java"
class BlockSearch.A
  minor version: 0
  major version: 61
  flags: (0x0020) ACC_SUPER
  this_class: #8                          // BlockSearch/A
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
{
  	int a;
    descriptor: I
    flags: (0x0000)

  public BlockSearch.A();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #7                  // Field a:I
        10: aload_0
        11: invokevirtual #13                 // Method print:()V
        14: return
      LineNumberTable:
        line 11: 0
        line 9: 4
        line 12: 10
        line 13: 14
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      15     0  this   LBlockSearch/A;

SourceFile: "Demo.java"

反编译字节码文件得出,在super后才进行显式初始化操作,也就是说在super当中访问到的a是在new分配内存空间时默认初始化赋值的a

这个问题的本质是:子类对实例变量的赋值初始化是在super执行之前完成还是执行之后完成。

从另一个角度理解,假设父类当中定义一个public变量name,子类与父类之间没有属性隐藏,父类对name显式初始化赋值zhangsan,子类构造方法对name进行构造方法初始化;如果子类构造方法初始化在父类显式初始化之前执行,父类给定的zhangsan一定会覆盖构造方法初始化的name值,也就说明了子类的构造方法初始化或显式初始化一定在super调用父类构造方法之后执行

对该结论进行测试:

public class Test {
    public static void main(String[] args) {
        new Son("lisi");
    }
}
class Father{
    String name = "zhangsan";

    public Father() {
        super();
        
        print();
    }

    public void print() {
        System.out.println("Father name " + name);
    }
}
class Son extends Father{
    public Son(String name) {
        super();
        this.name = name;
        print();
    }
    public void print() {
        System.out.println("Son name " + name);
    }
}
/*
Son name zhangsan
Son name lisi
*/

总结:

new关键字一共做了两件事:

  1. 分配内存空间
    1. 对象头
    2. 自己成员变量 + 继承的成员变量
    3. 对齐(8的整数倍)
  2. 调用构造方法

构造方法一共做了三件事:

  1. 调用父类构造方法
  2. 进行显式初始化、构造方法初始化操作
  3. 进行其他操作(print)

执行过程:

  1. new 分配内存空间,对所有成员变量默认初始化
  2. 执行B构造方法:super 调用父类A构造方法,A类构造方法进行
    1. super
    2. 对本类成员变量显式、构造初始化
    3. 执行其他操作:调用子类B的print方法,打印子类成员变量b
  3. 执行B构造方法:对本类成员变量进行显式初始化、构造初始化
  4. 执行B构造方法:执行其他操作

面试题引申

public class FuTest {  
	public static void main(String[] args) {  
		// 猜猜打印的内容  
		Zi zi = new Zi();  
	}  
	static class Fu {  
		int a = 10;  
		private void printA() {  
			System.out.println("Fu PrintA:" + this.a);  
		}  
		public Fu() {  
			this.printA();  
		}  
	}  
	  
	static class Zi extends Fu {  
		int a = 20;  
		public void printA() {  
			System.out.println("Zi PrintA:" + this.a);  
		}  
		public Zi() {  
			this.printA();  
		}  
	}  
}

将父类的printA方法改为private,输出的结果是什么?

Fu PrintA:10
Zi PrintA:20

父类构造方法this.printA调用的是本类中私有的构造方法,父类构造方法中隐含的参数:

public Fu(Fu this = new Zi()) {  
	this.printA();  
}  

可以调用成功,虽然子类无法继承private的方法,但是方法调用的流程是:

根据方法头绑定方法体执行,在编译阶段不需要绑定,编译器认为父类的构造方法调用的就是父类自己的printA方法,但是实际执行时,编译器会判断this是否指向了子类对象,如果是子类对象,再判断子类对象有没有覆盖这个方法。但是private修饰的方法不会参与这个过程,private修饰的方法在编译器就已经确定直接引用。

在[[第8章 虚拟机字节码执行引擎#动态分派]]中,有更深入的讲解

查看对象的内存结构

package test1;
public class A {
    int a;
    public A() {
        this.a = 0xaaaa;
    }
}

package test1;
public class B extends A{
    long b;
    public B() {
        this.b = 0xbbbb;
    }
}

import org.openjdk.jol.vm.VM;

public class Test1 {
    public static void main(String[] args) {
        B b = new B();
        System.out.println(Long.toHexString(VM.current().addressOf(b))); //返回Java对象的内存地址
        System.out.println();
    }
}

输出的711370ee0就是B对象在Java虚拟机中的内存地址

使用jhsdb hsdb打开图形界面

输入进程号,通过JPS查看进程号:

输入19272,打开Memory Viewer:

输入内存地址:

查看类型指针对应的类布局:

但是注意:直接输0x00c00c00会报错:

因为该地址是被压缩的,需要加一个偏移量:

static

以static关键字修饰的都是类级别的,都采用类名.的方式进行访问(静态变量、静态方法)

方法体内声明的是局部变量,方法体外的变量是成员变量,成员变量可以分为:实例变量/静态变量

静态、实例、构造方法运行的时候都会入栈; 但是静态变量在类加载的时候就进行初始化,也就是说静态变量的内存空间在不需要new对象就开辟出来了,静态变量储存在堆内存的静态区。

类加载阶段:为静态变量分配内存空间,默认初始化,准备阶段:对静态变量进行初始化

在JDK8之后,静态存储位置(静态区)挪到了堆内存当中:

空引用在访问静态方法/变量时不会产生空指针异常

c1 = null;
c1.doSome();

实际上c1还是看作类Chinese,静态变量不需要对象,编译器在检测到2行代码时直接把c1翻译为Chinese(也就是说明了:引用所指向的对象并没有参与)

静态方法是解析调用的,字节码是invokestatic,只与静态类型有关

静态相关的都不需要new对象,直接采用类名.访问(也可以采用引用.访问,但不建议)

实例相关的都需要new对象,必须采用引用.访问

工具类

可以做一些事情,但是不描述任何事物的类

特点:

  • 构造方法私有化
private 类名(){
    
}

工具类不是描述事物的,创建该类对象没有任何意义

  • 方法都是静态方法

定义数组工具类

编写工具类:ArrayUtil

  • 提供方法:printArr(int[] arr),用于返回字符串类型的数组内容
  • 提供方法:getAverage(double[] arr),用于返回数组平均值
  • 定义测试类,调用该工具类的工具方法,返回结果
public class ArrayUtil {
    private ArrayUtil() {
    }
    public static String printArr(int[] arr){
        StringJoiner joiner = new StringJoiner(", ","[","]");
        for (int i = 0; i < arr.length; i++) {
            joiner.add(String.valueOf(arr[i]));
        }
        return joiner.toString();
    }
    public static double getAverage(double[] arr){
        double sum = 0;
        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }
        return sum / arr.length;
    }
}

代码块

静态代码块

语法:

static{
	code;
}

随着类加载而加载,只执行一次

一般用来进行数据初始化

静态代码块在类加载的时候执行(在main方法之前执行),并且只执行一次;这种语法机制提供了一个类加载时机; 例如要求在类加载的时候解析某个文件并且只解析一次,那么此时就可以把解析该文件的代码写在静态代码块当中了。

public class StaticTest01 {
    static int i = 100;
    static {
        System.out.println(i);  //此处可以访问i
    }
    
    int k = 10;
    static {
        System.out.println(k); /*此处报错原因:k是实例变量,在构造方法执行的时候内存空间才会开辟*/
    }
    
    static {
        System.out.println(name);/*静态变量定义在静态代码块下方会报错:非法前向引用
           因为静态代码块和静态变量都是在类加载的时候执行,只能靠代码先后顺序决定谁先谁后*/
    }
    static String name = "liHua";
    
    public static void main(String[] args) {
    }
}
  1. 方法体中的代码是有顺序要求的,自上而下执行
  2. 静态代码块1和静态代码块2是有先后顺序要求的
  3. 静态代码块和静态变量是有先后顺序要求的

引例:

public class test {
    static int value = 9;

    public static void main(String[] args) {
        new test().printValue();
    }
    public void printValue(){
        int value = 99;
        System.out.println(this.value);
    }
}

输出的结果是9

  1. 两个value处在不同的域当中:一个在类体域,一个在方法体域,编译不会报错(在同一个域中变量不能重名)
  2. 输出的是this.value 也即是当前对象(类)中的value

输出判断

public class Text {
    public static int k = 0;
    public static Text t1 = new Text("t1");
    public static Text t2 = new Text("t2");
    public static int i = print("i");
    public static int n = 99;
    public static int j = print("j");

    static {
        print("静态块");
    }

    public Text(String str) {
        System.out.println((++k) + ":" + str + " i=" + i + " n=" + n);
        ++i;
        ++n;
    }
    public static int print(String str){
        System.out.println((++k) + ":" +str + " i=" + i + " n=" + n);
        ++n;
        return ++i;
    }

    public static void main(String[] args) {
        new Text("init");
    }
}
/*
1:t1 i=0 n=0
2:t2 i=1 n=1
3:i i=2 n=2
4:j i=3 n=99
5:静态块 i=4 n=100
6:init i=5 n=101*/

局部代码块

可以提前结束变量的生命周期

public class Test {
    public static void main(String[] args) {
        {
            int a = 10;
            System.out.println(a);
        }
    }
}

但是现在是用不到的,这个机制只是为了节省内存

实例代码块

实例代码块提供了 对象构造时机; 调用构造方法时实例代码块先执行

{ //实例语句块
    code1;
    code2;
    code3;
}

可能在一个类中有很多构造方法,而这些构造方法前几行都是相同的,就可以把这些相同的代码写在实例代码块中

public class ConstructorTest01 {
    public static void main(String[] args) {
        new ConstructorTest01();
        new ConstructorTest01("str");//但是这两个对象并没有引用指向,在结束之后就被GC回收了
    }
    {
        System.out.println("实例代码块执行");  //每次构造方法执行之前,实例代码块先执行
    }
    public ConstructorTest01(String str) {
        System.out.println("有参构造执行");
    }

    public ConstructorTest01() {
        System.out.println("无参构造执行");
    }
}
  • 重写实例代码块
    Student s1 = new Student(1, "小亮", 99);  
    Student s2 = new Student(2, "小勇", 85);  
    Student s3 = new Student(3, "小响", 90);  
    Student s4 = new Student(4, "小强", 89);  
  
    new ArrayList<Student>(){{  
        add(s1);  
        add(s2);  
        add(s3);  
        add(s4);  
    }}.stream().sorted(Comparator.comparingInt(Student::getScore)).forEach(System.out::println);  
}

封装

对象代表什么,就要封装对应的数据,并且提供数据对应的行为

需求:人画圆

public class Person{
    
}
class Circle{
    double radius;
    public void draw(){
        System.out.println("radius" + radius);
    }
}

封装在Circle类当中,实际上是因为圆是Circle类画的,只是人调用了Circle类的draw方法

需求:人关门

门是门自己关的,人只是给了门一个作用力

public class Door{
    boolean flag = true;// 门的状态
    public void open{
        //开门
    }
    public void close{
        //关门
    }
}

JavaBean

  • 用来描述一类事物的类,专业叫做:Javabean类;在Javabean类中是不写main方法的
  • 编写main方法的类叫做 测试类

IDEA插件:PTG

可以生成无参、全参构造方法、get、set、toString方法

数据和数据业务相分离

实体类的对象只负责数据存取,对数据的处理交给其他类的对象来完成。

全类名也叫全限定名,可以通过import简化:

  • 使用同一个包中的类,不需要导包
  • 使用java.lang包中的类,不需要导包
  • 其他情况都需要导包
  • 如果同时使用两个包中的同名类,需要使用类全名

继承

假如我们要定义如下类:
学生类,老师类和工人类,分析如下。

  1. 学生类
    属性:姓名,年龄
    行为:吃饭,睡觉

  2. 老师类
    属性:姓名,年龄,薪水
    行为:吃饭,睡觉,教书

  3. 班主任
    属性:姓名,年龄,薪水
    行为:吃饭,睡觉,管理

如果我们定义了这三个类去开发一个系统,那么这三个类中就存在大量重复的信息(属性:姓名,年龄。行为:吃饭,睡觉)。这样就导致了相同代码大量重复,代码显得很臃肿和冗余,那么如何解决呢?

假如多个类中存在相同属性和行为时,我们可以将这些内容抽取到单独一个类中,那么多个类无需再定义这些属性和行为,只要继承那一个类即可。如图所示:

其中,多个类可以称为子类,单独被继承的那一个类称为父类、超类(superclass)或者基类。

继承是子类继承父类的特征和行为,使得子类对象具有父类的属性,或子类从父类继承方法,使得子类和父类有相同的行为。继承符合的关系是 IS-A(Cat IS-A Animal),父类更通用,子类更具体(更强大),子类会具有父类的一般特性也会有自身的特性

继承概念下的IS-A是一个单向的关系;如果X IS-A Y是成立的话,隐喻着X可以完成Y所有的行为(并且可能做出更多的行为)

继承机制最基本的作用是解决了代码复用,还有两个很重要的作用是:有了继承才会衍生出方法的覆盖和多态机制。

继承相关特性

  • B类继承A类,A类称为父类、超类、基类,B类称为子类、派生类、扩展类
  • Java中的继承是单继承,不支持多继承,C++支持多继承
  • 虽然Java中不支持多继承,但是有时会产生间接继承的效果,例如:class C extends B,class B extends A,也就是说C直接继承B类,其实C间接继承A类
  • 如果一个类没有继承任何类,默认继承Object类
  • 继承会使得代码耦合度变高(组合是首选)
class X{} //继承Object
class Y extends X{} //Y继承X,Y间接继承Object
class Z extends Y{} //Z直接继承Y,Z间接继承X,Z间接继承Object

继承的意义:

  • 避免了重复的程序代码
  • 提出了共同的协议

继承:子类可以直接访问父类中的非私有的成员(成员变量、成员方法)。

继承强调的是一种关系,通过继承关系,在子类中可以访问父类的实例变量(非私有的)、实例方法。对于对象来说,因为继承关系的存在JVM在创建子类对象时会将父类的成员变量和子类的成员变量一同初始化并保存在子类对象中。

继承中对象和类是两个概念,对象是由类实例化出来的,自然会包含子类和父类的所有实例变量,而类间的继承只是产生了某种关联,在子类中可以访问父类中定义的成员变量、成员方法,权限修饰符和继承原本没有关系,只是因为private限制了修饰的成员只能在本类中访问,所以才说private修饰的成员无法被继承。

在对象实例化之后,super调用父类构造方法,父类中的private成员也会被实例化,保存至子类对象的父类型特征中,所以子类对象的空间会包含所有的成员变量。

在Java中,当子类调用父类的字段时,JVM使用继承关系和字段访问规则来确定要访问的字段。以下是JVM如何根据继承关系找到对应的字段的一般步骤:

  1. 类加载:当类加载器加载子类和父类的类文件时,它会构建类的内部数据结构,包括字段信息。这些字段信息包括字段的名称、类型和访问修饰符。

  2. 继承关系:JVM维护一个继承层次结构,其中子类包含对其父类的引用。这个关系是通过类的字节码文件中的特殊标记来建立的。

  3. 字段查找:当子类需要访问一个字段时,JVM首先查看子类自身的字段列表。如果在子类中找到匹配的字段,JVM将使用该字段。

  4. 向上搜索:如果子类没有匹配的字段,JVM会向上搜索继承层次结构,查看父类中是否有匹配的字段。这个搜索过程会递归进行,直到找到匹配的字段或达到继承层次的根(通常是java.lang.Object)。

  5. 访问权限检查:一旦找到匹配的字段,JVM还会检查字段的访问修饰符,确保子类有权限访问该字段。私有字段和一些其他受保护的字段可能无法被子类直接访问。

总之,JVM会根据继承关系和字段查找规则来确定要访问的字段。这使得子类能够继承和访问父类的字段,同时也可以在子类中定义自己的字段。

JVM虚拟机规范规定的字段访问步骤:

Java虚拟机规范(Java Virtual Machine Specification)详细描述了Java虚拟机的工作原理,包括继承和字段查找的规则。具体来说,它定义了以下几个步骤来查找字段:

  1. 查找字段符号引用:当在Java字节码中遇到对字段的引用时,首先需要解析字段符号引用,它包括字段的类名、字段名称和字段描述符。

  2. 类加载:虚拟机首先加载该字段所属的类。如果该类还没有加载,虚拟机会使用类加载器加载它。加载过程会包括类初始化,但不会导致该类的实例化。

  3. 查找字段:虚拟机在类的字段表中查找匹配字段名称和描述符的字段。这个搜索从子类开始,然后向上遍历继承层次结构,一直到Object类。

  4. 访问权限检查:一旦找到了匹配的字段,虚拟机会检查字段的访问修饰符。如果字段是private的或者访问权限不足,虚拟机将拒绝访问。

这些步骤确保了Java虚拟机在继承关系中正确查找字段,并根据访问权限进行访问控制。这些规则在Java语言中的继承机制中起到关键作用,确保了代码的正确性和安全性。这些规则可以在Java虚拟机规范的章节中找到,特别是在”Field Resolution”和”Field Access”等章节中有详细描述。

JVM虚拟机规范规定的方法访问步骤:

对于invokestatic、invokespecial来说,在编译阶段就确定了唯一的方法引用,对应了静态方法、私有方法、final方法、父类方法、构造方法

当子类调用父类的方法时,JVM使用继承关系和方法查找规则来确定要调用的方法。以下是JVM如何根据继承关系找到对应的方法的一般步骤:

  1. 类加载:当类加载器加载子类和父类的类文件时,它会构建类的内部数据结构,包括方法信息。这些方法信息包括方法的名称、参数列表和访问修饰符。

  2. 继承关系:JVM维护一个继承层次结构,其中子类包含对其父类的引用。这个关系是通过类的字节码文件中的特殊标记来建立的。

  3. 方法查找:当子类需要调用一个方法时,JVM首先查看子类自身的方法列表。如果在子类中找到匹配的方法,JVM将使用该方法。

  4. 向上搜索:如果子类没有匹配的方法,JVM会向上搜索继承层次结构,查看父类中是否有匹配的方法。这个搜索过程会递归进行,直到找到匹配的方法或达到继承层次的根(通常是java.lang.Object)。

  5. 方法覆盖:如果找到匹配的方法,JVM还会检查方法是否被子类覆盖(Override)。如果子类覆盖了父类的方法,JVM将调用子类中的覆盖方法而不是父类的原始方法。

总之,JVM会根据继承关系和方法查找规则来确定要调用的方法。这使得子类能够继承和调用父类的方法,同时也可以在子类中覆盖这些方法,实现方法的多态性。

可以继承的内容

构造方法 实例变量 实例方法
不能 可以(private也可以) 可以(private除外)

Oracle官方文档说明:

A subclass does not inherit the private members of its parent class. However, if the superclass has public or protected methods for accessing its private fields, these can also be used by the subclass.
子类不能继承父类的私有属性,但是如果父类中公有的方法影响到了父类私有属性,那么这些方法是能够被子类使用的

但是,父类的任何成员变量都是会被子类继承下去的。实际上,private、public、protected和继承没有关系,他们对成员方法和变量的限制只是在成员的可见性上

public class BaseClass{
    // 私有动态数组成员,注意它是"private"的
    private Vector objects;
    
    public BaseClass(){
        objects=new Vector();
    }
    
    /**
     * 公有函数,向动态数组成员objects添加字符串
     * @param str
     */
    @SuppressWarnings("unchecked")
    public void addStr2Obs(String str){
        objects.add(str);
    }
    
    /**
     * 公有函数,打印objects中的元素
     */
    public void printAll(){
        for(int i=0;i<objects.size();i++){
            System.out.println("序号="+i+"\t元素="+objects.get(i));
        }
    }
}

ChildClass,BaseClass的派生类:

/**
 \* ChildClass,BaseClass的派生类
 */
public class ChildClass extends BaseClass{
  public void printObjects(){
    // 下面是不能编译通过的
    /*for(int i=0;i<objects.size();i++){
      System.out.println("序号="+i+"\t元素="+objects.get(i));
    }*/
  }

  public static void main(String[] args){
    ChildClass childClass=new ChildClass();
    
    childClass.addStr2Obs("Hello");
    childClass.addStr2Obs("World");
    childClass.addStr2Obs("China");
    childClass.addStr2Obs("sitinspring");
    
    childClass.printAll();
  }
}

把断点停在main函数中的childClass.printAll()上:

证明:objects确实是ChildClass类实例childClass的成员,而且四个字符串也都被加进去了。

某些书中关于private限制的成员变量是这样写的:private 只允许来自该类内部的方法访问,不允许任何来自该类外部的访问。

上面添字符串和遍历输出方法都是BaseClass的成员,所以它当然被这两个方法访问;而ChildClass的printObjects是BaseClass类外部的方法,结果当然是编译也不能通过。

实际上,private、public、protected和继承没有关系;他们对成员方法和变量的限制只是在成员的可见性上。

  • public允许来自任何类的访问;
  • private只允许来自该类内部的方法访问,不允许任何来自该类外部的访问;
  • protected允许来自同一包中的任何类以及该类的任何地方的任何子类的方法访问;

而关于成员变量的继承,父类的任何成员变量都是会被子类继承下去的,私有的objects就是证明。这些继承下来的私有成员虽对子类来说不可见,但子类仍然可以用父类的方法操作他们.

这样的设计有何意义?

我们可以用这个方法将我们的成员保护得更好,让子类的设计者也只能通过父类指定的方法修改父类的私有成员,这样将能把类保护得更好。这对一个完整的继承体系是尤为可贵的,JDK源码就有这样的例子:java.util.Observable就是这样设计的。

实例变量继承的内存图

  • 先加载父类字节码,再加载子类字节码
public class Fu {
    String name;
    int age;
}
class Zi extends Fu{
    String game;
}
class Test{
    public static void main(String[] args) {
        Zi z = new Zi();
        System.out.println(z);
        z.name = "zhangsan";
        z.age = 23;
        z.game = "Feria";
        System.out.println(z.name + " ," + z.age + " ," + z.game);
    }
}

实例方法继承的内存图

虚方法表在类加载的链接-解析阶段创建

在继承链中,最顶级的类会创建一个虚方法表,该表中包含了可能会经常使用到的方法,这些方法有以下要求:

  • 非private
  • 非static
  • 非final

然后将虚方法表交给下级类,下级类在此方法表的基础上再添加自己类中的虚方法,一直类推

查找A类的虚方法表,如果有c方法直接加载该方法执行。

如果要通过A类实例调用的C类中方法不是虚方法,还是会逐层向上查找。

public class Fu {
    public void fuShow1() {
        System.out.println("public --- FuShow1");
    }

    private void fuShow2() {
        System.out.println("private --- FuShow2");
    }
}

class Zi extends Fu {
    public void ziShow(){
        System.out.println("public --- ZiShow");
    }
}

class Test {
    public static void main(String[] args) {
        Zi z = new Zi();
        System.out.println(z);
        z.ziShow();
        z.fuShow1();
        z.fuShow2();/*'fuShow2()' has private access in 'Fu'*/
    }
}

此时字节码文件才加载完毕

调用时先判断是否是虚方法,如果是虚方法就会从虚方法表中直接调用;

是虚方法,从子类的虚方法表中直接拿到该方法加载进内存

final、static的方法虽然不能被添加到虚方法表中,但是还是可以继承的;只是在访问的时候:

  • static:引用被翻译为类名,还是通过类名访问(不存在空指针异常)
  • final:不能被添加到虚方法表中,如果在子类中访问父类中定义的final方法,需要一层一层向上查找

private的实例方法的继承问题

子类是可以继承父类的所有属性和方法的,包括private。

但是这里的继承实际上是 “拥有”,并非是 “可调用的”;private、public修饰符针对的是可调用的作用域范围。

package demo07.test;

public class Father
{
    private void method()
    {
        System.out.println("父类private的方法");
    }

    public static void main(String[] args)
    {
        Father father = new Son();
        father.method(); //父类private的方法
    }
}


package demo07.test;
public class Son extends Father
{

}

运行是可以通过的,并且输出:父类private的方法

这是[[005-面向对象核心#面试题引申|面试题引申]]的简化版本,这样做就是在父类中特有,而子类中没有的方法。

静态绑定:编译器认为father的Father类型中包含了method方法,编译通过;而private不参与动态绑定,在编译期就确定了直接引用。

字节码文件:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #21                 // class demo07/test/Son
         3: dup
         4: invokespecial #23                 // Method demo07/test/Son."<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #24                 // Method method:()V
        12: return
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 19: 12
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  args   [Ljava/lang/String;
            8       5     1     f   Ldemo07/test/Father;
  
#24 = Methodref          #25.#26        // demo07/test/Father.method:()V

分析

多态是不能调用子类中特有的方法的,如果要调用,就必须向下转型。如果是父类中特有,而子类中没有的方法呢?(根据Java官方文档我们知道这是不能做到的),在此之前的想法是,只要把父类中的方法用private修饰,子类就不会继承了。猜测:编译可以通过,运行会报错。

测试:

package demo07.test;
//父类,有一个private修饰的方法
public class Father{
    private void method(){
        System.out.println("父类private的方法");
    }
}

package demo07.test;
//子类,什么都没写
public class Son extends Father{

}

测试类:

package demo07.test;
//测试类
public class Test01
{
    public static void main(String[] args)
    {
        Father father = new Son();
        father.method();//报错:'method()' has private access in 'demo07.test.Father'
    }
}

但是,private的方法在其他类(测试类)中无法调用,在测试类中是访问不到的,编译会报错

只对本类可见,就将main方法放在父类中测试:

package demo07.test;

public class Father
{
    private void method()
    {
        System.out.println("父类private的方法");
    }

    public static void main(String[] args)
    {
        Father father = new Son();
        father.method();
    }
}

这样就不会报错了,但是程序可以运行,并且输出:父类private的方法

子类中明明没有这个方法,为什么可以运行成功?(问题1

原因可能在于访问权限修饰符,父类这个private修饰的method方法,其实是被子类继承下去了的,只是对子类不可见。但是还是有个问题:在父类的main方法中调用了父类的method方法的,private可以对本类可见,所以没有问题。但是:运行的时候,是在父类中运行 “子类的” private修饰的方法,那也应该不能成功运行才对,但实际上却成功运行了,这又是为什么?(问题2

问题2的意思是:在父类中,为什么可以调用子类的private方法?private限定了只能在本类中访问,那么在父类中理应不能进行调用;实际上这个问题可以变为:父类中一个方法被子类继承下去,那么这个方法的权限修饰符是针对哪一个类而言的?

访问权限修饰符表:

访问控制修饰符 本类 同包中其他类 不同包的子类 不同包的无关类
public 可以 可以 可以 可以
protected 可以 可以 可以 不行
可以 可以 不行 不行
private 可以 不行 不行 不行

如果父类中有一个protected修饰的方法被子类继承下去了,那么这些本类、同包、子类是针对父类而言还是针对子类而言?

假如父类子类不在同一个包当中,被继承的方法是protected修饰的,如果是针对父类而言的,在子类所在包下建立测试类,创建子类对象,应该是不能通过子类对象调用这个方法的,因为这个方法和父类不在同一个包当中。而在父类所在的包下建立测试类,创建子类对象(父类和子类是一样的),就可以访问这个方法

测试(注意所在包):

package test;
//test包下的父类
public class Father{
    protected void method(){
        System.out.println("父类protected修饰的方法");
    }
}
package test.test;
import test.Father;
//test.test包下的子类,什么都没写,但是继承了test包下的父类
public class Son extends Father{

}
  • 在子类的包下建立测试类:
package test.test;
//test.test包下的测试类(和子类同包)
public class Test01{
    public static void main(String[] args){
        Son son = new Son();
        son.method();//报错:'method()' has protected access in 'test.Father'
    }
}

和猜测相同,不能访问

  • 在父类的包下建立测试类:
package test;
import test.test.Son;
//test包下的测试类(和父类同包)
public class Test02{
    public static void main(String[] args){
        Son son = new Son();
        son.method();
    }
}

可以访问

结论:在继承关系中,子类从父类继承的方法,它的访问权限修饰符是针对父类而言的

如果子类重写了这个方法,访问权限修饰符就是针对子类而言的

访问权限修饰符

  • private表示私有的 只能在本类中访问
  • public表示公开的 任何位置都能访问
  • default表示只能在本类、同package下访问
  • protected表示只能在本类,同package,子类中访问
访问控制修饰符 本类 同包中其他类 不同包的子类 不同包的无关类
public 可以 可以 可以 可以
protected 可以 可以 可以 不行
可以 可以 不行 不行
private 可以 不行 不行 不行

如果一个方法是多个方法共性内容的抽取,这个方法一般设置为私有的

protected修饰符:

在不同包下的子类当中,是可以访问父类的protected修饰的doSome()方法的,因为这个方法一定是子类对象调用的(隐含参数this),protected修饰的方法只能在本类、子类、同包中访问

在子类的main方法中,可以通过子类对象访问到子类的doSome();protected的含义是指子类可以访问,说的是子类直接访问父类的protected方法,而不是说子类中,可以调用父类的对象访问父类的protected方法;

虽然是在子类中,但是却是使用父类的对象,调用父类的protected方法。这是在不同包中访问该protected方法,这样是不可行的

在子类中新建子类对象访问doSome()方法,JDK应该有某种机制可以检测到这是在本类中访问;但是在子类中新建父类对象访问这个方法,就无法被检测到。

f.doSome() 失败,因为在编译阶段,对doSome方法进行静态绑定,查找静态类型Father的虚方法表(访问的是虚方法),可以找到doSome方法,但是访问权限验证不通过,对于Father的doSome方法来说,此时是在类外、包外访问

s.doSome() 成功,编译阶段,对doSome方法进行静态绑定,查找静态类型Son的虚方法表(访问的是虚方法),可以找到doSome方法,权限验证成功,此时的访问是本类中进行访问

如果子类与父类同包,这样的访问就是没有问题的:

这样对于父类来说就是在同包下访问

在子类中访问父类的protected方法

如果在子类中直接访问父类的protected方法是没有问题的,如果在子类中创建父类的对象访问这个方法就不可以了(非同包下)

protected修饰的成员变量和方法可以被包外的子类访问到,被包外的子类访问并不是可以在子类中创建父类的对象进行访问,而是在子类中直接进行访问(也就是隐含的this进行访问)

在本类中可以访问,修饰的方法会交给子类的虚方法表,通过引用可以在同包中调用该方法

Object虚方法表

对继承自Object的方法进行测试

注意:当源码中一个方法以;结尾并且修饰符列表中有native关键字表示底层调用C++写的dll程序(dll动态链接库文件)

Object类中的toString方法:

 public String toString() {
        return this.getClass().getName() + "@" + Integer.toHexString(this.hashCode());
    }

测试:

public class ExtendsText01 {
    public static void main(String[] args) {
        ExtendsText01 ex = new ExtendsText01();
        System.out.println(ex);//ExtendsText01@2d98a335
    }
}
  • 2d98a335是随机数经过哈希算法得到的十六进制结果
  • 如果println输出一个引用的话,会自动调用该引用的toString方法

对于hashCode()方法:

openjdk中查看C++程序:

static inline intptr_t get_next_hash(Thread* current, oop obj) {
  intptr_t value = 0;
  if (hashCode == 0) {
    // This form uses global Park-Miller RNG.
    // On MP system we'll have lots of RW access to a global, so the
    // mechanism induces lots of coherency traffic.
    value = os::random(); //1. 随机数作为hashCode
  } else if (hashCode == 1) {
    // This variation has the property of being stable (idempotent)
    // between STW operations.  This can be useful in some of the 1-0
    // synchronization schemes.
    intptr_t addr_bits = cast_from_oop<intptr_t>(obj) >> 3; //读取内存地址
    value = addr_bits ^ (addr_bits >> 5) ^ GVars.stw_random; //2. 内存地址结合随机数运算作为hashCode
  } else if (hashCode == 2) {
    value = 1;            // for sensitivity testing  3. 固定为1
  } else if (hashCode == 3) {
    value = ++GVars.hc_sequence;  // 4. 自增的序列作为hashCode
  } else if (hashCode == 4) {
    value = cast_from_oop<intptr_t>(obj); // 5. 对象的内存地址作为hashCode
  } else {
    // Marsaglia's xor-shift scheme with thread-specific state
    // This is probably the best overall implementation -- we'll
    // likely make this the default in future releases.
      
    //6. 随机数算法
    unsigned t = current->_hashStateX;
    t ^= (t << 11);
    current->_hashStateX = current->_hashStateY;
    current->_hashStateY = current->_hashStateZ;
    current->_hashStateZ = current->_hashStateW;
    unsigned v = current->_hashStateW;
    v = (v ^ (v >> 19)) ^ (t ^ (t >> 8));
    current->_hashStateW = v;
    value = v;
  }

  value &= markWord::hash_mask;
  if (value == 0) value = 0xBAD;
  assert(value != markWord::no_hash, "invariant");
  return value;
}

默认采用第6种 随机数算法

hashCode选项

  • hashCode == 0 随机数
  • hashCode == 1 地址^随机数
  • hashCode == 2 固定值1
  • hashCode == 3 递增数列
  • hashCode == 4 地址
  • hashCode == 5 随机数

使用参数

  1. -XX:+UnlockExperimentalVMOptions 解锁实验性质的虚拟机选项
  2. -XX:hashCode=? 指定hashCode生成方式

对象刚创建时hashCode是不存在的,第一次调用对象的hashCode方法时产生hashCode值,采用第六种策略计算出该值并存入对象的MarkWord部分,之后每次调用都从MarkWord部分读取

继承后的执行顺序

public class FuTest {

    public static void main(String[] args) {
        // 0:发现要new Zi,而此时内存中没有Zi这个类,而Zi又继承了Fu,所以会先加载 Fu、再加载 Zi(注意,此时只是类加载!)
        // 5:【类加载并初始化】完毕,开始【对象创建和初始化】
        Zi zi = new Zi();
    }

    static class Fu {
        // 类加载1:加载Fu,给Fu的静态字段默认初始化
        static int FU_STATIC_A = 10;

        static {
            // 类加载2:调用static代码块,给Fu静态字段初始化
            FU_STATIC_A = 11;
        }

        // 对象初始化8:初始化fu普通字段
        int a = 10;

        public void printA() {
            System.out.println("Fu PrintA:" + a);
        }

        public Fu() {
            // 对象初始化7:调用fu构造器
            printA();
        }
    }

    static class Zi extends Fu {
        // 类加载3:加载Zi,给Zi的静态字段默认初始化
        static int ZI_STATIC_A = 20;

        static {
            // 类加载4:调用static代码块,给Zi静态字段初始化
            ZI_STATIC_A = 21;
        }

        // 对象初始化9:初始化zi普通字段
        int a = 20;

        @Override
        public void printA() {
            System.out.println("Zi PrintA:" + a);
        }

        public Zi() {
            // 对象初始化6:优先初始化父对象
            super();
            // 对象初始化9:zi构造器执行完毕
            printA();
        }
    }
}

不希望类被继承的策略

  1. 存取控制:类不能被标记为私有的,但是类可以标记为不公开的,不公开的类只能被同一个包的类做出子类
  2. 使用final修饰符:表示这个类在继承树的末端
  3. 让类只有private的构造程序 ,提供静态方法传递该类对象

方法覆盖

从父类当中继承的方法不满足子类的使用需求时,就要考虑使用方法覆盖了;发生方法覆盖后子类对象会调用覆盖之后的方法

方法覆盖的条件:

  • 必须发生在具有继承关系的两个类之间;
  • 覆盖后的方法和原来的方法具有兼容的返回值类型,相同的方法名,相同的形式参数列表(子类的返回值类型必须是相同的类型或者是该类型的子类)
  • 只有虚方法表中的方法才可以被方法覆盖

注意:

  1. 私有方法无法被添加到虚方法表中,所以无法覆盖
  2. 构造方法无法被添加到虚方法表中,所以不能覆盖
  3. 覆盖之后的方法不能比原方法有更低的访问控制权限(可以从protected -> public)(不能在编译期间是公有的,然后在执行期间突然被JVM阻止)
  4. 覆盖之后的方法不能比原方法抛出更多/更宽泛的异常,可以相同或者更少,不能在编译期间显示异常被处理,运行期间显示未处理
  5. 方法覆盖只和方法有关,和属性无关
  6. 静态方法不存在覆盖【问题一】
  7. 只有被添加虚方法表中的方法才能被覆盖

例如:定义了一个动物类代码如下:

public class Animal  {
    public void run(){
        System.out.println("动物跑的很快!");
    }
    public void cry(){
        System.out.println("动物都可以叫~~~");
    }
}

然后定义一个猫类,猫类认为父类cry()方法不能满足自己的需求

代码如下:

public class Cat extends Animal {
    @Override
    public void cry(){
        System.out.println("我们一起学猫叫,喵喵喵!喵的非常好听!");
    }
}

public class Test {
	public static void main(String[] args) {
      	// 创建子类对象
      	Cat ddm = new Cat();
        // 调用父类继承而来的方法
        ddm.run();
      	// 调用子类重写的方法
      	ddm.cry();
	}
}

示例:

class Animal{
    void move(){
        System.out.println("Animal moving");
    }
    void sing(){
        
    }
}

要求子类Cat和Bird有不同的move方法:

class Cat extends Animal{
    @Override
    void move() {
        System.out.println("Cat wondering");
    }
}
class Bird extends Animal{
    @Override
    void move() {
        System.out.println("Bird flying");
    }
    void sing(int i){
        //此处sing和父类的sing没有发生方法覆盖,因为参数列表不同;但间接形成了方法重载
    }
}

@Override重写注解

  • @Override:注解,重写注解校验

  • 这个注解标记的方法,就说明这个方法必须是重写父类的方法,否则编译阶段报错。

  • 建议重写都加上这个注解,一方面可以提高代码的可读性,一方面可以防止重写出错!

    加上后的子类代码形式如下:

public class Cat extends Animal {
  @Override
  public void cry(){
	  System.out.println("我们一起学猫叫,喵喵喵!喵的非常好听!");
  }
}

方法覆盖的本质:覆盖虚方法表中的方法

如果B类型对象调用,就会查找B类的虚方法表

虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向了父类的实现入口。如果子类重写了这个方法,子类虚方法表的地址也会被替换为指向子类实现版本的入口地址。

当要覆盖父类的方法时,就必须要“履行约定”,比如这个约定是:

boolean trunOn(){}

这就表示:没有参数且具有布尔类型的返回值

字节码分析方法覆盖

方法覆盖就是子类对父类的实例方法进行重新定义功能,且返回类型、方法名以及参数列表保持一致,且对重写方法的调用主要看实际类型。实际类型如果实现了该方法则直接调用该方法。如果没有实现,从子类型的虚方法表中选择继承自父类的方法进行实现。

问题:为什么只有对实例方法才能重写?

理解三个概念:静态类型、实际类型、方法接受者

Person student= new Student();
student.work();
  • 静态类型就是编译器编译期间认为对象所属的类型,这个主要这个主要根据声明类型决定,所以上述Person就是静态类型
  • 实际类型就是解释器在执行时根据引用实际指向的对象所决定的,所以Student就是实际类型。
  • 方法接受者就是动态绑定所找到执行此方法的对象,比如student。

class文件中方法的字节码调用指令:

(1)invokestatic:调用静态方法
(2)invokespecial:调用实例构造器方法,私有方法。
(3)invokevirtual:调用所有的虚方法。
(4)invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
(5)invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

public class Father {  
	public void doSome(){  
		System.out.println("Father's non-static method");  
	}  
	  
	public static void doOther(){  
		System.out.println("Father's static method");  
	}  
}  
  
class Son extends Father {  
	public void doSome(){  
		System.out.println("Son's non-static method");  
	}  
	  
	public static void doOther(){  
		System.out.println("Son's static method");  
	}  
  
public static void main(String[] args) {  
	Father f = new Father();  
	Son s = new Son();  
	Father f_s = new Son(); // 父类引用指向子类对象  
	  
	f.doSome(); //Father's non-static method  
	f.doOther(); //Father's static method  
	s.doSome(); //Son's non-static method  
	s.doOther(); //Son's static method  
	f_s.doSome(); //Son's non-static method  
	f_s.doOther(); //Father's static method  
	}  
}

最后一次调用时,没有动态绑定,之前的解释是引用被翻译为类名访问。

main方法的字节码:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  // class demo01/Father
         3: dup
         4: invokespecial #1                  // Method demo01/Father."<init>":()V
         7: astore_1
         8: new           #23                 // class demo01/Son
        11: dup
        12: invokespecial #25                 // Method "<init>":()V
        15: astore_2
        16: new           #23                 // class demo01/Son
        19: dup
        20: invokespecial #25                 // Method "<init>":()V
        23: astore_3
        
        24: aload_1
        25: invokevirtual #26                 // Method demo01/Father.doSome:()V
        28: aload_1
        29: pop
        30: invokestatic  #29                 // Method demo01/Father.doOther:()V
        33: aload_2
        34: invokevirtual #32                 // Method doSome:()V
        37: aload_2
        38: pop
        39: invokestatic  #33                 // Method doOther:()V
        42: aload_3
        43: invokevirtual #26                 // Method demo01/Father.doSome:()V
        46: aload_3
        47: pop
        48: invokestatic  #29                 // Method demo01/Father.doOther:()V
        51: return

f.doSome() 这句话是调用父类的实例方法,对应字节码中25行的invokevirtual 调用虚方法,并且此方法的引用存在于方法表中,使用invokevirtual 指令会去方法表中寻找要调用的方法的引用。

f.doOther() 这句话是调用父类的静态方法,对应字节码30行的invokestatic 调用静态方法,无需经过方法表,这也就解释了静态方法的执行只看静态类型,与实际类型无关;而方法重写关注的是实际类型,所以静态方法不能被重写,这也解释了方法覆盖的问题1

f_s.doSome() 这句话是父类型引用调用子类的实例方法,对应字节码43行的invokevirtual 调用虚方法,说明运行期间会到方法表中去调用真实指向的方法,因为doSome可能被重写,所以编译期间应标明在在运行时调用doSome所在方法表中存储的真正方法的引用,因为doSome被Son重写,所以Son方法表中原先存储Father的doSome方法被替换为Son的doSome方法引用,运行时根据invokevirtual 调用的虚方法是子类重写的

f_s.doOther() 对应字节码48行invokestatic 不能访问方法表,运行时就是父类的静态方法

编译时把对象的静态类型作为方法接收者,运行时期再根据指令集更改

invokevirtual

public class ClassReference {  
	static class Person {  
		@Override  
		public String toString(){  
			return "I'm a person.";  
		}  
		public void eat(){  
			System.out.println("Person eat");  
		}  
		public void speak(){  
			System.out.println("Person speak");  
		}  
	}  
	static class Boy extends Person{  
		@Override  
		public String toString(){  
			return "I'm a boy";  
		}  
		@Override  
		public void speak(){  
			System.out.println("Boy speak");  
		}  
		public void fight(){  
			System.out.println("Boy fight");  
		}  
	}  
	static class Girl extends Person{  
		@Override  
		public String toString(){  
			return "I'm a girl";  
		}  
		@Override  
		public void speak(){  
			System.out.println("Girl speak");  
		}  
		public void sing(){  
			System.out.println("Girl sing");  
		}  
	}  
	public static void main(String[] args) {  
		Person boy = new Boy();  
		Person girl = new Girl();  
		System.out.println(boy);  //I'm a boy
		boy.eat();                //Person eat
		boy.speak();              //Boy speak
		System.out.println(girl); //I'm a girl
		girl.eat();               //Person eat
		girl.speak();             //Girl speak
	}  
}

Boy和Girl没有重写父类的eat方法,会调用父类的eat方法

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #7                  // class demo02/ClassReference$Boy
         3: dup
         4: invokespecial #9                  // Method demo02/ClassReference$Boy."<init>":()V
         7: astore_1
         8: new           #10                 // class demo02/ClassReference$Girl
        11: dup
        12: invokespecial #12                 // Method demo02/ClassReference$Girl."<init>":()V
        15: astore_2
        16: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
        19: aload_1
        20: invokevirtual #19                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
        23: aload_1
        24: invokevirtual #25                 // Method demo02/ClassReference$Person.eat:()V
        27: aload_1
        28: invokevirtual #30                 // Method demo02/ClassReference$Person.speak:()V
        31: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
        34: aload_2
        35: invokevirtual #19                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
        38: aload_2
        39: invokevirtual #25                 // Method demo02/ClassReference$Person.eat:()V
        42: aload_2
        43: invokevirtual #30                 // Method demo02/ClassReference$Person.speak:()V
        46: return

在16行,会将根类Object的toString方法引用写入class文件,编译器会将祖先的方法引用写入而非近亲

方法表在内存中的模型:

继承的方法从头到尾排列,方法在子类中有固定的索引,有相同的偏移量;如果子类重写某个方法,就会使子类方法表原先存在父类的方法引用变为重写后的方法引用,这就是为什么通过对象类型可以调用到正确的方法

girl.speak() 为例,解释INVOKEVIRTUAL 指令的流程

  1. 首先 invokevirtual demo02/ClassReference$Person.speak:()V 中,根据 demo02/ClassReference$Person.speak:()V 在常量池中查找该方法的偏移量
  2. 查看Person的方法表,得到speak方法在该方法表中的偏移量,得到该方法的直接引用
  3. 根据this实例判断该引用指向girl对象
  4. 在Girl方法表中,根据上面的偏移量在方法表中找到该方法引用,因为该方法引用的值在类加载时根据是否重写了方法已经确定了正确的方法引用,所以可以直接调用该方法

总结:在编译器将类编译为class文件时,方法会根据静态类型从而将对应的方法引用写入class中;运行时JVM会根据INVOKEVIRTUAL 所指向的方法引用在常量池中找到该方法的偏移量,再根据this找到引用类型所指向的真实对象,访问这个对象类型的方法表,根据偏移量找出存放目标方法引用的位置,取出这个引用,调用这个引用实际指向的方法,完成多态。

this

  • this是一个变量,一个引用 保存当前对象的内存地址,指向自身,严格来说this指向的就是当前对象
  • this只能用于实例方法当中,谁调用这个方法this就代表谁(实例方法一定是对象来触发的)
  • this不能出现在静态方法当中,因为静态方法不需要对象的参与,也就是说调用的时候不需要先创建对象,直接通过类名.的方式访问,而this代指当前对象,这两者是冲突的,也就是说静态方法不知道这个是哪个对象的成员变量
  • this大多数情况下都是可以省略的

public class Student {
    String name;
    int age;
    public void show(){
        System.out.println(name + ", " + age);
    }
}
public class Test {
    public static void main(String[] args) {
        Student s = new Student();
        s.name = "zhangsan";
        s.age = 23;

        s.show();

        //把对象在内存中的结构打印出来
        ClassLayout layout = ClassLayout.parseInstance(s); 
        System.out.println(layout.toPrintable());
    }
}

如果show方法添加三个局部变量,再查看字节码信息:

    public void show(){
        int a = 10;
        int b = 20;
        int c = 30;
        System.out.println(name + ", " + age);
    }

可以发现this只是一个局部变量

this的本质:所在方法调用者的地址值

以上两个age是不同的空间,age指的是局部变量,this.age是堆内存中的实例变量

this()

this在构造方法中的使用:

表示通过当前的构造方法调用本类中另外一个构造方法,格式如下:

this(args);

这个语法机制实现了:代码复用;

注意:this()只能出现在构造方法的第一行,并且只能使用一次。(否则会报错:对this的调用必须时构造器中的第一个语句)

实例方法的隐含参数this

public class Student {
    private String name;
    private int age;
    private String gender;
    
    public static String teacher;
    
    public void study(){
        System.out.println(this.name + "study");
    }
}

静态方法中没有this关键字,对于实例方法来说,形参列表是有一个隐藏的this:

    public void study(Student this){
        System.out.println(this.name + "study");
    }

但是注意:这个参数是在调用方法的时候由虚拟机对其赋值,不能手动赋值;谁调用这个方法,this就代表它的地址值

在study()方法中调用其他实例方法:

通过this找到堆内存中的对象,通过对象的ClassWord指针找到类对象,访问类对象的vtable来确认调用的是哪个方法

而对于静态方法来说:

就会直接报错

实例相关的数据都是与对象相关的,调用的时候必须找到对象(this的指向);而静态的数据是与对象无关的,是在类加载时就存在的,所以是没有this关键字的。

虽然不推荐使用引用去调用静态相关的数据,但是对于实例方法中访问静态数据,还是会用this去调用的:

继承中的this

内存

  • 静态方法不能调用实例变量:

静态方法是不会去对象内部查找该变量的,只会在静态区中查找

  • 静态方法不能调用实例方法:

调用show方法(实例方法)时,必须要对隐含的参数this进行传递,而静态方法中没有通过引用调用,JVM无法完成赋值操作。

  • 实例方法可以访问静态相关的数据:

self与this

以下是一段Python代码:

# 括号里的object,表示Student类继承自object。在Java里默认继承Object。当然,Python里不写也可以
class Student(object):

    # 构造函数,变量前面下划线,是访问修饰符,表示私有
    def __init__(self, name, age):
        self.__name = name
        self.__age = age

    # get方法,可不写。你会发现Python里方法形参都有self,其实就相当于Java里的this,只不过Java通常是隐式的
    def get_name(self):
        return self.__name

    def get_age(self):
        return self.__age

    def print_info(self):
        print("姓名:" + self.__name, "年龄:" + str(self.__age))


# 你可以将下面代码理解为Java的main方法,这里用来测试
if __name__ == '__main__':
    # 调用构造函数得到对象,Python不需要new关键字
    student = Student("eun", 18)
    # 实际调用方法时并不需要传self,会默认传递
    print(student.get_age())
    print(student.get_name())
    student.print_info()

对象的本质是“多个相关数据的统一载体”。比如一个人,有name、age、height等社会或生理体征,而这些数据是属于一个个体的,如果用数组去存,表现力有所欠缺,无法表达“它们属于同一个个体”的含义。

但在Java中对象是在堆空间中生成的,数据会在堆空间占据一定内存开销。而方法只有一份。

方法为什么被设计成只有一份?

因为多个个体,属性可能不同,比如我身高180,你身高150,我18岁,你30了。但我们都能跑、能跳、能吃饭,这些技能(method)都是共通的,没必要和属性数据一样单独在堆空间各存一份,所以被抽取出来存放。

此时,方法相当于一套指令模板,谁都可以传入数据交给它执行,然后得到执行后的结果返回。

但此时会存在一个问题:张三这个对象调用了eat()方法,应该把饭送到他嘴里,而不是送到李四嘴里。那么方法如何知道把饭送到哪里呢?

换句话说:共性的方法如何处理特定的数据?

Python的self、Java的this其实就是解决这个问题的。你可以理解为对象内部持有一个引用,当你调用某个方法时,必须传递这个对象引用,然后方法根据这个引用就知道当前这套指令是对哪个对象的数据进行操作了。

static与this

static修饰的属性或方法其实都是属于类的,是所有对象共享的。之所以一个变量或者方法要声明为static,是因为

  • static变量:大家共有的,大家都一样,不是特定的差异化数据
  • static方法:这个方法不处理差异化数据

也就是说,static注定与差异化数据无关,即与具体对象的数据无关。

以静态方法为例,当你确定一个方法只提供通用的操作流程,而不会在内部引用具体对象的数据时,你就可以把它定为静态方法。

一贯的解释都是上来就告诉你静态方法不能访问实例变量,再解释为什么,是倒着解释的。而上面这段话的出发点是,当你满足什么条件时,你就可以把一个方法定为静态方法。

比如在Python中定义了一个方法:

def simple_print(self):
    print("方法中不涉及具体的对象数据,啦啦啦啦~")

IDE发现你并没有操作具体的对象数据,是一个通用的操作,于是提醒你这个方法可以用static。

要解决这个警告,有两种方式:

  • 在方法中引用对象的数据,变成实例方法

  • 坚持不在方法内使用对象引用,但把它变成静态方法

发现:抽取成静态方法后,形参不需要self了,Python在调用这个方法时也不再传递当前对象,反正静态方法是不处理特定对象数据的

可以反过来解释为什么Java中静态方法无法访问非静态数据(实例字段)和非静态方法(实例方法):因为Java不会在调用静态方法时传递this,静态方法内没有this当然无法处理实例相关的一切。

我们在一个实例方法中调用另一个实例方法或者实例变量时,其实都是通过this调用的,比如

public void test(this){

System.out.println(this.name);

this.show();

}

只不过Java允许我们不显示书写。

示例

public class Demo {

    public static void main(String[] args) {
        /**
         * new一个子类对象
         * 我们知道,子类对象实例化时,会隐式调用父类的无参构造
         * 所以Father里的System.out.println()会执行
         * 猜猜打印的内容是什么?
         */
        Son son = new Son();

        Daughter daughter = new Daughter();
    }

}

class Father{
    public Father(){
        // 打印当前对象所属Class的名字
        System.out.println(this.getClass().getName());
    }
}

class Son extends Father {
}

class Daughter extends Father {
}

打印出子类Son、Daughter的名字。

子类实例化时会隐式调用父类的构造器,效果相当于这样:

class Father{
    /**
     * 父类构造器
     */
    public Father(){
        // 打印当前对象所属Class的名字
        System.out.println(this.getClass().getName());
    }
}

class Son extends Father {
    public Son() {
        // 显示调用父类无参构造
        super();
    }
}

调用构造器,其实也是调用方法,只不过构造器比较特殊。但可以肯定,这个过程中一定也会传递this。Python的构造器就是传递self:

# 构造函数,变量前面下划线,是访问修饰符,表示私有
def __init__(self, name, age):
    self.__name = name
    self.__age = age

本质和子类调用方法给父类传参一样的,只不过传参的过程很特殊:

  • new的时候自动传参,不是我们主动调用,所以感知不到
  • Java中的this是隐式传递的,所以我们更加注意不到了

静态方法:最接近全局的方法

调用静态方法/变量需要用类名调用,只是在本类中可以省略

某个动作在执行时需要对象的参与,这个方法应该定义为实例方法,例如:人可以学习,但是不同的人学习效果是不同的,显然是学习这个动作存在对象差异化,该方法应该定义为实例方法

在Java中没有任何东西是全局(global)的,但可以这样想:如果一个方法不依靠实例变量的值,例如Math类的round()方法,它永远都执行相同的工作:取出浮点数(方法的参数)的最接近的整数值。假定有10000个Math实例,并且都 执行round(42.2),所得到的值永远都是42。换句话说,这个方法会对参数进行操作,但是这个操作不受实例变量状态的影响,唯一能改变其行为的是传入的参数。(静态方法不需要对象的参与)

实际上,永远都无法创建Math的实例,在Math这个类中所有方法都不需要实例变量,所以无需创建Math的实例,会用到的只有Math这个类本身。

int x = Math.round(42.2);
int y = Math.min(56,12);
int z = Math.abs(-343);

这些方法不需要实例变量,因此也不需要特定对象来判别行为。

非静态方法:实例变量会影响到该方法的行为

内存分析static

static方法中是没有aload_0加载this指针的,编译器不会在调用static方法时传递this,所以无法处理实例相关的数据

super

  1. super.属性名 访问父类当中的属性

  2. super.方法名(实参) 访问父类当中的方法

  3. super(实参) 调用父类构造方法

super和this很相似,但是严格来说super并不是一个引用,只是一个关键字,super代表了当前对象中从父类继承过来的特征,this指向一个独立的对象,而super并不是指向某个“独立对象”

  • super只能出现在实例方法 super. 或者构造方法 super()
  • super. 大部分情况下是可以省略的
  • super不能用在静态方法中
  • super()只能出现在构造方法第一行,通过当前的构造方法调用父类中的构造方法,目的是:创建子类对象时初始化父类特征

重要结论:

当一个构造方法的第一行既没有this()也没有super()的时候,默认会有一个super(); 表示通过当前子类的构造方法调用父类的无参数构造方法,所以必须保证父类的无参构造是存在的

注意:this()和super()不能共存,他们都是只能出现在构造方法第一行(引申来说 不管构造方法如何实现,父类的构造方法一定会执行

目的:父类构造方法一定执行是为了对父类型特征进行初始化,子类中有可能访问父类中的数据,如果不进行初始化,子类就不能进行使用。

对父类变量的初始化也是遵循构造方法的执行流程的。

super内存结构

在构造方法执行的时候一并调用了父类的构造方法,父类构造方法又调用了Object的构造方法,但实际上对象只有一个

super(实参)的作用是:初始化当前对象的父类型特征,并不是创建新对象,实际上对象只创建了一个,最终这部分特征都是要继承过来的(super和当前对象紧密相连,静态相关中不能出现super)

public class SuperMemoryStructure {
    public static void main(String[] args) {
        CreditAccount ca1 = new CreditAccount();
        System.out.println(ca1.getActno() + " , " + ca1.getBalance() + " , " + ca1.getCredit());
        CreditAccount ca2 = new CreditAccount("111",100.0,0.99);
        System.out.println(ca2.getActno() + " , " + ca2.getBalance() + " , " + ca2.getCredit());
    }
}
class Account extends Object{
    private String actno;
    private double balance;

    public Account() {
    }

    public Account(String actno, double balance) {
        this.actno = actno;
        this.balance = balance;
    }

    public String getActno() {
        return actno;
    }

    public void setActno(String actno) {
        this.actno = actno;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

}
class CreditAccount extends Account{
    private double credit;

    public CreditAccount(String actno, double balance, double credit) {
        super(actno, balance);
        this.credit = credit;
    }
    /**
     * 此处不能写 this.actno = actno 因为私有属性只能本类中访问
     * 或者可以用set/get方法
     * */
    public double getCredit() {
        return credit;
    }

    public void setCredit(double credit) {
        this.credit = credit;
    }

    public CreditAccount() {
    }
}

内存结构如下:

属性隐藏

当父类和子类中定义了同名的属性时,super.调用的就是父类中的属性,this.调用的就是子类中的属性

public class PropertyIsHidden {
    public static void main(String[] args) {
        new Vip("lisa").shopping();
    }
}
class Customer{
    String name;

    public Customer() {
    }

    public Customer(String name) {
        this.name = name;
    }
}
class Vip extends Customer{
    String name;

    public Vip(String name) {
        super(name);
        //Vip中的name被赋默认值null
    }
    public void shopping(){
        System.out.println(this.name + " shopping");
        System.out.println(super.name + " shopping");//输出结果说明了,super.name和this.name是不同的存储空间
        System.out.println(name + " shopping");
    }
}

或者可以这样做:

    public void shopping(){
        System.out.println(super.name + " shopping");
        System.out.println(((Customer)this).name + " shopping");
    }
// Customer c = (Customer)this;

将子类强转为父类型,这种做法多用于多层继承的结构当中。

如何理解super

  • 在属性隐藏使访问父类成员变量a
super.a = 30;
((A)this) = 30;

字节码文件相同,都是:

aload_0
bipush   30
putfield  #7   //Field test1/A.a:I

就是找A.a的偏移量而已,结果是12

  • 访问子类中的a
this.a = 40;
((B)this).a = 40;

字节码文件相同:

aload_0
bipush   40
putfield  #11  //Field a:I

就是查找B.a的偏移量而已

多态

多态属于面向对象三大特征之一,前提是封装形成独立体,独立体之间形成继承关系,从而产生多态机制;

多态是同一个行为具有多个不同表现形式或者形态的能力。

多态指的是:编译阶段绑定父类中的方法,运行阶段动态绑定子类中的方法(编译时一种形态,运行时一种形态)

Animal a1 = new Animal();
a1.move();//Animal move
Cat c1 = new Cat();
c1.move();//Cat wondering
Bird b1 = new Bird();
b1.move();//Bird flying

Animal和Cat有继承关系,Cat is a Animal,java中支持语法:父类型引用指向子类型对象,即:

Animal a = new Cat()

a就是父类型引用, new Cat() 就是子类型对象。

Animal a2 = new Cat();
a2.move();//Cat wondering
Animal a3 = new Bird();
a3.move();//Bird flying

分析上述代码:

  1. 编译阶段:对于编译器来说,只知道a2的类型是Animal,在运行时会去Animal虚方法表中查找move方法(和继承结构向上),找到了就会绑定上move方法,静态绑定成功
  2. 运行阶段:实际上在堆内存中创建的java对象是Cat,所以move的时候真正参与运行的是Cat对象,执行阶段就会动态绑定Cat类虚方法表中的move方法

运行期的绑定流程:

  1. 找到操作数栈顶第一个元素指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限验证,如果通过则返回这个方法的直接引用,查找结束;不通过返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,抛出java.lang.AbstractMethodError异常。

Java中允许:

  • 向下转型:父类型引用指向子类型对象
  • 向上转型:父类型引用转换为子类型引用

如果要调用子类中特有的方法:

Animal animal = new Cat();  
animal.catchMouse(); // 报错

编译就会报错,因为编译阶段无法在Animal的虚方法表(及其向上继承结构)中查找到catchMouse(),静态绑定失败。

也就是说:子类中的方法无法静态绑定,如果想要访问子类特有的方法,必须向下转型。

将Animal类型的引用强制转型为Cat类型:

Animal animal = new Cat();  
Cat cat = (Cat) animal;  
cat.catchMouse();

但是向下转型是存在风险的:ClassCastException

Animal a6 = new Bird();
Cat cat1 = (Cat)a6;//这时编译器检测到a6是Animal类型,和Cat有继承关系,可以向下转型
cat1.catchMouse();//堆内存中实际创建的对象是Bird 实际运行过程中Bird转换为Cat就不行了 没有继承关系

编译器检测到a6是Animal类型,和Cat类型有继承关系,可以向下转型。但是在运行期间,堆内存中实际创建的对象是Bird,这样转换就不行了,Bird和Cat并没有继承关系。

在堆内存创建的是Bird对象,Animal类型引用可以访问到Bird对象中继承自Animal的部分内容,但是如果将Animal类型引用转型为Cat类型引用,Cat类型引用是无法访问到Bird对象中的内容的,这就是破坏了引用的访问能力;而编译器不知道a6具体指代的是哪个对象,检测到Animal和Cat没有继承关系就不会有错误提示,只有在运行阶段,JVM发现堆内存中实际是Bird对象,想要转型就会发生:ClassCastException

避免类型转换异常的发生:instanceof运算符,在运行阶段动态绑定引用指向对象的类型 结果只能是true或false

	Animal animal = new Cat();  
	if (animal instanceof Cat){  
	    ((Cat) animal).catchMouse();  
	}

新特性:如果判断成功直接转型:

	Animal animal = new Cat();  
	if (animal instanceof Cat cat){  
	    cat.catchMouse();  
	}

具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

为什么要使用instanceof运算符

public class InstanceofTest01 {
    public static void main(String[] args) {
        AnimalTest at = new AnimalTest();
        at.test(new Bird());
        at.test(new Cat());
    }
}
class AnimalTest{
    public void test(Animal a){
        if(a instanceof Cat c){
            c.catchMouse();
        } else if (a instanceof Bird b) {
            //Bird b = (Bird) a;
            b.getClass();
        }

    }
}

在test中,可能传过来的是Bird 也可能传过来的是Cat 就需要进行判断

字段没有多态性

有继承结构如下:

class Animal{
    String name = "动物";
    public void show(){
        System.out.println("Animal-show()");
    }
    public void doSome(){
        System.out.println("AnimalName:" + this.name);
    }
}
class Dog extends Animal{
    String name = "狗";

    @Override
    public void show() {
        System.out.println("Dog-show()");
    }

    @Override
    public void doSome() {
        System.out.println("DogName:" + this.name);
    }
}
class Cat extends Animal{
    String name = "猫";

    @Override
    public void show() {
        System.out.println("Cat-show()");
    }
    @Override
    public void doSome() {
        System.out.println("CatName:" + this.name);
    }
}
  • 直接访问实例变量:输出的就是左边类型中的内容(属性没有动态绑定的能力
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println(a.name);//动物
    }

多态性的根源在于虚方法的调用指令invokevirtual的执行逻辑,那自然可以得出结论,多态只对方法有效,对字段是无效的,因为字段的访问并不使用这条指令。事实上,Java中只有虚方法存在,字段是永远不可能变为虚的,换句话说,字段永远不参与多态。某个类的方法访问某个字段时,这个字段指的就是该类可以看到的那个字段。子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

如果是用子类引用Dog类型d变量访问name,会先去子类的内存空间中查找,如果没有再访问父类型特征当中的变量

  • 通过实例方法访问成员变量:输出的是属于本方法的成员变量
    public static void main(String[] args) {
        Animal a = new Dog();
        a.doSome();//DogName:狗
    }

多态的优势和弊端

在多态的形式下,右边的对象可以解耦合,便于扩展和维护。

定义方法的时候,使用多态作为参数,可以接收所有的子类对象。

例如StringBuilder类的append方法:

public StringBuilder append(Object obj) {
    return append(String.valueOf(obj));
}

valueOf:

public static String valueOf(Object obj) {
    return (obj == null) ? "null" : obj.toString();//valueOf调用了toString方法
}

继承与多态

当定义出一组类的父型时,可以用子类的任何类型来填补任何需要或期待的父型的位置

Animal[] animals = new Animal[5];

animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Wolf();
animals[3] = new Hippo();
animals[4] = new Lion();

for(int i = 0; i < animals.length; i++){
    animals[i].eat();
    animals[i].roam();
}

编译器会查找引用类型来决定是否可以调用该方法,但在执行期间,编译器寻找的不是引用的类型而是堆上的对象,假定此时编译器同意这个调用,则唯一能通过的方法是覆盖的方法也具有相同的参数和返回值。

并且与上例相同 参数与返回值类型也可以是多态

多态存在的三个条件:

  1. 继承
  2. 方法覆盖
  3. 父类型引用指向子类型对象

多态显然是离不开方法覆盖机制的,多态就是因为编译时绑定的父类中的方法,运行时实际上绑定的子类的方法,如果子类对象的方法没有重写,这个时候创建子类对象就没有意义了,只有子类将方法重写之后调用到子类对象上的方法产生不同效果时,多态就形成了。

三个问题

问题一:静态方法没有方法覆盖

public class StaticMethodOverWrite {
    public static void main(String[] args) {
        Math m = new Math();
        Math s = new MathSubClass();
        m.sum();
        s.sum();
    }
}
class Math{
    public static void sum() {
        System.out.println("Math sum execute");
    }
}
class MathSubClass extends Math{
    public static void sum() {
        System.out.println("MathSubClass sum execute");
    }
}

这就说明 静态方法执行的时候跟对象无关,虽然是引用.调用静态方法,但在执行的时候会变成类名.就会执行Animal的sum方法

静态方法的调用是通过invokestatic字节码指令来完成的,仅与对象的静态类型有关。

问题二:私有方法不能覆盖

public class PrivateMethodOverWrite {
    private void doSome(){
        System.out.println("Private doSome execute");
    }

    public static void main(String[] args) {
        PrivateMethodOverWrite privateMethodOverWrite = new T();
        privateMethodOverWrite.doSome();//此处执行的是子类中的方法 ,说明父类中私有方法没有继承
    }
}
class T extends PrivateMethodOverWrite{
    public void doSome(){//此处重写需要注意:访问权限不能更低,只能更高或者持平
        System.out.println("SubClass doSome execute");
    }
}

只有在虚方法表中的方法才能被覆盖,私有方法的方法引用在编译期就已经确定。

问题三:不能抛出更多/更宽泛的异常

重写之后的方法不能比重写之前的方法抛出更多 / 更宽泛的异常

class Animal{
    public void doSome(){
        
    }
}
class Cat extends Animal{
    @Override
    public void doSome() throws Exception{
        super.doSome();
    }
}

编译报错,原因在于:编译器检测到的父类是没有异常抛出的,而在子类执行期间却抛出了编译时异常

但是对于RunTimeException来说:

class Animal{
    public void doSome(){

    }
}
class Cat extends Animal{
    @Override
    public void doSome() throws RuntimeException{
        super.doSome();
    }
}

这是不会报错的,因为不要求对运行时异常进行处理

兼容的返回值类型

public class CompatibleReturnValue {
    public Animal doSome(){
        return null;
    }
}
class SubClass extends CompatibleReturnValue{
    @Override
    public Cat doSome() {//返回值类型变为父类方法中声明的子类 可行
        return null;
    }
}

兼容的返回值类型:子类的返回值类型必须是相同的类型或者是该类型的子类

不能破坏引的访问能力(也就是访问到的内容)

多态的作用

public class PolymorphismInActualDevelopment {
    public static void main(String[] args) {
        new Master(new Cat()).feed();
        new Master(new Bird()).feed();
        new Master(new Dog()).feed();
    }
}
class Master{
    Animal a;
    public Master(Animal a) {//Animal a = new Cat();
        this.a = a;
    }
    public void feed(){
        a.eat();
    }
}
class Dog extends Animal{
}

练习

需求:根据需求完成代码:
	1.定义狗类
		属性:
			年龄,颜色
		行为:
			eat(String something)(something表示吃的东西)
			看家lookHome方法(无参数)
2.定义猫类
	属性:
		年龄,颜色
	行为:
		eat(String something)方法(something表示吃的东西)
		逮老鼠catchMouse方法(无参数)
3.定义Person类//饲养员
	属性:
		姓名,年龄
	行为:
		keepPet(Dog dog,String something)方法
			功能:喂养宠物狗,something表示喂养的东西
	行为:
		keepPet(Cat cat,String something)方法
			功能:喂养宠物猫,something表示喂养的东西
	生成空参有参构造,set和get方法  
4.定义测试类(完成以下打印效果):
	keepPet(Dog dog,String somethind)方法打印内容如下:
		年龄为30岁的老王养了一只黑颜色的2岁的狗
		2岁的黑颜色的狗两只前腿死死的抱住骨头猛吃
	keepPet(Cat cat,String somethind)方法打印内容如下:
		年龄为25岁的老李养了一只灰颜色的3岁的猫
		3岁的灰颜色的猫眯着眼睛侧着头吃鱼
5.思考:		
	1.Dog和Cat都是Animal的子类,以上案例中针对不同的动物,定义了不同的keepPet方法,过于繁琐,能否简化,并体会简化后的好处?
	2.Dog和Cat虽然都是Animal的子类,但是都有其特有方法,能否想办法在keepPet中调用特有方法?

final

final表示最终的、不可变的; final可以修饰类、变量、方法等

  • final修饰的类无法继承 final class A
  • final修饰的方法无法覆盖 public final void doSome()
  • final修饰的变量只能赋一次值 final int i

其实final本质上就做一件事:把动态的变成静态的,把不确定的变成确定的。以final method为例,当一个方法被final修饰,那么子类就不允许重写了,所以obj.method()调用时就是确定的。

final修饰的变量

局部变量

final修饰的局部变量一旦赋值不能重新赋值;多用于多线程并发的环境下,多个线程需要操作这个共享的数据,而这个共享数据涉及到写操作时会导致线程不安全,为了保证数据的安全性可以用final修饰该变量,这样的变量值只允许读不允许写,可以避免线程安全问题。

public class FinalVariable {
    public static void main(String[] args) {
        final int i = 10;
        i = 200; //报错:无法为最终变量i赋值
        
        public void test(final int k){
            k = 20; //一旦赋值就无法修改
        }
    }
}

或者当局部变量作为闭包结构的一部分时,也需要使用final来保证变量一致性

实例变量

final修饰的实例变量 系统不会赋默认值 必须手动赋值实例变量在构造方法执行的时候赋值

class User{
    final double height;
	final int i = 1;
    public User(double height) {
        this.height = height;//这样写也是可以的 在构造的时候进行赋值
    }
}

但是实例变量被final修饰有一个问题:多次创建User对象会导致堆内存中重复出现i的内存空间,既然i是不可变的,初始化一份内存空间所有对象共享就可以了,也就是下面所说的静态变量

静态变量

final修饰的实例变量一般都还会带有static修饰符;static final修饰的变量称为常量,常量名全部大写,单词间下划线衔接,实际上常量和静态变量相同,都存储在静态区并且都是类加载时进行初始化

class Math{
    public static final double PI = 3.14;//常量一般都是公开的 用public修饰
}

出现常量的地方会全部被替换成其记住的字面量,这样可以保证使用常量和直接使用字面量的性能是一样的

final修饰的引用

上文已经说明,final修饰的变量只能赋一次值,而引用也是一个变量;这就说明了:

final修饰的引用只能指向一个对象并且它只能指向一个对象,该变量在整个程序执行过程中不会被GC回收,只有结束后才会被回收

public class FinalVariable {
    public static void main(String[] args) {
        final User u = new User(1);
        u = new User(1);//并且此处也无法让u = null
    }
}

但是对象的细节是可变的

final修饰的类无法继承

final class A{
}
class B extends A{
    /*报错:无法从最终类A进行继承*/
}

不希望被继承:比如字符串类String,String类的方法都是精心设计的,某些方法绝对不能改变,而继承之后重写的方法可能会破坏String类的整体结构

final修饰的方法无法被覆盖

class C{
    public final void doSome(){
        System.out.println("C doSome");
    }
}
class D extends C{
    public void doSome(){
        System.out.println("D doSome");/*报错:被覆盖的方法为final*/
    }
}

不希望被覆盖:某些方法中体现了核心算法,目的是让这个方法受到保护

String

对于String类来说:

final控制了value不能被重新赋值,private导致value不能被外界获取,进而不能修改数组当中的内容

final和static

实际开发中,final和static组合使用的场景居多:

class XxxService {
    // 当我们需要一个 静态常量 时,可以这样写
    private static final int a = 1;
    
    // 省略...
}

public final class ConnectionUtils {
    
    private ConnectionUtils() {}

    // 全局只要一个tl对象,而且final不允许改变
    private static final ThreadLocal<Connection> tl = new ThreadLocal<>();

    private static final BasicDataSource DATA_SOURCE = new BasicDataSource();

    // 对于static final修饰的DATA_SOURCE,希望做一些较为复杂的赋值工作,可以挪到静态代码块
    static {
        DATA_SOURCE.setDriverClassName("com.mysql.jdbc.Driver");
        DATA_SOURCE.setUrl("jdbc:mysql://localhost:3306/demo");
        DATA_SOURCE.setUsername("root");
        DATA_SOURCE.setPassword("123456");
    }
}

final和static单独使用的场景,final表示“不能更改”,static表示“属于类”。

public final class EnumUtil { // 工具类,没必要继承(当然,这玩意可写可不写)
    
}

public void method() {
    final long userId = 1L; // 不希望这个值被后面的语句覆盖(也是可写可不写)
    // ...
    
}

// 如果你有需求,不希望子类覆盖某个方法,要么用private,要么用final,取决于你要不要暴露这个方法

另外,static有个比较特别的用法,用来修饰内部类。一般来说,static是无法修饰class的:

但却可以修饰内部类:

public class UserDTO {
    
    private String name;
    private Department department;
    
    // 比如对于一个Response的TO,内部有个字段需要一个TO表示,且只会在你这个接口里使用,就没必要定义为公共类
    static class Department {
        private String name;
    }
    
}

静态内部类的好处是,外部调用者在new的时候无需实例化外部类:

如果内部类没用static修饰,使用起来就很痛苦:

抽象类

对于Animal继承树来说,这样的写法会很奇怪:

Animal animal = new Animal();

虽然是相同的类型,但是并不存在Animal这种动物,一定要有Animal这个类才能继承和产生多态,但是要限制它的子类才能够被初始化,希望得到的是Cat、Bird等对象,而不是一个Animal对象;也就是说 有些类不应该被实例化

通过标记某个类是抽象类,编译器就知道不能创建这个类的任何实例。

Java API中有很多抽象类,比如GUI的函数库里就有很多,只会对GUI组件下的具体子类做初始化动作

类和类之间具有共同特征,将这些共同特征提取出来形成的就是抽象类;类本身就是不存在的,所以抽象类无法创建对象。

抽象类也属于引用数据类型

语法:[修饰符列表] + abstract + class + 类名 { 类体 }

  • 抽象类无法实例化,是供子类继承的;所以abstract和final是非法的修饰符
  • 抽象类有构造方法,供子类super使用
  • 抽象类不一定有抽象方法,抽象方法一定出现在抽象类中
  • 构造方法、静态方法不能声明为抽象方法
public class AbstractTest01{
    public static void main(String[] args) {
       // Account a = new Account();/*报错:类是抽象的无法实例化*/
    }
}
abstract class Account{
    public Account(String s) {
        //子类super()无法调用无参构造,编译报错
    }
}
//抽象类的子类也可以是抽象类
abstract class SaveAccount extends Account{
    
}
//子类继承抽象类 子类可以实例化对象
class CreditAccount extends Account{
    
}

抽象类的目的只是被继承,除了抽象类可以有静态的成员之外。

抽象方法

抽象方法一定出现在抽象类当中,子类实现抽象类一定要把抽象方法实现了;

这是因为没有任何的通用实现是可行的,对于Animal继承树来说,不同的动物也有不同的eat()方法;抽象方法的意义是就算无法实现出方法的内容,但还是可以定义出一组子型共同的协议;抽象方法没有具体内容,它只是为了标记出多态而存在的,表示在继承树下第一个具体类必须实现出所有的抽象方法,当然也可以使用抽象机制把实现的负担再次转移给下层

public class AbstractMethod {
    public static void main(String[] args) {
        Animal a = new Cat();//面向抽象编程
        a.move();
        //抽象类 可以继承抽象方法,不能new对象
        //非抽象类 必须覆盖抽象方法
    }
}
abstract class Animal{
    public abstract void move();
}
class Cat extends Animal{
    @Override
    public void move() {
        System.out.println("Cat wondering");
    }
}
abstract class Bird extends Animal{
    
}

抽象类的细节

  1. 抽象类不能创建对象,如果创建,编译无法通过而报错。只能创建其非抽象子类的对象。

理解:假设创建了抽象类的对象,调用抽象的方法,而抽象方法没有具体的方法体,没有意义。

  1. 抽象类中,可以有构造方法,是供子类创建对象时,初始化父类成员使用的。

理解:子类的构造方法中,有默认的super(),需要访问父类构造方法。

  1. 抽象类中,不一定包含抽象方法,但是有抽象方法的类必定是抽象类。

理解:未包含抽象方法的抽象类,目的就是不想让调用者创建该类对象,通常用于某些特殊的类结构设计。例如适配器模式。

  1. 抽象类的子类,必须重写抽象父类中所有的抽象方法,否则子类也必须定义成抽象类,编译无法通过而报错。

理解:假设不重写所有抽象方法,则类中可能包含抽象方法。那么创建对象后,调用抽象的方法,没有意义。

  1. 抽象类存在的意义是为了被子类继承。

理解:抽象类中已经实现的是模板中确定的成员,抽象类不确定如何实现的定义成抽象方法,交给具体的子类去实现。

抽象类存在的意义是为了被子类继承,否则抽象类将毫无意义。抽象类可以强制让子类,一定要按照规定的格式进行重写。

接口

之前的Animal继承树:

如果现在想在其中加上宠物特有的功能,可以这样做:

方法一

最简单的做法是把宠物方法加入Animal类当中

优点是 所有动物都可以立刻继承宠物的行为,不需要改变子类程序的代码,新增加的动物会自动获得这些功能

缺点在于 Hippo、Wolf这些动物不会表现出宠物的行为

方法二

采取方法一,但是把宠物的功能设定为抽象的,强迫每个子类进行覆盖

优点:可以让非宠物的动物在覆盖这些方法时做出合理的动作或者什么都不做

缺点:所有具体动物都要实现宠物的行为,这样浪费时间;并且这种结构不理想,最重要的是这会让Animal的定义变得有局限性,反而更难让其他类型的程序重复利用

方法三

把方法加到需要的地方;也就是写在需要的类中

优点:只有宠物类才会有宠物的行为

缺点:失去了合约保证,无法确定执行的时doFriendly()还是beFriendly();其次多态无法实现,因为Animal没有共同的宠物行为

所以真正需要的方法是:

  1. 一种可以让宠物行为只应用在宠物身上的方法
  2. 一种确保所有宠物的类都有相同的方法定义的方法
  3. 一种可以运用到多态的方法

这样看起来需要用到两个父类:

但是这样的多重继承Java并不支持,因为会有“致命方块”的问题:

对于ComboDrive调用burn方法的时候到底要运行哪个版本?这必须要有某种规则来处理可能出现的模糊性,额外的规则意味着必须同时学习这些规则与观察适用这些规则的特殊情况,Java基于简单化的原则不允许这种致命方块的出现

示例二:

接口

接口解决致命方块问题其实很简单,把所有方法设置为抽象的,子类必须实现该方法

public class Dog extends Canine implements Pet{} 

实现关系用like – a描述,Dog is a Canine & Dog like a Pet

接口并不是真正的继承,因为无法在其中实现程序代码;接口的作用就是实现多态;如果以接口取代具体的子类或者抽象的父类作为参数或者返回值,就可以传入任何有实现该接口的东西。使用接口就可以继承超过一个以上的类来源:类可以继承过某个父类,并且实现其他的接口,同时其他的类也可以实现同一个接口。因此就可以为不同的需求组合出不同的继承层次

一个类作为多态类型使用时,相同的类型必定来自同一个继承树,而且必须是该多态类型的子类,定义为Canine类型的参数可以接受Wolf或者Dog,但无法接收Cat或Hippo,但用接口来作为多态类型时,对象就可以来自任何地方了,唯一的条件是该对象必须是来自有实现此接口的类。允许不同继承树的类实现共同的接口对Java API来说非常重要。

接口细节

  • 接口是一种引用数据类型。接口是完全抽象的,抽象类是半抽象的(也就说明接口中的内容必须完全抽象)
  • [修饰符列表] + interface + 接口名{}
  • 接口编译后也是一个.class文件
  • 接口支持继承,多继承
  • 接口中只有两部分内容:常量、抽象方法、(默认、静态、私有)
  • 抽象方法定义时 public abstract可以省略
  • 常量声明时public static final可以省略
  • 接口没有构造方法 无法实例化对象

JDK8更新:接口中可以定义默认方法、静态方法 JDK9更新:接口中可以定义私有方法

接口支持多继承

interface A{}
interface B{}
interface C extends A{}
interface D extends A,B //继承了A和B中的抽象方法 

前缀可以省略

interface MyMath{
    public static final double PI = 3.14;
    double Pi = 3.14;
    
    public abstract int sum(int a,int b);//接口中的方法不能带有方法体
    int sub(int a,int b);
}

类实现接口

class MyMathImpl implements MyMath{
    @Override
    public int sum(int a, int b) {
        return a + b;
    }

    @Override
    int sub(int a, int b) {/*报错:正在尝试分配更低的访问权限*/
        return a - b;
    }
}

在覆盖接口中的方法时,修饰符列表不能省略,因为 缺省 < public,而方法覆盖之后存取权限不能变低。

接口可以用来声明一个变量,例如定义一个接口:

public interface Usb {
    void read();
    void write();
}
public interface Wireless {
    void connect();
}

接口的实现类:

public class Kingston implements Usb,Wireless{
    public void read() {
        System.out.println("Kingston usb read");
    }
    public void write() {
        System.out.println("Kingston usb write");
    }
    public void connect() {
        System.out.println("WIFI connected");
    }
}

测试类:

public class KingstonTest {
    public static void main(String[] args) {
        Usb u = new Kingston();
        u.read();
        u.write();
    }
}

一个类可以实现多个接口

这个机制弥补了Java中类和类只支持单继承的缺陷

public interface A {
    void m1();
}
interface B{
    void m2();
}
interface C{
    void m3();
}
class D implements A,B,C{
    public void m1() {}
    public void m2() {}
    public void m3() {}
}

多态的使用:

public class Test {
    public static void main(String[] args) {
        A a = new D();

        /*如果此处想调用D(B)中的m2方法,需要接口转型
          直接调用a.m2();会报错,a引用无法静态绑定
        * */
        B b = (B) a;//这样转型A中的m1就无法访问了
        
        //但是此处要调用D中的方法,可以强制转换为D类型
        D d = (D) a;
    }
}

接口A和接口B虽然没有继承关系,但是可以强制类型转换;经过测试可得出:引用指向的对象实现了需要强制转换的类型就能强制类型转换。换句话说:接口间有公共的子类就能强转

接口转型

但是要注意,运行时可能会出现ClassCastException异常,所以在转型之前最好用instanceof进行判断:

public interface K {
}
interface M{}
class E implements M{
    //K和M没有公共实现类
}
public class KTest {
    public static void main(String[] args) {
        M m = new E();
        /*
        * 如果此处直接K k = (K)m;
        * 编译没有问题,运行就会报错:ClassCastException
        * */
        if (m instanceof K k){
           // K k = (K)m;
        }
    }
}

接口中的默认方法

java8之后为了增强接口的拓展能力,在接口中引入了默认方法

如果在之前的版本中创建了一个接口,并且已经被大量的类实现,如果需要再拓展这个接口的功能加入新的方法,就会导致所有已经实现的子类需要重写这个方法。如果在接口中使用默认方法就不会有这个问题。

关于默认方法:

  • 默认方法使用default关键字,一个接口中可以有多个默认方法
  • 接口中既可以定义抽象方法,又可以定义默认方法,默认方法不是抽象方法
  • 子类实现接口的时候,可以直接调用接口中的默认方法,即继承了接口中的默认方法,但是不能通过super调用

示例:

public interface Flyable {
    public default void run(){//默认方法子类可以直接调用
        System.out.println("Running");
    }
    void fly();
}

实现类:

package com.bjpowernode.javase.oo2;

public class Cat implements Flyable{
    @Override
    public void fly() {
        this.run();
        System.out.println("Cat flyable");
    }
}

测试程序:

package com.bjpowernode.javase.oo2;

public class CatTest {
    public static void main(String[] args) {
        Flyable flyable = new Cat();
        flyable.fly();
    }
}

注意:在重写默认方法时 要去掉default关键字

示例:

如果实现类实现了多个接口,接口中存在相同名字的默认方法,该默认方法必须重写:

接口中的静态方法

静态方法为本接口内的其他方法提供服务,无法在实现类中访问

java8之后,还允许在接口中定义静态方法,但是该静态方法只能用接口名调用,不能通过实现类名/引用调用

在实现类中也是不能访问的,为什么不能通过实现类访问?

原因可能在于,后续在接口中新增的静态方法可能与实现类中原有的实例方法重名,这样会导致报错

接口中的私有方法

since JDK9.0

这个机制和方法抽取共性代码并标记为私有是相同的

接口的细节

  1. 当两个接口中存在相同抽象方法的时候,该怎么办?

只要重写一次即可。此时重写的方法,既表示重写1接口的,也表示重写2接口的。

  1. 实现类能不能继承A类的时候,同时实现其他接口呢?

继承的父类,就好比是亲爸爸一样
实现的接口,就好比是干爹一样
可以继承一个类的同时,再实现多个接口,只不过,要把接口里面所有的抽象方法,全部实现。

  1. 实现类能不能继承一个抽象类的时候,同时实现其他接口呢?

实现类可以继承一个抽象类的同时,再实现其他多个接口,只不过要把里面所有的抽象方法全部重写。

  1. 实现类Zi,实现了一个接口,还继承了一个Fu类。假设在接口中有一个方法,父类中也有一个相同的方法。子类如何操作呢?

处理办法一:如果父类中的方法体,能满足当前业务的需求,在子类中可以不用重写。
处理办法二:如果父类中的方法体,不能满足当前业务的需求,需要在子类中重写。

  1. 如果一个接口中,有10个抽象方法,但是我在实现类中,只需要用其中一个,该怎么办?

可以在接口跟实现类中间,新建一个中间类(适配器类)
让这个适配器类去实现接口,对接口里面的所有的方法做空重写。
让子类继承这个适配器类,想要用到哪个方法,就重写哪个方法。
因为中间类没有什么实际的意义,所以一般会把中间类定义为抽象的,不让外界创建对象

注意事项

  • 一个接口继承多个接口,如果多个接口中的方法存在签名冲突,无法多继承
interface A{
	void doSome();
}
interface B{
	String doSome();
}
interface C extends A,B{ //methods have unrelated return types
	
}
  • 一个类实现多个接口,如果多个接口中的方法存在方法签名冲突,无法多实现
interface A{
	void doSome();
}
interface B{
	String doSome();
}
class C implements A,B{ //methods have unrelated return types
	
}
  • 一个类继承了父类,又同时实现了接口,父类中有和接口中同名的默认方法,实现类会优先使用父类的。
interface A{  
    default void doSome(){  
        System.out.println("======");  
    }  
}  
class B{  
    public void doSome(){  
    }  
}  
class C extends B implements A{  
    public void doOther(){  
        doSome(); //使用父类的  
        A.super.doSome(); //接口中的默认方法  
    }  
}

可以通过语法 A.super.doSome() 指定使用接口中的默认方法

  • 一个类实现多个接口,有相同的默认方法签名,子类重写该方法即可多实现。

可以通过语法 A.super.doSome() 指定使用接口中的默认方法

接口在实际开发中的作用

定义接口:

public interface FoodMenu {
    void makeDishes_1();
    void makeDishes_2();
}

接口的实现类:

public class ChinaCook implements FoodMenu{
    public void makeDishes_1() {
        System.out.println("Dishes_1 by ChinaCook");
    }
    public void makeDishes_2() {
        System.out.println("Dishes_2 by ChinaCook");
    }
}
public class AmericanCook implements FoodMenu{
    public void makeDishes_1() {
        System.out.println("Dishes_1 by AmericanCook");
    }
    public void makeDishes_2() {
        System.out.println("Dishes_2 by AmericanCook");
    }
}

接口的调用者:

public class Customer {
    FoodMenu foodMenu;//可以传China/American Cook
    void order(){
        foodMenu.makeDishes_1();
        foodMenu.makeDishes_2();
    }
    public Customer(FoodMenu foodMenu) {
        this.foodMenu = foodMenu;
    }
}

接口的作用就是解耦合,降低了Customer与Cook之间的耦合度,接口让这两者完全分离开了,让二者完全通过抽象的FoodMenu进行沟通;

接口和抽象类的选择

从实际的设计场景来说:Dog类具有eat()、sleep()方法,分别从抽象类和接口定义这个概念:

public abstract class Dog{
    void eat();
    void sleep();
}
public interface Dog{
    void eat();
    void sleep();
}

现在如果需要让Dog类拥有一种特殊的技能:钻火圈drillFireCircle();如何增加这个行为呢?

方案一:写入抽象类 但是这样的话,继承该抽象类的Dog都有了钻火圈功能,这样显然是不合适的

方案二:写入接口,但是实现钻火圈时要同时实现其他的两个方法,这也是不合适的

方案三:单独设计接口;eat和sleep都是Dog类本身具有的行为,而钻火圈显然是经过后天训练培养出来的,只能算是对Dog类的一种延申,两者不应该在同一个范畴内,所以考虑将这个单独的行为独立的设置一个接口

综合案例

  • 思路一:

  • 思路二:

对比发现第一种思路实现更简单

面向对象核心:多态

多态的实现

多态从语法表面上看,就是子类对象可以赋值给父类引用,并且通过该引用可以动态地调用不同子类的方法。

多态按实际用法又可以分为:

  • 继承多态
  • 接口多态

所谓继承多态:

// 我不抽烟 2022-04-29 20:25:27
class Son extends Father { 
    @Overrid
    public void smoke() {
        System.out.print("儿子抽烟");
    }
}
class Daughter extends Father {
    @Overrid
    public void smoke() {
        System.out.print("女儿抽烟");
    }
}

// 继承多态,因为Son、Daughter继承了Father
Father obj = new Son();
obj.smoke(); // 打印:儿子抽烟
obj = new Daughter();
obj.smoke(); // 打印:女儿抽烟

所谓接口多态:

class Son implements Swimmer {
    @Override
    public void swim() {
        System.out.print("儿子游泳");
    }
}
class Daughter implements Swimmer {
    @Overrid
    public void swim() {
        System.out.print("女儿游泳");
    }
}

// 接口多态,因为Son、Daughter实现了Swimmer
Swimmer obj = new Son();
obj.swim(); // 打印:儿子游泳
obj = new Daughter();
obj.swim(); // 打印:女儿游泳

实际开发接口多态更常用。多态的实现,依赖于2个大方面:

  • 机制上的支持
  • 编码上的支持

机制支持

首先,编译器要允许这种赋值方式,不然把son赋值给swimmer就会像把 int a赋值给String b一样报错。

其次,运行时要支持并且能通过某种机制找到真正的子类方法。

编码支持

必须存在继承(实现)关系 + 子类必须重写(实现)父类的方法

我们一般所说的多态,其实都是指方法的多态

以上面Swimmer的代码为例(假设整个工程只有这么几个类),当程序运行时,JVM中实际上并不存在一个对象叫Swimmer,自始至终只有Son和Daughter两个对象,而且Son和Daughter都实现了Swimmer,且重写了swim()方法。当JVM运行到16行时:

VM是怎么知道要打印“儿子游泳”?换句话说,JVM怎么知道调用Son#swim()而不是Swimmer#swim()或者Daughter#swim()?

这就涉及到“虚方法”和“虚方法表”。JVM有个所谓的“类加载子系统”,专门负责类的加载(下图中最上面的部分):

而在类加载过程中,有loading、linking、initialization三个阶段,其中linking阶段又包括3个小阶段:

  • verify(验证)
  • prepare(准备)
  • resolve(解析)

其中在resolve阶段,JVM会针对类或接口、字段、类方法、接口方法等进行相应解析,其中方法信息会形成“虚方法表”。

也就是说,当出现多态方法调用时,底层会多一次“查表”的过程,也就是通过搜索虚方法表,确定本次实际应该调用的方法(实际指向对象+实例对应的类有无重写父类方法),如果子类Override了父类方法,那么就会执行子类方法。

多态调用过程:

  1. 找到操作数栈顶第一个元素指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限验证,如果通过则返回这个方法的直接引用,查找结束;不通过返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,抛出java.lang.AbstractMethodError异常。

多态与设计模式

多态才是面向对象的核心和根本,甚至没有多态就没有面向对象。有了多态,才能写出更加抽象的代码,而抽象代表稳定。

假设有一段代码:

如果后期接入拼多多,就需要修改代码。但如果使用策略模式,就可以用增量的方式代替修改(开闭原则):

具体可以参考:优化代码中大量的if/else,你有什么方案?

设计模式本质是围绕着“在面向对象的基础上,如何复用设计”这个原则展开的,本质上又回到了面向对象;代码中的if-else分支是不会无故消失的,只是借助设计模式把分支下推,最终交给JVM查虚方法表。

也正因为多态调用底层需要查虚方法表,所以大部分设计模式的引入其实反而会降低执行效率(可以忽略),也可能增加内存负担(子类和子类对象增多)。但我们必须清楚,设计模式本来就不是为了解决效率问题,而是为了解决扩展问题,让编码复用性更高、更清晰。只有极少部分设计模式的初衷是为了效率和内存经济性,比如享元模式(Integer、Long这些包装类底层有缓存池)。

在java字节码中,进行函数调用的字节码指令有:

  • invokestatic:调用类的静态方法

  • invokespecial

    • 调用私有实例方法、构造器方法
    • 使用super关键词调用父类的实例方法、构造器
    • 调用所实现接口的default方法
  • invokevirtual:调用非私有实例方法

  • invokeinterface:调用接口方法

  • invokedynamic:调用动态方法

其中只有invokevirtual和invokeinterface会进行函数的动态绑定,即在运行期间绑定,也称为晚绑定,这是实现多态的本质。

在代码中实现多态的条件有两个:

  • 父类引用指向子类对象
  • 使用该引用调用父类中的非私有的实例方法

因为只有满足第二点,才会使用invokevirtual或是invokeinterface进行晚绑定,先找出实际类型,然后调用其方法。

由此也可以得出另外一个结论:父类引用在调用静态方法,私有方法或是接口default方法是不会发生多态,而是直接调用外观类型的方法。

适配器模式

解决接口与实现类的矛盾的问题

public interface Inter {
    public abstract void method1();
    public abstract void method2();
    public abstract void method3();
    public abstract void method4();
    public abstract void method5(); // <- 要使用这个方法
    public abstract void method6();
    public abstract void method7();
    public abstract void method8();
    public abstract void method9();
    public abstract void method10();
}

如果要使用method5,接口的语法规则决定了必须要实现全部十个抽象方法,就可以新增一个类InterAdapter:

对Inter类的抽象方法进行空实现,再用一个类继承InterAdapter,用到哪个方法就重写哪个方法就可以了

public class InterImpl extends InterAdapter{
    @Override
    public void method5() {
        System.out.println("doSome");
    }
}

注意:对于适配器InterAdapter来说,我们是不希望外界创建该类对象的,一般设置为抽象类:

如果InterImpl有其他需要继承的父类,只需要让InterAdapter继承所需要的类就可以了

内部类

成员内部类由对象持有,静态内部类由类持有

类的成员:属性、方法、构造方法、方法、内部类。

public class Outer{
    public class Inner{
        
    }
}

内部类的编译特点:

对于一个名为OuterClass的外部类和一个名为InnerClass的内部类,在编译成功后,会出现这样两个class文件:OuterClass.class和OuterClass$InnerClass.class。

为什么要学习内部类?

需求:定义Javabean描述汽车,包括:汽车品牌、年龄、颜色、发动机品牌、使用年限

如果这样设计:

public class Car{
    String carName;
    int age;
    String color;
    String engineName;
    int engineAge;
}

这样是不太合理的,发动机是一个独立的个体,与车本身还是有些区别的

public class Car{
    String carName;
    int carAge;
    int carColor;
}
public class Engine{
  	String engineName;
    int engineAge;
}

这样也是不合理的,Engine定义为一个单独的类,脱离了Car类单独存在是没有什么意义的

public class Car{
    String carName;
    int age;
    String color;
    
    class Engine{
  		String engineName;
    	int engineAge;
	}
}

如何使用内部类?

内部类表示的事物是外部类的一部分,内部类单独出现没有任何意义,从类间关系的角度来说,这种关系就是组合关系

一个事物内部还有一个独立的事物,内部的事物脱离外部的事物无法独立使用,换句话说外部事物完全控制了内部事物的生命周期

  1. 人里面有一颗心脏。

  2. 汽车内部有一个发动机。

    为了实现更好的封装性。

内部类的访问特点:

  • 内部类可以直接访问外部类的成员,包括私有;类似于闭包函数可以直接访问外层环境
  • 外部类要访问内部类的成员,必须创建对象

成员内部类

外部类名.内部类名 变量名 = new 外部类名(实参列表).new 内部类名(实参列表);

写在成员位置,属于外部类的成员

public class Car{
    String carName;
    int age;
    String color;
    
    class Engine{
  		String engineName;
    	int engineAge;
	}
}
// 外部类
class OuterClass {
	String name = "娜娜";
	// 成员内部类
	public class InnerClass {
		// 成员内部类的成员变量
		String name;
	    // 成员内部类的构造方法
		public InnerClass() {} // 无参构造方法
		public InnerClass(String name) { // 有参构造方法
			this.name = name;
		}
		// 成员内部类的成员方法
		public void show(String name) {
			// 局部变量,成员内部类成员变量,外部类成员变量同名时的访问
			System.out.println("局部变量:" + name);
			System.out.println("成员内部类成员变量:" + this.name);
			System.out.println("外部类成员变量:" + OuterClass.this.name);
		}
		// 2、成员内部类中不能存在任何的静态变量和静态方法。
		// static String company = ""; 编译错误
		// public static test() {} 编译错误
		
	}
	// 外部类的静态方法
	public static void show() {
		// 1、外部类的静态方法中不能访问成员内部变量。
		// InnerClass in = new InnerClass(); 编译错误
	}
}
// 测试类
public class InnerClassDemo {
	public static void main(String[] args) {
		OuterClass.InnerClass in = new OuterClass().new InnerClass("小明"); 
		// 获取成员内内部类的属性
		System.out.println(in.name); // 输出:小花
		// 调用成员内内部类的方法
		in.show("小花");
	}
}

可以被修饰符修饰:private、默认、protected、public、static等,只要是可以修饰成员变量的都可以修饰成员内部类

例如使用private:

public class Car{
    String carName;
    int age;
    String color;
    
    private class Engine{
  		String engineName;
    	int engineAge;
	}
    /*只能在此处创建对象*/
}

在外界就不能创建内部类对象,只能在外部类中创建

从JDK16开始,在成员内部类当中是可以定义静态变量的。

注意:

  1. 外部类的静态方法中不能访问成员内部类。
  2. 成员内部类中不能存在任何的静态变量和静态方法。
  3. 成员内部类是依附于外围类的,所以只有先创建了外围类才能够创建内部类。
  4. 访问外部类成员变量:外部类.this.成员变量,访问外部类成员方法:外部类.this.成员方法。

创建内部类对象

  • 方式一:在外部类中编写方法,对外提供内部类的对象(private修饰的内部类)
  • 方式二:外部类名.内部类名 对象名 = 外部类对象.内部类对象
public class Outer {
    String name;
    class Inner{

    }
}
如果想要访问外部类中的name:
public static void main(String[] args) {
     Outer o = new Outer();
     System.out.println(o.name);

}

可以使用链式编程:

    public static void main(String[] args) {
        System.out.println(new Outer().name);
    }

因为name是一个成员变量,只能通过对象调用;成员内部类的地位等同于成员变量,也需要对象调用:

public static void main(String[] args) {
    Outer.Inner oi = new Outer().new Inner();
}

成员内部类的修饰符

  • private:

对外提供内部类对象:

但是此时内部类是私有的,在外界还是不能通过Outer.Inner接收

可以使用多态 定义父类型接收返回值 或者使用链式编程

  • 定义父类型:

  • 链式编程

以$分隔

注意,此时Outer中提供Inner对象的方法是实例方法,如果想用静态方法对外提供内部类对象:

public class Outer {  
    private class Inner{  
  
    }  
    public static Inner getInner(){  
        return new Outer().new Inner();  
    }  
}
  1. 默认修饰符 只有本包中可以访问
  2. 本包 本包子类

定义静态变量 @sinceJDK16

ArrayList源代码

Itr是私有成员内部类,需要在外部类中提供方法获取Itr对象,但是Itr是private修饰的,等号左边不能用Itr类型接收:

成员内部类面试题

public class Outer {
    private int a = 10;

    class Inner {
        private int a = 20;
        public void show(){
            int a = 30;
            System.out.println(Outer.this.a);//10
            System.out.println(this.a);//20
            System.out.println(a); //30
        }
    }

}

public class Test {
    public static void main(String[] args) {
      	 Outer.Inner oi = new Outer().new Inner();
      	 oi.show();
    }
}

外部类、内部类的成员变量重名,如何访问外部类中的成员变量?

内存图分析:

第一步:Test类加载,JVM调用main方法,加载外部类和内部类的字节码文件:

注意:外部类和内部类会产生两个独立的字节码文件

第二步:创建外部类对象 创建内部类对象 将内部类对象的地址值赋给oi

在内部类对象中会有一个隐藏变量Outer.this记录外部类的地址值

在本例中继续oi.show()

使用内存分析工具查看:

静态内部类

public class Car{
    String carName;
    int age;
    String color;
    
    static class Engine{
  		String engineName;
    	int engineAge;
	}
}

其实是成员内部类的一种特殊情况

静态内部类只能访问外部类中的静态变量和静态方法,如果想要访问非静态的需要创建对象

创建对象: 外部类名.内部类名 对象名 = new 外部类名.内部类名();等同于静态变量,不需要创建外部类对象。

调用静态方法: 外部类名.内部类名.方法名

  • 静态内部类中访问外部类的非静态数据需要创建对象

  • 创建静态内部类对象

重点:静态相关的数据都是 类名. 调用的,new出来的不是Outer对象,是Outer里面的Inner对象

局部内部类

  1. 将内部类定义在方法里面就是局部内部类,类似于局部变量(能修饰局部变量的,都能修饰局部内部类

  2. 外界无法直接使用。需要在方法内部创建对象并使用

  3. 该类可以直接访问外部类的成员,也可以访问方法内的局部变量

  4. 局部内部类只能在对应的局部作用域中实例化。

  5. 局部内部类中不能存在任何的静态变量和静态方法。

  6. 局部内部类是依附于外围类的,所以只有先创建了外围类才能够创建内部类。

  7. 访问外部类成员变量:外部类.this.成员变量,访问外部类成员方法:外部类.this.成员方法。

  8. 局部内部类访问的外部局部变量都必须用 final修饰,以防止更改外部局部变量的值。JDK1.8之后不必添加final。

  • public、protected、private只能修饰实例相关数据

  • 在外界无法直接使用方法里的局部变量,所以也无法直接使用局部内部类
public class Outer {
    
    
    public void show(){
        //局部内部类
        class Inner{
            String name;
            int age;
            public void method1(){
                System.out.println("method1 in Inner");
            }
            public static void method2(){
                System.out.println("method2 in Inner");
            }
        }

        
        Inner i = new Inner();
        i.name = "zhangsan";
        i.age = 22;
        i.method1();
        Inner.method2();
        
        
    }
}

匿名内部类

本质上是隐藏了名字的内部类,本质上是一个子类,也是立即是一个子类对象

如果没有匿名内部类:

interface MyInterface{
    public void add();
}
class MyInterfaceImpl implements MyInterface{

    @Override
    public void add() {
        System.out.println("added");
    }
}

需要编写接口的实现类,然后才能调用:

public class InnerClassTest01 {
    public static void main(String[] args) {
        MyInterface myInterface = new MyInterfaceImpl();
        myInterface.add();
    }
}

如果使用匿名内部类:

public class InnerClassTest02 {
    public static void main(String[] args) {
        MyInterface myInterface = new MyInterface() {
            @Override
            public void add() {
                System.out.println("added");
            }
        };
        myInterface.add();
    }
}

注意匿名内部类结束有一个 ;

示例:

// 父类或一个接口,用于局部内部类继承或实现
abstract class Parent {
	abstract void show1();
	abstract void show2();
}
// 外部类
class OuterClass {
	public void test() {
		// 局部内部类
		class InnerClass extends Parent {
			@Override
			void show1() {
				System.out.println("内部类中的show1方法");
			}
			@Override
			void show2() {
				System.out.println("内部类中的show2方法");
			}
		}
		// 局部内部类实例化。
		InnerClass in = new InnerClass();
		in.show1();
		in.show2();
	}
}

使用匿名对象的方式,将定义子类(包含继承父类或实现接口)与创建子类对象两个步骤由一个格式一次完成。虽然是两个步骤,但是两个步骤是连在一起完成的。

【示例】匿名内部类使用案例

// 父类或一个接口,用于匿名内部类继承或实现
abstract class Parent {
	abstract void show1();
	abstract void show2();
}
// 外部类
class OuterClass {
	public void test() {
		// 匿名内部类
		Parent p = new Parent() {
			@Override
			void show1() {
				System.out.println("匿名内部类中的show1方法");
			}
			@Override
			void show2() {
				System.out.println("匿名内部类中的show2方法");
			}
		};
		p.show1();
		p.show2();
	}
}

匿名内部类如果不定义变量引用,则也是匿名对象。

        //接口多态
        Swim s = new Swim() {
            @Override
            public void swim() {
                System.out.println("swiming");
            }
        };
        
        //可以通过new出来的匿名内部类对象调用方法
        new Swim(){
            @Override
            public void swim() {
                System.out.println("swiming");
            }
        }.swim();

空指针异常

Java中的垃圾回收器 GC 主要针对的就是堆内存中的垃圾数据,没有任何引用指向该对象的时候被判定为垃圾。

出现空指针异常的前提是:空引用访问了对象相关的数据。

Person p = new Person();
p = null;
//new Person() 这个对象没有引用指向它了,GC会将这个对象释放掉

对于 “没有任何引用指向该对象的时候被判断为垃圾”,这句话其实是不太准确的,实际上GC并不计算对象的引用,因为这可能需要很长时间,并且对象有可能是互相引用的,如下图所示:

有三个对象互相引用,但是没有任何的变量引用到他们,换句话说就是程序的执行与这三个对象无关;如果GC只是计算引用就不会收集到这三个对象,也不会释放他们的内存。

因此,在Java中决定收集垃圾的不是基于引用计数,而是区分对象是可访问的或是不可访问的

如果一个对象被另一个可访问的对象引用,则该对象是可访问的,因此我们可以得到一个“可达性链”(chain of reachability),该链在程序启动时启动,并且在程序持续时间内持续,如下图所示:图中的箭头表示程序的可执行代码,代码创建对象的引用,这些对象可以引用其他对象,依次类推将形成一个“引用链”

如果可以追踪某个对象到达“根引用“(直接在可执行代码中创建的引用),就认为该对象是”可访问的“;但是,如果无法从引用链到达某个对象,就认为该对象是”不可访问的“

所有的对象可以分为两个类型:”simple objects“ 和 ”long-lived objects“;long-lived objects 是在多轮垃圾收集下来的幸存者,通常存活到程序执行结束。

存储对象的完整堆被分为几个部分:
​ 第一部分:eden,这是对象刚刚创建完成的位置,也就是使用关键字new时新建对象的内存部分,eden中可能会创建很多对象,当此区域空间不足时,将开始初始”快速“垃圾回收。GC根据堆中有更多”垃圾“还是更多”活动对象“来选择算法,如果大多数都是”垃圾“,GC会标记活动对象并将他们移到另一个内存区域,然后完全清除当前区域;如果垃圾不多,并且堆中大部分是活动对象,GC就标记垃圾对其清除,并将其他对象打包在一起。
​ GC标记活动对象并将其移动到另一个内存区域,在至少一轮垃圾回收幸存下来的的所有对象都被移动的内存区域称为”生存空间“,生存空间又分为”generations“,每一个对象都属于特定的一代,具体取决于它幸存了多少轮垃圾回收,如果一个对象在第一轮垃圾回收中幸存下来,它就处于“第一代”。
​ eden和生存空间共同形成了young generation(新生代),堆中除了新生代还有一个区域:“old generation”,老年代是经过多轮垃圾收集的长寿命物体到达的最终区域,仅当老年代已满时,才进行完全垃圾回收。

Enum

public enum A{
	X,Y,Z
}
  • 枚举类的第一行只能罗列一些名称,这些名称都是常量,并且每个常量记住的都是枚举类的一个对象
  • 枚举类的构造器只能是私有的(写不写都是私有),因此枚举类不能对外创建对象(反射也不可以)
  • 自定义的枚举类底层是final修饰的,不能被继承
  • 枚举类的第二行可以定义其他成员
  • 编译器自动为枚举类新增了几个方法,并且枚举类都继承自java.lang.enum类,从enum类也会继承一些方法
  • 枚举是单例的

反编译之后:

public final class A extends Enum{ //final修饰的
	public static final A X = new A("X", 0);
	public static final A Y = new A("Y", 1);
	public static final A Z = new A("Z", 2);
	private static final A[] $VALUES = $values(); //return (new A[] {X, Y, Z});
	
	public static A[] values(){
	   return (A[])$VALUES.clone(); // 避免调用者直接修改value数组的内容
	}

	public static A valueOf(String s){
		return (A)Enum.valueOf(A, s);// 调用父类的valueOf
	}

	private A(String s, int i){
		super(s, i); //调用父类的构造方法
	}

	private static A[] $values(){
		return (new A[] {X, Y, Z});
	}
}

父类中定义了两个属性:

public abstract class Enum<E extends Enum<E>>  
        implements Constable, Comparable<E>, Serializable {  
	
	private final String name;  //枚举的名字,就是在定义枚举的时候指定的变量名
	private final int ordinal;  //枚举的序号,默认从0开始

	protected Enum(String name, int ordinal) {  
	    this.name = name;  
	    this.ordinal = ordinal;  
	}
	
    public final String name() {  //继承到子类中
        return name;  
    }  

	public final int ordinal() {  //继承到子类中
	    return ordinal;  
    }

	public String toString() {  //重写了toString方法
	    return name;  
	}  
  
	public final boolean equals(Object other) {  //因为枚举是单例的,可以使用 == 判断相等
	    return this==other;  
	}

	public final int compareTo(E o) {  
	    Enum<?> other = o;  
	    Enum<E> self = this;  
	    if (self.getClass() != other.getClass() && // 如果不是枚举类型 抛出类型转换异常  
	        self.getDeclaringClass() != other.getDeclaringClass())  
	        throw new ClassCastException();  
	    return self.ordinal - other.ordinal;  //通过序号判断相等
	}

	public static <T extends Enum<T>> T valueOf(Class<T> enumClass,  String name) {//子类valueOf调用
	    T result = enumClass.enumConstantDirectory().get(name);  
	    if (result != null)  
	        return result;  
	    if (name == null)  
	        throw new NullPointerException("Name is null");  
	    throw new IllegalArgumentException(  
	        "No enum constant " + enumClass.getCanonicalName() + "." + name);  
	}
}

使用枚举

A x = A.X;  
A y = A.Y;  
A z = A.Z;  
  
System.out.println(x); //X  
System.out.println(x.name()); //X 父类的方法  
System.out.println(x.ordinal()); //0 父类的方法  
System.out.println(y); //Y  
System.out.println(y.name()); //Y 父类的方法  
System.out.println(y.ordinal()); //1 父类的方法  
System.out.println(z); //Z  
System.out.println(z.name()); //Z 父类的方法  
System.out.println(z.ordinal()); //2 父类的方法  
  
A x1 = A.valueOf("X"); //子类静态调用父类静态  
System.out.println(x1 == x); //true 单例的  
System.out.println(x1.compareTo(x)); //0 this.ordinal == x.ordinal 代表相等 单例的  
  
A[] values = A.values(); //拿到clone的枚举数组  
System.out.println(Arrays.toString(values)); //[X, Y, Z]  重写了toString  
  
for (A value : values) {  
    System.out.println(value.name() + " : " + value.ordinal());  
}

枚举的应用场景

枚举用来标识一组信息,然后作为参数进行传输

需求:getData方法,根据传入的参数2对参数1进行舍入

  • 使用int标识:
public static double getData(double a,int type){  
    double result = 0;  
    switch (type){  
        case 0 -> result = Math.round(a);  
        case 1 -> result = Math.ceil(a);  
        case 2 -> result = Math.floor(a);  
    }  
    return result;  
}

使用时:

System.out.println(getData(4.6,0));  
System.out.println(getData(4.6,1));  
System.out.println(getData(4.6,2));

缺点:没有明确的含义,hard code不利于维护

  • 使用常量标识:
class MathType{  
    public static final int ROUND = 0;  
    public static final int UP = 1;  
    public static final int DOWN = 2;  
}

使用时:

System.out.println(getData(4.6,MathType.ROUND));  
System.out.println(getData(4.6,MathType.UP));  
System.out.println(getData(4.6,MathType.DOWN));

虽然这样更清晰了,但是还是有缺点的:这并不强制要求使用,还可以传入一个数字:

System.out.println(getData(4.6,0));
  • 使用枚举标识
public enum MathConstant {  
    ROUND,UP,DOWN  
}
public static double getData(double a,MathConstant mathConstant){  
    double result = 0;  
    switch (mathConstant){  
        case MathConstant.ROUND -> result = Math.round(a);  
        case MathConstant.UP -> result = Math.ceil(a);  
        case MathConstant.DOWN -> result = Math.floor(a);  
    }  
    return result;  
}
System.out.println(getData(4.6, MathConstant.ROUND));  
System.out.println(getData(4.6, MathConstant.UP));  
System.out.println(getData(4.6, MathConstant.DOWN));

枚举可以省略类名.,因为形参处已经声明了为MathConstant类

public static double getData(double a,MathConstant mathConstant){  
    double result = 0;  
    switch (mathConstant){  
        case ROUND -> result = Math.round(a);  
        case UP -> result = Math.ceil(a);  
        case DOWN -> result = Math.floor(a);  
    }  
    return result;  
}

设计枚举

需求:在Employee类中,定义一个字段,用来表示在哪一天休息(星期几)。

最简单的设计是这样的:

@Data
public class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

使用时,只要传入1~7即可为employee对象指定具体的休息日:

public class EmployeeDemo {
    public static void main(String[] args) {
        Employee employee = new Employee();
        employee.setRestDay(1);
    }
}

@Data
class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

常量类改进

但是上面的代码有两个问题:

  • 业务含义不明确,在一部分人的认知里,1代表周日,而不是周一,可能会传错
  • 代码没有做任何限制,调用者可以传入任意数字,甚至是负数,这是不合逻辑的

有人可能想到了用接口常量或类常量来解决以上问题,比如:

public class EmployeeDemo {
    public static void main(String[] args) {
        Employee employee = new Employee();
        employee.setRestDay(WeekDay.MONDAY);
    }
}

@Data
class Employee {
    /**
     * 指定员工在哪一天休息
     */
    private Integer restDay;
}

/**
 * 常量类
 */
class WeekDay {
    public static final Integer MONDAY = 1;
    public static final Integer TUESDAY = 2;
    public static final Integer WEDNESDAY = 3;
    public static final Integer THURSDAY = 4;
    public static final Integer FRIDAY = 5;
    public static final Integer SATURDAY = 6;
    public static final Integer SUNDAY = 7;
}

自定义枚举改进

但实际上,常量类仅仅是解决了第一个问题:业务含义明确,代码可读性提高。但调用者仍然可以随意传参,比如仍然允许传入-1。

如果希望对入参进行限制,可以对POJO的set方法进行约束:

然而抛异常并不是最优解,虽然确实最终阻止了错误发生,但是太迟了!调用者在编写代码时仍然可能在毫不知情的情况下写出setRestDay(-1)这样的语句(IDEA只会提示传入Integer类型,却不会提示范围是1~7)。

《Effective Java》的作者说过:编译期错误优于运行期错误,如果一段代码注定会出错,应该尽早暴露以便在编译期就解决问题。但是Java编译器只会做语法检查,不会做逻辑运算。

怎么办?

要对方法的形参进行限制,无非从两个方面考虑:

  • 变量类型(已约束)
  • 变量范围(未约束)

变量类型已经被定为Integer,很大程度上阻止了String、Double等其他类型的参数传入,但变量的范围还没有得到约束。但是你想过为什么用户能传入-1吗?因为Integer本身的范围就是-2147483648 至 2147483647,包含了-1。

如果存在一种Xxx类型,它只有7个元素,分别代表周一到周日,那么我们把它作为setRestDay(Xxx xxx)的类型,不仅约束了变量类型(只能是Xxx类型),还约束了变量范围(只有7个)!

很明显,Java的[[002-Java程序基础#基本数据类型|8大基本类型]]都不符合。

最重要的不是范围太大,而是基本类型的范围不能按我们的需要改变。即,可选范围不能根据业务定制

那我们只剩一条路:根据业务自定义类型。

基本类型无法自定义,所以我们只能新建引用类型。再具体点,就是新建一个类

怎样才能限制Xxx类只有7个元素呢?

不要走回头路:

Xxx.MONDAY和WeekDay.MONDAY本质上没啥区别,就是换了个类名而已。

但是IDEA的错误提示却给了我们灵感:

也就是说,此时restDay需要的是Xxx类型的变量,而不是Xxx.MONDAY。解决问题的一个思路是想办法把Xxx.MONDAY变成Xxx类型。

这听起来很诡异,Xxx.MONDAY竟然是Xxx类型?!

先别想这么多,按这个思路写一下。是不是这样:

class Xxx {
    public Xxx MONDAY;
}

所以原先的代码可以改成这样:

OK,employee.setRestDay(Xxx.MONDAY)总算通过了。

我们再把类名改一下,换个有意义的名字:

初步完成,但别急,停下来仔细看看图中的代码,尝试理解。

理解了吧?

现在我告诉你,上面的代码还是有问题。

类型确实限制为WeekDay,但并没有限制范围。我们完全可以不从WeekDay拿,自己在外面new一个即可

如何限制外部随意创建某个类的呢?对,单例模式:

外界只能从WeekDay取出设定好的7个对象,这下restDay字段的类型和范围都限制住了。

为枚举添加字段让含义更明确

通过单例模式,我们新建了WeekDay,既解决了业务含义不明确的问题(MONDAY见名知意),又对入参做了限制(只能从WeekDay获取设定的7个元素)。但我总觉得原先的MONDAY=1更顺眼,MONDAY=new WeekDay()看起来怪怪的。

是的,直面你内心的疑惑:restDay字段的值是WeekDay对象,存入数据库后会变成什么?

我们原本打算用17代表一周七天,只不过为了**可读性**和**限定范围**,才搞了单例模式,但心里还是希望数据库存的是17。

所以我们必须让MONDAY、TUESDAY这些对象具备特征,最终和1~7形成对应关系。

由于在我们的项目中,1就代表周一,不希望被更改,所以我们可以给WeekDay加上final修饰的属性:

为什么提示我属性可能没有初始化呢?这就要看大家final掌握得如何了。final关键字的赋值有以下几种方式:

  • 显式赋值:private final Integer code = 1
  • 静态代码块/实例代码块赋值(先于构造方法执行)
  • 构造器赋值

因为final变量只能赋值一次(不可变),如果不赋值就是默认值,是没有意义的。就好比你想要一个水桶,希望可以存水,但是它的默认值是水泥,而且出厂以后就不能改了…结果你拿到一个装满水泥的水桶,毫无意义。

所以JDK会强制你给final字段赋值,以保证final字段存的是你期望的值。而创建对象时一定会经历显式赋值、代码块赋值、构造器赋值三个时期,只要在任意一个时期为final字段赋值即可保证对象创建后必然有初始值。

然而构造器有点特殊,因为一个对象可以同时拥有多个构造器。即使准备了Constructor A为final字段初始化,调用者仍可以使用无参构造或者Constructor B创建(假设B不给final字段赋值),如此一来final还是没有被赋值。

所以,当前案例使用final字段时必须禁用无参构造,强制走有参构造,确保final字段初始化。

代码修改如下:

  • 去除空参构造 、设置唯一的有参构造为private,禁止外界new对象并强制为final字段赋值
  • 提供getter方法(不需要setter,因为反正字段是final,无法改变)

枚举与数据库

一部分人可能从来没试过用MyBatis向数据库插入带有复杂类型的POJO

你们可能认为,最坏的结果是序列化存入JSON:

但实际上即使我把数据库Column设置为JSON类型也无法插入restDay:

因为如果不作任何配置,MyBatis默认只能处理简单类型和常见的引用类型,比如String、Integer等,对于复杂类型(自定义类、枚举)会自动忽略:

那么,如何处理POJO中复杂类型的字段呢?

通常来说我们会写一个转换器,不论存入还是取出,都要经过转换器:

  • 存入:从restDay中取出code存入数据库
  • 取出:根据code找到对应的WeekDay赋值给restDay

数据库实际存储的一般不会是整个WeekDay对象,而是WeekDay.code或者WeekDay.desc。

具体如何转换复杂对象,我们会在后续章节介绍。

但我个人有时懒得写转换器,都是直接用简单类型:

这个时候也就不存在什么转换了,你可以理解为就是以前的方式,就是Integer restDay。此时类型已经限制成Integer,但范围需要我们自己控制。可以在WeekDay中新增一个of()方法,用来校验前端传来的code是否合法:

@Getter
class WeekDay {
    public static final WeekDay MONDAY;
    public static final WeekDay TUESDAY;
    public static final WeekDay WEDNESDAY;
    public static final WeekDay THURSDAY;
    public static final WeekDay FRIDAY;
    public static final WeekDay SATURDAY;
    public static final WeekDay SUNDAY;

    private static final WeekDay[] VALUES;

    static {
        // 之前说过,final字段赋值有三种形式,现在我们换成静态代码块赋值
        MONDAY = new WeekDay(1, "星期一");
        TUESDAY = new WeekDay(2, "星期二");
        WEDNESDAY = new WeekDay(3, "星期三");
        THURSDAY = new WeekDay(4, "星期四");
        FRIDAY = new WeekDay(5, "星期五");
        SATURDAY = new WeekDay(6, "星期六");
        SUNDAY = new WeekDay(7, "星期日");
        // 在加载类时就收集所有的WeekDay对象
        VALUES = new WeekDay[]{
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
        };
    }

    /**
     * 校验前端传入的code是否合法
     *
     * @param code
     * @return
     */
    public static WeekDay of(Integer code) {
        for (WeekDay weekDay : VALUES) {
            if (weekDay.code.equals(code)) {
                return weekDay;
            }
        }
        // 如果根据code找不到对应的WeekDay,说明code范围不对,是非法的
        throw new IllegalArgumentException("Invalid Enum code:" + code);
    }

    private WeekDay(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    private final Integer code;
    private final String desc;
}
// 伪代码
public void saveUser(User user){
    // 校验一下
    WeekDay.of(user.getRestDay);
    // 插入
    userMapper.insertSelective(user);
}

请大家特别注意上面的新写法:用static静态代码块初始化final字段 + 用VALUE数组收集所有枚举单例对象。

这样我们就控制住了数值范围。当然,这个并不是编译期错误,本质上还是和一开始的处理方式一样:

讲完数据存入,接下来聊聊取出数据后怎么处理。

枚举与前端

比如我们从数据库查出一个Employee对象:

{
	"name": "bravo",
	"department": "技术部",
	"restDay": 1
}

难道前端这样写?

if (employee.restDay == 1) {
    $("#restDay").val("星期一");
} else if (employee.restDay == 2) {
    $("#restDay").val("星期二");
} else if (employee.restDay == 3) {
    $("#restDay").val("星期三");
} else if (employee.restDay == 4) {
    $("#restDay").val("星期四");
} else if (employee.restDay == 5) {
    $("#restDay").val("星期五");
} else if (employee.restDay == 6) {
    $("#restDay").val("星期六");
} else if (employee.restDay == 7) {
    $("#restDay").val("星期日");
}

这种转换工作最好在后端完成,理由是:后端更清楚各个状态的对应关系。所以我们应该在接口返回结果之前,就把转换工作完成,最终传递”星期一”而不是1。为此我们需要做两步:

  • Employee新增private String restDayDesc字段
  • 新增 getDescByCode()方法

最终代码:

@Getter
class WeekDay {
    public static final WeekDay MONDAY;
    public static final WeekDay TUESDAY;
    public static final WeekDay WEDNESDAY;
    public static final WeekDay THURSDAY;
    public static final WeekDay FRIDAY;
    public static final WeekDay SATURDAY;
    public static final WeekDay SUNDAY;

    private WeekDay(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    
    private static final WeekDay[] VALUES;
    
    static {
        MONDAY = new WeekDay(1, "星期一");
        TUESDAY = new WeekDay(2, "星期二");
        WEDNESDAY = new WeekDay(3, "星期三");
        THURSDAY = new WeekDay(4, "星期四");
        FRIDAY = new WeekDay(5, "星期五");
        SATURDAY = new WeekDay(6, "星期六");
        SUNDAY = new WeekDay(7, "星期日");
        VALUES = new WeekDay[]{
                MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
        };
    }

    private final Integer code;
    private final String desc;

    // 返回所有的对象
    public static WeekDay[] values() {
        return VALUES;
    }

    // 遍历对象,根据code返回code对应的desc
    public static String getDescByCode(Integer code) {
        WeekDay[] weekDays = WeekDay.values();
        for (WeekDay weekDay : weekDays) {
            if (weekDay.getCode().equals(code)) {
                return weekDay.getDesc();
            }
        }
        throw new IllegalArgumentException("Invalid Enum code:" + code);
    }
}
public User getUser(){
    User user = userMapper.selectByPrimaryKey(1L);
    // 为user设置restDayDesc,方便前端展示
    user.setRestDayDesc(WeekDay.getDescByCode(user.getCode()));
    return user;
}
打印结果

Employee{restDay=1, restDayDesc='星期一'}

后话

正如上面介绍的,你可以在DO的字段上直接使用枚举类型,但是要编写相对应的转换器:

关于MyBatis如何转换枚举,请参考后面的章节。

在本文中,我们退而求其次,演示了把restDay字段设置为Integer,然后人工转换的办法:

《阿里巴巴开发手册》中关于枚举有以下描述:

总之,不推荐返回值对象中直接使用枚举。

但大家肯定见过Result类中的这种写法:

但它并没有把枚举对象返回,ExceptionCodeEnum作为入参传入后,其实就被分解为code和desc了,是很普通的Integer和String类型。

枚举与应用

枚举是一种特殊的类,和class、interface等是一个级别的(其实就是一个类),一般用于表示多种固定的状态。

我们在上一节中用WeekDay表示周一到周日,但正版枚举的格式要精简很多

注意,WeekDayEnum命名规则并不是固定的,但推荐以Enum结尾,方便见名知意。当一个类声明为enum时,表示这是一个枚举类,比如enum WeekDayEnum。

对于枚举为何出现,你可以理解为JDK1.5以前很多人和我们之前一样都采用自定义山寨枚举的办法制作枚举类,所以JDK干脆提供了官方版,而且JDK枚举的底层原理正是“抄袭”了我们的山寨枚举。

枚举底层原理

我们对上面的WeekDayEnum反编译:

是不是和我们设计的”枚举”很相似?

区别在于:

  • enum关键字修饰的WeekDayEnum,在编译后会自动继承Enum类,且声明为final
  • 比山寨版枚举多了valueOf()方法,传入名称返回对应的枚举实例
  • values()并不是直接返回WeekDay[],而是$VALUES.clone()

特别是最后一点,我们来对比下。山寨版枚举:

正版枚举反编译:

为什么正版的枚举要$VALUES.clone()呢?

因为如果像山寨版枚举这样返回整个实际数组,外部调用者就可以随意增删枚举对象!

另外,我们可以尝试分别打印正版枚举和我们的山寨枚举:

说明正版枚举重写了toString()方法。

光看WeekDayEnum的代码好像看不出来哪里重写了toString(),于是推测肯定是它的父类Enum重写了:

在Enum这里,有且仅有一个构造器:

而我们都知道子类在new的时候,会调用父类的构造器。

所以子类WeekDayEnum在哪里调了这个构造器、又往它里面传入了什么呢?

我们发现,虽然当初编写WeekDayEnum时,构造器只传入了两个参数,但编译器却给我们额外加了两个(见图一),用来为父类Enum中的name和ordinal赋值(见图二)。name来自MONDAY、TUESDAY等枚举名称,而ordinal则是编写序号。

特别注意,name并不是MONDAY(1, “星期一”)里的desc,而是MONDAY字符串。这很好理解,因为不是所有枚举都有desc字段,它是我们自己定义的字段。如果你愿意,完全可以这样定义枚举:

枚举中的每个对象都有默认的name,就是你看到的通常意义上的枚举名称,而默认toString()会打印name。

name和ordinal

勇敢面对内心的疑问吧:为什么编译器要给构造器额外添加参数name、ordinal?

这个问题其实我们在设计山寨版枚举时讨论过了:

总的来说,就是为了区分枚举对象。因为在编写枚举时,极有可能会这样写:

这个是无参的,反编译后如果不额外添加name、ordinal,就会变成这样:

此时如果你把整个枚举存入数据库,就会都变成”{}”,那么下次取出来就没什么特征性了。

另一个原因是,新增的参数是为了满足valueOf()方法,调用者可以传入字符串,匹配对应的枚举类型。比如

FruitEnum BANANA = FruitEnum.valueOf(“BANANA”)

这样就可以根据name得到BANANA枚举(对象)。

枚举 VS 常量类

枚举和常量类有什么区别呢?

我想起了另一个经常会被问到的问题:

Dubbo和SpringCloud什么区别?

Dubbo只是一个RPC框架,而SpringCloud几乎代表了一整个微服务解决方案,从网关、注册中心到配置中心一应俱全,完全不是一个概念。

同样的,枚举本质是一个对象,可以设置对象该有的一切,而常量类往往只有字段(public static final),枚举对象和常量类的字段是没有可比性的。比如枚举可以自定义方法,常量可以吗?

总之,应该把枚举看成特殊的对象,而不是仅仅将其等同于常量。

作为常量使用时,区别不大

如果实际编码时传递的是枚举的字段而不是枚举本身,比如WeekDayEnum.TUESDAY.getCode(),此时枚举和常量类的用法差别不大。

甚至单纯作为常量用的话,常量类似乎更容易理解且方便。

枚举优点一:限制重复定义常量

枚举作为常量使用时,编译器会自动限制取值不能相同,而常量类做不到,有可能会重复

其实这个优点没什么卵用,凑数。

枚举优点二:包含更多维度的信息

枚举毕竟是对象,可以包含更多的信息

枚举优点三:枚举+Switch比常量+if/else简洁

枚举可以直接用于Switch判断,代码通常会比常量+if/else简洁一些:

至此终于把上面三个“优点”讲完,这也是网上抄来抄去的一些内容。实话实说,我不觉得if/else就怎么了。我的想法是,上面的优点好像都是强行凑数,用来捧枚举,然后踩一脚常量类,个人认为没什么卵用。

枚举真要谈优点,一定要从它作为对象的角度来说,而不是强行把它限定为“特殊的常量类”。

利用枚举完善状态码

枚举做统一返回结果,可以有效规范错误码及错误信息。

如果不用枚举,code和desc做不到绑定,用户传入1的同时可以传入”参数错误”,这样状态码和信息就错乱了。

所以最好不要这样设计:

状态码和状态描述之间没有必然联系,是散乱的。

枚举策略

再讲一个利用枚举消除if/else的案例,具体这是什么设计模式其实不重要,不管黑猫白猫,能抓老鼠就是好猫。

比如一个网上商城有会员制度:

  • 黄金会员:6折
  • 白银会员:7折
  • 青铜会员:8折

无论在商城里买什么,都会按照会员的折扣优惠。

用常量类实现:

public class MemberDemo {
    public static void main(String[] args) {
        User user = new User(1L, "bravo", Constants.GOLD_MEMBER);
        BigDecimal productPrice = new BigDecimal("1000");
        BigDecimal discountedPrice = calculateFinalPrice(productPrice, user.getMemberType());
        System.out.println(discountedPrice);
    }

    /**
     * 根据会员身份返回折扣后的商品价格
     *
     * @param originPrice
     * @param user
     * @return
     */
    public static BigDecimal calculateFinalPrice(BigDecimal originPrice, Integer type) {
        if (Constants.GOLD_MEMBER.equals(type)) {
            return originPrice.multiply(new BigDecimal("0.6"));
        } else if (Constants.SILVER_MEMBER.equals(type)) {
            return originPrice.multiply(new BigDecimal("0.7"));
        } else if (Constants.BRONZE_MEMBER.equals(type)) {
            return originPrice.multiply(new BigDecimal("0.8"));
        } else {
            return originPrice;
        }
    }
}

@Data
@AllArgsConstructor
class User {
    private Long id;
    private String name;
    /**
     * 会员身份
     * 1:黄金会员,6折优惠
     * 2:白银会员,7折优惠
     * 3:青铜会员,8折优惠
     */
    private Integer memberType;
}

class Constants {
    /**
     * 黄金会员
     */
    public static final Integer GOLD_MEMBER = 1;
    /**
     * 白银会员
     */
    public static final Integer SILVER_MEMBER = 2;
    /**
     * 青铜会员
     */
    public static final Integer BRONZE_MEMBER = 3;
}

上面的代码有两个缺点:

  • if/else过多,如果会员制度再复杂些,语句会更长,阅读性不佳
  • 不利于扩展。如果后期和百度一样不要脸地在vip基础上新增svip,需要修改逻辑判断的代码,这很容易出错

用枚举实现(在枚举类中定义抽象方法,强制子类实现):

/**
 * @author qiyu
 * @date 2020-09-26 02:57
 */
public class MemberDemo {
    public static void main(String[] args) {
        User user = new User(1L, "bravo", MemberEnum.GOLD_MEMBER.getType());
        BigDecimal productPrice = new BigDecimal("1000");
        BigDecimal discountedPrice = calculateFinalPrice(productPrice, user.getMemberType());
        System.out.println(discountedPrice);
    }

    /**
     * 根据会员身份返回折扣后的商品价格
     *
     * @param originPrice
     * @param user
     * @return
     */
    public static BigDecimal calculateFinalPrice(BigDecimal originPrice, Integer type) {
        return MemberEnum.getEnumByType(type).calculateFinalPrice(originPrice);
    }
}

@Data
@AllArgsConstructor
class User {
    private Long id;
    private String name;
    /**
     * 会员身份
     * 1:黄金会员,6折优惠
     * 2:白银会员,7折优惠
     * 3:青铜会员,8折优惠
     */
    private Integer memberType;
}

enum MemberEnum {
    // ---------- 把这几个枚举当做本应该在外面实现的MemberEnum子类,不要看成MemberEnum内部的 ----------
    GOLD_MEMBER(1, "黄金会员") {
        @Override
        public BigDecimal calculateFinalPrice(BigDecimal originPrice) {
            return originPrice.multiply(new BigDecimal(("0.6")));
        }
    },
    SILVER_MEMBER(2, "白银会员") {
        @Override
        public BigDecimal calculateFinalPrice(BigDecimal originPrice) {
            return originPrice.multiply(new BigDecimal(("0.7")));
        }
    },
    BRONZE_MEMBER(3, "青铜会员") {
        @Override
        public BigDecimal calculateFinalPrice(BigDecimal originPrice) {
            return originPrice.multiply(new BigDecimal(("0.8")));
        }
    },
    ;

    // ---------- 下面才是MemberEnum类的定义 ---------
    private final Integer type;
    private final String desc;

    MemberEnum(Integer type, String desc) {
        this.type = type;
        this.desc = desc;
    }

    /**
     * 定义抽象方法,留个子类实现
     *
     * @param originPrice
     * @return
     */
    protected abstract BigDecimal calculateFinalPrice(BigDecimal originPrice);

    public Integer getType() {
        return type;
    }

    public String getDesc() {
        return desc;
    }

    public static MemberEnum getEnumByType(Integer type) {
        MemberEnum[] values = MemberEnum.values();
        for (MemberEnum memberEnum : values) {
            if (memberEnum.getType().equals(type)) {
                return memberEnum;
            }
        }
        throw new IllegalArgumentException("Invalid Enum type:" + type);
    }
}

枚举类本身比较抽象,上面的代码可能一部分人会一时转不过弯。那你就想象着把三个枚举抽出来:

当然,你也可以新增一个接口,在接口内部定义calculateFinalPrice(),让MemberEnum实现该接口

我敢肯定,必然有部分同学觉得我在故弄玄虚,搞了一大堆,但看起来反而更麻烦。

大家不要光看当前的代码量,而要考虑扩展。如果此时需要新增会员类型,那么使用常量的版本需要改动两处:

  • 常量类
  • if/else所在的类

而枚举版本只要在枚举类中新增一个类型:

修改if/else的犯错概率一般远大于新增枚举类。这其实也体现了设计模式的思想:用增量代替修改。

还是觉得枚举麻烦?

此时应该给代码加上一根时间轴,动态地看待问题。比如,假设随着时间推移,不可避免地需要在很多地方对用户的身份进行判断,于是你的同事把你写的if/else拷贝到项目的各个角落。


而产品的需求改到第二版时才发现漏算了星耀会员,于是你需要找到所有相关的if/else进行修改…你必须细致,只要有一处忘了改就会出错,增加了测试和维护的成本。

但如果采用策略枚举的方式,把易变动的逻辑抽取出来共同维护就会方便很多。

设计模式中有个说法:越抽象越稳定,越具体越不稳定,所以提倡面向抽象编程。设计模式的目的不是消除变化,而是隔离变化。在软件工程中,变化的代码就像房间里一只活蹦乱跳的兔子,你并不能让它绝对安静(不要奢望需求永不变更),但可以准备一个笼子把它隔离起来,从而达到整体的稳定。

上面的代码中,if/else是兔子,而MemberEnum则扮演着笼子的角色。如果不用MemberEnum把兔子关在一起,那么兔子就会在整个项目蹦跶,很难管理。

上面案例充分说明作为对象的枚举作为字段的常量可玩性高很多。

枚举代码优化

优化1:尽量使用foreach

举个例子,对于普通的for循环:

每次循环其实都会判断i<list.size(),每次都会调用size()获取大小,而foreach则会进行优化:

其他写法:

如果你学过Java8的Stream,还可以这样写:

抛不抛异常看业务,个人觉得返回null意义也不大,反正要么空指针要么插入不可预计的值。

优化2:用Map替代For,缓存枚举值,利用key提高检索速度(实用小算法)

这种写法是我在研究MyBatis枚举转换器时看源码发现的:

优化3:静态导入简化代码(看个人喜好,不是很好用)

一般都是这样写:

但有时代码会看起来很长,此时可以使用静态导入:

优化4:结合Lombok简化代码

@Getter
@AllArgsConstructor
public enum PlatformEnum {
    TAOBAO(1, "淘宝", "taobao", "http://xxx"),
    PDD(2, "拼多多", "pdd", "http://xxx");

    private final Integer code;
    private final String sourceKey;
    private final String iconUrl;

    public static PlatformEnum getByCode(Integer code) {
        for (PlatformEnum platformEnum : PlatformEnum.values()) {
            if (platformEnum.getCode().equals(code)) {
                return platformEnum;
            }
        }
        return null;
    }
}

枚举与常量类的取舍

最后说一下个人平时对枚举和常量类的取舍:

  • 错误码的定义用枚举
  • 字段状态用枚举或常量类都可以,个人更倾向于使用枚举,可玩性会比常量类高
  • 一些全局变量的定义可以使用常量类,比如USER_INFO、SESSION_KEY

一点建议

大部分人设计枚举时都是使用以下格式:

MONDAY(1, “星期一”)

这种格式在当前情境下是合理的,但如果是其他场景:

WAIT_FOR_DEAL(1, “等待处理”)

SUITABLE(2, “合适”)

UNSUITABLE(3, “不合适”)

INVITE(4, “邀请面试”)

随着状态越来越多,前端同学会变得很痛苦。所以如果你的项目允许,可以使用MONDAY(“MONDAY”, “星期一”)这种形式,也就是存字符串。

这个时候其实没必要考虑索引键长问题,你就当它是一个普通字段。退一步想,如果这是name字段,难道你会因为索引的原因而使用int类型吗…考虑前后端对接及维护,代码的可读性有时比一丁点所谓的性能还重要。

扩展

在网上看到的写法,但个人觉得不是很好。他的出发点是每个枚举类都要写getEnumByType()方法,太麻烦了,希望把各个类中的getEnumByXxx()方法抽取到工具类中:

public class EnumUtil {
    private static final Map<Object, Object> key2EnumMap = new ConcurrentHashMap<>();
    private static final Set<Class<?>> enumSet = ConcurrentHashMap.newKeySet();

    /**
     * 获取枚举(带缓存)
     *
     * @param enumType          枚举Class类型
     * @param enumToValueMapper 抽取字段(按哪个字段搜索)
     * @param key               待匹配的字段值
     * @param <T>
     * @return
     */
    public static <T extends Enum<T>, R> Optional<T> getEnumWithCache(Class<T> enumType, Function<T, R> enumToValueMapper, Object key) {
        if (!enumSet.contains(enumType)) {
            // 不同的枚举类型互相不影响
            synchronized (enumType) {
                if (!enumSet.contains(enumType)) {
                    // 添加枚举
                    enumSet.add(enumType);
                    // 缓存枚举键值对
                    for (T enumConstant : enumType.getEnumConstants()) {
                        // enumToValueMapper.apply()表示从枚举中获得value。但不同枚举可能value相同,因此getKe()进一步加工,为value拼接路径做前缀
                        String mapKey = getKey(enumType, enumToValueMapper.apply(enumConstant));
                        key2EnumMap.put(mapKey, enumConstant);
                    }
                }
            }
        }

        return Optional.ofNullable((T) key2EnumMap.get(getKey(enumType, key)));
    }


    /**
     * 获取key
     * 注:带上枚举类名作为前缀,避免不同枚举的Key重复
     *
     * @param enumType
     * @param key
     * @param <T>
     * @return
     */
    public static <T extends Enum<T>, R> String getKey(Class<T> enumType, R key) {
        return enumType.getName().concat(key.toString());
    }

    /**
     * 获取枚举(不缓存)
     *
     * @param enumType
     * @param enumToValueMapper
     * @param key
     * @param <T>
     * @param <R>
     * @return
     */
    public static <T extends Enum<T>, R> Optional<T> getEnum(Class<T> enumType, Function<T, R> enumToValueMapper, Object key) {
        for (T enumThis : enumType.getEnumConstants()) {
            if (enumToValueMapper.apply(enumThis).equals(key)) {
                return Optional.of(enumThis);
            }
        }
        return Optional.empty();
    }

    // ----------- 测试工具类 ------------
    public static void main(String[] args) {
        // 按code搜索,寻找code=2的FruitEnum对象
        Optional<FruitEnum> fruitEnum = EnumUtil.getEnumWithCache(FruitEnum.class, FruitEnum::getCode, 2);
        fruitEnum.ifPresent(fruit -> System.out.println(fruit.getName()));
    }

    @Getter
    enum FruitEnum {
        APPLE(1, "苹果"),
        BANANA(2, "香蕉"),
        ORANGE(3, "橘子"),
        ;

        FruitEnum(Integer code, String name) {
            this.code = code;
            this.name = name;
        }

        private final Integer code;
        private final String name;
    }
}

为什么我认为上面的Util工具不好呢?

  • 首先,getEnumWithCache()最后返回值需要强转,IDEA提示看着很难受。但似乎无法解决,因为key2EnumMap只能用Object,所以注定了需要强转。
  • 其次,也是最重要的,没有对Function enumToValueMapper做限制。也就是说用户可以随意传入映射参数,有些人把枚举映射为name,有些人把枚举映射为oridnal,还有其他属性。也就说,很乱。到时取出时,你都不知道自己当初存的key是什么。

当然,如果你实在需要一个EnumUtil简化操作,那么下面这个简化版的可能更适合你(其实hutool这类第三方工具都有提供类似的功能):

@Slf4j
public class EnumUtil {
    
    /**
     * 获取枚举类型的枚举值,通过属性值
     *
     * @param clazz         枚举类
     * @param fieldFunction 属性值获取函数
     * @param fieldValue    属性值
     * @param <E>           枚举类
     * @param <V>           属性值
     * @return 匹配的枚举值,可能为null
     */
    public static <E extends Enum<E>, V> E getEnum(Class<E> clazz, Function<E, V> fieldFunction, V fieldValue) {
        Assert.notNull(clazz);
        E[] es = clazz.getEnumConstants();
        for (E e : es) {
            if (Objects.equals(fieldFunction.apply(e), fieldValue)) {
                return e;
            }
        }
        return null;
    }
    
    /**
     * 获取枚举类型的枚举值,通过属性值
     *
     * @param clazz         枚举类
     * @param fieldFunction 属性值获取函数
     * @param fieldValue    属性值
     * @param <E>           枚举类
     * @param <V>           属性值
     * @return 匹配的枚举值,Optional类型
     */
    public static <E extends Enum<E>, V> Optional<E> getEnumOptional(Class<E> clazz, Function<E, V> fieldFunction, V fieldValue) {
        Assert.notNull(clazz);
        E[] es = clazz.getEnumConstants();
        for (E e : es) {
            if (Objects.equals(fieldFunction.apply(e), fieldValue)) {
                return Optional.of(e);
            }
        }
        return Optional.empty();
    }
}

疑问解答

为什么枚举类内部可以定义抽象方法?

Enum本身就是一个抽象类,却没有定义抽象方法。按之前小册讲过的:一个类,没有抽象方法,却被定义为抽象类,主要是为了不被new。同理 UserStatusEnum extends Enum后,自己也是一个抽象类,也没有任何抽象方法。

我们通常说的 枚举,一般是指枚举对象,而不是枚举类。

@Getter
@AllArgsConstructor
public enum UserStatusEnum { // 这个才是枚举类

    // 这两个看起来是“字段”的玩意儿,其实是对象(枚举对象)
    DELETE(-1, "删除"),
    DEFAULT(0, "默认");

    private final Integer status;
    private final String desc;

}

所以,我在一个抽象类UserStatusEnum中定义一个抽象方法有什么问题呢?再者,既然我在UserStatusEnum中既然定义了抽象方法,那么DELETE、DEFAULT两个枚举对象就必须实现这个方法。

@Getter
@AllArgsConstructor
public enum UserStatusEnum {

    DELETE(-1 "删除"){
        // 实现UserStatusEnum定义的抽象方法
        @Override
        public void test() {
            
        }
    },
    DEFAULT(0, "默认") {
        @Override
        public void test() {
            
        }
    };

    private final Integer status;
    private final String desc;

    // 定义一个抽象方法
    public abstract void test();

}

枚举最令人困惑的地方在于,枚举类的定义和枚举对象是混在一个文件中的,再加上一些后期的自动编译处理,让初学者看起来觉得很抽象。但你如果把DELETE、DEFAULT单独拎出去,想象成一个独立的对象或实现类,会更容易接受。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。