MZ

99 阅读1小时+

一、Java基础

1. 核心注解及其作用

@SpringBootApplication

是一个组合注解,等价于以下三个注解的组合:

  • @SpringBootConfiguration:是 @Configuration 的派生注解,标识这是一个配置类。
  • @EnableAutoConfiguration:启用自动配置功能,根据类路径中的依赖自动配置 Spring 应用。
  • @ComponentScan:自动扫描当前包及其子包中的组件(如 @Controller, @Service, @Repository, @Component 等)。

deepseek_mermaid_20250510_075f6f.png


其他常见注解

注解名作用
@RestController组合注解,等价于 @Controller + @ResponseBody,用于创建 RESTful Web 服务。
@RequestMapping映射 HTTP 请求到控制器方法上,支持 GET、POST 等。
@Autowired自动注入依赖的 Bean。
@Value注入配置文件中的值。
@Configuration声明配置类,可使用 @Bean 注册 Bean。
@Bean用于方法上,表示该方法返回一个 Spring 管理的 Bean。

2. 启动流程详解

[创建][配置][广播][刷新][加载]
  1. 创建SpringApplication 实例(构造函数)
  2. 配置:环境+监听器(setInitializers/listeners
  3. 广播:事件通知(ApplicationStartingEvent
  4. 刷新:上下文(refreshContext
  5. 加载:根据 spring.factories 文件自动配置(AutoConfiguration

deepseek_mermaid_20250510_20d948.png

3. Spring Boot 与 Spring MVC 的区别

项目Spring BootSpring 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)以暴露引用

依赖注入流程大致如下:

  1. Spring 创建 A 实例并暴露其 ObjectFactory 到三级缓存;
  2. A 依赖 B,发现 B 不存在,于是去创建 B;
  3. B 依赖 A,从三级缓存中获取 A 的引用;
  4. 属性注入完成后,B 完成创建并放入一级缓存;
  5. 回到 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) {
        // 临界区代码
    }
}
  • 加锁对象:括号区域
  • 字节码使用 monitorentermonitorexit 指令实现。

3、synchronized 方法与代码块加锁的底层原理

1. 方法加锁(使用 ACC_SYNCHRONIZED)

  • 在字节码中,不使用 monitorentermonitorexit

  • JVM 会识别 ACC_SYNCHRONIZED 标志,在调用方法时自动加锁和释放。

2. 代码块加锁

  • 编译后生成字节码指令 monitorentermonitorexit

  • 加锁流程:

    1. 进入临界区前执行 monitorenter
    2. 获得对象锁后进入。
    3. 退出时执行 monitorexit
    4. 支持可重入(递归加锁):每次进入加 1,退出减 1,直到为 0 时释放锁。

image.png

图中 “两个 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 管理,线程阻塞挂起多线程竞争激烈、轻量锁多次失败后膨胀

image.png

锁升级路径

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

6、偏向锁(Biased Lock)

🧠 设计目的

偏向单线程:如果一个对象一直被同一个线程访问,加锁/解锁不再需要 CAS,提高性能。

✅ 加锁过程

  1. 线程访问对象,检查对象头是否为无锁;
  2. JVM 将当前线程 ID 写入 Mark Word,并设置为偏向锁状态;
  3. 后续该线程再进入同步块时,不需要真正加锁,只需判断是否是自己的偏向即可。

没有竞争,零成本加锁。

解锁过程

  • 无需处理,线程退出同步块即可。

❗ 失效与撤销

  • 如果另一个线程访问该对象,JVM 撤销偏向状态 ➜ 升级为轻量级锁。

deepseek_mermaid_20250507_38f429.png

7、轻量级锁(Lightweight Lock)

🧠 设计目的

线程间交替执行时,避免线程阻塞,采用 自旋(CAS)+ 锁记录 来加锁。

✅ 加锁过程

  1. 当前线程在栈中创建一个 锁记录,保存对象原始的 Mark Word;

  2. 使用 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的地址;

    • 如果成功:获取锁;
    • 如果失败:表示有线程持有锁 ➜ 自旋尝试获取;
  3. 自旋多次失败 ➜ 升级为重量级锁。

🔓 解锁过程

  1. 使用 CAS 把 Lock Record 中保存的 Displaced Mark Word 恢复到对象头;
  2. 如果 CAS 成功:释放成功;
  3. 如果失败:说明锁被争抢 ➜ 升级为重量级锁。

💡 特性

  • 适合少量线程交替加锁的场景;
  • 减少了线程挂起/唤醒的系统调用;
  • 自旋是核心。

deepseek_mermaid_20250507_fafed9.png


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自选一次抢占

✅ 加锁过程

  1. 竞争失败后,JVM 创建 ObjectMonitor(monitor对象);
  2. 所有进入同步块的线程将尝试进入 ObjectMonitor 的 EntryList 队列;
  3. EntryList 队列中的线程被阻塞;
  4. 当前持有锁的线程在退出后,会唤醒 EntryList 的下一个线程;
  5. 等待的线程获得 Monitor 后才能进入同步块。

🔓 解锁过程

  • 当前线程退出同步块,释放 ObjectMonitor;
  • JVM 从 EntryList 或 WaitSet 中唤醒一个线程进入。

💡 特性

  • 使用系统调用(如 park/unpark)进行线程挂起/恢复;
  • 开销大,但适合高并发场景;
  • 一般是“最后手段”。

deepseek_mermaid_20250507_fe0184.png


9、锁状态转化图


      单线程        多线程交替         激烈竞争
无锁 ───────► 偏向锁 ───────► 轻量级锁 ───────► 重量级锁
                      失败             失败

三、🔒 ReentrantLock 简介

ReentrantLock底层基于 AQS 实现,分为公平锁和不公平锁。对应的类是FairSyncNonfairSync,主要区别是公平锁判断前方是否有等待线程,如果有加入队列尾部,非公平锁是直接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. 锁释放流程

  1. 调用 unlock() 方法 ➔ 实际内部执行的是 AQS 的 release() 方法。

  2. release() 核心动作

    • 当前线程state减1。

    • 如果减到0:

      • 说明锁彻底释放;
      • 通过AQS唤醒队列头部的线程(unpark Successor)。
  3. 唤醒策略

    • 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 对比详解

对比项synchronizedReentrantLock
位置关键字,作用于方法或代码块中类(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

ListSet 都继承自 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)

流程:

  1. 计算哈希值(hash(key))。

  2. 定位数组索引(index = (n - 1) & hash)。

  3. 判断对应位置是否为空:

    • 空:直接新建节点。

    • 不空:

      • 遍历链表或树。
      • key相等就覆盖旧值。
      • 不相等,新节点插入链尾或树节点。
  4. 插入后判断是否需要扩容(size > threshold 先插入后判断)。

  5. 如果需要,触发 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.扩容步骤
  1. 创建新数组:扩容时,HashMap 会创建一个新的容量为原数组两倍的数组。

  2. 元素迁移:根据原有的 hash 值,通过判断第四位(最右侧是第0个,第五个 如:0b0001_0000)是否为 1,决定元素应该放在新数组的哪个位置。

    • 如果为 0:元素的索引位置不变。
    • 如果为 1:元素的索引位置变为 原索引 + oldCap(即 capacity / 2)。
  3. 清空原数组引用:为了避免内存泄漏,原数组的引用会被置为 null

2.4.示例

假设原数组容量为 16,扩容后变为 32,原数组中的元素如下:

keyhash原索引(index)
AhashA0
BhashB0
ChashC1
DhashD2
EhashE3

扩容后,数组容量变为 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.扩容后的新数组布局
索引0123456...16171819...31
元素ACDEBD

4. 为什么 HashMap 是线程不安全的?

多个线程同时进行 put 操作时,可能会发生数据覆盖等并发问题。

如何让HashMap线程安全?

  • synchronizedMap()
  • ConcurrentHashMap

5. HashMap vs Hashtable vs ConcurrentHashMap

特性HashMapHashtableConcurrentHashMap (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();
  • 可运行状态包括两个子情况:

    1. 正在运行(Running)
    2. 等待 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() 会抛异常)。

image.png

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.举例说明线程池运行策略

假设创建一个线程池,参数如下:

参数数值含义
corePoolSize2核心线程数2个
maximumPoolSize4最大线程数4个
keepAliveTime60秒线程空闲60秒后回收
workQueue容量为2的阻塞队列等待队列大小=2
拒绝策略抛异常(AbortPolicy)任务太多时直接抛异常

🔵 线程编号:T1T2T3T4T5T6T7……代表新创建的线程。

🔵 假设我们依次提交 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 写操作流程:
  1. 线程修改自己工作内存中 volatile 变量的副本;
  2. 修改后,立刻将新值刷新回主内存
  3. 这会使其他线程中该变量的副本失效,下次访问会重新从主内存读取。

📖 volatile 读操作流程:
  1. 线程访问 volatile 变量时,强制从主内存读取最新值
  2. 读取的值会覆盖工作内存中的旧副本。

image.png


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)

