Java 八股

常见 Java 面试八股知识总结(JVM、并发、集合、Spring 等)

Java 后端开发八股

基础语法

基本类型

8 种基本类型,分为四类

类型类别数据类型占用字节默认值说明
整数byte10最小整数类型
整数short20短整型
整数int40常用整数类型
整数long80L长整数类型
浮点float40.0f单精度浮点
浮点double80.0d双精度浮点(默认)
字符char2‘\u0000’Unicode 字符
布尔boolean1(JVM 依实现)false逻辑值 true/false

Object 类有哪些方法?

共 1 1 个,去掉重载一共有 9 种方法

方法返回类型作用
getClass()Class获取运行时类信息
hashCode()int返回对象哈希值
equals(Object obj)boolean判断对象是否相等
toString()String返回对象字符串表示
clone()Object克隆对象(需实现 Cloneable)
finalize()voidGC 回收前调用(已过时)
wait()void线程等待(需在同步块中)
wait(long timeout)void带超时等待
wait(long timeout, int nanos)void更精细等待
notify()void唤醒一个等待线程
notifyAll()void唤醒所有等待线程

String s = new String("hello");会创建几个对象?

1 个或 2 个对象

分情况讨论:

  1. 常量池没有 “hello”: 创建 2 个对象:常量池 “hello” 和 堆中 new String(“hello”)

  2. 常量池已有 “hello”: 创建 1 个对象:堆中 new String(“hello”)

创建对象实例的方式有几种?

4 种

方式示例说明
1. new 关键字User u = new User();最常用,直接调用构造方法
2. 反射机制User u = (User) Class.forName(“User”).newInstance();运行时创建对象
3. Clone 克隆User u2 = (User) u1.clone();通过对象复制创建(浅拷贝)
4. 反序列化User u = (User) ObjectInputStream.readObject();从文件/网络恢复对象

为什么 equals() 和 hashcode() 要同时重写?

JDK 规范中,如果两个对象 equals() 返回 true(逻辑相等),那么它们的 hashCode 必须相同;反之不成立,因为可能发生哈希冲突。

在 HashSet / HashMap 中,hashCode 决定“存放位置”,equals 决定“是否重复”,如果只重写一个,会导致逻辑不一致,出现重复数据或查找失败。

== 和 equals() 的区别

== 比较的是引用地址(或基本类型值),equals() 在 Object 类中默认比较地址,但在包装类中可以重写为比较内容,因此一般用于对象逻辑相等判断。

抽象类和接口的区别

对比项抽象类(abstract class)接口(interface)
是否可以实例化❌ 不可以❌ 不可以
方法可以有抽象方法 + 具体方法抽象方法 + default/static 方法(JDK8+)
变量可以有普通成员变量只能是常量(public static final)
构造方法✔ 有❌ 没有
继承方式单继承多实现
访问修饰符可以任意方法默认 public

为什么在 Java 8 之后接口中可以有 default/static 方法呢?

Java 8 之后接口引入 default 和 static 方法,是为了在不破坏已有实现类的情况下扩展接口能力,同时支持函数式编程和工具方法集中管理。default 方法属于给实现类用的或重写的“默认行为”,static 方法属于属于接口本身的工具方法。

类是什么?

类(Class)是面向对象编程(OOP)中用来描述对象的结构和行为的基本构造单元。

接口是什么?

接口(Interface)是面向对象编程(OOP)中用于定义行为规范的一种抽象机制。

抽象类是什么?

抽象类(Abstract Class)是面向对象编程(OOP)中用于描述“部分抽象对象”的一种类类型。

谈谈你对面向对象的理解

与面向过程相比,面向对象是一种以“对象”为中心来组织软件设计的编程思想,将数据与行为封装在一起,通过封装、继承和多态来模拟现实世界,从而降低系统复杂度并提升可扩展性。

OOP 的核心是什么?

一切皆“对象”

现实世界 → 抽象 → 对象 → 程序模型 ==> 人、车、订单、商品 → 都可以抽象成对象

OOP 三大核心特性

  1. 封装(Encapsulation):把数据和行为封装在一起,对外隐藏内部实现
  2. 继承(Inheritance):子类复用父类的属性和方法,实现代码复用
  3. 多态(Polymorphism):同一行为,不同实现,子类可以重写父类方法

StringBuilder 和 StringBuffer 的区别

StringBuffer 线程安全(方法使用 synchronized 修饰),StringBuilder 非线程安全

Error 和 Exception 的区别

Error 是 JVM 层面的严重错误,通常表示系统级问题,程序一般无法处理;Exception 是程序运行过程中可能出现的异常情况,程序可以捕获并处理。

Exception 又分为:

  1. 受检异常(Checked Exception):编译期必须处理(try-catch 或 throws),否则无法通过编译
  2. 运行时异常(RuntimeException):编译期不强制处理,运行时抛出,通常由程序逻辑错误引起,但也可以捕获处理

对象的组成

由三大部分组成, 对象头(Object Header),实例数据(Instance Data),对齐填充(Padding)

对象头
  1. Mark Word (8 个字节)

    markword

  2. Klass Pointer ( 8 个字节,如果开启了指针压缩(默认)是 4 个字节)

  3. 数组长度(length,如果是数组对象)

实例数据

class User { int id; // 4 字节 long age; // 8 字节 Object obj; // 4 字节(引用) }

对齐填充

保证对象总大小是 8 字节的整数倍, 因为 JVM 内存分配是按 8 字节对齐的

在 64 位 JVM(开启指针压缩)下,User 对象包含

  1. 12 字节对象头(Mark Word + Klass Pointer)

  2. 16 字节实例数据(long 8B + int 4B + obj 引用 4B,JVM 会重排对齐)

  3. 对齐填充 4 字节

最终大小为 32 字节

反射的原理

在运行时通过 Class 元信息 + 方法表 + 常量池解析,动态调用方法或访问字段的一套机制。

多线程

进程和线程的区别

对比维度进程线程
定义程序的一次执行实例,是资源分配的基本单位进程中的执行单元,是 CPU 调度的基本单位
资源拥有独立的内存空间和系统资源共享所属进程的内存和资源
调度由操作系统进行调度由 CPU 进行调度
开销创建和切换开销大创建和切换开销小
独立性进程之间相互独立线程之间共享资源
稳定性一个进程崩溃一般不影响其他进程一个线程崩溃可能导致整个进程崩溃
通信方式进程间通信(IPC),较复杂线程间可直接共享内存,通信简单

线程的分类

  1. 用户线程(User Thread)由用户程序创建和管理,JVM 主要执行的线程

  2. 守护线程(Daemon Thread)为用户线程提供服务的后台线程,如 GC 线程

线程的创建方式

  1. 继承 Thread 类,重写 run() 方法
  2. 实现 Runnable 接口,重写 run() 方法
  3. 实现 Callable 接口 + FutureTask,重写 call() 方法(可返回结果)
  4. 通过线程池创建线程(ExecutorService)

线程的生命周期

通过java.lang.Thread.State枚举定义的

thread-life

  • 新建 -> 可运行:调用 Thread 对象的 start()方法。
  • 可运行 -> 运行:被系统选中,获取 CPU 执行权。
  • 运行 -> 阻塞:请求获取无法马上获取的资源。
  • 运行 -> 等待:调用 Object 类的 wait()方法。
  • 运行 -> 超时等待:调用 Thread.sleep(long)或 Object.wait(long)方法,或者期待 join(long)的结束,或者锁定某个对象的 LockSupport.parkNanos()或 LockSupport.parkUntil()方法。
  • 运行 -> 终止:线程终止。

start()run()方法有什么区别

  1. start() 是启动一个新线程,JVM 会为该线程分配独立的执行栈并调用 run() 方法
  2. run() 只是普通方法调用,不会创建线程,直接在当前线程中执行。

多线程安全问题和产生原因

  1. 存在多个线程同时操作共享资源
  2. 对共享资源的操作不是原子的
  3. 线程执行由于 CPU 调度具有不确定性

解决方案:加锁(synchronized / Lock)保证同一时刻只有一个线程访问临界区

Synchronized 的底层原理

称呼层面

  1. 隐式锁:由 JVM 自动加锁与释放,无需手动控制
  2. 悲观锁:默认认为会发生竞争,访问前先加锁
  3. 非公平锁:不保证先来先得,线程获取锁具有随机性
  4. 可重入锁:同一线程可以重复获取同一把锁

状态层面

