文章

《Java 面向对象》

《Java 面向对象》

一、面向对象

1. 对面向对象的认知

面向过程:遇到一个问题时,将解决问题的方法拆分成一个个函数和数据,然后按一定的顺序执行完。这是一个具体的、流程化的过程。

📝 举例:洗衣服的流程

先加洗衣粉 → 再加水 → 再把衣服扔进洗衣机 → 洗衣机洗衣 → 再烘干

面向对象:将解决问题的方法模型化、抽象化成对象。然后给这些对象赋予属性和方法,并让对象来执行这些操作。

📝 同样是洗衣服:可以抽象出人和洗衣机两个对象

  • 人.放洗衣粉()
  • 人.加水()
  • 人.放入洗衣机()
  • 洗衣机.洗衣服()
  • 洗衣机.烘干()

2. 面向对象与面向过程的优缺点

特性面向对象面向过程
优点• 易维护、易拓展、易复用
• 封装、继承、多态等特性,能设计出低耦合的系统
• 性能更好
• 类调用时需要实例化,开销比较大
• 适合注重性能的程序(如 Linux、单片机)
缺点性能相对较低难维护、难拓展、难复用

3. 面向对象三大特性

(1)封装

定义:把对象中的属性私有化,不允许外部直接访问对象内部的属性。但可以提供被外部访问的方法。

优点:

  • ✅ 隐藏信息,实现细节
  • ✅ 良好的封装可以减少耦合
  • ✅ 可以对成员变量更加精准的控制

(2)继承

定义:在已有类的基础上建立新类的特性。继承使得代码复用非常容易。新的类可以调用父类的属性、方法,也可以有自己的属性、方法。

优点:

  • ✅ 提高代码的复用性
  • ✅ 提高代码的维护性
  • ✅ 让类与类之间产生了关系,为多态的到来提供了基础

(3)多态

定义:一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须由程序运行期间才能决定,这就是多态性。

必要条件:

  • 继承
  • 重写
  • 向上转型

实现方式:

  • 重写(运行时多态)
  • 重载(编译时多态)

优点:

  • ✅ 解耦
  • ✅ 提高代码的可扩展性

二、抽象类和接口

1. 抽象类和接口的区别

面向对象的设计中,复用的重点是抽象层的复用,抽象类和接口都是抽象层的体现。

相同点

  • 都不能被实例化
  • 都可以包含方法声明

不同点

特性抽象类接口
方法实现可以提供某些方法的实现JDK 8 之前不能提供方法实现
JDK 8 之后可以用 default 关键字实现默认方法
继承方式使用 extends 关键字
Java 单继承,一个类只能继承一个抽象类
使用 implements 关键字
一个类可以实现多个接口
访问修饰符方法和变量的访问修饰符可以是任意的方法和变量的访问修饰符必须是 public,且默认是 public
属性属性可以是任意的属性必须是 static final

💡 JDK 8 新特性:接口可以实现默认方法

在方法前加 default 关键字,就能在接口中实现该方法,继承该接口的类也默认可以调用这个方法。


2. 实现多个接口中的重名默认方法时的调用规则

当一个类实现了多个接口中的重名默认方法时,调用优先级如下:

  1. 本类重写优先:首先判断本类是否重写了默认方法,如果重写了,就直接调用本类的方法
  2. 父类方法优先:如果本类没重写,判断其父类是否重写了。本类和父类中方法优先级最高
  3. 子接口优先:如果以上无法判断,那么子接口的优先级更高
    • 例如:类 C 继承了接口 B,接口 B 继承了接口 A
    • 在接口 A 和 B 中都实现了一个重名默认方法
    • B 的优先级更高
  4. 显示声明:如果继承的多个接口是同级的(如接口 A 和接口 B 没有关系),那么就得在本类中显示声明要调用哪一个接口中的实现

3. 抽象类能用 final 修饰吗?

不能。抽象类是提供其他类继承的,用 final 修饰就不能被继承了。


三、方法

1. 构造器(构造方法)

构造方法的特点:

  • 方法名和类名相同
  • 构造方法不能被继承,所以不能被重写,但可以被重载
  • 没有返回值,但不能加 void
  • 只能在类创建时调用

2. 值传递和引用传递

定义

  • 值传递:传递的参数是值的拷贝。即使在函数中被改动也只是改动的副本
  • 引用传递:传递的参数是参数的地址,类似 C++ 中的指针,指向参数对应的内存地址。在方法中改动对应地址中的值会影响到任何指向该地址区域的变量

Java 中的参数传递方式

Java 中是值传递,无论是基本数据类型还是对象。

  • 如果方法传递的参数是基本类型:传递的是基本类型变量的拷贝(就是一个数值)
  • 如果方法传递的参数是引用类型:传递的是引用类型变量在堆中地址值的拷贝