流程
  1. 标记(Mark)

    • 从GC Roots出发,标记所有活的对象。
  2. 清除(Sweep)

    • 遍历堆内存,释放未被标记的对象。
优点
  • 实现简单。
缺点
  • 内存碎片严重

    • 活对象零散分布,导致后续大对象分配失败(需要进行堆压缩)。

3.4.2. 标记-整理(Mark-Compact)对标记-清除算法的优化

流程
  1. 标记阶段:标记所有活对象。
  2. 整理阶段:把活的对象移动到一端,保持内存连续。
优点
  • 没有碎片问题
  • 适合老年代(活对象多,复制开销太大)。
缺点
  • 对象需要移动,移动开销大
  • 停顿时间长(Stop The World)。

3.4.3复制算法

复制算法是JVM垃圾回收中使用的一种算法,主要应用于新生代(Young Generation)的垃圾回收。它将内存分为两块大小相等的空间(通常称为From空间和To空间),每次只使用其中一块。

特点
  1. 内存划分:将可用内存分为两个大小相等的半区(From和To)
  2. 工作方式:只使用其中一个半区(From),当该半区满时,将存活对象复制到另一个半区(To)
  3. 对象分配:新对象总是在活动的半区(From)中分配
  4. 回收过程:垃圾回收时,遍历From空间中的存活对象,将它们复制到To空间,然后一次性清理掉整个From空间
  5. 适用场景:特别适合对象"朝生夕死"(存活率低)的新生代
优点
  1. 高效清除:只需要遍历存活对象,不处理死亡对象,回收效率高
  2. 无碎片:复制过程中对象被紧凑排列,解决了内存碎片问题
  3. 简单快速:实现简单,对于存活对象较少的情况非常高效
  4. 分配快速:新对象分配使用指针碰撞(bump-the-pointer)技术,非常快速
缺点
  1. 内存浪费:总是有一半内存空间处于闲置状态,内存利用率只有50%
  2. 存活对象多时效率低:当存活对象比例较高时,复制开销大
  3. 需要额外空间:如果存活对象过多,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.8Parallel 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)

