java后端面试题整理

4 阅读31分钟

本文档由原始文本排版整理而成,适合用于 Java后端面试复习
内容覆盖:Java 集合、Java 并发、MySQL 索引优化、Redis 数据结构、缓存异常与缓存一致性。


目录


一、Java 集合

1. Map 有哪些实现类?

Java 中 Map 接口的主要实现类如下:

实现类底层结构 / 特点是否有序线程安全是否允许 null典型场景
HashMap基于哈希表实现,查询效率高,平均时间复杂度 O(1)无序允许 keyvaluenull普通键值缓存、业务对象映射
TreeMap基于红黑树实现,支持自然排序或自定义排序有序不允许 keynull需要按 key 排序的场景
LinkedHashMap继承 HashMap,通过双向链表维护插入顺序或访问顺序有序允许LRU 缓存、按插入顺序遍历
Hashtable古老线程安全实现,方法使用 synchronized 修饰无序不允许 keyvaluenull旧项目兼容,不推荐新项目使用
ConcurrentHashMap并发安全 Map,JDK 1.8 使用 CAS + synchronized无序不允许 keyvaluenull高并发读写场景

2. ConcurrentHashMap 如何实现线程安全?

ConcurrentHashMap 的线程安全实现机制在 JDK 1.7JDK 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();
        }
    }
}

Locksynchronized 更灵活,支持:

  • 可中断锁
  • 超时获取锁
  • 公平锁
  • 多条件队列

5.2.3 使用 volatile

private volatile boolean running = true;

volatile 可以保证:

  • 可见性
  • 一定程度的有序性

但它不能保证复合操作的原子性,例如:

count++;

即使 countvolatile,该操作也不是线程安全的。


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 使用线程安全容器

例如:

  • ConcurrentHashMap
  • CopyOnWriteArrayList
  • BlockingQueue
  • ConcurrentLinkedQueue

5.2.6 使用不可变对象

例如:

  • String
  • Integer
  • 自定义 final 不可变类

不可变对象天然线程安全。


6. synchronized 的锁升级机制是怎样的?

synchronized 锁升级是一个单向过程,随着锁竞争加剧,锁会逐步升级。

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

6.1 偏向锁

适用场景

只有一个线程反复获取同一把锁。

原理

当锁对象首次被线程访问时,JVM 会在对象头的 Mark Word 中记录当前线程 ID。

后续该线程再次获取锁时,只需要判断线程 ID 是否一致,不需要额外同步操作。

优点

无竞争场景下性能非常高。


6.2 轻量级锁

适用场景

多个线程交替竞争锁,但竞争不激烈。

原理

当有其他线程尝试获取已经偏向的锁时,偏向锁会升级为轻量级锁。

线程会通过自旋尝试获取锁,避免直接阻塞。

优点

短时间竞争场景下,可以减少用户态和内核态切换。


6.3 重量级锁

适用场景

竞争激烈或锁持有时间较长。

原理

当自旋多次仍然无法获取锁时,轻量级锁会升级为重量级锁。

重量级锁依赖操作系统互斥量实现,未获取锁的线程会进入阻塞状态。

缺点

涉及线程阻塞与唤醒,性能开销较大。


6.4 锁升级目的

锁升级机制是为了让 synchronized 在不同竞争程度下选择合适的锁策略:

阶段适用场景目标
偏向锁无竞争减少加锁成本
轻量级锁低竞争自旋避免阻塞
重量级锁高竞争保证线程安全

7. synchronized 与 ReentrantLock 的区别

7.1 本质区别

对比项synchronizedReentrantLock
实现层面JVM 关键字JDK API 类
所属包Java 语言内置java.util.concurrent.locks
加锁方式隐式加锁显式调用 lock()
释放方式自动释放必须手动 unlock()

7.2 相同点

二者都是 可重入锁

可重入锁指:同一个线程已经获取某把锁后,可以再次获取这把锁,不会被自己阻塞。

示例:

public synchronized void methodA() {
    methodB();
}

public synchronized void methodB() {
    // 同一个线程可以再次进入
}

7.3 主要区别

维度synchronizedReentrantLock
锁释放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 的区别

MyISAMInnoDB 是 MySQL 中两种常见存储引擎。

对比项MyISAMInnoDB
事务不支持事务支持 ACID 事务
锁机制表级锁行级锁、表级锁
并发能力较弱较强
MVCC不支持支持
外键不支持支持
全文索引早期原生支持MySQL 5.6 后支持全文索引
COUNT(*)存储总行数,速度较快不存储总行数,需要统计
文件结构.frm.MYD.MYI表空间文件,如 .ibd
索引结构非聚集索引聚集索引
崩溃恢复较弱支持崩溃恢复

8.1 核心区别总结

MyISAM

适合:

  • 读多写少
  • 不需要事务
  • 对并发要求不高
  • 旧系统兼容

InnoDB

适合:

  • 大多数现代业务系统
  • 需要事务
  • 高并发读写
  • 需要数据一致性和崩溃恢复

实际生产中,绝大多数业务表优先选择 InnoDB


9. 非聚集索引与聚集索引的区别

9.1 核心区别

对比项非聚集索引聚集索引
数据存储方式索引和数据分开存储索引叶子节点存储完整数据
叶子节点内容数据文件地址指针整行数据
典型引擎MyISAMInnoDB
查询过程先查索引,再根据指针查数据主键查询可直接拿到数据
辅助索引存储数据地址存储主键值
是否可能回表可能辅助索引查询通常需要回表

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,跳过 bc 无法继续使用
WHERE b = 2 AND c = 3未使用最左列 a
WHERE a = 1 AND b > 2 AND c = 3部分使用ab 可用,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 隐式类型转换

如果 phonevarchar 类型:

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;

通常只能使用到 ab


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

重点关注:

  • type
  • key
  • rows
  • Extra

如果出现:

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 BY
  • DISTINCT
  • 复杂排序

优化方向:

  • 给分组字段建立索引
  • 调整 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 部分线程池存在资源不可控风险。newFixedThreadPoolnewSingleThreadExecutor 使用无界队列,任务堆积可能导致 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 补偿