面试追问地图
- 基本类型与包装类型 — 存储、默认值、泛型、自动装箱 → Integer 缓存、拆箱 NPE、比较陷阱
- String 不可变 — final、字符存储、哈希缓存、安全性 → 字符串常量池、intern、拼接优化
- equals/hashCode — 相等契约、哈希容器定位 → 只重写 equals 会怎样、可变 Key 风险
- 抽象类与接口 — 单继承、多实现、状态与行为 → default 方法冲突、如何选型
- 反射与注解 — Class 元数据、运行期解析 → 性能开销、框架如何扫描、代理关系
- SPI — META-INF/services、ServiceLoader 懒加载 → 上下文类加载器为何破坏双亲委派、Dubbo SPI 改进点
- 泛型 — 类型擦除、编译期约束 → PECS、桥接方法、为何不能 new T
- Stream — 惰性求值、中间/终止操作 → 并行流线程池、副作用、性能边界
- CompletableFuture — 任务编排、异常传播、线程池 → thenApply/thenCompose、超时和取消
- IO/NIO — 阻塞模型、Channel/Buffer/Selector → 零拷贝、半包粘包、Netty 如何使用
- 序列化 — 对象到字节、版本兼容 → serialVersionUID、安全风险、替代协议
- 面向对象与设计模式 — 六大原则、单例、策略、代理 → DCL volatile、设计原则落地
- 内部类 — 四种内部类、语法糖、外部类引用 → 内存泄漏、final 变量拷贝、静态内部类单例
- Enum 枚举 — 本质是 final 类、ordinal、values() → EnumMap/EnumSet、反射限制、单例场景
- 异常处理 — 受检/非受检、try-with-resources → 异常链、性能开销、最佳实践
- 不可变类 — final 字段、防御性拷贝 → 与 Record 的关系
- 组合 vs 继承 — is-a vs has-a、耦合度 → “组合优于继承”原则
回答基础题时不要停留在语法定义,至少补充一个运行时行为或常见错误。
一、语言基础
JVM、JDK、JRE 三者关系?
频次 ★★★ · 难度 🟢
快答
- JDK > JRE > JVM,层层包含
- JDK = 开发工具包(javac、jdb 等)+ JRE
- JRE = 运行时环境 + JVM
- JVM = 虚拟机,负责字节码解释/编译执行
深答
- 跨平台的是 Java 程序(字节码),不是 JVM。JVM 是用 C/C++ 写的平台相关程序
- JVM 不只跑 Java,Kotlin、Scala 等语言编译后也能在 JVM 上运行
- Java 是编译 + 解释混合模式:先编译为字节码,JVM 中解释器 + JIT 编译器混合执行
- 现代口径:JDK 9+ 官方不再单独发行 JRE,模块化后用
jlink按需裁剪定制运行时,“JDK 包含 JRE”的三层说法只适用于 JDK 8 时代
Object 类有哪些重要方法?
频次 ★★★★ · 难度 🟡
是什么:java.lang.Object 是所有 Java 类的根,定义了 9 个核心方法(JDK 8 口径):
equals(Object)— 判断对象内容相等。需要自定义相等逻辑时重写(如 HashMap key)hashCode()— 返回哈希码。重写 equals 时必须重写toString()— 返回字符串表示。日志/调试时方便查看对象状态clone()— 创建并返回副本。需要支持克隆时重写(浅拷贝,需实现 Cloneable)getClass()— 返回运行时 Class 对象。一般不重写(final)notify()/notifyAll()/wait()— 线程间通信。并发编程中使用(final,不可重写)finalize()— GC 前回调。已废弃(JDK 9 deprecated,JDK 18 标记移除)
为什么这么设计:Object 的方法是所有对象的”公共协议”——hashCode/equals 支撑哈希容器,wait/notify 支撑线程协作,toString 支撑调试。这些方法放在 Object 层,保证了所有 Java 对象都有这些基础能力,不用每个类自己声明。
常见追问
- equals 和 hashCode 的契约是什么?→ ①equals 为 true 则 hashCode 必须相等(反向不成立);②同一个对象的 hashCode 多次调用必须一致(前提是 equals 用到的字段没变)。违反契约会导致 HashMap/HashSet 行为异常
- finalize 为什么被废弃?→ ①执行时机不确定(全靠 GC 心情),不能指望它释放资源;②有 finalize 的对象 GC 要多一轮才能回收,严重拖慢 GC;③finalize 中抛异常会被忽略,静默失败。替代方案:try-with-resources、Cleaner(JDK 9+)
- clone 的浅拷贝问题?→
Object.clone()默认是浅拷贝,引用字段只复制引用不复制对象。实现深拷贝需要递归 clone 引用字段,或走序列化深拷贝,见深拷贝和浅拷贝的区别
int 和 Integer 的区别?
频次 ★★★★ · 难度 🟡
快答
- int 是基本类型,Integer 是包装类(引用类型)
- Integer 支持自动装箱/拆箱,有缓存机制(-128 ~ 127)
- int 默认值 0,Integer 默认值 null
深答
为什么需要 Integer?
- 泛型只能用引用类型:
List<Integer>而非List<int> - 集合只能存对象,不能存基本类型
- 提供了 parseInt()、toString() 等工具方法
Integer 缓存机制:
Integer a = 127;
Integer b = 127;
a == b; // true(缓存复用)
Integer c = 128;
Integer d = 128;
c == d; // false(超出缓存范围,新建对象)默认缓存范围 -128 ~ 127,通过 Integer.valueOf() 创建时生效。
为什么还保留 int?
- int 读写效率更高,不需要对象分配
- 64 位 JVM 开启引用压缩后,一个 Integer 对象占 16 字节,int 只占 4 字节
- 自动装箱在循环中会创建大量无用对象,增加 GC 压力
String、StringBuilder、StringBuffer 区别?
频次 ★★★★ · 难度 🟡
一句话:单线程用 StringBuilder,多线程用 StringBuffer,不改动用 String。
- String:不可变,线程安全(因不可变),适合静态字符串。频繁修改性能低
- StringBuilder:可变,非线程安全,性能最高。适合单线程动态操作
- StringBuffer:可变,synchronized 保证线程安全,性能中等。适合多线程动态操作
String 不可变的原因:
- 内部数组
private final且不暴露修改方法——JDK 8 是char[] value,JDK 9+ 改为byte[] value+coder标记(Compact Strings:纯 Latin-1 内容每字符 1 字节,比 UTF-16 省一半内存,“为什么改 byte[]“本身就是高频追问) - 字符串常量池的需要(多个引用指向同一对象)
- 安全性(类加载器、网络连接等场景)
- 线程安全(不可变天然线程安全)
== 和 equals 的区别?
频次 ★★★★ · 难度 🟢
快答
==比较基本类型的值,比较引用类型的地址equals默认等价于==,但可以被重写为比较内容- String、Integer 等类重写了 equals 比较内容
hashCode 和 equals 的关系:
- 如果
a.equals(b)为 true,则a.hashCode() == b.hashCode()必须为 true - 如果 hashCode 相同,equals 不一定为 true(哈希冲突)
- 重写 equals 必须重写 hashCode,否则在 HashMap/HashSet 中会出问题
值传递 vs 引用传递
频次 ★★★★ · 难度 🟡
Java 只有值传递!
- 基本类型:传递值的副本,修改不影响原值
- 引用类型:传递引用的副本,通过副本可修改对象内容,但修改引用指向不影响原引用
常见追问
- String 传参能改吗?→ String 是不可变对象,方法内
str = "new"只是改了局部引用的指向,外部的引用不受影响。但如果是StringBuilder传参,方法内sb.append("x")会改变外部对象的内容——因为改的是对象本身,不是引用 - 数组传参能改吗?→ 能改元素内容(
arr[0] = 1),但不能改引用指向(arr = new int[10])——与对象引用传参的规则一致
static 关键字的四种用法
频次 ★★★ · 难度 🟢
- 静态变量:类级别共享,所有实例共用
- 静态方法:不依赖实例,不能访问非静态成员
- 静态代码块:类加载时执行一次,初始化静态资源
- 静态内部类:不依赖外部类实例,避免内存泄漏
常见追问
- 静态代码块和构造代码块谁先执行?→ 静态代码块 > 构造代码块(
{})> 构造器。静态代码块只在类加载时执行一次,构造代码块每次 new 都执行 - 子类父类静态代码块执行顺序?→ 父类静态 → 子类静态 → 父类构造代码块 → 父类构造器 → 子类构造代码块 → 子类构造器
- static 方法能重写吗?→ 不能。static 方法属于类,不是实例方法,没有多态。子类可以定义同名 static 方法隐藏父类方法(叫”隐藏”不叫”重写”),调用哪个版本取决于引用类型而非实际对象类型
final 关键字的三种用法
频次 ★★★★ · 难度 🟢
是什么:final 修饰类/方法/变量,分别表示”不可继承/不可重写/不可修改”。
- final 类:不可被继承。如
String、Integer、安全框架的核心类 - final 方法:不可被子类重写。如模板方法模式中锁定算法骨架、防止子类破坏不变量
- final 变量:赋值后不可改。常量(
static final)、局部变量传递给匿名内部类
为什么这么设计:三者共同点是”锁死”,但设计意图不同——final 类防继承破坏(安全),final 方法防重写破坏(正确性),final 变量防修改(线程安全+JMM 保证)。
常见追问
- final 变量和普通变量的 JMM 区别?→
final字段在构造函数中正确初始化后,JMM 保证其他线程不需要同步就能看到正确的值(final 字段语义)。这是不可变对象线程安全的基础——前提是构造函数中 this 没有逸出 - blank final 是什么?→ 声明时未赋值的 final 字段,必须在构造函数中赋值(每个构造器都要覆盖所有 blank final)。这允许 final 字段的值依赖构造器参数,同时保持不可变性
- final 方法能提高性能吗?→ 早期 JVM 会对 final 方法做内联优化,但现代 JIT 通过类层次分析(CHA)能自动识别”事实上 final”的方法,手动加 final 的收益很小。所以 final 的主要价值是语义,不是性能
BigDecimal 为什么比 double 更适合金额计算?
频次 ★★★ · 难度 🟢
double 使用二进制浮点运算,无法精确表示某些十进制小数(如 0.1),导致精度丢失:
System.out.println(0.05 + 0.01); // 0.060000000000000005正确做法:
BigDecimal a = new BigDecimal("0.05"); // 用字符串构造
BigDecimal b = new BigDecimal("0.01");
System.out.println(a.add(b)); // 0.06(精确)常见追问
- 为什么不能
new BigDecimal(0.1)?→ double 传进构造器时精度已经丢了(实际是 0.1000000000000000055…),要用字符串构造或BigDecimal.valueOf()(内部走Double.toString) equals和compareTo的区别?→equals连标度一起比(0.1与0.10不等),compareTo只比数值;金额判等要用compareTo() == 0,用 HashSet/HashMap 对 BigDecimal 去重是经典坑- 除法为什么会抛异常?→ 除不尽(如 1/3)时不指定精度直接抛
ArithmeticException,必须divide(b, scale, RoundingMode.HALF_UP)显式给舍入模式
Enum 枚举
频次 ★★★★★ · 难度 🟢
是什么:enum 是 Java 5 引入的语法糖,编译后生成继承 java.lang.Enum 的 final 类。枚举常量实质是类的静态 final 实例。
核心特性:
- 构造器默认 private,在 static 块中按声明顺序初始化所有常量
ordinal()返回声明顺序(0-based),依赖 ordinal 的代码在增删中间常量时静默出错values()是编译器生成的静态方法(反射不可见),返回所有常量数组valueOf(String)通过name()匹配枚举常量- enum 可以实现接口、定义抽象方法让每个常量提供不同实现
- 编译器禁止显式继承 Enum,但允许 implements 接口
public enum Status {
PENDING(0, "待支付"),
PAID(1, "已支付"),
REFUNDED(2, "已退款");
private final int code;
private final String desc;
Status(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static Status fromCode(int code) {
for (Status s : values()) {
if (s.code == code) return s;
}
throw new IllegalArgumentException("Unknown code: " + code);
}
}EnumMap / EnumSet:
EnumMap:内部用数组(ordinal 做下标),比 HashMap 更快更省内存。key 必须同类型枚举EnumSet:内部用位向量(64 位 long 或 long[]),比 HashSet 快几个数量级。适合枚举组合判断- 两者迭代顺序都是枚举声明顺序
常见追问:
- enum 可以被反射创建吗?→ 不能。
Constructor.newInstance()对 enum 类型抛IllegalArgumentException - enum 线程安全吗?→ 枚举常量在 static 块初始化,由 JVM 类加载保证线程安全
- 单例模式为什么要用 enum?→ 天然防反射攻击、防序列化破坏(Enum 的 readObject 返回同一实例)
- switch 可以用 enum 吗?→ 可以且推荐,编译器检查是否覆盖所有分支
- 枚举能序列化吗?→ 写的是 name,读时
valueOf(name),保证单例;即使 serialVersionUID 不同也能反序列化
Java 8+ 时间 API(java.time)
频次 ★★★ · 难度 🟡
是什么:Java 8 引入 java.time 包,解决旧版 Date/Calendar 的线程不安全、设计混乱、时区处理困难等问题。
新旧对比:
| 旧 API(Java 8 前) | 新 API(java.time) | 说明 |
|---|---|---|
java.util.Date | Instant | 时间戳,精确到纳秒 |
java.util.Calendar | LocalDate / LocalTime / LocalDateTime | 无时区的日期时间 |
SimpleDateFormat(线程不安全) | DateTimeFormatter(线程安全) | 格式化/解析,不可变且线程安全 |
new Date(year, month, day) | LocalDate.of(2024, 1, 15) | 构造方式,新 API 无 month 偏移 |
核心类:
// 1. 日期时间
LocalDate today = LocalDate.now(); // 2024-01-15
LocalTime now = LocalTime.now(); // 14:30:00
LocalDateTime dt = LocalDateTime.now(); // 2024-01-15T14:30:00
// 2. 时间戳(面向机器,UTC)
Instant instant = Instant.now(); // 2024-01-15T06:30:00Z
// 3. 时区处理
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
// 4. 时间间隔
Duration d = Duration.between(start, end); // 秒/纳秒精度
Period p = Period.between(startDate, endDate); // 年月日精度
// 5. 线程安全的格式化
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime.parse("2024-01-15 14:30:00", fmt); // 解析
dt.format(fmt); // 格式化
// 6. 日期运算(返回新对象,不可变)
LocalDate nextWeek = today.plusWeeks(1); // 加一周
LocalDate firstDay = today.withDayOfMonth(1); // 当月第一天常见追问:
SimpleDateFormat为什么不安全?→ 内部Calendar是共享可变状态,多线程并发调用format()/parse()导致数据错乱。DateTimeFormatter不可变,无此问题LocalDate和Date的转换?→Date.from(instant)/date.toInstant().atZone(zoneId).toLocalDate()- 新旧系统互操作时注意什么?→ 数据库驱动 4.2+ 支持
LocalDate↔java.sql.Date直接映射;Instant是 UTC 时间戳,LocalDateTime不带时区,跨时区系统用Instant或ZonedDateTime
二、面向对象与设计模式
面向对象六大设计原则?
频次 ★★★ · 难度 🟡
- 单一职责(SRP):一个类只负责一项职责
- 开闭原则(OCP):对扩展开放,对修改封闭
- 里氏替换(LSP):子类对象能替换父类对象
- 接口隔离(ISP):接口应该小而专
- 依赖倒置(DIP):依赖抽象而非具体实现
- 最少知识(LoD):只与直接朋友交互
多态的体现: 方法重载(编译时)、方法重写(运行时)、接口实现、向上/向下转型
常见追问
- 里氏替换的经典违反案例?→ 正方形继承长方形——正方形设宽时高也变,违反父类”宽高独立修改”的预期。根本原因是”正方形 is-a 长方形”只在数学上成立,在可变对象的行为语义上不成立
- 依赖倒置怎么落地?→ 关键不是”用接口”,而是”接口的归属权”——接口由使用方定义,实现方去适配。比如 Controller 需要的
UserService接口由业务层定义,不是由 DAO 层定义
抽象类和接口的区别?
频次 ★★★★ · 难度 🟡
抽象类
- 关键字:
extends,单继承 - 成员变量:可有实例变量
- 方法:可有具体实现
- 构造器:有
- 设计意图:is-a 关系,代码复用
接口
- 关键字:
implements,多实现 - 成员变量:只能有常量(
public static final) - 方法:Java 8 前只能有抽象方法,Java 8+ 可有 default/static,Java 9+ 可有 private
- 构造器:无
- 设计意图:has-a/can-do 能力,定义规范
常见追问
- default 方法冲突怎么解决?→ 实现两个接口的同名 default 方法,编译期强制子类重写;子类可通过
接口名.super.method()选择调用哪一方的实现 - 什么时候用抽象类而不是接口?→ 需要共享状态(实例变量)和构造器逻辑时用抽象类(模板方法模式是典型场景);只定义行为契约用接口。Java 8+ 接口有了 default 方法后,抽象类的存在空间被压缩,但”状态共享”仍是抽象类不可替代的
- 接口里能定义常量吗?→ 能(
public static final),但不推荐用接口做常量池——接口是用来定义行为的,用接口暴露常量是反模式(Effective Java 第 22 条)
方法重载和方法重写的区别?
频次 ★★★★ · 难度 🟡
是什么:重载(Overload)是编译期多态——同一个类中方法名相同、参数列表不同;重写(Override)是运行期多态——子类重新定义父类的方法。
重载(Overload)
- 发生在同一个类(或父子类),参数列表必须不同(类型/个数/顺序)
- 返回值可以不同,访问修饰符和异常可以不同
- 编译期决定调用哪个版本
重写(Override)
- 发生在子类重写父类方法,参数列表必须相同
- 返回值相同或协变(子类型),访问修饰符不能更严格,异常不能抛出更宽泛的受检异常
- 运行期决定调用哪个版本,推荐加
@Override注解
常见追问
- 重载能只靠返回值类型区分吗?→ 不能。JVM 的方法签名不包括返回值类型,编译期无法区分
int f()和String f()——调用方不强制接收返回值时,编译器不知道调用哪个 @Override注解有什么用?→ ①编译期检查:确保方法确实重写了父类方法(如果拼错或参数不对,编译器报错);②文档作用:明确告诉读者这是重写。不加也能重写,但容易因拼写错误导致”以为重写了其实没有”- 静态方法能被重写吗?→ 不能。static 方法属于类而非实例,子类定义同名 static 方法叫”隐藏”(hiding),调用哪个版本取决于引用类型,不是实际对象类型——所以没有多态
内部类
频次 ★★★★★ · 难度 🟢
是什么:定义在另一个类内部的类。Java 有四种内部类,本质是编译器语法糖——编译后全部提升为独立的顶级 class 文件(Outer$Inner.class)。
成员内部类
- 定义在类成员位置,必须通过
outer.new Inner()创建 - 依赖外部类实例 · 不可定义静态成员(Java 16 前)
- 典型用途:专属于外部类的逻辑,如集合的 Iterator
静态内部类
- 定义在类成员位置 +
static,可独立创建new Outer.StaticInner() - 不依赖外部类实例 · 可定义静态成员
- 典型用途:单例持有者、Builder、分组配置
局部内部类
- 定义在方法/代码块内,需在外部类方法中创建
- 依赖外部类实例 · 不可定义静态成员
- 典型用途:极少用,仅在该方法内复用
匿名内部类
- 方法内
new接口/类时直接定义,需在外部类方法中创建 - 依赖外部类实例 · 不可定义静态成员
- 典型用途:事件回调、Runnable、Comparator
关键特性:
- 成员内部类持有外部类
this引用,可以访问外部类 private 成员;这也意味着外部类无法被 GC(内部类对象存活时),是内存泄漏的常见来源 - 匿名内部类引用的局部变量必须是
final或 effectively final(JDK 8+) - 静态内部类不持有外部类引用,不会导致内存泄漏——Android/Handler 泄漏的修复方式就是改成静态内部类
- 所有内部类编译后独立成 class 文件:
Outer$Inner.class
常见追问:
- 为什么内部类访问的局部变量必须是 final?→ 内部类对象可能在方法返回后才执行(如回调),那时局部变量已出栈。Java 的解决方案是在内部类中拷贝一份,用 final 保证拷贝和原值始终一致
- 内部类会导致内存泄漏吗?→ 会。成员内部类隐式持有外部类引用,如果内部类生命周期比外部类长(如匿名 Runnable 提交到线程池),外部类就无法被 GC。修复:改用静态内部类 + 弱引用
- 静态内部类单例为什么线程安全?→ 见单例模式
this 和 super 关键字在 Java 中的作用和区别是什么?
是什么:this 引用当前对象实例,super 引用父类部分。this() 调用本类其他构造器,super() 调用父类构造器(必须放构造器首行,两者不能同时出现)。
常见用法:this.field(区分同名参数和实例变量)、super.method()(调用被重写的父类方法)、super()(子类构造器默认隐式调用父类无参构造器,父类没有无参构造器时必须显式调用)。
常见追问:this() 和 super() 为什么必须放构造器首行?→ Java 规定子类构造器必须先完成父类初始化才能执行自己的逻辑——语法层面的”父类优先”原则。这也解释了为什么两者不能同时出现:一个构造器只能有一个”首行”。
Java 创建对象有哪几种方式?
频次 ★★★ · 难度 🟡
- new 关键字:调用构造器,最常用,紧密耦合
- 反射(Constructor.newInstance):调用构造器,灵活,用于框架
- clone():不调用构造器,需实现 Cloneable,浅拷贝
- 反序列化:不调用构造器,需实现 Serializable
- 工厂模式:在方法内调用构造器,解耦,隐藏创建逻辑
常见追问
- 反序列化为什么不调构造器?→ 对象流在 native 层直接分配内存并逐字段赋值,走的是
ObjectInputStream内部机制,不是Constructor.newInstance();这也是为什么反序列化能绕过构造器里的校验逻辑,有安全风险 - clone() 和反序列化谁更快?→ clone 更快(native 内存拷贝),但两者都是浅拷贝且不调构造器,都需要实现标记接口
- 有没有不调构造器就能创建对象的方式?→
Unsafe.allocateInstance()(JDK 内部 API,不推荐)和反序列化都可以——这也说明了构造器不是创建对象的唯一路径
深拷贝和浅拷贝的区别?
频次 ★★★ · 难度 🟡
- 浅拷贝:只复制对象本身和值类型字段,引用类型字段复制的是引用地址(新旧对象共享同一个引用对象)
- 深拷贝:递归复制对象及其所有引用类型字段,生成完全独立的新对象
实现深拷贝的三种方式:
- 实现 Cloneable 接口并递归 clone 引用字段
- 序列化 + 反序列化(需实现 Serializable)
- 手动递归复制
常见追问
- 序列化深拷贝的缺点?→ ①慢(IO + 反射);②所有对象必须实现 Serializable;③transient 字段会丢失;④循环引用可能栈溢出。适合复杂对象图的深度拷贝,简单对象推荐手动递归复制
- clone() 为什么被诟病?→ Cloneable 是标记接口但不声明 clone 方法(设计缺陷),clone() 是 protected 方法,且默认为浅拷贝——几乎所有行为都靠”约定”而非”契约”
如何设计一个不可变类?
频次 ★★ · 难度 🟡
是什么:不可变类创建后其状态(字段值)不可改变。Java 的 String、Integer、BigDecimal、Record 都是不可变类。
设计规则:
- 类声明为
final,防止子类破坏不可变性 - 所有字段
private final - 不提供 setter 方法
- 如果字段是可变对象引用,防御性拷贝:构造器拷贝传入对象,getter 返回拷贝而非原始引用
- 可变操作返回新对象而非修改当前对象(
String.substring()返回新String)
为什么不可变类线程安全:状态不可变 → 不存在竞态条件 → 多线程随意共享,不需要同步。这是 String 作为 HashMap key 的原因——hashCode 可以缓存,不怕被改。
常见追问:final 修饰引用类型字段,引用不能变但对象内容能变 → 需要防御性拷贝,或使用 List.copyOf() / Collections.unmodifiableList() 包装。
组合与继承各有什么优缺点?什么情况下选择组合而不是继承?
频次 ★★ · 难度 🟡
是什么:继承用 extends 复用父类代码,组合在类内部持有另一个类的实例引用。
继承
- 关系:
is-a(子类是父类) - 耦合度:高(子类依赖父类实现细节)
- 灵活性:编译时确定,不可变
- 封装性:破坏封装(子类访问父类 protected 成员)
- 扩展性:只能单继承
组合
- 关系:
has-a(包含关系) - 耦合度:低(只依赖接口)
- 灵活性:运行时动态替换
- 封装性:不破坏封装
- 扩展性:可组合多个行为
“组合优于继承”原则:继承是强耦合——父类改实现子类可能跟着出问题。组合通过接口 + 委托实现更灵活的复用。只有当确实存在”is-a”关系且父类设计为继承而设计时(如模板方法模式),才用继承。
单例模式(双重检查锁定)
频次 ★★★★ · 难度 🟡
public class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}为什么需要 volatile?
- 保证可见性
- 禁止指令重排序(
instance = new Singleton()分为:分配内存 → 初始化 → 赋值给引用,重排序后其他线程可能拿到未初始化的对象)
另一种线程安全且惰性的写法是静态内部类单例——线程安全由 <clinit> 的加锁单次语义兜底,见JVM”类加载过程”一节。
策略模式 vs 责任链模式
频次 ★★★ · 难度 🟡
策略模式:封装一组可互换的算法,运行时选择
- 场景:支付方式选择(支付宝/微信/银行卡)、排序算法切换
责任链模式:请求沿处理者链传递,直到被处理
- 场景:请求校验链(登录 → 权限 → 频率限制)、审批流程
两者共同目的:消除复杂的 if-else,提高扩展性
常见追问
- 策略和责任链怎么区分?→ 策略是”选一个执行”,责任链是”逐个试直到有人处理”。策略只有一个实现被调用,责任链可能多个处理器都参与
- 策略模式不就用一个 Map 存实现类吗?→ 核心不是怎么存,而是”策略的互换性对调用方透明”——调用方不关心具体是哪个算法,只依赖策略接口。Map 只是存储方式,策略模式的价值在于封装变化
代理模式 vs 适配器模式
频次 ★★ · 难度 🟢
- 代理模式:控制对对象的访问,添加额外功能(如 Spring AOP)
- 适配器模式:转换接口,让不兼容的类协同工作
常见追问
- 代理和适配器的根本区别?→ 代理和被代理对象实现同一个接口,对调用方透明;适配器实现的是目标接口(和被适配对象不同),目的是把不兼容的接口转成调用方需要的接口
- 装饰器模式和代理模式怎么分?→ 装饰器是”增强”(层层包装,如 Java I/O 的
BufferedInputStream),代理是”控制访问”(如延迟加载、权限检查)。代理通常在编译期/启动期生成(动态代理),装饰器在运行时手动组合
三、泛型、反射与 SPI
泛型是如何实现的?为什么说是”伪泛型”?
频次 ★★★ · 难度 🟡
是什么:Java 泛型只存在于编译期,编译器做完类型检查后会擦除成原始类型(Type Erasure),运行时字节码里根本没有泛型信息——这就是常说的”伪泛型”,区别于 C++ 模板(真的为每个类型生成一份代码)。
擦除规则:
public class Box<T> { T value; } // 编译后 T 被擦成 Object
public class NumBox<T extends Number> { T v; } // 编译后 T 被擦成 Number(擦成上界)
List<String> list = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list.getClass() == list2.getClass()); // true —— 运行时都是 ArrayList,没有 <String>/<Integer> 之分桥接方法(源码验证擦除的证据):
class MyComparator implements Comparator<String> {
public int compare(String a, String b) { return a.length() - b.length(); }
}用 javap -p MyComparator 反编译能看到编译器额外生成了一个方法:
// 编译器生成的桥接方法(bridge method),字节码层面才存在
public int compare(Object a, Object b) {
return compare((String) a, (String) b); // 强转后调用真正的实现
}接口 Comparator<T> 擦除后方法签名是 compare(Object, Object),但子类写的是 compare(String, String)——两者签名不同,不构成重写。编译器靠生成桥接方法伪造出一个 compare(Object,Object) 覆盖接口方法,内部再强转调用真正实现,才让擦除后的多态继续成立。
常见追问
- 为什么不能
new T[10]?→ 擦除后T变成Object,new T[10]实际会创建Object[];但调用方赋值给String[]之类的具体数组类型引用时,运行时数组是有类型信息的(不像泛型集合),会在别的地方触发ClassCastException。所以 JDK 禁止直接写这行代码,要用(T[]) new Object[10]强转(不安全但能过编译,本质是绕过检查)或Array.newInstance(clazz, 10)。 - 泛型擦除会带来什么运行时开销问题?→ 基本类型泛型会被迫自动装箱(
List<Integer>存的是Integer对象不是int),大量数据场景有装箱拆箱和内存开销,这也是 JDK 一直没有List<int>的根因;Project Valhalla 的值类型提案目标之一就是解决这个问题(截至 JDK 25 仍未正式落地,面试别把它当已发布特性讲)。 - 通配符
? extends T和? super T怎么记?→ PECS 原则(Producer Extends, Consumer Super):只读取(生产数据给你用)就用extends,如List<? extends Number> src你能读出 Number 但不能往里加;只写入(消费你给的数据)就用super,如List<? super Integer> dest你能加 Integer 但读出来只能当 Object 用。
通用概念:类型擦除是编译期多态、运行期单态的一种权衡——在保证向后兼容(Java 5 引入泛型时,老代码用 List 不用 List<T> 也能和新代码互相调用)和不修改 JVM 字节码规范的前提下实现类型安全检查。C# 的泛型是运行时具体化(reified),没有这个问题,但代价是不能像 Java 一样直接对老字节码保持兼容。
反射机制及应用场景?
频次 ★★★ · 难度 🟡
快答
- 反射是在运行状态中动态获取类信息(属性、方法、构造器)并调用/修改的能力
- 核心类:
Class、Method、Field、Constructor
应用场景:
- Spring IOC 容器:根据配置文件动态加载和创建 Bean
- JDBC 驱动加载:
Class.forName("com.mysql.cj.jdbc.Driver") - 动态代理、ORM 框架(Hibernate、MyBatis)
获取私有字段:
Field field = clazz.getDeclaredField("privateField");
field.setAccessible(true);
Object value = field.get(obj);常见追问
- 反射的性能开销有多大?→ 比直接调用慢 1~2 个数量级(setAccessible 检查、类型包装、数组遍历参数);优化三板斧:①缓存 Method/Field 对象不重复查;②
setAccessible(true)跳过安全检查;③批量操作时用 MethodHandle(Java 7+)或 VarHandle(Java 9+)比反射快 setAccessible(true)在模块系统下还能用吗?→ JDK 9+ 强封装下反射访问java.*内部 API 会抛InaccessibleObjectException;JVM 参数--add-opens可临时放开,但 JDK 26 起 Final Mean Final 将进一步收紧- 反射和动态代理的关系?→ 动态代理底层就是反射——
Proxy.newProxyInstance()生成的代理类,在InvocationHandler.invoke()里通过Method.invoke()调用目标方法
Java 注解的原理?
频次 ★★★ · 难度 🟡
- 注解本质是继承
Annotation接口的特殊接口 - 运行时注解通过反射获取时,返回的是动态代理对象(
AnnotationInvocationHandler) - 注解信息存储在 class 文件的属性表中(
RuntimeVisibleAnnotations) @Retention控制保留策略:SOURCE(仅源码)、CLASS(class 文件)、RUNTIME(运行时可反射)@Target控制作用位置:TYPE、FIELD、METHOD、PARAMETER 等
常见追问
- 注解能继承吗?→
@Inherited元注解只能让子类继承父类上的注解,接口实现和重写方法不会继承注解——这是 Spring@Transactional在自调用时失效的底层原因之一 - 重复注解怎么实现?→ Java 8 引入
@Repeatable,把多个同类型注解包装进一个容器注解;getAnnotationsByType()能直接拿到数组 - 注解处理器(APT)和运行时反射有什么区别?→ APT 在编译期处理(如 Lombok、MapStruct),生成新代码,运行时零开销;运行时反射在 JVM 中动态获取,有性能开销但更灵活,是两个完全不同的阶段
SPI 机制:ServiceLoader 是怎么找到实现类的?
频次 ★★★ · 难度 🟡
是什么:SPI(Service Provider Interface)是”接口在框架、实现在第三方”的服务发现机制:框架只定义接口,实现方在自己 jar 的 META-INF/services/<接口全限定名> 文件里登记实现类,框架用 ServiceLoader.load(接口.class) 在运行时发现并实例化。典型:JDBC 驱动(java.sql.Driver)、SLF4J 绑定、Dubbo 扩展点。
为什么这么设计:解决”框架代码不能 import 实现类”的依赖倒置问题——JDK 的 DriverManager 不可能 import MySQL 驱动。没有 SPI 就得硬编码 Class.forName("com.mysql..."),换实现要改代码;SPI 把”配置文件登记 + 反射加载”这套约定标准化,是开闭原则在类加载层面的落地。
源码(JDK 8 java.util.ServiceLoader,主干):
public static <S> ServiceLoader<S> load(Class<S> service) {
// 取线程上下文类加载器,而不是 ServiceLoader 自己的加载器(见下文双亲委派衔接)
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
private class LazyIterator implements Iterator<S> {
Enumeration<URL> configs; // 各 jar 中 META-INF/services/ 下的同名文件
public boolean hasNextService() {
if (configs == null) // 第一次 hasNext 才去扫描配置文件 —— 懒加载
configs = loader.getResources(PREFIX + service.getName());
// 逐文件逐行读出实现类全限定名
...
}
public S nextService() {
Class<?> c = Class.forName(cn, false, loader); // 只加载不初始化
S p = service.cast(c.newInstance()); // 实例化并做类型检查
providers.put(cn, p); // LinkedHashMap 缓存已创建实例
return p;
}
}两个关键点:①懒加载——load() 只创建迭代器,不做任何 IO,遍历到哪个才加载/实例化哪个;②只能全量顺序迭代——想要特定实现也得从头逐个实例化再自己挑(DriverManager 就是全部实例化后靠 URL 前缀匹配)。
与双亲委派的衔接:DriverManager 在 java.sql 包、由 Bootstrap 加载,按双亲委派它”看不见”应用 classpath 下的驱动实现;所以 load() 取线程上下文类加载器(默认 AppClassLoader)来加载实现类——父加载器借子加载器干活,方向反了,这是双亲委派的经典破坏场景,见JVM”双亲委派的破坏场景”。
对比 Dubbo SPI 为什么重写:
JDK ServiceLoader
- 配置格式:一行一个类名
- 获取方式:只能全量迭代
- IoC:无
- AOP:无
- 失败表现:某个实现类加载失败,整个迭代抛异常
Dubbo ExtensionLoader
- 配置格式:KV(
dubbo=com.xxx.DubboProtocol) - 获取方式:按名取
getExtension("dubbo"),按需实例化 - IoC:扩展点之间可 setter 注入(自适应扩展)
- AOP:Wrapper 类自动层层包装
- 失败表现:单个扩展失败不影响其他,报错点名扩展名
常见追问:
- JDBC 4.0 之后为什么不用写
Class.forName了?→ DriverManager 静态初始化时用 ServiceLoader 自动发现驱动;之前的 Class.forName 是靠驱动类静态块里registerDriver完成注册 - Spring Boot 自动配置和 SPI 是什么关系?→ 思想同源:“配置文件登记 + 反射加载”,只是文件换成
spring.factories(2.7+ 为AutoConfiguration.imports),加载器换成 SpringFactoriesLoader,还叠加了条件注解按需生效,见SpringBoot自动配置原理 - ServiceLoader 线程安全吗?→ 不安全(Javadoc 明确标注),providers 缓存无同步,多线程共享要外部加锁
通用概念:SPI 是控制反转在”发现实现”环节的形态——使用方不 new 具体实现,由约定/容器反向提供。同一模式:Spring IoC(见Spring)、SLF4J 日志门面找绑定、K8s 的 CNI/CSI 插件机制。
四、异常处理
受检异常 vs 非受检异常?
频次 ★★★★ · 难度 🟢
快答
- 受检异常(Checked Exception):编译器检查,必须处理,如 IOException
- 非受检异常(Unchecked Exception):继承 RuntimeException,可不处理
为什么这么设计:受检异常想把”可预期、可恢复的失败”(文件不存在、网络中断)编码进方法签名,强迫调用方表态;运行时异常代表编程错误(空指针、越界),当场恢复没有意义所以不强制。但强制处理催生了大量 catch 后吞掉的反模式,Kotlin/C# 都放弃了受检异常,Spring 把 SQLException 包装成非受检的 DataAccessException 也是同一判断。
建议:
- 自定义异常:如果调用者能恢复,用受检异常;如果无法恢复,用非受检异常
- 不要捕获后什么都不做(empty catch)
- 使用特定的异常类,便于定位问题
常见追问
- finally 一定会执行吗?→ 除
System.exit()、JVM 崩溃、所在线程被杀外都执行;但 finally 里写 return 会吞掉 try 的返回值和异常,属于禁手 - try-with-resources 的原理?→ 编译器语法糖,自动生成 finally 调
close();close 抛出的异常会通过addSuppressed挂在主异常上而不是覆盖它——手写 finally close 恰好相反(close 异常覆盖业务异常),这是它的核心优势 - 异常的性能成本在哪?→ 构造异常时
fillInStackTrace抓取整个调用栈最贵;用异常做正常流程控制是反模式
try-with-resources 的原理和最佳实践
频次 ★★★★ · 难度 🟡
是什么:Java 7 引入的语法糖,实现了 AutoCloseable 接口的资源可以在 try 块结束时自动关闭,无需手动 finally。
// 传统写法:close() 异常会覆盖业务异常
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("file.txt"));
String line = br.readLine();
} finally {
if (br != null) br.close();
}
// try-with-resources(JDK 7+):close 异常挂在主异常上
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
String line = br.readLine();
}为什么这么设计:解决两个痛点:①忘记 close 导致资源泄漏;②手写 finally close 时,close 抛出的异常会覆盖 try 块中的业务异常,导致排查只能看到 close 失败而看不到真正的业务问题。try-with-resources 用 suppressed 异常机制解决了后者。
常见追问
- 多个资源关闭顺序?→ 后声明的先关闭(栈序,逆序关闭)。
try (a; b; c) { }关闭顺序是 c → b → a - 什么类可以用?→ 实现了
AutoCloseable(JDK 7+)的类。常见:InputStream/OutputStream、Reader/Writer、Connection/Statement/ResultSet - 和手动 finally close 的核心区别?→ close 异常不覆盖业务异常,而是通过
addSuppressed挂在主异常上,排查时既能看到业务异常也能看到 close 异常
Java 常见的异常类有哪些?
频次 ★★ · 难度 🟡
运行时异常(RuntimeException,非受检):
NullPointerException:对象为 null 时调用方法/访问字段IndexOutOfBoundsException:数组/集合索引越界(ArrayIndexOutOfBoundsException、StringIndexOutOfBoundsException)IllegalArgumentException:方法参数不合法(含NumberFormatException)IllegalStateException:对象状态不满足方法调用条件ClassCastException:类型转换错误ConcurrentModificationException:迭代集合时被结构修改(fail-fast)ArithmeticException:算术异常(如除零)
受检异常(Checked Exception,编译期强制处理):
IOException:I/O 操作失败(含FileNotFoundException)SQLException:数据库操作失败ClassNotFoundException:Class.forName()找不到类InterruptedException:线程被中断
Error(不要求处理,通常无法恢复):
OutOfMemoryError:堆内存耗尽StackOverflowError:递归过深NoClassDefFoundError:编译时存在但运行时找不到的类
如何在 Java 中自定义异常?
是什么:继承 Exception(受检异常)或 RuntimeException(非受检异常),提供构造器,可选添加错误码等额外信息。throw 抛出异常实例,throws 声明方法可能抛出的异常类型。
常见追问:受检 vs 非受检怎么选?→ 调用方能恢复的用受检(如文件不存在可提示用户重试),无法恢复的用非受检(如参数校验失败)。受检异常强制表态是好事也是坏事——太多受检异常会让调用方 catch 后吞掉,反而降低代码质量
五、Java 8+ 函数式编程
Java 17/21/25 的 Record、密封类、模式匹配和虚拟线程见 Java现代特性。
Lambda 表达式和函数式接口
频次 ★★★★ · 难度 🟡
Lambda 语法:
// 单表达式
(parameters) -> expression
// 多语句
(parameters) -> { statements; return value; }常见函数式接口:
Predicate<T>—boolean test(T)— 条件判断Function<T,R>—R apply(T)— 转换Consumer<T>—void accept(T)— 消费Supplier<T>—T get()— 供给
常见追问
- 为什么 Lambda 只能用 effectively final 的局部变量?→ Lambda 表达式本质是匿名内部类的语法糖,变量通过值拷贝传递;如果变量可变,内外不一致会导致语义歧义——所以强制 final/effectively final
- Lambda 序列化问题?→ Lambda 表达式默认不序列化,除非用
(Function & Serializable) obj -> ...交叉类型写法;但序列化 Lambda 依赖 JVM 实现细节,跨版本不稳定 - 方法引用和 Lambda 有性能差异吗?→ 几乎无差异——两者编译后都是
invokedynamic指令,由 JVM 在运行时生成相同的实现类
Java 中的方法引用(Method References)是什么?如何使用?
频次 ★★ · 难度 🟡
是什么:当 Lambda 表达式只是调用一个已存在的方法时,可用方法引用作为更简洁的替代。:: 语法。
- 静态方法引用:
ClassName::staticMethod→(args) -> ClassName.staticMethod(args) - 实例方法引用(特定对象):
instance::method→(args) -> instance.method(args) - 实例方法引用(任意对象):
ClassName::instanceMethod→(obj, args) -> obj.instanceMethod(args) - 构造器引用:
ClassName::new→(args) -> new ClassName(args)
实例:list.forEach(System.out::println)、stream.map(String::toUpperCase)、stream.collect(Collectors.toCollection(ArrayList::new))。
Stream API
频次 ★★★★ · 难度 🟡
常用操作:
List<Integer> result = list.stream()
.filter(n -> n > 0) // 过滤
.map(n -> n * 2) // 映射
.sorted() // 排序
.distinct() // 去重
.collect(Collectors.toList()); // 收集聚合操作:
long count = list.stream().count();
int sum = list.stream().mapToInt(Integer::intValue).sum();
Optional<Integer> max = list.stream().max(Integer::compareTo);并行流: list.parallelStream() — 基于 ForkJoinPool,适合 CPU 密集型任务,I/O 密集型不推荐
常见追问
- parallelStream 为什么可能更慢?→ ①线程切换和结果合并的开销可能超过计算本身;②默认使用公共 ForkJoinPool,其他并行任务会互相影响;③数据量小或操作链短时并行没有收益——必须用 JMH 实测,不能凭直觉
- findFirst 和 findAny 有什么区别?→ 串行流两者行为一样;并行流中
findFirst强制按顺序找第一个(性能受限),findAny返回任意一个(更自由更快)。需要顺序确定性用findFirst,只关心有没有用findAny - 短路操作有哪些?→
limit()、findFirst()、findAny()、anyMatch()、allMatch()、noneMatch()——它们不消费整个流,一旦条件满足立即终止
Optional 类
频次 ★★★ · 难度 🟢
Optional<String> opt = Optional.ofNullable(value);
opt.orElse("default"); // 为空时返回默认值
opt.orElseGet(() -> "computed"); // 为空时执行函数
opt.ifPresent(v -> println(v)); // 有值时执行
opt.map(String::toUpperCase).orElse("N/A"); // 链式调用常见追问
- orElse 和 orElseGet 的陷阱?→
orElse()参数总是会执行(即使 Optional 有值),orElseGet()只在为空时执行。所以昂贵操作(如查数据库)必须用orElseGet(),否则白白浪费性能 - Optional 为什么不应该做字段?→ ①Optional 没实现 Serializable;②有额外对象创建开销;③字段为 null 本身就是合法的”未设置”语义,包装一层 Optional 只是把 null 藏起来——Optional 的设计意图是返回值类型,不是字段或参数
CompletableFuture 异步编程
频次 ★★★★ · 难度 🔴
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "result1", executor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> "result2");
// 组合两个结果
cf1.thenCombine(cf2, (r1, r2) -> r1 + r2)
.thenAccept(System.out::println);相比 Future 的优势:支持回调、组合编排、不需要阻塞等待结果
常见追问
- thenApply 和 thenCompose 的区别?→
thenApply是T → U的普通映射(类似map),thenCompose是T → CompletableFuture<U>的扁平化(类似flatMap)。前者两个异步任务没有依赖,后者第二个任务依赖第一个的结果 - 不指定线程池会怎样?→
supplyAsync无参版本使用公共 ForkJoinPool.commonPool()(JDK 8 默认并行度 = CPU 核数 - 1)。生产环境必须显式传自定义线程池,否则多个业务共享公共池互相影响,且核心线程不会被 GC - 如何实现超时取消?→
orTimeout(timeout, unit)超时抛TimeoutException;completeOnTimeout(defaultValue, timeout, unit)超时返回默认值(Java 9+) - 如何统一处理异常?→
exceptionally()处理单个 CF 的异常返回默认值;handle()无论正常/异常都执行(类似 finally);多 CF 组合用allOf().whenComplete()统一处理
六、IO 与序列化
BIO、NIO、AIO 的区别?
频次 ★★★★ · 难度 🟡
是什么:
- BIO(同步阻塞):一连接一线程,线程阻塞在 read。适合连接数少且固定的场景
- NIO(同步非阻塞 + IO 多路复用):一个 Selector 线程管理成千上万连接。适合高并发网关、中间件
- AIO(异步,内核完成后回调):无需自己等待就绪。业界极少用(见追问)
为什么 NIO 能撑高并发(C10K 问题):BIO 一万个连接就要一万个线程——仅线程栈就吃掉约 10GB(-Xss 默认 1MB),加上上下文切换,机器先于业务被拖死。NIO 把”等数据到达”交给内核(select/epoll),应用线程只处理就绪的连接,阻塞点从 N 个线程收敛到 1 个 select() 调用。
常见追问
- NIO 的”非阻塞”到底哪里非阻塞?→ read 不再等数据:没数据立即返回 0;“等就绪”这件事由 Selector 统一阻塞在
select()上完成。所以 NIO 是”读写非阻塞 + 等待集中化”,不是没有阻塞。 - 为什么 Java AIO 没流行?→ Linux 上的实现用 epoll 模拟、并非内核真异步(io_uring 才是),相比 Netty 式 NIO 没有实际收益;Windows 的 IOCP 是真异步但服务器不跑 Windows。
- select/poll/epoll 的区别?→ 属于操作系统考点,见操作系统”select/poll/epoll 区别”。
NIO 三大核心组件
频次 ★★★ · 难度 🟡
- Channel(通道):双向数据传输,类似流但更强大
- Buffer(缓冲区):数据容器,读写切换通过 flip()
- Selector(选择器):I/O 多路复用,一个线程监听多个 Channel
NIO 工作流程:
Channel 注册到 Selector → Selector 轮询就绪事件 → 处理就绪的 Channel
实际应用: Netty 底层基于 NIO Selector + epoll 实现高并发网络通信
相关深挖:零拷贝(FileChannel.transferTo / sendfile)见操作系统”零拷贝”;半包粘包与拆包器见Netty与RPC”TCP 粘包/拆包如何解决?”。
常见追问
- Buffer 的 flip() 做了什么?→ 写模式切换到读模式:
limit = position(标记数据截止位置),position = 0(从头读),mark = -1。配套的clear()是读切回写(position=0,limit=capacity),compact()是把未读数据移到开头继续写 - DirectBuffer 和 HeapBuffer 的区别?→ HeapBuffer 在 JVM 堆上,受 GC 管理但多一次拷贝(堆→堆外→内核);DirectBuffer 在堆外内存,零拷贝直接操作,但分配/回收成本高,适合长期复用的网络 IO 场景
- mmap 和 sendfile 各自适用场景?→ mmap 适合小文件随机读写(数据先到用户态),sendfile 适合大文件网络传输(数据不经过用户态,DMA 直接到 socket 缓冲区,真零拷贝)。Kafka 就是靠 sendfile 实现高效日志消费
序列化和反序列化
频次 ★★★ · 难度 🟡
Java 原生序列化:
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("obj.ser"));
oos.writeObject(obj);
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("obj.ser"));
Object obj = ois.readObject();原生序列化的问题:
- 不跨语言(只适用于 Java)
- 安全性差(反序列化可执行任意代码)
- 性能差(序列化后的字节流大)
替代方案: Protobuf(高性能、跨语言)、JSON(Jackson、fastjson2)。注意别再推荐 fastjson 1.x——反序列化漏洞史太多,面试提它有减分风险。
常见追问
- serialVersionUID 有什么用?→ 反序列化时的版本校验凭据:不显式声明时由编译器按类结构哈希自动生成,类一改动它就变 → 老数据反序列化直接抛
InvalidClassException;显式声明后增删字段可以兼容(新增字段读出默认值,删掉的字段被忽略) - 为什么说反序列化有安全风险?→
readObject会执行对象图里各类的反序列化逻辑,攻击者用 gadget chain(如 Apache Commons Collections 链)可以达成远程代码执行;JDK 9(JEP 290,后移植到 8u121)引入反序列化过滤器,原则是永远不反序列化不可信数据 - transient 和 static 字段会被序列化吗?→ 都不会。transient 是显式排除;static 属于类不属于对象实例