3. 重载和重写的区别

特性重载(Overload)重写(Override)
发生位置同一个类中父类和子类中
多态类型编译时多态运行时多态
方法名相同相同
参数列表不同(类型、个数、顺序)必须完全相同
返回类型无关(不能通过返回类型区分)相同或协变
访问修饰符无关不能比父类更严格

4. 方法的调用:静态绑定与动态绑定

静态绑定

程序在编译期间就明确知道调用的方法是属于哪个类。

在 Java 里面一般特指:

  • private、static、final 修饰的方法
  • 构造方法

动态绑定

程序在运行期间才能知道具体调用的方法属于哪个类。

一般发生在:

  • 父类的引用指向子类的实例
1
2
People p = new Student();
p.getName(); // 运行时才能确定调用哪个 getName()

方法查找过程:

  • 会搜索包括 Student(子类)在内的所有 getName 方法以及其父类的 public 修饰 getName 方法
  • 如果存在唯一方法签名就调用它
  • 如果子类父类都存在,则先调用子类的,没有再调用父类的
  • 每一个类有一个方法表解决效率问题

四、static 关键字

1. 作用

  • 在不创建对象的情况下调用方法或使用属性
  • 提升程序性能

2. static 可以修饰的内容

(1)修饰变量 - 静态变量

  • 可以通过类名直接调用
  • 静态变量是被该类的所有对象共享
  • 在内存中只有一个副本
  • 在类加载时初始化

(2)修饰方法 - 静态方法

  • 可以通过类名直接调用
  • 比如工具类中大部分方法都是通过类名调用的

(3)修饰代码块 - 静态代码块

  • 静态代码块内的语句只会在类加载时执行一次
  • 可以将一些只需要操作一遍的代码放到静态代码块中,提升程序性能

(4)修饰内部类 - 静态内部类

  • 只有内部类可以加上 static 修饰符
  • 静态内部类不依赖于外部类
  • 在没有外部类的情况下,也能创建内部类对象

3. 注意事项

static 不能修饰局部变量,这是 Java 的规定。


五、类的初始化顺序

类的初始化遵循以下顺序:

  1. 先从上向下初始化静态数据(静态初始化块、静态初始化语句)
  2. 遇到 new,从上向下执行初始化非静态数据(非静态成员的初始化),最后执行对应的这个 new 的构造函数
  3. 下面还有静态初始化的接着初始化,直到所有静态的都初始化完,类加载完
  4. 执行 main 方法,有 new 操作符 → 从上往下执行非静态的初始化 → 最后执行该 new 的构造函数

六、内部类

1. 内部类的分类

(1)成员内部类

  • 直接将一个类 A 定义在另一个类 B 中,A 就是 B 的成员内部类
  • 成员内部类依赖于外部类,只有创建了外部类的实例后,才能创建内部类的实例
  • 内部类中可以访问外部类的所有属性

(2)局部内部类

  • 定义在一个方法中或一个作用域中
  • 作用域仅限方法内或代码块内
  • 就像局部变量一样,不能用 public、protect、private、static 修饰

(3)静态内部类

  • 和成员内部类都是定义在类里面,只是类前面多了关键字 static
  • 静态内部类不依赖于外部类,即在没有外部类的情况下,也能创建内部类对象
  • 但静态内部类无法访问外部类中的非静态属性,这点和静态方法类似

(4)匿名内部类

  • 没有名字的内部类
  • 通常是在方法的参数中实现一个接口或抽象类
  • 使用匿名内部类能简化开发,不用再专门定义一个实现类

2. 使用内部类的好处

  • ✅ 内部类对外界隐藏,有更好的私密性
  • ✅ 内部类可以访问外部类中的所有属性
  • ✅ 每个内部类能够继承一个接口或抽象类,有效解决了 Java 单继承的特性

3. 内部类中为什么静态域必须是 final 修饰的?

这个问题涉及到内部类的类型区分:

非静态内部类(成员内部类)

  • 根本不能定义静态成员(除了 static final 常量)
  • 原因:非静态内部类依赖于外部类的实例才能存在
  • 而静态成员属于类本身,不依赖于任何实例
  • 如果允许定义非 final 的静态成员,会造成矛盾:
    • 静态成员不依赖实例
    • 但非静态内部类又必须依赖外部类实例
  • 因此 Java 规定非静态内部类中的静态域必须是 static final 的
  • 这样它就是一个编译时常量,不会产生依赖问题

静态内部类

  • 可以定义任意的静态成员
  • 原因:静态内部类不依赖于外部类的实例
  • 它本身就是独立的,和普通的类没有区别

七、Lambda 表达式与函数式接口