底层阶段:

  1. 初始标记(STW):标记GC Roots可达的对象
  2. 并发标记(与应用程序并行):从初始标记的对象出发进行可达性分析
  3. 重新标记(STW):由于并发期间,应用程序可能又有对象引用发生了变化,所以要修正并发标记期间遗漏或错误的标记
  4. 并发清理:清理未标记的垃圾对象(不整理内存,会产生碎片)。

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 区满时。

  • 执行方式

    1. 并行复制:存活对象从 Eden/Survivor 复制到新的 Survivor 或晋升到 Old。
    2. 更新 Remembered Set(RSet) :记录跨 Region 引用,避免全堆扫描。
(2)混合回收(Mixed GC)
  • 触发条件:堆占用达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)。

  • 执行方式

    1. 初始标记(Initial Mark) (STW):标记 GC Roots 直接关联的对象。
    2. 并发标记(Concurrent Mark) :遍历堆,标记存活对象。
    3. 重新标记(Remark) (STW):修正并发标记期间的变动。
    4. 清理(Cleanup) (STW):回收完全空闲的 Region。
    5. 复制(Evacuation) (STW):将存活对象从待回收 Region 复制到新 Region(部分 Old + 年轻代)。

5. 垃圾收集器总结


收集器类型新生代算法老年代算法适用场景优点缺点
串行复制标记-整理客户端/嵌入式简单高效、资源占用少STW时间长
并行复制标记-整理后台计算高吞吐量STW时间较长
CMS(配合ParNew)标记-清除Web服务低延迟内存碎片、并发模式失败风险
G1混合Region混合Region大内存服务平衡吞吐/延迟内存占用较大

5.JVM调优

  • 监控:使用 jstatjstackjmap 等工具定位问题。
  • 分析:通过 GC 日志或堆转储(MATVisualVM)找到瓶颈。
  • 调整:针对性修改 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有序集合,带分数scoreSkipList(跳表) + HashTable

2.Redis 应用场景

场景适用数据结构常用命令常用命令说明
缓存String、HashSET/GET/HSET/HGETSET/GET用于简单键值缓存,HSET/HGET用于对象属性缓存
排行榜Sorted SetZADD/ZRANGE/ZREVRANKZADD添加成员和分数,ZRANGE获取排名,ZREVRANK反向排名,ZSCORE获取成员分数,ZINCRBY增加成员分数
计数器String(INCR)INCR/INCRBY/DECR原子性增减操作,适用于阅读量、点赞数等统计
消息队列List、StreamLPUSH/RPOP/XADD/XREADLPUSH/RPOP实现简单队列,XADD/XREAD实现消息流
社交关系Set(交集、并集)SADD/SINTER/SUNIONSADD添加成员,SINTER获取交集,SUNION获取并集

3. Skip List详解

3.1 跳表是什么?

1. 跳表基础概念

跳表(Skip List)是一种概率平衡的有序数据结构,它通过构建多级索引的方式,使得查找效率可以媲美平衡树,但实现更为简单。

2. 跳表核心设计思想
2.1 多层索引结构

跳表通过在原始有序链表上构建多层索引实现加速:

  • 底层(Level 1)是完整的有序链表
  • 每上一层都是下一层的"快速通道",节点数量约为下一层的1/P(Redis中P=0.25)
2.2 实际查找示例

deepseek_mermaid_20250507_8a7b27.png

查找 50 的流程

跳跃表会从 最高层(L3)  开始查找:

  1. L3 层HEAD 直接指向 50,比较发现分值匹配,立即返回结果。
  2. 无需降层:由于高层指针的“跳跃”特性,仅需 1 次比较 即可找到目标,时间复杂度为 O(1)(理想情况下)。

关键点:高层指针大幅减少了遍历次数,适合查询分布靠前或频繁访问的数据。


查找 40 的流程

由于 40 不在高层,需要 逐层降级查找

  1. L3 层HEAD → 5050 > 40),无法继续,降至 L2
  2. L2 层HEAD → 3030 < 40)→ 5050 > 40),降至 L1
  3. L1 层30 → 40(找到匹配节点)。
    总比较次数:3 次(503040),时间复杂度仍为 O(log N)

关键点:低层链表确保数据全覆盖,但查询效率依赖高层指针的“跳跃”优化。

3. Redis跳表实现细节
3.1 特点
  • 最大层数:32层(足够支持2^64个元素)
  • 高层链表充当“快速通道”,类似二分查找,将时间复杂度优化至 O(log N)
  • 新节点层数由概率算法(如25%概率升级)动态生成
  • 底层链表完整存储数据,支持顺序遍历
4. 插入67操作详解
4.1 插入过程示例
步骤 1:确定插入位置
  1. 从最高层(L3)开始搜索

    • HEAD → 50(67 > 50,继续向右)。
    • 50 → NULL(终止,记录 50 为插入点前驱)。
  2. 降层至 L2

    • 50 → 70(67 < 70,记录 50 为前驱)。
  3. 降层至 L1

    • 50 → 60 → 70(67 < 70,记录 60 为前驱)。
步骤 2:生成新节点层数
  • 调用 random_level() 随机生成层高(假设结果为 2)。
步骤 3:更新指针
  1. L2 层

    • 新节点 67 插入到 50 和 70 之间:
      50 → 67 → 70
  2. 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:定位待删除节点
  1. 从最高层(L3)开始搜索

    • HEAD → 50(70 > 50,继续向右)。
    • 50 → NULL(未找到,降层至 L2)。
  2. L2 层

    • 50 → 67 → 70(找到 70,记录各层前驱节点:L2:67L1:67)。
  3. L1 层

    • 确认 70 存在(67 → 70 → 90)。
