跳转到内容

Java

标签「Java」下的 4 篇文章

Java 校招面试题复盘清单

这是一份 Java 校招面试题复盘清单。

它不适合当作“背诵稿”逐字记忆,更适合当作复习地图:先知道面试会问哪些方向,再把每个问题整理成可以讲清楚的核心答案。

Java 校招面试通常会围绕三部分展开:

模块占比重点
技术基础约 40%Java、Spring、MySQL、Redis、JVM、并发
项目深挖约 40%项目背景、技术选型、难点、优化、问题排查
学习能力约 20%最近在学什么、为什么做 Java、如何解决问题

如果项目里有 Elasticsearch,那么 ES 往往会成为面试官重点追问的方向。

HashMap 底层主要是数组、链表和红黑树。

当放入一个 key-value 时,会先根据 key 的 hashCode() 计算 hash,再定位到数组下标。如果该位置没有元素,就直接放入;如果已经有元素,就会形成链表或红黑树。

在 Java 8 之后,当链表长度达到一定阈值,并且数组容量足够大时,链表会转换为红黑树,用来提高查询效率。

可以这样回答:

HashMap 通过 hash 定位数组下标,数组中每个位置叫 bucket。发生 hash 冲突时,Java 8 以前主要用链表,Java 8 之后链表过长会转为红黑树。扩容时会重新计算元素位置,所以 HashMap 的性能和初始容量、负载因子、hash 分布都有关系。

HashMap 没有做同步控制,多线程同时读写时可能出现数据覆盖、状态不一致、扩容异常等问题。

常见风险:

  • 多个线程同时 put,可能覆盖彼此写入。
  • 扩容时结构变化,其他线程同时访问可能拿到异常结果。
  • 统计数量 size 可能不准确。

所以多线程场景通常使用 ConcurrentHashMap

ConcurrentHashMap 如何实现线程安全

Section titled “ConcurrentHashMap 如何实现线程安全”

Java 8 中,ConcurrentHashMap 主要通过 CAS 和 synchronized 保证线程安全。

它不是给整张表加一把大锁,而是尽量缩小锁粒度。

常见回答:

Java 8 的 ConcurrentHashMap 底层也是数组、链表和红黑树。插入时,如果桶为空,会通过 CAS 放入节点;如果桶不为空,会对桶头节点加 synchronized,只锁当前桶。这样既保证线程安全,又比 Hashtable 整表加锁性能更好。

ArrayList 底层是动态数组,LinkedList 底层是双向链表。

对比项ArrayListLinkedList
底层结构动态数组双向链表
随机访问快,按下标访问 O(1)慢,需要遍历
中间插入删除需要移动元素找到节点后修改指针
内存占用相对较少每个节点要存前后指针
常用场景查询多插入删除多,但实际也要看位置

面试里要注意:不要简单说 LinkedList 插入删除一定快。因为如果要先按索引找到位置,遍历本身也有成本。

String、StringBuilder、StringBuffer 的区别

Section titled “String、StringBuilder、StringBuffer 的区别”

String 是不可变对象,每次修改都会产生新字符串。

StringBuilder 是可变字符序列,线程不安全,但性能较好。

StringBuffer 也是可变字符序列,方法加了同步,线程安全,但性能通常低于 StringBuilder

常见选择:

  • 少量字符串拼接:直接用 String
  • 单线程大量拼接:用 StringBuilder
  • 多线程共享拼接对象:用 StringBuffer,但实际业务中较少这样用。

== 比较的是两边是否相等。

对于基本类型,比较的是值。

对于引用类型,比较的是对象地址。

equals() 是对象方法,默认实现也是比较地址,但很多类会重写它,比如 String 会比较字符串内容。

equals-demo.java
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true

Java 异常体系的顶层是 Throwable

它下面主要分为:

  • Error:严重错误,程序通常不主动处理,比如 OutOfMemoryError
  • Exception:程序可以捕获和处理的异常。

Exception 又分为:

  • checked exception:编译期异常,必须处理或声明抛出。
  • unchecked exception:运行时异常,继承自 RuntimeException,例如空指针、数组越界。

面试里可以补一句:业务开发中不要滥用异常控制正常流程,异常更适合表示非预期情况。

反射是 Java 在运行时获取类信息、创建对象、调用方法、访问字段的能力。

常见应用场景:

  • Spring 创建 Bean、依赖注入。
  • MyBatis 映射对象字段。
  • 注解解析。
  • 测试框架调用测试方法。
  • 动态代理。

反射灵活,但也有缺点:性能相对普通调用更低,可读性和安全性更差。

进程是操作系统资源分配的基本单位。一个应用程序运行起来通常就是一个进程。

线程是 CPU 调度的基本单位,一个进程中可以包含多个线程。

可以这样回答:

进程拥有独立的内存空间,线程共享同一进程的内存资源。线程切换成本通常低于进程,但共享数据也会带来线程安全问题。

synchronized 可以修饰方法或代码块,用来保证同一时间只有一个线程进入临界区。

它依赖对象监视器锁,也就是 monitor。

进入同步代码块时,线程尝试获取对象锁;执行完或异常退出时释放锁。

可以补充:

  • 修饰普通方法,锁的是当前对象 this
  • 修饰静态方法,锁的是当前类的 Class 对象。
  • 修饰代码块,可以指定锁对象。

volatile 主要有两个作用:

  • 保证变量对多线程的可见性。
  • 禁止指令重排序。

它不能保证复合操作的原子性。

比如 count++ 包含读取、加一、写回,不是一个原子操作,所以只加 volatile 仍然不安全。

常见场景:

  • 状态标志位。
  • 单例模式双重检查锁中的实例变量。

线程池是提前创建并管理一组线程,任务来了以后交给线程池执行。

使用线程池的原因:

  • 避免频繁创建和销毁线程。
  • 控制并发线程数量。
  • 提高响应速度。
  • 统一管理任务队列、拒绝策略和线程生命周期。

ThreadPoolExecutor 常见核心参数:

