主题:Java 基础语法
1. 直击要点
核心定义:Java 是一门强类型、跨平台、面向对象的编程语言。
面试高频点:面试官通常通过基础语法考察你对内存模型(栈 vs 堆)、面向对象设计思想(多态)以及语言安全性(异常与类型系统)的理解。不仅仅是会写代码,而是理解代码在 JVM 中是如何组织的。
2. 详细拆解
第一层:基础概念(数据类型与内存)
强类型机制:变量必须声明类型。
8 大基本数据类型:
数值型:
byte,short,int,long,float,double字符型:
char布尔型:
boolean
内存分配规则(重要):
基本数据类型:直接存储数值,通常存放在栈(Stack)帧的局部变量表中。
引用数据类型(类、接口、数组):栈中存储的是内存地址(引用),真正的对象实例存储在堆(Heap)中。
第二层:核心原理(面向对象与控制流)
流程控制:
if-else/switch:逻辑分支。for/while:循环结构。生动类比:代码执行就像流水线工厂。
if是分拣员,决定包裹走哪条路;for是循环传送带,重复加工同一个零件。
面向对象三大特性(OOP):
封装:封装是指将对象的属性(数据)和行为(方法)结合在一起,对外隐藏对象的内部细节,仅通过对象提供的接口与外界交互。封装的目的是增强安全性和简化编程,使得对象更加独立。
继承:继承是一种可以使得子类自动共享父类数据结构和方法的机制。它是代码复用的重要手段,通过继承可以建立类与类之间的层次关系,使得结构更加清晰。
多态(重难点):多态是指允许不同类的对象对同一消息作出响应。即同一个接口,使用不同的实例而执行不同操作。多态性可以分为编译时多态(重载)和运行时多态(重写)。它使得程序具有良好的灵活性和扩展性。
生动类比:多态就像USB接口。电脑(使用者)只认识 USB 标准接口。你插上鼠标它就移动光标,插上键盘它就输入文字,插上打印机它就打印。电脑不需要知道具体设备的内部构造,只要符合 USB 标准(父类/接口)即可。
第三层:亮点/加分项(底层机制)
自动装箱/拆箱:
Java 5 引入,
int↔Integer自动转换。坑点:
Integer缓存池(-128 到 127)。在此范围内的对象是同一个引用,超出范围则是新对象。
异常体系:
Checked Exception(编译时异常):必须处理(try-catch 或 throws),如
IOException。体现了 Java 对稳定性的极致追求。Unchecked Exception(运行时异常):如
NullPointerException,通常是代码逻辑错误。
泛型擦除:
Java 的泛型只存在于编译期,编译成字节码后,泛型类型会被擦除为
Object。这是为了兼容旧版 JVM 的历史包袱。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
关于值传递:
问题:Java 只有值传递吗?如果我传入一个 List 到方法里,在方法里
add一个元素,外面的 List 会变吗?如果我在方法里把这个 List 赋值为null,外面的 List 会变吗?考察点:是否真正理解“引用地址”也是一种“值”的拷贝。
回答:Java 只有值传递(Pass-by-Value),没有引用传递。 无论传入的是基本类型还是引用类型,传递的都是“副本”。
基本类型传的是数值的副本。
引用类型传的是内存地址(引用)的副本。
场景一:方法内修改属性 (Mutating State)
现象:传入一个
List,在方法里list.add("data"),方法执行完后,外面的List变了。原理:你拿着地址副本找到了堆内存中的对象,修缮了房子(修改了堆内存数据),所以外面看到的房子也变了。
场景二:方法内重新赋值 (Reassignment)
现象:传入一个
List,在方法里list = null或list = new ArrayList(),方法执行完后,外面的List没变。原理:你只是把手里的“地址副本”扔掉了或换成了新地址,但原来的“地址正本”还在调用者手里,指向的还是原来的堆内存。
关于 String:
问题:
String s = new String("abc");这行代码到底创建了几个对象?考察点:字符串常量池与堆内存的区别。
回答:通常是 2 个(假设 "abc" 之前从未出现过)。
字符串常量池中的 "abc"(类加载或首次使用时创建)。
堆(Heap)中的
new String对象(它是对常量池内容的拷贝/包装)。
关于精度:
问题:为什么涉及钱的计算(金融场景)绝对不能用
float或double?应该用什么?考察点:浮点数的精度丢失问题及
BigDecimal的使用。回答:因为计算机底层用二进制表示小数(IEEE 754 标准),大部分十进制小数(如 0.1)在二进制中是无限循环小数,只能截断存储,从而导致精度丢失。因此必须使用
BigDecimal进行计算。构建 BigDecimal 必须使用 String 构造器:
new BigDecimal("0.1"),不要用new BigDecimal(0.1)(后者依然会丢失精度)。
主题:面向对象编程 (OOP)
1. 直击要点 (TL;DR)
核心定义:面向对象通过封装、继承、多态,将现实世界的事物抽象为代码中的对象,通过对象之间的交互来完成功能。
面试必杀技:不要只背定义,要强调 OOP 的终极目标是“高内聚、低耦合”。
高内聚:一个类只做它该做的事(单一职责)。
低耦合:类与类之间尽量少依赖,依靠接口交互(便于扩展)。
2. 详细拆解
第一层:基础概念(类、对象、接口)
类 (Class):是图纸/模板。
对象 (Object):是根据图纸造出来的具体实物。
抽象类 vs 接口 (面试高频):
抽象类 (Abstract Class):是
is-a关系(它是谁)。只能单继承。用于抽取子类的通用代码(模板设计模式)。接口 (Interface):是
can-do关系(它能做什么)。可以多实现。用于定义行为规范(契约)。生动类比:
抽象类就像“亲爹”。你只能有一个亲爹,他给你遗传了基因(复用代码)。
接口就像“驾照”或“英语六级证”。你可以同时拥有驾照和六级证(多实现),这代表你拥有了“开车”和“说英语”的能力,不管你亲爹是谁。
第二层:核心原理(三大特性)
封装 (Encapsulation):
原理:把数据藏起来(
private),只提供专门的门窗(Getter/Setter)让你操作。目的:保护数据安全,隔离复杂度。
类比:自动售货机。你只能看到按钮和投币口(Public 接口),你看不到里面的齿轮、弹簧和制冷压缩机(Private 实现)。如果内部零件换了,只要投币口没变,你就不需要关心。
继承 (Inheritance):
原理:子类继承父类的属性和方法。
目的:代码复用。
注意:Java 是单继承机制(避免“菱形继承”问题),但可以通过接口弥补。
多态 (Polymorphism) —— 重中之重:
定义:同一个接口/父类引用,指向不同的子类实例,执行不同的逻辑。
实现三要素:继承、重写 (Override)、父类引用指向子类对象。
生动类比:CEO 下命令。
CEO(调用者)对着所有员工大喊一声:“开始工作!”(调用通用接口
doWork())。程序员听到后开始写代码。
销售听到后开始打电话。
CEO 不需要分别对每个人发不同的指令,只需要发一个统一的指令,每个人根据自己的身份(对象类型)做出不同的反应。这就是多态。
应用:
方法重载:
方法重载是指同一类中可以有多个同名方法,它们具有不同的参数列表(参数类型、数量或顺序不同)。虽然方法名相同,但根据传入的参数不同,编译器会在编译时确定调用哪个方法。
示例:对于一个
add方法,可以定义为add(int a, int b)和add(double a, double b)。
方法重写:
方法重写是指子类能够提供对父类中同名方法的具体实现。在运行时,JVM会根据对象的实际类型确定调用哪个版本的方法。这是实现多态的主要方式。
示例:在一个动物类中,定义一个
sound方法,子类Dog可以重写该方法以实现bark,而Cat可以实现meow。
接口与实现:
多态也体现在接口的使用上,多个类可以实现同一个接口,并且用接口类型的引用来调用这些类的方法。这使得程序在面对不同具体实现时保持一贯的调用方式。
示例:多个类(如
Dog,Cat)都实现了一个Animal接口,当用Animal类型的引用来调用makeSound方法时,会触发对应的实现。
向上转型和向下转型:
在Java中,可以使用父类类型的引用指向子类对象,这是向上转型。通过这种方式,可以在运行时期采用不同的子类实现。
向下转型是将父类引用转回其子类类型,但在执行前需要确认引用实际指向的对象类型以避免
ClassCastException。
第三层:亮点/加分项(设计原则)
SOLID 原则:如果你能随口提两个 SOLID 原则,面试官会对你刮目相看。
S (单一职责):一个类只负责一件事。
实际场景 (Bad Case):
你写了一个
UserService类,里面既有login()(业务逻辑),又有saveUserToDB()(数据库操作),甚至还有sendWelcomeEmail()(发送邮件)。后果:DB 结构变了,你要改这个类;邮件服务商变了,你也要改这个类。这个类会变得极其臃肿(God Class)。
正确做法:拆分!
UserBizService(业务),UserDao(存取),EmailUtil(发信)。
O (开闭原则):对扩展开放,对修改关闭。(想加新功能?去写个新类继承我,别改我原来的代码)。
实际场景 (Bad Case):
支付模块。你写了
if (type == 支付宝) { ... } else if (type == 微信) { ... }。后果:老板明天说要接“抖音支付”,你就得去改原来的代码,万一改错了把支付宝搞挂了,你就背 P0 级事故。
正确做法:定义一个
Payment接口。支付宝写个类实现它,微信写个类实现它。新加抖音支付?新建一个类就行,老代码一行都不用动。1
// 1. 每个策略自己说自己支持谁2
class AlipayStrategy implements PaymentStrategy {3
public boolean isSupport(String type) { return "ALIPAY".equals(type); }4
public void pay(double amount) { /* ... */ }5
}6
// 2. 调用方全量注入,自动路由7
@Service8
public class PaymentService {9
@Autowired10
private List<PaymentStrategy> strategies; // Spring 会自动把所有实现类塞进来11
public void process(String type, double amount) {12
strategies.stream()13
.filter(s -> s.isSupport(type)) // 找到那个“自荐”的策略14
.findFirst()15
.orElseThrow(() -> new RuntimeException("无效类型"))16
.pay(amount);17
}18
}
L (里氏替换):子类别捣乱,要能完美顶替父类。
实际场景 (Bad Case):
经典的“正方形不是长方形”。如果你让
Square继承Rectangle,当你调用setWidth(5)时,长方形只改宽,正方形把高也改了。调用者以为改了宽不影响高,结果逻辑全错。
正确做法:如果没有完美的继承关系(行为不一致),就不要强行继承。
I (接口隔离):接口别搞成“大杂烩”,要拆细点。
实际场景 (Bad Case):
你定义了一个
Animal接口,里面有fly(),run(),swim()。后果:狗实现了这个接口,但狗不会飞。它被迫实现
fly()方法,里面只能写throw new CantFlyException()。这很尴尬。
正确做法:拆分成
Flyable,Runnable,Swimmable三个小接口。狗只实现Runnable和Swimmable。
D (依赖倒置):别依赖具体的“人”,要依赖“工种”(抽象)。
实际场景 (Bad Case):
OrderService类里面直接new MySqlDriver()。后果:明天公司要换 Oracle 数据库,你的
OrderService代码全得重写。
正确做法:
OrderService依赖DataSource接口。运行时通过 Spring 注入一个具体的实现(MySQL 或 Oracle)。这就是 Spring IOC 的核心思想。1
// 1. 定义抽象接口2
interface Database {3
void connect();4
}5
// 2. 实现类 (低层模块)6
class MySqlDriver implements Database { ... }7
class OracleDriver implements Database { ... }8
// 3. 业务类 (高层模块)9
public class UserService {10
private Database db; // 🟢 只依赖接口,不关心具体是谁11
// 通过构造函数注入 (IOC 的雏形)12
public UserService(Database db) {13
this.db = db;14
}15
public void add() {16
db.connect();17
}18
}19
// 4. 组装 (由 Spring 容器或 Main 方法完成)20
// 想用 MySQL 就传 MySQL,想用 Oracle 就传 Oracle,Service 自身不用改。21
UserService service = new UserService(new OracleDriver());
组合优于继承 (Composition over Inheritance):
继承虽然好,但耦合度太高(父类一变,子类全崩)。现代开发推崇用“组合”(在一个类里持有另一个类的对象)来代替复杂的继承关系。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “你刚才说了多态,那 Java 的
static方法可以被重写(Override)吗?为什么?”答案:不可以。
原理:重写是基于运行时的对象类型来动态绑定的(Dynamic Binding)。而
static方法在编译时就绑定到了类上(Static Binding),跟具体的对象实例无关。如果你在子类写了一个同名静态方法,那叫“隐藏”,不叫“重写”。
面试官: “接口(Interface)和抽象类(Abstract Class)在 Java 8 之后越来越像了(接口有了
default方法),那它们现在的本质区别到底是什么?”答案:本质区别在于状态(State)。
原理:抽象类可以有成员变量(字段),可以保存对象的状态。接口虽然能写方法逻辑了,但它仍然不能保存实例变量(只能有
static final常量)。只要你需要存“状态”,就必须用抽象类。
面试官: “为什么说‘组合优于继承’?能举个反例说明继承的坏处吗?”
答案:继承破坏了封装性,父类暴露了实现细节给子类。
举例:如果父类
HashSet的addAll方法内部调用了add方法。子类为了统计添加次数,重写了add和addAll,结果调用子类addAll时,次数会被统计两遍(一次在addAll,一次在内部调用的add),这就是脆弱基类问题。用组合(装饰器模式)就能避免这个问题。
面试官: “在高并发下,如果你的策略类是单例的(Spring 默认),且里面有成员变量,会产生什么问题?”
答案:线程安全问题。
原理:Spring 管理的 Bean(如 Service、Strategy)默认是单例(Singleton)的,多线程会共享同一个策略实例的成员变量。
规范建议:策略类应该是无状态的(Stateless),所有数据通过方法参数传入,或者使用
ThreadLocal隔离。
面试官: “如果不同的支付策略依赖不同的配置(如支付宝需要 AppId,微信需要秘钥),你的路由逻辑怎么兼容?”
答案:上下文对象 (Context)。
原理:传入一个统一的
PaymentContext对象,里面包含所有可能的参数。各策略根据需要自取。
主题:Java 常用工具类
1. 直击要点 (TL;DR)
核心价值:工具类是 Java 生态的“瑞士军刀”。
面试高频:
原生神器:
java.util下的Collections,Arrays,Objects。并发神器:
JUC (java.util.concurrent)下的CountDownLatch,Semaphore(重点)。三方神器:Apache Commons (
StringUtils), Google Guava, Hutool(国内常用)。一句话原则:优先用标准库,其次用成熟开源库,最后才自己写。
2. 详细拆解
第一层:JDK 原生基础工具
Objects (JDK 7+):
用途:优雅地处理
null。常用:
Objects.equals(a, b)。避坑:不要再写
if (a != null && a.equals(b))这种啰嗦代码了,直接用Objects.equals,它帮你处理了 null 判断。
Arrays & Collections:
用途:数组和集合的操作(排序、二分查找、转线程安全集合)。
面试点:
Collections.synchronizedList()确实能返回线程安全 List,但性能很差(全加了synchronized锁),高并发请转战 JUC。
Optional (JDK 8):
用途:防止
NullPointerException(NPE)。生动类比:防弹礼盒。
以前你直接拿礼物(对象),如果礼物是空的,你一摸就炸了(NPE)。
现在礼物被装在一个防弹盒子(Optional)里。你先问盒子“里面有东西吗?”(
isPresent),或者说“如果没东西给我个备用的”(orElse)。
第二层:高并发工具类 (JUC) —— 面试必问
这些工具类都在 java.util.concurrent 包下,用于协调多线程协作。
CountDownLatch (倒计时门闩)
作用:让一个线程等待其他 N 个线程执行完再执行。
场景:主服务启动前,必须等待数据库检查、缓存预热、文件加载这 3 个任务都完成。
生动类比:监考老师收卷。
老师(主线程)在讲台上等着。
学生(子线程)一个个交卷。
只有当最后一个学生交完卷(计数器归零),老师才能打包走人。
Semaphore (信号量)
作用:控制同时访问资源的线程数量(限流)。
场景:数据库连接池只有 10 个连接,同时只允许 10 个线程操作,多出来的要排队。
生动类比:停车场入口。
停车场只有 5 个车位(Permits = 5)。
车进来一辆,显示屏数字减 1。
数字变成 0 时,栏杆落下,外面的车必须等。
有一辆车出去了(
release),数字加 1,栏杆抬起,下一辆才能进。
CyclicBarrier (循环栅栏)
作用:让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达,屏障才会开门,所有线程同时继续。
生动类比:公司团建坐大巴。
导游举着旗子在车门口等。
来一个人(
await),上车坐好。人没齐绝对不发车。
人齐了(达到阈值),司机一脚油门,大家一起出发。
第三层:常用三方库 (加分项)
面试官问你“平时怎么判断字符串为空”,也是在考察你的工程经验。
Apache Commons Lang3:
StringUtils.isBlank(str)。比 Java 原生的isEmpty强在它能识别 " " (纯空格) 也是空。Guava (Google):提供了不可变集合 (
ImmutableList),布隆过滤器等高级工具。BeanUtils (Spring vs Apache):
场景:对象属性拷贝 (DTO -> DO)。
坑点:尽量用 Spring 的 BeanUtils,不要用 Apache Commons 的 BeanUtils。因为 Apache 的大量使用了反射,性能极差;Spring 的做了缓存优化。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “
Arrays.asList()转换出来的 List 有什么坑?”答案:
它是定长的(Fixed-size)。你不能
add也不能remove,否则报UnsupportedOperationException。它是视图。修改原数组,List 里的元素也会变。
底层:它返回的不是
java.util.ArrayList,而是Arrays内部的一个静态私有内部类。
面试官: “
Collection.sort和Arrays.sort的底层排序算法一样吗?”答案:
基本数据类型(如
int[]):使用 Dual-Pivot Quicksort (双轴快排)。效率高,但不稳定(相同元素位置可能变),但数字本身无所谓稳定性。对象类型(如
User[]):使用 TimSort (归并+插入的混合排序)。保证稳定性(这一点对业务对象排序很重要)。
面试官: “你是怎么理解深拷贝(Deep Copy)和浅拷贝(Shallow Copy)的?BeanUtils 是哪种?”
答案:
浅拷贝:只复制对象的第一层属性。如果属性是引用类型(如 List),两个对象共享同一个 List。
深拷贝:完全独立的副本。
结论:
BeanUtils.copyProperties是浅拷贝。如果对象里嵌套了对象,修改副本会影响原件。要实现深拷贝,推荐用 JSON 序列化再反序列化。
主题:异常处理体系
1. 直击要点 (TL;DR)
核心定义:异常是 Java 提供的用于处理程序运行错误的机制。它的核心在于“将错误处理代码与正常的业务代码分离”。
面试必考:
继承体系:
Throwable是所有异常的父类。分类:
Error(系统崩溃) vsException(程序错误)。性质:
Checked Exception(受检异常,编译不过) vsUnchecked/Runtime Exception(运行时异常,逻辑错误)。语法糖:
try-with-resources(JDK 7+ 自动关闭资源)。
2. 详细拆解
第一层:异常家族谱系 (Hierarchy)
Java 的异常体系就像一个家族:
Throwable:老祖宗。
Error:“绝症”。通常是 JVM 层面的严重错误,如
OutOfMemoryError(OOM)、StackOverflowError。程序无法处理,只能挂掉。Exception:“普通病”。程序可以捕获并处理。
Checked Exception (受检异常):“疫苗”。编译器强制要求你打疫苗(处理)。如果你不
try-catch或者throws,代码都编译不过。典型:
IOException,SQLException,ClassNotFoundException。意义:提醒开发者,这里可能会出问题(如文件读不到、网断了),请做好预案。
Unchecked Exception (运行时异常):“意外”。通常是程序员的代码逻辑写错了。编译器不管。
典型:
NullPointerException(NPE),IndexOutOfBoundsException,ArithmeticException(/0)。
第二层:核心原理 (捕获与处理)
try-catch-finally:
try:尝试执行高风险代码。catch:如果出事了,执行这里的补救措施。finally:无论出不出事,必须执行的代码(通常用于释放资源,如关流、关锁)。
try-with-resources (JDK 7+):
原理:只要实现了
AutoCloseable接口的类,放在
try(...)的括号里,Java 会自动为你调用close()方法。价值:这是大厂代码规范中强制要求的写法,彻底杜绝“忘记关流”导致的内存/句柄泄漏。
强制举例 (生动类比)
开车过收费站
try:你开车上高速。
catch:如果爆胎了(异常),你靠边换备胎(处理逻辑)。
finally:无论你是否爆胎,最后出收费站时,你都必须交过路费(释放资源)。
Throws:如果你不会换备胎,你把车扔在路边,打电话叫拖车(把异常抛给上层调用者处理)。
第三层:亮点/加分项 (底层与性能)
性能损耗:
抛出异常是昂贵的。
原因:JVM 需要建立完整的堆栈追踪 (Stack Trace),这需要把当前线程栈里的所有帧(Frame)信息都快照下来。
推论:绝对不要用异常来做流程控制(比如用 try-catch 来判断循环结束),这会让性能慢一个数量级。
JVM 实现:
JVM 编译后会生成一张 异常表 (Exception Table)。当异常发生时,JVM 去查表,看哪一行代码对应的异常应该跳转到哪一行指令去处理。这不是通过普通的
if-else实现的,而是指令级的跳转。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
(这部分是“送命题”,请务必死记硬背)
面试官: “
try-catch-finally中,如果在catch里return了,finally还会执行吗?如果是finally里也return了,谁说了算?”答案:
会执行。
finally块会在catch里的return执行前(准确说是准备返回结果之前)被插入执行。finally 说了算。如果
finally里也有return,它会覆盖掉try或catch里的返回值。这是个著名的反模式,绝对不要在 finally 里写 return,否则会丢失异常信息和原有的返回值。
面试官: “为什么阿里巴巴 Java 开发手册规定:‘禁止捕获
Exception这种通用的异常,必须捕获具体的异常’?”答案:为了精准定位问题。
场景:你写了
catch (Exception e)。结果你的代码既可能报IOException,也可能报NullPointerException。如果你一锅端,在日志里就很难区分到底是网络坏了,还是你代码逻辑写错了。这就叫“掩耳盗铃”。
面试官: “我们都知道 Error 是严重的错误,那
OutOfMemoryError(OOM) 可以被 catch 吗?catch 了有用吗?”答案:
语法上:可以 catch (因为 Error 也是 Throwable 的子类)。
实际上:没有意义。
原因:当 OOM 发生时,JVM 的堆内存已经耗尽了。你 catch 住了,想做点补救措施(比如打印日志),但打印日志也需要分配内存对象,结果就是二次崩溃。遇到 Error,最好的办法是让程序赶紧死掉,保留现场(Dump 文件)供事后分析。
代码实战:Bad vs Good
Bad Case (经典的烂代码)
1
public void readFile() {2
FileInputStream fis = null;3
try {4
fis = new FileInputStream("test.txt");5
// ... 读文件6
} catch (Exception e) { 7
// 🔴 1. 捕获太宽泛,不知道具体错哪了8
// 🔴 2. 空 catch (吞异常),出了事就像没发生一样,排查火葬场9
} finally {10
try {11
if (fis != null) fis.close(); 12
} catch (IOException e) {13
// 🔴 3. 连关闭流都写得这么啰嗦,还可能覆盖主逻辑的异常14
}15
}16
}Good Case (优雅的 try-with-resources)
1
public void readFile() {2
// 🟢 自动关闭:fis 实现了 AutoCloseable3
try (FileInputStream fis = new FileInputStream("test.txt")) {4
// ... 读文件5
} catch (FileNotFoundException e) {6
// 🟢 精准捕获:文件找不到的处理逻辑7
log.error("文件不存在", e);8
} catch (IOException e) {9
// 🟢 精准捕获:读写失败的处理逻辑10
log.error("IO 异常", e);11
}12
// 🟢 不需要写 finally,JVM 自动帮你调 close()13
}主题:try-with-resources (自动资源管理)
1. 直击要点 (TL;DR)
核心定义:一种专门用于自动关闭资源的语法糖。
解决痛点:以前如果你打开了文件、数据库连接、网络流,必须手动在 finally 块里关闭。如果忘了关,或者关的时候报错了,代码会非常难看且容易导致资源泄漏。
一句话口诀:“把资源创建写在 try 后面的圆括号里,出了括号自动关。”
2. 详细拆解
第一层:新旧对比 (以此突显你的专业度)
❌ The Old Way (JDK 6 及以前)
这是典型的“代码屎山”。为了安全关闭一个流,你需要写极其啰嗦的判空和嵌套 try-catch。
1
FileInputStream fis = null;2
try {3
fis = new FileInputStream("test.txt");4
// 业务逻辑...5
} catch (IOException e) {6
e.printStackTrace();7
} finally {8
// 🔴 必须手动关闭9
if (fis != null) {10
try {11
fis.close();12
} catch (IOException e) {13
// 🔴 关闭时可能又报错,还得再 catch,无限套娃14
e.printStackTrace();15
}16
}17
}✅ The New Way (JDK 7+ try-with-resources)
这是“现代 Java 写法”。清爽、优雅、安全。
1
// 关键点:把 new 对象的代码放到 try(...) 的括号里2
try (FileInputStream fis = new FileInputStream("test.txt")) {3
// 业务逻辑...4
// 🟢 你完全不需要写 finally,也不需要手动调 close()5
// 🟢 一旦代码执行出了这个大括号,JVM 自动帮你关流6
} catch (IOException e) {7
e.printStackTrace();8
}第二层:核心原理 (AutoCloseable 接口)
你可能会问:“凭什么写在括号里它就能自动关?它是怎么知道怎么关的?”
原理:Java 7 引入了一个新接口
java.lang.AutoCloseable。机制:凡是实现了这个接口的类(比如所有的 IO 流、数据库连接 Connection、Socket),都拥有一个
close()方法。编译期黑魔法:编译器在把
.java编译成.class字节码时,会偷偷帮你把try-with-resources还原成try-catch-finally的样子。也就是说,这是语法糖。底层还是用了 finally,只是编译器帮你写了,而且写得比你更严谨。
强制举例 (生动类比)
酒店的插卡取电模式
Old Way (手动关):你离开酒店房间,必须挨个把厕所灯、台灯、电视、空调都手动关掉。如果你忘关了空调,电费就一直在跑(资源泄漏)。
New Way (插卡取电):房间的总开关(try 的圆括号)就是那个插卡槽。
当你进门(进入 try 块),把卡插进去,通电。
当你拔卡出门(离开 try 块),所有电器瞬间自动断电。你根本不需要关心具体开了哪个灯,系统强制帮你全关了。
第三层:进阶技巧 (多资源与自定义)
处理多个资源
如果你既要读文件 A,又要写文件 B,中间用分号
;隔开即可。Java
1
try (2
FileInputStream fis = new FileInputStream("input.txt");3
FileOutputStream fos = new FileOutputStream("output.txt")4
) {5
// 读写逻辑6
} // 🟢 出来时,fos 先关,fis 后关 (像栈一样,后进先出)自定义资源
你也可以自己写一个类,让它能自动关闭。只要实现
AutoCloseable接口。Java
1
// 1. 定义资源2
class MyResource implements AutoCloseable {3
@Override4
public void close() {5
System.out.println("🔥 资源自动销毁中...");6
}7
}8
// 2. 使用9
public void test() {10
try (MyResource r = new MyResource()) {11
System.out.println("正在使用资源...");12
} // 🟢 打印:"🔥 资源自动销毁中..."13
}
3. 核心关键词总结 (复习速记)
4. 压力面追问 (模拟面试场景)
面试官: “我在 try-with-resources 的括号里写了 FileInputStream,但是我在 try 块里面手动调了 close(),然后再抛出异常,请问最后会发生什么?会报错说‘流已关闭’吗?”
你的回答:
不会报错。
原理:
AutoCloseable的规范要求close()方法必须是幂等的(Idempotent)。也就是说,调用一次和调用 N 次效果一样。如果你手动关了,JVM 自动再关一次时,通常会判断状态,直接跳过,不会抛异常。
面试官: “如果 try 块里的业务代码抛了异常 A,然后自动执行 close() 的时候也抛了异常 B(比如硬盘突然坏了)。老式的 try-catch-finally 会抛出哪个?try-with-resources 会抛出哪个?”
你的回答 (满分答案):
老式写法:会抛出 B (close 的异常)。异常 A 被覆盖了(吞掉了)。这很糟糕,因为开发者丢失了业务错误的线索。
try-with-resources:会抛出 A (业务异常)。异常 B 会被标记为 Suppressed Exception(被抑制的异常),挂在 A 的异常堆栈里。你可以通过
e.getSuppressed()查看到它。这才是完美的异常处理。
主题:集合框架与泛型 (容器与类型安全)
1. 直击要点 (TL;DR)
核心定义:
集合:用于存储对象的容器。主要分为
Collection(单列) 和Map(双列/键值对)。泛型:JDK 5 引入的编译时安全检查机制。它强制要求集合只存特定类型,避免了取值时的强制类型转换,消灭了
ClassCastException。面试必背:
List (有序, 可重复):
ArrayList(底层数组,查快增删慢),LinkedList(底层链表,增删快查慢)。Set (无序, 不重复):
HashSet(底层是 HashMap),TreeSet(底层是红黑树)。Map (键值对):
HashMap(数组+链表+红黑树),ConcurrentHashMap(CAS+synchronized)。
2. 详细拆解
第一层:泛型 (Generics) —— 伪泛型
核心原理:类型擦除 (Type Erasure)。
Java 的泛型只存在于编译期。
编译成字节码 (
.class) 后,所有的List<String>都会变成原生的List(即List<Object>)。JVM 根本不知道泛型的存在。
为什么要擦除?:为了兼容 JDK 5 之前的旧代码(那时候没有泛型)。
生动类比:便利贴标签。
泛型就是贴在箱子上的“书籍专用”便利贴。搬家工人(编译器)看到标签,会阻止你往里放砖头(编译报错)。
一旦封箱打包(编译)发车了,便利贴就被撕掉了。到了新家(运行时),这就是一个普普通通的箱子,里面装的都是 Object。
第二层:集合顶层架构 (Collection vs Map)
Collection 接口:
ListSetQueue
Map 接口:
Key-Value映射。Key 是唯一的(像身份证号),Value 可以重复(像名字)。
第三层:ArrayList 深度剖析
底层实现:
Object[] elementData(动态数组)。特点:
随机访问快 (Random Access):实现了
RandomAccess接口。知道下标i,直接根据内存地址偏移量就能取到数据,复杂度 O(1)。增删慢:如果在中间插入或删除,需要把后面的元素全部挪窝(System.arraycopy),复杂度 O(n)。
扩容机制 (重中之重):
初始容量:JDK 7 以前是 10。JDK 8 以后是 0 (懒加载,第一次
add时才变成 10)。扩容时机:装不下了。
扩容倍数:1.5 倍。
源码公式:
int newCapacity = oldCapacity + (oldCapacity >> 1);(右移一位等于除以 2)。
搬家代价:每次扩容都要创建一个新数组,把老数据全拷过去。非常耗性能。
强制举例 (生动类比)
ArrayList 扩容 = 公司搬家
你公司租了个10 人位的办公室(初始容量)。
招到第 11 个人时,坐不下了。
你不能在原办公室隔壁直接加个座位(数组在内存中是连续的,隔壁可能有别人)。
你必须去租一个新的、更大的办公室(15 人位)。
然后让所有员工停下工作,抱着电脑一个个走到新办公室(ArrayCopy)。
教训:如果你知道公司今年要招 1000 人,一开始就直接租 1000 人位的办公室(
new ArrayList(1000)),拒绝频繁搬家!
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “
ArrayList和LinkedList的区别?现在的项目中,你还会用LinkedList吗?”教科书回答:Array 查快增删慢,Linked 查慢增删快。
大厂实战回答 (加分):“实际上,我们几乎不用 LinkedList。”
原因:虽然 LinkedList 理论上增删快,但它对 CPU 缓存(Cache)不友好。ArrayList 的内存是连续的,CPU 预读取非常快;LinkedList 内存是分散的,CPU 命中率低。除非是极端的“头尾频繁操作”场景,否则无脑选 ArrayList。
面试官: “我们说
ArrayList线程不安全,那如果我需要在多线程环境下使用 List,怎么做?”方案 A (错误):
Vector。太老了,全方法加锁,性能极差。方案 B (不推荐):
Collections.synchronizedList()。也是全局锁,并发度低。方案 C (推荐):
CopyOnWriteArrayList(COW)。原理:写时复制。读的时候不加锁(读旧数组),写的时候拷贝一份新数组进行修改,改完把指针指过去。适合读多写少的场景(如白名单、配置列表)。
面试官: “ArrayList 的扩容是 1.5 倍,HashMap 是 2 倍,为什么不一样?”
答案:
HashMap 用 2 倍(2 的幂次方)是为了配合位运算
(n - 1) & hash来快速定位数组下标,代替取模运算%,提升效率。ArrayList 用 1.5 倍是为了平衡空间与时间。如果 2 倍可能浪费太多空间,如果 1.1 倍则扩容太频繁。1.5 倍是一个经验值。
主题:HashMap 深度解析
1. 直击要点 (TL;DR)
核心定义:HashMap 是基于哈希表实现的 Key-Value 容器。
数据结构 (JDK 1.8):数组 + 链表 + 红黑树。
使用红黑树的原因:在频繁插入、删除的场景下,红黑树相比 AVL 树平衡调整更少、性能更优。
详细分析:
提升高哈希冲突下的性能:当大量元素哈希值相同,导致某一个桶(bucket)中的链表过长时,链表遍历的线性时间复杂度O(n)会导致 HashMap 性能极剧下降。红黑树作为一种自平衡二叉查找树,其查找、插入、删除的时间复杂度均稳定在 O(logn)
红黑树比 AVL 树更适合工程应用:
插入/删除效率高:红黑树不追求完全的平衡,而是近似平衡(最大路径长度不超过最小路径的两倍),这使得在插入和删除操作时,所需的旋转和调整次数远少于要求严格平衡的 AVL 树。
结构紧凑:红黑树节点相对较小,内存占用少。
核心流程:
计算 Key 的 Hash 值。
通过
(n - 1) & hash找到数组下标。如果没有冲突,直接放入。
如果冲突(下标一样),挂在链表上。
进化点:当链表长度超过 8 且数组长度超过 64 时,链表转为红黑树,将查询复杂度从 O(n) 降为 O(log n)。
2. 详细拆解
第一层:物理结构 (数组+链表)
数组 (Node[] table):这是 HashMap 的躯干,是一排连续的桶(Bucket)。
链表:这是为了解决哈希冲突 (Hash Collision)。当两个不同的 Key 算出了同一个下标时,它们只能挤在同一个房间里,形成链表。
哈希算法:
hash(Object key)。扰动函数:JDK 1.8 中,为了让 Hash 值更散列,使用了
(h = key.hashCode()) ^ (h >>> 16)(高 16 位与低 16 位异或)。
第二层:Put 方法的执行流程 (面试必考)
Hash:拿到 Key,算 Hash 值。
Index:定位下标。
Check:看下标位置有没有人。
没人:直接
new Node占座。有人 (Hash 冲突):
如果是红黑树:按树的规则插入。
如果是链表:遍历链表。
如果有 Key 相同的,覆盖 Value。
如果没有,插在链表尾部 (JDK 1.8 尾插法)。
插完后,检查是否需要转红黑树 (链表长度 > 8)。
Resize:插入完成后,如果总元素个数 >
Capacity * LoadFactor(默认 16 * 0.75 = 12),触发扩容。
强制举例 (生动类比)
哈希魔法学校的宿舍分配
数组:学校的 16 间宿舍 (编号 0-15)。
Key:学生 (比如 Harry)。
Hash 算法 (分院帽):分院帽算出 Harry 应该住 5 号宿舍。
冲突:Ron 也被分到了 5 号宿舍。
JDK 1.7 (链表 - 拥挤的双层床):
宿舍里只能放双层床。如果来了 10 个人都分到 5 号房,就要搭 10 层高的床。
找人时(Get),宿管阿姨要爬梯子一个个看脸,累死 (O(n))。
JDK 1.8 (红黑树 - 魔法空间):
当 5 号房挤了超过 8 个人,分院帽施法,把双层床变成了一颗巨大的魔法树。
学生们按名字规律挂在树枝上。
找人时,阿姨只需要看“左边还是右边”,几下就能找到 (O(log n))。
第三层:JDK 1.7 vs 1.8 的关键区别 (高分项)
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “为什么 HashMap 的容量(Capacity)必须是 2 的次幂(16, 32, 64...)?如果我手动传个 17 进去会怎么样?”
答案:
为了位运算代替取模:只有当
n是 2 的次幂时,hash % n才等价于(n - 1) & hash。位运算比除法快几十倍。为了分布均匀:
(n - 1)的二进制全是 1 (如 15 是1111),这样 & 出来的结果能充分利用 Hash 值的每一位。如果是 17 (10001),& 完很多位永远是 0,导致严重的哈希冲突。结果:如果你传 17,HashMap 构造函数里的
tableSizeFor()方法会帮你把 17 向上取整变成 32。它根本不会让你用 17。
面试官: “JDK 1.7 的 HashMap 在多线程扩容时为什么会死循环?”
答案:
根本原因:并发扩容 + 头插法。 核心现象:
头插法会改变链表的顺序(比如原链表
A -> B,扩容后变成B -> A)。并发竞争:线程 1 挂起时记住了旧的顺序,线程 2 执行完把顺序反过来了。
结果:线程 1 恢复执行时,拿着旧引用去操作新顺序,导致
A.next = B且B.next = A,形成环形链表。爆发点:当后续有人调用
get()方法遍历该链表时,进入死循环,CPU 飙升 100%。
面试官: “如果 Key 是一个自定义的对象(比如 User),需要注意什么?”
答案:必须重写
equals()和hashCode()方法。原则:
equals相等的对象,hashCode必须相等。后果:如果只重写
equals不重写hashCode,把 User 存进去后,下次用一个一模一样的 User 去取,会因为 Hash 值不同而算出不同的下标,导致取不到数据(内存泄漏)。
主题:Java 线程安全集合 (并发容器 JUC)
1. 直击要点 (TL;DR)
核心分类:Java 中的线程安全集合主要分为三代:
第一代(骨灰级):
Vector,Hashtable。全方法加锁 (synchronized),性能极差,面试时要说“坚决不用”。第二代(包装级):
Collections.synchronizedList/Map。通过装饰器模式加了互斥锁,性能依然一般。第三代(并发级 - JUC):
ConcurrentHashMap,CopyOnWriteArrayList。采用了分段锁、CAS、读写分离等高级机制。
结论:高并发场景下,无脑选 JUC 包下的集合。
2. 详细拆解
第一层:为什么不用 Vector 和 Hashtable?
原理:它们简单粗暴地在所有
get,put,add方法上加了synchronized关键字。后果:这把锁是对象级别的重量级锁。一个线程在写,其他线程连读都不能读。
生动类比:
类比:不仅锁门,还锁大楼
你想去图书馆借一本书(读操作)。
但因为管理员怕有人偷书(写操作),规定只要有一个人在图书馆里,把整个图书馆大门锁死。
哪怕你在 1 楼看报纸,他在 10 楼搬桌子,你们完全不冲突,但你也得在门外等着。这就是 Vector 的效率。
第二层:ConcurrentHashMap (面试核心 - 1.7 vs 1.8) 🏆
这是面试中含金量最高的集合。它解决了 HashMap 线程不安全的问题,同时兼顾了超高的性能。
JDK 1.7 实现:分段锁 (Segment Locking)
原理:把大数组切分成 16 个小段(Segment)。每个 Segment 配一把锁(ReentrantLock)。
效果:线程 A 修改 Segment 1,线程 B 修改 Segment 2,互不干扰,支持 16 个线程并发。
生动类比:
类比:图书馆分馆管理
以前是锁大门(Hashtable)。
现在把图书馆分成 A区、B区...P区(16 个区)。
你想去 A 区借书,只锁 A 区的门。想去 B 区的人可以直接进。并发度提升了 16 倍。
JDK 1.8 实现:CAS + synchronized (节点锁)
原理:抛弃了分段锁。直接锁链表/红黑树的头节点。
机制:
如果没有 Hash 冲突:使用 CAS (Compare And Swap) 无锁插入。
如果有冲突:使用
synchronized锁住当前这个桶(Node)。
效果:锁的粒度细到了极致(Hash Bucket 级别)。只要 Hash 不冲突,并发度理论上是无限的。
生动类比:
类比:书架上的书
1.7 是锁一个房间(Segment)。
1.8 是只锁那一排书架(Node)。
你要拿第一排的书,完全不影响我去拿第二排的书。粒度更细,排队的人更少。
第三层:CopyOnWriteArrayList (读写分离)
它是 ArrayList 的线程安全兄弟,专门用于读多写少的场景(如白名单、配置缓存)。
原理:
读:不加锁,直接读原数组(极快)。
写:不直接修改原数组。而是加锁,复制一份新数组,修改新数组,然后把指针指向新数组。
生动类比:
类比:景区公示牌
游客(读线程):成千上万个游客在看公示牌,大家随便看,不需要排队。
管理员(写线程):要修改公告了。他不会把现在的牌子拆下来(那样游客就看不到了)。
他会在后台做一个新的牌子,改好后,趁大家不注意,瞬间把旧牌子换成新牌子。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “
ConcurrentHashMap的 Key 和 Value 可以是null吗?HashMap呢?为什么会有这种区别?”答案:
HashMap:Key 和 Value 都可以是 null。
ConcurrentHashMap:Key 和 Value 都不能是 null。
原因 (二义性问题):
在多线程环境下,如果你
get(key)返回了null,你无法分辨是 "Key 不存在" 还是 "Key 对应的值就是 null"。HashMap 可以用
containsKey()再查一次来确认。但多线程下,你刚查完
containsKey,可能另一个线程就把值改了,所以这种检查是不可靠的。为了避免歧义,Doug Lea 直接禁止了 null。
面试官: “为什么 JDK 1.8 的
ConcurrentHashMap要放弃分段锁,改用synchronized?”答案:
减少内存开销:每个 Segment 都要继承 ReentrantLock,只要创建 CHM 就会预创建这些对象,内存占用大。
锁粒度更细:1.8 锁的是 Node(桶),并发度随着数组容量增加而增加;1.7 受限于 Segment 个数(默认 16)。
JVM 优化:JDK 1.6 之后,
synchronized进行了大量优化(偏向锁、轻量级锁),性能已经不输给 ReentrantLock 了。
面试官: “
CopyOnWriteArrayList有什么缺点?什么场景绝对不能用?”答案:
内存占用高:每次写都要复制数组。如果数组很大(几百兆),频繁触发 GC,系统会卡死。
数据一致性:只能保证最终一致性,不能保证实时一致性。(读不到刚刚写入的数据,有一点延迟)。
结论:写多读少的场景绝对不能用(如实时日志写入)。
主题:Java 泛型 (编译期的守门员)
1. 直击要点 (TL;DR)
核心定义:泛型是 JDK 5 引入的参数化类型机制。
本质:语法糖。
作用:
编译期安全检查:把运行时的
ClassCastException提前到编译期解决。代码复用:一套代码可以适配多种数据类型(如
List<T>)。面试必背结论:Java 的泛型是伪泛型,编译后会进行“类型擦除”,运行时根本不存在泛型信息。
2. 详细拆解
第一层:类型擦除 (Type Erasure) —— 核心原理
现象:你在代码里写
List<String>和List<Integer>,编译成.class文件后,它们都变成了原生类型List(即List<Object>)。JVM 视角:JVM 看不懂
<String>。它只知道这是一个 List,里面装的是 Object。所有的类型转换(Checkcast)都是编译器帮你偷偷插入的。为什么这么设计?:为了 兼容性。Java 5 推出泛型时,为了让以前写的 JDK 1.4 的老代码(没有泛型)还能在新的 JVM 上跑,不得不做出的妥协。
强制举例 (生动类比)
便利贴与搬家箱子
编写代码 (编译前):你往箱子上贴了一张便利贴,写着“书本专用”。
编译器 (守门员):编译器看到便利贴,会检查你是不是往里放了书。如果你往里放砖头,它就拦截你(编译报错)。
编译后 (运行时):一旦检查通过,箱子封口发货。为了省事,便利贴被撕掉了(擦除)。
运行中:到了 JVM 仓库里,这个箱子就是一个普通的、没有任何标记的箱子。里面装的东西在 JVM 看来全是
Object。
第二层:通配符与 PECS 原则 (难点)
面试中最容易晕的地方:List<? extends T> 和 List<? super T> 到底有什么区别?
PECS 原则:Producer Extends, Consumer Super。
? extends T(上界通配符):含义:装的必须是 T 或 T 的子类。
能力:只读不写(除了 null)。因为编译器只知道里面是“某种 T”,但不知道具体是哪种 T,所以不敢让你往里放东西(怕类型不匹配)。
角色:生产者 (Producer),适合往外拿数据。
? super T(下界通配符):含义:装的必须是 T 或 T 的父类。
能力:只写不读(读出来的只能是 Object)。因为编译器知道里面装的至少是 T 的父类,所以把 T 放进去肯定是安全的。
角色:消费者 (Consumer),适合往里塞数据。
第一种情况:? extends T (只读不写)
口诀:“它是生产者 (Producer),只能往外拿数据。”
假设你有一个箱子,标签上写着:List<? extends Cat>。 这代表:“这个箱子里装的是 Cat,或者是 Cat 的某个子类。”
底层真相:这个箱子原本可能就是
List<Cat>,也可能甚至是List<Garfield>。编译器困境:因为编译器不知道箱子原本到底是装哪种猫的,所以为了安全,它禁止你往里放任何东西。
第二种情况:? super T (只写不读)
口诀:“它是消费者 (Consumer),只能往里塞数据。”
假设你有一个箱子,标签上写着:List<? super Cat>。 这代表:“这个箱子原本是用来装 Cat 的,或者装 Cat 的父类(Animal / Object)的。”
底层真相:这个箱子原本可能是
List<Cat>,也可能是List<Animal>,甚至是List<Object>。编译器逻辑:既然这个箱子至少能装
Cat,那么我往里扔Cat或者Cat的子孙,肯定装得下!
强制举例 (生动类比)
水果盘 (
extends) vs 垃圾桶 (super)
List<? extends Fruit>(水果盘):
这就是一盘某种水果。可能是“一盘苹果”,也可能是“一盘香蕉”。
能不能吃 (Get)? 能!拿出来的肯定是水果。
能不能放 (Add)? 不能! 如果这盘其实是香蕉,你硬塞一个苹果进去,就乱套了。
List<? super Apple>(苹果回收桶):
这是一个能装苹果的桶。它可能是“苹果桶”,也可能是“水果桶”,或者是“万物桶(Object)”。
能不能放 (Add)? 能!只要是苹果,尽管往里扔,肯定装得下。
能不能拿 (Get)? 不能! 你伸手进去掏,不知道掏出来的是苹果,还是香蕉,还是烂泥(Object)。
第三层:泛型的局限性 (坑点)
不能用基本类型:
List<int>是非法的,必须用List<Integer>。因为泛型擦除后是 Object,int 不是 Object。不能创建泛型数组:
new List<String>[10]是非法的。原因:数组是协变的(Covariant)且保留类型信息,泛型是不可变的且擦除类型信息。两者机制冲突,为了安全直接禁止。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
(这些问题专门用来识别你是否真正理解了“擦除”)
面试官: “
ArrayList<String> list1和ArrayList<Integer> list2,请问list1.getClass() == list2.getClass()的结果是什么?”答案:true。
解释:因为类型擦除。在运行时,它们全是
java.util.ArrayList,没有区别。
面试官: “既然泛型在编译期就检查了,那我有办法在运行时往
List<String>里塞一个Integer吗?”答案:可以。使用 反射 (Reflection)。
代码:
Java
1
List<String> list = new ArrayList<>();2
// list.add(1); // 编译报错3
Method add = list.getClass().getMethod("add", Object.class);4
add.invoke(list, 1); // 运行成功!因为运行时它就是个 List<Object>后果:当你尝试
list.get(0)时,会抛出ClassCastException。
面试官: “
List<?>和List<Object>有什么区别?”答案:区别很大。
List<Object>:表明它可以存储任何 Object。你可以随意add任何对象。List<?>:表明它是“未知类型的 List”。它是只读的(除了null)。编译器不知道它具体是什么类型,为了安全,禁止你往里add任何非 null 元素。
主题:IO 模型演进 (BIO → NIO → AIO)
1. 直击要点 (TL;DR)
核心演进:从“一个连接一个线程”的笨重模式,进化到“一个线程管理成千上万个连接”的高效模式。
BIO (Blocking IO):同步阻塞。传统的
java.io包。一根筋,死等。NIO (Non-blocking IO):同步非阻塞(多路复用)。
java.nio包。核心是 Selector(轮询)。AIO (Asynchronous IO):异步非阻塞。
java.nio.channels.AsynchronousSocketChannel。核心是 回调。
2. 详细拆解
第一层:BIO (Blocking IO) —— 传统派
别名:OIO (Old IO)。
模型:Thread-Per-Connection(一个连接配一个线程)。
工作流程:服务端
ServerSocket.accept()等待连接。一旦有客户端连上来,就这就分配一个线程专门伺候它。缺点:如果客户端连上来不说话(不发数据),这个线程就干等着(阻塞),浪费 CPU 和内存资源。并发量一高(比如 10 万连接),服务器直接 OOM 挂掉。
强制举例 (生动类比)
食堂排队打饭 (BIO)
场景:你是食堂阿姨(线程)。
工作:一个学生(连接)过来,你问他吃什么。
阻塞:学生说“我还没想好”,然后在窗口前思考了 5 分钟。你就必须拿着勺子傻等他 5 分钟,后面的人全堵死了,你也干不了别的事。
扩容:想服务更多人?只能多雇阿姨(多开线程),成本极高。
第二层:NIO (Non-blocking IO / New IO) —— 变革派 (面试重点)
核心组件:
Buffer (缓冲区):数据都在 Buffer 里读写(面向块),而不是面向流。
Channel (通道):双向的(类似铁路),既能读也能写。
Selector (选择器):灵魂所在。一个线程(Selector)可以监控成千上万个 Channel。
工作流程:所有连接都注册到 Selector 上。Selector 不断轮询(或通过 epoll 事件通知),谁有数据过来了,就处理谁。没数据的连接完全不占线程资源。
强制举例 (生动类比)
餐厅的点餐器 (NIO)
场景:你是唯一的服务员(Selector 线程)。
工作:进来了 100 个客人(连接)。你给每人发一个震动取餐器(注册 Channel)。
非阻塞:客人坐下思考吃什么(不发数据),你不等他,你就在柜台玩手机(处理其他事)。
多路复用:谁选好了,按一下取餐器,你的柜台响了(事件触发),你立马过去只给这一个客人点餐。
效果:你一个人就能轻松应付 100 个客人,只要他们不是同时按铃。
第三层:AIO (Asynchronous IO) —— 理想派
模型:真正的异步。
工作流程:应用程序向操作系统发起一个 IO 请求,然后直接返回去做别的事。等操作系统把数据读完、写到内存里了,再回调通知应用程序:“货到了,你来拿吧”。
现状:在 Linux 上,AIO 的底层实现(epoll 模拟)并不比 NIO 强太多,且编程极其复杂。所以主流框架(如 Netty)后来放弃了 AIO,回归了 NIO。
强制举例 (生动类比)
外卖送货上门 (AIO)
BIO/NIO:虽然 NIO 不用傻等,但饭做好了,还得你自己去窗口端过来(线程还要负责数据拷贝)。
AIO:你点完外卖就去打游戏了(完全不管 IO)。等饭送到了,外卖员敲你家门(回调),你直接开吃。数据拷贝的过程是外卖员(操作系统)帮你搞定的。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “你刚才提到了 NIO 的零拷贝 (Zero Copy),能详细讲讲是什么吗?在 Java 里怎么实现?”
答案:
传统 IO:数据从磁盘读到网卡,需要经过 4 次拷贝(磁盘 -> 内核 Buffer -> 用户 Buffer -> Socket Buffer -> 网卡)。CPU 很累。
零拷贝:减少拷贝次数。
实现方式:
mmap(内存映射):通过FileChannel.map(),将文件直接映射到内存,减少一次内核到用户的拷贝。sendfile:通过FileChannel.transferTo(),数据直接从文件通道传到 Socket 通道,完全不经过用户态,速度极快。
场景:Kafka 高吞吐量的秘诀就是用了
sendfile。
面试官: “Netty 是基于 NIO 的,为什么它不直接用 JDK 原生的 NIO,而要自己封装一套?”
答案:
API 繁琐:JDK 原生 NIO 的 API 极其难用,容易写出 Bug。
Epoll Bug:JDK 早期版本的 NIO 有著名的 空轮询 Bug(CPU 100%),Netty 巧妙地规避了这个问题。
功能增强:Netty 提供了更强大的 ByteBuf(池化、零拷贝支持)和完善的线程模型(Reactor 模型)。
面试官: “BIO、NIO、AIO 到底分别适用于什么场景?”
答案:
BIO:连接数少且固定(如公司内部的小系统)。
NIO:连接数多且连接比较短(轻操作),如聊天服务器、弹幕系统、Netty、Tomcat。
AIO:连接数多且连接比较长(重操作),如相册服务器(传大文件)。但在 Linux 下优势不明显。
主题:线程基础、线程池与锁机制
1. 直击要点 (TL;DR)
核心定义:
线程创建:不要只知道
new Thread。要掌握Runnable(无返回值) vsCallable(有返回值)。线程池:生产环境禁止显式创建线程,必须用线程池。核心是 7 大参数。
锁机制:
synchronized(JVM 层面的锁,会自动升级) vsReentrantLock(API 层面的锁,基于 AQS)。JMM:
volatile保证可见性和有序性,但不保证原子性。
2. 详细拆解
第一层:线程的创建与生命周期
创建方式:
继承
Thread(不推荐,Java 单继承局限)。实现
Runnable(推荐,解耦)。实现
Callable+FutureTask(面试重点):能拿到返回值,能抛出异常。
6 大生命周期 (State):
NEW(新建)。RUNNABLE(运行中):包含操作系统层面的 Running 和 Ready。BLOCKED(阻塞):专指等待 synchronized 锁。WAITING(等待):死等,需要被notify(如wait(),join())。TIMED_WAITING(超时等待):过时不候 (如sleep(1000)).TERMINATED(终止)。
第二层:线程池 (ThreadPoolExecutor) —— 必考中的必考
面试官会问:“线程池有哪几个参数?如果队列满了会发生什么?”
核心:7 大参数
corePoolSize:核心线程数(正式工)。就算没事干,这些线程也养着。maximumPoolSize:最大线程数(正式工 + 临时工)。keepAliveTime:临时工的空闲存活时间。没人来办事,临时工就解雇。unit:时间单位。workQueue:任务队列(候客区)。threadFactory:线程工厂(负责给线程起名字)。handler:拒绝策略(忙不过来了怎么办)。
强制举例 (生动类比)
银行网点办理业务
核心线程 (2个):银行常开的 2 个柜台。哪怕没人排队,柜员也在那坐着。
任务队列 (3个):里面的 3 把等待椅子。核心柜台忙时,新来的人坐椅子上等。
最大线程 (5个):椅子也坐满了!大堂经理赶紧把另外 3 个备用窗口打开(招聘临时工)。现在 5 个窗口火力全开。
拒绝策略:窗口全开,椅子全满。又来了一个人。经理直接说:“今天不办号了,你回去吧”(AbortPolicy),或者“你去那个角落自己填单子,别烦我”(CallerRunsPolicy)。
第三层:锁机制与 JMM (底层原理)
synchronized 的锁升级 (Highlights)
JDK 1.6 之前是重量级锁。之后引入了偏向锁 → 轻量级锁 → 重量级锁的升级机制。
偏向锁:只有一个线程访问时,贴个标签,不加锁。
轻量级锁 (CAS):出现竞争,但竞争不激烈(且持有锁时间短),通过自旋(循环)等待,不把线程挂起。
重量级锁:竞争激烈,直接让操作系统挂起线程(切换到内核态,开销大)。
volatile 关键字
作用:
可见性:一个线程改了,其他线程立刻看见(强制刷回主内存,使缓存失效)。
有序性:禁止指令重排序(通过内存屏障)。
短板:不保证原子性。
i++用 volatile 也不安全。
ReentrantLock (基于 AQS)
基于代码实现的锁。
功能比
synchronized多:可中断 (lockInterruptibly)、支持公平锁 (new ReentrantLock(true))、支持尝试拿锁 (tryLock)。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “为什么阿里巴巴手册禁止使用
Executors去创建线程池,而要让你手动new ThreadPoolExecutor?”答案:
FixedThreadPool和SingleThreadPool的队列是LinkedBlockingQueue,长度是Integer.MAX_VALUE(无界)。如果任务处理不过来,队列会无限膨胀,导致 OOM (Out Of Memory)。CachedThreadPool的最大线程数是Integer.MAX_VALUE。如果并发极高,会创建几万个线程,导致 CPU 卡死。结论:必须手动传参,明确限制队列大小和线程数,对系统负责。
面试官: “
synchronized和ReentrantLock有什么区别?你是怎么选择的?”答案:
用法:Sync 是关键字,自动释放;Lock 是类,必须在
finally中手动unlock()。功能:Lock 更强大(公平锁、可中断、Condition 分组唤醒)。
选择:
一般情况用 Sync(代码简洁,且 JDK一直在优化它)。
如果需要公平锁,或者需要尝试加锁(
tryLock),或者需要中断等待,必须用 Lock。
面试官: “怎么合理设置线程池的大小?有什么公式吗?”
答案:看任务类型。
CPU 密集型 (计算多):
N + 1(N 是 CPU 核数)。因为 CPU 一直在忙,线程多了也是切上下文浪费时间。IO 密集型 (读写库、网络多):
2N甚至更多 (如N / (1 - 阻塞系数))。因为线程大部分时间在等 IO,CPU 空闲,可以多开点线程让 CPU 忙起来。
主题:注解 (Annotation) —— 代码的“便利贴”
1. 直击要点 (TL;DR)
核心定义:注解本身没有任何逻辑,它只是元数据 (Metadata),也就是贴在代码上的“标签”或“便利贴”。
如何生效:
编译器扫描:在编译时检查(如
@Override)或生成代码(如 Lombok)。运行期反射:框架(Spring/MyBatis)在运行时通过反射读取标签,并执行相应的业务逻辑(如注入依赖、开启事务)。
2. 详细拆解
第一层:基础概念 (元注解)
要自定义注解,首先要了解“元注解” (Meta-Annotation),也就是“注解的注解”。
面试必考这两个:
@Target:贴在哪?ElementType.TYPE(类/接口),METHOD(方法),FIELD(字段)。
@Retention:活多久?(生命周期,最重要)SOURCE: 源码级。编译后丢弃。CLASS: 字节码级。编译在.class里,但 JVM 加载时丢弃(默认值)。RUNTIME: 运行时级。JVM 加载后依然存在,反射可见。(Spring 的注解几乎都是这个)。
第二层:生命周期与底层原理
Source (源码期):
例子:
@Override,Lombok 的@Data。作用:给编译器看的。Lombok 利用 APT (Annotation Processing Tool) 在编译时修改抽象语法树 (AST),自动“种”入 Getter/Setter 代码。
Runtime (运行期):
例子:
@Autowired,@RequestMapping。作用:给 JVM (框架) 看的。
原理:反射 (Reflection)。JVM 会解析这些注解,创建一个实现了该注解接口的动态代理对象。
强制举例 (生动类比)
商品标签的生命周期
SOURCE (便利贴):程序员写代码时贴的草稿纸(如
@Override)。检查完代码(编译)后,这张纸就被撕了扔进垃圾桶,成品里没有。CLASS (吊牌):衣服出厂时挂的吊牌。它在衣服(.class文件)上,但你穿衣服(JVM 运行)的时候,通常会把它剪掉。
RUNTIME (水洗标):缝在衣服里的水洗标。无论你什么时候穿(运行),它都在。洗衣店老板(Spring 容器) 需要翻开衣服,看一眼水洗标(反射读取),才知道这件衣服要“干洗”还是要“水洗”(注入 Bean 还是开启事务)。
第三层:自定义注解实战 (亮点)
面试官可能会让你口述一个自定义注解的场景。
场景:操作日志记录 (
@Log) 或 权限校验 (@Auth)。实现步骤:
定义
@Log注解 (@Retention(RUNTIME),@Target(METHOD)).写一个 AOP 切面 (Aspect)。
在切面里用
@Pointcut拦截所有贴了@Log的方法。在
@Around环绕通知里,通过反射获取注解上的参数(如模块名),记录日志。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “Lombok 的
@Data注解,和 Spring 的@Autowired注解,原理一样吗?”答案:完全不同。
Lombok (
SOURCE):利用 JSR 269 插件化注解处理 API (APT)。在 javac 编译期,它偷偷修改了代码的 AST(抽象语法树),把get/set方法直接“写”进了.class文件里。运行时不需要 Lombok 的包。Spring (
RUNTIME):利用 反射机制。在程序启动时,Spring 扫描类,发现有注解,就通过反射处理依赖注入。
面试官: “如果我定义一个注解,没写
@Retention,默认是什么?”答案:默认是
CLASS。陷阱:这意味着你写的注解在源码里有,编译后
.class里也有,但是在代码运行起来后,反射是读不到的! 这是一个常见的初学者 Bug。所以做业务注解一定要手动写@Retention(RetentionPolicy.RUNTIME)。
面试官: “注解可以是继承的吗?比如父类贴了注解,子类能读到吗?”
答案:
默认不能。
如果在定义注解时加上了
@Inherited元注解,那么子类可以继承父类类级别的注解。注意:接口上的注解、方法上的注解,永远无法被继承。Spring 里的
@Transactional建议写在实现类上而不是接口上,就是为了避免某些动态代理场景下注解失效。
主题:反射 (Reflection) —— 框架的灵魂
1. 直击要点 (TL;DR)
核心定义:反射机制允许程序在运行时 (Runtime) 自行解析类的结构(方法、属性、构造器),并能动态地创建对象、调用方法、修改属性。
一句话总结:“正射”是把类 new 出来使用;“反射”是把类解剖开来看。
核心价值:它是所有 Java 框架的基石(Spring、MyBatis、Tomcat)。框架本身不知道你将来会写什么类,只有通过反射,才能在运行时加载你的类并管理它们。
2. 详细拆解
第一层:什么是“正”?什么是“反”?
正向 (Static):
代码:
User user = new User(); user.sayHello();特点:编译时必须知道
User类存在,否则编译报错。类比:照着菜单点菜。菜单上有什么(编译期确定),你就只能点什么。
反向 (Dynamic):
代码:
Class clz = Class.forName("com.demo.User"); Method m = clz.getMethod("sayHello");特点:编译时可以不知道这个类叫什么,字符串变量传进来啥就是啥。
类比:进厨房自己做。你不看菜单,直接闯进后厨(运行时),翻看食材库(Class 对象),想怎么搭配就怎么搭配。
第二层:核心三要素 (Class, Field, Method)
反射的操作起点永远是 Class 对象。
JVM 加载完一个 .class 文件后,会在堆内存产生一个 Class 类型的对象,它包含了这个类的所有元信息。
获取 Class 对象的三种方式:
User.class(最快,编译时确定)。user.getClass()(运行时,得先有对象)。Class.forName("com.xxx.User")(最常用,Spring 配置全靠它,解耦最强)。
暴力反射 (
setAccessible):反射最“流氓”的地方在于,它可以无视
private修饰符。只要调用
field.setAccessible(true),私有属性也能读写,私有方法也能调用。生动类比:配钥匙 vs 砸窗户。
正常访问 (
public) 像是拿着钥匙开门。暴力反射 (
setAccessible) 像是直接把窗户砸了跳进去。虽然能进屋,但破坏了封装性(窗户破了),而且动作慢(性能差)。
第三层:亮点/加分项 (性能与应用)
为什么反射慢?
安全检查:每次反射调用,JVM 都要检查权限(能不能访问这个类?)。
无法内联优化:JIT 编译器没法对反射代码做深度优化(如方法内联)。
包装开销:基本类型(int)需要自动装箱成对象(Integer)。
如何优化?
缓存:把获取到的
Method、Field对象缓存起来,不要每次都Class.forName。ReflectASM:使用字节码操作库(如 ASM)直接生成调用代码,绕过反射。
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “
Class.forName("com.mysql.jdbc.Driver")这行代码经常看到,它除了获取 Class 对象,还干了什么?”答案:它会触发类的初始化 (Initialization),也就是执行
static静态代码块。原理:MySQL 驱动在静态代码块里把自己注册到了
DriverManager里。如果只用.class,是不会触发静态块的。
面试官: “反射能修改
final修饰的字段吗?”答案:大部分情况下可以,但有坑。
原理:通过
field.setAccessible(true)可以去除final的限制并修改值。坑:如果那个 final 字段在编译期就被优化成常量了(比如
final String s = "hello"),那你反射改了也没用,其他代码读取时依然会直接读取常量池里的 "hello"(编译器内联了)。
面试官: “既然反射这么慢,为什么 Spring 这种高性能框架还大规模使用?”
答案:
启动慢,运行快:Spring 主要在容器启动时狂用反射(扫描 Bean、创建对象),一旦启动完成,Bean 都放在单例池里了,业务运行时直接拿,不需要再反射。
字节码增强:对于高频调用的地方(如 AOP),Spring (CGLIB) 实际上是通过生成新的
.class字节码来运行的,而不是纯反射调用,性能接近原生。
代码实战:反射的标准套路
Java
1
public void testReflection() throws Exception {2
// 1. 获取 Class 对象 (模拟 Spring 读取配置文件)3
String className = "com.example.User"; 4
Class<?> clz = Class.forName(className);5
6
// 2. 创建实例 (模拟 Spring 创建 Bean)7
// JDK 9 之后推荐用 getConstructor().newInstance()8
Object user = clz.getDeclaredConstructor().newInstance();9
10
// 3. 获取方法 (模拟调用 controller 方法)11
Method method = clz.getMethod("sayHello", String.class);12
13
// 4. 调用方法14
// invoke(对象, 参数)15
method.invoke(user, "Interview");16
17
// 5. 暴力破解私有属性18
Field ageField = clz.getDeclaredField("age");19
ageField.setAccessible(true); // 🔓 砸窗户20
ageField.set(user, 18);21
}主题:Java 8 新特性
1. 直击要点 (TL;DR)
核心变革:引入了 函数式编程 (Functional Programming)。
四大支柱:
Lambda 表达式:让代码更简洁,把“行为”当作参数传递。
Stream API:像处理 SQL 一样处理集合数据(链式调用)。
Optional:优雅地解决
NullPointerException(NPE)。新日期时间 API (
java.time):解决了旧版 Date 线程不安全和设计混乱的问题。
2. 详细拆解
第一点:Lambda 表达式 (函数式编程)
痛点:以前为了写一个回调(比如线程启动),必须
new Runnable()写一大坨匿名内部类。解决:
() -> {}。只关注参数和执行体,省略画蛇添足的样板代码。底层:依赖 函数式接口 (Functional Interface),即只有一个抽象方法的接口(如
Runnable,Callable,Comparator)。场景:启动一个线程,或者给列表排序。
核心变化:省去了笨重的匿名内部类语法,只保留参数和核心逻辑。
❌ Old Way (Java 7 匿名内部类)
Java
1
new Thread(new Runnable() {2
@Override3
public void run() {4
System.out.println("线程启动了");5
}6
}).start();✅ New Way (Java 8 Lambda)
Java
1
// 只有核心逻辑:() 代表无参,-> 代表去执行,{} 代表逻辑2
new Thread(() -> System.out.println("线程启动了")).start();3
4
// 排序更是经典:(a, b) 代表两个元素,-> 后面是比较逻辑5
list.sort((a, b) -> a - b);
第二点:Stream API (流式处理) —— 面试最爱
痛点:以前要筛选 List 里大于 10 的数并排序,要写好几个
for循环和if,代码又臭又长。解决:一套流水线操作。
filter(过滤) →map(转换) →sorted(排序) →collect(装箱)。特性:惰性求值 (Lazy Evaluation)。只有调用了终端操作(如
collect,count),前面的中间操作(filter)才会真正执行。场景:从一堆用户里,找出“年龄大于 20 岁”的用户的“名字”,并“排序”。
核心变化:从“命令式编程”(怎么做)变成了“声明式编程”(做什么)。
❌ Old Way (For 循环地狱)
Java
1
List<String> names = new ArrayList<>();2
for (User u : users) {3
if (u.getAge() > 20) { // 1. 筛选4
names.add(u.getName()); // 2. 提取名字5
}6
}7
Collections.sort(names); // 3. 排序✅ New Way (Stream 流水线)
Java
1
List<String> names = users.stream()2
.filter(u -> u.getAge() > 20) // 1. 筛选:只要 > 20 的3
.map(User::getName) // 2. 映射:把 User 对象变成 String 名字4
.sorted() // 3. 排序5
.collect(Collectors.toList()); // 4. 装箱:结果收集回 List
第三点:Optional (空指针杀手)
痛点:
if (user != null && user.getAddress() != null)这种判空代码写得让人想吐。解决:给对象穿上一层“防弹衣”。
常用方法:
ofNullable(): 创建,允许为空。orElse(): 如果为空,给个备胎。map(): 如果不为空,转换一下。
场景:获取用户的家庭住址的城市。用户可能为空,地址可能为空,城市也可能为空。
核心变化:消灭了嵌套的
if (null)判断。❌ Old Way (嵌套判空)
Java
String city = "Unknown"; if (user != null) { Address address = user.getAddress(); if (address != null) { String c = address.getCity(); if (c != null) { city = c; } } }✅ New Way (Optional 链式调用)
Java
String city = Optional.ofNullable(user) // 1. 包装:不管 user 是不是 null,先包起来 .map(User::getAddress) // 2. 拆包取地址,如果 user 是 null,这里直接跳过 .map(Address::getCity) // 3. 拆包取城市 .orElse("Unknown"); // 4. 兜底:如果中间任何一步断了(是null),返回默认值
第四点:新日期时间 API (LocalDate/LocalDateTime)
痛点:
java.util.Date是可变的(Mutable),多线程不安全。月份是从 0 开始的(0 是 1 月),设计反人类。
解决:
java.time包下的LocalDate,LocalTime,LocalDateTime。不可变:类似 String,修改时间会返回一个新对象,线程绝对安全。
设计清晰:月份从 1 开始。
场景:获取“明天”的日期,并格式化输出。
核心变化:不可变对象,逻辑清晰,线程安全。
❌ Old Way (Date/Calendar)
Java
Calendar cal = Calendar.getInstance(); cal.add(Calendar.DATE, 1); // 这里的 1 是啥?很容易传错参数 Date tomorrow = cal.getTime(); // SimpleDateFormat 线程不安全,必须每次 new 或者加锁 System.out.println(new SimpleDateFormat("yyyy-MM-dd").format(tomorrow));✅ New Way (LocalDate)
Java
// 语义非常清晰:当前日期 -> 加一天 LocalDate tomorrow = LocalDate.now().plusDays(1); // DateTimeFormatter 是线程安全的,定义成 static 常量也没事 System.out.println(tomorrow.format(DateTimeFormatter.ISO_DATE));
3. 核心关键词总结 (复习速记)
4. 压力面追问 (深挖)
面试官: “
Stream并行流 (parallelStream) 是怎么实现的?它一定比普通流快吗?”答案:
原理:基于 ForkJoinPool 框架,把大任务拆成小任务多线程执行。
不一定快:
如果数据量小,线程切换开销反而比单线程慢。
如果涉及装箱/拆箱操作,性能损耗大。
大坑:它是全进程共享同一个 ForkJoinPool 的,如果你的任务里有 IO 阻塞,会拖慢整个系统的并行流。
面试官: “Lambda 表达式里使用的外部变量有什么限制?”
答案:必须是 final 或 effectively final (事实上的 final)。
原因:变量捕获。Lambda 运行可能在另一个线程或另一个时间点,它拷贝了变量的值而不是引用。如果变量后来变了,数据就不一致了,所以 Java 强制禁止修改。
面试官: “接口有了
default方法,那接口和抽象类还有区别吗?”答案:有本质区别。
状态:抽象类可以有成员变量(保存状态),接口不能(只能有
static final常量)。继承:类只能单继承抽象类,但可以多实现接口。
default方法主要是为了向后兼容(API 演进),而不是为了替代抽象类。
面试官: “
map和flatMap有什么区别?给我举个例子。”答案:
map (1对1):输入一个对象,输出一个对象。
例子:
Stream<String>(一堆单词) ->map(s -> s.length())->Stream<Integer>(一堆长度)。
flatMap (1对多/扁平化):输入一个对象,输出一个流(Stream),最后把所有流合并成一个大流。
例子:
Stream<List<String>>(几个班级的名单) ->flatMap(List::stream)->Stream<String>(全校所有学生的名单放在一起)。
面试官: “在使用 Stream 进行 list 转换 map 时 (
Collectors.toMap),如果 Key 重复了会怎么样?”答案:会直接报错
Duplicate key ...。解决:必须指定第三个参数(合并函数)。
代码:
Collectors.toMap(User::getId, User::getName, (oldValue, newValue) -> newValue)(如果 key 重复,用新值覆盖旧值)。
评论区