1. Lambda 表达式

(1)为什么用 Lambda 表达式?

受 Java 面向对象影响,我们做什么都需要一个对象。但是当我们仅仅需要某个接口中的某个方法时:

传统方式:

  1. 先通过一个类来实现这个接口
  2. 再实例化一个对象来调用这个方法
  3. 或者通过匿名内部类来实现接口
  4. 必须重写方法

Lambda 方式:

  • 我们仅仅想要传入参数,按照自定义方法体执行而已
  • Lambda 表达式只关注入参与方法内容,其他一律不管

(2)格式

1
() -> {}

说明:

  • 前面小括号 () 放的是入参
    • 可以没有,可以有多个,逗号隔开
    • 只有一个参数时,小括号可以省略
    • 参数类型可以省略
  • 后面大括号 {} 是要执行的方法体
    • 当只有一行代码时,大括号可以省略

(3)示例

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 常规编程,匿名内部类的方式,重写 run 方法
new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("多线程任务执行!");
    }
}).start();

// 2. Lambda 表达式
new Thread(() -> {
    System.out.println("多线程任务执行!");
}).start();

2. 函数式接口

定义:函数式接口通过搭配 Lambda 表达式来使用。

规范:

  • 是一个接口
  • 有且仅有一个抽象方法

写法示例

1
2
3
4
5
6
7
8
9
10
// 1. 按照规范书写
public interface MyFunctionalInterface {
    void myMethod();
}

// 2. 使用 @FunctionalInterface 注解
@FunctionalInterface
public interface MyFunctionalInterface {
    void myMethod();
}

💡 @FunctionalInterface 注解

该注解加在接口上,能自动判断是不是函数式接口,可加可不加。


八、异常

1. 异常的层次结构

所有异常的父类是 Throwable,它有两个子类:

  • Error
  • Exception

2. Error

定义:表示系统错误,通常不能处理和恢复。

示例:

  • StackOverFlowError
  • OutOfMemoryError

出了问题只能结束程序。


3. Exception

定义:表示程序可以处理的异常。

Exception 又分为:

  • Checked Exception(被检查异常)
  • Runtime Exception(运行时异常,也叫做不被检查的异常)

Checked Exception

  • 应该被检查并尝试修复
  • 例如:FileNotFoundException

Runtime Exception

  • 不要求被处理
  • 例如:NullPointerException
  • 通常是因为糟糕的编码造成的

4. throw 和 throws 的区别

特性throwthrows
使用位置在方法内在方法声明后面
作用抛出一个异常的实例声明该方法可能抛出的异常
后接内容异常的实例对象异常的类名
调用者处理-其他类调用这个方法时可以处理异常,或者继续抛出去

📌 注意:只要异常不是运行时异常,方法声明后面要加上 throws,接上对应的异常名。


5. 什么是异常链?

异常链就是在异常处理的过程中抛出了另外的异常,通常是人为的操作,从而产生了一个异常链。

在抛出新异常的时候要添加当前的异常作为参数,这样就方便追根溯源,获取异常的产生序列。


6. Java 中如何自定义一个异常?

  1. 直接继承一个现有的 Exception 类
  2. 取一个容易识别的名字,比如 UsernameNotExistException
  3. 还可以实现一个有参构造,用来传递详细信息

7. try-catch-finally 语句的流程走向

  1. try 包含可能出现异常的语句,出现异常,跳到相对应的 catch
  2. 如果 catch 有 return 语句:
    • 并不直接返回,进入 finally
    • finally 有 return:从 finally 返回,try-catch-finally 结束,包含 try 的方法也结束
    • finally 没有 return:将 catch 中的 return 返回,try-catch-finally 结束,包含 try 的方法也结束
  3. 如果 catch 没有 return 语句:
    • 进入 finally
    • finally 有 return:从 finally 返回,try-catch-finally 结束,包含 try 的方法也结束
    • finally 没有 return:try-catch-finally 结束,但是方法没有结束,执行 try-catch-finally 外面的语句

九、反射与代理

1. 反射

什么是反射?

JAVA 反射机制是在运行状态中:

  • 对于任意一个类,都能够获取这个类的所有属性和方法
  • 对于任意一个对象,都能够调用它的任意一个方法和属性

这种动态获取类信息以及动态调用对象内容就称为 Java 语言的反射机制。

获取 Class 对象的三种方式

1
2
3
4
5
6
7
8
9
// 方式 1:类名.class
Class<?> clazz1 = String.class;

// 方式 2:对象.getClass()
String str = "hello";
Class<?> clazz2 = str.getClass();

// 方式 3:Class.forName("类的全限定名")
Class<?> clazz3 = Class.forName("java.lang.String");