参数作用
corePoolSize核心线程数
maximumPoolSize最大线程数
keepAliveTime非核心线程空闲存活时间
unit时间单位
workQueue任务队列
threadFactory线程创建工厂
handler拒绝策略

执行流程可以概括为:

核心线程未满 -> 创建核心线程
核心线程已满 -> 放入任务队列
队列满了 -> 创建非核心线程
线程数达到最大且队列也满 -> 执行拒绝策略

死锁是多个线程互相持有对方需要的资源,导致都无法继续执行。

死锁常见四个条件:

  • 互斥。
  • 请求并保持。
  • 不可剥夺。
  • 循环等待。

避免方式:

  • 固定加锁顺序。
  • 减少锁范围。
  • 使用超时锁。
  • 避免嵌套锁。
  • 使用并发工具类代替手写锁。

JVM 运行时数据区主要包括:

  • 程序计数器。
  • Java 虚拟机栈。
  • 本地方法栈。
  • 堆。
  • 方法区。

线程私有:

  • 程序计数器。
  • Java 虚拟机栈。
  • 本地方法栈。

线程共享:

  • 堆。
  • 方法区。

栈主要存放方法调用相关信息,比如局部变量表、操作数栈、方法出口等。

堆主要存放对象实例,是垃圾回收重点关注的区域。

对比项
线程关系线程私有线程共享
存储内容方法调用、局部变量对象实例
生命周期随方法调用入栈出栈由 GC 管理
常见错误StackOverflowErrorOutOfMemoryError

垃圾回收是 JVM 自动回收不再使用对象的机制。

判断对象是否可回收,主流方法是可达性分析:从 GC Roots 出发,能到达的对象是存活对象,不能到达的对象可以被回收。

常见 GC Roots:

  • 虚拟机栈中的引用。
  • 方法区中的静态变量引用。
  • 常量引用。
  • 本地方法栈中的引用。

常见算法:

  • 标记-清除:先标记垃圾,再清除,可能产生内存碎片。
  • 复制算法:把存活对象复制到另一块区域,适合新生代。
  • 标记-整理:标记后整理存活对象,减少碎片。
  • 分代收集:按对象生命周期分区,不同区域用不同算法。

OOM 是内存不足导致的错误。

常见原因:

  • 创建了大量对象,堆空间不足。
  • 大对象过多。
  • 内存泄漏,旧对象一直被引用。
  • 线程过多导致栈空间不足。
  • 元空间加载类过多。

排查时通常会看日志、堆 dump、GC 情况和对象引用链。

常见关注点:

  • 堆大小:-Xms-Xmx
  • 新生代大小:-Xmn
  • 元空间大小:-XX:MetaspaceSize
  • GC 收集器选择。
  • GC 日志。
  • 停顿时间和吞吐量。

调优不是盲目改参数,而是先看现象:内存是否够、GC 是否频繁、停顿是否过长、对象是否异常增长。

IoC 是控制反转。

原本对象由程序自己创建和管理,现在交给 Spring 容器创建和管理。

DI 依赖注入是 IoC 的一种实现方式。比如一个 Service 依赖 Mapper,不需要自己 new,而是由 Spring 注入。

AOP 是面向切面编程,用来把通用逻辑从业务代码中抽离出来。

常见场景:

  • 日志。
  • 权限校验。
  • 事务。
  • 监控统计。
  • 接口耗时。

核心思想是:不修改业务方法本身,在方法执行前后织入增强逻辑。

简化流程:

实例化 -> 属性注入 -> 初始化前后处理 -> 初始化方法 -> 使用 -> 销毁

常见扩展点:

  • 构造方法。
  • 属性填充。
  • BeanPostProcessor
  • InitializingBeaninit-method
  • DisposableBeandestroy-method

Spring Boot 主要解决传统 Spring 项目配置繁琐的问题。

优势:

  • 自动配置。
  • 内置 Web 容器。
  • starter 依赖简化。
  • 快速创建项目。
  • 方便监控和部署。

一句话回答:

Spring Boot 让 Spring 项目更容易启动和维护,它通过自动配置和 starter 机制减少大量 XML 或手动配置。

自动配置的核心是根据类路径、配置文件和条件注解,自动创建合适的 Bean。

常见关键词:

  • starter。
  • auto configuration。
  • @EnableAutoConfiguration
  • 条件注解,比如 @ConditionalOnClass@ConditionalOnMissingBean

面试中可以这样说:

Spring Boot 会根据引入的依赖和当前环境判断是否满足条件,如果满足,就把对应配置类里的 Bean 注册到容器中。

RESTful API 是一种接口设计风格。

核心思想:

  • 使用 URL 表示资源。
  • 使用 HTTP 方法表示操作。
  • 使用状态码表示结果。

示例:

GET /users/1 查询用户
POST /users 创建用户
PUT /users/1 更新用户
DELETE /users/1 删除用户

索引是帮助 MySQL 快速查找数据的数据结构。

可以理解为书的目录。没有索引时,数据库可能要全表扫描;有索引时,可以更快定位数据。

索引能提高查询效率,但会增加写入成本和存储空间。

B+Tree 适合磁盘存储和范围查询。

原因:

  • 树高度低,减少磁盘 IO。
  • 非叶子节点只存索引,能放更多 key。
  • 叶子节点之间有链表,范围查询效率高。
  • 查询性能稳定。

覆盖索引指查询需要的字段都能从索引中获得,不需要回表查询。

比如有联合索引 (name, age)

SELECT name, age FROM user WHERE name = 'xiaoxi';

如果查询字段都在索引里,就可能走覆盖索引。

常见情况:

  • 对索引列使用函数。
  • 对索引列做计算。
  • 使用左模糊匹配,例如 LIKE '%abc'
  • 联合索引不符合最左前缀原则。
  • 隐式类型转换。
  • OR 条件使用不当。

事务是一组数据库操作的集合,要么全部成功,要么全部失败。

比如下单时:

  • 创建订单。
  • 扣减库存。
  • 扣减余额。