  1. 无锁(001): 对象初始状态,没有线程竞争
  2. 偏向锁(101):无竞争时偏向第一个线程,减少 CAS 开销 (在 JDK 16 被彻底移除)
  3. 轻量级锁(00):有少量竞争,通过 CAS 自旋获取锁
  4. 重量级锁(10):竞争激烈时升级为 Monitor 阻塞队列

语法层面

  1. 作用于代码块(编译后)

monitorenter:进入同步块获取 Monitor

monitorexit:退出同步块释放 Monitor

  1. 作用于方法(编译后)

方法的方法表中有 flags: ACC_SYNCHRONIZED

JVM 层面

  1. synchronized 底层依赖 Mark Word 与 Monitor 实现
  2. 对象头的 Mark Word 中存储锁状态标志

​ 倒数 2 位:锁标志位(lock flag)

​ 第 3 位:偏向锁标志位(biased_lock)

  1. 在多线程竞争下,锁会发生升级(锁膨胀)以优化性能

​ 先尝试偏向锁 (MarkWord 标记线程 ID)

​ 有竞争时升级为轻量级锁(CAS+自旋+线程栈中 Lock Record)

​ 多线程竞争时升级为重量级锁(ObjectMonitor(操作系统互斥量),线程进入阻塞状态(BLOCKED))

image-20260418184939926

ThreadLocalMap 的底层原理

对象的四种引用方式

  1. 强引用:永不回收

  2. 软引用:内存不够才回收

  3. 弱引用:发现即回收

  4. 虚引用:不影响对象生命周期,用于回收通知

ThreadLocalMap 结构

Entry: key → WeakReference value → 强引用

ThreadLocal 的 key 为什么是弱引用

  1. 如果 key 是强引用,Thread → ThreadLocalMap → Entry → ThreadLocal,即使 tl = null,ThreadLocal 仍然被 Map 强引用, 永远无法回收 → 内存泄漏
  2. 用弱引用之后 WeakReference(ThreadLocal),下一次 GC:key → null, ThreadLocal 对象可以被回收。

ThreadLocal 的 value 为什么是强引用

  1. ThreadLocal 的 value 使用强引用,是因为它存储的是线程实际使用的数据,必须在使用期间保持稳定,不能被 GC 随意回收。
  2. 因此设计上采用 key 使用弱引用避免 ThreadLocal 泄漏,而 value 使用强引用保证数据的正确性,并通过惰性清理机制处理 key 为 null 时的内存泄漏问题。

ThreadLocal 的设计本质是一种基于 GC 约束的工程权衡。它通过弱引用 key 来解决 ThreadLocal 被 ThreadLocalMap 强引用导致的无意识内存泄漏问题,使 ThreadLocal 对象在外部失去引用后可以正常被 GC 回收。但 value 仍然使用强引用,以保证线程在执行期间数据的稳定性,避免业务数据被 GC 意外回收。这种设计的副作用是 key 被回收后 value 仍然残留在线程中,因此 ThreadLocal 将清理责任部分转移给开发者,通过在合适时机显式调用 remove() 来避免 value 泄漏。

Java 集合框架

Java 集合主要分为三大类接口体系:

  1. Collection(单列集合):用于存储 单个元素的集合
    • List(有序、可重复):ArrayList,LinkedList
    • Set(无序、不可重复):HashSet,LinkedHashSet
    • Queue(先进先出):PriorityQueue
  2. Map(双列集合):用于存储键值对(key-value)结构
    • HashMap
    • LinkedHashMap

ArrayList 扩容机制和底层原理

  1. ArrayList 底层基于 Object[] 动态数组实现,默认是空数组。
  2. 第一次添加元素时才初始化容量,一般会变为 10。
  3. 当数组容量不足时(即 size 超过当前数组长度),会触发扩容机制,扩容规则是原容量的 1.5 倍。扩容过程包括创建一个更大的新数组,将旧数组中的元素通过数组拷贝方式(System.arraycopy)复制到新数组中,最后用新数组替换旧数组引用。
  4. 由于底层是数组结构,所以 ArrayList 查询效率较高,但插入和删除需要移动元素,性能较低,同时扩容时会有额外的拷贝开销。

ArrayList 和 LinkedList 的区别

  1. ArrayList 底层是基于动态数组实现的,支持通过下标快速访问元素,因此查询效率为 O(1),但在插入和删除时需要移动元素,效率较低。
  2. LinkedList 底层是双向链表结构,查询需要从头遍历,因此效率为 O(n),但在插入和删除时只需要修改节点指针,因此效率较高。
  3. 同时 ArrayList 内存占用较小,而 LinkedList 因为每个节点需要保存前后指针,内存开销更大。总体来说 ArrayList 适合查询多的场景,LinkedList 适合插入删除多的场景。

ArrayList 线程不安全体现和解决方案

  1. ArrayList 线程不安全的原因在于其 add 操作中的 size++ 不是原子操作,多线程同时修改会导致数据丢失或覆盖,同时扩容过程中也可能发生并发冲突。
  2. 此外,在遍历过程中如果发生结构修改,还会触发 fail-fast 机制,抛出 ConcurrentModificationException。解决方案包括使用 Vector(方法级加锁,性能较差)、Collections.synchronizedList(对 ArrayList 进行同步包装,但迭代需手动加锁),以及 CopyOnWriteArrayList(写时复制,读不加锁,适合读多写少场景,是推荐方案)。
  3. ArrayList 有自己的内部迭代器实现(ListIterator),属于 fail-fast 机制。在使用普通迭代器(Iterator)遍历集合时,如果对集合结构进行修改(如 add 或 remove),会导致 modCount 和 expectedModCount 不一致,从而触发 ConcurrentModificationException。

CopyOnWriteArrayList 底层实现和原理

  1. CopyOnWriteArrayList 底层只有一个核心字段:volatile Object[] array
  2. 核心思想: 写时复制(Copy On Write),读:直接用旧数组(不加锁), 写:复制新数组,在新数组上修改,再替换引用。
  3. 写操作加锁,在 JDK 6 以前用的 ReentrantLock,JDK 7 及以后改为 synchronized,写操作很简单,不需要 ReentrantLock 的复杂能力,synchronized 已经优化得很好了,性能已经不差 ReentrantLock。

HashMap 底层原理和实现

HashMap 的底层结构是 数组 + 链表 + 红黑树

扩容机制

  1. HashMap 的扩容本质就是 数组扩容 + 重新分布(rehash)
  2. 默认初始容量是 16,负载因子是 0.75,当元素数量超过 容量 × 负载因子 时就会触发扩容。扩容后容量变为原来的 2 倍,然后把原来所有元素重新计算位置(Java 8 之后做了优化,不是完全重新 hash,而是根据原 index + oldCap 判断去留,减少计算成本)。

两个参数

  • 初始容量(capacity):默认 16,决定桶数组大小

    📝 备注

    HashMap 的初始容量默认是 16,但如果初始化时传入的不是 2 的幂,底层会通过 tableSizeFor() 方法调整为大于等于该值的最小 2 的幂。这样做的目的是为了让 (n - 1) & hash 的位运算更高效且分布更均匀。

  • 负载因子(load factor):默认 0.75,决定扩容阈值

    📝 备注

    0.75 是在空间利用率和查询/插入性能之间的经验折中值。负载因子越大,HashMap 扩容越晚,桶内冲突概率越高(链表或红黑树变长),从而可能降低 get()put() 的性能;但同时可以减少扩容次数,降低 rehash 的开销。

    扩容阈值 = capacity × loadFactor

两个算法

  1. 计算元素脚标的算法:数组长度 -1 与 hashcode 值做与运算 (index) = (n - 1) & hash
  2. 计算 hash 值的算法:Key 的 hashcode 值无符号右移十六位做异或运算 hash = h ^ (h >>> 16)

成员方法

  • put():插入 key-value
  • get():根据 key 查 value
  • remove():删除元素
  • resize():扩容方法
  • hash():计算 key 的 hash 值(扰动函数,减少冲突)

HashMap 和 HashTable 的区别