步骤 2:更新指针
  1. L2 层

    • 将 67 的 forward 指向 70 的后继(NULL):
      67 → NULL
  2. 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();
    }
}

加锁流程

  1. 生成客户端唯一标识(UUID + threadId)

  2. 执行 Lua 脚本尝试获取锁

  3. 如果锁已被占用:

    • 订阅锁释放的 Channel
    • 等待锁释放通知(基于 Redis 的 Pub/Sub)
  4. 获取成功后启动看门狗线程

4.1.3 加锁注意事项
  1. 锁获取策略

    • ✅ 优先使用tryLock(waitTime, leaseTime)

      • 避免使用无参lock()方法(会无限阻塞)
      • 推荐设置waitTime(如5秒),超时快速失败
    • ⚠️ 直接lock()仅适用于:

      • 必须获得锁的场景
  2. 看门狗机制控制

    场景写法效果
    已知执行时长tryLock(5, 30, SECONDS)禁用看门狗,30秒后自动释放
    执行时长不确定lock() 或 tryLock(5, -1, SECONDS)启用看门狗(默认30秒TTL,每10秒续期)

    看门狗机制(Watchdog)

    • 自动续期:Redisson 会为持有锁的客户端启动一个看门狗线程,默认每10秒检查一次锁状态
    • 续期机制:如果客户端仍持有锁,会自动将锁的过期时间延长到30秒(默认值)
    • 宕机保护:客户端崩溃时,看门狗线程也会终止,锁最终会自动过期
  3. 锁释放保证

    • 使用try-finally代码块,在finally释放里面验证当前线程是否还持有锁isHeldByCurrentThread()
    • 只能释放自己加的锁,Redisson使用uuid+threadid保证唯一标识
  4. 锁设计规范

    • 合理的命名规则和粒度控制
4.1.4 Redis 分布式锁的缺点
缺点类别描述
可用性问题单节点 Redis 宕机锁失效,主从切换锁状态可能丢失
锁释放不可靠非原子释放,可能误删其他线程锁
看门狗失效风险GC、阻塞等导致自动续期失败
一致性不足Redis 是 AP 系统,不保证强一致性
RedLock 有争议理论安全性不足,不适合强一致性场景
无死锁感知宕机或异常不易清理锁,存在业务风险
  1. 主节点宕机 Redis Cluster 的高可用保障
  • 主从复制:每个分片至少1主N从(建议至少1从)
  • 自动故障转移:当主节点宕机,集群会自动选举从节点升级为主节点
  • 数据同步:如果锁信息已经写入了日志会通过RDB+AOF保证数据持久化
  1. 异常情况处理
  • 网络问题:自动重试(可配置重试次数)
  • 客户端崩溃:看门狗停止,锁自动过期释放
  • Redis节点故障:集群自动转移,Redisson 自动重定向

4.2 Redlock加锁机制

RedLock 设计明确要求每个参与加锁的 Redis 节点必须是独立的、无副本的主节点

4.2.1 RedLock加锁流程(4步)
  1. 记录起始时间

    • 客户端获取当前毫秒级时间戳
  2. 顺序节点加锁

    • 向N个独立Redis节点发送加锁请求
    • 使用相同键名和随机值(保证锁标识唯一)
  3. 计算加锁耗时

    • 总耗时= 当前时间 - 起始时间
  4. 有效性双重验证

    • 多数成功:获得锁的节点数 ≥ N/2 + 1
    • 时间有效:锁释放时间 > 总耗时 + 时钟漂移缓冲值(通常1ms)

4.2.2 异常处理原则(3条)
  1. 节点故障

    • 加锁阶段节点宕机:不计入成功数,继续尝试其他节点
    • 持锁阶段节点宕机:依赖TTL自动释放锁
  2. 网络/时钟异常

    • 加锁超时:立即放弃并向所有节点发送释放请求
    • 时钟跳跃:通过TTL - ΔT - 缓冲值补偿
  3. 释放锁异常

    • 无论是否成功释放,最终依赖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 多路复用机制;
  • 它使用事件回调机制,只在真正有事件发生时才通知程序
  • 可以处理无限制的并发连接而不会出现性能瓶颈。

image.png

6.5 图解:IO 多路复用 vs 传统模型

传统模型(每连接一个线程):

客户端1 ---> 线程1
客户端2 ---> 线程2
客户端3 ---> 线程3
...         ...

IO 多路复用(一个线程监听所有连接):

          +------------------+
客户端1 --|                  |
客户端2 --|   epoll + 主线程|----> 回调处理
客户端3 --|                  |
          +------------------+

7. Redis cluster集群

7.1 Redis Cluster 的设计目标

  1. 分布式存储与计算:自动将数据分散存储在多个节点;
  2. 高可用性:节点失效时自动完成主从切换;
  3. 去中心化架构:无单点瓶颈,节点之间平等;
  4. 客户端直连,性能高:客户端可连接任一节点自动重定向请求。

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 – 32760 – 22760 – 2500
B 节点3277 – 65532277 – 52762501 – 5276
C 节点6554 – 98305277 – 75535277 – 7553
D 节点9831 – 131077554 – 98307554 – 10329
E 节点13108 – 163839831 – 12107❌(节点已删除)
F 节点❌(节点不存在)12108 – 1518410330 – 13107
  • 步骤解释

    • 新增节点 F:从 A、B、C、D、E 各个节点迁移出一部分槽位,使用migrate命令让F 接管这些槽位后,所有节点的槽位范围就会发生变化。
    • 删除节点 E:通过 reshard 将 E 上的槽位(9831 – 16383)迁移到其他节点(A、B、C、D),并且最终将 E 节点从集群中移除。