这些操作应该作为一个整体处理。

特性含义
Atomicity 原子性事务要么全部成功,要么全部失败
Consistency 一致性事务前后数据满足约束
Isolation 隔离性并发事务之间互相隔离
Durability 持久性事务提交后数据持久保存

MVCC 是多版本并发控制。

它通过保存数据的多个版本,让读写尽量不互相阻塞。

在 InnoDB 中,MVCC 主要依赖:

  • 隐藏字段。
  • undo log。
  • ReadView。

常见作用是支持可重复读和快照读。

常见原因:

  • 数据主要在内存中。
  • 使用高效数据结构。
  • 单线程命令执行避免大量锁竞争。
  • IO 多路复用。
  • C 语言实现,执行效率高。

常见类型:

  • String。
  • Hash。
  • List。
  • Set。
  • Sorted Set。
  • Bitmap。
  • HyperLogLog。
  • Geo。
  • Stream。

缓存穿透是请求的数据在缓存和数据库中都不存在,导致请求持续打到数据库。

解决方式:

  • 缓存空对象。
  • 布隆过滤器。

缓存击穿是热点 Key 过期瞬间,大量并发请求同时打到数据库。

解决方式:

  • 互斥锁。
  • 逻辑过期。

缓存雪崩是大量 Key 同时过期,或者 Redis 整体不可用,导致大量请求涌向数据库。

解决方式:

  • 过期时间加随机值。
  • 多级缓存。
  • 限流降级。
  • Redis 高可用。

Elasticsearch 是一个分布式搜索和分析引擎,常用于全文搜索、日志检索、商品搜索等场景。

它底层基于 Lucene,对外提供 REST API,支持分布式、倒排索引和复杂查询。

倒排索引是从词到文档的映射。

普通索引更像:

文档 -> 包含哪些词

倒排索引更像:

词 -> 出现在哪些文档中

这就是 ES 做全文搜索快的重要原因。

index 类似一类数据的集合。

document 是一条 JSON 数据。

type 在早期版本中用于区分类型,但新版本已经逐步移除,不建议在新项目中依赖 type。

可以类比:

ES关系型数据库
indextable 或 database 的某种集合概念
documentrow
fieldcolumn

DSL 是 Elasticsearch 的查询语言,使用 JSON 描述查询条件。

示例:

es-dsl.json
{
"query": {
"match": {
"title": "redis"
}
}
}

常见条件:

  • must:必须匹配,影响评分。
  • should:可选匹配,可能影响评分。
  • filter:必须匹配,但不参与评分,适合过滤条件。
  • must_not:必须不匹配。

为什么 Elasticsearch 搜索比 MySQL 快

Section titled “为什么 Elasticsearch 搜索比 MySQL 快”

ES 在全文搜索场景下更快,主要因为它使用倒排索引。

MySQL 更擅长结构化数据查询和事务处理,全文检索不是它最核心的场景。

可以这样回答:

ES 会先对文本分词,再建立词到文档的倒排索引。查询关键词时,可以快速定位包含该词的文档。MySQL B+Tree 索引更适合精确匹配和范围查询,对复杂全文搜索、相关性评分和分词检索不如 ES 合适。

Docker 是容器化技术,可以把应用和依赖打包成镜像,再以容器方式运行。

它解决了“本地能跑,服务器不能跑”的环境一致性问题。

对比项Docker虚拟机
隔离方式进程级隔离硬件级虚拟化
启动速度
资源占用较少较多
系统内核共享宿主机内核每个虚拟机有完整操作系统
适用场景应用部署、微服务强隔离、多系统环境

常见命令:

linux-commands.sh
ls
cd
pwd
mkdir
rm
cp
mv
cat
tail -f app.log
grep "error" app.log
ps -ef
top
df -h
free -m
chmod
chown

面试时如果结合项目部署经历回答,会比单纯背命令更好。

REST API 是基于 REST 风格设计的接口。

它通常使用 HTTP 协议,通过 URL 表示资源,通过 HTTP 方法表示操作。

这个问题和 Spring 里的 RESTful API 本质相同。

微服务是把一个大系统拆成多个小服务,每个服务负责一个相对独立的业务能力。

优点:

  • 服务独立部署。
  • 技术栈可以更灵活。
  • 方便水平扩展。
  • 团队边界更清晰。

缺点:

  • 服务间调用复杂。
  • 分布式事务困难。
  • 监控、链路追踪、部署复杂度提高。

API 网关是系统入口,负责把外部请求转发到内部服务。

常见功能:

  • 路由转发。
  • 鉴权。
  • 限流。
  • 熔断。
  • 日志。
  • 跨域处理。

如果你的项目重点是 Elasticsearch,面试官很可能会围绕项目继续追问。

可以提前准备这些问题:

问题准备方向
为什么项目要用 ESMySQL 搜索能力不足、全文检索、分词、排序、性能
数据怎么同步到 ES同步写、异步消息、定时补偿
ES 和 MySQL 数据不一致怎么办重试、补偿任务、最终一致性
索引怎么设计index、mapping、分词器、字段类型
搜索结果怎么排序相关性评分、业务权重、时间、热度
查询慢怎么优化filter、分页限制、字段选择、索引设计

面试时不要只说“我用了 ES”。更好的说法是:

我在项目中用 Elasticsearch 解决全文检索问题。MySQL 更适合事务和结构化查询,但对分词搜索、相关性排序支持有限。所以把需要搜索的数据同步到 ES,通过倒排索引提升搜索效率。项目里还需要考虑 MySQL 和 ES 的数据一致性,比如通过消息队列异步同步,并配合定时任务补偿。

学习能力问题通常不会太难,但很考验真实感。

常见问题:

  • 最近在学什么技术?
  • 为什么选择 Java?
  • 遇到不会的问题怎么解决?
  • 看过哪些技术文档?
  • 项目里最有收获的地方是什么?

回答建议:

  • 不要只说“我在学 Java”。
  • 要说具体学了什么、为什么学、怎么实践。
  • 最好能和项目或面试岗位关联起来。

