目录

JavaSE

1.🌟什么是 Java?

Java 是一门面向对象的编程语言,由 Sun 公司的詹姆斯·高斯林团队于 1995 年推出。吸收了 C++ 语言中大量的优点,但又抛弃了 C++ 中容易出错的地方,如垃圾回收、指针。

同时,Java 又是一门平台无关的编程语言,即一次编译,处处运行。

只需要在对应的平台上安装 JDK,就可以实现跨平台,在 Windows、macOS、Linux 操作系统上运行。

Java 语言和 C 语言有哪些区别?

Java 是一种跨平台的编程语言,通过在不同操作系统上安装对应版本的 JVM 以实现“一次编译,处处运行”的目的。而 C 语言需要在不同的操作系统上重新编译。

Java 实现了内存的自动管理,而 C 语言需要使用 malloc 和 free 来手动管理内存。

7.🌟Java 有哪些数据类型?

Java 的数据类型可以分为两种:基本数据类型引用数据类型

20250722101015

基本数据类型有:
①、数值型

  • 整数类型(byte、short、int、long)
  • 浮点类型(float、double)

②、字符型(char)
③、布尔型(boolean)
它们的默认值和占用大小如下所示:

20250722101058

引用数据类型有:

boolean 类型实际占用几个字节?

推荐阅读:Java 进阶之路:基本数据类型篇

这要依据具体的 JVM 实现细节。Java 虚拟机规范中,并没有明确规定 boolean 类型的大小,只规定了 boolean 类型的取值 true 或 false。

boolean: The boolean data type has only two possible values: true and false. Use this data type for simple flags that track true/false conditions. This data type represents one bit of information, but its “size” isn’t something that’s precisely defined.

我本机的 64 位 JDK 中,通过 JOL 工具查看单独的 boolean 类型,以及 boolean 数组,所占用的空间都是 1 个字节。

给Integer最大值+1,是什么结果?

当给 Integer.MAX_VALUE 加 1 时,会发生溢出,变成 Integer.MIN_VALUE

1
2
3
4
5
6
7
int maxValue = Integer.MAX_VALUE;
System.out.println("Integer.MAX_VALUE = " + maxValue); // Integer.MAX_VALUE = 2147483647
System.out.println("Integer.MAX_VALUE + 1 = " + (maxValue + 1)); // Integer.MAX_VALUE + 1 = -2147483648

// 用二进制来表示最大值和最小值
System.out.println("Integer.MAX_VALUE in binary: " + Integer.toBinaryString(maxValue)); // Integer.MAX_VALUE in binary: 1111111111111111111111111111111
System.out.println("Integer.MIN_VALUE in binary: " + Integer.toBinaryString(Integer.MIN_VALUE)); // Integer.MIN_VALUE in binary: 10000000000000000000000000000000

这是因为 Java 的整数类型采用的是二进制补码表示法溢出时值会变成最小值

  • Integer.MAX_VALUE 的二进制表示是 01111111 11111111 11111111 11111111(32 位)。
  • 加 1 后结果变成 10000000 00000000 00000000 00000000,即 -2147483648(Integer.MIN_VALUE)。

18.🌟面向对象编程有哪些特性?

推荐阅读:深入理解 Java 三大特性

面向对象编程有三大特性:封装、继承、多态。

20250723102452

封装是什么?

封装是指将数据(属性,或者叫字段)和操作数据的方法(行为)捆绑在一起,形成一个独立的对象(类的实例)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Nvshen {
private String name;
private int age;

public void setName(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setAge(int age) {
this.age = age;
}
}

可以看得出,女神类对外没有提供 agegetter 方法,因为女神的年龄要保密。

所以,封装是把一个对象的属性私有化,同时提供一些可以被外界访问的方法。

继承是什么?

继承允许一个类(子类)继承现有类(父类或者基类)的属性和方法。以提高代码的复用性,建立类之间的层次关系

同时,子类还可以重写或者扩展从父类继承来的属性和方法,从而实现多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
protected String name;
protected int age;

public void eat() {
System.out.println("吃饭");
}
}

class Student extends Person {
private String school;

public void study() {
System.out.println("学习");
}
}

Student 类继承了 Person 类的属性(name、age)和方法(eat),同时还有自己的属性(school)和方法(study)。

什么是多态?

多态允许不同类的对象对同一消息做出响应,但表现出不同的行为(即方法的多样性)。

多态其实是一种能力——同一个行为具有不同的表现形式;换句话说就是,执行一段代码,Java 在运行时能根据对象类型的不同产生不同的结果。

多态的前置条件有三个:

  • 子类继承父类
  • 子类重写父类的方法
  • 父类引用指向子类的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

//子类继承父类
class Wangxiaoer extends Wanger {
public void write() { // 子类重写父类方法
System.out.println("记住仇恨,表明我们要奋发图强的心智");
}

public static void main(String[] args) {
// 父类引用指向子类对象
Wanger wanger = new Wangxiaoer();
wanger.write();
}
}

class Wanger {
public void write() {
System.out.println("王二是沙雕");
}
}

为什么Java里面要多组合少继承?

继承适合描述“is-a”的关系,但继承容易导致类之间的强耦合,一旦父类发生改变,子类也要随之改变,违背了开闭原则(尽量不修改现有代码,而是添加新的代码来实现)。

组合适合描述“has-a”或“can-do”的关系,通过在类中组合其他类,能够更灵活地扩展功能。组合避免了复杂的类继承体系,同时遵循了开闭原则和松耦合的设计原则。

举个例子,假设我们采用继承,每种形状和样式的组合都会导致类的急剧增加

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
42
43
44
45
// 基类
class Shape {
public void draw() {
System.out.println("Drawing a shape");
}
}

// 圆形
class Circle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
}

// 带红色的圆形
class RedCircle extends Circle {
@Override
public void draw() {
System.out.println("Drawing a red circle");
}
}

// 带绿色的圆形
class GreenCircle extends Circle {
@Override
public void draw() {
System.out.println("Drawing a green circle");
}
}

// 类似的,对于矩形也要创建多个类
class Rectangle extends Shape {
@Override
public void draw() {
System.out.println("Drawing a rectangle");
}
}

class RedRectangle extends Rectangle {
@Override
public void draw() {
System.out.println("Drawing a red rectangle");
}
}

组合模式更加灵活,可以将形状和颜色分开,松耦合。

1
2
3
4
5
6
7
8
9
// 形状接口
interface Shape {
void draw();
}

// 颜色接口
interface Color {
void applyColor();
}

形状干形状的事情。

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
// 圆形的实现
class Circle implements Shape {
private Color color; // 通过组合的方式持有颜色对象

public Circle(Color color) {
this.color = color;
}

@Override
public void draw() {
System.out.print("Drawing a circle with ");
color.applyColor(); // 调用颜色的逻辑
}
}

// 矩形的实现
class Rectangle implements Shape {
private Color color;

public Rectangle(Color color) {
this.color = color;
}

@Override
public void draw() {
System.out.print("Drawing a rectangle with ");
color.applyColor();
}
}

颜色干颜色的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 红色的实现
class RedColor implements Color {
@Override
public void applyColor() {
System.out.println("red color");
}
}

// 绿色的实现
class GreenColor implements Color {
@Override
public void applyColor() {
System.out.println("green color");
}
}

23.🌟抽象类和接口有什么区别?

一个类只能继承一个抽象类但一个类可以实现多个接口。所以我们在新建线程类的时候一般推荐使用实现 Runnable 接口的方式,这样线程类还可以继承其他类,而不单单是 Thread 类。

抽象类符合 is-a 的关系,而接口更像是 has-a 的关系,比如说一个类可以序列化的时候,它只需要实现 Serializable 接口就可以了,不需要去继承一个序列化类。

抽象类更多地是用来为多个相关的类提供一个共同的基础框架,包括状态的初始化,而接口则是定义一套行为标准,让不同的类可以实现同一接口,提供行为的多样化实现。

抽象类可以定义构造方法吗?

可以,抽象类可以有构造方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
abstract class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public abstract void makeSound();
}

public class Dog extends Animal {
private int age;

public Dog(String name, int age) {
super(name); // 调用抽象类的构造函数
this.age = age;
}
@Override
public void makeSound() {
System.out.println(name + " says: Bark");
}
}

接口可以定义构造方法吗?

不能,接口主要用于定义一组方法规范,没有具体的实现细节

20250723111427

Java支持多继承吗?

Java 不支持多继承,一个类只能继承一个类,多继承会引发菱形继承问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A {
void show() { System.out.println("A"); }
}

class B extends A {
void show() { System.out.println("B"); }
}

class C extends A {
void show() { System.out.println("C"); }
}

// 如果 Java 支持多继承
class D extends B, C {
// 调用 show() 方法时,D 应该调用 B 的 show() 还是 C 的 show()?
}

接口可以多继承吗?

接口可以多继承,一个接口可以继承多个接口,使用逗号分隔

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
interface InterfaceA {
void methodA();
}

interface InterfaceB {
void methodB();
}

interface InterfaceC extends InterfaceA, InterfaceB {
void methodC();
}

class MyClass implements InterfaceC {
public void methodA() {
System.out.println("Method A");
}

public void methodB() {
System.out.println("Method B");
}

public void methodC() {
System.out.println("Method C");
}

public static void main(String[] args) {
MyClass myClass = new MyClass();
myClass.methodA();
myClass.methodB();
myClass.methodC();
}
}

在上面的例子中,InterfaceA 和 InterfaceB 是两个独立的接口。

InterfaceC 继承了 InterfaceA 和 InterfaceB,并且定义了自己的方法 methodC。

MyClass 实现了 InterfaceC,因此需要实现 InterfaceA 和 InterfaceB 中的方法 methodA 和 methodB,以及 InterfaceC 中的方法 methodC。

继承和抽象的区别?

继承是一种允许子类继承父类属性和方法的机制。通过继承,子类可以重用父类的代码

抽象是一种隐藏复杂性和只显示必要部分的技术。在面向对象编程中,抽象可以通过抽象类和接口实现。

抽象类和普通类的区别?

抽象类使用 abstract 关键字定义,不能被实例化,只能作为其他类的父类。普通类没有 abstract 关键字,可以直接实例化。

抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须由子类实现。普通类只能包含非抽象方法。

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
abstract class Animal {
// 抽象方法
public abstract void makeSound();

// 非抽象方法
public void eat() {
System.out.println("This animal is eating.");
}
}

class Dog extends Animal {
// 实现抽象方法
@Override
public void makeSound() {
System.out.println("Woof");
}
}

public class Test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.makeSound(); // 输出 "Woof"
dog.eat(); // 输出 "This animal is eating."
}
}

抽象类使用 abstract 关键字定义,不能被实例化,只能作为其他类的父类。普通类没有 abstract 关键字,可以直接实例化。

抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须由子类实现。普通类只能包含非抽象方法。

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
abstract class Animal {
// 抽象方法
public abstract void makeSound();

// 非抽象方法
public void eat() {
System.out.println("This animal is eating.");
}
}

class Dog extends Animal {
// 实现抽象方法
@Override
public void makeSound() {
System.out.println("Woof");
}
}

public class Test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.makeSound(); // 输出 "Woof"
dog.eat(); // 输出 "This animal is eating."
}
}

29.🌟为什么重写 equals 时必须重写 hashCode ⽅法?

因为基于哈希的集合类(如 HashMap)需要基于这一点来正确存储和查找对象。

具体地说,HashMap 通过对象的哈希码将其存储在不同的“桶”中,当查找对象时,它需要使用 key 的哈希码来确定对象在哪个桶中,然后再通过 equals() 方法找到对应的对象。

如果重写了 equals()方法而没有重写 hashCode()方法,那么被认为相等的对象可能会有不同的哈希码,从而导致无法在 HashMap 中正确处理这些对象。

什么是 hashCode 方法?

hashCode() 方法的作⽤是获取哈希码,它会返回⼀个 int 整数,定义在 Object 类中, 是一个本地⽅法。

public native int hashCode();

为什么要有 hashCode 方法?

hashCode 方法主要用来获取对象的哈希码,哈希码是由对象的内存地址或者对象的属性计算出来的,它是⼀个 int 类型的整数,通常是不会重复的,因此可以用来作为键值对的建,以提高查询效率。

例如 HashMap 中的 key 就是通过 hashCode 来实现的,通过调用 hashCode 方法获取键的哈希码,并将其与右移 16 位的哈希码进行异或运算。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么两个对象有相同的 hashcode 值,它们也不⼀定相等?

这主要是由于哈希码(hashCode)的本质和目的所决定的。

哈希码是通过哈希函数将对象中映射成一个整数值,其主要目的是在哈希表中快速定位对象的存储位置。

由于哈希函数将一个较大的输入域映射到一个较小的输出域,不同的输入值(即不同的对象)可能会产生相同的输出值(即相同的哈希码)。

这种情况被称为哈希冲突。当两个不相等的对象发生哈希冲突时,它们会有相同的 hashCode

为了解决哈希冲突的问题,哈希表在处理键时,不仅会比较键对象的哈希码,还会使用 equals 方法来检查键对象是否真正相等。如果两个对象的哈希码相同,但通过 equals 方法比较结果为 false,那么这两个对象就不被视为相等。

1
2
3
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;

hashCode 和 equals 方法的关系?

如果两个对象通过 equals 相等,它们的 hashCode 必须相等。否则会导致哈希表类数据结构(如 HashMap、HashSet)的行为异常。

在哈希表中,如果 equals 相等但 hashCode 不相等,哈希表可能无法正确处理这些对象,导致重复元素或键值冲突等问题。

34.🌟String 和 StringBuilder、StringBuffer 的区别?

推荐阅读:StringBuffer 和 StringBuilder 两兄弟

StringStringBuilderStringBuffer在 Java 中都是用于处理字符串的,它们之间的区别是,String 是不可变的,平常开发用得最多,当遇到大量字符串连接时,就用 StringBuilder,它不会生成很多新的对象,StringBuffer 和 StringBuilder 类似,但每个方法上都加了 synchronized 关键字,所以是线程安全的。

请说说 String 的特点

  • String类的对象是不可变的。也就是说,一旦一个String对象被创建,它所包含的字符串内容是不可改变的。
  • 每次对String对象进行修改操作(如拼接、替换等)实际上都会生成一个新的String对象,而不是修改原有对象。这可能会导致内存和性能开销,尤其是在大量字符串操作的情况下。

请说说 StringBuilder 的特点

  • StringBuilder提供了一系列的方法来进行字符串的增删改查操作,这些操作都是直接在原有字符串对象的底层数组上进行的,而不是生成新的 String 对象
  • StringBuilder不是线程安全的。这意味着在没有外部同步的情况下,它不适用于多线程环境。
  • 相比于String,在进行频繁的字符串修改操作时,StringBuilder能提供更好的性能。 Java 中的字符串连+操作其实就是通过StringBuilder实现的。

请说说 StringBuffer 的特点

StringBufferStringBuilder类似,但StringBuffer是线程安全的,方法前面都加了synchronized关键字。

请总结一下使用场景

  • String:适用于字符串内容不会改变的场景,比如说作为 HashMap 的 key。
  • StringBuilder:适用于单线程环境下需要频繁修改字符串内容的场景,比如在循环中拼接或修改字符串,是 String 的完美替代品。
  • StringBuffer:现在已经不怎么用了,因为一般不会在多线程场景下去频繁的修改字符串内容。

41.🌟Java 中异常处理体系?

推荐阅读:一文彻底搞懂 Java 异常处理

Java 中的异常处理机制用于处理程序运行过程中可能发生的各种异常情况,通常通过 try-catch-finally 语句和 throw 关键字来实现。

20250730111419

Throwable 是 Java 语言中所有错误和异常的基类。它有两个主要的子类:ErrorException,这两个类分别代表了 Java 异常处理体系中的两个分支。

Error 类代表那些严重的错误,这类错误通常是程序无法处理的。比如,OutOfMemoryError 表示内存不足,StackOverflowError 表示栈溢出。这些错误通常与 JVM 的运行状态有关,一旦发生,应用程序通常无法恢复

Exception 类代表程序可以处理的异常。它分为两大类:编译时异常(Checked Exception)和运行时异常(Runtime Exception)。

①、编译时异常(Checked Exception):这类异常在编译时必须被显式处理(捕获或声明抛出)。

如果方法可能抛出某种编译时异常,但没有捕获它(try-catch)或没有在方法声明中用 throws 子句声明它,那么编译将不会通过。例如:IOException、SQLException 等。

②、运行时异常(Runtime Exception):这类异常在运行时抛出,它们都是 RuntimeException 的子类。对于运行时异常,Java 编译器不要求必须处理它们(即不需要捕获也不需要声明抛出)。

运行时异常通常是由程序逻辑错误导致的,如NullPointerExceptionIndexOutOfBoundsException 等。

46.🌟BIO、NIO、AIO 之间的区别?

推荐阅读:Java NIO 比传统 IO 强在哪里?

Java 常见的 IO 模型有三种:BIO、NIO 和 AIO。

20250731094624

BIO:采用阻塞式 I/O 模型,线程在执行 I/O 操作时被阻塞,无法处理其他任务,适用于连接数较少的场景。

NIO:采用非阻塞 I/O 模型,线程在等待 I/O 时可执行其他任务,通过 Selector 监控多个 Channel 上的事件,适用于连接数多但连接时间短的场景。

AIO:使用异步 I/O 模型,线程发起 I/O 请求后立即返回,当 I/O 操作完成时通过回调函数通知线程,适用于连接数多且连接时间长的场景。

简单说一下 BIO?

BIO,也就是传统的 IO,基于字节流或字符流(如 FileInputStream、BufferedReader 等)进行文件读写,基于 SocketServerSocket 进行网络通信。

对于每个连接,都需要创建一个独立的线程来处理读写操作。

20250731095217

简单说下 NIO?

NIO,JDK 1.4 时引入,放在 java.nio 包下,提供了 Channel、Buffer、Selector 等新的抽象,基于 RandomAccessFile、FileChannel、ByteBuffer 进行文件读写,基于 SocketChannelServerSocketChannel 进行网络通信。

实际上,“旧”的 I/O 包已经使用 NIO 重新实现过,所以在进行文件读写时,NIO 并无法体现出比 BIO 更可靠的性能。

NIO 的魅力主要体现在网络编程中,服务器可以用一个线程处理多个客户端连接,通过 Selector 监听多个 Channel 来实现多路复用,极大地提高了网络编程的性能。

NIO

缓冲区 Buffer 也能极大提升一次 IO 操作的效率。

20250731095841

简单说下 AIO?

AIO 是 Java 7 引入的,放在 java.nio.channels 包下,提供了 AsynchronousFileChannel、AsynchronousSocketChannel 等异步 Channel。

它引入了异步通道的概念,使得 I/O 操作可以异步进行。这意味着线程发起一个读写操作后不必等待其完成,可以立即进行其他任务,并且当读写操作真正完成时,线程会被异步地通知。

1
2
3
4
5
6
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get("test.txt"), StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
Future<Integer> result = fileChannel.read(buffer, 0);
while (!result.isDone()) {
// do something
}

52.🌟什么是反射?应用?原理?

反射允许 Java 在运行时检查和操作类的方法和字段。通过反射,可以动态地获取类的字段、方法、构造方法等信息,并在运行时调用方法或访问字段。

比如创建一个对象是通过 new 关键字来实现的:

1
Person person = new Person();

Person 类的信息在编译时就确定了,那假如在编译期无法确定类的信息,但又想在运行时获取类的信息、创建类的实例、调用类的方法,这时候就要用到反射。

反射功能主要通过 java.lang.Class 类及 java.lang.reflect 包中的类如 Method, Field, Constructor 等来实现。

20250731103546

比如说我们可以装来动态加载类并创建对象:

1
2
3
4
String className = "java.util.Date";
Class<?> cls = Class.forName(className);
Object obj = cls.newInstance();
System.out.println(obj.getClass().getName());

比如说我们可以这样来访问字段和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

// 加载并实例化类
Class<?> cls = Class.forName("java.util.Date");
Object obj = cls.newInstance();

// 获取并调用方法
Method method = cls.getMethod("getTime");
Object result = method.invoke(obj);
System.out.println("Time: " + result);

// 访问字段
Field field = cls.getDeclaredField("fastTime");
field.setAccessible(true); // 对于私有字段需要这样做
System.out.println("fastTime: " + field.getLong(obj));

反射有哪些应用场景?

①、Spring 框架就大量使用了反射来动态加载和管理 Bean。

1
2
Class<?> clazz = Class.forName("com.example.MyClass");
Object instance = clazz.newInstance();

②、Java 的动态代理(Dynamic Proxy)机制就使用了反射来创建代理类。代理类可以在运行时动态处理方法调用,这在实现 AOP 和拦截器时非常有用。

1
2
3
4
5
6
7
//创建一个处理器实例,负责实际方法的调用逻辑
InvocationHandler handler = new MyInvocationHandler();
MyInterface proxyInstance = (MyInterface) Proxy.newProxyInstance(
MyInterface.class.getClassLoader(),
new Class<?>[] { MyInterface.class },
handler
);

③、JUnit 和 TestNG 等测试框架使用反射机制来发现和执行测试方法。反射允许框架扫描类,查找带有特定注解(如 @Test)的方法,并在运行时调用它们。

1
2
Method testMethod = testClass.getMethod("testSomething");
testMethod.invoke(testInstance);

反射的原理是什么?

Java 程序的执行分为编译运行两步,编译之后会生成字节码(.class)文件,JVM 进行类加载的时候,会加载字节码文件,将类型相关的所有信息加载进方法区,反射就是去获取这些信息,然后进行各种操作。

Java集合框架

1.🌟说说有哪些常见的集合框架?

java集合主要关系

集合框架可以分为两条大的支线:

①、第一条支线 Collection,主要由 List、Set、Queue 组成:

  • List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayList 和封装了链表的 LinkedList
  • Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet;
  • Queue 代表队列,典型代表就是双端队列 ArrayDeque,以及优先级队列 PriorityQueue

②、第二条支线 Map,代表键值对的集合,典型代表就是 HashMap

另外一个回答版本:

①、Collection 接口:最基本的集合框架表示方式,提供了添加、删除、清空等基本操作,它主要有三个子接口:

  • List:一个有序的集合,可以包含重复的元素。实现类包括 ArrayList、LinkedList 等。
  • Set:一个不包含重复元素的集合。实现类包括 HashSet、LinkedHashSet、TreeSet 等。
  • Queue:一个用于保持元素队列的集合。实现类包括 PriorityQueue、ArrayDeque 等。

②、Map 接口:表示键值对的集合,一个键映射到一个值。键不能重复,每个键只能对应一个值。Map 接口的实现类包括 HashMap、LinkedHashMap、TreeMap 等。

集合框架有哪几个常用工具类?

集合框架位于 java.util 包下,提供了两个常用的工具类:

  • Collections:提供了一些对集合进行排序、二分查找、同步的静态方法。
  • Arrays:提供了一些对数组进行排序、打印、和 List 进行转换的静态方法。

简单介绍一下队列

Java 中的队列主要通过 Queue 接口和并发包下的 BlockingQueue 两个接口来实现。

优先级队列 PriorityQueue 实现了 Queue 接口,是一个无界队列,它的元素按照自然顺序排序或者 Comparator 比较器进行排序。

20250801105907

双端队列 ArrayDeque 也实现了 Queue 接口,是一个基于数组的,可以在两端插入和删除元素的队列。

20250801112309

LinkedList 实现了 Queue 接口的子类 Deque,所以也可以当做双端队列来使用。

20250801112729

用过哪些集合类,它们的优劣?

我常用的集合类有 ArrayList、LinkedList、HashMap、LinkedHashMap。

  1. ArrayList 可以看作是一个动态数组,可以在需要时动态扩容数组的容量,只不过需要复制元素到新的数组。优点是访问速度快,可以通过索引直接查找到元素。缺点是插入和删除元素可能需要移动或者复制元素。

  2. LinkedList 是一个双向链表,适合频繁的插入和删除操作。优点是插入和删除元素的时候只需要改变节点的前后指针,缺点是访问元素时需要遍历链表。

  3. HashMap 是一个基于哈希表的键值对集合。优点是可以根据键的哈希值快速查找到值,但有可能会发生哈希冲突,并且不保留键值对的插入顺序。

  4. LinkedHashMap 在 HashMap 的基础上增加了一个双向链表来保持键值对的插入顺序。

队列和栈的区别了解吗?

队列是一种先进先出(FIFO, First-In-First-Out)的数据结构,第一个加入队列的元素会成为第一个被移除的元素。

20250801112820

栈是一种后进先出(LIFO, Last-In-First-Out)的数据结构,最后一个加入栈的元素会成为第一个被移除的元素。

20250801112835

哪些是线程安全的容器?

像 Vector、Hashtable、ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、ArrayBlockingQueue、LinkedBlockingQueue 都是线程安全的。

Collection 继承了哪些接口?

Collection 继承了 Iterable 接口,这意味着所有实现 Collection 接口的类都必须实现 iterator() 方法,之后就可以使用增强型 for 循环遍历集合中的元素了。

20250801112927

List 推荐阅读文章

2.🌟ArrayList 和 LinkedList 有什么区别?

推荐阅读:二哥的 Java 进阶之路:ArrayList 和 LinkedList

ArrayList 是基于数组实现的,LinkedList 是基于链表实现的。

ArrayList 和 LinkedList 的用途有什么不同?

多数情况下,ArrayList 更利于查找,LinkedList 更利于增删

①、由于 ArrayList 是基于数组实现的,所以 get(int index) 可以直接通过数组下标获取,时间复杂度是 O(1);LinkedList 是基于链表实现的,get(int index) 需要遍历链表,时间复杂度是 O(n)。

当然,get(E element) 这种查找,两种集合都需要遍历通过 equals 比较获取元素,所以时间复杂度都是 O(n)。

②、ArrayList 如果增删的是数组的尾部,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会上升到 O(n)。

但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,变成 O(n)。

20250804110446

LinkedList 因为是链表结构,插入和删除只需要改变前置节点、后置节点和插入节点的引用,因此不需要移动元素。

如果是在链表的头部插入或者删除,时间复杂度是 O(1);如果是在链表的中间插入或者删除,时间复杂度是 O(n),因为需要遍历链表找到插入位置;如果是在链表的尾部插入或者删除,时间复杂度是 O(1)。

20250804110550

ArrayList 和 LinkedList 是否支持随机访问?

①、ArrayList 是基于数组的,也实现了 RandomAccess 接口,所以它支持随机访问,可以通过下标直接获取元素。

20250804110642

②、LinkedList 是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问。

20250804110656

ArrayList 和 LinkedList 内存占用有何不同?

ArrayList 是基于数组的,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的 1.5 倍。

20250804110718

LinkedList 是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间比 ArrayList 稍微大一点。

ArrayList 和 LinkedList 的使用场景有什么不同?

ArrayList 适用于:

  • 随机访问频繁:需要频繁通过索引访问元素的场景。
  • 读取操作远多于写入操作:如存储不经常改变的列表。
  • 末尾添加元素:需要频繁在列表末尾添加元素的场景。

LinkedList 适用于:

  • 频繁插入和删除:在列表中间频繁插入和删除元素的场景。
  • 不需要快速随机访问:顺序访问多于随机访问的场景。
  • 队列和栈:由于其双向链表的特性,LinkedList 可以实现队列(FIFO)和栈(LIFO)。

链表和数组有什么区别?

  • 数组在内存中占用的是一块连续的存储空间,因此我们可以通过数组下标快速访问任意元素。数组在创建时必须指定大小,一旦分配内存,数组的大小就固定了。
  • 链表的元素存储在于内存中的任意位置,每个节点通过指针指向下一个节点。

20250804110809

8.🌟能说一下 HashMap 的底层数据结构吗?

推荐阅读:二哥的 Java 进阶之路:详解 HashMap

JDK 8 中 HashMap 的数据结构是数组+链表+红黑树

20250804112738

数组用来存储键值对,每个键值对可以通过索引直接拿到,索引是通过对键的哈希值进行进一步的 hash() 处理得到的。

当多个键经过哈希处理后得到相同的索引时,需要通过链表来解决哈希冲突——将具有相同索引的键值对通过链表存储起来。

不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。

hash() 方法的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

如果键的哈希值已经在数组中存在,其对应的值将被新值覆盖。

HashMap 的初始容量是 16,随着元素的不断添加,HashMap 就需要进行扩容,阈值是capacity * loadFactor,capacity 为容量,loadFactor 为负载因子,默认为 0.75。

扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。

11.🌟HashMap 的 put 流程知道吗?

哈希寻址 → 处理哈希冲突(链表还是红黑树)→ 判断是否需要扩容 → 插入/覆盖节点。

20250805111458

详细版:

第一步,通过 hash 方法进一步扰动哈希值,以减少哈希冲突。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

第二步,进行第一次的数组扩容;并使用哈希值和数组长度进行取模运算,确定索引位置。

1
2
3
4
5
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

如果当前位置为空,直接将键值对插入该位置;
否则判断当前位置的第一个节点是否与新节点的 key 相同,如果相同直接覆盖 value,如果不同,说明发生哈希冲突。

如果是链表,将新节点添加到链表的尾部;如果链表长度大于等于 8,则将链表转换为红黑树。

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
42
43
44
45
46
47
48
49
50
51

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 table 为空,先进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

// 计算索引位置,并找到对应的桶
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 如果桶为空,直接插入
else {
Node<K,V> e; K k;
// 检查第一个节点是否匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 覆盖
// 如果是树节点,放入树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果是链表,遍历插入到尾部
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表长度达到阈值,转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 覆盖
p = e;
}
}
if (e != null) { // 如果找到匹配的 key,则覆盖旧值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 修改计数器
if (++size > threshold)
resize(); // 检查是否需要扩容
afterNodeInsertion(evict);
return null;
}

每次插入新元素后,检查是否需要扩容,如果当前元素个数大于阈值(capacity * loadFactor),则进行扩容,扩容后的数组大小是原来的 2 倍;并且重新计算每个节点的索引,进行数据重新分布。

只重写元素的 equals 方法没重写 hashCode,put 的时候会发生什么?

如果只重写 equals 方法,没有重写 hashCode 方法,那么会导致 equals 相等的两个对象,hashCode 不相等,这样的话,两个对象会被 put 到数组中不同的位置,导致 get 的时候,无法获取到正确的值。

21.🌟HashMap的扩容机制了解吗?

扩容时,HashMap 会创建一个新的数组,其容量是原来的两倍。然后遍历旧哈希表中的元素,将其重新分配到新的哈希表中。

如果当前桶中只有一个元素,那么直接通过键的哈希值与数组大小取模锁定新的索引位置:e.hash & (newCap - 1)

如果当前桶是红黑树,那么会调用 split() 方法分裂树节点,以保证树的平衡。

如果当前桶是链表,会通过旧键的哈希值与旧的数组大小取模 (e.hash & oldCap) == 0 来作为判断条件,如果条件为真,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。

JDK 7 扩容的时候有什么问题?

JDK 7 在扩容的时候使用头插法来重新插入链表节点,这样会导致链表无法保持原有的顺序。

详细解释一下。

JDK 7 是通过哈希值与数组大小-1 进行与运算确定元素下标的。

1
2
3
static int indexFor(int h, int length) {
return h & (length-1);
}

我们来假设:

  • 数组 table 的长度为 2
  • 键的哈希值为 3、7、5

取模运算后,键发生了哈希冲突,它们都需要放到 table[1] 的桶上。那么扩容前就是这个样子:

20250807102317

假设负载因子 loadFactor 为 1,也就是当元素的个数大于 table 的长度时进行扩容。

扩容后的数组容量为 4。

  • key 3 取模(3%4)后是 3,放在 table[3] 上。
  • key 7 取模(7%4)后是 3,放在 table[3] 上的链表头部。
  • key 5 取模(5%4)后是 1,放在 table[1] 上。

20250807102413

可以看到,由于 JDK 采用的是头插法,7 跑到 3 的前面了,原来的顺序是 3、7、5,7 在 3 的后面。

1
2
3
4
5
6
7
8
9
for (Entry<K,V> e : oldTable) {
while (null != e) {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}

最好的情况就是,扩容后的 7 还在 3 的后面,保持原来的顺序。

JDK 8 是怎么解决这个问题的?

JDK 8 改用了尾插法,并且当 (e.hash & oldCap) == 0 时,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loHead != null)
newTab[j] = loHead;
if (hiHead != null)
newTab[j + oldCap] = hiHead;

由于扩容时,数组长度会翻倍,例如:16 → 32, 因此,新数组的索引范围是原索引范围的两倍。

原索引 index = (n - 1) & hash,扩容后的新索引就是 index = (2n - 1) & hash

也就是说,如果 (e.hash & oldCap) == 0,元素在新数组中的位置与旧位置相同;否则,元素在新数组中的位置是旧位置 + 旧数组大小。

20250807102739

20250807102747

这样可以避免重新计算所有元素的哈希值,只需检查高位的某一位,就可以快速确定新位置。

20250807102803

扩容的时候每个节点都要进行位运算吗?

不需要。HashMap 会通过 (e.hash & oldCap) 来判断节点是否需要移动,0 的话保留原索引;1 才需要移动到新索引(原索引 + oldCap)。

这样就避免了 hashCode 的重新计算,大大提升了扩容的性能。

所以,哪怕有几十万条数据,可能只有一半的数据才需要移动到新位置。另外,位运算的计算速度非常快,因此,尽管扩容操作涉及到遍历整个哈希表并对每个节点进行判断,但这部分操作的计算成本是相对较低的。

24.🌟HashMap 是线程安全的吗?

推荐阅读:HashMap 详解

HashMap 不是线程安全的,主要有以下几个问题:

①、多线程下扩容会死循环。JDK7 中的 HashMap 使用的是头插法来处理链表,在多线程环境下扩容会出现环形链表,造成死循环。

20250807103405

不过,JDK 8 时通过尾插法修复了这个问题,扩容时会保持链表原来的顺序。

②、多线程在进行 put 元素的时候,可能会导致元素丢失。因为计算出来的位置可能会被其他线程覆盖掉,比如说一个县城 put 3 的时候,另外一个线程 put 了 7,就把 3 给弄丢了。

20250807103452

③、put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而扩容,线程 2 此时执行 get,就有可能出现这个问题。

20250807103509

因为线程 1 执行完 table = newTab 之后,线程 2 中的 table 已经发生了改变,比如说索引 3 的键值对移动到了索引 7 的位置,此时线程 2 去 get 索引 3 的元素就 get 不到了。

25.🌟怎么解决 HashMap 线程不安全的问题呢?

在早期的 JDK 版本中,可以用 Hashtable 来保证线程安全。Hashtable 在方法上加了 synchronized 关键字

20250807103648

另外,可以通过 Collections.synchronizedMap 方法返回一个线程安全的 Map,内部是通过 synchronized 对象锁来保证线程安全的,比在方法上直接加 synchronized 关键字更轻量级。

20250807103717

更优雅的解决方案是使用并发工具包下的 ConcurrentHashMap,使用了CASsynchronized 关键字来保证线程安全。
(分段锁+CAS)
20250807103755

Java并发编程

2.🌟说说进程和线程的区别?

推荐阅读:进程与线程的区别是什么?

进程说简单点就是我们在电脑上启动的一个个应用。它是操作系统分配资源的最小单位。

线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个线程都有自己独立的栈和寄存器。

20250808104808

如何理解协程?

协程被视为比线程更轻量级的并发单元,可以在单线程中实现并发执行,由我们开发者显式调度。

协程是在用户态进行调度的,避免了线程切换时的内核态开销。

Java 自身是不支持携程的,我们可以使用 QuasarKotlin 等框架来实现协程。

1
2
3
4
5
6
7
fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}

线程间是如何进行通信的?

原则上可以通过消息传递共享内存两种方法来实现。Java 采用的是共享内存的并发模型。

这个模型被称为 Java 内存模型,简写为 JMM,它决定了一个线程对共享变量的写入,何时对另外一个线程可见。当然了,本地内存是 JMM 的一个抽象概念,并不真实存在。

用一句话来概括就是:共享变量存储在主内存中,每个线程的私有本地内存,存储的是这个共享变量的副本

20250808105242

线程 A 与线程 B 之间如要通信,需要要经历 2 个步骤:

  1. 线程 A 把本地内存 A 中的共享变量副本刷新到主内存中。
  2. 线程 B 到主内存中读取线程 A 刷新过的共享变量,再同步到自己的共享变量副本中。

20250808110938

3.🌟说说线程有几种创建方式?

推荐阅读:室友打了一把王者就学会了 Java 多线程

有三种,分别是继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。

20250808111222

第一种需要重写父类 Threadrun() 方法,并且调用 start() 方法启动线程。

1
2
3
4
5
6
7
8
9
10
class ThreadTask extends Thread {
public void run() {
System.out.println("看完二哥的 Java 进阶之路,上岸了!");
}

public static void main(String[] args) {
ThreadTask task = new ThreadTask();
task.start();
}
}

这种方法的缺点是,如果 ThreadTask 已经继承了另外一个类,就不能再继承 Thread 类了,因为 Java 不支持多重继承。

第二种需要重写 Runnable 接口的 run() 方法,并将实现类的对象作为参数传递给 Thread 对象的构造方法,最后调用 start() 方法启动线程。

1
2
3
4
5
6
7
8
9
10
11
class RunnableTask implements Runnable {
public void run() {
System.out.println("看完二哥的 Java 进阶之路,上岸了!");
}

public static void main(String[] args) {
RunnableTask task = new RunnableTask();
Thread thread = new Thread(task);
thread.start();
}
}

这种方法的优点是可以避免 Java 的单继承限制,并且更符合面向对象的编程思想,因为 Runnable 接口将任务代码和线程控制的代码解耦了。

第三种需要重写 Callable 接口的 call() 方法,然后创建 FutureTask 对象,参数为 Callable 实现类的对象;紧接着创建 Thread 对象,参数为 FutureTask 对象,最后调用 start() 方法启动线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
class CallableTask implements Callable<String> {
public String call() {
return "看完二哥的 Java 进阶之路,上岸了!";
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
CallableTask task = new CallableTask();
FutureTask<String> futureTask = new FutureTask<>(task);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}

这种方法的优点是可以获取线程的执行结果。

一个 8G 内存的系统最多能创建多少个线程?

推荐阅读:深入理解 JVM 的运行时数据区

理论上大约 8000 个。

创建线程的时候,至少需要分配一个虚拟机栈,在 64 位操作系统中,默认大小为 1M,因此一个线程大约需要 1M 的内存。

JVM、操作系统本身的运行就要占一定的内存空间,所以实际上可以创建的线程数远比 8000 少。

详细解释一下。

可以通过 java -XX:+PrintFlagsFinal -version | grep ThreadStackSize 命令查看 JVM 栈的默认大小。

20250808141237

其中 ThreadStackSize 的单位是 KB,也就是说默认的 JVM 栈大小是 1024 KB,也就是 1M

启动一个 Java 程序,你能说说里面有哪些线程吗?

首先是 main 线程,这是程序执行的入口。

然后是垃圾回收线程,它是一个后台线程,负责回收不再使用的对象。

还有编译器线程,比如 JIT,负责把一部分热点代码编译后放到 codeCache 中。

20250808142112

可以通过下面的代码进行检测:

1
2
3
4
5
6
7
8
9
class ThreadLister {
public static void main(String[] args) {
// 获取所有线程的堆栈跟踪
Map<Thread, StackTraceElement[]> threads = Thread.getAllStackTraces();
for (Thread thread : threads.keySet()) {
System.out.println("Thread: " + thread.getName() + " (ID=" + thread.getId() + ")");
}
}
}

结果如下所示:

1
2
3
4
5
Thread: Monitor Ctrl-Break (ID=5)
Thread: Reference Handler (ID=2)
Thread: main (ID=1)
Thread: Signal Dispatcher (ID=4)
Thread: Finalizer (ID=3)

简单解释下:

  • Thread: main (ID=1) - 主线程,Java 程序启动时由 JVM 创建。
  • Thread: Reference Handler (ID=2) - 这个线程是用来处理引用对象的,如软引用、弱引用和虚引用。负责清理被 JVM 回收的对象。
  • Thread: Finalizer (ID=3) - 终结器线程,负责调用对象的 finalize 方法。对象在垃圾回收器标记为可回收之前,由该线程执行其 finalize 方法,用于执行特定的资源释放操作。
  • Thread: Signal Dispatcher (ID=4) - 信号调度线程,处理来自操作系统的信号,将它们转发给 JVM 进行进一步处理,例如响应中断、停止等信号。
  • Thread: Monitor Ctrl-Break (ID=5) - 监视器线程,通常由一些特定的 IDE 创建,用于在开发过程中监控和管理程序执行或者处理中断。

4.🌟调用 start 方法时会执行 run 方法,那怎么不直接调用 run方法?

调用 start() 会创建一个新的线程,并异步执行 run() 方法中的代码。

直接调用 run() 方法只是一个普通的同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新的线程创建,也就达不到多线程并发的目的。

通过敲代码体验一下。

1
2
3
4
5
6
7
8
9
10
11
class MyThread extends Thread {
public void run() {
System.out.println(Thread.currentThread().getName());
}

public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start(); // 正确的方式,创建一个新线程,并在新线程中执行 run()
t1.run(); // 仅在主线程中执行 run(),没有创建新线程
}
}

来看输出结果:

1
2
main
Thread-0

也就是说,调用 start() 方法会通知 JVM,去调用底层的线程调度机制来启动新线程。

20250809102015

调用 start() 后,线程进入就绪状态,等待操作系统调度;一旦调度执行,线程会执行其 run() 方法中的代码。

6.🌟线程有几种状态?

6 种。

  • new 代表线程被创建但未启动;
  • runnable 代表线程处于就绪或正在运行状态,由操作系统调度;
  • blocked 代表线程被阻塞,等待获取锁;
  • waiting 代表线程等待其他线程的通知或中断;
  • timed_waiting 代表线程会等待一段时间,超时后自动恢复;
  • terminated 代表线程执行完毕,生命周期结束。

20250809104257

也就是说,线程的生命周期可以分为五个主要阶段:新建、就绪、运行、阻塞和终止。线程在运行过程中会根据状态的变化在这些阶段之间切换。

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
class ThreadStateExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
Thread.sleep(2000); // TIMED_WAITING
synchronized (ThreadStateExample.class) {
ThreadStateExample.class.wait(); // WAITING
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

System.out.println("State after creation: " + thread.getState()); // NEW

thread.start();
System.out.println("State after start: " + thread.getState()); // RUNNABLE

Thread.sleep(500);
System.out.println("State while sleeping: " + thread.getState()); // TIMED_WAITING

synchronized (ThreadStateExample.class) {
ThreadStateExample.class.notify(); // 唤醒线程
}

thread.join();
System.out.println("State after termination: " + thread.getState()); // TERMINATED
}
}

用一个表格来做个总结:

20250809104355

如何强制终止线程?

第一步,调用线程的 interrupt() 方法,请求终止线程。

第二步,在线程的 run() 方法中检查中断状态,如果线程被中断,就退出线程。

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
class MyTask implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("Running...");
Thread.sleep(1000); // 模拟工作
} catch (InterruptedException e) {
// 捕获中断异常后,重置中断状态
Thread.currentThread().interrupt();
System.out.println("Thread interrupted, exiting...");
break;
}
}
}
}

public class Main {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyTask());
thread.start();
Thread.sleep(3000); // 主线程等待3秒
thread.interrupt(); // 请求终止线程
}
}

中断结果:

20250809104457

10.🌟请说说 sleep 和 wait 的区别?(补充)

sleep 会让当前线程休眠,不需要获取对象锁,属于 Thread 类的方法;
wait 会让获得对象锁的线程等待,要提前获得对象锁,属于 Object 类的方法。

①、所属类不同

  • sleep() 方法专属于 Thread 类。
  • wait() 方法专属于 Object 类。

②、锁行为不同

如果一个线程在持有某个对象锁时调用了 sleep 方法,它在睡眠期间仍然会持有这个锁。

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
class SleepDoesNotReleaseLock {

private static final Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
Thread sleepingThread = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 1 会继续持有锁,并且进入睡眠状态");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1 醒来了,并且释放了锁");
}
});

Thread waitingThread = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 2 进入同步代码块");
}
});

sleepingThread.start();
Thread.sleep(1000);
waitingThread.start();
}
}

输出结果:

1
2
3
4
Thread 1 会继续持有锁,并且进入睡眠状态
Thread 1 醒来了,并且释放了锁
Thread 2 进入同步代码块

从输出中我们可以看到,waitingThread 必须等待 sleepingThread 完成睡眠后才能进入同步代码块。

而当线程执行 wait 方法时,它会释放持有的对象锁,因此其他线程也有机会获取该对象的锁。

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
class WaitReleasesLock {

private static final Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
Thread waitingThread = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("Thread 1 持有锁,准备等待 5 秒");
lock.wait(5000);
System.out.println("Thread 1 醒来了,并且退出同步代码块");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

Thread notifyingThread = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 2 尝试唤醒等待中的线程");
lock.notify();
System.out.println("Thread 2 执行完了 notify");
}
});

waitingThread.start();
Thread.sleep(1000);
notifyingThread.start();
}
}

输出结果:

1
2
3
4
Thread 1 持有锁,准备等待 5 秒
Thread 2 尝试唤醒等待中的线程
Thread 2 执行完了 notify
Thread 1 醒来了,并且退出同步代码块

这表明 waitingThread 在调用 wait 后确实释放了锁。

③、使用条件不同

sleep() 方法可以在任何地方被调用。
wait() 方法必须在同步代码块或同步方法中被调用,这是因为调用 wait() 方法的前提是当前线程必须持有对象的锁。否则会抛出 IllegalMonitorStateException 异常。

20250820212712

④、唤醒方式不同

调用 sleep 方法后,线程会进入 TIMED_WAITING 状态,即在指定的时间内暂停执行。当指定的时间结束后,线程会自动恢复到 RUNNABLE 状态,等待 CPU 调度再次执行。

调用 wait 方法后,线程会进入 WAITING 状态,直到有其他线程在同一对象上调用 notifynotifyAll 方法,线程才会从 WAITING 状态转变为 RUNNABLE 状态,准备再次获得 CPU 的执行权。

我们来通过代码再感受一下 sleepwait 在用法上的区别,先看 sleep 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SleepExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("线程准备休眠 2 秒");
try {
Thread.sleep(2000); // 线程将睡眠2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程醒来了");
});

thread.start();
}
}

再来看 wait() 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class WaitExample {
public static void main(String[] args) {
final Object lock = new Object();

Thread thread = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("线程准备等待 2 秒");
lock.wait(2000); // 线程会等待2秒,或者直到其他线程调用 lock.notify()/notifyAll()
System.out.println("线程结束等待");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

thread.start();
}
}

11.🌟怎么保证线程安全?(补充)

线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致的问题。

为了保证线程安全,可以使用 synchronized 关键字对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁。

如果需要更细粒度的锁,可以使用 ReentrantLock 并发重入锁等。

如果需要保证变量的内存可见性,可以使用 volatile 关键字

对于简单的原子变量操作,还可以使用 Atomic 原子类

对于线程独立的数据,可以使用 ThreadLocal 来为每个线程提供专属的变量副本。

对于需要并发容器的地方,可以使用 ConcurrentHashMapCopyOnWriteArrayList 等。

有个int的变量为0,十个线程轮流对其进行++操作(循环10000次),结果大于10 万还是小于等于10万,为什么?

在这个场景中,最终的结果会小于 100000,原因是多线程环境下,++ 操作并不是一个原子操作,而是分为读取、加 1、写回三个步骤。

  1. 读取变量的值。
  2. 将读取到的值加 1。
  3. 将结果写回变量。

这样的话,就会有多个线程读取到相同的值,然后对这个值进行加 1 操作,最终导致结果小于 100000。

详细解释下。

多个线程在并发执行 ++ 操作时,可能出现以下竞态条件:

  • 线程 1 读取变量值为 0。
  • 线程 2 也读取变量值为 0。
  • 线程 1 进行加法运算并将结果 1 写回变量。
  • 线程 2 进行加法运算并将结果 1 写回变量,覆盖了线程 1 的结果。

可以通过 synchronized 关键字为 ++ 操作加锁。

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
class Main {
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
synchronized (Main.class) {
count++;
}
}
};

List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(task);
threads.add(thread);
thread.start();
}

for (Thread thread : threads) {
thread.join();
}

System.out.println("Final count: " + count);
}
}

或者使用 AtomicInteger 的 incrementAndGet() 方法来替代 ++ 操作,保证变量的原子性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Main {
private static AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
count.incrementAndGet();
}
};

List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(task);
threads.add(thread);
thread.start();
}

for (Thread thread : threads) {
thread.join();
}

System.out.println("Final count: " + count.get());
}
}

场景:有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?

会。

在单节点环境中,可以使用 synchronized 关键字或 ReentrantLock 来保证对 key 的修改操作是原子的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class KeyManager {
private final ReentrantLock lock = new ReentrantLock();

private String key = "{\"tasks\": [\"task1\", \"task2\"]}";

public String readKey() {
lock.lock();
try {
return key;
} finally {
lock.unlock();
}
}

public void updateKey(String newKey) {
lock.lock();
try {
this.key = newKey;
} finally {
lock.unlock();
}
}
}

在多节点环境中,可以使用分布式锁 Redisson 来保证对 key 的修改操作是原子的。

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
class DistributedKeyManager {
private final RedissonClient redisson;

public DistributedKeyManager() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
this.redisson = Redisson.create(config);
}

public void updateKey(String key, String newValue) {
RLock lock = redisson.getLock(key);
lock.lock();
try {
// 模拟读取和更新操作
String currentValue = readFromDatabase(key); // 假设读取 JSON 数据
String updatedValue = modifyJson(currentValue, newValue); // 修改 JSON
writeToDatabase(key, updatedValue); // 写回数据库
} finally {
lock.unlock();
}
}

private String readFromDatabase(String key) {
// 模拟从数据库读取
return "{\"tasks\": [\"task1\", \"task2\"]}";
}

private String modifyJson(String json, String newValue) {
// 使用 JSON 库解析并修改
return json.replace("task1", newValue);
}

private void writeToDatabase(String key, String value) {
// 模拟写回数据库
}
}

说一个线程安全的使用场景?

单例模式。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例,并提供一个全局访问点。

饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全。

1
2
3
4
5
6
7
8
9
10
class Singleton {
private static final Singleton instance = new Singleton();

private Singleton() {
}

public static Singleton getInstance() {
return instance;
}
}

懒汉式单例则在第一次使用时初始化单例对象,这种方式需要使用双重检查锁定来确保线程安全,volatile 关键字用来保证可见性,syncronized 关键字用来保证同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class LazySingleton {
private static volatile LazySingleton instance;

private LazySingleton() {}

public static LazySingleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (LazySingleton.class) {
if (instance == null) { // 第二次检查
instance = new LazySingleton();
}
}
}
return instance;
}
}

能说一下 Hashtable 的底层数据结构吗?

与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全。

20250928191717
二哥的Java 进阶之路:Hashtable源码

推荐阅读:ThreadLocal 全面解析

12.🌟ThreadLocal 是什么?

ThreadLocal 是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。

20250928193013
三分恶面渣逆袭:ThreadLocal线程副本

使用 ThreadLocal 通常分为四步:

①、创建 ThreadLocal

1
2
//创建一个ThreadLocal变量
public static ThreadLocal<String> localVariable = new ThreadLocal<>();

②、设置 ThreadLocal 的值

1
2
//设置ThreadLocal变量的值
localVariable.set("沉默王二是沙雕");

③、获取 ThreadLocal 的值

1
2
//获取ThreadLocal变量的值
String value = localVariable.get();

④、删除 ThreadLocal 的值

1
2
//删除ThreadLocal变量的值
localVariable.remove();

在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。

在数据库操作中,可以使用 ThreadLocal 存储数据库连接对象,每个线程有自己独立的数据库连接,从而避免了多线程竞争同一数据库连接的问题。

在格式化操作中,例如日期格式化,可以使用 ThreadLocal 存储 SimpleDateFormat 实例,避免多线程共享同一实例导致的线程安全问题。

ThreadLocal 有哪些优点?

每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。由于 ThreadLocal 实现了变量的线程独占,使得变量不需要同步处理,因此能够避免资源竞争。

ThreadLocal 可用于跨方法、跨类时传递上下文数据,不需要在方法间传递参数。

14.🌟ThreadLocal 怎么实现的呢?

当我们创建一个 ThreadLocal 对象并调用 set 方法时,其实是在当前线程中初始化了一个 ThreadLocalMap。

20250930165350
二哥的 Java 进阶之路:ThreadLocalMap

ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量,这样就相当于为每个线程维护了一个变量副本。

20250930165403
三分恶面渣逆袭:ThreadLoca结构图

Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry.value 设置为 null,这样可以在很大程度上避免内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

//节点类
Entry(ThreadLocal<?> k, Object v) {
//key赋值
super(k);
//value赋值
value = v;
}
}

总结一下:

ThreadLocal 的实现原理是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。

1、通过 ThreadLocal 的 set 方法将对象存入 Map 中。

2、通过 ThreadLocal 的 get 方法从 Map 中取出对象。

3、Map 的大小由 ThreadLocal 对象的多少决定。

20251027082309
ThreadLocal 的结构

什么是弱引用,什么是强引用?

我先说一下强引用,比如 User user = new User("沉默王二") 中,user 就是一个强引用,new User("沉默王二") 就是强引用对象。

当 user 被置为 null 时(user = null),new User("沉默王二") 对象就会被垃圾回收;否则即便是内存空间不足,JVM 也不会回收 new User("沉默王二") 这个强引用对象,宁愿抛出 OutOfMemoryError。

弱引用,比如说在使用 ThreadLocal 中,Entry 的 key 就是一个弱引用对象。

1
2
ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
userThreadLocal.set(new User("沉默王二"));

userThreadLocal 是一个强引用,new ThreadLocal<>() 是一个强引用对象;

new User("沉默王二") 是一个强引用对象。

调用 set 方法后,会将 key = new ThreadLocal<>() 放入 ThreadLocalMap 中,此时的 key 是一个弱引用对象。当 JVM 进行垃圾回收时,如果发现了弱引用对象,就会将其回收。

20250930165420
三分恶面渣逆袭:ThreadLocal内存分配

其关系链就是:

  • ThreadLocal 强引用 -> ThreadLocal 对象。
  • Thread 强引用 -> ThreadLocalMap。
  • ThreadLocalMap[i] 强引用了 -> Entry。
  • Entry.key 弱引用 -> ThreadLocal 对象。
  • Entry.value 强引用 -> 线程的局部变量对象。

15.🌟ThreadLocal 内存泄露是怎么回事?

ThreadLocalMap 的 Key 是 弱引用,但 Value 是强引用。

如果一个线程一直在运行,并且 value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。

20251003165249
二哥的 Java 进阶之路:ThreadLocalMap 内存溢出

那怎么解决内存泄漏问题呢?

很简单,使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。

1
2
3
4
5
6
try {
threadLocal.set(value);
// 执行业务操作
} finally {
threadLocal.remove(); // 确保能够执行清理
}

remove() 会调用 ThreadLocalMap 的 remove 方法遍历哈希表,找到 key 等于当前 ThreadLocal 的 Entry,找到后会调用 Entry 的 clear 方法,将 Entry 的 value 设置为 null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
// 计算 key 的 hash 值
int i = key.threadLocalHashCode & (len-1);
// 遍历数组,找到 key 为 null 的 Entry
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 将该 Entry 的 key 置为 null(即 Entry 失效)
e.clear();
// 清理过期的 entry
expungeStaleEntry(i);
return;
}
}
}

public void clear() {
this.referent = null;
}

然后执行 expungeStaleEntry() 方法,清除 key 为 null 的 Entry。

20251003170914
二哥的Java进阶之路:expungeStaleEntry

那为什么 key 要设计成弱引用?

弱引用的好处是,当内存不足的时候,JVM 能够及时回收掉弱引用的对象。

比如说:

1
WeakReference key = new WeakReference(new ThreadLocal());

key 是弱引用,new WeakReference(new ThreadLocal()) 是弱引用对象,当 JVM 进行垃圾回收时,只要发现了弱引用对象,就会将其回收。

一旦 key 被回收,ThreadLocalMap 在进行 set、get 的时候就会对 key 为 null 的 Entry 进行清理。

20251003171039
二哥的 Java 进阶之路:清理 entry

总结一下,在 ThreadLocal 被垃圾收集后,下一次访问 ThreadLocalMap 时,Java 会自动清理那些键为 null 的 entry,这个过程会在执行 get()set()remove()时触发。

20251003171336
二哥的 Java 进阶之路:replaceStaleEntry方法

你了解哪些 ThreadLocal 的改进方案?

在 JDK 20 Early-Access Build 28 版本中,出现了 ThreadLocal 的改进方案,即 ScopedValue

还有 Netty 中的 FastThreadLocal,它是 Netty 对 ThreadLocal 的优化,内部维护了一个索引常量 index,每次创建 FastThreadLocal 中都会自动+1,用来取代 hash 冲突带来的损耗,用空间换时间。

1
2
3
4
5
6
7
8
9
10
11
12
private final int index;

public FastThreadLocal() {
index = InternalThreadLocalMap.nextVariableIndex();
}
public static int nextVariableIndex() {
int index = nextIndex.getAndIncrement();
if (index < 0) {
nextIndex.decrementAndGet();
}
return index;
}

以及阿里的 TransmittableThreadLocal,不仅实现了子线程可以继承父线程 ThreadLocal 的功能,并且还可以跨线程池传递值。

1
2
3
4
5
6
7
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();

// 在父线程中设置
context.set("value-set-in-parent");

// 在子线程中可以读取,值是"value-set-in-parent"
String value = context.get();

20.🌟说一下你对 Java 内存模型的理解?

推荐阅读:说说 Java 的内存模型

Java 内存模型是 Java 虚拟机规范中定义的一个抽象模型,用来描述多线程环境中共享变量的内存可见性。

20251004132340
深入浅出 Java 多线程:Java内存模型

共享变量存储在主内存中,每个线程都有一个私有的本地内存,存储了共享变量的副本。

  • 当一个线程更改了本地内存中共享变量的副本,它需要 JVM 刷新到主内存中,以确保其他线程可以看到这些更改。
  • 当一个线程需要读取共享变量时,它一版会从本地内存中读取。如果本地内存中的副本是过时的,JVM 会将主内存中的共享变量最新值刷新到本地内存中。

20251004132756
三分恶面渣逆袭:实际线程工作模型

为什么线程要用自己的内存?

线程从主内存拷贝变量到工作内存,可以减少 CPU 访问 RAM 的开销。

每个线程都有自己的变量副本,可以避免多个线程同时修改共享变量导致的数据冲突。

25.🌟volatile 了解吗?

推荐阅读:volatile 关键字解析

了解。

第一,保证可见性,线程修改 volatile 变量后,其他线程能够立即看到最新值;第二,防止指令重排,volatile 变量的写入不会被重排序到它之前的代码。

volatile 怎么保证可见性的?

当线程对 volatile 变量进行写操作时,JVM 会在这个变量写入之后插入一个写屏障指令,这个指令会强制将本地内存中的变量值刷新到主内存中。

20251004145546
三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图

1
2
3
StoreStore;   // 保证写入之前的操作不会重排
volatile_write(); // 写入 volatile 变量
StoreLoad; // 保证写入后,其他线程立即可见

在 x86 架构下,通常会使用 lock 指令来实现写屏障,例如:

1
2
mov [a], 2          ; 将值 2 写入内存地址 a
lock add [a], 0 ; lock 指令充当写屏障,确保内存可见性

当线程对 volatile 变量进行读操作时,JVM 会插入一个读屏障指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。

20251004152020
三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图

我们来声明一个 volatile 变量 x:

1
volatile int x = 0

线程 A 对 x 写入后会将其最新的值刷新到主内存中,线程 B 读取 x 时由于本地内存中的 x 失效了,就会从主内存中读取最新的值。

20251004152103
三分恶面渣逆袭:volatile内存可见性

volatile 怎么保证有序性的?

JVM 会在 volatile 变量的读写前后插入 “内存屏障”,以约束 CPU 和编译器的优化行为:

  • StoreStore 屏障可以禁止volatile 写操作与普通写操作的重排
  • StoreLoad 屏障会禁止volatile 写volatile 读重排
  • LoadLoad 屏障会禁止volatile 读与后续普通读操作重排
  • LoadStore 屏障会禁止volatile 读与后续普通写操作重排

volatile 和 synchronized 的区别?

volatile 关键字用于修饰变量,确保该变量的更新操作对所有线程是可见的,即一旦某个线程修改了 volatile 变量,其他线程会立即看到最新的值。

synchronized 关键字用于修饰方法或代码块,确保同一时刻只有一个线程能够执行该方法或代码块,从而实现互斥访问。

volatile 加在基本类型和对象上的区别?

volatile 用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。

1
private volatile int count = 0;

volatile 用于引用类型时,能确保引用本身的可见性,即确保引用指向的对象地址是最新的。

但是,volatile 并不能保证引用对象内部状态的线程安全。

1
private volatile SomeObject obj = new SomeObject();

虽然 volatile 确保了 obj 引用的可见性,但对 obj 引用的 new SomeObject() 对象并不受 volatile 保护。

如果需要保证引用对象内部状态的线程安全,需要使用 synchronizedReentrantLock 等锁机制。

29.🌟synchronized 锁升级了解吗?

推荐阅读:偏向锁、轻量级锁、重量级锁到底是什么?

JDK 1.6 的时候,为了提升 synchronized 的性能,引入了锁升级机制,从低开销的锁逐步升级到高开销的锁,以最大程度减少锁的竞争。

20251009111617
三分恶面渣逆袭:Mark Word变化

没有线程竞争时,就使用低开销的“偏向锁”,此时没有额外的 CAS 操作;轻度竞争时,使用“轻量级锁”,采用 CAS 自旋,避免线程阻塞;只有在重度竞争时,才使用“重量级锁”,由 Monitor 机制实现,需要线程阻塞。

了解 synchronized 四种锁状态吗?

了解。

①、无锁状态,对象未被锁定,Mark Word 存储对象的哈希码等信息。

②、偏向锁,当线程第一次获取锁时,会进入偏向模式。Mark Word 会记录线程 ID,后续同一线程再次获取锁时,可以直接进入 synchronized 加锁的代码,无需额外加锁。

20251009112449
博客园boluo1230:偏向锁

③、轻量级锁,当多个线程在不同时段获取同一把锁,即不存在锁竞争的情况时,JVM 会采用轻量级锁来避免线程阻塞。

未持有锁的线程通过CAS 自旋等待锁释放。

20251009141857
TodoCoder:自旋和阻塞的区别

当线程进入 synchronized 加锁的代码时,如果对象的锁状态为偏向锁,也就是锁类型为“01”,偏向锁标记为“0”的状态。

20251009142009
博客园wade&luffy:Mark Word

然后采用 CAS 自旋的方式,尝试将对象头中的 Mark Word 替换为指向 Lock Record 的指针,并将 Lock Record 中的 owner 指针指向对象的 Mark Word。

20251009142022
博客园boluo1230:轻量级锁

如果这个替换动作成功了,线程就拥有了该对象的锁,对象头 Mark Word 的锁标志位会更新为“00”,表示对象处于轻量级锁状态。

④、重量级锁,如果自旋超过一定的次数,或者一个线程持有锁,一个自旋,又有第三个线程进入 synchronized 加锁的代码时,轻量级锁就会升级为重量级锁。

此时,对象头的锁类型会更新为“10”,Mark Word 会存储指向 Monitor 对象的指针,其他等待锁的线程都会进入阻塞状态。

synchronized 做了哪些优化?

在 JDK 1.6 之前,synchronized 是直接调用 ObjectMonitor 的 enter 和 exit 指令实现的,这种锁也被称为重量级锁,性能较差。

随着 JDK 版本的更新,synchronized 的性能得到了极大的优化:

①、偏向锁:同一个线程可以多次获取同一把锁,无需重复加锁。

②、轻量级锁:当没有线程竞争时,通过 CAS 自旋等待锁,避免直接进入阻塞。

③、锁消除JIT 可以在运行时进行代码分析,如果发现某些锁操作不可能被多个线程同时访问,就会对这些锁进行消除,从而减少上锁开销。

请详细说说锁升级的过程?

懵逼状态下的回答:锁升级会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁。

20251009143540
三分恶面渣逆袭:锁升级简略过程

知道一点,但不深入的回答:

20251009143612
三分恶面渣逆袭:synchronized 锁升级过程

①、偏向锁:当一个线程第一次获取锁时,JVM 会在对象头的 Mark Word 记录这个线程 ID,下次进入 synchronized 时,如果还是同一个线程,可以直接执行,无需额外加锁。

②、轻量级锁:当多个线程尝试获取锁但不是同一个时段,偏向锁会升级为轻量级锁,等待锁的线程通过 CAS 自旋避免进入阻塞状态。

③、重量级锁:如果自旋失败,锁会升级为重量级锁,等待锁的线程会进入阻塞状态,等待监视器 Monitor 进行调度。

详细解释一下:

①、从无锁到偏向锁:

当一个线程首次访问同步代码时,如果此对象处于无锁状态且偏向锁未被禁用,JVM 会将该对象头的锁标记改为偏向锁状态,并记录当前线程 ID。此时,对象头中的 Mark Word 中存储了持有偏向锁的线程 ID。

如果另一个线程尝试获取这个已被偏向的锁,JVM 会检查当前持有偏向锁的线程是否活跃。如果持有偏向锁的线程不活跃,可以将锁偏向给新的线程;否则撤销偏向锁,升级为轻量级锁。

②、偏向锁的轻量级锁:

进行偏向锁撤销时,会遍历堆栈的所有锁记录,暂停拥有偏向锁的线程,并检查锁对象。如果这个过程中发现有其他线程试图获取这个锁,JVM 会撤销偏向锁,并将锁升级为轻量级锁。

当有两个或以上线程竞争同一个偏向锁时,偏向锁模式不再有效,此时偏向锁会被撤销,对象的锁状态会升级为轻量级锁。

③、轻量级锁到重量级锁:

轻量级锁通过自旋来等待锁释放。如果自旋超过预定次数(自旋次数是可调的,并且是自适应的,失败次数多自旋次数就少),表明锁竞争激烈。

当自旋多次失败,或者有线程在等待队列中等待相同的轻量级锁时,轻量级锁会升级为重量级锁。在这种情况下,JVM 会在操作系统层面创建一个互斥锁——Mutex,所有进一步尝试获取该锁的线程将会被阻塞,直到锁被释放。

30.🌟synchronized 和 ReentrantLock 的区别了解吗?

两句话回答:synchronized 由 JVM 内部的 Monitor 机制实现,ReentrantLock基于 AQS 实现。

synchronized 可以自动加锁和解锁,ReentrantLock 需要手动 lock()unlock()

20251009151428
三分恶面渣逆袭:synchronized和ReentrantLock的区别

如果面试官还想知道更多,可以继续回答:

①、ReentrantLock 可以实现多路选择通知,绑定多个 Condition,而 synchronized 只能通过 wait 和 notify 唤醒,属于单路通知;

1
2
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

②、synchronized 可以在方法和代码块上加锁,ReentrantLock 只能在代码块上加锁,但可以指定是公平锁还是非公平锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// synchronized 修饰方法
public synchronized void method() {
// 业务代码
}

// synchronized 修饰代码块
synchronized (this) {
// 业务代码
}

// ReentrantLock 加锁
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 业务代码
} finally {
lock.unlock();
}

③、ReentrantLock 提供了一种能够中断等待锁的线程机制,通过 lock.lockInterruptibly() 来实现。

1
2
3
4
5
6
ReentrantLock lock = new ReentrantLock();
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
// 处理中断异常
}

并发量大的情况下,使用 synchronized 还是 ReentrantLock?

我更倾向于 ReentrantLock,因为:

  • ReentrantLock 提供了超时和公平锁等特性,可以应对更复杂的并发场景。
  • ReentrantLock 允许更细粒度的锁控制,能有效减少锁竞争。
  • ReentrantLock 支持条件变量 Condition,可以实现比 synchronized 更友好的线程间通信机制。

Lock 了解吗?

Lock 是 JUC 中的一个接口,最常用的实现类包括可重入锁 ReentrantLock、读写锁 ReentrantReadWriteLock 等。

ReentrantLock 的 lock() 方法实现逻辑了解吗?

lock 方法的具体实现由 ReentrantLock 内部的 Sync 类来实现,涉及到线程的自旋、阻塞队列、CAS、AQS 等。

20251027092407
二哥的Java 进阶之路:Lock.lock() 方法源码

lock 方法会首先尝试通过 CAS 来获取锁。如果当前锁没有被持有,会将锁状态设置为 1,表示锁已被占用。否则,会将当前线程加入到 AQS 的等待队列中。

1
2
3
4
5
6
final void lock() {
if (compareAndSetState(0, 1)) // 尝试直接获取锁
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 如果获取失败,进入AQS队列等待
}

32.🌟说说 ReentrantLock 的实现原理?

ReentrantLock 是基于 AQS 实现的 可重入排他锁,使用 CAS 尝试获取锁,失败的话,会进入 CLH 阻塞队列,支持公平锁、非公平锁,可以中断、超时等待。

20251009155017
三分恶面渣逆袭:ReentrantLock 非公平锁加锁流程简图

内部通过一个计数器 state 来跟踪锁的状态和持有次数。当线程调用 lock() 方法获取锁时,ReentrantLock 会检查 state 的值,如果为 0,通过 CAS 修改为 1,表示成功加锁。否则根据当前线程的公平性策略,加入到等待队列中。

线程首次获取锁时,state 值设为 1;如果同一个线程再次获取锁时,state 加 1;每释放一次锁,state 减 1。

当线程调用 unlock() 方法时,ReentrantLock 会将持有锁的 state 减 1,如果 state = 0,则释放锁,并唤醒等待队列中的线程来竞争锁。

使用方式非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CounterWithLock {
private int count = 0;
private final Lock lock = new ReentrantLock();

public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 释放锁
}
}

public int getCount() {
return count;
}
}

new ReentrantLock() 默认创建的是非公平锁 NonfairSync。在非公平锁模式下,锁可能会授予刚刚请求它的线程,而不考虑等待时间。当切换到公平锁模式下,锁会授予等待时间最长的线程。

34.🌟CAS 了解多少?

推荐阅读:一文彻底搞清楚 Java 实现 CAS 的原理

CAS 是一种乐观锁,用于比较一个变量的当前值是否等于预期值,如果相等,则更新值,否则重试。

20251009160404
CAS 原子性:博客园的紫薇哥哥

在 CAS 中,有三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

先判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,当前线程就放弃更新。

这个比较和替换的操作需要是原子的,不可中断的。Java 中的 CAS 是由 Unsafe 类实现的。

AtomicInteger 类的 compareAndSet 就是一个 CAS 方法:

1
2
3
4
AtomicInteger atomicInteger = new AtomicInteger(0);
int expect = 0;
int update = 1;
atomicInteger.compareAndSet(expect, update);

它调用的是 Unsafe 的 compareAndSwapInt。

20251009160530
二哥的 Java 进阶之路:compareAndSwapInt

怎么保证 CAS 的原子性?

CPU 会发出一个 LOCK 指令进行总线锁定,阻止其他处理器对内存地址进行操作,直到当前指令执行完成。

1
lock cmpxchg [esi], eax  ; 比较 esi 地址中的值与 eax,如果相等则替换

20251027093600
总线锁定:博客园的紫薇哥哥

35.🌟CAS 有什么问题?

CAS 存在三个经典问题,ABA 问题、自旋开销大、只能操作一个变量等。

20251009160610
三分恶面渣逆袭:CAS三大问题

什么是 ABA 问题?

ABA 问题指的是,一个值原来是 A,后来被改为 B,再后来又被改回 A,这时 CAS 会误认为这个值没有发生变化。

1
2
3
线程 1:CAS(A → B),修改变量 A → B
线程 2:CAS(B → A),变量又变回 A
线程 3:CAS(A → C),CAS 成功,但实际数据已被修改过!

可以使用版本号/时间戳的方式来解决 ABA 问题。

比如说,每次变量更新时,不仅更新变量的值,还更新一个版本号。CAS 操作时,不仅比较变量的值,还比较版本号。

1
2
3
4
5
6
7
8
9
10
11
12
13
class OptimisticLockExample {
private int version;
private int value;

public synchronized boolean updateValue(int newValue, int currentVersion) {
if (this.version == currentVersion) {
this.value = newValue;
this.version++;
return true;
}
return false;
}
}

Java 的 AtomicStampedReference 就增加了版本号,它会同时检查引用值和 stamp 是否都相等。

20251009160653
二哥的 Java 进阶之路:AtomicStampedReference

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ABAFix {
private static AtomicStampedReference<String> ref = new AtomicStampedReference<>("100", 1);

public static void main(String[] args) {
new Thread(() -> {
int stamp = ref.getStamp();
ref.compareAndSet("100", "200", stamp, stamp + 1);
ref.compareAndSet("200", "100", ref.getStamp(), ref.getStamp() + 1);
}).start();

new Thread(() -> {
try { Thread.sleep(100); } catch (InterruptedException e) {}
int stamp = ref.getStamp();
System.out.println("CAS 结果:" + ref.compareAndSet("100", "300", stamp, stamp + 1));
}).start();
}
}

自旋开销大怎么解决?

CAS 失败时会不断自旋重试,如果一直不成功,会给 CPU 带来非常大的执行开销。

可以加一个自旋次数的限制,超过一定次数,就切换到 synchronized 挂起线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
int MAX_RETRIES = 10;
int retries = 0;
while (!atomicInt.compareAndSet(expect, update)) {
retries++;
if (retries > MAX_RETRIES) {
synchronized (this) { // 超过次数,使用 synchronized 处理
if (atomicInt.get() == expect) {
atomicInt.set(update);
}
}
break;
}
}

涉及到多个变量同时更新怎么办?

可以将多个变量封装为一个对象,使用 AtomicReference 进行 CAS 更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Account {
static class Balance {
final int money;
final int points;

Balance(int money, int points) {
this.money = money;
this.points = points;
}
}

private AtomicReference<Balance> balance = new AtomicReference<>(new Balance(100, 10));

public void update(int newMoney, int newPoints) {
Balance oldBalance, newBalance;
do {
oldBalance = balance.get();
newBalance = new Balance(newMoney, newPoints);
} while (!balance.compareAndSet(oldBalance, newBalance));
}
}

40.🌟死锁问题怎么排查呢?

首先从系统级别上排查,比如说在 Linux 生产环境中,可以先使用 top ps 等命令查看进程状态,看看是否有进程占用了过多的资源。

接着,使用 JDK 自带的一些性能监控工具进行排查,比如说 使用 jps -l 查看当前进程,然后使用 jstack 进程号 查看当前进程的线程堆栈信息,看看是否有线程在等待锁资源。

也可以使用一些可视化的性能监控工具,比如说 JConsole、VisualVM 等,查看线程的运行状态、锁的竞争情况等。

20251009170605
三分恶面渣逆袭:线程死锁检测

我们来通过实际代码说明一下:

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
class DeadLockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1) {
System.out.println("线程1获取到了锁1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("线程1获取到了锁2");
}
}
}).start();

new Thread(() -> {
synchronized (lock2) {
System.out.println("线程2获取到了锁2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("线程2获取到了锁1");
}
}
}).start();
}
}

创建两个线程,每个线程都试图按照不同的顺序获取两个锁(lock1 和 lock2)

锁的获取顺序不一致很容易导致死锁。运行这段代码,会发现两个线程都无法继续执行,进入了死锁状态。

20251009170646
二哥的 Java 进阶之路:死锁发生了

运行 jstack pid 命令,可以看到死锁的线程信息。

20251009170656
jstack pid 查看死锁信息

编码时,尽量使用 tryLock() 代替 lock()tryLock() 可以设置超时时间,避免线程一直等待。

同时,尽量避免一个线程同时获取多个锁,如果需要多个锁,可以按照固定的顺序获取。

推荐阅读:

42.🌟聊聊悲观锁和乐观锁?(补充)

好的。

悲观锁认为每次访问共享资源时都会发生冲突,所在在操作前一定要先加锁,防止其他线程修改数据。

乐观锁认为冲突不会总是发生,所以在操作前不加锁,而是在更新数据时检查是否有其他线程修改了数据。如果发现数据被修改了,就会重试。

乐观锁发现有线程过来修改数据,怎么办?

可以重新读取数据,然后再尝试更新,直到成功为止或达到最大重试次数。

1
2
3
读取数据 -> 尝试更新 -> 成功(返回成功)
|
-> 失败 -> 重试 -> 达到最大次数 -> 返回失败

写个代码演示一下:

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
class CasRetryExample {
private static AtomicInteger counter = new AtomicInteger(0);
private static final int MAX_RETRIES = 5;

public static void main(String[] args) {
boolean success = false;
int retries = 0;

while (retries < MAX_RETRIES) {
int currentValue = counter.get();
boolean updated = counter.compareAndSet(currentValue, currentValue + 1);

if (updated) {
System.out.println("更新成功,当前值: " + counter.get());
success = true;
break;
} else {
retries++;
System.out.println("更新失败,进行第 " + retries + " 次重试");
}
}

if (!success) {
System.out.println("达到最大重试次数,操作失败");
}
}
}

48.🌟能说一下 ConcurrentHashMap 的实现吗?(补充)

好的。ConcurrentHashMap 是 HashMap 的线程安全版本。

JDK 7 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁。不同的线程可以同时操作不同的段,从而实现并发。

20251010104933
初念初恋:JDK 7 ConcurrentHashMap

JDK 8 使用了一种更加细粒度的锁——桶锁,再配合 CAS + synchronized 代码块控制并发写入,以最大程度减少锁的竞争。

20251010105023
初念初恋:JDK 8 ConcurrentHashMap

对于读操作,ConcurrentHashMap 使用了 volatile 变量来保证内存可见性。

对于写操作,ConcurrentHashMap 优先使用 CAS 尝试插入,如果成功就直接返回;否则使用 synchronized 代码块进行加锁处理。

说一下 JDK 7 中 ConcurrentHashMap 的实现原理?

好的。

JDK 7 的 ConcurrentHashMap 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁,每个段类似一个 Hashtable。

20251010105058
三分恶面渣逆袭:ConcurrentHashMap示意图

每个段维护一个键值对数组 HashEntry<K, V>[] table,HashEntry 是一个单项链表。

1
2
3
4
5
6
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
final HashEntry<K,V> next;
}

段继承了 ReentrantLock,所以每个段都是一个可重入锁,不同的线程可以同时操作不同的段,从而实现并发。

1
2
3
4
static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
transient int count;
}

说一下 JDK 7 中 ConcurrentHashMap 的 put 流程?

put 流程和 HashMap 非常类似,只不过是先定位到具体的段,再通过 ReentrantLock 去操作而已。一共可以分为 4 个步骤:

第一步,计算 key 的 hash,定位到段,段如果是空就先初始化;

第二步,使用 ReentrantLock 进行加锁,如果加锁失败就自旋,自旋超过次数就阻塞,保证一定能获取到锁;

第三步,遍历段中的键值对 HashEntry,key 相同直接替换,key 不存在就插入。

第四步,释放锁。

20251010105204
三分恶面渣逆袭:JDK7 put 流程

说一下 JDK 7 中 ConcurrentHashMap 的 get 流程?

get 就更简单了,先计算 key 的 hash 找到段,再遍历段中的键值对,找到就直接返回 value。

get 不用加锁,因为是 value 是 volatile 的,所以线程读取 value 时不会出现可见性问题。

说一下 JDK 8 中 ConcurrentHashMap 的实现原理?

好的。

JDK 8 中的 ConcurrentHashMap 取消了分段锁,采用 CAS + synchronized 来实现更细粒度的桶锁,并且使用红黑树来优化链表以提高哈希冲突时的查询效率,性能比 JDK 7 有了很大的提升。

说一下 JDK 8 中 ConcurrentHashMap 的 put 流程?

20251010105242
三分恶面渣逆袭:Java 8 put 流程

第一步,计算 key 的 hash,以确定桶在数组中的位置。如果数组为空,采用 CAS 的方式初始化,以确保只有一个线程在初始化数组。

1
2
3
4
5
6
7
8
9
// 计算 hash
int hash = spread(key.hashCode());

// 初始化数组
if (tab == null || (n = tab.length) == 0)
tab = initTable();

// 计算桶的位置
int i = (n - 1) & hash;

第二步,如果桶为空,直接 CAS 插入节点。如果 CAS 操作失败,会退化为 synchronized 代码块来插入节点。

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
// CAS 插入节点
if (tabAt(tab, i) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}

// 否则,使用 synchronized 代码块插入节点
else {
synchronized (f) { // **只锁当前桶**
if (tabAt(tab, i) == f) { // 确保未被其他线程修改
if (f.hash >= 0) { // 链表处理
for (Node<K,V> e = f;;) {
K ek;
if (e.hash == hash && ((ek = e.key) == key || (key != null && key.equals(ek)))) {
e.val = value;
break;
}
e = e.next;
}
} else if (f instanceof TreeBin) { // **红黑树处理**
((TreeBin<K,V>) f).putTreeVal(hash, key, value);
}
}
}
}

插入的过程中会判断桶的哈希是否小于 0(f.hash >= 0),小于 0 说明是红黑树,大于等于 0 说明是链表。

这里补充一点:在 ConcurrentHashMap 的实现中,红黑树节点 TreeBin 的 hash 值固定为 -2。

20251010105550
二哥的 Java 进阶之路:TreeBin 的哈希值固定为 -2

第三步,如果链表长度超过 8,转换为红黑树。

1
2
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);

第四步,在插入新节点后,会调用 addCount() 方法检查是否需要扩容。

1
addCount(1L, binCount);

说一下 JDK 8 中 ConcurrentHashMap 的 get 流程?

get 也是通过 key 的 hash 进行定位,如果该位置节点的哈希匹配且键相等,则直接返回值。

20251010105607
二哥的 Java 进阶之路:HashMap 和 ConcurrentHashMap 的 get 方法

如果节点的哈希为负数,说明是个特殊节点,比如说如树节点或者正在迁移的节点,就调用find方法查找。

20251010105625
二哥的 Java 进阶之路:ForwardingNode和TreeNode的 find 方法

否则遍历链表查找匹配的键。如果都没找到,返回 null。

说一下 HashMap 和 ConcurrentHashMap 的区别?

HashMap 是非线程安全的,多线程环境下应该使用 ConcurrentHashMap。

你项目中怎么使用 ConcurrentHashMap 的?

技术派实战项目中,很多地方都用到了 ConcurrentHashMap,比如说在异步工具类 AsyncUtil 中,就使用了 ConcurrentHashMap 来存储任务的名称和它们的运行时间,以便观察和分析任务的执行情况。

20251027103144
二哥的 Java 进阶之路:技术派的源码封装 ConcurrentHashMap

说一下 ConcurrentHashMap 对 HashMap 的改进?

首先是 hash 的计算方法上,ConcurrentHashMap 的 spread 方法接收一个已经计算好的 hashCode,然后将这个哈希码的高 16 位与自身进行异或运算。

1
2
3
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}

比 HashMap 的 hash 计算多了一个 & HASH_BITS 的操作。这里的 HASH_BITS 是一个常数,值为 0x7fffffff,它确保结果是一个非负整数。

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

另外,ConcurrentHashMap 对节点 Node 做了进一步的封装,比如说用 Forwarding Node 来表示正在进行扩容的节点。

1
2
3
4
5
6
7
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}

最后就是 put 方法,通过 CAS + synchronized 代码块来进行并发写入。

20251027104143
二哥的 Java 进阶之路:ConcurrentHashMap 的源码

为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而在 JDK 1.8 要用 synchronized

JDK 1.7 中的 ConcurrentHashMap 使用了分段锁机制,每个 Segment 都继承了 ReentrantLock,这样可以保证每个 Segment 都可以独立地加锁。

而在 JDK 1.8 中,ConcurrentHashMap 取消了 Segment 分段锁,采用了更加精细化的锁——桶锁,以及 CAS 无锁算法,每个桶都可以独立地加锁,只有在 CAS 失败时才会使用 synchronized 代码块加锁,这样可以减少锁的竞争,提高并发性能。

53.🌟什么是线程池?

线程池是用来管理和复用线程的工具,它可以减少线程的创建和销毁开销。

20251010110039
三分恶面渣逆袭:管理线程的池子

在 Java 中,ThreadPoolExecutor 是线程池的核心实现,它通过核心线程数、最大线程数、任务队列和拒绝策略来控制线程的创建和执行。

举个例子:就像你开了一家餐厅,线程池就相当于固定数量的服务员,顾客(任务)来了就安排空闲的服务员(线程)处理,避免了频繁招人和解雇的成本。

55.🌟说一下线程池的工作流程?

可以简单总结为:

任务提交 → 核心线程执行 → 任务队列缓存 → 非核心线程执行 → 拒绝策略处理。

第一步,线程池通过 submit() 提交任务。

1
2
3
4
ExecutorService threadPool = Executors.newFixedThreadPool(5);
threadPool.submit(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "办理业务");
});

第二步,线程池会先创建核心线程来执行任务。

1
2
3
4
5
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) {
return;
}
}

第三步,如果核心线程都在忙,任务会被放入任务队列中。

1
workQueue.offer(task);

第四步,如果任务队列已满,且当前线程数量小于最大线程数,线程池会创建新的线程来处理任务。

1
if (!addWorker(command, false))

第五步,如果线程池中的线程数量已经达到最大线程数,且任务队列已满,线程池会执行拒绝策略。

1
handler.rejectedExecution(command, this);

另外一版回答。

第一步,创建线程池。

第二步,调用线程池的 execute()方法,准备执行任务。

  • 如果正在运行的线程数量小于 corePoolSize,那么线程池会创建一个新的线程来执行这个任务;
  • 如果正在运行的线程数量大于或等于 corePoolSize,那么线程池会将这个任务放入等待队列;
  • 如果等待队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么线程池会创建新的线程来执行这个任务;
  • 如果等待队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会执行拒绝策略。

20251010110236
三分恶面渣逆袭:线程池执行流程

第三步,线程执行完毕后,线程并不会立即销毁,而是继续保持在池中等待下一个任务。

第四步,当线程空闲时间超出指定时间,且当前线程数量大于核心线程数时,线程会被回收。

能用一个生活中的例子说明下吗?

可以。有个名叫“你一定暴富”的银行,该银行有 6 个窗口,现在开放了 3 个窗口,坐着 3 个小姐姐在办理业务。

靓仔小二去办理业务,会遇到什么情况呢?

第一情况,小二发现有个空闲的小姐姐,正在翘首以盼,于是小二就快马加鞭跑过去办理了。

20251010110317
三分恶面渣逆袭:直接办理

第二种情况,小姐姐们都在忙,接待员小美招呼小二去排队区区取号排队,让小二稍安勿躁。

20251010110343
三分恶面渣逆袭:排队等待

第三种情况,不仅小姐姐们都在忙,排队区也满了,小二着急用钱,于是脾气就上来了,和接待员小美对线了起来,要求开放另外 3 个空闲的窗口。

小美迫于小二的压力,开放了另外 3 个窗口,排队区的人立马就冲了过去。

20251010110423
三分恶面渣逆袭:排队区满

第四种情况,6 个窗口的小姐姐都在忙,排队区也满了。。。

20251010110434
三分恶面渣逆袭:等待区,排队区都满

接待员小美给了小二 4 个选项:

  1. 对不起,我们暴富银行系统瘫痪了。
  2. 没看忙着呢,谁叫你来办的你找谁去!
  3. 靓仔,看你比较急,去队里偷偷加个塞。
  4. 不好意思,今天没办法,你改天再来吧。

这个流程和线程池不能说一模一样,简直就是一模一样:

  1. corePoolSize 对应营业窗口数 3
  2. maximumPoolSize 对应最大窗口数 6
  3. workQueue 对应排队区
  4. handler 对应接待员小美
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
class ThreadPoolDemo {
public static void main(String[] args) {
// 创建一个线程池
ExecutorService threadPool = new ThreadPoolExecutor(
3, // 核心线程数
6, // 最大线程数
0, // 线程空闲时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(10), // 等待队列
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
// 模拟 10 个顾客来银行办理业务
try {
for (int i = 1; i <= 10; i++) {
final int tempInt = i;
threadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "办理业务" + tempInt);
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
threadPool.shutdown();
}
}
}

56.🌟线程池的主要参数有哪些?

线程池有 7 个参数,需要重点关注的有核心线程数、最大线程数、等待队列、拒绝策略。

20251010110511
三分恶面渣逆袭:线程池参数

①、corePoolSize:核心线程数,长期存活,执行任务的主力。

②、maximumPoolSize:线程池允许的最大线程数。

③、workQueue:任务队列,存储等待执行的任务。

④、handler:拒绝策略,任务超载时的处理方式。也就是线程数达到 maximumPoolSiz,任务队列也满了的时候,就会触发拒绝策略。

⑤、threadFactory:线程工厂,用于创建线程,可自定义线程名。

⑥、keepAliveTime:非核心线程的存活时间,空闲时间超过该值就销毁。

⑦、unit:keepAliveTime 参数的时间单位:

  • TimeUnit.DAYS; 天
  • TimeUnit.HOURS; 小时
  • TimeUnit.MINUTES; 分钟
  • TimeUnit.SECONDS; 秒
  • TimeUnit.MILLISECONDS; 毫秒
  • TimeUnit.MICROSECONDS; 微秒
  • TimeUnit.NANOSECONDS; 纳秒

能简单说一下参数之间的关系吗?

一句话:任务优先使用核心线程执行,满了进入等待队列,队列满了启用非核心线程备用,线程池达到最大线程数量后触发拒绝策略,非核心线程的空闲时间超过存活时间就被回收。

核心线程数不够会怎么进行处理?

当提交的任务数超过了 corePoolSize,但是小于 maximumPoolSize 时,线程池会创建新的线程来处理任务。

当提交的任务数超过了 maximumPoolSize 时,线程池会根据拒绝策略来处理任务。

举个例子说一下这些参数的变化?

假设一个场景,线程池的配置如下:

1
2
3
4
5
corePoolSize = 5
maximumPoolSize = 10
keepAliveTime = 60
workQueue = LinkedBlockingQueue(容量为100
handler = ThreadPoolExecutor.AbortPolicy()

场景一:当系统启动后,有 10 个任务提交到线程池。

  • 前 5 个任务会立即执行,因为核心线程数足够容纳它们。
  • 随后的 5 个任务会被放入等待队列。

场景二:如果此时再有 100 个任务提交到线程池。

  • 工作队列已满,线程池会创建额外的线程来执行这些任务,直到线程总数达到 10。
  • 如果任务继续增加,超过了工作队列+最大线程数的限制,新来的任务会被 AbortPolicy 拒绝,抛出 RejectedExecutionException 异常。

场景三:如果任务突然减少:

核心线程会一直运行,而超出核心线程数的线程,会在 60 秒后回收。

57.🌟线程池的拒绝策略有哪些?

有四种:

  • AbortPolicy:默认的拒绝策略,会抛 RejectedExecutionException 异常。
  • CallerRunsPolicy:让提交任务的线程自己来执行这个任务,也就是调用 execute 方法的线程。
  • DiscardOldestPolicy:等待队列会丢弃队列中最老的一个任务,也就是队列中等待最久的任务,然后尝试重新提交被拒绝的任务。
  • DiscardPolicy:丢弃被拒绝的任务,不做任何处理也不抛出异常。

20251010110646
三分恶面渣逆袭:四种策略

分别对应着小二去银行办理业务被经理“薄纱”的四个场景:“我们系统瘫痪了”、“谁叫你来办的你找谁去”、“看你比较急,去队里加个塞”、“今天没办法,不行你看改一天”。

当线程池无法接受新的任务时,也就是线程数达到 maximumPoolSize,任务队列也满了的时候,就会触发拒绝策略。

如果默认策略不能满足需求,可以通过实现 RejectedExecutionHandler 接口来定义自己的淘汰策略。例如:记录被拒绝任务的日志。

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
class CustomRejectedHandler {
public static void main(String[] args) {
// 自定义拒绝策略
RejectedExecutionHandler rejectedHandler = (r, executor) -> {
System.out.println("Task " + r.toString() + " rejected. Queue size: "
+ executor.getQueue().size());
};

// 自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
10, // 空闲线程存活时间
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2), // 阻塞队列容量
Executors.defaultThreadFactory(),
rejectedHandler // 自定义拒绝策略
);

for (int i = 0; i < 10; i++) {
final int taskNumber = i;
executor.execute(() -> {
System.out.println("Executing task " + taskNumber);
try {
Thread.sleep(1000); // 模拟任务耗时
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}

executor.shutdown();
}
}

67.🌟线程池调优了解吗?(补充)

20251010111648
三分恶面渣逆袭:线程池调优

首先我会根据任务类型设置核心线程数参数,比如 IO 密集型任务会设置为 CPU 核心数*2 的经验值。

其次我会结合线程池动态调整的能力,在流量波动时通过 setCorePoolSize 平滑扩容,或者直接使用 DynamicTp 实现线程池参数的自动化调整。

最后,我会通过内置的监控指标建立容量预警机制。比如通过 JMX 监控线程池的运行状态,设置阈值,当线程池的任务队列长度超过阈值时,触发告警。

69.🌟你能设计实现一个线程池吗?

推荐阅读:三分恶线程池原理

线程池的主要目的是为了避免频繁地创建和销毁线程。

20251010111742
三分恶面渣逆袭:线程池主要实现流程

我会把线程池看作一个工厂,里面有一群“工人”,也就是线程了,专门用来做任务。

当任务来了,需要先判断有没有空闲的工人,如果有就把任务交给他们;如果没有,就把任务暂存到一个任务队列里,等工人忙完了再去处理。

如果队列满了,还没有空闲的工人,就要考虑扩容,让预备的工人过来干活,但不能超过预定的最大值,防止工厂被挤爆。

如果连扩容也没法解决,就需要一个拒绝策略,可能直接拒绝任务或者报个错。

核心线程池类(可参考):

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class CustomThreadPoolExecutor {

private final int corePoolSize;
private final int maximumPoolSize;
private final long keepAliveTime;
private final TimeUnit unit;
private final BlockingQueue<Runnable> workQueue;
private final RejectedExecutionHandler handler;

private volatile boolean isShutdown = false;
private int currentPoolSize = 0;

// 构造方法
public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) {
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.keepAliveTime = keepAliveTime;
this.unit = unit;
this.workQueue = workQueue;
this.handler = handler;
}

// 提交任务
public void execute(Runnable task) {
if (isShutdown) {
throw new IllegalStateException("ThreadPool is shutdown");
}

synchronized (this) {
// 如果当前线程数小于核心线程数,直接创建新线程
if (currentPoolSize < corePoolSize) {
new Worker(task).start();
currentPoolSize++;
return;
}

// 尝试将任务添加到队列中
if (!workQueue.offer(task)) {
if (currentPoolSize < maximumPoolSize) {
new Worker(task).start();
currentPoolSize++;
} else {
// 调用拒绝策略
handler.rejectedExecution(task, null);
}
}
}
}

// 关闭线程池
public void shutdown() {
isShutdown = true;
}

// 工作线程
private class Worker extends Thread {
private Runnable task;

Worker(Runnable task) {
this.task = task;
}

@Override
public void run() {
while (task != null || (task = getTask()) != null) {
try {
task.run();
} finally {
task = null;
}
}
}

// 从队列中获取任务
private Runnable getTask() {
try {
return workQueue.poll(keepAliveTime, unit);
} catch (InterruptedException e) {
return null;
}
}
}
}

拒绝策略:

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
/**
* 拒绝策略
*/
class CustomRejectedExecutionHandler {

// AbortPolicy 抛出异常
public static class AbortPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RuntimeException("Task " + r.toString() + " rejected from " + e.toString());
}
}

// DiscardPolicy 什么都不做
public static class DiscardPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// Do nothing
}
}

// DiscardOldestPolicy 丢弃队列中最旧的任务
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run();
}
}
}
}

使用示例:

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
class ThreadPoolTest {
public static void main(String[] args) {
// 创建线程池
CustomThreadPoolExecutor executor = new CustomThreadPoolExecutor(
2, 4, 10, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2),
new CustomRejectedExecutionHandler.AbortPolicy());

// 提交任务
for (int i = 0; i < 10; i++) {
final int index = i;
executor.execute(() -> {
System.out.println("Task " + index + " is running");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}

// 关闭线程池
executor.shutdown();
}
}

执行结果:

20251010111815
二哥的 Java 进阶之路:自定义线程池

手写一个数据库连接池,可以吗?

可以的,我的思路是这样的:数据库连接池主要是为了避免每次操作数据库时都去创建连接,因为那样很浪费资源。所以我打算在初始化时预先创建好固定数量的连接,然后把它们放到一个线程安全的容器里,后续有请求的时候就从队列里拿,使用完后再归还到队列中。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class SimpleConnectionPool {
// 配置
private String jdbcUrl;
private String username;
private String password;
private int maxConnections;
private BlockingQueue<Connection> connectionPool;

// 构造方法
public SimpleConnectionPool(String jdbcUrl, String username, String password, int maxConnections) throws SQLException {
this.jdbcUrl = jdbcUrl;
this.username = username;
this.password = password;
this.maxConnections = maxConnections;
this.connectionPool = new LinkedBlockingQueue<>(maxConnections);

// 初始化连接池
for (int i = 0; i < maxConnections; i++) {
connectionPool.add(createNewConnection());
}
}

// 创建新连接
private Connection createNewConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl, username, password);
}

// 获取连接
public Connection getConnection(long timeout, TimeUnit unit) throws InterruptedException, SQLException {
Connection connection = connectionPool.poll(timeout, unit); // 等待指定时间获取连接
if (connection == null) {
throw new SQLException("Timeout: Unable to acquire a connection.");
}
return connection;
}

// 归还连接
public void releaseConnection(Connection connection) throws SQLException {
if (connection != null) {
if (connection.isClosed()) {
// 如果连接已关闭,创建一个新连接补充到池中
connectionPool.add(createNewConnection());
} else {
// 将连接归还到池中
connectionPool.offer(connection);
}
}
}

// 关闭所有连接
public void closeAllConnections() throws SQLException {
for (Connection connection : connectionPool) {
if (!connection.isClosed()) {
connection.close();
}
}
}

// 测试用例
public static void main(String[] args) {
try {
SimpleConnectionPool pool = new SimpleConnectionPool(
"jdbc:mysql://localhost:3306/pai_coding", "root", "", 5
);

// 获取连接
Connection conn = pool.getConnection(5, TimeUnit.SECONDS);

// 使用连接(示例查询)
System.out.println("Connection acquired: " + conn);
Thread.sleep(2000); // 模拟查询

// 归还连接
pool.releaseConnection(conn);
System.out.println("Connection returned.");

// 关闭所有连接
pool.closeAllConnections();
} catch (Exception e) {
e.printStackTrace();
}
}
}

运行结果:

20251010111849
二哥的Java 进阶之路:数据库连接池

JVM

3.🌟能说一下 JVM 的内存区域吗?

推荐阅读:深入理解 JVM 的运行时数据区

按照 Java 虚拟机规范,JVM 的内存区域可以细分为程序计数器虚拟机栈本地方法栈方法区

20251010150406
三分恶面渣逆袭:Java虚拟机运行时数据区

其中方法区是线程共享的,虚拟机栈本地方法栈程序计数器是线程私有的。

介绍一下程序计数器?

程序计数器也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码行号指示器。

介绍一下 Java 虚拟机栈?

Java 虚拟机栈的生命周期与线程相同。

当线程执行一个方法时,会创建一个对应的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入虚拟机栈中。当方法执行完毕后,栈帧会从虚拟机栈中移除。

20251010150604
三分恶面渣逆袭:Java虚拟机栈

一个什么都没有的空方法,空的参数都没有,那局部变量表里有没有变量?

对于静态方法,由于不需要访问实例对象 this,因此在局部变量表中不会有任何变量。

对于非静态方法,即使是一个完全空的方法,局部变量表中也会有一个用于存储 this 引用的变量。this 引用指向当前实例对象,在方法调用时被隐式传入。

详细解释一下:

比如说有这样一段代码:

1
2
3
4
5
6
7
8
9
public class VarDemo1 {
public void emptyMethod() {
// 什么都没有
}

public static void staticEmptyMethod() {
// 什么都没有
}
}

javap -v VarDemo1 命令查看编译后的字节码,就可以在 emptyMethod 中看到这样的内容:

20251010150844
二哥的 Java 进阶之路:javap emptyMethod

这里的 locals=1 表示局部变量表有一个变量,即 this,Slot 0 位置存储了 this 引用。

而在静态方法 staticEmptyMethod 中,你会看到这样的内容:

20251010150900
二哥的 Java 进阶之路:javap staticEmptyMethod

这里的 locals=0 表示局部变量表为空,因为静态方法属于类级别方法,不需要 this 引用,也就没有局部变量。

介绍一下本地方法栈?

本地方法栈与虚拟机栈相似,区别在于虚拟机栈是为 JVM 执行 Java 编写的方法服务的,而本地方法栈是为 Java 调用本地 native 方法服务的,通常由 C/C++ 编写。

在本地方法栈中,主要存放了 native 方法的局部变量、动态链接和方法出口等信息。当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法。

介绍一下本地方法栈的运行场景?

当 Java 应用需要与操作系统底层或硬件交互时,通常会用到本地方法栈。

比如调用操作系统的特定功能,如内存管理、文件操作、系统时间、系统调用等。

详细说明一下:

比如说获取系统时间的 System.currentTimeMillis() 方法就是调用本地方法,来获取操作系统当前时间的。

20251010151152
二哥的Java 进阶之路:currentTimeMillis方法源码

再比如 JVM 自身的一些底层功能也需要通过本地方法来实现。像 Object 类中的 hashCode() 方法、clone() 方法等。

20251010151210
二哥的Java 进阶之路:hashCode方法源码

native 方法解释一下?

推荐阅读:手把手教你用 C语言实现 Java native 本地方法

native 方法是在 Java 中通过 native 关键字声明的,用于调用非 Java 语言,如 C/C++ 编写的代码。Java 可以通过 JNI,也就是 Java Native Interface 与底层系统、硬件设备、或者本地库进行交互。

介绍一下 Java 堆?

堆是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,主要用来存储 new 出来的对象。

20251010151832
二哥的 Java 进阶之路:堆

Java 中“几乎”所有的对象都会在堆中分配,堆也是垃圾收集器管理的目标区域。

从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以堆又被细分为新生代老年代Eden空间From Survivor空间To Survivor空间等。

20251010151924
三分恶面渣逆袭:Java 堆内存结构

随着 JIT 编译器的发展和逃逸技术的逐渐成熟,“所有的对象都会分配到堆上”就不再那么绝对了。

从 JDK 7 开始,JVM 默认开启了逃逸分析,意味着如果某些方法中的对象引用没有被返回或者没有在方法体外使用,也就是未逃逸出去,那么对象可以直接在栈上分配内存。

堆和栈的区别是什么?

堆属于线程共享的内存区域,几乎所有 new 出来的对象都会堆上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不再被任何变量引用,最后被垃圾收集器回收。

栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用等,通常随着方法调用的结束而自动释放,不需要垃圾收集器处理。

介绍一下方法区?

方法区并不真实存在,属于 Java 虚拟机规范中的一个逻辑概念,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。

在 HotSpot 虚拟机中,方法区的实现称为永久代 PermGen,但在 Java 8 及之后的版本中,已经被元空间 Metaspace 所替代。

变量存在堆栈的什么位置?

对于局部变量,它存储在当前方法栈帧中的局部变量表中。当方法执行完毕,栈帧被回收,局部变量也会被释放。

1
2
3
public void method() {
int localVar = 100; // 局部变量,存储在栈帧中的局部变量表里
}

对于静态变量来说,它存储在 Java 虚拟机规范中的方法区中,在 Java 7 中是永久代,在 Java8 及以后 是元空间。

1
2
3
public class StaticVarDemo {
public static int staticVar = 100; // 静态变量,存储在方法区中
}

6.🌟对象创建的过程了解吗?

当我们使用 new 关键字创建一个对象时,JVM 首先会检查 new 指令的参数是否能在常量池中定位到类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,就先执行类加载。

20251010153321
二哥的 Java 进阶之路:对象的创建过程

如果已经加载,JVM 会为对象分配内存完成初始化,比如数值类型的成员变量初始值是 0,布尔类型是 false,对象类型是 null。

接下来会设置对象头,里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。

最后,JVM 会执行构造方法 <init> 完成赋值操作,将成员变量赋值为预期的值,比如 int age = 18,这样一个对象就创建完成了。

对象的销毁过程了解吗?

当对象不再被任何引用指向时,就会变成垃圾。垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。

垃圾收集器通过标记清除、标记复制、标记整理等算法来回收内存,将对象占用的内存空间释放出来。

可以通过 java -XX:+PrintCommandLineFlags -versionjava -XX:+PrintGCDetails -version 命令查看 JVM 的 GC 收集器。

20251010153455
二哥的 Java 进阶之路:JVM 使用的垃圾收集器

可以看到,我本机安装的 JDK 8 默认使用的是 Parallel Scavenge + Parallel Old

不同参数代表对应的垃圾收集器表单:

新生代 老年代 JVM参数
Serial Serial -XX:+UseSerialGC
Parallel Scavenge Serial -XX:+UseParallelGC -XX:-UseParallelOldGC
Parallel Scavenge Parallel Old -XX:+UseParallelGC -XX:+UseParallelOldGC
Parallel New CMS -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
G1 -XX:+UseG1GC

14.🌟对象什么时候会进入老年代?

对象通常会在年轻代中分配,随着时间的推移和垃圾收集的进程,某些满足条件的对象会进入到老年代中,如长期存活的对象。

20251010162108
二哥的 Java 进阶之路:对象进入老年代

长期存活的对象如何判断?

JVM 会为对象维护一个“年龄”计数器,记录对象在新生代中经历 Minor GC 的次数。每次 GC 未被回收的对象,其年龄会加 1。

当超过一个特定阈值,默认值是 15,就会被认为老对象了,需要重点关照。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold来设置。

可以通过 jinfo -flag MaxTenuringThreshold $(jps | grep -i nacos | awk '{print $1}') 来查看当前 JVM 的年龄阈值。

20251010162712
二哥的 Java 进阶之路:年龄阈值

  1. 如果应用中的对象存活时间较短,可以适当调大这个值,让对象在新生代多待一会儿
  2. 如果对象存活时间较长,可以适当调小这个值,让对象更快进入老年代,减少在新生代的复制次数

大对象如何判断?

大对象是指占用内存较大的对象,如大数组、长字符串等。

1
2
int[] array = new int[1000000];
String str = new String(new char[1000000]);

其大小由 JVM 参数 -XX:PretenureSizeThreshold 控制,但在 JDK 8 中,默认值为 0,也就是说默认情况下,对象仅根据 GC 存活的次数来判断是否进入老年代。

20251010162802
二哥的 Java 进阶之路:PretenureSizeThreshold

G1 垃圾收集器中,大对象会直接分配到 HUMONGOUS 区域。当对象大小超过一个 Region 容量的 50% 时,会被认为是大对象。

20251010162812
有梦想的肥宅:G1

Region 的大小可以通过 JVM 参数 -XX:G1HeapRegionSize 来设置,默认情况下从 1MB 到 32MB 不等,会根据堆内存大小动态调整。

可以通过 java -XX:+UseG1GC -XX:+PrintGCDetails -version 查看 G1 垃圾收集器的相关信息。

20251010162907
二哥的 Java 进阶之路:UseG1GC

从结果上来看,我本机上 G1 的堆大小为 2GB,Region 的大小为 4MB。

动态年龄判定了解吗?

如果 Survivor 区中所有对象的总大小超过了一定比例,通常是 Survivor 区的一半,那么年龄较小的对象也可能会被提前晋升到老年代。

这是因为如果年龄较小的对象在 Survivor 区中占用了较大的空间,会导致 Survivor 区中的对象复制次数增多,影响垃圾回收的效率。

23.🌟讲讲 JVM 的垃圾回收机制(补充)

本题是增补的内容 参照:深入理解 JVM 的垃圾回收机制

垃圾回收就是对内存堆中已经死亡的或者长时间没有使用的对象进行清除或回收。

JVM 在做 GC 之前,会先搞清楚什么是垃圾,什么不是垃圾,通常会通过可达性分析算法来判断对象是否存活。

20251011095331
二哥的 Java 进阶之路:可达性分析

在确定了哪些垃圾可以被回收后,垃圾收集器(如 CMS、G1、ZGC)要做的事情就是进行垃圾回收,可以采用标记清除算法、复制算法、标记整理算法、分代收集算法等。

技术派项目使用的 JDK 8,采用的是 CMS 垃圾收集器。

1
2
3
4
5
java -XX:+UseConcMarkSweepGC \
-XX:+UseParNewGC \
-XX:CMSInitiatingOccupancyFraction=75 \
-XX:+UseCMSInitiatingOccupancyOnly \
-jar your-application.jar

垃圾回收的过程是什么?

Java 的垃圾回收过程主要分为标记存活对象、清除无用对象、以及内存压缩/整理三个阶段。不同的垃圾回收器在执行这些步骤时会采用不同的策略和算法。

24.🌟如何判断对象仍然存活?

Java 通过可达性分析算法来判断一个对象是否还存活。

通过一组名为 “GC Roots” 的根对象,进行递归扫描,无法从根对象到达的对象就是“垃圾”,可以被回收。

20251011095524
三分恶面渣逆袭:GC Root

这也是 G1、CMS 等主流垃圾收集器使用的主要算法。

什么是引用计数法?

每个对象有一个引用计数器,记录引用它的次数。当计数器为零时,对象可以被回收。

20251011095558
三分恶面渣逆袭:引用计数法

引用计数法无法解决循环引用的问题。例如,两个对象互相引用,但不再被其他对象引用,它们的引用计数都不为零,因此不会被回收。

做可达性分析的时候,应该有哪些前置性的操作?

在进行垃圾回收之前,JVM 会暂停所有正在执行的应用线程。

这是因为可达性分析过程必须确保在执行分析时,内存中的对象关系不会被应用线程修改。如果不暂停应用线程,可能会出现对象引用的改变,导致垃圾回收过程中判断对象是否可达的结果不一致,从而引发严重的内存错误或数据丢失。

27.🌟垃圾收集算法了解吗?

垃圾收集算法主要有三种,分别是标记-清除算法、标记-复制算法和标记-整理算法。

说说标记-清除算法?

标记-清除算法分为两个阶段:

  • 标记:标记所有需要回收的对象
  • 清除:回收所有被标记的对象

20251011102820
三分恶面渣逆袭:标记-清除算法

优点是实现简单,缺点是回收过程中会产生内存碎片。

说说标记-复制算法?

标记-复制算法可以解决标记-清除算法的内存碎片问题,因为它将内存空间划分为两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后清理掉这一块。

20251011102844
三分恶面渣逆袭:标记-复制算法

缺点是浪费了一半的内存空间。

说说标记-整理算法?

标记-整理算法是标记-清除复制算法的升级版,它不再划分内存空间,而是将存活的对象向内存的一端移动,然后清理边界以外的内存。

20251011102906
标记-整理算法

缺点是移动对象的成本比较高。

说说分代收集算法?

分代收集算法是目前主流的垃圾收集算法,它根据对象存活周期的不同将内存划分为几块,一般分为新生代和老年代。

20251011103059
二哥的 Java 进阶之路:Java 堆划分

新生代用复制算法,因为大部分对象生命周期短。老年代用标记-整理算法,因为对象存活率较高。

为什么要用分代收集呢?

分代收集算法的核心思想是根据对象的生命周期优化垃圾回收。

新生代的对象生命周期短,使用复制算法可以快速回收。老年代的对象生命周期长,使用标记-整理算法可以减少移动对象的成本。

标记复制的标记过程和复制过程会不会停顿?

在标记-复制算法 中,标记阶段和复制阶段都会触发STW。

  • 标记阶段停顿是为了保证对象的引用关系不被修改。
  • 复制阶段停顿是防止对象在复制过程中被修改。

31.🌟知道哪些垃圾收集器?

推荐阅读:深入理解 JVM 的垃圾收集器:CMS、G1、ZGC

JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC。

20251011103352
三分恶面渣逆袭:HotSpot虚拟机垃圾收集器

CMS 是第一个关注 GC 停顿时间的垃圾收集器,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除。

G1 在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。

ZGC 是 JDK11 推出的一款低延迟垃圾收集器,适用于大内存低延迟服务的内存管理和回收,在 128G 的大堆下,最大停顿时间才 1.68 ms,性能远胜于 G1 和 CMS。

说说 Serial 收集器?

Serial 收集器是最基础、历史最悠久的收集器。

如同它的名字(串行),它是一个单线程工作的收集器,使用一个处理器或一条收集线程去完成垃圾收集工作。并且进行垃圾收集时,必须暂停其他所有工作线程,直到垃圾收集结束——这就是所谓的“Stop The World”。

Serial/Serial Old 收集器的运行过程如图:

20251011103544
三分恶面渣逆袭:Serial/Serial Old收集器运行示意图

说说 ParNew 收集器?

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,使用多条线程进行垃圾收集。

ParNew/Serial Old 收集器运行示意图如下:

20251011104114
三分恶面渣逆袭:ParNew/Serial Old收集器运行示意图

说说 Parallel Scavenge 收集器?

Parallel Scavenge 收集器是一款新生代收集器,基于标记-复制算法实现,也能够并行收集。和 ParNew 有些类似,但 Parallel Scavenge 主要关注的是垃圾收集的吞吐量——所谓吞吐量,就是 CPU 用于运行用户代码的时间和总消耗时间的比值,比值越大,说明垃圾收集的占比越小。

20251011104156
三分恶面渣逆袭:吞吐量

根据对象存活周期的不同会将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

说说 Serial Old 收集器?

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

说说 Parallel Old 收集器?

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,基于标记-整理算法实现,使用多条 GC 线程在 STW 期间同时进行垃圾回收。

20251011104247
三分恶面渣逆袭:Parallel Old收集器

说说 CMS 收集器?

CMS 在 JDK 1.5 时引入,JDK 9 时被标记弃用,JDK 14 时被移除。

CMS 是一种低延迟的垃圾收集器,采用标记-清除算法,分为初始标记、并发标记、重新标记和并发清除四个阶段,优点是垃圾回收线程和应用线程同时运行,停顿时间短,适合延迟敏感的应用,但容易产生内存碎片,可能触发 Full GC。

20251011104304
小潘:CMS

说说 G1 收集器?

G1 在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为默认的垃圾收集器。

G1 是一种面向大内存、高吞吐场景的垃圾收集器,它将堆划分为多个小的 Region,通过标记-整理算法,避免了内存碎片问题。优点是停顿时间可控,适合大堆场景,但调优较复杂。

20251011104352
有梦想的肥宅:G1

说说 ZGC 收集器?

ZGC 是 JDK 11 时引入的一款低延迟的垃圾收集器,最大特点是将垃圾收集的停顿时间控制在 10ms 以内,即使在 TB 级别的堆内存下也能保持较低的停顿时间。

它通过并发标记和重定位来避免大部分 Stop-The-World 停顿,主要依赖指针染色来管理对象状态。

20251011104424
得物技术:指针染色

  • 标记对象的可达性:通过在指针上增加标记位,不需要额外的标记位即可判断对象的存活状态。
  • 重定位状态:在对象被移动时,可以通过指针染色来更新对象的引用,而不需要等待全局同步。

适用于需要超低延迟的场景,比如金融交易系统、电商平台。

垃圾回收器的作用是什么?

垃圾回收器的核心作用是自动管理 Java 应用程序的运行时内存。它负责识别哪些内存是不再被应用程序使用的,并释放这些内存以便重新使用。

这一过程减少了程序员手动管理内存的负担,降低了内存泄漏和溢出错误的风险。

32.🌟能详细说一下 CMS 的垃圾收集过程吗?

20251011105011
三分恶面渣逆袭:Concurrent Mark Sweep收集器运行示意图

CMS 使用标记-清除算法进行垃圾收集,分 4 大步:

  • 初始标记:标记所有从 GC Roots 直接可达的对象,这个阶段需要 STW,但速度很快。
  • 并发标记:从初始标记的对象出发,遍历所有对象,标记所有可达的对象。这个阶段是并发进行的。
  • 重新标记:完成剩余的标记工作,包括处理并发阶段遗留下来的少量变动,这个阶段通常需要短暂的 STW 停顿。
  • 并发清除:清除未被标记的对象,回收它们占用的内存空间。

你提到了remark,那它remark具体是怎么执行的?三色标记法?

是的,remark 阶段通常会结合三色标记法来执行,确保在并发标记期间所有存活对象都被正确标记。目的是修正并发标记阶段中可能遗漏的对象引用变化。

在 remark 阶段,垃圾收集器会停止应用线程,以确保在这个阶段不会有引用关系的进一步变化。这种暂停通常很短暂。remark 阶段主要包括以下操作:

  1. 处理写屏障记录的引用变化:在并发标记阶段,应用程序可能会更新对象的引用(比如一个黑色对象新增了对一个白色对象的引用),这些变化通过写屏障记录下来。在 remark 阶段,GC 会处理这些记录,确保所有可达对象都正确地标记为灰色或黑色。
  2. 扫描灰色对象:再次遍历灰色对象,处理它们的所有引用,确保引用的对象正确标记为灰色或黑色。
  3. 清理:确保所有引用关系正确处理后,灰色对象标记为黑色,白色对象保持不变。这一步完成后,所有存活对象都应当是黑色的。

什么是三色标记法?

20251011105137
Java全栈架构师:三色标记法

三色标记法用于标记对象的存活状态,它将对象分为三类:

  1. 白色(White):尚未访问的对象。垃圾回收结束后,仍然为白色的对象会被认为是不可达的对象,可以回收。
  2. 灰色(Gray):已经访问到但未标记完其引用的对象。灰色对象是需要进一步处理的。
  3. 黑色(Black):已经访问到并且其所有引用对象都已经标记过。黑色对象是完全处理过的,不需要再处理。

三色标记法的工作流程:

①、初始标记(Initial Marking):从 GC Roots 开始,标记所有直接可达的对象为灰色。

②、并发标记(Concurrent Marking):在此阶段,标记所有灰色对象引用的对象为灰色,然后将灰色对象自身标记为黑色。这个过程是并发的,和应用线程同时进行。

此阶段的一个问题是,应用线程可能在并发标记期间修改对象的引用关系,导致一些对象的标记状态不准确。

③、重新标记(Remarking):重新标记阶段的目标是处理并发标记阶段遗漏的引用变化。为了确保所有存活对象都被正确标记,remark 需要在 STW 暂停期间执行。

④、使用写屏障(Write Barrier)来捕捉并发标记阶段应用线程对对象引用的更新。通过遍历这些更新的引用来修正标记状态,确保遗漏的对象不会被错误地回收。

推荐阅读:小道哥的三色标记

33.🌟G1 垃圾收集器了解吗?

G1 在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为默认的垃圾收集器。

20251011105431
有梦想的肥宅:G1 收集器

G1 把 Java 堆划分为多个大小相等的独立区域Region,每个区域都可以扮演新生代或老年代的角色。

同时,G1 还有一个专门为大对象设计的 Region,叫 Humongous 区。

大对象的判定规则是,如果一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中。

这种区域化管理使得 G1 可以更灵活地进行垃圾收集,只回收部分区域而不是整个新生代或老年代。

G1 收集器的运行过程大致可划分为这几个步骤:

①、并发标记,G1 通过并发标记的方式找出堆中的垃圾对象。并发标记阶段与应用线程同时执行,不会导致应用线程暂停。

②、混合收集,在并发标记完成后,G1 会计算出哪些区域的回收价值最高(也就是包含最多垃圾的区域),然后优先回收这些区域。这种回收方式包括了部分新生代区域和老年代区域。

选择回收成本低而收益高的区域进行回收,可以提高回收效率和减少停顿时间。

③、可预测的停顿,G1 在垃圾回收期间仍然需要「Stop the World」。不过,G1 在停顿时间上添加了预测机制,用户可以 JVM 启动时指定期望停顿时间,G1 会尽可能地在这个时间内完成垃圾回收。

20251011105556
三分恶面渣逆袭:G1收集器运行示意图

45.🌟了解类的加载机制吗?(补充)

了解。

JVM 的操作对象是 Class 文件,JVM 把 Class 文件中描述类的数据结构加载到内存中,并对数据进行校验、解析和初始化,最终转化成可以被 JVM 直接使用的类型,这个过程被称为类加载机制。

其中最重要的三个概念就是:类加载器、类加载过程和双亲委派模型。

  • 类加载器:负责加载类文件,将类文件加载到内存中,生成 Class 对象。
  • 类加载过程:包括加载、验证、准备、解析和初始化等步骤。
  • 双亲委派模型:当一个类加载器接收到类加载请求时,它会把请求委派给父——类加载器去完成,依次递归,直到最顶层的类加载器,如果父——类加载器无法完成加载请求,子类加载器才会尝试自己去加载。

48.🌟类装载的过程知道吗?

推荐阅读:一文彻底搞懂 Java 类加载机制

知道。

类装载过程包括三个阶段:载入、链接和初始化。

①、载入:将类的二进制字节码加载到内存中。

②、链接可以细分为三个小的阶段:

  • 验证:检查类文件格式是否符合 JVM 规范
  • 准备:为类的静态变量分配内存并设置默认值。
  • 解析:将符号引用替换为直接引用。

③、初始化:执行静态代码块和静态变量初始化。

在准备阶段,静态变量已经被赋过默认初始值了,在初始化阶段,静态变量将被赋值为代码期望赋的值。比如说 static int a = 1;,在准备阶段,a 的值为 0,在初始化阶段,a 的值为 1。

换句话说,初始化阶段是在执行类的构造方法,也就是 javap 中看到的 <clinit>()

载入过程 JVM 会做什么?

20251011111643
三分恶面渣逆袭:载入

  • 1)通过一个类的全限定名来获取定义此类的二进制字节流。
  • 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为这个类的访问入口。

49.🌟什么是双亲委派模型?

双亲委派模型要求类加载器在加载类时,先委托父加载器尝试加载,只有父加载器无法加载时,子加载器才会加载。

20251011111737
三分恶面渣逆袭:双亲委派模型

这个过程会一直向上递归,也就是说,从子加载器到父加载器,再到更上层的加载器,一直到最顶层的启动类加载器。

启动类加载器会尝试加载这个类。如果它能够加载这个类,就直接返回;如果它不能加载这个类,就会将加载任务返回给委托它的子加载器。

子加载器尝试加载这个类。如果子加载器也无法加载这个类,它就会继续向下传递这个加载任务,依此类推。

直到某个加载器能够加载这个类,或者所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。

MySQL

🌟0.什么是MYSQL

MySQL 是⼀个开源的关系型数据库,现在⾪属于 Oracle 公司。

删除/创建一张表

DROP TABLE 删除表
CREATE TABLE 创建表
创建表的时候,可以通过 PRIMARY KEY 设定主键。

1
2
3
4
5
6
CREATE TABLE users (
id INT AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(100),
PRIMARY KEY (id)
);

写一个升序/降序的SQL语句

可以使用ORDER BY字句对查询结果进行排序.
默认情况下是升序排序.如需要降序,使用关键字DESC
例子:

1
2
3
SELECT id, name, salary
FROM employees
ORDER BY salary DESC;

如若对多个字段进行排序:

1
2
3
SELECT id, name, salary
FROM employees
ORDER BY salary DESC, name ASC;

优先级从左到右,相当于先按工资降序,工资相同再按照姓名升序.

MYSQL出现性能差的原因

可能是 SQL 查询使⽤了全表扫描,也可能是查询语句过于复杂,如多表JOIN或嵌套⼦查询。
也有可能是单表数据量过⼤。

通常情况下,增加索引就可以解决大部分的性能问题.对于热点数据,增加redis缓存,减轻对数据库的压力.

9.🌟如何存储emoji?

因为 emoji是 4 个字节的 UTF-8 字符,⽽ MySQL 的 utf8 字符集只⽀持最多 3 个字节的 UTF-8 字符,所以在 MySQL 中存储 emoji 时,需要使⽤ utf8mb4 字符集。

MySQL 8.0 已经默认⽀持 utf8mb4 字符集,可以通过 SHOW VARIABLES WHERE Variable_name LIKE 'character\_set\_%' OR Variable_name LIKE 'collation%'; 查看。

21.🌟一条查询语句SELECT是如何执行的?

当我们执行一条 SELECT 语句时,MySQL 并不会直接去磁盘读取数据,而是经过 6 个步骤来解析、优化、执行,然后再返回结果。
20250612103550
第一步,客户端发送 SQL 查询语句到 MySQL 服务器

第二步,MySQL 服务器的连接器开始处理这个请求,跟客户端建立连接、获取权限、管理连接。

第三步,解析器对 SQL 语句进行解析,检查语句是否符合 SQL 语法规则,确保数据库、表和列都是存在的,并处理 SQL 语句中的名称解析和权限验证。

第四步,优化器负责确定 SQL 语句的执行计划,这包括选择使用哪些索引,以及决定表之间的连接顺序等。

第五步,执行器会调用存储引擎的 API来进行数据的读写。

第六步,存储引擎负责查询数据,并将执行结果返回给客户端。客户端接收到查询结果,完成这次查询请求。

24.🌟MySQL 有哪些常见存储引擎?

MySQL 支持多种存储引擎,常见的有 MyISAMInnoDBMEMORY 等。
—这部分是帮助理解 start,面试中可不背—
20250612112408
我来做一个表格对比:
20250612112435
—这部分是帮助理解 end,面试中可不背—
除此之外,我还了解到:
①、MySQL 5.5 之前,默认存储引擎是 MyISAM,5.5 之后是 InnoDB
②、InnoDB 支持的哈希索引是自适应的,不能人为干预。
③、InnoDB 从 MySQL 5.6 开始,支持全文索引。
④、InnoDB 的最小表空间略小于 10M,最大表空间取决于页面大小。
如何切换 MySQL 的数据引擎?
可以通过 alter table 语句来切换 MySQL 的数据引擎。
ALTER TABLE your_table_name ENGINE=InnoDB;
不过不建议,应该提前设计好到底用哪一种存储引擎

28.🌟MySQL 日志文件有哪些?

有 6 大类,其中错误日志用于问题诊断慢查询日志用于 SQL 性能分析general log 用于记录所有的 SQL 语句binlog 用于主从复制和数据恢复redo log 用于保证事务持久性undo log 用于事务回滚和 MVCC

—-这部分是帮助理解 start,面试中可不背—-

①、错误日志(Error Log):记录 MySQL 服务器启动、运行或停止时出现的问题。
②、慢查询日志(Slow Query Log):记录执行时间超过 long_query_time 值的所有 SQL 语句。这个时间值是可配置的,默认情况下,慢查询日志功能是关闭的。
③、一般查询日志(General Query Log):记录 MySQL 服务器的启动关闭信息,客户端的连接信息,以及更新、查询的 SQL 语句等。
④、二进制日志(Binary Log):记录所有修改数据库状态的 SQL 语句,以及每个语句的执行时间,如 INSERT、UPDATE、DELETE 等,但不包括 SELECT 和 SHOW 这类的操作。
⑤、重做日志(Redo Log):记录对于 InnoDB 表的每个写操作,不是 SQL 级别的,而是物理级别的,主要用于崩溃恢复。
⑥、回滚日志(Undo Log,或者叫事务日志):记录数据被修改前的值,用于事务的回滚。

—-这部分是帮助理解 end,面试中可不背—-

请重点说说 binlog?

binlog 是一种物理日志,会在磁盘上记录数据库的所有修改操作
如果误删了数据,就可以使用 binlog 进行回退到误删之前的状态。

1
2
3
4
# 步骤1:恢复全量备份
mysql -u root -p < full_backup.sql
# 步骤2:应用Binlog到指定时间点
mysqlbinlog --start-datetime="2025-03-13 14:00:00" --stop-datetime="2025-03-13 15:00:00" binlog.000001 | mysql -u root -p

如果要搭建主从复制,就可以让从库定时读取主库的 binlog。
MySQL 提供了三种格式的 binlog:StatementRowMixed,分别对应 SQL 语句级别、行级别和混合级别,默认为行级别。
从后缀名上来看,binlog 文件分为两类:以 .index 结尾的索引文件,以 .00000* 结尾的二进制日志文件
binlog 默认是没有启用的。

生产环境中是一定要启用的,可以通过在 my.cnf 文件中配置 log_bin 参数,以启用 binlog。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
log_bin = mysql-bin #开启binlog
#mysql-bin.*日志文件最大字节(单位:字节)
#设置最大100MB
max_binlog_size=104857600

#设置了只保留7天BINLOG(单位:天)
expire_logs_days = 7

#binlog日志只记录指定库的更新
#binlog-do-db=db_name

#binlog日志不记录指定库的更新
#binlog-ignore-db=db_name

#写缓冲多少次,刷一次磁盘,默认0
sync_binlog=0

binlog 的配置参数都了解哪些?

log_bin = mysql-bin 用于启用 binlog,这样就可以在 MySQL 的数据目录中找到 db-bin.000001、db-bin.000002 等日志文件。
max_binlog_size=104857600 用于设置每个 binlog 文件的大小,不建议设置太大,网络传送起来比较麻烦。
当 binlog 文件达到 max_binlog_size 时,MySQL 会关闭当前文件并创建一个新的 binlog 文件。
expire_logs_days = 7 用于设置 binlog 文件的自动过期时间为 7 天。过期的 binlog 文件会被自动删除。防止长时间累积的 binlog 文件占用过多存储空间,所以这个配置很重要。
binlog-do-db=db_name指定哪些数据库表的更新应该被记录
binlog-ignore-db=db_name,指定忽略哪些数据库表的更新
sync_binlog=0,设置每多少次 binlog 写操作会触发一次磁盘同步操作。默认值为 0,表示 MySQL 不会主动触发同步操作,而是依赖操作系统的磁盘缓存策略。
即当执行写操作时,数据会先写入缓存,当缓存区满了再由操作系统将数据一次性刷入磁盘。
如果设置为 1,表示每次 binlog 写操作后都会同步到磁盘,虽然可以保证数据能够及时写入磁盘,但会降低性能。
可以通过 show variables like '%log_bin%'; 查看 binlog 是否开启。

有了binlog为什么还要undolog redolog?

binlog 属于 Server 层,与存储引擎无关,无法直接操作物理数据页。而 redo logundo logInnoDB 存储引擎实现 ACID的基石。
————–ps————-
ACID:

  • **原子性(Atomicity)**:
    事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败回滚
    通过undo log实现,记录事务开始前的状态,用于回滚
  • **一致性(Consistency)**:
    事务执行前后,数据库从一个一致状态转变为另一个一致状态
    通过其他三个特性(AID)共同保证
  • **隔离性(Isolation)**:
    多个并发事务执行时,一个事务的执行不应影响其他事务
    通过锁机制和MVCC(多版本并发控制)实现
  • **持久性(Durability)**:
    事务一旦提交,其结果就是永久性的
    通过redo log实现,即使系统崩溃也能恢复数据

————–ps————-

binlog 关注的是逻辑变更的全局记录redo log 用于确保物理变更的持久性,确保事务最终能够刷盘成功;undo log逻辑逆向操作日志,记录的是旧值,方便恢复到事务开始前的状态。

另外一种回答方式。

binlog 会记录整个 SQL 或行变化redo log 是为了恢复已提交但未刷盘的数据,undo log 是为了撤销未提交的事务

以一次事务更新为例:

1
2
3
4
5
6
# 开启事务
BEGIN;
# 更新数据
UPDATE users SET age = age + 1 WHERE id = 1;
# 提交事务
COMMIT;

事务开始的时候会生成 undo log,记录更新前的数据,比如原值是 18:

undo log: id=1, age=18

修改数据的时候,会将数据写入到 redo log

比如数据页 page_id=123 上,id=1 的用户被更新为 age=26:

redo log (prepare):
page_id=123, offset=0x40, before=18, after=26

等事务提交的时候,redo log 刷盘,binlog 刷盘。

binlog 写完之后,redo log 的状态会变为 commit

redo log (commit):
page_id=123, offset=0x40, before=18, after=26

binlog 如果是 Statement 格式,会记录一条 SQL 语句:
UPDATE users SET age = age + 1 WHERE id = 1;
binlog 如果是 Row 格式,会记录:

1
2
3
表:users
before: id=1, age=18
after: id=1, age=26

随后,后台线程会将 redo log 中的变更异步刷新到磁盘。

详细探究一下binlog(长文警告⚠️):

MySQL 的 Binlog 日志是一种二进制格式的日志,Binlog 记录所有的 DDL 和 DML 语句(除了数据查询语句SELECT、SHOW等),以 Event 的形式记录,同时记录语句执行时间。

Binlog 的主要作用有两个:

1. 数据恢复:
因为 Binlog 详细记录了所有修改数据的 SQL,当某一时刻的数据误操作而导致出问题,或者数据库宕机数据丢失,那么可以根据 Binlog 来回放历史数据。
2. 主从复制:
想要做多机备份的业务,可以去监听当前写库的 Binlog 日志,同步写库的所有更改。

Binlog 包括两类文件:
二进制日志索引文件(.index):记录所有的二进制文件。
二进制日志文件(.00000*):记录所有 DDLDML 语句事件。

Binlog 日志功能默认是开启的,线上情况下 Binlog 日志的增长速度是很快的,在 MySQL 的配置文件 my.cnf 中提供一些参数来对 Binlog 进行设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#设置此参数表示启用binlog功能,并制定二进制日志的存储目录
log-bin=/home/mysql/binlog/

#mysql-bin.*日志文件最大字节(单位:字节)
#设置最大100MB
max_binlog_size=104857600

#设置了只保留7天BINLOG(单位:天)
expire_logs_days = 7

#binlog日志只记录指定库的更新
#binlog-do-db=db_name

#binlog日志不记录指定库的更新
#binlog-ignore-db=db_name

#写缓冲多少次,刷一次磁盘,默认0
sync_binlog=0

需要注意的是:
max_binlog_size :Binlog 最大和默认值是 1G,该设置并不能严格控制 Binlog 的大小,尤其是 Binlog 比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性不可能做切换日志的动作,只能将该事务的所有 SQL 都记录进当前日志直到事务结束。所以真实文件有时候会大于 max_binlog_size 设定值。
expire_logs_days :Binlog 过期删除不是服务定时执行,是需要借助事件触发才执行,事件包括:

  • 服务器重启
  • 服务器被更新
  • 日志达到了最大日志长度 max_binlog_size
  • 日志被刷新

二进制日志由配置文件的 log-bin 选项负责启用,MySQL 服务器将在数据根目录创建两个新文件mysql-bin.000001 和 mysql-bin.index,若配置选项没有给出文件名,MySQL 将使用主机名称命名这两个文件,其中 .index 文件包含一份全体日志文件的清单。

sync_binlog:这个参数决定了 Binlog 日志的更新频率。默认 0 ,表示该操作由操作系统根据自身负载自行决定多久写一次磁盘。

sync_binlog = 1 表示每一条事务提交都会立刻写盘。sync_binlog=n 表示 n 个事务提交才会写盘。

根据 MySQL 文档,写 Binlog 的时机是:SQL transaction 执行完,但任何相关的 Locks 还未释放事务还未最终 commit 前。这样保证了 Binlog 记录的操作时序与数据库实际的数据变更顺序一致。

检查 Binlog 文件是否已开启:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> show variables like '%log_bin%';
+---------------------------------+------------------------------------+
| Variable_name | Value |
+---------------------------------+------------------------------------+
| log_bin | ON |
| log_bin_basename | /usr/local/mysql/data/binlog |
| log_bin_index | /usr/local/mysql/data/binlog.index |
| log_bin_trust_function_creators | OFF |
| log_bin_use_v1_row_events | OFF |
| sql_log_bin | ON |
+---------------------------------+------------------------------------+
6 rows in set (0.00 sec)

MySQL 会把用户对所有数据库的内容和结构的修改情况记入 mysql-bin.n 文件,而不会记录 SELECT 和没有实际更新的 UPDATE 语句。

如果你不知道现在有哪些 Binlog 文件,可以使用如下命令:

1
2
3
4
5
6
7
8
9
10
11
show binary logs; #查看binlog列表
show master status; #查看最新的binlog

mysql> show binary logs;
+------------------+-----------+-----------+
| Log_name | File_size | Encrypted |
+------------------+-----------+-----------+
| mysql-bin.000001 | 179 | No |
| mysql-bin.000002 | 156 | No |
+------------------+-----------+-----------+
2 rows in set (0.00 sec)

Binlog 文件是二进制文件,强行打开看到的必然是乱码,MySQL 提供了命令行的方式来展示 Binlog 日志:

1
mysqlbinlog mysql-bin.000002 | more

mysqlbinlog 命令即可查看。
虽然看起来凌乱其实也有迹可循。Binlog 通过事件的方式来管理日志信息,可以通过 show binlog events in 的语法来查看当前 Binlog 文件对应的详细事件信息。

1
2
3
4
5
6
7
8
9
mysql> show binlog events in 'mysql-bin.000001';
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
| mysql-bin.000001 | 4 | Format_desc | 1 | 125 | Server ver: 8.0.21, Binlog ver: 4 |
| mysql-bin.000001 | 125 | Previous_gtids | 1 | 156 | |
| mysql-bin.000001 | 156 | Stop | 1 | 179 | |
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
3 rows in set (0.01 sec)

这是一份没有任何写入数据的 Binlog 日志文件。
Binlog 的版本是V4,可以看到日志的结束时间为 Stop。出现 Stop event 有两种情况:

  1. 是 master shut down 的时候会在 Binlog 文件结尾出现
  2. 是备机在关闭的时候会写入 relay log 结尾,或者执行 RESET SLAVE 命令执行

本文出现的原因是我有手动停止过 MySQL 服务
一般来说一份正常的 Binlog 日志文件会以 Rotate event 结束。当 Binlog 文件超过指定大小,Rotate event 会写在文件最后,指向下一个 Binlog 文件。
我们来看看有过数据操作的 Binlog 日志文件是什么样子的。

1
2
3
4
5
6
7
8
mysql> show binlog events in 'mysql-bin.000002';
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
| mysql-bin.000002 | 4 | Format_desc | 1 | 125 | Server ver: 8.0.21, Binlog ver: 4 |
| mysql-bin.000002 | 125 | Previous_gtids | 1 | 156 | |
+------------------+-----+----------------+-----------+-------------+-----------------------------------+
2 rows in set (0.00 sec)

上面是没有任何数据操作且没有被截断的 Binlog。接下来我们插入一条数据,再看看 Binlog 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> show binlog events in 'mysql-bin.000002';
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
| mysql-bin.000002 | 4 | Format_desc | 1 | 125 | Server ver: 8.0.21, Binlog ver: 4 |
| mysql-bin.000002 | 125 | Previous_gtids | 1 | 156 | |
| mysql-bin.000002 | 156 | Anonymous_Gtid | 1 | 235 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000002 | 235 | Query | 1 | 323 | BEGIN |
| mysql-bin.000002 | 323 | Intvar | 1 | 355 | INSERT_ID=13 |
| mysql-bin.000002 | 355 | Query | 1 | 494 | use `test_db`; INSERT INTO `test_db`.`test_db`(`name`) VALUES ('xdfdf') |
| mysql-bin.000002 | 494 | Xid | 1 | 525 | COMMIT /* xid=192 */ |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
7 rows in set (0.00 sec)

这是加入一条数据之后的 Binlog 事件。

我们对 event 查询的数据行关键字段来解释一下:

  • Pos:当前事件的开始位置,每个事件都占用固定的字节大小,结束位置(End_log_position)减去Pos,就是这个事件占用的字节数。
    上面的日志中我们能看到,第一个事件位置并不是从 0 开始,而是从 4。MySQL 通过文件中的前 4 个字节,来判断这是不是一个 Binlog 文件。这种方式很常见,很多格式的文件,如 pdf、doc、jpg等,都会通常前几个特定字符判断是否是合法文件
  • Event_type:表示事件的类型
  • Server_id:表示产生这个事件的 MySQL server_id,通过设置 my.cnf 中的 server-id 选项进行配置
  • End_log_position:下一个事件的开始位置
  • Info:包含事件的具体信息
Binlog 日志格式:

针对不同的使用场景,Binlog 也提供了可定制化的服务,提供了三种模式来提供不同详细程度的日志内容。

  • Statement 模式:基于 SQL 语句的复制(statement-based replication-SBR)
  • Row 模式:基于行的复制(row-based replication-RBR)
  • Mixed 模式:混合模式复制(mixed-based replication-MBR)
  1. Statement 模式
    保存每一条修改数据的SQL。
    该模式只保存一条普通的SQL语句,不涉及到执行的上下文信息。
    因为每台 MySQL 数据库的本地环境可能不一样,那么对于依赖到本地环境的函数或者上下文处理的逻辑 SQL 去处理的时候可能同样的语句在不同的机器上执行出来的效果不一致。
    比如像 sleep()函数,last_insert_id()函数,等等,这些都跟特定时间的本地环境有关。

  2. Row 模式
    MySQL V5.1.5 版本开始支持Row模式的 Binlog,它与 Statement 模式的区别在于它不保存具体的 SQL 语句,而是记录具体被修改的信息
    比如一条 update 语句更新10条数据,如果是 Statement 模式那就保存一条 SQL 就够,但是 Row 模式会保存每一行分别更新了什么,有10条数据。
    Row 模式的优缺点就很明显了。保存每一个更改的详细信息必然会带来存储空间的快速膨胀,换来的是事件操作的详细记录。所以要求越高代价越高。

  3. Mixed 模式
    Mixed 模式即以上两种模式的综合体。既然上面两种模式分别走了极简和一丝不苟的极端,那是否可以区分使用场景的情况下将这两种模式综合起来呢?
    在 Mixed 模式中,一般的更新语句使用 Statement 模式来保存 Binlog,但是遇到一些函数操作,可能会影响数据准确性的操作则使用 Row 模式来保存。这种方式需要根据每一条具体的 SQL 语句来区分选择哪种模式。
    MySQL 从 V5.1.8 开始提供 Mixed 模式,V5.7.7 之前的版本默认是Statement 模式,之后默认使用Row模式, 但是在 8.0 以上版本已经默认使用 Mixed 模式了

查询当前 Binlog 日志使用格式:

1
2
3
4
5
6
7
8
9
10
11
mysql> show global variables like '%binlog_format%';
+---------------------------------+---------+
| Variable_name | Value |
+---------------------------------+---------+
| binlog_format | MIXED |
| default_week_format | 0 |
| information_schema_stats_expiry | 86400 |
| innodb_default_row_format | dynamic |
| require_row_format | OFF |
+---------------------------------+---------+
5 rows in set (0.01 sec)
如何通过 mysqlbinlog 命令手动恢复数据

上面说过每一条 event 都有位点信息,如果我们当前的 MySQL 库被无操作或者误删除了,那么该如何通过 Binlog 来恢复到删除之前的数据状态呢?
首先发现误操作之后,先停止 MySQL 服务,防止继续更新
接着通过 mysqlbinlog命令对二进制文件进行分析,查看误操作之前的位点信息在哪里。
接下来肯定就是恢复数据,当前数据库的数据已经是错的,那么就从开始位置到误操作之前位点的数据肯定的都是正确的;如果误操作之后也有正常的数据进来,这一段时间的位点数据也要备份。
比如说:
误操作的位点开始值为 501,误操作结束的位置为705,之后到800的位点都是正确数据。
那么从 0 - 500 ,706 - 800 都是有效数据,接着我们就可以进行数据恢复了。
先将数据库备份并清空。
接着使用 mysqlbinlog 来恢复数据:
0 - 500 的数据:

1
mysqlbinlog --start-position=0  --stop-position=500  bin-log.000003 > /root/back.sql;

上面命令的作用就是将 0 -500 位点的数据恢复到自定义的 SQL 文件中。同理 706 - 800 的数据也是一样操作。之后我们执行这两个 SQL 文件就行了。

Binlog 事件类型
上面我们说到了 Binlog 日志中的事件,不同的操作会对应着不同的事件类型,且不同的 Binlog 日志模式同一个操作的事件类型也不同,下面我们一起看看常见的事件类型。
首先我们看看源码中的事件类型定义:
源码位置:/libbinlogevents/include/binlog_event.h

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
enum Log_event_type
{
/**
Every time you update this enum (when you add a type), you have to
fix Format_description_event::Format_description_event().
*/
UNKNOWN_EVENT= 0,
START_EVENT_V3= 1,
QUERY_EVENT= 2,
STOP_EVENT= 3,
ROTATE_EVENT= 4,
INTVAR_EVENT= 5,
LOAD_EVENT= 6,
SLAVE_EVENT= 7,
CREATE_FILE_EVENT= 8,
APPEND_BLOCK_EVENT= 9,
EXEC_LOAD_EVENT= 10,
DELETE_FILE_EVENT= 11,
/**
NEW_LOAD_EVENT is like LOAD_EVENT except that it has a longer
sql_ex, allowing multibyte TERMINATED BY etc; both types share the
same class (Load_event)
*/
NEW_LOAD_EVENT= 12,
RAND_EVENT= 13,
USER_VAR_EVENT= 14,
FORMAT_DESCRIPTION_EVENT= 15,
XID_EVENT= 16,
BEGIN_LOAD_QUERY_EVENT= 17,
EXECUTE_LOAD_QUERY_EVENT= 18,

TABLE_MAP_EVENT = 19,

/**
The PRE_GA event numbers were used for 5.1.0 to 5.1.15 and are
therefore obsolete.
*/
PRE_GA_WRITE_ROWS_EVENT = 20,
PRE_GA_UPDATE_ROWS_EVENT = 21,
PRE_GA_DELETE_ROWS_EVENT = 22,

/**
The V1 event numbers are used from 5.1.16 until mysql-trunk-xx
*/
WRITE_ROWS_EVENT_V1 = 23,
UPDATE_ROWS_EVENT_V1 = 24,
DELETE_ROWS_EVENT_V1 = 25,

/**
Something out of the ordinary happened on the master
*/
INCIDENT_EVENT= 26,

/**
Heartbeat event to be send by master at its idle time
to ensure master's online status to slave
*/
HEARTBEAT_LOG_EVENT= 27,

/**
In some situations, it is necessary to send over ignorable
data to the slave: data that a slave can handle in case there
is code for handling it, but which can be ignored if it is not
recognized.
*/
IGNORABLE_LOG_EVENT= 28,
ROWS_QUERY_LOG_EVENT= 29,

/** Version 2 of the Row events */
WRITE_ROWS_EVENT = 30,
UPDATE_ROWS_EVENT = 31,
DELETE_ROWS_EVENT = 32,

GTID_LOG_EVENT= 33,
ANONYMOUS_GTID_LOG_EVENT= 34,

PREVIOUS_GTIDS_LOG_EVENT= 35,

TRANSACTION_CONTEXT_EVENT= 36,

VIEW_CHANGE_EVENT= 37,

/* Prepared XA transaction terminal event similar to Xid */
XA_PREPARE_LOG_EVENT= 38,
/**
Add new events here - right above this comment!
Existing events (except ENUM_END_EVENT) should never change their numbers
*/
ENUM_END_EVENT /* end marker */
};

这么多的事件类型我们就不一一介绍,挑出来一些常用的来看看。
FORMAT_DESCRIPTION_EVENT
FORMAT_DESCRIPTION_EVENT 是 Binlog V4 中为了取代之前版本中的 START_EVENT_V3 事件而引入的。它是 Binlog 文件中的第一个事件,而且,该事件只会在 Binlog 中出现一次。MySQL 根据 FORMAT_DESCRIPTION_EVENT 的定义来解析其它事件。
它通常指定了 MySQL 的版本,Binlog 的版本,该 Binlog 文件的创建时间。

QUERY_EVENT

QUERY_EVENT 类型的事件通常在以下几种情况下使用:

事务开始时,执行的 BEGIN 操作
STATEMENT 格式中的 DML 操作
ROW 格式中的 DDL 操作
比如上文我们插入一条数据之后的 Binlog 日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> show binlog events in 'mysql-bin.000002';
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
| mysql-bin.000002 | 4 | Format_desc | 1 | 125 | Server ver: 8.0.21, Binlog ver: 4 |
| mysql-bin.000002 | 125 | Previous_gtids | 1 | 156 | |
| mysql-bin.000002 | 156 | Anonymous_Gtid | 1 | 235 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000002 | 235 | Query | 1 | 323 | BEGIN |
| mysql-bin.000002 | 323 | Intvar | 1 | 355 | INSERT_ID=13 |
| mysql-bin.000002 | 355 | Query | 1 | 494 | use `test_db`; INSERT INTO `test_db`.`test_db`(`name`) VALUES ('xdfdf') |
| mysql-bin.000002 | 494 | Xid | 1 | 525 | COMMIT /* xid=192 */ |
+------------------+-----+----------------+-----------+-------------+-------------------------------------------------------------------------+
7 rows in set (0.00 sec)

XID_EVENT
在事务提交时,不管是 STATEMENT 还 是ROW 格式的 Binlog,都会在末尾添加一个 XID_EVENT 事件代表事务的结束。该事件记录了该事务的 ID,在 MySQL 进行崩溃恢复时,根据事务在 Binlog 中的提交情况来决定是否提交存储引擎中状态为 prepared 的事务。
ROWS_EVENT
对于 ROW 格式的 Binlog,所有的 DML 语句都是记录在 ROWS_EVENT 中。
ROWS_EVENT分为三种:
WRITE_ROWS_EVENT
UPDATE_ROWS_EVENT
DELETE_ROWS_EVENT
分别对应 insertupdatedelete 操作。
对于 insert 操作,WRITE_ROWS_EVENT 包含了要插入的数据。
对于 update 操作,UPDATE_ROWS_EVENT 不仅包含了修改后的数据,还包含了修改前的值。
对于 delete 操作,仅仅需要指定删除的主键(在没有主键的情况下,会给定所有列)。

对比 QUERY_EVENT 事件,是以文本形式记录 DML 操作的。而对于 ROWS_EVENT 事件,并不是文本形式,所以在通过 mysqlbinlog 查看基于 ROW 格式的 Binlog 时,需要指定 -vv –base64-output=decode-rows。

我们来测试一下,首先将日志格式改为 Rows:

1
2
3
4
5
6
mysql> set binlog_format=row;
Query OK, 0 rows affected (0.00 sec)

mysql>
mysql> flush logs;
Query OK, 0 rows affected (0.01 sec)

然后刷新一下日志文件,重新开始一个 Binlog 日志。我们插入一条数据之后看一下日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> show binlog events in 'binlog.000008';
+---------------+-----+----------------+-----------+-------------+--------------------------------------+
| Log_name | Pos | Event_type | Server_id | End_log_pos | Info |
+---------------+-----+----------------+-----------+-------------+--------------------------------------+
| binlog.000008 | 4 | Format_desc | 1 | 125 | Server ver: 8.0.21, Binlog ver: 4 |
| binlog.000008 | 125 | Previous_gtids | 1 | 156 | |
| binlog.000008 | 156 | Anonymous_Gtid | 1 | 235 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| binlog.000008 | 235 | Query | 1 | 313 | BEGIN |
| binlog.000008 | 313 | Table_map | 1 | 377 | table_id: 85 (test_db.test_db) |
| binlog.000008 | 377 | Write_rows | 1 | 423 | table_id: 85 flags: STMT_END_F |
| binlog.000008 | 423 | Xid | 1 | 454 | COMMIT /* xid=44 */ |
+---------------+-----+----------------+-----------+-------------+--------------------------------------+
7 rows in set (0.01 sec)

说说 redo log 的工作机制?

事务启动时,MySQL 会为该事务分配一个唯一标识符。
事务执行过程中,每次对数据进行修改,MySQL 都会生成一条 Redo Log,记录修改前后的数据状态。
这些 Redo Log 首先会被写入内存中的 Redo Log Buffer
20250613161219
当事务提交时,MySQL 再将 Redo Log Buffer 中的记录刷新到磁盘上的 Redo Log 文件中。
只有当 Redo Log 成功写入磁盘,事务才算真正提交成功。
20250613161254
当 MySQL 崩溃重启时,会先检查 Redo Log。对于已提交的事务,MySQL 会重放 Redo Log 中的记录。
20250613161338
对于未提交的事务,MySQL 会通过 Undo Log 回滚这些修改,确保数据恢复到崩溃前的一致性状态。
Redo Log 是循环使用的,当文件写满后会覆盖最早的记录。
为避免覆盖未持久化的记录,MySQL 会定期执行 CheckPoint 操作,将内存中的数据页刷新到磁盘,并记录 CheckPoint 点。

20250613161415

重启时,MySQL 只会重放 CheckPoint 之后的 Redo Log,从而提高恢复效率。

省流版:

  1. 事务开始
  2. 记录undo log(旧数据)
  3. 修改Buffer Pool中的数据
  4. 写入redo log(prepare状态)
  5. 写入binlog
  6. 提交事务(redo log标记为commit)
  7. 后台异步刷脏页到磁盘

redo log 文件的大小是固定的吗?

redo log 文件是固定大小的,通常配置为一组文件,使用环形方式写入,旧的日志会在空间需要时被覆盖。

20250613162534
命名方式为 ib_logfile0、iblogfile1、、、iblogfilen。默认 2 个文件,每个文件大小为 48MB。
可以通过 show variables like 'innodb_log_file_size'; 查看 redo log 文件的大小;通过 show variables like 'innodb_log_files_in_group'; 查看 redo log 文件的数量。

说一说WAL?

WAL——Write-Ahead Logging。

预写日志是 InnoDB 实现事务持久化的核心机制,它的思想是:先写日志再刷磁盘
即在修改数据页之前,先将修改记录写入 Redo Log
这样的话,即使数据页尚未写入磁盘,系统崩溃时也能通过 Redo Log 恢复数据。
20250613163109
—-这部分是帮助理解 start,面试中可不背—-
解释一下为什么需要 WAL:
数据最终是要写入磁盘的,但磁盘 IO 很慢
如果每次更新都立刻把数据页刷盘,性能很差;
如果还没写入磁盘就宕机,事务会丢失。
WAL 的好处是更新时不直接写数据页,而是先写一份变更记录到 redo log,后台再慢慢把真正的数据页刷盘,一举多得。
—-这部分是帮助理解 end,面试中可不背—-

30.🌟为什么要两阶段提交?

为了保证 redo log 和 binlog 中的数据一致性,防止主从复制和事务状态不一致。

20250616093841

为什么 2PC 能保证 redo log 和 binlog 的强⼀致性?

假如 MySQL 在预写 redo log 之后、写入 binlog 之前崩溃。那么 MySQL 重启后 InnoDB 会回滚该事务,因为 redo log 不是提交状态。并且由于 binlog 中没有写入数据,所以从库也不会有该事务的数据。

20250616093925

假如 MySQL 在写入 binlog 之后、redo log 提交之前崩溃。那么 MySQL 重启后 InnoDB 会提交该事务,因为 redo log 是提交状态。并且由于 binlog 中有写入数据,所以从库也会同步到该事务的数据。
伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 事务开始
begin;
// try
{
// 执行 SQL
execute SQL;

// 写入 redo log 并标记为 prepare
write redo log prepare xid;

// 写入 binlog
write binlog xid sql;

// 提交 redo log
commit redo log xid;
}
// catch
{
// 回滚 redo log
innodb rollback redo log xid;
}
// 事务结束
end;

XID 了解吗?

XID 是 binlog 中用来标识事务提交的唯一标识符。
20250616094148

在事务提交时,会写入一个 XID_EVENTbinlog,表示这个事务真正完成了。

1
2
3
4
5
6
  Log_name         | Pos  | Event_type     | Server_id | End_log_pos | Info      
| mysql-bin.000003 | 2005 | Gtid | 1013307 | 2070 | SET @@SESSION.GTID_NEXT= 'f971d5f1-d450-11ec-9e7b-5254000a56df:11' |
| mysql-bin.000003 | 2070 | Query | 1013307 | 2142 | BEGIN |
| mysql-bin.000003 | 2142 | Table_map | 1013307 | 2187 | table_id: 109 (test.t1) |
| mysql-bin.000003 | 2187 | Write_rows | 1013307 | 2227 | table_id: 109 flags: STMT_END_F |
| mysql-bin.000003 | 2227 | Xid | 1013307 | 2258 | COMMIT /* xid=121 */

它不仅用于主从复制中事务完整性的判断,也在崩溃恢复中对 redo log 和 binlog 的一致性校验起到关键作用。

XID 可以帮助 MySQL 判断哪些 redo log 是已提交的,哪些是未提交需要回滚的,是两阶段提交机制中非常关键的一环。

31.🌟redo log 的写入过程了解吗?

InnoDB 会先将 Redo Log 写入内存中的 Redo Log Buffer,之后再以一定的频率刷入到磁盘的 Redo Log File 中。

20250616101033

哪些场景会触发 redo log 的刷盘动作?

比如说 Redo Log Buffer 的空间不足时事务提交时触发 Checkpoint 时,后台线程定期刷盘时。
不过,Redo Log Buffer 刷盘到 Redo Log File 还会涉及到操作系统的磁盘缓存策略,可能不会立即刷盘,而是等待一定时间后才刷盘。

20250616110133

innodb_flush_log_at_trx_commit 参数你了解多少?

innodb_flush_log_at_trx_commit 参数是用来控制事务提交时,Redo Log 的刷盘策略,一共有三种。

0 表示事务提交时不刷盘,而是交给后台线程每隔 1 秒执行一次。这种方式性能最好,但是在 MySQL 宕机时可能会丢失一秒内的事务

1 表示事务提交时会立即刷盘,确保事务提交后数据就持久化到磁盘。这种方式是最安全的,也是 InnoDB 的默认值

20250616112123

2 表示事务提交时只把 Redo Log Buffer 写入到 Page Cache,由操作系统决定什么时候刷盘。操作系统宕机时,可能会丢失一部分数据。

一个没有提交事务的 redo log,会不会刷盘?

InnoDB 有一个后台线程,每隔 1 秒会把Redo Log Buffer中的日志写入到文件系统的缓存中,然后调用刷盘操作。
20250616113040

因此,一个没有提交事务的 Redo Log 也可能会被刷新到磁盘中。
另外,如果当 Redo Log Buffer 占用的空间即将达到 innodb_log_buffer_size 的一半时,也会触发刷盘操作。

Redo Log Buffer 是顺序写还是随机写?

MySQL 在启动后会向操作系统申请一块连续的内存空间作为 Redo Log Buffer,并将其分为若干个连续的 Redo Log Block。
20250616113305
那为了提高写入效率,Redo Log Buffer 采用了顺序写入的方式,会先往前面的 Redo Log Block 中写入,当写满后再往后面的 Block 中写入。
20250616113328
于此同时,InnoDB 还提供了一个全局变量 buf_free,来控制后续的 redo log 记录应该写入到 block 中的哪个位置。

buf_next_to_write 了解吗?

buf_next_to_write 指向 Redo Log Buffer 中下一次需要写入硬盘的起始位置。

20250616113533
而 buf_free 指向的是 Redo Log Buffer 中空闲区域的起始位置。

了解 MTR 吗?

Mini Transaction 是 InnoDB 内部用于操作数据页的原子操作单元。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mtr_t mtr;
mtr_start(&mtr);

// 1. 加锁

// 对待访问的index加锁
mtr_s_lock(rw_lock_t, mtr);
mtr_x_lock(rw_lock_t, mtr);

// 对待读写的page加锁
mtr_memo_push(mtr, buf_block_t, MTR_MEMO_PAGE_S_FIX);
mtr_memo_push(mtr, buf_block_t, MTR_MEMO_PAGE_X_FIX);

// 2. 访问或修改page
btr_cur_search_to_nth_level
btr_cur_optimistic_insert

// 3. 为修改操作生成redo
mlog_open
mlog_write_initial_log_record_fast
mlog_close

// 4. 持久化redo,解锁
mtr_commit(&mtr);

多个事务的 Redo Log 会以 MTR 为单位交替写入到 Redo Log Buffer 中,假如事务 1 和事务 2 均有两个 MTR,一旦某个 MTR 结束,就会将其生成的若干条 Redo Log 记录顺序写入到 Redo Log Buffer 中。

20250616151955

也就是说,一个 MTR 会包含一组 Redo Log 记录,是 MySQL 崩溃后恢复事务的最小执行单元。

20250616154016

Redo Log Block 的结构了解吗?

Redo Log Block 由日志头、日志体和日志尾组成,一共占用 512 个字节,其中日志头占用 12 个字节,日志尾占用 4 个字节,剩余的 496 个字节用于存储日志体。
20250616154915
日志头包含了当前 Block 的序列号、第一条日志的序列号、类型等信息。
20250616154950
日志尾主要存储的是 LOG_BLOCK_CHECKSUM,也就是 Block 的校验和,主要用于判断 Block 是否完整。

Redo Log Block 为什么设计成 512 字节?

因为机械硬盘的物理扇区大小通常为 512 字节,Redo Log Block 也设计为同样的大小,就可以确保每次写入都是整数个扇区,减少对齐开销。

20250616155054

比如说操作系统的页缓存默认为 4KB,8 个 Redo Log Block 就可以组合成一个页缓存单元,从而提升 Redo Log Buffer 的写入效率。

LSN 了解吗?

Log Sequence Number 是一个 8 字节的单调递增整数,用来标识事务写入 redo log 的字节总量,存在于 redo log、数据页头部和 checkpoint 中。

20250616160916

—-这部分是帮助理解 start,面试中可不背—-
MySQL 在第一次启动时,LSN 的初始值并不为 0,而是 8704;当 MySQL 再次启动时,会继续使用上一次服务停止时的 LSN。

在计算 LSN 的增量时,不仅需要考虑 log block body 的大小,还需要考虑 log block header 和 log block tail 中部分字节数

比如说在上图中,事务 3 的 MTR 总量为 300 字节,那么写入到 Redo Log Buffer 中的 LSN 会增长为 8704 + 300 + 12 = 9016。

假如事务 4 的 MTR 总量为 900 字节,那么再次写入到 Redo Log Buffer 中的 LSN 会增长为 9016 + 900 + 122 + 42 = 9948。

2 个 12 字节的 log block header + 2 个 4 字节的 log block tail。

—-这部分是帮助理解 end,面试中可不背—-

核心作用有三个:

第一,redo log 按照 LSN 递增顺序记录所有数据的修改操作。LSN 的递增量等于每次写入日志的字节数。

第二,InnoDB 的每个数据页头部中,都会记录该页最后一次刷新到磁盘时的 LSN。如果数据页的 LSN 小于 redo log 的 LSN,说明该页需要从日志中恢复;否则说明该页已更新。

第三,checkpoint 通过 LSN 记录已刷新到磁盘的数据页位置,减少恢复时需要处理的日志。

—-这部分是帮助理解 start,面试中可不背—-
20250616162021

可以通过 show engine innodb status; 查看当前的 LSN 信息。

20250616162445

  • Log sequence number:当前系统最大 LSN(已生成的日志总量)。
  • Log flushed up to:已写入磁盘的 redo log LSN。
  • Pages flushed up to:已刷新到数据页的 LSN。
  • Last checkpoint at:最后一次检查点的 LSN,表示已持久化的数据状态。

—-这部分是帮助理解 end,面试中可不背—-

Checkpoint 了解多少?

Checkpoint 是 InnoDB 为了保证事务持久性和回收 redo log 空间的一种机制。

它的作用是在合适的时机将部分脏页刷入磁盘,比如说 buffer pool 的容量不足时。并记录当前 LSNCheckpoint LSN,表示这个位置之前的 redo log file 已经安全,可以被覆盖了。

MySQL 崩溃恢复时只需要从 Checkpoint 之后开始恢复 redo log 就可以了,这样可以最大程度减少恢复所花费的时间。

redo log file 的写入是循环的,其中有两个标记位置非常重要,也就是 Checkpoint 和 write pos。
20250616162937

write pos 是 redo log 当前写入的位置,Checkpoint 是可以被覆盖的位置。

当 write pos 追上 Checkpoint 时,表示 redo log 日志已经写满。这时候就要暂停写入并强制刷盘,释放可覆写的日志空间。

20250616163140

关于redo log 的调优参数了解多少?

如果是高并发写入的电商系统,可以最大化写入吞吐量,容忍秒级数据丢失的风险。

1
2
3
4
5
6
innodb_flush_log_at_trx_commit = 2
sync_binlog = 1000
innodb_redo_log_capacity = 64G
innodb_io_capacity = 5000
innodb_lru_scan_depth = 512
innodb_log_buffer_size = 256M

如果是金融交易系统,需要保证数据零丢失,接受较低的吞吐量。

1
2
3
4
5
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
innodb_redo_log_capacity = 32G
innodb_io_capacity = 2000
innodb_lru_scan_depth = 1024

核心参数一览表:
20250616163309

总结

  • 对数据一致性要求高的场景,如金融交易使用innodb_flush_log_at_trx_commit=1,对写入吞吐量敏感的场景,如日志采集可以使用 =2 或 =0,需要结合 sync_binlog 参数
  • sync_binlog 参数控制 binlog 的刷盘策略,可以设置为 0、1、N,0 表示依赖系统刷盘,1 表示每次事务提交都刷盘(推荐与 innodb_flush_log_at_trx_commit=1 搭配),N=1000 表示累计 1000 次事务后刷盘
  • innodb_redo_log_capacity 动态调整 Redo Log 总容量,可以根据业务负载情况调整,建议设置为 1 小时写入量的峰值(如每秒 10MB 写入则设为 36GB)
  • innodb_io_capacity 定义 InnoDB 后台线程的每秒 I/O 操作上限,直接影响脏页刷新速率;机械硬盘建议 200-500,SSD 建议 1000-2000,NVMe SSD 可设为 5000+
  • innodb_lru_scan_depth 控制每个缓冲池实例中 LRU 列表的扫描深度,决定每秒可刷新的脏页数量,默认值 1024 适用于多数场景,I/O 密集型负载可适当降低(如 512),减少 CPU 开销。

🌟32.什么是慢 SQL?

拓展阅读: https://juejin.cn/post/7048974570228809741
MySQL 中有一个叫long_query_time的参数,原则上执行时间超过该参数值的 SQL 就是慢 SQL,会被记录到慢查询日志中。

—-这部分是帮助理解 start,面试中可不背—-

可通过 show variables like ‘long_query_time’; 查看当前的 long_query_time 的参数值。
—-这部分是帮助理解 end,面试中可不背—-

SQL 的执行过程了解吗?

SQL 的执行过程大致可以分为六个阶段:连接管理语法解析语义分析查询优化执行器调度存储引擎读写等。Server 层负责理解和规划 SQL 怎么执行,存储引擎层负责数据的真正读写。
20250617154612

—-这部分是帮助理解 start,面试中可不背—-

来详细拆解一下:

  1. 客户端发送 SQL 语句给 MySQL 服务器。
  2. 如果查询缓存打开则会优先查询缓存,缓存中有对应的结果就直接返回。不过,MySQL 8.0 已经移除了查询缓存。这部分的功能正在被 Redis 等缓存中间件取代。
  3. 分析器对 SQL 语句进行语法分析,判断是否有语法错误。
  4. 搞清楚 SQL 语句要干嘛后,MySQL 会通过优化器生成执行计划。
  5. 执行器调用存储引擎的接口,执行 SQL 语句。

SQL 执行过程中,优化器通过成本计算预估出执行效率最高的方式,基本的预估维度为:

  • IO 成本:从磁盘读取数据到内存的开销。
  • CPU 成本:CPU 处理内存中数据的开销。

基于这两个维度,可以得出影响 SQL 执行效率的因素有:

①、IO 成本,数据量越大,IO 成本越高。所以要尽量查询必要的字段;尽量分页查询;尽量通过索引加快查询。

②、CPU 成本,尽量避免复杂的查询条件,如有必要,考虑对子查询结果进行过滤。

—-这部分是帮助理解 end,面试中可不背—-

如何优化慢SQL?

首先,需要找到那些比较慢的 SQL,可以通过启用慢查询日志,记录那些超过指定执行时间的 SQL 查询。

也可以使用 show processlist; 命令查看当前正在执行的 SQL 语句,找出执行时间较长的 SQL。

或者在业务基建中加入对慢 SQL 的监控,常见的方案有字节码插桩、连接池扩展、ORM 框架扩展等。

然后,使用 EXPLAIN 查看慢 SQL 的执行计划,看看有没有用索引,大部分情况下,慢 SQL 的原因都是因为没有用到索引。

EXPLAIN SELECT * FROM your_table WHERE conditions;
最后,根据分析结果,通过添加索引优化查询条件减少返回字段等方式进行优化。

慢sql日志怎么开启?

编辑 MySQL 的配置文件 my.cnf,设置 slow_query_log 参数为 1。

1
2
3
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # 记录执行时间超过2秒的查询

然后重启 MySQL 就好了。

也可以通过 set global 命令动态设置。

1
2
3
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 2;

🌟33.你知道哪些方法来优化 SQL?

SQL 优化的方法非常多,但本质上就一句话:尽可能少地扫描、尽快地返回结果。
最常见的做法就是加索引、改写 SQL 让它用上索引,比如说使用覆盖索引、让联合索引遵守最左前缀原则等。

20250618110546

如何利用覆盖索引?

覆盖索引的核心是“查询所需的字段都在同一个索引里”,这样 MySQL 就不需要回表,直接从索引中返回结果。
20250618110821

实际使用中,我会优先考虑把 WHERESELECT 涉及的字段一起建联合索引,并通过 EXPLAIN 观察结果是否有 Using index,确认命中索引。

—-这部分是帮助理解 start,面试中可不背—-

举个例子,现在要从 test 表中查询 city 为上海的 name 字段。

1
select name from test where city='上海'

如果仅在 city 字段上添加索引,那么这条查询语句会先通过索引找到 city 为上海的行,然后再回表查询 name 字段。

为了避免回表查询,可以在 city 和 name 字段上建立联合索引,这样查询结果就可以直接从索引中获取。

1
alter table test add index index1(city,name);

相当于利用空间换时间,把查询结果都放到了索引里,不需要回表查询。
—-这部分是帮助理解 end,面试中可不背—-

如何正确使用联合索引?

使用联合索引最重要的一条是遵守最左前缀原则,也就是查询条件需要从索引的左侧字段开始

—-这部分是帮助理解 start,面试中可不背—-
比如说我们创建了一个三列的联合索引。

1
CREATE INDEX idx_name_age_sex ON user(name, age, sex);

我们来看一下什么样的查询条件可以用到这个索引:
20250618111513
—-这部分是帮助理解 end,面试中可不背—-

如何进行分页优化?

分页优化的核心是避免深度偏移(Deep Offset)带来的全表扫描,可以通过两种方式来优化:延迟关联和添加书签

延迟关联适用于需要从多个表中获取数据且主表行数较多的情况
它首先从索引表中检索出需要的行 ID,然后再根据这些 ID 去关联其他的表获取详细信息。

1
2
3
4
5
SELECT e.id, e.name, d.details
FROM employees e
JOIN department d ON e.department_id = d.id
ORDER BY e.id
LIMIT 1000, 20;

延迟关联后,第一步只查主键,速度快,第二步只处理 20 条数据,效率高。

1
2
3
4
5
6
7
8
9
SELECT e.id, e.name, d.details
FROM (
SELECT id
FROM employees
ORDER BY id
LIMIT 1000, 20
) AS sub
JOIN employees e ON sub.id = e.id
JOIN department d ON e.department_id = d.id;

添加书签的方式是通过记住上一次查询返回的最后一行主键值,然后在下一次查询的时候从这个值开始,从而跳过偏移量计算,仅扫描目标数据,适合翻页、资讯流等场景。

假设需要对用户表进行分页。

1
2
3
4
SELECT id, name
FROM users
ORDER BY id
LIMIT 1000, 20;

通过添加书签来优化后,查询不再使用OFFSET,而是从上一页最后一个用户的 ID 开始查询。这种方法可以有效避免不必要的数据扫描,提高了分页查询的效率。

1
2
3
4
5
SELECT id, name
FROM users
WHERE id > last_max_id -- 假设last_max_id是上一页最后一行的ID
ORDER BY id
LIMIT 20;

为什么分页会变慢?

分页查询的效率问题主要是由于 OFFSET 的存在,OFFSET 会导致 MySQL 必须扫描和跳过 offset + limit 条数据,这个过程是非常耗时的。

比如说,我们要查询第 100000 条数据,那么 MySQL 就必须扫描 100000 条数据,然后再返回 10 条数据。

1
SELECT * FROM user ORDER BY id LIMIT 100000, 10;

数据越多、偏移越大,就越慢!

JOIN 代替子查询有什么好处?

第一,JOIN 的 ON 条件能更直接地触发索引,而子查询可能因嵌套导致索引失效
第二,JOIN 的一次连接操作替代了子查询的多次重复执行,尤其在大数据量的情况下性能差异明显。

—-这部分是帮助理解 start,面试中可不背—-

比如说我们有两个表 orders 和 customers。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT,
amount DECIMAL(10,2),
INDEX idx_customer_id (customer_id) -- customer_id字段有索引
);
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
name VARCHAR(100)
);

子查询的写法:

1
2
3
4
5
SELECT o.order_id, o.amount, 
(SELECT c.name
FROM customers c
WHERE c.customer_id = o.customer_id) AS customer_name
FROM orders o;

JOIN 的写法:

1
2
3
SELECT o.order_id, o.amount, c.name AS customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;

20250618140806

对于子查询,执行流程是这样的:

  • 外层 orders 表的每一行都会触发一次子查询。
  • 如果 orders 表有 1000 条记录,则子查询会执行 1000 次。
  • 每次子查询都需要单独查询 customers 表(即使 customer_id 相同)。

而 JOIN 的执行流程是这样的:

  • 数据库优化器会将两张表的连接操作合并为一次执行。
  • 通过索引(如 orders.customer_id 和 customers.customer_id)快速关联数据。
  • 仅执行一次关联操作,而非多次子查询。
    来看一下子查询的执行计划:
    1
    2
    3
    EXPLAIN SELECT o.order_id, 
    (SELECT c.name FROM customers c WHERE c.customer_id = o.customer_id)
    FROM orders o;
    20250618142719
    子查询(DEPENDENT SUBQUERY)类型表明其依赖外层查询的每一行,导致重复执行。

再对比看一下 JOIN 的执行计划:

1
2
3
EXPLAIN SELECT o.order_id, 
(SELECT c.name FROM customers c WHERE c.customer_id = o.customer_id)
FROM orders o;

20250618142740
JOIN 通过 eq_ref 类型直接利用主键(customers.customer_id)快速关联,减少扫描次数。

JOIN操作为什么要小表驱动大表?

  • 第一,如果大表的 JOIN 字段有索引,那么小表的每一行都可以通过索引快速匹配大表。
    20250618143231
    时间复杂度为**小表行数 N 乘以大表索引查找复杂度 log(大表行数 M)**,总复杂度为 N*log(M)
    显然小表做驱动表比大表做驱动表的时间复杂度 M*log(N) 更低。

  • 第二,如果大表没有索引,需要将小表的数据加载到内存,再全表扫描大表进行匹配。
    20250618143445
    时间复杂度为小表分段数 K 乘以大表行数 M,其中 K = 小表行数 N / 内存大小 join_buffer_size。
    显然小表做驱动表的时候 K 的值更小,大表做驱动表的时候需要多次分段。

    1
    2
    3
    4
    5
    6
    7
    -- 小表驱动(高效)
    SELECT * FROM small_table s
    JOIN large_table l ON s.id = l.id; -- l.id有索引

    -- 大表驱动(低效)
    SELECT * FROM large_table l
    JOIN small_table s ON l.id = s.id; -- s.id无索引
  • 当使用 left join 时,左表是驱动表,右表是被驱动表。

  • 当使用 right join 时,刚好相反。

  • 当使用 join 时,MySQL 会选择数据量比较小的表作为驱动表,大表作为被驱动表。

这里的小表指实际参与 JOIN 的数据量,而不是表的总行数。大表经过 where 条件过滤后也可能成为逻辑小表。
实际参与JOIN的数据量决定小表

1
2
3
SELECT * FROM large_table l
JOIN small_table s ON l.id = s.id
WHERE l.created_at > '2025-01-01'; -- l经过过滤后可能成为小表

也可以强制通过 STRAIGHT_JOIN 提示 MySQL 使用指定的驱动表。

1
2
3
4
5
6
7
8
9
explain select table_1.col1, table_2.col2, table_3.col2
from table_1
straight_join table_2 on table_1.col1=table_2.col1
straight_join table_3 on table_1.col1 = table_3.col1;

explain select straight_join table_1.col1, table_2.col2, table_3.col2
from table_1
join table_2 on table_1.col1=table_2.col1
join table_3 on table_1.col1 = table_3.col1;

为什么要避免使用 JOIN 关联太多的表?

第一,多表 JOIN 的执行路径会随着表的数量呈现指数级增长,优化器需要估算所有路径的成本,有可能会导致出现大表驱动小表的情况。

1
2
3
4
5
SELECT * FROM A
JOIN B ON A.id = B.a_id
JOIN C ON B.id = C.b_id
JOIN D ON C.id = D.c_id
JOIN E ON D.id = E.d_id; -- 5 个表,优化器需评估 5! = 120 种顺序

第二,多表 JOIN 需要缓存中间结果集,可能超出 join_buffer_size,这种情况下内存临时表就会转为磁盘临时表,性能也会急剧下降。
《阿里巴巴 Java 开发手册》上就规定,不要使用 join 关联太多的表,最多不要超过 3 张表
20250618151308

如何进行排序优化?

第⼀,对 ORDER BY 涉及的字段创建索引,避免 filesort。

1
2
3
4
-- 优化前(可能触发 filesort)
SELECT * FROM users ORDER BY age DESC;
-- 优化后(添加索引)
ALTER TABLE users ADD INDEX idx_age (age);

如果是多个字段,联合索引需要保证ORDER BY 的列是索引的最左前缀

1
2
3
4
5
6
-- 联合索引需与 ORDER BY 顺序⼀致(age 在前,name 在后)
ALTER TABLE users ADD INDEX idx_age_name (age, name);
-- 有效利⽤索引的查询
SELECT * FROM users ORDER BY age, name;
-- ⽆效案例(索引失效,因 name 在索引中排在 age 之后)
SELECT * FROM users ORDER BY name, age;

第⼆,可以适当调整排序参数,如增⼤ sort_buffer_sizemax_length_for_sort_data 等,让排序在内存中完成。
—-这部分是帮助理解 start,⾯试中可不背—-

20250618152056

  • sort_buffer_size:用于控制排序缓冲区的大小,默认为 256KB。也就是说,如果排序的数据量小于 256KB,MySQL 会在内存中直接排序;否则就要在磁盘上进行 filesort。
  • max_length_for_sort_data:单行数据的最大长度,会影响排序算法选择。如果单行数据超过该值,MySQL 会使用双路排序,否则使用单路排序。
  • max_sort_length:限制字符串排序时比较的前缀长度。当 MySQL 不得不对 text、blob 字段进行排序时,会截取前 max_sort_length 个字符进行比较。
    —-这部分是帮助理解 end,面试中可不背—-
    第三,可以通过 where 和 limit 限制待排序的数据量,减少排序的开销。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    -- 优化前
    SELECT * FROM users ORDER BY age LIMIT 100;

    -- 优化后(减少数据传输和排序开销)
    SELECT id, name, age FROM users ORDER BY age LIMIT 100;

    -- 深度分页优化(避免 OFFSET 扫描全表)
    SELECT * FROM users ORDER BY age LIMIT 10000, 20; -- 低效
    SELECT * FROM users WHERE age > last_age ORDER BY age LIMIT 20; -- 高效(记录上一页最后一条的 age 值)

什么是 filesort?

Mysql如何执行ORDER BY

不能使用索引生成排序结果的时候,MySQL 需要自己进行排序,如果数据量比较小,会在内存中进行;如果数据量比较大就需要写临时文件到磁盘再排序,我们将这个过程称为文件排序

20250619095220

—-这部分是帮助理解 start,面试中可不背—-
让我们来验证一下 filesort 的情况
20250619095335

能够看得出来,当 order by id 也就是主键的时候,没有触发 filesort;当 order by age 的时候,由于没有索引,就触发了 filesort。
—-这部分是帮助理解 end,面试中可不背—-

全字段排序和 rowid 排序了解多少?

当排序字段是索引字段且满足最左前缀原则时,MySQL 可以直接利用索引的有序性完成排序

20250619095617

当无法使用索引排序时,MySQL 需要在内存或磁盘中进行排序操作,分为全字段排序rowid 排序两种算法。

全字段排序
全字段排序会一次性取出满足条件行的所有字段,然后在 sort buffer 中进行排序,排序后直接返回结果,无需回表。

SELECT * FROM user WHERE name = "王二" ORDER BY age 为例:

从 name 索引中找到第一个满足 name=’张三’ 的主键 id;
根据主键 id 取出整行所有的字段,存入 sort buffer;
重复上述过程直到处理完所有满足条件的行
对 sort buffer 中的数据按 age 排序,返回结果。

  • 优点是仅需要一次磁盘 IO
  • 缺点是内存占用大,如果数量超过 sort buffer 的话,需要分片读取并借助临时文件合并排序,IO 次数反而会增加。
    也无法处理包含 text 和 blob 类型的字段。

rowid 排序

rowid 排序分为两个阶段:

  • 第一阶段:根据查询条件取出排序字段和主键 ID,存入sort buffer进行排序;
  • 第二阶段:根据排序后的主键 ID 回表取出其他需要的字段

同样以 SELECT * FROM user WHERE name = "王二" ORDER BY age 为例:

  • 从 name 索引中找到第一个满足 name=’张三’ 的主键 id;
  • 根据主键 id 取出排序字段 age,连同主键 id 一起存入 sort buffer;
  • 重复上述过程直到处理完所有满足条件的行
  • 对 sort buffer 中的数据按 age 排序;
  • 遍历排序后的主键 id,回表取出其他所需字段,返回结果。

优点是内存占用较少,适合字段多或者数据量大的场景,缺点是需要两次磁盘 IO

MySQL 会根据系统变量 max_length_for_sort_data查询字段的总大小来决定使用全字段排序还是 rowid 排序。

如果查询字段总长度 <= max_length_for_sort_data,MySQL 会使用全字段排序;否则会使用 rowid 排序。

你对 Sort_merge_passes 参数了解吗?

深入了解 MySQL Order By 文件排序
Sort_merge_passes 是一个状态变量,用于统计 MySQL 在执行排序操作时进行归并排序的次数
当 MySQL 需要进行排序但排序数据无法完全放入 sort_buffer_size 定义的内存缓冲区时,就会使用临时文件进行外部排序,这时就会产生 Sort_merge_passes。

如果 Sort_merge_passes 在短时间内快速激增,说明排序操作的数据量较大,需要调整 sort_buffer_size 或者优化查询语句
20250619101635

MySQL 在执行排序操作时,会经历两个过程:

  • 内存排序阶段,MySQL 首先尝试在 sort buffer 中进行排序。如果数据量小于 sort_buffer_size 缓冲区大小,会完全在内存中完成快速排序。
  • 外部排序阶段,如果数据量超过 sort_buffer_size,MySQL 会将数据分成多个块,每块单独排序后写入临时文件,然后对这些已排序的块进行归并排序。每次归并操作都会增加 Sort_merge_passes 的计数。

20250619101736

条件下推你了解多少?

条件下推的核心思想是将外层的过滤条件,比如说 where、join 等,尽可能地下推到查询计划的更底层比如说子查询、连接操作之前,从而减少中间结果的数据量

比如说原始查询是:

1
2
3
4
SELECT * FROM (
SELECT * FROM orders WHERE total > 100
) AS subquery
WHERE subquery.status = 'shipped';

就可以将条件下推到子查询:

1
2
3
SELECT * FROM (
SELECT * FROM orders WHERE total > 100 AND status = 'shipped'
) AS subquery;

这样就可以减少查询返回的数据量,避免外层再过滤。

再比如说 union 中的原始查询是:

1
2
3
4
(SELECT * FROM t1) 
UNION ALL
(SELECT * FROM t2)
ORDER BY col LIMIT 10;

就可以将条件下推到每个子查询:

1
2
3
(SELECT * FROM t1 ORDER BY col LIMIT 10)
UNION ALL
(SELECT * FROM t2 ORDER BY col LIMIT 10);

每个子查询仅返回前 10 条数据,减少临时表的数据量。

再比如说连接查询 join 中的原始查询是:

1
2
3
SELECT * FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE customers.country = 'china';

就可以将条件下推到表扫描的时候:

1
2
3
4
5
SELECT * FROM orders
JOIN (
SELECT * FROM customers WHERE country = 'china'
) AS filtered_customers
ON orders.customer_id = filtered_customers.id;

先过滤 customers 表,减少 join 时的数据量。

为什么要尽量避免使用 select *?

SELECT * 会强制 MySQL 读取表中所有字段的数据,包括应用程序可能并不需要的,比如 text、blob 类型的大字段
加载冗余数据会占用更多的缓存空间,从而挤占其他重要数据的缓存资源,降低整体系统的吞吐量
也会增加网络传输的开销,尤其是在大字段的情况下。
最重要的是,SELECT * 可能会导致覆盖索引失效,本来可以走索引的查询最后变成了全表扫描。

1
2
3
4
-- 使用覆盖索引(假设索引为 idx_country)
SELECT id, country FROM users WHERE country = 'china'; -- 可能仅扫描索引
-- 使用 SELECT *
SELECT * FROM users WHERE country = 'china'; -- 需回表读取所有列

你还知道哪些 SQL 优化方法?

①、避免使用 != 或者 <> 操作符

!= 或者 <> 操作符会导致 MySQL 无法使用索引,从而导致全表扫描

可以把column<>’aaa’,改成column>’aaa’ or column<’aaa’。

②、使用前缀索引

比如,邮箱的后缀一般都是固定的@xxx.com,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引
alter table test add index index2(email(6));
需要注意的是,MySQL 无法利用前缀索引做 order bygroup by 操作。

③、避免在列上使用函数

在 where 子句中直接对列使用函数会导致索引失效,因为 MySQL 需要对每行的列应用函数后再进行比较。
select name from test where date_format(create_time,'%Y-%m-%d')='2021-01-01';
可以改成:

1
select name from test where create_time>='2021-01-01 00:00:00' and create_time<'2021-01-02 00:00:00';

通过日期的范围查询,而不是在列上使用函数,可以利用 create_time 上的索引。

34.🌟explain平常有用过吗?

经常用,explain 是 MySQL 提供的一个用于查看 SQL 执行计划的工具,可以帮助我们分析查询语句的性能问题

一共有 10 来个输出参数。

20250619105445

比如说 type=ALL,key=NULL 表示 SQL 正在全表扫描,可以考虑为 where 字段添加索引进行优化;
Extra=Using filesort 表示 SQL 正在文件排序,可以考虑为 order by 字段添加索引。

使用方式也非常简单,直接在 select 前加上 explain 关键字就可以了。

explain select * from students where name='王二';
更高级的用法可以配合 format=json 参数,将 explain 的输出结果以 JSON 格式返回。
explain format=json select * from students where name='王二';

explain 输出结果中常见的字段含义理解吗?

在 EXPLAIN 输出结果中我最关注的字段是 typekeyrowsExtra

我会通过它们判断 SQL 有没有走索引是否全表扫描预估扫描行数是否太大,以及是否触发了 filesort 或临时表
一旦发现问题,比如 type=ALL 或者 Extra=Using filesort,我会考虑建索引、改写 SQL 或控制查询结果集来做优化。

—-这部分是帮助理解 start,面试中可不背—-
EXPLAIN SELECT * FROM orders WHERE user_id = 100 的输出为例:

20250619105921

非表格版本:
①、id 列:查询的执行顺序编号。id 相同:同一执行层级,按 table 列从上到下顺序执行(如多表 JOIN);id 递增:嵌套子查询,数值越大优先级越高,越先执行。

1
EXPLAIN SELECT * FROM t1 JOIN (SELECT * FROM t2 WHERE id = 1) AS sub;

t2 子查询的 id=2,优先执行。

②、select_type 列:查询的类型。常见的类型有:

  • SIMPLE:简单查询,不包含子查询或者 UNION。
  • PRIMARY:查询中如果包含子查询,则最外层查询被标记为 PRIMARY。需要关注子查询或派生表性能。
  • SUBQUERY:子查询;需要避免多层嵌套,尽量改写为 JOIN。
  • DERIVED:派生表(FROM 子句中的子查询)。需要减少派生表数据量,或物化为临时表。

③、table 列:查的哪个表。

  • derivedN:表示派生表(N 对应 id)。
  • unionNM,N:表示 UNION 合并的结果(M、N 为参与 UNION 的 id)。

④、type 列:表示 MySQL 在表中找到所需行的方式。

  • system,表仅有一行(系统表或衍生表),无需优化。
  • const:通过主键或唯一索引找到一行(如 WHERE id = 1)。理想情况。
  • eq_ref:对主键/唯一索引 JOIN 匹配(如 A JOIN B ON A.id = B.id)。确保 JOIN 字段有索引。
  • ref:非唯一索引匹配(如 WHERE name = ‘王二’,name 有普通索引)。
  • range:只检索给定范围的行,使用索引来检索。在where语句中使用 bettween…and、<、>、<=、in 等条件查询 type 都是 range。
  • index:全索引扫描,如果不需要回表,可接受;否则考虑覆盖索引。
  • ALL:全表扫描,效率最低。
    ⑤、possible_keys 列:可能会用到的索引,但并不一定实际被使用。

⑥、key 列:实际使用的索引。如果为 NULL,则没有使用索引。如果为 PRIMARY,则使用了主键索引。

⑦、key_len 列:使用的索引字节数,反映索引列的利用率。使用联合索引 (a, b),key_len 是 a 和 b 的字节总和(仅当查询条件用到 a 或 a+b 时有效)。

– 表结构:CREATE TABLE t (a INT, b VARCHAR(20), INDEX idx_a_b (a, b));
EXPLAIN SELECT * FROM t WHERE a = 1 AND b = ‘test’;
key_len = 4(INT) + 20*3(utf8) + 2 = 66 字节。

⑧、ref 列:与索引列比较的值或列。

  • const:常量。例如 WHERE column = ‘value’。
  • func:函数。例如 WHERE column = func(column)。

⑨、rows 列:优化器估算的需要扫描的行数。数值越小越好,若与实际差距大,可能统计信息过期(需 ANALYZE TABLE)。结合 filtered 字段可以计算最终返回行数(rows × filtered)。

⑩、Extra 列:附加信息。

  • Using index:覆盖索引,无需回表。
  • Using where:存储引擎返回结果后,Server 层需要再次过滤(条件未完全下推)。
  • Using temporary :使用临时表(常见于 GROUP BY、DISTINCT)。
  • Using filesort:文件排序(常见于 ORDER BY)。考虑为 ORDER BY 字段添加索引。
  • Select tables optimized away:优化器已优化(如 COUNT(*) 通过索引直接统计)。
  • Using join buffer:使用连接缓冲区(Block Nested Loop 或 Hash Join)。考虑增大 join_buffer_size。

—-这部分是帮助理解 end,面试中可不背—-

type的执行效率等级,达到什么级别比较合适?

从高到低的效率排序是 systemconsteq_refrefrangeindexALL

一般情况下,建议 type 值达到 consteq_refref,因为这些类型表明查询使用了索引,效率较高。
如果是范围查询,range 类型也是可以接受的。
ALL 类型表示全表扫描,性能最差,往往不可接受,需要优化。

35.🌟索引为什么能提高MySQL查询效率?

索引就像一本书的目录,能让 MySQL 快速定位数据,避免全表扫描。

索引加快查询原理

它一般是 B+ 树结构,查找效率是 O(log n),比从头到尾扫一遍数据要快得多。

MYSQL索引结构

除了查得快,索引还能加速排序、分组、连接等操作。
可以通过 create index 创建索引,比如:
create index idx_name on students(name);

36.🌟能简单说一下索引的分类吗?

功能上分类的话,有主键索引唯一索引全文索引;从数据结构上分类的话,有 B+ 树索引哈希索引;从存储内容上分类的话,有聚簇索引非聚簇索引

索引类型

你对主键索引了解多少?

主键索引用于唯一标识表中的每条记录,其列值必须唯一且非空。创建主键时,MySQL 会自动生成对应的唯一索引

主键索引

每个表只能有一个主键索引,一般是表中的自增 id 字段。

1
2
3
4
5
6
CREATE TABLE emp6 (emp_id INT PRIMARY KEY, name VARCHAR(50));  -- 单列主键
CREATE TABLE CountryLanguage (
CountryCode CHAR(3),
Language VARCHAR(30),
PRIMARY KEY (CountryCode, Language) -- 复合主键
);

—- 这部分是帮助理解 start,面试中可不背 —-

如果创建表的时候没有指定主键,MySQL 的 InnoDB 存储引擎会优先选择一个非空的唯一索引作为主键;如果没有符合条件的索引,MySQL 会自动生成一个隐藏的 _rowid 列作为主键。

可以通过 show index from table_name 查看索引信息:
20250620110109

  • Table 当前索引所属的表名
  • Non_unique 是否唯一索引,0 表示唯一索引(如主键),1 表示非唯一。
  • Key_name 主键索引默认叫 PRIMARY;普通索引为自定义名。
  • Seq_in_index 索引中的列顺序,在联合索引中这个字段表示第几列(第 1 个)。
  • Column_name 当前索引中包含的字段名
  • Collation A 表示升序(Ascend);D 表示降序。
  • Cardinality 索引的基数,即不重复的索引值的数量。越高说明区分度越好(影响优化器是否用此索引)。
  • Sub_part 前缀索引的长度。
  • Packed 是否压缩存储索引;一般不用,默认为 NULL。
  • Null 字段是否允许为 NULL;主键字段不允许为 NULL。
  • Index_type 索引底层结构,InnoDB 默认是 B+ 树(BTREE)。
  • Comment 索引的注释。
  • Visible 是否可见;MySQL 8.0+ 可隐藏索引。
    —- 这部分是帮助理解 end,面试中可不背 —-

唯一索引和主键索引有什么区别?

主键索引=唯一索引+非空。每个表只能有一个主键索引,但可以有多个唯一索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 在 email 列上添加唯一索引
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
UNIQUE KEY uk_email (email) -- 唯一索引
);

-- 复合唯一索引(保证 user_id 和 role 组合唯一)
CREATE TABLE user_roles (
user_id INT NOT NULL,
role VARCHAR(20) NOT NULL,
UNIQUE KEY uk_user_role (user_id, role)
);

主键索引不允许插入 NULL 值,尝试插入 NULL 会报错;唯一索引允许插入多个 NULL 值

unique key 和 unique index 有什么区别?

创建唯一键时,MySQL 会自动生成一个同名的唯一索引;反之,创建唯一索引也会隐式添加唯一性约束

可通过 UNIQUE KEY uk_name 定义或者 CONSTRAINT uk_name UNIQUE 定义唯一键。

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR(100),
-- 显式命名唯一键
CONSTRAINT uk_email UNIQUE (email)
);

CREATE TABLE users3 (
id INT PRIMARY KEY,
email VARCHAR(100),
UNIQUE KEY uk_email (email) -- 唯一索引
);

可通过 CREATE UNIQUE INDEX 创建唯一索引。

1
2
3
4
5
6
7
CREATE TABLE users (
id INT PRIMARY KEY,
email VARCHAR(100)
);

-- 手动创建唯一索引
CREATE UNIQUE INDEX uk_email ON users(email);

通过 SHOW CREATE TABLE table_name 查看表结构时,结果都是一样的。

普通索引和唯一索引有什么区别?

普通索引仅用于加速查询,不限制字段值的唯一性;适用于高频写入的字段、范围查询的字段

1
2
3
4
5
-- 日志时间戳允许重复,无需唯一性检查
CREATE INDEX idx_log_time ON access_logs(access_time);

-- 订单状态允许重复,但需频繁按状态过滤数据
CREATE INDEX idx_order_status ON orders(status);

唯一索引强制字段值的唯一性,插入或更新时会触发唯一性检查;适用于业务唯一性约束的字段、防止数据重复插入的字段

1
2
3
4
-- 用户邮箱必须唯一
CREATE UNIQUE INDEX uk_email ON users(email);
-- 确保同一用户对同一商品只能有一条未支付订单
CREATE UNIQUE INDEX uk_user_product ON orders(user_id, product_id) WHERE status = 'unpaid';

你对全文索引了解多少?

全文索引是 MySQL 一种优化文本数据检索的特殊类型索引,适用于 CHAR、VARCHAR 和 TEXT 等字段。

MySQL 5.7 及以上版本内置了 ngram 解析器,可处理中文、日文和韩文等分词。

建表时通过 FULLTEXT (title, body) 来定义。通过 MATCH(col1, col2) AGAINST('keyword') 进行检索,默认按照降序返回结果,支持布尔模式查询。

  • + 表示必须包含;
  • - 表示排除;
  • * 表示通配符;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    -- 建表时创建全文索引(支持中文)
    CREATE TABLE articles (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200),
    content TEXT,
    FULLTEXT(title, content) WITH PARSER ngram
    ) ENGINE=InnoDB;

    -- 使用布尔模式查询
    SELECT * FROM articles
    WHERE MATCH(title, content) AGAINST('+MySQL -Oracle' IN BOOLEAN MODE);
    底层使用倒排索引将字段中的文本内容进行分词,然后建立一个倒排表。性能比 LIKE ‘%keyword%’ 高很多。

—- 这部分是帮助理解 start,面试中可不背 —-

倒排索引通过一个辅助表存储单词与单词自身在一个或多个文档中所在位置之间的映射,通常采用关联数组实现。

有两种表现形式:inverted file index({单词,单词所在文档的ID})和full inverted index({单词,(单词所在文档的ID,在具体文档中的位置)})

比如有这样一个文档:

1
2
3
4
5
6
7
DocumentId  Text  
1 Pease porridge hot, pease porridge cold
2 Pease porridge in the pot
3 Nine days old
4 Some like it hot, some like it cold
5 Some like it in the pot
6 Nine days old

inverted file index 的关联数组存储形式为:

1
2
3
4
5
days → 3,6  
old3,6
pease → 1,2
porridge → 1,2
...

full inverted index 更加详细:

1
2
3
4
5
days → (3:5),(6:5)  
old → (3:11),(6:11)
pease → (1:1),(1:7),(2:1)
porridge → (1:7),(2:7)
...

full inverted index 不仅存储了文档 ID,还存储了单词在文档中的具体位置。

InnoDB 采用的是 full inverted index 的方式实现全文索引。

如果需要处理中文分词的话,一定要记得加上 WITH PARSER ngram,否则可能查不出来数据。

不过,对于复杂的中文场景,建议使用 Elasticsearch 等专业搜索引擎替代,技术派项目中就用了这种方案。

—- 这部分是帮助理解 end,面试中可不背 —-

37.🌟创建索引有哪些注意点?

第一,选择合适的字段

  • 比如说频繁出现在 WHEREJOINORDER BYGROUP BY 中的字段。
  • 优先选择区分度高的字段,比如用户 ID、手机号等唯一值多的,而不是性别、状态等区分度极低的字段,如果真的需要,可以考虑联合索引。

第二,要控制索引的数量,避免过度索引,每个索引都要占用存储空间,单表的索引数量不建议超过 5 个。

要定期通过 SHOW INDEX FROM table_name 查看索引的使用情况,删除不必要的索引。比如说已经有联合索引 (a, b),单索引(a)就是冗余的。

第三,联合索引的时候要遵循最左前缀原则,即在查询条件中使用联合索引的第一个字段,才能充分利用索引。

比如说联合索引 (A, B, C) 可支持 A、A+B、A+B+C 的查询,但无法支持 B 或 C 的单独查询。

区分度高的字段放在左侧等值查询的字段优先于范围查询的字段。例如 WHERE A=1 AND B>10 AND C=2,优先 (A, C, B)。

如果联合索引包含查询的所需字段,还可以避免回表,提高查询效率。

38.🌟索引哪些情况下会失效呢?

简版:比如索引列使用了函数使用了通配符开头的模糊查询联合索引不满足最左前缀原则,或者使用 or 的时候部分字段无索引等。

第一,对索引列使用函数或表达式会导致索引失效。

1
2
3
4
5
6
-- 索引失效
SELECT * FROM users WHERE YEAR(create_time) = 2023;
SELECT * FROM products WHERE price*2 > 100;
-- 优化方案(使用范围查询)
SELECT * FROM users WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31';
SELECT * FROM products WHERE price > 50;

第二,LIKE 模糊查询以通配符开头会导致索引失效。

1
2
3
4
5
6
7
8
-- 索引失效
SELECT * FROM articles WHERE title LIKE '%数据库%';

-- 可以使用索引(但范围有限)
SELECT * FROM articles WHERE title LIKE '数据库%';

-- 解决方案:考虑全文索引或搜索引擎
SELECT * FROM articles WHERE MATCH(title) AGAINST('数据库');

第三,联合索引违反了最左前缀原则,索引会失效。

1
2
3
4
5
6
-- 假设有联合索引 (a, b, c)
SELECT * FROM table WHERE b = 2 AND c = 3; -- 索引失效
SELECT * FROM table WHERE a = 1 AND c = 3; -- 只使用a列索引

-- 正确使用联合索引
SELECT * FROM table WHERE a = 1 AND b = 2 AND c = 3;

联合索引,但 WHERE 不满足最左前缀原则,索引无法起效。例如:SELECT * FROM table WHERE column2 = 2,联合索引为 (column1, column2)。

—- 这部分是帮助理解 start,面试中可不背 —-

第四,使用 OR 连接非索引列条件,会导致索引失效。

1
2
3
4
5
6
7
8
9
-- 假设name有索引但age没有
SELECT * FROM users WHERE name = '张三' OR age = 25; -- 全表扫描

-- 优化方案1:使用UNION ALL
SELECT * FROM users WHERE name = '张三'
UNION ALL
SELECT * FROM users WHERE age = 25 AND name != '张三';

-- 优化方案2:考虑为age添加索引

第五,使用 != 或 <> 不等值查询会导致索引失效。

1
2
3
4
SELECT * FROM user WHERE status != 1;  -- 若大部分行 `status=1`,可能全表扫描

-- 优化方案:使用范围查询
SELECT * FROM user WHERE status < 1 OR status > 1;

—- 这部分是帮助理解 end,面试中可不背 —-

什么情况下模糊查询不走索引?

模糊查询主要使用 LIKE 语句,结合通配符来实现。
%(代表任意多个字符)和 _(代表单个字符)

1
SELECT * FROM table WHERE column LIKE '%xxx%';

这个查询会返回所有 column 列中包含 xxx 的记录。
但是,如果模糊查询的通配符** % 出现在搜索字符串的开始位置,如 LIKE ‘%xxx’,MySQL 将无法使用索引,因为数据库必须扫描全表以匹配任意位置的字符串**。

41.🌟为什么 InnoDB 要使用 B+树作为索引?

一句话总结:
因为 B+ 树是一种高度平衡的多路查找树,能有效降低磁盘的 IO 次数,并且支持有序遍历和范围查询

B+树

查询性能非常高,其结构也适合 MySQL 按照页为单位在磁盘上存储。

像其他选项,比如说哈希表不支持范围查询二叉树层级太深B 树又不方便范围扫描,所以最终选择了 B+ 树。

再换一种回答:

相比哈希表:B+ 树支持范围查询和排序
相比二叉树和红黑树:B+ 树更“矮胖”,层级更少,磁盘 IO 次数更少
相比 B 树:B+ 树的非叶子节点只存储键值,叶子节点存储数据并通过链表连接,支持范围查询
另外一种回答版本:

B+树是一种自平衡的多路查找树,和红黑树、二叉平衡树不同,B+树的每个节点可以有 m 个子节点,而红黑树和二叉平衡树都只有 2 个。

B+树2
另外,和 B 树不同,B+树的非叶子节点只存储键值不存储数据,而叶子节点存储了所有的数据,并且构成了一个有序链表

这样做的好处是,非叶子节点上由于没有存储数据,就可以存储更多的键值对,再加上叶子节点构成了一个有序链表,范围查询时就可以直接通过叶子节点间的指针顺序访问整个查询范围内的所有记录,而无需对树进行多次遍历。查询的效率比 B 树更高。

先说说 B 树。
B 树是一种自平衡的多路查找树,和红黑树、二叉平衡树不同,B 树的每个节点可以有 m 个子节点,而红黑树和二叉平衡树都只有 2 个。
换句话说,红黑树、二叉平衡树是细高个,而 B 树是矮胖子。
B树

再来说说内存和磁盘的 IO 读写。

为了提高读写效率,从磁盘往内存中读数据的时候,一次会读取至少一页的数据,如果不满一页,会再多读点。

比如说查询只需要读取 2KB 的数据,但 MySQL 实际上会读取 4KB 的数据,以装满整页。页是 MySQL 进行内存和磁盘交互的最小逻辑单元。

再比如说需要读取 5KB 的数据,实际上 MySQL 会读取 8KB 的数据,刚好两页。

因为读的次数越多,效率就越低。就好比我们在工地上搬砖,一次搬 10 块砖肯定比一次搬 1 块砖的效率要高,反正我每次都搬 10 块(😁)。

对于红黑树、二叉平衡树这种细高个来说,每次搬的砖少,因为力气不够嘛,那来回跑的次数就越多。

通常 B+ 树高度为 3-4 层即可支持 TB 级数据,而每次查询只需 2-4 次磁盘 I/O,远低于二叉树或红黑树的 O(log2N) 复杂度

树越高,意味着查找数据时就需要更多的磁盘 IO,因为每一层都可能需要从磁盘加载新的节点。

B 树的节点通常与页的大小对齐,这样每次从磁盘加载一个节点时,正好就是一页的大小。

b树2

B 树的一个节点通常包括三个部分:

  • 键值:即表中的主键
  • 指针:存储子节点的信息
  • 数据:除主键外的行数据
    正所谓“祸兮福所倚,福兮祸所伏”,因为 B 树的每个节点上都存储了数据,就导致每个节点能存储的键值和指针变少了,因为每一个节点的大小是固定的,对吧?
    于是 B+树就来了,B+树的非叶子节点只存储键值,不存储数据,而叶子节点会存储所有的行数据,并且构成一个有序链表。

20250621105348

这样做的好处是,非叶子节点由于没有存储数据,就可以存储更多的键值对,树就变得更加矮胖了,于是就更有劲了,每次搬的砖也就更多了(😂)。

相比 B 树,B+ 树的非叶子节点可容纳的键值更多,一个 16KB 的节点可存储约 1200 个键值,大幅降低树的高度。

由此一来,查找数据进行的磁盘 IO 就更少了,查询的效率也就更高了。

再加上叶子节点构成了一个有序链表,范围查询时就可以直接通过叶子节点间的指针顺序访问整个查询范围内的所有记录,而无需对树进行多次遍历。

B 树就做不到这一点。

—- 这部分是帮助理解 end,面试中可不背 —-

B+树的叶子节点是单向链表还是双向链表?如果从大值向小值检索,如何操作?

B+树的叶子节点是通过双向链表连接的,这样可以方便范围查询和反向遍历。

当执行范围查询时,可以从范围的开始点或结束点开始,向前或向后遍历。
在需要对数据进行逆序处理时,双向链表非常有用。
如果需要在 B+树中从大值向小值进行检索,可以先定位到最右侧节点,找到包含最大值的叶子节点。从根节点开始向右遍历树的方式实现。

20250621105458

定位到最右侧的叶子节点后,再利用叶节点间的双向链表向左遍历就好了。

为什么 MongoDB 的索引用 B树,而 MySQL 用 B+ 树?

MongoDB 通常以 JSON 格式存储文档,查询以单键查询(如 find({_id: 123}))为主。B 树的“节点既存键又存数据”的特性允许查询在非叶子节点提前终止,从而减少 I/O 次数。
20250621105550

MySQL 的查询通常涉及范围(WHERE id > 100)、排序(ORDER BY)、连接(JOIN)等操作。B+ 树的叶子节点是链表结构,天然支持顺序遍历,无需回溯至根节点或中序遍历,效率远高于 B 树。
20250621105601

20250621105641

42.🌟一棵B+树能存储多少条数据呢?

一句话回复:
一棵 B+ 树能存多少数据,取决于它的分支因子和高度。在 InnoDB 中,页的默认大小为 16KB,当主键为 bigint 时,3 层 B+ 树通常可以存储约 2000 万条数据。

20250623101840

—- 这部分是帮助理解 start,面试中可不背 —-

先来看一下计算公式

最大记录数 = (分支因子)^(树高度-1) × 叶子节点容量

再来看一下关键参数:
①、页大小,默认 16KB
②、主键大小,假设是 bigint 类型,那么它的大小就是 8 个字节。
③、页指针大小,InnoDB 源码中设置为 6 字节,4 字节页号 + 2 字节页内偏移。

所以非叶子节点可以存储 16384/14(键值+指针)=1170 个这样的单元。

当层高为 2 时,根节点可以存储 1170 个指针,指向 1170 个叶子节点,所以总数据量为 1170×16 =18720 条。

当层高为 3 时,根节点指向 1170 个非叶子节点,每个非叶子节点再指向 1170 个叶子节点,所以总数据量为 1170×1170×16≈21,902,400 条(约2,190万条)记录。

推荐阅读:清幽之地:InnoDB 一棵 B+树可以存放多少行数据?

—- 这部分是帮助理解 end,面试中可不背 —-

现在有一张表 2kw 数据,我这个 b+树的高度有几层?
对于 2KW 条数据来说,B+树的高度为 3 层就够了。

3层B+树

每个叶子节点能存放多少条数据?

如果单行数据大小为 1KB,那么每页可存储约 16 行(16KB/1KB)数据。

—- 这部分是帮助理解 start,面试中可不背 —-

假设有这样一个表结构:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` BIGINT PRIMARY KEY, -- 8字节
`name` VARCHAR(255) NOT NULL, -- 实际长度50字节(UTF8MB4,每个字符最多4字节)
`age` TINYINT, -- 1字节
`email` VARCHAR(255) -- 实际长度30字节,可为NULL
) ROW_FORMAT=COMPACT;

那么一行数据的大小为:8 + 50 + 1 + 30 = 89 字节。

行格式的开销为:行头 5 字节+指针 6 字节+可变长度字段开销 2 字节(name 和 email 各占 1 字节)+ NULL 位图 1 字节 = 14 字节。

所以每行数据的实际大小为:89 + 14 = 103 字节。

每页大小默认为 16KB,那么每页最多可以存储 16384 / 103 ≈ 158 行数据。

—- 这部分是帮助理解 end,面试中可不背 —-

44.🌟为什么用 B+ 树而不用 B 树呢?

B+ 树相比 B 树有 3 个显著优势:

第一,B 树的每个节点既存储键值,又存储数据和指针,导致单节点存储的键值数量较少
20250623103448

一个 16KB 的 InnoDB 页,如果数据较大,B 树的非叶子节点只能容纳几十个键值,而 B+ 树的非叶子节点可以容纳上千个键值。

第二,B 树的范围查询需要通过中序遍历逐层回溯;而 B+ 树的叶子节点通过双向链表顺序连接,范围查询只需定位起始点后顺序遍历链表即可,没有回溯开销。
20250623104021

第三,B 树的数据可能存储在任意节点,假如目标数据恰好位于根节点或上层节点,查询仅需 1-2 次 I/O;但如果数据位于底层节点,则需多次 I/O,导致查询时间波动较大。

B+ 树的所有数据都存储在叶子节点,查询路径的长度是固定的,**时间稳定为 O(logN)**,对 MySQL 在高并发场景下的稳定性至关重要

B+树的时间复杂度是多少?

O(logN)

20250623104724

为什么用 B+树不用跳表呢?

跳表本质上还是链表结构,只不过把某些节点抽到上层做了索引。

20250623104917

一条数据一个节点,如果需要存放 2000 万条数据,且每次查询都要能达到二分查找的效果,那么跳表的高度大约为 24 层(2 的 24 次方)。

在最坏的情况下,这 24 层数据分散在不同的数据页,查找一次数据就需要 24 次磁盘 I/O。

而 2000 万条数据在 B+树中只需要 3 层就可以了。

B+树的范围查找怎么做的?

一句话回答:

通过索引路径定位到第一个满足条件的叶子节点,然后顺着叶子节点之间的链表向右/向左扫描,直到超过范围

详细版:

B+ 树索引的范围查找主要依赖叶子节点之间的双向链表来完成

第一步,从 B+ 树的根节点开始,通过索引键值逐层向下,找到第一个满足条件的叶子节点

第二步,利用叶子节点之间的双向链表,从起始节点开始,依次向后遍历每个节点。当索引值超过查询范围,或者遍历到链表末尾时,终止查询。

了解快排吗

快速排序使用分治法将一个序列分为较小和较大的 2 个子序列,然后递归排序两个子序列,由东尼·霍尔在 1960 年提出。

演示
快排
其核心思想是:

  • 选择一个基准值。
  • 将数组分为两部分,左边小于基准值右边大于或等于基准值
  • 对左右两部分递归排序,最终合并。
    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

    public static void quickSort(int[] arr, int low, int high) {
    if (low < high) {
    int pivotIndex = partition(arr, low, high);
    quickSort(arr, low, pivotIndex - 1);
    quickSort(arr, pivotIndex + 1, high);
    }
    }
    private static int partition(int[] arr, int low, int high) {
    int pivot = arr[high];
    int i = low - 1;
    for (int j = low; j < high; j++) {
    if (arr[j] <= pivot) {
    i++;
    swap(arr, i, j);
    }
    }
    swap(arr, i + 1, high);
    return i + 1;
    }
    private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
    }

46.🌟聚族索引和非聚族索引有什么区别?

聚簇索引的叶子节点存储了完整的数据行,数据和索引是在一起的。InnoDB 的主键索引就是聚簇索引,叶子节点不仅存储了主键值,还存储了其他列的值,因此按照主键进行查询的速度会非常快。
20250623111022

每个表只能有一个聚簇索引,通常由主键定义。如果没有显式指定主键,InnoDB 会隐式创建一个隐藏的主键索引 row_id
非聚簇索引的叶子节点只包含了主键值,需要通过回表按照主键去聚簇索引查找其他列的值,唯一索引、普通索引等非主键索引都是非聚簇索引

20250623111225

每个表都可以创建多个非聚簇索引,如果不想回表的话,可以通过覆盖索引把要查询的字段也放到索引中。

—- 这部分是帮助大家理解 start,面试中可不背 —-

一张表只能有一个聚簇索引。

CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(100),
age INT
);
主键 id 是聚簇索引,B+ 树的叶子节点直接存储了 (id, name, age)。

一张表可以有多个非聚簇索引。

CREATE INDEX idx_name ON user(name);
CREATE INDEX idx_age ON user(age);
idx_name 是非聚簇索引,叶子节点存的是 name -> id,查整行数据要回表。

idx_age 也是非聚簇索引,叶子节点存的是 age -> id,查整行数据也要回表。

想要了解更多聚簇索引和非聚簇索引,推荐阅读:
https://www.cnblogs.com/vipstone/p/16370305.html
https://learnku.com/articles/50096
https://blog.csdn.net/m0_52226803/article/details/135494499
https://mp.weixin.qq.com/s/F0cEzIqecF4sWg7ZRmHKRQ
—- 这部分是帮助理解 end,面试中可不背 —-

47.🌟回表了解吗?

当使用非聚簇索引进行查询时,MySQL 需要先通过非聚簇索引找到主键值,然后再根据主键值回到聚簇索引中查找完整数据行,这个过程称为回表

20250623111452

假设现在有一张用户表 users:

1
2
3
4
5
6
7
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
email VARCHAR(50),
INDEX (name)
);

执行查询:

1
SELECT * FROM users WHERE name = '王二';

查询过程如下:

  • 第一步,MySQL 使用 name 列上的非聚簇索引查找所有 name = ‘王二’ 的主键 id。
  • 第二步,使用主键 id 到聚簇索引中查找完整记录。

回表的代价是什么?

回表通常需要访问额外的数据页,如果数据不在内存中,还需要从磁盘读取,增加 I/O 开销

20250623112230

可通过覆盖索引或者联合索引来避免回表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 原表结构
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_name (name)
);

-- 需要查询name和age
SELECT name, age FROM users WHERE name = '张三';
-- 这会回表,因为age不在idx_name索引中

-- 优化方案1:创建包含age的联合索引
ALTER TABLE users ADD INDEX idx_name_age (name, age);
-- 现在同样的查询不需要回表

什么情况下会触发回表?

第一,当查询字段不在非聚簇索引中时,必须回表到主键索引获取数据。
第二,查询字段包含非索引列(如 SELECT *),必然触发回表。

回表记录越多好吗?
回表记录越多,通常代表性能越差,因为每条记录都需要通过主键再查询一次完整数据。这个过程涉及内存访问或磁盘 IO,尤其当缓存命中率不高时,回表会严重影响查询效率

了解 MRR 吗?

MRR 是 InnoDB 为了解决回表带来的大量随机 IO 问题而引入的一种优化策略

MRR

它会先把非聚簇索引查到的主键值列表进行排序再按顺序去主键索引中批量回表,将随机 I/O 转换为顺序 I/O,以减少磁盘寻道时间。

—- 这部分是帮助理解 start,面试中可不背 —-

可通过 SHOW VARIABLES LIKE 'optimizer_switch'; 查看 MRR 是否启用。

20250623113200

其中 mrr=on 表示启用 MRR,mrr_cost_based=on 表示基于成本决定使用 MRR。

另外可以通过 show variables like 'read_rnd_buffer_size'; 查看 MRR 的缓冲区大小,默认是 256KB。

20250623113223
我们来创建一个表,插入一些数据,然后执行一个查询来演示 MRR 的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CREATE DATABASE IF NOT EXISTS mrr_test; 
USE mrr_test;
CREATE TABLE IF NOT EXISTS orders (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, order_date DATE, amount DECIMAL(10,2), status VARCHAR(20), INDEX idx_user_date(user_id, order_date));

DELIMITER //
CREATE PROCEDURE generate_test_data()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 100000 DO
INSERT INTO orders (user_id, order_date, amount, status)
VALUES (
FLOOR(1 + RAND() * 1000), -- Random user_id between 1 and 1000
DATE_ADD('2023-01-01', INTERVAL FLOOR(RAND() * 365) DAY), -- Random date in 2023
ROUND(10 + RAND() * 990, 2), -- Random amount between 10 and 1000
ELT(1 + FLOOR(RAND() * 3), 'completed', 'pending', 'cancelled') -- Random status
);
SET i = i + 1;
END WHILE;
END //
DELIMITER ;

CALL generate_test_data();
DROP PROCEDURE generate_test_data;"

查看 MRR 开启和关闭时的性能数据:

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
-- 确保MRR开启并设置足够大的缓冲区
SET SESSION optimizer_switch='mrr=on,mrr_cost_based=off';
SET SESSION read_rnd_buffer_size = 16*1024*1024;

-- 清理缓存和状态
FLUSH STATUS;
FLUSH TABLES;

-- 强制使用二级索引并回表查询(通过选择未被索引的列)
SELECT 'Raw data access pattern with MRR ON' as test_case;
SELECT /*+ MRR(orders_mrr_test) */ id, shipping_address, customer_name
FROM orders_mrr_test FORCE INDEX(idx_user_date)
WHERE user_id IN (100,200,300,400,500,600,700,800,900,1000)
AND order_date BETWEEN '2023-03-01' AND '2023-04-01'
LIMIT 15;

-- 显示处理器状态
SHOW STATUS LIKE 'Handler_%';
SHOW STATUS LIKE '%mrr%';

-- 对比:关闭MRR
SET SESSION optimizer_switch='mrr=off,mrr_cost_based=off';
FLUSH STATUS;
FLUSH TABLES;

SELECT 'Raw data access pattern with MRR OFF' as test_case;
SELECT id, shipping_address, customer_name
FROM orders_mrr_test FORCE INDEX(idx_user_date)
WHERE user_id IN (100,200,300,400,500,600,700,800,900,1000)
AND order_date BETWEEN '2023-03-01' AND '2023-04-01'
LIMIT 15;
-- 显示处理器状态
SHOW STATUS LIKE 'Handler_%';
SHOW STATUS LIKE '%mrr%';

-- 显示详细的执行计划
EXPLAIN FORMAT=TREE
SELECT /*+ MRR(orders_mrr_test) */ id, shipping_address, customer_name
FROM orders_mrr_test FORCE INDEX(idx_user_date)
WHERE user_id IN (100,200,300,400,500,600,700,800,900,1000)
AND order_date BETWEEN '2023-03-01' AND '2023-04-01';"

可以看到 MRR 开启时的结果对比:
20250623113305

Wrap 也给出了对应的结果说明:
20250623113322
也可以在 explain 中确认 MRR 的使用情况。
20250623113335
—- 这部分是帮助理解 end,面试中可不背 —-

48.🌟联合索引了解吗?(补充)

联合索引就是把多个字段放在一个索引里,但必须遵守“最左前缀”原则,只有从第一个字段开始连续使用,索引才会生效。

联合索引

联合索引会按字段顺序构建B+树。例如(age, name)索引会先按照 age 排序,age 相同则按照 name 排序,若两者都相同则按主键排序,确保叶子节点无重复索引项。

创建(A,B,C)联合索引相当于同时创建了(A)、(A,B)和(A,B,C)三个索引。

1
2
3
4
5
6
7
-- 创建联合索引
CREATE INDEX idx_order_user_product ON orders(user_id, product_id, create_time)

-- 高效查询
SELECT * FROM orders
WHERE user_id=1001 AND product_id=2002
ORDER BY create_time DESC

联合索引底层的存储结构是怎样的?

联合索引在底层采用 B+ 树结构进行存储,这一点与单列索引相同。

20250624093822

与单列索引不同的是,联合索引的每个节点会存储所有索引列的值,而不仅仅是第一列的值。例如,对于联合索引(a,b,c),每个节点都包含 a、b、c 三列的值

1
2
3
4
5
6
非叶子节点示例:  
[(a=1, b=2, c=3) → 子节点1, (a=5, b=3, c=1) → 子节点2]

叶子节点示例(InnoDB):
(a=1, b=2, c=3) → PK=100 | (a=1, b=2, c=4) → PK=101
(通过指针连接形成双向链表)

联合索引的叶子节点存的什么内容?

联合索引属于非聚簇索引,叶子节点存储的是联合索引各列的值和对应行的主键值,而不是完整的数据行。查询非索引字段时,需要通过主键值回表到聚簇索引获取完整数据。

20250624094019

例如索引(a, b)的叶子节点会完整存储(a, b)的值,并按字段顺序排序(如 a 优先,a 相同则按 b 排序)。如果主键是 id,叶子节点会存储 (a, b, id) 的组合。

49.🌟覆盖索引了解吗?

覆盖索引指的是:查询所需的字段全部都在索引中不需要回表,从索引页就能直接返回结果。

20250624094156

empnamejob 两个字段是一个联合索引,而查询也恰好是这两个字段,这时候单次查询就可以达到目的,不需要回表。

可以将高频查询的字段(如 WHERE 条件和 SELECT 列)组合为联合索引,实现覆盖索引。 例如:

1
CREATE INDEX idx_empname_job ON employee(empname, job);

这样查询的时候就可以走索引:

1
SELECT empname, job FROM employee WHERE empname = '王二' AND job = '程序员';

普通索引只用于加速查询条件的匹配,而覆盖索引还能直接提供查询结果。

一个表(name, sex,age,id),select age,id,name from tblname where name=’paicoding’;怎么建索引
由于查询条件有 name 字段,所以最少应该为 name 字段添加一个索引。、

1
CREATE INDEX idx_name ON tblname(name);

查询结果中还需要 age、id 字段,可以为这三个字段创建一个联合索引,利用覆盖索引,直接从索引中获取数据,减少回表。

1
CREATE INDEX idx_name_age_id ON tblname (name, age, id);

50.🌟什么是最左前缀原则?

最左前缀原则指的是:MySQL 使用联合索引时,必须从最左边的字段开始匹配,才能命中索引。

假设有一个联合索引 (A, B, C),其生效条件如下:
20250624094631

如果排序或分组的列是最左前缀的一部分,索引还可以加速操作。

1
2
-- 索引(a,b)
SELECT * FROM table WHERE a = 1 ORDER BY b; -- 可以利用索引排序

范围查询后的列还能用索引吗?

范围查询只能应用于最左前缀的最后一列。范围查询之后的列无法使用索引。

1
2
3
-- 索引(a,b,c)
SELECT * FROM table WHERE a = 1 AND b > 2 AND c = 3;
-- 只能使用a和b,c无法使用索引

为什么不从最左开始查,就无法匹配呢?

一句话回答:

因为联合索引在 B+ 树中是按照最左字段优先排序构建的,如果跳过最左字段,MySQL 无法判断查找范围从哪里开始,自然也就无法使用索引。

20250624094835

比如有一个 user 表,我们给 name 和 age 建立了一个联合索引 (name, age)。

1
ALTER TABLE user add INDEX comidx_name_phone (name,age);

联合索引在 B+ 树中按照从左到右的顺序依次建立搜索树,name 在左,age 在右。

当我们使用 where name= ‘王二’ and age = ‘20’ 去查询的时候, B+ 树会优先比较 name 来确定下一步应该搜索的方向,往左还是往右。

如果 name 相同的时候再比较 age。

但如果查询条件没有 name,就不知道应该怎么查了,因为 name 是 B+树中的前置条件,没有 name,索引就派不上用场了。

联合索引 (a, b),where a = 1 和 where b = 1,效果是一样的吗

不一样。

WHERE a = 1 能命中联合索引,因为 a 是联合索引的第一个字段,符合最左前缀匹配原则。而 WHERE b = 1 无法命中联合索引,因为缺少 a 的匹配条件,MySQL 会全表扫描

—- 这部分是帮助理解 start,面试中可不背 —-

我们来验证一下,假设有一个 ab 表,建立了联合索引 (a, b)

1
2
3
4
5
CREATE TABLE ab (
a INT,
b INT,
INDEX ab_index (a, b)
);

插入数据:

1
INSERT INTO ab (a, b) VALUES (1, 2), (1, 3), (2, 1), (3, 3), (2, 2);

执行查询:
20250624100243

通过 explain 可以看到,WHERE a = 1 使用了联合索引,而 WHERE b = 1 需要全表扫描,依次检查每一行。

—- 这部分是帮助理解 end,面试中可不背 —-

假如有联合索引 abc,下面的 sql 怎么走的联合索引?

1
2
3
select * from t where a = 2 and b = 2;
select * from t where b = 2 and c = 2;
select * from t where a > 2 and b = 2;

第一条 SQL 语句包含条件 a = 2 和 b = 2,刚好符合联合索引的前两列。
20250624100956

第二条 SQL 语句由于未使用最左前缀中的 a,会触发全表扫描。
20250624101013

第三条 SQL 语句在范围条件 a > 2 之后,索引后会停止匹配,b = 2 的条件需要额外过滤。
20250624101041

(A,B,C) 联合索引 select * from tbn where a=? and b in (?,?) and c>? 会走索引吗?

这个查询会命中联合索引,因为 a 是等值匹配,b 是 IN 等值多匹配,c 是 b 之后的范围条件,符合最左前缀原则。

对于 a=?:这是一个精确匹配,并且是联合索引的第一个字段,所以一定会命中索引。

对于 b IN (?, ?):等价于 b=? OR b=?,属于多值匹配,并且是联合索引的第二个字段,所以也会命中索引。

对于 c>?:这是一个范围条件,属于联合索引的第三个字段,也会命中索引。

—- 这部分是帮助理解 start,面试中可不背 —-

来验证一下。

第一步,建表。

1
CREATE TABLE tbn (A INT, B INT, C INT, D TEXT);

第二步,创建索引。

1
CREATE INDEX idx_abc ON tbn (A, B, C);

第三步,插入数据。

1
2
3
4
5
INSERT INTO tbn VALUES (1, 2, 3, 'First');
INSERT INTO tbn VALUES (1, 2, 4, 'Second');
INSERT INTO tbn VALUES (1, 3, 5, 'Third');
INSERT INTO tbn VALUES (2, 2, 3, 'Fourth');
INSERT INTO tbn VALUES (2, 3, 4, 'Fifth');

第四步,执行查询。

1
EXPLAIN SELECT * FROM tbn WHERE A=1 AND B IN (2, 3) AND C>3\G

20250624101415
EXPLAIN 输出结果来看,我们可以得到 MySQL 是如何执行查询的一些关键信息:

  • type: 查询类型,这里是 range,表示 MySQL 使用了范围查找,这是因为查询条件包含了 > 操作符。
  • possible_keys: 可能被用来执行查询的索引,这里是 idx_abc,表示 MySQL 认为 idx_abc 索引会用于查询优化。
  • key: 实际用来执行查询的索引,也是 idx_abc,这确定这条查询命中了联合索引。
  • Extra: 提供了关于查询执行的额外信息。Using index condition 表示 MySQL 使用了索引下推(Index Condition Pushdown,ICP),这是 MySQL 的一个优化方式,它允许在索引层面过滤数据。

—- 这部分是帮助理解 end,面试中可不背 —-

联合索引的一个场景题:(a,b,c)联合索引,(b,c)是否会走索引吗?

根据最左前缀原则,(b,c) 查询不会走索引。

因为联合索引 (a,b,c) 中,a 是最左边的列,联合索引在创建索引树的时候需要先有 a,然后才会有 b 和 c。而查询条件中没有包含 a,所以 MySQL 无法利用这个索引。

1
EXPLAIN SELECT * FROM tbn WHERE B=1 AND C=1\G

20250624101535

建立联合索引(a,b,c),where c = 5 是否会用到索引?为什么?

不会。只有索引的第三列 c 被用作查询条件,而前两列 a 和 b 都没有被使用。这不符合最左前缀原则

1
EXPLAIN SELECT * FROM tbn WHERE C=5\G

20250624101624

sql中使用like,如果遵循最左前缀匹配,查询是不是一定会用到索引?

如果查询模式是后缀通配符 LIKE 'prefix%',且该字段有索引,优化器通常会使用索引。否则即便是遵循最左前缀匹配,LIKE 字段也无法命中索引。

如 age = 18 and name LIKE ‘%xxx’,MySQL 会先使用联合索引 age_name 找到 age 符合条件的所有行,然后再全表扫描进行 name 字段的过滤。

20250624101721

type: ref 表示使用索引查找匹配某个值的所有行。

20250624101751

如果是后缀通配符,如 age = 18 and name LIKE 'xxx%',MySQL 会直接使用联合索引 age_name 找到所有符合条件的行。

20250624101837

type 为 range,表示 MySQL 使用了索引范围扫描,filtered 为 100.00%,表示在扫描的行中,所有的行都满足 WHERE 条件。

51.🌟什么是索引下推?

索引下推是指:MySQL 把 WHERE 条件尽可能“下推”到索引扫描阶段,在存储引擎层提前过滤掉不符合条件的记录。

20250624102101

当查询条件包含索引列但未完全匹配时,ICP 会在存储引擎层过滤非索引列条件,以减少回表次数。

传统的查询流程是,存储引擎通过联合索引定位到符合最左前缀条件的主键 ID;回表读取完整数据行并返回给 Server 层;Server 层对所有返回的行进行 WHERE 条件过滤。

有了 ICP 后,存储引擎在索引层直接过滤可下推的条件,仅对符合索引条件的记录回表读取数据,再返回给 Server 层进行剩余条件过滤。

—- 这部分是帮助理解 start,面试中可不背 —-

例如有一张 user 表,建了一个联合索引(name, age),查询语句:select * from user where name like '张%' and age=10;,没有索引下推优化的情况下:

MySQL 会使用索引 name 找到所有 name like '张%' 的主键,根据这些主键,一条条回表查询整行数据,并在 Server 层过滤掉不符合 age=10 的数据行。

20250624102301

启用 ICP 后,InnoDB 会通过联合索引直接筛选出符合条件的主键 ID(name like '张%' and age=10),然后再回表查询整行数据。

20250624102354

换句话说,假设 name like ‘张%’ 找到 10000 行数据,age=10 只有其中 10 行,没有索引下推的情况下,MySQL 会回表 10000 次,读取 10000 行数据,然后在 Server 层过滤掉 9990 行。

而有了索引下推后,MySQL 只会回表 10 次,读取 10 行数据。

我们来验证一下。

20250624102426

从结果中我们可以清楚地看到 ICP 的效果。ICP 开启时,Extra 列显示”Using index condition”,表明过滤条件被下推到存储引擎层。

ICP关闭时,Extra 列仅显示”Using where”,表明过滤条件在服务器层执行。

20250624102507

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
-- 开启ICP
SET optimizer_switch='index_condition_pushdown=on';

-- 清理状态
FLUSH STATUS;

SELECT 'Performance test with ICP ON' as test_case;
-- 执行查询并分析性能
EXPLAIN ANALYZE
SELECT /*+ ICP_ON */ *
FROM orders_mrr_test
WHERE user_id BETWEEN 100 AND 200
AND order_date >= '2023-01-01'
AND order_date < '2023-02-01'
AND order_date NOT LIKE '2023-01-15%';

-- 显示处理器状态
SHOW STATUS LIKE 'Handler_read%';

-- 关闭ICP
SET optimizer_switch='index_condition_pushdown=off';

-- 清理状态
FLUSH STATUS;

SELECT 'Performance test with ICP OFF' as test_case;
-- 执行相同的查询
EXPLAIN ANALYZE
SELECT *
FROM orders_mrr_test
WHERE user_id BETWEEN 100 AND 200
AND order_date >= '2023-01-01'
AND order_date < '2023-02-01'
AND order_date NOT LIKE '2023-01-15%';

-- 显示处理器状态
SHOW STATUS LIKE 'Handler_read%';"

实际的性能差距也很大。ICP 开启时,实际扫描行数:1,649 行,执行时间:约12.3 毫秒。关闭时,实际扫描行数:19,959 行,执行时间:约 32.1 毫秒。
Spring 事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,Spring 是无法提供事务功能的。Spring 只提供统一事务管理接口,具体实现都是由各数据库自己实现,数据库事务的提交和回滚是通过数据库自己的事务机制实现。

20250624102546

53.🌟MySQL 中有哪几种锁?

MySQL 中有多种类型的锁,可以从不同维度来分类,按锁粒度划分的话,有表锁、行锁。

按照加锁机制划分的话,有乐观锁和悲观锁。按照兼容性划分的话,有共享锁和排他锁。

20250625092449

—- 这部分是帮助理解 start,面试中可不背 —-

表锁:锁定整个表,资源开销小,加锁快,但并发度低,不会出现死锁;适合查询为主、少量更新的场景(如 MyISAM 引擎)。

20250625093141

再细分的话,有表共享读锁(S锁):允许多个事务同时读,但阻塞写操作;表独占写锁(X锁):独占表,阻塞其他事务的读写。

20250625095630

行锁:锁定单行或多行,开销大、加锁慢,可能出现死锁,但并发度高(InnoDB 默认支持)。

再细分的话,有记录锁(Record Lock):锁定索引中的具体记录;间隙锁(Gap Lock):锁定索引记录之间的间隙,防止幻读;临键锁(Next-Key Lock):结合记录锁和间隙锁,锁定一个左开右闭的区间(如 (5, 10])。

共享锁(S锁/读锁),允许多个事务同时读取数据,但阻塞写操作。语法:SELECT ... LOCK IN SHARE MODE

排他锁(X锁/写锁),独占数据,阻塞其他事务的读写。语法:SELECT ... FOR UPDATE

乐观锁假设冲突少,通过版本号或 CAS 机制检测冲突(如 UPDATE SET version=version+1 WHERE version=old_version)。

悲观锁假设并发冲突频繁,先加锁再操作SELECT FOR UPDATE
—- 这部分是帮助理解 end,面试中可不背 —-

55.🌟说说 MySQL 的行锁?

行锁是 InnoDB 存储引擎中最细粒度的锁,它锁定表中的一行记录,允许其他事务访问表中的其他行。

底层是通过给索引加锁实现的,这就意味着只有通过索引条件检索数据时,InnoDB 才能使用行级锁,否则会退化为表锁。

20250625103938

行锁又可以细分为记录锁间隙锁临键锁三种形式。通过 SELECT ... FOR UPDATE 可以加排他锁。

1
2
3
4
5
6
7
8
9
START TRANSACTION;

-- 加排他锁,锁定某一行
SELECT * FROM your_table WHERE id = 1 FOR UPDATE;
-- 对该行进行操作
UPDATE your_table SET column1 = 'new_value' WHERE id = 1;

COMMIT;

通过 SELECT ...LOCK IN SHARE MODE 可以加共享锁。

1
2
3
4
5
6
7
START TRANSACTION;

-- 加共享锁,锁定某一行
SELECT * FROM your_table WHERE id = 1 LOCK IN SHARE MODE;
-- 只能读取该行,不能修改

COMMIT;

select for update 有什么需要注意的?

第一,必须在事务中使用,否则锁会立即释放。

1
2
3
4
START TRANSACTION;
SELECT * FROM your_table WHERE id = 1 FOR UPDATE;
-- 对该行进行操作
COMMIT;

第二,使用时必须注意是否命中索引,否则可能锁全表。

1
2
-- name 没有索引,会退化为表锁
SELECT * FROM user WHERE name = '王二' FOR UPDATE;

—- 这部分是帮助理解 start,面试中可不背 —-

假设有一张名为 orders 的表,包含以下数据:

1
2
3
4
5
6
7
CREATE TABLE orders (
id INT PRIMARY KEY,
order_no VARCHAR(255),
amount DECIMAL(10,2),
status VARCHAR(50),
INDEX (order_no) -- order_no 上有索引
);

表中的数据是这样的:

20250625104220
如果我们通过主键索引执行 SELECT FOR UPDATE,确实只会锁定特定的行:

1
2
3
4
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 对 id=1 的行进行操作
COMMIT;

由于 id 是主键,所以只会锁定 id=1 这行,不会影响其他行的操作。其他事务依然可以对 id = 2, 3, 4, 5 等行执行更新操作,因为它们没有被锁定。
如果使用 order_no 这个普通索引执行 SELECT FOR UPDATE,也只会锁定特定的行:

1
2
3
4
START TRANSACTION;
SELECT * FROM orders WHERE order_no = '10001' FOR UPDATE;
-- 对 order_no=10001 的行进行操作
COMMIT;

因为 order_no 是唯一索引,所以只会锁定 order_no=10001 这行,不会影响其他行的操作。

但如果 WHERE 条件是 status=’pending’,而 status 上没有索引:

1
2
3
4
START TRANSACTION;
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 对 status=pending 的行进行操作
COMMIT;

就会退化为表锁,因为在这种情况下,MySQL 需要全表扫描检查每一行的 status。

—- 这部分是帮助理解 end,面试中可不背 —-

说说记录锁吧?

记录锁是行锁最基本的表现形式,当我们使用唯一索引或者主键索引进行等值查询时,MySQL 会为该记录自动添加排他锁禁止其他事务读取或者修改锁定记录

20250625105204

例如:

1
2
3
4

SELECT * FROM table WHERE id = 1 FOR UPDATE; -- 加X锁

UPDATE table SET name = '王二' WHERE id = 1; -- 隐式加X锁

间隙锁了解吗?

间隙锁用于在范围查询时锁定记录之间的“间隙”防止其他事务在该范围内插入新记录。仅在可重复读及以上的隔离级别下生效,主要用于防止幻读。

20250625105253

—- 这部分是帮助大家理解 start,面试中可不背 —-

例如事务 A 锁定了 (1000,2000) 区间,会阻止事务 B 在此区间插入新记录:

1
2
3
4
5
6
-- 事务A
BEGIN;
SELECT * FROM orders WHERE amount BETWEEN 1000 AND 2000 FOR UPDATE;

-- 事务B尝试插入会被阻塞
INSERT INTO orders VALUES(null,1500,'pending'); -- 阻塞</code>

假设表 test_gaplock 有 id、age、name 三个字段,其中 id 是主键,age 上有索引,并插入了 4 条数据。

1
2
3
4
5
6
7
8
9
CREATE TABLE `test_gaplock` (
`id` int(11) NOT NULL,
`age` int(11) DEFAULT NULL,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `age` (`age`)
) ENGINE=InnoDB;

insert into test_gaplock values(1,1,'张三'),(6,6,'吴老二'),(8,8,'赵四'),(12,12,'熊大');

间隙锁会锁住:

  • (−∞, 1):最小记录之前的间隙。
  • (1, 6)、(6, 8)、(8, 12):记录之间的间隙。
  • (12, +∞):最大记录之后的间隙。

20250625110538

假设有两个事务,T1 执行以下语句:

1
2
START TRANSACTION;
SELECT * FROM test_gaplock WHERE age > 5 FOR UPDATE;

T2 执行以下语句:

1
2
START TRANSACTION;
INSERT INTO test_gaplock VALUES (7, 7, '王五');

T1 会锁住 (6, 8) 的间隙,防止其他事务在这个范围内插入新记录。

T2 在插入 (7, 7, '王五') 时,会被阻塞,可以在另外一个会话中执行 SHOW ENGINE INNODB STATUS 查看到间隙锁的信息。
20250625112428

执行什么命令会加上间隙锁?

在可重复读隔离级别下,执行FOR UPDATE / LOCK IN SHARE MODE等加锁语句,且查询条件是范围查询时,就会自动加上间隙锁。

1
2
3
4
5
6
-- SELECT ... FOR UPDATE + 范围查询
SELECT * FROM user WHERE score > 100 FOR UPDATE;
-- SELECT ... LOCK IN SHARE MODE + 范围查询
SELECT * FROM user WHERE id BETWEEN 10 AND 20 LOCK IN SHARE MODE;
-- UPDATE/DELETE + 范围查询
DELETE FROM user WHERE score < 50;

58.🌟MySQL的乐观锁和悲观锁了解吗?

悲观锁是一种”先上锁再操作”的保守策略,它假设数据被外界访问时必然会产生冲突,因此在数据处理过程中全程加锁,保证同一时间只有一个线程可以访问数据。

20251011152502
牧小农:悲观锁

MySQL 中的行锁和表锁都是悲观锁。

20251011152533
牧小农:悲观锁的处理思路

乐观锁会假设并发操作不会总发生冲突,属于小概率事件,因此不会在读取数据时加锁,而是在提交更新时才检查数据是否被其他事务修改过。

20251011152635
牧小农:乐观锁

乐观锁并不是 MySQL 内置的锁机制,而是通过程序逻辑实现的,常见的实现方式有版本号机制和时间戳机制。通过在表中增加 version 字段或者 timestamp 字段来实现。

-— 这部分是帮助大家理解 start,面试中可不背 —-

当事务 A 已经上锁后,事务 B 会一直等待事务 A 释放锁;如果事务 A 长时间不释放锁,事务 B 就会报错 Lock wait timeout exceeded; try restarting transaction

20251011152707
牧小农:的实现方式

事务 A 和事务 B 同时读取同一个主键 ID 的数据,版本号为 0;事务 A 将版本号(version=1)作为条件进行数据更新,同时版本号 +1;事务 B 也将 version=1 作为更新条件,发现版本号不匹配,更新失败。

20251011152751
牧小农:乐观锁的实现方式

-— 这部分是帮助大家理解 end,面试中可不背 —-

如何通过悲观锁和乐观锁解决库存超卖问题?

悲观锁通过 SELECT ... FOR UPDATE 在查询时直接锁定记录,确保其他事务必须等待当前事务完成才能操作该行数据。

1
2
3
4
5
6
7
8
BEGIN;
-- 对id=1的商品记录加排他锁
SELECT stock FROM products WHERE id=1 FOR UPDATE;
-- 生成订单
INSERT INTO orders (user_id, product_id) VALUES (123, 1);
-- 扣减库存
UPDATE products SET stock=stock-1 WHERE id=1;
COMMIT;

乐观锁通过在表中增加 version 字段作为判断条件。

1
2
3
4
5
6
7
-- 查询商品信息,获取版本号
SELECT stock, version FROM products WHERE id=1;

-- 更新库存时检查版本号
UPDATE products
SET stock=stock-1, version=version+1
WHERE id=1 AND version=旧版本号;

-— 这部分是帮助大家理解 start,面试中可不背 —-

库存超卖是一个非常经典的问题:

  • 事务A查询商品库存,得到库存值为1
  • 事务B也查询同一商品库存,同样得到库存值为1
  • 事务A基于查询结果执行库存扣减,将库存更新为0
  • 事务B也执行库存扣减,将库存更新为-1

悲观锁的关键点:

  • 必须在一个事务中执行;
  • 通过 SELECT ... FOR UPDATE 锁定行,确保其他事务必须等待当前事务完成才能操作该行数据;
  • 记得给查询条件加索引,避免全表扫描导致锁升级为表锁。

乐观锁的关键点:

  • 在表中增加 version 字段;
  • 查询时获取当前版本号;
  • 更新时检查版本号是否发生了变化。

Java 程序的完整代码示例:

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
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;

@Transactional
public boolean purchaseWithOptimisticLock(Long productId, int quantity) {
int retryCount = 0;
while(retryCount < 3) { // 最大重试次数
Product product = productMapper.selectById(productId);
if(product.getStock() < quantity) {
return false; // 库存不足
}

int updated = productMapper.reduceStockWithVersion(
productId, quantity, product.getVersion());

if(updated > 0) {
return true; // 更新成功
}
retryCount++;
}
return false; // 更新失败
}
}

对应的 mapper:

1
2
3
4
5
@Update("UPDATE products SET stock=stock-#{quantity}, version=version+1 " +
"WHERE id=#{productId} AND version=#{version}")
int reduceStockWithVersion(@Param("productId") Long productId,
@Param("quantity") int quantity,
@Param("version") int version);

时间戳机制实现的乐观锁:

1
2
UPDATE products SET stock=stock-1, update_time=NOW() 
WHERE id=1 AND update_time=旧时间戳;

这两种方式都需要保证操作的原子性,需要将多个 SQL 放在同一个事务中执行。

推荐阅读:牧小农:悲观锁和乐观锁

-— 这部分是帮助大家理解 end,面试中可不背 —-

60.🌟MySQL事务的四大特性说一下?

事务是一条或多条 SQL 语句组成的执行单元。四个特性分别是原子性、一致性、隔离性和持久性。原子性保证事务中的操作要么全部执行、要么全部失败;一致性保证数据从事务开始前的一个一致状态转移到结束后的另外一个一致状态;隔离性保证并发事务之间互不干扰;持久性保证事务提交后数据不会丢失。

20251011155649
北野新津:ACID

详细说一下原子性?

原子性意味着事务中的所有操作要么全部完成,要么全部不完成,它是不可分割的单位。如果事务中的任何一个操作失败了,整个事务都会回滚到事务开始之前的状态,如同这些操作从未被执行过一样。

1
2
3
4
5
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
-- 如果第二条语句失败,第一条也会回滚
COMMIT;

简短回答:原子性要求事务的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务中的操作不能只执行其中一部分。

详细说一下一致性?

一致性确保事务从一个一致的状态转换到另一个一致的状态。

比如在银行转账事务中,无论发生什么,转账前后两个账户的总金额应保持不变。假如 A 账户(100 块)给 B 账户(10 块)转了 10 块钱,不管成功与否,A 和 B 的总金额都是 110 块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 假设 A 账户余额为 100,B 账户余额为 10

-- 转账前状态
SELECT balance FROM accounts WHERE user_id = 'A'; -- 100
SELECT balance FROM accounts WHERE user_id = 'B'; -- 10

-- 转账操作
START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 10 WHERE user_id = 'B';
COMMIT;

-- 转账后状态
SELECT balance FROM accounts WHERE user_id = 'A'; -- 90
SELECT balance FROM accounts WHERE user_id = 'B'; -- 20`
-- 总金额仍然是 110

简短回答:一致性确保数据的状态从一个一致状态转变为另一个一致状态。一致性与业务规则有关,比如银行转账,不论事务成功还是失败,转账双方的总金额应该是不变的。

详细说一下隔离性?

隔离性意味着并发执行的事务是彼此隔离的,一个事务的执行不会被其他事务干扰。事务之间是井水不犯河水的。

隔离性主要是为了解决事务并发执行时可能出现的脏读、不可重复读、幻读等问题。

-— 这部分是帮助大家理解 start,面试中可不背 —-

比如说在读未提交的隔离级别下,会出现脏读现象:一个事务C 读取了事务B 尚未提交的修改数据。如果事务B 最终回滚,事务C 读取的数据就是无效的“脏数据”。

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
-- 会话 A
-- 创建模拟并发的测试表
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
balance DECIMAL(10,2)
);

-- 插入测试数据
INSERT INTO accounts (name, balance) VALUES
('王二', 1000.00),
('张三', 2000.00),
('李四', 3000.00);

-- 会话B 中,设置隔离级别为读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;

-- 在会话 B 中更新数据但不提交
UPDATE accounts SET balance = balance - 500 WHERE name='王二';

-- 会话C 是读为提交级别,读取数据,得到 500
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT * FROM accounts WHERE name='王二';
-- 继续别的操作,基于 500

-- 会话 B 的事务回滚,导致会话 A 读到的数据其实是脏数据
ROLLBACK;

20251011160550
二哥的 Java 进阶之路:读未提交下出现脏读

通过升级隔离级别为读已提交可以解决脏读的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 会话 B 修改为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 执行第一次查询 1000
SELECT * FROM accounts WHERE name='王二';

-- 会话 C 中,设置隔离级别为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 在会话 C 中更新数据但不提交
START TRANSACTION;
UPDATE accounts SET balance = balance + 200 WHERE name='王二';

-- 会话 B 中再次读取数据,结果仍然为 1000
SELECT * FROM accounts WHERE name='王二';

-- 会话 C 中回滚事务
ROLLBACK;
-- 会话 B 中再次读取数据,结果仍然为 1000
SELECT * FROM accounts WHERE name='王二';

20251011162301
二哥的 Java 进阶之路:读已提交可以解决脏读问题

但会出现不可重复读的问题:事务B 第一次读取某行数据值为X,期间事务C修改该数据为Y并提交,事务B 再次读取时发现值变为Y,导致两次读取结果不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 会话 B 修改为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 执行第一次查询 1000
START TRANSACTION;
SELECT * FROM accounts WHERE name='王二';

-- 会话 C 中,设置隔离级别为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 在会话 C 中更新数据并提交
START TRANSACTION;
UPDATE accounts SET balance = balance + 200 WHERE name='王二';
-- 会话 C 提交事务
COMMIT;

-- 会话 B 中再次读取数据,结果仍然为 1200
SELECT * FROM accounts WHERE name='王二';

20251011162620
二哥的 Java 进阶之路:读已提交会出现不可重复读的问题

可以通过升级隔离级别为可重复读来解决不可重复读的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 会话 B 修改为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 开始事务并执行第一次查询 1000
START TRANSACTION;
SELECT * FROM accounts WHERE name='王二';

-- 会话 C 中,设置隔离级别为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 在会话 C 中更新数据并提交
START TRANSACTION;
UPDATE accounts SET balance = balance + 200 WHERE name='王二';
-- 会话 C 提交事务
COMMIT;

-- 会话 B 中再次读取数据,结果仍然为 1000
SELECT * FROM accounts WHERE name='王二';

20251011165009
二哥的 Java 进阶之路:可重复读级别解决不可重复读的问题

但可重复读级别下仍然会出现幻读的问题:事务B 第一次查询获得 2条数据,事务C 新增 1条数据并提交后,事务B 再次查询时仍然为 2 条数据,但可以更新新增的数据,再次查询时就发现有 3 条数据了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 会话 B 修改为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 执行第一次查询,查到 2 条记录
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 1000;

-- 会话 C 中,设置隔离级别为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 在会话 C 中新增数据并提交
START TRANSACTION;
INSERT INTO accounts (name, balance) VALUES ('王五', 4000);
-- 会话 C 提交事务
COMMIT;

-- 会话 B 中再次读取数据,结果仍然为 2 条
SELECT * FROM accounts WHERE balance > 1000;
-- 会话 B 中尝试更新王五的余额为 5000,竟然成功了
UPDATE accounts SET balance = 5000 WHERE name='王五';
-- 会话 B 中再次读取数据,发现 3 条记录
SELECT * FROM accounts WHERE balance > 1000;

20251027183826
二哥的 Java 进阶之路:可重复读级别下可能出现幻读

可以通过升级隔离级别为串行化来解决幻读的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 会话 B 修改为可串行化
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 执行第一次查询,查到 2 条记录
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 1000;

-- 会话 C 中,设置隔离级别为可串行化
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 在会话 C 中新增数据,会卡住
START TRANSACTION;
INSERT INTO accounts (name, balance) VALUES ('王五', 4000);
-- 只有等会话 B 提交事务后会话 C 才会继续执行并提交事务
COMMIT;

20251011165026
二哥的 Java 进阶之路:串行化隔离级别下不会出现幻读问题

隔离级别 是否会脏读 是否会不可重复读 是否会幻读
Read Uncommitted(读未提交) ✅ 可能 ✅ 可能 ✅ 可能
Read Committed(读已提交) ❌ 不会 ✅ 可能 ✅ 可能
Repeatable Read(可重复读) ❌ 不会 ❌ 不会 ✅ 可能(但 InnoDB 已解决)
Serializable(可串行化) ❌ 不会 ❌ 不会 ❌ 不会

-— 这部分是帮助大家理解 end,面试中可不背 —-

简短回答:多个并发事务之间需要相互隔离,即一个事务的执行不能被其他事务干扰。

详细说一下持久性?

持久性确保事务一旦提交,它对数据所做的更改就是永久性的,即使系统发生崩溃,数据也能恢复到最近一次提交的状态。

MySQL 的持久性是通过 InnoDB 引擎的 redo log 实现的。在事务提交时,InnoDB 会先将修改操作写入 redo log,并刷盘持久化。崩溃后,InnoDB 会通过 redo log 恢复数据,从而保证事务提交成功的数据不会丢失。

20251011170430
Mayank Sharma:可持久化

简短回答:一旦事务提交,则其所做的修改将永久保存到 MySQL 中。即使发生系统崩溃,修改的数据也不会丢失。

62.🌟事务的隔离级别有哪些?

隔离级别定义了一个事务可能受其他事务影响的程度,MySQL 支持四种隔离级别,分别是:读未提交、读已提交、可重复读和串行化。

20251012183903
draven.co:事务的四个隔离级别

读未提交会出现脏读,读已提交会出现不可重复读,可重复读是 InnoDB 默认的隔离级别,可以避免脏读和不可重复读,但会出现幻读。不过通过 MVCC 和临键锁,能够防止大多数并发问题。

串行化最安全,但性能较差,通常不推荐使用。

详细说说读未提交?

事务可以读取其他未提交事务修改的数据。也就是说,如果未提交的事务一旦回滚,读取到的数据就会变成了“脏数据”,通常不会使用。

易尘埃:读未提交

易尘埃:读未提交

什么是读已提交?

读已提交避免了脏读,但可能会出现不可重复读,即同一事务内多次读取同一数据结果会不同,因为其他事务提交的修改,对当前事务是可见的。

20251012193756
易尘埃:读已提交

是 Oracle、SQL Server 等数据库的默认隔离级别。

什么是可重复读?

可重复读能确保同一事务内多次读取相同数据的结果一致,即使其他事务已提交修改。

20251012193828
易尘埃:可重复读

是 MySQL 默认的隔离级别,避免了“脏读”和“不可重复读”,通过 MVCC 和临键锁也能在一定程度上避免幻读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Session A:
START TRANSACTION;
SELECT balance FROM accounts WHERE id=1; --返回500

-- Session B:
UPDATE accounts SET balance = balance +100 WHERE id=1;
COMMIT;

-- Session A再次查询:
SELECT balance FROM accounts WHERE id=1; --仍返回500(可重复读)

-- Session A更新后查询:
UPDATE accounts SET balance = balance +50 WHERE id=1; --基于最新值550更新为600
SELECT balance FROM accounts WHERE id=1; --返回600

什么是串行化?

串行化是最高的隔离级别,通过强制事务串行执行来解决“幻读”问题。

20251012193847
易尘埃:串行化

但会导致大量的锁竞争问题,实际应用中很少用。

A 事务未提交,B 事务上查询到的是旧值还是新值?

如果 B 是普通的 SELECT,也就是快照读,它读的是旧值,即事务 A 修改前的快照,并且不会阻塞;如果 B 是当前读,比如 SELECT … FOR UPDATE,它会被阻塞直到事务 A 提交或回滚。

1
2
3
4
5
6
7
8
9
10
11
12
-- 会话 A 中,更新王二的余额
START TRANSACTION;
UPDATE accounts SET balance = 8000 WHERE name = '王二';
-- 此时并没有 COMMIT

-- 会话 B 中查询王二的余额
SELECT * FROM accounts WHERE name = '王二';
-- 会话 B 会读取到 旧值 1000

-- 会话 C 中使用当前读查询王二的余额
SELECT * FROM accounts WHERE name = '王二' FOR UPDATE;
-- 会话 C 会被阻塞,直到会话 A 提交或回滚

20251012193913
二哥的 Java 进阶之路:快照读和当前读的差别

怎么更改事务的隔离级别?

MySQL 支持通过 SET 语句修改事务隔离级别,包括全局级别、当前会话,但一般不建议在生产环境中随意修改隔离级别。

测试环境下可以使用 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; 可以修改当前会话的隔离级别。

使用 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; 可以修改全局隔离级别,影响新的连接,但不会改变现有会话。

64.🌟请详细说说幻读呢?

幻读是指在同一个事务中,多次执行相同的范围查询,结果却不同。这种现象通常发生在其他事务在两次查询之间插入或删除了符合当前查询条件的数据。

20251012194100
Jenny:Phantom read

-— 这部分是帮助大家理解 start,面试中可以不背 —-

比如说事务 A 在第一次查询某个条件范围的数据行后,事务 B 插入了一条新数据且符合条件范围,事务 A 再次查询时,发现多了一条数据。

我们来验证一下,先创建测试表,插入测试数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `user_info` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键id',
`name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '姓名',
`gender` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '性别',
`email` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';

-- 插入测试数据
INSERT INTO `user_info` (`id`, `name`, `gender`, `email`) VALUES
(1, 'Curry', '男', 'curry@163.com'),
(2, 'Wade', '男', 'wade@163.com'),
(3, 'James', '男', 'james@163.com');

COMMIT;

然后我们在事务 A 中执行查询 SELECT * FROM user_info WHERE id > 1;,在事务 B 中插入数据 INSERT INTO user_info (name, gender, email) VALUES ('wanger', '女', 'wanger@163.com');,再在事务 A 中修改刚刚插入的数据 update user_info set gender='男' where id = 4;,最后在事务 A 中再次查询 SELECT * FROM user_info WHERE id > 1;

20251012194118
二哥的 Java 进阶之路:可以发现产生幻读了

-— 这部分是帮助大家理解 end,面试中可以不背 —-

如何避免幻读?

MySQL 在可重复读隔离级别下,通过 MVCC 和临键锁可以在一定程度上避免幻读。

比如说在查询时显示加锁,利用临键锁锁定查询范围,防止其他事务插入新的数据。

1
2
3
START TRANSACTION;
SELECT * FROM user_info WHERE id > 1 FOR UPDATE; -- 加临键锁
COMMIT;

其他事务在插入数据时,会被阻塞,直到当前事务提交或回滚。

20251012194134
二哥的 Java 进阶之路:临键锁能防止幻读

-— 这部分是帮助大家理解 start,面试中可以不背 —-

解释一下。

如果查询语句中包含显式加锁(如 FOR UPDATE),InnoDB 会使用当前读,直接读取最新的数据,并加锁。

在范围查询时,InnoDB 不仅会对符合条件的记录加行锁,还会对相邻的索引间隙加间隙锁,从而形成临键锁。

20251012194152
转转技术:临键锁

临键锁可以防止其他事务在间隙中插入新数据,从而避免幻读。

-— 这部分是帮助大家理解 end,面试中可以不背 —-

比如说在执行查询的事务中,不要尝试去更新其他事务插入/删除的数据,利用快照读来避免幻读。

20251012194203
二哥的 Java 进阶之路:只用快照读

-— 这部分是帮助大家理解 start,面试中可以不背 —-

使用 SELECT 查询时,如果没有显式加锁,InnoDB 会使用 MVCC 提供一致性视图。

每个事务在启动时都会生成一个 Read View,用来确定哪些数据对当前事务可见。

20251012194215
Keep It Simple:Read View

其他事务在当前事务启动后插入的新数据不会被当前事务看到,因此不会出现幻读。

-— 这部分是帮助大家理解 end,面试中可以不背 —-

什么是当前读呢?

当前读是指读取记录的最新已提交版本,并且在读取时对记录加锁,确保其他并发事务不能修改当前记录。

比如 SELECT ... LOCK IN SHARE MODESELECT ... FOR UPDATE,以及 UPDATE、DELETE,都属于当前读。

为什么 UPDATE 和 DELETE 也属于当前读?

因为更新、删除这些操作,本质上不仅是写操作,还需要在写之前读取数据,然后才能修改或删除。为了保证修改的是最新的数据,并防止并发冲突,InnoDB 必须读取最新版本的数据并加锁,因此 UPDATE 和 DELETE 也属于当前读。

20251012194240
溪水静幽:当前读

SQL语句 是否当前读 是否加锁
SELECT * FROM user WHERE id=1 ❌ 否 ❌ 否
SELECT * FROM user WHERE id=1 FOR UPDATE ✅ 是 ✅ 加排他锁
SELECT * FROM user WHERE id=1 LOCK IN SHARE MODE ✅ 是 ✅ 加共享锁
UPDATE user SET ... WHERE id=1 ✅ 是 ✅ 加排他锁
DELETE FROM user WHERE id=1 ✅ 是 ✅ 加排他锁

什么是快照读呢?

快照读是 InnoDB 通过 MVCC 实现的一种非阻塞读方式。当事务执行 SELECT 查询时,InnoDB 并不会直接读当前最新的数据,而是根据事务开始时生成的 Read View 去判断每条记录的可见性,从而读取符合条件的历史版本。

20251012194259
爱吃鱼饼的猫:快照读

SQL 是否快照读? 说明
SELECT * FROM t WHERE id=1 ✅ 是 快照读
SELECT * FROM t WHERE id=1 FOR UPDATE ❌ 否 当前读,读取最新版本并加锁
UPDATE / DELETE ❌ 否 当前读,必须读取当前版本并加锁
INSERT ❌ 否 写操作,不存在历史版本

65.🌟MVCC 了解吗?

MVCC 指的是多版本并发控制,每次修改数据时,都会生成一个新的版本,而不是直接在原有数据上进行修改。并且每个事务只能看到在它开始之前已经提交的数据版本。

20251012194321
天瑕:undo log 版本链和 ReadView

这样的话,读操作就不会阻塞写操作,写操作也不会阻塞读操作,从而避免加锁带来的性能损耗。

其底层实现主要依赖于 Undo Log 和 Read View。

每次修改数据前,先将记录拷贝到Undo Log,并且每条记录会包含三个隐藏列,DB_TRX_ID 用来记录修改该行的事务 ID,DB_ROLL_PTR 用来指向 Undo Log 中的前一个版本,DB_ROW_ID 用来唯一标识该行数据(仅无主键时生成)。

20251012194333
guozhchun:额外的存储信息

每次读取数据时,都会生成一个 ReadView,其中记录了当前活跃事务的 ID 集合、最小事务 ID、最大事务 ID 等信息,通过与 DB_TRX_ID 进行对比,判断当前事务是否可以看到该数据版本。

20251012194342
luozhiyun:ReadView

请详细说说什么是版本链?

版本链是指 InnoDB 中同一条记录的多个历史版本,通过 DB_ROLL_PTR 字段将它们像链表一样串起来,用来支持 MVCC 的快照读。

20251012194355
二哥的 Java 进阶之路:版本链

假设有一张hero表,表中有这样一行记录,name 为张三,city 为帝都,插入这行记录的事务 id 是 80。

此时,DB_TRX_ID的值就是 80,DB_ROLL_PTR的值就是指向这条 insert undo 日志的指针。

20251012194406
三分恶面渣逆袭:DB_ROLL_PTR

接下来,如果有两个DB_TRX_ID分别为100200的事务对这条记录进行了update操作,那么这条记录的版本链就会变成下面这样:

20251012194414
三分恶面渣逆袭:update 操作

也就是说,当更新一行数据时,InnoDB 不会直接覆盖原有数据,而是创建一个新的数据版本,并更新 DB_TRX_ID 和 DB_ROLL_PTR,使它们指向前一个版本和相关的 undo 日志。

这样,老版本的数据就不会丢失,可以通过版本链找到。

由于 undo 日志会记录每一次的 update,并且新插入的行数据会记录上一条 undo 日志的指针,所以可以通过 DB_ROLL_PTR 这个指针找到上一条记录,这样就形成了一个版本链。

20251012194427
三分恶面渣逆袭:版本链

请详细说说什么是ReadView?

ReadView 是 InnoDB 为每个事务创建的一份“可见性视图”,用于判断在执行快照读时,哪些数据版本是当前这个事务可以看到的,哪些不能看到。

20251012194443
二哥的 Java 进阶之路:ReadView

当事务开始执行时,InnoDB 会为该事务创建一个 ReadView,这个 ReadView 会记录 4 个重要的信息:

  • creator_trx_id:创建该 ReadView 的事务 ID。
  • m_ids:所有活跃事务的 ID 列表,活跃事务是指那些已经开始但尚未提交的事务。
  • min_trx_id:所有活跃事务中最小的事务 ID。它是 m_ids 数组中最小的事务 ID。
  • max_trx_id :事务 ID 的最大值加一。换句话说,它是下一个将要生成的事务 ID。

ReadView 是如何判断记录的某个版本是否可见的?

会通过三个步骤来判断:

20251012194516
二哥的 Java 进阶之路:ReadView判断规则

①、如果某个数据版本的 DB_TRX_ID 小于 min_trx_id,则该数据版本在生成 ReadView 之前就已经提交,因此对当前事务是可见的。

②、如果 DB_TRX_ID 大于 max_trx_id,则表示创建该数据版本的事务在生成 ReadView 之后开始,因此对当前事务不可见。

③、如果 DB_TRX_ID 在 min_trx_id 和 max_trx_id 之间,需要判断 DB_TRX_ID 是否在 m_ids 列表中:

  • 不在,表示创建该数据版本的事务在生成 ReadView 之后已经提交,因此对当前事务也是可见的。
  • 在,表示事务仍然活跃,或者在当前事务生成 ReadView 之后才开始,因此是不可见的。

20251012194526
小许 code:可见性匹配规则

举个实际的例子。

读事务开启了一个 ReadView,这个 ReadView 里面记录了当前活跃事务的 ID 列表(444、555、665),以及最小事务 ID(444)和最大事务 ID(666)。当然还有自己的事务 ID 520,也就是 creator_trx_id。

它要读的这行数据的写事务 ID 是 x,也就是 DB_TRX_ID。

  • 如果 x = 110,显然在 ReadView 生成之前就提交了,所以这行数据是可见的。
  • 如果 x = 667,显然是未知世界,所以这行数据对读操作是不可见的。
  • 如果 x = 519,虽然 519 大于 444 小于 666,但是 519 不在活跃事务列表里,所以这行数据是可见的。因为 519 是在 520 生成 ReadView 之前就提交了。
  • 如果 x = 555,虽然 555 大于 444 小于 666,但是 555 在活跃事务列表里,所以这行数据是不可见的。因为 555 不确定有没有提交。

可重复读和读已提交在 ReadView 上的区别是什么?

可重复读:在第一次读取数据时生成一个 ReadView,这个 ReadView 会一直保持到事务结束,这样可以保证在事务中多次读取同一行数据时,读取到的数据是一致的。

20251012194544
程序员x:readview 在可重复读和读已提交下的不同

读已提交:每次读取数据前都生成一个 ReadView,这样就能保证每次读取的数据都是最新的。

推荐阅读:搞懂Mysql之InnoDB MVCC

如果两个 AB 事务并发修改一个变量,那么 A 读到的值是什么,怎么分析。

事务 A 在读取时是否能读到事务 B 的修改,取决于 A 是快照读还是当前读。如果是快照读,InnoDB 会使用 MVCC 的 ReadView 判断记录版本是否可见,若事务 B 尚未提交或在 A 的视图不可见,则 A 会读到旧值;如果是当前读,则需要加锁,若 B 已提交可直接读取,否则 A 会阻塞直到 B 结束。

70.🌟你们一般是怎么分库的呢?

分库的策略有两种,第一种是垂直分库:按照业务模块将不同的表拆分到不同的库中,比如说用户、登录、权限等表放在用户库中,商品、分类、库存放在商品库中,优惠券、满减、秒杀放在活动库中。

20251012195251
三分恶面渣逆袭:垂直分库

第二种是水平分库:按照一定的策略将一个表中的数据拆分到多个库中,比如哈希分片和范围分片,对用户 id 进行取模运算或者范围划分,将数据分散到不同的库中。

20251012195301
三分恶面渣逆袭:水平分库

贴一段使用 ShardingSphere 的 inline 算法定义分片规则:

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
rules:
- !SHARDING
tables:
order:
actualDataNodes: db_${0..3}.order_${0..15}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db_hash_mod
tableStrategy:
standard:
shardingColumn: order_time
shardingAlgorithmName: table_interval_yearly
shardingAlgorithms:
db_hash_mod:
type: HASH_MOD
props:
sharding-count: 4
table_interval_yearly:
type: INTERVAL
props:
datetime-pattern: 'yyyy-MM-dd HH:mm:ss'
datetime-lower: '2024-01-01 00:00:00'
datetime-upper: '2025-01-01 00:00:00'
sharding-suffix-pattern: 'yyyy'
datetime-interval-amount: 1
datetime-interval-unit: 'Years'

71.🌟那你们是怎么分表的?

当单表超过 500 万条数据,就可以考虑水平分表了。比如说我们可以将文章表拆分成多个表,如 article_0、article_9999、article_19999 等。

20251012195335
三分恶面渣逆袭:表拆分

技术派实战项目中,我们将文章的基本信息和内容详情做了垂直分表处理,因为文章的内容会占用比较大的空间,在只需要查看文章基本信息时把文章详情也带出来的话,就会占用更多的网络 IO 和内存导致查询变慢;而文章的基本信息,如标题、作者、状态等信息占用的空间较小,很适合不需要查询文章详情的场景。

20251012195346
二哥的 Java 进阶之路:文章和详情垂直分表

Redis

1.🌟说说什么是 Redis?

Redis 是一种基于键值对的 NoSQL 数据库。

20250725112831

它主要的特点是把数据放在内存当中,相比直接访问磁盘的关系型数据库,读写速度会快很多,基本上能达到微秒级的响应。

所以在一些对性能要求很高的场景,比如缓存热点数据、防止接口爆刷,都会用到 Redis。

不仅如此,Redis 还支持持久化,可以将内存中的数据异步落盘,以便服务宕机重启后能恢复数据。

Redis 和 MySQL 的区别?

Redis 属于非关系型数据库,数据是通过键值对的形式放在内存当中的;MySQL 属于关系型数据库,数据以行和列的形式存储在磁盘当中。

20250725112927

实际开发中,会将 MySQL 作为主存储,Redis 作为缓存,通过先查 Redis,未命中再查 MySQL 并写回Redis 的方式来提高系统的整体性能。

TecHub项目里哪里用到了 Redis?

在TecHub实战项目当中,有很多地方都用到了 Redis,比如说用户活跃排行榜用到了 zset作者白名单用到了 set
还有用户登录后的 Session、站点地图 SiteMap,分别用到了 Redis 的字符串和哈希表两种数据类型。
其中比较有挑战性的一个应用是,通过 Lua 脚本封装 Redis 的 setnex 命令来实现分布式锁,以保证在高并发场景下,热点文章在短时间内的高频访问不会击穿 MySQL。

部署过 Redis 吗?

第一种回答版本:

我只在本地部署过单机版,下载 Redis 的安装包,解压后运行 redis-server 命令即可。

第二种回答版本:

我有在生产环境中部署单机版 Redis,从官网下载源码包解压后执行 make && make install 编译安装。然后编辑 redis.conf 文件,开启远程访问、设置密码、限制内存、设置内存过期淘汰策略、开启 AOF 持久化等:

1
2
3
4
5
bind 0.0.0.0        # 允许远程访问
requirepass your_password # 设置密码
maxmemory 4gb # 限制内存,避免 OOM
maxmemory-policy allkeys-lru # 内存淘汰策略
appendonly yes # 开启 AOF 持久化

第三种回答版本:

我有使用 Docker 拉取 Redis 镜像后进行容器化部署。

1
docker run -d --name redis -p 6379:6379 redis:7.0-alpine

Redis 的高可用方案有部署过吗?

有部署过哨兵机制,这是一个相对成熟的高可用解决方案,我们生产环境部署的是一主两从的 Redis 实例,再加上三个 Sentinel 节点监控它们。Sentinel 的配置相对简单,主要设置了故障转移的判定条件超时阈值

主节点配置:

1
2
port 6379
appendonly yes</code>

从节点配置:

1
replicaof 192.168.1.10 6379</code>

哨兵节点配置:

1
2
3
4
sentinel monitor mymaster 192.168.1.10 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

当主节点发生故障时,Sentinel 能够自动检测并协商选出新的主节点,这个过程大概需要 10-15 秒。

另一个大型项目中,我们使用了 Redis Cluster 集群方案。该项目数据量大且增长快,需要水平扩展能力。我们部署了 6 个主节点,每个主节点配备一个从节点,形成了一个 3主3从 的初始集群。Redis Cluster 的设置比Sentinel 复杂一些,需要正确配置集群节点间通信分片映射等。

1
2
3
4
5
6
7
8
9
10
11
redis-server redis-7000.conf
redis-server redis-7001.conf
...

# 使用 redis-cli 创建集群
# Redis 会自动将 key 哈希到 16384 个槽位
# 主节点均分槽位,从节点自动跟随
redis-cli --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1

Redis Cluster 最大的优势是数据自动分片,我们可以通过简单地增加节点来扩展集群容量。此外,它的故障转移也很快,通常在几秒内就能完成。

对于一些轻量级应用,我也使用过主从复制手动故障转移的方案。主节点负责读写操作从节点负责读操作。手动故障转移时,我们会先将从节点提升为主节点,然后重新配置其他从节点。

1
2
3
4
5
6
# 1\. 取消从节点身份
redis-cli -h <slave-ip> slaveof no one

# 2\. 将其他从节点指向新的主节点
redis-cli -h <other-slave-ip> slaveof <new-master-ip> <port>

3.🌟Redis有哪些数据类型?

Redis 支持五种基本数据类型,分别是字符串列表哈希集合有序集合

20250811101352

还有三种扩展数据类型,分别是用于位级操作的 Bitmap、用于基数估算的 HyperLogLog、支持存储和查询地理坐标的 GEO。

详细介绍下字符串?

字符串是最基本的数据类型,可以存储文本、数字或者二进制数据,最大容量是 512 MB

20250811102451

适合缓存单个对象,比如验证码、token、计数器等。

详细介绍下列表?

列表是一个有序的元素集合,支持从头部或尾部插入/删除元素,常用于消息队列或任务列表。

20250811102931

详细介绍下哈希?

哈希是一个键值对集合,适合存储对象,如商品信息、用户信息等。比如说 value = {name: '沉默王二', age: 18}

20250811103014

详细介绍下集合?

集合是无序且不重复的,支持交集、并集操作,查询效率能达到 O(1) 级别,主要用于去重、标签、共同好友等场景。

20250811103028

详细介绍下有序集合?

有序集合的元素按分数进行排序,支持范围查询,适用于排行榜或优先级队列。

20250811103046

详细介绍下Bitmap?

Bitmap 可以把一组二进制位紧凑地存储在一块连续内存中,每一位代表一个对象的状态,比如是否签到、是否活跃等。

20250811103101

比如用户 0 的已签到 1、用户 1 未签到 0、用户 2 已签到,Redis 就会把这些状态放进一个连续的二进制串 101,1 亿用户签到仅需 100,000,000 / 8 / 1024 ≈ 12MB 的空间,真的省到离谱。

详细介绍下HyperLogLog?

HyperLogLog 是一种用于基数统计的概率性数据结构,可以在仅有 12KB 的内存空间下,统计海量数据集中不重复元素的个数,误差率仅 0.81%。

20250811103115

底层基于 LogLog 算法改进,先把每个元素哈希成一个二进制串,然后取前 14 位进行分组,放到 16384 个桶中,记录每组最大的前导零数量,最后用一个近似公式推算出总体的基数。

20250811103129

20250811103146

可以发现,哈希值越长前导零越多,也就说明集合里的元素越多。

大型网站 UV 统计系统示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UVCounter {
private Jedis jedis;

public void recordVisit(String date, String userId) {
String key = "uv:" + date;
jedis.pfadd(key, userId);
}

public long getUV(String date) {
return jedis.pfcount("uv:" + date);
}

public long getUVBetween(String startDate, String endDate) {
List<String> keys = getDateKeys(startDate, endDate);
return jedis.pfcount(keys.toArray(new String[0]));
}
}

详细介绍下GEO?

GEO 用于存储和查询地理位置信息,可以用来计算两点之间的距离,查找某位置半径内的其他元素。

常见的应用场景包括:附近的人或者商家、计算外卖员和商家的距离、判断用户是否进入某个区域等。

底层基于 ZSet 实现,通过 Geohash 算法把经纬度编码成 score。

20250811103203

比如说查询附近的商家时,Redis 会根据中心点经纬度反推可能的 Geohash 范围, 在 ZSet 上做范围查询,拿到候选点后,用 Haversine 公式精确计算球面距离,筛选出最终符合要求的位置。

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
public class NearbyShopService {
private Jedis jedis;
private static final String SHOP_KEY = "shops:geo";

// 添加商铺
public void addShop(String shopId, double longitude, double latitude) {
jedis.geoadd(SHOP_KEY, longitude, latitude, shopId);
}

// 查询附近的商铺
public List<GeoRadiusResponse> getNearbyShops(
double longitude,
double latitude,
double radiusKm) {
return jedis.georadius(SHOP_KEY,
longitude,
latitude,
radiusKm,
GeoUnit.KM,
GeoRadiusParam.geoRadiusParam()
.withCoord()
.withDist()
.sortAscending()
.count(20));
}

// 计算两个商铺之间的距离
public double getShopDistance(String shop1Id, String shop2Id) {
return jedis.geodist(SHOP_KEY,
shop1Id,
shop2Id,
GeoUnit.KILOMETERS);
}
}

为什么使用 hash 类型而不使用 string 类型序列化存储?

为什么使用 hash 类型而不使用 string 类型序列化存储?
Hash 可以只读取或者修改某一个字段,而 String 需要一次性把整个对象取出来。

20250811103219

比如说有一个用户对象 user = {name: ‘沉默王二’, age: 18},如果使用 Hash 存储,可以直接修改 age 字段:

1
redis.hset("user:1", "age", 19);

如果使用 String 存储,需要先取出整个对象,修改后再存回去:

1
2
3
4
String userJson = redis.get("user:1");
User user = JSON.parseObject(userJson, User.class);
user.setAge(19);
redis.set("user:1", JSON.toJSONString(user));

4.🌟Redis 为什么快呢?

第一,Redis 的所有数据都放在内存中,而内存的读写速度本身就比磁盘快几个数量级。

20250912205152

第二,Redis 采用了基于 IO 多路复用技术的事件驱动模型来处理客户端请求和执行 Redis 命令。

20250912205641

其中的 IO 多路复用技术可以在只有一个线程的情况下,同时监听成千上万个客户端连接,解决传统 IO 模型中每个连接都需要一个独立线程带来的性能开销。

20250912205919

Redis 会根据操作系统选择最优的 IO 多路复用技术,比如 Linux 下使用 epoll,macOS 下使用 kqueue 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// epoll 的创建和使用
int epfd = epoll_create(1024); // 创建 epoll 实例
struct epoll_event ev, events[MAX_EVENTS];

// 添加监听事件
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);

// 等待事件发生
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
// 处理就绪的文件描述符
}
}

在 Redis 6.0 之前,包括连接建立、请求读取、响应发送,以及命令执行都是在主线程中顺序执行的,这样可以避免多线程环境下的锁竞争和上下文切换,因为 Redis 的绝大部分操作都是在内存中进行的,性能瓶颈主要是内存操作网络通信,而不是 CPU。

20250912210551

为了进一步解决网络 IO 的性能瓶颈,Redis 6.0 引入了多线程机制,把网络 IO 和命令执行分开,网络 IO 交给线程池来处理,而命令执行仍然在主线程中进行,这样就可以充分利用多核 CPU 的性能。

20250912210654

主线程专注于命令执行,网络IO 由其他线程分担,在多核 CPU 环境下,Redis 的性能可以得到显著提升。

(有点像单片机的中断机制,保持主线程专注核心任务,让IO操作在后台异步处理,既保证了性能又保证了数据一致性。)

20250912211024

第三,Redis 对底层数据结构做了极致的优化,比如说 String 的底层数据结构动态字符串支持动态扩容、预分配冗余空间,能够减少内存碎片和内存分配的开销。

20250912211154

总结:

20250912211332

10.🌟Redis的持久化方式有哪些?

主要有两种,RDB 和 AOF。RDB 通过创建时间点快照来实现持久化,AOF 通过记录每个写操作命令来实现持久化。

20250928161918
三分恶面渣逆袭:Redis持久化的两种方式

这两种方式可以单独使用,也可以同时使用。这样就可以保证 Redis 服务器在重启后不丢失数据,通过 RDBAOF 文件来恢复内存中原有的数据。

20250928162003
Gaurav:RDB 和 AOF

详细说一下 RDB?

RDB 持久化机制可以在指定的时间间隔内将 Redis 某一时刻的数据保存到磁盘上的 RDB 文件中,当 Redis 重启时,可以通过加载这个 RDB 文件来恢复数据。

20250928162408
Animesh Gaitonde:RDB

RDB 持久化可以通过 savebgsave 命令手动触发,也可以通过配置文件中的 save 指令自动触发。

20250928163257
三分恶面渣逆袭:save和bgsave

save 命令会阻塞 Redis 进程,直到 RDB 文件创建完成。
20250928163337
二哥的 Java 进阶之路:手动执行 RDB

bgsave 命令会在后台 fork 一个子进程来执行 RDB 持久化操作,主进程不会被阻塞。
20250928163355
Mr于:Redis bgsave

什么情况下会自动触发 RDB 持久化?

第一种,在 Redis 配置文件中设置 RDB 持久化参数 save <seconds> <changes>,表示在指定时间间隔内,如果有指定数量的键发生变化,就会自动触发 RDB 持久化。

1
2
3
save 900 1      # 900 秒(15 分钟)内有 1 个 key 发生变化,触发快照
save 300 10 # 300 秒(5 分钟)内有 10 个 key 发生变化,触发快照
save 60 10000 # 60 秒内有 10000 个 key 发生变化,触发快照

第二种,主从复制时,当从节点第一次连接到主节点时,主节点会自动执行 bgsave 生成 RDB 文件,并将其发送给从节点。

20250928163542
达摩院的BLOG:Redis 主从复制时 RDB 自动生成

第三种,如果没有开启 AOF,执行 shutdown 命令时,Redis 会自动保存一次 RDB 文件,以确保数据不会丢失。

详细说一下 AOF?

AOF 通过记录每个写操作命令,并将其追加到 AOF 文件来实现持久化,Redis 服务器宕机后可以通过重新执行这些命令来恢复数据。

20250928163946
Animesh Gaitonde:AOF

当 Redis 执行写操作时,会将写命令追加到 AOF 缓冲区;Redis 会根据同步策略将缓冲区的数据写入到 AOF 文件。

20250928164026
三分恶面渣逆袭:AOF工作流程

当 AOF 文件过大时,Redis 会自动进行 AOF 重写,剔除多余的命令,比如说多次对同一个 key 的 set 和 del,生成一个新的 AOF 文件;当 Redis 重启时,读取 AOF 文件中的命令并重新执行,以恢复数据。

AOF 的刷盘策略了解吗?

Redis 将 AOF 缓冲区的数据写入到 AOF 文件时,涉及两个系统调用:write 将数据写入到操作系统的缓冲区,fsync 将 OS 缓冲区的数据刷新到磁盘。

这里的刷盘涉及到三种策略:always、everysec 和 no。

20250928164208
bytebytego:Redis AOF 的刷盘策略

  • always:每次写命令执行完,立即调用 fsync 同步到磁盘,这样可以保证数据不丢失,但性能较差。
  • everysec:每秒调用一次 fsync,将多条命令一次性同步到磁盘,性能较好,数据丢失的时间窗口为 1 秒。
  • no:不主动调用 fsync,由操作系统决定,性能最好,但数据丢失的时间窗口不确定,依赖于操作系统的缓存策略,可能会丢失大量数据。

可以通过配置文件中的 appendfsync 参数进行设置。

1
appendfsync everysec  # 每秒 fsync 一次

说说AOF的重写机制?

由于 AOF 文件会随着写操作的增加而不断增长,为了解决这个问题, Redis 提供了重写机制来对 AOF 文件进行压缩和优化。

20250928165143
pdai.tech:AOF 文件瘦身

AOF 重写可以通过两种方式触发,第一种是手动执行 BGREWRITEAOF 命令,适用于需要立即减小AOF文件大小的场景。

第二种是在 Redis 配置文件中设置自动重写参数,比如说 auto-aof-rewrite-percentageauto-aof-rewrite-min-size,表示当 AOF 文件大小超过指定值时,自动触发重写。

1
2
auto-aof-rewrite-percentage 100  # 默认值100,表示当前AOF文件大小相比上次重写后大小增长了多少百分比时触发重写
auto-aof-rewrite-min-size 64mb # 默认值64MB,表示AOF文件至少要达到这个大小才会考虑重写

AOF 重写的具体过程是怎样的?

Redis 在收到重写指令后,会创建一个子进程,并 fork 一份与父进程完全相同的数据副本,然后遍历内存中的所有键值对,生成重建它们所需的最少命令。

20250928165506
云烟成雨:Redis 的 AOF 重写机制

比如说多个 RPUSH 命令可以合并为一个带有多个参数的 RPUSH;

比如说一个键被设置后又被删除,这个键的所有操作都不会被写入新 AOF。

比如说使用 SADD key member1 member2 member3 代替多个单独的 SADD key memberX

子进程在执行 AOF 重写的同时,主进程可以继续处理来自客户端的命令。

为了保证数据一致性,Redis 使用了 AOF 重写缓冲区机制,主进程在执行写操作时,会将命令同时写入旧的 AOF 文件和重写缓冲区。

等子进程完成重写后,会向主进程发送一个信号,主进程收到后将重写缓冲区中的命令追加到新的 AOF 文件中,然后调用操作系统的 rename,将旧的 AOF 文件替换为新的 AOF 文件。

1
2
3
4
5
6
7
8
9
10
11
主进程(fork)  

├─→ 子进程(生成新的 AOF 文件)
│ │
│ ├─→ 内存快照
│ ├─→ 写入临时 AOF 文件
│ ├─→ 通知主进程完成

├─→ 主进程(追加缓冲区到新 AOF 文件)
├─→ 替换旧 AOF 文件
├─→ 重写完成

AOF 重写期间,Redis 服务器会处于特殊状态:

  • aof_child_pid 不为 0,表示有子进程在执行 AOF 重写
  • aof_rewrite_buf_blocks 链表不为空,存储 AOF 重写缓冲区内容

如果在配置文件中设置 no-appendfsync-on-rewrite 为 yes,那么重写期间可能会暂停 AOF 文件的 fsync 操作。

1
2
3
4
5
6
appendonly yes                # 开启AOF
appendfilename "appendonly.aof" # AOF文件名
appendfsync everysec # 写入磁盘策略
no-appendfsync-on-rewrite no # 重写期间是否临时关闭fsync
auto-aof-rewrite-percentage 100 # AOF文件增长到原来多少百分比时触发重写
auto-aof-rewrite-min-size 64mb # AOF文件最小多大时才允许重写

AOF 文件存储的是什么类型的数据?

AOF 文件存储的是 Redis 服务器接收到的写命令数据,以 Redis 协议格式保存。

这种格式的特点是,每个命令以*开头,后跟参数的数量,每个参数前用$符号,后跟参数字节长度,然后是参数的实际内容。

20251016095627
二哥的Java 进阶之路:AOF文件内容格式

AOF重写期间命令可能会写入两次,会造成什么影响?

AOF 重写期间命令会同时写入现有AOF文件和重写缓冲区,这种机制是有意设计的,并不会导致数据重复或不一致问题。

20251016095640
UStarGao:AOF 双写机制

因为新旧文件是分离的,现有命令写入当前 AOF 文件,重写缓冲区的命令最终写入新的 AOF 文件,完成后,新文件通过原子性的 rename 操作替换旧文件。两个文件是完全分离的,不会导致同一个 AOF 文件中出现重复命令。

14.🌟Redis 4.0 的混合持久化了解吗?

是的。

混合持久化结合了 RDB 和 AOF 两种方式的优点,解决了它们各自的不足。在 Redis 4.0 之前,我们要么面临 RDB 可能丢失数据的风险,要么承受 AOF 恢复慢的问题,很难两全其美。

20250928170958
Animesh Gaitonde:aof-use-rdb-preamble

混合持久化的工作原理非常巧妙:在 AOF 重写期间,先以 RDB 格式将内存中的数据快照保存到 AOF 文件的开头,再将重写期间的命令以 AOF 格式追加到文件末尾。

20250928171009
三分恶面渣逆袭:混合持久化

这样,当需要恢复数据时,Redis 先加载 RDB 格式的数据来快速恢复大部分的数据,然后通过重放命令恢复最近的数据,这样就能在保证数据完整性的同时,提升恢复速度。

如何设置持久化模式?

启用混合持久化的方式非常简单,只需要在配置文件中设置 aof-use-rdb-preamble yes 就可以了。

1
aof-use-rdb-preamble yes

你在开发中是怎么配置 RDB 和 AOF 的?

对于大多数生产环境,我倾向于使用混合持久化方式,结合 RDB 和 AOF 的优点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 启用AOF
appendonly yes

# 使用混合持久化
aof-use-rdb-preamble yes

# 每秒同步一次AOF,平衡性能和安全性
appendfsync everysec

# AOF重写触发条件:文件增长100%且至少达到64MB
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# RDB备份策略
save 900 1 # 15分钟内有1个修改
save 300 10 # 5分钟内有10个修改
save 60 10000 # 1分钟内有10000个修改

对于单纯的缓存场景,或者本地开发,我会只启用 RDB,关闭 AOF:

1
2
3
4
5
6
# 禁用AOF
appendonly no

# 较宽松的RDB策略
save 3600 1 # 1小时内有1个修改
save 300 100 # 5分钟内有100个修改

而对于金融类等高一致性的系统,我通常会在关键时间窗口动态将 appendfsync 设置为 always

1
2
3
4
5
6
7
8
9
10
11
12
13
# 启用AOF
appendonly yes

# 使用混合持久化
aof-use-rdb-preamble yes

# 每个命令都同步(谨慎使用,性能影响大)
# 通常我会在关键时间窗口动态修改为always
appendfsync always

# 更频繁的RDB快照
save 300 1 # 5分钟内有1个修改
save 60 100 # 1分钟内有100个修改

另外,对于高并发场景,应该设置no-appendfsync-on-rewrite yes,避免 AOF 重写影响主进程性能;对于大型实例,也应该设置 rdb-save-incremental-fsync yes 来减少大型 RDB 保存对性能的影响。

1
2
3
4
# AOF重写期间不fsync,AOF 重写期间,主进程不会对新写入的 AOF 缓冲区执行 fsync 操作(即不强制刷盘),而是等重写结束后再统一刷盘。
no-appendfsync-on-rewrite yes
# RDB 快照保存时采用增量 fsync,即每写入一定量的数据就执行一次 fsync,将数据分批同步到磁盘。
rdb-save-incremental-fsync yes

29.🌟什么是缓存击穿?

缓存击穿是指某个热点数据缓存过期时,大量请求就会穿透缓存直接访问数据库,导致数据库瞬间承受的压力巨大。

20251022093956
fengkui.net:缓存击穿

解决缓存击穿有两种常用的策略:

第一种是加互斥锁。当缓存失效时,第一个访问的线程先获取锁并负责重建缓存,其他线程等待或重试。

20251022094156
三分恶面渣逆袭:加锁更新

这种策略虽然会导致部分请求延迟,但实现起来相对简单。在技术派实战项目中,我们就使用了 Redisson 的分布式锁来确保只有一个服务实例能更新缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
String cacheKey = "product::" + productId;
RLock lock = redissonClient.getLock("lock::" + productId);
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
String result = cache.get(cacheKey);
if (result == null) {
result = database.queryProductById(productId);
cache.set(cacheKey, result, 60 * 1000); // 设置缓存
}
} finally {
lock.unlock();
}
}

第二种是永不过期策略。缓存项本身不设置过期时间,也就是永不过期,但在缓存值中维护一个逻辑过期时间。当缓存逻辑上过期时,返回旧值的同时,异步启动一个线程去更新缓存。

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
public String getData(String key) {
CacheItem item = cache.get(key);

if (item == null) {
// 缓存不存在,同步加载
String data = db.query(key);
cache.set(key, new CacheItem(data, System.currentTimeMillis() + expireTime));
return data;
} else if (item.isLogicalExpired()) {
// 逻辑过期,异步刷新
asyncRefresh(key);
// 返回旧数据
return item.getData();
}

return item.getData();
}

// 异步刷新缓存
private void asyncRefresh(final String key) {
threadPool.execute(() -> {
// 重新查询数据库
String newData = db.query(key);
// 更新缓存
cache.set(key, new CacheItem(newData, System.currentTimeMillis() + expireTime));
});
}

memo:2025 年 5 月 18 日修改至此,今天给球友改简历时,碰到一个西北工业大学的球友,这又是一所 985 院校,希望这个社群能把所有的 985 院校集齐,也希望去帮助到更多院校的同学,希望都能拿到一个满意的 offer。

什么是缓存穿透?

缓存穿透是指查询的数据在缓存中没有命中,因为数据压根不存在,所以请求会直接落到数据库上。如果这种查询非常频繁,就会给数据库造成很大的压力。

20251022094922
fengkui.net:缓存穿透

缓存击穿是因为单个热点数据缓存失效导致的,而缓存穿透是因为查询的数据不存在,原因可能是自身的业务代码有问题,或者是恶意攻击造成的,比如爬虫。

常用的解决方案有两种:第一种是布隆过滤器,它是一种空间效率很高的数据结构,可以用来判断一个元素是否在集合中。

我们可以将所有可能存在的数据哈希到布隆过滤器中,查询时先检查布隆过滤器,如果布隆过滤器认为该数据不存在,就直接返回空;否则再去查询缓存,这样就可以避免无效的缓存查询。

20251022095153
酒剑仙:布隆过滤器解决缓存穿透

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String getData(String key) {
// 缓存中不存在该key
String cacheResult = cache.get(key);
if (cacheResult != null) {
return cacheResult;
}

// 布隆过滤器判断key是否可能存在
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}

// 可能存在,查询数据库
String dbResult = db.query(key);

// 将结果放入缓存,包括空值
cache.set(key, dbResult != null ? dbResult : "", expireTime);

return dbResult;
}

布隆过滤器存在误判,即可能会认为某个数据存在,但实际上并不存在。但绝不会漏判,即如果布隆过滤器认为某个数据不存在,那它一定不存在。因此它可以有效拦截不存在的数据查询,减轻数据库压力。

第二种是缓存空值。对于不存在的数据,我们将空值写入缓存,并设置一个合理的过期时间。这样下次相同的查询就能直接从缓存返回,而不再访问数据库。

20251028151204
三分恶面渣逆袭:缓存空值/默认值

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public String getData(String key) {
String cacheResult = cache.get(key);

// 缓存命中,包括空值
if (cacheResult != null) {
// 特殊值表示空结果
if (cacheResult.equals("")) {
return null;
}
return cacheResult;
}

// 缓存未命中,查询数据库
String dbResult = db.query(key);

// 写入缓存,空值也缓存,但设置较短的过期时间
int expireTime = dbResult == null ? EMPTY_EXPIRE_TIME : NORMAL_EXPIRE_TIME;
cache.set(key, dbResult != null ? dbResult : "", expireTime);

return dbResult;
}

缓存空值的方法实现起来比较简单,但需要给空值设置一个合理的过期时间,以免数据库中新增了这些数据后,缓存仍然返回空值。

在实际的项目当中,还需要在接口层面做一些处理,比如说对参数进行校验,拦截明显不合理的请求;或者对疑似攻击的 IP 进行限流和封禁。

什么是缓存雪崩?

缓存雪崩是指在某一时间段,大量缓存同时失效或者缓存服务突然宕机了,导致大量请求直接涌向数据库,导致数据库压力剧增,甚至引发系统崩溃的现象。

20251022100013
三分恶面渣逆袭:缓存雪崩

缓存击穿是单个热点数据失效导致的,缓存穿透是因为请求不存在的数据,而缓存雪崩是因为大范围的缓存失效。

缓存雪崩主要有三种成因和应对策略。

第一种,大量缓存同时过期,解决方法是添加随机过期时间。

1
2
3
4
5
6
7
8
public void setCache(String key, String value) {
// 基础过期时间,例如30分钟
int baseExpireSeconds = 1800;
// 增加随机过期时间,范围0-300秒
int randomSeconds = new Random().nextInt(300);
// 最终过期时间为基础时间加随机时间
cache.set(key, value, baseExpireSeconds + randomSeconds);
}

第二种,缓存服务崩溃,解决方法是使用高可用的缓存集群。

比如说使用 Redis Cluster 构建多节点集群,确保数据在多个节点上有备份,并且支持自动故障转移。

20251022101036
Rajat Pachauri:Redis Cluster

对于一些高频关键数据,可以配置本地缓存作为二级缓存,缓解 Redis 的压力。在技术派实战项目中,我们就采用了多级缓存的策略,其中就包括使用本地缓存 Caffeine 来作为二级缓存,当 Redis 出现问题时自动切换到本地缓存。

这个过程称为“缓存降级”,保证 Redis 发生故障时,系统能够继续提供服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
LoadingCache<String, UserPermissions> permissionsCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(this::loadPermissionsFromRedis);

public UserPermissions loadPermissionsFromRedis(String userId) {
try {
return redisClient.getPermissions(userId);
} catch (Exception ex) {
// Redis 异常处理,尝试从本地缓存获取
return permissionsCache.getIfPresent(userId);
}
}

第三种,缓存服务正常但并发请求量超过了缓存服务的承载能力,这种情况下可以采用限流和降级措施。

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
public String getData(String key) {
try {
// 尝试从缓存获取数据
return cache.get(key);
} catch (Exception e) {
// 缓存服务异常,触发熔断
if (circuitBreaker.shouldTrip()) {
// 直接从数据库获取,并进入降级模式
circuitBreaker.trip();
return getFromDbDirectly(key);
}
throw e;
}
}

private String getFromDbDirectly(String key) {
// 实施限流保护
if (!rateLimit.tryAcquire()) {
// 超过限流阈值,返回兜底数据或默认值
return getDefaultValue(key);
}

// 限流通过,从数据库查询
return db.query(key);
}
  1. Java 面试指南(付费)收录的腾讯面经同学 22 暑期实习一面面试原题:缓存雪崩,如何解决
  2. Java 面试指南(付费)收录的快手面经同学 7 Java 后端技术一面面试原题:说一下 缓存穿透、缓存击穿、缓存雪崩
  3. Java 面试指南(付费)收录的字节跳动同学 7 Java 后端实习一面的原题:Redis 宕机会不会对权限系统有影响?
  4. Java 面试指南(付费)收录的字节跳动同学 7 Java 后端实习一面的原题:说一下 Redis 雪崩、穿透、击穿等场景的解决方案
  5. Java 面试指南(付费)收录的小米同学 F 面试原题:缓存常见问题和解决方案(引申到多级缓存),多级缓存(redis,nginx,本地缓存)的实现思路

30.🌟能说说布隆过滤器吗?

布隆过滤器是一种空间效率极高的概率性数据结构,用于快速判断一个元素是否在一个集合中。它的特点是能够以极小的内存消耗,判断一个元素“一定不在集合中”或“可能在集合中”,常用来解决 Redis 缓存穿透的问题。

20251022105702
三分恶面渣逆袭:布隆过滤器

-—这部分面试中可以不背start—-

布隆过滤器的核心由一个很长的二进制向量和一系列哈希函数组成。

  • 初始化的时候,创建一个长度为 m 的位数组,初始值全为 0,同时选择 k 个不同的哈希函数
  • 当添加一个元素时,用 k 个哈希函数计算出 k 个哈希值,然后对 m 取模,得到 k 个位置,将这些位置的二进制位都设为 1
  • 当需要判断一个元素是否在集合中时,同样用 k 个哈希函数计算出 k 个位置,如果这些位置的二进制位有任何一个为 0,该元素一定不在集合中;如果全部为 1,则该元素可能在集合中
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
public class BloomFilter<T> {
private BitSet bitSet;
private int bitSetSize;
private int numberOfHashFunctions;

public BloomFilter(double falsePositiveProbability, int expectedNumberOfElements) {
// 根据预期元素数量和期望的误判率,计算最优的位数组大小和哈希函数个数
this.bitSetSize = calculateOptimalBitSetSize(expectedNumberOfElements, falsePositiveProbability);
this.numberOfHashFunctions = calculateOptimalNumberOfHashFunctions(expectedNumberOfElements, bitSetSize);
this.bitSet = new BitSet(bitSetSize);
}

public void add(T element) {
int[] hashes = createHashes(element);
for (int hash : hashes) {
bitSet.set(Math.abs(hash % bitSetSize), true);
}
}

public boolean mightContain(T element) {
int[] hashes = createHashes(element);
for (int hash : hashes) {
if (!bitSet.get(Math.abs(hash % bitSetSize))) {
return false; // 如果任何一位为0,元素一定不存在
}
}
return true; // 所有位都为1,元素可能存在
}

// 其他辅助方法,如计算哈希值,计算最优参数等
}

-—这部分面试中可以不背end—-

布隆过滤器存在误判吗?

是的,布隆过滤器存在误判。它可能会错误地认为某个元素在集合中,而元素实际上并不在集合中。

20251022105811
勇哥:布隆过滤器

但如果布隆过滤器认为某个元素不存在于集合中,那么它一定不存在。

误判产生的原因是因为哈希冲突。在布隆过滤器中,多个不同的元素可能映射到相同的位置。随着向布隆过滤器中添加的元素越来越多,位数组中的 1 也越来越多,发生哈希冲突的概率随之增加,误判率也就随之上升。

20251022110143
勇哥:布隆过滤器的误判

误判率取决于以下 3 个因素:

  1. 位数组的大小(m):m 决定了可以存储的标志位数量。如果位数组过小,那么哈希碰撞的几率就会增加,从而导致更高的误判率。
  2. 哈希函数的数量(k):k 决定了每个元素在位数组中标记的位数。哈希函数越多,碰撞的概率也会相应变化。如果哈希函数太少,过滤器很快会变得不精确;如果太多,误判率也会升高,效率下降。
  3. 存入的元素数量(n):n 越多,哈希碰撞的几率越大,从而导致更高的误判率。

要降低误判率,可以增加位数组的大小或者减少插入的元素数量。

要彻底解决布隆过滤器的误判问题,可以在布隆过滤器返回”可能存在”时,再通过数据库进行二次确认。

布隆过滤器支持删除吗?

布隆过滤器并不支持删除操作,这是它的一个重要限制。

当我们添加一个元素时,会将位数组中的 k 个位置设置为 1。由于多个不同元素可能共享相同的位,如果我们尝试删除一个元素,将其对应的 k 个位重置为 0,可能会错误地影响到其他元素的判断结果。

例如,元素 A 和元素 B 都将位置 5 设为 1,如果删除元素 A 时将位置 5 重置为 0,那么对元素 B 的查询就会产生错误的”不存在”结果,这违背了布隆过滤器的基本特性。

如果想要实现删除操作,可以使用计数布隆过滤器,它在每个位置上存储一个计数器而不是单一的位。这样可以通过减少计数器的值来实现删除操作,但会增加内存开销。

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
public class CountingBloomFilter<T> {
private int[] counters;
private int size;
private int hashFunctions;

public CountingBloomFilter(int size, int hashFunctions) {
this.size = size;
this.hashFunctions = hashFunctions;
this.counters = new int[size];
}

public void add(T element) {
int[] positions = getHashPositions(element);
for (int position : positions) {
counters[position]++;
}
}

public void remove(T element) {
int[] positions = getHashPositions(element);
for (int position : positions) {
if (counters[position] > 0) {
counters[position]--;
}
}
}

public boolean mightContain(T element) {
int[] positions = getHashPositions(element);
for (int position : positions) {
if (counters[position] == 0) {
return false;
}
}
return true;
}

private int[] getHashPositions(T element) {
// 计算哈希位置的代码
}
}

为什么不能用哈希表而是用布隆过滤器?

布隆过滤器最突出的优势是内存效率。

假如我们要判断 10 亿个用户 ID 是否曾经访问过特定页面,使用哈希表至少需要 10G 内存(每个 ID 至少需要8字节),而使用布隆过滤器只需要 1.2G 内存。

1
m ≈ -n*ln(p)/ln(2)² ≈ -10⁹*ln(0.01)/ln(2)² ≈ 9.6 billion bits ≈ 1.2GB
  1. Java 面试指南(付费)收录的字节跳动同学 7 Java 后端实习一面的原题:有了解过布隆过滤器吗?
  2. Java 面试指南(付费)收录的TP联洲同学 5 Java 后端一面的原题:布隆过滤器原理,这种方式下5%的错误率可接受?
  3. Java 面试指南(付费)收录的美团同学 9 一面面试原题:布隆过滤器?布隆过滤器优点?为什么不能用哈希表要用布隆过滤器?
  4. Java 面试指南(付费)收录的理想汽车面经同学 2 一面面试原题:追问:说明一下布隆过滤器

31.🌟如何保证缓存和数据库的数据⼀致性?

技术派实战项目中,对于文章标签这种允许短暂不一致的数据,我会采用 Cache Aside + TTL 过期机制来保证缓存和数据库的一致性。

20251022110356
技术派教程:MySQL 和 Redis 一致性

具体做法是读取时先查 Redis,未命中再查 MySQL,同时为缓存设置一个合理的过期时间;更新时先更新 MySQL,再删除 Redis。

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
// 读取逻辑
public UserInfo getUser(String userId) {
// 先查缓存
UserInfo user = cache.get("user:" + userId);
if (user != null) {
return user;
}

// 缓存未命中,查数据库
user = database.selectUser(userId);
if (user != null) {
// 放入缓存,设置合理的过期时间
cache.set("user:" + userId, user, 3600);
}

return user;
}

// 更新逻辑
public void updateUser(UserInfo user) {
// 先更新数据库
database.updateUser(user);

// 删除缓存
cache.delete("user:" + user.getId());
}

这种方式简单有效,适用于读多写少的场景。TTL 过期时间也能够保证即使更新操作失败,未能及时删除缓存,过期时间也能确保数据最终一致。

那再来说说为什么要删除缓存而不是更新缓存?

最初设计缓存策略时,我也考虑过直接更新缓存,但通过实践发现,删除缓存是更优的选择。

20251022110609
技术派:更新 Redis 而不是删除 Redis

最主要的原因是在并发环境下,假设我们有两个并发的更新操作,如果采用更新缓存的策略,就可能出现这样的时序问题:

  • 操作 A 和操作 B 同时发生,A 先更新 MySQL 将值改为 10,B 后更新 MySQL 将值改为 11。但在缓存更新时,可能 B 先执行将缓存设为 11,然后 A 才执行将缓存设为10。这样就会造成 MySQL 是 11 但 Redis 是 10 的不一致状态。

而采用删除策略,无论 A 和 B 谁先删除缓存,后续的读取操作都会从 MySQL 获取最新值。

另外,相对而言,删除缓存的速度比更新缓存的速度快得多。

20251022110559
三分恶面渣逆袭:删除缓存和更新缓存

因为删除操作只是简单的 DEL 命令,而更新可能需要重新序列化整个对象再写入缓存。

那再说说为什么要先更新数据库,再删除缓存?

这个操作顺序的选择也是我在实际项目中踩过坑才深刻理解的。假设我们采用先删缓存再更新数据库的策略,在高并发场景下就可能出现这样的问题:

  • 线程 A 要更新用户信息,先删除了缓存
  • 线程 B 恰好此时要读取该用户信息,发现缓存为空,于是查询数据库,此时还是旧值
  • 线程 B 将查到的旧值重新放入缓存
  • 线程 A 完成数据库更新

结果就是数据库是新的值,但缓存中还是旧值。

20251022110834
技术派:先删 Redis 再更新 MySQL

而采用先更新数据库再删缓存的策略,即使出现类似的并发情况,最坏的情况也只是短暂地从缓存中读取到了旧值,但缓存删除后的请求会直接从数据库中获取最新值。

另外,如果先删缓存再更新数据库,当数据库更新失败时,缓存已经被删除了。这会导致短期内所有读请求都会穿透到数据库,对数据库造成额外的压力。

20251022110950
三分恶面渣逆袭:先更数据库还是先删缓存

而先更新数据库再删缓存,如果数据库更新失败,缓存保持原状,系统仍然能继续正常提供服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void updateUser(User user) {
try {
// 先更新数据库
database.updateUser(user);

// 再删除缓存
cache.delete("user:" + user.getId());
} catch (DatabaseException e) {
// 数据库更新失败,缓存保持原状,系统仍可正常提供服务
log.error("Database update failed", e);
throw e;
} catch (CacheException e) {
// 缓存删除失败,数据库已更新,数据会在TTL后自动一致
log.warn("Cache deletion failed, will be eventually consistent", e);
// 可以选择不抛异常,因为有TTL兜底
}
}

那假如对缓存数据库一致性要求很高,该怎么办呢?

当业务对缓存与数据库的一致性要求很高时,比如支付系统、库存管理等场景,我会采用多种策略来保证强一致性。

20251022111101
二哥的 Java 进阶之路:缓存强一致性

第一种,引入消息队列来保证缓存最终被删除,比如说在数据库更新的事务中插入一条本地消息记录,事务提交后异步发送给 MQ 进行缓存删除。

20251022111121
三分恶面渣逆袭:消息队列保证key被删除

即使缓存删除失败,消息队列的重试机制也能保证最终一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
public void updateUser(UserInfo user) {
// 在事务中更新数据库
database.updateUser(user);

// 在同一事务中记录需要删除的缓存信息
LocalMessage message = new LocalMessage("CACHE_DELETE", "user:" + user.getId());
database.insertLocalMessage(message);

// 显式发布事件,供监听器捕获
eventPublisher.publishEvent(new UserUpdateEvent(this, "user:" + user.getId()));
}

// 事务提交后发送MQ消息
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendCacheDeleteMessage(UserUpdateEvent event) {
messageQueue.send("cache-delete-topic", event.getCacheKey());
}

第二种,使用 Canal 监听 MySQL 的 binlog,在数据更新时,将数据变更记录到消息队列中,消费者消息监听到变更后去删除缓存。

20251022111140
三分恶面渣逆袭:数据库订阅+消息队列保证key被删除

这种方案的优势是完全解耦了业务代码和缓存维护逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@CanalListener
public class CacheUpdateListener {

@EventHandler
public void handleUserUpdate(UserUpdateEvent event) {
// 从binlog事件中提取变更信息
String userId = event.getUserId();

// 发送缓存删除消息
CacheDeleteMessage message = new CacheDeleteMessage();
message.setCacheKey("user:" + userId);
messageQueue.send("cache-delete-topic", message);
}
}
// 消费者监听消息队列
@KafkaListener(topics = "cache-delete-topic")
public void handleCacheDeleteMessage(CacheDeleteMessage message) {
// 删除缓存
cache.delete(message.getCacheKey());
}

当然了,如果说业务比较简单,不需要上消息队列,可以通过延迟双删策略降低缓存和数据库不一致的时间窗口,在第一次删除缓存之后,过一段时间之后,再次尝试删除缓存。

20251022111245
三分恶面渣逆袭:延时双删

这种方式主要针对缓存不存在,但写入了脏数据的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void updateUser(UserInfo user) {
// 第一次删除缓存,减少不一致时间窗口
cache.delete("user:" + user.getId());

// 更新数据库
database.updateUser(user);

// 立即删除缓存
cache.delete("user:" + user.getId());

// 延时删除,应对可能的并发读取
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(1000); // 延时时间根据主从同步延迟调整
cache.delete("user:" + user.getId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}

最后,无论采用哪种策略,最好为缓存设置一个合理的过期时间作为最后的保障。即使所有的主动删除机制都失败了,TTL 也能确保数据最终达到一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 根据数据的重要程度设置不同的TTL
public void setCache(String key, Object value, DataImportance importance) {
int ttl;
switch (importance) {
case HIGH: // 关键数据,短TTL
ttl = 300; // 5分钟
break;
case MEDIUM: // 一般数据
ttl = 1800; // 30分钟
break;
case LOW: // 不太重要的数据
ttl = 3600; // 1小时
break;
}

cache.setWithTTL(key, value, ttl);
}

这种方式虽然简单,但能确保即使出现极端情况,数据不一致的影响也是可控的。

  1. Java 面试指南(付费)收录的华为面经同学 8 技术二面面试原题:怎样保证数据的最终一致性?
  2. Java 面试指南(付费)收录的腾讯面经同学 23 QQ 后台技术一面面试原题:数据一致性问题
  3. Java 面试指南(付费)收录的微众银行同学 1 Java 后端一面的原题:MySQL 和缓存一致性问题了解吗?
  4. Java 面试指南(付费)收录的美团面经同学 3 Java 后端技术一面面试原题:如何保证 redis 缓存与数据库的一致性,为什么这么设计
  5. Java 面试指南(付费)收录的比亚迪面经同学 12 Java 技术面试原题:怎么解决redis和mysql的缓存一致性问题
  6. Java 面试指南(付费)收录的字节跳动同学 17 后端技术面试原题:双写一致性怎么解决的
  7. Java 面试指南(付费)收录的京东面经同学 9 面试原题:redis的数据和缓存不一致应该处理

40.🌟Redis有哪些内存淘汰策略?

当内存使用接近 maxmemory 限制时,Redis 会依据内存淘汰策略来决定删除哪些 key 以缓解内存压力。

20251023112833
码哥字节:内存淘汰策略

常用的内存淘汰策略有八种,分别是默认的 noeviction,内存不足时不会删除任何 key,直接返回错误信息,生产环境下基本上不会使用。

然后是针对所有 key 的 allkeys-lruallkeys-lfuallkeys-random。lru 会删除最近最少使用的 key,在纯缓存场景中最常用,能自动保留热点数据;lfu 会删除访问频率最低的 key,更适合长期运行的系统;random 会随机删除一些 key,一般不推荐使用。

其次是针对设置了过期时间的 key,有 volatile-lru、volatile-lfu、volatile-ttl 和 volatile-random。

lru 在混合存储场景中经常使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class HybridStorageService {

// 重要数据不设置过期时间,临时数据设置过期时间
public void storeData(String key, Object data, DataImportance importance) {
if (importance == DataImportance.HIGH) {
// 重要数据不设置过期时间,在volatile-*策略下不会被淘汰
redisTemplate.opsForValue().set(key, data);
} else {
// 临时数据设置过期时间,可以被volatile-*策略淘汰
redisTemplate.opsForValue().set(key, data, Duration.ofHours(1));
}
}
}

lfu 适合需要保护某些重要数据不被淘汰的场景;ttl 优先删除即将过期的 key,在用户会话管理系统中推荐使用;random 仍然很少用。

  1. Java 面试指南(付费)收录的小米春招同学 K 一面面试原题:为什么 redis 快,淘汰策略 持久化
  2. Java 面试指南(付费)收录的去哪儿面经同学 1 技术 2 面面试原题:redis 内存淘汰和过期策略
  3. Java 面试指南(付费)收录的作业帮面经同学 1 Java 后端一面面试原题:redis内存淘汰策略

45.🌟Redis支持事务吗?

是的,Redis 支持简单的事务,可以将 multi、exec、discard 和 watch 命令打包,然后一次性的按顺序执行。

20251023160847
Redis设计与实现:事务

基本流程是用 multi 开启事务,然后执行一系列命令,最后用 exec 提交。这些命令会被放入队列,在 exec 时批量执行。

20251023160907
二哥的 Java 进阶之路:Redis 事务

当客户端处于非事务状态时,所有发送给 Redis 服务的命令都会立即执行;但当客户端进入事务状态之后,这些命令会被放入一个事务队列中,然后立即返回 QUEUED,表示命令已入队。

20251023160922
Redis设计与实现:事务和非事务的区别

当 exec 命令执行时,Redis 会将事务队列中的所有命令按先进先出的顺序执行。当事务队列里的命令全部执行完毕后,Redis 会返回一个数组,包含每个命令的执行结果。

discard 命令用于取消一个事务,它会清空事务队列并退出事务状态。

20251023160930
二哥的 Java 进阶之路:discard

watch 命令用于监视一个或者多个 key,如果这个 key 在事务执行之前 被其他命令改动,那么事务将会被打断。

20251023160946
码哥字节:watch

但 Redis 的事务与 MySQL 的有很大不同,它并不支持回滚,也不支持隔离级别。

说一下 Redis 事务的原理?

Redis 事务的原理并不复杂,核心就是一个”先排队,后执行”的机制。

20251023161016
小生凡一:Redis事务

当执行 MULTI 命令时,Redis 会给这个客户端打一个事务的标记,表示这个客户端后面发送的命令不会被立即执行,而是被放到一个队列里排队等着。

20251023161037
小生凡一:MULTI

当 Redis 收到 EXEC 命令时,它会把队列里的命令一个个拿出来执行。因为 Redis 是单线程的,所以这个过程不会被其他命令打断,这就保证了Redis 事务的原子性。

20251023161053
小生凡一:WATCH

当执行 WATCH 命令时,Redis 会将 key 添加到全局监视字典中;只要这些 key 在 EXEC 前被其他客户端修改,Redis 就会给相关客户端打上脏标记,EXEC 时发现事务已被干扰就会直接取消整个事务。

1
2
3
4
5
6
7
// 全局监视字典
dict *watched_keys;

typedef struct watchedKey {
robj *key;
redisDb *db;
} watchedKey;

DISCARD 做的事情很简单直接,首先检查客户端是否真的在事务状态,如果不在就报错;如果在事务状态,就清空事务队列并退出事务状态。

1
2
3
4
5
6
7
8
void discardCommand(client *c) {
if (!(c->flags & CLIENT_MULTI)) {
addReplyError(c,"DISCARD without MULTI");
return;
}
discardTransaction(c);
addReply(c,shared.ok);
}

Redis 事务有哪些注意点?

最重要的的一点是,Redis 事务不支持回滚,一旦 EXEC 命令被调用,所有命令都会被执行,即使有些命令可能执行失败。

Redis事务为什么不支持回滚?

Redis 的核心设计理念是简单、高效,而不是完整的 ACID 特性。而实现回滚需要在执行过程中保存大量的状态信息,并在发生错误时逆向执行命令以恢复原始状态。这会增加 Redis 的复杂性和性能开销。

20251023161146
redis.io:不支持事务回滚

Redis事务满足原子性吗?要怎么改进?

Redis 的事务不能满足标准的原子性,因为它不支持事务回滚,也就是说,假如某个命令执行失败,整个事务并不会自动回滚到初始状态。

1
2
3
4
5
6
7
8
9
// 一个转账事务
redisTemplate.multi();
redisTemplate.opsForValue().decrement("user:1:balance", 100); // 成功
redisTemplate.opsForList().leftPush("user:1:balance", "log"); // 类型错误,失败
redisTemplate.opsForValue().increment("user:2:balance", 100); // 还是会执行
List<Object> results = redisTemplate.exec();

// 结果:用户1被扣了钱,用户2也收到了钱,但中间的日志操作失败了
// 这符合Redis的原子性定义,但不符合业务期望

可以使用 Lua 脚本来替代事务,脚本运行期间,Redis 不会处理其他命令,并且我们可以在脚本中处理整个业务逻辑,包括条件检查和错误处理,保证要么执行成功,要么保持最初的状态,不会出现一个命令执行失败、其他命令执行成功的情况。

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
@Service
public class ImprovedTransactionService {

public boolean atomicTransfer(String fromUser, String toUser, int amount) {
String luaScript =
"local from_key = KEYS[1] " +
"local to_key = KEYS[2] " +
"local amount = tonumber(ARGV[1]) " +

// 检查转出账户余额
"local from_balance = redis.call('GET', from_key) " +
"if not from_balance then return -1 end " +

"from_balance = tonumber(from_balance) " +
"if from_balance < amount then return -2 end " +

// 检查转入账户是否存在
"if redis.call('EXISTS', to_key) == 0 then return -3 end " +

// 所有检查通过,执行转账
"redis.call('DECRBY', from_key, amount) " +
"redis.call('INCRBY', to_key, amount) " +

// 记录转账日志
"local log = from_key .. ':' .. to_key .. ':' .. amount " +
"redis.call('LPUSH', 'transfer:log', log) " +

"return 1";

DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);

Long result = redisTemplate.execute(script,
Arrays.asList("user:" + fromUser + ":balance", "user:" + toUser + ":balance"),
amount);

return result != null && result == 1;
}
}

Redis 事务的 ACID 特性如何体现?

单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务在执行过程中如果某个命令失败了,其他命令还是会继续执行,不会回滚。

20251023161307
小生凡一:Redis 事务的原子性

一致性指的是,如果数据在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据也应该是一致的。但 Redis 事务并不保证一致性,因为如果事务中的某个命令失败了,其他命令仍然会执行,就会出现数据不一致的情况。

Redis 是单线程执行事务的,并且不会中断,直到执行完所有事务队列中的命令为止。因此,我认为 Redis 的事务具有隔离性的特征。

20251023161344
小生凡一:Redis 事务的隔离性

Redis 事务的持久性完全依赖于 Redis 本身的持久化机制,如果开启了 AOF,那么事务中的命令会作为一个整体记录到 AOF 文件中,当然也要看 AOF 的 fsync 策略。

如果只开启了 RDB,事务中的命令可能会在下次快照前丢失。如果两个都没有开启,肯定是不满足持久性的。

  1. Java 面试指南(付费)收录的华为一面原题:说下 Redis 事务
  2. 二哥编程星球球友枕云眠美团 AI 面试原题:什么是 redis 的事务,它的 ACID 属性如何体现
  3. Java 面试指南(付费)收录的快手同学 4 一面原题:Redis事务满足原子性吗?要怎么改进?

48.🌟Redis能实现分布式锁吗?

分布式锁是一种用于控制多个不同进程在分布式系统中访问共享资源的锁机制。它能确保在同一时刻,只有一个节点可以对资源进行访问,从而避免分布式场景下的并发问题。

可以使用 Redis 的 SETNX 命令实现简单的分布式锁。比如 SET key value NX PX 3000 就创建了一个锁名为 key 的分布式锁,锁的持有者为 value。NX 保证只有在 key 不存在时才能创建成功,EX 设置过期时间用以防止死锁。

20251023162344
三分恶面渣逆袭:set原子命令

Redis如何保证 SETNX 不会发生冲突?

当我们使用 SET key value NX EX 30 这个命令进行加锁时,Redis 会把整个操作当作一个原子指令来执行。因为 Redis 的命令处理是单线程的,所以在同一时刻只能有一个命令在执行。

比如说两个客户端 A 和 B 同时请求同一个锁:

1
2
客户端A: SET lock_key uuid_a NX EX 30
客户端B: SET lock_key uuid_b NX EX 30

虽然这两个请求可能几乎同时到达 Redis 服务器,但 Redis 会严格按照到达的先后顺序来处理。假设 A 的请求先到,Redis 会先执行 A 的 SET 命令,这时 lock_key 被设置为 uuid_a。

当处理 B 的请求时,因为 lock_key 已经存在了,NX 条件不满足,所以 B 的 SET 命令会失败,返回 NULL。这样就保证了只有 A 能获取到锁。

关键点在于 NX 的语义:NOT EXISTS,只有在 key 不存在的时候才会设置成功。Redis 在执行这个命令时,会先检查 key 是否存在,如果不存在才会设置值,这整个过程是原子的,不会被其他命令打断。

SETNX有什么问题,如何解决?

使用 SETNX 创建分布式锁时,虽然可以通过设置过期时间来避免死锁,但会误删锁。比如线程 A 获取锁后,业务执行时间比较长,锁过期了。这时线程 B 获取到锁,但线程 A 执行完业务逻辑后,会尝试删除锁,这时候删掉的其实是线程 B 的锁。

20251023162513
技术派:Redis 锁

可以通过锁的自动续期机制来解决锁过期的问题,比如 Redisson 的看门狗机制,在后台启动一个定时任务,每隔一段时间就检查锁是否还被当前线程持有,如果是就自动延长过期时间。这样既避免了死锁,又防止了锁被提前释放。

20251023162536
技术派:redisson 看门狗

Redisson了解多少?

Redisson 是一个基于 Redis 的 Java 客户端,它不只是对 Redis 的操作进行简单地封装,还提供了很多分布式的数据结构和服务,比如最常用的分布式锁。

1
2
3
4
5
6
7
RLock lock = redisson.getLock("lock");
lock.lock();
try {
// do something
} finally {
lock.unlock();
}

Redisson 的分布式锁比 SETNX 完善的得多,它的看门狗机制可以让我们在获取锁的时候省去手动设置过期时间的步骤,它在内部封装了一个定时任务,每隔 10 秒会检查一次,如果当前线程还持有锁就自动续期 30 秒。

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
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
// 手动设置过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 启用看门狗机制,使用默认的30秒过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}

// 处理获取锁成功的情况
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// 如果获取锁成功且启用看门狗机制
if (ttlRemaining == null) {
if (leaseTime != -1) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
scheduleExpirationRenewal(threadId); // 启动看门狗
}
}
});
return ttlRemainingFuture;
}

另外,Redisson 还提供了分布式限流器 RRateLimiter,基于令牌桶算法实现,用于控制分布式环境下的访问频率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// API 接口限流
@RestController
public class ApiController {

@Autowired
private RedissonClient redissonClient;

@GetMapping("/api/data")
public ResponseEntity<?> getData() {
RRateLimiter limiter = redissonClient.getRateLimiter("api.data");
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.MINUTES);

if (limiter.tryAcquire()) {
// 处理请求
return ResponseEntity.ok(processData());
} else {
// 限流触发
return ResponseEntity.status(429).body("Rate limit exceeded");
}
}
}

详细说说Redisson的看门狗机制?

Redisson 的看门狗机制是一种自动续期机制,用于解决分布式锁的过期问题。

基本原理是这样的:当调用 lock() 方法加锁时,如果没有显式设置过期时间,Redisson 会默认给锁加一个 30 秒的过期时间,同时启用一个名为“看门狗”的定时任务,每隔 10 秒(默认是过期时间的 1/3),去检查一次锁是否还被当前线程持有,如果是,就自动续期,将过期时间延长到 30 秒。

20251023162648
郭慕荣博客园:看门狗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 伪代码展示核心逻辑
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager()
.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) {
// 用 Lua 脚本检查并续期
if (redis.call("get", lockKey) == currentThreadId) {
redis.call("expire", lockKey, 30);
// 递归调用,继续下一次续期
renewExpiration();
}
}
}, 10, TimeUnit.SECONDS);
}

续期的 Lua 脚本会检查锁的 value 是否匹配当前线程,如果匹配就延长过期时间。这样就能保证只有锁的真正持有者才能续期。

当调用 unlock() 方法时,看门狗任务会被取消。或者如果业务逻辑执行完但忘记 unlock 了,看门狗也会帮我们自动检查锁,如果锁已经不属于当前线程了,也会自动停止续期。

这样我们就不用担心业务执行时间过长导致锁被提前释放,也避免了手动估算过期时间的麻烦,同时也解决了分布式环境下的死锁问题。

看门狗机制中的检查锁过程是原子操作吗?

是的,Redisson 使用了 Lua 脚本来保证锁检查的原子性。

20251023162726
二哥的 Java 进阶之路:看门狗 lua 脚本检查锁

Redis 在执行 Lua 脚本时,会把整个脚本当作一个命令来处理,期间不会执行其他命令。所以 hexists 检查和 expire 续期是原子执行的。

Redlock你了解多少?

Redlock 是 Redis 作者 antirez 提出的一种分布式锁算法,用于解决单个 Redis 实例作为分布式锁时存在的单点故障问题。

Redlock 的核心思想是通过在多个完全独立的 Redis 实例上同时获取锁来实现容错。

20251023162805
二哥的 Java 进阶之路:RedissonRedLock

minLocksAmount 方法返回的 locks.size()/2 + 1,正是 Redlock 算法要求的少数服从多数原则。failedLocksLimit 方法会计算允许失败的锁数量,确保即使部分实例失败,只要成功的实例数量超过一半就认为获取锁成功。

红锁会尝试依次向所有 Redis 实例获取锁,并记录成功获取的锁数量,当数量达到 minLocksAmount 时就认为获取成功,否则释放已获取的锁并返回失败。

虽然 Redlock 存在一些争议,比如说时钟漂移问题、网络分区导致的脑裂问题,但它仍然是一个相对成熟的分布式锁解决方案。

红锁能不能保证百分百上锁?

不能,Redlock 无法保证百分百上锁成功,这是由分布式系统的本质特性决定的。

当有网络分区时,客户端可能无法与足够数量的 Redis 实例通信。比如在 5 个 Redis 实例的部署中,如果网络分区导致客户端只能访问到 2 个实例,那么无论如何都无法满足红锁要求的少数服从多数原则,获取锁的时候必然失败。

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
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// ...
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
} catch (RedisResponseTimeoutException e) {
lockAcquired = false; // 网络超时导致失败
} catch (Exception e) {
lockAcquired = false; // 其他异常导致失败
}

// 如果剩余可尝试的实例数量不足以达到多数派,直接退出
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
}

// 检查是否达到多数派要求
if (acquiredLocks.size() >= minLocksAmount(locks)) {
return true;
} else {
unlockInner(acquiredLocks);
return false; // 未达到多数派,获取失败
}
}

时钟漂移也会影响成功率。即使所有实例都可达,如果各个 Redis 实例之间存在明显的时钟漂移,或者客户端在获取锁的过程中耗时过长,比如网络延迟、GC 停顿等,都可能会导致锁在获取完成前就过期,从而获取失败。

在实际应用中,可以通过重试机制来提高锁的成功率。

1
2
3
4
5
6
7
for (int i = 0; i < maxRetries; i++) {
if (redLock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS)) {
return true;
}
Thread.sleep(retryDelay);
}
return false;

项目中有用到分布式锁吗?

PmHub项目中,我有使用 Redission 的分布式锁来确保流程状态的更新按顺序执行,且不被其他流程服务干扰。

20251023164922
PmHub:分布式锁保障流程状态更新

49.🌟Redis都有哪些底层数据结构?

Redis 之所以快,除了基于内存读写之外,还有很重要的一点就是它精心设计的底层数据结构。Redis 总共有 8 种核心的底层数据结构,我按照重要程度来说一下。

20251023164947
三分恶面渣逆袭:Redis Object对应的映射

首先是 SDS,这是 Redis 自己实现的动态字符串,它保留了 C 语言原生的字符串长度,所以获取长度的时间复杂度是 O(1),在此基础上还支持动态扩容,以及存储二进制数据。

20251023165020
三分恶面渣逆袭:SDS

然后是字典,更底层是用数组+链表实现的哈希表。它的设计很巧妙,用了两个哈希表,平时用第一个,rehash 的时候用第二个,这样可以渐进式地进行扩容,不会阻塞太久。

20251023165052
三分恶面渣逆袭:字典

接下来压缩列表 ziplist,这个设计很有意思。Redis 为了节省内存,设计了这种紧凑型的数据结构,把所有元素连续存储在一块内存里。但是它有个致命问题叫”连锁更新”,就是当我们修改一个元素的时候,可能会导致后面所有的元素都要重新编码,性能会急剧下降。

20251023165136
Shubhi Jain:Ziplist

为了解决压缩列表的问题,Redis 后来设计了 quicklist。这个设计思路很聪明,它把 ziplist 拆分成小块,然后用双向链表把这些小块串起来。这样既保持了 ziplist 节省内存的优势,又避免了连锁更新的问题,因为每个小块的 ziplist 都不会太大。

20251023165213
Mr.于博客园:quicklist

再后来,Redis 又设计了 listpack,这个可以说是 ziplist 的完美替代品。它最大的特点是每个元素只记录自己的长度,不记录前一个元素的长度,这样就彻底解决了连锁更新的问题。Redis 5.0 已经用 listpack 替换了 ziplist。

20251023165228
baseoncpp:listpack

跳表skiplist 主要用在 ZSet 中。它的设计很巧妙,通过多层指针来实现快速查找,平均时间复杂度是 O(log N)。相比红黑树,跳表的实现更简单,而且支持范围查询,这对 Redis 的有序集合来说很重要。

20251023165726
三分恶面渣逆袭:跳表

还有整数集合intset,当 Set 中都是整数且元素数量较少时使用,内部是一个有序数组,查找用的二分法。

20251023165813
zhangtielei.com:intset

最后是双向链表LinkedList,早期版本的 Redis 会在 List 中用到,但 Redis 3.2 后就被 quicklist 替代了,因为纯链表的问题是内存不连续,影响 CPU 缓存性能。

20251023165854
pdai:Redis 底层数据结构和数据类型关系

简单介绍下链表?

Redis 的 linkedlist 是⼀个双向⽆环链表结构,和 Java 中的 LinkedList 类似。

节点由 listNode 表示,每个节点都有指向其前置节点和后置节点的指针,头节点的前置和尾节点的后置均指向 null。

20251023165930
三分恶面渣逆袭:链表linkedlist

关于整数集合,能再详细说说吗?

整数集合是 Redis 中一个非常精巧的数据结构,当一个 Set 只包含整数元素,并且数量不多时,默认不超过 512 个,Redis 就会用 intset 来存储这些数据。

20251023170000
三分恶面渣逆袭:整数集合intset

intset 最有意思的地方是类型升级机制。它有三种编码方式:16位、32位和 64位,会根据存储的整数大小动态调整。比如原来存的都是小整数,用 16 位编码就够了,但突然插入了一个很大的数,超出了 16 位的范围,这时整个数组会升级到 32 位编码。

1
2
3
4
5
typedef struct intset {
uint32_t encoding; // 编码方式:16位、32位或64位
uint32_t length; // 元素数量
int8_t contents[]; // 保存元素的数组
} intset;

当然了,这种升级是有代价的,因为需要重新分配内存并复制数据,并且是不可逆的,但它的好处是可以节省内存空间,特别是在存储大量小整数时。

另外,所有元素在数组中按照从小到大的顺序排列,这样就可以使用二分查找来定位元素,时间复杂度为 O(log N)

说一下zset 的底层原理?

ZSet 是 Redis 最复杂的数据类型,它有两种底层实现方式:压缩列表和跳表。

20251023170235
0xcafebabe:zset 的底层实现

当保存的元素数量少于 128 个,且保存的所有元素大小都小于 64 字节时,Redis 会采用压缩列表的编码方式;否则就用跳表。

当然,这两个条件都可以通过参数进行调整。

选择压缩列表作为底层实现时,每个元素会使用两个紧挨在一起的节点来保存:第一个节点保存元素的成员,第二个节点保存元素的分值。

20251023170247
0xcafebabe:zset 使用压缩列表

所有元素按分值从小到大有序排列,小的放在靠近表头的位置,大的放在靠近表尾的位置。

但跳表的缺点是查找只能按顺序进行,时间复杂度为 O(N),而且在最坏的情况下,插入和删除操作还可能会引起连锁更新。

当元素数量较多或元素较大时,Redis 会使用 skiplist 的编码方式;这个设计非常的巧妙,同时使用了两种数据结构:

1
2
3
4
typedef struct zset {
zskiplist *zsl; // 跳跃表
dict *dict; // 字典
} zset;

跳表按分数有序保存所有元素,且支持范围查询(如 ZRANGEZRANGEBYSCORE),平均时间复杂度为 O(log N)。而哈希表则用来存储成员和分值的映射关系,查找时间复杂度为 O(1)

20251023170359
0xcafebabe:zset 使用跳表

虽然同时使用两种结构,但它们会通过指针来共享相同元素的成员和分值,因此不会浪费额外的内存。

你知道为什么Redis 7.0要用listpack来替代ziplist吗?

答:主要是为了解决压缩列表的一个核心问题——连锁更新。在压缩列表中,每个节点都需要记录前一个节点的长度信息。

20251023170416
wenfh2020.com:redis ziplist

当插入或删除一个节点时,如果这个操作导致某个节点的长度发生了变化,那么后续的节点可能都需要更新它们存储的”前一个节点长度”字段。最坏的情况下,一次操作可能触发整个链表的更新,时间复杂度会从 O(1)退化到 O(n²)

而 listpack 的设计理念完全不同。它让每个节点只记录自己的长度信息,不再依赖前一个节点的长度。这样就从根本上避免了连锁更新的问题。

20251023170521
极客时间:listpack

listpack 中的节点不再保存其前一个节点的长度,而是保存当前节点的编码类型、数据和长度。

20251023170535
极客时间:listpack 的元素

连锁更新是怎么发生的?

比如说我们有一个压缩列表,其中有几个节点的长度都是 253 个字节。在 ziplist 的编码中,如果前一个节点的长度小于 254 字节,我们只需要 1 个字节来存储这个长度信息。

Hello Jelly:连锁更新

Hello Jelly:连锁更新

但如果在这些节点前面插入一个长度为 254 字节的节点,那么原来只需要 1 个字节存储长度的节点现在需要 5 个字节来存储长度信息。这就会导致后续所有节点的长度信息都需要更新。

  1. Java 面试指南(付费)收录的字节跳动商业化一面的原题:说说 Redis 的 zset,什么是跳表,插入一个节点要构建几层索引
  2. Java 面试指南(付费)收录的字节跳动面经同学 9 飞书后端技术一面面试原题:Redis 的数据类型,ZSet 的实现
  3. Java 面试指南(付费)收录的小米暑期实习同学 E 一面面试原题:你知道 Redis 的 zset 底层实现吗
  4. Java 面试指南(付费)收录的腾讯面经同学 23 QQ 后台技术一面面试原题:zset 的底层原理
  5. Java 面试指南(付费)收录的快手面经同学 7 Java 后端技术一面面试原题:说一下 ZSet 底层结构
  6. Java 面试指南(付费)收录的美团同学 9 一面面试原题:redis的数据结构底层原理?
  7. Java 面试指南(付费)收录的腾讯面经同学 27 云后台技术一面面试原题:Zset的底层实现?
  8. Java 面试指南(付费)收录的得物面经同学 9 面试题目原题:Zset的底层如何实现?

52.🌟你了解跳表吗?

跳表是一种非常巧妙的数据结构,它在有序链表的基础上建立了多层索引,最底层包含所有数据,每往上一层,节点数量就减少一半。

20251024094421
metahub follower:skiplist

它的核心思想是”用空间换时间”,通过多层索引来跳过大量节点,从而提高查找效率。

20251024094451
三分恶面渣逆袭:跳表

每个节点有 50% 的概率只在第 1 层出现,25% 的概率在第 2 层出现,依此类推。查找的时候从最高层开始水平移动,当下一个节点值大于目标时,就向下跳一层,直到找到目标节点。

20251024094551
Dylan Wang:Skiplist

怎么往跳表插入节点呢?

首先是找到插入位置,从最高层的头节点开始,在每一层都找到应该插入位置的前驱节点,用一个 update 数组把这些前驱节点记录下来。这个查找过程和普通查找一样,在每层向右移动直到下个节点的值大于要插入的值,然后下降到下一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 记录每层的插入位置
zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
zskiplistNode *x;
int i, level;

// 从最高层开始查找
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
// 在当前层水平移动,找到插入位置
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) < 0)))
{
x = x->level[i].forward;
}
update[i] = x; // 记录每层的前驱节点
}

接下来随机生成新节点的层数。通常用一个循环,每次有 50% 的概率继续往上,直到随机失败或达到最大层数限制。

1
2
3
4
5
6
7
8
9
10
// Redis 中的随机层数生成
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

// 生成新节点的层数
level = zslRandomLevel();

创建新节点后,从底层开始到新节点的最高层,在每一层都进行标准的链表插入操作。这一步要利用之前记录的 update 数组,将新节点插入到正确位置,然后更新前后指针的连接关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 更新前进指针
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;

// 更新跨度信息
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}

// 更新未涉及层的跨度
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}

// 更新后退指针
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;

// 更新跳表长度
zsl->length++;

我们来模拟一个跳表的插入过程,假设插入的数据依次是 22、19、7、3、37、11、26。

20251024095121
zhangtielei.com:跳表插入过程

那假如我们在一个已经分布了 1、14、27、31、44、56、63、70、80、91 的跳表中插入一个 67 的节点,插入过程是这样的:

20251024095130
Dylan Wang:插入节点

zset为什么要使用跳表呢?

第一,跳表天然就是有序的数据结构,查找、插入和删除都能保持 O(log n) 的时间复杂度。

第二,跳表支持范围查询,找到起始位置后可以直接沿着底层链表顺序遍历,满足 ZRANGE 按排名获取元素,或者 ZRANGEBYSCORE 按分值范围获取元素。

跳表是如何定义的呢?

跳表本质上是一个多层链表,底层是一个包含所有元素的有序链表,上一层作为索引层,包含了下一层的部分节点;层数通过随机算法确定,理论上可以无限高。

20251024095312
metahub follower:跳表

跳表节点包含分值 score、成员对象 obj、一个后退指针 backward,以及一个层级数组 level。每个层级包含 forward 前进指针和 span 跨度信息。

1
2
3
4
5
6
7
8
9
typedef struct skiplistNode {
double score; // 分值(用于排序)
robj *obj; // 数据对象
struct skiplistNode *backward; // 后退指针
struct skiplistLevel {
struct skiplistNode *forward; // 前进指针
unsigned int span; // 跨度(到下个节点的距离)
} level[]; // 层级数组
} skiplistNode;

跳表本身包含头尾节点指针、节点总数 length 和当前最大层数 level。

1
2
3
4
5
typedef struct skiplist {
struct skiplistNode *header, *tail; // 头尾节点
unsigned long length; // 节点数量
int level; // 最大层数
} skiplist;

span 跨度有什么用?

span 记录了当前节点到下一节点之间,底层到底跨越了几个节点,它的主要作用是快速找到 ZSet 中某个分值的排名。

20251024100031
Aparajita Pandey:span

比如说我们执行 ZRANK 命令时,如果没有 span,就需要从头节点开始遍历每个节点,直到找到目标分值,这样时间复杂度是 O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 没有span的排名查询 - O(n)
int getRankWithoutSpan(skiplist *zsl, double score, robj *obj) {
skiplistNode *x = zsl->header->level[0].forward;
int rank = 0;

while (x) {
if (x->score == score && equalStringObjects(x->obj, obj)) {
return rank + 1; // 排名从1开始
}
rank++;
x = x->level[0].forward;
}
return 0;
}

但有了 span,我们在从高层往低层搜索的时候,可以直接跳过一些节点,快速定位到目标分值所在的范围。这样就能把时间复杂度降到 O(log n)

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
long skiplistGetRank(skiplist *zsl, double score, robj *obj) {
skiplistNode *x = zsl->header;
unsigned long rank = 0;

// 从最高层开始查找
for (int i = zsl->level - 1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj, obj) < 0))) {

rank += x->level[i].span; // 累加跨度
x = x->level[i].forward;
}

// 找到目标节点
if (x->level[i].forward &&
x->level[i].forward->score == score &&
equalStringObjects(x->level[i].forward->obj, obj)) {
rank += x->level[i].span;
return rank;
}
}

return 0;
}

为什么跳表的范围查询效率比字典高?

字典是通过哈希函数将键值对分散存储的,元素在内存中是无序分布的,没有任何顺序关系。而跳表本身就是有序的数据结构,所有元素按照分值从小到大排列。

20251024100154
WARRIOR:跳表

当需要进行范围查询时,字典必须遍历所有元素,逐个检查每个元素是否在指定范围内,时间复杂度是 O(n)。比如要找分值在 60 到 80 之间的所有元素,字典只能把整个哈希表扫描一遍,因为它无法知道符合条件的元素在哪里。

而跳表的范围查询就高效多了。首先用 O(log n) 时间找到范围的起始位置,然后沿着底层的有序链表顺序遍历,直到超出范围为止。总时间复杂度是 O(log n + k),其中 k 是结果集的大小。这种效率差异在数据量大的时候非常明显。

20251024100219
晴天哥:zset 底层由字典和跳表组成

这也是为什么 Redis 的 zset 要用跳表而不是纯哈希表的重要原因,因为 zset 经常需要 ZRANGE、ZRANGEBYSCORE 这类范围操作。实际上 Redis 的 zset 是跳表和哈希表的组合:跳表保证有序性支持范围查询,哈希表保证 O(1) 的单点查找效率,两者互补。

  1. Java 面试指南(付费)收录的小米暑期实习同学 E 一面面试原题:为什么 hash 表范围查询效率比跳表低
  2. Java 面试指南(付费)收录的得物面经同学 8 一面面试原题:跳表的结构
  3. Java 面试指南(付费)收录的美团面经同学 4 一面面试原题:Redis 跳表
  4. Java 面试指南(付费)收录的阿里系面经同学 19 饿了么面试原题:跳表了解吗

Spring

4.🌟Spring用了哪些设计模式?

Spring 框架里面确实用了很多设计模式,我从平时工作中能观察到的几个来说说。

首先是工厂模式,这个在 Spring 里用得非常多。BeanFactory 就是一个典型的工厂,它负责创建和管理所有的 Bean 对象。我们平时用的 ApplicationContext 其实也是 BeanFactory 的一个实现。当我们通过 @Autowired 获取一个 Bean 的时候,底层就是通过工厂模式来创建和获取对象的。

20251022155254
三分恶面渣逆袭:Spring中用到的设计模式

单例模式也是 Spring 的默认行为。默认情况下,Spring 容器中的 Bean 都是单例的,整个应用中只会有一个实例。这样可以节省内存,提高性能。当然我们也可以通过 @Scope 注解来改变 Bean 的作用域,比如设置为 prototype 就是每次获取都创建新实例。

20251022155349
二哥的 Java 进阶之路:@Scope注解

代理模式在 AOP 中用得特别多。Spring AOP 的底层实现就是基于动态代理的,对于实现了接口的类用 JDK 动态代理,没有实现接口的类用 CGLIB 代理。比如我们用 @Transactional 注解的时候,Spring 会为我们的类创建一个代理对象,在方法执行前后添加事务处理逻辑。

模板方法模式在 Spring 里也很常见,比如 JdbcTemplate。它定义了数据库操作的基本流程:获取连接、执行 SQL、处理结果、关闭连接,但是具体的 SQL 语句和结果处理逻辑由我们来实现。

20251022155821
技术派源码:JdbcTemplate

观察者模式在 Spring 的事件机制中有所体现。我们可以通过 ApplicationEvent 和 ApplicationListener 来实现事件的发布和监听。比如用户注册成功后,我们可以发布一个用户注册事件,然后有多个监听器来处理后续的业务逻辑,比如发送邮件、记录日志等。

20251022155845
技术派源码:ApplicationListener

这些设计模式的应用让 Spring 框架既灵活又强大,也让我在实际的开发中学到很多经典的设计思想。

Spring如何实现单例模式?

传统的单例模式是在类的内部控制只能创建一个实例,比如用 private 构造方法加 static getInstance() 这种方式。但是 Spring 的单例是容器级别的,同一个 Bean 在整个 Spring 容器中只会有一个实例。

具体的实现机制是这样的:Spring 在启动的时候会把所有的 Bean 定义信息加载进来,然后在 DefaultSingletonBeanRegistry 这个类里面维护了一个叫 singletonObjects 的 ConcurrentHashMap,这个 Map 就是用来存储单例 Bean 的。key 是 Bean 的名称,value 就是 Bean 的实例对象。

20251022160045
二哥的 Java 进阶之路:DefaultSingletonBeanRegistry

当我们第一次获取某个 Bean 的时候,Spring 会先检查 singletonObjects 这个 Map 里面有没有这个 Bean,如果没有就会创建一个新的实例,然后放到 Map 里面。后面再获取同一个 Bean 的时候,直接从 Map 里面取就行了,这样就保证了单例。

20251022160058
二哥的 Java 进阶之路:registerSingleton

还有一个细节就是 Spring 为了解决循环依赖的问题,还用了三级缓存。除了 singletonObjects 这个一级缓存,还有 earlySingletonObjects 二级缓存和 singletonFactories 三级缓存。这样即使有循环依赖,Spring 也能正确处理。

而且 Spring 的单例是线程安全的,因为用的是 ConcurrentHashMap,多线程访问不会有问题。

  1. Java 面试指南(付费)收录的携程面经同学 10 Java 暑期实习一面面试原题:Spring IoC 的设计模式,AOP 的设计模式
  2. Java 面试指南(付费)收录的小公司面经合集同学 1 Java 后端面试原题:Spring 框架使用到的设计模式?
  3. Java 面试指南(付费)收录的同学 1 贝壳找房后端技术一面面试原题:Spring用了什么设计模式?
  4. Java 面试指南(付费)收录的快手同学 4 一面原题:Spring中使用了哪些设计模式,以其中一种模式举例说明?Spring如何实现单例模式?

7.🌟能说一下Bean的生命周期吗?

推荐阅读:三分恶:Spring Bean 生命周期,好像人的一生

好的。

Bean 的生命周期可以分为 5 个主要阶段,我按照实际的执行顺序来说一下。

20251022164805
三分恶面渣逆袭:Bean生命周期五个阶段

第一个阶段是实例化。Spring 容器会根据 BeanDefinition,通过反射调用 Bean 的构造方法创建对象实例。如果有多个构造方法,Spring 会根据依赖注入的规则选择合适的构造方法。

20251022164814
三分恶面渣逆袭:Spring Bean生命周期

第二阶段是属性赋值。这个阶段 Spring 会给 Bean 的属性赋值,包括通过 @Autowired@Resource 这些注解注入的依赖对象,以及通过 @Value 注入的配置值。

20251022164823
二哥的 Java 进阶之路:doCreateBean 方法源码

第三阶段是初始化。这个阶段会依次执行:

  • @PostConstruct 标注的方法
  • InitializingBean 接口的 afterPropertiesSet 方法
  • 通过 @Bean 的 initMethod 指定的初始化方法

20251022164834
三分恶面渣逆袭:Bean生命周期源码追踪

我在项目中经常用 @PostConstruct 来做一些初始化工作,比如缓存预加载、DB 配置等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// CategoryServiceImpl中的缓存初始化
@PostConstruct
public void init() {
categoryCaches = CacheBuilder.newBuilder().maximumSize(300).build(new CacheLoader<Long, CategoryDTO>() {
@Override
public CategoryDTO load(@NotNull Long categoryId) throws Exception {
CategoryDO category = categoryDao.getById(categoryId);
// ...
}
});
}

// DynamicConfigContainer中的配置初始化
@PostConstruct
public void init() {
cache = Maps.newHashMap();
bindBeansFromLocalCache("dbConfig", cache);
}

初始化后,Spring 还会调用所有注册的 BeanPostProcessor 后置处理方法。这个阶段经常用来创建代理对象,比如 AOP 代理。

第五阶段是使用 Bean。比如我们的 Controller 调用 Service,Service 调用 DAO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UserController中的使用示例
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
// UserService中的使用示例
@Autowired
private UserDao userDao;
public UserDTO getUserById(Long id) {
return userDao.getById(id);
}
// UserDao中的使用示例
@Autowired
private JdbcTemplate jdbcTemplate;
public UserDTO getById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper());
}

最后是销毁阶段。当容器关闭或者 Bean 被移除的时候,会依次执行:

  • @PreDestroy 标注的方法
  • DisposableBean 接口的 destroy 方法
  • 通过 @Bean 的 destroyMethod 指定的销毁方法

20251022164849
二哥的 Java 进阶之路:close 源码

Aware 类型的接口有什么作用?

Aware 接口在 Spring 中是一个很有意思的设计,它们的作用是让 Bean 能够感知到 Spring 容器的一些内部组件。

从设计理念来说,Aware 接口实现了一种“回调”机制。正常情况下,Bean 不应该直接依赖 Spring 容器,这样可以保持代码的独立性。但有些时候,Bean 确实需要获取容器的一些信息或者组件,Aware 接口就提供了这样一个能力。

我最常用的 Aware 接口是 ApplicationContextAware,它可以让 Bean 获取到 ApplicationContext 容器本身。

20251022164904
技术派源码:ApplicationContextAware

技术派项目中,我就通过实现 ApplicationContextAware 和 EnvironmentAware 接口封装了一个 SpringUtil 工具类,通过 getBean 和 getProperty 方法来获取 Bean 和配置属性。

1
2
3
4
5
6
7
8
9
// 静态方法获取Bean,方便在非Spring管理的类中使用
public static <T> T getBean(Class<T> clazz) {
return context.getBean(clazz);
}

// 获取配置属性
public static String getProperty(String key) {
return environment.getProperty(key);
}

如果配置了 init-method 和 destroy-method,Spring 会在什么时候调用其配置的方法?

init-method 指定的初始化方法会在 Bean 的初始化阶段被调用,具体的执行顺序是:

  • 先执行 @PostConstruct 标注的方法
  • 然后执行 InitializingBean 接口的 afterPropertiesSet() 方法
  • 最后再执行 init-method 指定的方法

也就是说,init-method 是在所有其他初始化方法之后执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class MyService {
@Autowired
private UserDao userDao;

@PostConstruct
public void postConstruct() {
System.out.println("1. @PostConstruct执行");
}

public void customInit() { // 通过@Bean的initMethod指定
System.out.println("3. init-method执行");
}
}

@Configuration
public class AppConfig {
@Bean(initMethod = "customInit")
public MyService myService() {
return new MyService();
}
}

destroy-method 会在 Bean 销毁阶段被调用。

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyService {
@PreDestroy
public void preDestroy() {
System.out.println("1. @PreDestroy执行");
}

public void customDestroy() { // 通过@Bean的destroyMethod指定
System.out.println("3. destroy-method执行");
}
}

不过在实际开发中,通常用 @PostConstruct@PreDestroy 就够了,它们更简洁。

  1. Java 面试指南(付费)收录的小米 25 届日常实习一面原题:说说 Bean 的生命周期
  2. Java 面试指南(付费)收录的百度面经同学 1 文心一言 25 实习 Java 后端面试原题:Spring中bean生命周期
  3. Java 面试指南(付费)收录的8 后端开发秋招一面面试原题:讲一下Spring Bean的生命周期
  4. Java 面试指南(付费)收录的同学 1 贝壳找房后端技术一面面试原题:bean生命周期
  5. Java 面试指南(付费)收录的快手同学 4 一面原题:介绍下Bean的生命周期?Aware类型接口的作用?如果配置了init-method和destroy-method,Spring会在什么时候调用其配置的方法?

14.🌟Spring怎么解决循环依赖呢?

Spring 通过三级缓存机制来解决循环依赖:

  1. 一级缓存:存放完全初始化好的单例 Bean。
  2. 二级缓存:存放提前暴露的 Bean,实例化完成,但未初始化完成。
  3. 三级缓存:存放 Bean 工厂,用于生成提前暴露的 Bean。

20251022165402
三分恶面渣逆袭:三级缓存

以 A、B 两个类发生循环依赖为例:

20251022165409
三分恶面渣逆袭:循环依赖

第 1 步:开始创建 Bean A。

  • Spring 调用 A 的构造方法,创建 A 的实例。此时 A 对象已存在,但 b属性还是 null。
  • 将 A 的对象工厂放入三级缓存。
  • 开始进行 A 的属性注入。

20251022165417
三分恶面渣逆袭:A 对象工厂

第 2 步:A 需要注入 B,开始创建 Bean B。

  • 发现需要 B,但 B 还不存在,所以开始创建 B。
  • 调用 B 的构造方法,创建 B 的实例。此时 B 对象已存在,但 a 属性还是 null。
  • 将 B 的对象工厂放入三级缓存。
  • 开始进行 B 的属性注入。

第 3 步:B 需要注入 A,从缓存中获取 A。

  • B 需要注入 A,先从一级缓存找 A,没找到。
  • 再从二级缓存找 A,也没找到。
  • 最后从三级缓存找 A,找到了 A 的对象工厂。
  • 调用 A 的对象工厂得到 A 的实例。这时 A 已经实例化了,虽然还没完全初始化。
  • 将 A 从三级缓存移到二级缓存。
  • B 拿到 A 的引用,完成属性注入。

20251022165426
三分恶面渣逆袭:A 放入二级缓存,B 放入一级缓存

第 4 步:B 完成初始化。

  • B 的属性注入完成,执行 @PostConstruct 等初始化逻辑。
  • B 完全创建完成,从三级缓存移除,放入一级缓存。

第 5 步:A 完成初始化。

  • 回到 A 的创建过程,A 拿到完整的 B 实例,完成属性注入。
  • A 执行初始化逻辑,创建完成。
  • A 从二级缓存移除,放入一级缓存。

20251022165435
三分恶面渣逆袭:AB 都好了

用代码来模拟这个过程,是这样的:

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
// 模拟Spring的解决过程
public class CircularDependencyDemo {
// 三级缓存
Map<String, Object> singletonObjects = new HashMap<>();
Map<String, Object> earlySingletonObjects = new HashMap<>();
Map<String, ObjectFactory> singletonFactories = new HashMap<>();

public Object getBean(String beanName) {
// 先从一级缓存获取
Object bean = singletonObjects.get(beanName);
if (bean != null) return bean;

// 再从二级缓存获取
bean = earlySingletonObjects.get(beanName);
if (bean != null) return bean;

// 最后从三级缓存获取
ObjectFactory factory = singletonFactories.get(beanName);
if (factory != null) {
bean = factory.getObject();
earlySingletonObjects.put(beanName, bean); // 移到二级缓存
singletonFactories.remove(beanName); // 从三级缓存移除
}

return bean;
}
}

哪些情况下Spring无法解决循环依赖?

Spring 虽然能解决大部分循环依赖问题,但确实有几种情况是无法处理的,我来详细说说。

20251022165456
三分恶面渣逆袭:循环依赖的几种情形

第一种,构造方法的循环依赖,这种情况 Spring 会直接抛出 BeanCurrentlyInCreationException 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class A {
private B b;

public A(B b) { // 构造方法注入
this.b = b;
}
}

@Component
public class B {
private A a;

public B(A a) { // 构造方法注入
this.a = a;
}
}

因为构造方法注入发生在实例化阶段,创建 A 的时候必须先有 B,但创建 B又必须先有 A,这时候两个对象都还没创建出来,无法提前暴露到缓存中。

第二种,prototype 作用域的循环依赖。prototype 作用域的 Bean 每次获取都会创建新实例,Spring 无法缓存这些实例,所以也无法解决循环依赖。

-—面试中可以不背,方便大家理解 start—-

我们来看一个实例,先是 PrototypeBeanA:

1
2
3
4
5
6
7
8
9
10
@Component
@Scope("prototype")
public class PrototypeBeanA {
private final PrototypeBeanB prototypeBeanB;

@Autowired
public PrototypeBeanA(PrototypeBeanB prototypeBeanB) {
this.prototypeBeanB = prototypeBeanB;
}
}

然后是 PrototypeBeanB:

1
2
3
4
5
6
7
8
9
10
@Component
@Scope("prototype")
public class PrototypeBeanB {
private final PrototypeBeanA prototypeBeanA;

@Autowired
public PrototypeBeanB(PrototypeBeanA prototypeBeanA) {
this.prototypeBeanA = prototypeBeanA;
}
}

再然后是测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}

@Bean
CommandLineRunner commandLineRunner(ApplicationContext ctx) {
return args -> {
// 尝试获取PrototypeBeanA的实例
PrototypeBeanA beanA = ctx.getBean(PrototypeBeanA.class);
};
}
}

运行结果:

20251022165524
二哥的 Java 进阶之路:循环依赖

-—面试中可以不背,方便大家理解 end—-

  1. Java 面试指南(付费)收录的小米 25 届日常实习一面原题:如何解决循环依赖?
  2. Java 面试指南(付费)收录的百度面经同学 1 文心一言 25 实习 Java 后端面试原题:Spring如何解决循环依赖?
  3. Java 面试指南(付费)收录的得物面经同学 9 面试题目原题:Spring源码看过吗?Spring的三级缓存知道吗?
  4. Java 面试指南(付费)收录的阿里云面经同学 22 面经:spring三级缓存解决循环依赖问题

16.🌟说一说什么是IoC?

推荐阅读:IoC 扫盲

IoC 的全称是 Inversion of Control,也就是控制反转。这里的“控制”指的是对象创建和依赖关系管理的控制权。

20251022165715
图片来源于网络:IoC

以前我们写代码的时候,如果 A 类需要用到 B 类,我们就在 A 类里面直接 new 一个 B 对象出来,这样 A 类就控制了 B 类对象的创建。

1
2
3
4
5
6
7
8
9
// 传统方式:对象主动创建依赖
public class UserService {
private UserDao userDao;

public UserService() {
// 主动创建依赖对象
this.userDao = new UserDaoImpl();
}
}

有了 IoC 之后,这个控制权就“反转”了,不再由 A 类来控制 B 对象的创建,而是交给外部的容器来管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** 
* 使用 Spring IoC 容器来管理 UserDao 的创建和注入
* 技术派源码:https://github.com/itwanger/paicoding
*/
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao;

// 不需要主动创建 UserDao,由 Spring 容器注入
public BaseUserInfoDTO getAndUpdateUserIpInfoBySessionId(String session, String clientIp) {
// 直接使用注入的 userDao
return userDao.getBySessionId(session);
}
}

-—这部分面试中可以不背 start—-

没有 IoC 之前:

我需要一个女朋友,刚好大街上突然看到了一个小姐姐,人很好看,于是我就自己主动上去搭讪,要她的微信号,找机会聊天关心她,然后约她出来吃饭,打听她的爱好,三观。。。

有了 IoC 之后:

我需要一个女朋友,于是我就去找婚介所,告诉婚介所,我需要一个长的像赵露思的,会打 Dota2 的,于是婚介所在它的人才库里开始找,找不到它就直接说没有,找到它就直接介绍给我。

婚介所就相当于一个 IoC 容器,我就是一个对象,我需要的女朋友就是另一个对象,我不用关心女朋友是怎么来的,我只需要告诉婚介所我需要什么样的女朋友,婚介所就帮我去找。

20251022165731
三分恶面渣逆袭:引入IoC之前和引入IoC之后

-—这部分面试中可以不背 end—-

DI和IoC的区别了解吗?

IoC 的思想是把对象创建和依赖关系的控制权由业务代码转移给 Spring 容器。这是一个比较抽象的概念,告诉我们应该怎么去设计系统架构。

20251022165745
Martin Fowler’s Definition

而 DI,也就是依赖注入,它是实现 IoC 这种思想的具体技术手段。在 Spring 里,我们用 @Autowired 注解就是在使用 DI 的字段注入方式。

1
2
3
4
5
6
7
8
@Service
public class ArticleReadServiceImpl implements ArticleReadService {
@Autowired
private ArticleDao articleDao; // 字段注入

@Autowired
private UserDao userDao;
}

从实现角度来看,DI 除了字段注入,还有构造方法注入和 Setter 方法注入等方式。在做技术派项目的时候,我就尝试过构造方法注入的方式。

20251022165756
技术派源码:构造方法的注入方式

当然了,DI 并不是实现 IoC 的唯一方式,还有 Service Locator 模式,可以通过实现 ApplicationContextAware 接口来获取 Spring 容器中的 Bean。

20251022165805
技术派源码:IoC 的Service Locator 模式

之所以 ID 后成为 IoC 的首选实现方式,是因为代码更清晰、可读性更高。

1
2
3
4
5
6
7
8
IoC(控制反转)
├── DI(依赖注入) ← 主要实现方式
│ ├── 构造器注入
│ ├── 字段注入
│ └── Setter注入
├── 服务定位器模式
├── 工厂模式
└── 其他实现方式

为什么要使用 IoC 呢?

在日常开发中,如果我们需要实现某一个功能,可能至少需要两个以上的对象来协助完成,在没有 Spring 之前,每个对象在需要它的合作对象时,需要自己 new 一个,比如说 A 要使用 B,A 就对 B 产生了依赖,也就是 A 和 B 之间存在了一种耦合关系。

1
2
3
4
5
6
7
8
// 传统方式:对象自己创建依赖
public class UserService {
private UserDao userDao = new UserDaoImpl(); // 硬编码依赖

public User getUser(Long id) {
return userDao.findById(id);
}
}

有了 Spring 之后,创建 B 的工作交给了 Spring 来完成,Spring 创建好了 B 对象后就放到容器中,A 告诉 Spring 我需要 B,Spring 就从容器中取出 B 交给 A 来使用。

1
2
3
4
5
6
7
8
9
10
// IoC 方式:依赖由外部注入
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao; // 依赖注入,不关心具体实现

public User getUser(Long id) {
return userDao.findById(id);
}
}

至于 B 是怎么来的,A 就不再关心了,Spring 容器想通过 newnew 创建 B 还是 new 创建 B,无所谓。

这就是 IoC 的好处,它降低了对象之间的耦合度,让每个对象只关注自己的业务实现,不关心其他对象是怎么创建的。

推荐阅读:孤傲苍狼:谈谈对 Spring IOC 的理解

  1. Java 面试指南(付费)收录的小米 25 届日常实习一面原题:说说你对 AOP 和 IoC 的理解。
  2. Java 面试指南(付费)收录的小公司面经合集同学 1 Java 后端面试原题:介绍 Spring IoC 和 AOP?
  3. Java 面试指南(付费)收录的招商银行面经同学 6 招银网络科技面试原题:SpringBoot框架的AOP、IOC/DI?
  4. Java 面试指南(付费)收录的京东面经同学 8 面试原题:IOC,AOP
  5. Java 面试指南(付费)收录的快手同学 4 一面原题:解释下什么是IOC和AOP?分别解决了什么问题?IOC和DI的区别?

19.🌟项目启动时Spring的IoC会做什么?

第一件事是扫描和注册 Bean。IoC 容器会根据我们的配置,比如 @ComponentScan 指定的包路径,去扫描所有标注了 @Component@Service@Controller 这些注解的类。然后把这些类的元信息包装成 BeanDefinition 对象,注册到容器的 BeanDefinitionRegistry 中。这个阶段只是收集信息,还没有真正创建对象。

20251024162351
pdai.tech:IoC

第二件事是 Bean 的实例化和注入。这是最核心的过程,IoC 容器会按照依赖关系的顺序开始创建 Bean 实例。对于单例 Bean,容器会通过反射调用构造方法创建实例,然后进行属性注入,最后执行初始化回调方法。

20251024162423
Tom弹架构:Bean 的实例化和注入

在依赖注入时,容器会根据 @Autowired@Resource 这些注解,把相应的依赖对象注入到目标 Bean 中。比如 UserService 需要 UserDao,容器就会把 UserDao 的实例注入到 UserService 中。

说说Spring的Bean实例化方式?

Spring 提供了 4 种方式来实例化 Bean,以满足不同场景下的需求。

第一种是通过构造方法实例化,这是最常用的方式。当我们用 @Component@Service 这些注解标注类的时候,Spring 默认通过无参构造器来创建实例的。如果类只有一个有参构造方法,Spring 会自动进行构造方法注入。

1
2
3
4
5
6
7
8
@Service
public class UserService {
private UserDao userDao;

public UserService(UserDao userDao) { // 构造方法注入
this.userDao = userDao;
}
}

第二种是通过静态工厂方法实例化。有时候对象的创建比较复杂,我们会写一个静态工厂方法来创建,然后用 @Bean 注解来标注这个方法。Spring 会调用这个静态方法来获取 Bean 实例。

1
2
3
4
5
6
7
8
@Configuration
public class AppConfig {
@Bean
public static DataSource createDataSource() {
// 复杂的DataSource创建逻辑
return new HikariDataSource();
}
}

第三种是通过实例工厂方法实例化。这种方式是先创建工厂对象,然后通过工厂对象的方法来创建Bean:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class AppConfig {
@Bean
public ConnectionFactory connectionFactory() {
return new ConnectionFactory();
}

@Bean
public Connection createConnection(ConnectionFactory factory) {
return factory.createConnection();
}
}

第四种是通过 FactoryBean 接口实例化。这是 Spring 提供的一个特殊接口,当我们需要创建复杂对象的时候特别有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class MyFactoryBean implements FactoryBean<MyObject> {
@Override
public MyObject getObject() throws Exception {
// 复杂的对象创建逻辑
return new MyObject();
}

@Override
public Class<?> getObjectType() {
return MyObject.class;
}
}

在实际工作中,用得最多的还是构造方法实例化,因为简单直接。工厂方法一般用在需要复杂初始化逻辑的场景,比如数据库连接池、消息队列连接这些。FactoryBean 主要是在框架开发或者需要动态创建对象的时候使用。

Spring 在实例化的时候会根据 Bean 的定义自动选择合适的方式,我们作为开发者主要是通过注解和配置来告诉 Spring 应该怎么创建对象。

  1. Java 面试指南(付费)收录的华为面经同学 8 技术二面面试原题:说说 Spring 的 Bean 实例化方式
  2. Java 面试指南(付费)收录的美团同学 2 优选物流调度技术 2 面面试原题:bean加工有哪些方法?

20.🌟说说什么是 AOP?

AOP,也就是面向切面编程,简单点说,AOP 就是把一些业务逻辑中的相同代码抽取到一个独立的模块中,让业务逻辑更加清爽。

20251024163612
三分恶面渣逆袭:横向抽取

-—这部分面试中可以不背,方便大家理解 start—-

举个简单的例子,假设我们有很多个 Service 方法,每个方法都需要记录执行日志、检查权限、管理事务等等。如果没有 AOP 的话,我们可能需要在每个方法里都写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void createUser(User user) {
log.info("开始执行createUser方法");
// 权限检查
if (!hasPermission()) {
throw new SecurityException("无权限");
}
// 开启事务
transactionManager.begin();
try {
// 真正的业务逻辑
userDao.save(user);
transactionManager.commit();
log.info("createUser方法执行成功");
} catch (Exception e) {
transactionManager.rollback();
log.error("createUser方法执行失败", e);
throw e;
}
}

如果每个方法都这样写,代码就会变得非常臃肿,AOP 就是为了解决这个问题,它可以让我们把这些横切关注点(如日志、权限、事务等)从业务代码中抽取出来。

这样,我们就可以定义一个切面,在切面中统一处理这些横切关注点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.service.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
log.info("开始执行方法: " + joinPoint.getSignature().getName());
}
@AfterReturning("execution(* com.example.service.*.*(..))")
public void logAfterReturning(JoinPoint joinPoint) {
log.info("方法执行成功: " + joinPoint.getSignature().getName());
}
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex")
public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) {
log.error("方法执行失败: " + joinPoint.getSignature().getName(), ex);
}
}

然后,业务代码就变得非常干净了:

1
2
3
4
public void createUser(User user) {
// 只需要关注业务逻辑,不需要关心日志、权限、事务等
userDao.save(user);
}

-—面试中可以不背,方便大家理解 end—-

从技术实现上来说,AOP 主要是通过动态代理来实现的。如果目标类实现了接口,就用 JDK 动态代理;如果没有实现接口,就用 CGLIB 来创建子类代理。代理对象会在方法执行前后插入我们定义的切面逻辑。

20251024163711
stack overflow:JDK Proxy vs CGLIB Proxy

Spring AOP 有哪些核心概念?

Spring AOP 是 AOP 的一个具体实现,我按照在工作/学习中理解的重要程度来说一下:

20251024163740
DataFlair Team:AOP 核心概念

①、切面:我们定义的一个类,包含了要在什么时候、什么地方执行什么逻辑。比如我们定义一个日志切面,专门负责记录方法的执行情况。在 Spring 中,我们会用 @Aspect 注解来标识一个切面类。

②、切点:定义了在哪些地方应用切面逻辑。说白了就是告诉 Spring,我这个切面要在哪些方法上生效。比如我们可以定义一个切点表达式,让它匹配所有 Service 层的方法,或者匹配某个特定包下的所有方法。在 Spring 中用 @Pointcut 注解来定义,通常会写一些表达式,比如 execution( com.example.service..*(..)) 这样的。

③、通知:是切面中具体要执行的代码逻辑。它有几种类型:@Before 是在方法执行前执行,@After 是在方法执行后执行,@Around 是环绕通知,可以在方法执行前后都执行,@AfterReturning 是在方法正常返回后执行,@AfterThrowing 是在方法抛出异常后执行。我一般用得最多的是 @Around,因为它最灵活,可以控制方法是否执行,也可以修改参数和返回值。

④、连接点:被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中,连接点指的是被拦截到的方法,实际上连接点还可以是字段或者构造方法。

⑤、织入:是把切面逻辑应用到目标对象的过程。Spring AOP 是在运行时通过动态代理来实现织入的,当我们从 Spring 容器中获取 Bean 的时候,如果这个 Bean 需要被切面处理,Spring 就会返回一个代理对象给我们。

⑥、目标对象:被切面处理的对象,也就是我们平时写的 Service、Controller 等类。Spring AOP 会在目标对象上织入切面逻辑。

它们之间的逻辑关系图是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
切面(Aspect)
├── 切入点(Pointcut)─── 定义在哪里执行
└── 通知(Advice) ─── 定义何时执行什么
├── @Before
├── @After
├── @AfterReturning
├── @AfterThrowing
└── @Around

目标对象(Target)──→ 代理对象(Proxy)──→ 织入(Weaving)
↑ ↓
连接点(Join Point) 客户端调用

Spring AOP 织入有哪几种方式?

织入有三种主要方式,我按照它们的执行时机来说一下。

20251024163839
AOP 织入方式

编译期织入是在编译 Java 源码的时候就把切面逻辑织入到目标类中。这种方式最典型的实现就是 AspectJ 编译器。它会在编译的时候直接修改字节码,把切面的逻辑插入到目标方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 源代码
@Aspect
public class LoggingAspect {
@Before("execution(* com.example.service.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
System.out.println("方法执行前: " + joinPoint.getSignature().getName());
}
}

@Service
public class UserService {
public void saveUser(String username) {
System.out.println("保存用户: " + username);
}
}

这样生成的 class 文件就已经包含了切面逻辑,运行时不需要额外的代理机制。

1
2
3
4
5
6
7
8
9
10
// 编译器自动生成的代码
public class UserService {
public void saveUser(String username) {
// 织入的切面代码
System.out.println("方法执行前: saveUser");

// 原始业务代码
System.out.println("保存用户: " + username);
}
}

编译期织入的优点是性能最好,因为没有代理的开销,但缺点是需要使用特殊的编译器,而且比较复杂,在 Spring 项目中用得不多。

类加载期织入是在 JVM 加载 class 文件的时候进行织入。这种方式通过 Java 的 Instrumentation API 或者自定义的 ClassLoader 来实现,在类被加载到 JVM 之前修改字节码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class WeavingClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classBytes = loadClassBytes(name);

// 在这里进行字节码织入
byte[] wovenBytes = weaveAspects(classBytes);

return defineClass(name, wovenBytes, 0, wovenBytes.length);
}

private byte[] weaveAspects(byte[] classBytes) {
// 使用 ASM 或其他字节码操作库进行织入
return classBytes;
}
}

AspectJ 的 Load-Time Weaving 就是这种方式的典型实现。它比编译期织入更灵活一些,但是配置相对复杂,需要在 JVM 启动参数中指定 Java agent,在 Spring 中也有支持,但用得不是特别多。

1
2
# JVM 启动参数
java -javaagent:aspectjweaver.jar -jar myapp.jar

运行时织入是我们在 Spring 中最常见的方式,也就是通过动态代理来实现。Spring AOP 采用的就是这种方式。当 Spring 容器启动的时候,如果发现某个 Bean 需要被切面处理,就会为这个 Bean 创建一个代理对象。如果目标类实现了接口,Spring 会使用 JDK 的动态代理技术。

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
// 接口
public interface UserService {
void saveUser(String username);
}

// 实现类
@Service
public class UserServiceImpl implements UserService {
@Override
public void saveUser(String username) {
System.out.println("保存用户: " + username);
}
}

// Spring 自动创建的代理(伪代码)
public class UserServiceProxy implements UserService {
private UserService target;
private List<Advisor> advisors;

@Override
public void saveUser(String username) {
// 执行前置通知
for (Advisor advisor : advisors) {
if (advisor.getPointcut().matches(this.getClass().getMethod("saveUser", String.class))) {
advisor.getAdvice().before();
}
}

// 执行目标方法
target.saveUser(username);

// 执行后置通知
for (Advisor advisor : advisors) {
advisor.getAdvice().after();
}
}
}

如果目标类没有实现接口,就会使用 CGLIB 来创建一个子类作为代理。运行时织入的优点是实现简单,不需要特殊的编译器或 JVM 配置,缺点是有一定的性能开销,因为每次方法调用都要经过代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 没有接口的类
@Service
public class OrderService {
public void createOrder(String orderId) {
System.out.println("创建订单: " + orderId);
}
}

// CGLIB 生成的代理子类(伪代码)
public class OrderService$$EnhancerByCGLIB$$12345 extends OrderService {
private MethodInterceptor interceptor;

@Override
public void createOrder(String orderId) {
// 通过 MethodInterceptor 执行切面逻辑
interceptor.intercept(this, getMethod("createOrder"), new Object[]{orderId},
new MethodProxy() {
@Override
public Object invokeSuper(Object obj, Object[] args) {
return OrderService.super.createOrder((String) args[0]);
}
});
}
}

Spring AOP 默认的织入方式就是运行时织入,使用起来非常简单,只需要加个 @Aspect 注解和相应的通知注解就可以了。虽然性能上不如编译期织入,但是对于大部分业务场景来说,这点性能开销是完全可以接受的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Spring AOP 的代理创建过程
@Configuration
@EnableAspectJAutoProxy // 启用 AOP 自动代理
public class AopConfig {
}

// Spring 内部的代理创建逻辑(简化版)
public class DefaultAopProxyFactory implements AopProxyFactory {

@Override
public AopProxy createAopProxy(AdvisedSupport config) {
if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) {
// 使用 CGLIB 代理
return new CglibAopProxy(config);
} else {
// 使用 JDK 动态代理
return new JdkDynamicAopProxy(config);
}
}
}

AspectJ 是什么?

AspectJ 是一个 AOP 框架,它可以做很多 Spring AOP 干不了的事情,比如说编译时、编译后和类加载时织入切面。并且提供了很多复杂的切点表达式和通知类型。

20251024164017
AspectJ 官网

Spring AOP 只支持方法级别的拦截,而且只能拦截 Spring 容器管理的 Bean。但是 AspectJ 可以拦截任何 Java 对象的方法调用、字段访问、构造方法执行、异常处理等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Spring AOP 只能做到这些
@Aspect
@Component
public class SpringAopAspect {
// ✅ 可以拦截:public 方法调用
@Around("execution(public * com.example.service.*.*(..))")
public Object aroundPublicMethod(ProceedingJoinPoint pjp) {
return pjp.proceed();
}

// ❌ 无法拦截:字段访问
// ❌ 无法拦截:构造函数
// ❌ 无法拦截:私有方法
// ❌ 无法拦截:静态方法
}

Spring AOP 有哪些通知方式?

Spring AOP 提供了多种通知方式,允许我们在方法执行的不同阶段插入逻辑。常用的通知方式有:

  • 前置通知 (@Before)
  • 返回通知 (@AfterReturning)
  • 异常通知 (@AfterThrowing)
  • 后置通知 (@After)
  • 环绕通知 (@Around)

20251024164058
三分恶面渣逆袭:Spring AOP 通知方式

前置通知是在目标方法执行之前执行的通知。这种通知比较简单,主要用来做一些准备工作,比如参数校验、权限检查、记录方法开始执行的日志等等。前置通知无法阻止目标方法的执行,也无法修改方法的参数,它只能在方法执行前做一些额外的操作。我们在项目中经常用它来记录操作日志,比如记录谁在什么时候调用了什么方法。

1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.service.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
// 打印方法名和参数
System.out.println("调用方法: " + joinPoint.getSignature().getName());
System.out.println("参数: " + Arrays.toString(joinPoint.getArgs()));
}
}

后置通知是在目标方法执行完成后执行的,不管方法是正常返回还是抛出异常都会执行。这种通知主要用来做一些清理工作,比如释放资源、记录方法执行完成的日志等等。需要注意的是,后置通知拿不到方法的返回值,也捕获不到异常信息,它就是纯粹的在方法执行后做一些收尾工作。

1
2
3
4
5
6
7
8
9
@Aspect
@Component
public class LoggingAspect {
@After("execution(* com.example.service.*.*(..))")
public void logAfter(JoinPoint joinPoint) {
// 打印方法执行完成的日志
System.out.println("方法执行完成: " + joinPoint.getSignature().getName());
}
}

返回通知是在目标方法正常返回后执行的。这种通知可以获取到方法的返回值,我们可以在注解中指定 returning 参数来接收返回值。返回通知经常用来做一些基于返回结果的后续处理,比如缓存方法的返回结果、根据返回值发送通知等等。如果方法抛出异常的话,返回通知是不会执行的。

1
2
3
4
5
6
7
8
9
10
11
@Aspect
@Component
public class LoggingAspect {
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
// 打印方法执行完成的日志
System.out.println("方法执行完成: " + joinPoint.getSignature().getName());
// 打印方法返回值
System.out.println("返回值: " + result);
}
}

异常通知是在目标方法抛出异常后执行的。我们可以在注解中指定 throwing 参数来接收异常对象。异常通知主要用来做异常处理和记录,比如记录错误日志、发送告警、异常统计等等。需要注意的是,异常通知不能处理异常,异常还是会继续向上抛出。

1
2
3
4
5
6
7
8
9
10
11
@Aspect
@Component
public class LoggingAspect {
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex")
public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) {
// 打印方法名和异常信息
System.out.println("方法执行异常: " + joinPoint.getSignature().getName());
System.out.println("异常信息: " + ex.getMessage());
}
}

环绕通知是最强大也是我们用得最多的一种通知。它可以在方法执行前后都执行逻辑,而且可以控制目标方法是否执行,还可以修改方法的参数和返回值。环绕通知的方法必须接收一个 ProceedingJoinPoint 参数,通过调用其 proceed() 方法来执行目标方法。

技术派 项目中就主要是通过环绕通知来实现切面。

20251024164130
技术派源码:环绕通知

如果有多个切面,还可以通过 @Order 注解指定先后顺序,数字越小,优先级越高。代码示例如下:

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
42
43
44
45
46
@Aspect
@Component
public class WebLogAspect {

private final static Logger logger = LoggerFactory.getLogger(WebLogAspect.class);

@Pointcut("@annotation(cn.fighter3.spring.aop_demo.WebLog)")
public void webLog() {}

@Before("webLog()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
// 开始打印请求日志
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 打印请求相关参数
logger.info("========================================== Start ==========================================");
// 打印请求 url
logger.info("URL : {}", request.getRequestURL().toString());
// 打印 Http method
logger.info("HTTP Method : {}", request.getMethod());
// 打印调用 controller 的全路径以及执行方法
logger.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
// 打印请求的 IP
logger.info("IP : {}", request.getRemoteAddr());
// 打印请求入参
logger.info("Request Args : {}",new ObjectMapper().writeValueAsString(joinPoint.getArgs()));
}

@After("webLog()")
public void doAfter() throws Throwable {
// 结束后打个分隔线,方便查看
logger.info("=========================================== End ===========================================");
}

@Around("webLog()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
//开始时间
long startTime = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed();
// 打印出参
logger.info("Response Args : {}", new ObjectMapper().writeValueAsString(result));
// 执行耗时
logger.info("Time-Consuming : {} ms", System.currentTimeMillis() - startTime);
return result;
}
}

Spring AOP 发生在什么时候?

Spring AOP 是在 Bean 的初始化阶段发生的,具体来说是在 Bean 生命周期的后置处理阶段。

在 Bean 实例化完成、属性注入完成之后,Spring 会调用所有 BeanPostProcessor 的 postProcessAfterInitialization 方法,AOP 代理的创建就是在这个阶段完成的。

20251024164150
二哥的 Java 进阶之路:BeanPostProcessor

简单总结一下 AOP

AOP,也就是面向切面编程,是一种编程范式,旨在提高代码的模块化。比如说可以将日志记录、事务管理等分离出来,来提高代码的可重用性。

AOP 的核心概念包括切面、连接点、通知、切点和织入等。

① 像日志打印、事务管理等都可以抽离为切面,可以声明在类的方法上。像 @Transactional 注解,就是一个典型的 AOP 应用,它就是通过 AOP 来实现事务管理的。我们只需要在方法上添加 @Transactional 注解,Spring 就会在方法执行前后添加事务管理的逻辑。

② Spring AOP 是基于代理的,它默认使用 JDK 动态代理和 CGLIB 代理来实现 AOP。

③ Spring AOP 的织入方式是运行时织入,而 AspectJ 支持编译时织入、类加载时织入。

AOP和 OOP 的关系?

AOP 和 OOP 是互补的编程思想:

  1. OOP 通过类和对象封装数据和行为,专注于核心业务逻辑。
  2. AOP 提供了解决横切关注点(如日志、权限、事务等)的机制,将这些逻辑集中管理。
  1. Java 面试指南(付费)收录的腾讯 Java 后端实习一面原题:说说 AOP 的原理。
  2. Java 面试指南(付费)收录的小米 25 届日常实习一面原题:说说你对 AOP 和 IoC 的理解。
  3. Java 面试指南(付费)收录的快手面经同学 7 Java 后端技术一面面试原题:说一下 Spring AOP 的实现原理
  4. Java 面试指南(付费)收录的小公司面经合集同学 1 Java 后端面试原题:介绍 Spring IoC 和 AOP?
  5. Java 面试指南(付费)收录的招商银行面经同学 6 招银网络科技面试原题:SpringBoot框架的AOP、IOC/DI?
  6. Java 面试指南(付费)收录的美团面经同学 4 一面面试原题:Spring AOP发生在什么时候
  7. Java 面试指南(付费)收录的理想汽车面经同学 2 一面面试原题:Spring AOP的概念了解吗?AOP和 OOP 的关系?

21.🌟AOP的应用场景有哪些?

答:AOP 在实际工作/编码学习中有很多应用场景,我按照使用频率来说说几个主要的。

事务管理是用得最多的场景,基本上每个项目都会用到。只需要在 Service 方法上加个 @Transactional 注解,Spring 就会自动帮我们管理事务的开启、提交和回滚。

20251024164321
技术派源码:@Transactional事务

日志记录也是一个很常见的应用。在技术派实战项目中,就利用了 AOP 来打印接口的入参和出参日志、执行时间,方便后期 bug 溯源和性能调优。

20251024164332
沉默王二:技术派教程

-—这部分面试可以不背,方便大家理解 start—-

第一步,定义 @MdcDot 注解:

1
2
3
4
5
6
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MdcDot {
String bizCode() default "";
}

第二步,配置 MdcAspect 切面,拦截带有 @MdcDot 注解的方法或类,在方法执行前后进行 MDC 操作,记录方法执行耗时。

20251024164346
技术派项目:配置 AOP 切面

第三步,在需要的地方加上 @MdcDot 注解。

20251024164358
技术派项目:使用注解

第四步,当接口被调用时,就可以看到对应的执行日志。

1
2023-06-16 11:06:13,008 [http-nio-8080-exec-3] INFO |00000000.1686884772947.468581113|101|c.g.p.forum.core.mdc.MdcAspect.handle(MdcAspect.java:47) - 方法执行耗时: com.github.paicoding.forum.web.front.article.rest.ArticleRestController#recommend = 47

-—面试可以不背,方便大家理解 end—-

除此之外,还有权限控制、性能监控、缓存处理等场景。总的来说,任何需要在多个地方重复执行的通用逻辑,都可以考虑用 AOP 来实现。

  1. Java 面试指南(付费)收录的京东面经同学 5 Java 后端技术一面面试原题:AOP应用场景
  2. Java 面试指南(付费)收录的理想汽车面经同学 2 一面面试原题:AOP的使用场景有哪些?
  3. Java 面试指南(付费)收录的京东面经同学 9 面试原题:项目中的AOP是怎么用到的

24.🌟说说JDK动态代理和CGLIB代理的区别?

JDK 动态代理和 CGLIB 代理是 Spring AOP 用来创建代理对象的两种方式。

20251024164722
logbasex:JDK 动态代理和 CGLIB 代理

从使用条件来说,JDK 动态代理要求目标类必须实现至少一个接口,因为它是基于接口来创建代理的。而 CGLIB 代理不需要目标类实现接口,它是通过继承目标类来创建代理的。

这是两者最根本的区别。比如我们有一个 TransferService 接口和 TransferServiceImpl 实现类,如果用 JDK 动态代理,创建的代理对象会实现 TransferService 接口;

20251024164917
logbasex:JDK 动态代理

如果用 CGLIB,代理对象会继承 TransferServiceImpl 类。

20251024165053
logbasex:CGLIB 代理

从实现原理来说,JDK 动态代理是 Java 原生支持的,它通过反射机制在运行时动态创建一个实现了指定接口的代理类。当我们调用代理对象的方法时,会被转发到 InvocationHandler 的 invoke 方法中,我们可以在这个方法里插入切面逻辑,然后再通过反射调用目标对象的真实方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JdkProxyExample {
public static void main(String[] args) {
UserService target = new UserServiceImpl();

UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(proxy1, method, args1) -> {
System.out.println("Before method: " + method.getName());
Object result = method.invoke(target, args1);
System.out.println("After method: " + method.getName());
return result;
}
);

proxy.findUser(1L);
}
}

CGLIB 则是一个第三方的字节码生成库,它通过 ASM 字节码框架动态生成目标类的子类,然后重写父类的方法来插入切面逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CglibProxyExample {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserController.class);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("Before method: " + method.getName());
Object result = proxy.invokeSuper(obj, args);
System.out.println("After method: " + method.getName());
return result;
}
});

UserController proxy = (UserController) enhancer.create();
proxy.getUser(1L);
}
}

选择 CGLIB 还是 JDK 动态代理?

如果目标对象没有实现任何接口,就只能使用 CGLIB 代理,就比如说 Controller 层的类。

1
2
3
4
5
6
7
8
// 没有实现接口的Controller
@RestController
public class ArticleController {
@MdcDot(bizCode = "article.create")
public ResponseVo<String> create(@RequestBody ArticleReq req) {
// 业务逻辑
}
}

如果目标对象实现了接口,通常首选 JDK 动态代理,比如说 Service 层的类,一般都会先定义接口,再实现接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 接口定义
public interface ArticleService {
void saveArticle(Article article);
}

// 实现类
@Service
public class ArticleServiceImpl implements ArticleService {
@Transactional(rollbackFor = Exception.class)
@Override
public void saveArticle(Article article) {
// 业务逻辑
}
}

在 Spring Boot 2.0 之后,Spring AOP 默认使用 CGLIB 代理。这是因为 Spring Boot 作为一个追求“约定优于配置”的框架,选择 CGLIB,可以简化开发者的心智负担,避免因为忘记实现接口而导致 AOP 不生效的问题。

20251024165224
技术派源码:AopAutoConfiguration

你会用 JDK 动态代理吗?

会的。

假设我们有这样一个小场景,客服中转,解决用户问题:

20251024165235
三分恶面渣逆袭:用户向客服提问题

我们可以用 JDK 动态代理来实现这个场景。JDK 动态代理的核心是通过反射机制在运行时创建一个实现了指定接口的代理类。

20251024165247
三分恶面渣逆袭:JDK动态代理类图

第一步,创建接口。

1
2
3
public interface ISolver {
void solve();
}

第二步,实现接口。

1
2
3
4
5
6
public class Solver implements ISolver {
@Override
public void solve() {
System.out.println("疯狂掉头发解决问题……");
}
}

第三步,使用用反射生成目标对象的代理,这里用了一个匿名内部类方式重写 InvocationHandler 方法。

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
public class ProxyFactory {

// 维护一个目标对象
private Object target;

public ProxyFactory(Object target) {
this.target = target;
}

// 为目标对象生成代理对象
public Object getProxyInstance() {
return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("请问有什么可以帮到您?");

// 调用目标对象方法
Object returnValue = method.invoke(target, args);

System.out.println("问题已经解决啦!");
return null;
}
});
}
}

第四步,生成一个代理对象实例,通过代理对象调用目标对象方法。

1
2
3
4
5
6
7
8
9
10
public class Client {
public static void main(String[] args) {
//目标对象:程序员
ISolver developer = new Solver();
//代理:客服小姐姐
ISolver csProxy = (ISolver) new ProxyFactory(developer).getProxyInstance();
//目标方法:解决问题
csProxy.solve();
}
}

你会用 CGLIB 动态代理吗?

会的。

20251024165344
三分恶面渣逆袭:CGLIB动态代理类图

第一步:定义目标类 Solver,定义 solve 方法,模拟解决问题的行为。目标类不需要实现任何接口,这与 JDK 动态代理的要求不同。

1
2
3
4
5
6
public class Solver {

public void solve() {
System.out.println("疯狂掉头发解决问题……");
}
}

第二步:创建代理工厂 ProxyFactory,使用 CGLIB 的 Enhancer 类来生成目标类的子类(代理对象)。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
public class ProxyFactory implements MethodInterceptor {

//维护一个目标对象
private Object target;

public ProxyFactory(Object target) {
this.target = target;
}

//为目标对象生成代理对象
public Object getProxyInstance() {
//工具类
Enhancer en = new Enhancer();
//设置父类
en.setSuperclass(target.getClass());
//设置回调函数
en.setCallback(this);
//创建子类对象代理
return en.create();
}

@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("请问有什么可以帮到您?");
// 执行目标对象的方法
Object returnValue = method.invoke(target, args);
System.out.println("问题已经解决啦!");
return null;
}

}

第三步:创建客户端 Client,获取代理对象并调用目标方法。

1
2
3
4
5
6
7
8
9
10
public class Client {
public static void main(String[] args) {
//目标对象:程序员
Solver developer = new Solver();
//代理:客服小姐姐
Solver csProxy = (Solver) new ProxyFactory(developer).getProxyInstance();
//目标方法:解决问题
csProxy.solve();
}
}
  1. Java 面试指南(付费)收录的帆软同学 3 Java 后端一面的原题:cglib 的原理
  2. Java 面试指南(付费)收录的腾讯面经同学 22 暑期实习一面面试原题:Spring AOP 实现原理
  3. Java 面试指南(付费)收录的小米面经同学 F 面试原题:两种动态代理的区别
  4. Java 面试指南(付费)收录的字节跳动面经同学 8 Java 后端实习一面面试原题:spring的aop是如何实现的
  5. Java 面试指南(付费)收录的腾讯云智面经同学 20 二面面试原题:spring aop的底层原理是什么?
  6. Java 面试指南(付费)收录的美团面经同学 3 Java 后端技术一面面试原题:java的反射机制,反射的应用场景AOP的实现原理是什么,与动态代理和反射有什么区别
  7. Java 面试指南(付费)收录的比亚迪面经同学 12 Java 技术面试原题:代理介绍一下,jdk和cglib的区别
  8. Java 面试指南(付费)收录的快手同学 4 一面原题:Spring AOP的实现原理?JDK动态代理和CGLib动态代理的各自实现及其区别?现在需要统计方法的具体执行时间,说下如何使用AOP来实现?
  9. Java 面试指南(付费)收录的理想汽车面经同学 2 一面面试原题:了解AOP底层是怎么做的吗?

25.🌟说说你对Spring事务的理解?

Spring 提供了两种事务管理方式,编程式事务和声明式事务。编程式事务就是我们要手动调用事务的开始、提交、回滚这些操作,虽然灵活但是代码比较繁琐。声明式事务只需要在需要事务的方法上加上 @Transactional 注解就好了,Spring 会帮我们自动处理事务的整个生命周期。

20251024184234
Spring TransactionInterceptor

-—这部分可以不背,方便大家理解 start—-

编程式事务可以使用 TransactionTemplate 和 PlatformTransactionManager 来实现,允许我们在代码中直接控制事务的边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class AccountService {
private TransactionTemplate transactionTemplate;

public void setTransactionTemplate(TransactionTemplate transactionTemplate) {
this.transactionTemplate = transactionTemplate;
}

public void transfer(final String out, final String in, final Double money) {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
// 转出
accountDao.outMoney(out, money);
// 转入
accountDao.inMoney(in, money);
}
});
}
}

-—这部分可以不背,方便大家理解 end—-

Spring 事务的底层实现是通过 AOP 来完成的。当我们在方法上加 @Transactional 注解后,Spring 会为这个 Bean 创建代理对象,在方法执行前开启事务,方法正常返回时提交事务,如果方法抛出异常就回滚事务。

声明式事务的优点是不需要在业务逻辑代码中掺杂事务管理的代码,缺点是,最细粒度只能到方法级别,无法到代码块级别。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class AccountService {
@Autowired
private AccountDao accountDao;

@Transactional
public void transfer(String out, String in, Double money) {
// 转出
accountDao.outMoney(out, money);
// 转入
accountDao.inMoney(in, money);
}
}
  1. Java 面试指南(付费)收录的京东同学 10 后端实习一面的原题:Spring 事务怎么实现的
  2. Java 面试指南(付费)收录的农业银行面经同学 7 Java 后端面试原题:Spring 如何保证事务
  3. Java 面试指南(付费)收录的比亚迪面经同学 12 Java 技术面试原题:Spring的事务用过吗,在项目里面怎么使用的
  4. Java 面试指南(付费)收录的虾皮面经同学 13 一面面试原题:spring事务
  5. Java 面试指南(付费)收录的阿里云面经同学 22 面经:如何使用spring实现事务

29.🌟说说Spring的事务传播机制?

简单来说,当一个事务方法 A 调用另一个事务方法 B 时,方法 B 的事务应该如何运行?是加入方法 A 的现有事务,还是开启一个新事务,或者以非事务方式运行?这就是事务传播机制要解决的问题。

Spring 定义了七种事务传播行为,其中 REQUIRED 是默认的传播行为,表示如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。

20251024184604
三分恶面渣逆袭:事务传播机制

比如说在技术派实战项目中,一个用户解锁付费文章的操作,会涉及到创建支付订单、更新订单状态等好几个数据库操作。

20251024184803
技术派源码:Spring事务传播机制

这些不同操作的方法就可以放在一个 @Transactional 注解的方法里,它们就自动在同一个事务里了,要么一起成功,要么一起失败。

当然,还有一些特殊情况。比如,我们希望记录一些操作日志,但不想因为主业务失败导致日志回滚。这时候 REQUIRES_NEW 就派上用场了。它不管当前有没有事务,都重新开启一个全新的、独立的事务来执行。这样,日志保存的事务和主业务的事务就互不干扰,即使主业务失败回滚,日志也能妥妥地保存下来。

另外,还有像 SUPPORTS、 NOT_SUPPORTED 这些。SUPPORTS 比较佛系,有事务就用,没事务就不用,适合一些不重要的更新操作。而 NOT_SUPPORTED 则更干脆,它会把当前的事务挂起,以非事务的方式去执行。比如说我们的事务里需要调用一个第三方的、响应很慢的接口,如果这个调用也包含在事务里,就会长时间占用数据库连接。把它用 NOT_SUPPORTED 包起来,就可以避免这个问题。

1
2
3
4
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void callExternalApi() {
// 调用第三方接口
}

最后还有一个比较特殊的 NESTED,嵌套事务。它有点像 REQUIRES_NEW,但又不完全一样。NESTED 是父事务的一个子事务,父事务回滚,它肯定也得回滚。但它自己回滚,却不会影响到父事务。这个特性在处理一些批量操作,希望能部分回滚的场景下特别有用。不过它需要数据库支持 Savepoint 功能,MySQL 就支持。

事务能在新线程中传播吗?

事务传播机制是通过 ThreadLocal 实现的,所以,如果调用的方法是在新线程中,事务传播就会失效。

1
2
3
4
5
6
7
8
@Transactional
public void parentMethod() {
new Thread(() -> childMethod()).start();
}

public void childMethod() {
// 这里的操作将不会在 parentMethod 的事务范围内执行
}

protected 和 private 方法加事务会生效吗?

我的理解是:在 private 方法上加事务是肯定不会生效的,而 protected 方法在特定的代理模式下是可能生效的,但这两种用法都应该避免,不是推荐的使用方式。

这背后涉及到 Spring AOP 的代理机制。

我先说一下 JDK 动态代理,它要求目标类必须实现一个或者多个接口。也就意味着代理只能拦截接口中声明的方法,而 protected 和 private 方法并不能在接口中声明,因此在 JDK 动态代理下,这些方法的事务注解是会被直接忽略的。

那 Spring Boot 2.0 之后,Spring AOP 默认使用的是 CGLIB 代理。CGLIB 代理是通过继承目标类来创建代理对象的。

那对于 private 方法来说,由于无法被子类重写,所以 CGLIB 代理也无法拦截,事务也就无法生效。对于 protected 方法来说,因为它可以被子类重写,所以理论上事务是生效的。

-—这部分可以不背,方便大家理解 start—-

我们创建一个 protected 方法,名为 protectedTransactionalMethod ,它被 @Transactional 注解标记。这个方法会先向数据库中插入一条记录(一个 TestEntity 实例)。紧接着,它会立即抛出一个 RuntimeException 。

20251024185126
派聪明源码:测试 protected 方法的事务是否生效

  • 如果事务生效:当 RuntimeException 抛出时,Spring 的事务管理器会捕获它,并触发事务回滚。这意味着,之前插入数据库的那条记录会被撤销。最终,数据库里不会留下这条记录。
  • 如果事务失效:即使 RuntimeException 被抛出,由于没有事务管理,已经执行的数据库插入操作不会被撤销。最终,数据库里会留下这条记录。

我们创建了一个 public 方法 testProtectedTransaction ,它通过 this.protectedTransactionalMethod() 的方式直接调用了那个 protected 方法。接着我们访问 /api/v1/test/transaction/protected 来触发这个调用。

结果:数据库中会留下一条名为 ‘test-protected’ 的记录。这证明了由于是内部调用,绕过了 Spring AOP 代理,@Transactional 注解没有生效。

我们创建了另一个 public 方法 testProtectedTransactionWithSelfProxy。在这个方法里,我们通过一个“自注入”的代理对象 self 来调用 self.protectedTransactionalMethod()。接着我们通过访问 /api/v1/test/transaction/protected/proxy 来触发这个调用。

结果:数据库中不会留下名为 ‘test-protected-proxy’ 的记录。这证明通过代理对象的调用,Spring AOP 成功拦截并开启了事务,最终在异常发生时正确地回滚了事务。

派聪明源码:protected 方法的事务生效结果

派聪明源码:protected 方法的事务生效结果

-—这部分可以不背,方便大家理解 end—-

  1. Java 面试指南(付费)收录的京东同学 10 后端实习一面的原题:事务的传播机制
  2. Java 面试指南(付费)收录的小米春招同学 K 一面面试原题:事务传播,protected 和 private 加事务会生效吗,还有那些不生效的情况
  3. Java 面试指南(付费)收录的华为面经同学 8 技术二面面试原题:Spring 中的事务的隔离级别,事务的传播行为?
  4. Java 面试指南(付费)收录的oppo 面经同学 8 后端开发秋招一面面试原题:讲一下Spring事务传播机制
  5. Java 面试指南(付费)收录的阿里云面经同学 22 面经:介绍事务传播模型

31.🌟Spring MVC 的工作流程了解吗?

简单来说,Spring MVC 是一个基于 Servlet 的请求处理框架,核心流程可以概括为:请求接收 → 路由分发 → 控制器处理 → 视图解析。

20251024185306
三分恶面渣逆袭:Spring MVC的工作流程

20251024185314
图片来源于网络:SpringMVC工作流程图

20251024185323
_未来可期:SpringMVC工作流程图

用户发起的 HTTP 请求,首先会被 DispatcherServlet 捕获,这是 Spring MVC 的“前端控制器”,负责拦截所有请求,起到统一入口的作用。

DispatcherServlet 接收到请求后,会根据 URL、请求方法等信息,交给 HandlerMapping 进行路由匹配,查找对应的处理器,也就是 Controller 中的具体方法。

20251024185332
技术派源码:Controller

找到对应 Controller 方法后,DispatcherServlet 会委托给处理器适配器 HandlerAdapter 进行调用。处理器适配器负责执行方法本身,并处理参数绑定、数据类型转换等。在注解驱动开发中,常用的是 RequestMappingHandlerAdapter。这一层会把请求参数自动注入到方法形参中,并调用 Controller 执行实际的业务逻辑。

20251024185702
技术派源码:RequestMappingHandlerAdapter

Controller 方法最终会返回结果,比如视图名称、ModelAndView 或直接返回 JSON 数据。

当 Controller 方法返回视图名时,DispatcherServlet 会调用 ViewResolver 将其解析为实际的 View 对象,比如 Thymeleaf 页面。在前后端分离的接口项目中,这一步则通常是返回 JSON 数据。

最后,由 View 对象完成渲染,或者将 JSON 结果直接通过 DispatcherServlet 返回给客户端。

为什么还需要 HandlerAdapter?

Spring MVC 支持多种风格的处理器,比如基于 @Controller 注解的处理器、实现了 Controller 接口的处理器等。如果没有处理器适配器,DispatcherServlet 就需要硬编码每种处理器的调用方式,框架就会变得非常僵硬——新增一种 Controller 类型,就必须改 DispatcherServlet 的代码。

因此,Spring 引入了 HandlerAdapter 作为适配器,屏蔽不同控制器的差异,给 DispatcherServlet 提供一个统一的调用入口。

比如说,如果是实现了 Controller 接口的处理器,DispatcherServlet 会使用 SimpleControllerHandlerAdapter 来适配它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SimpleControllerHandlerAdapter implements HandlerAdapter {

@Override
public boolean supports(Object handler) {
return (handler instanceof Controller);
}

@Override
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {

return ((Controller) handler).handleRequest(request, response);
}

// ... 省略一个无关方法 ...
}

如果是使用 @RequestMapping 注解的处理器,DispatcherServlet 则会使用 RequestMappingHandlerAdapter 来适配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RequestMappingHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof HandlerMethod);
}
@Override
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 执行方法并返回 ModelAndView
return invokeHandlerMethod(handlerMethod, request, response);
}
}
  1. Java 面试指南(付费)收录的腾讯 Java 后端实习一面原题:说说前端发起请求到 SpringMVC 的整个处理流程。
  2. Java 面试指南(付费)收录的国企面试原题:说说 SpringMVC 的流程吧
  3. Java 面试指南(付费)收录的小公司面经同学 5 Java 后端面试原题:springMVC 工作流程 我大概就是按面渣逆袭里答的,答到一半打断我:然后会有个 Handler,这个 Handler 是什么东西啊。前面 Handler 不是已经知道 controller 了吗,我直接执行不就行了,为什么还要 Adapter 呢。
  4. Java 面试指南(付费)收录的京东面经同学 8 面试原题:SpringMVC框架
  5. Java 面试指南(付费)收录的字节跳动同学 17 后端技术面试原题:springmvc执行流程

33.🌟介绍一下 SpringBoot?

Spring Boot 可以说是 Spring 生态的一个重大突破,它极大地简化了 Spring 应用的开发和部署过程。

20251024190129
SpringBoot图标

以前我们用 Spring 开发项目的时候,需要配置一大堆 XML 文件,包括 Bean 的定义、数据源配置、事务配置等等,非常繁琐。而且还要手动管理各种 jar 包的依赖关系,很容易出现版本冲突的问题。部署的时候还要单独搭建 Tomcat 服务器,整个过程很复杂。Spring Boot 就是为了解决这些痛点而生的。

“约定大于配置”是 Spring Boot 最核心的理念。它预设了很多默认配置,比如默认使用内嵌的 Tomcat 服务器,默认的日志框架是 Logback 等等。这样,我们开发者就只需要关注业务逻辑,不用再纠结于各种配置细节。

自动装配也是 Spring Boot 的一大特色,它会根据项目中引入的依赖自动配置合适的 Bean。比如说,我们引入了 Spring Data JPA,Spring Boot 就会自动配置数据源;比如说,我们引入了 Spring Security,Spring Boot 就会自动配置安全相关的 Bean。

Spring Boot 还提供了很多开箱即用的功能,比如 Actuator 监控、DevTools 开发工具、Spring Boot Starter 等等。Actuator 可以让我们轻松监控应用的健康状态、性能指标等;DevTools 可以加快开发效率,比如自动重启、热部署等;Spring Boot Starter 则是一些预配置好的依赖集合,让我们可以快速引入某些常用的功能。

Spring Boot常用注解有哪些?

Spring Boot 的注解很多,我就挑两个说一下吧。

  • @SpringBootApplication:这是 Spring Boot 的核心注解,它是一个组合注解,包含了 @Configuration@EnableAutoConfiguration@ComponentScan。它标志着一个 Spring Boot 应用的入口。
  • @SpringBootTest:用于测试 Spring Boot 应用的注解,它会加载整个 Spring 上下文,适合集成测试。
  1. Java 面试指南(付费)收录的华为 OD 面经中出现过该题:讲讲 Spring Boot 的特性。
  2. Java 面试指南(付费)收录的百度面经同学 1 文心一言 25 实习 Java 后端面试原题:SpringBoot基本原理
  3. Java 面试指南(付费)收录的国企零碎面经同学 9 面试原题:Springboot基于Spring的配置有哪几种
  4. Java 面试指南(付费)收录的阿里云面经同学 22 面经:springboot常用注解

34.🌟Spring Boot的自动装配原理了解吗?

在 Spring Boot 中,开启自动装配的注解是@EnableAutoConfiguration。这个注解会告诉 Spring 去扫描所有可用的自动配置类。

20251024190137
二哥的 Java 进阶之路:@EnableAutoConfiguration 源码

Spring Boot 为了进一步简化,把这个注解包含到了 @SpringBootApplication 注解中。也就是说,当我们在主类上使用 @SpringBootApplication 注解时,实际上就已经开启了自动装配。

当 main 方法运行的时候,Spring 会去类路径下找 spring.factories 这个文件,读取里面配置的自动配置类列表。比如在我们的技术派项目中,paicoding-core 和 paicoding-service 模块里都有 spring.factories,分别注册了 ForumCoreAutoConfig 和 ServiceAutoConfig,这两个配置类就会在项目启动的时候被自动加载。

20251024190147
技术派源码:spring.factories

然后每个自动配置类内部,通常会有一个 @Configuration 注解,同时结合各种 @Conditional 注解来做条件控制。像技术派的 RabbitMqAutoConfig 类,就用了 @ConditionalOnProperty 注解来判断配置文件里有没有开启 rabbitmq.switchFlag,来决定是否初始化 RabbitMQ 消费线程。

20251024190156
技术派源码:RabbitMqAutoConfig

另外一个常见的场景是自动注入 Bean,比如技术派的 ServiceAutoConfig 中就用了 @ComponentScan 来扫描 service 包,@MapperScan 扫描 MyBatis 的 mapper 接口,实现业务层和 DAO 层的自动装配。

具体的执行过程可以总结为:Spring Boot 项目在启动时加载所有的自动配置类,然后逐个检查它们的生效条件,当条件满足时就实例化并创建相应的 Bean。

20251024190207
三分恶面渣逆袭:Spring Boot的自动装配原理

自动装配的执行时机是在 Spring 容器启动的时候。具体来说是在 ConfigurationClassPostProcessor 这个 BeanPostProcessor 中处理的,它会解析 @Configuration 类,包括通过 @Import 导入的自动配置类。

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
protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
// 检查自动配置是否启用。如果@ConditionalOnClass等条件注解使得自动配置不适用于当前环境,则返回一个空的配置条目。
if (!isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
}

// 获取启动类上的@EnableAutoConfiguration注解的属性,这可能包括对特定自动配置类的排除。
AnnotationAttributes attributes = getAttributes(annotationMetadata);

// 从spring.factories中获取所有候选的自动配置类。这是通过加载META-INF/spring.factories文件中对应的条目来实现的。
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);

// 移除配置列表中的重复项,确保每个自动配置类只被考虑一次。
configurations = removeDuplicates(configurations);

// 根据注解属性解析出需要排除的自动配置类。
Set<String> exclusions = getExclusions(annotationMetadata, attributes);

// 检查排除的类是否存在于候选配置中,如果存在,则抛出异常。
checkExcludedClasses(configurations, exclusions);

// 从候选配置中移除排除的类。
configurations.removeAll(exclusions);

// 应用过滤器进一步筛选自动配置类。过滤器可能基于条件注解如@ConditionalOnBean等来排除特定的配置类。
configurations = getConfigurationClassFilter().filter(configurations);

// 触发自动配置导入事件,允许监听器对自动配置过程进行干预。
fireAutoConfigurationImportEvents(configurations, exclusions);

// 创建并返回一个包含最终确定的自动配置类和排除的配置类的AutoConfigurationEntry对象。
return new AutoConfigurationEntry(configurations, exclusions);
}
  1. Java 面试指南(付费)收录的滴滴同学 2 技术二面的原题:SpringBoot 启动时为什么能够自动装配
  2. Java 面试指南(付费)收录的腾讯面经同学 22 暑期实习一面面试原题:Spring Boot 如何做到启动的时候注入一些 bean
  3. Java 面试指南(付费)收录的比亚迪面经同学 3 Java 技术一面面试原题:说一下 Spring Boot 的自动装配原理
  4. Java 面试指南(付费)收录的农业银行同学 1 面试原题:spring boot 的自动装配
  5. Java 面试指南(付费)收录的百度面经同学 1 文心一言 25 实习 Java 后端面试原题:SpringBoot如何实现自动装配
  6. Java 面试指南(付费)收录的 OPPO 面经同学 1 面试原题:自动配置怎么实现的?

35.🌟如何自定义一个 SpringBoot Starter?

第一步,SpringBoot 官方建议第三方 starter 的命名格式是 xxx-spring-boot-starter,所以我们可以创建一个名为 my-spring-boot-starter 的项目,一共包括两个模块,一个是 autoconfigure 模块,包含自动配置逻辑;一个是 starter 模块,只包含依赖声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
<spring.boot.version>2.3.1.RELEASE</spring.boot.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${spring.boot.version}</version>
</dependency>
</dependencies>

第二步,创建一个自动配置类,通常在 autoconfigure 包下,该类的作用是根据配置文件中的属性来创建和配置 Bean。

1
2
3
4
5
6
7
8
9
10
@Configuration
@EnableConfigurationProperties(MyStarterProperties.class)
public class MyServiceAutoConfiguration {

@Bean
@ConditionalOnMissingBean
public MyService myService(MyStarterProperties properties) {
return new MyService(properties.getMessage());
}
}

第三步,创建一个配置属性类,用于读取配置文件中的属性。通常使用 @ConfigurationProperties 注解来标记这个类。

1
2
3
4
5
6
7
8
9
10
11
12
@ConfigurationProperties(prefix = "mystarter")
public class MyStarterProperties {
private String message = "二哥的 Java 进阶之路不错啊!";

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}
}

第四步,创建一个简单的服务类,用于提供业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
public class MyService {
private final String message;

public MyService(String message) {
this.message = message;
}

public String getMessage() {
return message;
}
}

第五步,在 src/main/resources/META-INF 目录下创建一个名为 spring.factories 文件,告诉 SpringBoot 在启动时要加载我们的自动配置类。

1
2
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.itwanger.mystarter.autoconfigure.MyServiceAutoConfiguration

第六步,使用 Maven 打包这个项目。

1
mvn clean install

第七步,在其他的 Spring Boot 项目中,通过 Maven 来添加这个自定义的 Starter 依赖,并通过 application.properties 配置信息:

1
mystarter.message=javabetter.cn

然后就可以在 Spring Boot 项目中注入 MyStarterProperties 来使用它。

20251024190258
MyStarterProperties 注入示例

启动项目,然后在浏览器中输入 localhost:8081/hello,就可以看到返回的内容是 javabetter.cn,说明我们的自定义 Starter 已经成功工作了。

20251024190310
二哥的 Java 进阶之路:自定义 Spring Boot Stater

Spring Boot Starter 的原理了解吗?

Starter 的核心思想是把相关的依赖打包在一起,让开发者只需要引入一个 starter 依赖,就能获得完整的功能模块。

当我们在 pom.xml 中引入一个 starter 时,Maven 就会自动解析这个 starter 的依赖树,把所有需要的 jar 包都下载下来。

每个 starter 都会包含对应的自动配置类,这些配置类通过条件注解来判断是否应该生效。比如当我们引入了 spring-boot-starter-web,它会自动配置 Spring MVC、内嵌的 Tomcat 服务器等。

spring.factories 文件是 Spring Boot 自动装配的核心,它位于每个 starter 的 META-INF 目录下。这个文件列出了所有的自动配置类,Spring Boot 在启动时会读取这个文件,加载对应的配置类。

1
2
3
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.demo.autoconfigure.DemoAutoConfiguration,\
com.example.demo.autoconfigure.AnotherAutoConfiguration
  1. Java 面试指南(付费)收录的字节跳动面经同学 1 Java 后端技术一面面试原题:你封装过 springboot starter 吗?
  2. Java 面试指南(付费)收录的腾讯云智面经同学 20 二面面试原题:Spring Boot Starter 的原理了解吗?
  3. Java 面试指南(付费)收录的快手同学 4 一面原题:为什么使用SpringBoot?SpringBoot自动装配的原理及流程?@Import的作用?如果想让SpringBoot对自定义的jar包进行自动配置的话,需要怎么做?

36.🌟Spring Boot 启动原理了解吗?

Spring Boot 的启动主要围绕两个核心展开,一个是 @SpringBootApplication 注解,一个是 SpringApplication.run() 方法。

20251024190348
SpringBoot 启动大致流程-图片来源网络

我先说一下 @SpringBootApplication 注解,它是一个组合注解,包含了 @SpringBootConfiguration@EnableAutoConfiguration@ComponentScan,这三个注解的作用分别是:

  • @SpringBootConfiguration:标记这个类是一个 Spring Boot 配置类,相当于一个 Spring 配置文件。
  • @EnableAutoConfiguration:告诉 Spring Boot 可以进行自动配置。比如说,项目引入了 Spring MVC 的依赖,那么 Spring Boot 就会自动配置 DispatcherServlet、HandlerMapping 等组件。
  • @ComponentScan:扫描当前包及其子包下的组件,注册为 Bean。

20251024190356
派聪明源码:启动类

好,接下来我再说一下 SpringApplication.run() 方法,它是 Spring Boot 项目的启动入口,内部流程大致可以分为 5 个步骤:

①、创建 SpringApplication 实例,并识别应用类型,比如说是标准的 Servlet Web 还是响应式的 WebFlux,然后准备监听器和初始化监听容器。

②、创建并准备 ApplicationContext,将主类作为配置源进行加载。

③、刷新 Spring 上下文,触发 Bean 的实例化,比如说扫描并注册 @ComponentScan 指定路径下的 Bean。

④、触发自动配置,在 Spring Boot 2.7 及之前是通过 spring.factories 加载的,3.x 是通过读取 AutoConfiguration.imports,并结合 @ConditionalOn 系列注解依据条件注册 Bean。

⑤、如果引入了 Web 相关依赖,会创建并启动 Tomcat 容器,完成 HTTP 端口监听。

关键的代码逻辑如下:

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
public ConfigurableApplicationContext run(String... args) {
// 1. 创建启动时的监听器并触发启动事件
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting();

// 2. 准备运行环境
ConfigurableEnvironment environment = prepareEnvironment(listeners);
configureIgnoreBeanInfo(environment);

// 3. 创建上下文
ConfigurableApplicationContext context = createApplicationContext();

try {
// 4. 准备上下文
prepareContext(context, environment, listeners, args);

// 5. 刷新上下文,完成 Bean 初始化和装配
refreshContext(context);

// 6. 调用运行器
afterRefresh(context, args);

// 7. 触发启动完成事件
listeners.started(context);
} catch (Exception ex) {
handleRunFailure(context, ex, listeners);
}

return context;
}

要在启动阶段自定义逻辑该怎么做?

可以通过实现 ApplicationRunner 接口来完成启动后的自定义逻辑。

比如说在技术派项目中,我们就在 run 方法中追加了:JSON 类型转换配置和动态设置应用访问地址等。

20251024190407
技术派源码:启动后添加自定义逻辑

为什么 Spring Boot 在启动的时候能够找到 main 方法上的@SpringBootApplication 注解?

其实 Spring Boot 并不是自己找到 @SpringBootApplication 注解的,而是我们通过程序告诉它的。

1
2
3
4
5
6
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}

我们把 Application.class 作为参数传给了 run 方法。这个 Application 类标注了 @SpringBootApplication 注解,用来告诉 Spring Boot:请用这个类作为配置类来启动。

然后,SpringApplication 在运行时就会把这个类注册到 Spring 容器中。

Spring Boot 默认的包扫描路径是什么?

Spring Boot 默认的包扫描路径是主类所在的包及其子包。

比如说在技术派实战项目中,启动类QuickForumApplication所在的包是com.github.paicoding.forum.web,那么 Spring Boot 默认会扫描com.github.paicoding.forum.web包及其子包下的所有组件。

20251024190421
沉默王二:技术派项目截图

  1. Java 面试指南(付费)收录的滴滴同学 2 技术二面的原题:为什么 Spring Boot 启动时能找到 Main 类上面的注解
  2. Java 面试指南(付费)收录的腾讯面经同学 22 暑期实习一面面试原题:Spring Boot 默认的包扫描路径?
  3. Java 面试指南(付费)收录的微众银行同学 1 Java 后端一面的原题:@SpringBootApplication 注解了解吗?
  4. Java 面试指南(付费)收录的国企零碎面经同学 9 面试原题:Springboot的工作原理?
  5. Java 面试指南(付费)收录的京东面经同学 5 Java 后端技术一面面试原题:SpringBoot启动流程(忘了)
  6. Java 面试指南(付费)收录的哔哩哔哩同学 1 二面面试原题:springBoot启动机制,启动之后做了哪些步骤

操作系统

详见操作系统学习笔记

计算机网络

🌟18.说说 HTTP 与 HTTPS 有哪些区别?

HTTPSHTTP 的增强版,在 HTTP 的基础上加入了 SSL/TLS 协议,确保数据在传输过程中是加密的

20251025143412

HTTP 的默认端⼝号是 80URLhttp://开头;
HTTPS 的默认端⼝号是 443URLhttps://开头。

🌟25.TCP 握手为什么是三次,为什么不能是两次?不能是四次?

使用三次握手可以建立一个可靠的连接。这一过程的目的是确保双方都知道对方已准备好进行通信,并同步双方的序列号,从而保持数据包的顺序和完整性。

为什么 TCP 握手不能是两次?

  • 为了防止服务器一直等,等到黄花菜都凉了
  • 为了防止客户端已经失效的连接请求突然又传送到了服务器

要知道,网络传输是有延时的(要通过网络光纤、WIFI、卫星信号传输等)。

假如说客户端发起了 SYN=1 的第一次握手。服务器也及时回复了 SYN=2 和 ACK=1 的第二次握手,但是这个 ACK=1 的确认报文段因为某些原因在传输过程中丢失了。

如果没有第三次握手告诉服务器,客户端收到了服务器的回应,那服务器是不知道客户端有没有接收到的。

于是服务器就一直干巴巴地开着端口在等着客户端发消息呢,但其实客户端并没有收到服务器的回应,心灰意冷地跑了。

20251025143839

还有一种情况是,一个旧的、延迟的连接请求(SYN=1)被服务器接受,导致服务器错误地开启一个不再需要的连接。

20250715112359

举个例子:假设你(客户端)给你的朋友(服务器)发送了一个邮件(连接请求)。因为某些原因,这封邮件迟迟没有到达朋友那里,可能是因为邮局的延误。于是你决定再发一封新的邮件。朋友收到了第二封邮件,你们成功地建立了连接并开始通信。

但是,过了很久,那封延误的旧邮件突然也到了你朋友那里。如果没有一种机制来识别和处理这种延误的邮件,你的朋友可能会以为这是一个新的连接请求,并尝试响应它,但其实你已经重新发了请求,原来的不需要了。这就导致了不必要的混乱和资源浪费。

所以我们需要“三次握手”来确认这个过程:

第一次握手:客户端发送 SYN 包(连接请求)给服务器,如果这个包延迟了,客户端不会一直等待,它可能会重试并发送一个新的连接请求
第二次握手:服务器收到 SYN 包后,发送一个 SYN-ACK 包(确认接收到连接请求)回客户端。
第三次握手:客户端收到 SYN-ACK 包后,再发送一个 ACK 包给服务器,确认收到了服务器的响应。

为什么不是四次?

三次握手已经足够创建可靠的连接了,没有必要再多一次握手。

什么是泛洪攻击?

泛洪攻击(SYN Flood Attack)是一种常见的 DoS(拒绝服务)攻击,攻击者会发送大量的伪造的 TCP 连接请求,导致服务器资源耗尽,无法处理正常的连接请求。

半连接服务拒绝,也称为 SYN 洪泛攻击或 SYN Flood。

所谓的半连接就是指在 TCP 的三次握手过程中,当服务器接收到来自客户端的第一个 SYN 包后,它会回复一个 SYN-ACK 包,此时连接处于“半开”状态,因为连接的建立还需要客户端发送最后一个 ACK 包。

在收到最后的 ACK 包之前,服务器会为这个尚未完成的连接分配一定的资源,并在它的队列中保留这个连接的位置。

如果让你重新设计,怎么设计?

如果重新设计 TCP 的连接建立过程,可以考虑引入 SYN cookies,这种技术通过在 SYN-ACK 响应中编码连接信息,从而在不占用大量资源的情况下验证客户端。

🌟30.说说 TCP 四次挥手的过程?

TCP 连接的断开过程被形象地概括为四次挥手

20251025144223

第一次挥手:客户端向服务器发送一个 FIN 结束报文,表示客户端没有数据要发送了,但仍然可以接收数据。客户端进入 FIN-WAIT-1 状态。

第二次挥手:服务器接收到 FIN 报文后,向客户端发送一个 ACK 报文,确认已接收到客户端的 FIN 请求。服务器进入 CLOSE-WAIT 状态,客户端进入 FIN-WAIT-2 状态。

第三次挥手:服务器向客户端发送一个 FIN 报文,表示服务器也没有数据要发送了。服务器进入 LAST-ACK 状态。

第四次挥手:客户端接收到 FIN 报文后,向服务器发送一个 ACK 报文,确认已接收到服务器的 FIN 请求。客户端进入 TIME-WAIT 状态,等待一段时间以确保服务器接收到 ACK 报文。服务器接收到 ACK 报文后进入 CLOSED 状态。客户端在等待一段时间后也进入 CLOSED 状态。

🌟31.TCP 挥手为什么需要四次呢?

因为 TCP 是全双工通信协议,数据的发送和接收需要两次一来一回,也就是四次,来确保双方都能正确关闭连接。

20250716110915

  1. 第一次挥手:客户端表示数据发送完成了,准备关闭,你确认一下。
  2. 第二次挥手:服务端回话说 ok,我马上处理完数据,稍等。
  3. 第三次挥手:服务端表示处理完了,可以关闭了。
  4. 第四次挥手:客户端说好,进入 TIME_WAIT 状态,确保服务端关闭连接后,自己再关闭连接。