  1. HashMap 是线程不安全的,多线程环境下同时 put 可能导致数据覆盖、死循环(早期扩容链表反转问题)。 Hashtable 是线程安全的,它的方法基本都加了 synchronized 锁,但代价是性能较低。现在一般不推荐使用 Hashtable,多线程场景通常用 ConcurrentHashMap
  2. HashMap 允许一个 null key 和多个 null value,因为它对 null key 做了特殊处理,不走 hash 计算,而是固定放在 0 号桶。 Hashtable 不允许任何 null key 或 null value,因为它在调用 hash 方法时直接做了 null 检查,避免空指针异常。

HashMap 中 put 方法的执行流程

  1. 对 key 进行 hash 计算
  2. 根据 hash 定位数组下标
  3. 如果该位置为空 → 直接插入
  4. 如果不为空 → 发生冲突:
    • key 相同 → 直接覆盖 value
    • key 不同 → 挂到链表后面
  5. 链表长度 ≥ 8 且数组长度 ≥ 64 → 转红黑树
  6. 插入完成后判断是否需要扩容

HashMap 线程不安全的体现和解决方案

体现

  1. 并发 put 导致数据覆盖
  • 两个线程同时操作同一个桶
  1. 扩容时结构可能异常(JDK7 更严重)
  • 多线程 resize 可能导致链表形成环(死循环)
  1. size 统计不准确
  • 多线程同时修改 size 导致计数错误

解决方案

  1. 使用 ConcurrentHashMap(推荐)
  • JDK8:CAS + synchronized(锁粒度更细)
  • 高并发性能好
  1. 使用 Collections.synchronizedMap()
  • 给整个 Map 加一把大锁
  • 简单但性能差

ConcurrentHashMap 底层实现和原理

  1. put() 桶空 CAS + 桶非空 Synchronized
  2. JDK 7 分段锁(segment),JDK 8 锁链表头节点 + 红黑树树根节点

ConcurrentHashMap 是如何保证线程安全的?

  1. JDK 7:底层通过 Segment,继承自 ReentrantLock,初始化时有 16 个 Segment,每个 Segment 内部维护 HashEntry 数组(初始容量一般为 2),整体并不是只能存 32 个元素,而是每个 Segment 都可以扩容并存储大量元素,16 个 Segment 只是默认并发度配置。并发控制通过对 Segment 加锁实现,但锁的粒度较大(Segment 级别),在高并发下容易产生竞争,从而影响性能。
  2. JDK 8:底层采用数组+链表+红黑树结构,通过 CAS + Synchronized 保证线程安全。空桶情况下使用 CAS 进行无锁插入,发生哈希冲突时对桶进行加锁,锁的对象是链表的头节点或红黑树对应的 TreeBin 节点,锁粒度从 Segment 级别细化为桶级别,从而提升并发性能。

ConcurrentHashMap 是为何不允许键值为 null?

  1. 无法区分“key 不存在”和“value 为 null”:

    在 ConcurrentHashMap 中,get(key) 返回 null 在语义上是模糊的,它既可能表示 key 不存在,也可能表示 key 存在但 value 为 null。在单线程结构里可以通过额外判断来区分,但在并发环境下,这种状态可能在不同线程之间瞬间变化,导致判断结果不可靠,从而引发语义混乱。

  2. 并发操作下 null 无法保证一致性

    ConcurrentHashMap 的 put、remove、resize 都是并发执行的,如果允许 value 为 null,那么一个线程写入 null 的过程中,另一个线程可能同时读取或修改该桶状态,导致无法判断当前看到的 null 是“真实值”、“未完成写入”还是“被删除后的状态”,从而破坏并发读写的一致性语义。

  3. 简化底层实现(CAS + volatile + 红黑树结构)

    ConcurrentHashMap 的核心机制依赖 CAS、volatile 和桶级锁来实现高并发,如果允许 null,会导致 CAS 无法区分“空桶”和“值为 null”的状态,volatile 读到 null 时也无法判断语义,红黑树与链表结构中还需要额外处理 null 节点情况,因此设计上直接禁止 null,从而简化并发控制逻辑并避免边界复杂性。

JVM

User.java →(编译)→ User.class → 类加载子系统(加载,验证,准备,解析,初始化) → 运行时数据区子系统(程序计数器,方法区,Java 堆,虚拟机栈,本地方法栈) ← 执行引擎子系统(方法启动执行和垃圾回收)

类加载器子系统

负责把 .class 文件加载进 JVM。

流程包括:

  • 加载(Loading):读取 class 文件,生成 Class 对象
  • 验证(Verification):检查字节码是否合法、安全
  • 准备(Preparation):为 static 变量分配内存并赋默认值
  • 解析(Resolution):符号引用转为直接引用
  • 初始化(Initialization):执行 <clinit>,初始化 static 变量

双亲委派

流程

当一个类加载器收到类加载请求时,不会自己先加载,而是先委托给父加载器处理,逐层向上递归,直到启动类加载器(Bootstrap ClassLoader)。如果父加载器无法完成加载,子加载器才会尝试自己加载该类。

好处
  1. 避免类的重复加载 同一个类不会被不同类加载器重复加载,保证 JVM 中 Class 的唯一性(同一个类在不同加载器下会被认为是不同类)。

  2. 保证核心类安全性 防止用户自定义类覆盖 Java 核心类,例如 java.lang.String,因为核心类优先由 Bootstrap ClassLoader 加载。

  3. 保证类加载的层次结构稳定 加载顺序具有严格的父子关系,结构清晰,避免混乱的类依赖加载问题。

为什么 tomcat 和 jdbc 打破了双亲委派?

tomcat:

  1. Tomcat 是 Web 容器,一个核心需求是:不同 Web 应用之间必须隔离,且允许同名类版本不同,如果遵循双亲委派会导致所有类都会先交给父加载器(AppClassLoader / ExtClassLoader),所有应用共享同一份类 -> 无法隔离版本

  2. Tomcat 的解决方案:自定义类加载器 + “反向委派”, Tomcat 设计了 WebAppClassLoader:加载顺序变成先自己找 → 找不到再委派父类

  3. 目的: 实现 Web 应用隔离(不同 war 包互不影响),支持同名类不同版本共存, 避免全局污染(尤其是 lib 冲突)。

jdbc:

  1. JDBC 的核心问题是:接口在 JDK 中,但实现类在第三方驱动中。如果严格双亲委派:DriverManager 在 Bootstrap ClassLoader,MySQL Driver 在 AppClassLoader,Bootstrap 不认识 AppClassLoader 加载的类。
  2. 解决方式:SPI(服务发现机制),JDBC 使用:ServiceLoader + Thread Context ClassLoader, DriverManager 在启动时不会自己加载具体 Driver, 而是通过:Thread.currentThread().getContextClassLoader()去加载第三方驱动。
  3. 本质:JDBC 并不是直接破坏双亲委派,而是绕过默认委派路径,改用线程上下文类加载器主动加载 SPI 实现类,从而实现“接口在上层,实现在下层”的解耦结构。

运行时数据区(Runtime Data Area)

JVM 的内存结构,用来存放运行时数据:

  • 堆(Heap):存对象实例(new 出来的东西)
  • 方法区(Method Area / MetaSpace):类信息、常量池、static 变量
  • 虚拟机栈(Stack):方法调用栈帧(局部变量、方法调用)
  • 程序计数器(PC Register):记录当前线程执行到哪一行
  • 本地方法栈(Native Stack):JNI 调用

执行引擎子系统(Execution Engine)

负责真正“执行代码”。

包含:

  • 解释器(Interpreter):逐行解释执行 bytecode
  • JIT 编译器(Just-In-Time Compiler):热点代码编译成本地机器码
  • GC(垃圾回收器):回收堆内存对象

如何判断一个对象为垃圾对象?

  1. 没有任何“GC Roots”可达的对象,就是垃圾对象。

  2. GC Roots 常见来源:

    • 虚拟机栈中的局部变量引用(方法里的对象)
    • 方法区中的静态变量(static)
    • 方法区中的常量
    • JNI(Native 方法)引用
    • 正在运行的线程对象

内存泄漏和内存溢出有什么区别?