例如:

最近我在补 Redis 和 Elasticsearch。Redis 主要关注缓存穿透、击穿、雪崩以及数据结构的使用场景;Elasticsearch 主要学习倒排索引、DSL 查询和数据同步。因为我的项目里有搜索场景,所以我想把搜索链路和缓存链路都理解得更完整。

如果时间有限,可以按这个顺序准备:

  1. Java 基础:集合、字符串、异常、反射。
  2. MySQL:索引、事务、MVCC、索引失效。
  3. Redis:数据结构、缓存问题、常用命令。
  4. Spring Boot:IoC、AOP、自动配置、REST API。
  5. JVM:内存结构、GC、OOM。
  6. 并发:线程池、锁、volatile、死锁。
  7. Elasticsearch 项目:倒排索引、DSL、数据同步、搜索优化。
  8. Docker 和 Linux:能讲清楚部署和常用命令即可。

Java 校招面试不是只背八股。

基础题要能答清楚概念,项目题要能讲清楚自己做了什么、为什么这样做、遇到了什么问题、怎么解决。

这份清单的重点不是一次性背完,而是把每个问题都整理成三层:

一句话结论
核心原理
项目或使用场景

这样回答时会更稳,也更像真的理解过。

Java 集合底层:List、Set、HashMap 与红黑树

Java 集合面试题经常会从一个很小的问题开始:

List 和 Set 有什么区别?

如果继续追问,很快就会进入 HashMapHashSetTreeSet、红黑树、哈希冲突、扩容、equals()hashCode() 这些内容。

这篇文章把几份旧笔记整理成一条完整的复习线:先看集合接口,再看哈希表,最后看树结构。

ListSet 都继承自 Collection 接口,都属于 Java 集合体系中存放单个元素的容器。

它们的核心区别可以从三个角度理解。

List 允许重复元素:

List<String> list = new ArrayList<>();
list.add("Java");
list.add("Java");
System.out.println(list.size()); // 2

Set 不允许重复元素:

Set<String> set = new HashSet<>();
set.add("Java");
set.add("Java");
System.out.println(set.size()); // 1

需要注意的是,Set 判断重复通常依赖 equals()hashCode()。如果自定义对象放入 HashSet,却没有正确重写这两个方法,就可能出现“看起来相同的对象却没有去重”的问题。

List 按插入顺序保存元素,可以通过下标访问:

list.get(0);

Set 不一定保持插入顺序,也不能通过下标访问元素。

不同 Set 实现的顺序语义也不同:

  • HashSet:不保证插入顺序,也不保证排序。
  • LinkedHashSet:按插入顺序迭代。
  • TreeSet:按自然顺序或比较器规则排序。

所以不能简单说“Set 会升序排序”。准确说法是:TreeSet 会排序,HashSet 不保证顺序。

List 更像动态数组或链表,具体性能取决于实现类。

ArrayList 的特点:

  • 按下标访问快,时间复杂度通常是 O(1)
  • 中间插入和删除可能需要移动元素,时间复杂度通常是 O(n)

LinkedList 的特点:

  • 插入和删除节点时,只需要调整节点引用。
  • 但查找指定位置的元素需要遍历链表。

Set 更强调“唯一性”。例如 HashSet 底层依赖哈希表,添加、删除、查找在理想情况下可以接近 O(1)

CollectionListSet 等集合接口的上层接口,常见方法包括:

boolean add(E e);
boolean remove(Object o);
boolean contains(Object o);
boolean isEmpty();
int size();
void clear();
Iterator<E> iterator();
Object[] toArray();

有一个细节很容易忽略:Collection 本身没有 get(index) 方法。

因为不是所有集合都有下标语义。例如 List 可以按下标取值,但 Set 没有稳定的下标概念,只能通过迭代器遍历:

for (String item : set) {
System.out.println(item);
}

哈希表也叫散列表,是一种非常重要的数据结构。很多缓存、字典、索引、去重结构的核心思想,都是在内存中维护一张哈希表。

哈希表的核心公式可以理解为:

存储位置 = f(关键字)

这里的 f 就是哈希函数。它会根据 key 计算出一个哈希值,再根据数组长度换算成数组下标。

理想情况下,哈希表的查询过程是:

key -> hash -> 数组下标 -> 找到元素

如果没有哈希冲突,查找、插入、删除都可以接近 O(1)

和其他结构对比:

  • 数组:按下标访问快,按值查找通常需要遍历。
  • 链表:插入删除节点方便,但查找需要遍历。
  • 平衡二叉搜索树:查找、插入、删除通常是 O(log n)
  • 哈希表:理想情况下查找、插入、删除接近 O(1)

不过哈希表的性能高度依赖哈希函数、数组容量、负载因子和冲突处理方式。

哈希冲突指的是:不同 key 经过哈希计算后,落到了同一个数组位置。

例如:

keyA -> index 3
keyB -> index 3

这时就需要处理冲突。

常见方式有三类:

  1. 开放地址法:当前位置冲突后,继续寻找下一个可用位置。
  2. 再哈希法:使用另一个哈希函数重新计算位置。
  3. 链地址法:数组每个位置挂一个链表或树,把冲突元素放到同一个桶里。

Java HashMap 使用的就是链地址法的思路。更准确地说,在 Java 8 之后,HashMap 的桶结构可能是:

数组 + 链表
数组 + 红黑树

当同一个桶里的元素过多时,链表会在满足条件后树化,变成红黑树,以降低极端冲突下的查询成本。

HashMap 存储的是 key-value 键值对。

可以把它简化理解成:

HashMap
table 数组
bucket 0 -> null
bucket 1 -> Node -> Node
bucket 2 -> Node
bucket 3 -> TreeNode 红黑树

每个节点大致保存:

hash
key
value
next

put 一个元素时,大致过程是:

  1. 计算 key 的 hash。
  2. 根据 hash 和数组长度计算桶下标。
  3. 如果桶为空,直接放入。
  4. 如果桶不为空,判断 key 是否已经存在。
  5. 如果 key 已存在,更新 value。
  6. 如果 key 不存在,挂到链表或红黑树中。
  7. 如果元素数量超过阈值,触发扩容。

