本文档由原始文本排版整理而成,适合用于 Java后端面试复习。
内容覆盖:Java 集合、Java 并发、MySQL 索引优化、Redis 数据结构、缓存异常与缓存一致性。
目录
一、Java 集合
1. Map 有哪些实现类?
Java 中 Map 接口的主要实现类如下:
| 实现类 | 底层结构 / 特点 | 是否有序 | 线程安全 | 是否允许 null | 典型场景 |
|---|---|---|---|---|---|
HashMap | 基于哈希表实现,查询效率高,平均时间复杂度 O(1) | 无序 | 否 | 允许 key 和 value 为 null | 普通键值缓存、业务对象映射 |
TreeMap | 基于红黑树实现,支持自然排序或自定义排序 | 有序 | 否 | 不允许 key 为 null | 需要按 key 排序的场景 |
LinkedHashMap | 继承 HashMap,通过双向链表维护插入顺序或访问顺序 | 有序 | 否 | 允许 | LRU 缓存、按插入顺序遍历 |
Hashtable | 古老线程安全实现,方法使用 synchronized 修饰 | 无序 | 是 | 不允许 key 或 value 为 null | 旧项目兼容,不推荐新项目使用 |
ConcurrentHashMap | 并发安全 Map,JDK 1.8 使用 CAS + synchronized | 无序 | 是 | 不允许 key 或 value 为 null | 高并发读写场景 |
2. ConcurrentHashMap 如何实现线程安全?
ConcurrentHashMap 的线程安全实现机制在 JDK 1.7 和 JDK 1.8 中有明显区别。
2.1 JDK 1.7:Segment 分段锁机制
底层结构
JDK 1.7 中,ConcurrentHashMap 采用:
Segment 数组 + HashEntry 数组 + 链表
一个 ConcurrentHashMap 内部包含多个 Segment,每个 Segment 本质上类似一个小型 HashMap。
锁机制
- 每个
Segment独立拥有一把锁。 - 操作某个
Segment时,只需要锁住当前Segment。 - 不同
Segment可以被不同线程并发访问。
优点
这种设计实现了 锁分离,降低锁竞争,提高并发性能。
ConcurrentHashMap
├── Segment 1 ── HashEntry[]
├── Segment 2 ── HashEntry[]
├── Segment 3 ── HashEntry[]
└── Segment N ── HashEntry[]
2.2 JDK 1.8:CAS + synchronized
JDK 1.8 中,ConcurrentHashMap 摒弃了 Segment 结构,底层更接近 HashMap。
底层结构
Node 数组 + 链表 + 红黑树
核心机制
| 机制 | 说明 |
|---|---|
CAS | 用于数组初始化、扩容、插入等无锁操作 |
synchronized | 只锁定链表头节点或红黑树根节点,锁粒度更小 |
| 红黑树 | 当链表过长时转为红黑树,提高查询效率 |
| volatile | 保证节点状态在多线程下的可见性 |
总结
JDK 1.8 的 ConcurrentHashMap 通过:
CAS 无锁操作 + synchronized 小粒度加锁 + volatile 可见性保证
实现了较高性能的线程安全。
注意:
ConcurrentHashMap的迭代器是弱一致性的。迭代过程中如果集合发生修改,不会抛出ConcurrentModificationException,但也不保证一定看到最新修改。
二、Java 多线程与线程池
3. 多线程的创建方式有哪些?
Java 中多线程的创建方式主要有以下几种。
3.1 继承 Thread 类
通过继承 Thread 类,并重写 run() 方法定义线程逻辑。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行中");
}
public static void main(String[] args) {
new MyThread().start();
}
}
特点
- 使用简单。
- Java 单继承限制较明显。
- 不利于任务和线程解耦。
3.2 实现 Runnable 接口
实现 Runnable 接口的 run() 方法,再交给 Thread 执行。
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable 线程执行中");
}
public static void main(String[] args) {
new Thread(new MyRunnable()).start();
}
}
特点
- 任务和线程分离。
- 更适合实际开发。
- 不支持返回值。
3.3 通过 Callable 和 Future 创建线程
Callable 支持返回值,也可以抛出异常。
import java.util.concurrent.*;
public class CallableDemo {
public static void main(String[] args) throws Exception {
Callable<String> task = () -> "执行结果";
FutureTask<String> futureTask = new FutureTask<>(task);
new Thread(futureTask).start();
String result = futureTask.get();
System.out.println(result);
}
}
特点
- 支持返回结果。
- 支持异常抛出。
- 通常配合线程池使用。
3.4 通过线程池创建线程
实际项目中更推荐使用线程池管理线程。
import java.util.concurrent.*;
public class ThreadPoolDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> {
System.out.println("线程池执行任务");
});
executor.shutdown();
}
}
特点
- 避免频繁创建和销毁线程。
- 支持任务队列、拒绝策略、线程复用。
- 更适合高并发业务场景。
4. 为什么建议自己创建线程池?线程池有哪些参数?
4.1 为什么建议自己创建线程池?
实际项目中推荐使用 ThreadPoolExecutor 自定义线程池,而不是直接使用 Executors 工具类。
原因
Executors 创建线程池存在潜在风险:
| 创建方式 | 潜在问题 |
|---|---|
Executors.newFixedThreadPool() | 使用无界队列,队列长度可能达到 Integer.MAX_VALUE,任务堆积可能导致 OOM |
Executors.newSingleThreadExecutor() | 使用无界队列,任务堆积可能导致 OOM |
Executors.newCachedThreadPool() | 最大线程数为 Integer.MAX_VALUE,可能创建过多线程导致资源耗尽 |
推荐方式
使用 ThreadPoolExecutor 显式指定核心参数:
import java.util.concurrent.*;
public class CustomThreadPoolDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10,
20,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.submit(() -> System.out.println("执行任务"));
executor.shutdown();
}
}
4.2 ThreadPoolExecutor 的 7 个核心参数
| 参数 | 含义 | 说明 |
|---|---|---|
corePoolSize | 核心线程数 | 线程池长期维持的线程数量 |
maximumPoolSize | 最大线程数 | 队列满且核心线程都忙时,可继续创建线程,直到最大线程数 |
keepAliveTime | 空闲线程存活时间 | 超过核心线程数的空闲线程超过该时间会被销毁 |
unit | 时间单位 | keepAliveTime 的时间单位 |
workQueue | 阻塞队列 | 用于存储等待执行的任务 |
threadFactory | 线程工厂 | 用于自定义线程名称、优先级、是否守护线程等 |
handler | 拒绝策略 | 当线程池和队列都满时的处理策略 |
4.3 常见阻塞队列
| 队列 | 特点 | 适用场景 |
|---|---|---|
ArrayBlockingQueue | 有界队列,数组结构 | 推荐用于生产环境,防止任务无限堆积 |
LinkedBlockingQueue | 可有界,也可无界 | 使用时建议指定容量 |
SynchronousQueue | 不存储任务,直接移交线程 | 适合快速转交任务 |
PriorityBlockingQueue | 支持优先级 | 任务需要优先级排序的场景 |
4.4 常见拒绝策略
| 拒绝策略 | 说明 |
|---|---|
AbortPolicy | 默认策略,直接抛出异常 |
CallerRunsPolicy | 由提交任务的线程自己执行任务 |
DiscardPolicy | 直接丢弃任务,不抛异常 |
DiscardOldestPolicy | 丢弃队列中最旧的任务,然后重新提交当前任务 |
5. 线程安全问题为什么会出现?如何实现线程安全?
5.1 线程安全问题的根本原因
线程安全问题的根本原因是多个线程并发访问共享资源时,破坏了以下三大特性:
原子性、可见性、有序性
5.1.1 原子性问题
原子性指一个操作要么全部执行成功,要么完全不执行。
典型问题:
count++;
这行代码看似只有一步,实际上包含三步:
1. 读取 count
2. count + 1
3. 写回 count
如果多个线程同时执行,就可能出现数据覆盖。
5.1.2 可见性问题
每个线程都有自己的工作内存。
当一个线程修改共享变量后,如果没有及时刷新到主内存,其他线程可能读取到旧值。
5.1.3 有序性问题
编译器或 CPU 可能会对指令进行重排序,以提高执行效率。
在单线程中通常不会出问题,但在多线程场景下可能导致执行结果异常。
5.2 实现线程安全的方法
5.2.1 使用 synchronized
public synchronized void add() {
count++;
}
synchronized 可以保证:
- 原子性
- 可见性
- 有序性
5.2.2 使用 Lock
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void add() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
Lock 比 synchronized 更灵活,支持:
- 可中断锁
- 超时获取锁
- 公平锁
- 多条件队列
5.2.3 使用 volatile
private volatile boolean running = true;
volatile 可以保证:
- 可见性
- 一定程度的有序性
但它不能保证复合操作的原子性,例如:
count++;
即使 count 是 volatile,该操作也不是线程安全的。
5.2.4 使用原子类
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
private final AtomicInteger count = new AtomicInteger(0);
public void add() {
count.incrementAndGet();
}
}
常见原子类:
| 原子类 | 说明 |
|---|---|
AtomicInteger | 原子更新 int |
AtomicLong | 原子更新 long |
AtomicReference | 原子更新对象引用 |
LongAdder | 高并发计数场景性能更好 |
5.2.5 使用线程安全容器
例如:
ConcurrentHashMapCopyOnWriteArrayListBlockingQueueConcurrentLinkedQueue
5.2.6 使用不可变对象
例如:
StringInteger- 自定义
final不可变类
不可变对象天然线程安全。
6. synchronized 的锁升级机制是怎样的?
synchronized 锁升级是一个单向过程,随着锁竞争加剧,锁会逐步升级。
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
6.1 偏向锁
适用场景
只有一个线程反复获取同一把锁。
原理
当锁对象首次被线程访问时,JVM 会在对象头的 Mark Word 中记录当前线程 ID。
后续该线程再次获取锁时,只需要判断线程 ID 是否一致,不需要额外同步操作。
优点
无竞争场景下性能非常高。
6.2 轻量级锁
适用场景
多个线程交替竞争锁,但竞争不激烈。
原理
当有其他线程尝试获取已经偏向的锁时,偏向锁会升级为轻量级锁。
线程会通过自旋尝试获取锁,避免直接阻塞。
优点
短时间竞争场景下,可以减少用户态和内核态切换。
6.3 重量级锁
适用场景
竞争激烈或锁持有时间较长。
原理
当自旋多次仍然无法获取锁时,轻量级锁会升级为重量级锁。
重量级锁依赖操作系统互斥量实现,未获取锁的线程会进入阻塞状态。
缺点
涉及线程阻塞与唤醒,性能开销较大。
6.4 锁升级目的
锁升级机制是为了让 synchronized 在不同竞争程度下选择合适的锁策略:
| 阶段 | 适用场景 | 目标 |
|---|---|---|
| 偏向锁 | 无竞争 | 减少加锁成本 |
| 轻量级锁 | 低竞争 | 自旋避免阻塞 |
| 重量级锁 | 高竞争 | 保证线程安全 |
7. synchronized 与 ReentrantLock 的区别
7.1 本质区别
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 关键字 | JDK API 类 |
| 所属包 | Java 语言内置 | java.util.concurrent.locks |
| 加锁方式 | 隐式加锁 | 显式调用 lock() |
| 释放方式 | 自动释放 | 必须手动 unlock() |
7.2 相同点
二者都是 可重入锁。
可重入锁指:同一个线程已经获取某把锁后,可以再次获取这把锁,不会被自己阻塞。
示例:
public synchronized void methodA() {
methodB();
}
public synchronized void methodB() {
// 同一个线程可以再次进入
}
7.3 主要区别
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁释放 | JVM 自动释放 | 需要手动释放,通常写在 finally 中 |
| 锁获取 | 阻塞式获取 | 支持阻塞、非阻塞、超时、中断 |
| 公平锁 | 不支持 | 支持公平锁和非公平锁 |
| 条件队列 | 只有一个等待队列 | 支持多个 Condition |
| 可中断 | 不支持等待锁时中断 | 支持 lockInterruptibly() |
| 性能 | JDK 6 后性能已大幅优化 | 高并发复杂控制场景更灵活 |
| 使用复杂度 | 简单 | 相对复杂 |
7.4 ReentrantLock 使用示例
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
System.out.println("执行业务逻辑");
} finally {
lock.unlock();
}
}
}
使用
ReentrantLock时,必须在finally中释放锁,否则可能导致死锁。
三、MySQL 存储引擎与索引
8. MyISAM 与 InnoDB 的区别
MyISAM 和 InnoDB 是 MySQL 中两种常见存储引擎。
| 对比项 | MyISAM | InnoDB |
|---|---|---|
| 事务 | 不支持事务 | 支持 ACID 事务 |
| 锁机制 | 表级锁 | 行级锁、表级锁 |
| 并发能力 | 较弱 | 较强 |
| MVCC | 不支持 | 支持 |
| 外键 | 不支持 | 支持 |
| 全文索引 | 早期原生支持 | MySQL 5.6 后支持全文索引 |
| COUNT(*) | 存储总行数,速度较快 | 不存储总行数,需要统计 |
| 文件结构 | .frm、.MYD、.MYI | 表空间文件,如 .ibd |
| 索引结构 | 非聚集索引 | 聚集索引 |
| 崩溃恢复 | 较弱 | 支持崩溃恢复 |
8.1 核心区别总结
MyISAM
适合:
- 读多写少
- 不需要事务
- 对并发要求不高
- 旧系统兼容
InnoDB
适合:
- 大多数现代业务系统
- 需要事务
- 高并发读写
- 需要数据一致性和崩溃恢复
实际生产中,绝大多数业务表优先选择
InnoDB。
9. 非聚集索引与聚集索引的区别
9.1 核心区别
| 对比项 | 非聚集索引 | 聚集索引 |
|---|---|---|
| 数据存储方式 | 索引和数据分开存储 | 索引叶子节点存储完整数据 |
| 叶子节点内容 | 数据文件地址指针 | 整行数据 |
| 典型引擎 | MyISAM | InnoDB |
| 查询过程 | 先查索引,再根据指针查数据 | 主键查询可直接拿到数据 |
| 辅助索引 | 存储数据地址 | 存储主键值 |
| 是否可能回表 | 可能 | 辅助索引查询通常需要回表 |
9.2 MyISAM:非聚集索引
MyISAM 的索引文件和数据文件是分离的。
索引文件:存储索引值 + 数据地址
数据文件:存储真实数据
查询流程:
查询索引 → 找到数据地址 → 根据地址读取数据
9.3 InnoDB:聚集索引
InnoDB 的主键索引是聚集索引,B+ 树叶子节点直接存储整行数据。
查询流程:
根据主键查询 → 直接在聚集索引叶子节点拿到整行数据
9.4 InnoDB 辅助索引为什么需要回表?
InnoDB 的普通索引叶子节点存储的不是完整数据,而是主键值。
因此普通索引查询流程通常是:
普通索引 → 查到主键 ID → 主键索引 → 查到整行数据
这个过程称为 回表。
10. MySQL 中联合索引有什么用?
联合索引是覆盖多个字段的索引,例如:
CREATE INDEX idx_user_status_time ON user_order(user_id, status, create_time);
10.1 联合索引的作用
1. 提高多条件查询性能
例如:
SELECT *
FROM user_order
WHERE user_id = 1001
AND status = 1;
如果存在联合索引 (user_id, status),MySQL 可以快速定位数据,减少全表扫描。
2. 减少索引数量
相比给多个字段分别创建单列索引,联合索引在多条件查询中更高效。
3. 支持覆盖索引
如果查询字段都包含在联合索引中,则无需回表。
SELECT user_id, status
FROM user_order
WHERE user_id = 1001;
如果存在索引:
CREATE INDEX idx_user_status ON user_order(user_id, status);
该查询可能直接通过索引完成。
10.2 联合索引的代价
联合索引会增加写操作成本:
- 插入时需要维护索引
- 更新索引字段时需要调整索引结构
- 删除数据时需要删除索引记录
- 占用额外磁盘空间
11. 最左匹配原则是什么?
最左匹配原则是 MySQL 联合索引的核心使用规则。
对于联合索引:
CREATE INDEX idx_abc ON table_name(a, b, c);
MySQL 会按照:
a → b → c
的顺序构建 B+ 树。
因此查询必须从最左侧字段开始连续匹配。
11.1 示例说明
| 查询条件 | 是否能使用索引 | 使用情况 |
|---|---|---|
WHERE a = 1 AND b = 2 AND c = 3 | 是 | 使用完整索引 (a,b,c) |
WHERE a = 1 AND b = 2 | 是 | 使用 (a,b) |
WHERE a = 1 | 是 | 使用 (a) |
WHERE a = 1 AND c = 3 | 部分使用 | 只使用 a,跳过 b 后 c 无法继续使用 |
WHERE b = 2 AND c = 3 | 否 | 未使用最左列 a |
WHERE a = 1 AND b > 2 AND c = 3 | 部分使用 | a、b 可用,c 通常无法继续使用 |
11.2 范围查询影响
联合索引中,如果某一列使用范围查询:
WHERE a = 1 AND b > 2 AND c = 3
通常:
a 可以使用索引
b 可以使用索引
c 无法继续使用索引
原因是 b 范围内的 c 已经不再整体有序。
11.3 查询条件顺序是否影响索引使用?
一般不会。
例如联合索引为 (a, b):
WHERE b = 2 AND a = 1
MySQL 优化器通常会自动调整为:
WHERE a = 1 AND b = 2
因此仍可能使用联合索引。
11.4 联合索引设计建议
- 高频查询字段放在左侧。
- 区分度高的字段优先。
- 范围查询字段尽量放在联合索引靠右位置。
- 尽量让联合索引满足更多查询条件。
- 避免对索引字段使用函数、表达式和隐式类型转换。
12. 什么时候索引会失效?
索引失效是指查询语句本应使用索引,但最终未使用索引,可能转为全表扫描。
核心原因通常是:
查询条件破坏了索引有序性
或
优化器认为全表扫描更划算
12.1 破坏最左匹配原则
联合索引:
CREATE INDEX idx_abc ON t(a, b, c);
以下查询可能导致索引无法充分使用:
WHERE b = 2 AND c = 3;
原因:没有使用最左列 a。
WHERE a = 1 AND c = 3;
原因:跳过了中间列 b。
12.2 在索引列上使用函数
WHERE SUBSTR(name, 1, 3) = 'abc';
索引中保存的是原始 name 值,使用函数后破坏了索引有序性。
12.3 在索引列上进行表达式计算
WHERE age + 1 = 20;
推荐改为:
WHERE age = 19;
12.4 隐式类型转换
如果 phone 是 varchar 类型:
WHERE phone = 13800138000;
可能触发隐式类型转换,导致索引失效。
推荐写法:
WHERE phone = '13800138000';
12.5 使用否定条件
以下条件可能导致索引效果变差或失效:
WHERE status != 1;
WHERE status <> 1;
WHERE id NOT IN (1, 2, 3);
WHERE name IS NOT NULL;
原因:否定条件通常会匹配大量数据,优化器可能认为全表扫描更划算。
12.6 范围查询后的列无法继续使用联合索引
联合索引:
CREATE INDEX idx_abc ON t(a, b, c);
查询:
WHERE a = 1 AND b > 2 AND c = 3;
通常只能使用到 a 和 b。
12.7 OR 连接非索引列
如果只有 a 有索引:
WHERE a = 1 OR b = 2;
由于 b 没有索引,可能导致整体无法使用索引。
12.8 LIKE 以 % 开头
WHERE name LIKE '%张';
WHERE name LIKE '%张%';
这种写法无法利用 B+ 树的前缀有序性。
可以使用索引的写法:
WHERE name LIKE '张%';
12.9 查询结果集过大
如果查询返回数据量占表数据比例很高,例如超过 30%,优化器可能选择全表扫描。
原因是:
索引扫描 + 回表成本 > 全表扫描成本
12.10 字段为 NULL 的特殊情况
虽然 MySQL 支持对 NULL 建索引,但如果 NULL 值非常多,优化器可能放弃索引。
WHERE name IS NULL;
12.11 如何避免索引失效?
| 优化方向 | 建议 |
|---|---|
| 联合索引 | 遵循最左匹配原则 |
| 函数操作 | 不要在索引列上使用函数 |
| 类型转换 | 查询条件类型与字段类型保持一致 |
| 范围查询 | 范围字段尽量放在联合索引右侧 |
| 模糊查询 | 避免 LIKE '%xxx' |
| SQL 分析 | 使用 EXPLAIN 查看执行计划 |
| 覆盖索引 | 尽量减少回表 |
| 统计信息 | 必要时执行 ANALYZE TABLE |
13. MySQL 如何优化?
MySQL 优化可以从索引设计、SQL 编写、执行计划分析等角度入手。
13.1 尽量使用主键查询
InnoDB 的主键索引是聚集索引,叶子节点直接存储整行数据。
主键查询路径短,通常性能最好。
SELECT *
FROM user
WHERE id = 1001;
13.2 使用覆盖索引避免回表
如果查询字段都在索引中,就可以避免回表。
例如:
CREATE INDEX idx_user_name_age ON user(name, age);
SELECT name, age
FROM user
WHERE name = '张三';
该查询可能直接通过索引返回结果。
13.3 利用索引下推
MySQL 5.6 及以上支持索引下推,即 Index Condition Pushdown,简称 ICP。
作用
在存储引擎层利用索引条件提前过滤数据,减少回表次数。
执行计划中可能看到:
Using index condition
13.4 联合索引字段顺序设计
联合索引应综合考虑:
- 查询频率
- 字段区分度
- 是否用于范围查询
- 是否参与排序
- 是否可以形成覆盖索引
通常建议:
等值查询字段 → 高区分度字段 → 范围查询字段 → 排序字段
13.5 避免 SELECT *
SELECT *
FROM user
WHERE name = '张三';
如果只需要部分字段,推荐明确指定:
SELECT id, name, age
FROM user
WHERE name = '张三';
这样更容易利用覆盖索引,也能减少网络传输。
13.6 使用 EXPLAIN 分析 SQL
重点关注:
typekeyrowsExtra
如果出现:
type = ALL
Using temporary
Using filesort
通常需要重点优化。
14. EXPLAIN 中的参数解析
EXPLAIN 是 MySQL 分析 SQL 执行计划的重要工具。
示例:
EXPLAIN
SELECT *
FROM user
WHERE id = 1;
14.1 核心字段
| 字段 | 含义 | 关注点 |
|---|---|---|
id | 查询执行顺序标识 | 数字越大越先执行,相同则从上到下执行 |
select_type | 查询类型 | 判断是否存在子查询、派生表、UNION |
table | 当前访问的表 | 可能是真实表、别名或临时表 |
type | 访问类型 | 判断查询效率的重要字段 |
possible_keys | 可能使用的索引 | 如果为 NULL,说明没有可用索引 |
key | 实际使用的索引 | 如果为 NULL,说明没有实际使用索引 |
key_len | 使用的索引长度 | 判断联合索引使用到了几列 |
ref | 与索引比较的列或常量 | 如 const 或其他表字段 |
rows | 预计扫描行数 | 越少越好 |
Extra | 额外信息 | 判断是否回表、排序、临时表等 |
14.2 type 访问类型
type 是非常重要的字段,表示 MySQL 如何访问表。
性能从好到差:
system > const > eq_ref > ref > range > index > ALL
| type | 含义 | 性能 |
|---|---|---|
system | 表中只有一行数据 | 最好 |
const | 主键或唯一索引等值查询,最多一行 | 很好 |
eq_ref | 多表关联时,被驱动表通过主键或唯一索引匹配 | 很好 |
ref | 非唯一索引等值查询 | 较好 |
range | 索引范围查询 | 一般 |
index | 全索引扫描 | 较差 |
ALL | 全表扫描 | 最差,需要重点关注 |
14.3 Extra 常见值
| Extra | 含义 | 是否需要关注 |
|---|---|---|
Using index | 使用覆盖索引,无需回表 | 好 |
Using where | 使用 WHERE 条件过滤 | 需结合 type 判断 |
Using index condition | 使用索引下推 | 好 |
Using temporary | 使用临时表 | 需要优化 |
Using filesort | 使用文件排序 | 需要优化 |
Using join buffer | 关联查询未有效使用索引 | 需要优化 |
14.4 如何通过 EXPLAIN 优化 SQL?
1. 避免 type = ALL
如果出现全表扫描,应检查:
- 是否缺少索引
- 是否索引失效
- 是否查询条件不合理
- 是否返回数据量过大
2. 消除 Using temporary
通常出现在:
GROUP BYDISTINCT- 复杂排序
优化方向:
- 给分组字段建立索引
- 调整 SQL 结构
- 减少不必要的去重和分组
3. 消除 Using filesort
优化方向:
- 给
ORDER BY字段建立合适索引 - 让排序字段符合联合索引顺序
- 避免对排序字段使用函数
4. 追求 Using index
通过覆盖索引减少回表,提高查询性能。
四、Redis
15. Redis 中的数据类型及使用场景
Redis 支持多种数据类型,不同类型适合不同业务场景。
15.1 String 字符串
特性
- Redis 最基础的数据类型。
- 可存储字符串、整数、浮点数。
- 支持原子自增、自减。
常用命令
SET key value
GET key
INCR counter
DECR counter
SET lock_key value NX EX 10
使用场景
| 场景 | 说明 |
|---|---|
| 计数器 | 阅读量、播放量、接口调用次数 |
| 简单缓存 | 商品详情、用户信息 |
| 分布式锁 | 基于 SET NX EX 实现互斥 |
| 限流 | 结合过期时间统计窗口内请求次数 |
15.2 Hash 哈希
特性
- 键值对集合。
- 适合存储对象。
- 可单独操作某个字段。
常用命令
HSET user:1 name 张三
HSET user:1 age 18
HGET user:1 name
HGETALL user:1
使用场景
| 场景 | 说明 |
|---|---|
| 对象缓存 | 用户信息、商品属性 |
| 会话缓存 | 统一存储用户 Session |
| 配置信息 | 存储多个字段组成的配置对象 |
15.3 List 列表
特性
- 有序列表。
- 支持两端插入和弹出。
- 可实现队列和栈。
常用命令
LPUSH queue msg1
RPOP queue
RPUSH queue msg2
LPOP queue
LRANGE queue 0 9
使用场景
| 场景 | 说明 |
|---|---|
| 简单消息队列 | LPUSH + RPOP |
| 最新列表 | 最新动态、最新消息 |
| 栈结构 | LPUSH + LPOP |
15.4 Set 集合
特性
- 无序。
- 元素唯一。
- 支持交集、并集、差集。
常用命令
SADD tag:java article1 article2
SMEMBERS tag:java
SINTER tag:java tag:redis
SUNION tag:java tag:mysql
SDIFF tag:java tag:go
使用场景
| 场景 | 说明 |
|---|---|
| 去重 | 用户访问记录、抽奖用户去重 |
| 标签系统 | 文章标签、商品标签 |
| 共同关注 | 利用交集计算共同好友 |
| 黑白名单 | 快速判断元素是否存在 |
15.5 Sorted Set / ZSet 有序集合
特性
- 元素唯一。
- 每个元素关联一个分数
score。 - 按分数排序。
- 支持范围查询。
常用命令
ZADD rank 100 user1
ZADD rank 200 user2
ZRANGE rank 0 9 WITHSCORES
ZREVRANGE rank 0 9 WITHSCORES
ZRANGEBYSCORE rank 100 200
使用场景
| 场景 | 说明 |
|---|---|
| 排行榜 | 积分榜、销量榜、热度榜 |
| 延时队列 | 使用时间戳作为 score |
| 优先级队列 | 使用优先级作为 score |
| 范围查询 | 按时间或分数范围检索 |
16. Redis 缓存穿透、击穿、雪崩如何解决?
16.1 缓存穿透
定义
缓存穿透指查询的数据在缓存和数据库中都不存在,导致请求每次都访问数据库。
请求 → 缓存不存在 → 数据库也不存在 → 下次请求继续打到数据库
解决方案
| 方案 | 说明 |
|---|---|
| 缓存空值 | 数据不存在时缓存 null,设置较短过期时间 |
| 接口层校验 | 校验非法参数,如 id <= 0 直接拦截 |
| 布隆过滤器 | 判断数据是否可能存在,不存在则直接拦截 |
示例
Object value = redis.get(key);
if (value != null) {
return value;
}
Object dbValue = database.query(key);
if (dbValue == null) {
redis.set(key, "null", 30);
return null;
}
redis.set(key, dbValue, 3600);
return dbValue;
16.2 缓存击穿
定义
缓存击穿指某个热点 Key 过期瞬间,大量并发请求同时访问数据库。
热点 Key 过期 → 大量请求同时进来 → 全部打到数据库
解决方案
| 方案 | 说明 |
|---|---|
| 热点 Key 永不过期 | 逻辑过期,由后台线程异步刷新 |
| 互斥锁 | 只允许一个线程查询数据库并回写缓存 |
| 提前续期 | 热点 Key 快过期时提前刷新 |
| 分布式锁 | 多实例环境下控制并发回源 |
互斥锁示例逻辑
1. 查询缓存
2. 缓存不存在,尝试获取锁
3. 获取锁成功,查询数据库并回写缓存
4. 获取锁失败,短暂 sleep 后重试缓存
16.3 缓存雪崩
定义
缓存雪崩指大量缓存 Key 在同一时间失效,导致请求集中访问数据库。
大量 Key 同时过期 → 请求全部访问数据库 → 数据库压力暴增
解决方案
| 方案 | 说明 |
|---|---|
| 过期时间加随机值 | 避免大量 Key 同时过期 |
| 热点数据永不过期 | 逻辑过期 + 异步刷新 |
| 加锁排队 | 控制并发回源 |
| 多级缓存 | 本地缓存 + Redis 缓存 |
| 限流降级 | 防止数据库被打崩 |
| Redis 高可用 | 哨兵、集群、防止 Redis 整体不可用 |
过期时间随机化示例
int baseExpireSeconds = 3600;
int randomSeconds = ThreadLocalRandom.current().nextInt(300);
redis.set(key, value, baseExpireSeconds + randomSeconds);
16.4 三者对比
| 问题 | 核心原因 | 典型场景 | 解决重点 |
|---|---|---|---|
| 缓存穿透 | 数据根本不存在 | 恶意请求不存在 ID | 拦截无效请求 |
| 缓存击穿 | 热点 Key 失效 | 秒杀商品、热点文章 | 防止并发回源 |
| 缓存雪崩 | 大量 Key 同时失效 | 批量缓存同时过期 | 打散过期时间、限流降级 |
17. 数据库与缓存一致性如何实现?
缓存与数据库双写时,核心问题是:
数据库和缓存可能在并发读写下出现不一致
17.1 常见错误方案
方案一:先写缓存,再写数据库
写缓存成功 → 写数据库失败
问题:
- 缓存中存在脏数据。
- 后续读取会读到错误数据。
因此不推荐。
方案二:先写数据库,再写缓存
写数据库成功 → 写缓存失败
问题:
- 数据库是新数据。
- 缓存还是旧数据。
- 并发写时还可能发生旧值覆盖新值。
因此也不推荐直接更新缓存。
17.2 推荐方案:先更新数据库,再删除缓存
写流程
1. 更新数据库
2. 删除缓存
读流程
1. 查询缓存
2. 缓存命中,直接返回
3. 缓存未命中,查询数据库
4. 将数据库结果写入缓存
5. 返回结果
优点
- 实现简单。
- 大多数业务能满足最终一致性。
- 避免并发更新缓存导致旧值覆盖新值。
17.3 为什么是删除缓存,而不是更新缓存?
因为更新缓存可能出现并发覆盖问题。
示例:
线程 A 更新数据库为 100
线程 B 更新数据库为 200
线程 B 先写缓存 200
线程 A 后写缓存 100
最终缓存变成旧数据 100。
删除缓存可以降低这个风险。
17.4 删除缓存失败怎么办?
方案一:重试删除
删除缓存失败后,进行有限次数重试。
删除失败 → 放入重试队列 → 异步重试删除
方案二:消息队列异步删除
更新数据库成功 → 发送 MQ 消息 → 消费者删除缓存
优点:
- 与业务解耦。
- 支持失败重试。
- 适合分布式系统。
方案三:监听 binlog 删除缓存
通过 Canal 等组件监听 MySQL binlog。
MySQL binlog → Canal → MQ → 缓存删除消费者 → 删除 Redis
优点:
- 对业务代码侵入较低。
- 能捕获所有数据库变更。
- 适合大型系统。
17.5 延迟双删
流程
1. 删除缓存
2. 更新数据库
3. sleep 一小段时间
4. 再次删除缓存
或更常见:
1. 更新数据库
2. 删除缓存
3. 延迟一段时间后再次删除缓存
作用
防止在更新数据库过程中,有并发读请求将旧数据重新写入缓存。
注意
延迟时间需要结合业务和数据库耗时评估,不能随意设置。
17.6 强一致方案:读写串行化
原理
将同一个数据的读写请求放入同一个队列串行执行。
请求 → 内存队列 → 单线程顺序执行 → 返回结果
优点
可以最大程度保证一致性。
缺点
- 吞吐量下降明显。
- 系统复杂度增加。
- 成本较高。
适用场景
- 金融交易
- 余额扣减
- 库存强一致
- 对一致性要求极高的业务
17.7 常见方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 先更新数据库,再删除缓存 | 最终一致 | 高 | 低 | 大多数业务 |
| 删除缓存失败重试 | 最终一致更可靠 | 较高 | 中 | 普通分布式系统 |
| MQ 异步删除缓存 | 最终一致 | 高 | 中 | 高并发业务 |
| 监听 binlog 删除缓存 | 最终一致 | 高 | 高 | 大型系统 |
| 延迟双删 | 最终一致 | 中 | 中 | 并发读写较多 |
| 读写串行化 | 强一致 | 低 | 高 | 金融、库存、余额 |
17.8 推荐落地方案
大多数 Java 后端业务推荐:
写操作:更新数据库 → 删除 Redis 缓存
读操作:读 Redis → 未命中读 MySQL → 回写 Redis
异常处理:删除失败 → MQ / 重试队列补偿
最终架构
┌──────────────┐
│ 用户请求 │
└──────┬───────┘
│
┌──────────▼──────────┐
│ Java 服务层 │
└──────┬────────┬─────┘
│ │
读请求 │ │ 写请求
│ │
┌────────────▼───┐ │
│ 查询 Redis │ │
└──────┬─────────┘ │
│ │
命中 │ 未命中 │
│ │
┌──────▼──────┐ │
│ 返回缓存值 │ │
└─────────────┘ │
│
┌───────▼───────┐
│ 更新 MySQL │
└───────┬───────┘
│
┌───────▼───────┐
│ 删除 Redis │
└───────┬───────┘
│ 删除失败
┌───────▼───────┐
│ MQ / 重试补偿 │
└───────────────┘
五、面试简短回答模板
1. ConcurrentHashMap 如何保证线程安全?
JDK 1.7 使用 Segment 分段锁机制,每个 Segment 独立加锁,降低锁竞争;JDK 1.8 取消 Segment,采用 CAS + synchronized,初始化、扩容等操作优先使用 CAS,插入冲突时只锁链表头节点或红黑树根节点,锁粒度更细,并结合 volatile 保证可见性。
2. 为什么不推荐 Executors 创建线程池?
因为 Executors 部分线程池存在资源不可控风险。newFixedThreadPool 和 newSingleThreadExecutor 使用无界队列,任务堆积可能导致 OOM;newCachedThreadPool 最大线程数接近无限,可能创建大量线程导致系统资源耗尽。所以生产环境推荐使用 ThreadPoolExecutor 显式指定核心线程数、最大线程数、队列容量和拒绝策略。
3. synchronized 和 ReentrantLock 区别?
synchronized 是 JVM 层面的关键字,自动加锁和释放锁,使用简单;ReentrantLock 是 JDK 提供的 API,需要手动 lock() 和 unlock(),但功能更灵活,支持公平锁、可中断锁、超时获取锁和多个 Condition 条件队列。简单同步场景优先使用 synchronized,复杂并发控制场景可以使用 ReentrantLock。
4. MyISAM 和 InnoDB 区别?
MyISAM 不支持事务和行锁,只支持表锁,适合读多写少场景;InnoDB 支持事务、行锁、MVCC、外键和崩溃恢复,适合高并发和强一致性业务。现代 MySQL 生产系统一般优先使用 InnoDB。
5. 最左匹配原则是什么?
联合索引按照字段顺序构建 B+ 树,例如 (a,b,c) 会先按 a 排序,a 相同再按 b 排序,最后按 c 排序。因此查询必须从最左列开始连续匹配,不能跳过中间列;如果中间出现范围查询,后续列通常无法继续使用索引。
6. Redis 缓存穿透、击穿、雪崩区别?
缓存穿透是查询不存在的数据,缓存和数据库都没有,解决方式是缓存空值、参数校验、布隆过滤器。缓存击穿是热点 Key 过期瞬间大量请求打到数据库,解决方式是互斥锁、逻辑过期、热点 Key 永不过期。缓存雪崩是大量 Key 同时失效,解决方式是过期时间随机化、多级缓存、限流降级和 Redis 高可用。
7. 数据库与缓存一致性如何保证?
大多数业务推荐使用“先更新数据库,再删除缓存”的方案。读请求先查缓存,未命中再查数据库并回写缓存;写请求先更新数据库,成功后删除缓存。如果删除缓存失败,可通过 MQ、重试队列或监听 binlog 进行补偿。对于强一致场景,可以使用读写串行化,但性能损耗较大。
六、总结
本文档主要整理了 Java 后端面试中高频的集合、并发、MySQL、Redis 与缓存一致性问题。
核心记忆点:
Java 集合:HashMap、TreeMap、LinkedHashMap、Hashtable、ConcurrentHashMap
Java 并发:线程创建、线程池、线程安全、锁升级、ReentrantLock
MySQL:InnoDB、聚集索引、联合索引、最左匹配、索引失效、EXPLAIN
Redis:String、Hash、List、Set、ZSet、缓存穿透、击穿、雪崩
缓存一致性:更新数据库后删除缓存,失败通过 MQ / binlog 补偿