4. 主节点挂了,从节点如何晋升为主节点?

故障转移(Failover)流程

  1. 检测主节点失效

    • 其他主节点通过 心跳机制(PING/PONG)发现某主节点(如 M1)超时无响应。
    • 超过半数主节点确认 M1 失效后,触发故障转移。
  2. 从节点竞选新主节点

    • M1 的从节点(S1)向其他主节点发起竞选请求。
    • 其他主节点投票,多数同意后 S1 晋升为新主节点
  3. 槽所有权转移

    • S1 接管原 M1 负责的槽(0-5460),并更新集群元数据。
    • 客户端请求会被重定向到新的主节点 S1。
  4. 集群状态恢复

    • 如果原主节点 M1 恢复,它会变为 S1 的从节点(除非手动调整)。

8. Redis持久化机制

1. RDB(Redis Database)

RDB 是 Redis 默认的持久化方式,它通过创建数据集的快照(snapshot) 来保存某一时刻的完整数据。

工作原理
  • Redis 会定期将内存中的数据以二进制格式写入磁盘(默认文件名为 dump.rdb)。
  • 可以通过配置 save 指令设置触发快照的条件(如 save 900 1 表示 900 秒内至少 1 个 key 被修改时触发快照)。
  • 也可以手动执行 SAVE(阻塞主线程)或 BGSAVE(后台异步执行)命令生成 RDB 文件。
优点
  1. 高性能:RDB 是二进制文件,恢复速度快,适合大规模数据备份。
  2. 紧凑存储:RDB 文件比 AOF 更小,节省磁盘空间。
  3. 适合灾难恢复:可以定期备份 RDB 文件到远程服务器。
缺点
  1. 可能丢失数据:如果 Redis 崩溃,最后一次快照之后的数据会丢失(取决于备份频率)。
  2. 大数据量时可能阻塞服务BGSAVE 虽然异步,但在数据量极大时仍可能影响性能。

2. AOF(Append-Only File)

AOF 通过记录所有写操作命令(如 SETDEL 等)来持久化数据,类似于 MySQL 的 binlog。

工作原理
  • 默认不开启,需在配置中设置 appendonly yes

  • 所有写命令会追加到 AOF 缓冲区,并根据策略(appendfsync)同步到磁盘:

    • always:每个命令都同步(最安全,但性能最差)。
    • everysec(默认):每秒同步一次(平衡性能与安全)。
    • no:由操作系统决定何时同步(最快,但可能丢失数据)。
  • AOF 文件会不断增长,Redis 提供 BGREWRITEAOF 命令重写 AOF 文件(移除冗余命令,优化体积)。

优点
  1. 数据更安全:最多丢失 1 秒的数据(appendfsync everysec)。
  2. 可读性强:AOF 是文本格式,可以手动编辑(如修复误操作)。
  3. 灵活恢复:支持通过回放 AOF 日志恢复数据。
缺点
  1. 文件体积大:AOF 文件通常比 RDB 大,恢复速度较慢。
  2. 写入性能开销:高频同步策略(如 always)可能影响吞吐量。

3. 混合持久化(RDB + AOF)

Redis 4.0 后支持混合持久化(aof-use-rdb-preamble yes),结合了两者的优势:

  • AOF 文件前半部分是 RDB 格式的快照,后半部分是增量 AOF 命令。
  • 恢复时先加载 RDB 快照,再回放 AOF 命令,兼顾速度和数据完整性

9. Redis的过期删除策略和内存淘汰策略

Redis 的淘汰策略分为两大类:

  1. 主动淘汰(定期删除/惰性删除):无论内存是否满,都会按规则清理过期键。
  2. 被动淘汰(内存满时触发):当内存达到 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 定位对应节点;
    • 将节点移动到链表头部(表示最近访问);
    • 返回对应的值。

image.png

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 个独立的哈希函数,每个能将输入元素映射到位数组中的某个下标位置。

✅ 元素添加过程

当插入一个元素时,布隆过滤器会:

  1. 使用 k 个哈希函数计算出该元素的 k 个哈希下标;
  2. 将这些下标位置对应的位设置为 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 一定不存在

❗ 特点与注意事项

特性描述
时间复杂度插入与查询时间复杂度为 O(k) (k 是哈希函数个数)
空间效率高相比 HashSet、List 等结构,能以更小内存存储大量数据
存在误判会有假阳性(误判存在),但不会有假阴性(不会误判不存在)
不支持删除一旦位被置为 1,就无法恢复,除非使用 计数型布隆过滤器
容量与误判率存储越多数据,冲突概率越高;位数组越大、哈希函数越合理,误判率越低

12. 缓存更新策略

旁路缓存模式

  • 读流程

    1. 先查缓存,命中则返回
    2. 未命中则查数据库,将数据库结果写入缓存
  • 写流程

    1. 直接更新数据库
    2. 删除缓存中对应数据

缺点

  • 存在短暂不一致窗口期
  • 可能发生缓存击穿

适用场景:读多写少场景

九、Zookeeper

1. Zookeeper的模式?

  • Zookeeper是CP模式(一致性+分区容错性)

2. Zookeeper的角色

集群架构:

  1. Leader

    • 负责处理所有写请求和事务性操作
    • 集群中只有一个Leader,通过选举产生
    • 负责与Followers同步数据
  2. Follower

    • 处理客户端读请求,转发写请求给Leader
    • 参与Leader选举投票
    • 参与事务请求的提案投票
  3. Observer

    • 与Follower类似但不参与投票
    • 只接收Leader的事务结果并应用
    • 用于扩展读性能而不影响写性能