六、HashMap 的初始容量和负载因子

Section titled “六、HashMap 的初始容量和负载因子”

HashMap 有两个重要参数:

initialCapacity 初始容量
loadFactor 负载因子

常见默认值是:

initialCapacity = 16
loadFactor = 0.75

阈值计算方式可以理解为:

threshold = capacity * loadFactor

默认情况下:

16 * 0.75 = 12

当元素数量超过阈值后,HashMap 会扩容。扩容通常会带来重新分布元素的成本,所以如果能预估数据量,创建 HashMap 时可以指定合适的初始容量。

HashMap 的扩容通常发生在元素数量超过阈值之后:

size > threshold

而阈值由容量和负载因子决定:

threshold = capacity * loadFactor

默认情况下:

capacity = 16
loadFactor = 0.75
threshold = 12

也就是说,当第 13 个元素放入时,HashMap 就可能触发扩容。

扩容的核心动作可以概括成三步:

  1. 创建一个更大的新数组。
  2. 把旧数组中的节点迁移到新数组。
  3. 重新计算扩容后的阈值。

通常情况下,新容量会变成旧容量的 2 倍:

oldCapacity = 16
newCapacity = 32

新阈值也会随之变化:

oldThreshold = 16 * 0.75 = 12
newThreshold = 32 * 0.75 = 24

在 Java 8 之后,HashMap 扩容迁移时有一个很重要的规律:

元素的新位置要么是原位置,要么是原位置 + oldCapacity

例如旧数组长度是 16,某个元素原来在下标 5

旧位置:5
新位置:5 或 5 + 16 = 21

为什么会这样?因为数组长度翻倍后,参与下标计算的二进制位多了一位。只需要看 hash 在这一位上是 0 还是 1

hash & oldCapacity == 0 -> 留在原位置
hash & oldCapacity != 0 -> 移到原位置 + oldCapacity

可以用一个简化例子理解:

旧容量 16:下标范围 0 ~ 15
新容量 32:下标范围 0 ~ 31
原 bucket 5 拆成:
bucket 5
bucket 21

这也是 HashMap 容量使用 2 的次幂的一个好处:扩容后不需要完全重新计算每个元素的位置,可以通过位运算快速拆分旧桶。

Java 8 之后,HashMap 桶内链表过长时可能会树化为红黑树,但并不是链表一长就立刻树化。

常见条件可以记成:

桶内链表长度 >= 8
数组容量 >= 64

如果桶内链表已经很长,但数组容量还比较小,HashMap 通常会优先扩容,而不是马上树化。

原因也很直观:容量太小时,冲突可能只是数组太小导致的。先扩容可以让元素重新分布,很多冲突自然会减少。

面试里可以这样总结:

HashMap 默认容量是 16,负载因子是 0.75,超过阈值后通常扩容为原来的 2 倍。Java 8 扩容迁移时,元素的新位置要么保持原下标,要么移动到原下标加旧容量。桶内链表过长时可能树化,但容量较小时会优先扩容。

八、为什么 HashMap 容量常用 2 的次幂

Section titled “八、为什么 HashMap 容量常用 2 的次幂”

HashMap 的数组长度通常会调整为 2 的次幂。

这样做的一个重要原因是可以用位运算快速计算下标:

index = (n - 1) & hash;

n 是 2 的次幂时,n - 1 的二进制低位全是 1,可以更好地利用 hash 的低位信息。

扩容时也有一个好处:元素的新位置通常只有两种可能:

原位置
原位置 + oldCapacity

这可以减少扩容后重新计算和移动元素的成本。

九、equals 和 hashCode 为什么要一起重写

Section titled “九、equals 和 hashCode 为什么要一起重写”

HashMapHashSet 这类哈希结构里,hashCode() 决定元素大致落在哪个桶,equals() 决定桶内元素是否真的相等。

如果两个对象通过 equals() 判断相等,它们的 hashCode() 必须相等。

错误示例:

class User {
private Long id;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User other)) return false;
return Objects.equals(this.id, other.id);
}
}

这个类只重写了 equals(),没有重写 hashCode()。放进 HashSet 时,相同 id 的对象可能因为 hash 不同,被放到不同桶里,导致去重失败。

正确做法:

class User {
private Long id;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User other)) return false;
return Objects.equals(this.id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}

面试里可以总结成一句话:

只要重写 equals(),通常就必须重写 hashCode(),否则哈希集合和哈希映射可能出现行为异常。

HashSet 的底层主要依赖 HashMap

可以粗略理解成:

HashSet<E>
内部维护 HashMap<E, Object>

HashSet 添加元素时,本质上是把元素作为 HashMap 的 key 存进去:

map.put(element, PRESENT);

因为 HashMap 的 key 不能重复,所以 HashSet 就天然具备了去重能力。

因此,HashSet 的几个特点可以这样理解:

  • 不允许重复元素。
  • 不保证插入顺序。
  • 底层依赖 hashCode()equals() 判断重复。
  • 查询、插入、删除在理想情况下接近 O(1)

如果需要保持插入顺序,可以使用 LinkedHashSet。如果需要排序,可以使用 TreeSet

十一、二叉树、二叉搜索树和平衡二叉树

Section titled “十一、二叉树、二叉搜索树和平衡二叉树”

普通二叉树只限制每个节点最多有两个子节点,本身不要求元素大小关系。

二叉搜索树、退化树和四种平衡旋转示意图

二叉搜索树则有明确规则:

左子树节点 < 当前节点 < 右子树节点

例如下面这棵树就是一棵二叉搜索树:

8
/ \
4 12
/ \ / \
2 6 10 14

每个节点都满足:

左边比自己小,右边比自己大

插入时:

  • 比当前节点小,放左边。
  • 比当前节点大,放右边。
  • 如果不允许重复,相等元素不再插入。

二叉搜索树的中序遍历可以得到升序结果:

