《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. 实现多个接口中的重名默认方法时的调用规则
当一个类实现了多个接口中的重名默认方法时,调用优先级如下:
- 本类重写优先:首先判断本类是否重写了默认方法,如果重写了,就直接调用本类的方法
- 父类方法优先:如果本类没重写,判断其父类是否重写了。本类和父类中方法优先级最高
- 子接口优先:如果以上无法判断,那么子接口的优先级更高
- 例如:类 C 继承了接口 B,接口 B 继承了接口 A
- 在接口 A 和 B 中都实现了一个重名默认方法
- B 的优先级更高
- 显示声明:如果继承的多个接口是同级的(如接口 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 的规定。
五、类的初始化顺序
类的初始化遵循以下顺序:
- 先从上向下初始化静态数据(静态初始化块、静态初始化语句)
- 遇到 new,从上向下执行初始化非静态数据(非静态成员的初始化),最后执行对应的这个 new 的构造函数
- 下面还有静态初始化的接着初始化,直到所有静态的都初始化完,类加载完
- 执行 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 面向对象影响,我们做什么都需要一个对象。但是当我们仅仅需要某个接口中的某个方法时:
传统方式:
- 先通过一个类来实现这个接口
- 再实例化一个对象来调用这个方法
- 或者通过匿名内部类来实现接口
- 必须重写方法
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,它有两个子类:
ErrorException
2. Error
定义:表示系统错误,通常不能处理和恢复。
示例:
StackOverFlowErrorOutOfMemoryError
出了问题只能结束程序。
3. Exception
定义:表示程序可以处理的异常。
Exception 又分为:
- Checked Exception(被检查异常)
- Runtime Exception(运行时异常,也叫做不被检查的异常)
Checked Exception
- 应该被检查并尝试修复
- 例如:
FileNotFoundException
Runtime Exception
- 不要求被处理
- 例如:
NullPointerException - 通常是因为糟糕的编码造成的
4. throw 和 throws 的区别
| 特性 | throw | throws |
|---|---|---|
| 使用位置 | 在方法内 | 在方法声明后面 |
| 作用 | 抛出一个异常的实例 | 声明该方法可能抛出的异常 |
| 后接内容 | 异常的实例对象 | 异常的类名 |
| 调用者处理 | - | 其他类调用这个方法时可以处理异常,或者继续抛出去 |
📌 注意:只要异常不是运行时异常,方法声明后面要加上 throws,接上对应的异常名。
5. 什么是异常链?
异常链就是在异常处理的过程中抛出了另外的异常,通常是人为的操作,从而产生了一个异常链。
在抛出新异常的时候要添加当前的异常作为参数,这样就方便追根溯源,获取异常的产生序列。
6. Java 中如何自定义一个异常?
- 直接继承一个现有的 Exception 类
- 取一个容易识别的名字,比如
UsernameNotExistException - 还可以实现一个有参构造,用来传递详细信息
7. try-catch-finally 语句的流程走向
- try 包含可能出现异常的语句,出现异常,跳到相对应的 catch
- 如果 catch 有 return 语句:
- 并不直接返回,进入 finally
- finally 有 return:从 finally 返回,try-catch-finally 结束,包含 try 的方法也结束
- finally 没有 return:将 catch 中的 return 返回,try-catch-finally 结束,包含 try 的方法也结束
- 如果 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 对象要差得多
- ⚠️ 使用反射操作类中的私有成员增加了安全隐患
反射的作用
- 在运行时,获取类的信息
- 在运行时,根据对象反推得到所属的类
- 在运行时,实例化对象,并且可以调用该对象方法
反射的常见应用场景
- Spring 框架:根据 xml 中配置的 Bean 的类名,反射创建对应的对象
- 数据库连接:使用
Class.forName("com.mysql.jdbc.Driver")来加载驱动 - 大多数框架:例如 Tomcat 根据配置文件扫描对应的包,然后获取类的信息,从而获取到实例对象的能力
十、代理模式
1. 代理的作用
在不改变原代码的情况下实现增强代码。
📝 例如:为公司项目的每一个方法前后添加打印日志功能,并且不修改源代码。
2. 静态代理
实现方式:
- 需要为项目的每一个类编写一个对应的代理类
- 代理类和目标类实现同一个接口
代理对象干的事:
- 通过构造器塞入一个目标对象
- 然后在代理对象的方法内部调用目标对象同名方法
- 并在调用前后打印日志
💡 代理对象 = 增强代码 + 目标对象(原对象)
静态代理的缺陷:
- 需要为每一个类编写代理对象
- 假设项目有 1000 个类,就需要写 1000 个代理类
所以我们需要动态代理。
3. 动态代理
静态代理和动态代理本质都是最终生成代理对象:
- 静态代理:需要先手动创建代理类,再创建代理对象
- 动态代理:因为接口中含有了被代理类的构造信息,只需要通过接口 + 一个构造方法自动生成一个代理对象,直接跳过了代理类的书写过程
4. JDK 的动态代理
自动生成代理对象的过程
- 通过
Proxy.getProxyClass(ClassLoader, interfaces)获取代理类的 Class 对象 - 通过代理类的 Class 对象获取一个代理类的有参构造器,参数为 InvocationHandler 接口的 Class 对象
- 通过有参构造器的
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;
}
}
📚 参考资料:本文内容基于个人学习笔记整理