一、Java 单例模式 (Singleton) 秒懂:全局唯一的“霸道总裁”
在软件开发中,有些对象我们只需要一个就够了。
比如你电脑上的“任务管理器”,不管你按多少次 Ctrl+Alt+Delete,弹出的永远是同一个窗口。如果每次都弹出一个新的,不仅浪费内存,还会导致数据错乱。
在 Java 中,保证一个类在整个运行期间只有一个实例对象的设计模式,就叫做单例模式(Singleton)。
1. 打造单例类的“三步曲”
要把一个普通的类改造成单例类,不需要什么高深的语法,只需要记住这三个雷打不动的步骤:
-
私有化构造器:这是最关键的一步!把构造器设为
private,彻底断绝外部代码通过new随便创建对象的念头。 -
创建私有静态实例:在类内部自己创建一个对象,并用
private static把它藏起来。 -
提供公共静态方法:开一扇唯一的“窗户”(通常叫
getInstance()),让外部只能通过这个通道拿到内部建好的那个唯一对象。
2. 经典写法一:饿汉式(着急投胎型)
“饿汉式”的特点是:不管你现在用不用,只要类一加载,我就立刻把对象创建好。
- 优点:写法超级简单,没有多线程同步的烦恼,天生线程安全。
- 缺点:如果这个对象特别大,而你整个程序运行期间又没用到它,就会白白占用内存。
public class Boss {
// 2. 自己内部先 new 一个唯一的实例出来 (static 保证它属于类)
private static final Boss INSTANCE = new Boss();
// 1. 构造器私有化,防外人偷建
private Boss() {
}
// 3. 提供唯一的获取通道
public static Boss getInstance() {
return INSTANCE;
}
}
3. 经典写法二:懒汉式(双重检查锁 DCL)
“懒汉式”的特点是:不见兔子不撒鹰。只有当你第一次调用 getInstance() 时,我才去创建对象(即延迟加载)。
为了在多线程环境下既保证安全,又保证性能,业界标准的写法是双重检查锁(Double-Checked Locking, DCL)机制,这也是面试官最喜欢考的代码!
public class LazyBoss {
// 注意:必须加上 volatile 关键字,防止指令重排序导致的“半初始化”隐患!
private static volatile LazyBoss instance;
// 1. 构造器私有化
private LazyBoss() {
}
// 3. 提供获取通道
public static LazyBoss getInstance() {
// 第一层检查:如果已经创建了,直接返回,避免去排队拿锁,提升性能
if (instance == null) {
// 加锁,保证多线程下只有一个线程能进去创建
synchronized (LazyBoss.class) {
// 第二层检查:防止多个线程同时进到第一层 if,锁释放后又重复创建一次
if (instance == null) {
instance = new LazyBoss();
}
}
}
return instance;
}
}
总结
- 核心目的:保证全局唯一,节省内存,方便管理共享资源。
- 适用场景:配置管理器、数据库连接池、日志记录器、任务管理器等。
- 选型建议:如果对象不大且一定会用到,直接写饿汉式(简单省事);如果对象初始化非常耗时且可能用不到,推荐使用双重检查锁的懒汉式。
二、Java 枚举类 (Enum) 深度详解:从入门到企业级实战
在写代码时,我们经常会遇到一些取值范围固定的数据。比如:
- 订单状态:只有“待支付”、“已发货”、“已完成”、“已取消”。
- 季节:只有“春”、“夏”、“秋”、“冬”。
很多新手喜欢用数字(比如 1 代表待支付,2 代表已发货)或者字符串来表示。这就是传说中的“魔法值”,除了写代码的人,别人根本看不懂 1 到底代表什么,且极其容易传错参数。
为了解决这个问题,Java 引入了枚举类(Enum)。它不仅是消除魔法值的利器,更是保证类型安全的“VIP 专属集合”。
1. 基础用法:最简单的枚举
使用 enum 关键字,你可以轻松定义一组固定的常量。
// 定义一个订单状态的枚举
public enum OrderStatus {
UNPAID, // 待支付
SHIPPED, // 已发货
COMPLETED, // 已完成
CANCELED // 已取消
}
怎么用?
// 1. 赋值:限制了变量只能是枚举中定义好的这 4 个值,极其安全!
OrderStatus status = OrderStatus.SHIPPED;
// 2. 比较:推荐直接使用 == 进行比较,不仅效率高,还能完美避免 NullPointerException!
if (status == OrderStatus.SHIPPED) {
System.out.println("状态匹配");
}
// 3. 结合 switch 使用,简直是绝配
switch (status) {
case UNPAID:
System.out.println("请尽快付款");
break;
case SHIPPED:
System.out.println("快递小哥正在路上");
break;
// ...
}
2. 企业级实战:带属性的枚举
在实际的企业级开发中,普通的枚举往往不够用。比如做 API 接口返回时,我们通常需要一个“状态码”(给前端判断)和一个“提示信息”(给用户看)。
这时候,我们可以给枚举添加自定义属性和构造器。
⚠️ 铁律:枚举的构造器必须是私有(private)的。因为枚举的本质是单例,实例在类加载时就已经固定好了,绝对不能让别人在外面随便 new。
public enum ResultCode {
// 1. 这些其实就是调用构造器创建的枚举实例对象(必须写在最前面,用逗号隔开,分号结尾!)
SUCCESS(200, "操作成功"),
ERROR(500, "系统开小差了"),
NOT_FOUND(404, "页面找不到"),
PARAM_ERROR(400, "参数校验失败");
// 2. 定义属性(强烈建议使用 final 修饰,保证一旦创建不可篡改)
private final int code;
private final String msg;
// 3. 私有构造器
private ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
// 4. 提供 Getter 方法让外部获取属性
public int getCode() { return code; }
public String getMsg() { return msg; }
// 5. 【高频实战】提供反向查找方法:根据前端传来的数字 code,获取对应的枚举实例
public static ResultCode getByCode(int code) {
for (ResultCode result : ResultCode.values()) {
if (result.getCode() == code) {
return result;
}
}
return null; // 没找到则返回 null,或者抛出业务异常
}
}
实战场景:
前端传过来一个状态码 200,后端可以通过 ResultCode.getByCode(200) 瞬间将其转换为安全的 SUCCESS 枚举对象,再获取对应的 msg 或进行严谨的业务流转。
3. 枚举自带的 4 个神仙方法
Java 编译器会在底层自动为枚举类加上几个超好用的方法:
-
name():获取枚举常量的精确名字(比如返回字符串"SUCCESS")。 -
ordinal():获取枚举常量的排序索引(从 0 开始计数)。强烈建议不要依赖这个值写核心业务逻辑,一旦有同事在枚举中间插了一个新值,原本的索引顺序就会全盘崩溃! -
values():返回一个包含所有枚举常量的数组,常用于遍历。 -
valueOf(String name):把一个字符串直接转换成对应的枚举对象。注意:如果传进来的字符串拼写错误,在枚举中不存在,会抛出IllegalArgumentException异常。
// 遍历所有的订单状态
for (OrderStatus status : OrderStatus.values()) {
System.out.println("枚举名: " + status.name() + ", 索引: " + status.ordinal());
}
// 字符串转枚举
OrderStatus parsedStatus = OrderStatus.valueOf("UNPAID");
4. 枚举的高阶认知
- 最安全的单例模式:单元素的枚举类型是《Effective Java》作者极力推荐的单例实现方式。因为它不仅天生防多线程并发问题,还能绝对防止“反射”和“反序列化”破坏单例对象。
-
不能继承,但能实现接口:枚举类底层默认继承了
java.lang.Enum,因为 Java 是单继承,所以枚举不能再继承其他类。但是,它可以实现接口(implements),这在构建统一的系统错误码接口时极其有用。
总结
- 核心作用:限定变量的取值范围,消除代码里的“魔法值”,极大地提升代码的可读性和安全性。
-
最佳实践:
- 比较枚举值时,直接用
==,别用equals()。 - 企业开发中,尽量使用带属性和
Getter的枚举,并配套写一个反向查找的方法。 - 绝对不要依赖
ordinal()的值做任何业务判断。
- 比较枚举值时,直接用
三、Java 抽象类 (Abstract) 秒懂:只画图纸,绝不施工
在面向对象编程中,我们经常会遇到一些非常“宽泛”的概念。比如“动物”、“图形”、“交通工具”。
你会发现,你可以去 new 一只具体的狗、一只猫,但你很难去 new 一个极其宽泛的“动物”。因为动物只是一个统称,它太抽象了,没有具体的长相和明确的行为。
在 Java 中,专门用来表示这种“半成品”或“统称”的类,就是抽象类(Abstract Class)。
1. 抽象类的三大核心特征
使用 abstract 关键字修饰的类就是抽象类。它有三大不容挑战的铁律:
-
绝对不能被实例化:你不能用
new关键字去创建抽象类的对象。编译器会直接拦截你,因为它是个“半成品”,强行拿来用只会导致程序逻辑混乱。 -
可以包含抽象方法:抽象方法就是只有方法名和参数,没有方法体(没有大括号
{})的方法。这就相当于给子类下达了“强制命令”:谁继承我,谁就必须把这个方法具体实现出来! - 强制重写规则:一个类继承抽象类,必须重写完抽象类的全部抽象方法,否则这个子类也必须定义成抽象类(相当于把没填完的坑继续留给下一代)。
2. 代码实战:图形的面积计算
假设我们要做一个计算图形面积的程序。我们可以定义一个抽象类 Shape(图形):
// 1. 定义抽象类(只需在 class 前加 abstract)
public abstract class Shape {
// 可以有普通属性
String name;
public Shape(String name) {
this.name = name;
}
// 可以有普通方法(已有具体实现,子类直接继承使用)
public void printName() {
System.out.println("当前图形是:" + name);
}
// 2. 抽象方法(没有方法体,强制要求子类去实现!)
public abstract double getArea();
}
既然 Shape 不能被 new,我们就必须写具体的子类(比如圆形、矩形)来继承它,并完成那些未完工的抽象方法:
// 子类:圆形
public class Circle extends Shape {
double radius; // 圆特有的半径属性
public Circle(String name, double radius) {
super(name); // 调用父类构造器
this.radius = radius;
}
// 3. 强制重写父类的抽象方法(如果不写,编译器会直接标红报错)
@Override
public double getArea() {
return Math.PI * radius * radius;
}
}
// 测试类
public class Test {
public static void main(String[] args) {
// Shape s = new Shape(); // ❌ 报错!抽象类不能被实例化
// ✅ 多态经典用法:父类引用指向子类对象
Shape circle = new Circle("大圆盘", 5.0);
circle.printName(); // 调用继承自父类的普通方法
System.out.println("面积:" + circle.getArea()); // 调用子类具体实现的方法
}
}
3. 为什么非要用抽象类?
很多新手会疑惑:我直接写一个普通的父类,里面随便写个空的 getArea 方法,然后让子类去重写不就行了吗?为什么要搞成这么严格的抽象类?
-
防呆机制(防止误用):如果
Shape是普通类,其他同事在业务代码里不小心new Shape(),这在逻辑上是荒谬的(世界上不存在一个没有特定形状的“纯图形”实例)。改成抽象类后,从根本上杜绝了这种可能。 -
强制契约:普通类的普通方法,子类是可以选择“不重写”的。这就可能导致子类忘记实现核心逻辑(比如忘记写
getArea返回了错误的默认值 0)。而抽象方法是强制要求,子类只要敢不写,连编译都过不去。 - 代码复用(模板方法模式的基础):抽象类非常适合用来写“半成品的模板代码”。父类在普通方法里把核心流程或通用功能写好,遇到不同子类有不同表现的地方,就挖个“坑”(声明为抽象方法),留给具体的子类去填坑。
总结
-
抽象类(
abstract class):不能被new出对象,天生就是用来被别人继承的。 -
抽象方法(
abstract method):没有方法体,必须被非抽象的子类重写。 - 它是作为系统的“骨架”或“蓝图”而存在的,完美结合了强制规范与代码复用。
四、 Java 接口 (Interface) 秒懂:万物皆可插拔的“规范合同”
上一篇我们聊了抽象类,它是一个半成品。但在 Java 中,有一种比抽象类还要彻底、还要纯粹的东西,那就是接口(Interface)。
生活中最典型的接口就是电脑上的 USB 接口。电脑厂商制定了 USB 的长宽、触点规范(这就是接口),而不管你是鼠标、键盘还是 U 盘,只要你符合这个规范(实现了接口),就能插到电脑上工作。
在 Java 中,接口就是一份“强制性的规范合同”。它不关心你是谁,它只关心你“会不会做某些事”。
1. 接口的两大核心语法
-
定义规范 (
interface):使用interface关键字定义接口。接口里通常只有常量和抽象方法(Java 8 之前)。 -
签署合同 (
implements):一个普通的类,通过implements关键字来实现接口。一旦签了合同,就必须实现接口里规定的所有抽象方法。
2. 代码实战:万能的 USB 接口
我们用代码来模拟 USB 设备的接入:
// 1. 定义一份 USB 规范合同
public interface USB {
// 接口中的方法默认都是 public abstract 的,可以省略不写
void connect();
void disconnect();
}
// 2. 鼠标厂商签合同,生产符合规范的鼠标
public class Mouse implements USB {
@Override
public void connect() {
System.out.println("鼠标已连接,光标亮起");
}
@Override
public void disconnect() {
System.out.println("鼠标已断开");
}
}
// 3. 键盘厂商签合同
public class Keyboard implements USB {
@Override
public void connect() {
System.out.println("键盘已连接,可以打字了");
}
@Override
public void disconnect() {
System.out.println("键盘已断开");
}
}
// 测试类(电脑)
public class Computer {
public static void main(String[] args) {
// 多态的终极体现:接口类型引用指向实现类对象!
USB myMouse = new Mouse();
USB myKeyboard = new Keyboard();
myMouse.connect(); // 输出:鼠标已连接,光标亮起
myKeyboard.connect(); // 输出:键盘已连接,可以打字了
}
}
3. 接口的最强魔法:打破单继承的魔咒
还记得继承的铁律吗?Java 是单继承的,一个类只能有一个亲爹。这在现实中有时很尴尬。
比如有一个 Dog 类继承了 Animal。现在想让这只狗当警犬,具备“搜救”和“防爆”的能力。如果你用继承,狗就没法同时继承“搜救类”和“防爆类”。
接口的出现完美解决了这个问题:Java 允许一个类实现多个接口!
public class PoliceDog extends Animal implements Searchable, ExplosiveDefuse {
@Override
public void search() {
System.out.println("搜救被困人员...");
}
@Override
public void defuse() {
System.out.println("排查危险爆炸物...");
}
}
就像一个人虽然只有一个亲爹(单继承),但他可以同时考取“驾照”、“厨师证”、“教师资格证”(多实现)。
4. 面试必考:接口 vs 抽象类
这是 Java 面试中几乎必问的经典题目,记住以下核心区别:
-
设计思想不同:
- 抽象类是
is-a(是什么) 的关系。比如Dog is an Animal,强调家族血统。 - 接口是
has-a / can-do(具备什么能力) 的关系。比如飞机会飞,鸟也会飞,它们血统完全不同,但可以实现同一个Flyable接口。
- 抽象类是
-
多重性不同:
- 抽象类只能单继承(
extends)。 - 接口可以多实现(
implements),甚至一个接口可以继承多个其他接口。
- 抽象类只能单继承(
-
成员构成不同:
- 抽象类可以有普通成员变量和普通方法。
- 接口里只能有常量(
public static final)和抽象方法(注:Java 8 之后加入了default默认方法和静态方法)。
总结
接口(Interface)是对行为的极端抽象,是 Java 实现解耦和多态的终极武器。在企业级开发中,我们常常提倡“面向接口编程”:大家只谈规范,不看底层,代码才能写得像搭积木一样灵活!