JAVA基础
Java 面向对象的四大特性
- 封装(Encapsulation)
- 含义:把数据和操作数据的方法封装在对象内部,对外只暴露必要的接口,隐藏实现细节。
- 好处:降低耦合、提高安全性、便于维护。
- 继承(Inheritance)
- 含义:子类继承父类,复用代码,同时可以扩展或重写父类功能。
- 好处:提高代码复用性,建立层次结构。
- 多态(Polymorphism)
- 含义:同一方法调用在不同对象上表现出不同的行为(编译时多态/运行时多态)。
- 好处:增强灵活性、可扩展性。
- 抽象(Abstraction)
- 含义:只关注核心特征,隐藏非必要细节。通过接口和抽象类来实现。
- 好处:定义规范,便于扩展和解耦。
在框架层面的应用
封装:Spring 的 Bean的字段通常 private
,通过 setter/getter 控制访问。
继承:模板方法模式提供了执行流程的骨架,而将部分可变的步骤延迟到子类实现。
// 伪代码示例:JdbcTemplate的execute骨架
public abstract class JdbcTemplate {
public final void execute() { // final防止子类重写算法骨架
getConnection(); // 固定步骤1
doStatement(); // 抽象方法,由子类实现
releaseConnection(); // 固定步骤2
}
protected abstract void doStatement(); // 可变部分,子类实现
}
多态:Spring 的依赖注入(DI)注入接口类型,运行时由容器选择具体实现(Servcie)。
抽象:MyBatis:SqlSession
、Executor
等都是接口。你只需要和接口交互,无需关心底层是连接哪个数据库、如何执行SQL的。
说说 Java 中 HashMap 的原理?
HashMap
允许 1 个 null key、多条 null value,因为单线程场景下没歧义。
为什么引入红黑树: 当hash冲突较多的时候,链表中的元素会增多,插入、删除、查询的效率会变低,退化成O(n)使用红黑树可以优化插入、删除、查询的效率,logN级别。
转换时机: 链表上的元素个数大于等于8 且 数组长度大于等于64; 链表上的元素个数小于等于6的时候,红黑树退化成链表。
JDK8为什么从"头插法"改为"尾插法"
-
头插法特点:
- 插入时不需要遍历链表
- 直接替换头结点
- 扩容时会导致链表逆序
- 多线程环境下可能产生死循环
-
尾插法改进:
- 避免扩容时的链表逆序
- 解决多线程环境下的潜在死循环问题
死循环产生场景
旧桶链表:
A -> B -> C -> null
线程1 迁移时:
- 先拿到
A
,放到新桶头部。 - 此时新桶是
A -> null
。
线程2 也在迁移,刚好也拿到 B
,打算放到新桶头:
- 修改
B.next = A
,新桶成了B -> A -> null
。
线程1 继续迁移 B
(因为它也在遍历旧链表):
- 此时它的
B.next
已经被线程2 改过了! - 它会把
B
再放到新桶头,把指针一改,可能出现:
A.next = B
B.next = A
扩容
假设某个key的hash = ...10010b
,旧容量n = 16
:
oldIndex = hash & 01111b = 2
扩容后 2n = 32
:
newIndex = hash & 11111b
= (hash & 01111b) + (hash & 10000b)
= oldIndex + 16(如果第 5 位是 1)
于是只需要 检查 hash 的第 5 位(10000b):
- 如果为 0 →
newIndex = 2
- 如果为 1 →
newIndex = 2 + 16 = 18
JAVA8及之后,扩容时只需要看 hash 的新高位,而不用重新计算哈希!!!这个 hash
会存进 Node 对象里,而不是每次都重新算。
Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?
ConcurrentHashMap
不允许null的key和value
ConcurrentHashMap 不同JDK版本的实现对比
- 数据结构
-
JDK1.7:
- 使用
Segment(分段锁) + HashEntry数组 + 链表
的数据结构
- 使用
-
JDK1.8及之后:
- 使用
数组 + 链表/红黑树
的数据结构(与HashMap类似)
- 使用
- 锁的类型与宽度
-
JDK1.7:
- 分段锁(Segment)继承了
ReentrantLock
- Segment容量默认16,不会扩容 → 默认支持16个线程并发访问
- 分段锁(Segment)继承了
-
JDK1.8:
- 使用
synchronized + CAS
保证线程安全 - 空节点:通过CAS添加(put操作,多个线程可能同时想要将一个新的键值对插入到同一个桶中,这时它们会使用 CAS 来比较当前桶中的元素(或节点)是否已经被修改过。)
- 非空节点:通过synchronized加锁,只锁住该桶,其他桶可以并行访问。
- 使用
- 渐进式扩容(JDK1.8+)
- 触发条件:元素数量 ≥
数组容量 × 负载因子(默认0.75)
- 扩容过程:
- 创建2倍大小的新数组
- 线程操作数据时,逐步迁移旧数组数据到新数组
- 使用
transferIndex
标记迁移进度 - 直到旧数组数据完全迁移完成
==
和 equals
的区别是什么?
在 Java 中,基本数据类型
==
:比较的是 值。equals
:基本数据类型(int
、double
、char
等)不是对象,没有equals
方法,所以只能用==
比较。
引用数据类型(对象)以及集合类
==
:比较的是 引用是否相同,即两个变量是否指向同一个对象。equals
:默认继承自Object.equals
,比较地址;但很多类(如String
、Integer
、List
)都 重写了equals
方法,用于比较对象的 值。
<Point>
大坑
HashSet在 Java 中,有一个 HashSet<Point> points
,Point
是自定义类,里面有两个成员 x
和 y
。
如果我往里面 add
了一些 Point
,之后想通过新构造一个同样 x
、y
的 Point
来调用 points.remove(point)
,能否成功删除?
HashSet和HashMap
两者底层都用 哈希表(JDK8 之后是数组 + 链表 + 红黑树):
- 数组:用
hashCode()
定位桶(数组下标)。 - 链表/红黑树:解决哈希冲突。
- 扩容机制:负载因子超过阈值时,数组扩容并重新计算 hash。
HashSet.remove(Object o)
的底层流程:
计算哈希值
调用传入对象(o
)的 hashCode()
方法,得到哈希值。
定位桶(bucket) 根据哈希值取模,找到可能存放该对象的 桶。
如果
hashCode
不一样,元素直接定位到不同的桶,根本不会去比较equals
。
遍历桶中链表/红黑树 在对应的桶里,依次拿已有元素跟传入对象比较:
- 先比
hashCode
(已经定位桶了,这里一般相同)。 - 再比
equals
,确认是否是逻辑相等的对象。
删除成功
如果找到了 equals
为 true
的对象,就把它移除。否则返回 false
。
contains
也是类似的流程。
回答
默认情况下不能删除
- 自定义类
Point
没有重写equals
和hashCode
,继承的是Object
的实现。 - 这样比较时:
equals
比较的是 引用地址,不是内容;hashCode
也是基于对象地址生成。
- 即使
x
、y
相同,new Point(1,2)
和之前插入的对象也被视为 不同对象,所以remove
会失败。
正确做法
- 必须同时重写
equals
和hashCode
,让两个Point
在x
和y
相同的情况下被认为相等。
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
//只要两个 Point 的 x 和 y 相同,它们的 hashCode 就相同。
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
这样,下面的代码才会返回 true
:
points.remove(new Point(1, 2)); // ✅ 成功删除
扩展
List<Point>
在使用contains
和remove
的时候,必须重写Point中的equals
方法,但不用重写HashCode
,因为用不上。
限流算法
1.固定窗口算法
将时间划分为固定的窗口(如1分钟),在每个窗口内对请求进行计数。
- 如果计数超过阈值,则后续请求被拒绝。
- 时间窗口重置时,计数器归零。
缺点:
在窗口切换的临界点附近,可能会遭遇两倍的流量冲击。
2.滑动窗口
滑动窗口将一个大窗口划分为多个更小的时间片(Slice)。每个时间片独立计数,窗口随着时间向前滑动,计算的是最近N个时间片内的总和。
3.漏桶算法
请求来是加++++
想象一个底部有固定开口的桶:
- 请求像水一样以任意速率流入桶中。
- 如果水超过桶的容量,则溢出(拒绝请求)。
- 处理请求像水从桶底以恒定速率漏出。
// 伪代码概念
long capacity; // 桶的容量
long outflowRate; // 漏出的速率(如:100ms/个)
long waterLevel; // 当前水位
long lastLeakTime; // 上次漏水的时间
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 1. 先计算上次到现在漏掉了多少水
long waterToLeak = (now - lastLeakTime) / outflowRate;
waterLevel = Math.max(0, waterLevel - waterToLeak);
lastLeakTime = now;
// 2. 判断加水后是否会溢出
if (waterLevel < capacity) {
waterLevel++;
return true;
}
return false;
}
4.令牌桶
请求来是减-------
想象一个以恒定速率生成令牌的桶:
- 令牌以固定速率放入桶中,直到桶满为止。
- 每个请求需要从桶中获取一个令牌才能被处理。
- 如果桶中有令牌,请求立即被处理(允许突发流量)。
- 如果桶中无令牌,则请求被拒绝或等待。
// 伪代码概念
long capacity; // 桶的容量
long refillRate; // 添加令牌的速率(如:每秒10个)
long tokens; // 当前令牌数量
long lastRefillTime; // 上次添加令牌的时间
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 1. 计算上次到现在应该添加多少令牌
long tokensToAdd = (now - lastRefillTime) * refillRate / 1000;
tokens = Math.min(capacity, tokens + tokensToAdd); // 桶不能超过容量
lastRefillTime = now;
// 2. 取一个令牌
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
全局变量和局部变量
public class Person {
// 实例变量(对象的属性,每个对象一份)
String name;
int age;
// 静态变量(类变量,所有对象共享一份)
static String species = "Human";
public void introduce() {
// 局部变量(方法里的变量,每次调用方法时创建)
String greeting = "Hello, my name is ";
System.out.println(greeting + name + ", age " + age + ", species " + species);
}
}
name
, age
)
① 实例变量(- 定义位置:类中、方法外,且没有
static
修饰。 - 归属:属于对象,每个对象都有自己独立的副本。
- 存储位置:堆内存(Heap),随对象存活。
- 默认值:如果没赋值,自动有默认值(
null
、0
等)。
Person p1 = new Person();
p1.name = "Alice";
p1.age = 20;
Person p2 = new Person();
p2.name = "Bob";
p2.age = 25;
// p1 和 p2 的实例变量互不影响
System.out.println(p1.name); // Alice
System.out.println(p2.name); // Bob
p1、p2都是 局部变量(引用变量),存储在栈里。
p1
指向一个堆内存中新建的 Person
对象,这个对象有自己的实例变量 name = "Alice", age = 20
。
species
)
② 静态变量(- 定义位置:用
static
修饰,写在类中。 - 归属:属于类本身,不属于某个对象。所有对象共享一份。
- 存储位置:方法区(JDK 8 以后在堆里的元空间)。
- 默认值:和实例变量一样,有默认值。
greeting
)
③ 局部变量(- 定义位置:在方法、构造方法、代码块里。
- 归属:只在当前方法或代码块有效。
- 存储位置:栈内存(Stack),随方法调用创建,方法结束销毁。
- 默认值:必须手动赋值,否则不能使用。
500. 什么是 Java 的 CAS(Compare-And-Swap)操作?
CAS操作包含三个基本操作数:
- 内存位置(V):要更新的变量
- 预期原值(A):认为变量当前应该具有的值
- 新值(B):想要更新为的值
CAS 工作原理:
- 读取内存位置V的当前值为A
- 计算新值B
- 当且仅当V的值等于A时,将V的值设置为B
- 如果不等于A,则操作失败(通常重试)
伪代码表示:
if (V == A) {
V = B;
return true;
} else {
return false;
}
JAVA异常体系
两大核心子类:Error 和 Exception
Error (错误)指程序无法处理的严重问题,通常与代码编写无关,而是JVM运行时本身出现的错误。应用程序不应该尝试捕获(catch) 这些错误,因为通常无法恢复。
Exception(异常)指程序本身可以处理的问题,是开发者需要关心的异常。
接口变慢的排查思路
1)检查网络是否存在延迟或带宽瓶颈
常见于:带宽满、跨服务 / 跨区域 / 跨 VPC 调用
工具:ping、traceroute、netstat
2)查看接口日志
核心:是否一直慢,还是在某些时间段或特定请求下变慢?
log.info("营销锁单开始执行:拼团交易锁单入参 userId={} req={}", userId, JSON.toJSONString(lockMarketPayOrderRequestDTO));
log.error("拼团交易锁单业务异常 userId={} req={}", ..., e);
接口开始/结束时间戳差值,看这次请求花了多久。
日志里是否有重试、异常堆栈、timeout、fallback、熔断等字眼?
3)观察数据库指标
是否存在慢查询、锁等待、连接池爆满等?
工具:数据库日志、EXPLAIN、慢 SQL 查询工具
-- 1. 开启慢查询日志功能
SET GLOBAL slow_query_log = 'ON';
-- 2. 设置慢查询阈值(单位:秒,默认 10 秒,建议设置为 1 秒或更小)
SET GLOBAL long_query_time = 1;
MySQL 会把 执行时间超过 1 秒的 SQL 语句 自动写入到 慢查询日志文件(slow query log) 中
内容形如:
# Time: 2025-09-09T11:45:12.123456Z
# User@Host: app_user[app_user] @ 192.168.1.10
# Thread_id: 12345 Schema: mydb QC_hit: No
# Query_time: 3.214654 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 30542
SET timestamp=1694259912;
SELECT * FROM trade_order WHERE status = 'PAID';
开启慢日志找到耗时 SQL 后,我会用 EXPLAIN 看执行计划,重点关注是否走了全表扫描、索引有没有命中、扫描行数是否过多以及是否有临时表或排序;如果发现问题,就通过建合适的组合索引、做覆盖索引、或者重写 SQL(比如避免函数、只查必要字段、改用游标分页替代大 OFFSET)来优化
4)查看代码 & 发布变更
是否新上线的代码引入了性能问题?
排查逻辑是否复杂、是否有死循环、递归、重复查询等问题?
5)查看服务监控
观察:系统资源是否有瓶颈?(CPU、内存、磁盘、网络 等)
Stream流
Java 8 引入的 Stream API 是函数式编程风格的集合操作工具,能让你用声明式写法来处理数据。
1.创建流
List<String> list = List.of("a","b","c");
Stream<String> s = list.stream();
2.中间操作(常用的)
-
filter
:过滤list.stream().filter(x -> x.startsWith("a"))
-
map
:转换list.stream().map(String::toUpperCase)
-
sorted
:排序list.stream().sorted()
-
distinct
:去重list.stream().distinct()
-
limit / skip
:截取list.stream().skip(1).limit(2)
- 终止操作(常用的)
-
forEach
:遍历list.stream().forEach(System.out::println);
-
collect
:收集List<String> res = list.stream().collect(Collectors.toList());
-
count
:计数long c = list.stream().count();
-
anyMatch / allMatch
:匹配判断boolean hasA = list.stream().anyMatch(x -> x.equals("a"));
-
reduce
:聚合(比如求和)int sum = List.of(1,2,3).stream().reduce(0, Integer::sum);
示例:
import java.util.*;
import java.util.stream.*;
public class StreamDemo {
public static void main(String[] args) {
List<String> names = Arrays.asList("Tom", "Jerry", "Alice", "Tom", "Bob");
// 示例:取长度大于 3 的名字,去重,按字母排序,转成大写,收集到 List
List<String> result = names.stream()
.filter(name -> name.length() > 3) // 筛选
.distinct() // 去重
.sorted() // 排序
.map(String::toUpperCase) // 转换
.collect(Collectors.toList()); // 收集
System.out.println(result); // [ALICE, JERRY]
}
}
使用注意点:
流只能用一次
Stream<String> s = names.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // ❌ 报错:stream has already been operated upon or closed
二进制序列化和 JSON 序列化
对比维度 | 二进制序列化(如 Protobuf、Kryo) | JSON 序列化 |
---|---|---|
数据格式 | 二进制(紧凑、不可读) | 文本(可读、结构化) |
体积大小 | 更小(无冗余字段名,高效编码) | 更大(带字段名,格式冗余) |
解析速度 | 更快(直接二进制解析,无词法分析) | 较慢(需解析字符串) |
可读性 | 不可读(需工具解析) | 可读(直接查看) |
适用场景 | 高性能 RPC(gRPC)、游戏、金融高频交易 | REST API、前端交互、日志存储 |
序列号和反序列化在哪一层?
属于 应用层,解决应用数据的跨网络传输或持久化问题,例如:
将 Java 对象转为 JSON/二进制,通过 HTTP(REST API)或 TCP(Dubbo RPC)发送。
将 Redis 中的字符串反序列化为程序中的对象。
四种引用类型
引用类型 | 强度 | GC 回收时机 | 常见用途 | 代码示例 |
---|---|---|---|---|
强引用 (Strong Reference) | 最强 | 永远不会被 GC 回收(除非失去所有强引用) | 普通对象赋值 | Object obj = new Object(); |
软引用 (Soft Reference) | 次强 | 内存不足时回收 | 内存敏感缓存(如图片缓存) | Bitmap bitmap = new Bitmap(); SoftReference<Bitmap> cache = new SoftReference<>(bitmap); bitmap = null; |
弱引用 (Weak Reference) | 较弱 | 下一次 GC 时回收 | 临时缓存、ThreadLocal.Key | WeakReference<Metadata> metaRef = new WeakReference<>(data); |
虚引用 (Phantom Reference) | 最弱 | GC 时回收,但需手动清理 | 对象销毁跟踪(如堆外内存回收) | ReferenceQueue<File> queue = new ReferenceQueue<>(); PhantomReference<File> phantomRef = new PhantomReference<>(file, queue); |
JUC多线程并发
volatile
在 Java 中,volatile
是一个 轻量级同步机制,主要有两个作用:
保证可见性:当一个线程修改了 volatile
变量的值,其他线程能立即“看到”最新值。
禁止指令重排序:保证对该变量的读写操作不会和前后的指令发生重排序,从而在多线程下避免出现“乱序执行”的问题。
原理:
JVM 底层通过 内存屏障(Memory Barrier) 来实现这两个效果:
可见性:
volatile
写操作 → 编译器在生成的字节码对应的机器指令后面会插入一条 store 屏障,强制把本地工作内存的值刷新到主内存。volatile
读操作 → 在前面会插入一条 load 屏障,强制从主内存读取最新的值。
禁止重排序:
- 内存屏障同时约束了编译器和 CPU 的优化,不允许把
volatile
读写与其前后的操作交换顺序。
happens-before:
volatile 写 → happens-before → 后续对该 volatile 变量的读。也就是说,线程 A 写入一个 volatile 变量,线程 B 之后读取到这个 volatile 变量时,能看到 A 写入之前的所有修改。
normalVar = 1; // 普通写
flag = true; // volatile 写
根据 happens-before 规则,其他线程只要看到 flag == true
(volatile 读),就一定能看到 normalVar = 1
。
volatile
不能保证原子性,比如 count++
这样的复合操作仍然存在线程安全问题。
ThreadLocal
每个线程(Thread)内部维护一个私有变量 ThreadLocalMap
,每个 ThreadLocal 对象 作为键(Key),存储的值(Value)由 ThreadLocalMap
管理。
假设你在代码中定义了两个 ThreadLocal 变量并设置值:
ThreadLocal<String> userContext = new ThreadLocal<>();
ThreadLocal<Integer> requestId = new ThreadLocal<>();
userContext.set("Alice"); // 存储第一个字段
requestId.set(123); // 存储第二个字段
底层存储结构:
// 伪代码展示当前线程的存储结构
Thread.currentThread().threadLocals = {
// Entry[] table (ThreadLocalMap 内部数组)
[0]: Entry(userContext, "Alice"), // Key 是 ThreadLocal 对象,Value 是存储的值
[1]: Entry(requestId, 123)
};
Entry
的 Key(ThreadLocal 对象)是弱引用,避免内存泄漏(但 Value 是强引用,需手动 remove()
)。
try {
userContext.set("Alice");
// ...业务逻辑
} finally {
userContext.remove(); // 必须清理!
}
在 Spring Boot 中,通过 HandlerInterceptor
实现 ThreadLocal 的安全清理。
具体做法是:在拦截器的 preHandle
方法中设置 ThreadLocal 值,在 afterCompletion
方法(无论请求成功或异常都会触发)中调用 remove()
彻底清理。这样能确保线程池中的线程被重用时,不会残留脏数据。
remove()
的后果
不调用 Key(ThreadLocal 对象):由于 ThreadLocalMap.Entry
的 Key 是弱引用(WeakReference),当 ThreadLocal
对象失去强引用时(如置为 null
),Key 会在下一次 GC 时被回收。
Value(存储的值):Value 是强引用,即使 Key 被回收,Value 仍会保留在 ThreadLocalMap
中,除非显式调用 remove()
或线程终止。
CountDownLatch
字面意思就是 “倒计数锁存器”。
作用:让一个或多个线程 等待,直到一组操作完成。
内部维护一个 计数器(初始值由构造函数指定),每次调用 countDown()
就减 1,当计数器减到 0 时,所有在 await()
上等待的线程都会被唤醒。
使用场景:1)主线程等待子任务完成
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
// 执行任务
} finally {
latch.countDown(); // 完成一个任务
}
}).start();
}
latch.await(); // 主线程阻塞,直到3个子任务都完成
System.out.println("所有任务完成,主线程继续");
2)多个线程等待统一起点
CountDownLatch latch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
latch.await(); // 等待统一起跑
System.out.println(Thread.currentThread().getName() + " 开始执行");
} catch (InterruptedException e) {}
}).start();
}
Thread.sleep(2000);
System.out.println("主线程发令");
latch.countDown(); // 所有等待的线程同时被唤醒
如何多线程循环打印1-100数字?
public class AlternatePrint {
private static int count = 1;
private static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
while (count <= 100) {
synchronized (lock) {
if (count % 2 == 1) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
lock.notify();
} else {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}, "Thread-1").start();
new Thread(() -> {
while (count <= 100) {
synchronized (lock) {
if (count % 2 == 0) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
lock.notify();
} else {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}, "Thread-2").start();
}
}
线程间是如何通信的?
共享内存:线程通过共享变量通信,需要同步机制保证可见性和原子性。比如 volatile
、synchronized
、Lock
、wait/notify
。
消息传递:线程通过消息队列或管道传递数据,比如 BlockingQueue
、PipedStream
、并发工具类。
在 Java 里,最常见的做法是用 volatile
做标志位通知,或者用 BlockingQueue
实现生产者-消费者模型。
共享内存方式 —— 用 volatile
做标志位
public class VolatileFlagDemo {
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
// 工作线程
Thread worker = new Thread(() -> {
while (running) {
System.out.println("Working...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Stopped.");
});
worker.start();
// 主线程 2 秒后修改标志位
Thread.sleep(2000);
running = false;
System.out.println("Main thread set running=false");
}
}
消息传递方式 —— 用 BlockingQueue
实现生产者-消费者
生产者调用 queue.put(msg)
把消息放入队列:
- 如果队列满了,会阻塞,直到有空间。
消费者调用 queue.take()
:
- 如果队列为空,消费者会阻塞,直到有新消息进入。
import java.util.concurrent.*;
public class BlockingQueueDemo {
private static final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
public static void main(String[] args) {
// 生产者线程
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
String msg = "Message-" + i;
queue.put(msg); // 放入队列
System.out.println("Produced: " + msg);
Thread.sleep(300);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
try {
while (true) {
String msg = queue.take(); // 从队列取出
System.out.println("Consumed: " + msg);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
}
}
前面的多线程循环打印1-100,线程之间通过 共享变量 count
来传递状态,同时用 wait/notify
来协调线程的执行顺序,属于共享内存模型 下的通信。
CountDownLatch内部其实也是用 共享内存 + AQS,线程通过 await()
进入阻塞队列,等计数器归零后被唤醒,但从使用者角度,它更像是 消息/信号通知 的抽象。
Reentrant
Reentrant是可重入锁,可重入是指:同一个线程可以重复获取自己已经持有的锁,不会死锁。
lock.lock();
lock.lock();
try {
// 重入两次,只有 unlock 两次才能释放
} finally {
lock.unlock();
lock.unlock();
}
但它是独占锁,同一时刻只允许一个线程持有锁。
AQS
AQS 是作为构建各种锁/同步器的基础。
基于它实现的有:
- 独占锁:
ReentrantLock
- 共享锁:
Semaphore
、CountDownLatch
- 读写锁:
ReentrantReadWriteLock
- 其他同步工具:
FutureTask
等
获取资源失败的线程 → 被放入 等待队列(FIFO)。
唤醒机制 → 当资源释放时,队列中的线程按顺序被唤醒,尝试再次获取资源。
公平/非公平
公平/非公平 → AQS 提供了公平/非公平两种获取策略,子类可选择。
公平锁(Fair): 按照 FIFO 顺序 获取锁,谁先来谁先拿。线程必须排队,避免“插队”。
非公平锁(NonFair): 允许线程在 CAS 抢占时“插队”。锁释放时,队头线程会被唤醒尝试获取,但与此同时,外部新来的线程也能立刻尝试 CAS 抢占。
独占模式 vs 共享模式
独占模式:同一时刻只能有一个线程获取资源。
代表:ReentrantLock、ReentrantReadWriteLock.WriteLock
共享模式:同一时刻多个线程可以共享资源。
代表:Semaphore、CountDownLatch、ReentrantReadWriteLock.ReadLock
示例1:Semaphore
Semaphore semaphore = new Semaphore(3); // 最多允许 3 个线程同时访问
Runnable task = () -> {
try {
semaphore.acquire(); // 请求许可,拿不到就阻塞
System.out.println(Thread.currentThread().getName() + " 获取许可,执行中");
Thread.sleep(1000); // 模拟工作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放许可
}
};
// 启动 10 个线程
for (int i = 0; i < 10; i++) {
new Thread(task, "线程-" + i).start();
}
最多只能看到3 个线程同时在执行;其他线程会阻塞,直到前面的释放许可。
示例2:ReentrantReadWriteLock
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
Runnable readTask = () -> {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 读数据中...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
};
Runnable writeTask = () -> {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 写数据中...");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
};
// 启动多个读线程
for (int i = 0; i < 3; i++) {
new Thread(readTask, "读线程-" + i).start();
}
Thread.sleep(200); // 稍等一会儿启动写线程
new Thread(writeTask, "写线程").start();
多个读线程可以并行执行;写线程必须等待所有读线程完成;读线程也会在写线程执行时阻塞。
AQS为什么用双向链表
最主要的原因是为了支持高效的节点移除操作。在 AQS 的实现中,线程可能会因为超时、中断等原因需要从队列中移除。
在单向链表中,要删除一个节点,必须从头开始遍历找到它的前驱节点,时间复杂度是 O(n)。而双向链表可以直接通过前驱指针(prev)快速定位前驱节点,实现 O(1) 时间复杂度的节点移除。
JAVA高级
JMM
JMM (Java Memory Model) 是 Java 语言规范里定义的一套内存可见性与有序性规则。
因为底层硬件(CPU、缓存、多核架构)和编译器优化会导致:
- 指令重排(out-of-order execution)
- 缓存不一致(每个线程可能有本地缓存)
- 编译器优化导致的“读旧值”
JMM 主要解决:
可见性 一个线程对共享变量的修改,能不能被其他线程及时看到?
-
volatile
/synchronized
/Lock
都能保证可见性。JMM 会在读写时插入 内存屏障,强制刷新到主内存、强制从主内存读取,确保线程之间的修改可见。当线程进入
synchronized
块时:- JVM 会执行 加锁(monitorenter) 指令,清空工作内存中该共享变量的值,从 主内存中重新读取最新的值;
- 这样保证了线程读到的是最新数据。
当线程退出
synchronized
块时:- JVM 会执行 释放锁(monitorexit) 指令,把该线程对共享变量的修改 刷新回主内存;
- 并且会插入 内存屏障,禁止指令重排。
有序性 编译器和 CPU 会对指令做重排序优化,JMM 提供了 happens-before 规则 来约束哪些操作必须有序。
happens-before
规则定义了哪些操作必须有序。- 程序顺序规则,一个线程内的代码按顺序执行。
原子性 单次操作不可分割。
- 复合操作(如
i++
)不是原子操作,需要用 锁(synchronized/Lock) 或 原子类(AtomicInteger 等,利用 CAS 实现) 来保证原子性。
happens-before 规则
如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的,并且 A 的执行顺序排在 B 前面。
常见的规则有:
- 程序顺序规则:同一个线程里,代码顺序就是 happens-before 顺序。
- 锁规则:解锁 happens-before 后续的加锁。
- volatile 规则:对 volatile 变量的写 happens-before 后续的读。
- 线程启动与终止规则:线程
start()
happens-before 线程中所有操作;线程中所有操作 happens-before 另一个线程成功从join()
返回。
即主线程在 start()
之前对共享变量的写入,对子线程是可见的。
元空间
元空间(Metaspace)是 JDK8 之后用来存储类的元信息(class metadata)的内存区域(方法区),位于本地内存(Native Memory)中。在 JDK 8 之后,它取代了以前的 永久代(PermGen)
什么是类的元数据
元信息内容 | 举例说明 |
---|---|
类的名称、修饰符 | public class User |
常量池(符号引用) | 字面值、字符串常量等 |
方法信息 | 方法签名、参数表、字节码等 |
字段信息 | 类型、修饰符、偏移量等 |
方法表、接口表、虚方法表 | 虚方法调用的支持机制 |
用 元空间(Metaspace) 替代永久代
对比维度 | 永久代(PermGen) | 元空间(Metaspace) |
---|---|---|
存储位置 | JVM 堆内 | 本地内存(Native memory) |
是否固定大小 | 是,需手动指定 | 否,默认按需增长 |
容易 OOM 吗? | 是,类多时易崩 | 少见,但可用参数限制防爆 |
是否会被 GC 扫描 | 会 | 一般不会(但类卸载可手动触发) |
从哪个版本开始移除 | JDK8 | JDK8 引入 |
如何排查 OOM 问题?
一、第一步:识别 OOM 类型
OOM 错误信息本身就提供了最重要的线索。常见的 OOM 类型及含义如下:
OOM 类型 | 关联内存区域 | 常见原因 |
---|---|---|
java.lang.OutOfMemoryError: Java heap space |
堆内存 | 最常见。对象太多或太大,无法在堆中分配。 |
java.lang.OutOfMemoryError: Metaspace |
元空间(方法区) | 加载的类太多(如反射、动态代理、字节码增强框架)。 |
java.lang.OutOfMemoryError: Direct buffer memory |
直接内存 | NIO 的 DirectByteBuffer 使用过多,未释放。 |
java.lang.OutOfMemoryError: Unable to create new native thread |
系统内存 | 创建的线程数过多,超出系统或进程限制。 |
java.lang.OutOfMemoryError: GC overhead limit exceeded |
堆内存 | GC 花费了太多时间(如98%)却只回收了很少的内存(如2%),这是一种“几乎内存泄漏”的信号。 |
StackOverflowError |
栈空间 | 递归、深层嵌套 |
行动: 首先查看错误日志,明确是哪种 OOM。
二、第二步:根据类型采取不同排查策略
场景 1:堆内存 OOM (Java heap space
) 最常见
这是最复杂的场景,核心是分析堆转储文件(Heap Dump)。
1. 在启动时添加 JVM 参数,以便在发生 OOM 时自动生成堆转储文件(.hprof):
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/save/dump.hprof
(如果未提前设置,在 OOM 发生后也可以用 jmap
命令手动抓取,但可能已错过最佳现场)
2. 使用分析工具加载堆转储文件:
- Eclipse MAT (Memory Analyzer Tool):功能强大,首选。
3. 分析步骤(以 MAT 为例):
-
查看 Dominator Tree(支配树):列出占用内存最大的对象线程,快速找到“罪魁祸首”。 按对象实例(而不是类)占用的保留大小 (Retained Heap) 排序。保留大小是指这个对象本身加上它直接或间接引用的所有对象的总大小。
-
查看 Leak Suspects Report(泄漏嫌疑报告):MAT 会自动生成一个分析报告,直接给出可能发生内存泄漏的点,非常有用。
-
查看 Histogram(直方图):按类名统计实例数量和总大小,看看哪个类的对象异常得多。
-
分析引用链:对疑似泄漏的对象,查看其到 GC Roots 的引用链,重点排查为什么这些对象无法被垃圾回收。常见原因:
-
静态集合类(如
static Map
)持有对象引用,忘记移除。private static Map<Long, User> cache = new HashMap<>();
所有存入的
User
对象会永久存活,即使业务上已不再需要。 -
各种缓存(如本地缓存)没有淘汰策略或清理机制。
-
线程池中的线程局部变量(ThreadLocal)未清理。 (ThreadLocalMap 的 key 是弱引用指向 ThreadLocal 实例,而value是强引用)
-
数据库连接、网络连接、文件流等未显式关闭。
// 错误示例:文件流未关闭 public void readFile(String filePath) { FileInputStream fis = new FileInputStream(filePath); byte[] buffer = new byte[1024]; while (fis.read(buffer) != -1) { System.out.println(new String(buffer)); } // 忘记 fis.close()! }
-
排查 CPU 100% 的问题
第1步:找到罪魁祸首的 Java 进程
使用 top
命令找到哪个 Java 进程最耗 CPU。
top
第2步:找到这个进程下的问题线程
看 %CPU
列,找到最耗 CPU 的线程,记下它的 TID
(线程 ID)。
-H
:显示线程模式 -p <PID>
:只监控指定进程
top -Hp <PID>
第3步:将线程ID转换为16进制
printf "%x\n" <TID>
第4步:抓取整个进程的线程堆栈
jstack <PID> > jstack_dump.txt
第5步:定位问题线程
在生成的 jstack_dump.txt
文件中搜索第3步得到的十六进制 nid
。
grep -n <nid> jstack_dump.txt
# 例如:grep -n 1a4e jstack_dump.txt
类加载过程
类加载过程分为 加载(Loading)→ 链接(Linking)→ 初始化(Initialization) 三个阶段。
**加载:**将 .class
文件的二进制字节流加载到 JVM 内存,并生成一个 Class<?>
对象。
链接:
(1) 验证:检查字节码合法性,确保符合 JVM 规范,防止恶意代码(如篡改字节码)。
(2) 准备:为静态变量分配内存并赋默认值(非初始值!)基本类型:int
→ 0
,boolean
→ false
。
(3) 解析:将符号引用转为直接引用
- 符号引用:类/方法/字段的文本描述(如
com.example.User.getName
)。 - 直接引用:指向方法区内存地址的指针或句柄。
**初始化:**静态代码块和静态变量赋值
触发条件(首次主动使用时):
new
实例化对象。- 访问类的静态字段或调用静态方法。
- 反射调用(如
Class.forName("com.example.User")
)。 - 子类初始化时,父类需先初始化。
静态代理、动态代理
动态代理代理类(如 $Proxy0
或 UserService$$EnhancerByCGLIB$$123456
)在程序运行时动态生成。代理类的字节码在内存中生成,不会出现在你的源代码或编译后的 .class
文件中。
静态代理代理类(如 UserServiceProxy
)是你手动编写的,编译前就已经存在。
静态代理
静态代理 是一种在编译期就已经确定的代理模式,它通过手动编写一个代理类,在不修改原始类代码的情况下,增强或控制目标对象的行为。
示例:
假设我们有一个 UserService
接口,它有一个方法 getUser()
,我们想在调用这个方法前后添加日志,但不修改 UserServiceImpl
的代码。
1. 定义接口和实现类
// 接口
public interface UserService {
String getUser(int id);
}
// 真实实现类
public class UserServiceImpl implements UserService {
@Override
public String getUser(int id) {
return "User-" + id;
}
}
2. 编写静态代理类
// 代理类(手动编写)
public class UserServiceProxy implements UserService {
private UserService target; // 持有真实对象
public UserServiceProxy(UserService target) {
this.target = target;
}
@Override
public String getUser(int id) {
System.out.println("【日志】开始查询用户,id=" + id); // 增强逻辑(日志)
String result = target.getUser(id); // 调用真实对象的方法
System.out.println("【日志】查询结果:" + result); // 增强逻辑(日志)
return result;
}
}
3. 使用代理类
public class Main {
public static void main(String[] args) {
// 真实对象
UserService realService = new UserServiceImpl();
// 代理对象(包装真实对象)
UserService proxy = new UserServiceProxy(realService);
// 调用代理对象的方法
String user = proxy.getUser(100);
System.out.println("最终结果:" + user);
}
}
优点:不侵入目标类:无需修改 UserServiceImpl
的代码。
缺点:灵活性差:代理关系在编译期固定,无法动态调整。如果要代理多个类,需要为每个类写一个代理类。
JDK Proxy动态代理
JDK 动态代理是 Java 原生提供的、基于接口的代理技术,通过反射动态生成代理类,在运行时拦截目标方法调用,实现增强逻辑(如日志、事务等)。
简单示例
(1) 定义接口和实现类
// 接口
public interface UserService {
String getUser(int id);
void deleteUser(int id);
}
// 真实实现类
public class UserServiceImpl implements UserService {
@Override
public String getUser(int id) {
return "User-" + id;
}
@Override
public void deleteUser(int id) {
System.out.println("删除用户: " + id);
}
}
(2) 实现 InvocationHandler
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class UserServiceInvocationHandler implements InvocationHandler {
private final Object target; // 被代理的真实对象
public UserServiceInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 前置增强:日志
System.out.println("【日志】调用方法: " + method.getName());
// 调用真实对象的方法
Object result = method.invoke(target, args);
// 后置增强:记录结果
System.out.println("【日志】方法结果: " + result);
return result;
}
}
3.使用 Proxy.newProxyInstance
生成代理对象
import java.lang.reflect.Proxy;
public class Main {
public static void main(String[] args) {
// 1. 创建真实对象
UserService realService = new UserServiceImpl();
// 2. 创建 InvocationHandler,传入真实对象
InvocationHandler handler = new UserServiceInvocationHandler(realService);
// 3. 动态生成代理对象
UserService proxy = (UserService) Proxy.newProxyInstance(
realService.getClass().getClassLoader(), // 使用真实对象的类加载器
realService.getClass().getInterfaces(), // 代理的接口(UserService)
handler // 方法调用处理器
);
// 4. 调用代理对象的方法
String user = proxy.getUser(100); // 会触发 InvocationHandler.invoke()
proxy.deleteUser(200);
}
}
生成的代理类结构
// JDK 动态生成的代理类(简化版)
public final class $Proxy0 extends Proxy implements UserService {
private InvocationHandler handler;
public $Proxy0(InvocationHandler h) {
this.handler = h;
}
@Override
public String getUser(int id) {
return handler.invoke(
this,
UserService.class.getMethod("getUser", int.class),
new Object[]{id}
);
}
@Override
public void deleteUser(int id) {
handler.invoke(
this,
UserService.class.getMethod("deleteUser", int.class),
new Object[]{id}
);
}
}
局限性:如果类没有实现接口了,JDK Proxy 无法代理;接口中的 final 方法无法被代理。
应用:
- Spring AOP
- 如果目标类实现了接口,Spring 默认使用 JDK Proxy。
- 示例:
@Transactional
的事务管理就是通过动态代理实现的。
- RPC 框架(如 Dubbo)
- 客户端通过 JDK Proxy 生成远程服务的本地代理,调用时触发网络请求。
CGLIB 动态代理
它通过 继承目标类 的方式在运行时动态生成代理类。与 JDK Proxy 不同,CGLIB 不需要目标类实现接口,可以直接代理普通类。
(1) 定义目标类(无需接口)
// 目标类(没有实现任何接口)
public class UserService {
public String getUser(int id) {
return "User-" + id;
}
public void deleteUser(int id) {
System.out.println("删除用户: " + id);
}
}
(2)实现 MethodInterceptor
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
public class UserServiceMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
// 前置增强:日志
System.out.println("【CGLIB日志】调用方法: " + method.getName());
// 调用父类(即真实对象)的方法
Object result = proxy.invokeSuper(obj, args);
// 后置增强:记录结果
System.out.println("【CGLIB日志】方法结果: " + result);
return result;
}
}
(3) 使用 Enhancer
生成代理对象
import net.sf.cglib.proxy.Enhancer;
public class Main {
public static void main(String[] args) {
// 1. 创建 Enhancer(CGLIB 的核心类)
Enhancer enhancer = new Enhancer();
// 2. 设置父类(即被代理的目标类)
enhancer.setSuperclass(UserService.class);
// 3. 设置回调(方法拦截器)
enhancer.setCallback(new UserServiceMethodInterceptor());
// 4. 创建代理对象
UserService proxy = (UserService) enhancer.create();
// 5. 调用代理对象的方法
String user = proxy.getUser(100); // 会触发 MethodInterceptor.intercept()
proxy.deleteUser(200);
}
}
Enhancer
是 CGLIB 的核心工具类,它会在运行时动态生成一个继承目标类(如 UserService
)的子类,而这一切对开发者是透明的。
角色 | 作用 |
---|---|
目标类(如 UserService ) |
被代理的原始类 |
MethodInterceptor |
定义代理逻辑(如日志、事务) |
Enhancer |
CGLIB 的核心类,用于生成代理类 |
动态生成的代理类 | 继承目标类,覆盖其方法 |
UserService(目标类)
↑ 继承
UserService$$EnhancerByCGLIB$$123456(动态生成的代理类)
代理类 只在第一次调用 newProxyInstance()
时生成,后续调用复用已生成的类。
你会怎样设计一个RPC框架
1.动态代理 (Proxy Layer) - 对开发者透明
- 是什么:自动生成客户端代理类,伪装成本地接口。用户调用
userService.getUser(1)
,感觉像调用本地方法,但实际上是由代理类发起网络请求。 - 为什么:这是 RPC 的初心——隐藏远程调用的复杂性,让开发者专注于业务逻辑。这是框架易用性的最关键体现。
2. 序列化与反序列化 (Serialization/Deserialization) - 协议层核心
- 是什么:将 Java 对象转换成可在网络上传送的二进制字节流的过程叫序列化;反之叫反序列化。
- 为什么:网络只能传输二进制数据。这是通信的基石。
3. 网络传输 (Transport) - 通信基础
- 是什么:负责在客户端和服务端之间收发序列化后的二进制数据。
- 为什么:这是通信的物理基础。
- 考量点:
- 协议:基于 TCP 还是 HTTP?TCP 性能更优,HTTP 更通用、易调试。
- IO 模型:主流选择是 NIO,能够用少量线程处理大量连接,性能极高(Netty 是事实标准)。
- 连接管理:使用长连接+连接池,避免频繁创建和销毁 TCP 连接的开销。
4. 服务发现与注册 (Service Discovery & Registry) - 治理基石
- 是什么:
- 服务注册:服务提供者启动时,将自己的地址(IP:Port)、服务名等信息上报到“注册中心”。
- 服务发现:消费者调用前,先连接到“注册中心”,根据服务名拉取所有可用提供者的地址列表。
- 为什么:在微服务动态环境中,服务的 IP 和端口是随时变化的(扩容、缩容、宕机)。不能将服务地址硬编码在配置文件中,需要一个中心化的目录服务来动态管理。
- 常见选型:ZooKeeper, Nacos
5. 负载均衡 (Load Balancing) - 公平调度
- 是什么:当从一个服务名下获取到多个提供者地址时,需要选择一个来发起调用。选择策略就是负载均衡。
- 为什么:将请求均匀分散到多个提供者实例上,避免单机过热,提高整体吞吐量和可用性。
- 常见策略:随机(Random)、轮询(RoundRobin)、加权(根据性能分配权重)、一致性哈希(同一用户请求总是落到同一台机器,适用于有状态或缓存场景)。
6. 容错机制 (Fault Tolerance) - 系统韧性
网络是不可靠的,服务可能会宕机。必须有完善的容错策略来保证系统的鲁棒性。
- 超时控制 (Timeout):防止调用者无限期等待,快速释放资源。
- 重试机制 (Retry):对超时或失败调用进行有限次数的重试(注意:对非幂等操作要谨慎)。
- 熔断器 (Circuit Breaker):类似电路保险丝。当失败率达到阈值,熔断器“跳闸”,后续请求直接失败,不再发起远程调用。给服务提供者恢复的时间。一段时间后进入“半开状态”尝试放行少量请求。
- 降级 (Fallback):调用失败后,提供一种备选方案,如返回默认值、缓存数据等,保证主流程可用。
如何优化?
1)序列化层优化,优化目标是:更小、更快。
2)网络传输层优化,使用长连接 (Keep-Alive) 和连接池:避免每次 RPC 调用都经历三次握手和四次挥手;采用高性能 NIO 框架:使用 Netty 作为网络通信框架。
Netty核心流程
-
注册与监听:
首先,将所有的Channel注册到Selector上,并告诉Selector关心每个Channel的什么事件(比如“可读”事件)。
-
事件就绪:
Selector作为调度员,在一个线程中循环检查。当某个客户端发送数据,对应的Channel连接上有数据可读时,Selector会检测到这个
OP_READ
事件就绪,并将其汇报出来。注意:epoll是它的底层,epoll是事件驱动 + 就绪列表返回,不是轮询。
-
数据入站:
系统根据Selector的汇报,找到就绪的Channel,然后从该Channel中将数据读取到缓冲区(ByteBuf)中。至此,网络I/O操作完成。
-
流水线加工:
数据到达缓冲区后,就开始在ChannelPipeline这条流水线上流动。ChannelHandler链被依次触发:
- 首先,解码Handler(如ByteToMessageDecoder)将缓冲区里的原始字节解码成一个有意义的业务请求对象(比如一个HTTP请求对象)。
- 然后,这个业务请求对象被传递给业务逻辑Handler,在这里执行具体的业务计算(比如查询数据库)。
- 业务逻辑Handler处理完后,通常会生成一个响应结果。
-
数据出站(响应):
响应结果的发送是反向流程:
- 业务Handler将响应对象传递给Pipeline中的编码Handler(如MessageToByteEncoder)。
- 编码Handler将响应对象编码回字节,并写入缓冲区(ByteBuf)。
- 最后,这些在缓冲区中的字节数据再通过原来的Channel发送回客户端。在此过程中,Selector可能同时也在监听Channel是否“可写”,以高效地完成发送任务。
Spring
Spring Bean 生命周期与 AOP 代理生成流程
类加载(JVM 层面):负责把 .class
文件加载进内存,生成 Class
对象。Spring 要使用某个类(比如反射扫描注解)时,前提是这个类已经被 JVM 加载过了。
Bean 定义加载(Spring 层面):基于 JVM 提供的 Class
对象,Spring 读取类的注解/配置,把信息整理成 BeanDefinition
,存到容器里,等待后续实例化。
本笔记以 UserService
为例,详细说明一个 Bean 从定义到成为代理对象的完整过程。
第一部分:Bean 定义加载(容器启动阶段)
发生在容器启动时,旨在收集所有 Bean 的元数据(BeanDefinition
)。
- 【配置解析】:容器解析所有配置源(如 XML 文件、Java Config 类、注解等)。
- 【组件扫描】:
ClassPathBeanDefinitionScanner
扫描指定的包路径,识别带有@Component
、@Service
等注解的类。 - 【注册定义】:将为每个 Bean 解析出的元信息(如类名、作用域、是否懒加载等)封装为一个
BeanDefinition
对象,并注册到BeanFactory
的beanDefinitionMap
中。
- class = UserService.class
- scope = singleton
- lazy = false
此时的状态:UserService
类已经被类加载器加载,JVM中有它的 Class
对象,Spring容器中有它的 BeanDefinition
,但尚未创建任何实例,也未生成任何代理。
第二部分:Bean 实例化与代理生成(按需触发)
当容器需要某个 Bean 实例时(例如遇到依赖注入,或初始化非懒加载的单例 Bean),将按以下步骤创建。AOP 代理在此过程中生成。
序号 | 阶段 | 说明 | 关键点 |
---|---|---|---|
1 | 实例化 (Instantiation) | 调用构造函数,创建 Bean 的原始对象。 UserService rawInstance = new UserServiceImpl(); |
此时是原始对象,非代理。 |
2 | 依赖注入 (Population) | 按照注解(如 @Autowired )或配置,为原始对象注入其所需的依赖。 |
|
3 | BeanPostProcessor 前置处理 | 执行所有 BeanPostProcessor 的 postProcessBeforeInitialization 方法。 |
可用于修改 Bean 实例,此时仍是原始对象。 |
4 | 初始化 (Initialization) | 1. 执行 @PostConstruct 注解的方法。 2. 执行 InitializingBean.afterPropertiesSet() 方法。 3. 执行自定义的 init-method 。 |
Bean 自身定义的初始化逻辑。 |
5 | BeanPostProcessor 后置处理 (AOP 切入点!) | 执行所有 BeanPostProcessor 的 postProcessAfterInitialization 方法。这是 AOP 代理生成的核心时机! - AnnotationAwareAspectJAutoProxyCreator 会检查当前 Bean 是否需要被代理(如是否被 @Transactional 、AOP 切面匹配)。 - 如果需要:则调用 JDK Proxy 或 CGLIB 动态生成代理对象,并返回此代理对象。 - 如果不需要:则直接返回原始对象。 |
代理对象在此刻诞生,并替换了原始对象。 |
6 | Bean 就绪 | 经过上述步骤的最终对象(可能是代理对象,也可能是原始对象)被放入单例池(singletonObjects ),标志着 Bean 完全就绪,可供容器和其他 Bean 使用。 |
第三部分:使用代理 Bean
- 当其他组件(如
UserController
)通过@Autowired
注入UserService
时,它们拿到的是第二步第5步中返回的代理对象,而非最初的原始对象。 - 当调用
userService.someMethod()
时,调用的是代理对象的方法。 - 代理对象会先执行增强逻辑(如事务管理、日志记录),然后通过反射调用原始对象(
rawInstance
)的相应方法。
SpringBoot启动流程
Spring Boot 启动顺序就是: JVM 类加载 → 创建 SpringApplication → 加载配置 → 扫描并注册 BeanDefinition → 实例化 & 注入 & 初始化 → AOP 代理 → 启动 Web 容器 → 发布启动完成事件。
- 入口:
main
方法
-
做什么:所有 Spring Boot 应用从带有
@SpringBootApplication
注解的类的main
方法启动。 -
关键代码
public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); }
-
作用:调用
SpringApplication.run()
启动整个流程。 -
前提:启动类本身(
MyApplication.class
)已经被 JVM 类加载器加载到内存。
- 初始化
SpringApplication
- 做什么
- 创建
SpringApplication
实例,负责管理整个启动流程。 - 准备一系列监听器(Listeners)、初始化器(Initializers)。
- 创建
- 加载扩展组件:从
META-INF/spring.factories
读取自动配置、监听器等第三方扩展点。 - 事件机制:发布
ApplicationStartingEvent
等事件,允许开发者在早期介入。
- 读取配置文件
- 做什么
- 加载配置:读取
application.properties
或application.yml
。 - 设置环境变量:构建
ConfigurableEnvironment
,整合系统属性、JVM 参数、配置文件参数。
- 加载配置:读取
- 刷新应用上下文(核心步骤)
调用 context.refresh()
→ Spring Boot 启动的心脏。
关键子流程:
- 加载 Bean 定义
- 组件扫描:解析
@Component
、@Service
、@Controller
等注解类。 - 配置类解析:读取
@Configuration
类中的@Bean
方法。 - 注册成
BeanDefinition
,存放在beanDefinitionMap
中。 👉 注意:此时只是“配方”,尚未new
出对象。
- 组件扫描:解析
- 自动配置
- 触发
@EnableAutoConfiguration
。 - Spring Boot 根据类路径依赖,动态加载相应的配置类(如
spring-boot-autoconfigure
中的模块)。
- 触发
- 条件化配置
- 根据环境(存在某依赖、系统属性、条件注解
@ConditionalOnXxx
)决定是否启用配置。
- 根据环境(存在某依赖、系统属性、条件注解
- 实例化与依赖注入
- 按需实例化 Bean(
new
出对象)。 - 执行依赖注入(
@Autowired
、@Resource
等)。
- 按需实例化 Bean(
- Bean 初始化
- 调用
@PostConstruct
、InitializingBean.afterPropertiesSet()
、init-method
等初始化方法。
- 调用
- 后置处理器(AOP 切入点)
BeanPostProcessor.postProcessAfterInitialization()
阶段,如果匹配到切面(如事务、日志),生成 代理对象,替换原始 Bean。
- 启动内嵌 Web 服务器(Web 场景)
- 做什么:如果是 Web 应用,会自动启动内嵌的 Tomcat/Jetty/Undertow。
- 关键点:端口监听完成后,Spring Boot 应用对外提供服务。
- 完成启动
- 做什么:容器进入就绪状态。
- 回调执行:执行
ApplicationRunner
、CommandLineRunner
中的逻辑。 - 事件发布:发布
ApplicationReadyEvent
,通知应用完全启动成功。
Spring循环依赖为什么需要三级缓存
在Spring的默认单例Bean创建过程中,三级缓存指的是三个Map:
- 一级缓存
singletonObjects
:存放完全初始化好的、成熟的Bean。这是我们实际用到Bean的地方。 - 二级缓存
earlySingletonObjects
:存放早期的Bean引用(已实例化但未完成属性填充和初始化)。 - 三级缓存
singletonFactories
:存放Bean的工厂对象(ObjectFactory
)。这个工厂能根据条件(如是否需要AOP)返回一个早期的Bean引用。
如果只有二级缓存(早期暴露)
-
开始创建 A
- 实例化 A(调用构造函数,得到一个原始对象
A@123
) - 将原始对象
A@123
放入二级缓存(earlySingletonObjects
) - 为 A 填充属性时发现需要注入 B → 触发
getBean("B")
- 实例化 A(调用构造函数,得到一个原始对象
-
开始创建 B
- 实例化 B(得到原始对象
B@456
) - 将
B@456
放入二级缓存 - 为 B 填充属性时发现需要注入 A → 从二级缓存拿到
A@123
(原始对象) - B 完成属性注入(此时 B 中持有的是
A@123
) - B 完成初始化,放入一级缓存(
singletonObjects
)
- 实例化 B(得到原始对象
-
回到 A 的创建
-
从一级缓存拿到
B@456
,注入到 A 中 -
问题来了:如果 A 需要被 AOP 代理(比如有
@Transactional
注解),Spring 会生成代理对象A$Proxy@789
,并放入一级缓存 -
最终结果:
-
一级缓存中是
A$Proxy@789
(代理对象) -
但 B 中已经注入了
A@123
(原始对象),导致 B 依赖的 A 和最终暴露的 A 不是同一个对象!(AOP 失效,事务等增强不生效)
-
-
引入三级缓存
-
开始创建 A
-
实例化 A(得到原始对象
A@123
) -
向三级缓存暴露一个工厂(
ObjectFactory
),而非直接放入二级缓存(工厂的作用:在需要时能返回原始对象或代理对象)
-
为 A 填充属性时发现需要注入 B → 触发
getBean("B")
-
-
开始创建 B
-
实例化 B(得到
B@456
) -
向三级缓存暴露 B 的工厂
-
为 B 填充属性时发现需要注入 A → 从三级缓存拿到 A 的工厂
-
工厂执行
getObject()
,此时发现 A 需要 AOP 代理 → 生成代理对象A$Proxy@789
(代理逻辑会根据 A 的最终状态生成,但此时仅提前生成引用)
-
-
将
A$Proxy@789
放入二级缓存(标记为“早期引用”) -
B 完成属性注入(此时 B 中持有的是
A$Proxy@789
) -
B 完成初始化,放入一级缓存
-
-
回到 A 的创建
-
从一级缓存拿到
B@456
,注入到 A 中 -
A 完成初始化,检查二级缓存:
-
如果存在(说明有工厂提前生成过代理),则确保最终暴露的对象与早期引用的代理一致
(这里直接返回二级缓存的
A$Proxy@789
,避免重复生成代理)
-
-
将
A$Proxy@789
放入一级缓存
-
Spring事务失效的情况
一、自调用
同一个类中,一个方法调用另一个标注了 @Transactional
的方法时,事务不会生效。
原因:Spring AOP 是基于代理的,调用必须通过 Spring 生成的代理对象进行。如果是类内部调用,相当于 this.method()
,绕过了代理。
@Service
public class UserService {
@Transactional
public void methodA() {
methodB(); // 自调用,事务失效
}
@Transactional
public void methodB() {
// 不会开启事务
}
}
正确调用方式:
将 methodB()
提取到另一个类中
@Service
public class MethodBService {
@Transactional
public void methodB() {
// 事务生效
}
}
@Service
public class UserService {
@Autowired
private MethodBService methodBService;
public void methodA() {
methodBService.methodB(); // ✅ 通过 Spring 管理的代理对象调用,事务生效
}
}
public
导致事务失效
二、方法不是 被 @Transactional
注解的方法如果不是 public
的,事务不会生效。
原因:Spring 默认使用 JDK 或 CGLIB 代理,而代理只对 public
方法进行增强。
三、异常未被正确抛出
如果抛出的异常是 checked exception
或者被 catch
捕获而没有重新抛出,事务不会回滚。
默认情况下,只有 RuntimeException
和 Error 会触发回滚,可以通过设置 rollbackFor
参数来指定其他异常:
@Transactional(rollbackFor = Exception.class)
被 catch
捕获而没有重新抛出,正确做法:捕获后继续抛出
@Transactional
public void saveUser() {
try {
userRepository.save(null);
} catch (Exception e) {
throw new RuntimeException("数据库保存失败", e); // 重新抛出,触发回滚
}
}
四、数据库引擎不支持事务
使用的数据库或数据库表不支持事务(如 MySQL 的 MyISAM 引擎),即使开启事务也不会生效。
计算机网络
三次握手
三次握手过程
TCP三次握手的目标是双方同步序列号(Sequence Number, SN) 和确认序列号(Acknowledgment Number, AN),并建立一条可靠的连接。
假设客户端(Client)想要与服务器(Server)建立连接。
第一步:SYN (Synchronize)
- 动作:客户端发送一个TCP报文段(Segment)。
- 标志位:设置
SYN = 1
(这是一个连接请求)。 - 序列号:客户端随机选择一个初始序列号
seq = x
。 - 状态变化:客户端进入
SYN-SENT
状态。
第二步:SYN-ACK (Synchronize-Acknowledge)
- 动作:服务器收到客户端的SYN报文后,如果同意建立连接,则回复一个报文段。
- 标志位:设置
SYN = 1
和ACK = 1
。 - 序列号:服务器随机选择自己的初始序列号
seq = y
。 - 确认号:设置
ack = x + 1
(表明服务器已经收到了客户端的序列号x,期望下次从x+1开始接收数据)。 - 状态变化:服务器进入
SYN-RCVD
状态。
第三步:ACK (Acknowledge)
- 动作:客户端收到服务器的SYN-ACK报文后,必须再回复一个确认报文。
- 标志位:设置
ACK = 1
。 - 序列号:
seq = x + 1
(因为第一次握手发送的序列号是x,这次是下一个数据字节的序列号)。 - 确认号:设置
ack = y + 1
(表明客户端已经收到了服务器的序列号y,期望下次从y+1开始接收数据)。 - 状态变化:客户端发送完此报文后进入
ESTABLISHED
状态。服务器收到这个ACK报文后,也进入ESTABLISHED
状态。
至此,连接成功建立,双方可以开始可靠的数据传输。
为什么两次握手不行
防止已失效的连接请求报文突然传到,导致错误(核心原因)
- 场景:假设客户端发送了一个SYN请求(第一次握手),但这个报文在网络中滞留了,迟迟没有到达服务器。客户端超时后未收到回复,于是重发了一个新的SYN请求,这次成功建立连接,数据传输完毕后关闭了连接。
- 问题:此时,那个滞留的SYN报文终于到达了服务器。服务器误以为这是客户端发起的一个新连接请求,于是回复SYN-ACK(第二次握手)并提前分配了资源(如缓冲区、连接变量)。如果只有两次握手,服务器发出SYN-ACK后就会认为连接已经建立,并一直等待客户端发送数据。
- 结果:但客户端根本没有想要建立这个新连接!客户端不会发送任何数据。这将导致服务器的资源被白白浪费,等待一个永远不会到来的数据请求,造成资源耗尽和服务不可用的风险。
三次握手如何解决?
在三次握手的机制下,服务器收到滞留的SYN报文后,会回复SYN-ACK。但客户端不会回复第三个ACK(因为它没有请求这个新连接)。服务器在长时间收不到ACK后,会超时重传SYN-ACK,最终失败并释放资源,从而避免了错误。
四次挥手
四次挥手过程
TCP是全双工通信,这意味着数据在两个方向上可以独立传输。因此,关闭连接需要每个方向都单独被关闭。
假设客户端(Client)主动发起关闭连接。
第一步:FIN (Finish)
- 动作:客户端希望关闭连接,于是发送一个TCP报文段。
- 标志位:设置
FIN = 1
(表示它没有更多数据要发送了)。 - 序列号:
seq = u
(u等于客户端之前已传送数据的最后一个字节的序列号加1)。 - 状态变化:客户端进入
FIN-WAIT-1
状态。这意味着客户端已经没有数据要发送了,但如果服务器还有数据要发送,客户端仍然需要接收。
第二步:ACK (Acknowledge)
- 动作:服务器收到客户端的FIN报文后,对其进行确认。
- 标志位:设置
ACK = 1
。 - 确认号:
ack = u + 1
(确认收到了客户端的关闭请求)。 - 状态变化:服务器进入
CLOSE-WAIT
状态。 - 此时状态:从客户端到服务器的这个方向的连接已经关闭。客户端收到这个ACK后,进入
FIN-WAIT-2
状态。但服务器到客户端的连接仍然打开,服务器可能还有未发送完的数据需要继续发送。
第三步:FIN (Finish)
- 动作:当服务器也完成了所有数据的发送后,它也会发送一个FIN报文。
- 标志位:设置
FIN = 1
(有时这个报文会和第二步的ACK合并,此时标志位为FIN = 1, ACK = 1
)。 - 序列号:
seq = w
(w等于服务器之前已传送数据的最后一个字节的序列号加1)。 - 确认号:
ack = u + 1
(通常还会再次确认客户端的FIN)。 - 状态变化:服务器进入
LAST-ACK
状态。
第四步:ACK (Acknowledge)
- 动作:客户端收到服务器的FIN报文后,必须发送一个确认报文。
- 标志位:设置
ACK = 1
。 - 确认号:
ack = w + 1
。 - 状态变化:客户端进入
TIME-WAIT
状态。等待一段时间(2MSL,Maximum Segment Lifetime)后,客户端才进入CLOSED
状态,彻底释放资源。服务器在收到这个最终的ACK后,立即进入CLOSED
状态,释放资源。
为什么客户端需要 TIME-WAIT 状态?
客户端发送完最后一个ACK后,必须等待 2MSL 的时间长度。
主要原因有两个:
- 可靠地终止连接:确保最后一个ACK能被服务器收到。如果这个ACK报文丢失,服务器会在超时后重传它的FIN报文(第三次挥手)。客户端在
TIME-WAIT
状态下可以收到这个重传的FIN,并重新发送ACK,从而保证双方都能正常关闭。如果没有这个等待,客户端直接关闭,服务器将永远收不到ACK,会一直处于LAST-ACK
状态,无法正常关闭。 - 让旧连接的报文在网络中消散:等待2MSL时间,可以确保本次连接所产生的所有报文都从网络中消失,这样就不会影响到未来可能建立的、具有相同四元组(源IP、源端口、目的IP、目的端口)的新连接。
TCP和UDP
特性 | TCP | UDP |
---|---|---|
连接 | 面向连接 (需三次握手) | 无连接 |
可靠性 | 高可靠,不丢包、不乱序 | 不可靠,可能丢包、乱序 |
速度/延迟 | 慢,开销大 | 快,延迟低 |
控制机制 | 有流量控制、拥塞控制 | 无控制,直接发送 |
数据形式 | 字节流 (无边界) | 数据报文 (有边界) |
特性 | 流量控制 (Flow Control) | 拥塞控制 (Congestion Control) |
---|---|---|
控制对象 | 发送方 vs 接收方 (一对一) | 发送方 vs 网络 (一对多) |
解决什么问题 | 发送太快,接收方处理不过来(缓冲区溢出) | 发送太快,网络处理不过来(路由器丢包) |
根本原因 | 接收方处理能力、缓冲区大小有限 | 网络带宽、路由器缓存等公共资源有限 |
谁在控制 | 接收方主导(通过通知发送方自己的窗口大小) | 发送方主动探测和调整(根据网络反馈) |
实现机制 | 滑动窗口协议 (TCP头部中的窗口大小 字段) |
多种算法 (慢启动、拥塞避免、快重传、快恢复) |
目标 | 防止接收方被淹没 | 防止网络被塞满瘫痪 |
使用场景:
TCP:用于需要“万无一失”的场景
- Web浏览 (HTTP/HTTPS)
- 文件传输 (FTP)
- 邮件发送 (SMTP)
- 远程登录 (SSH)
UDP:用于需要“越快越好”的场景
- 视频直播/视频会议
- 在线实时游戏 (如吃鸡、王者荣耀)
- 语音通话 (VoIP)
- DNS域名解析
TCP是面向连接的、可靠的协议,通过三次握手、确认重传等机制保证数据正确送达,但速度慢,适合网页、邮件、文件传输。UDP是无连接的、不可靠的协议,不做任何保证,但速度快、延迟低。
HTTP 版本对比表
特性/版本 | HTTP/1.0 | HTTP/1.1 | HTTP/2 |
---|---|---|---|
连接方式 | 每次请求都新建 TCP 连接,用完立即关闭(短连接) | 默认支持 长连接(Connection: keep-alive ),一个 TCP 连接可复用 |
多路复用:一个 TCP 连接里并发处理多个请求/响应 |
请求并发 | 不支持,需要并发就建多个连接 | 支持管道化(pipelining),但存在队头阻塞,实际应用少 | 真正解决队头阻塞,多路复用并行传输 |
带宽利用 | 头部重复多,浪费带宽 | 引入 Host 头字段,支持虚拟主机;依然是明文文本传输 | 头部压缩(HPACK),大幅减少头部开销 |
传输效率 | 每次请求-响应一个连接,效率低 | 长连接提升效率,但仍有队头阻塞问题 | 二进制分帧、并行传输、服务器推送,效率最高 |
使用场景 | 早期 Web | 目前最广泛使用(浏览器默认) | 大型应用、高并发、移动端、CDN,逐渐普及 |
HTTP/1.1 解决了 1.0 的短连接问题,但仍然存在队头阻塞、并发效率低、头部冗余的问题。
HTTP/2 的主要优势体现在:
-
二进制分帧:高效传输,方便扩展;
-
多路复用:一个连接并发多个请求,解决了 1.1 的队头阻塞;
-
头部压缩:减少带宽浪费;
-
服务器推送:服务器可以主动把可能用到的资源(CSS/JS)推给客户端,减少延迟。
访问一个网站的过程
1.建立连接
- DNS 解析:浏览器先把域名(如
www.example.com
)解析成 IP 地址。 - TCP 三次握手:和目标服务器建立 TCP 连接(默认端口 80,若是 HTTPS 还要做 TLS 握手)。
- 建立 HTTP/1.1 长连接
- 请求头里会带上
Connection: keep-alive
(默认启用),同一个 TCP 连接可以复用多次请求。
- 请求头里会带上
2.浏览器先请求 HTML 页面:
GET /index.html HTTP/1.1
Host: www.example.com
Connection: keep-alive
User-Agent: Chrome/...
Accept: text/html,application/xhtml+xml
3.服务器返回响应
服务器处理请求,返回 HTML 页面:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
Cache-Control: max-age=3600
<html> ... </html>
4.浏览器解析 HTML
解析 HTML 时,发现里面引用了 CSS、JS、图片 等静态资源。
浏览器会发起新的请求去获取这些资源。
5.并发请求资源
HTTP/1.1 一个 TCP 连接虽然可以复用,但请求必须顺序执行 → 存在队头阻塞。
为了提高并发性能,浏览器会:同时打开多个 TCP 连接(6~8 个/域名),每个连接里顺序下载多个资源;
6.页面渲染
CSS、JS、图片加载完成后,浏览器开始渲染页面。JS 可能会再发起 Ajax 请求(同样走 HTTP/1.1 长连接)。
数据库Mysql
B+树结构
计算三层B+树大致存多少数据:
数据页大小:16KB(16384字节) 单条记录大小:1KB(主键+数据,实际通常更小)
内部节点存储内容:指向子节点的指针 + 索引键 指针大小:6字节 索引键大小:8字节(bigint类型)
计算:
叶子节点层(第三层):每个叶子节点页可存储记录数:16KB ÷ 1KB = 16
条
每个中间节点页可指向的子节点数:16 × 1024 ÷ (6 + 8) = 1170
个
根节点可指向的中间节点数:1170
个
1170 × 1170 × 16 = 21,902,400
条,一棵三层的B+树在MySQL中可存储约2000万条记录
1. 非叶子节点(Internal Node)
- 作用:索引导航,决定往哪个子节点走。
- 内容
- 键值(key,可能是单列值,也可能是联合索引的前缀组合值)。
- 子节点指针。
- 特点
- 非叶节点里的键值是**“分隔键”**,用于划分范围。
- 不是表里的真实记录,不存完整数据,也不存主键。
👉 它就像一本书的目录,告诉你“第 1-100 页是 A,101-200 页是 B”。
2. 叶子节点(Leaf Node)
-
聚簇索引 (PRIMARY KEY):叶子节点存整行数据(所有列)。
-
二级索引
-
叶子节点存 索引列 + 主键值
-
因为二级索引不是数据行存储的地方,需要主键值才能回表。
-
-
联合索引:叶子节点存 (col1, col2, …, colN) + 主键值。
👉 它就像图书馆的书架,摆放着书的完整信息(至少包含你要定位的索引列 + 主键)。
3. 排序 & 查找规则
-
联合索引按
字典序排序
- 先比第一列;
- 相同再比第二列;
- 相同再比第三列;
- …依此类推。
-
查找过程:
- 在非叶子节点里二分定位范围;
- 进入叶子节点,顺序扫描(或精确匹配)到目标记录。
节点内部查找

页目录分了多个槽,每个槽都指向对应一个分组内的最后一条记录,每个分组内都会包含若干条记录。
通过二分查询,利用槽就能直接定位到记录所在的组,从而就能获取到对应的记录。
举个例子,现在有5个槽,如果想查找主键为3的记录,此时的流程是:
1)通过二分得到槽的中间位置,low=0,high=4,(0+4)/2=2;
2)通过槽定位到第二个分组中的主键为4的记录,4大于3,low=0不变,high=2;
3)继续二分(0+2)/2=1;槽1中主键2小于3,low=1,high=2;
4)此时high-low=1,可以确定值在high即槽2中,但是槽2只能定位到主键为4的记录,又因为槽之间是挨着的,所以可以得到槽1的位置,从槽1入手拿到主键2的记录,然后因为记录是通过单向链表串起来的,往下遍历即可定位到主键3的记录。
聚簇索引和二级索引
聚簇索引和二级索引都用 B+ 树实现,区别在于叶子节点存的内容。聚簇索引的叶子节点存整行数据,所以查主键不需要回表;而二级索引的叶子节点只存索引列和主键值,如果要取不在索引里的列,就要用主键回表去聚簇索引查数据。
什么是最左匹配原则?
当你为多列创建复合索引时,例如:
CREATE INDEX idx_d_b_a ON test_optimizer(d, b, a);
索引本质是按照 (d, b, a)
这个顺序生成的 B+Tree。只有从最左边的列开始,连续的条件才能被索引用上。
一旦中间某一列无法利用,就会“断掉”,后面的列通常用不上。
针对的是查询条件的顺序。
例子1:
WHERE b=1 AND a=123
没有 d
的条件,最左列 d
没匹配 → 索引基本用不上
例子2:
WHERE d=10 AND a=123
有 d
,但缺了中间的 b
。索引利用度仅止于 (d)
。
关键总结!!!
WHERE 子句的顺序无关紧要。
索引的定义顺序才重要(最左列必须命中,才能往后用)。
一旦遇到 范围条件(b>1),匹配就停止,后续列不能再用于索引过滤,但可以用于覆盖索引。因为b>1
的结果集已经是一整片连续范围,需要顺序扫描,不可以用a
来做定位了
还可以用于索引下推过滤!!!
什么是覆盖索引?
查询所需要的字段(包括 SELECT
的列和 WHERE
条件中用来过滤的列)都能在某一个索引里找到,这样数据库只需扫描索引,不用回表访问数据行。
非覆盖索引:索引只能帮你定位行,还得回到表里再取其他字段。
覆盖索引:索引里就有全部需要的列,直接返回,不用回表。
举例1:
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50)
);
CREATE INDEX idx_age_city ON user(age, city);
SELECT city FROM user WHERE age = 30;
需要的列是 age
(用于条件)和 city
(用于结果)。
这两个列都在 idx_age_city
里。👉 所以查询只访问索引即可,覆盖索引。
举例2:
SELECT name FROM user WHERE age = 30;
过滤用的 age
在索引里。但查询要返回 name
,而 name
不在索引里。👉 必须回表去拿 name
,所以不是覆盖索引。
索引下推
把本来需要在回表之后才能判断的条件,尽量提前“下推”到索引扫描阶段,用索引本身的数据来过滤掉更多无效行。
例一:
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50),
KEY idx_age_city (age, city)
);
SELECT id FROM user WHERE age > 30 AND city = 'Beijing';
MySQL 5.5 及以前:
使用 age > 30
,在索引里做范围扫描,得到一批候选主键 id。
回表(访问聚簇索引/数据页),再判断 city='Beijing'
是否成立。 👉 换句话说,只有最左前缀能利用索引,后续条件只能在回表之后判断。这会带来大量“无效回表”,I/O 消耗很大。
MySQL 5.6 之后:
用 age > 30
在索引里做范围扫描。
在扫描索引的过程中,直接判断索引里是否满足 city='Beijing'
。
因为 (age, city)
索引的叶子节点里就有 city
的值。
只有满足两个条件的行,才去回表拿 id
。
例二:
-- 索引
CREATE INDEX idx_zip_last_addr ON people(zipcode, lastname, address);
-- 查询
SELECT *
FROM people
WHERE zipcode='95054'
AND lastname LIKE '%etrunia%'
AND address LIKE '%Main Street%';
**无索引下推:**lastname LIKE '%etrunia%',注意 %
前缀通配,不能利用 B-Tree 前缀有序性,因此这一步索引无法继续用来缩小范围,索引利用度其实只到第一列 (zipcode)
。理论上这会退化成: “找到 zipcode=95054 的所有记录 → 回表 → 再判断 lastname 和 address 条件”。
使用索引下推:MySQL 在扫描 (zipcode, lastname, address)
索引时,已经能拿到 lastname 和 address 的值,因为它们就在复合索引的叶子节点上。
即使 lastname/address 不能帮你缩小索引扫描范围,MySQL 也可以把它们的过滤条件“下推”到索引扫描阶段:
- 扫描到某条
zipcode=95054
的索引记录时,立刻在索引层检查lastname LIKE '%etrunia%' AND address LIKE '%Main Street%'
。 - 如果不满足,就直接丢掉,不去回表。
- 只有满足的记录才回表取出
*
。
ICP 的触发条件总结
在 MySQL 里,索引下推的触发条件大致是:
- 使用了二级索引扫描(不是全表扫描,ICP 对主键聚簇索引没意义)。
- 查询条件涉及的列都包含在索引中。
- 某些列因为最左匹配或范围条件导致不能直接用于索引定位,但它们的值在索引叶子节点里存在,可以被“下推”来过滤。
- ICP 必须在叶子节点做,因为只有叶子节点才有“完整的、逐条的索引值”。
场景题:设计索引
表t
有查询:
SELECT a
FROM t
WHERE b > 1 AND c <> 5 AND d = 10; # <> 等价于 '不等于'
在 MySQL(InnoDB) 中,应该如何设计索引?为什么?优化器会自动调整这些条件的顺序吗?
1.索引设计
-
建复合覆盖索引
INDEX(d, b, a)
d=10
是等值 → 放首列,先把范围收窄。b>1
是范围 → 放第二列,在d=10
的段内做 range 扫描。a
放末位做覆盖,减少回表。c<>5
不等值对 B-Tree 不友好,通常由 ICP(索引条件下推)或残留过滤处理,不强求入键位。
-
索引字段选择:
=
、IN
能很好地用上索引
>
、<
能部分利用索引(范围扫描)
<>
(不等于)会导致“两段范围”((-∞,5)
和 (5,+∞)
),多数时候优化器宁愿直接全表扫或残留过滤。所以通 常 不会专门为了 <>
建索引。
-
为什么把
a
放进索引索引作用点主要是
WHERE
条件,帮你缩小扫描范围,但是查询里SELECT
的列如果不在索引里,MySQL 要回到 聚簇索引(主键数据页)去拿数据,这叫“回表”。如果你在二级索引里同时包含了要查询的列,那结果就可以直接从索引里拿出来 → 减少回表 I/O,速度会快很多。
2.优化器“会不会自动调整顺序
- 谓词求值顺序:优化器会重排,选择性强的会先评估。
- 索引列顺序:不会自动调换;B-Tree 的物理顺序由你建索引时决定,所以仍需遵循“等值在前、范围其后”的建模原则。
场景题:统计字段中xx出现的次数
在 SQL 里,没有直接的“数某个字符出现次数”的函数。
但我们可以用 字符串长度差 来实现:
LENGTH(content)
:原始字符串长度。LENGTH(REPLACE(content, ',', ''))
:把逗号都去掉之后的长度。- 两者之差,就是逗号的个数。
SELECT
id,
content,
LENGTH(content) - LENGTH(REPLACE(content, ',', '')) AS num
FROM information;
MySQL 大数据排序(ORDER BY)实现机制
MySQL 处理大数据排序时,会根据数据量大小和可用资源采用不同的策略,主要涉及两种核心算法:内存排序和外部排序。
一、排序基本流程
- 解析阶段:确定排序字段和顺序
- 数据获取:读取需要排序的数据
- 排序执行:根据数据量选择排序算法
- 结果返回:将排序后的数据返回客户端
二、排序模式(sort_mode)
通过 EXPLAIN
的 Extra
字段可以查看排序模式:
模式 | 含义 | 触发条件 |
---|---|---|
Using filesort |
需要额外排序 | 无合适索引时 |
Using index |
索引覆盖排序 | 索引满足排序顺序 |
三、大数据排序实现策略
- 内存排序(单路排序)
适用场景:
- 排序数据量 <
sort_buffer_size
- 查询字段较少(特别是使用
LIMIT
时)
特点:
-
一次性读取所有需要排序的字段(select *)
SELECT * FROM employees ORDER BY salary DESC; -- 示例:需要读取所有列
当使用覆盖索引时:
-- 示例:索引覆盖了查询字段 CREATE INDEX idx_salary_name ON employees(salary, name); SELECT name, salary FROM employees ORDER BY salary;
-
在内存中快速排序(通常使用快速排序)
-
无需临时文件
配置参数:
SET sort_buffer_size = 4M; -- 默认值通常为256K-2M
- 外部排序(双路排序)
适用场景:
- 排序数据量 >
sort_buffer_size
- 包含
TEXT/BLOB
等大字段
实现过程:
-
第一次扫描:只读取排序字段和行指针rowid
-- 假设employees表有10万行数据 SELECT name FROM employees ORDER BY salary DESC;
强制分阶段读取,即使查询name,第一阶段还是只读取
salary
和rowid
-
内存排序:对这些指针进行排序
-
第二次扫描:根据排序结果回表读取完整数据
-
多轮归并:如果数据极大,会使用多路归并算法
Mysql8.0为什么移除缓存
工作原理:
- 当执行一个
SELECT
语句时,MySQL 会先计算该语句的“哈希值”作为键(Key)。 - 然后检查查询缓存中是否存在这个键。
- 如果存在(即缓存命中),则直接返回缓存的结果集,完全跳过解析、优化和执行等所有后续步骤,效率极高。
- 如果不存在(即缓存未命中),则正常执行查询流程,并将执行得到的结果集存储到查询缓存中,以便下次使用。
性能原因:
1:严重的锁竞争,查询缓存有一个全局锁(lock_query_cache
)。任何试图访问查询缓存的操作(检查缓存或失效缓存)都需要先获得这个全局锁。
2.缓存失效策略过于粗暴,缓存失效是以“表”为粒度的。只要表有任何变动,所有涉及到该表的查询缓存,无论其具体内容是否真的被影响,都会被全部清除。
insert操作背后执行了什么?
1.客户端发送 SQL
应用端通过 **连接器(Connector)**先通过 JDBC 连接建立连接,此时进行账号验证。 把 INSERT ...
语句发送给 MySQL 服务器。
2.语法解析 & 优化
词法分析是哪种执行语句,SELECT还是INSERT..语法分析SQL 语法是否正确
优化器决定具体执行计划(比如选择哪个索引、怎样写数据)。
3.执行器
打开目标表(Table handler)。
检查当前用户是否对目标表有相应权限(如 INSERT 权限)。
检查表的读写锁、元数据锁。
准备调用存储引擎写数据。
4.写入存储引擎
写入 Buffer Pool(内存缓存):新行先写入缓冲区,而不是直接落盘。
更新索引结构:修改 B+ 树,把新记录插入主键索引和必要的二级索引。
5.日志记录
为了保证 崩溃恢复和事务一致性,InnoDB 会记录两类日志:
- Redo Log(重做日志)
- 记录“做了什么修改”。
- 先写入 redo log buffer,再刷到磁盘(
prepare
→commit
两阶段提交)。 - 事务提交后,redo log 不会马上清空,而是被标记为“已提交”,之后可以被覆盖(循环写,固定大小的 log file)。
- Undo Log(回滚日志)
- 记录“如何撤销修改”,保证事务回滚时能恢复。
- 存在系统表空间或 Undo 表空间里。
- 提交后,Undo Log 并不会立刻删除,因为可能有其他正在执行的快照事务还需要它。
6.返回结果
当 redo log 写入成功,并且 binlog 写入成功(双写保证),事务提交成功。
Mysql二阶段提交
binlog的作用:
1)数据恢复。binlog 会记录所有修改数据的 SQL 语句或行事件(如 INSERT
、UPDATE
、DELETE
等)。
当数据库崩溃或需要恢复到某个时间点时,可以先恢复最近一次完整备份,再通过回放 binlog 中的事件,将数据恢复到指定时刻。
2)主从复制。主库将写入操作记录在 binlog,从库通过 I/O 线程 读取 binlog,再由 SQL 线程 重放这些事件,实现主从数据同步。
二阶段提交的两个阶段:
- 准备阶段(Prepare Phase):在事务提交时,MySQL 的 InnoDB 引擎会先写入 redo log,并将其状态标记为 prepare,表示事务已经准备提交但还未真正完成。此时的 redo log 是预提交状态,还未标记为完成提交。
- 提交阶段(Commit Phase):当 redo log 的状态变为 prepare 后,MySQL Server 会写入 binlog(记录用户的 DML 操作)。binlog 写入成功后,MySQL 会通知 InnoDB,将 redo log 状态改为 commit,完整整个事务的提交过程。
为什么需要二阶段提交
如果没有二阶段提交,关于这两个日志,要么就是先写完 redo log,再写 binlog,或者先写 binlog 再写 redo log。我们来分析一下会产生什么后果。
1) 先写 redo log,再写 binlog
redo log 已经落盘 → 主库事务恢复时能看到数据。但 binlog 还没写入 → 从库永远不会收到这个事务。
结果:主库比从库多一条数据(主库领先,从库落后)。
2) 先写 binlog,再写 redo log
binlog 已经写入 → 从库收到并重放事务。但 redo log 没写入 → 主库宕机恢复后,事务回滚,数据丢失。
结果:从库比主库多一条数据(从库领先,主库落后)。
有了二阶段提交,MySQL 异常宕机恢复后如何保证数据一致呢?
情况 1:redo log = prepare,binlog 未写入 崩溃恢复时,因为 redo log 没有 commit,binlog 也没记录 → 事务直接回滚,数据一致。
情况 2:redo log = prepare,binlog 已写入但 redo log 未 commit 崩溃恢复时,对比 redo log 与 binlog:一致就提交,不一致就回滚。
所以才需要 **两阶段提交(2PC)**让 redo log 和 binlog 的提交状态严格绑定,要么一起成功,要么一起失败,保证主从一致。
MVCC
多版本并发控制。
ungo log