左 -> 根 -> 右

以上面这棵树为例,中序遍历结果是:

2 -> 4 -> 6 -> 8 -> 10 -> 12 -> 14

但普通二叉搜索树有一个问题:如果插入数据本身接近有序,就可能退化成链表。

例如连续插入:

1, 2, 3, 4, 5

树可能长成这样:

1
\
2
\
3
\
4
\
5

这时查找复杂度会从 O(log n) 退化到 O(n)

平衡二叉树就是为了解决这个问题。它要求任意节点左右子树高度差不能过大,常见规则是高度差不超过 1。插入或删除节点后,如果树失衡,就通过旋转恢复平衡。

常见失衡情况可以先记四种:

  • 左左:一次右旋。
  • 左右:先局部左旋,再整体右旋。
  • 右右:一次左旋。
  • 右左:先局部右旋,再整体左旋。

左左失衡,做一次右旋:

旋转前:
30
/
20
/
10
右旋后:
20
/ \
10 30

右右失衡,做一次左旋:

旋转前:
10
\
20
\
30
左旋后:
20
/ \
10 30

左右失衡,先对左子树左旋,再对整体右旋:

旋转前:
30
/
10
\
20
先局部左旋:
30
/
20
/
10
再整体右旋:
20
/ \
10 30

右左失衡,先对右子树右旋,再对整体左旋:

旋转前:
10
\
30
/
20
先局部右旋:
10
\
20
\
30
再整体左旋:
20
/ \
10 30

这几张图可以帮助记忆:哪边重,就先看它是不是同方向;同方向一次旋转,折线方向两次旋转。

红黑树是一种自平衡二叉搜索树。

它不像严格平衡二叉树那样要求左右高度差非常精确,而是通过节点颜色和一组规则,让树保持“大致平衡”。

可以简单理解为:

红黑树 = 带颜色规则的二叉搜索树

它的目标是避免树退化成链表,使查找、插入、删除都能保持在 O(log n) 级别。

Java 中:

  • TreeMap 底层是红黑树。
  • TreeSet 底层通常基于 TreeMap
  • HashMap 在桶内链表过长时,也可能把链表树化成红黑树。

TreeSet 的特点:

  • 元素不能重复。
  • 不保留插入顺序。
  • 会按照自然顺序或自定义比较器排序。
  • 线程不安全。

示例:

Set<Integer> set = new TreeSet<>();
set.add(3);
set.add(1);
set.add(2);
System.out.println(set); // [1, 2, 3]

如果放入自定义对象,需要让对象实现 Comparable,或者创建 TreeSet 时传入 Comparator

如果面试官问 HashMap 原理,可以这样答:

HashMap 底层是数组加链表或红黑树。put 时先计算 key 的 hash,再通过数组长度计算桶下标。如果没有冲突直接放入;如果有冲突,就在桶内链表或红黑树中比较 key。默认初始容量是 16,负载因子是 0.75,超过阈值会扩容。Java 8 之后,当桶内链表过长并且数组容量满足条件时,链表会树化成红黑树,以优化极端冲突下的查询性能。

如果问 HashSet 原理,可以这样答:

HashSet 底层依赖 HashMap,元素作为 HashMap 的 key 保存,value 使用一个固定占位对象。因此 HashSet 不允许重复元素,去重依赖 hashCode()equals()

如果问 ListSet 区别,可以这样答:

List 有序、可重复、可按下标访问;Set 不允许重复,通常不能按下标访问。HashSet 不保证顺序,LinkedHashSet 保持插入顺序,TreeSet 按比较规则排序。

如果问 TreeSet 原理,可以这样答:

TreeSet 底层基于红黑树,元素不能重复,并按自然顺序或比较器排序。它不保留插入顺序,查找、插入、删除通常是 O(log n)

这几个知识点可以串成一张图:

Collection
├── List
│ ├── ArrayList
│ └── LinkedList
└── Set
├── HashSet -> HashMap -> 哈希表
├── LinkedHashSet -> HashMap + 链表顺序
└── TreeSet -> TreeMap -> 红黑树
Map
├── HashMap -> 数组 + 链表 + 红黑树
└── TreeMap -> 红黑树

复习时不要只背结论,而要抓住底层结构:

  • List 关注顺序和下标。
  • Set 关注唯一性。
  • HashMap 关注哈希、冲突、扩容和树化。
  • HashSet 关注 HashMap 的 key 去重。
  • TreeSetTreeMap 关注红黑树排序。

这样从使用层一路讲到底层结构,面试回答会更完整。

队列、栈与 ArrayDeque 面试题整理

队列和栈是数据结构里最基础、也最容易在面试中被追问的两个概念。

它们看起来都像是“存放一组元素的容器”,但核心区别在于元素进出顺序操作位置限制。如果再放到 Java 集合体系里,还会涉及 QueueDequeListStackArrayDeque 这些类和接口的关系。

这篇文章按面试回答的方式整理一遍。

队列遵循 先进先出

Queue: First In First Out, FIFO

也就是说,先进入队列的元素,会先被取出。

入队顺序:A -> B -> C
出队顺序:A -> B -> C

栈遵循 先进后出

Stack: First In Last Out, FILO

也可以说是 后进先出

Last In First Out, LIFO

也就是说,最后进入栈的元素,会最先被取出。

入栈顺序:A -> B -> C
出栈顺序:C -> B -> A

这是队列和栈最核心的区别。

队列的插入和删除发生在不同端:

  • 在一端插入元素,通常称为入队。
  • 在另一端删除元素,通常称为出队。

可以理解成排队买票:

队尾入队 -> [A, B, C] -> 队头出队

栈的插入和删除发生在同一端:

  • 插入元素叫入栈。
  • 删除元素叫出栈。
  • 能直接操作的一端叫栈顶。

可以理解成往桌上叠盘子:

栈顶
C
B
A
栈底

新盘子放在最上面,拿的时候也只能从最上面拿。

队列通常适合按顺序处理任务,例如消息队列、线程池任务队列、广度优先搜索等场景。