反射机制的优缺点

优点:

  • ✅ 可以运行时进行类或对象的操作
  • ✅ 能提高程序的灵活性和拓展性

缺点:

  • ⚠️ 使用反射相当于一系列的解释操作,性能比直接 new 对象要差得多
  • ⚠️ 使用反射操作类中的私有成员增加了安全隐患

反射的作用

  1. 在运行时,获取类的信息
  2. 在运行时,根据对象反推得到所属的类
  3. 在运行时,实例化对象,并且可以调用该对象方法

反射的常见应用场景

  1. Spring 框架:根据 xml 中配置的 Bean 的类名,反射创建对应的对象
  2. 数据库连接:使用 Class.forName("com.mysql.jdbc.Driver") 来加载驱动
  3. 大多数框架:例如 Tomcat 根据配置文件扫描对应的包,然后获取类的信息,从而获取到实例对象的能力

十、代理模式

1. 代理的作用

在不改变原代码的情况下实现增强代码。

📝 例如:为公司项目的每一个方法前后添加打印日志功能,并且不修改源代码。


2. 静态代理

实现方式:

  • 需要为项目的每一个类编写一个对应的代理类
  • 代理类和目标类实现同一个接口

代理对象干的事:

  • 通过构造器塞入一个目标对象
  • 然后在代理对象的方法内部调用目标对象同名方法
  • 并在调用前后打印日志

💡 代理对象 = 增强代码 + 目标对象(原对象)

静态代理的缺陷:

  • 需要为每一个类编写代理对象
  • 假设项目有 1000 个类,就需要写 1000 个代理类

所以我们需要动态代理。


3. 动态代理

静态代理和动态代理本质都是最终生成代理对象:

  • 静态代理:需要先手动创建代理类,再创建代理对象
  • 动态代理:因为接口中含有了被代理类的构造信息,只需要通过接口 + 一个构造方法自动生成一个代理对象,直接跳过了代理类的书写过程

4. JDK 的动态代理

自动生成代理对象的过程

  1. 通过 Proxy.getProxyClass(ClassLoader, interfaces) 获取代理类的 Class 对象
  2. 通过代理类的 Class 对象获取一个代理类的有参构造器,参数为 InvocationHandler 接口的 Class 对象
  3. 通过有参构造器的 newInstance(new InvocationHandler(){}) 方法反射获得代理对象

原理展示代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.lang.reflect.*;

public class Main {
    public static void main(String[] args) throws Exception {
        /**
         * 第一步:通过接口的类加载器以及接口的 class 对象获取代理类的 class 对象
         */
        Class proxyClass = Proxy.getProxyClass(
            Study.class.getClassLoader(), 
            Study.class
        );
        
        /**
         * 第二步:获取有参的构造器
         */
        Constructor constructor = proxyClass.getConstructor(InvocationHandler.class);
        
        /**
         * 第三步:通过构造器反射生成代理对象
         */
        Study s = (Study) constructor.newInstance(new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) 
                    throws Throwable {
                // 代理增强,学习算法前我需要先熟悉数据结构
                System.out.println("先学习数据结构");
                return method.invoke(proxy, args);
            }
        });
    }
}

实际开发中的使用方式

现实中编程一般会用 Proxy.newProxyInstance(),它是上面三步的组合,隐藏了获取代理对象的细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Main2 {
    public static void main(String[] args) throws Exception {
        /**
         * 被代理目标对象,实现了 Study 接口
         */
        Student student = new Student();
        
        /**
         * 获取代理对象,类型为 Study 接口
         */
        Study studentProxy = (Study) getProxy(student);
        
        /**
         * 代理对象调用目标对象方法,并实现代码增强
         * 调用目标对象方法之前都会先调用 invoke 方法
         */
        studentProxy.suanfa();
    }
    
    public static Object getProxy(final Object target) throws Exception {
        /**
         * 传入一个类加载器,目标对象实现的接口,以及一个 InvocationHandler
         * 使用 newProxyInstance 可以帮你隐藏具体得到代理对象的细节
         */
        Object proxy = Proxy.newProxyInstance(
            target.getClass().getClassLoader(),
            target.getClass().getInterfaces(),
            new InvocationHandler() {
                @Override
                public Object invoke(Object proxy, Method method, Object[] args) 
                        throws Throwable {
                    System.out.println("代码增强,先学习数据结构");
                    System.out.println(method.getName() + "方法开始执行...");
                    Object result = method.invoke(target, args);
                    System.out.println(result);
                    System.out.println(method.getName() + "方法执行结束...");
                    return result;
                }
            }
        );
        return proxy;
    }
}

📚 参考资料:本文内容基于个人学习笔记整理

本文由作者按照 CC BY 4.0 进行授权