常见八股
八股学习笔记
1. Java基础
1.1 Java基础详解
Java的特点?
Write Once, Run Anywhere(一次编写,随处运行)
- 面向对象(封装,继承,多态)
- 平台无关性(Write Once, Run Anywhere):Java 程序编译后生成的是字节码(.class 文件),可以在任何安装了 Java 虚拟机(JVM)的系统上运行,而不需要重新编译。(一次编译,处处运行)
- 自动内存管理:Java 有自己的垃圾回收机制(GC),程序员无需手动管理内存分配和释放,降低内存泄漏风险
- 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持)
JVM & JDK & JRE 对比?
| 名称 | 全称 | 作用 | 是否包含其他部分 |
|---|---|---|---|
| JVM | Java Virtual Machine | 执行 Java 字节码 | 是 JRE 的一部分 |
| JRE | Java Runtime Environment | Java 程序的运行环境 | 包含 JVM + 核心类库 |
| JDK | Java Development Kit | Java 的开发工具包 | 包含 JRE + 编译工具等 |
- JDK 包含 JRE,JRE 包含 JVM;
- JVM 负责运行,JRE 提供环境;
- JDK 除了运行,还有编译功能。
Java的编译与解释?
- 在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。
- 因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。
Java 和 C++ 的区别?
Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是,它们还是有挺多不相同的地方:
- Java 不提供指针来直接访问内存,程序内存更加安全
- Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承
- Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存
- C++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。
什么是包装类型的缓存机制?
Java 会对某些包装类(如 Integer, Byte, Short, Long, Character)的 一部分数值范围内的对象进行缓存,避免频繁创建新对象,提高性能。
- 例如: Java 的
Integer范围是-128 ~ 127。缓存这些小整数,可以极大地减少内存消耗。
1 | |
自动装箱与拆箱了解吗?原理是什么?
什么是自动拆装箱?
- 自动装箱:基本类型 → 包装类(Java 自动把 int 转成 Integer)
- 自动拆箱:包装类 → 基本类型(Java 自动把 Integer 转成 int)
原理:装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。
1 | |
对应的原理:
1 | |
静态方法为什么不能调用非静态成员?
静态方法属于类本身,非静态成员属于对象。没有对象,就不能访问对象的成员。
- 静态成员:属于类,类加载时就存在
- 不依赖对象,可以通过 类名.静态方法() 来调用
- 非静态成员:属于对象实例,必须创建对象之后才存在
- 你要访问它,必须先 new 一个对象出来
- 所以:静态方法里没有 this 指针,也就找不到具体的对象,自然不能访问非静态成员。
谈谈对stream流的理解?
主要是结合Lambda表达式,用来简化集合、数组的操作
方法重载和方法重写有什么区别?
- 方法重载:同一个类中,方法名相同,参数不同(类型、个数或顺序)
- 方法重写:子类中重新定义父类的方法实现(使用 @Override)
面向对象和面向过程的区别?
- 面向过程编程:面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象编程:面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
什么是对象实例,什么是对象引用?
- 对象实例:是通过 new 创建出来、真实存在于内存堆区的数据。是真正的对象数据(变量、方法等)
- 对象引用:是指向这个对象实例的变量,存放在栈中。是一个变量,保存对象的地址
1 | |
- new Student() 👉 对象实例(在堆内存中)
- stu 👉 对象引用(在栈内存中,保存的是对象地址)
什么是面向对象编程?⭐⭐
将现实世界中可以独立存在的事物抽象为对象,通过类(Class)来描述对象的共同特征(属性和行为),并通过对象来进行具体操作。
面向对象的三大基本特性:
封装:将现实世界中可以独立存在的事物抽象为对象,封装成一个类
继承:子类拥有父类对象所有的属性和方法,子类可以拥有自己属性和方法,即子类可以对父类进行扩展
多态:表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例
使用继承的好处
- 可以把多个子类中重复的代码抽取到父类中,提高代码的复用性
- 子类可以在父类的基础上增加其他功能,使子类更强大
多态的优势和缺点
- 优势:定义方法时,使用父类型作为参数,可以接收所有子类对象,体现多态的扩展性和便利
- 缺点:不能使用子类的特有功能(方法)
多态是如何实现的?从解释器的角度讲一下多态是如何实现的?
多态指的是:同一个方法调用(或操作)作用于不同对象时,表现出不同的行为。
Java 的多态依赖三个条件:
- 继承 或 接口实现
- 方法重写(override)
- 父类引用指向子类对象
编译看父类,运行看子类
- 编译器也不关心实际对象,编译器只根据引用变量的静态类型(声明时的类型)来检查是否有这个方法
- 运行阶段会结合实际对象(Child)去找最终调用哪个方法
接口和抽象类有什么区别? ⭐⭐⭐⭐⭐
- 共同点:都需要被实例化,都可以包含抽象方法
- 区别:
- 接口:更像是定义一个行业规范,主要用于对类的行为进行约束
- 一个类可以实现多个接口
- 抽象类:用于代码复用,强调的是所属关系,有部分代码实现要复用,有公共状态(字段),提供默认行为
- 一个类只能继承一个抽象类(Java 是单继承语言)
- 接口:更像是定义一个行业规范,主要用于对类的行为进行约束
抽象类有哪些接口做不到的事情场景?
- 抽象类可以定义成员变量(字段),而接口不行(接口里的变量默认是 public static final 常量)
- 抽象类可以有构造方法,用来初始化父类中的字段。接口完全没有构造方法
- 定义非 public 方法:抽象类可以定义 protected、private 方法,接口的方法(非 static 或 private 方法)默认是 public
什么是深拷贝、浅拷贝、引用拷贝?
- 浅拷贝: 拷贝的是对象本身,但引用类型字段不拷贝内容,仍然指向原对象(新旧两个对象会指向同一个地方)
- 深拷贝: 拷贝对象本身 + 所有引用类型字段内容,完全独立的新对象
- 引用拷贝: 引用拷贝不会在堆上创建一个新的对象,只会在栈上生成一个新的引用地址,最终指向依然是堆上的同一个对象。
- 深浅拷贝的主要区别:在于对引用类型字段的拷贝,深拷贝就是直接完全复制引用类型字段,而浅拷贝不会复制引用类型字段,只是记录该字段的地址,新旧两个对象会同时指向这一个地址
| 类型 | 是否新对象 | 是否拷贝引用对象的内容 | 是否共享内部对象 |
|---|---|---|---|
| 引用拷贝 | ❌ 否 | ❌ 否 | ✅ 是 |
| 浅拷贝 | ✅ 是 | ❌ 否(仅复制引用) | ✅ 是 |
| 深拷贝 | ✅ 是 | ✅ 是(递归拷贝) | ❌ 否 |
java的基础数据类型有哪些?他们占用的内存空间一样大吗?
Java 基础数据类型(8 种)
| 类型 | 描述 | 默认值 | 大小 | 范围 |
|---|---|---|---|---|
byte |
8 位有符号整数 | 0 | 1 字节 | -128 到 127 |
short |
16 位有符号整数 | 0 | 2 字节 | -32,768 到 32,767 |
int |
32 位有符号整数 | 0 | 4 字节 | -2^31 到 2^31-1(-2,147,483,648 到 2,147,483,647) |
long |
64 位有符号整数 | 0L | 8 字节 | -2^63 到 2^63-1 |
float |
32 位单精度浮点数 | 0.0f | 4 字节 | ±1.4E-45 到 ±3.4028235E+38 |
double |
64 位双精度浮点数 | 0.0d | 8 字节 | ±4.9E-324 到 ±1.7976931348623157E+308 |
char |
16 位 Unicode 字符 | ‘\u0000’ | 2 字节 | 0 到 65,535(0 到 ‘ÿ’ 字符) |
boolean |
布尔类型(true 或 false) | false | 1 字节(JVM 实现不同) | true 或 false |
float数为什么不精确?
float 精度丢失是因为它的二进制表示方式无法精确存储一些十进制数
- float 类型有 23 位尾数,所以它只能表示大约 6-7 位有效数字
小数在二进制里常常是“无限循环”
1 | |
正确做法:使用 BigDecimal表示小数
1 | |
== 和 equals() 的区别?
- == 比较的是 地址(内存地址是否相同)
- 基本类型 ➜ 值;引用类型 ➜ 地址
- equals() 比较的是 内容(值是否相同,默认也是地址比较,但可重写)
- Object 的 equals 方法是比较的对象的内存地址
- 像 String、Integer、Double、List、Map 等类都重写了 equals() 方法,用来比较对象的内容(值)是否相等。
对于基本类型:
1 | |
对于引用类型:
1 | |
hashCode()的若干问题
- hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
- hashCode() 和 equals()都是用于比较两个对象是否相等。
- 如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)
- 重写 equals() 时必须重写 hashCode() 方法
String 为什么是不可变的?
- String 类是 final 的,不能被继承
不可变的好处:
多线程安全(天然线程安全):多个线程共享同一个 String 实例,不能被修改,让它在多线程环境下天然安全。
StringBuilder 或 StringBuffer,它们是可变字符串
final关键词可以修饰什么?⭐
| 修饰对象 | 含义和效果 |
|---|---|
| 变量(局部变量、成员变量、静态变量) | 一旦赋值,值不能再修改(即常量) |
| 方法 | 子类不能重写(override)该方法 |
| 类 | 不能被继承 |
以下变量分别存在 JVM 哪个区域?
1 | |
- Integer.valueOf():使用缓存,缓存池在方法区里
- new Integer():永远创建新对象,在堆里
- == 比较:比较的是 引用地址,不是值
- equals() 比较的是值
什么是序列化和反序列化? ⭐⭐
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 序列化:把对象 ➡️ 变成字节流(方便传输、存储),通常是二进制字节流,也可以是 JSON, XML 等文本格式
- 反序列化:把字节流 ➡️ 还原成原来的对象
序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化
- 将对象存储到文件之前需要进行序列化
- 将对象存储到数据库(如 Redis)之前需要用到序列化
- 将对象存储到内存之前需要进行序列化
Java 中如何做序列化?
- 实现
Serializable接口
1 | |
- serialVersionUID: 序列化版本号,用于兼容不同版本的类
- 如果有些字段不想进行序列化怎么办: 使用
transient关键字修饰
static 的作用?
| 修饰对象 | 含义 |
|---|---|
| 变量(成员变量) | 静态变量,属于类,所有对象共享同一份 |
| 方法 | 静态方法,不依赖对象,可以通过类名直接调用 |
| 代码块 | 静态代码块,类加载时执行一次,用于初始化静态资源 |
为什么 Java 只有值传递?而没有引用传递?
- 值传递: 方法参数是实参的 拷贝,方法里改参数,不影响原变量
- 引用传递: 方法参数是实参的 地址引用本身,方法里改引用,会影响原变量本身
Java 中将实参传递给方法的方式是 值传递
- 如果参数是基本类型的话,很简单,明显是值传递
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值
1.2 集合
常见集合类型
| 类型 | 常见类/接口 | 是否有序 | 是否重复 | 底层实现 | 线程安全 | 特点/常考点 |
|---|---|---|---|---|---|---|
| List | ArrayList |
有序 | 允许 | 动态数组 | 否 | 查询快、增删慢(尤其是中间元素);扩容机制;默认容量10 |
LinkedList |
有序 | 允许 | 双向链表 | 否 | 插入删除快,查询慢;支持队列和栈操作 | |
| Set | HashSet |
无序 | 不允许 | 哈希表(基于 HashMap) | 否 | 元素唯一;依赖元素的 hashCode() 和 equals() 方法 |
LinkedHashSet |
有序(插入顺序) | 不允许 | 哈希表 + 双向链表 | 否 | 保证插入顺序,不重复 | |
TreeSet |
有序(可排序) | 不允许 | 红黑树(自平衡排序) | 否 | 自动排序(自然排序或自定义排序),不允许 null 元素 | |
| Map | HashMap |
无序 | 键不重复 | 哈希表 | 否 | 面试重点:扩容机制、hash 冲突处理(拉链法)、负载因子等 |
LinkedHashMap |
有序(插入顺序) | 键不重复 | 哈希表 + 双向链表 | 否 | 常用于实现 LRU 缓存 | |
TreeMap |
有序(按键排序) | 键不重复 | 红黑树 | 否 | 键自动排序,不能为 null | |
ConcurrentHashMap |
无序 | 键不重复 | 分段锁 + 哈希表 | 是 | 高并发下推荐使用;JDK8 后用 CAS + synchronized 替代分段锁 |
为什么要使用集合?数组和集合的区别有哪些?
集合是为了更方便、更灵活地管理对象数据,相比数组:
- 数组长度固定,不易扩展,集合可以动态增长
- 数组操作不灵活,集合提供了丰富的操作方法
- 数组只能存储相同类型(或需要强制转换),集合可以泛型支持任意类型
- Java 提供了各种集合类型,适应不同的数据结构需求(如:队列、集合、映射等)
Java 中使用集合是为了更灵活地操作对象集合,弥补数组长度固定、操作受限的问题。集合可以动态扩容,并且提供了多种数据结构的支持(如列表、集合、映射),大大提高了程序开发的效率和可维护性。
如何实现数组和List之间的转换?
数组转List ,使用JDK中java.util.Arrays工具类的asList方法
List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
如何选择集合类型?
单列集合总结
- 如果想要集合中的元素可重复
用ArrayList集合,基于数组的。(用的最多) - 如果想要集合中的元素可重复,而且当前的增删操作明显多于查询
用LinkedList集合,基于链表的。 - 如果想对集合中的元素去重
用HashSet集合,基于哈希表的。(用的最多) - 如果想对集合中的元素去重,而且保证存取顺序
用LinkedHashSet集合,基于哈希表和双链表,效率低于HashSet。 - 如果想对集合中的元素进行排序
用TreeSet集合,基于红黑树。后续也可以用List集合实现排序。
双列集合总结(键值对存储)
- 如果想根据键值对存储和查找元素
用HashMap集合,基于哈希表,无序、允许 null 键值,使用最广泛 - 如果想根据键值对存储,并且保持插入顺序
用LinkedHashMap集合,基于哈希表 + 双向链表,有序版本的HashMap - 如果想对键值对进行自动排序(按 key 自然顺序或自定义顺序)
用TreeMap集合,基于红黑树,有序、不允许 null 键 - 如果想在并发环境下使用 Map
用ConcurrentHashMap,高并发支持,JDK8 后基于 CAS + 分段锁机制,推荐用于多线程环境
HashMap 最常用,Linked 保顺序,TreeMap 自动排,线程用 Concurrent。
1.2.1 ArrayList 详细分析
ArrayList 简介
- ArrayList 的底层是数组队列,相当于动态数组。
- 与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用
ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。 - ArrayList只能存对象(引用类型)
ArrayList 实现了哪些接口?
ArrayList 继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。
1 | |
List:表明它是一个链表,支持添加、删除、查找等操作,并且可以通过下标进行访问RandomAccess:支持快速随机访问,通过元素的索引序号快速获取元素对象,这就是快速随机访问Cloneable:支持克隆拷贝能力Serializable:支持序列化,可以将对象转换为字节流进行持久化存储或网络传输
ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括 null 值。且可以存多个 null 值。
Arraylist 与 LinkedList 区别? ⭐
| 对比项 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 增删效率 | 慢(O(n)) | 快(O(1)) |
| 查询效率 | 快(O(1)) | 慢(O(n)) |
| 内存占用 | 小 | 大(多了节点指针) |
| 是否线程安全 | ❌ 不安全 | ❌ 不安全 |
- 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是都不保证线程安全
- 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构
- 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了
RandomAccess接口) 支持 - 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
ArrayList的扩容机制? ⭐⭐⭐⭐⭐
ArrayList 底层是一个可变数组(Object类型的数组elementData),当容量不够时,会自动扩容(位运算),新容量为原容量的 1.5 倍。
- 当使用无参构造方法创建ArrayList时,初始的elementData容量为0。
- 在第一次添加元素时,elementData会扩容到默认容量,通常是10。
- 如果再次需要扩容,新容量将是原容量的1.5倍。如果不够就按需要的最小容量扩
- 如果使用指定大小的构造器,则初始的elementData容量就是指定的大小,扩容策略也是增加到原容量的1.5倍。
扩容带来的问题
- 性能问题:每次扩容都要开辟新数组并拷贝旧数组 → 会频繁触发 GC
- 因此,在预知元素数量的情况下,使用初始容量构造器可以减少扩容次数,从而提高性能。
new ArrayList<>(10000); // 避免频繁扩容
- 看 ArrayList 的添加方法(add):
1 | |
- 再看 ensureCapacityInternal():
1 | |
- 最终调用
grow()方法完成扩容:- 负责计算新容量并复制数组(创建新数组 + 拷贝旧数据)
1 | |
ArrayList底层的实现原理是什么?
- 底层数据结构
- ArrayList底层是用动态的数组实现的
- 初始容量
- ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
- 扩容逻辑
- ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
- 添加逻辑
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
- 返回添加成功布尔值。
1.2.2 LinkedList 详细分析
LinkedList 是一个基于双向链表实现的集合类
- LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。
LinkedList 实现了哪些接口?
1 | |
List: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问(不过是通过指针移动一个一个找的)Deque:继承自 Queue 接口,具有双端队列的特性, 支持从两端插入和删除元素,方便实现栈和队列等数据结构Cloneable:表明它具有拷贝能力Serializable: 表明它可以进行序列化操作
LinkedList 中的元素是通过 Node 定义的
1 | |
1.2.3 HashMap 详细分析
HashMap简介
- HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是非线程安全的。
- HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个
- JDK1.8 之前 HashMap 由 数组+链表 组成的;链表则是主要为了解决哈希冲突而存在的,使用头插法插入新数据
- JDK1.8 以后:HashMap 的底层是一个数组 + 链表 + 红黑树 的复合结构。当链表长度超过阈值(默认是8),并且数组长度 >= 64 时,会将链表转换为红黑树,以提高查询效率。
HashMap实现的接口
1 | |
- Map接口
- Cloneable接口
- Serializable接口
JDK1.8之后的核心原理与扩容机制 ⭐⭐⭐⭐⭐⭐⭐⭐
数组 + 链表 + 红黑树
- 插入数据时(put)的位置判断
- 如果该位置为空,则直接存储数据
- 如果该位置已被占用
- 检查是否存在相同的 key。
- 如果 key 已经存在,则更新对应的 value。
- 如果 key 不存在,则将新的键值对添加到链表或红黑树中。
- 检查是否存在相同的 key。
- 处理哈希冲突
- 如果冲突数量较少(链表长度 ≤ 8),使用链表存储冲突数据,将新数据插入到旧数据尾部(后插法)
- 当数组的容量大于或等于一个特定的值,称为 MIN_TREEIFY_CAPACITY(默认是64),并且链表的长度大于8时,哈希表才会将链表转换为红黑树
- 哈希冲突时是否转为红黑树的判断条件是:链表的长度 > 8 && 数组的容量 >= MIN_TREEIFY_CAPACITY(默认64)
- 自动扩容机制
- HashMap初始容量是16,当 HashMap 的数据量超过阈值(容量 * 负载因子,默认是 0.75)时,HashMap 会进行扩容,容量变为原来的两倍,并重新分配数据位置。
为什么链表转红黑树的size要8,为什么红黑树size小于6转化为链表?
| 操作 | 条件 | 原因 |
|---|---|---|
| 链表 → 红黑树 | 链表长度 ≥8 且数组 ≥64 | 链表太长查找慢,换树提升 O(log n) 查询 |
| 红黑树 → 链表 | 树节点 <6 | 节点太少,不值得用红黑树结构维护 |
- 这两个阈值(8、6)是性能、内存、复杂度之间的平衡点,也是源码作者(Doug Lea)的调优结果。
hashmap的扩容为什么是两倍?⭐
扩容的时候,容量大小保证是 16 -> 32 -> 64 -> 128;一直是2的倍数,方便后续取模运算(用的是按位与运算&)
- 保持哈希表长度为 2 的幂次方(2^n)
- 当长度是 2 的幂次方时,使用 (n - 1) & hash 这种按位与运算,就相当于取 hash 的低 n 位,避免了 取模运算(%),速度更快
HashMap 的线程安全问题?hashmap为什么不安全?⭐⭐⭐
原因是:
- 内部没有任何同步机制(没有 synchronized,没有 volatile,没有 Lock)
问题:多线程同时 put() 时,导致数据丢失或覆盖
比如两个线程同时往 HashMap 里 put 数据:
- 两个线程都认为是当前桶
- 都往这个桶添加新节点
- 后写入的节点会覆盖前面的节点
👉 结果:前一个写入的数据丢失
HashMap 是非线程安全的,在多线程环境下可能会导致数据丢失或死循环。
- 可以选择
ConcurrentHashMap来避免线程安全问题 - ConcurrentHashMap使用了CAS + synchronized来实现线程安全,保证每个桶(或桶下链表/树)操作是线程安全的,避免了结构破坏
扩容过程中resize() 方法的作用?
resize() 是 HashMap 扩容的核心方法,当实际元素个数超过阈值 threshold 时就会触发。
- 创建一个更大的新数组(容量变为原来的 2 倍);
- rehash: 重新计算每个元素在新数组中的位置;
- 把旧数组的数据迁移到新数组中,并替换原数组引用。
1 | |
hash的具体操作,hash函数可以怎么实现? 多线程下hash会有什么问题?
在 HashMap 里,Hash 的作用是:
- 根据 key 计算出 hash 值,然后根据 hash 值定位数组索引。
key.hashCode() + 位运算扰动函数
- 调用 key.hashCode() 获取 原始 hashCode
- 通过 扰动函数 再处理一遍 hashCode(避免 hashCode 分布不均)
- 用 hash & (table.length - 1) 计算索引(位运算代替取模)
多线程下 hash 有什么问题?
- hash 运算本身是纯计算、无副作用 → 没有线程安全问题。
- 多线程下问题出在“用 hash 值定位、读写数组/链表/红黑树”过程,不是 hash 函数本身
扩容的时候为什么要重新计算位置(rehash)?
因为数组容量变了,(n - 1) & hash 得到的 index 也会变,元素可能不在原位置。如果不重新分配位置,查找和存取都会出错。
哈希函数的设计原理?
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | key.hashCode() |
获取 key 本身的哈希值 |
| 2 | h ^ (h >>> 16) |
扰动高位,提高低位的分布质量 |
| 3 | (n - 1) & hash |
定位数组桶的位置(效率高) |
- 第一步:调用对象本身的 hashCode 方法计算哈希值
- 第二步:扰动函数:高位与低位进行混合,减少碰撞
- 第三步:定位数组的下标位置
红黑树是什么?
- 二叉搜索树:左子树始终小于根结点;右子树始终大于根节点
- 引入红黑树的目的:优化查找性能,相比与链表的查询,查找效率就从 O(n) 变成了 O(log n)
红黑树是一种自平衡的二叉搜索树(BST),每个节点多了一个颜色属性(红或黑),它通过规则来控制树的高度,使得最坏情况下的操作(插入、删除、查找)时间复杂度为 O(log n)。
红黑树是一种插入/删除高效且高度受控的二叉搜索树,通过颜色标记和旋转操作,始终维持接近平衡的状态,被 HashMap 用来替代高冲突时的链表结构,避免性能退化为 O(n)。
HashMap和HashSet有什么区别和联系?
HashSet 是基于 HashMap 实现的,底层用 HashMap 来存储元素,只不过它只关注 key,不关心 value。
HashMap和HashTable的区别和联系?
两者都使用 数组 + 链表 + 红黑树
- HashMap:非线程安全
- Hashtable:线程安全,内部所有关键方法都使用了 synchronized 关键字修饰,因此是同步的,但性能差
- 多线程安全的情况下,一般用 ConcurrentHashMap 更推荐,不用 Hashtable
1.2.4 LinkedHashMap 详细分析
什么是LinkedHashMap?
LinkedHashMap 它继承了 HashMap 的所有属性和方法,并在 HashMap 基础上维护一条双向链表,使之拥有顺序插入和访问有序的特性。
- LinkedHashMap:有序、不重复、无索引
- HashMap: 无序、不重复、无索引
LinkedHashMap 是怎么维护顺序的?
- LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序
LinkedHashMap 按照插入顺序迭代元素是它的默认行为。LinkedHashMap 内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。
如何利用LinkedHashMap实现 LRU 缓存?
LRU(Least Recently Used):最近最少使用:当缓存满了时,移除最近最少使用的数据。
用 LinkedHashMap 实现的核心原理
- 设置 accessOrder = true,表示使用访问顺序;
- 重写 removeEldestEntry() 方法,当缓存超出容量时删除最旧的节点(也就是最久未被访问的);
其他集合常见问题
Set集合利用什么机制保证数据去重?
Set 通过元素的 hashCode() 和 equals() 方法,来判断元素是否“相等”,从而保证不重复。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同?
| 特性 | HashSet |
LinkedHashSet |
TreeSet |
|---|---|---|---|
| 是否有序 | 无序 | 插入顺序 | 排序(自然排序或自定义 Comparator) |
| 底层结构 | HashMap |
LinkedHashMap(HashMap + 双向链表) |
TreeMap(红黑树) |
| 元素去重依据 | hashCode + equals |
hashCode + equals |
compareTo 或 Comparator |
| 是否允许 null | 允许一个 null 元素 | 允许一个 null 元素 | ❌ 不允许(因为 null 无法比较) |
| 查询/插入效率 | 非常快 O(1)(理想情况) | 快 O(1),但比 HashSet 略慢 | 较慢 O(logN),因基于红黑树 |
| 线程安全性 | ❌ 线程不安全 | ❌ 线程不安全 | ❌ 线程不安全 |
| 使用场景 | 快速去重查找 | 保持元素插入顺序 | 需要排序或范围查询的场景 |
1.3 异常
Java 异常类总览结构图:
1 | |
- 所有的异常都有一个共同的祖先 java.lang 包中的
Throwable类。Throwable类有两个重要的子类:- Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获
- 受检查异常(Checked Exception): 除了RuntimeException以外的异常,必须要用try-catch或者throws捕获才能通过编译
- 不受检查异常(Unchecked Exception): RuntimeException 及其子类都统称为非受检查异常, 不处理不受检查异常也可以正常通过编译。
- Error:Error 属于程序无法处理的错误
- Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获
try-catch-finally 如何使用?
- try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
- catch块:用于处理 try 捕获到的异常。
- finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行
注意事项: try 中有 return,finally 仍会执行!
finally 中的代码一定会执行吗?
- finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
- 程序所在的线程死亡, finally 块的代码也不会被执行
1 | |
基本用法:
1 | |
灵魂四问:
- 灵魂一问:如果try中没有遇到问题,怎么执行?
- 正常执行
- 灵魂二问:如果try中可能会遇到多个问题,怎么执行?
- 会写多个catch与之对应
- 细节:如果我们要捕获多个异常,这些异常中如果存在父子关系的话,那么父类一定要写在下面
- 灵魂三问:如果try中遇到的问题没有被捕获,怎么执行?
- 相当于try..catch白写了,最终交给虚拟机处理
- 灵魂四问:如果try中遇到了问题,那么try下面的其他代码还会执行吗?
- 下面的代码就不会执行了,直接跳转到对应的catch当中,执行catch里面的语句体
异常类型和处理?⭐
类型:
- Checked Exception(受检查异常)
- Unchecked Exception(运行时异常)RuntimeException 及其子类
处理:
- try..catch..finally代码块
- throw和throws
- throw: 在方法中 实际抛出一个异常对象(用于手动触发异常)
- throws: 在方法声明处 告诉调用者这个方法可能会抛出哪些异常
ClassNotFoundException可能的原因?
ClassNotFoundException 是 Java 开发中常见的受检查异常(checked exception),它表示 JVM 在运行时找不到指定类
- JVM 没有在类路径(classpath)中找到名字为 xxx 的类
- 类名写错
- 类文件不在 classpath 中
- jar 包缺失
1.4 反射
什么是反射? ⭐
- 反射是封装的天敌
- 封装:是把数据和操作数据的方法包装在一个类中,外部无法访问这个类,对外隐藏内部实现细节。
- 而反射:是基于反射分析类的信息,然后获取到类/成员变量/成员方法/成员方法的参数
反射的应用场景了解么?
常见应用场景:
- 像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制
- 像 Java 中的一大利器注解的实现也用到了反射
- 为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
- 都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理
什么是动态代理?⭐⭐
框架(比如 Spring AOP、MyBatis、RPC 框架)中也大量使用了动态代理,而动态代理的实现也依赖反射
- “代理”就是“代表别人做事”。
- 比如你点外卖,骑手就是代理 —— 他不是做饭的那个人(目标对象),但你跟他打交道就行了。
- 代理可以无侵入式给对象增加其他功能
- 调用者–>代理–>对象
动态代理的优势:
- 不修改原代码,就能增加功能(这就是 面向切面编程 AOP 的本质)
- 一个代理类可以代理多个目标对象
- 灵活、通用,适合框架底层使用
1.5 I/O流
IO 流简介
IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。
IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
字节流和字符流的区别?
- 字节流:用于处理原始二进制数据,如文件、图片、音频等。
- 字符流:用于处理字符数据,主要用于文本文件的读写。
| 对比项 | 字节流(InputStream/OutputStream) | 字符流(Reader/Writer) |
|---|---|---|
| 处理单位 | 字节(byte) | 字符(char) |
| 编码转换 | 不处理编码问题 | 会根据字符集(如 UTF-8)做编码/解码 |
| 使用场景 | 二进制文件(图片、音频、压缩包等) | 文本文件(txt、html、xml、Java源代码等) |
| 性能差异 | 一般更底层、性能更高 | 高层封装,适合文本场景 |
| 是否支持字符集 | ❌ 不涉及编码 | ✅ 可指定字符集,如 UTF-8、GBK 等 |
- 字节流的输入输出流:
- 输入流:InputStream,如 FileInputStream
- 输出流:OutputStream,如 FileOutputStream
- 字符流的输入输出流:
- 输入流:Reader,如 FileReader
- 输出流:Writer,如 FileWriter
文件操作中最常用的流包括 FileInputStream、FileOutputStream、FileReader、FileWriter 等。
Java 中 3 种常见 IO 模型
IO 模型一共有 5 种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
- BIO (Blocking I/O)
- BIO 属于同步阻塞 IO 模型 。
- 同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间
- NIO (Non-blocking/New I/O)
- 它是支持面向缓冲的,基于通道的 非阻塞式I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
- Java 中的 NIO 可以看作是 I/O 多路复用模型
- 非阻塞 IO不会因为等待数据而阻塞线程,可以在等待数据时执行其他任务。
- 使用一个线程监听多个 socket 连接,哪个 socket 有数据,才触发读写操作
- AIO (Asynchronous I/O)
- AIO 也就是 NIO 2,是异步 IO 模型。
- 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
介绍一下阻塞io/非阻塞io,异步io/同步io?
阻塞 I/O(Blocking I/O)
- 线程不能干别的事,一直等着 I/O 完成
非阻塞 I/O(Non-blocking I/O)
- read() 不会阻塞线程,但需要自己“反复问内核”:有没有数据?会浪费 CPU 不断轮询
同步 I/O(Synchronous I/O)
- 应用程序发起 I/O 请求后,自己负责完成数据的获取/处理(需要自己 read/write)
- 阻塞 I/O、非阻塞 I/O、多路复用 I/O 都是同步IO
- 应用程序发起 I/O 请求后,自己负责完成数据的获取/处理(需要自己 read/write)
异步 I/O(Asynchronous I/O)
- 应用发起 I/O 后 立即返回
- 等数据准备 + 传输都完成后 → 内核通知你
介绍一下io多路复用(连接队列满了,但是每个连接阻塞着,怎么办?)
I/O 多路复用是一种机制:
👉 一个线程(或进程)同时监听多个 I/O 通道(文件描述符、socket),当有 I/O 事件就绪时通知你进行读写操作。
- 代表:用一个或少量线程同时处理很多网络连接(socket)
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(FD_SET) | 无固定上限(受系统限制) | 无固定上限 |
| 查询方式 | 轮询 | 轮询 | 回调(事件驱动) |
| 性能 | 低 | 中 | 高 |
| 是否支持边缘触发 | ❌ | ❌ | ✅ |
epoll 是性能最好的,因为:
- 不需要轮询所有 fd(文件描述符(FD)),只通知“有事件”的 fd
- 内核和用户空间共享一块内存(mmap)
- 只有真正有数据时,epoll_wait() 才返回 → 单线程可以同时处理多个 I/O 事件
发明IO多路复用的动机是什么?
- 解决传统 阻塞 I/O 模型中的 线程/进程过多 和 资源浪费 问题
- 通过 单线程异步处理多个连接 来提高系统的 并发性 和 资源利用率
epoll和select的区别?
| 特性 | select | epoll |
|---|---|---|
| 工作方式 | 轮询检查所有文件描述符 | 事件驱动,内核通知 |
| 最大文件描述符数 | 受限于 FD_SETSIZE(通常为 1024) |
无限制(只受系统资源限制) |
2. Java并发
2.1 并发编程基础
什么是进程和线程?⭐⭐
进程是程序运行的最小单位,是系统资源分配的基本单位。
- 每个进程都有自己独立的内存空间(代码区、堆区、栈区等)。
线程是 CPU 调度和执行的最小单位,是程序执行的实际单位。
- 所有线程共享进程的资源(比如内存),但每个线程有自己的程序计数器、栈、局部变量等。
- 一个进程内部可以包含多个线程,这叫“多线程”。
一个程序运行后会至少有一个进程
一个进程中至少有一个线程(主线程),也可以有多个线程(多线程)
二者对比
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
Chrome 浏览器每个 tab 是进程还是线程?
- 每个 tab(标签页)通常是一个单独的 进程
Web 服务每个请求是进程还是线程?
- 一般是用 线程 来处理每个请求
为什么一般的 Web 服务是单进程多线程?
- 线程比进程轻量、创建销毁开销小
并行和并发有什么区别?
现在都是多核CPU,在多核CPU下
- 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
- 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
并发产生的原因
- 主要源于 “多个任务共享有限资源”
同一时刻能有多少线程?
在 Java 程序里:JVM 里开几百 ~ 几千个线程是常见的
- CPU 核心数(影响并发效率,不是上限)
- 每个线程的栈大小
- JVM 参数 -Xss 决定每个线程栈大小(默认一般是 1M 左右)
- 如果堆内存 2GB,线程栈 1M,那么最多理论支持约 2000 个线程
如何创建线程?
创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等
使用线程池是项目常用的方式
严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()。
runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值
- Callable接口call方法有返回值,是个泛型
线程的 run()和 start()有什么区别?
- start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
- run(): 封装了要被线程执行的代码,可以被调用多次。
新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
可以使用线程中的join方法解决
- join() 等待线程运行结束
1 | |
notify()和 notifyAll()有什么区别?
- notifyAll:唤醒所有wait的线程
- notify:只随机唤醒一个 wait 线程
在java中wait和sleep方法的不同?
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
- 方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
- 醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的synchonized锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
简要介绍一下park和unpark?
在 java.util.concurrent.locks.LockSupportLockSupport.park()
→ 让当前线程挂起(阻塞),进入等待状态,直到被唤醒。LockSupport.unpark(Thread thread)
→ 唤醒指定线程,让它从 park() 中恢复继续运行
- 每个线程有一个“许可证”,最多只能有一个。
- park():检查有没有许可证,如果有直接消费掉、继续运行;如果没有,就挂起。
- unpark():给指定线程发放许可证
- 先 unpark 后 park 也能正常工作,不像 wait/notify 那样必须先 wait
如何停止一个正在运行的线程?如何优雅的终止一个线程?⭐
两种方式
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用interrupt方法中断线程
- 打断阻塞的线程( sleep,wait,join )的线程,线程会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
主线程通知一个任务线程结束,任务线程是怎么知道的?
任务线程要 自己检查一个由主线程更新的状态标志,主动退出
- 用中断标志interrupt
- 用自定义的标志位
说说线程的生命周期和状态?⭐⭐⭐⭐
处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用 start()
- RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态
- BLOCKED:阻塞状态,需要等待锁释放
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待
- TERMINATED:终止状态,表示该线程已经运行完毕
什么是线程上下文切换?
线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
- 主动让出 CPU,比如调用了 sleep(), wait() 等。
- 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
什么是死锁、活锁、饥饿,如何避免死锁、活锁、饥饿?
1️⃣ 死锁(Deadlock)
定义:
多个线程互相等待对方释放资源,互不让步,形成死循环,程序无法继续执行。
经典例子:
- 线程A持有资源1,想申请资源2;
- 线程B持有资源2,想申请资源1;
- 双方互相等待,永远卡住,谁也不释放。
避免方法:
- 资源申请顺序:固定资源获取顺序,按顺序申请锁。
- 加锁超时机制:用
Lock.tryLock(timeout)尝试获取锁,失败则释放已有锁并退出。 - 死锁检测:主动检测锁依赖关系,发现循环依赖时打破等待。
2️⃣ 活锁(Livelock)
定义:
多个线程没有阻塞,也在不停运行,但互相让步,导致始终无法完成任务。
生活类比:
两个人面对面让路,A说“你先”,B说“你先”,于是两人一直来回让路,永远无法通过。
避免方法:
- 引入随机等待,让线程等待一个随机时间后重试,避免“好心办坏事”的死循环。
- 设计合理的重试上限,防止无限循环。
- 合理协调线程通信,避免过度让步。
3️⃣ 饥饿(Starvation)
定义:
某个线程一直无法获取资源,长期被系统忽视,得不到CPU时间片,无法运行。
原因:
- 高优先级线程长时间霸占资源;
- 同步机制中某些线程总是得不到锁。
避免方法:
- 使用公平锁(如
ReentrantLock(true)),保证先来先服务; - 合理设置线程优先级,避免优先级反转;
- 控制线程池,防止某些任务无限插队。
| 问题类型 | 描述 | 避免策略 |
|---|---|---|
| 死锁 | 线程互相等待资源,互不释放,程序卡死。 | 资源排序、加锁超时、死锁检测。 |
| 活锁 | 线程不停让步,状态频繁变化,任务无法完成。 | 随机等待、重试上限、合理协调让步策略。 |
| 饥饿 | 某线程长期得不到执行机会。 | 公平锁、调整优先级、合理设计调度策略。 |
死锁产生的条件是什么?如何进行死锁诊断?
一个线程需要同时获取多把锁,这时就容易发生死锁
- 当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
- jps:输出JVM中运行的进程状态信息
- jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁,如果有死锁现象,需要查看具体代码分析后,可修复
- 可视化工具jconsole也可以检查死锁问题
导致并发程序出现问题的根本原因是什么?
- 原子性 synchronized、lock
- 内存可见性 volatile、synchronized、lock
- 有序性 volatile
你谈谈 JMM(Java内存模型)
- JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存
volatile的作用和原理?⭐⭐
保证可见性: 当一个线程修改了变量,其他线程立刻可以看到最新值,不会从缓存读旧值。
保证有序性: 禁止指令重排序,防止 JVM 和 CPU 对代码执行顺序重排,保证代码执行顺序符合预期。
synchronized可以保证原子性、可见性、有序性,volatile可以保证可见性和有序性
原理:
- 内存可见性
volatile保证:- 写操作:立刻刷新主内存;
- 读操作:强制从主内存读取。
- 禁止指令重排序
volatile前后会插入内存屏障(Memory Barrier),避免指令乱序,保证执行顺序安全。
- 内存可见性
2.2 乐观锁和悲观锁
CAS 你知道吗?
- CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
- CAS使用到的地方很多:AQS框架、AtomicXXX类
- 在操作共享变量的时候使用的自旋锁,效率上更高一些
- CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
乐观锁和悲观锁的区别?
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
什么是悲观锁?
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
- synchronized和ReentrantLock独占锁就是悲观锁思想的实现。
什么是乐观锁?⭐⭐
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了
- 使用版本号机制或 CAS 算法
版本号机制实现乐观锁
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
CAS 算法实现乐观锁
CAS 的全称是 Compare And Swap(比较与交换)
- CAS 是一种乐观锁机制,通过比较内存中的值是否是预期值,如果是,就更新;否则就重试
- CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令
- Java 中的并发类(juc 包下 API)如 AtomicInteger、AtomicReference 等都基于 CAS 来实现高效的原子操作
Java 中 CAS 是如何实现的?
CAS 操作底层是通过 Unsafe 类的 native 方法 实现的。
CAS 算法存在哪些问题?⭐⭐
- ABA 问题:内存值从 A -> B -> A,CAS 看到还是 A,但其实数据已经变过了。
- 自旋开销:如果 CAS 一直失败,线程会不断自旋,浪费 CPU。
- 只能保证一个变量的原子性 CAS 只能操作一个变量,如果涉及多个变量的原子性操作,要用加锁或 AtomicReference。
如何解决这些问题?⭐⭐
- 自旋开销太大?:通常配合限制重试次数
- ABA 问题?:引入一个版本号,每次修改时一起修改版本,用 AtomicStampedReference 或 AtomicMarkableReference
谈谈你对 volatile 的理解?
- 保证线程间的可见性
- 用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
- 禁止进行指令重排序(保证有序性)
- 指令重排:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
2.3 Synchronized
如何使用 synchronized?
synchronized 关键字的使用方式主要有下面 3 种:
- 修饰普通方法: 锁当前对象实例
- 修饰静态方法: 锁当前类
- 修饰代码块: 对括号里指定的变量/对象/类加锁
- 构造方法不能使用 synchronized 关键字修饰。
说说synchronized的底层原理?
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
- 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
基于对象的 Monitor synchronized 背后的原理就是依靠**对象头里的 Monitor(监视器锁)**来实现的。
- Java 对象在内存中的结构里,包含Mark Word(对象头的一部分)。
synchronized会通过Monitor对象实现线程的互斥。
每个对象都可以成为锁,每个锁都对应一个 Monitor。
锁的状态变化⭐⭐⭐⭐
JVM 为了优化锁的性能,设计了锁升级机制,主要有四种状态:
| 锁状态 | 特点 | 说明 |
|---|---|---|
| 无锁 | 没有线程竞争 | 普通的对象,没有加锁。 |
| 偏向锁(Biased) | 偏向第一个访问的线程 | 单线程访问时,轻量级,几乎零开销。 |
| 轻量级锁(Lightweight) | 多线程交替执行,有竞争,但无阻塞 | 用CAS尝试抢占锁,未成功则膨胀成重量级锁。 |
| 重量级锁(Heavyweight) | 多线程并发争用,发生阻塞挂起 | 使用 Monitor,线程进入内核态,阻塞等待唤醒。 |
synchronized 会自动在无锁 → 偏向锁 → 轻量级锁 → 重量级锁之间切换,根据竞争情况升级或降级,最大化性能。
1 | |
Monitor实现的锁属于重量级锁,你了解过锁升级吗?⭐⭐⭐⭐
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
| 锁类型 | 描述 |
|---|---|
| 重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
| 轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。 |
| 偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。 |
synchronized锁升级过程?
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- 有竞争时逐步升级,不会降级
为什么要有轻量级锁?
为了优化多线程场景下的性能,用乐观锁(CAS)代替重量级锁的阻塞挂起,减少上下文切换开销
2.4 ReentrantLock
ReentrantLock的特点
- ReentrantLock 实现了 Lock 接口
| 特点 | 说明 |
|---|---|
| 可重入(Reentrant) | 同一个线程可以多次获取同一把锁,不会发生死锁。类似 synchronized。 |
| 可中断(Interruptible) | 支持 lockInterruptibly(),等待锁期间可以响应中断,避免死等。 |
| 可公平(Fair) | 支持公平锁/非公平锁,构造方法中传 true 为公平锁,按照线程等待顺序依次获取锁。 |
| 可超时(tryLock) | 支持尝试获取锁,并设置等待时间,超过时间放弃,避免无限阻塞。 |
| 支持条件变量(Condition) | 可创建多个 Condition,精确控制线程的等待与唤醒,替代 wait()/notify()。 |
| 灵活手动释放锁 | 加锁 lock() 后需要手动释放 unlock(),比 synchronized 更灵活但容易忘。 |
ReentrantLock的实现原理?⭐⭐⭐⭐⭐
- ReentrantLock表示支持重新进入的锁,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞
- ReentrantLock主要利用CAS+AQS队列来实现
- 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁
ReentrantLock 公平锁的实现原理?⭐⭐⭐
ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer)实现的可重入锁。
- 非公平锁(默认,new ReentrantLock())
- 公平锁(new ReentrantLock(true))
- 当锁可用时(state=0),只有队列中没有其他等待线程时,当前线程才能尝试获取锁
- 在尝试获取锁前检查是否有前驱节点
- 新来的线程必须排队,不能直接插队获取锁
- 严格按照 FIFO 顺序唤醒等待线程
synchronized 和 ReentrantLock 有什么区别?⭐⭐⭐⭐⭐⭐
- 语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
- 功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock,ReentrantReadWriteLock(读写锁)
- 性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
两者都是可重入锁
- 指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
- synchronized 是依赖于 JVM 实现的
- ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
ReentrantLock 比 synchronized 增加了一些高级功能
- 等待可中断: ReentrantLock提供了一种能够中断等待锁的线程的机制
- 可实现公平锁:防止饥饿
- 支持超时:可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。防止死锁
- 可实现选择性通知(锁可以绑定多个条件):synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
2.5 线程池
什么是线程池?
顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
为什么要用线程池?
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池的最顶层父类是什么?
线程池的最顶层父类是 java.util.concurrent.Executor 接口
1 | |
如何创建线程池?
通过ThreadPoolExecutor构造函数来创建(推荐)。
线程池常见参数有哪些?如何解释?⭐⭐⭐⭐⭐
1 | |
- corePoolSize 核心线程数目
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
- 核心线程数corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
- 最大线程数maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。最大线程数=核心线程数+阻塞队列大小
- 阻塞队列workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中
- keepAliveTime:救急线程的存活时长
如何确定核心线程数⭐⭐⭐⭐⭐
- 高并发、任务执行时间短( CPU核数+1 ),减少线程上下文的切换
- 并发不高、任务执行时间长
- IO密集型的任务 (CPU核数 * 2 + 1)
- 计算密集型任务 ( CPU核数+1 )
- 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)
线程池的拒绝策略有哪些?⭐⭐⭐⭐⭐
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些拒绝策略:
- AbortPolicy: 默认策略:抛出异常:抛出 RejectedExecutionException来拒绝新任务的处理。
- CallerRunsPolicy: 调用执行者自己的线程运行任务
- DiscardPolicy: 不处理新任务,直接丢弃掉
- DiscardOldestPolicy: 此策略将丢弃阻塞队列中最早的未处理的任务请求
线程池处理任务的流程?线程池的原理?⭐⭐⭐⭐⭐
1 | |
线程池中的常用阻塞队列?
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
- DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
| 队列类型 | 是否有界 | 特点 | 适用场景 |
|---|---|---|---|
ArrayBlockingQueue 数组有界队列 |
有界 | FIFO,容量固定,稳定限流 | 任务量可预估,限流,避免 OOM |
LinkedBlockingQueue 链表无界队列 |
无界 | FIFO,容量大,内存风险 | 任务堆积,缓冲任务 |
SynchronousQueue 直接移交队列 |
无界 | 无缓冲,任务直接交接,线程数不固定 | 高并发,快速交付任务 |
PriorityBlockingQueue 优先级队列 |
无界 | 自定义优先级,非FIFO,按优先级出队 | 优先级调度型任务系统 |
ArrayBlockingQueue的LinkedBlockingQueue区别
| 特性 | LinkedBlockingQueue | ArrayBlockingQueue |
|---|---|---|
| 容量限制 | 默认无界,支持有界 | 强制有界 |
| 底层结构 | 链表 | 数组 |
| 初始化方式 | 懒惰的,创建节点时添加数据 | 提前初始化 Node 数组 |
| 入队操作 | 生成新 Node | Node 需提前创建好 |
| 锁机制 | 两把锁(头尾) | 一把锁 |
线程池的种类有哪些?
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种
- newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待
- 核心线程数与最大线程数一样,没有救急线程
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
- 适用于任务量已知,相对耗时的任务
- newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
- 核心线程数和最大线程数都是1
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
- 适用于按照顺序执行的任务
- newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
- 核心线程数为0
- 最大线程数是Integer.MAX_VALUE
- 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
- 适合任务数比较密集,但每个任务执行时间较短的情况
- newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行
为什么不建议用Executors创建线程池?
针对的是 Executors 工具类中几个工厂方法(如 newFixedThreadPool、newCachedThreadPool 等)
简要来说:不推荐使用 Executors 创建线程池,是因为它们默认的配置容易引发风险,如:
- newFixedThreadPool 和 newSingleThreadExecutor:
- 队列是无界的(LinkedBlockingQueue) → 任务堆积过多,可能导致 OOM(内存溢出)。
- newCachedThreadPool:
- 线程数几乎无限(Integer.MAX_VALUE) → 高并发下,可能导致过多线程被创建 → 资源耗尽 → OOM 或系统崩溃。
推荐用 ThreadPoolExecutor 自己手动设置:
- 核心线程数
- 最大线程数
- 队列容量
- 拒绝策略
- → 更可控、更安全。
2.6 AQS⭐⭐⭐
什么是AQS?
- 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore、CountDownLatch都是基于AQS实现的
- AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
- 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源
- 原子性:在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性
AQS是公平锁吗,还是非公平锁?
既是公平锁又是非公平锁
- 新的线程与队列中的线程共同来抢资源,是非公平锁
- 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁
AQS与Synchronized的区别?
| 特性 | synchronized | AQS |
|---|---|---|
| 实现方式 | 关键字,JVM 语言实现 | Java 语言实现 |
| 锁类型 | 悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
| 锁竞争激烈时的表现 | 都是重量级锁,性能差 | 提供了多种解决方案 |
如何控制某个方法允许并发访问线程的数量?
在多线程中提供了一个工具类Semaphore,信号量。在并发的情况下,可以控制方法的访问量
- 创建Semaphore对象,可以给一个容量
- acquire()可以请求一个信号量,这时候的信号量个数-1
- release()释放一个信号量,此时信号量个数+1
你们项目哪里用到了多线程?CountDownLatch
我们的项目用到了CountDownLatch
- 在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
- 其中构造参数用来初始化等待计数值
- await() 用来等待计数归零
- countDown() 用来让计数减一
你们项目哪里用到了多线程?(数据汇总)
在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
- 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
2.7 ThreadLocal ⭐⭐⭐⭐⭐⭐⭐⭐
谈谈你对ThreadLocal的理解?
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
- 每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
a. 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线
程的 ThreadLocalMap 集合中
b. 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
c. 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值 - ThreadLocal内存泄漏问题
ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value
ThreadLocal 有什么用?
通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。那么,如果想让每个线程都有自己的专属本地变量,该如何实现呢?
- ThreadLocal 类允许每个线程绑定自己的值,可以将其形象地比喻为一个“存放数据的盒子”。每个线程都有自己独立的盒子,用于存储私有数据,确保不同线程之间的数据互不干扰。
ThreadLocal 原理了解吗?
每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。
ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap 的 key 和 value 引用机制:
- key 是弱引用:ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (
WeakReference<ThreadLocal<?>>)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null。 - value 是强引用:即使 key 被 GC 回收,value 仍然被 ThreadLocalMap.Entry 强引用存在,无法被 GC 回收。
- 如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,即会造成内存泄漏。
如何避免内存泄漏的发生?
- 在使用完 ThreadLocal 后,务必调用 remove() 方法
- 使用 try-finally 块可以确保即使发生异常,remove() 方法也一定会被执行
2.8 ConcurrentHashMap
ConcurrentHashMap 线程安全的具体实现方式/底层具体实现?⭐⭐
ConcurrentHashMap 是 Java 并发包下的线程安全 Map,解决了多线程环境下 HashMap 会出现死循环、数据不一致的问题。
采用 Node + CAS + synchronized 来保证并发安全
synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升
不允许键值为 null : null key / value 会抛 NullPointerException
加锁方式:
- 写操作:采用 CAS + synchronized,锁住的是某个桶节点而不是整个表;
- 读操作:多线程下读操作大多是无锁的,保证高并发性能。
聊一下ConcurrentHashMap?⭐⭐
- 底层数据结构:
- JDK1.7底层采用分段的数组+链表实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树
- 加锁的方式
- JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
- JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
3. JVM原理
3.1 JVM内存结构
JVM是什么?
Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
- 一次编写,到处运行
- 自动内存管理,垃圾回收机制
介绍一下Java 内存区域(运行时数据区)? ⭐⭐⭐⭐⭐
Java 程序运行时,JVM 会把内存划分为运行时数据区,大致长这样:
1 | |
| 区域 | 作用 | 线程共享? | 抛出异常 |
|---|---|---|---|
| 方法区 | 类信息、静态变量、常量池 | ✅ 共享 | OutOfMemoryError |
| 堆 | 对象实例存放 | ✅ 共享 | OutOfMemoryError |
| Java 虚拟机栈 | 局部变量、方法调用 | ❌ 私有 | StackOverflowError |
| 本地方法栈 | 调用本地方法用 | ❌ 私有 | StackOverflowError |
| 程序计数器 | 当前线程字节码执行地址 | ❌ 私有 | 不会 OOM |
介绍一下程序计数器的作用?
线程私有的,每个线程一份,没有线程安全问题,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
程序计数器主要有两个作用:
记录下一条指令地址: 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
JVM 通过它支持线程切换(线程上下文切换时恢复正确执行位置)
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
记录当前线程正在执行的字节码指令地址。
唯一一个不会 OOM(OutOfMemoryError)内存溢出的区域。它的生命周期随着线程的创建而创建,随着线程的结束而死亡
线程私有的,是线程安全的
介绍一下Java虚拟机栈的作用?
每个线程创建时都会分配一个栈,存储该线程的局部变量、方法调用信息等。
每次方法调用时,会创建一个栈帧
所有的 Java 方法调用都是通过栈来实现的
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、方法返回地址
是线程私有的,生命周期与线程相同。随着线程的创建而创建,随着线程的死亡而死亡。
会出现 StackOverflowError和OOM,说明方法递归调用过多。
什么是虚拟机栈?
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
虚拟机栈包含哪些部分?
每个线程都有一个 虚拟机栈(JVM Stack),它的核心组成是:栈帧(Stack Frame)
- 栈帧里包含三个主要部分
- 局部变量表
- 中间结果和操作数栈
- 方法的返回地址和运行时常量池的引用
垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k,栈帧过大会导致线程数变少
方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
什么情况下会导致栈内存溢出?
- 栈帧过多导致栈内存溢出,典型问题:递归调用
- 栈帧过大导致栈内存溢出
介绍一下本地方法栈?
和 Java 栈类似,但专门用于本地方法(C、C++)的调用。
通过 native 关键字调用的 C/C++ 方法,使用本地方法栈。
也是线程私有的
StackOverflowError和OOM 同样可能发生
介绍一下堆?
- 线程共享的区域:主要用来保存对象实例,数组等,内存不够则抛出OutOfMemoryError异常。
- 组成:年轻代+老年代
- 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区
- 老年代主要保存生命周期长的对象,一般是一些老的对象
- Jdk1.7和1.8的区别
- 1.7中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
- 1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
就是存放new出来的对象的位置
堆是所有线程共享的一块内存区域
唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存
被 GC 主要管理的区域
JVM 会把堆划分为:
- 新生代(Young Generation)👉 放新生对象
- 老年代(Old Generation)👉 放长期存活对象
堆这里最容易出现的就是 OutOfMemoryError 错误
堆和栈的区别是什么?
- 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
- 栈内存是线程私有的,而堆内存是线程共有的。
- 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
- 栈空间不足:java.lang.StackOverFlowError。
- 堆空间不足:java.lang.OutOfMemoryError
介绍一下方法区?
- 方法区(Method Area)是各个线程共享的内存区域
- 主要存储类的信息、运行时常量池
- 虚拟机启动的时候创建,关闭虚拟机时释放
- 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace
又叫 永久代(PermGen),Java8 之后叫元空间(MetaSpace)
- 存储:类的结构信息(类名、字段、方法)、静态变量、常量池(运行时常量池、字符串常量池)。
- 类加载后,类信息都会放在这里。
- JVM 共享的区域(所有线程共享)。
介绍一下运行时常量池和字符串常量池?
- 常量池:可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 当类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
常量池:就是一张常量表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
- 运行时常量池:
常量池是 .class 文件中的,当该类被加载以后,它的*常量池信息就会被加载成运行时常量池,并把里面的符号地址变为真实地址
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
串池StringTable中保存的内容是什么?
串池(StringTable)中保存的是:
- 指向堆中 String 对象的引用
- 不是字符串本身的字符数据拷贝
1 | |
为什么串池里不直接存“字符串内容”?
- JVM所有对象都必须在堆中分配
- String本质上仍然是普通Java对象,必须分配在堆中,由 GC 管理
什么是直接内存?(JVM的堆外内存就是直接内存)
- 并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存
- 常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
- 直接内存并不是虚拟机运行时数据区的一部分
Java 对象创建的完整流程?⭐⭐⭐⭐⭐
1️⃣ 类加载检查
2️⃣ 内存分配
3️⃣ 成员变量初始化为默认值
4️⃣ 设置对象头
5️⃣ 执行构造方法初始化
- 类加载检查
- JVM 会先检查这个类的 .class 文件是否已经被加载、链接、初始化。
- 如果还没有,就会通过类加载器去加载,完成类的准备工作。
- 内存分配
- JVM 在堆内存中,为新对象划分一块合适的空间。
- 分配方式有两种:
- 指针碰撞(Bump-the-Pointer) 堆内存规整,空闲内存连续,指针往后一移即可分配
- 空闲列表(Free List) 堆内存不规整,维护一个空闲块链表,找到合适的空间分配
- 成员变量初始化为默认值
- 所有的成员变量,JVM会自动赋予零值:
- 整型
0 - 浮点型
0.0 boolean为false- 引用类型为
null
- 设置对象头
- JVM 会为对象设置对象头,包含:
Mark Word:哈希码、GC 分代年龄、锁标志位等信息。Class Pointer:指向该对象的类元数据,JVM 通过它知道对象的类型。
每个对象都有对象头,JVM通过它快速定位类型信息。
- 执行构造方法初始化
- 最后,执行构造函数
constructor,初始化成员变量的实际值(覆盖默认值)。 - 如果有父类,会先执行父类的构造方法,再执行子类的。
1 | |
- 访问对象:使用直接指针访问,reference 中存储的直接就是对象的地址。
3.2 垃圾回收机制
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆内存中对象的分配与回收。
如何判断对象是否可以回收(两种方法)?
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)
- 引用计数法
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
- 问题:它很难解决对象之间循环引用的问题。
- 可达性分析算法
- 从GC Roots 出发,能找到的对象就是“活的”
- 找不到的,判定为垃圾,等待回收。
- 常见 GC Roots:
- 虚拟机栈的本地变量表引用的对象
- 方法区中静态属性引用的对象
- 常量引用的对象
- JNI(本地方法)引用的对象
Java 用的就是可达性分析算法!
介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)?
强引用:只要所有 GC Roots 能找到,就不会被回收
软引用:需要配合SoftReference使用,当垃圾多次回收,内存依然不够的时候会回收软引用对象
弱引用:需要配合WeakReference使用,只要进行了垃圾回收,就会把弱引用对象回收
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱),
强引用就是 Java 中普通的对象,而软引用、弱引用、虚引用在 JDK 中定义的类分别是 SoftReference、WeakReference、PhantomReference。
- 强引用
- 只要有强引用,垃圾回收器永远不会回收这个对象。
1 | |
- 软引用(Soft Reference)
- 内存足够时,不回收;
- 内存不足时,优先回收软引用对象。
- 比如图片缓存,内存紧张时自动释放,空闲时继续使用。
1 | |
- 弱引用(Weak Reference)
- 只要GC发生,无论内存是否紧张,都会回收弱引用对象。
1 | |
- 虚引用(Phantom Reference)
- 无法通过虚引用获取对象(
get()方法永远返回null)。 - 必须配合
ReferenceQueue使用。 - 对象被回收前,会加入
ReferenceQueue,通知程序。
1 | |
常用于:
- 监控对象回收、做资源释放(类似析构函数)。
| 引用类型 | 是否阻止GC回收 | 回收时机 | 典型应用场景 |
|---|---|---|---|
| 强引用 | ❌ 永不回收 | 无特殊情况,不会被回收 | 普通对象引用 |
| 软引用 | ✅ 内存紧张时回收 | 内存不足,GC会回收 | 缓存系统,图片缓存 |
| 弱引用 | ✅ GC必回收 | 下一次GC一定会被回收 | ThreadLocal Map Key |
| 虚引用 | ✅ GC必回收 | 对象被回收时,进入 ReferenceQueue |
资源释放、监控对象生命周期 |
虚引用与软引用和弱引用的一个区别在于:
- 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
一般很少使用弱引用与虚引用,使用软引用的情况较多
- 因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
垃圾收集有哪些算法,各自的特点?
- 标记-清除(很少用)
- 分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片。
- 标记-整理
- 让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存
- 适合老年代这种垃圾回收频率不是很高的场景
- 不会产生碎片
- 复制
- 把内存划分为两块(一般是新生代);活着的对象从一块复制到另一块;清空原内存。
- 没有内存碎片
- 内存浪费,需要一块备用空间
- 适合新生代
- 分代收集算法【实际使用】
根据对象生命周期长短,将内存划分:
新生代(Young):频繁回收,使用复制算法;
老年代(Old):长寿对象,使用标记-整理算法。
标记清除算法:垃圾回收分为2个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续
标记整理算法:标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低
复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低
说一下JVM中的分代回收?
一、堆的区域划分
- 堆被分为了两份:新生代和老年代【1:2】
- 对于新生代,内部又被分为了三个区域。Eden区,幸存者区survivor(分成from和to)【8:1:1】
二、对象回收分代回收策略 - 新创建的对象,都会先分配到eden区
- 当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
- 将存活对象采用复制算法复制到to中,复制完毕后,伊甸园和 from 内存都得到释放
- 经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将其复制到from区
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升)
JVM中对象什么时候会进入老年代?
- 对象的年龄达到阈值 ,JVM 配置的 MaxTenuringThreshold(默认 15)
- 大对象直接进入老年代
- Survivor 区满了之后,又有对象进入Survivor 区,此时存活对象会直接被晋升到老年代,以避免 Survivor 区溢出
- 长期存活的对象或被引用的对象,比如永久存在的线程、静态字段引用的对象
STW会影响什么?
STW(Stop The World)是 JVM 在执行垃圾回收(GC)时的一个机制,它会 暂停所有应用线程,让 GC 线程独占 CPU 去做垃圾回收工作。
- 简单来说,就是:垃圾回收期间,应用完全停摆
MinorGC、 Mixed GC 、 FullGC的区别是什么?
- MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
- Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
- FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
常见的垃圾回收器有哪些?⭐⭐⭐
在jvm中,实现了多种垃圾收集器,包括:
- 串行垃圾收集器:Serial GC、Serial Old GC
- 并行垃圾收集器:Parallel Old GC、ParNew GC
- CMS(并发)垃圾收集器:CMS GC,作用在老年代
- G1垃圾收集器,作用在新生代和老年代
JDK 默认垃圾收集器
- JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK22:G1(Garbage First)收集器
1 | |
Serial 收集器(串行)
- 这个收集器是一个单线程收集器
- 它只会使用一条垃圾收集线程去完成垃圾收集工作
- 它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。
- 新生代采用标记-复制算法,老年代采用标记-整理算法
ParNew 收集器
- 就是 Serial 收集器的多线程版本
- 其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样
- 新生代采用标记-复制算法,老年代采用标记-整理算法
Parallel Scavenge 收集器
- Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样
- Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)
- CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)
- 新生代采用标记-复制算法,老年代采用标记-整理算法
Serial Old 收集器
- Serial 收集器的老年代版本,它同样是一个单线程收集器
Parallel Old 收集器
- Parallel Scavenge 收集器的老年代版本
介绍一下 CMS,G1 收集器?
- CMS 收集器
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
- 第一款真正意义上的并发收集器,实现了让垃圾收集线程与用户线程同时工作。
- CMS 收集器是一种 “标记-清除”算法实现的
基本流程:
初始标记(STW)👉 并发标记 👉 重新标记(STW)👉 并发清除
初始标记 & 重新标记:会暂停应用线程,时间短
并发标记 & 并发清除:与业务线程并行,响应速度好
会产生内存碎片
如果内存回收速度赶不上对象创建速度,可能提前Full GC
- G1 收集器(Garbage First)
G1 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
Region 设计:回收时按“收益最大”优先,灵活回收,避免碎片,按Region活跃度动态回收。
自动避免碎片
停顿时间可控(可以设置目标:-XX:MaxGCPauseMillis=200)
新老年代一体设计,适合大内存场景
详细聊一下G1垃圾回收器? ⭐⭐⭐⭐⭐
- 应用于新生代和老年代,在JDK9之后默认使用G1
- 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收、并发标记、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
Young Collection(年轻代垃圾回收)
- 初始时,所有区域都处于空闲状态
- 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
- 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
- 随着时间流逝,伊甸园的内存又有不足
- 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代
Young Collection + Concurrent Mark (年轻代垃圾回收+并发标记)
- 当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程
- 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。
- 这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)
Mixed Collection (混合垃圾回收)
- 混合收集阶段中,参与复制的有 eden、survivor、old
- 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
Minor Gc 和 Full GC 有什么不同呢?
| 类型 | 触发条件 | 回收范围 | 速度 |
|---|---|---|---|
| Minor GC | 新生代空间不足时触发 | 只回收新生代(Eden + S0/S1) | 快,停顿短,频繁发生 |
| Full GC | 老年代不足,或System.gc()调用 |
新生代 + 老年代(甚至Metaspace) | 慢,停顿长,影响较大 |
本质区别:
- Minor GC 只清理新生代,短平快,触发频繁;
- Full GC 清理新老年代全部,耗时长,性能压力大。
垃圾回收器CMS和G1的区别?怎么选择?
CMS:
- CMS 是基于标记-清除算法的低延迟收集器,关注缩短老年代回收停顿,但可能产生碎片
- 适合响应时间敏感的应用
- 如果是中小堆、响应时间敏感的场景用 CMS
G1:
- G1 是更先进的收集器,按 Region 管理内存,能同时兼顾高吞吐和低延迟,还能通过参数预测停顿时间
- 如果是大堆、高并发、对停顿敏感的系统,用 G1 更合适
3.3 类加载
什么是类加载器?
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
类加载器有哪些?
- 启动类加载器(BootStrap ClassLoader):加载JAVA_HOME/jre/lib目录下的库
- 扩展类加载器(ExtClassLoader):主要加载JAVA_HOME/jre/lib/ext目录中的类
- 应用类加载器(AppClassLoader):用于加载classPath下的类
- 自定义类加载器(CustomizeClassLoader):自定义类继承ClassLoader,实现自定义类加载规则。
什么是双亲委派模型?⭐⭐⭐
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类
JVM为什么采用双亲委派机制?
- 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
- 为了安全,保证类库API不会被修改
如何打破双亲委派?
要打破双亲委派模型,可以通过自定义类加载器并重写 loadClass 方法,绕过 super.loadClass
类加载的执行过程 ⭐⭐⭐⭐⭐
- 加载:查找和导入class文件
- 验证:保证加载类的准确性
- 准备:为类变量分配内存并设置类变量初始值
- 解析:把类中的符号引用转换为直接引用
- 初始化:对类的静态变量,静态代码块执行初始化操作
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化
- 加载(Loading)
- 通过类的全限定名找到
.class文件。 - 将
.class文件的二进制数据加载进内存。 - 生成一个Class对象。
负责:类加载器(ClassLoader)
常用:AppClassLoader / ExtClassLoader / BootstrapClassLoader
- 连接
分为三步:验证->准备->解析
- 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
- 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配
- 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 初始化(Initialization)
初始化阶段是执行初始化方法<clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
- 执行
static代码块和静态变量的显式赋值操作; - Java程序中真正第一次主动使用类时才触发;
- 按照父类优先,子类后初始化顺序执行。
JVM能不能加载两个限定名一样的类?
- JVM 是可以加载两个限定名(即包名 + 类名)相同的类的
- 前提是它们由不同的类加载器加载
- 因为在 JVM 里,类的唯一标识不是单纯靠“全限定名”,而是由 类加载器 + 全限定名 一起决定的
类加载器
| 类加载器 | 作用 | 加载内容 |
|---|---|---|
| Bootstrap ClassLoader | 启动类加载器(C/C++实现) | JDK核心类库(rt.jar) |
| Extension ClassLoader | 扩展类加载器 | JDK扩展类(ext目录下) |
| Application ClassLoader | 应用类加载器 | classpath 下的类 |
| 自定义 ClassLoader | 用户自定义的加载器 | 特殊业务场景(如加密类加载) |
双亲委派机制
加载顺序遵循:
1 | |
只有当父加载器无法找到对应类,才由子加载器尝试加载,目的是:
- 避免重复加载;
- 保证核心Java类的安全性;
- 实现版本隔离(不同的 ClassLoader 加载同名类,互不干扰)。
3.4 JVM 调优
真正的调优靠:理解原理 + 实际场景 + 工具分析
工具层面:先上手
别急着调优,先学会看数据:
| 工具 | 用途 |
|---|---|
jps |
查看 JVM 进程号 |
jstat |
查看垃圾回收统计信息 |
jmap |
查看堆内存结构 / dump快照 |
jconsole / VisualVM |
可视化内存、GC、线程状态 |
arthas |
阿里出品,线上排查神器 |
搞懂这些工具的输出,你就能找到问题!
调优不调优,先会分析问题。
初级调优,其实就是根据需求改几个JVM参数:
| 场景 | 建议方案 |
|---|---|
| 响应时间敏感(用户操作) | CMS / G1 + -XX:MaxGCPauseMillis 限制最大停顿 |
| 吞吐量优先(后台服务) | Parallel GC + -XX:+UseParallelGC |
| 超大堆,低延迟场景 | G1 / ZGC + 合理堆大小配置 |
JVM 调优的参数可以在哪里设置参数值?
如果你是想给你的 Java 程序设置 JVM 参数(如 -Xms512m -Xmx1024m):
- 在 IDEA 中打开你的项目。
- 点击右上角的 运行/调试配置(Edit Configurations...)。
- 在 VM options 输入框中填写你的 JVM 参数(如 -Xms512m -Xmx1024m -XX:+PrintGCDetails)。
- 之后运行这个配置时,就会带上这些 JVM 参数。
常用的JVM 调优的参数都有哪些?
- 堆内存相关参数(最常用)
- 用于控制 JVM 堆内存大小,防止 OOM 或频繁 GC。
-Xms<size>初始堆内存大小(如-Xms512m)-Xmx<size>最大堆内存大小(如-Xmx2g)-XX:NewRatio=n新生代和老年代的比例(如-XX:NewRatio=2→ 新生代:老年代 = 1:2)
- GC 相关参数
- 用于选择垃圾收集器、打印 GC 日志、调优回收策略。
-XX:+UseSerialGC使用串行垃圾收集器-XX:+UseG1GC使用 G1 收集器(推荐 JDK8+)
- 线程栈
- 控制每个线程的栈大小。
-Xss<size>每个线程的栈大小(默认 1M,调小可以支持更多线程)
- OOM 处理
- 让 JVM 在 OOM 时生成堆转储文件,便于分析。
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时生成 dump 文件-XX:HeapDumpPath=<path>dump 文件保存路径
说一下 JVM 调优的工具?
命令工具:
- jps 进程状态信息
- jstack 查看java进程内线程的堆栈信息
- jmap 查看堆转信息,查看堆内存分布
- jhat 堆转储快照分析工具
- jstat JVM统计监测工具
可视化工具:
- jconsole 用于对jvm的内存,线程,类 的监控
java内存溢出的排查思路?
内存溢出通常是指堆内存,通常是指一些大对象不被回收的情况
- 通过jmap或设置jvm参数获取堆内存快照dump
- 通过工具, VisualVM去分析dump文件,VisualVM可以加载离线的dump文件
- 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
- 找到对应的代码,通过阅读上下文的情况,进行修复即可
出现OOM了怎么排查?
OOM一般是堆内存溢出,产生OOM的原因:
- 分配过少:JVM 初始化内存小,业务使用了大量内存;或者不同 JVM 区域分配内存不合理
- 内存泄漏:某一个对象被频繁申请,不用了之后却没有被释放,发生内存泄漏,导致内存耗尽(比如ThreadLocal泄露)
- 使用jmap,生成Heap Dump文件
- 使用jvisualvm或jconsole等工具可以实时监控Java应用的内存使用情况
- 分析Heap Dump文件:VisualVM:除了监控功能外,也支持加载和分析Heap Dump文件
怎么看堆内存分布相关?
- jmap(JDK 自带)
1 | |
输出堆的总体布局,比如:
- 新生代、老年代、Metaspace 大小和使用率
- jconsole
CPU飙高排查方案与思路?
- 主要是查看是哪一个进程占用cpu较高
- 然后再查看哪一个线程出了问题
4. Java框架
4.1 IOC 控制反转
谈谈自己对于 Spring IoC 的了解?
属于Spring IOC,IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。
- 控制:指的是对象创建(实例化、管理)的权力
- 反转:控制权交给外部环境(Spring 框架、IoC 容器)
什么是 IoC?
例如:现有类 A 依赖于类 B
- 传统的开发方式 :往往是在类 A 中手动通过 new 关键字来 new 一个 B 的对象出来
- 使用 IoC 思想的开发方式 :不通过 new 关键字来创建对象,而是通过 IoC 容器(Spring 框架) 来帮助我们实例化对象。我们需要哪个对象,直接从 IoC 容器里面去取即可
1 | |
为什么要使用IOC?
IoC 的思想就是两方之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢?
- 对象之间的耦合度或者说依赖程度降低;
- 资源变的容易管理;比如你用 Spring 容器提供的话很容易就可以实现一个单例。
IOC 和 DI?
| 名称 | 全称 | 中文名 | 核心含义 |
|---|---|---|---|
| IOC | Inversion of Control | 控制反转 | 对象的创建和管理,交给框架(容器)完成。 |
| DI | Dependency Injection | 依赖注入 | 容器把所需对象“注入”到使用它的地方。如 @Autowired |
IOC 是一种思想,核心是谁控制对象的创建;
DI 是一种实现方式,核心是如何把依赖的对象传递进来。
4.2 AOP
什么是AOP?
AOP(Aspect Oriented Programming)即面向切面编程,AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立。
- AOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。
AOP称为面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为“切面”(Aspect),减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。
AOP 为什么叫面向切面编程?
AOP 之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)。
AOP 常见的通知类型有哪些?
- Before(前置通知):目标对象的方法调用之前触发
- After (后置通知):目标对象的方法调用之后触发
- AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
- AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发
- Around (环绕通知):在目标对象的方法调用前后搞事
AOP 的应用场景有哪些?
- 日志记录:自定义日志记录注解,利用 AOP,一行代码即可实现日志记录。
- 事务管理:@Transactional 注解可以让 Spring 为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional注解就是基于 AOP 实现的。
AOP 实现方式有哪些?
AOP 的常见实现方式有动态代理、字节码操作等方式。
- Spring AOP 就是基于动态代理的
你们项目中有没有使用到AOP?
记录操作日志,缓存,spring实现的事务(@Transactional)
- 核心是:使用aop中的环绕通知+切点表达式(找到要记录日志的方法),通过环绕通知的参数获取请求方法的参数(类、方法、注解、请求方式等),获取到这些参数以后,保存到数据库
Spring中的事务是如何实现的?
Spring支持编程式事务管理和声明式事务管理两种方式。
- 编程式事务控制:需使用
TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用 - 声明式事务管理(
@Transactional):声明式事务管理建立在AOP之上的。其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。
4.3 SpringBoot 自动配置
Springboot自动配置原理?
- 在Spring Boot项目中的引导类上有一个注解
@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:
@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan
- 其中
@EnableAutoConfiguration是实现自动化配置的核心注解。 该注解通过@Import注解导入对应的配置选择器。
内部就是读取了该项目和该项目引用的Jar包的的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。 在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中。 - 条件判断会有像
@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用
什么是 SpringBoot 自动装配?
通过注解或者一些简单的配置就能在 Spring Boot 的帮助下实现某块功能。
- Spring Boot 自动装配(AutoConfiguration)就是根据项目依赖和配置文件,自动帮你把需要的 Bean 配置好,省去手动写 @Configuration 或 xml 的繁琐步骤!
SpringBoot 是如何实现自动装配的?
Spring Boot 通过 @EnableAutoConfiguration 注解,配合 spring.factories 文件,扫描并加载自动配置类,结合 @Conditional 条件判断,按需自动装配Bean。
1️⃣ 关键注解
1 | |
这个注解其实是组合注解,等价于:
1 | |
其中:
1 | |
就是启动自动装配的入口。
这三个注解的作用分别是:
- @EnableAutoConfiguration:启用 SpringBoot 的自动配置机制
- @ComponentScan:扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。
- @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类
2️⃣ spring.factories 文件
Spring Boot 在 META-INF/spring.factories 中,
列出所有自动配置类的清单:
1 | |
作用:
启动时 Spring Boot 会扫描这些配置类,自动加载进IOC容器。
3️⃣ 条件注解 @Conditional 系列
自动配置类并不是无脑加载,而是通过条件注解决定:
| 注解 | 作用 |
|---|---|
@ConditionalOnClass |
当类路径下存在某个类时,才会生效。 |
@ConditionalOnMissingBean |
如果容器里没有某个Bean,才注入默认Bean。 |
@ConditionalOnProperty |
配置文件中某个属性满足条件时,才加载配置类。 |
根据你的项目依赖,按需加载!
自动装配完整流程
1️⃣ 启动类扫描 @SpringBootApplication
2️⃣ 发现 @EnableAutoConfiguration 注解;
3️⃣ 加载 spring.factories 文件,找到自动配置类;
4️⃣ 遍历每个自动配置类,使用 @Conditional 判断是否满足条件;
5️⃣ 满足条件,就将配置类中的 @Bean 方法注册到Spring容器中。
4.4 Spring
spring的理解?spring是什么?
Spring 提供了一个全面的基础架构支持,用于构建 Java 应用程序
Spring 的核心特性是 控制反转(IOC) 和 面向切面编程(AOP)
springboot和spring的区别?
- Spring 是一个功能强大的 Java 开发框架,提供了 IOC、AOP、事务、MVC 等核心功能,但需要繁琐配置
- Spring Boot 是对 Spring 的封装,提供了自动配置、内置容器、starter 依赖、Actuator 等,极大简化了 Spring 项目的开发、部署和运维
Spring框架中的bean是单例的吗?会有线程安全问题吗?
1 | |
singleton : bean在每个Spring IOC容器中只有一个实例。(默认情况)
prototype:一个bean的定义可以有多个实例。
不是线程安全的
Spring框架中有一个@Scope注解,默认的值就是singleton,单例的。
因为一般在spring的bean的中都是注入无状态(不可变)的对象,没有线程安全问题,如果在bean中定义了可修改的成员变量,是要考虑线程安全问题的,可以使用多例或者加锁来解决
Spring的事务传播机制
事务传播机制决定了在一个方法调用另一个方法时,如何处理事务的开始、提交和回滚等操作
Spring 提供了以下几种常用的事务传播行为,通常通过 @Transactional 注解来配置:
- PROPAGATION_REQUIRED(默认值)
- 如果当前存在事务,则加入该事务;如果当前没有事务,则新建一个事务
- PROPAGATION_REQUIRES_NEW
- 无论当前是否存在事务,都新建一个事务。如果当前存在事务,则将当前事务挂起
Spring中事务失效的场景有哪些?
- 异常捕获处理,自己处理了异常,没有抛出,解决:手动抛出
- 抛出检查异常,配置rollbackFor属性为Exception
- 非public方法导致的事务失效,改为public
- 异常捕获处理
- 事务通知只有捉到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知悉
- 解决:在catch块添加throw new RuntimeException(e)抛出
1 | |
- 抛出检查异常
- Spring 默认只会回滚非检查异常(RuntimeException及其子类)
- 解决:配置rollbackFor属性
@Transactional(rollbackFor=Exception.class)
1 | |
- 非public方法导致的事务失效
- Spring 为方法创建代理、添加事务通知、前提条件都是该方法是 public 的
- 解决:改为 public 方法
Spring的bean的生命周期?Spring容器是如何管理和创建bean实例?Bean加载过程?⭐⭐⭐⭐⭐
- 通过BeanDefinition获取bean的定义信息
- 调用构造函数实例化bean
- bean的依赖注入
- 处理Aware接口(BeanNameAware、BeanFactoryAware、ApplicationContextAware)
- Bean的后置处理器BeanPostProcessor-前置
- 初始化方法(InitializingBean、init-method)
- Bean的后置处理器BeanPostProcessor-后置
- 销毁bean
Spring中的循环引用?
A 需要注入 B B 需要注入 A
- A 创建 → 需要 B → B 创建 → 需要 A → A 还没创建完 → 依赖循环
1 | |
- 循环依赖:循环依赖其实就是循环引用,也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A
- 循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部分的循环依赖
- 一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
- 二级缓存:缓存早期的bean对象(生命周期还没走完)
- 三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的
构造方法出现了循环依赖怎么解决?⭐
A依赖于B,B依赖于A,注入的方式是构造函数
- 原因:由于bean的生命周期中构造函数是第一个执行的,spring框架并不能解决构造函数的的依赖注入
- 解决方案:使用
@Lazy进行懒加载,什么时候需要对象再进行bean对象的创建
1 | |
- 使用
@Lazy延迟注入 - 延迟注入某个 bean(等用到时再注入)
1 | |
讲讲spring的代理实现?Spring AOP代理的方式?
Spring 的代理实现是其核心特性之一,代理是实现面向切面编程(AOP)的基础
Spring 提供了两种代理方式:JDK 动态代理 和 CGLIB 代理。
- 如果要代理的对象,实现了某个接口,就使用JDK 动态代理,去创建代理对象
- 而对于没有实现接口的对象,会使用 Cglib 生成一个被代理对象的子类来作为代理
4.5 SpringMVC
Spring mvc介绍一下?
MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码
Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成
SpringMVC的执行流程知道嘛?
前后端分离场景下,SpringMVC 接口开发执行流程
在 前后端分离 模式:
- 后端 = 只负责提供数据(JSON、XML等)
- 前端 = 独立工程,通过 Ajax/Fetch/axios 请求后端接口
- 后端 不会返回 HTML 页面,只返回数据
在 SpringMVC 中:
- 返回 JSON 走 @ResponseBody / @RestController
流程步骤:
- 浏览器/前端 → 发请求
- DispatcherServlet(前端控制器)收到请求,DispatcherServlet统一处理所有请求
- 根据 URL → 查找 Controller 中匹配的方法(比如 /api/user/{id})
- HandlerAdapter → 调用 Controller 方法
- 执行 Controller 方法,返回对象,返回的是 User 对象
- 把 Java 对象序列化为 JSON 字符串(User → JSON)
- DispatcherServlet 把 JSON 写入 HTTP 响应体
- 浏览器/前端收到 JSON 响应
4.6 Spring框架常见注解
- Spring 的常见注解有哪些?
- SpringMVC常见的注解有哪些?
- Springboot常见注解有哪些?
Spring用过哪些注解?
在 Spring 中,我使用过以下几个常见的注解:
- @Component:标记一个类为 Spring 容器中的一个组件,Spring 会自动将它注册为 Bean。
- @Autowired:用于自动注入依赖,Spring 会根据类型自动匹配 Bean。
- @RequestMapping:用于映射 HTTP 请求到方法上,用于 Spring MVC 中处理请求的路由
- @PostMapping / @GetMapping / @PutMapping / @DeleteMapping:是 @RequestMapping 的快捷方式,分别用于处理 POST、GET、PUT、DELETE 请求
- @Transactional:用于标记事务性方法,自动管理事务
@Autowired和@Resource区别?
- @Autowired 是 Spring 提供的注解,默认按类型注入
- Spring 会通过反射机制,查找与目标字段数据类型匹配的 Bean
- @Resource 是 Java 标准注解,默认按名称注入
- Spring 会查找容器中与目标字段名称相匹配的 Bean
Spring 的常见注解有哪些?
| 注解 | 说明 |
|---|---|
| @Component、@Controller、@Service、@Repository | 使用在类上用于实例化Bean |
| @Autowired | 使用在字段上用于根据类型依赖注入 |
| @Qualifier | 结合@Autowired一起使用用于根据名称进行依赖注入 |
| @Scope | 标注Bean的作用范围 |
| @Configuration | 指定当前类是一个Spring配置类,当创建容器时会从该类上加载注解 |
| @ComponentScan | 用于指定Spring在初始化容器时要扫描的包 |
| @Bean | 使用在方法上,标注将该方法的返回值存储到Spring容器中 |
| @Import | 使用@Import导入的类会被Spring加载到IOC容器中 |
| @Aspect、@Before、@After、@Around、@Pointcut | 用于切面编程(AOP) |
SpringMVC常见的注解有哪些?
| 注解 | 说明 |
|---|---|
| @RequestMapping | 用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中的所有方法都是以该地址作为父路径 |
| @RequestBody | 注解实现接收http请求的json数据,将json转换为java对象 |
| @RequestParam | 指定请求参数的名称 |
| @PathVariable | 从请求路径中获取请求参数(如/user/{id}),传递给方法的形式参数 |
| @ResponseBody | 注解实现将Controller方法返回对象转化为json对象响应给客户端 |
| @RequestHeader | 获取指定的请求头数据 |
| @RestController | @Controller + @ResponseBody |
Springboot常见注解有哪些?
| 注解 | 说明 |
|---|---|
| @SpringBootConfiguration | 组合了@Configuration注解,实现配置文件的功能 |
| @EnableAutoConfiguration | 打开自动配置的功能,也可以关闭某个自动配置的选项 |
| @ComponentScan | Spring组件扫描 |
4.7 MyBatis
MyBatis执行流程?
当我们调用一个 Mapper 方法(比如 userMapper.findById(1))
- 加载配置(初始化阶段)
- 读取 mybatis-config.xml 配置文件
- 解析 数据库连接、映射文件(Mapper XML)、插件、类型别名、类型处理器等
- 创建 SqlSessionFactory(工厂对象)
- 获取 SqlSession
- 从 SqlSessionFactory.openSession() 获取一个 SqlSession 实例
- 通过 SqlSession 获取 Mapper 的代理对象,执行 SQL
- MyBatis 会使用 动态代理(JDK Proxy) 生成 Mapper 接口的实现类
- 返回结果
- SQL 执行完成 → 返回 Java 对象(List、单个对象、基本类型等)
Mybatis是否支持延迟加载?懒加载?
Mybatis支持延迟记载,但默认没有开启
什么叫做延迟加载?
查询用户的时候,把用户所属的订单数据也查询出来,这个是立即加载
查询用户的时候,暂时不查询订单数据,当需要订单的时候,再查询订单,这个就是延迟加载
延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。
Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载
在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的
延迟加载的底层原理知道吗?
- 使用CGLIB创建目标对象的代理对象(动态代理)
- 当调用目标方法
user.getOrderList()时,进入拦截器invoke方法,发现user.getOrderList()是null值,执行sql查询order列表 - 把order查询上来,然后调用
user.setOrderList(List<Order> orderList),接着完成user.getOrderList()方法的调用
mybatis中#{}和${}的区别?
在 MyBatis 中,#{} 和 ${} 都是用于处理 SQL 语句中的动态内容的占位符
- #{} - 预处理语句占位符
- 对于大部分参数,尤其是用户输入的参数,应该使用 #{} 来确保 SQL 注入的安全
- 参数作为绑定变量,自动转义,防止 SQL 注入
- ${} - 直接替换占位符
- 参数直接插入到 SQL 语句中,不进行转义或类型转换
5. MySQL
5.1 数据库基础知识
整数类型的 UNSIGNED 属性有什么用?
整数类型可以使用可选的 UNSIGNED 属性来表示不允许负值的无符号整数。使用 UNSIGNED 属性可以将正整数的上限提高一倍,因为它不需要存储负数值。
- 例如, TINYINT UNSIGNED 类型的取值范围是 0 ~ 255
CHAR 和 VARCHAR 的区别是什么?
- CHAR 是定长字符串
- VARCHAR 是变长字符串。
- CHAR 在存储时会在右边填充空格以达到指定的长度,检索时会去掉空格
- VARCHAR 在存储时需要使用 1 或 2 个额外字节记录字符串的长度,检索时不需要处理。
MySQL 的 NULL 值是怎么存放的?
MySQL 的 Compact 行格式中会用「NULL值列表」来标记值为 NULL 的列,NULL 值并不会存储在行格式中的真实数据部分。
为什么 MySQL 不建议使用 NULL 作为列默认值?
- NULL 语义不明确 : NULL 在数据库中表示“未知”或“没有值”,不是“空”或者“0”。
- NULL 在索引中处理有特殊逻辑。对于含 NULL 的字段,索引的存储、查找效率会略有下降
数据库中的join
join是把多个表的数据组合在一起的操作
- INNER JOIN(内连接)
- 只返回两个表中“匹配”的行
- LEFT JOIN(左连接 / 左外连接)
- 返回左表的所有行,即使右表没有匹配
- RIGHT JOIN(右连接 / 右外连接)
- 和 LEFT JOIN 类似,但保留右表的所有行
- FULL JOIN(全连接 / 全外连接)
- 返回左右两边所有的行,没匹配到的部分用 NULL 填充
数据库中常用的函数?
聚合函数(配合 GROUP BY 用)
| 函数 | 作用 | 示例 |
|---|---|---|
COUNT(*) |
统计行数 | SELECT COUNT(*) FROM orders |
SUM(col) |
求和 | SUM(amount) |
AVG(col) |
平均值 | AVG(price) |
MAX(col) / MIN(col) |
最大值 / 最小值 | MAX(age) → 60, MIN(age) → 18 |
limit(a,b)是什么意思; a变大,对于这个查询的性能有影响么?
1 | |
- 跳过前 10 行 → 从第 11 行开始
- 返回 5 行 → 总共返回第 11~15 行
a 变大,对性能有影响吗?
- 有影响,尤其是在大表上!
- MySQL 会先扫描出前 a + b 行,然后扔掉前 a 行,只保留后 b 行返回
如何优化深分页查询?
- 使用覆盖索引 + 延迟关联
- 记录上次查询位置
- 使用子查询优化
MySQL 执行流程是怎样的?⭐
MySQL 的架构共分为两层:Server 层和存储引擎层
- Server 层负责建立连接、分析和执行 SQL
- 存储引擎层负责数据的存储和提取
- 连接器:建立连接,管理连接、校验用户身份;
- 查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块;
- 解析 SQL: 通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树
- 执行 SQL:执行 SQL 共有三个阶段:
- 预处理阶段:检查表或字段是否存在;将 select * 中的 * 符号扩展为表上的所有列。
- 优化阶段:基于查询成本的考虑, 选择查询成本最小的执行计划;
- 执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端;
1 | |
undo log和redo log的区别?
- 缓冲池(buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度
- 数据页(page):是InnoDB 存储引擎磁盘管理的最小单元,每个页的大小默认为 16KB。页中存储的是行数据
redo log
重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性。
该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中, 用于在刷新脏页到磁盘,发生错误时, 进行数据恢复使用。
undo log
回滚日志,用于记录数据被修改前的信息 , 作用包含两个 : 提供回滚 和 MVCC(多版本并发控制) 。undo log和redo log记录物理日志不一样,它是逻辑日志。
- 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,
- 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。
- undo log可以实现事务的一致性和原子性
undo log和redo log的区别
- redo log: 记录的是数据页的物理变化,服务宕机可用来同步数据
- undo log :记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据
- redo log保证了事务的持久性,undo log保证了事务的原子性和一致性
你的项目中用到了分库分表了吗?
虽然我的项目是微服务项目,但是因为数据量小,业务模型不复杂,所有的服务就共同使用了同一个数据库,数据库里面就是正常的表结构。
5.2 MySQL 存储引擎
MySQL 当前默认的存储引擎是 InnoDB。并且,所有的存储引擎中只有 InnoDB 是事务性存储引擎,也就是说只有 InnoDB 支持事务。
MySQL 的数据存放在哪个文件?
创建一个 database(数据库)共有三个文件,这三个文件分别代表着:
- db.opt: 用来存储当前数据库的默认字符集和字符校验规则。
- t_order.frm: t_order 的表结构会保存在这个文件
- t_order.ibd,t_order 的表数据会保存在这个文件
一张数据库表的数据是保存在「 表名字.ibd 」的文件里的,这个文件也称为表空间文件。
表空间文件的结构是怎么样的?
表空间由段(segment)、区(extent)、页(page)、行(row)组成
- 行: 数据库表中的记录都是按行(row)进行存放的
- 页: InnoDB 的数据是按「页」为单位来读写的,默认每个页的大小为 16KB,按照页读取是为了确保IO读取效率
- 区: 在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了
- 段: 表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。
InnoDB 行格式有哪些?
行格式(row_format),就是一条记录的存储结构。
这次重点介绍 Compact 行格式
COMPACT 行格式长什么样?
一条完整的记录分为「记录的额外信息」和「记录的真实数据」两个部分
- 记录的额外信息包含 3 个部分:变长字段长度列表、NULL 值列表、记录头信息
- 变长字段长度列表
- NULL 值列表
- 如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列
- 二进制位的值为1时,代表该列的值为NULL
- 当一条记录有 9 个字段值都是 NULL,那么就会创建 2 字节空间的「NULL 值列表」,以此类推
- 记录头信息
- delete_mask :标识此条数据是否被删除
- next_record:下一条记录的位置
- 记录的真实数据
- 除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer
- row_id:如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了
- trx_id:事务id,表示这个数据是由哪个事务生成的。
- roll_pointer:记录上一个版本的指针
- 除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer
行溢出后,MySQL 是怎么处理的?
发生行溢出,多的数据就会存到另外的「溢出页」中
- 在一般情况下,InnoDB 的数据都是存放在 「数据页」中。
- 但是当发生行溢出时,溢出的数据会存放到「溢出页」中。
5.3 索引
会写创建索引的DDL吗?
1 | |
了解过索引吗?什么是索引?
索引是数据的目录
- 索引和数据就是位于存储引擎中
- 索引(index)是帮助MySQL高效获取数据的数据结构(有序)
- 提高数据检索的效率,降低数据库的IO成本(不需要全表扫描)
索引的分类
按照四个角度来分类索引:
- 按「数据结构」分类:B+tree索引、Hash索引、Full-text索引
- 按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)
- 按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引
- 按「字段个数」分类:单列索引、联合索引
1. 按数据结构分类
MySQL 常见索引有 B+Tree 索引、HASH 索引、Full-Text 索引
创建的主键索引和二级索引默认使用的是 B+Tree 索引。
其它索引都属于辅助索引(Secondary Index),也被称为二级索引
B+Tree 是一种多叉树,叶子节点才存放数据,非叶子节点只存放索引
每个节点里的数据是按主键顺序存放的
每一层父节点的索引值都会出现在下层子节点的索引值中,因此在叶子节点中,包括了所有的索引值信息
每一个叶子节点都有两个指针,分别指向下一个叶子节点和上一个叶子节点,形成一个双向链表。
B+Tree 相比于 B 树和二叉树来说,最大的优势在于查询效率很高,即使在数据量很大的情况,查询一个数据的磁盘 I/O 依然维持在 3-4次
通过二级索引查询商品数据的过程
- 主键索引的 B+Tree 的叶子节点存放的是实际数据
- 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
- 会先检二级索引中的 B+Tree 的索引值,找到对应的叶子节点,然后获取主键值
- 再通过主键索引中的 B+Tree 树查询到对应的叶子节点,然后获取整行数据。
- 也就是说要查两个 B+Tree 才能查到数据
2. 按物理存储分类
从物理存储的角度来看,索引分为聚簇索引(主键索引)、二级索引(辅助索引)
- 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
- 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
3. 按字段特性分类
索引分为主键索引、唯一索引、普通索引、前缀索引。
- 主键索引
- 主键索引就是建立在主键字段上的索引,一张表最多只有一个主键索引
- 唯一索引
- 唯一索引建立在 UNIQUE 字段上的索引,一张表可以有多个唯一索引,索引列的值必须唯一
- 普通索引
- 普通索引就是建立在普通字段上的索引,既不要求字段为主键,也不要求字段为 UNIQUE。
- 前缀索引
- 前缀索引是指对字符类型字段的前几个字符建立的索引,而不是在整个字段上建立的索引
4. 按字段个数分类
索引分为单列索引、联合索引(复合索引)
- 建立在单列上的索引称为单列索引,比如主键索引;
- 建立在多列上的索引称为联合索引;
- 使用联合索引时,存在最左匹配原则
什么是聚簇索引什么是非聚簇索引? 什么是回表?⭐⭐⭐⭐⭐
- 聚簇索引(聚集索引):数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个
- 主键索引、唯一索引、InnoDB自动生成一个默认索引
- 非聚簇索引(二级索引):数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个
通过二级索引找到对应的主键值,到聚集索引中查找整行数据,这个过程就是回表查询
知道什么叫覆盖索引嘛 ?
覆盖索引是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到 。
1 | |
- 使用id查询,直接走聚集索引查询,一次索引扫描,直接返回数据,性能高。
- 如果返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用select *
二级索引会比全盘扫描效率低吗?
大多数情况下,二级索引查询比全表扫描快
MYSQL超大分页怎么处理?
1 | |
MySQL 必须从头开始扫描 1000000 + 10 行数据,再扔掉前面 1000000 行,只返回最后 10 行
- 随着 offset 增大,扫描、丢弃的数据越来越多,响应越来越慢。
解决方案:
- 先定位 id,再根据 id 查整行数据,避免回表扫描太多数据
- 先查询id,子查询
1 | |
- 再根据 id 查整行数据
为什么 MySQL InnoDB 选择 B+tree 作为索引的数据结构?⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
B+Tree 是一种多路平衡查找树,特点:
- 所有数据都在叶子节点;
- 非叶子节点只存储键,不存储值;
- 叶子节点通过链表指针串联,顺序访问超快。
- 阶数更多,路径更短,一般三层就能存储两千万以内的数据
- 磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据
- B+树便于扫库和范围查询,叶子节点是一个双向链表
- B+Tree vs B Tree
- B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的磁盘 I/O 次数下,就能查询更多的节点
- B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找
- B+Tree vs 二叉树
- 二叉树的每个父节点的儿子节点个数只能是 2 个
- 二叉树检索到目标数据所经历的磁盘 I/O 次数要更多
- B+Tree vs Hash
- Hash 表不适合做范围查询,它更适合做等值的查询
什么时候需要使用索引?索引创建原则有哪些?
- 针对于数据量较大,且查询比较频繁的表建立索引。
- 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
- 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
- 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
- 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
- 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率
- 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。
为什么不能给每个字段都设置索引?
- 索引会占用磁盘空间
- 索引会拖慢写入性能(增删改)
- 有些字段根本不适合作为索引
什么时候索引会失效?
怎么判断索引是否失效呢?
- 执行计划explain
- 如果key和key_len字段为NULL说明此时索引失效了
- 联合索引时,违反最左前缀法则
- 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效
- 联合索引时,范围查询右边的列,不能使用索引
- 如果一个索引使用了范围查询(大于、小于等),则此索引之后的索引失效
- 不要在索引列上进行运算操作, 索引将失效
- 例如做函数运算,substring()等
- 字符串不加单引号,造成索引失效。(类型转换)
- 没有对字符串加单引号, MySQL的查询优化器,会自动的进行类型转换,造成索引失效
- 以%开头的Like模糊查询,索引失效
- 如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。
有什么优化索引的方法?
根据索引创建原则和索引失效回答
- 覆盖索引
- 查询字段全部被索引覆盖,MySQL 可直接从索引返回数据,不需要回表
- 主键索引最好是自增的
- 根据查询条件,优先使用覆盖索引,减少回表。
- 使用 联合索引时注意最左前缀法则,避免中间列跳用导致索引失效
- 避免对索引列进行函数、隐式类型转换,否则索引失效
Mysql设计索引的原则,主键用自增id和uuid哪个好?
- 自增 ID:普通业务场景、单库或简单分库分表系统
- UUID: 全局唯一,适合分布式,不依赖数据库生成,分布式场景,需要全局唯一ID、数据迁移场景
谈一谈你对sql的优化的经验?
- 索引优化
- SQL 语句结构优化
- SELECT语句务必指明字段名称(避免直接使用select * )
- SQL语句要避免造成索引失效的写法
- 避免在where子句中对字段进行表达式操作
- 表的设计优化
- 比如设置合适的数值(tinyint int bigint)
- 比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低
- 分库分表
为什么MYSQL使用B+树存储索引详解?
MySQL 是会将数据持久化在硬盘,而存储功能是由MySQL存储引擎实现的,所以讨论MySQL使用哪种数据结构作为索引,实际上是在讨论存储引使用哪种数据结构作为索引,InnoDB是MySQL默认的存储引擎,它就是采用了B+树作为索引的数据结构。
要设计一个MySQL的索引数据结构,不仅仅考虑数据结构增删改的时间复杂度,更重要的是要考虑磁盘I/O的操作次数.因为索引和记录都是存放在硬盘,硬盘是一个非常慢的存储设备,我们在查询数据的时候,最好能在尽可能少的磁盘I/0的操作次数内完成。
二分查找树虽然是一个天然的二分结构,能很好的利用二分查找快速定位数据,但是它存在一种极端的情况,每当插入的元素都是树内最大的元素,就会导致二分查找树退化成一个链表,此时查询复杂度就会从O(logn)降低为O(n)。
为了解决二分查找树退化成链表的问题,就出现了自平衡二叉树,保证了查询操作的时间复杂度就会一直维持在O(logn)。但是它本质上还是一个二叉树,每个节点只能有2个子节点,随着元素的增多,树的高度会越来越高。
而树的高度决定于磁盘I/O操作的次数,因为树是存储在磁盘中的,访问每个节点,都对应一次磁盘I/O操作,也就是说树的高度就等于每次查询数据时磁盘IO操作的次数,所以树的高度越高,就会影响查询性能。
B树和B+都是通过多叉树的方式,会将树的高度变矮,所以这两个数据结构非常适合检索存于磁盘中的数据。
但是MySQL默认的存储引擎InnoDB采用的是B+作为索引的数据结构,原因有:
- B+树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的B树,B+树的非叶子节点可以存放更多的索引,因此B+树可以比B树更「矮胖」,查询底层节点的磁盘I/O次数会更少。
- B+树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让B+树在插入、删除的效率都更高,比如删除根节点的时候,不会像B树那样会发生复杂的树的变化;
- B+树叶子节点之间用链表连接了起来,有利于范围查询,而B树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘I/O操作,范围查询效率不如B+树。
5.4 事务
sql中启动事务、提交事务的命令是什么?
1 | |
事务的特性是什么?
ACID
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成
- 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
- 持久性是通过 redo log (重做日志)来保证的;
- 原子性是通过 undo log(回滚日志) 来保证的;
- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;
- 一致性则是通过持久性+原子性+隔离性来保证;
- 只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的
数据库事务有什么作用呢?
数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行
并发事务会引发什么问题?
MySQL 服务端是允许多个客户端连接的,这意味着 MySQL 会出现同时处理多个事务的情况。
在同时处理多个事务的时候,就可能出现脏读、不可重复读,幻读的问题。
- 脏读:如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。
- 不可重复读: 在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。
- 幻读:在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。
不可重复读和幻读有什么区别?
- 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改
- 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了
事务的隔离级别有哪些?
- 脏读:读到其他事务未提交的数据;
- 不可重复读:前后读取的数据不一致;
- 幻读:前后读取的记录数量不一致。
严重性排序如下:脏读>不可重复读>幻读
SQL 标准定义了四个隔离级别:
- READ-UNCOMMITTED(读取未提交) :最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
- READ-COMMITTED(读取已提交) :允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
- REPEATABLE-READ(可重复读) :对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
- SERIALIZABLE(可串行化) :最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重复读)
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读取未提交 | 可能 | 可能 | 可能 |
| 读取已提交 | 避免 | 可能 | 可能 |
| 可重复读 | 避免 | 避免 | 可能(MySQL已解决) |
| 可串行化 | 避免 | 避免 | 避免 |
为什么可重复读会发生幻读?说下原理,举个例子
MySQL InnoDB 默认的事务隔离级别: 可重复读(Repeatable Read)
- 保证同一个事务内,多次读取同一行记录的结果一致
- 不保证多次查询时,满足条件的行数、总记录数不变
MySQL 的隔离级别是基于锁实现的吗?
- MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。
- SERIALIZABLE 隔离级别是通过锁来实现的
- READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的
四种隔离级别下的锁有什么区别?
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 锁机制 |
|---|---|---|---|---|
| 读未提交 | 允许 | 可能发生 | 可能发生 | 不加锁 |
| 读已提交 | 不允许 | 可能发生 | 可能发生 | 共享锁(S锁) |
| 可重复读 | 不允许 | 不允许 | 可能发生 | 共享锁(S锁) + 间隙锁(gap lock) |
| 串行化 | 不允许 | 不允许 | 不允许 | 排他锁(X锁) |
并发事务的控制方式有哪些?
MySQL 中并发事务的控制方式无非就两种:锁 和 MVCC
- 锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。
MySQL 中主要是通过 读写锁来实现并发控制。
- 共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)
- 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)
- 读写锁可以做到读读并行,但是无法做到写读、写写并行
MVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本。通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。
事务中的隔离性是如何保证的呢?
- 锁:排他锁(如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁)
- mvcc : 多版本并发控制
你解释一下MVCC?事务中的隔离性是如何保证的呢?
READ-COMMITTED 和 REPEATABLE-READ (RC和RR)隔离级别是基于 MVCC 实现的
MySQL中的多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突
- 隐藏字段:
- trx_id(事务id),记录每一次操作的事务id,是自增的
- roll_pointer(回滚指针),指向上一个版本的事务版本记录地址
- undo log:
- 回滚日志,存储老版本数据
- 版本链:多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表
- readView解决的是一个事务查询选择版本的问题
- 根据readView的匹配规则和当前的一些事务id判断该访问那个版本的数据
- 不同的隔离级别快照读是不一样的,最终的访问的结果不一样
- RC :每一次执行快照读时生成ReadView
- RR:仅在事务中第一次执行快照读时生成ReadView,后续复用
5.5 MySQL优化
在MySQL中,如何定位慢查询?
现象:
在做聚合查询、多表查询、表数据量过大查询、深度分页查询
表象:页面加载过慢、接口压测响应时间过长(超过1s)
方法1:开源工具
方法2:MySQL自带慢查询日志
- 慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志
开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置如下信息:
1 | |
一个SQL语句执行很慢, 如何分析?
可以采用EXPLAIN命令获取 MySQL 如何执行 SELECT 语句的信息
- 通过key和key_len检查是否命中了索引(索引本身存在是否有失效的情况)
- 通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描
- 通过extra建议判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复
1 | |
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ref | idx_user_id | idx_user_id | 4 | const | 10 | Using where |
- possible_key 当前sql可能会使用到的索引
- key 当前sql实际命中的索引
- key_len 索引占用的大小
- Extra 额外的优化建议
通过key和key_len两个查看是否可能会命中索引
EXTRA 中常见的值含义(可以看出索引是否回表查询)
- Using index:查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
- Using where:查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
- Using index condition:查找使用了索引,但是需要回表查询数据
type 是非常重要的字段 → 表示访问方式
ALL:全表扫描(最差的)index:索引全扫描range:索引范围扫描ref:非唯一索引扫描eq_ref:唯一索引扫描const、system:最快的(常量或系统表)
- 要避免
ALL和index的出现
用户的每一次操作都需要更新数据库,写频繁如何优化?
- 批量写入:将多次操作合并成一次 INSERT 或 UPDATE
- 延迟更新:写入 Redis 等缓存,定时批量刷入数据库
- 异步写入:前端操作只入消息队列(MQ),后台异步消费更新数据库
脏数据写入了表中,如何处理,如何避免影响扩大化?
- 定位脏数据范围
- 修复或剔除脏数据:通过 SQL 更新为正确状态
- 通知依赖系统: 通知接口、缓存、下游数据仓库团队,让他们也做同步修正
sql三大日志详细说一下?
三大日志通常指的是 事务日志、错误日志 和 慢查询日志
| 日志类型 | 作用 | 内容示例 | 使用场景 |
|---|---|---|---|
| 事务日志 | 记录数据库的所有事务操作,用于崩溃恢复和数据一致性 | 事务提交、回滚、修改数据记录 | 数据恢复、事务管理、主从同步、备份恢复 |
| 错误日志 | 记录数据库的错误、警告、系统崩溃等信息 | 启动、停止信息,系统崩溃错误 | 故障排查、性能分析、健康监控 |
| 慢查询日志 | 记录执行时间较长的 SQL 查询,用于性能优化 | 执行的 SQL、执行时间 | 性能优化、查询分析、数据库监控 |
update更新记录,三种日志怎么记录的?
| 日志类型 | 记录内容 | 作用 | 工作原理 |
|---|---|---|---|
| 事务日志 | 记录 UPDATE 操作的修改数据、修改前后的值、提交/回滚的情况 |
用于数据恢复、保证数据一致性 | 记录修改操作的详细内容,并在崩溃后进行恢复;支持回滚和回放操作。 |
| 错误日志 | 记录数据库的错误信息,如 UPDATE 操作失败、系统崩溃等 |
用于故障排查,帮助定位数据库问题 | 记录与 UPDATE 操作相关的任何错误或异常。 |
| 慢查询日志 | 记录执行时间超过阈值的 UPDATE 语句,记录执行时间、扫描的行数等 |
用于性能优化,识别性能瓶颈 | 记录执行时间较长的查询,帮助优化慢速查询。 |
数据库优化后,扫描的数据量依然很大,直接去查询数据过多,如何应对?
- 限制返回量(limit + 分页)
- 分析业务需求,减少字段
- 不要 SELECT *,只查业务需要的字段
- 分表、分库、分区
6. Redis
6.1 Redis基础
我看你做的项目中,都用到了redis,你在最近的项目中哪些场景使用了redis呢?
- 缓存
- 缓存三兄弟(穿透、击穿、雪崩)、双写一致、持久化、数据过期策略,数据淘汰策略
- 分布式锁
- setnx、redisson
- 消息队列、延迟队列
- 何种数据类型
Redis是单线程的,但是为什么还那么快?⭐⭐⭐
- Redis是纯内存操作,执行速度非常快
- 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题
- 使用I/O多路复用模型,非阻塞IO
- Redis执行命令等操作是单线程的,但是IO操作时是多线程的
能解释一下I/O多路复用模型?
是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。
Redis网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求
MySQL和Redis的事务有什么区别吗?
- MySQL 的事务更加复杂,支持强大的 ACID 特性,适合需要强一致性和事务管理的场景。
- Redis 的事务设计简单,重点是 原子性 和高性能,但不支持复杂的隔离级别和回滚机制,适合对性能要求较高、且容忍一定不一致性的场景
| 特性 | MySQL 事务 | Redis 事务 |
|---|---|---|
| ACID 支持 | 完全支持,遵循 ACID 原则 | 只支持原子性,不支持 ACID 中的隔离性与持久性 |
| 事务隔离级别 | 支持多种隔离级别(如 READ COMMITTED, REPEATABLE READ) | 不支持隔离级别,事务没有强隔离性 |
| 回滚机制 | 支持回滚,错误时撤销所有操作 | 不支持回滚,命令一旦执行不可撤销 |
| 并发控制 | 支持锁(行级锁、表级锁) | 不支持锁机制,事务中的命令按顺序执行 |
| 性能 | 性能较慢,尤其在高并发场景下 | 性能高,操作快速 |
| 事务的执行方式 | 使用 BEGIN、COMMIT、ROLLBACK 管理事务 |
使用 MULTI 和 EXEC 封装多个命令 |
| 事务一致性 | 强一致性,保证事务的原子性和一致性 | 只能保证原子性,不保证一致性 |
Redis事务处理过程中,服务器崩了,会怎样?
- 事务命令丢失:如果 Redis 崩溃时尚未执行的命令将丢失,且 Redis 没有回滚机制,因此,事务中的部分命令可能会被丢失
- 持久化机制影响:启用 AOF 或 RDB 持久化能在一定程度上减少数据丢失
为什么要用redis? 你怎么判断会打崩mysql?
- 缓存热点数据:将频繁查询的热点数据缓存到 Redis 中,减轻 MySQL 的负担,避免每次都去查询 MySQL,尤其是在高并发场景下
- 会话管理:对于需要存储会话数据的应用,可以使用 Redis 来管理会话状态,避免 MySQL 每次都存取会话信息
redis数据存在哪里?
Redis 是一个 内存数据库,它将数据主要存储在 RAM(内存) 中
6.2 缓存
什么是缓存穿透?怎么解决?
问题描述:
查询一个不存在的数据,缓存不命中,每次都打到数据库,造成数据库压力大。
解决方案:
- 布隆过滤器:使用布隆过滤器存储所有可能存在的key,查询前先用布隆过滤器判断,过滤掉不存在的请求,避免无效穿透。
- 缓存空值:对于数据库查不到的结果,缓存一个空对象(如
null或默认值),并设置短过期时间,防止频繁请求击穿数据库。
你能介绍一下布隆过滤器吗?
布隆过滤器使用一个bitmap位数组和多个哈希函数
当一个元素被加入集合:
- 用 k 个不同的哈希函数对元素哈希,得到 k 个数组下标
- 把这 k 个下标位置的位都置为 1
查询时,只要目标元素对应位置都为 1,就可能存在,否则必定不存在
有误判率
- 可能会误判“存在”(即说“在”,但其实不在)
- 一般可以通过增大bitmap数组的长度来降低误判率
怎么降低布隆过滤器的误判率?
- 增加位数组的大小
- 增加哈希函数的数量
- 选择合适的哈希函数:理想的哈希函数应该是均匀分布的,能够尽量避免哈希冲突
什么是缓存击穿?怎么解决?
问题描述:
热点key刚好失效,大量请求同时访问,缓存未命中,瞬间压垮数据库。
解决方案:
互斥锁/分布式锁:只有一个请求去加载数据库,其余请求等待锁释放,确保数据库不会被击穿。使用如 Redis 的 SETNX 去设置一个互斥锁
提前预热/自动续期:在key快过期时,提前异步刷新缓存,保证热点数据持续有效。
逻辑过期:
- 在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间
- 当查询的时候,从redis取出数据后判断时间是否过期
- 如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新
如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题
如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致
什么是缓存雪崩?怎么解决?
问题描述:
大量缓存同一时间集中失效,所有请求直接落到数据库,造成系统雪崩。
解决方案:
- 随机过期时间:给不同的Key的TTL添加随机值,设置缓存时,在基础时间上加一个随机值,避免大量key同一时间失效。
- 利用Redis集群提高服务的可用性:哨兵模式、集群模式
- 给缓存业务添加降级限流策略:ngxin或spring cloud gateway,通过限流(如令牌桶)或熔断机制保护数据库,防止瞬间压力过大。
- 给业务添加多级缓存:Guava或Caffeine
redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
我的业务里面没有那么强一致性:
- 先更新数据库,再删除缓存
- 延迟双删
- 先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据
我的业务里面如果保证强一致性:
- Redisson实现的读写锁
- 在读的时候添加共享锁,可以保证读读不互斥、读写互斥
- 更新数据的时候,添加排他锁。它是读写、读读都互斥,排他锁底层使用的也是SETNX,保证了同时只能有一个线程操作锁住的方法
允许延时一致的业务,采用异步通知
- 使用MQ中间中间件,更新数据之后,通知缓存删除
redis做为缓存,数据的持久化是怎么做的?
在Redis中提供了两种数据持久化的方式:1 RDB;2 AOF
RDB(适合备份、迁移)
Redis 会在指定时间间隔将内存中的数据生成一个快照(snapshot)保存到磁盘,当 Redis 重启时,会从这个快照文件加载数据到内存。
触发方式:
- 自动触发(通过 save 配置,比如 save 900 1 表示 900 秒内至少 1 次修改触发快照)
- 手动触发 SAVE 或 bgsave 命令
- SAVE:主线程阻塞生成 RDB
- BGSAVE:后台子进程 fork() 出来处理,主线程继续响应请求
AOF(Append Only File)
- Redis 会把 每次执行的写命令追加到 AOF 文件
- Redis 重启时,读取 AOF 文件,按顺序“重放”命令来恢复数据
写入策略(可配置):
- appendfsync always 每个写命令都追加到磁盘(最安全,最慢)
- appendfsync everysec 每秒追加一次(平衡,默认推荐)
- appendfsync no 交给操作系统自己决定什么时候刷盘(最快,最不安全)
数据更安全(最多丢失 1 秒数据,比 RDB 粒度小)
AOF 重写机制(AOF Rewrite)
- 为了压缩体积,Redis 支持 AOF 重写:生成一份 当前数据状态对应的最少命令集
- 通过
bgrewriteaof触发
AOF中系统调用是哪个函数?
- write():将命令写入 AOF 文件
- fsync():确保将写入的命令同步到磁盘,保证数据持久化
假如redis的key过期之后,会立即删除吗?Redis的数据过期策略
Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略)
- 惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key
- 优点 :对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
- 缺点 :对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放
- 定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。
定期清理有两种模式:
- SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf 的hz 选项来调整这个次数
- FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用
假如缓存过多,内存是有限的,内存被占满了怎么办?Redis的数据淘汰策略
数据的淘汰策略:当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。
Redis支持8种不同策略来选择要删除的key:
noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
allkeys-random:对全体key ,随机进行淘汰。
volatile-random:对设置了TTL的key ,随机进行淘汰。
allkeys-lru: 对全体key,基于LRU算法进行淘汰
volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
allkeys-lfu: 对全体key,基于LFU算法进行淘汰
volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
- key1是在3s之前访问的, key2是在9s之前访问的,删除的就是key2
LFU(Least Frequently Used)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
- key1最近5s访问了4次, key2最近5s访问了9次, 删除的就是key1
数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
- 使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据
Redis的内存用完了会发生什么?
- 主要看数据淘汰策略是什么?如果是默认的配置( noeviction ),会直接报错
6.3 Redis分布式锁⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Redis分布式锁如何实现?
在分布式系统中,不同节点或服务访问同一资源时,需要一种“互斥机制”来防止并发问题。
1. 用 SETNX 实现分布式锁
1 | |
- lock_key:锁名
- unique_value:锁值(唯一标识这个客户端/线程)
- NX:仅当 key 不存在时才设置(等同 SETNX)
- EX 30:设置 30 秒过期,解决“死锁”问题(进程挂掉后,锁 30 秒后自动释放)
设置redis过期时间的原子性问题?
先设置一个 key,然后给它加一个 60 秒的过期时间
1 | |
- 这两个命令是 分开的,它们之间不是原子操作
解决方案:原子性命令
- SET 带 EX 参数(推荐用)
SET key value EX 60
这个命令是 原子性的,保证写入和设置过期时间在同一个步骤完成
问题:如果锁过期后被别人重新抢到,旧客户端误删新锁?锁释放的校验机制?
- 只释放属于自己的锁 → 需要判断 value 是否还是自己的
- 锁的 value 保存客户端的唯一标识(例如 UUID、线程 ID 等)
- 用 Lua 脚本确保 “get + del” 原子操作
redis中setnx的NX的原理?
SETNX key value: 只有 key 不存在时,才设置 value,返回 1,否则不做操作,返回 0
Redis 的数据库结构是一个字典 dict,里面存的是键值对
- 在 dict 里查找 key 是否存在。
- 如果不存在,调用 dictAdd() 将 key/value 插入字典。
- 如果已存在,直接返回,不修改字典。
获取锁的节点挂了怎么办?
- 第一层防护 → 务必加过期时间
- 更安全的方式:锁续约 + 检查
应该怎么考虑过期时间的大小?
基本原则:过期时间 ≥ 业务代码的最大预期执行时间 + 预留缓冲时间
| 情况 | 问题 |
|---|---|
| TTL 太短 | 客户端业务还没跑完,锁已到期 → 其他客户端拿到锁 → 出现并发冲突 |
| TTL 太长 | 客户端崩溃后,锁迟迟不释放 → 系统恢复慢 / 阻塞其他客户端 |
2. Redisson:高级 Redis 分布式锁框架
它内部实现了:
- 自动续期(watchdog)机制
- 锁重入、超时释放
- 可重试、自旋等待
- 支持集群、主从、哨兵模式
1 | |
锁续期: Redisson 内部有 watchdog 自动延长锁过期时间,只要线程还活着
- 在redisson的分布式锁中,提供了一个WatchDog(看门狗),一个线程获取锁成功以后, WatchDog会给持有锁的线程续期(默认是每隔10秒续期一次)
可重入
- 多个锁重入需要判断是否是当前线程,在redis中进行存储的时候使用的hash结构,来存储线程信息和重入的次数
除了Redisson以外还了解哪些?
还了解基于 ZooKeeper 的分布式锁,但是没有用过
6.4 Redis高可用和集群
Redis集群模式知道几种,各自优缺点?
- Redis 主从模式(Replication)
- 优点:简单、实现容易、读写分离、一定程度的高可用性
- 缺点:没有数据分片,写入瓶颈,单点故障问题。
- Redis 分片集群模式(Redis Cluster)
- 优点:支持数据分片、水平扩展、高可用性、自动故障转移、增加吞吐量
- 缺点:配置复杂、请求路由复杂、每个节点的存储有限。
- Redis Sentinel(哨兵模式)
- 优点:高可用性、自动故障转移、健康检查、监控报警
- 缺点:并不支持数据分片,增加了管理复杂度
Redis集群有哪些方案,知道吗?
在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群
介绍一下redis的主从同步?
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
- 一般都是一主多从,主节点负责写数据,从节点负责读数据
能说一下,主从同步数据的流程?
全量同步:
- 从节点请求主节点同步数据(replication id、 offset )
- 主节点判断是否是第一次请求,是第一次就与从节点同步版本信息(replication id和offset)
- 主节点执行bgsave,生成rdb文件后,发送给从节点去执行
- 在rdb生成执行期间,主节点会以命令的方式记录到缓冲区(一个日志文件)
- 把生成之后的命令日志文件发送给从节点进行同步
增量同步:
- 从节点请求主节点同步数据,主节点判断不是第一次请求,不是第一次就获取从节点的offset值
- 主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步
怎么保证Redis的高并发高可用? ⭐⭐⭐⭐⭐
首先可以搭建主从集群,再加上使用Redis中的哨兵模式
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复
- 监控:Sentinel 会不断检查您的master和slave是否按预期工作
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
- 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
哨兵选主规则
- 首先判断主与从节点断开时间长短,如超过指定值就排该从节点
- 然后判断从节点的slave-priority值,越小优先级越高
- 如果slave-prority一样,则判断slave节点的offset值,越大优先级越高
- 最后是判断slave节点的运行id大小,越小优先级越高。
你们使用Redis是单点还是集群,哪种集群?
我们当时使用的是主从(1主1从)加哨兵。一般单节点不超过10G内存
Redis的分片集群有什么作用?
- 集群中有多个master,每个master保存不同数据
- 每个master都可以有多个slave节点
- master之间通过ping监测彼此健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
Redis分片集群中数据是怎么存储和读取的?
- Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽
- 将16384个插槽分配到不同的实例
- 读写数据:根据key的有效部分计算哈希值,对16384取余(有效部分,如果key前面有大括号,大括号的内容就是有效部分,如果没有,则以key本身做为有效部分)余数做为插槽,寻找插槽所在的实例
redis做分布式缓存,流量很大怎么处理?
- 读写分离,使用主从结构、哨兵或 Cluster 分片来扩展性能。
- 限流与降级,比如用布隆过滤器、限流令牌桶、降级返回兜底数据
- 降低单个节点压力,比如使用一致性哈希、分片路由,把流量分散到多个 Redis 实例。
- 优化热点数据和大 key:对热点 key 做本地缓存、多级缓存,对大 key 做拆分、分段存储。
当你存储的value值比较大时该如何处理?
当你把很大的 value 存进 Redis,比如:
- 一个 value 里存了几 MB 的图片、视频、超长 JSON、几十万行的 List
解决:
- 分片拆分 value:把大 value 拆成多个小 key,比如用 key+分片号存储
- 外部存储,把大对象存到对象存储或文件系统,Redis 只存索引或元信息
- 压缩存储
Redis 中的 1000w 数据,如果有 20w 个数据已经过期,并且这些数据的 key 前缀相同,如何快速找出这些过期的 key?
- 定时过期任务 + 主动删除过期数据
- 使用 Redis 的 KEYS 命令(小范围数据可用)
- 可以使用 Redis 的 KEYS 命令来查找指定前缀的所有 key:
KEYS prefix:*
- 可以使用 Redis 的 KEYS 命令来查找指定前缀的所有 key:
6.5 Redis数据结构
跳表插入数据的流程?
跳表的结构由多层链表组成,底层(Level 0)包含所有元素,而上层则逐渐减少元素数量
- Step 1 (查找插入位置):我们从跳表的最高层开始,逐层向下查找,直到找到比目标值大的节点或到达底层。每一层的节点指针保存在 update 数组中,以便在插入时用来更新指针。
- Step 2 (决定新节点的层数):根据一定的概率决定新节点的层数。每增加一层,概率会减半,直到停止。
- Step 3 (创建新节点并插入):新节点被创建,并且在每一层都插入到相应位置。
- Step 4 (更新指针):更新跳表的指针,确保新节点插入后,各层节点之间的链接依然有效。
- Step 5 (更新跳表层数):如果新节点的层数超过当前跳表的层数,更新跳表的层数
跳表的主要操作的时间复杂度如下:
- 查找(Search):O(log n)
- 插入(Insert):O(log n)
- 删除(Delete):O(log n)
- 更新(Update):O(log n)
redis中List的底层数据结构?压缩列表为什么不好?如何解决的
Redis list 底层有两种结构:ziplist(压缩列表)和 quicklist(双端链表 + 压缩列表)
- ziplist 优点是节省内存,但缺点是扩容、插入、删除开销大,不适合大列表
- 为了解决这个问题,Redis 3.2 引入 quicklist,用双向链表挂载多个 ziplist,兼顾压缩率和操作性能
7. SpringCloud
7.1 Spring Cloud
谈谈你对微服务的理解?⭐
- 每个微服务负责实现一个特定的业务功能,比如用户服务、订单服务、支付服务等。每个服务的范围非常明确,避免了功能过多的复杂性
- 每个微服务可以独立部署、升级、扩展。即使一个服务出现问题,也不会影响到其他服务。它们之间通过 API 进行交互
- 微服务是一个分布式系统,每个服务通常运行在不同的服务器或者容器中,它们通过网络进行通信
微服务和分布式分别是什么?
- 微服务 是一种架构风格,指的是将一个大而复杂的应用拆解成多个小的、独立的服务
- 分布式系统 是指由多个独立的计算机(节点)通过网络连接形成的系统,这些计算机共享资源并协作完成任务
Spring Cloud 5大组件有哪些?
随着SpringCloudAlibba在国内兴起 , 我们项目中使用了一些阿里巴巴的组件
- 注册中心/配置中心 Nacos
- 服务调用 Feign
- 服务保护 sentinel
- 服务网关 Gateway
- 负载均衡 Ribbon/LoadBalancer
服务注册和发现是什么意思?Spring Cloud 如何实现服务注册发现?⭐⭐⭐
我们当时项目采用的Nacos作为注册中心,这个也是spring cloud Alibaba体系中的一个核心组件
- 服务注册:服务提供者需要把自己的信息注册到Nacos,由Nacos来保存这些信息,比如服务名称、ip、端口等等
- 服务发现:消费者向Nacos拉取服务列表信息,如果服务提供者有集群,则消费者会利用负载均衡算法,选择一个发起调用
- 服务监控:服务提供者会每隔30秒向Nacos发送心跳,报告健康状态,如果Nacos服务90秒没接收到心跳,从Nacos中剔除
- Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
- Nacos还支持了配置中心
微服务之间调用是怎么实现的?⭐⭐⭐
OpenFeign 通过注解的方式定义 HTTP 调用的接口,将 HTTP 请求转换为 Java 方法调用,减少了很多繁琐的代码
OpenFeign远程调用用的什么协议?
- HTTP/HTTPS
OpenFeign的原理是什么?
- 基于 动态代理 的
你们项目负载均衡如何实现的?
我们是在客户端实现的负载均衡
- 底层基于 Ribbon 或 Spring Cloud LoadBalancer
- 默认是轮询策略,也支持自定义负载均衡策略
负载均衡策略有哪些 ?
默认的是:轮询策略
| 负载均衡策略 | 说明 |
|---|---|
| 轮询 | 按顺序轮流选取 |
| 随机 | 随机选一个 |
| 权重随机 | 按实例权重随机选择 |
| 最小并发 | 选择当前处理请求数量最少的实例 |
| 响应时间权重 | 响应快的实例优先 |
| 区域优先 | 同区域实例优先,跨区域退化调用 |
| IP 哈希 | 根据请求 IP 哈希定位实例,实现同一来源 IP 请求固定路由 |
| 自定义标签 | 根据请求中的自定义标签(如灰度发布标记、租户标记)选择目标实例 |
什么是服务雪崩,怎么解决这个问题?
- 服务雪崩:一个服务失败,导致整条链路的服务都失败的情形
- 服务降级:服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑
- 服务降级(Fallback):当某服务不可用时,调用备用逻辑或返回默认值
- Sentinel 中配置 fallback 方法;Feign 支持 fallback 实现类
- 服务降级(Fallback):当某服务不可用时,调用备用逻辑或返回默认值
- 服务熔断:默认关闭,需要手动打开,如果检测到 10 秒内请求的失败率超过 50%,就触发熔断机制。之后每隔 5 秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求
- 当下游服务连续失败、响应超时等 → 启动熔断
- 临时断开调用(返回默认值/失败提示),避免继续压垮服务
- 熔断后经过一段时间进入“半开”状态测试恢复
- 阿里 Sentinel、Resilience4j、Hystrix 都支持熔断
Sentinel 的作用
- 流量控制(限流)
- 熔断降级
- 实时监控
- 系统负载保护
你们的微服务是怎么监控的?
我们的项目好像没用到监控,不过我知道skywalking可以进行监控的
- skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。
- 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复
7.2 分布式相关
你们项目中有没有做过限流 ? 怎么做的?⭐⭐
用过,是在Springcloud Gateway进行的限流,在yml配置文件配置限流,基于的是令牌桶算法
为什么要限流? ⭐⭐
- 并发的确大(突发流量)
- 防止用户恶意刷接口
限流的实现方式:
- Tomcat:可以设置最大连接数
- Nginx,漏桶算法
- 网关,令牌桶算法
- 自定义拦截器
我们当时有一个活动,到了假期就会抢购优惠券,QPS最高可以达到2000,平时10-50之间,为了应对突发流量,需要做限流
网关限流
- 在spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法
- yml配置文件中,微服务路由设置添加局部过滤器RequestRateLimiter
- 可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量
令牌桶算法(Token Bucket) ⭐⭐
- 系统以固定速率 往桶里放令牌
- 请求到来时:
- 如果桶里有令牌 → 取走一个 → 允许请求通过
- 如果桶里没令牌 → 拒绝请求/排队等待
举例:桶容量10,每秒放1个令牌 → 最多能积累10个令牌 → 短时间内可以放行10个请求,然后按1/s速度补充
漏桶算法(Leaky Bucket)
- 请求进入一个“漏桶”
- 桶以固定速率 匀速漏水(处理请求)
- 如果桶满了(请求太多、流入速率 > 漏出速率) → 新请求被丢弃
举例: 桶容量10,固定每秒处理1个 → 请求突然来了20个 → 只能处理前10个,后10个因桶满被丢弃。
解释一下CAP和BASE?
- CAP 定理(一致性、可用性、分区容错性)
- 分布式系统节点通过网络连接,一定会出现分区问题(P)
- 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
- BASE理论
- 基本可用:分布式系统在出现故障时,允许损失部分可用性,即保证核心可用
- 软状态:在一定时间内,允许出现中间状态,比如临时的不一致状态。
- 最终一致:虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
- 解决分布式事务的思想和模型:
- 最终一致思想(强可用性):各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据(AP)
- 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚(CP)
你们采用哪种分布式事务解决方案?
Seata框架(XA、AT、TCC)
简历上写的微服务,只要是发生了多个服务之间的写操作,都需要进行分布式事务控制
描述项目中采用的哪种方案(seata)
- seata的XA模式,CP,需要互相等待各个分支事务提交,可以保证强一致性,性能差
- seata的AT模式(常用),AP,底层使用undo log 实现,性能好
- seata的TCC模式,AP,性能较好,不过需要人工编码实现
分布式服务的接口幂等性如何设计?
幂等: 多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。
- 新增和更新的操作不是幂等的;查询和删除是幂等的
解决幂等性:
- 分布式锁,性能较低
- 使用token+redis来实现,性能较好
8. RabbitMQ
8.1 基础问题
RabbitMQ用在哪里?
- 异步发送(验证码、短信、邮件…)
- MYSQL和Redis , ES之间的数据同步
- 分布式事务
- 削峰填谷
RabbitMQ-如何保证消息不丢失? ⭐⭐⭐
- 开启生产者确认机制,确保生产者的消息能到达队列
- 开启持久化功能,确保消息未消费前在队列中不会丢失
- 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack
- 开启消费者失败重试机制,多次重试失败后将消息投递到异常交换机,交由人工处理
RabbitMQ消息的重复消费问题如何解决的?⭐⭐
每条消息设置一个唯一的标识id,校验业务id是否存在
消息队列出现异常怎么处理?
- 重试和延迟队列:对消费失败的消息进行重试,避免消息丢失
- 消息持久化和高可用性:确保消息不会因系统崩溃而丢失,配置高可用性和消息持久化
RabbitMQ中死信交换机 ? (RabbitMQ延迟队列有了解过嘛)
- 延迟队列:进入队列的消息会被延迟消费的队列
- 场景:超时订单、限时优惠、定时发布
延迟队列=死信交换机+TTL(生存时间)
- 我们当时一个什么业务使用到了延迟队列(超时订单、限时优惠、定时发布…)
- 其中延迟队列就用到了死信交换机和TTL(消息存活时间)实现的
- 消息超时未消费就会变成死信(死信的其他情况:拒绝被消费,队列满了)
死信交换机
- 如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):
- 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期消息,超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
RabbitMQ 的 镜像队列?
一个队列的主节点(master)会将消息完全复制(镜像)到其他节点(slaves)上
- 当你向主节点发送消息时,主节点会同步把消息复制到所有镜像节点
- 当主节点宕机时,集群会自动从镜像节点中选出一个作为新的主节点继续工作
RabbitMQ如果有100万消息堆积在MQ , 如何解决(消息堆积怎么解决)
当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题
解决消息堆积有三种种思路:
- 增加更多消费者,提高消费速度
- 在消费者内开启线程池加快消息处理速度
- 扩大队列容积,提高堆积上限,采用惰性队列
- 接收到消息后直接存入磁盘而非内存
- 在声明队列的时候可以设置属性x-queue-mode为lazy,即为惰性队列
- 基于磁盘存储,消息上限高
- 性能比较稳定,但基于磁盘存储,受限于磁盘IO,时效性会降低
即使使用消息队列,请求还是很大,如何减轻数据库的压力?
- 引入缓存,减少数据库访问
- 限流、降级、熔断保护数据库
mq消费消息是什么模式?轮询还是推送?
RabbitMQ 用的是 推送(Push)+ 轮询调度
- 服务器主动推送消息给消费者
- 如果一个队列绑定了多个消费者,RabbitMQ 会采用轮询方式分发消息
RabbitMQ vs Kafka vs RocketMQ 核心区别? ⭐⭐⭐
RabbitMQ:
适合 需要可靠投递、低延迟 的场景,主要保证可靠性
Kafka:适合 大数据日志采集、流式计算 → 高吞吐、消息堆积无压力
RabbitMQ 基于 AMQP 协议,特点是高可靠性、低延迟、支持复杂路由,适合中小规模业务消息处理
Kafka 更侧重于大数据场景,具有极高吞吐量、强消息堆积能力,适合日志收集、流处理,但不适合低延迟业务消息
RocketMQ 兼顾高吞吐和高可靠性,同时支持事务消息,适合电商、金融等对可靠性有高要求的业务场景。
为什么你的项目使用RabbitMQ,简要回答?
相比 Kafka,RabbitMQ 更适合我们这种中等流量、业务消息驱动的场景,不需要复杂的分布式日志处理能力。Kafka 更适合大数据场景,而我们主要是电商订单、支付等业务消息
RabbitMQ中如何保证消息的有序性?
单一队列(FIFO)/FIFO(先进先出) 队列
- 最简单的方式就是将所有消息都发送到 单一队列 中,这样消息会按照它们进入队列的顺序被消费
使用唯一标识符(如UUID)标记每个消息,确保消息不会被重复处理。这个处理结果要存储吗?
需要存储处理结果
- 消息幂等性保证
- 存储该处理结果可以防止同一个消息被多次处理,避免重复操作或状态变化
- 消息重试机制
- 存储已处理消息的状态可以帮助在失败后进行重试操作,避免重复处理已成功的消息
8.2 简历上的问题
1️⃣ 交换机类型
RabbitMQ的交换机(Exchange)是决定消息路由方式的核心组件,主要有四种类型:
Direct(直连交换机)
根据消息的RoutingKey进行精确匹配,路由到指定队列。Fanout(广播交换机)
忽略RoutingKey,消息会广播给绑定到这个交换机的所有队列。Topic(主题交换机)
支持模糊匹配,RoutingKey支持通配符,适合复杂的消息路由场景。Headers(头交换机)
根据消息头属性进行匹配,灵活性很强,适用于高级路由逻辑。
2️⃣ 死信队列(DLX)
死信队列用于处理无法正常消费的消息,避免消息丢失。
消息成为死信的几种常见情况:
- 消息被拒绝(
basic.reject或basic.nack)且requeue=false。 - 消息超时未被消费(队列设置了TTL)。
- 队列长度达到上限,无法继续入队。
当发生死信,消息会被转发到预先绑定的死信交换机,通过它再路由到死信队列,供后续排查和补偿处理。
3️⃣ 消息确认机制(ACK机制)
RabbitMQ的确认机制保证消息投递和消费的可靠性,分三个阶段:
生产者确认:
- 开启
publisher confirm模式,发送后等待RabbitMQ返回ACK/NACK,确保消息已进入队列。
- 开启
消费者确认:
- 默认
autoAck为false,消费者收到消息后,手动调用basicAck确认,确保消息处理完成。 - 若处理失败可调用
basicNack或basicReject,实现重新投递或丢弃。
- 默认
Broker持久化确认:
- 消息设置
deliveryMode=2,代表持久化,保证Broker重启后消息不会丢失。
- 消息设置
4️⃣ 消息可靠性保障措施
RabbitMQ保证消息可靠投递一般通过四重保障机制:
1️⃣ 生产端保障:
- 开启
confirm机制,确保消息成功到达Broker。 - 配合
return机制,防止消息无路由丢失。
2️⃣ Broker端保障:
- 队列持久化
durable=true。 - 消息持久化
deliveryMode=2。
3️⃣ 消费端保障:
- 关闭自动ACK,使用手动ACK,确保业务逻辑处理完成后再确认。
4️⃣ 异常情况保障:
- 配置死信队列(DLX)避免消息丢失。
- 搭配补偿机制(如定时任务+数据库状态)做最终一致性补偿。
9. 计算机网络和操作系统常见问题
9.1 计算机网络
TCP/IP 网络模型有哪几层?
- 网络接口层
- 网络层
- 传输层
- 应用层
OSI 模型的七层网络模型
- 物理层(Physical Layer)
- 数据链路层(Data Link Layer)
- 网络层(Network Layer)
- 主要协议:IP(互联网协议)、ICMP(控制消息协议)、ARP(地址解析协议)
- 传输层(Transport Layer)
- 主要协议:TCP(传输控制协议)和 UDP(用户数据报协议)
- 会话层(Session Layer)
- 表示层(Presentation Layer)
- 应用层(Application Layer)
- 主要协议:HTTP/HTTPS、DNS、FTP、SMTP、Telnet等
简要回答TCP协议和IP协议的区别?
TCP协议(传输控制协议)和IP协议(互联网协议)是网络通信中的两种重要协议,它们在网络协议栈中扮演不同角色:
- IP协议负责数据包的路由和转发,它处理数据从源地址到目的地址的传输,但它不保证数据的可靠性、顺序或完整性。IP协议属于网络层。
- TCP协议位于传输层,它提供可靠的、面向连接的通信,确保数据正确无误地传送,并且保证数据包按顺序到达。TCP通过重传丢失的数据包、流量控制和拥塞控制等机制来保证传输的可靠性。
简而言之,IP协议负责数据包的寻址和传输,而TCP协议负责确保数据传输的可靠性和顺序。
简要回答TCP协议和UDP协议的区别?
- 连接方式:
- TCP是面向连接的协议,在数据传输前需要建立连接(三次握手)。
- UDP是无连接的协议,数据传输前不需要建立连接。
- 可靠性:
- TCP提供可靠的数据传输,保证数据按顺序到达并进行错误校验、重传等机制。
- UDP不保证数据的可靠性,可能丢失、重复或乱序。
- 传输速度:
- TCP因为需要连接管理、数据确认和重传机制,传输速度较慢。
- UDP没有这些额外的机制,因此传输速度较快,适合实时应用。
- 适用场景:
- TCP适合要求高可靠性的数据传输,如文件传输、网页浏览等。
- UDP适合实时性要求高但对丢包容忍的应用,如视频流、在线游戏等。
总结:TCP提供可靠性和顺序保证,适合需要高可靠性的数据传输;UDP则速度更快,但不保证可靠性,适合实时性要求高的场景。
TCP如何保证可靠传输?
TCP通过以下几种方式保证可靠传输:
- 数据确认(ACK):接收方确认收到的数据,并发送ACK给发送方。未收到确认的数据会重传。
- 重传机制:丢失或损坏的数据会根据超时和ACK机制进行重传。
- 顺序控制:每个数据包都有序列号,确保数据按顺序到达接收方。
- 流量控制:通过滑动窗口机制控制发送方的发送速度,避免接收方过载。
- 拥塞控制:动态调整数据发送速率,避免网络拥堵。
- 校验和:确保数据在传输过程中未发生损坏。
这些机制确保了TCP数据传输的可靠性和顺序性。
TCP三次握手过程?
TCP三次握手过程用于建立一个可靠的连接。其步骤如下:
第一次握手(SYN):
- 客户端向服务器发送一个SYN(同步)包,表示客户端请求建立连接。此包中包含一个初始的序列号
Seq = x。
- 客户端向服务器发送一个SYN(同步)包,表示客户端请求建立连接。此包中包含一个初始的序列号
第二次握手(SYN-ACK):
- 服务器收到客户端的SYN包后,响应一个SYN-ACK包。服务器确认收到客户端的SYN请求,并发送自己的SYN请求,包含确认号
Ack = x + 1和自己的初始序列号Seq = y。
- 服务器收到客户端的SYN包后,响应一个SYN-ACK包。服务器确认收到客户端的SYN请求,并发送自己的SYN请求,包含确认号
第三次握手(ACK):
- 客户端收到服务器的SYN-ACK包后,发送一个确认包(ACK)给服务器,确认号为
Ack = y + 1,并且序列号为Seq = x + 1。 - 这样,客户端和服务器之间的连接就建立成功。
- 客户端收到服务器的SYN-ACK包后,发送一个确认包(ACK)给服务器,确认号为
通过三次握手,确保了双方都能确认彼此的接收能力和初始化状态,从而建立一个可靠的TCP连接。
局域网内一台主机如何通过ip找到另一台主机?
ARP(地址解析协议)查询
- 主机A通过ARP协议查询目标主机B的MAC地址。
- 一旦获得目标主机的MAC地址,主机A就可以通过数据链路层(使用MAC地址)进行通信
简要介绍一下HTTP协议?(应用层)
HTTP(超文本传输协议,HyperText Transfer Protocol)是用于客户端和服务器之间传输超文本数据的协议
- 它是一个无状态、请求-响应的协议,通常基于TCP/IP协议进行通信
- 无状态:HTTP协议本身不保存任何会话信息。每次请求都是独立的,服务器无法记住前一次的请求状态。为了实现会话保持,通常会使用cookies、sessions等机制
- 请求-响应模型:客户端(通常是浏览器)向服务器发送请求,服务器收到请求后做出响应。每次通信都是一个请求与响应的交互。
- 基于TCP:HTTP通常运行在TCP/IP协议栈的应用层,使用TCP协议确保数据传输的可靠性。
HTTPS为什么安全?
- 数据加密:HTTPS使用SSL/TLS(安全套接字层/传输层安全协议)对数据进行加密(RSA公钥加密)
- 身份认证:HTTPS通过使用数字证书和**公钥基础设施(PKI)**来认证服务器的身份
- 数据完整性:SSL/TLS协议在数据传输时提供完整性校验,确保数据在传输过程中没有被篡改。
9.2 操作系统
操作系统中的虚拟内存是什么?
虚拟内存是操作系统管理内存的一种技术,它通过将物理内存与硬盘存储(如交换空间或页面文件)结合使用,使得程序可以认为自己拥有连续且足够大的内存空间,即使物理内存的容量有限。虚拟内存的核心目的是提供更大的地址空间,并使得程序可以在不受物理内存限制的情况下运行
10. 项目提问
你负责项目的时候遇到了哪些比较棘手的问题?怎么解决的?⭐⭐⭐⭐
举例在我的GoodsKill项目中,用到了责任链模式,可以从责任链模式的设计过程下手
你用什么工具进行的压测?
Apache Jmeter
jwt的原理?
JWT的token验证,除了这种方法,还有什么验证的方法