队列在逻辑上更强调“从头部取、从尾部放”。如果底层是链表结构,可以通过节点指针向后遍历;如果底层是数组结构,也可以通过下标或循环数组逻辑访问。

栈更强调“只看栈顶”。如果要取出最早进入栈底的元素,就必须先把上面的元素依次弹出。

例如:

栈顶 -> C
B
栈底 -> A

如果想拿到 A,就要先处理 CB

需要注意的是,面试里有时会说“队列遍历比栈快”。这个说法不能绝对化,因为遍历速度还取决于底层实现:

  • ArrayDeque 基于数组实现,访问和扩容方式与链表不同。
  • LinkedList 基于链表实现,遍历依赖节点引用。
  • Stack 继承自 Vector,底层也是数组结构,并且很多方法带同步开销。

所以更准确的说法是:队列和栈限制的是操作语义,不是简单决定遍历速度。具体性能要看底层实现。

从 Java 集合体系看,队列和栈并不是完全对称的两个接口。

队列通常使用 Queue 接口:

Queue<Integer> queue = new ArrayDeque<>();

Queue 继承自 Collection,常见方法包括:

offer(e) // 入队,失败时通常返回 false
poll() // 出队,队列为空时返回 null
peek() // 查看队头,队列为空时返回 null

栈在早期可以使用 Stack 类:

Stack<Integer> stack = new Stack<>();

但在现代 Java 代码里,通常更推荐用 Deque 来模拟栈:

Deque<Integer> stack = new ArrayDeque<>();

常见方法包括:

push(e) // 入栈
pop() // 出栈
peek() // 查看栈顶

原因是 Stack 是一个比较旧的类,它继承自 VectorVector 的很多方法带同步机制,在大多数普通场景下并不是首选。

因此,面试里可以这样回答:

Java 中队列通常使用 QueueDeque;栈不一定要用 Stack 类,实际开发中更推荐使用 Deque,常见实现是 ArrayDeque

ArrayDequeDeque 接口的一个常用实现。

Deque 的全称是 double-ended queue,也就是双端队列。它允许在两端插入和删除元素,因此既可以当队列用,也可以当栈用。

当作队列:

Deque<String> queue = new ArrayDeque<>();
queue.offer("A");
queue.offer("B");
queue.offer("C");
System.out.println(queue.poll()); // A
System.out.println(queue.poll()); // B
System.out.println(queue.poll()); // C

当作栈:

Deque<String> stack = new ArrayDeque<>();
stack.push("A");
stack.push("B");
stack.push("C");
System.out.println(stack.pop()); // C
System.out.println(stack.pop()); // B
System.out.println(stack.pop()); // A

可以看到,同样是 ArrayDeque,只要使用的方法不同,就可以表达不同的数据结构语义。

如果面试官问“队列和栈有什么区别”,可以按这几个点回答:

  1. 规则不同:队列是 FIFO,栈是 FILO/LIFO。
  2. 操作位置不同:队列一端插入、另一端删除;栈只在栈顶插入和删除。
  3. 使用场景不同:队列适合按顺序处理任务;栈适合回退、括号匹配、函数调用、深度优先搜索等场景。
  4. Java 实现不同:队列常用 QueueDeque;栈可以用 Stack,但更推荐用 Deque 的实现类 ArrayDeque
  5. 性能不能只按“队列快、栈慢”简单判断,要结合底层结构,例如数组、链表、同步开销和扩容机制。

队列和栈的区别可以先抓住一句话:

队列:先进先出,像排队。
栈:先进后出,像叠盘子。

在 Java 中,还要补充一句:

需要队列或栈语义时,优先考虑 Queue、Deque 和 ArrayDeque;不要一看到栈就只想到 Stack。

这样回答既覆盖了基础概念,也能体现出对 Java 集合体系的理解。

ServletConfig 接口介绍

这篇文章迁移自我早年写在博客园的一篇记录。当时主要是在学习 Servlet 初始化参数:Servlet 容器初始化 Servlet 时,会创建一个 ServletConfig 对象,并把它交给当前 Servlet 使用。

简单说,ServletConfig 适合保存“只属于某一个 Servlet 的配置”。比如某个 Servlet 自己需要的用户名、开关、路径、默认参数等。

当 Web 容器创建并初始化 Servlet 时,会为这个 Servlet 准备一个 ServletConfig 对象。这个对象里包含当前 Servlet 的初始化信息,也可以通过它拿到整个 Web 应用的 ServletContext

需要记住两点:

  • 一个 Web 应用里可以有多个 Servlet,也就可以有多个 ServletConfig 对象。
  • 一个 Servlet 只对应一个 ServletConfig 对象,所以 Servlet 初始化参数默认只对当前 Servlet 有效。

如果把 Web 应用理解成一个项目,那么:

  • ServletContext 更像“整个项目的全局上下文”。
  • ServletConfig 更像“某一个 Servlet 自己的配置说明”。

ServletConfig 常用方法不多,重点是下面这几个:

方法作用
String getInitParameter(String name)根据参数名获取当前 Servlet 的初始化参数值
Enumeration<String> getInitParameterNames()获取当前 Servlet 所有初始化参数名
ServletContext getServletContext()获取当前 Web 应用的 ServletContext 对象
String getServletName()获取当前 Servlet 名称,也就是 web.xml<servlet-name> 的值

这里最常用的是 getInitParameter()getInitParameterNames()。前者适合读取单个配置,后者适合遍历所有配置。

Servlet 里有两类参数很容易混在一起:

配置位置所属对象读取方式生效范围
<context-param>ServletContextgetServletContext().getInitParameter()整个 Web 应用
<servlet> 里的 <init-param>ServletConfiggetServletConfig().getInitParameter()当前 Servlet

也就是说,context-param 不是当前 Servlet 的私有配置,它属于整个 Web 应用。ServletConfig#getServletContext() 只是让你可以从当前 Servlet 拿到全局上下文,并不代表这些全局参数属于 ServletConfig