  1. 内存泄漏:对象不再使用,但仍然被引用,GC 回收不了,导致内存占用越来越高,最终可能导致 OOM。
    • static 集合一直存对象
    • ThreadLocal 没 remove
    • 缓存无限增长
  2. 内存溢出(OutOfMemoryError):JVM 申请不到更多内存了
    • 内存泄漏积累
    • 加载超大对象

垃圾回收算法有哪些?

  1. 标记清除:标记所有可达对象,清除未标记对象。

    • 优点:简单
    • 缺点:1. 内存碎片严重 2.清理效率不稳定
  2. 标记复制(新生代常用 s0 s1):内存分两块,一边用,一边空,存活对象复制到另一块。

    • 优点:没有碎片,分配快
    • 缺点:内存浪费一半
  3. 标记整理(老年代常用):标记存活对象, 向一端移动,清理边界外内存。

    • 优点:没有碎片,利用率高。
    • 缺点:移动对象成本高
  4. 分代收集

垃圾回收器有哪些?

  1. 串行垃圾回收器:
    • Serial 新生代 + Serial Old 老年代
  2. 并行垃圾回收器:
    • Parallel Scavenge 新生代 + Parallel Old 老年代 (JDK 8 默认)
  3. 并发垃圾回收器:
    • ParNew 新生代 + CMS 老年代
    • G1 (JDK 9 之后默认)
    • ZGC

G1 特点和实现:

  1. 堆被划分为多个 Region
  2. 不再严格分代
  3. 优先回收垃圾最多的区域
  4. 可预测停顿时间(比如 -XX:MaxGCPauseMillis)

卡表和记忆集,三色标记法,SATB(Snapshot At The Beginning 解决漏标问题)

ZGC 特点和实现:

  1. GC 停顿时间:通常 < 10ms
  2. 大部分工作都放到与用户线程并发执行, 只有极少阶段需要 STW
  3. 支持 TB 级堆内存

内存多重映射,染色指针技术,读屏障

JVM 常用参数

  1. -Xmx1024M 最大堆内存 -Xms1024M 初始化堆内存,正常和最大堆内存相同,减少动态改变的内存损 -Xmn384M 年轻代内存

  2. -XX:+PrintGCDetails 打印 gc 信息,可参考 gc 的比例进行调优 -XX:+UseConcMarkSweepGC 老年代使用 cms,标记-清除算法会产生碎片 -XX:+UseParNewGC 年轻代使用并行收集器 -XX:SurvivorRatio=6

JVM 调优

  1. 哪些情况会导致 Full GC
  • 晋升失败(Survivor 区对象无法进入老年代)
  • 大对象直接进入老年代(Eden 无法容纳)
  • 空间分配担保失败(Minor GC 前老年代预估空间不足)
  1. 项目中哪些情况会导致 OOM
  • 静态对象未释放,比如 ThreadLocal 未调用 remove
  • 资源未关闭:数据库连接、网络连接、文件流未使用 try-with-resources
  • 一次性加载大量数据:如一次查出百万数据到 List,或大文件全部加载到内存
  • 堆内存设置过小:-Xmx 配置不合理,无法支撑运行负载
  • 线程池或线程使用不合理:无限制创建线程或队列无界,导致线程数暴涨

Java Web

过滤器(Filter)和拦截器(Interceptor)的区别

  1. 执行时机

​ Filter 是 Servlet 规范提供的,作用于 Servlet 容器层面,在DispatcherServlet执行前就会执行

​ Interceptor 是 Spring 框架提供的,作用于 Spring MVC 层,在HandlerMapping匹配之后,Controller 执行前调用

  1. 实现方式

​ Filter 实现javax.servlet.Filter接口,依赖web.xml或注解进行注册

​ Interceptor 实现HandlerInterceptor接口,通过实现WebMvcConfigureraddInterceptors()方法注册

总结: 过滤器是 Servlet 规范定义的,依赖 Web 容器(如 Tomcat),在请求进入 Servlet 之前和响应发出之后工作,更底层; 拦截器是 Spring MVC 框架提供的,在请求进入 Controller 之前和视图渲染之后工作,更贴近业务

常用框架

谈谈你对 Spring 的理解

广义上

Spring 指的是 Spring 全家桶生态(Spring Ecosystem),不仅仅是一个框架,而是一整套企业级开发解决方案。

  1. Spring Framework(核心基础)
  2. Spring Boot(快速开发、自动配置)
  3. Spring MVC(Web 框架)
  4. Spring Data(数据访问统一封装)
  5. Spring Cloud(微服务治理)
  6. Spring Security(安全认证授权)

狭义上

Spring 指的是 Spring Framework 核心容器,主要解决的是对象如何创建、如何管理、如何协作的问题。

  1. IoC(控制反转):对象的创建和管理交给 Spring 容器,通过 DI(依赖注入) 完成对象装配,解耦对象之间的依赖关系。
  2. AOP(面向切面编程):在不修改业务代码的情况下增强功能,如日志、事务、权限、监控,解耦横切逻辑。
  3. Bean 生命周期管理:Bean 的创建 → 初始化 → 使用 → 销毁,统一由容器管理。
  4. 事件机制 & 扩展机制:支持事件发布监听模式(ApplicationEvent),提供强扩展能力(BeanPostProcessor)

谈谈你对 AOP 的理解

AOP(面向切面编程)是一种在不修改业务代码的情况下,对日志、事务、权限等横切逻辑进行统一增强的编程思想,本质是通过切面和切点定义增强范围,并借助动态代理将增强逻辑织入目标方法中,实现解耦与复用。

JDK 动态代理

基于 Java 反射机制实现,要求目标类必须实现接口。通过 InvocationHandler 对方法调用进行拦截,在方法执行前后织入增强逻辑。

CGLib 动态代理

基于继承实现代理,通过生成目标类的子类实现方法拦截,使用 MethodInterceptor 进行增强处理。

Spring AOP 默认优先使用 JDK 动态代理,只有在没有接口时才使用 CGLIB。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.evorsio;

import java.lang.reflect.Proxy;

/**
 * @author Evorsio
 * @since 2026/4/18
 */
public class JdkProxy {
    public interface Student {
        void study();
    }
    public static class Zhangsan implements Student {
        @Override
        public void study() {
            System.out.println("张三正在学习");
        }
    }
    public static void main(String[] args) {
        Zhangsan zhangsan = new Zhangsan();
        ClassLoader classLoader = zhangsan.getClass()
                .getClassLoader();
        Class<?>[] interfaces = zhangsan.getClass()
                .getInterfaces();

        Student proxy = (Student) Proxy.newProxyInstance(
                classLoader,
                interfaces,
                (p, method, args1) -> {
                    System.out.println("准备开始学习");

                    Object result = method.invoke(zhangsan, args1);

                    System.out.println("学习完成");
                    return result;
                }
        );

        proxy.study();
    }
}

控制台输出

1
2
3
4
5
6
C:\Users\Admin\.jdks\ms-17.0.18\bin\java.exe "-javaagent:D:\JetBrains\IntelliJ IDEA\lib\idea_rt.jar=60217" -Dfile.encoding=UTF-8 -classpath D:\JetBrainsProjects\IdeaProjects\java-demo\target\classes com.evorsio.JdkProxy
准备开始学习
张三正在学习
学习完成

Process finished with exit code 0

谈谈你对 IoC(控制反转)的理解

IoC(控制反转)是一种设计思想,将对象的创建和依赖关系的控制权交给容器管理,在 Spring 中通过依赖注入(DI)实现,从而实现对象之间的解耦和统一管理。

依赖注入方式

  1. 字段注入:(@AutowiredSpring 提供的按类型注入@ResourceJDK 提供的按名称优先注入。)

  2. 构造器注入:(推荐)

  3. Setter 方法注入

循环依赖问题和解决办法

1
2
3
4
5
6
7
8
9
class A {
    @Autowired
    private B b;
}

class B {
    @Autowired
    private A a;
}

三个缓存

Spring 通过三级缓存机制解决单例 Bean 的循环依赖:

  1. 一级缓存(singletonObjects = new ConcurrentHashMap(256)):已初始化完成的 Bean
  2. 二级缓存(earlySingletonObjects = new ConcurrentHashMap(16)):提前暴露的半成品 Bean 引用(可能是原始对象或代理对象)
  3. 三级缓存(singletonFactories = new HashMap(16)):用于生成早期 Bean 的工厂对象(支持 AOP 代理提前暴露)

流程