1)当你执行 insert (1, XX)
时,除了在表里存储 ID 和 name,还会自动记录两个隐藏字段:
-
trx_id:事务 ID。
-
roll_pointer:指向对应的 undo log。
这个 undo log 是一条类型为 TRX_UNDO_INSERT_REC
的日志,表示它是由 insert
操作产生的。undo log 会保存主键等必要信息,如果事务回滚,InnoDB 就能根据这些信息删除对应记录,从而恢复到执行前的状态。

2)当事务 1 提交后,另一个事务(事务 ID=5)执行 update id=1
时,新的记录会带有 trx_id=5 和新的 roll_pointer,同时生成一条新的 undo log。
这条 undo log 类型是 TRX_UNDO_UPD_EXIST_REC
,它会保存:
-
旧的
trx_id
-
旧的
roll_pointer
-
被修改前的字段值(比如 name 原本是 XX)
旧值 → 用于回滚。
old_trx_id + old_roll_pointer → 用于构建版本链,支持 MVCC 下的一致性读。
与 insert 的 undo log 不同:
- insert 生成的 undo log 在事务提交后就会被清理(因为不会有人需要看到插入之前不存在的版本)。
- update 生成的 undo log 则会保留一段时间,用于回滚和多版本并发控制(MVCC,其他事务可能还需要访问旧值)。