3.Zookeeper节点类型

  • 临时节点
  • 持久节点
  • 临时有序节点(用于分布式锁)
    • 创建临时顺序节点/lock/lock-
    • 获取所有子节点
    • 如果不是最小节点,watch前一个节点
    • 前一个节点删除时获取锁

只监测前一个节点,避免羊群效应(ZooKeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。)

  • 持久有序节点

4. Zookeeper的选举和同步机制

1. 触发条件

  • 集群启动:所有节点初始启动时,无Leader存在。
  • Leader崩溃:Leader失去与多数Follower的连接(心跳超时)。
  • 运维干预:手动触发Leader重新选举(如kill -SIGTERM Leader进程)。

2. Leader选举阶段(Leader Election)

核心原则

  1. 优先比较 zxid:数据最新的节点(zxid 更大)优先成为 Leader
  2. zxid 相同时:比较 myid,投票给 myid 更大的节点
  3. 每轮投票:节点始终优先投给自己,除非发现更优的候选者
选举流程
  1. 状态初始化

    • 所有节点启动时进入LOOKING状态,开始选举。
  2. 投票规则

    • 每个节点投票给自己,投票内容包含:

      • myid(配置的服务器ID,如server.1server.2
      • zxid(最后一次事务ID,越大代表数据越新)
    • 比较优先级

      1. 优先比较zxid:选择数据最新的节点(zxid最大)。
      2. zxid相同则比较myid:选择myid更大的节点(避免平局)。
  3. 投票传播

    • 节点通过TCP(默认端口3888)广播投票信息。
    • 每个节点维护一个投票箱,记录收到的投票。
  4. 选举结果(假如有多个节点所有节点会投给已知节点最大的,比如12345所有节点都会投给5)

    • 当某个节点获得超过半数(n/2+1) 的相同投票时:

      • 如果该节点是自己 → 成为Leader,状态变为LEADING
      • 如果是其他节点 → 成为Follower,状态变为FOLLOWING

image.png

image.png image.png

3. 数据同步阶段(Data Synchronization)

同步流程
  1. Leader确认数据基准

    • 新Leader确定集群中最大的zxid(即最新数据状态)。
  2. 差异化同步策略

    • 全量同步(SNAP) :如果Follower数据太旧(zxid远小于Leader),直接发送完整数据快照。
    • 增量同步(DIFF) :如果Follower仅落后少量事务,只发送差异部分。
    • 截断同步(TRUNC) :如果Follower有Leader不存在的zxid(异常情况),要求Follower回滚到一致点。
  3. 同步完成

    • 所有Follower同步到最新zxid后:

      • Leader进入BROADCAST模式(正常服务)。
      • Follower保持FOLLOWING状态,准备接收客户端请求。

image.png

十、 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 存储不同类型的消息(如 orderslogs)。
    • 消费者按 Topic 订阅消息
4. Partition(分区)
  • 作用:Topic 的 物理存储单元,用于 并行处理和扩展

  • 关键点

    • 每个 Partition 是 有序的、不可变的消息序列
    • 相同 Key 的消息会进入同一个 Partition(保证顺序)。
    • 分区可以 分布在多个 Broker 上,提高吞吐量。
5. Consumer(消费者)
  • 作用:从 Kafka 读取消息 的客户端(pull的方式)。

  • 关键点

    • 消费者以 Consumer Group(消费者组)  形式工作,组内 每个消费者读取不同 Partition

image.png


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. 索引的类型

image.png

聚簇索引(Clustered Index) vs 非聚簇索引(Non-Clustered Index)

特性聚簇索引非聚簇索引(二级索引)
存储方式数据行实际存储在索引的叶子节点叶子节点存储主键值(指向数据行)
数量限制每表只能有一个每表可以有多个
默认创建主键自动成为聚簇索引需要显式创建
查询效率范围查询效率高单点查询效率高
包含数据包含完整数据记录只包含索引列和主键值

3. 索引失效的情况

  1. 违反最左前缀原则:对于组合索引(a,b,c),查询条件无a则索引失效 -- 索引(a,b,c) SELECT * FROM table WHERE b = 1 AND c = 2; -- 失效

  2. 在索引列上做计算或函数操作 SELECT * FROM table WHERE YEAR(create_time) = 2023; -- 失效

  3. 使用不等于(!=或<>) SELECT * FROM table WHERE status != 1; -- 失效

  4. 使用IS NULL或IS NOT NULL SELECT * FROM table WHERE name IS NULL; -- 可能失效

  5. LIKE以通配符开头,如果在结尾就会走索引 SELECT * FROM table WHERE name LIKE '%张'; -- 失效

  6. 类型转换 SELECT * FROM table WHERE id = '123'; -- 如果id是int类型,失效

  7. OR条件前后未同时使用索引 SELECT * FROM table WHERE a = 1 OR b = 2; -- 如果b无索引,整个查询失效

4. SQL的执行计划(排查慢SQL)

在排查一个 SQL 性能时,执行 EXPLAIN(或者 EXPLAIN ANALYZE,更详细)是必经之路
但是 EXPLAIN 的输出列很多,一般要重点关注下面这些字段:


EXPLAIN 中需要重点关注的内容:

字段名重要性解释理想情况
type★★★连接类型,查询扫描方式,非常重要!最好是 constrefrange,避免 ALL(全表扫描)。
key★★★MySQL 实际选中的索引。确认是否用了正确的索引。
rows★★★预估要扫描的行数,越小越好。尽量小,比如几十、几百以内。
Extra★★★补充信息,有时候会告诉你索引失效、Using temporary、Using filesort等。希望没有 "Using filesort"、"Using temporary"。

最最关键的检查流程(简化版)

  1. type

    • const结果只有一条的主键或唯一索引扫描 > eq_ref唯一索引扫描 > ref非唯一索引扫描 > range索引范围扫描 > index全索引扫描 > ALL全表扫描
    • 如果是 ALL,就要小心了!意味着全表扫描
  2. key 是否命中

    • 显示的使用哪个索引
  3. rows

    • 预估扫描行数,太多(几百万)肯定要优化。
  4. Extra

    • 出现下面这些,需要警惕:

      • Using filesort:使用了外部排序,说明索引不够好。
      • Using temporary:使用了临时表,通常出现在复杂排序或分组中,影响性能。
      • Using where:正常,表示用 WHERE 条件筛选。
      • Using index:超好!表示只用到了索引中的数据,不回表。

5. InnoDB的数据存取机制

InnoDB采用"页(Page)"作为磁盘与内存交互的基本单位,默认大小为16KB。当需要访问某条记录时,存储引擎不会单独读取该记录,而是将整个数据页加载到内存的缓冲池(Buffer Pool)中。

B+树的每个叶子节点就是一个数据页。

deepseek_mermaid_20250506_71fc32.png

6. B+树索引

1. 特点

核心特点:

  • 多路搜索,节点有多个子节点。
  • 每个节点存储多个 key(键值对)
  • 所有的数据(记录)只存在于叶子节点
  • 叶子节点之间是有序链表结构,支持范围查询
  • 通常只有2-4层树
  • 查询路径固定,从根节点一层一层往下,直到叶子节点,路径唯一、稳定。

2. 查询流程

B+ 树查找主键为 6 的记录(结合图示)

  1. 从根节点开始查找(页38)
    根节点存有两个目录项:

    • 键值 1 → 页30
    • 键值 7 → 页36
      查询键为 6,介于 [1, 7) 之间,因此定位到页30继续查找。
  2. 进入中间非叶子节点页30,继续查找
    页30目录项为:

    • 1 → 页10
    • 3 → 页22
    • 5 → 页16
      查询键为 6,大于所有键值,因此选择最后一个目录项(5 → 页16)进入页16查找。
  3. 进入叶子节点页16,查找目标记录
    页16包含主键为 5 和 6 的记录。
    通过页内二分查找或槽定位,快速找到主键为 6 的记录并返回结果。 image.png

3. 什么是回表?

当查询使用二级索引时,如果查询的字段不全部包含在索引中(即不是"覆盖索引"),MySQL就需要:

  1. 先通过二级索引找到对应的主键值
  2. 再通过主键值到聚簇索引中查找完整的行记录

假设有表:

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 = '北京';

查询过程:

  1. 先通过二级索引idx_age_city查找age=25且city='北京'的记录,获取到主键id值
  2. 再通过主键id到聚簇索引中查找完整的user记录
  3. 这个第二步就是"回表"操作

如何避免回表

  1. 使用覆盖索引

    SELECT id, age, city FROM user WHERE age = 25 AND city = '北京';
    

    (查询的字段都包含在索引中)

  2. 使用主键查询

    SELECT * FROM user WHERE id = 123;
    

回表的影响

  1. 性能开销

    • 需要两次索引查找(二级索引+聚簇索引)
    • 比直接使用聚簇索引或覆盖索引更耗时

7. B+树和B-树的区别

项目B-树B+树
数据存储位置数据既存在内部节点,也存在叶子节点数据只存在叶子节点
查询路径可能在中间节点结束查询必须到叶子节点
叶子节点叶子节点无指针连接叶子节点有顺序链表
范围查询不方便,需要中序遍历直接链表扫描,非常快
磁盘访问随机性大,跳来跳去顺序性好,I/O效率高

8. 事物

1. 四大特性

  1. 原子性(Atomicity)
    事务是不可分割的最小工作单元,要么全部成功,要么全部失败回滚,不存在部分执行的情况。
  2. 一致性(Consistency)
    事务执行前后,数据库必须保持数据的一致性状态
  3. 隔离性(Isolation)
    多个并发事务之间互不干扰,每个事务的操作对其他事务不可见,直到事务提交。
  4. 持久性(Durability)
    事务一旦提交,其对数据的修改就是永久性的,即使系统崩溃也不会丢失。

2. 事物的隔离级别

1. 并发问题

脏读(Dirty Read)

  • 现象:事务A读取到事务B未提交的修改
  • 后果:可能读取到最终会回滚的无效数据

不可重复读(Non-Repeatable Read)

  • 现象:事务A内两次相同查询返回不同结果(其他事务修改数据)
  • 后果:影响事务内一致性判断

幻读(Phantom Read)

  • 现象:事务A范围查询时,其他事务新增/删除符合条件的数据
  • 后果:导致同一事务内相同查询返回不同行数

严重性排序

image.png

2. 四大隔离级别
2.1 隔离级别介绍
  1. READ UNCOMMITTED(读未提交)
    事务可以读取其他事务未提交的数据,可能导致脏读、不可重复读、幻读。
  2. READ COMMITTED(读已提交)
    事务只能读取其他事务已提交的数据,避免脏读,但仍可能不可重复读、幻读。
  3. REPEATABLE READ(可重复读)
    事务执行期间多次读取同一数据结果一致(InnoDB默认级别),避免脏读、不可重复读,但仍可能幻读。
  4. SERIALIZABLE(串行化)
    强制事务串行执行,完全避免脏读、不可重复读、幻读,但性能最低。

image.png

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 核心依赖两个组件:

  1. Undo Log(回滚日志)
    每当事务对某条记录进行 INSERTUPDATEDELETE 操作时,InnoDB 会在 Undo Log 中记录该行被修改前的版本
  2. 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 logSHOW 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 流程图

deepseek_mermaid_20250506_a79a95.png

7. 分库分表

7.1 水平拆分(横向拆分)
  • 按行拆分,表结构相同,按照时间(日月)等拆分
7.2 垂直拆分(纵向拆分)
  • 按列拆分,将宽表拆为多个窄表,通常按业务维度拆分

十二、Linux

1.CPU满了排查流程

    1. 用top命令查询CPU占用比较高的进程
    1. 用top -p 进程id查看占用比较高的线程id
    1. 通过在线进制转换器把线程id转换成十六进制
    1. 用jstack命令+进程id查看堆栈信息
    1. 用十六进制的线程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(确认连接建立)。

image.png

3. TCP的四次挥手

  • 第一次挥手:客户端发送 FIN 包,告诉服务器要断开连接。

  • 第二次挥手:服务器收到 FIN 包,回复 ACK 包,确认关闭客户端到服务器的连接。

  • 第三次挥手:服务器发送 FIN 包,告诉客户端服务器端的连接也要关闭。

  • 第四次挥手:客户端收到 FIN 包后,回复 ACK 包,确认服务器的连接关闭。

image.png

十四、微服务

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. 分布式锁

Rediszookeeper

2. 分布式事物

2.1 2PC

  • 准备阶段:协调者询问所有参与者是否可以提交
  • 提交阶段:根据参与者反馈决定提交或回滚

2.2 3PC

改进点

  • 引入超时机制
  • 增加预提交阶段

2.3 TCC

  • Try:预留资源
  • Confirm:确认执行业务
  • Cancel:取消预留

2.4 本地消息表

本地消息表是一种基于最终一致性的分布式事务解决方案,其核心思想是将分布式事务拆分为:

1. 业务服务A执行本地事务,同时将需要发送的消息写入本地数据库的消息表中
2. 后台定时任务扫描消息表,将未发送的消息发送到消息中间件
3. 消息中间件确保消息投递到业务服务B
4. 业务服务B消费消息并执行业务处理
5. 如果业务服务B处理成功,则流程结束;如果失败,消息中间件会重试投递

image.png

2.5 最大努力通知

分布式事物最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。

流程: 系统 A 本地事务执行完之后,发送个消息到 MQ; 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口; 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。

我画了一个对比流程图,帮助你整体理解:

                      ┌───────────────────────────────────────┐
                      │            业务操作成功                │
                      └───────────────────────────────────────┘
                                   ↓
                    ┌─────────────────────────────┐
                    │ 是否使用本地消息表?           │
                    └─────────────────────────────┘
                     Yes ↓                           No ↓
┌────────────────────────────────────────────┐   ┌────────────────────────────────────────┐
│ 开启事务                                   │   │ 尝试发送通知                            │
│  - 执行业务操作                            │   │   - 成功 -> 结束                        │
│  - 同时插入一条消息表记录                   │   │   - 失败 -> 重试机制                    │
│ 提交事务                                   │   │   - 多次失败后 -> 人工补偿                │
└────────────────────────────────────────────┘   └────────────────────────────────────────┘
           ↓
┌────────────────────────────────────────────┐
│ 后台进程扫描消息表,发送消息到MQ/外部系统    │
│  - 成功后更新消息状态为"已发送"              │
│  - 失败则继续重试                           │
└────────────────────────────────────────────┘

本地消息表+最大努力通知组合

image.png

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)030, 3, 6, 9, 12...
DB2(机器ID=1)131, 4, 7, 10...
DB3(机器ID=2)232, 5, 8, 11...