  1. Spring 创建 A 时,先从一级缓存 singletonObjects 获取 A,未命中,则标记 A 为“正在创建”,进入创建流程。
  2. A 实例化(构造方法执行)后,Spring 不会直接暴露对象,而是将 ObjectFactory(A) 放入三级缓存 singletonFactories,用于后续生成 A 的早期引用。
  3. A 执行 populateBean 进行属性填充时,发现依赖 B,触发 getBean(B),开始创建 B。
  4. B 创建流程与 A 一致:实例化后将 ObjectFactory(B) 放入三级缓存,并开始属性填充。
  5. B 在 populateBean 时发现依赖 A,此时触发获取 A:
  • 一级缓存无
  • 二级缓存无
  • 三级缓存存在 A 的 ObjectFactory Spring 调用 getObject() 得到 A 的早期引用(可能是代理对象),并放入二级缓存 earlySingletonObjects
  1. B 完成初始化后进入一级缓存;A 随后完成属性填充与初始化,最终进入一级缓存,同时清理二级、三级缓存中的 A,循环依赖解决完成。

四个方法

  1. getBean() -> doGetBean() -> getSingleton() 从缓存中获取 Bean 对象。
  2. createBean() -> doGetBean() -> createBeanInstance() 创建 Bean 对象。
  3. populateBean()初始化 Bean 对象
  4. addSingleton() 将 Bean 对象保存到缓存中

如果需要循环依赖,可以手动开启 spring.main.allow-circular-references=true

默认在 Spring Boot 2.6 之后是关闭的

Spring 常用注解

  1. IoC 相关注解: @Component @Autowired @Configuration @Bean @Value

  2. AOP 相关注解: @Transactional @Aspect @Before @After @AfterReturning @AfterThrowing @Around @Pointcut

Spring Bean 的作用域有哪些?

  1. singleton(单例,默认)
  2. prototype(原型):每次获取都会创建一个新对象,不由容器统一管理完整生命周期。
  3. request(Web 环境):每个 HTTP 请求创建一个 Bean,请求结束后销毁。
  4. session(Web 环境):每个 HTTP Session 创建一个 Bean,Session 结束后销毁。
  5. application:整个 Web 应用生命周期内只有一个 Bean,类似 ServletContext 级别。
  6. websocket:每个 WebSocket 会话对应一个 Bean。

Spring Bean 的生命周期有哪些?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.evorsio.springdemo;

/**
 * @author Evorsio
 * @since 2026/4/18
 */
public class User {
    private String username;

    @Override
    public String toString() {
        return "userA: 使用userA对象: User{" +
                "username='" + username + '\'' +
                '}';
    }

    public void setUsername(String username) {
        System.out.println("userA: 调用setter方法,对username属性进行赋值");
        this.username = username;
    }

    public User() {
        System.out.println("userA: 调用无参构造");
    }

    public User(String username) {
        System.out.println("userA: 调用有参构造");
        this.username = username;
    }

    public void init(){
        System.out.println("userA: 调用初始化方法,对userA对象进行初始化");
    }