3)又有id=11的事务执行 update yes where id=1

ReadView
readView 用来判断 某个版本的数据 对当前事务是否可见。 它依赖四个核心概念:
- creator_trx_id:当前事务 ID。
- m_ids:生成 readView 时,系统里所有未提交事务的 ID 集合。
- min_trx_id:m_ids 中最小的事务 ID。
- max_trx_id:生成 readView 时,系统将分配给下一个事务的 ID(比现有所有事务 ID 都大)。
注意,如果一个事务是查询,它的creator_trx_id 为0
可见性判断流程
判断逻辑是:从最新版本开始,沿着版本链往回找,遇到第一个符合条件的版本就返回。
具体条件:
- trx_id = creator_trx_id → 说明是当前事务自己修改的,可见。
- trx_id < min_trx_id → 修改这个数据的事务在 readView 生成前就提交了,所以可见。
- min_trx_id ≤ trx_id < max_trx_id:
- 如果 trx_id ∈ m_ids(事务未提交),不可见。
- 如果 trx_id ∉ m_ids(事务已提交),可见。
- trx_id ≥ max_trx_id → 说明修改事务在 readView 生成时还没开始,所以不可见。
读已提交下的MVCC
场景说明:
-
事务 1 已经提交。
-
事务 5 执行了
update
,但还没提交。 -
此时新开一个事务(事务 ID=6),去查询
select name where id=1
。
readView 的关键参数:
-
creator_trx_id = 6 → 当前查询事务的 ID。
-
m_ids = {5,6} → 活跃事务 ID 集合(未提交的事务)。
-
min_trx_id = 5 → m_ids 中最小值。
-
max_trx_id = 7 → 下一个待分配事务 ID。
查询过程:
- 查询到 id=1 的最新版本,其 trx_id=5。
- 不小于 min_trx_id,说明不是历史提交版本。
- 又在 m_ids 集合里,说明事务 5 还没提交。
- 所以这个版本 不可见。
- 根据 roll_pointer 追溯到上一个版本(undo log)。
- 该版本 trx_id=1,小于 min_trx_id=5,说明生成 readView 时它已经提交。
- 所以这个版本 可见。

