一、Java基础
1. 核心注解及其作用
@SpringBootApplication
是一个组合注解,等价于以下三个注解的组合:
- @SpringBootConfiguration:是
@Configuration的派生注解,标识这是一个配置类。 - @EnableAutoConfiguration:启用自动配置功能,根据类路径中的依赖自动配置 Spring 应用。
- @ComponentScan:自动扫描当前包及其子包中的组件(如
@Controller,@Service,@Repository,@Component等)。
其他常见注解
| 注解名 | 作用 |
|---|---|
@RestController | 组合注解,等价于 @Controller + @ResponseBody,用于创建 RESTful Web 服务。 |
@RequestMapping | 映射 HTTP 请求到控制器方法上,支持 GET、POST 等。 |
@Autowired | 自动注入依赖的 Bean。 |
@Value | 注入配置文件中的值。 |
@Configuration | 声明配置类,可使用 @Bean 注册 Bean。 |
@Bean | 用于方法上,表示该方法返回一个 Spring 管理的 Bean。 |
2. 启动流程详解
[创建] → [配置] → [广播] → [刷新] → [加载]
- 创建:
SpringApplication实例(构造函数) - 配置:环境+监听器(
setInitializers/listeners) - 广播:事件通知(
ApplicationStartingEvent) - 刷新:上下文(
refreshContext) - 加载:根据
spring.factories文件自动配置(AutoConfiguration)
3. Spring Boot 与 Spring MVC 的区别
| 项目 | Spring Boot | Spring MVC |
|---|---|---|
| 核心目标 | 简化配置,快速开发 | 提供 Web MVC 功能 |
| 配置 | 基于约定优于配置,零 XML | 需要大量手动配置 |
| 依赖管理 | 内嵌依赖版本控制(starter) | 需手动管理依赖 |
| 服务器 | 支持内嵌服务器(如 Tomcat) | 通常部署在外部容器中 |
| 自动化 | 提供自动配置机制 | 无自动配置,需手动初始化 |
| 应用类型 | 一站式开发平台 | 仅为 Web 模块服务 |
✅ 总结:Spring Boot = Spring(包括 Spring MVC) + 自动配置 + 起步依赖 + 内嵌服务器
4. IoC(控制反转)
4.1 什么是 IoC?
控制反转(Inversion of Control) 是一种设计思想,指对象的创建及其依赖的管理不再由程序员手动控制,而是交由 Spring 容器统一管理。
通俗地说:你不再“new”对象了,而是“要”对象(通过注解、配置等) 。
4.2 IoC 实现机制:依赖注入(DI)
Spring 支持三种方式的依赖注入:
| 方式 | 示例 | 优缺点 |
|---|---|---|
| 构造器注入 | 推荐 | 线程安全、适用于不可变依赖 |
| Setter 方法注入 | 有默认构造函数的 Bean | 可选注入、容易出错 |
| 字段注入 | 使用 @Autowired | 简洁,但不易测试,不推荐 |
4.4 Bean 的生命周期(简要)
实例化 → 属性注入 → 初始化方法 → 业务使用 → 销毁前操作
5. AOP(面向切面编程)
5.1 什么是 AOP?
AOP(Aspect-Oriented Programming) 是一种编程范式,用于将 横切关注点(如日志、权限、事务) 从主业务逻辑中解耦出来。
比如:记录日志、权限校验、事务控制,不应该和业务逻辑混在一起。
5.2 AOP 的核心概念
| 概念 | 说明 |
|---|---|
| Aspect(切面) | 横切关注点的模块(类) |
| Pointcut(切点) | 匹配连接点的表达式 |
| Advice(通知) | 要织入的逻辑,如 before、after、around |
5.3 常见通知类型
| 通知类型 | 注解 | 执行时机 |
|---|---|---|
| 前置通知 | @Before | 方法执行前 |
| 后置通知 | @After | 方法执行后(无论是否异常) |
| 环绕通知 | @Around | 控制方法执行前后、自定义返回值 |
5.4 AOP 的实现原理(面试重点)
Spring AOP 是基于 JDK 动态代理 和 CGLIB 字节码增强 实现的:
- 如果目标类实现了接口 → 使用 JDK 动态代理;
- 如果目标类没有实现接口 → 使用 CGLIB 生成子类代理。
// 有接口的情况 - 可以使用JDK动态代理
interface Service {
void serve();
}
class RealService implements Service { // 实现了接口
public void serve() { /*...*/ }
}
6. 声明式事务管理(Declarative Transaction Management)
6.1 什么是事务?
事务具有 4 个特性(ACID):
- 原子性(Atomicity)
- 一致性(Consistency)
- 隔离性(Isolation)
- 持久性(Durability)
在 Spring 中,事务可通过 声明式 或 编程式 管理,推荐使用声明式事务。
6.2 声明式事务注解
可用在 类上(作用于所有方法)或 方法上(作用于当前方法):
@Transactional
public void createOrder() {
// 数据库操作
}
6.3 常用属性解释
| 属性 | 说明 |
|---|---|
propagation | 事务的传播级别,默认 REQUIRED |
isolation | 事务隔离级别,默认 DEFAULT |
rollbackFor | 指定哪些异常回滚,默认只回滚 RuntimeException |
6.4 事务传播级别
| 类型 | 描述 |
|---|---|
| REQUIRED | 默认值,有事务则加入,没事务则新建 |
| REQUIRES_NEW | 每次都新建事务,原事务挂起 |
| SUPPORTS | 有事务就用,没有就非事务运行 |
6.5 事务失效的几种常见情况
| 情况 | 原因 |
|---|---|
| 同一个类中调用自身方法 | Spring AOP 代理失效,调用未被代理 |
方法不是 public | 事务注解无效,只对 public 方法生效 |
| 捕获异常未重新抛出 | 异常被 catch 后未抛出,无法触发回滚 |
| 没有使用 Spring 管理的 Bean | 非 Spring 容器管理的类不支持事务 |
✅ 面试建议:记住 Spring 声明式事务是基于 AOP 实现 的,通过
TransactionInterceptor拦截方法调用。
7. 循环依赖(Circular Dependency)
7.1 什么是循环依赖?
当两个或多个 Bean 相互依赖,形成死循环依赖链,例如:
@Component
public class A {
@Autowired
private B b;
}
@Component
public class B {
@Autowired
private A a;
}
这就构成了一个构造器层级的循环依赖。
7.2 Spring 是怎么解决循环依赖的?
Spring 使用三级缓存机制解决 “属性注入级别的循环依赖” :
三层缓存机制(面试重点)
| 缓存名 | 类型 | 描述 |
|---|---|---|
| singletonObjects | 一级缓存 | 存放完全初始化好的 Bean |
| earlySingletonObjects | 二级缓存 | 存放早期曝光的 Bean(未填充属性) |
| singletonFactories | 三级缓存 | 存放 Bean 工厂(ObjectFactory)以暴露引用 |
依赖注入流程大致如下:
- Spring 创建 A 实例并暴露其 ObjectFactory 到三级缓存;
- A 依赖 B,发现 B 不存在,于是去创建 B;
- B 依赖 A,从三级缓存中获取 A 的引用;
- 属性注入完成后,B 完成创建并放入一级缓存;
- 回到 A,完成属性注入和初始化,放入一级缓存。
8. Spring 中常见的设计模式及其应用场景
Spring 框架大量使用了经典的设计模式,以实现高内聚、低耦合、可扩展的系统架构。以下是常见设计模式及其在 Spring 中的典型应用:
| 序号 | 设计模式 | 类型 | Spring 中的应用场景 |
|---|---|---|---|
| 1 | 单例模式 | 创建型 | Spring Bean 默认是单例(@Scope("singleton")),容器缓存单例对象 |
| 2 | 工厂模式 | 创建型 | BeanFactory, ApplicationContext 负责创建和管理 Bean 实例 |
| 3 | 控制反转模式 | 创建型 | 常规的新建对象是通过new来实现,对象不一样时需要更改程序。 而通过IOC仅仅修改配置文件即可实现不同对象的新建。 |
| 4 | 原型模式 | 创建型 | 使用 @Scope("prototype") 每次注入新实例,适用于无状态 Bean |
| 5 | 代理模式 | 结构型 | Spring AOP、声明式事务、缓存等功能使用 JDK 或 CGLIB 代理实现 |
| 6 | 策略模式 | 行为型 | TransactionManager, ViewResolver, ResourceLoader 选择不同策略实现 |
| 7 | 模板方法模式 | 行为型 | JdbcTemplate, RestTemplate, RedisTemplate 封装公共流程,子类实现关键逻辑 |
| 8 | 观察者模式 | 行为型 | ApplicationEventPublisher 发布事件,监听器解耦响应逻辑 |
| 9 | 责任链模式 | 行为型 | Filter 链、Spring MVC 中的拦截器链 (HandlerInterceptor) |
二、 Synchronized
1、synchronized 的基本概念
synchronized 是 Java 提供的一种同步机制,用于解决多线程访问共享资源时的并发问题,具有以下特点:
- 非公平锁,加锁是依赖底层 Monitor 的实现,先来的线程不一定先获得锁,线程调度由 JVM 控制。
- 可作用于代码块、实例方法和静态方法。
2、synchronized 的使用方式
1. 修饰实例方法
public synchronized void method() {
// 临界区代码
}
- 加锁对象:当前实例对象 (
this)。 - 字节码标识:设置方法的
ACC_SYNCHRONIZED标志。
2. 修饰静态方法
public static synchronized void staticMethod() {
// 临界区代码
}
- 加锁对象:当前类的
Class对象。 - 同样通过
ACC_SYNCHRONIZED标志控制 monitor。
3. 修饰代码块
public void method() {
synchronized (lockObject) {
// 临界区代码
}
}
- 加锁对象:
括号区域。 - 字节码使用
monitorenter和monitorexit指令实现。
3、synchronized 方法与代码块加锁的底层原理
1. 方法加锁(使用 ACC_SYNCHRONIZED)
-
在字节码中,不使用
monitorenter和monitorexit。 -
JVM 会识别
ACC_SYNCHRONIZED标志,在调用方法时自动加锁和释放。
2. 代码块加锁
-
编译后生成字节码指令
monitorenter和monitorexit。 -
加锁流程:
- 进入临界区前执行
monitorenter。 - 获得对象锁后进入。
- 退出时执行
monitorexit。 - 支持可重入(递归加锁):每次进入加 1,退出减 1,直到为 0 时释放锁。
- 进入临界区前执行
图中 “两个 Monitor”是为了说明两个阶段:
Monitor(左上) :表示 JVM 根据方法的ACC_SYNCHRONIZED标志识别这是一个同步方法;monitor(下方) :指的是 ObjectMonitor 对象,也就是实际参与加锁的“锁实体”。
➡️ 实际上 这两处都指向同一个概念本体:Monitor,只是在 JVM 中通过不同入口触发加锁逻辑。
4、Monitor(对象监视器)机制
-
每个对象在 JVM 中都有一个 Monitor。
-
Monitor 内部有以下结构:
- Owner:当前占用锁的线程。
- EntryList:等待获取锁的线程队列。
- WaitSet:调用
wait()进入等待的线程集合。 - recursion count:支持重入锁计数。
5、synchronized 与锁的类型演化(锁升级)
synchronized 在 JVM 中通过对象头中的 Mark Word 实现锁状态标识,支持以下几种锁:
| 锁类型 | 特点 | 使用场景 |
|---|---|---|
| 偏向锁(Biased Lock) | 单线程无竞争加锁,几乎无开销 | 默认开启,用于无竞争环境 |
| 轻量级锁(Lightweight Lock) | 使用 CAS 自旋机制,无阻塞 | 少量线程竞争 |
| 重量级锁(Heavyweight Lock) | Monitor 管理,线程阻塞挂起 | 多线程竞争激烈、轻量锁多次失败后膨胀 |
锁升级路径
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
6、偏向锁(Biased Lock)
🧠 设计目的
偏向单线程:如果一个对象一直被同一个线程访问,加锁/解锁不再需要 CAS,提高性能。
✅ 加锁过程
- 线程访问对象,检查对象头是否为无锁;
- JVM 将当前线程 ID 写入 Mark Word,并设置为偏向锁状态;
- 后续该线程再进入同步块时,不需要真正加锁,只需判断是否是自己的偏向即可。
✅ 没有竞争,零成本加锁。
解锁过程
- 无需处理,线程退出同步块即可。
❗ 失效与撤销
- 如果另一个线程访问该对象,JVM 撤销偏向状态 ➜ 升级为轻量级锁。
7、轻量级锁(Lightweight Lock)
🧠 设计目的
线程间交替执行时,避免线程阻塞,采用 自旋(CAS)+ 锁记录 来加锁。
✅ 加锁过程
-
当前线程在栈中创建一个 锁记录,保存对象原始的 Mark Word;
-
使用 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的地址;
- 如果成功:获取锁;
- 如果失败:表示有线程持有锁 ➜ 自旋尝试获取;
-
自旋多次失败 ➜ 升级为重量级锁。
🔓 解锁过程
- 使用 CAS 把 Lock Record 中保存的 Displaced Mark Word 恢复到对象头;
- 如果 CAS 成功:释放成功;
- 如果失败:说明锁被争抢 ➜ 升级为重量级锁。
💡 特性
- 适合少量线程交替加锁的场景;
- 减少了线程挂起/唤醒的系统调用;
- 自旋是核心。
8、重量级锁(Heavyweight Lock)
🧠 设计目的
在有大量线程竞争时,轻量锁的自旋将浪费 CPU,因此升级为重量级锁,引入阻塞机制。
升级到重量级锁的关键策略是自选失败超过阈值。
-
线程A持有轻量级锁,线程B通过自旋(CAS) 尝试获取锁。
-
第一步:自旋竞争
线程B和C会先通过 CAS自旋 尝试获取锁(轻量级锁的默认策略)。- 若其中一个线程(如B)成功通过CAS获取锁(也可能是C获取锁,因为B是先来的,所以这是轻量级锁不公平的体现),另一个线程(C)会继续自旋。
- 此时仍保持轻量级锁状态。
-
第二步:自旋失败后升级
如果线程C自旋超过一定次数(或时间)仍未获取锁(例如B持有锁时间较长),JVM会触发锁膨胀:- 撤销轻量级锁,升级为 重量级锁。
- 线程C被放入
EntryList队列阻塞等待。
-
补充
- 如果此时来个D线程并不会直接放到等待队列,而是先CAS自选一次,如果失败才放入队列(这是重量级锁不公平的体现)
❗ 关键点
- 竞争不直接导致升级,自旋失败才是升级的触发条件。
- 轻量级锁时候升级的阈值由JVM自适应策略决定(如
-XX:PreBlockSpin参数控制初始自旋次数)。 - 重量级锁时候新来的线程会CAS自选一次抢占
✅ 加锁过程
- 竞争失败后,JVM 创建 ObjectMonitor(monitor对象);
- 所有进入同步块的线程将尝试进入 ObjectMonitor 的 EntryList 队列;
- EntryList 队列中的线程被阻塞;
- 当前持有锁的线程在退出后,会唤醒 EntryList 的下一个线程;
- 等待的线程获得 Monitor 后才能进入同步块。
🔓 解锁过程
- 当前线程退出同步块,释放 ObjectMonitor;
- JVM 从 EntryList 或 WaitSet 中唤醒一个线程进入。
💡 特性
- 使用系统调用(如 park/unpark)进行线程挂起/恢复;
- 开销大,但适合高并发场景;
- 一般是“最后手段”。
9、锁状态转化图
单线程 多线程交替 激烈竞争
无锁 ───────► 偏向锁 ───────► 轻量级锁 ───────► 重量级锁
失败 失败
三、🔒 ReentrantLock 简介
ReentrantLock底层基于 AQS 实现,分为公平锁和不公平锁。对应的类是FairSync和NonfairSync,主要区别是公平锁判断前方是否有等待线程,如果有加入队列尾部,非公平锁是直接CAS获取锁。
1.. 锁的状态管理:AQS + state
AQS 维护一个 volatile int state 字段,表示锁的状态。
state = 0:锁空闲;state > 0:被持有;如果是同一线程再次持有,state++;CAS保证对state操作的原子性;
ReentrantLock 是可重入的,所以
state可多次递增。
2. 锁获取流程详解
1. 非公平锁(默认)获取锁流程:
final void lock() {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
} else {
acquire(1); // 走 AQS 逻辑
}
}
2.加锁流程
🔸2.1. 调用:tryAcquire()尝试快速抢锁
-
线程首先尝试 通过 CAS 将
state从 0 修改为 1:- 成功 ➔ 当前线程成功获取锁,设置锁持有者。
- 失败 ➔ 表示锁已被占用,继续后续判断。
✅ 非公平锁(默认) :永远会先直接尝试 CAS 抢锁。
✅ 公平锁:调用 hasQueuedPredecessors()等待队列 判断是否有节点排队,若有,则拒绝抢锁,直接排队。
🔸2.2. 判断是否可重入
-
如果当前线程已经持有锁:
- 允许重入:
state += 1,最多可重入Integer.MAX_VALUE次
- 允许重入:
🔸2.3. 抢锁失败 ➔ 加入等待队列
- 抢锁失败后,线程会被封装成
Node对象 - 然后通过
CAS尾插入到 AQS 的 FIFO 同步队列尾部 - 入队后调用
acquireQueued(),线程会进入 自旋 状态等待锁
✅ 区别点:公平锁更早就判断是否排队,非公平锁尝试抢锁失败后才入队。
3.非公平锁流程图
线程尝试获取锁
[线程尝试获取锁]
↓
┌────────────────────┐
│ CAS(state==0)? │
└────────────────────┘
↓ 是 ↓ 否
[成功获取锁] ┌──────────────────────────┐
│ state ≠ 0 → 是否可重入? │
└──────────────────────────┘
↓ 是 ↓ 否
[state+1] [入FIFO队列放到尾部+自旋等待]
3. 锁释放流程
-
调用 unlock() 方法 ➔ 实际内部执行的是 AQS 的 release() 方法。
-
release() 核心动作:
-
当前线程state减1。
-
如果减到0:
- 说明锁彻底释放;
- 通过AQS唤醒队列头部的线程(unpark Successor)。
-
-
唤醒策略
- AQS通过遍历找到head.next节点且为等待状态的线程,进行unpark操作(LockSupport.unpark())。
4.可重入机制
- 如果一个线程已经持有锁,再次
lock()不会阻塞自己; state变量记录持有次数;- 必须多次
unlock()对应释放;
4.CAS(Compare And Swap)
4.1.基本原理
-
读取变量的旧值(expected value)。
-
将变量与旧值比较(compare)。
-
如果相等 ➔ 把变量更新为新值(swap)。
如果不相等 ➔ 说明别的线程改过了,更新失败,可以选择重试。
4.2.示例
AtomicInteger ai = new AtomicInteger(0);
ai.compareAndSet(0, 1); // 如果当前为0,则设置为1
CAS 保证了原子性,是乐观锁的核心机制;
4.3.CAS 的 ABA 问题
-
ABA问题
- A线程读取到值是A;
- 在它准备修改之前,别的线程把A改成了B,又改回A;
- A线程看到的还是A,但实际上数据已经变过了 ➔ 潜在风险。
- 解决方案:引入版本号(如
AtomicStampedReference)。
5.AQS(AbstractQueuedSynchronizer)核心结构
5.1.队列结构
AQS 维护一个 双向链表 FIFO(First In First Out先进先出) 队列:
HEAD -> Node1 -> Node2 -> ... -> Tail
- 每个节点(Node)代表一个等待的线程;
- 获取不到锁的线程会被封装为 Node,加入队尾;
state = 0时,队头线程会被唤醒。
获取锁流程
AQS 通过一个 volatile 的 state 变量(0 表示无锁,>0 表示锁被占)和 FIFO(双向链表)队列管理同步状态。线程获取锁时,先尝试通过 CAS 操作快速抢锁(tryAcquire),成功则修改 state 并标记独占线程;若失败则被封装为 Node 加入队列尾部,并不停检查自己是否处于队首第二个节点(即即将被唤醒的位置)。释放锁时,线程重置 state 状态,并唤醒队列中下一个等待线程,形成"获取-排队-释放-通知"的完整同步闭环。
为什么是第二个节点?
答:当前持有锁的线程是头结点
所有入队的节点都会自旋获取锁么?
答:只有第二个节点会自旋会尝试获取锁,其他节点只是为了检查中断或超时
6.synchronized vs ReentrantLock 对比详解
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 位置 | 关键字,作用于方法或代码块中 | 类(java.util.concurrent.locks) |
| 公平性选择 | 非公平 | 可设置为公平锁或非公平锁 |
| 中断响应 | 不支持在等待锁期间响应中断 | 支持 lockInterruptibly() 中断响应 |
| 加解锁 | 自动 | 手动 |
7. 互斥锁和自旋锁
7.1. 互斥锁
- 加锁失败时,线程从 用户态 切换到 内核态,进入 休眠 状态,释放 CPU 给其他线程。
- 锁释放后,内核会将线程唤醒为 就绪 状态。
- 存在 两次上下文切换 和 用户态与内核态切换,导致性能开销。
7.2. 自旋锁
- 通过 CAS 操作尝试获取锁,失败后线程会 等待,不释放CPU,循环检查锁是否释放,直到成功获取锁。
- 无线程阻塞或唤醒,避免了上下文切换,性能开销较低。
- 适合短时间锁竞争,避免了因线程阻塞而带来的开销。
8. 锁消除与锁粗化
8.1. 锁消除
- 定义:JIT 编译器在运行时通过逃逸分析,判断某对象不会被多个线程共享(即线程私有),自动去除无意义的加锁操作,从而提升性能。
- 示例:
public String append(String str1, String str2) {
StringBuilder sb = new StringBuilder();
sb.append(str1);
sb.append(str2);
return sb.toString();
}
StringBuilder是线程不安全的,但这里作为局部变量,只在当前线程中使用,不存在线程共享,因此锁被消除。
8.2. 锁粗化
- 定义:当程序在短时间内对同一个对象频繁地加锁和释放锁,JIT 编译器会将多次加锁合并为一次较大的加锁范围,减少频繁加解锁带来的性能损耗。
- 示例:
for (int i = 0; i < 100; i++) {
synchronized(lock) {
doSomething(); // 临界区非常小
}
}
改为
synchronized(lock){
for (int i = 0; i < 100; i++) {
doSomething(); // 临界区非常小
}
}
每次循环都加解锁效率低,JVM 会自动将锁延伸到整个循环外层,即“锁粗化”,提升执行效率。
三、数组和集合
1.List和Set
List 和 Set 都继承自 Collection 接口,但它们在使用特性和底层结构上有显著不同:
- List 是有序集合,元素按插入顺序排列,允许重复。支持通过索引访问元素,因此查询效率较高。但由于底层是数组或链表结构,插入或删除元素时会导致数据移动,效率相对较低。
- Set 是无重复集合,不允许出现重复元素。底层通常基于哈希表(如
HashSet)实现,插入和删除元素效率较高,但一般不支持通过索引访问,查询时需要依赖hashCode()或排序结构。
2. ArrayList 和 LinkedList 的区别
- ArrayList:
基于动态数组,默认容量 10,扩容为原来的 1.5 倍。
查询快 O(1),插入/删除慢 O(n),特别是中间位置要移动元素。 - LinkedList:
基于双向链表,无初始容量限制。
查询慢 O(n),但已定位后插入/删除快 O(1),适合频繁增删场景。
四、HashMap
1.HashMap的结构
- 数组 + 链表 + 红黑树(树化链表,提高极端情况下的性能)
扩展知识点:为什么加红黑树?—— 因为链表查询是 O(n),数组是O(1),而红黑树是 O(logn),极端情况下防止性能退化。
2. 重要成员变量
transient Node<K,V>[] table; // 存储元素的数组(懒加载,第一次put才初始化)
int size; // 当前HashMap中键值对的数量
int threshold; // 阈值,= 容量 * 负载因子
final float loadFactor; // 负载因子,默认 0.75
- 初始容量capacity为16
- 负载因子(Load Factor) :表明 HashMap 允许的最大填充程度。默认 0.75,是性能和空间浪费之间的折中。
扩展:如果负载因子设置太小,会浪费内存;太大,冲突率高,查询慢。
3. 核心方法分析
1.put(K key, V value)
流程:
-
计算哈希值(hash(key))。
-
定位数组索引(index = (n - 1) & hash)。
-
判断对应位置是否为空:
-
空:直接新建节点。
-
不空:
- 遍历链表或树。
- key相等就覆盖旧值。
- 不相等,新节点插入链尾或树节点。
-
-
插入后判断是否需要扩容(size > threshold 先插入后判断)。
-
如果需要,触发 resize() 。
扩展:为什么用
(n-1) & hash而不是%?
因为位运算&比求模%快太多,且 n 必须是 2 的幂才能保证均匀分布。
2. 扩容
2.1.HashMap扩容发生的两种情况
| 情况 | 描述 | 触发点 |
|---|---|---|
| 1 | 正常扩容 | 整体元素数量达到 容量 × 负载因子(比如16×0.75=12) |
| 2 | 局部扩容 | 单个桶(数组槽位)的链表长度超过8,且数组容量<64 |
| 条件 | 动作 | 说明 |
|---|---|---|
整体元素数量 > threshold(即 capacity16 × loadFactor0.75) | 扩容 | 最常见的扩容触发,和链表无关 |
| 单个桶链表长度 > 8 且数组容量 < 64 | 扩容 | 优先通过增加容量解决 hash 冲突 |
| 单个桶链表长度 > 8 且数组容量 ≥ 64 | 树化 | 达到阈值且容量足够,才进行红黑树转换 |
| 单个桶链表长度 ≤ 8 | 不扩容不树化 | 正常插入,不触发结构调整 |
| 节点删除后,红黑树节点 ≤ 6 | 红黑树退化为链表 | 避免红黑树维护开销太高(JDK 8+) |
链表长度刚好 = 8 时并不会马上树化:必须是“插入后长度 > 8”,也就是从第9个节点开始判断,删除节点后≤6退化为链表。
2.2.扩容过程
HashMap 的扩容机制就是调用 resize 方法,重新申请一个容量是当前的两倍的桶数组,并将原有数组中的元素迁移到新的数组中。在 JDK 8 中,扩容过程不需要像 JDK 7 那样重新计算每个元素的 hash 值,而是通过查看原 hash 值的第五位(即 hash & oldCap)来判断元素是否需要搬迁到新的索引。
2.3.扩容步骤
-
创建新数组:扩容时,HashMap 会创建一个新的容量为原数组两倍的数组。
-
元素迁移:根据原有的 hash 值,通过判断第四位(最右侧是第0个,第五个 如:0b0001_0000)是否为 1,决定元素应该放在新数组的哪个位置。
- 如果为 0:元素的索引位置不变。
- 如果为 1:元素的索引位置变为
原索引 + oldCap(即capacity / 2)。
-
清空原数组引用:为了避免内存泄漏,原数组的引用会被置为
null。
2.4.示例
假设原数组容量为 16,扩容后变为 32,原数组中的元素如下:
| key | hash | 原索引(index) |
|---|---|---|
| A | hashA | 0 |
| B | hashB | 0 |
| C | hashC | 1 |
| D | hashD | 2 |
| E | hashE | 3 |
扩容后,数组容量变为 32,重新计算元素位置:
A的 hash 值的第五位是0,因此A仍然位于索引0。B的 hash 值的第五位是1,因此B会被移动到index 16(原索引 + 16)。C的 hash 值的第五位是0,因此C仍然位于index 1。D的 hash 值的第五位是1,因此D会被移动到index 18(原索引 + 16)。E的 hash 值的第五位是0,因此E仍然位于index 3。
2.5.扩容后的新数组布局
| 索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | ... | 16 | 17 | 18 | 19 | ... | 31 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 元素 | A | C | D | E | B | D |
4. 为什么 HashMap 是线程不安全的?
多个线程同时进行 put 操作时,可能会发生数据覆盖等并发问题。
如何让HashMap线程安全?
- 用
synchronizedMap()- 用
ConcurrentHashMap。
5. HashMap vs Hashtable vs ConcurrentHashMap
| 特性 | HashMap | Hashtable | ConcurrentHashMap (JDK 1.8+) |
|---|---|---|---|
| 线程安全 | 否 | 是(全方法 synchronized) | 是(CAS + synchronized) |
| null 键/值 | 允许 1 个 null key 和多个 null value | 不允许 null 键或 null 值 | 不允许 null 键或 null 值 |
| 并发机制 | 无 | 整个表加锁 | CAS + synchronized(桶级锁) |
| 数据结构 | 数组 + 链表/红黑树 | 同上 | 同上 |
五、线程和线程池
1. 线程
1. 线程 vs 进程
- 进程是操作系统资源分配的最小单位。
- 线程是程序执行的最小单位,是轻量级的进程。
- 一个进程可以有多个线程,共享内存、文件描述符等资源。
2. 创建线程的方式
- 继承Thread类,重写run方法。
- 实现Runnable接口,重写run方法。
- 实现Callable接口(带返回值、可以抛异常)。
3. 线程状态(生命周期)
Java 中的线程在生命周期中,可能处于以下六种状态(对应 Thread.State 枚举):
| 状态名 | 描述 |
|---|---|
| NEW | 新建状态 |
| RUNNABLE | 可运行状态 |
| BLOCKED | 阻塞状态(等待获取锁) |
| WAITING | 等待状态(无限等待) |
| TIMED_WAITING | 超时等待状态 |
| TERMINATED | 终止状态 |
🟢 3.1. NEW(新建状态)
当通过以下代码创建线程对象时,线程处于 新建状态:
Thread t = new Thread(); // 或 new 子类Thread();
- 此时线程对象已经创建,但还没有调用
start(),所以尚未进入 JVM 的调度范围。 - 线程还没有真正开始执行。
🟡 3.2. RUNNABLE(可运行状态)
当线程调用 start() 方法后,就进入了 可运行状态:
t.start();
-
可运行状态包括两个子情况:
- 正在运行(Running)
- 等待 CPU 时间片(Ready)
-
JVM 会根据操作系统的调度策略(如时间片轮转)在这些线程之间切换执行。
📝 注意:Java 中的 RUNNABLE 并不等于“正在运行”,它表示线程有资格运行,但可能还没拿到 CPU。
🔴 3.3. BLOCKED(阻塞状态)
线程尝试获取某个对象的内置锁(synchronized)时:
- 如果锁被其他线程占用,线程就会进入阻塞状态。
- 一旦锁被释放,线程才会进入 RUNNABLE 状态等待调度。
📝 BLOCKED 与 WAITING/TIMED_WAITING 不同,它专门表示“等待获取锁”这一行为。
🔵 3.4. WAITING(无限等待)
线程进入无限期等待状态,直到被其他线程显示唤醒(通过 notify() 或 notifyAll()):
📝 WAITING 线程不会自动恢复,必须依赖外部通知。
🟠 3.5. TIMED_WAITING(限时等待)
线程进入限时等待状态,在设定时间内被唤醒,或等待时间到期后自动唤醒。
常见的方法包括:
Thread.sleep(ms)
💡 线程在限时等待期间不会占用 CPU,但它会在等待时间到或被唤醒后重新进入 RUNNABLE 状态。
⚫ 3.6. TERMINATED(终止状态)
线程执行完 run() 方法后,或者在运行中被异常终止(如抛出未捕获异常),就会进入 终止状态。
- 调用
stop()(不推荐)也会导致线程终止。 - 终止的线程无法再次启动(调用
start()会抛异常)。
4. wait和sleep的区别
| 特性 | wait() | sleep() |
|---|---|---|
| 是否释放锁 | ✅ 是 | ❌ 否 |
| 是否要求同步块 | 必须在 synchronized 内 | ❌ 否 |
| 是否自动唤醒 | 需 notify/notifyAll | ✅ 是(按时间自动唤醒) |
2.线程池
1.创建线程池的几种方式
1.1. newFixedThreadPool(int nThreads)
固定大小线程池
线程数固定,超出部分任务排队。
1.2. newCachedThreadPool()
缓存线程池
线程数无限扩展,需要多少开多少(但是60秒不用就回收线程)。
1.3. newSingleThreadExecutor()
单线程线程池
只有一个线程,任务一个一个顺序执行。
1.4. newScheduledThreadPool(int corePoolSize)
定时调度线程池
可以执行延迟任务、周期性任务。
1.5. 自定义 ThreadPoolExecutor()
真正灵活可控的方式
你可以自己指定每一个参数,绝对自由。
2.线程池核心参数
-
corePoolSize 核心线程数
-
maximumPoolSize 最大线程数
-
keepAliveTime 回收时间
-
workQueue(阻塞队列)
-
handler(饱和策略)
3.举例说明线程池运行策略
假设创建一个线程池,参数如下:
| 参数 | 数值 | 含义 |
|---|---|---|
| corePoolSize | 2 | 核心线程数2个 |
| maximumPoolSize | 4 | 最大线程数4个 |
| keepAliveTime | 60秒 | 线程空闲60秒后回收 |
| workQueue | 容量为2的阻塞队列 | 等待队列大小=2 |
| 拒绝策略 | 抛异常(AbortPolicy) | 任务太多时直接抛异常 |
🔵 线程编号:T1、T2、T3、T4、T5、T6、T7……代表新创建的线程。
🔵 假设我们依次提交 7个任务(任务编号:Task-1 ~ Task-7)
执行过程:任务1、2立刻被执行,任务3、4放到阻塞队列,最大线程数是4还没满执行任务5、6,任务7执行拒绝策略
执行流程:提交任务时,先创建核心线程执行,核心线程满了任务进队列;队列满了继续创建线程直到最大线程数;最大线程数也满了则触发拒绝策略。
4. 四种拒绝策略
- AbortPolicy(默认):抛异常。
- CallerRunsPolicy:由提交任务的线程自己执行。
- DiscardPolicy:不执行新任务,也不抛出异常,基本上为静默模式。
- DiscardOldestPolicy:丢掉队列里最老的任务,尝试重新提交。
5. 常见队列
5.1 ArrayBlockingQueue(数组有界队列)
- 特点:内部使用数组实现,有界,FIFO顺序。
- 添加元素和取出元素用同一把锁,性能较好。
- 需要在创建时指定大小(不可动态扩容)。
✅ 优点:
- 内存可控,不易OOM。
- 在生产者消费者模型中,性能稳定。
❌ 缺点:
- 容量固定,一旦设小容易导致任务堆积拒绝;设大浪费内存。
- 入队和出队竞争同一把锁,可能有轻微性能瓶颈。
🔵 适合:
- 任务量可以预估,且希望系统稳定受控,比如订单处理系统。
5.2 LinkedBlockingQueue(链表有界/无界队列)
- 特点:内部使用链表实现,默认无界(Integer.MAX_VALUE大小)。
- 入队锁、出队锁分离,减少锁竞争(高并发友好)。
✅ 优点:
- 入队、出队互不阻塞,吞吐量比ArrayBlockingQueue高。
- 大量积压任务时,能继续缓冲而不是拒绝。
❌ 缺点:
- 如果无界,任务积压会导致OOM(内存爆掉)。
- 如果容量太大,响应时间变长(排队多)。
🔵 适合:
- 任务波峰波谷变化大,但系统资源充足,比如异步日志收集。
6.优化线程池
6.1 设置合理的核心参数
✅ 经验公式(核心线程数corePoolSize):
CPU密集型: core = CPU核数 + 1
IO密集型: core = 2 × CPU核数
- CPU密集型(如图像处理):靠多核。
- IO密集型(如数据库、网络请求):靠等待,线程可以多开。
✅ 设置合理的workQueue大小:
- 小队列,保护系统优先响应。
- 大队列,减少线程频繁创建销毁。
✅ 适当缩短keepAliveTime,可以减少资源浪费,尤其在高并发场景下。
7. volatile关键字
7.1 特性
内存可见性:一个线程修改了变量,其他线程能马上看到。
禁止指令重排序:JVM 会插入类似
ACC_VOLATILE的标记,告诉编译器需要遵守特殊规则。
7.2 怎么保证可见性
在计算机中,所有指令都在 CPU 中执行,但变量的数据通常存储在主内存中。为了提高效率,每个线程会有一块工作内存(类似缓存),用来保存主内存中变量的副本。线程对变量的读取和写入,通常是先在自己的工作内存中进行。
✍ volatile 写操作流程:
- 线程修改自己工作内存中
volatile变量的副本; - 修改后,立刻将新值刷新回主内存;
- 这会使其他线程中该变量的副本失效,下次访问会重新从主内存读取。
📖 volatile 读操作流程:
- 线程访问
volatile变量时,强制从主内存读取最新值; - 读取的值会覆盖工作内存中的旧副本。
8. ThreadLocal
8.1 定义
ThreadLocal提供了线程私有的变量副本,每个线程都有一个独立的ThreadLocalMap,所以每个线程对变量的操作只影响自己,线程是安全的, Map存储的键是ThreadLocal对象,值是当前线程对应的局部变量副本。用 完threadlocal要remove避免内存泄漏。
六、JVM
1. 类加载过程
| 阶段 | 关键动作 | 备注 |
|---|---|---|
| 加载 | 查找 .class 文件,生成 Class 对象 | 由 ClassLoader 完成 |
| 验证 | 检查字节码合法性 | 确保安全 |
| 准备 | 分配静态变量内存,设零值 | final 常量直接赋值 |
| 解析 | 符号引用 → 直接引用 | 可能触发其他类加载 |
| 初始化 | 执行 <clinit>(),初始化静态变量 | 静态代码块按顺序执行 |
关键点:
- 类加载是 懒加载 + 双亲委派。
2. JVM内存结构
JVM在运行Java程序时划分了几个区域:
| 区域 | 生命周期 | 作用 |
|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程正在执行的字节码指令地址 |
| 虚拟机栈 | 线程私有 | 局部变量、方法调用和执行的内存结构 |
| 本地方法栈 | 线程私有 | Native方法调用 |
| 堆(Heap) | 线程共享 | 存放对象实例、数组,内存最大的区域 |
| 方法区(MetaSpace) | 线程共享 | 存放类信息、常量、静态变量等 |
这是一个非常经典、面试常问的 JVM 内存布局题。我们来逐个拆解这行代码:
String str = new String("hello");
| 成分 | 存储区域 | 说明 |
|---|---|---|
"hello" | 方法区(运行时常量池) | 在编译时 "hello" 作为字面量常量存储在 class 文件中,运行时加载进常量池。 |
"hello" 对应的 String 对象 | 堆 | 常量池中的 "hello" 会作为一个 String 对象在堆中存在(首次使用时才真正创建,懒加载),此对象是被 intern 的 |
new String(...) 产生的新对象 | 堆 | new 强制创建一个新的 String 对象,它内部会引用上面常量池中的 "hello" 对象。 |
str 引用变量 | 虚拟机栈 → 局部变量表 | str 是当前方法的局部变量,保存的是新建 String 对象的引用地址,存在栈帧中。 |
3. GC
3.1 什么是GC(Garbage Collection)
GC(垃圾回收) 是指:
- 自动回收不再被引用的对象内存。
- 让程序员不需要手动释放内存,避免内存泄漏、悬挂指针问题。
GC核心:
- 找到垃圾
- 回收垃圾
3.2 为什么需要GC?
- Java中的对象是动态分配在堆上的。
- 程序执行中有很多对象变成无用(如临时变量、局部对象)。
- 如果不回收,内存泄漏、系统崩溃。
3.3 对象如何判定为垃圾?
JVM使用两种主流方法判定对象是否可以回收:
3.3.1. 引用计数法(Reference Counting)
- 每个对象有一个计数器,被引用一次+1,引用断开-1。
- 计数为0即可以回收。
缺点:
-
不能处理循环引用的问题。
- 比如:A引用B,B引用A,但A和B都不再被外部引用。
因此Java没有采用引用计数。
3.3.2. 可达性分析算法(Reachability Analysis)
- 从一些GC Roots对象出发,沿着引用链查找。
- 能连通到的对象是活的。
- 不能连通的对象是垃圾。
GC Roots包括:
- 栈中局部变量表的引用
- 方法区中的静态变量、常量引用
- 本地方法栈JNI引用
3.4 GC回收算法
下面是GC实现的经典基本算法:
| 算法 | 核心思想 | 优缺点 |
|---|---|---|
| 标记-清除(老年代) | 两次遍历:标记活的,清理死的 | 简单;清理后内存碎片严重 |
| 标记-整理(老年代) | 标记活的对象后,移动压缩到一块 | 解决碎片问题;移动成本高 |
| 复制(新生代) | 将活的对象复制到新区域 | 效率高,适合新生代;浪费内存 |
下面详细单独展开每一种!
3.4.1. 标记-清除(Mark-Sweep)
流程
-
标记(Mark) :
- 从GC Roots出发,标记所有活的对象。
-
清除(Sweep) :
- 遍历堆内存,释放未被标记的对象。
优点
- 实现简单。
缺点
-
内存碎片严重:
- 活对象零散分布,导致后续大对象分配失败(需要进行堆压缩)。
3.4.2. 标记-整理(Mark-Compact)对标记-清除算法的优化
流程
- 标记阶段:标记所有活对象。
- 整理阶段:把活的对象移动到一端,保持内存连续。
优点
- 没有碎片问题。
- 适合老年代(活对象多,复制开销太大)。
缺点
- 对象需要移动,移动开销大。
- 停顿时间长(Stop The World)。
3.4.3复制算法
复制算法是JVM垃圾回收中使用的一种算法,主要应用于新生代(Young Generation)的垃圾回收。它将内存分为两块大小相等的空间(通常称为From空间和To空间),每次只使用其中一块。
特点
- 内存划分:将可用内存分为两个大小相等的半区(From和To)
- 工作方式:只使用其中一个半区(From),当该半区满时,将存活对象复制到另一个半区(To)
- 对象分配:新对象总是在活动的半区(From)中分配
- 回收过程:垃圾回收时,遍历From空间中的存活对象,将它们复制到To空间,然后一次性清理掉整个From空间
- 适用场景:特别适合对象"朝生夕死"(存活率低)的新生代
优点
- 高效清除:只需要遍历存活对象,不处理死亡对象,回收效率高
- 无碎片:复制过程中对象被紧凑排列,解决了内存碎片问题
- 简单快速:实现简单,对于存活对象较少的情况非常高效
- 分配快速:新对象分配使用指针碰撞(bump-the-pointer)技术,非常快速
缺点
- 内存浪费:总是有一半内存空间处于闲置状态,内存利用率只有50%
- 存活对象多时效率低:当存活对象比例较高时,复制开销大
- 需要额外空间:如果存活对象过多,To空间不足,需要依赖其他内存(如老年代)进行分配担保
- 新生代分成三个区:Eden区、Survivor0区(S0) 、Survivor1区(S1) 。
- 内存划分比例通常为 8:1:1,即Eden占8份,两个Survivor各占1份。
- 可用于分配新对象的有效空间是总空间的90% (即Eden+一个Survivor区)。
HotSpot JVM中的Serial、ParNew等新生代收集器都使用了复制算法,但进行了优化:将新生代划分为一个Eden区和两个Survivor区(通常比例为8:1:1)
1. 对象分配
- 新创建的对象优先分配在 Eden区。
2. Minor GC触发
-
当Eden区满时,触发Minor GC(小规模垃圾回收)。
-
Minor GC时,会同时回收:
- Eden区
- 当前正在使用的Survivor区(S0或S1,第一次可能没有但是后面S区会有所以要加上S区)
3. 对象迁移
- 存活下来的对象被复制到另一个空闲的Survivor区(S0或S1交替使用)。
- 原Eden和使用过的Survivor区在GC后被清空。
4. 对象年龄管理
-
每次对象在Survivor区存活下来一次,年龄加1。
-
对象达到一定年龄阈值(默认15岁)后,会晋升(Promote)到老年代(Tenured Generation) 。
-
这个年龄阈值可以通过参数设置:
-XX:MaxTenuringThreshold=N
5. Survivor区放不下时的处理
-
如果在复制过程中,目标Survivor区容纳不下活着的对象:
- 一部分对象会直接晋升到老年代(即使年龄不到晋升阈值)。
6. Full GC触发情况
-
如果在晋升到老年代时,老年代空间不足:
- 则可能触发一次Full GC(大规模垃圾回收,回收老年代+年轻代+元空间)。
-
(不同GC策略下行为略有差异,如G1可能采取Mixed GC代替Full GC。)
3.4.4 分代收集(Generational Collection)
现实中的垃圾回收器不是单一算法,而是组合使用不同算法。
核心思想:
- 不同生命周期的对象采用不同策略。
| 区域 | 特点 | 回收策略 |
|---|---|---|
| 新生代 | 对象生命周期短,死亡率高 | 复制算法 |
| 老年代 | 对象存活时间长 | 标记-整理或标记-清除 |
为什么要分代?
- 因为不同阶段的对象特性不同,单一策略效率低下。
总结
| JDK 版本 | 默认 GC | 算法 | 适用场景 |
|---|---|---|---|
| JDK 1.8 | Parallel GC(Parallel Scavenge + Parallel Old) | 标记-复制(年轻代) 标记-整理(老年代) | 高吞吐量,计算密集型 |
| JDK 9+ | G1 GC | 分区回收(Region-Based) | 平衡吞吐量和延迟,大堆内存 |
4.JVM垃圾收集器
1. Serial 收集器(适用新生代,单线程):
- 使用单线程进行垃圾回收,回收期间会“Stop-The-World”(暂停所有用户线程) 。
- 停顿时间较长,适用于单核或低并发场景(如客户端程序、小型服务器)。
- 优点是实现简单、内存开销小,适合对响应时间要求不高的系统。
2. ParNew 收集器(适用新生代,多线程):
- 是 Serial 收集器的多线程版本,支持多核 CPU 并行进行 Minor GC。
- 吞吐量高,但仍会“Stop-The-World”,停顿时间虽短于 Serial,但仍不可忽略。
- 通常与 CMS 老年代收集器 配合使用,适用于中等以上并发场景(如多核服务器)。
3. CMS(适用老年代)
- 老年代并发回收,降低停顿时间(低延迟)。
- 标记 - 清除算法。
- 新生代通常搭配Parallel Scavenge。
特点:
- 低停顿
- 存在浮动垃圾(Concurrent Mode Failure)
底层阶段:
- 初始标记(STW):标记GC Roots可达的对象
- 并发标记(与应用程序并行):从初始标记的对象出发进行可达性分析
- 重新标记(STW):由于并发期间,应用程序可能又有对象引用发生了变化,所以要修正并发标记期间遗漏或错误的标记。
- 并发清理:清理未标记的垃圾对象(不整理内存,会产生碎片)。
4. G1
4.1 核心设计
(1)分区(Region-Based)模型
-
不再严格分代,而是将堆划分为多个 大小相同 的 Region(默认 2048 个)。
-
每个 Region(通常 1MB~32MB)可以是:
- Eden(年轻代)
- Survivor(年轻代)
- Old(老年代)
- Humongous(存放大对象,占用多个连续 Region)
(2)Garbage-First 策略
- 优先回收垃圾最多的 Region(Garbage-First),提高回收效率。
- 增量回收:每次只回收部分 Region,避免全堆回收,减少 STW 时间。
4.2. G1 的工作流程
G1 的回收过程分为 年轻代回收(Young GC) 和 混合回收(Mixed GC) 。
(1)年轻代回收(Young GC)
-
触发条件:Eden 区满时。
-
执行方式:
- 并行复制:存活对象从 Eden/Survivor 复制到新的 Survivor 或晋升到 Old。
- 更新 Remembered Set(RSet) :记录跨 Region 引用,避免全堆扫描。
(2)混合回收(Mixed GC)
-
触发条件:堆占用达到
-XX:InitiatingHeapOccupancyPercent(默认 45%)。 -
执行方式:
- 初始标记(Initial Mark) (STW):标记 GC Roots 直接关联的对象。
- 并发标记(Concurrent Mark) :遍历堆,标记存活对象。
- 重新标记(Remark) (STW):修正并发标记期间的变动。
- 清理(Cleanup) (STW):回收完全空闲的 Region。
- 复制(Evacuation) (STW):将存活对象从待回收 Region 复制到新 Region(部分 Old + 年轻代)。
5. 垃圾收集器总结
| 收集器类型 | 新生代算法 | 老年代算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 串行 | 复制 | 标记-整理 | 客户端/嵌入式 | 简单高效、资源占用少 | STW时间长 |
| 并行 | 复制 | 标记-整理 | 后台计算 | 高吞吐量 | STW时间较长 |
| CMS | (配合ParNew) | 标记-清除 | Web服务 | 低延迟 | 内存碎片、并发模式失败风险 |
| G1 | 混合Region | 混合Region | 大内存服务 | 平衡吞吐/延迟 | 内存占用较大 |
5.JVM调优
- 监控:使用
jstat、jstack、jmap等工具定位问题。 - 分析:通过 GC 日志或堆转储(
MAT、VisualVM)找到瓶颈。 - 调整:针对性修改 JVM 参数:
-Xms和-Xmx(初始堆和最大堆,建议设为相同值避免动态扩容)
七、双亲委派机制
1. 双亲委派机制是什么
答:“向上委托,向下加载”
当一个类加载器收到加载请求时,它不会立即尝试加载,而是先交给父加载器处理,只有父加载器无法完成时,子加载器才会尝试加载。
2. 为什么需要双亲委派?
答:保证核心类库的安全性,避免重复加载。
3. 如何打破双亲委派?
答:重写
loadClass()方法(如 Tomcat),或使用线程上下文类加载器(如 JDBC SPI)。
4. Tomcat 为什么不用双亲委派?
答:为了支持多个 Web 应用隔离(不同 WebApp 的相同类互不冲突)。
5. 类加载器层级
答:Bootstrap ClassLoader(启动类加载器,加载JVM核心类库)→Extension ClassLoader(扩展类加载器,加载JAVA_HOME/lib/ext目录下的类库)→Application ClassLoader(应用程序类加载器,加载用户类路径(ClassPath)上的所有类库)→Custom ClassLoader(自定义类加载器,用户自定义的类加载需求)
八、深拷贝、浅拷贝和零拷贝
1. 浅拷贝(Shallow Copy)
定义
浅拷贝只复制对象的 基本数据类型字段 和 引用类型的内存地址,不会递归复制引用指向的实际对象。
特点
- 引用类型共享:拷贝对象和原对象的引用类型字段指向同一个内存地址。
- 修改影响原对象:如果修改拷贝对象的引用类型字段,原对象也会受影响。
示例
Person p1 = new Person("Alice", new Address("Beijing"));
Person p2 = (Person) p1.clone();
p2.address.city = "Shanghai"; // p1.address.city 也会变成 "Shanghai"
2. 深拷贝(Deep Copy)
定义
深拷贝会 递归复制所有基本类型和引用类型字段,生成一个完全独立的新对象。
特点
- 引用类型独立:拷贝对象和原对象的引用类型字段指向不同的内存地址。
- 修改互不影响:修改拷贝对象不会影响原对象。
示例
Person p1 = new Person("Alice", new Address("Beijing"));
Person p2 = deepCopy(p1);
p2.address.city = "Shanghai"; // p1.address.city 仍然是 "Beijing"
3. 零拷贝(Zero-Copy)
定义
零拷贝是一种 优化数据传输的技术,避免 CPU 在内存间复制数据,减少上下文切换和内存占用。
4. 三者的对比
| 特性 | 浅拷贝 | 深拷贝 | 零拷贝 |
|---|---|---|---|
| 复制范围 | 仅复制对象本身 | 递归复制所有引用对象 | 不复制数据,直接传输 |
| 内存占用 | 低(共享引用对象) | 高(完全独立副本) | 最低(无额外内存复制) |
| 性能 | 快 | 慢(递归复制耗时) | 最快(避免 CPU 拷贝) |
| 适用场景 | 简单对象,无嵌套引用 | 复杂对象,需完全隔离 | 大数据传输(文件/网络) |
5. 常见面试题
Q1:如何实现深拷贝?
- 答:递归
clone()或序列化/反序列化。
Q2:零拷贝为什么快?
- 答:避免了用户态和内核态之间的数据拷贝,直接通过 DMA 传输。
Q3:浅拷贝的隐患?
- 答:修改拷贝对象的引用字段会影响原对象(如集合、数组)。
八、Redis
1. Redis 常用数据类型及底层实现
| 类型 | 作用 | 底层数据结构 |
|---|---|---|
| String | 字符串、整数、浮点数 | SDS(简单动态字符串) |
| List | 列表,链表、队列功能 | QuickList(快速列表) |
| Hash | 键值对集合(对象) | ZipList 或 HashTable |
| Set | 无序集合,去重功能 | HashTable 或 IntSet |
| Sorted Set | 有序集合,带分数score | SkipList(跳表) + HashTable |
2.Redis 应用场景
| 场景 | 适用数据结构 | 常用命令 | 常用命令说明 |
|---|---|---|---|
| 缓存 | String、Hash | SET/GET/HSET/HGET | SET/GET用于简单键值缓存,HSET/HGET用于对象属性缓存 |
| 排行榜 | Sorted Set | ZADD/ZRANGE/ZREVRANK | ZADD添加成员和分数,ZRANGE获取排名,ZREVRANK反向排名,ZSCORE获取成员分数,ZINCRBY增加成员分数 |
| 计数器 | String(INCR) | INCR/INCRBY/DECR | 原子性增减操作,适用于阅读量、点赞数等统计 |
| 消息队列 | List、Stream | LPUSH/RPOP/XADD/XREAD | LPUSH/RPOP实现简单队列,XADD/XREAD实现消息流 |
| 社交关系 | Set(交集、并集) | SADD/SINTER/SUNION | SADD添加成员,SINTER获取交集,SUNION获取并集 |
3. Skip List详解
3.1 跳表是什么?
1. 跳表基础概念
跳表(Skip List)是一种概率平衡的有序数据结构,它通过构建多级索引的方式,使得查找效率可以媲美平衡树,但实现更为简单。
2. 跳表核心设计思想
2.1 多层索引结构
跳表通过在原始有序链表上构建多层索引实现加速:
- 底层(Level 1)是完整的有序链表
- 每上一层都是下一层的"快速通道",节点数量约为下一层的1/P(Redis中P=0.25)
2.2 实际查找示例
查找 50 的流程
跳跃表会从 最高层(L3) 开始查找:
- L3 层:
HEAD直接指向50,比较发现分值匹配,立即返回结果。 - 无需降层:由于高层指针的“跳跃”特性,仅需 1 次比较 即可找到目标,时间复杂度为
O(1)(理想情况下)。
关键点:高层指针大幅减少了遍历次数,适合查询分布靠前或频繁访问的数据。
查找 40 的流程
由于 40 不在高层,需要 逐层降级查找:
- L3 层:
HEAD→50(50 > 40),无法继续,降至L2。 - L2 层:
HEAD→30(30 < 40)→50(50 > 40),降至L1。 - L1 层:
30→40(找到匹配节点)。
总比较次数:3 次(50、30、40),时间复杂度仍为O(log N)。
关键点:低层链表确保数据全覆盖,但查询效率依赖高层指针的“跳跃”优化。
3. Redis跳表实现细节
3.1 特点
- 最大层数:32层(足够支持2^64个元素)
- 高层链表充当“快速通道”,类似二分查找,将时间复杂度优化至 O(log N)
- 新节点层数由概率算法(如25%概率升级)动态生成
- 底层链表完整存储数据,支持顺序遍历
4. 插入67操作详解
4.1 插入过程示例
步骤 1:确定插入位置
-
从最高层(L3)开始搜索:
HEAD→50(67 > 50,继续向右)。50→NULL(终止,记录50为插入点前驱)。
-
降层至 L2:
50→70(67 < 70,记录50为前驱)。
-
降层至 L1:
50→60→70(67 < 70,记录60为前驱)。
步骤 2:生成新节点层数
- 调用
random_level()随机生成层高(假设结果为 2)。
步骤 3:更新指针
-
L2 层:
- 新节点
67插入到50和70之间:
50 → 67 → 70。
- 新节点
-
L1 层:
- 新节点
67插入到60和70之间:
60 → 67 → 70。
- 新节点
最终结构
层3: HEAD --------------------------> 50 ------------------------> NULL
层2: HEAD ------------> 30 ------------> 50 --> 67 --> 70 --> NULL
层1: HEAD ---> 10 ---> 30 ---> 40 ---> 50 ---> 60 --> 67 --> 70 --> 90 --> NULL
4.2 删除70过程示例
步骤 1:定位待删除节点
-
从最高层(L3)开始搜索:
HEAD→50(70 > 50,继续向右)。50→NULL(未找到,降层至 L2)。
-
L2 层:
50→67→70(找到70,记录各层前驱节点:L2:67,L1:67)。
-
L1 层:
- 确认
70存在(67 → 70 → 90)。
- 确认
步骤 2:更新指针
-
L2 层:
- 将
67的forward指向70的后继(NULL):
67 → NULL。
- 将
-
L1 层:
- 将
67的forward指向90:
67 → 90。
- 将
步骤 3:释放节点
- 解除
70的所有指针引用,并释放内存。
最终结构
层3: HEAD --------------------------> 50 ------------------------> NULL
层2: HEAD ------------> 30 ------------> 50 --> 67 --> NULL
层1: HEAD ---> 10 ---> 30 ---> 40 ---> 50 ---> 60 --> 67 --> 90 --> NULL
5. Redis选择跳表的原因
-
排序集合(zset)要求:
- 快速范围查找(范围查询 score )
- 快速按顺序遍历
-
跳表相比红黑树:
- 插入、删除更简单。
- 范围查询性能更稳定。
4. Redis分布式锁
4.1 Redisson(一般搭配Redis Cluster集群使用)
4.1.1.Redisson Lock 对象
Redisson 提供的锁对象是 RLock,支持以下特性:
| 特性 | 描述 |
|---|---|
| 可重入 | 同一个线程可以多次获取同一把锁,不会死锁 |
| 自动续期 | 如果线程还在执行任务,会自动续期防止锁过期(看门狗机制) |
| 加锁失败自动重试 | 可设置超时时间 + 等待时间 |
| 异常宕机释放锁 | Redis 自动过期 + 唯一标识防止误删 |
4.1.2. 示例代码
// 获取分布式锁实例(建议使用业务相关的锁名称)
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
// 场景1:tryLock带leaseTime(明确知道业务执行时间)
// 尝试获取锁:最多等待5秒,锁持有时间30秒(自动释放)
// 适用场景:能准确预估业务耗时的短任务(如<30秒的简单操作)
boolean isLocked = lock.tryLock(5, 30, TimeUnit.SECONDS);
// 场景2:tryLock不带leaseTime(启用看门狗)
// 尝试获取锁:等待5秒,获取后启动看门狗(默认30秒TTL,每10秒续期)
// 适用场景:无法预估耗时的长任务(如复杂计算/外部调用)
boolean isLocked = lock.tryLock(5, -1,TimeUnit.SECONDS);
// 场景3:直接使用lock()(阻塞式获取)
// 阻塞式获取锁(无限等待,获取后启动看门狗)
// 适用场景:必须获得锁且能接受线程阻塞的关键业务
lock.lock();
if (isLocked) {
// 执行业务逻辑(建议在此处添加业务超时控制)
} else {
// 获取锁失败处理(记录日志/快速失败等)
}
} finally {
// 确保只释放当前线程持有的锁
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
加锁流程
-
生成客户端唯一标识(UUID + threadId)
-
执行 Lua 脚本尝试获取锁
-
如果锁已被占用:
- 订阅锁释放的 Channel
- 等待锁释放通知(基于 Redis 的 Pub/Sub)
-
获取成功后启动看门狗线程
4.1.3 加锁注意事项
-
锁获取策略
-
✅ 优先使用
tryLock(waitTime, leaseTime)- 避免使用无参
lock()方法(会无限阻塞) - 推荐设置
waitTime(如5秒),超时快速失败
- 避免使用无参
-
⚠️ 直接
lock()仅适用于:- 必须获得锁的场景
-
-
看门狗机制控制
场景 写法 效果 已知执行时长 tryLock(5, 30, SECONDS)禁用看门狗,30秒后自动释放 执行时长不确定 lock()或tryLock(5, -1, SECONDS)启用看门狗(默认30秒TTL,每10秒续期) 看门狗机制(Watchdog)
- 自动续期:Redisson 会为持有锁的客户端启动一个看门狗线程,默认每10秒检查一次锁状态
- 续期机制:如果客户端仍持有锁,会自动将锁的过期时间延长到30秒(默认值)
- 宕机保护:客户端崩溃时,看门狗线程也会终止,锁最终会自动过期
-
锁释放保证
- 使用
try-finally代码块,在finally释放里面验证当前线程是否还持有锁isHeldByCurrentThread() - 只能释放自己加的锁,Redisson使用uuid+threadid保证唯一标识
- 使用
-
锁设计规范
- 合理的命名规则和粒度控制
4.1.4 Redis 分布式锁的缺点
| 缺点类别 | 描述 |
|---|---|
| 可用性问题 | 单节点 Redis 宕机锁失效,主从切换锁状态可能丢失 |
| 锁释放不可靠 | 非原子释放,可能误删其他线程锁 |
| 看门狗失效风险 | GC、阻塞等导致自动续期失败 |
| 一致性不足 | Redis 是 AP 系统,不保证强一致性 |
| RedLock 有争议 | 理论安全性不足,不适合强一致性场景 |
| 无死锁感知 | 宕机或异常不易清理锁,存在业务风险 |
- 主节点宕机 Redis Cluster 的高可用保障
- 主从复制:每个分片至少1主N从(建议至少1从)
- 自动故障转移:当主节点宕机,集群会自动选举从节点升级为主节点
- 数据同步:如果锁信息已经写入了日志会通过RDB+AOF保证数据持久化
- 异常情况处理
- 网络问题:自动重试(可配置重试次数)
- 客户端崩溃:看门狗停止,锁自动过期释放
- Redis节点故障:集群自动转移,Redisson 自动重定向
4.2 Redlock加锁机制
RedLock 设计明确要求每个参与加锁的 Redis 节点必须是独立的、无副本的主节点
4.2.1 RedLock加锁流程(4步)
-
记录起始时间
- 客户端获取当前毫秒级时间戳
-
顺序节点加锁
- 向N个独立Redis节点发送加锁请求
- 使用相同键名和随机值(保证锁标识唯一)
-
计算加锁耗时
- 总耗时= 当前时间 - 起始时间
-
有效性双重验证
- 多数成功:获得锁的节点数 ≥ N/2 + 1
- 时间有效:锁释放时间 > 总耗时 + 时钟漂移缓冲值(通常1ms)
4.2.2 异常处理原则(3条)
-
节点故障
- 加锁阶段节点宕机:不计入成功数,继续尝试其他节点
- 持锁阶段节点宕机:依赖TTL自动释放锁
-
网络/时钟异常
- 加锁超时:立即放弃并向所有节点发送释放请求
- 时钟跳跃:通过TTL - ΔT - 缓冲值补偿
-
释放锁异常
- 无论是否成功释放,最终依赖TTL保证锁自动失效
5. Redis为什么这么快?
- 基于内存操作
- 单线程+IO多路复用避免了多线程切换带来的上下文开销和锁竞争
- 特殊设计的数据结构
6. IO多路复用
6.1为什么需要 IO 多路复用?
传统的阻塞 IO(Blocking IO)模型是这样的:
- 每个连接对应一个线程;
- 如果有 1000 个客户端连接,就需要 1000 个线程;
- 每个线程都在阻塞等待数据(如
read()),浪费大量资源。
问题:
- 线程开销大(内存 + 上下文切换);
- 大量连接中,大多数时候是“空闲”,但线程还是存在;
- 无法支持高并发场景。
6.2什么是 IO 多路复用?
IO 多路复用的核心是:一个线程同时监听多个文件描述符(Socket)是否“就绪”,只在就绪时进行读写。
它通过操作系统内核提供的机制(如 select / poll / epoll),只需一个线程,就可以高效管理大量连接。
6.3常见 IO 多路复用机制对比
| 模型 | 特点 | 操作系统支持 | Redis 默认 |
|---|---|---|---|
select | 最早、支持少量连接(1024) | 所有系统 | ❌ |
poll | 支持更多连接,效率低 | 所有系统 | ❌ |
epoll | 事件驱动、非阻塞、性能优 | Linux | ✅ |
6.4 Redis 在 Linux 下使用 epoll
epoll是 Linux 下的高性能 IO 多路复用机制;- 它使用事件回调机制,只在真正有事件发生时才通知程序;
- 可以处理无限制的并发连接而不会出现性能瓶颈。
6.5 图解:IO 多路复用 vs 传统模型
传统模型(每连接一个线程):
客户端1 ---> 线程1
客户端2 ---> 线程2
客户端3 ---> 线程3
... ...
IO 多路复用(一个线程监听所有连接):
+------------------+
客户端1 --| |
客户端2 --| epoll + 主线程|----> 回调处理
客户端3 --| |
+------------------+
7. Redis cluster集群
7.1 Redis Cluster 的设计目标
- 分布式存储与计算:自动将数据分散存储在多个节点;
- 高可用性:节点失效时自动完成主从切换;
- 去中心化架构:无单点瓶颈,节点之间平等;
- 客户端直连,性能高:客户端可连接任一节点自动重定向请求。
7.2 Redis Cluster 核心概念
1. 分片(Sharding)
Redis Cluster 将所有数据分成 16384 个槽(slot) ,范围是 [0 ~ 16383]。
- 每个键通过 CRC16(key) 对 16384 取模,映射到某一个 slot;
- 每个节点负责一定范围的槽;
- 所以键值分布的单位是 slot,而不是节点。
2. 节点类型
Redis Cluster 中的节点分为:
- 主节点(Master) :负责读写操作和槽位;
- 从节点(Slave) :作为主节点备份,只复制主节点的数据,不参与槽的分配,提升高可用性。
一般建议:每个主节点搭配一个从节点,实现主从复制。
3. 节点增加或删除
增加或删除不会停止服务所以不会造成集群不可用
槽位变更示意表
假设初始时,Redis 集群有 5 个节点(A、B、C、D、E),每个节点大约负责 3276 个槽。然后我们进行新增节点 F 和删除节点 E 的操作:
Redis Cluster 槽位变更流程示意(三阶段对比)
| 状态阶段 | 初始状态 | 新增节点 F(使用 migrate) | 删除节点 E(使用 reshard) |
|---|---|---|---|
| A 节点 | 0 – 3276 | 0 – 2276 | 0 – 2500 |
| B 节点 | 3277 – 6553 | 2277 – 5276 | 2501 – 5276 |
| C 节点 | 6554 – 9830 | 5277 – 7553 | 5277 – 7553 |
| D 节点 | 9831 – 13107 | 7554 – 9830 | 7554 – 10329 |
| E 节点 | 13108 – 16383 | 9831 – 12107 | ❌(节点已删除) |
| F 节点 | ❌(节点不存在) | 12108 – 15184 | 10330 – 13107 |
-
步骤解释:
- 新增节点 F:从 A、B、C、D、E 各个节点迁移出一部分槽位,使用
migrate命令让F 接管这些槽位后,所有节点的槽位范围就会发生变化。 - 删除节点 E:通过
reshard将 E 上的槽位(9831 – 16383)迁移到其他节点(A、B、C、D),并且最终将 E 节点从集群中移除。
- 新增节点 F:从 A、B、C、D、E 各个节点迁移出一部分槽位,使用
4. 主节点挂了,从节点如何晋升为主节点?
故障转移(Failover)流程
-
检测主节点失效
- 其他主节点通过 心跳机制(PING/PONG)发现某主节点(如 M1)超时无响应。
- 超过半数主节点确认 M1 失效后,触发故障转移。
-
从节点竞选新主节点
- M1 的从节点(S1)向其他主节点发起竞选请求。
- 其他主节点投票,多数同意后 S1 晋升为新主节点。
-
槽所有权转移
- S1 接管原 M1 负责的槽(0-5460),并更新集群元数据。
- 客户端请求会被重定向到新的主节点 S1。
-
集群状态恢复
- 如果原主节点 M1 恢复,它会变为 S1 的从节点(除非手动调整)。
8. Redis持久化机制
1. RDB(Redis Database)
RDB 是 Redis 默认的持久化方式,它通过创建数据集的快照(snapshot) 来保存某一时刻的完整数据。
工作原理
- Redis 会定期将内存中的数据以二进制格式写入磁盘(默认文件名为
dump.rdb)。 - 可以通过配置
save指令设置触发快照的条件(如save 900 1表示 900 秒内至少 1 个 key 被修改时触发快照)。 - 也可以手动执行
SAVE(阻塞主线程)或BGSAVE(后台异步执行)命令生成 RDB 文件。
优点
- 高性能:RDB 是二进制文件,恢复速度快,适合大规模数据备份。
- 紧凑存储:RDB 文件比 AOF 更小,节省磁盘空间。
- 适合灾难恢复:可以定期备份 RDB 文件到远程服务器。
缺点
- 可能丢失数据:如果 Redis 崩溃,最后一次快照之后的数据会丢失(取决于备份频率)。
- 大数据量时可能阻塞服务:
BGSAVE虽然异步,但在数据量极大时仍可能影响性能。
2. AOF(Append-Only File)
AOF 通过记录所有写操作命令(如 SET、DEL 等)来持久化数据,类似于 MySQL 的 binlog。
工作原理
-
默认不开启,需在配置中设置
appendonly yes。 -
所有写命令会追加到 AOF 缓冲区,并根据策略(
appendfsync)同步到磁盘:always:每个命令都同步(最安全,但性能最差)。everysec(默认):每秒同步一次(平衡性能与安全)。no:由操作系统决定何时同步(最快,但可能丢失数据)。
-
AOF 文件会不断增长,Redis 提供
BGREWRITEAOF命令重写 AOF 文件(移除冗余命令,优化体积)。
优点
- 数据更安全:最多丢失 1 秒的数据(
appendfsync everysec)。 - 可读性强:AOF 是文本格式,可以手动编辑(如修复误操作)。
- 灵活恢复:支持通过回放 AOF 日志恢复数据。
缺点
- 文件体积大:AOF 文件通常比 RDB 大,恢复速度较慢。
- 写入性能开销:高频同步策略(如
always)可能影响吞吐量。
3. 混合持久化(RDB + AOF)
Redis 4.0 后支持混合持久化(aof-use-rdb-preamble yes),结合了两者的优势:
- AOF 文件前半部分是 RDB 格式的快照,后半部分是增量 AOF 命令。
- 恢复时先加载 RDB 快照,再回放 AOF 命令,兼顾速度和数据完整性
9. Redis的过期删除策略和内存淘汰策略
Redis 的淘汰策略分为两大类:
- 主动淘汰(定期删除/惰性删除):无论内存是否满,都会按规则清理过期键。
- 被动淘汰(内存满时触发):当内存达到
maxmemory时,根据配置的策略删除数据。
9.1 过期删除策略(主动淘汰)
9.1.1 定义
Redis 对过期数据的处理采用 "惰性删除 + 定期删除" 的混合策略,同时结合内存淘汰机制,形成多层次的过期数据管理方案。
9.1.2 惰性删除
当客户端访问某个key时候检查是否过期,如果过期则删除,没过期返回value
9.1.3 定期删除
定期删除(Active Expiration)
-
Redis 内部定时任务(默认每 100ms 执行一次)
-
两种子模式:
-
快模式(快速扫描):
- 随机检查 20 个 Key
- 删除其中已过期的 Key
- 如果过期 Key 占比 >25%,则重复该过程再次扫描,直到过期key小于25%
-
慢模式(全量扫描):
-
扫描范围:遍历所有设置了过期时间的 Key(
expires字典) -
执行时机:
- 当快模式多次发现过期 Key 比例 >25%
- 或 Redis 检测到内存压力时自动增强
-
-
9.3 内存淘汰策略对比总结(被动淘汰)
| 策略名 | 作用范围 | 淘汰规则 | 适用场景 |
|---|---|---|---|
noeviction | 不淘汰 | 返回错误 | 数据不可丢失 |
allkeys-random | 所有键 | 随机删除 | 无优先级要求的缓存 |
volatile-random | 带过期时间的键 | 随机删除 | 仅缓存临时数据 |
volatile-lru | 带过期时间的键 | 淘汰最近最少使用的键 | 缓存中区分冷热数据 |
allkeys-lru | 所有键 | 淘汰最近最少使用的键 | 需要长期保留热点数据 |
volatile-lfu | 带过期时间的键 | 淘汰访问频率最低的键 | 缓存中区分高频/低频数据 |
allkeys-lfu | 所有键 | 淘汰访问频率最低的键 | 长期保留高频数据(如排行榜) |
volatile-ttl | 带过期时间的键 | 淘汰剩余存活时间最短的键 | 优先清理即将过期的临时数据 |
9.4 LRU 实现细节(近似LRU)
9.4.1 Java中LRU
LRU(Least Recently Used)最近最少使用是一种常用的缓存淘汰策略,其核心思想是:
如果某个数据在最近一段时间没有被访问,那么它在未来被访问的可能性也较低,应优先被淘汰。
在 Java 中,常用的 LRU 缓存一般基于 HashMap + 双向链表 实现,能够在 O(1) 时间内完成查找、插入、更新与淘汰操作:
数据结构说明
-
使用 HashMap 存储 Key 到链表节点的映射,便于快速定位。
-
使用 双向链表(Doubly Linked List) 维护访问顺序:
- Head(头部) :代表最近访问的节点;
- Tail(尾部) :代表最久未访问的节点。
核心操作说明
-
save(key, value):-
如果 Key 已存在,更新值并将对应节点移动到链表头部;
-
如果 Key 不存在,创建新节点放入链表头;
- 如果当前容量已满,移除链表尾部节点(即最近最少使用的节点),并从 HashMap 中删除对应条目。
-
-
get(key):- 若 Key 存在,从 HashMap 定位对应节点;
- 将节点移动到链表头部(表示最近访问);
- 返回对应的值。
9.4.2 Redis中LRU
-
Redis 采用采样算法:
- 随机抽取 N 个 key(默认 5个)淘汰其中最老的一个
maxmemory-samples 5 // 可修改参数调整数量
10. Redis雪崩、击穿、穿透
10.1 Redis雪崩
1. 问题描述
大量缓存数据同时过期,导致所有请求直接打到数据库,造成数据库压力过大甚至崩溃。
2. 原因
- 缓存集中过期:如果缓存的过期时间设置不均匀,可能在同一时间内大量的缓存数据过期,导致大量请求都查询数据库,造成数据库压力过大。
- 缓存大量失效:当多个热点数据的缓存失效时,所有请求都会打到数据库,瞬间导致数据库压力爆炸。
3. 解决方案
-
解决方案1:设置不同的缓存过期时间
- 避免缓存过期时间集中:可以为不同的数据设置不同的缓存过期时间,避免大量缓存同时过期。例如,可以通过设置随机过期时间来实现。
-
解决方案2:缓存预热
- 系统启动时提前加载热点数据
- 定时任务刷新即将过期的数据
10.2 缓存击穿
1. 问题描述
某个热点key突然过期,此时大量并发请求直接穿透到数据库。
2. 原因
- 热点数据失效:热点数据的缓存失效时,可能有多个并发请求同时去查询数据库,造成数据库压力骤增。
- 缓存穿透问题引发:某些无效的数据(例如不存在的数据)会直接访问数据库,也可能是由于请求的数据刚好失效或不存在。
3. 解决方案
-
解决方案1:设置热点数据缓存不过期
-
解决方案2:使用互斥锁/分布式锁
- 锁住缓存失效的热点数据,在缓存失效时,利用分布式锁或者互斥锁确保只有一个线程/请求能去数据库查询并更新缓存,其他请求等待。
10.3 缓存穿透
1. 问题描述
缓存穿透指的是一些 查询无效数据(如不存在的ID或数据) 的请求绕过缓存直接访问数据库,导致缓存失效时无法起到性能提升作用,数据库压力增大。
2. 原因
- 查询无效数据:有些请求查询的数据可能是从未存在的数据(如数据库中不存在的ID),或者是恶意攻击,这些请求直接访问数据库,绕过缓存机制。
- 缓存未存储无效数据:对于某些不存在的数据(如查询的ID在数据库中没有),缓存也不应保存这些数据,因为这样无效数据缓存会占用内存。
3. 解决方案
-
解决方案1:缓存空对象
- 对于不存在的数据(如数据库查询为空),可以将其空对象缓存一段时间,这样即使请求多次查询相同的无效数据,也能直接从缓存读取,避免数据库被频繁访问。
-
解决方案2:布隆过滤器
- 使用布隆过滤器来判断请求的数据是否存在于数据库中,布隆过滤器能有效地拦截无效查询,减少不必要的数据库访问。
-
解决方案3:参数合法性检验
11. 布隆过滤器
布隆过滤器(Bloom Filter)详解
布隆过滤器是一种高效的空间优化型数据结构,用于判断某个元素是否在一个集合中,常用于防止缓存穿透、黑名单过滤、重复检测等场景。
📌 结构组成:
- 一个长度为
m的位数组(bit array),初始所有位为0; k个独立的哈希函数,每个能将输入元素映射到位数组中的某个下标位置。
✅ 元素添加过程
当插入一个元素时,布隆过滤器会:
- 使用
k个哈希函数计算出该元素的k个哈希下标; - 将这些下标位置对应的位设置为
1。
例子:
-
插入元素
a,哈希下标为[1, 4, 6]→ 位数组第 1、4、6 位设为1; -
插入元素
b,哈希下标为[2, 4, 5]→ 位数组第 2、4、5 位设为1(注意:下标 4 已被a占用); -
此时,位数组大致如下(仅展示部分):
下标: 0 1 2 3 4 5 6 7 状态: 0 1 1 0 1 1 1 0
🔍 元素查询过程
查询时,同样使用 k 个哈希函数计算元素的 k 个下标,然后检查这些下标对应的位:
- 若 全部为 1,则该元素“可能存在”;
- 若 至少有一个为 0,则该元素“一定不存在”。
举例:
-
查询元素
c,哈希下标为[3, 6, 7]:- 位数组中第 3 位和第 7 位为
0,说明c一定不存在。
- 位数组中第 3 位和第 7 位为
❗ 特点与注意事项
| 特性 | 描述 |
|---|---|
| 时间复杂度 | 插入与查询时间复杂度为 O(k) (k 是哈希函数个数) |
| 空间效率高 | 相比 HashSet、List 等结构,能以更小内存存储大量数据 |
| 存在误判 | 会有假阳性(误判存在),但不会有假阴性(不会误判不存在) |
| 不支持删除 | 一旦位被置为 1,就无法恢复,除非使用 计数型布隆过滤器 |
| 容量与误判率 | 存储越多数据,冲突概率越高;位数组越大、哈希函数越合理,误判率越低 |
12. 缓存更新策略
旁路缓存模式
-
读流程:
- 先查缓存,命中则返回
- 未命中则查数据库,将数据库结果写入缓存
-
写流程:
- 直接更新数据库
- 删除缓存中对应数据
缺点:
- 存在短暂不一致窗口期
- 可能发生缓存击穿
适用场景:读多写少场景
九、Zookeeper
1. Zookeeper的模式?
- Zookeeper是CP模式(一致性+分区容错性)
2. Zookeeper的角色
集群架构:
-
Leader:
- 负责处理所有写请求和事务性操作
- 集群中只有一个Leader,通过选举产生
- 负责与Followers同步数据
-
Follower:
- 处理客户端读请求,转发写请求给Leader
- 参与Leader选举投票
- 参与事务请求的提案投票
-
Observer:
- 与Follower类似但不参与投票
- 只接收Leader的事务结果并应用
- 用于扩展读性能而不影响写性能
3.Zookeeper节点类型
- 临时节点
- 持久节点
- 临时有序节点(用于分布式锁)
- 创建临时顺序节点
/lock/lock- - 获取所有子节点
- 如果不是最小节点,watch前一个节点
- 前一个节点删除时获取锁
- 创建临时顺序节点
只监测前一个节点,避免羊群效应(ZooKeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。)
- 持久有序节点
4. Zookeeper的选举和同步机制
1. 触发条件
- 集群启动:所有节点初始启动时,无Leader存在。
- Leader崩溃:Leader失去与多数Follower的连接(心跳超时)。
- 运维干预:手动触发Leader重新选举(如
kill -SIGTERMLeader进程)。
2. Leader选举阶段(Leader Election)
核心原则
- 优先比较 zxid:数据最新的节点(zxid 更大)优先成为 Leader
- zxid 相同时:比较 myid,投票给 myid 更大的节点
- 每轮投票:节点始终优先投给自己,除非发现更优的候选者
选举流程
-
状态初始化
- 所有节点启动时进入
LOOKING状态,开始选举。
- 所有节点启动时进入
-
投票规则
-
每个节点投票给自己,投票内容包含:
- myid(配置的服务器ID,如
server.1,server.2) - zxid(最后一次事务ID,越大代表数据越新)
- myid(配置的服务器ID,如
-
比较优先级:
- 优先比较zxid:选择数据最新的节点(zxid最大)。
- zxid相同则比较myid:选择myid更大的节点(避免平局)。
-
-
投票传播
- 节点通过TCP(默认端口
3888)广播投票信息。 - 每个节点维护一个投票箱,记录收到的投票。
- 节点通过TCP(默认端口
-
选举结果(假如有多个节点所有节点会投给已知节点最大的,比如12345所有节点都会投给5)
-
当某个节点获得超过半数(n/2+1) 的相同投票时:
- 如果该节点是自己 → 成为Leader,状态变为
LEADING。 - 如果是其他节点 → 成为Follower,状态变为
FOLLOWING。
- 如果该节点是自己 → 成为Leader,状态变为
-
3. 数据同步阶段(Data Synchronization)
同步流程
-
Leader确认数据基准
- 新Leader确定集群中最大的zxid(即最新数据状态)。
-
差异化同步策略
- 全量同步(SNAP) :如果Follower数据太旧(zxid远小于Leader),直接发送完整数据快照。
- 增量同步(DIFF) :如果Follower仅落后少量事务,只发送差异部分。
- 截断同步(TRUNC) :如果Follower有Leader不存在的zxid(异常情况),要求Follower回滚到一致点。
-
同步完成
-
所有Follower同步到最新zxid后:
- Leader进入
BROADCAST模式(正常服务)。 - Follower保持
FOLLOWING状态,准备接收客户端请求。
- Leader进入
-
十、 Kafka
1. Kafka的核心组件
1. Producer(生产者)
-
作用:向 Kafka 发送消息 的客户端。
-
关键点:
- 可以指定消息发送到哪个 Topic(主题)。
- 支持 批量发送 以提高吞吐量。
- 可配置 消息确认机制(如
acks=all确保数据不丢失)。-
消息确认(acks) :
-
acks=0:不等待确认
-
acks=1:等待leader确认
-
acks=all/-1:等待所有ISR确认
-
2. Broker(代理/服务器)
-
作用:Kafka 集群中的 单个服务器节点,负责 存储和转发消息。
-
关键点:
- 多个 Broker 组成 Kafka 集群,提供高可用性。
- 每个 Broker 存储 部分 Topic 的分区数据。
- 客户端(Producer/Consumer)直接与 Broker 通信。
3. Topic(主题)
-
作用:消息的 逻辑分类,类似于数据库中的表。
-
关键点:
- 一个 Topic 可以分成多个 Partition(分区) ,提高并发能力。
- 不同 Topic 存储不同类型的消息(如
orders、logs)。 - 消费者按 Topic 订阅消息。
4. Partition(分区)
-
作用:Topic 的 物理存储单元,用于 并行处理和扩展。
-
关键点:
- 每个 Partition 是 有序的、不可变的消息序列。
- 相同 Key 的消息会进入同一个 Partition(保证顺序)。
- 分区可以 分布在多个 Broker 上,提高吞吐量。
5. Consumer(消费者)
-
作用:从 Kafka 读取消息 的客户端(pull的方式)。
-
关键点:
- 消费者以 Consumer Group(消费者组) 形式工作,组内 每个消费者读取不同 Partition。
2. kafka的特点
1. 高吞吐量 & 低延迟
- 每秒可处理几十万甚至百万级消息,适用于大数据场景。
- 延迟极低(生产到消费仅需几毫秒),适合实时流处理。
2. 高可扩展性
- 集群支持动态扩展(新增Broker无需停机),可轻松应对流量增长。
- Topic分区可横向扩容,通过增加分区提升并发能力。
3. 持久化与可靠性
- 消息持久化到磁盘,避免内存丢失风险。
- 多副本机制(Replication) :数据自动备份,即使部分节点故障也不丢失。
4. 高容错性
- 自动故障转移:若Leader副本失效,Follower会自动接管(需ISR机制保障)。
- 允许节点宕机:若副本数为
n,最多容忍n-1个节点同时失败。
5. 高并发支持
- 支持数千客户端同时读写,分区机制实现并行消费。
- 消费者组(Consumer Group) :允许多组消费者独立消费同一Topic。
3. Kafka 的典型应用场景
1. 日志聚合(Log Aggregation)
- 场景:集中收集各服务的运行日志(如 Nginx、应用日志)。
2. 消息系统(Messaging System)
- 场景:替代传统消息队列(如 RabbitMQ),实现服务间通信。
3. 系统解耦(Decoupling)
- 场景:服务A完成核心操作后,通过 Kafka 通知服务B执行后续任务(如支付成功后触发物流调度)。避免服务间直接依赖,提升系统可维护性
4. 流量削峰(Peak Load Handling)
- 场景:应对突发高流量(如秒杀、抢购),将请求暂存到 Kafka 后逐步处理。保护后端系统,避免过载崩溃。
5. 异步处理(Asynchronous Processing)
- 场景:非实时任务(如发送邮件、生成报表),先发消息到 Kafka,后续异步消费。提升主流程响应速度,资源按需分配。
4. kafka的消费模式
通过pull,主要原因消费者可以控制频率、避免消息堆积(故障了生产者不会持续推送未处理的消息),批量拉取多条更高效
5. 怎么保证消息可靠性
-
消息不丢失:
- 生产者:acks=all,retries>0
- broker:min.insync.replicas>=2
- 消费者:禁用自动提交,处理完再提交
-
消息不重复:
- 生产者:启用幂等和事务
- 消费者:实现幂等处理
-
顺序性保证:
- 单分区内有序
6. 补充
1. retries > 0(生产者重试机制)
- 作用:当生产者发送消息失败时(如网络抖动、Broker 短暂不可用),自动重试发送。
2. min.insync.replicas >= 2(Broker 最小同步副本数)
十一、 MySQL
1. 一条SQL的执行过程
-
客户端请求:
- 用户通过客户端向 MySQL 数据库发出查询请求。
-
SQL 解析:
- MySQL 接收到查询后,会首先进行 SQL 语法解析,检查 SQL 语句的正确性。如果有错误,MySQL 会返回错误信息。
-
优化器:
- MySQL 使用查询优化器来生成执行计划。优化器的任务是选择最优的执行方案。例如,它会选择最合适的索引,确定表连接的顺序等。
-
执行计划生成:
- 在查询优化器选择执行计划之后,MySQL 会为查询生成具体的执行计划。这个计划包括了如何读取数据(例如扫描整个表还是使用索引)和如何执行数据操作(例如 JOIN 操作如何执行)。
-
查询执行:
- MySQL 根据生成的执行计划开始执行查询操作。它会遍历数据,使用索引、过滤器、排序等方法,逐步获取查询所需的数据。
-
缓存和存储引擎:
- MySQL 会使用查询缓存(如果启用了)来存储频繁执行的查询结果,减少重复计算。
-
返回结果:
- 查询结果返回给客户端。
-
日志记录:
- 所有数据变更都会被记录在二进制日志中,以便后续的恢复、复制等操作。
2. 索引的类型
聚簇索引(Clustered Index) vs 非聚簇索引(Non-Clustered Index)
| 特性 | 聚簇索引 | 非聚簇索引(二级索引) |
|---|---|---|
| 存储方式 | 数据行实际存储在索引的叶子节点 | 叶子节点存储主键值(指向数据行) |
| 数量限制 | 每表只能有一个 | 每表可以有多个 |
| 默认创建 | 主键自动成为聚簇索引 | 需要显式创建 |
| 查询效率 | 范围查询效率高 | 单点查询效率高 |
| 包含数据 | 包含完整数据记录 | 只包含索引列和主键值 |
3. 索引失效的情况
-
违反最左前缀原则:对于组合索引(a,b,c),查询条件无a则索引失效 -- 索引(a,b,c) SELECT * FROM table WHERE b = 1 AND c = 2; -- 失效
-
在索引列上做计算或函数操作 SELECT * FROM table WHERE YEAR(create_time) = 2023; -- 失效
-
使用不等于(!=或<>) SELECT * FROM table WHERE status != 1; -- 失效
-
使用IS NULL或IS NOT NULL SELECT * FROM table WHERE name IS NULL; -- 可能失效
-
LIKE以通配符开头,如果在结尾就会走索引 SELECT * FROM table WHERE name LIKE '%张'; -- 失效
-
类型转换 SELECT * FROM table WHERE id = '123'; -- 如果id是int类型,失效
-
OR条件前后未同时使用索引 SELECT * FROM table WHERE a = 1 OR b = 2; -- 如果b无索引,整个查询失效
4. SQL的执行计划(排查慢SQL)
在排查一个 SQL 性能时,执行 EXPLAIN(或者 EXPLAIN ANALYZE,更详细)是必经之路。
但是 EXPLAIN 的输出列很多,一般要重点关注下面这些字段:
EXPLAIN 中需要重点关注的内容:
| 字段名 | 重要性 | 解释 | 理想情况 |
|---|---|---|---|
type | ★★★ | 连接类型,查询扫描方式,非常重要! | 最好是 const、ref、range,避免 ALL(全表扫描)。 |
key | ★★★ | MySQL 实际选中的索引。 | 确认是否用了正确的索引。 |
rows | ★★★ | 预估要扫描的行数,越小越好。 | 尽量小,比如几十、几百以内。 |
Extra | ★★★ | 补充信息,有时候会告诉你索引失效、Using temporary、Using filesort等。 | 希望没有 "Using filesort"、"Using temporary"。 |
最最关键的检查流程(简化版)
-
看
typeconst结果只有一条的主键或唯一索引扫描>eq_ref唯一索引扫描>ref非唯一索引扫描>range索引范围扫描>index全索引扫描>ALL全表扫描- 如果是
ALL,就要小心了!意味着全表扫描。
-
看
key是否命中- 显示的使用哪个索引
-
看
rows- 预估扫描行数,太多(几百万)肯定要优化。
-
看
Extra-
出现下面这些,需要警惕:
Using filesort:使用了外部排序,说明索引不够好。Using temporary:使用了临时表,通常出现在复杂排序或分组中,影响性能。Using where:正常,表示用WHERE条件筛选。Using index:超好!表示只用到了索引中的数据,不回表。
-
5. InnoDB的数据存取机制
InnoDB采用"页(Page)"作为磁盘与内存交互的基本单位,默认大小为16KB。当需要访问某条记录时,存储引擎不会单独读取该记录,而是将整个数据页加载到内存的缓冲池(Buffer Pool)中。
B+树的每个叶子节点就是一个数据页。
6. B+树索引
1. 特点
核心特点:
- 多路搜索,节点有多个子节点。
- 每个节点存储多个 key(键值对) 。
- 所有的数据(记录)只存在于叶子节点。
- 叶子节点之间是有序链表结构,支持范围查询。
- 通常只有2-4层树
- 查询路径固定,从根节点一层一层往下,直到叶子节点,路径唯一、稳定。
2. 查询流程
B+ 树查找主键为 6 的记录(结合图示)
-
从根节点开始查找(页38)
根节点存有两个目录项:- 键值 1 → 页30
- 键值 7 → 页36
查询键为 6,介于[1, 7)之间,因此定位到页30继续查找。
-
进入中间非叶子节点页30,继续查找
页30目录项为:- 1 → 页10
- 3 → 页22
- 5 → 页16
查询键为 6,大于所有键值,因此选择最后一个目录项(5 → 页16)进入页16查找。
-
进入叶子节点页16,查找目标记录
页16包含主键为 5 和 6 的记录。
通过页内二分查找或槽定位,快速找到主键为 6 的记录并返回结果。
3. 什么是回表?
当查询使用二级索引时,如果查询的字段不全部包含在索引中(即不是"覆盖索引"),MySQL就需要:
- 先通过二级索引找到对应的主键值
- 再通过主键值到聚簇索引中查找完整的行记录
假设有表:
CREATE TABLE `user` (
`id` int PRIMARY KEY,
`name` varchar(20),
`age` int,
`city` varchar(20),
INDEX `idx_age_city` (`age`, `city`)
);
执行查询:
SELECT * FROM user WHERE age = 25 AND city = '北京';
查询过程:
- 先通过二级索引
idx_age_city查找age=25且city='北京'的记录,获取到主键id值 - 再通过主键id到聚簇索引中查找完整的user记录
- 这个第二步就是"回表"操作
如何避免回表
-
使用覆盖索引:
SELECT id, age, city FROM user WHERE age = 25 AND city = '北京';(查询的字段都包含在索引中)
-
使用主键查询:
SELECT * FROM user WHERE id = 123;
回表的影响
-
性能开销:
- 需要两次索引查找(二级索引+聚簇索引)
- 比直接使用聚簇索引或覆盖索引更耗时
7. B+树和B-树的区别
| 项目 | B-树 | B+树 |
|---|---|---|
| 数据存储位置 | 数据既存在内部节点,也存在叶子节点 | 数据只存在叶子节点 |
| 查询路径 | 可能在中间节点结束查询 | 必须到叶子节点 |
| 叶子节点 | 叶子节点无指针连接 | 叶子节点有顺序链表 |
| 范围查询 | 不方便,需要中序遍历 | 直接链表扫描,非常快 |
| 磁盘访问 | 随机性大,跳来跳去 | 顺序性好,I/O效率高 |
8. 事物
1. 四大特性
- 原子性(Atomicity)
事务是不可分割的最小工作单元,要么全部成功,要么全部失败回滚,不存在部分执行的情况。 - 一致性(Consistency)
事务执行前后,数据库必须保持数据的一致性状态。 - 隔离性(Isolation)
多个并发事务之间互不干扰,每个事务的操作对其他事务不可见,直到事务提交。 - 持久性(Durability)
事务一旦提交,其对数据的修改就是永久性的,即使系统崩溃也不会丢失。
2. 事物的隔离级别
1. 并发问题
脏读(Dirty Read)
- 现象:事务A读取到事务B未提交的修改
- 后果:可能读取到最终会回滚的无效数据
不可重复读(Non-Repeatable Read)
- 现象:事务A内两次相同查询返回不同结果(其他事务修改数据)
- 后果:影响事务内一致性判断
幻读(Phantom Read)
- 现象:事务A范围查询时,其他事务新增/删除符合条件的数据
- 后果:导致同一事务内相同查询返回不同行数
严重性排序
2. 四大隔离级别
2.1 隔离级别介绍
- READ UNCOMMITTED(读未提交)
事务可以读取其他事务未提交的数据,可能导致脏读、不可重复读、幻读。 - READ COMMITTED(读已提交)
事务只能读取其他事务已提交的数据,避免脏读,但仍可能不可重复读、幻读。 - REPEATABLE READ(可重复读)
事务执行期间多次读取同一数据结果一致(InnoDB默认级别),避免脏读、不可重复读,但仍可能幻读。 - SERIALIZABLE(串行化)
强制事务串行执行,完全避免脏读、不可重复读、幻读,但性能最低。
2.2 可重复读如何避免幻读
MVCC(多版本并发控制) :仅对普通SELECT有效,快照读
间隙锁(Gap Lock) :锁定索引记录之间的间隙(即使间隙中不存在实际记录),防止其他事务在间隙中插入新数据,当前读
2.3 MVCC
2.3.1 定义
在 MySQL 的 InnoDB 存储引擎中,MVCC 是通过 Undo Log(回滚日志)+ ReadView(一致性视图) 实现的。
它的目标是:在高并发场景下,允许多个事务同时读写数据,又能保证读到的是一致性的快照数据,避免加锁带来的性能开销。
2.3.2 哪些隔离级别可以使用MVCC
可重复读 和 读已提交
2.3.3 MVCC 是怎么实现的?
MVCC 核心依赖两个组件:
- Undo Log(回滚日志) :
每当事务对某条记录进行INSERT、UPDATE、DELETE操作时,InnoDB 会在 Undo Log 中记录该行被修改前的版本。 - ReadView(一致性视图) :
每个事务在第一次执行快照读(SELECT)时,会生成一个 ReadView,记录当前活跃事务的ID列表,表示哪些事务的修改它不可见。
随后的所有快照读都会基于这个 ReadView 来判断可见性,从而读取对应版本链上的某一个版本。
9. 死锁
9.1 死锁的原因
死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。
9.2 怎么避免死锁
1️⃣ 保持一致的加锁顺序
所有事务中访问表/行的顺序保持一致,如总是先锁用户表,再锁订单表,避免交叉锁定。
2️⃣ 设置事物等待锁的超时时间
- 当一个事务的等待时间超过该值后对这个事务进行回滚,参数
innodb_lock_wait_timeout是用来设置超时时间的,默认值时 50 秒。
3️⃣ 避免大事务、拆分事务
长事务锁资源时间更长,更容易产生死锁。将大事务拆分成多个小事务执行,不要在事务中执行过多逻辑。
10. Mysql日志类型
1. redo log(重做日志)
-
属于 InnoDB引擎。
-
作用:保证事务持久性(crash-safe) 。
-
crash后可通过 redo log 进行恢复(物理恢复)。
2. undo log(回滚日志)
-
属于 InnoDB引擎。
-
作用:
- 事务回滚(撤销未提交操作)。
- MVCC(提供历史版本快照)。
3. binlog(二进制日志)
-
属于 MySQL Server 层。
-
作用:
- 记录所有数据库更改(DML、DDL)。
- 用于主从同步、数据恢复
4. 逻辑日志 vs 物理日志 —— 正确认知
| 类型 | 描述 | 例子 |
|---|---|---|
| 逻辑日志 | 记录的是操作的逻辑意图或SQL语义,不关心底层页面或物理地址。 | 如:UPDATE user SET age=20 WHERE id=1; |
| 物理日志 | 记录的是数据库中数据页或行的具体物理变更,包含页号、偏移量等信息。 | 如:将页100中第3行的age字段从18改为20 |
5. MySQL 中三类核心日志:逻辑 or 物理?
| 日志类型 | 是逻辑日志还是物理日志? | 描述 |
|---|---|---|
| Binlog(二进制日志) | ✅ 逻辑日志 | 记录的是 SQL 语句(STATEMENT 模式)或行变化(ROW 模式),主要用于主从复制、恢复数据 |
| Redo Log(重做日志) | ✅ 物理日志 | 记录的是数据页的“物理变化”,保证崩溃恢复后数据不丢失,属于 WAL(预写式日志)机制 |
| Undo Log(回滚日志) | ✅ 逻辑+物理混合 | 记录的是数据修改前的版本(行记录的旧值),用于回滚事务和 MVCC,并不完全等于 SQL 或页 |
6. MySQL主从复制过程
主从复制三大线程
6.1 主库线程
- Binlog Dump线程:读取binlog并发送给从库
6.2 从库线程
| 线程名称 | 作用 | 状态查看命令 |
|---|---|---|
| IO线程 | 从主库拉取binlog,写入relay log | SHOW SLAVE STATUS中的Slave_IO_Running |
| SQL线程 | 执行relay log中的事件 | SHOW SLAVE STATUS中的Slave_SQL_Running |
6.3 详细流程
MySQL 主从复制采用异步复制机制。主库在执行写操作后,会将变更记录写入 Binlog(二进制日志),并根据 sync_binlog 参数控制其刷盘时机;主库通过 Binlog Dump 线程 将这些 Binlog 事件发送给从库。
从库的 I/O 线程 负责连接主库并接收 Binlog 事件,写入本地的 Relay Log(中继日志) ;随后,从库的 SQL 线程 顺序读取 Relay Log 中的事件并执行,从而完成数据同步,使主从库数据保持一致。
6.4 流程图
7. 分库分表
7.1 水平拆分(横向拆分)
- 按行拆分,表结构相同,按照时间(日月)等拆分
7.2 垂直拆分(纵向拆分)
- 按列拆分,将宽表拆为多个窄表,通常按业务维度拆分
十二、Linux
1.CPU满了排查流程
-
- 用top命令查询CPU占用比较高的进程
-
- 用top -p 进程id查看占用比较高的线程id
-
- 通过在线进制转换器把线程id转换成十六进制
-
- 用jstack命令+进程id查看堆栈信息
-
- 用十六进制的线程id在堆栈里面去搜找到nid = 0 x 16进制的地方
2. OOM出现的原因
- 内存泄漏
- 对象不再使用却无法被 GC 回收,持续占用堆空间。
- 内存溢出
- 对象数量过多,超出 JVM 配置的内存上限(例如加载大量数据到内存中)。
- 线程过多导致栈内存耗尽
- 每个线程分配的栈内存(默认1M),线程数量多时会很快耗尽内存。
3. 常用Linux命令
ls、cd、pwd、mkdir、rm、cat、tail、top、free、grep、ps
十三、网络
1. TCP和UDP的区别
TCP 是一种面向连接的、可靠的、有序的协议,UDP 是一种无连接的、不可靠的、无序的协议。
2. TCP的三次握手
-
第一次握手:客户端发送 SYN 包(请求建立连接)。
-
第二次握手:服务器收到 SYN 包,回复 SYN-ACK(同意建立连接)。
-
第三次握手:客户端收到 SYN-ACK 包,回复 ACK(确认连接建立)。
3. TCP的四次挥手
-
第一次挥手:客户端发送 FIN 包,告诉服务器要断开连接。
-
第二次挥手:服务器收到 FIN 包,回复 ACK 包,确认关闭客户端到服务器的连接。
-
第三次挥手:服务器发送 FIN 包,告诉客户端服务器端的连接也要关闭。
-
第四次挥手:客户端收到 FIN 包后,回复 ACK 包,确认服务器的连接关闭。
十四、微服务
1. SpringCloud
1. 常用组件
- 服务注册:Nacos
- 配置中心:Nacos
- 负载均衡:Ribbon(轮询、随机、权重、并发最小的服务器)
- 声明HTTP客户端: OpenFeign
-
Feign的工作原理是什么?
- 通过@FeignClient注解定义接口
- 启动时,Feign会为这些接口创建动态代理
- 调用接口方法时,代理会根据注解信息构造HTTP请求
- 默认使用Ribbon进行负载均衡
-
- 熔断限流:Hystrix(sentinel)
- 网关:Gateway
- 服务监控:Prometheus
- 日志平台:ELK
- 链路追踪:zipkin
1. 服务注册与配置中心:Nacos
1. Nacos 和 Eureka 区别?
- Nacos 支持 DNS 和 RPC 服务发现,Eureka 只支持 RPC。
- Nacos 支持 动态配置管理,Eureka 不支持。
- Nacos 支持 AP 和 CP 模式切换,Eureka 偏 AP。
2. Nacos 动态配置如何实现?
- 通过
@NacosValue或@RefreshScope+@Value,监听配置中心变更事件,实时刷新 Bean 值。
2. 负载均衡:Ribbon
1. 默认负载均衡策略?
- RoundRobinRule(轮询) 。
2. 如何自定义策略?
- 继承
IRule接口,实现choose()方法。
3. 与 Nacos 的配合?
- Ribbon 从 Nacos 中拉取服务实例列表,实现本地负载均衡。
3. HTTP 客户端:OpenFeign
1. Feign 是什么?
- 声明式 HTTP 客户端,底层基于 Ribbon+RestTemplate(旧)或 LoadBalancer(新)。
2. 如何使用?
@FeignClient("service-name")
public interface UserClient {
@GetMapping("/user/{id}")
User getUser(@PathVariable Long id);
}
3. 支持哪些功能?
- 自动服务调用、超时重试、请求拦截器、负载均衡、集成 Hystrix/Sentinel。
4、熔断限流:Hystrix / Sentinel
1. Hystrix 三个状态?
- Closed(正常)→ Open(熔断)→ Half-Open(尝试恢复)。
2. Sentinel 限流模式?
- QPS 限流、并发线程数、链路限流、热点参数限流等。
3. Sentinel 熔断规则?
- 平均响应时间、异常比例、异常数。
4. 和 Hystrix 区别?
- Sentinel 支持更多策略,性能更好;Hystrix 已不维护。
十五、分布式
1. 分布式锁
Redis 和 zookeeper
2. 分布式事物
2.1 2PC
- 准备阶段:协调者询问所有参与者是否可以提交
- 提交阶段:根据参与者反馈决定提交或回滚
2.2 3PC
改进点:
- 引入超时机制
- 增加预提交阶段
2.3 TCC
- Try:预留资源
- Confirm:确认执行业务
- Cancel:取消预留
2.4 本地消息表
本地消息表是一种基于最终一致性的分布式事务解决方案,其核心思想是将分布式事务拆分为:
1. 业务服务A执行本地事务,同时将需要发送的消息写入本地数据库的消息表中
2. 后台定时任务扫描消息表,将未发送的消息发送到消息中间件
3. 消息中间件确保消息投递到业务服务B
4. 业务服务B消费消息并执行业务处理
5. 如果业务服务B处理成功,则流程结束;如果失败,消息中间件会重试投递
2.5 最大努力通知
分布式事物最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。
流程: 系统 A 本地事务执行完之后,发送个消息到 MQ; 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口; 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。
我画了一个对比流程图,帮助你整体理解:
┌───────────────────────────────────────┐
│ 业务操作成功 │
└───────────────────────────────────────┘
↓
┌─────────────────────────────┐
│ 是否使用本地消息表? │
└─────────────────────────────┘
Yes ↓ No ↓
┌────────────────────────────────────────────┐ ┌────────────────────────────────────────┐
│ 开启事务 │ │ 尝试发送通知 │
│ - 执行业务操作 │ │ - 成功 -> 结束 │
│ - 同时插入一条消息表记录 │ │ - 失败 -> 重试机制 │
│ 提交事务 │ │ - 多次失败后 -> 人工补偿 │
└────────────────────────────────────────────┘ └────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ 后台进程扫描消息表,发送消息到MQ/外部系统 │
│ - 成功后更新消息状态为"已发送" │
│ - 失败则继续重试 │
└────────────────────────────────────────────┘
本地消息表+最大努力通知组合
3. 分布式ID
-
UUID
-
雪花算法
-
基于Redis
- 利用
redis的incr命令实现ID的原子性自增。
- 利用
-
基于数据库自增主键
- 设置
起始值和自增步长
- 设置
-
数据库号段模式
- 号段模式的核心就是: 从数据库中一次性“申请一段 ID 区间”(如 10000 到 10100),然后这段区间在内存中自增使用,直到用完,再向数据库申请下一段。
这样做有两个关键优势:
- 减少数据库频繁访问(高性能)
- 多个节点可用数据库来协调 ID 的“分段”,避免重复(保证唯一)
所有分片使用相同的 biz_tag,但:
- 每个分片的
step相同- 起始值不同(偏移)
- 并通过 mod 步长 = 唯一机器 ID 控制唯一性
举个例子(3 个节点,step = 3):
| 节点 | 初始 max_id | 步长 step | 生成 ID 序列 |
|---|---|---|---|
| DB1(机器ID=0) | 0 | 3 | 0, 3, 6, 9, 12... |
| DB2(机器ID=1) | 1 | 3 | 1, 4, 7, 10... |
| DB3(机器ID=2) | 2 | 3 | 2, 5, 8, 11... |
十六、限流
1. 单机限流
1. 令牌桶算法(Token Bucket)
🌟 原理
- 系统以固定速率往桶中添加令牌,如每秒生成5个令牌
- 每个请求必须获取一个令牌才能继续,否则被拒绝或排队,如果一下来了6个请求有一个请求就需要排队等下一秒
2. 分布式限流
Sentinel
项目难点:营销活动中台的高并发库存扣减问题
难点描述
在营销活动中台项目中,我们遇到了高并发场景下的库存超发问题。特别是在"限时秒杀"和"大额优惠券发放"等高热度活动时,瞬时并发请求可达5000+ QPS,传统的数据库库存扣减方式会出现以下问题:
- 数据库行锁竞争激烈,导致大量请求阻塞超时
- 基于数据库的乐观锁重试机制在超高并发下效果不佳
- 库存扣减与订单创建不是原子操作,可能出现库存扣减成功但订单创建失败的数据不一致情况
解决方案
1. 多级库存校验与扣减设计
我们设计了三级库存防护体系来保证数据一致性和系统高可用:
[第一层:Redis缓存库存] → [第二层:Redis分布式锁] → [第三层:数据库最终扣减]
第一层:Redis预扣减
- 活动开始前将库存加载到Redis,使用
DECR原子操作扣减 - 采用分段库存设计(如将1000库存分为10个key)减少热点key压力
- Lua脚本保证"判断+扣减"的原子性:
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
return redis.call('DECR', KEYS[1])
else
return -1
end
第二层:分布式锁控制
- 对用户ID+活动ID加分布式锁(Redisson实现),防止用户重复请求
- 采用"锁分段"技术,不同用户落在不同锁分片,降低锁竞争
第三层:数据库最终一致性
- 通过Kafka异步消息实现数据库库存最终扣减
- 采用补偿机制定时核对Redis与数据库库存差异
2. 订单创建与库存扣减的分布式事务
使用本地消息表+最大努力通知模式保证两个操作的最终一致性:
- 先扣减Redis库存
- 生成预订单写入本地数据库(状态为"处理中")
- 发送Kafka消息触发后续服务处理
- 消费成功后更新订单状态为"已完成"
- 定时任务补偿处理异常订单
3. 熔断降级策略
- 当Redis库存耗尽时,直接在网关层返回"已售罄",减轻后端压力
- 监控数据库压力,超过阈值时启动限流(使用Sentinel)
- 降级方案:极端情况下切换为"令牌桶"模式,先到先得