这个区别非常重要。很多初学 Servlet 的时候,会把“通过 ServletConfig 拿到 ServletContext,再读取全局参数”误认为是在读取 Servlet 自己的初始化参数。

先看全局参数的写法。下面的 admin-emailadmin-nameadmin-password 都配置在 <context-param> 中,因此它们属于整个 Web 应用。

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>Servlet</servlet-name>
<servlet-class>main.java.com.Servlet</servlet-class>
</servlet>
<context-param>
<param-name>admin-email</param-name>
<param-value>123456@qq.com</param-value>
</context-param>
<context-param>
<param-name>admin-name</param-name>
<param-value>xiaoxi</param-value>
</context-param>
<context-param>
<param-name>admin-password</param-name>
<param-value>123456</param-value>
</context-param>
<servlet-mapping>
<servlet-name>Servlet</servlet-name>
<url-pattern>/Servlet</url-pattern>
</servlet-mapping>
</web-app>

读取这些全局参数时,需要先拿到 ServletContext

Servlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "Servlet", value = "/Servlet")
public class Servlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/plain;charset=UTF-8");
ServletConfig config = getServletConfig();
ServletContext servletContext = config.getServletContext();
String adminEmail = servletContext.getInitParameter("admin-email");
String adminName = servletContext.getInitParameter("admin-name");
String password = servletContext.getInitParameter("admin-password");
response.getWriter().println("admin-email: " + adminEmail);
response.getWriter().println("admin-name: " + adminName);
response.getWriter().println("admin-password: " + password);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这段代码里虽然先调用了 getServletConfig(),但真正读取参数的是 servletContext.getInitParameter()。所以它读取的是全局初始化参数。

使用 web.xml 配置当前 Servlet 参数

Section titled “使用 web.xml 配置当前 Servlet 参数”

如果希望参数只属于当前 Servlet,就应该把参数写到 <servlet> 里的 <init-param> 中。

web.xml
<servlet>
<servlet-name>MyServlet</servlet-name>
<servlet-class>java.com.MyServlet</servlet-class>
<init-param>
<param-name>name</param-name>
<param-value>xiaoxi</param-value>
</init-param>
<init-param>
<param-name>admin</param-name>
<param-value>xiaoxi</param-value>
</init-param>
</servlet>

这种参数才是 ServletConfig 最典型的使用场景。

MyServlet.java
ServletConfig config = getServletConfig();
String name = config.getInitParameter("name");
String admin = config.getInitParameter("admin");

如果另一个 Servlet 也想使用同名参数,需要在另一个 Servlet 的配置里重新声明。Servlet 私有参数不会自动共享。

除了 web.xml,也可以直接使用 @WebServlet@WebInitParam 配置当前 Servlet 的初始化参数。

这种方式更适合简单项目或示例代码,因为配置和 Servlet 类写在一起,阅读起来更直观。

HelloServlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Enumeration;
@WebServlet(
name = "helloServlet",
value = "/helloServlet",
initParams = {
@WebInitParam(name = "name", value = "测试"),
@WebInitParam(name = "admin", value = "123456")
}
)
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
ServletConfig config = getServletConfig();
String servletName = config.getServletName();
Enumeration<String> initParameterNames = config.getInitParameterNames();
PrintWriter writer = response.getWriter();
writer.write("servletName: " + servletName + "<br/>");
while (initParameterNames.hasMoreElements()) {
String initParamName = initParameterNames.nextElement();
String initParamValue = config.getInitParameter(initParamName);
writer.write(initParamName + ": " + initParamValue + "<br/>");
}
writer.close();
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这里的 initParams 配置只对 HelloServlet 生效。别的 Servlet 不能直接通过自己的 ServletConfig 读取这些参数。

如果项目很小,或者只是练习 Servlet,使用注解会比较方便。

如果项目配置比较多,或者需要把代码和配置分离,使用 web.xml 会更清晰。尤其是早期 Java Web 项目里,很多 Servlet、Filter、Listener 都会统一写在 web.xml 中。

可以简单按下面的方式判断:

场景推荐方式
示例代码、学习项目@WebServlet
配置较少的小项目@WebServletweb.xml 都可以
多个 Servlet 需要统一管理web.xml
希望参数不写死在 Java 类里web.xml

两种方式本质上都是告诉 Servlet 容器:这个 Servlet 叫什么、映射到哪个路径、初始化时带哪些参数。

第一,context-paraminit-param 不要混用。

如果一个参数是全站通用的,比如站点名称、管理员邮箱、上传目录,可以放在 <context-param> 中。如果一个参数只服务于某个 Servlet,就放在对应 Servlet 的 <init-param> 中。

第二,读取参数时要找对对象。

// 读取当前 Servlet 的初始化参数
getServletConfig().getInitParameter("name");
// 读取整个 Web 应用的全局参数
getServletContext().getInitParameter("admin-email");

第三,输出中文时记得设置响应编码。

response.setContentType("text/html;charset=UTF-8");

如果不设置编码,浏览器里可能会出现中文乱码。

第四,注意包名差异。

早期 Servlet 项目常见包名是 javax.servlet。如果使用的是 Tomcat 10、Spring Boot 3 或 Jakarta EE 新版本,包名会变成 jakarta.servlet

// 旧版本常见写法
import javax.servlet.ServletConfig;
// 新版本常见写法
import jakarta.servlet.ServletConfig;

学习旧项目或迁移项目时,这个差异很常见。

ServletConfig 的核心作用,是保存并读取当前 Servlet 的初始化参数。

需要特别分清楚:

  • ServletConfig 面向当前 Servlet。
  • ServletContext 面向整个 Web 应用。
  • <init-param> 是 Servlet 私有配置。
  • <context-param> 是 Web 应用全局配置。
  • 注解里的 initParams 只对当前 Servlet 生效。

把这几个概念理顺之后,Servlet 初始化参数就不难了。真正容易出错的地方,往往不是 API 本身,而是没有分清“这个配置到底属于谁”。

原文记录:ServletConfig接口介绍