Java(三):3.0 [核心] 面向对象编程

Java(三):3.0 [核心] 面向对象编程
Prorise3.0 [核心] 面向对象编程
在完成了对Java基础语法、数据类型和运算符的学习之后,我们掌握了构建程序的“砖块”与“水泥”。现在,我们将开始学习如何运用这些材料来设计和建造宏伟的“建筑”——这便是面向对象编程(OOP)的范畴。它是现代软件工程的基石,也是Java语言设计的核心哲学。
3.0 面向对象思想
面试题引入
面试官:“请谈谈你对面向对象编程(OOP)和面向过程编程(POP)的理解,以及它们之间的主要区别是什么?”
核心思想:思维模型的转变
要理解面向对象,最好的方式是将其与我们更熟悉的面向过程进行对比。这代表了两种截然不同的解决问题的思维模型。
面向过程 (Procedural-Oriented Programming, POP)
面向过程的思维模型,就像一份详尽的菜谱或操作手册。它将解决问题的步骤清晰地列出,核心是**“流程”和“函数”**。比如要实现“把大象装进冰箱”这个任务,面向过程的代码会是这样:- 定义一个
打开冰箱门()
的函数。 - 定义一个
抬起大象()
的函数。 - 定义一个
放进大象()
的函数。 - 定义一个
关闭冰箱门()
的函数。然后按照顺序调用这些函数来完成任务。在这种模型中,数据(大象、冰箱)和操作数据的函数是分离的。
- 定义一个
面向对象 (Object-Oriented Programming, OOP)
面向对象的思维模型,则更像是在扮演一个总设计师。我们不再关注具体的操作步骤,而是首先分析问题领域中存在哪些**“实体”(即对象)。对于“把大象装进冰箱”这个任务,我们会识别出三个对象:大象
、冰箱
、操作者
。我们为每个实体设计一个“图纸”——也就是类(Class)**。冰箱
类:它有自己的属性(如品牌、容量)和行为(如开门()
、关门()
、存储(物品)
)。大象
类:它有自己的属性(如体重)和行为(如被放进(容器)
)。操作者
类:它的行为是操作(冰箱, 大象)
。任务的完成,变成了对象之间的交互:操作者
调用冰箱
的开门()
方法,然后调用冰箱
的存储(大象)
方法,最后调用冰箱
的关门()
方法。
在这种模型中,数据和操作数据的方法被紧密地“封装”在对象内部,程序由对象间的协作和消息传递来驱动。
两大范式的优缺点对比
对比维度 | 面向过程 (POP) | 面向对象 (OOP) |
---|---|---|
优点 | 性能高。因为是直接的函数调用,没有对象创建、方法动态派发等开销,更接近底层。因此在操作系统、嵌入式、驱动开发等对性能要求极致的领域仍被广泛使用。 | 易维护、易复用、易扩展。通过封装、继承、多态,可以构建出高内聚、低耦合的系统,模块清晰,修改一个模块不易影响其他模块。 |
缺点 | 维护、复用、扩展困难。数据与操作分离,导致代码耦合度高,一个数据结构的改变可能需要修改所有相关的函数,牵一发而动全身。 | 性能相对较低。对象创建、垃圾回收、方法调用等会引入一定的开销。但对于绝大多数现代商业应用而言,这点性能差异可以忽略不计,换来的可维护性收益远大于此。 |
三大基本特性概览
面向对象的强大之处,源于其三大核心支柱。我们将在后续的章节中逐一深度剖析它们。
封装
- 一句话理解:隐藏对象的内部实现细节,仅对外暴露必要的、受控的访问接口。
- 生活中的比喻:就像我们使用电视遥控器。我们只需按动“音量+”这个按钮(公共接口),就能增大电视音量,而完全不必关心遥控器内部的电路板(私有数据和实现)是如何工作的。
继承
- 一句话理解:基于一个已存在的类,创建出一个新的类,新的类将拥有父类的所有属性和行为,并可以添加自己独有的特性。
- 生活中的比喻:
特斯拉
继承自汽车
。作为一辆汽车,它天然就拥有轮子、方向盘等属性和前进、后退等行为。同时,它又增加了自己独有的特性,如“自动驾驶”和“电能驱动”。
多态
- 一句话理解:“一种接口,多种形态”。指同一个行为(方法),作用于不同的对象上,会产生不同的实现效果。
- 生活中的比喻:一个
USB
接口。你可以插入鼠标、键盘或者U盘。对于电脑
来说,都是“接收USB设备插入”这同一个行为,但鼠标会实现“移动光标”,键盘会实现“输入文字”,U盘会实现“传输文件”,展现出了不同的形态。
3.1 万物皆对象:类与对象
在Java的世界里,我们秉持“万物皆对象”的哲学。要构建任何复杂的系统,我们首先需要学会如何定义和创造构成这个系统的基本“物件”。“类”就是我们用来定义这些物件的图纸,而“对象”就是根据图纸制造出来的、独一无二的实体。
3.1.1 类的定义与对象的创建
核心概念
类 (Class):类是模板,是蓝图。它是一个静态的、在编译时就已确定的概念。一个类定义了一类事物所共有的属性(状态)和行为(方法)。例如,“汽车图纸”就是一个类,它定义了所有汽车都应该有轮子、颜色、品牌等属性,以及能够前进、刹车等行为。
对象 (Object):对象是类的一个具体实例 (Instance)。它是一个动态的、在程序运行时才存在的实体,真实地存在于内存之中,并拥有自己独立的状态。例如,根据同一份“汽车图纸”,我们可以制造出“一辆红色的法拉利”和“一辆黑色的宝马”,这两辆车就是两个独立的对象。
类的构成
一个设计良好的类通常包含以下三个核心部分:
- 字段 (Fields):也称为成员变量,用于描述对象的状态或属性。例如,一个
Car
类的字段可以是String brand;
和String color;
。 - 方法 (Methods):也称为成员函数,用于描述对象的行为或功能。例如,
Car
类的方法可以是void startEngine()
和void accelerate()
。 - 构造器 (Constructors):一种特殊的方法,用于在创建对象时进行初始化操作。
代码示例:定义Car
类并创建对象
1 | package com.example; |
3.1.2 构造器 (Constructors)
核心用途
构造器的唯一使命,就是在通过new
关键字创建对象时,对这个新生的对象进行初始化,为其属性赋予有意义的初值。
特点与规则
- 名称必须与类名完全相同。
- 没有返回值类型,连
void
也不能写。 - 默认构造器:如果一个类没有显式地定义任何构造器,Java编译器会自动为其提供一个公共的、无参数的默认构造器。但一旦你手动定义了任何一个构造器,编译器就不会再自动提供默认构造器了,这一点是初学者常犯的错误。
- 构造器重载:一个类可以有多个构造器,只要它们的参数列表不同(参数个数、类型或顺序不同),这就构成了构造器重载,为对象的创建提供了多种灵活的方式。
代码示例:Car
类的多种构造器
1 | package com.example; |
3.1.3 this
关键字的核心用法
this
是Java中一个非常重要的关键字,它代表当前对象实例的引用。简单来说,在方法或构造器内部,this
就指向了“调用这个方法或构造器的那个对象本身”。它只能在实例方法或构造器中使用。
用法一:区分同名变量(最常用)
当方法的参数名或局部变量名与类的成员变量名相同时,使用this.成员变量名
可以明确地指代成员变量。
1 | package com.example; |
用法二:调用本类其他构造器
在一个构造器中,可以使用 this(...)
的语法来调用本类中其他的构造器,以达到代码复用的目的。
规则:this(...)
的调用必须是构造器中的第一条语句。
1 | package com.example; |
3.1.4 [面试高频] 对象的创建过程
面试官:“当执行
new Car()
时,JVM内部到底发生了什么?”
这是一个考察Java底层知识的经典问题。一个对象的创建过程大致可以分为以下几个步骤:
- 类加载检查:JVM首先检查
Car.class
是否已经被加载。如果没有,则触发类加载机制(加载、链接、初始化),将类的元信息加载到方法区。 - 分配内存:JVM在堆内存中为新的
Car
对象分配一块大小合适的内存空间。 - 零值初始化:JVM将这块新分配的内存空间“清零”,即对象的所有实例字段都被赋予其数据类型的默认值(例如,
int
为0,boolean
为false
,所有引用类型为null
)。 - 设置对象头:JVM设置对象的头部信息(Object Header),这部分信息包含了对象的哈希码、GC分代年龄、锁状态标志以及指向其类元数据的指针等。
- 执行实例初始化:这是程序员可见的初始化部分,按顺序执行:
a. 父类初始化:(如果存在继承)先执行父类的实例初始化过程。
b. 实例变量初始化/代码块:执行当前类中定义的实例变量的初始化语句(如int speed = 10;
)和实例初始化代码块{}
。
c. 执行构造器:最后,执行与new
关键字匹配的构造器代码。
至此,一个完整的对象才算创建成功,并将它的内存地址返回给引用变量。
3.2 第一大特性:封装
封装,顾名思义,就是将物体的某些部分“包装并隐藏”起来。在面向对象的世界里,它指的是将对象的状态(字段)和行为(方法)捆绑在一起,形成一个不可分割的独立实体(即类),同时尽可能地隐藏对象的内部实现细节,仅对外暴露有限的、受控的访问接口。
可以将其想象成一个“胶囊”或“黑盒”。用户只需知道如何使用这个黑盒的按钮(公共方法),而无需关心其内部复杂的电路(私有数据和实现逻辑)。
3.2.1 封装的核心:信息隐藏
面试题引入
面试官:“什么是封装?请谈谈它在实际开发中的好处。”
核心概念与实现
封装的核心思想是信息隐藏。在Java中,我们主要通过**访问修饰符(Access Modifiers)**来实现这一目标。
最核心的封装实践原则是:将类的字段(成员变量)声明为 private
,并提供 public
的getter
和setter
方法作为外部世界与这些私有字段交互的唯一通道。
private
字段:确保了对象的内部状态不能被外部代码随意篡改,保护了数据的完整性和安全性。public
getter/setter 方法:getter
提供了一个只读的访问点。setter
提供了一个受控的写入点,我们可以在setter
方法中加入验证逻辑,确保赋给字段的值是合法的。
封装的好处
- 安全性:防止了外部代码对对象内部状态的非法访问和破坏。例如,我们可以在
setAge(int age)
方法中检查年龄是否为负数,从而杜绝无效数据的产生。 - 易用性:调用者无需关心对象内部复杂的实现逻辑,只需调用我们提供的简单、清晰的公共方法即可,降低了类的使用门槛。
- 可维护性:封装实现了调用者与实现者的解耦。只要公共方法(API)的签名不变,我们可以随时修改类内部的实现细节,而不会影响到任何调用它的外部代码。这使得系统升级和重构变得更加容易。
访问修饰符对比表
下表清晰地展示了Java中四种访问修饰符的权限范围:
修饰符 | 本类 | 同包 | 子类 (不同包) | 任何地方 |
---|---|---|---|---|
public | ✓ | ✓ | ✓ | ✓ |
protected | ✓ | ✓ | ✓ | ❌ |
default (无修饰符) | ✓ | ✓ | ❌ | ❌ |
private | ✓ | ❌ | ❌ | ❌ |
3.2.2 JavaBean 规范:封装的最佳实践
JavaBean并非一种具体的技术,而是一套广为遵循的设计约定或标准,用于创建可重用的、高度封装的Java组件。几乎所有的Java框架(如Spring)都深度依赖JavaBean规范。
核心规则:
- 类必须是
public
的。 - 必须提供一个
public
的无参构造器。 - 所有字段都必须是
private
的。 - 为每个私有字段提供
public
的getter
和setter
方法,并遵循命名约定:getXxx()
用于获取字段xxx
的值。setXxx(type xxx)
用于设置字段xxx
的值。- 对于
boolean
类型的字段,getter
方法可以是isXxx()
。
代码示例:一个标准的Person
JavaBean
1 | package com.example; |
3.2.3 代码块:特殊的初始化封装
代码块是类中用于封装初始化逻辑的特殊结构,它没有方法名,只有一对 {}
。
1. 构造代码块 (实例初始化块)
- 语法:直接在类中用
{}
包裹的代码。 - 执行时机:每次创建对象时都会执行,且执行顺序在构造器之前。
- 核心用途:用于提取所有构造器中公共的初始化代码,减少冗余。
2. 静态代码块
- 语法:使用
static { ... }
包裹。 - 执行时机:仅在类第一次被加载到JVM时执行一次。它的执行时机非常早,在任何对象创建之前。
- 核心用途:用于对类级别的静态(
static
)变量进行一次性的、复杂的初始化。例如,加载数据库驱动、初始化静态资源等。
[面试高频] 初始化执行顺序
面试官:“当
new
一个子类对象时,父类和子类的静态代码块、构造代码块、构造器的执行顺序是怎样的?”
铁律:父类静态部分 -> 子类静态部分 -> 父类实例初始化(构造代码块 -> 构造器) -> 子类实例初始化(构造代码块 -> 构造器)。
3.3 第二大特性:继承
继承,在现实世界中指子女从父母那里继承特征。在Java中,这一概念非常相似:一个类(子类或派生类)可以从另一个类(父类、超类或基类)那里获取其非私有的字段和方法。这种机制不仅极大地促进了代码复用,更重要的是,它建立了一种“is-a”(是一个)的关系,例如,“狗”是一个“动物”,这是实现多态的前提。
3.3.1 继承的实现与本质 (extends
)
面试题引入
面试官:“谈谈Java中的继承,它解决了什么问题?
super
关键字有什么用?”
extends
关键字
在Java中,我们使用 extends
关键字来声明一个类继承自另一个类。Java只支持单继承,即一个类最多只能有一个直接父类,但支持多层继承(A继承B,B继承C)。
1 | // Animal是父类 |
继承的内容
子类会继承父类所有 public
和 protected
的成员。如果子类与父类在同一个包中,default
(包私有)成员也会被继承。需要注意的是,父类的 private
成员虽然也被子类“拥有”了(存在于子类对象的内存中),但子类无法直接访问它们,只能通过父类提供的public
或protected
方法间接使用。
super
关键字的两种核心用法
super
关键字是对当前对象的直接父类的引用。它主要用于解决子类与父类成员重名时的访问冲突,以及调用父类的构造器。
调用父类构造器
super(...)
- 铁律:子类的构造器在执行时,其第一行必须是调用父类的构造器。
- 隐式调用:如果你没有在子类构造器中显式地写
super(...)
,编译器会自动为你插入一句super()
,即调用父类的无参构造器。 - [避坑指南]:如果父类没有提供无参构造器(只提供了带参数的构造器),那么在子类的构造器中,必须显式地使用
super(...)
来调用父类某个已存在的构造器,否则代码将无法通过编译。
调用父类成员
super.xxx
- 当子类重写了父类的方法,或者定义了与父类同名的字段时,如果你想在子类中访问父类被“覆盖”的版本,就需要使用
super.方法名()
或super.字段名
。
- 当子类重写了父类的方法,或者定义了与父类同名的字段时,如果你想在子类中访问父类被“覆盖”的版本,就需要使用
[面试高频] 子类实例化过程
当执行 new Child()
时,遵循“先有父,再有子”的原则。JVM会先完成父类部分的初始化,然后再执行子类部分的初始化。
3.3.2 方法重写 (Overriding)
方法重写是子类根据自己的需求,重新定义从父类继承来的方法的实现。这是实现多态的关键。
@Override
注解
虽然不是语法强制,但强烈建议在所有重写的方法上都加上@Override
注解。它像一个“安全卫士”,会请编译器帮忙检查你写的方法签名是否真的与父类中的某个方法完全一致。如果签名有误(如方法名拼写错误、参数列表不同),编译器会报错,从而避免了许多难以察觉的BUG。
重写的规则
俗称“两同两小一大”原则:
- 方法名相同,参数列表相同。
- 子类的返回值类型应小于或等于父类方法的返回值类型(协变返回类型)。
- 子类抛出的异常类型应小于或等于父类方法声明抛出的异常类型。
- 子类的访问修饰符应大于或等于父类方法的访问修饰符 (
public
>protected
>default
)。 - 父类中
private
和final
的方法不能被重写。
3.3.3 final
关键字在继承中的应用
final
在继承体系中的含义是“最终的,不可改变的”。
final
方法
当一个方法被final
修饰后,它就不能被任何子类重写。这样做通常是为了保证父类中某个核心方法的逻辑不被篡改,确保体系的稳定性。
1 | package com.example; |
final
类
当一个类被final
修饰后,它就不能被任何类继承,它成为了继承链的“终点”。
- 著名示例:Java核心库中的
java.lang.String
类就是final
的。这样做主要是出于安全和性能的考虑,确保了String
对象的不可变性,使其可以在多线程环境中安全共享,并放心地用于HashMap
的键。
1 | package com.example; |
3.4 第三大特性:多态
“多态”一词源于希腊语,意为“多种形态”。在面向对象编程中,它指代的是同一种行为(方法调用),作用于不同类型的对象上时,能够产生不同的执行结果。多态性允许我们将子类的对象视为其父类的类型,从而在不关心对象具体子类型的情况下,编写出通用的代码。
生活中的比喻:想象一个电脑的USB
接口。这个接口是统一的(同一个方法),但当你插入鼠标时,它表现出的行为是“移动光标”;当你插入U盘时,它的行为是“传输文件”;当你插入一个USB小风扇时,它的行为又是“吹风”。这个USB接口就是多态的体现:同一个接口,根据插入设备(对象)的不同,展现出多种形态。
3.4.1 多态的实现前提与表现形式
面试题引入
面试官:“什么是多态?在Java中要实现多态,需要满足哪些条件?”
三大前提
要在Java中实现多态,必须同时满足以下三个条件:
- 继承 (Inheritance):必须存在类之间的继承关系,或者类与接口之间的实现关系。
- 方法重写 (Overriding):子类必须重写父类中的方法(或实现接口中的方法)。
- 父类引用指向子类对象:这是多态在代码中的最终体现,例如
Animal myPet = new Dog();
。
表现形式:动态方法分派
当一个父类引用指向子类对象时,我们通过这个父类引用去调用一个被子类重写了的方法,此时Java虚拟机会执行动态方法分派。这意味着,JVM在运行时才会去判断该引用指向的堆内存中对象的实际类型,然后调用该实际类型所对应的方法。
简单来说:编译看左边,运行看右边。
- 编译看左边:编译器在检查语法时,只看引用变量的类型(父类)。因此,你只能调用父类中已定义的方法,否则编译不通过。
- 运行看右边:在实际运行时,JVM会看
new
出来的对象实例(子类),并执行子类重写后的方法。
1 | package com.example; |
3.4.2 向上转型与向下转型
向上转型 (Upcasting)
将一个子类对象赋值给一个父类引用,这个过程被称为向上转型。它是自动发生的,无需任何强制转换。
- 代码形式:
Animal myPet = new Dog();
- 效果:
myPet
这个引用虽然指向一个Dog
对象,但它的“视野”被限制在了Animal
的范围内。你只能通过myPet
调用Animal
类中定义的方法,而无法调用Dog
类中独有的方法(如wagTail()
)。
向下转型 (Downcasting)
当我们需要调用子类独有的方法时,就必须将父类引用“还原”回子类类型,这个过程被称为向下转型。它需要强制类型转换。
- 代码形式:
Dog myRealDog = (Dog) myPet;
- [避坑指南]
instanceof
关键字:向下转型是有风险的。如果一个父类引用实际指向的是一个Cat
对象,你却试图将它强转为Dog
,程序会在运行时抛出ClassCastException
(类转换异常)。因此,在进行向下转型之前,使用instanceof
关键字进行类型检查是一种安全、专业的编程习惯。
代码示例:转型与instanceof
的现代用法
1 | package com.example; |
3.4.3 接口:更彻底的抽象与多态
如果说继承是基于“is-a”(是一个)关系的多态,那么接口则提供了一种基于“can-do”(能做什么)关系的多态,它将抽象推向了极致。
- 核心思想:接口只定义行为规范(契约),即一个对象应该“能做什么”(拥有哪些方法),但完全不关心“如何做”(方法的具体实现)。任何类,无论它处于继承树的哪个位置,只要它承诺遵守这个契约,就可以通过
implements
关键字实现该接口。 - 优势:接口打破了Java的单继承限制,一个类可以实现多个接口。这使得完全不相关的类,只要它们实现了同一个接口,就可以被多态地统一处理。
想象一个场景,我们需要让系统里不同类型的事物“飞起来”。这些事物可能是一个鸟 (Bird)
、一架飞机 (Airplane)
,甚至是一个超人 (Superman)
。
从继承关系(is-a)来看,鸟
是一个动物
,飞机
是一个机器
,超人
是一个超能英雄
。它们之间没有任何共同的父类(除了Object
),因此无法用继承来实现统一的fly()
行为。
这时,接口
就派上了用场。我们可以定义一个 Flyable
(可飞行的) 接口,它只规定一个行为:fly()
。
1. [行为契约] 定义 Flyable
接口
这个接口就是我们的“can-do”契约。任何实现了它的类,都必须提供飞行的具体方法。
1 | /** |
2. [独立实现] 创建不相关的实现类
现在,我们创建三个完全不同的类,它们唯一的共同点就是都承诺遵守 Flyable
契约。
- 鸟 (Bird)
1 | // Bird是一个具体的类,它实现了Flyable接口。 |
- 飞机 (Airplane)
1 | // Airplane是另一个完全不相关的类,它也实现了Flyable接口。 |
- 超人 (Superman)
1 | // Superman是第三个不相关的类。 |
3. [多态应用] 统一处理所有“能飞”的对象
这是最关键的一步。在主程序中,我们可以将这些不同类型的对象,因为它们都实现了 Flyable
接口,而将它们视为同一种类型——Flyable
类型。
1 | import java.util.ArrayList; |
3.4.4 抽象与接口:更高层次的设计
在掌握了继承和多态后,我们进入了更高层次的抽象设计。有时,我们设计的类本身并不代表一个具体的实体,而是一种概念的提炼或行为的规范。Java为此提供了另一大工具:抽象类(abstract class
)
抽象类 (abstract class
)
核心用途与场景
当多个子类拥有一部分共同的状态(字段)和行为(方法),同时又各自拥有一些独特的行为时,我们可以将这些共性部分向上抽取,形成一个抽象类。
- 核心思想:抽象类作为一个“不完整”的模板,它既可以包含具体实现的方法(供所有子类直接复用),也可以定义抽象方法(强制子类必须提供自己的实现)。
- 最佳场景:
- 当你想在多个紧密相关的类之间共享代码时。
- 当你设计的类包含一些公共的字段或方法,但其本身作为一个概念不应该被实例化时。
类型介绍与语法
- 使用
abstract
关键字来修饰类和方法。 - 抽象方法:只有方法签名,没有方法体(没有
{}
),以分号结尾。例如:public abstract void makeSound();
。 - 规则:
- 包含任何一个抽象方法的类,必须被声明为抽象类。
- 抽象类可以不包含任何抽象方法。这样做仅仅是为了禁止该类被实例化。
- 抽象类不能被实例化(不能
new
),它只能被继承。 - 子类继承一个抽象类后,必须实现父类中所有的抽象方法,除非该子类自己也是一个抽象类。
- 抽象类可以有构造器,其目的是为了供子类在初始化时通过
super()
调用。
[设计模式] 模板方法模式
这是抽象类最经典的应用场景。父类定义一个算法的整体骨架(模板),而将算法中某些可变的步骤延迟到子类中去实现。
- 代码示例:制作饮品
Beverage
(饮品)这个抽象类定义了制作饮品的通用流程prepareRecipe()
,这个流程是固定的(final
),但其中的brew()
(冲泡)和addCondiments()
(加调料)两个步骤,对于咖啡和茶来说是不同的,因此定义为抽象方法。
1 | package com.example; |
3.4.5 [面试高频] 抽象类 vs. 接口 对比
现在,我们已经分别详细了解了抽象类和接口,可以对它们进行一个全面的对比。
面试题引入
“抽象类和接口有什么区别?在项目中你是如何选择的?”
抽象类 vs. 接口 对比表
对比维度 | 抽象类 (abstract class ) | 接口 (interface ) |
---|---|---|
继承/实现 | 单继承 (extends ),一个类只能继承一个抽象类。 | 多实现 (implements ),一个类可以实现多个接口。 |
成员变量 | 可以有各种类型的成员变量(实例变量、静态变量)。 | 只能有public static final 类型的常量。 |
构造器 | 有构造器,用于子类初始化。 | 没有构造器。 |
方法 | 可包含抽象方法和具体方法。 | 在Java 8之前只能有抽象方法,Java 8+可包含抽象方法、default 默认方法和static 静态方法。 |
设计目的 | 倾向于表达“is-a”关系(是一个),对一类事物的共性进行抽象,强调“是什么”。 | 倾向于表达“can-do”关系(能做什么),对一种能力或行为进行定义,强调“能做什么”。 |
如何选择?
- 优先选择接口:在大多数情况下,接口是更好的选择,因为它更灵活,耦合度更低。面向接口编程是软件设计的重要原则。
- 使用抽象类的情况:
- 当你想在多个子类中**共享代码和状态(字段)**时。
- 当这些子类共享一个明显的“is-a”关系,并且具有共同的基础行为时。
- 当你需要控制非
public
的成员时(接口成员都是public
的)。
面试题引入
“抽象类和接口有什么区别?”
抽象类 vs. 接口 对比表
对比维度 | 抽象类 (abstract class ) | 接口 (interface ) |
---|---|---|
继承/实现 | 单继承 (extends ),一个类只能继承一个抽象类。 | 多实现 (implements ),一个类可以实现多个接口。 |
成员变量 | 可以有各种类型的成员变量(实例变量、静态变量)。 | 只能有public static final 类型的常量。 |
构造器 | 有构造器,用于子类初始化。 | 没有构造器。 |
方法 | 可包含抽象方法和具体方法。 | 可包含抽象方法、default 默认方法和**static 静态方法**。 |
设计目的 | 倾向于表达“is-a”关系,对一类事物的共性进行抽象。 | 倾向于表达“can-do”关系,对一种能力或行为进行定义。 |
3.5 Object
类:万物之源
在Java的类继承体系中,java.lang.Object
类是位于金字塔最顶端的、唯一的根节点。无论我们创建任何类,如果它没有用extends
关键字明确指定父类,那么它就默认继承自Object
类。可以说,Object
类是所有Java对象的“创世神”,它提供的方法是每个对象都具备的通用能力。
3.5.1 equals(Object obj)
方法详解
面试题引入
“
==
和equals()
有什么本质区别?”
这是一个入门级但极其重要的面试题,回答的深度能直接反映候选人的基础水平。
==
运算符- 当用于基本数据类型时,它比较的是值是否相等。
- 当用于引用数据类型时,它比较的是两个引用变量是否指向同一个内存地址,即是否为同一个对象实例。
Object.equals()
的默认行为
如果我们查看Object
类的源码,会发现它的equals()
方法实现极其简单:1
2
3public boolean equals(Object obj) {
return (this == obj);
}这清晰地表明,在未被重写的情况下,
equals()
方法与==
对于引用类型的比较,行为完全一致,都是比较对象的身份(内存地址)。重写的必要性与契约
在实际业务中,我们往往不关心两个引用是否指向同一个对象,而是关心它们所代表的逻辑内容是否相等。例如,两个不同的Person
对象,只要它们的身份证号相同,我们就应认为它们是“相等”的。为此,我们必须重写equals()
方法来定义自己的逻辑相等性。在重写
equals()
时,必须遵守Java官方定义的五大契约,以保证其行为的正确和可预测性:- 自反性: 对于任何非
null
的引用x
,x.equals(x)
必须返回true
。 - 对称性: 对于任何非
null
的引用x
和y
,如果x.equals(y)
为true
,那么y.equals(x)
也必须为true
。 - **传递性 **: 如果
x.equals(y)
为true
,且y.equals(z)
为true
,那么x.equals(z)
也必须为true
。 - **一致性 **: 只要
x
和y
对象中用于比较的信息没有被修改,无论调用多少次x.equals(y)
,都应返回相同的结果。 - 与
null
的比较: 对于任何非null
的引用x
,x.equals(null)
必须返回false
。
所以使用Idea的快速重写equals方法时,或增加Lombok注解,最终返回的即是如下的代码示例
- 自反性: 对于任何非
代码示例:正确地重写equals
1 | package com.example; |
3.5.2 hashCode()
方法详解
面试题引入
“为什么重写
equals
时必须重写hashCode
?请解释它们之间的契约关系。”
核心契约
hashCode()
方法返回一个对象的哈希码(一个int
值),这个值主要供HashMap
、HashSet
等哈希集合使用。equals()
和hashCode()
之间存在一个必须被严格遵守的契约:
- 如果两个对象通过
equals()
方法比较是相等的,那么它们的hashCode()
值必须相等。 - 如果两个对象的
hashCode()
相等,它们的equals()
不一定相等(这被称为哈希冲突)。
违反契约的后果
如果你只重写了equals()
而没有重写hashCode()
,那么Object
类默认的hashCode()
方法(通常基于内存地址计算)依然会被使用。这将导致两个内容上equals
的、但地址不同的对象,拥有不同的hashCode
。当这样的对象被放入HashSet
或作为HashMap
的键时,集合将无法正常工作。
代码示例:违反hashCode
契约的后果
1 | package com.example; |
所以这也是说明了,为什么Idea提供的快捷插入指令会将二者绑定到一起
3.5.3 toString()
方法
- 核心用途:返回一个对象的“自我描述”字符串,这对于日志记录、调试打印和程序输出至关重要。
- 默认行为:
Object
类的toString()
默认返回"类名@哈希码的十六进制表示"
,如com.example.Person@1a2b3c4d
,信息量很小。 - 重写建议:强烈建议所有自定义类都重写
toString()
,以提供有意义的对象状态信息。
1 | // 在Person类中添加 |
3.5.4 clone()
方法与深/浅拷贝
面试题引入
“谈谈你对深拷贝和浅拷贝的理解,在Java中如何实现对象克隆?”
clone()
方法用于创建并返回一个对象的副本。要使用它,一个类必须:
- 实现
java.lang.Cloneable
接口(这是一个标记接口,本身没有方法)。 - 重写
Object
的clone()
方法,并将其访问修饰符提升为public
。
浅拷贝 (Shallow Copy)
super.clone()
执行的是浅拷贝。它会创建一个新对象,然后将原始对象中所有字段的值原封不动地复制到新对象中。
- 对于基本类型字段,复制的是值。
- 对于引用类型字段,复制的是内存地址。
这意味着,浅拷贝后,原对象和克隆对象的引用类型字段将指向同一个子对象。修改任何一个都会影响另一个。
深拷贝 (Deep Copy)
深拷贝不仅复制对象本身,还会递归地复制其内部引用的所有可变对象,直到所有对象都被复制为新的实例。最终,原对象和克隆对象完全独立,互不影响。
代码示例:深浅拷贝对比
1 | package com.example; |
3.5.5 其他方法简介
getClass()
: 反射的入口,返回一个对象的运行时Class
实例。wait()
,notify()
,notifyAll()
: 用于多线程协作的底层方法,它们必须在synchronized
代码块中被锁对象调用。详细内容将在后续的并发编程章节中深入探讨。
3.6 static
关键字深度剖析
static
是Java中一个非常基础但功能强大的修饰符。它的核心作用是声明一个不依赖于任何对象实例而存在的成员,这个成员直接隶属于类本身。理解static
是区分“实例成员”与“类成员”的关键,也是掌握单例模式、工具类设计等高级技巧的前提。
3.6.1 static
的核心本质:属于类,而非对象
面试题引入
“请谈谈你对
static
关键字的理解,它的生命周期是怎样的?”
核心概念
一个类就像一张“图纸”,而对象是根据这张图纸制造出来的“产品”。
- 非静态成员(实例成员):属于每个“产品”各自的属性。比如,对于
Car
类,color
(颜色)字段就是实例成员,因为每辆车都可以有不同的颜色。你必须先有一辆具体的车(对象),才能谈论它的颜色(myCar.color
)。 - 静态成员(类成员):属于“图纸”本身的属性,被所有产品共享。比如,我们可以定义一个
static int numberOfWheels = 4;
,因为“所有汽车都有4个轮子”是这张图纸的固有设定,与任何一辆具体的车无关。你可以通过图纸直接访问它(Car.numberOfWheels
)。
内存与生命周期
- 内存位置:
- 静态成员(静态变量、静态方法)存储在JVM的方法区(在Java 8及之后称为Metaspace)。无论这个类创建了多少个对象,静态成员在内存中只有一份副本。
- 实例成员(非静态字段)存储在**堆内存(Heap)**中,每创建一个对象,就会在堆上为它的实例成员分配一块新的内存。
- 生命周期:
static
成员的生命周期与类绑定,遵循“先有类,后有对象”的原则。- 类加载时:当一个类的
.class
文件首次被JVM加载时,其static
成员就会被分配内存并进行初始化。这个过程只会发生一次。 - 对象创建时:之后,每当使用
new
关键字创建对象时,才会在堆上为该对象的实例成员分配内存。
- 类加载时:当一个类的
3.6.2 static
的四种核心应用场景
**1. 静态变量 **
- 用途:用于定义被一个类的所有实例共享的状态或数据。
- 场景示例:实现一个对象创建计数器,统计某个类总共被实例化了多少次。
1 | package com.example; |
**2. 静态方法 **
- 用途:用于定义那些不依赖于任何对象内部状态(实例字段)的工具类行为。
- 场景示例:几乎所有的工具类,如
java.lang.Math
和java.util.Arrays
,其方法都是静态的,因为它们的计算只依赖于传入的参数。
1 | package com.example; |
3. 静态代码块
- 用途:用于执行类级别的、仅在类首次加载时运行一次的复杂初始化操作。
- 场景示例:加载数据库驱动,或者从配置文件中读取信息来初始化一个静态的
Map
。
1 | package com.example; |
4. 静态内部类
- 用途:定义一个逻辑上与外部类紧密相关,但实例化时不依赖于外部类对象的类。
- 与非静态内部类的核心区别:非静态内部类会隐式地持有一个外部类实例的引用,而静态内部类则不会。
- [设计模式] 场景示例:建造者模式(Builder Pattern) 是静态内部类的绝佳应用场景。
1 | package com.example; |
3.6.3 [面试核心] 静态上下文的限制
面试官:“静态方法为什么不能直接访问非静态成员(字段或方法)?”
原理解析:这个问题的根本原因在于生命周期的不同,即“先有类,后有对象”。
- **静态成员(类成员)**在类被加载到JVM时就诞生了,此时内存中可能还没有任何该类的对象实例。
- **非静态成员(实例成员)**必须依赖于具体的对象实例而存在。每
new
一个对象,才会在堆内存中为这些成员开辟一块空间。 - 因此,当你在一个静态方法(它属于类,不属于任何特定对象)中,试图去访问一个非静态字段(它必须属于某个特定对象)时,JVM会感到困惑:“你到底想访问哪一个对象的这个字段呢?”——因为此时可能一个对象都没有,也可能有一万个。在没有明确的对象实例(即没有
this
引用)的静态上下文中,访问实例成员是不合逻辑的,也是不被允许的。
反之,实例方法可以随意访问静态成员,因为当实例方法被调用时,必然已经存在一个对象实例,而这个对象所属的类也必然早已被加载,所以静态成员一定存在于内存中,可以安全访问。
3.7 内部类
内部类,顾名思义,就是定义在另一个类内部的类。它并非一个可有可无的语法糖,而是一种强大的编程工具,能够帮助我们编写出结构更清晰、封装性更好的代码。
3.7.1 为什么需要内部类?
面试题引入
“你为什么会在项目中使用内部类?它解决了什么问题?”
核心价值
- 逻辑组织与代码可读性:当一个类(如
Engine
)在逻辑上只为另一个类(如Car
)服务时,将它作为内部类可以清晰地表达这种从属关系,避免了在包中创建大量仅被单一类使用的辅助类。 - 增强封装:这是内部类最强大的特性。内部类可以无条件地访问其外部类的所有成员,包括
private
修饰的字段和方法。这提供了一种比常规封装更紧密的耦合方式,允许外部类将实现细节完全隐藏在内部,仅通过内部类来操作。 - 优雅地实现回调:匿名内部类(将在后面讲到)是实现事件监听和回调机制的经典方式。
3.7.2 成员内部类
成员内部类是最普通的一种内部类,它作为外部类的一个非静态成员存在,地位与实例字段和实例方法相同。
- 核心特性:成员内部类的实例隐式地持有一个外部类实例的引用。这意味着,它的生命周期与外部类对象绑定,并且可以直接访问外部类的所有实例成员。
- 实例化方式:它的创建必须依赖于一个外部类的对象。
代码示例:Car
与 Engine
Engine
(引擎)是Car
(汽车)的核心部件,引擎的状态(如转速)可能需要依赖汽车的状态(如油门深度)。将Engine
作为Car
的成员内部类,可以完美地模拟这种关系。
1 | package com.example; |
3.7.3 静态内部类
静态内部类是被static
修饰的内部类。它与成员内部类的核心区别在于它不持有外部类实例的引用。
- 核心特性:因为它不依赖于任何外部类对象,所以它只能访问外部类的静态成员。本质上,静态内部类更像是一个被“藏”在外部类命名空间下的一个独立的顶层类。
- 实例化方式:可以独立于外部类对象直接创建。
[设计模式] 场景回顾:建造者模式 (Builder Pattern)
静态内部类最经典的应用就是实现建造者模式,用于构建具有多个可选参数的复杂对象,使用lombok,仅需要加上@Builder
注解,他在内部相当于做了和我们静态代码块相似的操作,最后返回一个携带好了的Computer
对象供我们使用,无需使用new
关键字
1 | package com.example; |
3.7.4 局部内部类
局部内部类是定义在方法体内部的类,是四种内部类中用得最少的一种。
- 核心特性:它的作用域被严格限制在定义它的那个方法之内,对外部世界完全不可见。它可以访问方法内的局部变量,但这些变量必须是
final
或事实上的final(即初始化后未被再次赋值)。 - 原因:因为方法执行完毕后,局部变量的生命周期就结束了,但此时局部内部类的对象可能还存活着(例如被返回或被其他对象持有)。为了保证内部类对象在未来还能访问到这个变量的值,Java会将被访问的局部变量的值复制一份给内部类。为了防止数据不一致,这个变量必须是不可变的。
1 | package com.example; |
3.7.5 匿名内部类
匿名内部类是一种没有名字的局部内部类。它通常用于快速地、一次性地实现一个接口或继承一个类,并立即创建一个该实现类的对象。
场景一:GUI事件监听器(最经典的应用)
在Java的图形界面编程(如Swing, AWT)中,为按钮、菜单等组件添加事件响应逻辑,是匿名内部类的“主战场”。
目的:为一个“注册”按钮添加点击事件。当按钮被点击时,执行一段特定的业务逻辑。
1 | package com.example; |
分析:在这个场景中,我们只需要一个一次性的、与“注册按钮”紧密绑定的点击行为。专门为此定义一个独立的具名类会显得非常冗余。匿名内部类让我们可以在需要的地方,就地完成实现类的定义和实例化,代码紧凑且意图清晰。
场景二:自定义集合排序规则 (Comparator
)
当我们需要对一个集合进行一次性的、非标准的排序时,匿名内部类是定义临时排序逻辑的绝佳工具。
目的:有一个
Product
(商品)列表,我们希望不修改Product
类本身,而是根据价格对其进行降序排序。
1 | package com.example; |
分析:这种“按价格排序”的逻辑可能只在此处使用一次。使用匿名内部类,我们可以将这个特定的排序规则直接定义在调用sort
方法的地方,而无需污染代码库,增加一个几乎不会被复用的ProductPriceComparator
类。
3.8 枚举 (enum
):类型安全的“多例”模式
枚举(enum
)是Java 5引入的一项关键特性。它远不止是“一组常量”的集合,而是一种功能强大的、类型安全的、面向对象的枚举模式实现。理解并善用枚举,是编写健壮、可读、可维护代码的重要一环。
3.8.1 enum
的诞生:告别“魔法值”与不安全
面试题引入
“枚举(
enum
)相比于用public static final int
常量来表示一组固定值,有什么核心优势?”
“旧时代”的做法:使用静态常量
在没有枚举的时代,我们通常这样定义一组相关的常量:
1 | // 使用静态常量定义一周的星期 |
传统方式的痛点
- 非类型安全:
schedule
方法的参数是int
,这意味着我可以传入任何整数,如schedule(999)
,编译器无法发现错误,只能在运行时产生逻辑BUG。 - 无意义的“魔法值”:数字
1
本身没有任何业务含义,它与“星期一”的关联全靠开发者的记忆和文档。 - 可读性差:在调试或日志中看到一个数字
1
,远不如看到MONDAY
来得直观。 - 难以扩展:无法将更多的信息(如“星期一”的中文名)与常量
1
结构化地关联起来。
枚举的出现,完美地解决了以上所有问题。
3.8.2 enum
的基本用法与核心方法
基本定义
enum
关键字用于定义一个枚举类型。每个枚举中列出的名称都代表该枚举类型的一个唯一的、公开的、静态的、final的实例。
1 | // 定义一个简单的星期枚举 |
常用方法速查表
方法签名 | 功能描述 |
---|---|
values() | 静态方法,返回一个包含所有枚举实例的数组,常用于遍历。 |
valueOf(String name) | 静态方法,根据字符串名称返回对应的枚举实例(大小写敏感)。 |
name() | 返回枚举实例的声明名称(如 “MONDAY”)。 |
ordinal() | 返回枚举实例的序数(从0开始)。强烈不推荐在业务逻辑中依赖它,因为顺序改变会导致BUG。 |
toString() | 默认返回name() 的值,但可以被重写以提供更友好的输出。 |
代码示例:在switch
中使用enum
这是enum
最常见的场景之一,代码不仅可读性高,而且switch
表达式(Java 14+)还能利用编译器的穷尽性检查来保证安全性。
1 | package com.example; |
3.8.3 enum
的进阶用法:枚举也是类
这是enum
最强大的地方——它本质上是一个特殊的类。这意味着它可以拥有自己的字段、构造器和方法。
面试题引入
“你能在枚举中定义方法和字段吗?请举例说明。”
场景一:为枚举添加自定义属性和方法
需求:定义一组支付方式,每种方式都有其中文名和对应的手续费率,并能计算手续费。
1 | package com.example; |
场景二:为枚举实现接口,实现策略模式
需求:定义一组运算操作,每个操作都能执行自己的运算逻辑。
1 | package com.example; |
3.9 [设计模式] 面向对象设计模式
3.9.1 设计模式思想
面试题引入
“你最熟悉的设计模式有哪些?你认为,我们为什么要使用设计模式?”
核心思想:可复用的解决方案
设计模式并非一种具体的代码、框架或算法,而是在软件设计过程中,针对特定问题的、一套可复用的、经过无数次实践验证的解决方案。
可以把设计模式理解为软件开发的“兵法”或“棋谱”。就像古代将军打仗有各种阵法(一字长蛇阵、八门金锁阵),象棋高手有各种开局和残局的定式一样,这些“阵法”和“定式”都是前人耗费了大量心血,从无数次成功与失败中总结出的、在特定情境下最高效、最稳妥的策略。
学习设计模式,不是为了死记硬背,而是为了:
- 站在巨人的肩膀上:我们遇到的大多数设计问题,前人都已经遇到过并找到了优雅的解决方案。使用设计模式可以让我们避免“重复发明轮子”,直接采用成熟、可靠的设计。
- 提升代码质量:设计模式的核心目标是提升软件的可维护性、可复用性和可扩展性,遵循设计模式编写的代码通常具有更好的结构,更容易被理解和修改。
- 提供通用词汇:当你说“我这里用了一个单例模式”,团队里的其他工程师能立刻理解你的设计意图,这极大地提高了沟通效率。
设计模式通常分为三大类:创建型模式(如何创建对象)、结构型模式(如何组合类和对象)和行为型模式(对象之间如何交互和分配职责)。本章将聚焦于几种最基础、最核心的模式。
3.9.2 单例模式: 保证实例的独一无二
核心思想与用途
单例模式是一种创建型模式,其核心目标是:确保一个类在整个应用程序的生命周期中,只有一个实例存在,并提供一个全局的、统一的访问点来获取这个唯一的实例。
应用场景
当一个对象需要被系统中的多个部分共享,且它的存在只需要一份时,就应该使用单例模式。这份“唯一”的实例通常代表着一种全局性的资源或服务。
- 配置管理器:整个应用的配置信息只需要加载一次,并由一个统一的对象管理,供各处读取。
- 数据库连接池:连接池的初始化和管理是重量级操作,整个应用共享一个连接池实例可以避免资源的浪费和竞争。
- 日志对象:应用中的所有模块都应该使用同一个日志记录器,以便将日志输出到同一个地方。
- 操作系统中的任务管理器、回收站:这些在整个系统中都只能有一个实例存在。
- Spring框架中的Bean:在Spring容器中,默认作用域(Scope)的Bean就是单例的。
实现方式详解
1. 饿汉式
“饿汉式”正如其名,非常“饥渴”,不管你将来用不用,在类被加载的时候,它就立刻把实例创建出来了。
- 代码实现
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
29package com.example;
// 饿汉式单例
public class Main {
// 1. 构造器私有化,防止外部通过 new 来创建实例
private Main() {
System.out.println("饿汉式单例的构造器被调用。");
}
// 2. 在类加载时就直接创建并持有一个静态的、final的实例
private static final Main INSTANCE = new Main();
// 3. 提供一个公共的静态方法,作为全局唯一的访问点
public static Main getInstance() {
return INSTANCE;
}
public void doSomething() {
System.out.println("饿汉式单例正在工作...");
}
public static void main(String[] args) {
Main instance1 = Main.getInstance();
Main instance2 = Main.getInstance();
System.out.println("instance1 和 instance2 是否是同一个对象? " + (instance1 == instance2));
instance1.doSomething();
}
} - 思想与优缺点
- 优点:实现非常简单。因为实例是在类加载的静态初始化阶段创建的,这个过程由JVM保证线程安全,所以天生就是线程安全的。
- 缺点:可能造成资源浪费。如果这个单例对象非常消耗资源(比如,它在构造时需要加载一个很大的文件),而你的程序在整个运行过程中一次都没有使用过它,那么这次实例化的开销就白白浪费了。
2. 懒汉式
“懒汉式”则比较“懒惰”,它不会在类加载时就创建实例,而是等到第一次有人调用getInstance()
方法时,才去检查并创建实例。
基础懒汉式(线程不安全)
1
2
3
4
5
6
7
8
9
10
11// 这是一个线程不安全的版本,仅用于理解思想
class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}问题分析:在多线程环境下,假设线程A和线程B同时执行到
if (instance == null)
,都判断为true
,那么它们都会去执行new LazySingleton()
,最终导致创建出两个不同的实例,违背了单例的原则。[面试核心] DCL (Double-Checked Locking) 懒汉式
为了解决懒汉式的线程安全问题,同时又尽可能地减少同步带来的性能开销,业界演进出了“双重检查锁定”这一工业级的标准实现。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
38package com.example;
public class Main {
// 1. 使用 volatile 关键字确保多线程下的可见性和禁止指令重排
private static volatile Main instance;
private Main() {
System.out.println("DCL懒汉式单例的构造器被调用。");
}
public static Main getInstance() {
// 2. 第一次检查:如果实例已存在,直接返回,避免不必要的加锁
if (instance == null) {
// 3. 同步代码块:只在实例未创建时才进入,保证只有一个线程能创建实例
synchronized (Main.class) {
// 4. 第二次检查:防止其他线程已创建实例
if (instance == null) {
instance = new Main();
}
}
}
return instance;
}
public void doSomething() {
System.out.println("DCL懒汉式单例正在工作...");
}
public static void main(String[] args) {
// 模拟多线程并发访问
for (int i = 0; i < 10; i++) {
new Thread(() -> {
Main instance = Main.getInstance();
System.out.println(Thread.currentThread().getName() + " 获取到的实例哈希码: " + instance.hashCode());
}).start();
}
}
}思想与DCL细节解析
- 双重检查:
if (instance == null)
检查了两次。第一次检查是为了在实例已经存在的情况下,让后续线程无需进入重量级的synchronized
块,直接返回,极大地提高了性能。第二次检查是在锁内部,确保了即使有多个线程通过了第一次检查,也只有一个线程能真正创建实例。 volatile
关键字:这是一个至关重要的点。new Main()
这个操作在JVM中并非原子性的,它大致可以分为三步:- a. 分配内存空间;
- b. 初始化对象;
- c. 将
instance
引用指向分配的内存地址。 - 由于指令重排序的存在,b和c的顺序可能会被颠倒。如果一个线程执行了a和c但还没执行b,另一个线程在第一次检查时就会看到
instance
不为null
而直接返回一个“半成品”对象,使用时就会出错。volatile
关键字可以禁止这种指令重排序,并保证instance
变量在多线程间的可见性,确保任何线程拿到的都是完整的实例。
- 双重检查:
3.9.3 工厂模式: 解耦对象的创建与使用
核心思想与用途
工厂模式是一种创建型模式,它的核心思想是:定义一个用于创建对象的接口(或类),但让实现这个接口的类(或子类)来决定实例化哪个类。工厂方法让类的实例化推迟到子类中进行。
简单来说,就是将对象的创建过程从对象的使用过程中分离出来。客户端代码不再需要自己去new
一个具体的产品对象,而是向一个“工厂”索要。这样做的好处是,如果未来需要更换产品的具体实现,或者增加新的产品,客户端代码完全不需要修改,只需要修改工厂内部的逻辑即可。
应用场景
- 当你需要一个能生产多种产品,但具体生产哪一种是在运行时才决定的系统。
- 当你希望将产品的创建逻辑封装起来,不让客户端知道具体的实现细节。
- JDBC数据库连接:
DriverManager.getConnection()
就是一个典型的工厂方法,你传入不同的数据库URL,它会返回不同厂商(如MySQL, Oracle)的Connection
实现类的实例。 - 各种解析器(XML, JSON)的创建。
- 日志框架中根据配置创建不同类型的Logger。
实现方式详解(简单工厂模式)
简单工厂模式是工厂模式家族中最基础的一种。它有一个专门的工厂类,负责根据传入的参数创建并返回不同产品的实例。
- 代码示例:一个生产各种形状的工厂
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66package com.example;
// 1. 定义接口
interface Shape {
void draw();
}
// 2. 实现类
class Circle implements Shape {
public void draw() {
System.out.println("画圆圈");
}
}
class Rectangle implements Shape {
public void draw() {
System.out.println("画矩形");
}
}
class Triangle implements Shape {
public void draw() {
System.out.println("画三角形");
}
}
// 3. 工厂类
class ShapeFactory {
public static Shape getShape(String shapeType) {
if (shapeType == null) {
return null;
}
switch (
shapeType
) {
case "CIRCLE":
return new Circle();
case "RECTANGLE":
return new Rectangle();
case "TRIANGLE":
return new Triangle();
default:
return null;
}
}
}
public class Main {
public static void main(String[] args) {
// 客户端代码通过工厂来获取对象,而不需要知道Circle或Rectangle的存在
Shape shape1 = ShapeFactory.getShape("CIRCLE");
shape1.draw();
Shape shape2 = ShapeFactory.getShape("RECTANGLE");
shape2.draw();
Shape shape3 = ShapeFactory.getShape("TRIANGLE");
shape3.draw();
}
}
总结:工厂模式完美体现了面向对象中的依赖倒置原则——高层模块(客户端Main
)不应该依赖于低层模块(具体产品Circle
),两者都应该依赖于抽象(接口Shape
)。
3.9.4 代理模式: 控制对象的访问
核心思想与用途
代理模式是一种结构型模式,它的核心思想是:为一个对象提供一个代理(Proxy),以控制对这个对象的访问。
代理对象和真实对象通常会实现同一个接口。客户端代码只与代理对象交互,代理对象内部再决定何时以及如何调用真实对象。这种方式可以在不修改真实对象代码的前提下,为其增加额外的功能或控制逻辑。
应用场景
代理模式的应用极其广泛,是实现许多高级功能的基石。
- 权限控制:代理在调用真实业务方法前,先检查当前用户是否有执行该操作的权限。
- 懒加载:如果一个对象的创建非常耗时耗资源(如加载一张高清大图),可以先创建一个轻量级的代理对象。只有当客户端真正需要使用这个对象时,代理才去创建并加载真实的重量级对象。
- 日志记录:代理可以在真实方法被调用前后,记录下方法的入参、返回值、执行时间等日志信息。
- 事务管理:代理在方法开始前开启事务,在方法成功结束后提交事务,在方法抛出异常时回滚事务。
- 远程代理(RPC):代理对象在本地,但它封装了与远程服务器通信的细节,使得客户端调用本地代理就像调用本地对象一样简单。
实现方式详解(静态代理)
静态代理是在编译时就已经确定了代理关系。我们需要手动为每个真实服务类创建一个代理类。
- 代码示例:一个实现懒加载的图片查看器
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63package com.example;
// 1. 定义共同的接口
interface Image {
void display();
}
// 2. 创建真实的、重量级的服务类
class RealImage implements Image {
private String fileName;
public RealImage(String fileName) {
this.fileName = fileName;
loadFromDisk(); // 构造时就执行耗时操作
}
private void loadFromDisk() {
System.out.println("正在从磁盘加载图片: " + fileName + " (这是一个耗时操作)...");
}
public void display() {
System.out.println("正在显示图片: " + fileName);
}
}
// 3. 创建代理类,它也实现Image接口
class ProxyImage implements Image {
private RealImage realImage; // 持有一个真实对象的引用
private String fileName;
public ProxyImage(String fileName) {
this.fileName = fileName;
}
public void display() {
// 实现懒加载:只有在display方法被调用时,才真正创建RealImage对象
if (realImage == null) {
realImage = new RealImage(fileName);
}
// 调用真实对象的方法
realImage.display();
}
}
public class Main {
public static void main(String[] args) {
// 创建代理对象,此时并不会加载图片
Image image = new ProxyImage("风景照.jpg");
System.out.println("代理对象已创建,但图片尚未加载。");
System.out.println("---");
// 第一次调用display,会触发真实对象的创建和加载
image.display();
System.out.println("---");
// 第二次调用display,直接使用已创建的真实对象
image.display();
}
}
[进阶] 动态代理简介
静态代理的缺点是,如果接口很多,或者接口发生变化,需要手动维护大量的代理类。为了解决这个问题,Java提供了动态代理机制。
- JDK动态代理:基于接口实现。它可以在运行时,动态地为一个或多个接口生成一个代理对象,无需手动编写代理类。
- CGLIB动态代理:基于继承实现。它可以为一个没有实现接口的类生成一个子类作为其代理。这两种技术是Spring AOP等框架实现“面向切面编程”的底层基石,它们与反射技术密切相关。我们将在后续章节中深入探讨。
3.10 [深度] 泛型 (Generics):编写类型安全、可复用的代码
泛型是Java 5引入的里程碑式特性,它将“类型”这个概念参数化,允许我们在编写代码时使用一个“类型占位符”,而在实际使用时再指定具体的类型。掌握泛型,是编写现代化、类型安全且高度可复用Java代码的基础,也是理解所有Java集合框架源码的前提。
3.10.1 泛型的诞生:没有泛型的“黑暗时代”
面试题引入
“什么是泛型?它解决了什么核心问题(为什么需要泛型)?”
场景回溯:一个不安全的ArrayList
在Java 5之前,所有的集合类都只能持有Object
类型的引用。这带来了一系列严重的问题。
1 | package com.example; |
泛型的核心价值
泛型的出现,正是为了解决以上三大痛点,其核心价值在于:
- 类型安全:将类型的检查工作从运行时提前到了编译期。
List<String>
就明确告诉编译器,这个列表只能存放String
,任何试图存入其他类型的操作都会直接导致编译失败。 - 代码简洁:从泛型集合中获取元素时,不再需要手动进行强制类型转换,编译器会自动处理。
- 提升可读性与代码复用:代码的意图变得一目了然(
List<User>
显然比List
更易懂),同时我们可以编写一次泛型类或方法,就能安全地服务于多种数据类型。
3.10.2 泛型的核心概念与用法
1. 泛型类
最常见的泛型应用,即在定义类时声明一个或多个类型参数。
- 代码示例:一个可以容纳任何物品的
Box<T>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24package com.example;
import lombok.Data;
// T 是一个类型参数(占位符),在使用Box类时会被替换为具体类型
class Box<T> {
private T item;
}
public class Main {
public static void main(String[] args) {
// 创建一个只能存放String的Box
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello, Generics!");
// stringBox.setItem(123); // 这行会导致编译错误,保证了类型安全
System.out.println("stringBox里的物品: " + stringBox.getItem());
// 创建一个只能存放Integer的Box
Box<Integer> integerBox = new Box<>();
integerBox.setItem(999);
System.out.println("integerBox里的物品: " + integerBox.getItem());
}
}
2. 泛型接口
与泛型类类似,接口也可以定义类型参数。
- 代码示例:一个通用的内容生成器
Generator<T>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package com.example;
interface Generator<T> {
T next();
}
// 实现泛型接口,生成随机数字
class RandomNumberGenerator implements Generator<Integer> {
public Integer next() {
return (int) (Math.random() * 100);
}
}
public class Main {
public static void main(String[] args) {
Generator<Integer> numberGen = new RandomNumberGenerator();
System.out.println("生成的随机数: " + numberGen.next());
}
}
3. 泛型方法
泛型方法允许方法的类型参数独立于其所在类的类型参数。
- 代码示例:一个可以打印任何类型数组的工具方法
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
28package com.example;
class Utils {
// <T>是该方法的类型参数声明,它在返回值类型之前
public static <T> void printArray(T[] inputArray) {
System.out.print("[");
for (int i = 0; i < inputArray.length; i++) {
System.out.print(inputArray[i]);
if (i < inputArray.length - 1) {
System.out.print(", ");
}
}
System.out.println("]");
}
}
public class Main {
public static void main(String[] args) {
Integer[] intArray = {1, 2, 3, 4, 5};
String[] stringArray = {"A", "B", "C"};
System.out.print("整型数组: ");
Utils.printArray(intArray);
System.out.print("字符串数组: ");
Utils.printArray(stringArray);
}
}
3.10.3 [重点] 泛型通配符:处理未知的类型
问题根源:List<Dog>
不是 List<Animal>
在Java中,即使Dog
是Animal
的子类,List<Dog>
也不是List<Animal>
的子类。这是Java泛型设计中的一个核心原则,目的是为了保证类型安全。如果允许List<Animal> list = new ArrayList<Dog>();
这样的赋值,那么我们就可以通过list.add(new Cat())
向一个本应只存放Dog
的列表中添加Cat
,这会造成混乱。
1 | package com.example; |
为了解决这种需要处理“某一类”泛型集合的场景,Java引入了通配符。
1. 上界通配符: ? extends T
含义:“一个持有
T
或T
的某种未知子类的集合”。PECS原则 (Producer Extends, Consumer Super):
extends
关键字在这里意味着集合是一个生产者(Producer),你只能从中读取(get)数据,而不能向其中**添加(add)**数据(null
除外)。因为编译器无法确定?
代表的是哪一个具体的子类型,所以不允许添加任何元素以防出错。场景示例:想象一下,我们正在开发一个电商系统,里面有各种不同类型的商品,比如
Book
(书)和Phone
(手机)。它们虽然是不同的类,但都有一个共同的父类Product
(商品),并且都包含一个getPrice()
方法。现在的需求是:编写一个通用的工具方法,用来计算任何一个“商品列表”的总价,无论这个列表里装的是书、是手机,还是其他任何种类的商品。
如果我们不使用通配符,很自然地会写出这样的方法:
1 | // 一个试图计算总价的“死板”方法 |
这个方法看起来没问题,但当 我们尝试使用它时,问题就暴露了:
1 | package com.example; |
错误原因:正如我们之前所说,即使Book
是Product
的子类,List<Book>
也不是List<Product>
的子类。因此,你无法将一个List<Book>
类型的变量传递给一个需要List<Product>
类型参数的方法。我们的calculateTotalPrice
方法因为参数类型写得太死,导致它完全没有复用性。
这时,上界通配符 ? extends Product
就派上了用场。它的含义是:“一个持有Product
或Product
的某种未知子类的列表”。
1 | package com.example; |
2. 下界通配符: ? super T
含义:“一个持有
T
或T
的某种未知父类的集合”。PECS原则:
super
关键字在这里意味着集合是一个消费者(Consumer),你只能向其中添加(add)T
类型及其子类型的对象。但当你从中**读取(get)**数据时,因为无法确定其具体类型,只能保证取出的东西是Object
。
下界通配符 ? super T
的应用场景虽然不如 ? extends T
那么频繁,但它在某些特定场景下同样至关重要,尤其是在设计需要“接收”或“消费”数据的灵活API时。
想象一下,我们正在为一个动物收容所系统编写工具方法。其中一个需求是:创建一个通用的方法,能够将一批新来的动物添加到各种不同的“动物名册”中。这些名册可能是专门的狗狗名册(List<Dog>)
,也可能是更宽泛的动物名册(List<Animal>)
,甚至是包含一切的生物名册(List<Creature>)
。
核心需求是:编写一个 addDogsToList
方法,它应该能接收任何**能装得下Dog
**的列表。
如果我们不使用通配符,最直观的写法可能是这样的:
1 | // 一个只能接收“狗狗名册”的“死板”方法 |
这个方法本身没有错,但它的适用范围太窄了。当我们想把狗狗添加到更通用的动物名册
时,问题就来了:
1 | package com.example; |
错误原因:List<Dog>
和 List<Animal>
是两种完全不同的类型,前者不能赋值给后者。尽管从逻辑上讲,把一只Dog
放进一个Animal
列表是天经地义的,但Java的泛型机制不允许这种直接的赋值。我们的addDogs
方法因为参数类型太具体,失去了通用性。
下界通配符 ? super Dog
在这里就派上了大用场。它的含义是:“一个持有Dog
或Dog
的某种未知父类的列表”。
通过使用它,我们的方法就能接收所有“能装得下狗”的容器了
1 | package com.example; |
3.10.4 [底层] 类型擦除
面试题引入
“Java的泛型是真泛型还是伪泛型?谈谈你对类型擦除的理解,它为什么被认为是‘变态’级的面试题?”
核心原理:编译期的“皇帝新衣”
Java的泛型是伪泛型。这意味着泛型提供的类型安全检查只存在于编译期。一旦代码被成功编译为.class
字节码文件,其中绝大部分的泛型类型信息都会被“擦除”掉,替换为它们的上界类型。
- 擦除规则:
- 无界泛型(如
<T>
):会被擦除为Object
类型。一个List<String>
在运行时看来就是一个List<Object>
。 - 有界泛型(如
<T extends Number>
):会被擦除为其指定的上界Number
类型。
- 无界泛型(如
[设计哲学] 为什么要擦除?
这是一个历史与工程权衡的决策,主要基于以下两点:
- 向后兼容 :这是最主要的原因。Java 5引入泛型时,需要确保海量的、没有使用泛型的老代码(如使用原始
List
)能够与新的、使用泛型的代码库协同工作,而不会产生兼容性灾难。类型擦除使得这一切成为可能。 - 避免“类爆炸”:如果Java采用真泛型(像C++的模板),那么
List<String>
、List<Integer>
、List<Double>
在运行时都会生成各自独立的.class
文件。这会导致一个泛型类在被不同类型参数化时,产生大量重复的类文件,极大地增加JVM的内存消耗和类加载负担。类型擦除确保了无论有多少种泛型实例化,运行时永远只有一份List.class
字节码。
类型擦除的运行时表现
getClass()
的证明
在运行时,JVM无法区分不同泛型参数的同一个泛型类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.example;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
// 尽管编译时类型不同,但运行时它们的Class对象是完全相同的
System.out.println("list1.getClass(): " + stringList.getClass());
System.out.println("list2.getClass(): " + integerList.getClass());
System.out.println("两者是否相等: " + (stringList.getClass() == integerList.getClass())); // 输出: true
}
}
[进阶] 如何“反擦除”:获取真实的泛型类型
尽管JVM在运行时常规操作中会忽略泛型,但为了支持反射等高级操作,泛型的真实类型信息实际上被以**签名(Signature)**的形式保留在了字节码中。因此,在特定场景下,我们是可以通过反射API“窥探”到这些被擦除的信息的。
场景:框架中获取父类的泛型类型
问题:在Spring或MyBatis等框架中,我们常写
public class UserDao extends BaseDao<User>
。框架是如何知道UserDao
操作的泛型实体就是User
这个类的呢?
答案:正是通过反射APIgetGenericSuperclass()
。
1 | package com.example; |
类型擦除带来的限制
- 不能对泛型使用基本类型:
List<int>
非法,必须使用包装类List<Integer>
。 - 不能在运行时检查泛型类型:
if (myList instanceof List<String>)
非法。 - 不能创建泛型数组:
T[] array = new T[10];
非法。 - 不能创建泛型实例:
T instance = new T();
非法。