Explain
EXPLAIN
会展示执行计划的关键信息,常见字段有:
- id:查询中执行步骤的顺序和嵌套关系,id 越大优先级越高。
- select_type:查询的类型,比如 SIMPLE、PRIMARY、SUBQUERY、DERIVED 等。
- table:当前访问的表。
- type:最关键,表示表的访问方式,也就是执行效率。
- possible_keys / key:优化器认为可能使用的索引、实际使用的索引。
- rows:预计扫描的行数,越少越好。
- filtered:返回记录的百分比。
- Extra:额外信息,比如
Using index
(覆盖索引)、Using where
、Using filesort
(额外排序)、Using temporary
(临时表)。
type
的性能排序(从好到差,大致顺序)
-
system:表只有一行(特殊情况)。
-
const:通过主键/唯一索引等一次命中。(单表直接查唯一值。)
CREATE TABLE user ( id INT PRIMARY KEY, email VARCHAR(100) UNIQUE, name VARCHAR(50) ); EXPLAIN SELECT * FROM user WHERE id = 1; -- type = const
-
eq_ref:唯一索引等值连接。(多表 join 时)
-
ref:普通索引等值匹配,返回多行。
CREATE TABLE product ( id INT PRIMARY KEY, category_id INT, KEY idx_category(category_id) ); EXPLAIN SELECT * FROM product WHERE category_id = 10; -- type = ref
-
range:索引范围扫描(
BETWEEN
、> <
等)。EXPLAIN SELECT * FROM product WHERE category_id BETWEEN 1 AND 5; -- type = range
-
index:全索引扫描(比全表稍好)。
EXPLAIN SELECT category_id FROM product; -- 如果只扫索引列,不回表,type = index
-
ALL:全表扫描(最差)。
mysql查询优化
如果查询性能出现问题,我会先从 单库层面优化,比如通过慢日志结合 EXPLAIN 找到慢 SQL,合理设计和调整 索引(组合索引、覆盖索引、避免索引失效),必要时重写 SQL 来减少全表扫描。其次,会考虑在业务层引入 缓存(如 Redis)来承接热点读,降低数据库压力。最后,如果单库已经成为瓶颈,则会通过 分库分表、冷热数据拆分、读写分离等手段来进一步扩展系统的吞吐能力。
Mysql删除数据的三种方法
命令 | 示例 | 结果 | 结构是否还在 | 是否可回滚 |
---|---|---|---|---|
DELETE | DELETE FROM demo WHERE name='A'; |
只删除 name='A' 的那行,剩下 B、C |
✅ 保留 | ✅ 可回滚(事务内) |
TRUNCATE | TRUNCATE TABLE demo; |
表中数据全部清空,B、C 也没了 | ✅ 保留 | ❌ 不可回滚 |
DROP | DROP TABLE demo; |
整个 demo 表被删掉,表结构和数据都没了 |
❌ 删除 | ❌ 不可回滚 |
- DELETE:删行,可加条件,事务可回滚。
- TRUNCATE:清空表,比 DELETE 快,不能回滚。
- DROP:连表带结构一起删。
MyBatis
MyBatis的缓存机制
怎样算在同一个sqlsession:
1.同一个对象实例
SqlSession session = sqlSessionFactory.openSession();
UserMapper mapper1 = session.getMapper(UserMapper.class);
UserMapper mapper2 = session.getMapper(UserMapper.class);
// mapper1 和 mapper2 使用的是同一个 SqlSession
2.在同一个事务范围内
@Transactional
public void processOrders(List<Order> orders) {
for (Order order : orders) {
User user = userMapper.findById(order.getUserId());
// 处理订单逻辑
}
}
一级缓存(本地缓存)
- 作用范围:SqlSession 级别(默认开启)
- 特点:
- 同一个 SqlSession 中执行相同的 SQL 查询时,会直接从缓存中获取结果
- 执行 INSERT/UPDATE/DELETE 操作或调用
commit()
、close()
、clearCache()
时会清空一级缓存
- 生命周期:与 SqlSession 相同
二级缓存(全局缓存)
存储位置:JVM 堆内存(SqlSession 内部的一个 HashMap)
-
作用范围:Mapper 命名空间级别(需要手动配置开启)
-
配置方式:
<mapper namespace="..."> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> <select id="queryGroupBuyProgress" resultType="GroupBuyOrder"> SELECT * FROM group_buy WHERE team_id = #{teamId} </select> </mapper>
-
特点:
- 多个 SqlSession 可以共享缓存
- 缓存数据存储在内存中,可以配置存储到硬盘或其他存储介质
- 执行 INSERT/UPDATE/DELETE 操作会清空对应命名空间的二级缓存
缓存工作流程
- 查询顺序:二级缓存 → 一级缓存 → 数据库
- 更新顺序:数据库 → 清空相关缓存
二级缓存的数据不一致情况:
- SqlSession1 读取了数据A,并将其缓存
- SqlSession2 将数据更新为B(这会清除二级缓存)
- 但当SqlSession1再次读取时,如果配置不当,可能仍然从自己的缓存中获取到旧的A数据
Redis
Redis主从集群
数据备份、适合读多写少的场景
主节点(Master):负责写操作和数据复制。
从节点(Slave):负责读操作,数据由主节点异步复制过来。
1)如果判断redis从节点是第一次来建立连接?
根据replid,没建立集群时,每个节点都认为自己是主节点,有自己的replid;如果从节点A slaveof B,那么节点A和B的replid会改为全新的且一样的replid。这样,master节点只要看请求过来的replid和自己是否一样,如果一样就不是第一次建立;如果不一样就是第一次建立连接。
2)第一次来建立连接,怎么全量复制?
主节点执行 BGSAVE
,生成一个 RDB 快照文件。
主节点将 RDB 文件发送给从节点,从节点清空现有数据,加载 RDB。
3)Slave如果宕机,少执行一些命令咋办?
有个backlog记录master第一次与从节点建立连接后所执行的所有命令。增量复制,主节点和从节点都维护一个复制偏移量 offset。如果从节点掉线时间不长,重新连上时会带上自己已同步的 offset,主节点只发送缺失的那部分数据。
RDB(快照)
方式:某个时间点,把 Redis 整个内存的数据写成一个二进制文件。
记录的是某一时刻的全量状态(snapshot)。
快照之间的所有写入操作不会被记录。
AOF(写日志)
方式:把 Redis 执行的每一条写命令,按顺序追加到日志文件(appendonly.aof)。
特点:
- 记录的是操作过程,几乎每条写入都能还原。
- 根据
appendfsync
配置:always
→ 每次写命令都落盘,理论上不会丢任何写。everysec
(默认)→ 最多丢 1 秒内的写。no
→ 可能丢更多,取决于 OS 缓冲。
backlog
本质:Redis 主从复制用的一个内存环形缓冲区。
位置:只存在于 Master 节点的内存里。
内容:存放最近一段时间 Master 执行的 写命令流,不是持久化文件。
Redis主从+哨兵
哨兵本质是一个独立运行的进程,可以部署多个实例,形成一个哨兵集群。
它的作用有三个:
- 监控:定期向主从节点发心跳,判断是否存活。
- 通知:发现节点宕机,会通知客户端或者其他服务。
- 自动故障转移:主节点宕机时,选举出一个新的主节点,并让其他从节点改为复制它。
工作流程
正常运行
- 主节点负责写,从节点负责读,哨兵不断检测主从的健康状态。
主节点故障
- 哨兵集群通过投票机制确认主节点不可用(超过一定数量的哨兵认为它下线)。
- 选举出一个从节点作为新的主节点。
故障转移
- 通知其他从节点去复制新的主节点。
- 通知客户端更新主节点地址,继续访问。
主观下线:某个 哨兵 觉得主节点不可达(可能是网络原因误判)。
客观下线:当 多数哨兵 都认为主节点不可达时,就进入“客观下线”状态,这个时候,才会真正发起故障转移流程。
Redis分片集群
分片集群可以看作多个主从集群+分片路由机制
哈希槽
Redis Cluster 并不是随便把 key 平均分到节点上,而是用 16384 个固定的哈希槽 (slot) 来管理。
每个 key 先经过哈希计算,然后被分配到某个槽,槽再映射到对应的节点。
路由机制:
- 计算槽号
- 算法:
CRC16(key) % 16384
- 即用 CRC16 算法对 key 做哈希,再取模 16384。
- 得到一个槽号(范围 0 ~ 16383)。
- 算法:
- 槽到节点的映射
- 集群启动时,先看有几个主节点,会把 16384 个槽分配给不同的主节点。
- 比如:
- 节点 A 管理槽
0 ~ 5460
- 节点 B 管理槽
5461 ~ 10922
- 节点 C 管理槽
10923 ~ 16383
- 节点 A 管理槽
- 定位节点
- 客户端要访问一个 key 时,先算出槽号。
- 然后根据槽分布表,找到负责这个槽的主节点,直接发请求过去。
缓存一致性
什么是Cache Aside Pattern?
更新数据库的同时更新缓存。
先删缓存再删数据库
线程A(右边)删除数据,先删Redis;此时换到线程B来查询,发现Redis为空,它去查mysql,查到了旧数据(线程A还没来得及删mysql),然后回写到redis中;此时A缓过神来,把mysql旧数据删除了。这样redis中有数据,但mysql中无,造成了数据的不一致。这种情况出现的概率比较大,因为线程B查询操作一般比线程A更新操作速度快!!!
先删数据库,再删缓存
线程A(左边)先查询redis,未命名,查mysql,查到了数据;此时换到线程B来删除数据,它先删mysql,再删缓存;再轮到A执行,它回写Redis。此时又出现了数据的不一致:redis中有脏数据,mysql中该数据被删了。但这种概率比较低,因此推荐该方法。
默认情况下这种出现概率极低,尽管出现也可以依靠TTL过期来达到最终一致性。如果实在想避免这种短暂的不一致,可以使用延迟双删策略,就是过一段时间后,再主动把缓存删除。
压缩列表
zlbytes
(4字节)- 作用:记录整个压缩列表占用的总字节数。
- 目的:用于内存重分配时,无需遍历整个列表即可知道需要分配多少内存。
zltail
(4字节)- 作用:记录从压缩列表起始地址到最后一个节点(entry)的起始地址的偏移量。
- 目的:允许程序直接定位到列表尾部,从而在尾部进行
push
或pop
操作时非常高效,时间复杂度为 O(1)。
zllen
(2字节)- 作用:记录压缩列表中包含的节点(entry)数量。
- 注意:由于它只有 2 字节(16 位),最大能表示
2^16 - 1 = 65535
。当节点数超过这个值时,这个字段的值会置为 65535,此时如果需要获取真实节点数,必须遍历整个列表。
entryX
(可变长度)- 作用:存储实际数据的节点。每个节点可以存储一个字节数组或一个整数值。
- 特点:为了节省内存,每个节点的长度是可变的,其结构设计得非常精巧(下文详述)。
zlend
(1字节)- 作用:一个特殊的标记,用于标识压缩列表的结束。
- 值:恒定为
0xFF
(十进制 255)。
每个 entry
由三部分组成,其长度都是可变的:
previous_entry_length | encoding | content |
---|---|---|
1字节或5字节 | 1字节、2字节或5字节 | 实际存储的数据 |
previous_entry_length
(前一个节点的长度)
目的:有了这个字段,程序可以从列表的任意位置(特别是尾部)向前遍历。当前节点的地址减去这个长度,就能找到前一个节点的起始地址。
encoding
(编码)
作用:这个字段非常关键,它同时编码了两方面信息:
- content 中存储的数据类型(是整数还是字节数组?)
- content 的数据长度(如果是字节数组)或整数值的具体类型(如果是整数)。
content
(内容)
作用:实际存储节点的值,可以是一个字节数组(如字符串 "hello")或一个整数值(如整数 10086)。
压缩列表是 “时间换空间” 的经典设计。它通过精巧的结构设计,用 CPU 计算时间来换取极致的内存节省。
在 Redis 中,它被用于存储少量、小体积的数据元素。例如:
- 当 Hash 类型的所有键值对都较小时,底层使用 ziplist。
- 当 List 的元素都是小字符串时,底层使用 ziplist。
- 当 Sorted Set 的元素数量和值都较小时,底层使用 ziplist。
RabbitMq
Rabbit mq怎么保证消息消费的顺序性?
想要最简单的严格顺序:单队列 + 单线程 + prefetch=1
就行,但吞吐拉胯、易被卡。
想要既有顺序又要并发:把 Key 做 hash→shard,每个 shard 单线程消费,不同 shard 并行,既保同 Key 有序,又能扩展。
消息顺序错乱的原因
1)多并发消费
多个消费者同时从一个队列拉消息执行(配置同一个queue),顺序就不可控了
队列中的消息 | 消费者1 执行耗时 | 消费者2 执行耗时 | 最终结果 |
---|---|---|---|
msg1 | 1000ms | ✔️ | |
msg2 | 100ms | ✔️ |
虽然 msg1
在前面,但由于 msg2 执行更快,先被确认,所以“处理顺序”乱了。
2)重试回退(requeue)
失败的消息重新放回队列头或尾,扰乱原有顺序
3)批量预取(prefetch > 1)
消费者一次拿多个消息,哪条先被处理完就先 ack,顺序不再可靠
分片 + 单线程消费
在高并发业务中,“要求顺序处理”的通常不是全部消息,而是同一个业务 key 的消息。按 key 有序的原理:“分片 + 单线程消费”
要求:
同一个 key
(比如 userId = 123)内的消息,必须按照发送顺序处理
不同 key
(userId = 123 vs 456)之间可以并行处理,不要求顺序
核心思想:
对业务 key 做哈希,分片(shard)
每个 shard 对应一个 专属队列
每个队列只绑定1 个消费者(线程)
消息自然按顺序进入、顺序消费
这样,同一个 key 一定总是落到同一个 shard → 同一个 queue → 同一个消费者 → 顺序 ✅
举例:假设你要处理 userId 相关消息,希望同一个用户按顺序消费
发消息时:
int shard = userId.hashCode() % 4;
发送到:queue.user.shard.0~3 之一
消费时:
queue.user.shard.0 → consumer0(串行处理)
queue.user.shard.1 → consumer1(串行处理)
...
效果:
userId=101 落到 shard 1,所有与之相关的消息在 consumer1
串行处理
userId=202 落到 shard 2,由 consumer2
处理,互不影响
背压机制
背压是一种 流量控制机制,当下游(消费者 / 处理器)处理不过来时,通知上游(生产者)减速或暂停,从而避免系统过载。
常见的背压机制
1)阻塞生产者
生产者往队列放消息,如果队列满了,阻塞等待。
BlockingQueue<String> queue = new ArrayBlockingQueue<>(1000);
queue.put("msg"); // 队列满时阻塞
2)丢弃或拒绝策略
队列满了,直接丢弃新消息,或者返回错误给生产者。RabbitMQ 中可以配置:
.overflow(QueueBuilder.OverflowPolicy.rejectPublish)
3)生产者端处理 publish 被拒绝
当队列满时,如果用了 rejectPublish
策略,生产者会收到异常:
try {
rabbitTemplate.convertAndSend("order-queue", orderMessage);
} catch (AmqpException e) {
// 背压触发:队列已满,生产者需要降速 or 丢弃
Thread.sleep(100); // 简单降速
}
4)限流 (Rate Limit / Prefetch)
默认情况下,消费者会一次性接收尽可能多的消息
开发者需要显式设置:
channel.basicQos(50); // 每次最多取 50 条
表示消费者告诉 MQ:一次只给我 N 条,避免自己被压垮。这样消息会堆积在MQ的消息队列中!!
Git
git merge和git rebase的区别
git merge
- 把两个分支合并在一起,产生一个新的 merge commit。
- 保留了两个分支的提交历史。
- 历史可能会有「分叉」和「合并点」。
git rebase
- 把一个分支的提交“挪到”另一个分支上,好像在新的基底上重新提交一遍。
- 没有 merge commit,历史会更干净。
- 实质上是「修改历史」。
示例:
main: A --- B --- C
\
feature: D --- E
1)使用merge
git checkout main
git merge feature
Git 会生成一个 新的合并提交(merge commit),把两个分支的历史接到一起:
main: A --- B --- C ------- F (merge commit)
\ /
feature: D --- E ---
历史完整保留(能看到分支是怎么分叉和合并的)。
但历史中会有很多「叉路」,可能显得比较乱。
2)使用 rebase
git checkout feature
git rebase main
Git 会把 feature
的提交 D、E 拿下来,重新放到 main
的最新提交后面,好像是“重新提交”一遍:
main: A --- B --- C
\
feature: D' --- E'