    public void destroy(){
        System.out.println("userA: 调用销毁方法");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.evorsio.springdemo;

import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;

/**
 * @author Evorsio
 * @since 2026/4/18
 */
public class MyBeanPostProcessor implements BeanPostProcessor {
    @Override
    public @Nullable Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        System.out.println("myBeanPostProcessor: 对" + beanName+"对象初始化前执行的操作");
        return BeanPostProcessor.super.postProcessBeforeInitialization(bean, beanName);
    }

    @Override
    public @Nullable Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        System.out.println("myBeanPostProcessor: 对" + beanName+"对象初始化后执行的操作");
        return BeanPostProcessor.super.postProcessAfterInitialization(bean, beanName);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="userA" class="com.evorsio.springdemo.User" init-method="init" destroy-method="destroy">
        <property name="username" value="zhangsan"/>
    </bean>

    <bean id="myBeanPostProcessor" class="com.evorsio.springdemo.MyBeanPostProcessor"/>
</beans>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.evorsio.springdemo;

import org.springframework.context.support.ClassPathXmlApplicationContext;

public class SpringDemoApplication {
    public static void main(String[] args) {

        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring.xml");
        User userA = context.getBean("userA", User.class);
        System.out.println(userA);
        context.close();
    }
}

流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
C:\Users\Admin\.jdks\ms-17.0.18\bin\java.exe ...
com.evorsio.springdemo.SpringDemoApplication
1. userA: 调用无参构造
2. userA: 调用setter方法,对username属性进行赋值
3. myBeanPostProcessor: 对userA对象初始化前执行的操作
4. userA: 调用初始化方法,对userA对象进行初始化
5. myBeanPostProcessor: 对userA对象初始化后执行的操作
6. userA: 使用userA对象: User{username='zhangsan'}
7. userA: 调用销毁方法

Process finished with exit code 0

Spring Cloud

项目中使用了哪些 Spring Cloud 组件?

  1. Nacos 注册中心 + 配置中心
  2. OpenFeign 声明式远程调用
  3. Spring Cloud Gateway 全局网关
  4. Sentinel 限流熔断降级
  5. LoadBalancer 负载均衡
  6. Sleuth 链路追踪( Spring Boot 3 中被移除 ,改用 Micrometer Tracing + OpenTelemtry ) + Zipkin 进行链路可视化。

Open Feign 和 Feign 的区别

  1. Feign 是 Netflix 早期的声明式 HTTP 客户端,不支持 Spring MVC 注解,不再维护。
  2. OpenFeign 是 Spring Cloud 对 Feign 的增强版本,深度集成了 Spring 生态,支持 Spring MVC 注解、服务发现、负载均衡和熔断机制,是目前微服务中主流的远程调用方式,社区依然活跃。

实际开发过程中 OpenFeign 请求头丢失的问题

消费方通过 OpenFeign 调用生产方接口时,Feign 底层会重新发起一个新的 HTTP 请求,而不是在原请求基础上进行转发,原始请求中的 Header(如 Token、用户信息、TraceId 等)不会自动携带到新的 Feign 请求中,导致请求头信息丢失或不一致。

Spring 中的请求上下文是通过 RequestContextHolder(基于 ThreadLocal)存储的,在 Feign 调用过程中不会自动传播,因此需要通过 RequestInterceptor 手动从 RequestContextHolder 获取原始请求 Header 并进行透传。

基本用法:

  1. 导入依赖: spring-cloud-starter-alibaba-nacos-discovery

  2. 配置 Nacos 地址: spring.cloud.nacos.discovery.server-addr = localhost:8848

  3. 1
    2
    3
    4
    5
    
    @EnableFeignClients
    @SpringBootApplication
    public class OrderApplication {
        //...
    }
    
  4.  1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    @FeignClient(
            value = "stock-nacos",
            fallback = StockFeignFallback.class
    )
    public interface StockFeignClient {
    
        @GetMapping("/stock/reduce")
        String reduceStock();
    }
    
    @Component
    public class StockFeignFallback implements StockFeignClient {
    
        @Override
        public String reduceStock() {
            return "库存服务降级,当前不可用";
        }
    }
    

项目中哪些场景使用到了 Spring Cloud Gateway 的功能?

  1. 项目中将网关作为一个独立的功能进行部署,基于路径断言将请求通过负载均衡的方式进行分发。
  2. 全局的跨域问题处理(由于浏览器的同源策略导致),使用 spring.cloud.gateway.globalcors.cors-configurations.[/**].allowed-origins=*
  3. 通过实现网关的全局过滤进行身份验证
  4. 配置限流

组成:

  1. Route(路由): 定义请求转发规则
  2. Predicate(断言): 判断请求是否匹配(路径、Header、参数)
  3. Filter(过滤器): 请求前后处理逻辑(鉴权、日志、限流)

有了 Nginx 为什么还需要网关呢?

  1. Nginx 主要负责:反向代理,静态资源托管,负载均衡,SSL/TLS 等,只管“流量怎么走”,不关心“业务是什么”。
  2. Spring Cloud Gateway 负责:统一 API 入口,路由转发,JWT 鉴权 / 登录校验等,关注“业务请求如何被处理”,服务于微服务。

请求过来之后如何进行流量控制?

  1. 客户端削峰填谷: 前端限流(按钮置灰),请求缓存

  2. 网关层限流:RequestRateLimiter

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    spring:
      cloud:
        gateway:
          routes:
            - id: order-service
              uri: lb://order-service
              predicates:
                - Path=/order/**
    
              filters:
                # =========================
                # 限流 RequestRateLimiter
                # =========================
                - name: RequestRateLimiter
                  args:
                    redis-rate-limiter.replenishRate: 10 # 每秒放入令牌数(QPS)
                    redis-rate-limiter.burstCapacity: 20 # 最大突发流量
                    key-resolver: "#{@ipKeyResolver}"
    
  3. 应用层限流: Sentinel( 限制 QPS 或者线程数 )

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    @SentinelResource(
            value = "getOrder",
            blockHandler = "blockHandlerMethod",
            fallback = "fallbackMethod"
    )
    public String getOrder(Long id) {
        return "正常业务逻辑";
    }
    
    public String blockHandlerMethod(Long id, BlockException ex) {
        return "请求过多,请稍后重试";
    }
    public String fallbackMethod(Long id) {
        return "系统异常,已降级处理";
    }
    
  4. 服务熔断与降级:Open Feign 熔断降级

Nacos 作为服务注册和配置中心的原理

启动参数

-Dnacos.standalone=true

服务注册

客户端(服务提供者)启动后:

  • 向 Nacos Server 发送注册请求
  • 请求内容包含:serviceName、ip、port、metadata
  • 数据通过 HTTP REST 接口发送
  • Server 使用 request 对象接收参数

注册完成后:

  • 服务实例被存入 Nacos 内存注册表

服务健康检测

Nacos Server 会定时执行健康检查任务:

  • 检查所有注册实例的心跳时间
  • 判断实例是否存活

判断规则:

  • 当前时间 - 最后心跳时间 > 15 秒 → 标记为不健康
  • 当前时间 - 最后心跳时间 > 30 秒 → 从注册表移除实例

服务心跳任务(Client)

客户端在服务注册后会启动心跳任务:

  • 每 5 秒发送一次心跳请求
  • 请求接口:beat(REST)
  • 通过定时 + 延迟任务实现
  • 作用:告诉 Server 当前服务仍然存活

客户端服务发现

服务消费者(Nacos Client)调用服务时:

  • 向 Nacos Server 发送 REST 请求
  • 获取服务实例列表
  • 将结果缓存到本地(serviceInfoMap)

同时:

  • 在本地启动定时任务
  • 周期性拉取服务端最新注册表
  • 更新本地缓存

Nacos 1.x 和 2.x 的区别

1.x 基于 HTTP + 定时任务(心跳、轮询);2.x 引入 gRPC 长连接 + 事件驱动模型,支持服务变更实时推送。

除了 Nacos 还有那些注册中心

  1. Eureka(Netflix):AP(高可用),去中心化设计(Peer-to-Peer,无主节点,节点对等,数据通过复制实现同步)。
  2. Zookeeper(Apache):CP(强一致),Leader 选举机制(集群选举主节点负责协调,保证一致性)。
  3. Nacos:AP/CP 可切换,默认 AP;同时支持注册中心 + 配置中心,采用临时实例(心跳)+ 长轮询/推送机制实现服务发现。

MySQL

如何进行 SQL 优化

  1. 慢查询定位:开启慢查询日志定位慢 SQL,通常 0.5 秒(默认 10 秒)开始,但应该根据业务类型和性能要求调整。
  2. explain 指令查看执行计划:
    • id: SQL 执行的顺序与层级, id 相同:从上到下执行, id 不同:数字大的先执行(子查询)。
    • type: 查询类型一旦为 ALL(全表扫描)则必须优化,合理取值 ref(普通索引查询),eq_ref(唯一索引 join),range(范围查询),index(对整个索引进行扫描)。
    • key:真正使用到的索引
    • key_len:索引使用的长度
    • rows:为了获取结果所需要扫描的行数
    • extra:主要用于分析分组和排序操作,using index(使用覆盖索引,不需要回表查询数据行,性能好),using where(where 的条件没有用到索引),using filesort(使用磁盘排序),using index condition(使用索引下推优化,在存储引擎层提前过滤数据,减少回表次数)。

可以通过 keykey_len 判断是否命中了索引以及索引是否被完整使用,从而发现索引是否失效;通过 type 判断 SQL 的访问方式和扫描类型,评估是否存在全表扫描或全索引扫描等优化空间;再结合 extra 字段判断执行细节,例如 Using index 表示覆盖索引无需回表,Using where 表示条件可能未充分利用索引,Using filesort 表示排序或分组未走索引需要优化,整体通过这些字段综合判断 SQL 是否需要优化以及优化方向。

合理创建索引

  1. 表数据量达到一定规模( 15 万以上),才具备明显优化意义

  2. 考虑性价比更高的复合索引设计

  3. 尽量让查询走覆盖索引以减少回表

  4. 需要重点关注索引失效的场景:

    • 未遵循最左匹配原则
    • 模糊查询中 % 在左边
    • 对索引字段使用函数或发生类型转换
    • 范围查询导致右边索引失效
    • 以及 is not null 等导致扫描范围过大的情况
  5. 在关联查询中,应尽量选择小表作为驱动表,并为关联字段创建索引以提升 join 查询效率。

  6. 编写 SQL 语句时避免使用 select *,防止全表扫描或多余回表查询。

  7. 表关联查询优先使用 inner join,避免不必要的 left/right join,如需使用也应以小表为驱动表。

  8. 排序字段尽量保持一致的排序规则(避免一列升序一列降序),以便索引生效。

  9. 建表时结合字段内容选择合适类型

    • 数值:TINYINT:很小的整数(状态位、标志位),INT:常用整数(默认选择),BIGINT:大数据量 ID(如雪花 ID)
    • 字符串:CHAR(n):长度固定(如手机号、身份证),VARCHAR(n):长度可变(最常用),TEXT:大文本(不建议频繁索引)
    • 价格:不要用 FLOAT / DOUBLE(会有精度问题)推荐:DECIMAL(m,n),m:总位数 n:小数位数
    • 时间:用于时间记录(创建时间、更新时间等)DATE:日期(2026-04-19),DATETIME:日期+时间(推荐),TIMESTAMP:时间戳(带时区转换)

B-Tree 和 B+Tree 的区别

  1. B-Tree:所有节点(非叶子 + 叶子)都存储数据,每个节点既存 key 也存 data。
  2. B+Tree: 只有叶子节点存数据,非叶子节点只存索引(key)。

B+Tree 的优点

  1. B+Tree 数据全部存储在叶子节点,非叶子节点只存索引,因此单个节点可以容纳更多的索引项,从而降低树的高度,减少磁盘 IO 次数,提升查询效率。
  2. B+Tree 叶子节点之间通过双向指针相连,可以非常高效地进行范围查询和顺序遍历,可以直接在叶子层顺序向前或向后扫描,从而显著减少磁盘 IO,提高查询性能。同时双向结构还支持反向遍历,进一步增强查询灵活性。
  3. B+Tree 查询路径固定,每次查询都必须从根节点一路访问到叶子节点,每一层只进行一次节点匹配,因此整体查询次数是稳定且可预测的。
  4. B+Tree 通常在 3 层结构下就可以存储约 2000 万级别的数据。
📝 备注

InnoDB 一个页(page)默认大小:16KB = 16384 bytes

非叶子节点存只存索引 key(BIGINT = 8 bytes)和指针(通常 6-8 bytes)

每个节点能存多少“分支”:16384 / 16 ≈ 1000 个指针

三层 B+Tree 推导:1000 × 1000 × 16( 一行 ≈ 1KB)≈ 2000 万

什么是回表

通过二级索引(非聚簇索引)找到主键后,还需要再回到主键索引(聚簇索引)中查询完整数据行的过程。

Select * 查询一定会回表吗?

不一定会回表,如果查询条件是主键索引(聚簇索引),例如 where id = ?,MySQL 会直接在聚簇索引中找到整行数据,因此不会发生回表;只有在使用二级索引查询且无法覆盖所有字段时,才会发生回表。

MySQL 深分页(超大分页)问题如何优化?

  1. 游标分页:

    1
    2
    3
    4
    
    SELECT * FROM user
    WHERE id > last_id
    ORDER BY id
    LIMIT 10;
    

    通过记录上一页最后一条数据的主键(如 last_id),作为下一页查询的起点,利用索引有序性进行范围查询。

  2. 延迟关联(子查询优化)

1
2
3
4
SELECT * FROM user u
JOIN (
    SELECT id FROM user ORDER BY id LIMIT 1000000, 10
) t ON u.id = t.id;

先通过子查询在索引中只查询主键(小结果集),再通过主键与原表关联回表获取完整数据,从而减少大范围扫描带来的性能损耗。

什么是索引下推,原理?

索引下推是 MySQL(InnoDB)的一种优化机制,核心思想是:在存储引擎层就先用索引过滤数据,减少回表次数。

1
2
SELECT * FROM user
WHERE name LIKE 'A%' AND age = 20;
  1. 先用索引(如 name)定位数据范围

  2. 索引层同时判断 age = 20

  3. 只把满足条件的记录回表查询完整数据

  4. 减少无效回表次数,提高性能

InnoDB 和 MyISAM 的区别

对比项InnoDBMyISAM
事务✔ 支持(ACID)❌ 不支持
锁机制行级锁表级锁
外键✔ 支持❌ 不支持
崩溃恢复✔ 支持(redo log)❌ 不支持
索引结构聚簇索引非聚簇索引
查询性能稍慢(但稳定)快(读多场景)

数据库三大范式

  1. 第一范式(1NF):字段必须是不可再分的原子值
  2. 第二范式(2NF):每个非主键字段必须完全依赖主键
  3. 第三范式(3NF):非主键字段之间不能传递依赖

事务特性(ACID)

  1. A - Atomicity(原子性):事务中的操作要么全部执行成功,要么全部失败回滚,通过 undo log 保证回滚能力。
  2. C - Consistency(一致性):事务执行前后,数据必须从一个合法状态转换到另一个合法状态,保证数据符合业务规则。
  3. I - Isolation(隔离性):多个事务并发执行时互不干扰,通过 锁机制(排他锁等)MVCC 保证并发隔离。
  4. D - Durability(持久性):事务一旦提交,数据就会永久保存,即使系统崩溃也不会丢失,通过 redo log 保证。

事务隔离级别和解决的问题

  1. 读未提交(Read Uncommitted):不解决任何并发问题,可能出现脏读、不可重复读、幻读。

  2. 读已提交(Read Committed):(Oracle 默认)解决脏读,但仍可能出现不可重复读和幻读,适合银行业务对资金实时业务的要求。

  3. 可重复读(Repeatable Read)(MySQL 默认):解决脏读和不可重复读,通过 MVCC + 间隙锁在一定程度上避免幻读,适合互联网电商业务“强一致读场景”。

  4. 串行化(Serializable):完全解决脏读、不可重复读和幻读,通过强制事务串行执行实现最高隔离级别,但并发性能最差。

分布式事务

分布式事务是指跨多个服务或数据库的事务一致性问题,无法依赖本地事务实现,常见解决方案是基于最终一致性的方案,如消息队列和 Seata。

长事务问题

事务执行时间过长,持有锁或版本过久未释放的情况

  1. 占用数据库连接资源
  2. 锁持有时间长,导致阻塞和死锁概率增加
  3. undo log / MVCC 版本堆积,影响性能
  4. 可能导致数据库性能整体下降

优化思路:

  1. 拆分大事务(拆成多个小事务)
  2. 避免事务中包含远程调用
  3. 减少锁持有时间
  4. 异步化处理非核心逻辑

MySQL 的 MVCC 会导致表膨胀吗?

MySQL 的 MVCC 通过 undo log 记录数据的历史版本,并结合 Read View 判断可见性,同时依赖 purge 线程在没有活跃事务引用旧版本时清理 undo log,从而避免历史版本无限增长导致的空间膨胀。

此外,对于删除操作,InnoDB 并不会立即物理删除数据,而是先做“标记删除”,旧版本仍通过 undo log 保留;当 purge 线程确认没有事务再引用该版本后,才会真正物理删除该记录,并将这块空间标记为可复用(供后续插入使用),从而避免空间浪费。

但如果存在长事务,会导致旧版本无法被清理,进而造成 undo log 堆积和空间占用增加。

MySQL 中的锁

  1. 锁性质
  • 悲观锁:假设会发生冲突,先加锁再操作,通过数据库锁机制实现(如 SELECT ... FOR UPDATE),适用于写多、冲突概率高的场景。
  • 乐观锁:假设不会冲突,提交时再检查,通常通过版本号(version)或 CAS 实现,适用于读多写少的场景。
  1. 锁粒度

    • 表级锁:锁整张表,开销小,实现简单,但并发性能低,常见于 MyISAM 引擎。
    • 行级锁:锁具体某一行数据,并发能力强,粒度最小,InnoDB 默认使用(提升并发性能)。
    • 意向锁(InnoDB 特有):是一种“表级的标记锁”,用于表示某个事务即将对某些行加锁,不直接锁数据,只是“声明意图”,避免表锁与行锁冲突,提高加锁效率。
      1. 意向共享锁(IS):表示“事务准备在某些行上加共享锁”,读操作前会加。
      2. 意向排他锁(IX):表示“事务准备在某些行上加排他锁”,写操作前会加。
  2. 锁类型

    • 共享锁(S 锁):读锁,多个事务可以同时持有,读读不冲突,但读写冲突。
    • 排他锁(X 锁):写锁,同一时间只能一个事务持有,写写、读写都会冲突。

如何进行数据库数据迁移?

迁移方案

  • 停机一次性迁移:停止业务写入,进行全量数据导出(mysqldump / pg_dump),导入新库后切换应用连接,适用于小数据量或可接受停机的场景。
  • 在线分批次迁移:采用“全量 + 增量”方式,先迁移历史数据,再通过 binlog/CDC(如 Canal)同步增量数据,最后灰度切换流量,适用于大规模或不能停机的系统。

迁移过程可能导致的问题

  • 数据一致性问题:迁移期间仍有写入,导致新旧库数据不一致或延迟同步问题。
  • 应用兼容性问题:新旧库表结构或 SQL 语法不一致,导致接口报错或查询异常。
  • 性能抖动问题:全量导入或 binlog 同步造成数据库 IO 和网络压力上升,影响线上业务。
  • 回滚困难问题:若未保留旧库或未做双写,切换后出现问题难以快速回退。

如何减少迁移过程对用户体验的影响

  • 兼容性测试:在迁移前进行 SQL 兼容性、表结构、接口回归测试,确保新旧库在读写行为上一致。
  • 采用“全量+增量“的同步方案:先进行历史数据全量迁移,再通过 binlog / CDC(如 Canal)同步增量数据,保证业务不中断。
  • 设计灰度切换:通过按用户比例或业务模块逐步切换流量到新库,降低整体切换风险。
  • 监控与回滚预案:全程监控延迟、错误率、数据一致性等指标,一旦异常可快速切回旧库,保证业务可恢复性。

一般会做全量迁移,再做增量同步,分阶段灰度切换流量,监控数据一致性和业务指标,遇到异常可快速回滚,尽量把对用户的影响降至最低。

业务每天新增 1000 万笔订单,某天需要导出应该如何做?

  1. 按条件分批导出:按 时间 / ID / 分片键 将数据拆分成多个区间分批查询导出,避免一次性全表扫描导致数据库压力过大。
  2. 分布式并行导出:将导出任务拆分为多个子任务,通过多线程或分布式任务执行(按 ID hash / 时间分片),提升导出效率。
  3. 异步任务执行:将导出操作放入后台任务系统(如 MQ / XXL-Job),避免阻塞请求线程,提升系统可用性。
  4. 分页批量拉取数据:采用 limit + 游标(last_id) 或按范围分页方式逐批查询数据,避免 OFFSET 深分页性能问题。
  5. 流式写文件:使用流式读取 + 流式写出(如 BufferedWriter / JDBC fetch size),边查询边写入文件,避免内存占用过高。
  6. 上传 S3 对象存储:导出完成后将文件上传到对象存储(如 S3 / OSS),提供下载链接,避免本地磁盘压力并支持高并发下载。

对于大订单导出,通常采用异步任务+分页批处理+流式写出+对象存储的组合,用户点击导出,只是在后端提交异步任务,后台消息队列异步消费,数据库分批拉取避免全表扫描,使用游标或 id 分页,分批流式写出文件,不放在内存中而是写到对象存储服务(OSS)中供用户下载。

导入具体步骤:

  1. 将业务数据预处理为 CSV / TSV 等标准格式文件,保证字段顺序与目标表结构一致。

  2. 通过 SCP / FTP / OSS 下载等方式,将数据文件传输到数据库所在机器本地磁盘,提高导入性能。

  3. 在导入前暂时禁用二级索引或约束,减少索引维护开销,提高导入速度。

    1
    2
    3
    4
    5
    
    ALTER TABLE orders DISABLE KEYS;
    
    #导入完成后再恢复:
    
    ALTER TABLE orders ENABLE KEYS;
    
  4. 使用 MySQL 高效导入命令进行快速加载:

    1
    2
    3
    4
    
    LOAD DATA INFILE '/path/file.csv'
    INTO TABLE orders
    FIELDS TERMINATED BY ','
    LINES TERMINATED BY '\n';
    
  5. 导入完成后重新创建索引(或恢复索引),并进行数据校验(行数对比 / checksum)确保数据一致性。

Redis

Redis 中的基本类型及应用场景

数据类型底层数据结构特点常见应用场景示例
StringSDS(简单动态字符串)O(1) 访问、可扩展、支持二进制安全缓存、计数器、分布式锁、TokenSET user:1 "json" / INCR pv
Hashziplist / hashtable(小对象 ziplist,大对象 hashtable)适合存对象,节省内存用户信息、订单对象HSET user:1 name Tom
Listquicklist(ziplist + linkedlist(双向链表))双端队列结构,支持阻塞消息队列、最新列表LPUSH queue order1
Sethashtable(整数集合 intset / hashtable)无序唯一、支持集合运算去重、共同好友SADD tags java redis
ZSetskiplist(跳表) + hashtable有序、支持范围查询和排序排行榜、热度排序ZADD rank 100 user1
BitMapString(位操作,本质是 bit 数组)极省内存,高效状态标记签到、活跃用户统计SETBIT sign 1 1
Pub/Sub事件驱动(channel + dict 结构)消息广播,实时通知聊天室、系统通知PUBLISH msg "hi"

跳表(SkipList)的说明

跳表(SkipList)是一种基于有序链表 + 多级索引的数据结构,通过“空间换时间”来实现类似二分查找的效率。

为什么不用红黑树?

  1. 红黑树:中序遍历较复杂,跳表:链表天然顺序结构,范围查询非常快。
  2. 不需要复杂旋转(红黑树需要旋转维护平衡),更容易维护和调试。
  3. 局部修改,不需要大范围结构调整。

Redis 是单线程为什么会快呢?

Redis 只是命令处理模型是单线程的,避免了锁竞争和线程切换开销,同时基于 I/O 多路复用(epoll)实现高并发连接处理,所有数据在内存中操作,并配合高度优化的数据结构,使得单线程也能实现极高吞吐;在 Redis 6 之后引入 I/O 多线程用于提升网络读写性能,但核心命令执行仍然是单线程。

谈谈你对 I/O 多路复用的理解

I/O 多路复用是一种通过一个线程监听多个 socket 的机制,利用 epoll 等系统调用在内核层管理连接状态,只处理就绪的 I/O 事件,从而避免线程阻塞和频繁创建线程的问题,提高系统并发能力。

I/O 多路复用实现的三种方式

  1. select:最早的 I/O 多路复用实现,通过轮询所有 fd检查状态(fd 数量有限(默认 1024))。

    • 轮询
    • 有 fd(文件描述符)大小限制
  2. poll:是 select 的改进版,去掉了 fd 数量限制,同样需要遍历所有 fd(仍然是 O(n) 轮询)。

    • 仍是轮询
    • 没有 fd 限制
  3. epoll:事件驱动模型,注册 fd 到内核,等待事件发生,返回“就绪 fd 列表”。

    • 事件驱动
    • 高性能

操作系统用户态和内核态以及它们之间的切换

  1. 用户态:调用系统 API(间接操作文件、网络等),运行普通应用程序,权限较低,不能直接访问硬件。
  2. 内核态:操作系统内核运行状态,拥有最高权限,可以直接访问 CPU,内存,硬盘,网络设备等。

用户态和内核态切换

用户程序通过系统调用进入内核执行,再返回用户态的过程

Redis

Redis 大 Key 解决方案

  1. 定期使用 redis-cli --bigkeys 命令进行扫描,快速定位可能的大 Key(注意:该命令为抽样统计,不保证全量精确)
  2. 对于 Big List 一般采用拆分策略,比如按 1000 个元素一组进行分片存储;如果只关心最新数据,可以结合 LTRIM 定期修剪,避免 List 无限增长导致阻塞操作(如 LRANGE / LLEN 变慢)
  3. 对于 Big Hash,同样需要拆分,可以根据字段名做 hash 取模,将数据分散到多个小 Hash 中;并且在所有代码中,避免对大 Hash 使用 HGETALL,应强制使用 HSCAN 进行增量遍历,避免一次性全量拉取导致阻塞 Redis 单线程

SCAN 命令

  1. 分批次增量遍历整个 Redis,每次只返回一小部分 key(避免 KEYS 命令造成阻塞)
  2. 基于游标(cursor)的遍历方式,非阻塞性操作,每次 SCAN 只消耗很短时间,不影响主线程正常执行其他命令
  3. 牺牲强一致性(可能出现重复或遗漏),换取高可用性与低阻塞性,适用于在线环境的渐进式遍历
  4. 通常需要配合 MATCH(模式匹配)和 COUNT(控制每次扫描数量)使用,以提高扫描效率
  5. SCAN 不保证一次遍历完整数据集,必须循环直到 cursor 回到 0 才算完成一次完整遍历

Redis 内存淘汰策略

  1. noeviction(默认)
    • 不删除任何数据
    • 内存满后写入直接报错(OOM)
    • 适合:严格不能丢数据的场景
  2. allkeys-lru
    • 在所有 key 中,删除“最近最少使用”的 key
    • 最常用的缓存策略(推荐)
  3. volatile-lru
    • 只在设置了 TTL 的 key 中做 LRU 淘汰
    • 永不过期的 key 不参与
  4. allkeys-lfu
    • 在所有 key 中删除“访问频率最低”的 key
    • 比 LRU 更智能(更适合热点数据)
  5. volatile-lfu
    • 只在带 TTL 的 key 中按访问频率淘汰
  6. allkeys-random
    • 随机删除 key(不看冷热)
  7. volatile-random
    • 在设置 TTL 的 key 中随机删除

Redis 过期删除策略

  1. 惰性删除(Lazy Deletion)

    • 访问 key 时才检查是否过期
    • 如果过期才删除
    • 优点:不耗 CPU
    • 缺点:过期 key 可能长期占内存
  2. 定期删除(Periodic Deletion)

    • Redis 每隔一段时间随机抽查一批 key
    • 删除其中已过期的 key
    • 优点:平衡 CPU 和内存
    • 缺点:不是全量扫描
  3. 定期 + 惰性混合

Redis 持久化策略

  1. RDB(数据快照)

    • 优点: 恢复速度快、对性能影响小、文件体积小。
    • 缺点: 可能丢失最近一次快照之后的数据。
  2. AOF(追加日志)

    • 优点: 数据安全性高,最多只丢最后一小段写入数据。
    • 缺点: 文件体积大,恢复速度较慢。
  3. RDB + AOF(混合模式)

    • 优点: 兼顾恢复速度快和数据安全性高。
    • 缺点: 实现更复杂,占用资源略高。

Redis 集群模式

  1. 主从复制:一主多从结构,主负责写、从负责读,用于数据备份和读扩展,但不支持自动分片,主节点宕机需要手动处理或配合其他机制。
  2. 哨兵模式:在主从基础上增加监控和自动故障转移,主节点挂了可以自动选新主,但仍然是单主架构,容量扩展能力有限。
  3. Cluster 集群模式:官方分布式方案,通过分片(slot)把数据分散到多个主节点,实现水平扩展和自动故障转移,是生产环境大规模使用的方案,但配置和使用复杂度更高。
Licensed under CC BY-NC-SA 4.0
最后更新于 2026年4月21日星期二