十六、限流

1. 单机限流

1. 令牌桶算法(Token Bucket)

🌟 原理
  • 系统以固定速率往桶中添加令牌,如每秒生成5个令牌
  • 每个请求必须获取一个令牌才能继续,否则被拒绝或排队,如果一下来了6个请求有一个请求就需要排队等下一秒

2. 分布式限流

Sentinel

项目难点:营销活动中台的高并发库存扣减问题

难点描述

在营销活动中台项目中,我们遇到了高并发场景下的库存超发问题。特别是在"限时秒杀"和"大额优惠券发放"等高热度活动时,瞬时并发请求可达5000+ QPS,传统的数据库库存扣减方式会出现以下问题:

  1. 数据库行锁竞争激烈,导致大量请求阻塞超时
  2. 基于数据库的乐观锁重试机制在超高并发下效果不佳
  3. 库存扣减与订单创建不是原子操作,可能出现库存扣减成功但订单创建失败的数据不一致情况

解决方案

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. 订单创建与库存扣减的分布式事务

使用本地消息表+最大努力通知模式保证两个操作的最终一致性:

  1. 先扣减Redis库存
  2. 生成预订单写入本地数据库(状态为"处理中")
  3. 发送Kafka消息触发后续服务处理
  4. 消费成功后更新订单状态为"已完成"
  5. 定时任务补偿处理异常订单

3. 熔断降级策略

  • 当Redis库存耗尽时,直接在网关层返回"已售罄",减轻后端压力
  • 监控数据库压力,超过阈值时启动限流(使用Sentinel)
  • 降级方案:极端情况下切换为"令牌桶"模式,先到先得

image.png