4. Java

4.9 static 有哪些用法,类的初始化顺序是怎样的

用法 说明
静态变量 属于类,所有实例共享一份
静态方法 不依赖实例,不能直接访问实例成员,也不能使用 this
静态代码块 类初始化时执行一次
静态内部类 不持有外部类实例的引用
静态导入 import static java.lang.Math.max;

4.14 异常体系是怎样的,受检异常和非受检异常有什么区别

Throwable
├── Error                    JVM 层面的严重问题,程序一般无法处理:OutOfMemoryError、StackOverflowError
└── Exception
    ├── 受检异常              编译器强制处理(catch 或 throws):IOException、SQLException
    └── RuntimeException     非受检异常,多是程序错误:NullPointerException、IllegalArgumentException、
                             IndexOutOfBoundsException、ClassCastException

怎么选择:默认用非受检异常。非受检异常大多代表代码缺陷,应该让它暴露出来再修复代码,不要用 try-catch 掩盖。受检异常只用于调用方能够、而且应该处理的可恢复情况,例如文件不存在时让调用方换一个路径。Spring 把 JDBC 的 SQLException 包装成非受检的 DataAccessException,也是出于这个考虑。

要点 说明
try-with-resources 实现了 AutoCloseable 的资源写在 try (...) 里,结束时自动关闭。关闭时抛出的异常作为「被抑制的异常」挂在主异常上,用 getSuppressed() 取。多个资源用分号隔开,按声明的相反顺序关闭
finally 里不要 return finally 里的 return 会覆盖 try 里的返回值,还会吞掉 try 里抛出的异常
return 和 finally 的顺序 try 里 return x; 时先把 x 的值存起来,再执行 finally。finally 里修改基本类型的 x 不影响返回值;x 是对象时,修改对象内容会影响
不要吞异常 catch 之后至少要记录日志并带上原异常,包装成新异常时把原异常作为 cause 传入
不要用异常控制流程 创建异常要填充调用栈,开销大

Spring 的事务默认只对 RuntimeException 和 Error 回滚,受检异常不回滚,见 4.80 节。

4.15 反射是什么,有什么用,性能如何

反射是在运行时获取类的信息(字段、方法、构造函数、注解),并动态创建对象、调用方法、读写字段。

Class<?> clazz = Class.forName("com.example.User");
Object user = clazz.getDeclaredConstructor().newInstance();
Method m = clazz.getMethod("setName", String.class);
m.invoke(user, "tom");
Field f = clazz.getDeclaredField("age");
f.setAccessible(true);          // 访问 private 字段
f.set(user, 18);

获取 Class 对象的四种方式:

方式 写法 是否触发类的初始化
类字面量 User.class 否
Class.forName Class.forName("com.example.User") 是,执行静态代码块;Class.forName(name, false, loader) 可以不初始化
对象实例 user.getClass() 对象已经存在,类早已初始化
类加载器 ClassLoader.getSystemClassLoader().loadClass("com.example.User") 否,只加载,不链接也不初始化

早年加载 JDBC 驱动用 Class.forName,正是利用它会初始化类:驱动类在静态代码块里把自己注册到 DriverManager。

用途 例子
框架的依赖注入 Spring 按注解找到字段和构造函数,反射创建 Bean、注入依赖
动态代理 JDK 动态代理通过 Method 对象把调用转发给 InvocationHandler
ORM 和序列化 MyBatis、Jackson 通过反射把列或 JSON 字段映射到对象属性
注解处理 运行时读取 @Transactional、@RequestMapping 等注解
缺点 说明
性能 比直接调用慢,主要是参数装箱、访问检查、难以内联。JDK 18 起反射改用方法句柄实现(JEP 416),热点调用经 JIT 优化后差距明显缩小
破坏封装 可以访问 private 成员
失去编译期检查 方法名写错要到运行时才报错
模块系统的限制 JDK 9 起,反射访问其他模块未开放的包会被拒绝;JDK 16 起默认强封装 JDK 内部 API,需要用 --add-opens 显式开放

4.16 JDK 动态代理和 CGLIB 有什么区别

两者都在运行时生成代理类,把方法调用拦截下来,转交给增强逻辑。Spring AOP、MyBatis 的 Mapper、RPC 框架的客户端都靠它们实现。

interface UserService { String find(long id); }

UserService target = id -> "user-" + id;
UserService proxy = (UserService) Proxy.newProxyInstance(
        UserService.class.getClassLoader(),
        new Class<?>[] {UserService.class},
        (p, method, args) -> {
            System.out.println("before " + method.getName());
            Object r = method.invoke(target, args);
            System.out.println("after");
            return r;
        });
proxy.find(1);
JDK 动态代理 CGLIB
前提 目标类必须实现接口 不需要接口
原理 生成一个实现相同接口的代理类,方法调用转给 InvocationHandler 用 ASM 生成目标类的子类,重写父类方法,调用转给 MethodInterceptor
限制 只能代理接口里的方法 不能代理 final 类和 final、private、static 方法
依赖 JDK 自带 第三方库;Spring 内置了一份重新打包的 CGLIB

Spring Framework 的规则是:目标实现了接口就用 JDK 代理,否则用 CGLIB。Spring Boot 2.0 起默认 spring.aop.proxy-target-class=true,统一用 CGLIB,避免「按实现类注入时找不到 Bean」这类问题。

4.17 注解是怎么生效的

注解本身只是附在类、方法、字段上的元数据,不包含逻辑,要靠「有人去读它」才会生效。

读取时机 由谁读取 例子
编译期 编译器或注解处理器(APT) @Override 由编译器检查;Lombok、MapStruct 在编译期生成代码
运行期 框架通过反射读取 Spring 扫描 @Component、@Autowired;@Transactional 由 AOP 代理读取

元注解用来描述注解本身:

元注解 作用
@Retention 保留到什么时候:SOURCE(编译后丢弃)、CLASS(写进 class 文件但运行时不可见,默认值)、RUNTIME(运行时可以反射读取)
@Target 能用在哪里:类、方法、字段、参数等
@Inherited 类上的注解能否被子类继承
@Documented 是否出现在 Javadoc 中
@Repeatable 能否在同一处重复使用

自定义一个运行时注解,再用 Spring AOP 的切面拦截带这个注解的方法,是实现日志、权限、限流这类横切逻辑的常见做法。

和编译器的关系:注解处理器(JSR 269,实现 javax.annotation.processing.Processor)是 javac 提供的标准插件接口,通过 jar 里的 META-INF/services 注册。标准接口只能生成新的源文件或资源文件,不能修改已有的类;Lombok 能直接给类加方法,是因为它修改了 javac 内部的语法树,所以 JDK 升级时它要跟着适配。

Spring 的 @Component、@Bean 是 RUNTIME 注解,编译器只负责把它们写进 class 文件的 RuntimeVisibleAnnotations 属性。Spring 启动时扫描 classpath,用 ASM 直接读 class 文件里的注解(这一步不加载类),再用反射创建对象,整个过程和编译器没有交互。运行时通过反射拿到的注解实例是 JDK 生成的动态代理,调用 value() 这类方法时,从代理保存的属性表里取值。

4.21 Object 类有哪些方法

方法 作用
equals、hashCode 相等性与哈希,见 4.5 节
toString 默认返回「类名@十六进制哈希值」
getClass 返回运行时类型,是 final 方法
clone 浅拷贝,见 4.13 节
wait、notify、notifyAll 线程等待与通知,必须在持有该对象锁时调用,见 4.38 节
finalize 已过时,见 4.8 节

4.25 枚举有什么特点,为什么说枚举是实现单例的最好方式

枚举编译后是一个继承 java.lang.Enum 的 final 类,每个枚举常量都是这个类的一个 public static final 实例,在类初始化时创建。枚举可以有字段、构造函数(只能是私有的)、方法,也可以实现接口,每个常量还可以有自己的方法实现。

public enum OrderStatus {
    CREATED(1), PAID(2), SHIPPED(3);

    private final int code;
    OrderStatus(int code) { this.code = code; }
    public int code() { return code; }

    public static OrderStatus of(int code) {
        for (OrderStatus s : values()) if (s.code == code) return s;
        throw new IllegalArgumentException("unknown status " + code);
    }
}

public enum IdGenerator {          // 枚举单例
    INSTANCE;
    private final AtomicLong seq = new AtomicLong();
    public long next() { return seq.incrementAndGet(); }
}

枚举单例的优点:

优点 说明
线程安全 实例在类初始化时创建,JVM 保证类初始化只执行一次
防反射 反射调用枚举的构造函数会抛 IllegalArgumentException: Cannot reflectively create enum objects
防序列化破坏 枚举的序列化只写名字,反序列化时用 valueOf 找回原来的实例,不会创建新对象
代码最短 不需要双重检查锁、volatile

缺点是不能懒加载,也不能继承别的类。

switch 可以直接作用于枚举;需要以枚举为键的映射时用 EnumMap,内部是数组,比 HashMap 快。存数据库时存 code 字段,不要存 ordinal(),调整枚举常量的顺序会改变 ordinal。

4.26 Java 集合框架的整体结构是怎样的

Collection
├── List      有序、可重复:ArrayList、LinkedList、CopyOnWriteArrayList、Vector(已过时)
├── Set       不重复:HashSet、LinkedHashSet(保持插入顺序)、TreeSet(排序)
└── Queue     队列:ArrayDeque、PriorityQueue、LinkedList、各种 BlockingQueue
Map           键值对:HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap、Hashtable(已过时)
需求 选择
按下标随机访问 ArrayList
栈、队列、双端队列 ArrayDeque,比 Stack、LinkedList 都快
键值查找 HashMap
保持插入顺序或做 LRU LinkedHashMap
按键排序、范围查询 TreeMap(红黑树)
优先级 PriorityQueue(二叉堆)
多线程 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue
实现 底层结构
ArrayList Object[] 数组
Vector Object[] 数组,方法加了 synchronized
Stack 继承 Vector,后进先出;已被 ArrayDeque 取代
LinkedList 双向链表(JDK 7 起不再是循环链表)
HashSet 基于 HashMap,元素存在键上
LinkedHashSet 基于 LinkedHashMap
TreeSet 基于 TreeMap,红黑树
PriorityQueue Object[] 数组实现的二叉小顶堆
ArrayDeque 可扩容的循环数组
DelayQueue 内部是一个 PriorityQueue,加锁保护

4.33 什么是 fail-fast 和 fail-safe,遍历时怎么删除元素

fail-fast fail-safe
例子 ArrayList、HashMap 的迭代器 CopyOnWriteArrayList、ConcurrentHashMap 的迭代器
原理 集合维护一个修改计数 modCount,迭代器创建时记下它,每次 next 时比较,不一致就抛 ConcurrentModificationException 遍历的是快照(写时复制),或者允许遍历时并发修改(弱一致性)
结果 尽早暴露并发修改的错误 不抛异常,但可能看不到最新的修改

单线程里一边遍历一边调用 list.remove 也会触发 fail-fast,因为修改改变了 modCount。正确的删除方式:

list.removeIf(x -> x % 2 == 0);              // 推荐

Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    if (it.next() % 2 == 0) it.remove();     // 通过迭代器删除,会同步更新计数
}

HashSet 的底层就是一个 HashMap:元素作为键,值统一放一个占位对象,所以它的去重依赖 hashCode 和 equals。

4.34 CopyOnWriteArrayList 的原理是什么,适合什么场景

写时复制:每次写操作(add、set、remove)先加锁,把整个底层数组复制一份,在新数组上修改,再把引用指向新数组。读操作不加锁,直接读当前数组。

优点 缺点
读完全无锁,读多时性能很好 每次写都复制整个数组,写多或数组很大时开销大,还会产生大量垃圾
迭代器遍历的是快照,不会抛 ConcurrentModificationException 弱一致:读线程可能读到旧数据

适合读远多于写、元素不多的场景,例如监听器列表、配置项、黑白名单。

4.35 有哪些线程安全的集合

类型 线程安全的实现
Map ConcurrentHashMap;需要排序时用 ConcurrentSkipListMap(跳表)
List CopyOnWriteArrayList
Set CopyOnWriteArraySet、ConcurrentSkipListSet、ConcurrentHashMap.newKeySet()
非阻塞队列 ConcurrentLinkedQueue(基于 CAS 的无锁链表队列)
阻塞队列 ArrayBlockingQueue(有界数组,一把锁)、LinkedBlockingQueue(链表,可选有界,入队和出队两把锁)、PriorityBlockingQueue、DelayQueue、SynchronousQueue(不存元素,一个插入必须等待一个取出)
包装 Collections.synchronizedList 等,每个方法加锁;遍历时仍然要手动锁住整个集合
过时的 Vector、Hashtable、Stack,方法级 synchronized,并发度低

线程池的工作队列就是这些阻塞队列,选择哪一种会影响线程池的行为,见 4.45 节。

4.36 创建线程有哪几种方式,start 和 run 有什么区别

方式 说明
继承 Thread,重写 run 占用了唯一的继承机会,不推荐
实现 Runnable,交给 Thread 任务和线程分离
实现 Callable,配合 FutureTask 或线程池 可以有返回值,可以抛受检异常
线程池 生产中的标准做法
虚拟线程(JDK 21) Thread.ofVirtual().start(task),或 Executors.newVirtualThreadPerTaskExecutor()

本质上只有一种:创建 Thread 对象并调用 start。其他方式都是在提供要执行的任务。

start() run()
做了什么 请求 JVM 创建新线程,新线程里执行 run 普通方法调用,在当前线程里执行
能否重复调用 同一个线程对象第二次调用会抛 IllegalThreadStateException 可以

4.37 线程有哪些状态,怎么转换

Thread.State 定义了 6 种状态:

状态 含义 进入方式
NEW 已创建,还没 start new Thread()
RUNNABLE 可运行,包括正在 CPU 上跑和等待操作系统调度 start();阻塞在 socket 读写上的线程,在 Java 层面也是 RUNNABLE
BLOCKED 等待进入 synchronized 块 抢 monitor 锁失败
WAITING 无限期等待,直到被唤醒 Object.wait()、Thread.join()、LockSupport.park()
TIMED_WAITING 限时等待 sleep(ms)、wait(ms)、join(ms)、parkNanos
TERMINATED 已结束 run 执行完或抛出未捕获的异常

jstack 导出的线程栈里能看到每个线程的状态,排查死锁时找处于 BLOCKED 状态、互相等待对方锁的线程。

4.38 sleep 和 wait 有什么区别,为什么 wait 必须在 synchronized 里调用

Thread.sleep Object.wait
属于 Thread 的静态方法 Object 的方法
是否释放锁 不释放 释放当前对象的 monitor 锁
调用前提 任何地方都能调用 必须持有该对象的锁,否则抛 IllegalMonitorStateException
唤醒方式 时间到 其他线程调用 notify 或 notifyAll,或等待超时
用途 暂停一段时间 线程之间的条件等待

为什么必须先持有锁:等待的典型写法是「检查条件 → 不满足就 wait」。如果检查和 wait 之间没有锁保护,另一个线程可能恰好在这两步之间修改条件并调用 notify,这次通知就丢了,等待的线程可能永远醒不过来。

synchronized (lock) {
    while (!condition) {   // 必须用 while:可能被虚假唤醒,或被唤醒时条件已被别的线程改掉
        lock.wait();
    }
    // 条件满足,继续执行
}

notify 只随机唤醒一个等待线程,如果被唤醒的线程等的不是这个条件,可能导致所有线程都在等;不确定时用 notifyAll。更好的做法是用 ReentrantLock 的多个 Condition,不同的条件分开等待,例如阻塞队列的「非空」和「非满」。

4.39 Java 内存模型是什么,happens-before 是什么

Java 内存模型(JMM)规定了多线程程序里,一个线程写的值什么时候、在什么条件下能被另一个线程看到。它要解决的是三个问题:

问题 原因
可见性 每个 CPU 核有自己的缓存,写操作可能还在缓存或写缓冲区里,其他核看不到
有序性 编译器和 CPU 会重排指令,单线程内结果不变,多线程下可能出错
原子性 i++ 是「读、加、写」三步,可能被其他线程打断

happens-before 是 JMM 给出的保证:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 排在 B 之前。主要规则:

规则 内容
程序顺序 同一个线程里,前面的操作 happens-before 后面的操作
监视器锁 对一个锁的解锁 happens-before 之后对同一个锁的加锁
volatile 对一个 volatile 变量的写 happens-before 之后对它的读
线程启动 thread.start() happens-before 这个线程里的任何操作
线程结束 线程里的所有操作 happens-before 其他线程从 join() 返回
传递性 A 先于 B、B 先于 C,则 A 先于 C

写并发代码时要做的,就是让共享变量的写和读之间存在一条 happens-before 链,常用的手段是锁、volatile、并发工具类。

4.40 volatile 有什么作用,为什么不能保证原子性

作用 实现
可见性 写 volatile 变量时立即刷新到主存,读时从主存读最新值。在 x86 上,写操作后会插入一条带 lock 前缀的指令,作用相当于全屏障
禁止重排序 编译器和 CPU 不能把 volatile 写之前的操作移到它后面,也不能把 volatile 读之后的操作移到它前面

不保证原子性:volatile int count; count++; 仍然是「读、加一、写」三步,两个线程可能读到同一个值,各加一后写回,结果只加了一次。计数要用 AtomicInteger、LongAdder 或加锁。

适用场景:一个线程写、多个线程读的状态标志,例如 volatile boolean running;以及双重检查锁单例:

class Singleton {
    private static volatile Singleton instance;
    static Singleton get() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

new Singleton() 分三步:分配内存、调用构造函数、把引用赋给 instance。没有 volatile 时后两步可能被重排,另一个线程在第一次检查时看到 instance 不为 null,拿到的却是还没构造完的对象。

4.41 synchronized 是怎么实现的,锁升级是什么

用法 锁住的对象
修饰实例方法 当前实例 this
修饰静态方法 当前类的 Class 对象
修饰代码块 括号里指定的对象

字节码层面:同步代码块编译成 monitorenter 和 monitorexit 两条指令,异常路径上还有一条 monitorexit 保证释放锁;同步方法则是在方法标志位上加 ACC_SYNCHRONIZED。

对象头:每个对象的对象头里有一个 Mark Word(64 位 JVM 上 8 字节),记录锁状态、哈希码、GC 分代年龄。锁的信息就存在这里。

锁的状态(HotSpot):

状态 适用场景 实现
无锁 没有线程加锁 —
偏向锁 一直是同一个线程加锁 Mark Word 记下线程 ID,之后这个线程加锁不需要 CAS。JDK 15 起默认关闭并标为过时(JEP 374),JDK 18 起相关参数不再生效
轻量级锁 多个线程交替加锁,没有真正同时竞争 在线程栈上建一条锁记录,用 CAS 把 Mark Word 指向它;失败时短暂自旋
重量级锁 真正发生竞争 Mark Word 指向一个 ObjectMonitor,拿不到锁的线程进入等待队列挂起,依赖操作系统的互斥量,需要切换到内核态

锁通常只升级不降级,这样可以避免反复切换的开销。JIT 还会做锁消除(逃逸分析发现对象只在一个线程里用,就去掉锁)和锁粗化(把相邻的多次加锁合并成一次)。

synchronized 是可重入的:同一个线程可以多次进入同一把锁,monitor 里有计数器。

4.42 synchronized 和 ReentrantLock 有什么区别

synchronized ReentrantLock
层次 JVM 关键字 JDK 类,基于 AQS
释放锁 自动,异常时也会释放 必须在 finally 里手动 unlock()
可重入 是 是
公平锁 只有非公平 可选,构造时传 true
可中断地等待 不能 lockInterruptibly()
尝试加锁、超时 不能 tryLock()、tryLock(timeout)
多个等待条件 只有一个 wait/notify 队列 可以创建多个 Condition
性能 JDK 6 起加入锁升级等优化后与 ReentrantLock 相近 竞争激烈时略有优势
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

一般用 synchronized,简单且不会忘记释放;需要超时、可中断、公平锁或多个条件时用 ReentrantLock。读多写少用 ReentrantReadWriteLock;读远多于写、并且能接受乐观读后重试时用 StampedLock。

在 JDK 21 的虚拟线程里,synchronized 块内发生阻塞会把虚拟线程钉在载体线程上(pinning),JDK 24(JEP 491)解决了这个问题,见 4.52 节。

4.43 AQS 是什么,原理是怎样的

AQS(AbstractQueuedSynchronizer)是 java.util.concurrent 里大多数同步器的基础框架:ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch 都基于它实现。

组成 作用
volatile int state 同步状态,含义由子类决定:ReentrantLock 里是重入次数,Semaphore 里是剩余许可数,CountDownLatch 里是剩余计数
等待队列 一个 CLH 变种的双向链表,抢不到资源的线程包装成节点排队,并用 LockSupport.park 挂起
模板方法 子类只需实现 tryAcquire、tryRelease(独占模式)或 tryAcquireShared、tryReleaseShared(共享模式),排队、挂起、唤醒都由 AQS 完成

以 ReentrantLock 的非公平锁为例:

  1. lock() 先直接 CAS 把 state 从 0 改成 1,成功就把持有线程设为自己。
  2. 失败时,如果持有者就是自己,state 加一(重入)。
  3. 否则包装成节点加入队尾并挂起。
  4. unlock() 把 state 减一,减到 0 时唤醒队列中的下一个节点,被唤醒的节点再去抢锁。

公平锁与非公平锁的区别只在于:公平锁抢锁前先检查队列里有没有人在排队(hasQueuedPredecessors),有就直接排队。非公平锁吞吐更高,因为刚释放锁时正在运行的线程可以直接拿到锁,省去唤醒排队线程的开销,代价是排队的线程可能等很久。

4.44 CAS 是什么,ABA 问题怎么解决,LongAdder 为什么比 AtomicLong 快

CAS(Compare And Swap):给定内存地址、期望值、新值,只有当前值等于期望值时才写入新值,整个过程是一条原子的 CPU 指令(x86 上是 lock cmpxchg)。Java 里通过 Unsafe 或 VarHandle 调用,原子类都基于它实现:

AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();      // 内部是「读当前值 → CAS 写入加一后的值 → 失败就重试」的循环
问题 说明 解决
ABA 值从 A 变成 B 又变回 A,CAS 认为没变过。对无锁链表、栈这类结构会造成错误 AtomicStampedReference,同时比较值和版本号
自旋开销 竞争激烈时大量重试,浪费 CPU 分散竞争(LongAdder),或改用锁
只能保证单个变量 多个变量要一起改时无能为力 合并成一个对象用 AtomicReference,或加锁

LongAdder:AtomicLong 让所有线程 CAS 同一个变量,竞争越激烈失败重试越多。LongAdder 维护一个 base 和一个 Cell 数组,竞争时不同的线程累加到不同的 Cell 上,求和时再把它们加起来。

代价是 sum() 不是精确的瞬时值。所以:高并发计数、统计用 LongAdder;需要精确读取、或者需要 CAS 语义(例如生成序号)时用 AtomicLong。

4.45 线程池的核心参数和执行流程是什么

new ThreadPoolExecutor(
    corePoolSize,      // 核心线程数:空闲时也保留(除非 allowCoreThreadTimeOut)
    maximumPoolSize,   // 最大线程数
    keepAliveTime,     // 非核心线程空闲多久后回收
    unit,
    workQueue,         // 任务队列
    threadFactory,     // 创建线程,用来给线程起有意义的名字
    handler            // 拒绝策略
);

提交任务后的处理顺序:

运行中的线程数 < corePoolSize ? ── 是 → 创建核心线程执行任务
        │ 否
队列能放下?                  ── 是 → 放入队列,等空闲线程来取
        │ 否
线程数 < maximumPoolSize ?    ── 是 → 创建非核心线程执行任务
        │ 否
执行拒绝策略

要注意:核心线程满了之后先进队列,队列满了才创建新线程。 所以如果用的是无界队列,maximumPoolSize 永远不会生效。

拒绝策略 行为
AbortPolicy(默认) 抛 RejectedExecutionException
CallerRunsPolicy 由提交任务的线程自己执行,起到让提交方变慢的反压作用
DiscardPolicy 静默丢弃
DiscardOldestPolicy 丢弃队列里最老的任务,再尝试提交

Tomcat 的线程池改造了这个顺序:先把线程加到最大值,再进队列,因为 Web 请求更需要低延迟,而不是排队。

4.46 为什么不建议用 Executors 创建线程池,线程数怎么设置

工厂方法 问题
newFixedThreadPool、newSingleThreadExecutor 用的是 LinkedBlockingQueue,容量是 Integer.MAX_VALUE,任务积压会导致 OOM
newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,任务多时会创建大量线程
newScheduledThreadPool 延迟队列无界,同样可能 OOM

所以阿里巴巴 Java 开发手册要求用 ThreadPoolExecutor 显式指定参数:用有界队列、明确拒绝策略、给线程起名。

线程数的经验公式:

任务类型 线程数
CPU 密集 CPU 核数 + 1
IO 密集 CPU 核数 × (1 + 平均等待时间 / 平均计算时间)

公式只是起点,实际要压测,并观察队列长度、活跃线程数、任务耗时。生产中常把线程池参数做成可以动态调整的配置:ThreadPoolExecutor 提供了 setCorePoolSize、setMaximumPoolSize,可以运行时修改。

在容器里运行时,JDK 10 起 Runtime.availableProcessors() 能识别容器的 CPU 配额,更早的版本拿到的是宿主机的核数。

4.47 submit 和 execute 有什么区别,线程池怎么关闭

execute(Runnable) submit(Runnable/Callable)
返回值 无 Future
任务抛出异常 异常抛到线程上,交给 UncaughtExceptionHandler,默认打印到标准错误;执行它的工作线程会结束,线程池补一个新线程 异常被封装进 Future,不调用 future.get() 就看不到,异常被悄悄吞掉

用 submit 时要么调用 get() 处理异常,要么在任务内部 try-catch 并记录日志。

关闭方法 行为
shutdown() 不再接收新任务,已提交的任务(包括队列里的)继续执行完
shutdownNow() 不再接收新任务,中断正在执行的线程,返回队列里还没执行的任务
awaitTermination(timeout) 等待线程池真正结束

优雅关闭的做法:先 shutdown(),再 awaitTermination 等一段时间,超时后 shutdownNow()。线程池的状态依次是 RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED,和线程数一起存在一个 AtomicInteger 里(高 3 位是状态,低 29 位是线程数)。

4.48 ThreadLocal 的原理是什么,为什么会内存泄漏

每个 Thread 对象里有一个 ThreadLocalMap,键是 ThreadLocal 对象,值是这个线程的副本。threadLocal.get() 实际是「取当前线程的 Map,用 this 作键去查」。所以数据存在线程上,不同线程互不干扰。

ThreadLocalMap 用开放寻址法(线性探测)解决冲突,不是 HashMap。

内存泄漏的原因:

部分 引用类型 结果
键:ThreadLocal 对象 弱引用 外部不再引用这个 ThreadLocal 时,键被回收,变成 null
值 强引用 只要线程还活着,值就一直被 Map 引用着,回收不了

线程池里的线程会一直存活,键为 null 的条目就一直占着内存。ThreadLocalMap 在 get、set 时会顺带清理一部分这样的条目,但不保证清理干净。

键为什么用弱引用:这是一种兜底。键如果是强引用,ThreadLocal 对象本身也会跟着线程一直存活;用弱引用,外部不再使用这个 ThreadLocal 后键能被回收,ThreadLocalMap 在后续的 get、set、remove 中发现键为 null 的条目,就会清理它的值。即便如此,值仍然要等到下一次清理或者线程结束才释放,所以 remove() 不能省。

更常见的问题是数据串号:线程池复用线程,上一个请求放进去的用户信息没有清理,下一个请求读到了别人的数据。

try {
    USER.set(user);
    handle();
} finally {
    USER.remove();      // 必须在 finally 里清理
}

异步执行时,任务换到了另一个线程上,每个线程的 ThreadLocalMap 各自独立,所以默认情况下上下文不会跟着传过去。

相关类 用途
InheritableThreadLocal 创建子线程时把父线程的值复制过去;但线程池里的线程早已创建好,复制不到提交任务时的值
阿里的 TransmittableThreadLocal 在提交任务时捕获上下文、执行时恢复,解决线程池中传递链路追踪 ID 等上下文的问题
JDK 的 ScopedValue 不可变、有明确作用范围的上下文传递,适合虚拟线程。JDK 21 起预览,JDK 25 转正(JEP 506)

4.49 CountDownLatch、CyclicBarrier、Semaphore 有什么区别

CountDownLatch CyclicBarrier Semaphore
作用 一个或多个线程等待其他 N 个操作完成 N 个线程互相等待,全部到达后一起继续 限制同时访问资源的线程数
计数 只减不增,减到 0 后不能重置 全部到达后自动重置,可以循环使用 获取许可时减,释放时加
典型用法 主线程等所有子任务完成;服务启动时等依赖都就绪 多线程分阶段计算,每个阶段都要等所有线程完成 限流、连接池
实现 AQS 共享模式 ReentrantLock + Condition AQS 共享模式
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
    pool.execute(() -> {
        try { work(); } finally { latch.countDown(); }   // 放进 finally,否则异常时永远减不到 0
    });
}
latch.await(5, TimeUnit.SECONDS);                       // 设超时,避免永久等待

4.50 CompletableFuture 怎么用,有哪些坑

CompletableFuture 可以把异步任务串联、并联、组合起来,不用阻塞等待 Future.get()。

ExecutorService pool = Executors.newFixedThreadPool(8);   // 演示用,生产应显式创建线程池

CompletableFuture<User>  userF  = CompletableFuture.supplyAsync(() -> userService.get(uid), pool);
CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> orderService.latest(uid), pool);

CompletableFuture<Page> pageF = userF
        .thenCombine(orderF, Page::new)                    // 两个结果都到齐后合并
        .orTimeout(500, TimeUnit.MILLISECONDS)             // JDK 9 起支持
        .exceptionally(ex -> Page.fallback());             // 异常或超时时降级
Page page = pageF.join();
方法 作用
supplyAsync、runAsync 提交有返回值、无返回值的异步任务
thenApply、thenAccept、thenRun 上一步完成后处理结果
thenCompose 上一步的结果用来发起另一个异步调用,避免嵌套
thenCombine 合并两个独立任务的结果
allOf、anyOf 等待全部完成、任意一个完成
exceptionally、handle、whenComplete 异常处理
坑 说明
默认线程池 不传线程池时用 ForkJoinPool.commonPool(),并行度是 CPU 核数减一,整个 JVM 共享。在里面做阻塞 IO 会把它占满,拖慢所有用到它的地方,包括并行流。要为 IO 任务单独传一个线程池
异常被吞 没有 exceptionally、handle 或 join,异常就静默丢失
不设超时 某个依赖卡住,join() 就一直等
线程池嵌套死锁 外层任务在同一个有界线程池里同步等待内层任务,线程全被外层占满,内层永远排不上
上下文丢失 换了线程,ThreadLocal 里的用户信息、链路追踪 ID 不会自动传过去

4.51 死锁是怎么产生的,怎么排查和避免

死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。最常见的情况是两个线程以相反的顺序获取两把锁。

排查:

  1. jps 找到进程号。
  2. jstack <pid>,输出末尾会直接报告 Found one Java-level deadlock,并列出每个线程持有哪把锁、在等哪把锁。
  3. 也可以用 jconsole、VisualVM 的死锁检测,或 Arthas 的 thread -b。

避免:

做法 破坏的条件
所有线程按固定的全局顺序加锁,例如按账户 ID 从小到大 循环等待
用 tryLock(timeout),拿不到就释放已持有的锁,稍后重试 持有并等待
缩小锁的范围,持锁期间不调用外部方法和远程服务 降低发生的概率
一次性申请全部资源 持有并等待

数据库层面也会死锁,例如两个事务以不同顺序更新相同的两行。MySQL 会检测到并回滚其中一个事务,应用要能处理这种失败并重试。

4.52 虚拟线程是什么,适合什么场景

虚拟线程(JDK 21 转正,JEP 444)是 JVM 管理的轻量级线程,大量虚拟线程运行在少量的平台线程(载体线程,默认数量等于 CPU 核数)上。虚拟线程执行阻塞操作(网络 IO、sleep、等锁)时,JVM 把它从载体线程上卸下,载体线程转去执行别的虚拟线程,IO 完成后再挂回来继续执行。

平台线程 虚拟线程
对应关系 1:1 对应一个操作系统线程 M:N,由 JVM 调度
栈 固定大小,默认 1 MB 左右 按需增长,存在堆上
数量 几千个就很重 可以创建上百万个
适用 CPU 密集计算 大量阻塞 IO 的服务,例如每个请求都要调多个下游
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> callRemoteService());       // 每个任务一个虚拟线程
    }
}
注意点 说明
不要池化 创建成本很低,每个任务新建一个即可;要限制并发度时用 Semaphore
钉住(pinning) JDK 21 中,在 synchronized 块里阻塞、或调用本地方法时,虚拟线程无法卸载,会占住载体线程。JDK 24(JEP 491)解决了 synchronized 导致的钉住
不提升 CPU 密集任务 它提高的是并发数,不是计算速度
ThreadLocal 每个虚拟线程都有一份,大量虚拟线程时内存开销大,可以考虑 ScopedValue
Spring Boot 3.2 起设置 spring.threads.virtual.enabled=true,Tomcat 处理请求、@Async 等都改用虚拟线程

它和 Go 的 goroutine 思路相同:用同步的写法获得接近异步的并发能力,见 3.35 节。

4.53 怎么正确地停止一个线程,interrupt 是怎么工作的

Java 没有安全的强制停止线程的方法。Thread.stop() 会让线程在任意位置抛出异常并释放它持有的所有锁,被保护的数据可能停留在不一致的状态,早已过时,JDK 20 起调用它会直接抛 UnsupportedOperationException。正确的做法是协作式中断:通知线程该停了,由线程自己在合适的位置退出。

方法 作用
t.interrupt() 设置线程 t 的中断标志。如果 t 正阻塞在 sleep、wait、join 上,它会立即抛出 InterruptedException,同时清除中断标志
t.isInterrupted() 查询中断标志,不清除
Thread.interrupted() 静态方法,查询当前线程的中断标志,并且清除它
public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Task task = queue.take();          // 阻塞时被中断会抛 InterruptedException
            handle(task);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // 恢复中断标志,让上层也能知道
            break;
        }
    }
}
要点 说明
不要吞掉 InterruptedException catch 之后什么都不做,中断请求就丢失了。要么继续向上抛,要么调用 interrupt() 恢复标志
不响应中断的阻塞 synchronized 等锁、传统的 socket IO 不响应中断。等锁要可中断,用 lockInterruptibly();socket 要通过关闭它来唤醒
线程池 shutdownNow() 和 future.cancel(true) 都是通过 interrupt() 通知任务停止,任务代码不检查中断就停不下来
标志位 也可以用 volatile boolean running 控制循环,但它无法唤醒正阻塞在 sleep、take 上的线程

4.54 线程池里的线程是怎么复用的

线程池里的每个线程被包装成一个 Worker(它继承了 AQS 并实现了 Runnable)。线程启动后执行的是 runWorker 循环,而不是某一个具体的任务:

runWorker:
    task = 创建 Worker 时带进来的第一个任务
    while (task != null || (task = getTask()) != null):
        加 Worker 自己的锁(表示正忙,shutdown 时不会中断忙碌的线程)
        beforeExecute → task.run() → afterExecute
        task = null
    getTask 返回 null → 线程退出,从线程池移除

getTask:
    当前线程数 > corePoolSize,或者允许核心线程超时:
        queue.poll(keepAliveTime)   等待超时还没任务就返回 null,线程退出
    否则:
        queue.take()                一直阻塞等待,所以核心线程不会被回收

所以「复用」就是:线程执行完一个任务后不退出,而是回到循环里从阻塞队列取下一个任务。核心线程和非核心线程本身没有区别,只是当线程数超过核心数时,空闲的线程用带超时的 poll 取任务,超时就退出。

任务抛出未捕获的异常时,这个 Worker 线程会结束,线程池会新建一个线程补上,所以频繁抛异常会导致线程不断地被创建和销毁。

4.55 Java 中有哪些锁,乐观锁和悲观锁有什么区别

分类 说明 例子
悲观锁 认为一定会冲突,访问前先加锁 synchronized、ReentrantLock、SELECT ... FOR UPDATE
乐观锁 认为很少冲突,先修改,提交时检查是否被别人改过,冲突了就重试或失败 CAS、原子类、数据库的版本号:UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?
公平锁、非公平锁 是否按申请顺序获得锁 new ReentrantLock(true) 是公平锁
可重入锁 同一个线程可以重复获得 synchronized、ReentrantLock
读写锁 读读不互斥,读写、写写互斥 ReentrantReadWriteLock
自旋锁 拿不到锁时不挂起,而是循环重试 轻量级锁的自旋、CAS 重试循环
分段锁 把数据分段,每段一把锁 JDK 7 的 ConcurrentHashMap
选择 场景
乐观锁 读多写少,冲突少;重试的代价低
悲观锁 写多、冲突多;或者冲突后重试的代价很高

ReentrantReadWriteLock:读锁可以被多个线程同时持有;持有写锁的线程可以再获取读锁(锁降级),持有读锁时不能升级为写锁,否则会死锁。读多写少时,写线程可能长时间拿不到锁。

StampedLock(JDK 8):增加了乐观读。读之前拿一个版本戳,读完后校验期间有没有发生写;没有就直接使用,有就退回到加读锁再读一次。乐观读完全不加锁,适合读远多于写的场景。乐观读期间写线程仍然可以拿到写锁,所以读多写少时写线程不容易被饿死。它不基于 AQS,而是自己实现了一个类似 CLH 的等待队列;不可重入,也不支持 Condition。

4.56 ForkJoinPool 和工作窃取是什么

ForkJoinPool 用来执行可以递归拆分的任务:大任务拆成小任务(fork),小任务的结果再合并(join)。

工作窃取:每个工作线程有一个自己的双端队列。

操作 位置
线程把自己拆分出的子任务放进自己的队列 队头
线程从自己的队列取任务执行 队头,后进先出,最近拆出的任务数据还在缓存里
空闲线程从别的线程的队列偷任务 队尾,先进先出,偷到的通常是较大的任务,减少和队列主人的竞争

这样负载会自动在线程之间平衡,不需要一个全局的共享队列。

class SumTask extends RecursiveTask<Long> {
    private final long[] a; private final int lo, hi;
    SumTask(long[] a, int lo, int hi) { this.a = a; this.lo = lo; this.hi = hi; }
    @Override protected Long compute() {
        if (hi - lo <= 10_000) {
            long s = 0; for (int i = lo; i < hi; i++) s += a[i]; return s;
        }
        int mid = (lo + hi) >>> 1;
        SumTask left = new SumTask(a, lo, mid);
        left.fork();                                   // 左半边交给别的线程
        return new SumTask(a, mid, hi).compute() + left.join();
    }
}
long sum = ForkJoinPool.commonPool().invoke(new SumTask(arr, 0, arr.length));

并行流 parallelStream() 和不指定线程池的 CompletableFuture 都使用 ForkJoinPool.commonPool(),它是整个 JVM 共享的,适合 CPU 密集的计算,不要在里面做阻塞 IO。

4.57 什么是伪共享,Java 里怎么避免

CPU 缓存以缓存行(通常 64 字节)为单位读写。两个线程分别修改同一个缓存行里的两个不同变量,虽然逻辑上互不相关,但每次写都会让对方核上的这个缓存行失效,缓存行在核之间来回传递,性能大幅下降。

Java 中的做法:

做法 说明
手工填充 在热点字段前后各加 7 个 long 字段,让它独占一个缓存行。早期的 Disruptor 就是这样做的
@Contended 注解 JDK 8 起提供,JDK 9 起位于 jdk.internal.vm.annotation。JVM 自动在字段前后填充。JDK 内部的类可以直接使用;应用代码使用时要加 -XX:-RestrictContended,并且要用 --add-exports 开放这个包

JDK 自己在这些地方用了 @Contended:LongAdder 的 Cell、Thread 里的随机数种子字段、ConcurrentHashMap 的 CounterCell。

伪共享只在高频写、多核竞争的场景下才明显,例如计数器、队列的头尾指针。不要到处加填充,它会浪费内存、降低缓存利用率。

4.58 JVM 的内存区域是怎么划分的

区域 线程共享 存放 可能的异常
程序计数器 否 当前线程正在执行的字节码地址 唯一不会 OOM 的区域
虚拟机栈 否 栈帧:局部变量表、操作数栈、动态链接、返回地址 递归太深抛 StackOverflowError
本地方法栈 否 本地方法(native)使用的栈 同上
堆 是 几乎所有对象实例和数组;JDK 7 起字符串常量池、静态变量也在堆上 OutOfMemoryError: Java heap space
方法区 是 类的元数据、运行时常量池、JIT 编译后的代码 OutOfMemoryError: Metaspace
直接内存 是 NIO 的 DirectByteBuffer,不属于 JVM 规范定义的区域 OutOfMemoryError: Direct buffer memory

方法区的实现变化:JDK 7 及以前用「永久代」,放在堆里,大小固定,经常因为动态生成的类太多而 OOM;JDK 8 起改为元空间(Metaspace),使用本地内存,默认上限只受机器内存限制,通常用 -XX:MaxMetaspaceSize 设置上限。

堆按分代划分为新生代(Eden 和两个 Survivor 区,默认比例 8:1:1)和老年代。G1 和 ZGC 把堆划分成很多个区域(Region),分代只是逻辑上的概念。

4.59 对象是怎么创建的,对象的内存布局是怎样的

创建过程:

  1. 类加载检查:检查类是否已加载,没有就先加载。
  2. 分配内存:堆内存规整时用「指针碰撞」,不规整时用「空闲列表」。为了避免多线程同时分配时的竞争,每个线程在 Eden 里预先分到一小块私有区域(TLAB),分配时只需移动自己的指针。
  3. 零值初始化:字段先设为 0、null、false。
  4. 设置对象头:类型指针、哈希码、GC 分代年龄、锁信息。
  5. 执行构造函数 <init>。

内存布局(64 位 HotSpot,开启压缩指针,这是 32 GB 以下堆的默认设置):

┌─────────────────────┬───────────────────┬──────────────────┬─────────────┬──────┐
│ Mark Word 8 字节     │ 类型指针 4 字节    │ 数组长度 4 字节   │ 实例数据     │ 填充  │
│ 哈希码、分代年龄、锁 │ 指向类的元数据     │ 仅数组对象有      │ 各个字段     │ 补齐到 8 字节的倍数 │
└─────────────────────┴───────────────────┴──────────────────┴─────────────┴──────┘

new Object() 占 16 字节:对象头 12 字节,再填充到 8 的倍数。JDK 25 的紧凑对象头(JEP 519,需要用参数开启)把对象头压缩到 8 字节,大量小对象的程序能省下明显的内存。

访问方式:HotSpot 用直接指针,引用里存的就是对象地址;另一种方式是句柄,引用指向句柄池,句柄再指向对象,对象移动时只需改句柄,但访问多一次跳转。

4.60 类加载的过程是怎样的,什么是双亲委派模型

类的生命周期:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。其中验证、准备、解析三步合称连接。

阶段 做什么
加载 根据类名找到字节码(文件、jar、网络、动态生成),在方法区生成类的元数据,在堆上生成对应的 Class 对象
验证 检查字节码格式和语义是否合法、是否安全
准备 给静态变量分配内存,赋零值。static int a = 5; 此时 a 是 0;static final 的编译期常量在这一步直接赋值
解析 把常量池里的符号引用替换成直接引用(内存地址或偏移)
初始化 执行类构造器 <clinit>,也就是静态变量赋值语句和静态代码块;JVM 保证它在多线程下只执行一次

类加载器(JDK 9 及以后):

加载器 负责
启动类加载器(Bootstrap) 核心模块,例如 java.base,由 C++ 实现
平台类加载器(Platform) 其他 JDK 模块;JDK 8 及以前是扩展类加载器
应用类加载器(Application) classpath 上的应用代码

双亲委派:类加载器收到加载请求时,先交给父加载器去加载,父加载器加载不了,才由自己加载。

好处 说明
安全 自己写一个 java.lang.String 也不会替换掉核心类
避免重复加载 同一个类只由一个加载器加载。JVM 里「类的唯一标识」是「类加载器 + 全限定类名」

打破双亲委派的场景:

场景 原因
Tomcat 每个 Web 应用一个类加载器,优先加载应用自己的类,这样不同应用可以使用同一个库的不同版本
JDBC 等 SPI 接口在核心库里,由启动类加载器加载,但实现在应用的 classpath 上,父加载器看不到。于是用线程上下文类加载器去加载实现
热部署、OSGi、各种插件框架 每个模块有独立的类加载器,可以单独卸载和重新加载

打破的方法是继承 ClassLoader 并重写 loadClass;只是想自定义类的查找位置时,重写 findClass 就够了,不会破坏双亲委派。

4.61 怎么判断一个对象可以被回收,GC Roots 有哪些

方法 说明 问题
引用计数 被引用一次计数加一,计数为 0 就回收 无法处理循环引用:两个对象互相引用,计数都不为 0
可达性分析 从一组 GC Roots 出发沿着引用遍历,遍历不到的对象就是垃圾 HotSpot 采用这种方法

GC Roots 包括:

来源 例子
虚拟机栈中的引用 各个线程当前栈帧里的局部变量、方法参数
方法区中的引用 类的静态变量、常量
本地方法栈中的引用 JNI 引用
JVM 内部的引用 基本类型对应的 Class 对象、常驻的异常对象、系统类加载器
被 synchronized 持有的对象 正在作为锁的对象

4.62 强引用、软引用、弱引用、虚引用有什么区别

引用类型 什么时候被回收 用途
强引用 只要还能从 GC Roots 到达,就不会被回收 普通的 Object o = new Object()
软引用 SoftReference 内存不足、即将 OOM 时回收 内存敏感的缓存
弱引用 WeakReference 下一次 GC 时就回收 WeakHashMap、ThreadLocalMap 的键;监听器、缓存的键
虚引用 PhantomReference 不影响回收,get() 永远返回 null 对象被回收后收到通知,用来清理堆外资源,Cleaner 和 DirectByteBuffer 的内存释放基于它

后三种都可以关联一个 ReferenceQueue,引用的对象被回收后,引用本身会被放进队列,程序可以据此做清理工作。

用软引用做缓存要谨慎:堆接近满时才回收,会导致 GC 频繁且时间长;生产中更多用 Caffeine 这类按容量和时间淘汰的缓存。

4.63 垃圾回收算法有哪些,为什么要分代

算法 做法 优点 缺点
标记-清除 标记存活对象,清除其余对象 简单,不移动对象 产生内存碎片
标记-复制 把内存分成两半,存活对象复制到另一半 没有碎片,分配快 浪费一部分空间;存活对象多时复制开销大
标记-整理 标记后把存活对象移到一端 没有碎片 移动对象的开销大,要更新所有引用

分代收集的依据是「大部分对象朝生夕死」:

区域 特点 算法
新生代 绝大多数对象很快死亡,存活的很少 复制。Eden 和一个 Survivor 区里的存活对象复制到另一个 Survivor 区,所以只需预留 10% 的空间
老年代 存活时间长,存活率高 标记-清除或标记-整理

对象什么时候进入老年代:

条件 说明
年龄达到阈值 每熬过一次 Minor GC 年龄加一,达到 -XX:MaxTenuringThreshold(默认 15,年龄在对象头里只占 4 位)就晋升
动态年龄判定 Survivor 区中某个年龄及以下的对象总大小超过 Survivor 的一半,大于等于这个年龄的对象直接晋升
大对象 需要大块连续内存的对象(例如大数组)可以直接在老年代分配
Survivor 放不下 Minor GC 后存活对象太多,超出的部分直接进入老年代

跨代引用:Minor GC 只扫描新生代,但老年代的对象可能引用新生代的对象。JVM 用卡表记录老年代中哪些区域有指向新生代的引用,Minor GC 时只需额外扫描这些区域,不必扫描整个老年代。

4.64 常见的垃圾收集器有哪些,怎么选

收集器 区域 特点 状态
Serial、Serial Old 新生代、老年代 单线程,停顿期间所有业务线程暂停(Stop The World) 适合客户端、小内存
Parallel Scavenge、Parallel Old 新生代、老年代 多线程并行回收,目标是吞吐量 JDK 8 的默认收集器
CMS 老年代 并发标记、并发清除,停顿短;产生碎片,并发回收失败时退化成单线程的 Full GC JDK 9 起过时,JDK 14 移除(JEP 363)
G1 整个堆 把堆分成很多 Region,优先回收垃圾最多的 Region,可以设定停顿时间目标 JDK 9 起的默认收集器
ZGC 整个堆 几乎所有阶段都并发,停顿通常在 1 毫秒以下,且不随堆大小增长 JDK 15 可用于生产;JDK 21 支持分代(JEP 439);JDK 24 起只保留分代模式
Shenandoah 整个堆 低停顿,并发整理 Red Hat 开发,OpenJDK 包含
场景 选择
一般的服务,堆在几 GB 到几十 GB G1,默认就是
对延迟非常敏感,或者堆非常大 分代 ZGC
批处理、离线计算,只看吞吐量 Parallel
很小的堆、单核容器 Serial

4.65 G1 是怎么工作的

概念 说明
Region 堆被划分成大小相等的区域(1 MB 到 32 MB),每个 Region 动态地充当 Eden、Survivor 或老年代
Humongous 超过 Region 一半大小的对象,单独占用一个或多个连续的 Region
记忆集(Remembered Set) 每个 Region 记录「哪些别的 Region 引用了我」,回收某些 Region 时不必扫描整个堆
停顿时间目标 -XX:MaxGCPauseMillis,默认 200 ms。G1 根据历史数据估算每个 Region 的回收收益和耗时,在目标时间内优先回收收益最高的 Region,「Garbage First」由此得名
回收类型 做什么
Young GC 回收所有 Eden 和 Survivor Region,存活对象复制到新的 Survivor 或老年代 Region
并发标记 老年代占用超过阈值(-XX:InitiatingHeapOccupancyPercent,默认 45%)时启动,大部分阶段和业务线程并发执行,使用 SATB(开始时快照)算法保证标记正确
Mixed GC 回收所有新生代 Region,再加上一部分垃圾最多的老年代 Region
Full GC 回收速度跟不上分配速度时发生,停顿很长。JDK 10 起改为多线程执行,但仍应避免

调优时一般只设堆大小和停顿时间目标,不要再手动设新生代大小(-Xmn),否则 G1 无法通过调整新生代大小来满足停顿时间目标。

4.66 ZGC 为什么停顿时间这么短

ZGC 把标记、对象转移(整理)、引用更新几乎全部放到并发阶段,只在几个很短的阶段暂停业务线程,停顿时间只和 GC Roots 的数量有关,与堆的大小无关。

技术 作用
染色指针 把对象的 GC 状态(是否标记、是否已转移)编码在 64 位引用的高位上,不用修改对象本身就能知道它的状态
读屏障 每次从堆里读取引用时执行一小段检查:如果对象已经被搬走,就修正为新地址,并更新这个引用。所以业务线程运行的同时可以搬动对象
并发转移 对象搬迁和业务线程同时进行,旧地址到新地址的映射记在转发表里

代价是读屏障带来的吞吐量损失,以及需要预留更多内存,因为回收和分配同时进行。

JDK 21 起支持分代(-XX:+UseZGC -XX:+ZGenerational),JDK 23 起分代成为默认模式,JDK 24 移除了不分代的模式。分代后回收效率明显提升,内存开销更低。

用 ZGC 时调优的重点:要调的参数很少,最重要的是给足堆的余量。分配速度超过并发回收的速度时会出现分配停顿(Allocation Stall),业务线程只能等 GC,这时的停顿可能比 G1 还长,要在 GC 日志或 JFR 里监控这个事件。ZGC 不支持压缩普通对象指针,同样的数据比 G1 占用更多内存;读屏障和并发 GC 线程也会多消耗 CPU。所以调优的重点从调参数变成了容量规划和监控。

4.67 Minor GC 和 Full GC 分别什么时候触发

Minor GC(Young GC) Full GC
范围 新生代 整个堆加方法区
触发 Eden 区满了 老年代空间不足;元空间不足;调用 System.gc()(可以用 -XX:+DisableExplicitGC 禁用);新生代晋升失败;G1 中回收速度跟不上分配速度;jmap -dump:live 等工具触发
停顿 短,通常几毫秒到几十毫秒 长,可能达到秒级

频繁 Full GC 的常见原因:

原因 排查方向
内存泄漏,老年代被占满 导出堆转储,看是什么对象占用了内存
堆太小 结合流量判断是否需要扩大堆
大对象太多 一次查询太多数据、一次读入大文件
元空间被打满 反射、动态代理、Groovy 等脚本在运行时生成大量的类
代码里调用了 System.gc() 某些依赖库(例如 NIO 的直接内存分配)会调用

查看 GC 情况:jstat -gcutil <pid> 1000 每秒输出各区域使用率和 GC 次数;用 -Xlog:gc*:file=gc.log(JDK 9 起的统一日志)记录 GC 日志,再用 GCeasy 等工具分析。

4.68 线上出现 OOM 怎么排查

先根据错误信息判断是哪一块内存不足:

错误信息 原因
Java heap space 堆不够:内存泄漏,或者数据量确实太大
GC overhead limit exceeded 大部分时间都在 GC,但每次回收的内存很少,本质也是堆不够
Metaspace 加载的类太多
Direct buffer memory 直接内存超过 -XX:MaxDirectMemorySize,常见于 Netty 的堆外内存没有释放
unable to create native thread 线程数达到操作系统或容器的上限
进程被系统杀掉,没有 Java 层面的错误 容器内存限制小于「堆 + 元空间 + 直接内存 + 线程栈」的总和,被 OOM Killer 杀掉

排查步骤:

  1. 启动参数预先加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path,OOM 时自动导出堆转储。
  2. 没有自动转储时,用 jmap -dump:live,format=b,file=heap.hprof <pid> 或 jcmd <pid> GC.heap_dump 手动导出。注意 live 会先触发一次 Full GC。
  3. 用 MAT(Eclipse Memory Analyzer)或 VisualVM 打开,看「支配树」里占用内存最多的对象,再看它到 GC Roots 的引用链,就能找到是谁在持有它。
  4. 现场排查可以用 Arthas:dashboard 看整体情况,heapdump 导出,vmtool 查看某个类的实例。

常见的内存泄漏原因:

  • 静态集合只放不删。
  • 缓存没有上限,也不过期。
  • ThreadLocal 没有 remove。
  • 监听器注册后没有注销。
  • 连接、流没有关闭。
  • 一次查询返回了太多数据。

完整的泄漏来源和做法见 4.71 节。

4.70 常用的 JVM 参数和排查工具有哪些,GC 调优的思路是什么

参数 作用
-Xms、-Xmx 初始堆和最大堆,生产中一般设为相同的值,避免堆伸缩
-Xss 每个线程的栈大小
-XX:MaxMetaspaceSize 元空间上限
-XX:MaxDirectMemorySize 直接内存上限
-XX:+UseG1GC、-XX:+UseZGC 选择收集器
-XX:MaxGCPauseMillis G1 的停顿时间目标
-XX:MaxRAMPercentage=75.0 在容器里按容器内存的百分比设置堆大小,替代固定的 -Xmx
-XX:+HeapDumpOnOutOfMemoryError OOM 时导出堆转储
-Xlog:gc*:file=gc.log GC 日志

JDK 自带的命令行工具:

工具 用途
jps -l 列出 Java 进程和主类
jstat -gcutil <pid> 1000 每秒输出各区域的使用率和 GC 次数
jinfo -flags <pid> 查看 JVM 参数;少数可管理的参数可以在运行时修改
jmap -histo:live <pid>、jmap -dump 对象直方图、堆转储
jstack <pid> 线程栈,排查死锁、CPU 高
jcmd 上面这些功能的统一入口:GC.heap_info、GC.class_histogram、GC.heap_dump、Thread.print、VM.native_memory、JFR.start
JFR 和 JMC 低开销的运行记录,可以在生产环境长期开启,用 JMC 分析

jhat 在 JDK 9 已被移除,堆转储改用 MAT 或 VisualVM 分析。

调优的思路:

  1. 先确认是 GC 的问题。 看 GC 日志里的停顿时间、频率、GC 耗时占总时间的比例。很多「性能问题」的根源是慢 SQL 或锁竞争。
  2. 先查代码和数据。 是否存在内存泄漏,是否一次加载了太多数据,是否频繁创建大对象。做法见 4.71 节。
  3. 再调参数。
    • 先选合适的收集器:一般服务用 G1,延迟敏感的用 ZGC。
    • 设置合理的堆大小,保证一次 Full GC 后老年代的占用不超过堆的一半左右。
    • G1 一般只调停顿时间目标。
  4. 改一个参数、压测一次、对比一次。 每次只改一处,用数据验证效果。

常见现象和处理:

现象 常见原因 做法
Young GC 太频繁 对象分配太快 加大堆;减少热点路径上的临时对象,见 4.71 节
Young GC 停顿长 每次存活下来的对象多,复制开销大 检查本地缓存、大集合是否在新生代里反复存活,是否一次把大批数据加载进内存
老年代增长很快 过早晋升;大对象 调整 Survivor 区大小和晋升年龄(-XX:MaxTenuringThreshold);G1 下排查 Humongous 对象
G1 出现 Full GC 疏散失败(to-space exhausted),并发标记跟不上分配 调低 -XX:InitiatingHeapOccupancyPercent、增加 -XX:ConcGCThreads、加大堆;排查 Humongous 对象
元空间导致 Full GC 或 OOM 类泄漏:反复动态生成代理类、脚本引擎反复编译、类加载器泄漏 设置 -XX:MaxMetaspaceSize;用 jcmd <pid> VM.classloader_stats 排查
System.gc() 触发 Full GC RMI 的分布式 GC;NIO 在直接内存不足时主动调用 用 -XX:+ExplicitGCInvokesConcurrent 改成并发回收。不要用 -XX:+DisableExplicitGC,直接内存的回收依赖这次调用
每次 Full GC 后老年代的占用持续上升 内存泄漏 堆转储加 MAT,见 4.68 节、4.71 节
容器被杀,没有 Java 的 OOM 堆外内存超限:线程栈、直接内存、元空间、代码缓存、glibc 的 malloc arena 用 -XX:NativeMemoryTracking=summary 加 jcmd <pid> VM.native_memory summary 排查;降低 MaxRAMPercentage;设置环境变量 MALLOC_ARENA_MAX=2
停顿时间远大于 GC 本身的耗时 等待所有线程到达安全点的时间长,例如 JIT 去掉了很长的计数循环里的安全点检查 用 -Xlog:safepoint 查看

例子:G1 的 Humongous 对象引起 Full GC。 堆为 4 GB 时,G1 自动选择的 Region 大小是 2 MB(目标是 2048 个左右的 Region,实现相关),超过 1 MB 的对象就是 Humongous 对象,直接占用老年代的 Region。服务每个请求都把约 3 MB 的 JSON 读成 byte[],这些对象很快占满老年代,并发标记来不及完成,退化成 Full GC。临时的办法是 -XX:G1HeapRegionSize=8m,让 3 MB 的数组变成普通对象;根本的办法是流式解析,不把整个响应读进内存。

4.71 怎么防止内存泄漏,怎么减少 GC 的压力

Java 的内存泄漏是指对象已经不再使用,但仍然能从 GC Roots 到达,GC 无法回收。GC 的开销由两个量决定:分配得有多快,决定 GC 多频繁;存活的对象有多少,决定每次 GC 要标记和复制多少。减少 GC 压力也分这两个方向。

常见的泄漏来源:

来源 原因 做法
静态集合当缓存用 static Map 只放不删,随运行时间增长 改用有容量上限和过期时间的缓存,例如 Caffeine
ThreadLocal 没有 remove 线程池的线程一直存活,它的 ThreadLocalMap 强引用着值 在 finally 里调用 remove(),见 4.48 节
监听器、回调没有注销 事件源一直持有监听器,监听器又引用着大对象 在 close 或销毁回调里注销;或者让事件源用弱引用持有监听器
资源没有关闭 流、连接、ResultSet 占着直接内存和文件描述符 用 try-with-resources
非静态内部类、匿名类、Lambda 隐式持有外部类的实例;被长期保存时,外部对象也回收不掉 改成静态嵌套类;Lambda 里不引用外部实例的字段
放进 HashSet 后修改了参与 hashCode 的字段 哈希值变了,remove 找不到它,对象一直留在集合里 作为键的对象设计成不可变的
无界队列 生产快于消费时任务无限积压,Executors.newFixedThreadPool 用的就是无界队列 用有界队列并设置拒绝策略,见 4.46 节
定时任务没有取消 ScheduledExecutorService、Timer 里的任务一直持有它引用的对象 保存返回的 Future,不再需要时调用 cancel
日志的 MDC 没有清理 和 ThreadLocal 是同一个问题 在请求结束处统一调用 MDC.clear()
类加载器泄漏 热部署时,旧的类加载器被应用启动的线程、JDBC 驱动的注册表、ThreadLocal 里的值引用着,它加载的类全部无法卸载;在循环里动态生成类(CGLIB、Groovy 脚本)会撑满元空间 应用停止时停掉自己创建的线程、注销 JDBC 驱动;缓存动态生成的类
Netty 的 ByteBuf 没有 release 池化的直接内存靠引用计数回收 遵守「最后一个使用者负责释放」的约定;测试环境开启 -Dio.netty.leakDetection.level=paranoid

发现泄漏的方法见 4.68 节:看每次 Full GC 后老年代的占用是否持续上升,再用堆转储找到引用链。

减少对象分配:

办法 说明
热点路径上避免自动装箱 List<Integer>、Map<Long, Long> 的每个元素都是一个对象。数据量大时改用 int[],或 fastutil、Eclipse Collections 这类原始类型集合;流计算用 IntStream 代替 Stream<Integer>
集合预先指定容量 new ArrayList<>(n);HashMap 的初始容量设为 n / 0.75 + 1,JDK 19 起可以直接用 HashMap.newHashMap(n)。避免扩容时反复复制数组
字符串拼接 循环里用 StringBuilder,并预估容量
日志 用占位符 log.debug("x={}", x);参数本身计算成本高时,先判断 isDebugEnabled()
流式处理,不一次性加载 JDBC 设置 fetchSize 或分页查询;大 JSON 用 Jackson 的流式 API 解析;大文件按块读
避免巨大的临时数组 G1 中超过 Region 一半大小的对象是 Humongous 对象,直接占用老年代的 Region,见 4.65 节
复用缓冲区 热点路径上用 ThreadLocal 保存 byte[]、StringBuilder 重复使用,用完重置
让 JIT 能做逃逸分析 方法写小,便于内联;没有逃逸的对象可以被标量替换,不在堆上分配,见 4.72 节

对象池要慎用。在 TLAB 里分配一个小对象只是移动一下指针,回收一个已经死亡的新生代对象几乎没有成本。把廉价的小对象放进池里,反而让它们长期存活、晋升到老年代,还会增加老年代指向新生代的引用,使 Minor GC 变慢。对象池只适合创建成本高的对象,例如数据库连接、线程、大块的直接内存缓冲区。

减少长期存活的数据:

办法 说明
本地缓存设置合理的上限 缓存不是越大越好。很大的缓存可以放到堆外(OHC、Chronicle Map),或者交给 Redis
用紧凑的数据结构 原始类型数组代替包装类集合;BitSet 代替 Set<Integer>;枚举代替字符串常量
字符串去重 -XX:+UseStringDeduplication 让内容相同的 String 共用同一个底层数组。JDK 8u20 起 G1 支持,JDK 18 起扩展到其他收集器
堆不超过约 32 GB 这个范围内 JVM 使用压缩指针,每个引用占 4 字节;超过后变成 8 字节,同样的数据占用更多内存(实现相关)
不让短命对象活得太久 请求级别的对象被放进异步队列或长期存活的集合,会熬过多次 Minor GC,最终晋升到老年代
升级 JDK JDK 9 的 Compact Strings 让只含 Latin-1 字符的字符串每个字符占 1 字节;JDK 25 的紧凑对象头(JEP 519,-XX:+UseCompactObjectHeaders)把 64 位 JVM 的对象头从 12 字节缩小到 8 字节

4.72 JIT 编译是怎么工作的,逃逸分析是什么

HotSpot 一开始解释执行字节码,同时统计每个方法的调用次数和循环次数。达到阈值的热点代码交给 JIT 编译器编译成本地机器码。

编译器 特点
C1 编译快,优化少
C2 编译慢,但根据运行时收集的信息做激进优化,生成的代码质量高
分层编译(JDK 8 起默认) 热点代码先由 C1 编译,同时收集更多运行信息;特别热的代码再交给 C2 重新编译

常见的 JIT 优化:

优化 说明
方法内联 把被调用的小方法直接展开到调用处,这是其他很多优化的前提
逃逸分析 分析对象是否会被方法外部或其他线程访问
标量替换 没有逃逸的对象不在堆上分配,而是拆成几个局部变量放进寄存器或栈里,减少 GC 压力
锁消除 没有逃逸的对象上的锁可以直接去掉
去虚拟化 运行时发现某个虚方法调用点只出现过一种实现,就当作普通调用内联;之后加载了新的子类时再退回(去优化)

这也是为什么压测时要先预热:程序刚启动时代码还在解释执行,性能比稳定运行时差很多。启动速度敏感的场景可以考虑 AOT,例如 GraalVM Native Image,或 JDK 24 起的 AOT 缓存(Project Leyden)。

4.73 什么是 IoC 和依赖注入,有哪几种注入方式

IoC(控制反转):对象不再自己 new 依赖,而是由容器创建对象、管理对象之间的依赖关系,再把依赖交给它。类 A 依赖类 B 时,传统写法在 A 里 new B();用 IoC 后,A 只声明自己需要 B,B 由容器创建并交给 A。「控制」指创建和装配对象的权力,「反转」指这个权力从业务代码交给了容器。**依赖注入(DI)**是实现 IoC 最主要的方式。

概念 层次 含义
DIP(依赖倒置原则) 设计原则 高层模块和低层模块都依赖抽象,不直接互相依赖
IoC(控制反转) 设计原则 对象的创建、装配,以及程序的执行流程,交给框架控制
DI(依赖注入) 实现方式 容器通过构造函数、setter 或字段把依赖传进来
依赖查找 实现方式 对象主动向容器要依赖:getBean、ObjectProvider、@Lookup

好处是解耦:业务类只依赖接口,具体用哪个实现由容器配置决定;测试时可以方便地换成模拟对象;对象的生命周期、单例、AOP 代理也都由容器统一管理。

注入方式 写法 特点
构造函数注入(推荐) 依赖作为构造函数参数,只有一个构造函数时可以省略 @Autowired 依赖可以是 final 的;对象创建出来就是完整可用的;依赖太多时构造函数会很长,提示这个类职责过多
Setter 注入 在 setter 上加 @Autowired 适合可选的依赖
字段注入 在字段上加 @Autowired 写起来最短;但字段不能是 final,不用 Spring 容器就无法给它赋值,单元测试不方便,也容易掩盖循环依赖
@Service
@RequiredArgsConstructor           // Lombok 为 final 字段生成构造函数
public class OrderService {
    private final UserRepository userRepository;
    private final PaymentClient paymentClient;
}

4.74 @Autowired 和 @Resource 有什么区别

@Autowired @Resource @Inject
来源 Spring Jakarta Annotations(JSR-250)。JDK 8 及以前在 javax.annotation,Spring Boot 3 起是 jakarta.annotation Jakarta Dependency Injection(JSR-330),jakarta.inject,需要引入依赖
匹配顺序 先按类型;同一类型有多个 Bean 时,再按参数名或字段名匹配 Bean 名 先按名字(name 属性,默认是字段名),找不到再按类型 同 @Autowired
指定 Bean 配合 @Qualifier("name") @Resource(name = "...") 配合 @Named("name")
可选依赖 @Autowired(required = false) 不支持 没有 required 属性
能用在构造函数上 能 不能,只能用在字段和 setter 上 能

同一个接口有多个实现时,还可以在其中一个实现上加 @Primary 作为默认选择;或者注入 List<接口>、Map<String, 接口> 拿到全部实现,这是实现策略模式的常见写法。

4.75 Bean 有哪些作用域,单例 Bean 是线程安全的吗

作用域 说明
singleton(默认) 整个容器只有一个实例
prototype 每次获取都创建新实例,容器不负责销毁
request 每个 HTTP 请求一个实例
session 每个 HTTP 会话一个实例
application 每个 ServletContext 一个实例
websocket 每个 WebSocket 会话一个实例

单例 Bean 本身不是线程安全的,Spring 不做任何同步。它安全与否,取决于有没有可变的共享状态:

情况 是否安全
无状态,只有注入的依赖和局部变量(大部分 Service、Controller 都是这样) 安全
有可变的成员变量,例如计数器、缓存了当前用户的字段 不安全

有状态时的做法:把状态改成方法的局部变量;用 ThreadLocal;用并发集合或原子类;或者把作用域改成 prototype、request。

把 prototype 的 Bean 注入到单例 Bean 里,只会注入一次,之后一直是同一个实例。每次都要新实例时,可以注入 ObjectProvider<T> 再调用 getObject(),或者使用 @Lookup 方法。

prototype 只是每次获取都新建实例,本身不提供线程安全:同一个实例被多个线程共享时,照样会有竞争。Spring 5 去掉了 Portlet 支持,globalSession 作用域也随之移除。

Spring 的单例和 GoF 的单例模式不同:GoF 单例由类自己保证一个类加载器里只有一个实例;Spring 的单例是一个容器里每个 Bean 定义只有一个实例,同一个类可以注册成多个单例 Bean(例如两个 DataSource),也仍然可以自己 new。

4.77 Spring 怎么解决循环依赖,为什么需要三级缓存

A 依赖 B,B 又依赖 A。Spring 对单例 Bean 的字段注入或 setter 注入,用三级缓存解决:

缓存 存放
一级 singletonObjects 完全初始化好的 Bean
二级 earlySingletonObjects 提前暴露出来的、还没初始化完的 Bean
三级 singletonFactories 能生成提前引用的工厂(ObjectFactory)
1. 创建 A:实例化后,把「生成 A 的提前引用」的工厂放进三级缓存
2. 给 A 填充属性,发现依赖 B → 去创建 B
3. 创建 B:实例化,填充属性时发现依赖 A
4. 查缓存:一级、二级都没有,三级有 A 的工厂 → 调用工厂得到 A 的提前引用,
   放入二级缓存,从三级缓存删除,注入给 B
5. B 初始化完成,放入一级缓存
6. 回到 A,注入 B,A 初始化完成,放入一级缓存

为什么要三级,二级不够吗:如果 A 需要 AOP 代理,注入给 B 的应该是 A 的代理对象,而不是原始对象。但正常情况下代理要到初始化完成之后才生成。三级缓存里放的是工厂,只有真的发生循环依赖、有人提前要 A 时,才调用工厂(getEarlyBeanReference)提前生成代理;没有循环依赖时,代理仍然按正常流程在最后生成。只用二级缓存的话,就必须在实例化之后立刻为所有 Bean 生成代理,打乱了正常的生命周期。

解决不了的情况:

情况 原因
构造函数注入的循环依赖 实例化之前就需要对方,此时还没有可以提前暴露的对象。可以在其中一个参数上加 @Lazy,先注入一个代理
prototype 作用域 不缓存,每次都创建新对象,会无限循环
涉及 @Async 的 Bean @Async 的代理不走提前引用这条路,B 拿到的是原始对象,最后启动时报错

Spring Boot 2.6 起默认禁止循环依赖,启动时直接报错,需要显式设置 spring.main.allow-circular-references=true 才能放开。官方的态度是:循环依赖说明设计有问题,应该拆分或重构。

4.78 AOP 是怎么实现的

AOP(面向切面编程)把日志、事务、权限、监控这类横切逻辑从业务代码里抽出来,集中写在切面里。

概念 说明
切面(Aspect) 横切逻辑的模块,例如日志切面
切点(Pointcut) 在哪些方法上生效,例如 execution(* com.x.service.*.*(..)) 或 @annotation(com.x.Log)
通知(Advice) 在什么时机做什么:@Before、@AfterReturning、@AfterThrowing、@After、@Around
连接点(Join Point) 程序执行中可以插入切面的点。Spring AOP 只支持方法调用
织入(Weaving) 把切面应用到目标对象上,Spring 在运行时通过生成代理完成

实现:容器创建 Bean 时,BeanPostProcessor(AnnotationAwareAspectJAutoProxyCreator)检查这个 Bean 的方法是否匹配某个切点,匹配就生成代理对象替换原对象放进容器。调用方拿到的是代理,代理按顺序执行通知链,最后调用目标方法。代理用 JDK 动态代理或 CGLIB 生成,见 4.16 节。

Spring AOP AspectJ
织入时机 运行时生成代理 编译期或类加载期修改字节码
连接点 只有方法调用 方法、构造函数、字段访问等
自调用 不生效 生效
final、static、private 方法,构造函数,字段访问 不能增强 能
不由 Spring 管理的对象 不能增强 能,例如配合 @Configurable

自调用问题:同一个类里,方法 A 直接调用方法 B,走的是 this.B(),绕过了代理,B 上的 @Transactional、@Async、@Cacheable 都不会生效。解决办法:把 B 移到另一个 Bean;或者注入自己(加 @Lazy);或者开启 exposeProxy 后用 AopContext.currentProxy() 调用。

4.79 Spring 事务是怎么实现的,有哪些传播行为

实现:@Transactional 也是通过 AOP 实现的。代理里的 TransactionInterceptor 在方法执行前通过 PlatformTransactionManager 开启事务:从数据源拿一个连接,设置 autoCommit=false,再通过 TransactionSynchronizationManager 把这个连接绑定到当前线程(ThreadLocal)。方法里 MyBatis、JdbcTemplate 执行 SQL 时,从当前线程取到同一个连接。方法正常返回就提交,抛出需要回滚的异常就回滚。

传播行为:一个事务方法调用另一个事务方法时,被调用的方法如何对待已有的事务。

传播行为 当前有事务 当前没有事务
REQUIRED(默认) 加入当前事务 新建事务
REQUIRES_NEW 挂起当前事务,新建一个独立的事务 新建事务
NESTED 在当前事务里建一个保存点,内层回滚只回滚到保存点 新建事务
SUPPORTS 加入 以非事务方式执行
NOT_SUPPORTED 挂起当前事务,以非事务方式执行 以非事务方式执行
MANDATORY 加入 抛异常
NEVER 抛异常 以非事务方式执行

REQUIRES_NEW 和 NESTED 的区别:REQUIRES_NEW 是完全独立的事务,用另一个数据库连接,内层提交后外层回滚也不影响它,适合「无论业务成败都要记录的操作日志」。NESTED 和外层是同一个事务,外层回滚时内层一起回滚;内层失败可以只回滚到保存点,外层继续执行。

REQUIRED 下的一个经典问题:内层方法抛异常、外层 catch 住不往外抛,提交时会报 UnexpectedRollbackException。原因是内层已经把这个共享的事务标记为只能回滚,外层再想提交就失败了。

其他属性:isolation 隔离级别,默认使用数据库的级别(MySQL InnoDB 是可重复读,Oracle 是读已提交,各级别能防止哪些现象见 8.3 节);timeout 超时;readOnly 只读;rollbackFor 指定回滚的异常类型。

4.80 @Transactional 在哪些情况下会失效

场景 原因
同一个类中自调用 没有经过代理,见 4.78 节
异常被 catch 后没有重新抛出 拦截器看不到异常,照常提交
抛出的是受检异常 默认只对 RuntimeException 和 Error 回滚。要用 @Transactional(rollbackFor = Exception.class)
方法不是 public Spring 6 之前只拦截 public 方法。Spring 6 起使用 CGLIB 代理时,protected 和包可见的方法也可以
方法是 final 或 static CGLIB 无法重写
类没有被 Spring 管理 自己 new 出来的对象没有代理
在新线程里执行 SQL 事务和连接绑定在原线程上,新线程拿到的是另一个连接
传播行为设置不当 例如设成 NOT_SUPPORTED、NEVER,或者调用的是 SUPPORTS 方法而当前没有事务
数据库引擎不支持事务 MySQL 的 MyISAM
多数据源时用错了事务管理器 事务开在 A 数据源上,SQL 执行在 B 数据源上

另外要注意大事务:在事务方法里调用远程服务、发消息、做大量计算,会长时间占用数据库连接和行锁,并发高时连接池被耗尽。远程调用要放到事务外面;需要在事务提交后执行的动作(例如发消息),用 @TransactionalEventListener(phase = AFTER_COMMIT) 或 TransactionSynchronization。

4.81 BeanFactory 和 ApplicationContext 有什么区别,FactoryBean 是什么

BeanFactory ApplicationContext
定位 最基础的 IoC 容器接口 BeanFactory 的子接口,功能完整的容器
单例 Bean 的创建时机 第一次获取时才创建 启动时创建全部非懒加载的单例,配置错误能尽早暴露
额外功能 无 国际化、事件发布、资源加载、环境与配置、自动注册 BeanPostProcessor

实际开发中用的都是 ApplicationContext。

FactoryBean 是一种特殊的 Bean:容器里注册的是工厂,getBean("name") 拿到的是它的 getObject() 返回的对象;要拿工厂本身,需要用 getBean("&name")。它用来封装创建过程复杂的对象,例如 MyBatis 的 MapperFactoryBean 为每个 Mapper 接口生成代理,SqlSessionFactoryBean 构建 SqlSessionFactory。

注意和 BeanFactory 区分:BeanFactory 是容器,FactoryBean 是容器里一种能生产对象的 Bean。

4.82 @Configuration 和 @Component 有什么区别

两者都能让类成为 Bean,里面也都可以写 @Bean 方法,区别在于 @Bean 方法之间互相调用时:

@Configuration
public class AppConfig {
    @Bean
    public DataSource dataSource() { return new HikariDataSource(); }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dataSource());   // 调用的是同一个 dataSource Bean
    }
}
@Configuration(默认 proxyBeanMethods = true) @Component,或 @Configuration(proxyBeanMethods = false)
实现 配置类本身被 CGLIB 增强,调用 @Bean 方法时先到容器里找,找到就直接返回 普通类,调用 @Bean 方法就是普通的方法调用
上例中 dataSource() 的结果 容器里那个单例 每次调用都 new 一个新的

Spring Boot 的自动配置类大多设为 proxyBeanMethods = false,省掉 CGLIB 增强,启动更快,依赖关系改为通过方法参数注入。

4.83 Spring MVC 处理一个请求的流程是怎样的

请求 → Filter 链(Servlet 规范)
     → DispatcherServlet
         1. HandlerMapping:根据 URL 找到处理器(Controller 方法)和拦截器链
         2. 拦截器 preHandle
         3. HandlerAdapter:解析参数(@RequestParam、@RequestBody 等,由 HttpMessageConverter 把 JSON 转成对象),调用 Controller 方法
         4. 返回值处理:@ResponseBody 由 HttpMessageConverter 转成 JSON;返回视图名时由 ViewResolver 解析并渲染视图
         5. 拦截器 postHandle
         6. 异常交给 HandlerExceptionResolver(@ControllerAdvice 在这里生效)
         7. 拦截器 afterCompletion
     → 响应

DispatcherServlet 是前端控制器,所有请求都先到它这里,由它协调各个组件,这样组件之间互不依赖,每一个都可以替换。

4.84 过滤器和拦截器有什么区别

过滤器 Filter 拦截器 HandlerInterceptor
规范 Servlet 规范,由 Web 容器管理 Spring MVC 提供,由 Spring 管理
执行位置 DispatcherServlet 之前 DispatcherServlet 之内、Controller 方法前后
作用范围 所有请求,包括静态资源 只有进入 Spring MVC 的请求
能拿到的信息 请求和响应对象 还能拿到将要执行的处理器(Controller 方法和它上面的注解)
回调 doFilter,可以包装或替换请求、响应对象 preHandle、postHandle、afterCompletion
用途 编码设置、跨域、XSS 过滤、请求日志、读取请求体后重新包装 登录校验、权限校验(结合方法上的注解)、接口耗时统计

AOP 则在更内层,作用于任意 Bean 的方法调用,不限于 Web 请求。三者的执行顺序从外到内是:过滤器 → 拦截器 → AOP 切面 → 目标方法。

4.85 Spring 中用到了哪些设计模式

模式 体现
工厂 BeanFactory、FactoryBean
单例 默认作用域的 Bean,由容器保证只有一个实例
代理 AOP、事务、@Async
模板方法 JdbcTemplate、RestTemplate、TransactionTemplate;AbstractApplicationContext.refresh() 固定了启动流程,子类实现其中的步骤
观察者 ApplicationEvent 和 @EventListener
适配器 HandlerAdapter 用统一的方式调用不同类型的处理器;AOP 的 AdvisorAdapter 把 MethodBeforeAdvice 等通知适配成 MethodInterceptor
策略 Resource 的多种实现;InstantiationStrategy
责任链 过滤器链、拦截器链、AOP 的通知链
装饰器 BeanWrapper;TransactionAwareDataSourceProxy

4.86 Spring 的事件机制怎么用

public record OrderPaidEvent(long orderId) {}

@Service
class OrderService {
    private final ApplicationEventPublisher publisher;
    OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }

    @Transactional
    public void pay(long orderId) {
        // 更新订单状态
        publisher.publishEvent(new OrderPaidEvent(orderId));
    }
}

@Component
class PointsListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPaidEvent e) { /* 加积分、发通知 */ }
}
要点 说明
默认是同步的 监听器在发布者的线程里执行,监听器抛异常会影响发布者
异步执行 在监听方法上加 @Async,并且开启 @EnableAsync
事务 @TransactionalEventListener 可以等事务提交后再执行,避免订单回滚了积分却已经加上
适用范围 同一个进程内解耦;需要可靠投递、跨服务时要用消息队列

4.88 往 Spring 容器里注册 Bean 有哪些方式

方式 说明 典型用途
@Component 及其派生注解 @Service、@Repository、@Controller,配合组件扫描 自己写的业务类
@Configuration + @Bean 在配置类的方法里创建对象 第三方库的类、需要定制创建过程的对象
@Import(普通类) 直接把一个类注册为 Bean 引入其他配置类
@Import(ImportSelector 的实现) 在代码里返回一组要注册的类名 @EnableAutoConfiguration 就是用它批量导入自动配置类
@Import(ImportBeanDefinitionRegistrar 的实现) 拿到注册器,可以按任意逻辑注册 Bean 定义 @MapperScan 扫描 Mapper 接口、@EnableFeignClients 注册 Feign 客户端
FactoryBean 注册的是工厂,获取到的是它生产的对象 为接口生成代理:Mapper、Feign 客户端
BeanDefinitionRegistryPostProcessor 在所有 Bean 定义加载完之后,再添加或修改 Bean 定义 框架扩展
编程式注册 context.registerBean(...),或 registry.registerBeanDefinition(...) 运行时动态注册

面试常问的是后三种:框架怎么把「只有接口没有实现类」的东西变成 Bean。答案是 ImportBeanDefinitionRegistrar 扫描接口,为每个接口注册一个 FactoryBean,由 FactoryBean 用动态代理生成实现。

@Component 和 @Bean 的区别:@Component 标在类上,靠组件扫描发现;@Bean 标在配置类的方法上,由方法返回对象,创建过程完全由代码控制。第三方库里的类无法加 @Component,只能用 @Bean 注册。

4.89 跨域是什么,Spring 中怎么解决

浏览器的同源策略:协议、域名、端口三者完全相同才算同源。前端页面用 JavaScript 请求不同源的接口时,浏览器会拦截响应,除非服务端明确允许。这个限制只存在于浏览器,服务端之间调用没有跨域问题。

CORS(跨源资源共享)的过程:

  1. 简单请求(GET、POST 加简单的请求头)直接发送,浏览器检查响应头 Access-Control-Allow-Origin 是否允许当前来源。
  2. 其他请求(例如带 Authorization 头、Content-Type: application/json、PUT、DELETE)先发一个 OPTIONS 预检请求,服务端回复允许的来源、方法、请求头之后,浏览器才发送真正的请求。

Spring 中的配置方式:

方式 说明
@CrossOrigin 加在 Controller 或方法上,粒度最细
WebMvcConfigurer.addCorsMappings 全局配置
CorsFilter 过滤器层面处理,在 Spring Security 等过滤器之前生效
网关统一配置 微服务中在 Spring Cloud Gateway 的 globalcors 里配置一次,后端服务不再配置,否则会出现重复的响应头
Nginx 在反向代理上加响应头

坑:allowCredentials(true)(允许携带 Cookie)时,Access-Control-Allow-Origin 不能是 *,必须写具体的域名,或者用 allowedOriginPatterns。引入 Spring Security 后,还要在它的配置里调用 cors(),否则预检请求会被安全过滤器拦下。

4.90 @Scheduled 定时任务有哪些坑,多实例部署时怎么避免重复执行

@EnableScheduling 开启后,@Scheduled(cron = "0 0 2 * * ?")、fixedRate、fixedDelay 标注的方法会被定时调用。

坑 说明
默认单线程 Spring Boot 自动配置的调度线程池只有 1 个线程,一个任务执行慢,其他任务全部推迟。用 spring.task.scheduling.pool.size 调大
fixedRate 和 fixedDelay fixedRate 按固定频率触发,不管上次是否执行完;fixedDelay 是上次执行结束后再等固定的时间
异常 任务抛异常只打日志,不会重试,下一个周期照常执行
多实例重复执行 服务部署了 N 个实例,每个实例都会执行一次

多实例的解决方案:

方案 说明
ShedLock 执行前抢一把分布式锁(数据库或 Redis),抢到的实例才执行,改动最小
XXL-JOB 独立的调度中心统一触发,执行器注册到调度中心;支持分片广播、失败重试、超时控制、管理界面,国内最常用
ElasticJob、PowerJob 支持分片,适合大批量数据处理
Kubernetes CronJob 定时启动一个单独的容器执行任务

需要把大量数据分给多个实例处理时,用分片:每个实例拿到「分片序号 / 分片总数」,只处理 id % 总数 == 序号 的数据。

4.91 Spring Boot 解决了什么问题,和 Spring 是什么关系

Spring Boot 不是新的框架,而是建立在 Spring Framework 之上,让 Spring 应用「开箱即用」的一套工具。

以前用 Spring 的痛点 Spring Boot 的做法
大量 XML 或 Java 配置 自动配置:根据 classpath 里有哪些类、用户定义了哪些 Bean,自动配好数据源、MVC、Jackson 等
依赖版本难以协调 starter:一个 starter 引入一整组版本互相兼容的依赖;父 POM 统一管理版本
要单独部署到 Tomcat 内嵌 Web 容器,打成可执行 jar,java -jar 直接运行
运维信息不好获取 Actuator:健康检查、指标、配置信息等端点
配置分散 统一的 application.yml、多环境 profile、外部化配置

4.92 Spring Boot 的自动配置是怎么实现的

@SpringBootApplication
// 等价于下面三个注解的组合:
// @SpringBootConfiguration:本质是 @Configuration
// @EnableAutoConfiguration:开启自动配置
// @ComponentScan:扫描启动类所在的包及其子包

过程:

  1. @EnableAutoConfiguration 通过 @Import 引入 AutoConfigurationImportSelector。
  2. 它读取所有 jar 包中 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件里列出的自动配置类。Spring Boot 2.7 引入这个文件;更早的版本写在 META-INF/spring.factories 里,Spring Boot 3.0 起不再从 spring.factories 读取自动配置类。
  3. 用条件注解过滤,只有条件满足的配置类才生效。
  4. 生效的配置类注册各自的 Bean,属性通过 @ConfigurationProperties 绑定配置文件里的值。
条件注解 条件
@ConditionalOnClass classpath 中存在某个类,例如有 DataSource 和 JdbcTemplate 的类才配置 JDBC
@ConditionalOnMissingBean 容器里还没有这类 Bean。用户自己定义了,自动配置就让路
@ConditionalOnProperty 某个配置项满足条件
@ConditionalOnBean 容器里已经有某个 Bean
@ConditionalOnWebApplication 当前是 Web 应用

自动配置类在用户自己的配置之后处理,再加上 @ConditionalOnMissingBean,所以用户可以随时覆盖默认配置。

排查哪些配置生效了:启动时加 --debug,会打印条件评估报告,列出每个自动配置类生效或不生效的原因;也可以用 Actuator 的 /actuator/conditions 端点。要排除某个自动配置,用 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class)。

4.93 starter 是什么,怎么自定义一个 starter

starter 是一个依赖描述:引入一个 starter,就引入了某项功能需要的全部依赖和对应的自动配置。例如 spring-boot-starter-web 带来 Spring MVC、内嵌 Tomcat 和 Jackson。

自定义 starter 的步骤:

  1. 命名:官方 starter 叫 spring-boot-starter-xxx,第三方的应该叫 xxx-spring-boot-starter。通常拆成 xxx-spring-boot-autoconfigure(自动配置代码)和 xxx-spring-boot-starter(只负责引入依赖)两个模块。
  2. 写配置属性类:
@ConfigurationProperties(prefix = "sms")
public record SmsProperties(String endpoint, String accessKey, Duration timeout) {}
  1. 写自动配置类:
@AutoConfiguration
@ConditionalOnClass(SmsClient.class)
@EnableConfigurationProperties(SmsProperties.class)
public class SmsAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean                  // 用户自己定义了 SmsClient 就不再创建
    public SmsClient smsClient(SmsProperties props) {
        return new SmsClient(props.endpoint(), props.accessKey(), props.timeout());
    }
}
  1. 在 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中写上自动配置类的全限定名。
  2. 可选:加入 spring-boot-configuration-processor 依赖,编译时生成配置元数据,用户在 IDE 里写配置时就有提示。

4.94 Spring Boot 的启动流程是怎样的

SpringApplication.run(App.class, args) 分两步:

一、创建 SpringApplication:

  • 推断应用类型:普通应用、Servlet Web 应用,还是响应式 Web 应用。
  • 从 spring.factories 加载 ApplicationContextInitializer 和 ApplicationListener。
  • 推断启动类。

二、执行 run():

  1. 通知监听器「开始启动」。
  2. 准备 Environment:加载命令行参数、环境变量、配置文件。
  3. 打印 Banner。
  4. 根据应用类型创建 ApplicationContext。
  5. 准备上下文:执行 Initializer,把启动类注册为 Bean 定义。
  6. 刷新上下文 refresh(),这是核心:
    • 调用 BeanFactoryPostProcessor:解析配置类,执行组件扫描,处理自动配置,注册所有 Bean 定义。
    • 注册 BeanPostProcessor。
    • onRefresh():创建内嵌的 Web 服务器。
    • 实例化所有非懒加载的单例 Bean,完成依赖注入、AOP 代理。
    • 完成刷新:启动 Web 服务器,开始接收请求,发布刷新完成事件。
  7. 调用所有 ApplicationRunner 和 CommandLineRunner。
  8. 发布「应用就绪」事件(ApplicationReadyEvent)。

想在启动完成后执行初始化任务,用 ApplicationRunner 或监听 ApplicationReadyEvent。

4.95 配置文件的加载顺序和优先级是怎样的

同一个配置项出现在多个地方时,优先级从高到低(常用部分):

  1. 命令行参数,例如 --server.port=8081
  2. Java 系统属性,例如 -Dserver.port=8081
  3. 操作系统环境变量,例如 SERVER_PORT=8081
  4. jar 包外的 application-{profile}.yml
  5. jar 包外的 application.yml
  6. jar 包内的 application-{profile}.yml
  7. jar 包内的 application.yml
  8. @PropertySource 指定的文件
  9. 代码中的默认值

规律是:外部的覆盖内部的,指定了 profile 的覆盖通用的。这样同一个 jar 包在不同环境中只需要改启动参数或环境变量。多环境用 spring.profiles.active=prod 激活;接入 Nacos 这类配置中心后,配置中心的配置一般优先于本地文件。

@Profile("dev") 让一个 Bean 只在指定的环境里注册,例如接口文档只在测试环境开启。

@Value("${x}") @ConfigurationProperties
绑定方式 逐个属性 按前缀批量绑定到一个对象
宽松绑定 不支持(要求名字完全一致) 支持,user-name、userName、USER_NAME 都能绑定
校验 不支持 支持,配合 @Validated
SpEL 表达式 支持 不支持
适用 少量零散的配置 一组相关的配置,推荐

4.96 Spring Boot 的内嵌容器是怎么工作的,可以换成别的吗

spring-boot-starter-web 默认带 Tomcat。上下文刷新时,ServletWebServerApplicationContext 在 onRefresh() 阶段通过 ServletWebServerFactory 创建并启动 Tomcat,再把 DispatcherServlet 注册进去。所以不需要再把应用打成 war 包部署到外部的 Tomcat。

换成 Jetty:在 spring-boot-starter-web 中排除 spring-boot-starter-tomcat,再加入 spring-boot-starter-jetty。Spring Boot 3 及以前也支持 Undertow,Spring Boot 4 起不再支持 Undertow。

常用的配置:

配置 作用
server.port 端口
server.tomcat.threads.max 最大工作线程数,默认 200
server.tomcat.accept-count 线程都忙时,等待队列的长度
server.tomcat.max-connections 最大连接数
server.shutdown=graceful 优雅停机:先停止接收新请求,等进行中的请求处理完再关闭(2.3 起支持)

4.97 Actuator 有什么用,有哪些常用端点

Actuator 提供生产环境需要的监控和管理端点。

端点 作用
/actuator/health 健康检查,汇总数据库、Redis、磁盘等组件的状态;Kubernetes 的存活探针和就绪探针可以分别用 /health/liveness、/health/readiness
/actuator/metrics 基于 Micrometer 的指标:JVM 内存、GC、线程、HTTP 请求耗时、连接池
/actuator/prometheus Prometheus 格式的指标,供 Prometheus 拉取
/actuator/info 应用信息,例如版本、Git 提交号
/actuator/env、/configprops 当前生效的配置
/actuator/loggers 查看和运行时修改日志级别
/actuator/threaddump、/heapdump 线程栈、堆转储
/actuator/conditions 自动配置的条件评估报告

默认只通过 HTTP 暴露 health,其他端点用 management.endpoints.web.exposure.include 打开。env、heapdump 会泄露配置中的密码和内存数据,生产中要单独放在内网的管理端口(management.server.port)上,或者加上认证。

存活探针和就绪探针要分开:liveness 失败时 Kubernetes 重启 Pod,只应该检查进程自身是否卡死;readiness 失败时只把 Pod 从流量里摘掉,可以包含数据库这类外部依赖。把数据库放进 liveness,数据库一出问题,所有 Pod 都会被反复重启。优雅停机时先让 readiness 变为不可用,等流量摘掉后再停止进程。

指标基于 Micrometer。它和 SLF4J 一样是门面:代码只依赖 Micrometer 的 API,底层可以接 Prometheus、Datadog 等不同的监控系统。只通过 Dubbo 对外提供服务、没有 REST 接口的应用,同样要开一个 HTTP 端口给 Actuator,供探针和 Prometheus 访问。

4.98 怎么做全局异常处理和参数校验

public record CreateUserReq(
        @NotBlank String name,
        @Email String email,
        @Min(18) int age) {}

@RestController
class UserController {
    @PostMapping("/users")
    public Result<Long> create(@Valid @RequestBody CreateUserReq req) { /* ... */ return Result.ok(1L); }
}

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)       // 参数校验失败
    public Result<Void> onInvalid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }

    @ExceptionHandler(BizException.class)                          // 业务异常
    public Result<Void> onBiz(BizException e) { return Result.fail(e.getCode(), e.getMessage()); }

    @ExceptionHandler(Exception.class)                             // 兜底,记录日志,不向外暴露细节
    public Result<Void> onOther(Exception e) { return Result.fail(500, "系统繁忙"); }
}
要点 说明
依赖 Spring Boot 2.3 起要单独引入 spring-boot-starter-validation
@Valid 和 @Validated @Valid 是规范的注解,可以用于嵌套校验;@Validated 是 Spring 的注解,支持分组校验,放在类上时可以校验方法的普通参数(@RequestParam 上的约束)
统一返回结构 包含状态码、消息、数据,前端统一处理
不要泄露细节 兜底的处理器不要把异常栈返回给调用方

4.99 Spring Boot 2 升级到 3 要注意什么

变化 影响
最低 Java 17 先升级 JDK
Jakarta EE 9+ javax.servlet、javax.persistence、javax.validation、javax.annotation 全部改成 jakarta.*;依赖的第三方库也要升级到支持 Jakarta 的版本
Spring Framework 6 移除了一些过时的 API
自动配置的注册文件 自定义的自动配置要写在 AutoConfiguration.imports 中,不再从 spring.factories 读取
可观测性 Spring Cloud Sleuth 被移除,链路追踪改用 Micrometer Tracing
配置项 部分配置项改名或删除。可以先引入 spring-boot-properties-migrator,启动时会提示需要修改的配置,改完再移除
新增能力 GraalVM 原生镜像(Spring AOT);3.2 起支持虚拟线程,新增 RestClient、JdbcClient

做法:先升级到 2.7 并清理所有过时 API 的告警,再升级到 3.x;可以用 OpenRewrite 的迁移规则自动完成包名替换。

4.100 怎么优化 Spring Boot 的启动速度

做法 说明
缩小组件扫描范围 只扫描需要的包
排除不需要的自动配置 少创建 Bean,少做条件判断
懒加载 spring.main.lazy-initialization=true,Bean 在第一次使用时才创建。代价是配置错误要到运行时才暴露,第一次请求变慢
减少启动时的远程调用 启动时加载缓存、预热连接改为异步执行
类数据共享(AppCDS) 把类的元数据存成归档文件,下次启动直接映射。Spring Boot 3.3 起支持把应用解压成适合 CDS 的目录结构
JDK 的 AOT 缓存 JDK 24 起(JEP 483)可以把类的加载和链接结果缓存下来
GraalVM 原生镜像 启动时间降到毫秒级,代价是构建慢、反射等动态特性要额外配置、峰值性能通常不如 JIT

先用 Spring Boot 的 ApplicationStartup(例如 BufferingApplicationStartup 配合 /actuator/startup 端点)找出哪些 Bean 初始化耗时最长,再针对性地优化。

4.101 Spring Boot 打出的 jar 为什么能直接用 java -jar 运行

普通的 jar 不能包含别的 jar,依赖要靠 classpath 指定。Spring Boot 的 Maven、Gradle 插件把应用打成「fat jar」,结构如下:

app.jar
├── META-INF/MANIFEST.MF
│     Main-Class: org.springframework.boot.loader.launch.JarLauncher   ← java -jar 执行的入口
│     Start-Class: com.example.App                                     ← 你写的 main 方法所在的类
├── org/springframework/boot/loader/...     Spring Boot 的启动器代码
├── BOOT-INF/classes/                       应用自己的 class 文件和配置文件
├── BOOT-INF/lib/                           所有依赖的 jar,原样嵌套在里面
└── BOOT-INF/classpath.idx                  依赖的加载顺序

启动过程:

  1. java -jar 读取 Main-Class,执行的是 JarLauncher,而不是你的启动类。
  2. JarLauncher 创建一个自定义的类加载器,它能读取「jar 里嵌套的 jar」,把 BOOT-INF/classes 和 BOOT-INF/lib 下的所有 jar 加入 classpath。
  3. 用这个类加载器加载 Start-Class,通过反射调用它的 main 方法。

JarLauncher 的包名在 Spring Boot 3.2 改成了 org.springframework.boot.loader.launch,之前是 org.springframework.boot.loader。

相关的做法:

做法 用途
分层 jar(layertools) 把依赖、快照依赖、应用代码分成不同的层,构建 Docker 镜像时依赖层可以复用缓存,每次只更新应用代码那一层
解压运行 解压后用 java -cp 启动,启动略快,也是使用 CDS 的前提
war 包 继承 SpringBootServletInitializer,部署到外部的 Tomcat

4.102 Spring Boot 怎么配置多数据源和读写分离

原理:Spring 提供了 AbstractRoutingDataSource,它本身也是一个数据源,内部持有多个真实的数据源。每次获取连接时调用 determineCurrentLookupKey() 决定用哪一个。通常的做法是:用一个注解标记方法应该使用哪个数据源,切面在方法执行前把数据源的名字放进 ThreadLocal,determineCurrentLookupKey() 从 ThreadLocal 里取。

方案 说明
自己实现 AbstractRoutingDataSource + AOP 代码量不大,可控
dynamic-datasource-spring-boot-starter 在方法或类上加 @DS("slave") 切换数据源,支持主从分组、负载均衡
ShardingSphere-JDBC 配置主从关系后自动把读请求路由到从库、写请求路由到主库,同时支持分库分表
完全分开配置 每个数据源各配一套 SqlSessionFactory 和事务管理器,按包区分 Mapper,适合几个完全独立的数据库
坑 说明
事务里切换数据源无效 事务开始时就已经拿到连接,并绑定到当前线程,之后在事务里切换数据源不起作用。切换必须发生在事务开始之前;切面的执行顺序要早于事务切面
主从延迟 写完立即去读从库可能读不到。写后马上要读的场景强制走主库,例如在一个请求里写过之后都走主库
ThreadLocal 没有清理 切面要在 finally 里清除,否则线程复用时会走错数据源
跨库事务 普通的本地事务管不了两个数据库,需要分布式事务,或者在设计上避免

4.103 Spring Security 的认证和鉴权流程是怎样的

Spring Security 是一条过滤器链。请求进入 Servlet 容器后,DelegatingFilterProxy 把请求交给 Spring 容器中的 FilterChainProxy,后者按请求路径选择一条 SecurityFilterChain 依次执行。

用户名密码登录的认证流程:

UsernamePasswordAuthenticationFilter(或自己写的登录接口)
  → AuthenticationManager(ProviderManager)
      → AuthenticationProvider(DaoAuthenticationProvider)
          → UserDetailsService.loadUserByUsername:从数据库查用户和权限
          → PasswordEncoder.matches:校验密码
  → 成功:Authentication 对象存入 SecurityContextHolder(默认基于 ThreadLocal)
  → 失败:抛 AuthenticationException

鉴权:AuthorizationFilter 根据配置的规则(URL 规则,或方法上的 @PreAuthorize)检查 SecurityContextHolder 里当前用户的权限;不通过时,ExceptionTranslationFilter 把异常转换成 401(未认证)或 403(无权限)。

用 JWT 时的配置要点(Spring Security 6):

要点 说明
配置方式 定义一个 SecurityFilterChain 类型的 Bean。旧的 WebSecurityConfigurerAdapter 已被移除
无状态 sessionManagement 设为 STATELESS,不创建会话;关闭 CSRF 防护,因为令牌放在请求头里,不依赖 Cookie
解析令牌 写一个 OncePerRequestFilter,放在 UsernamePasswordAuthenticationFilter 之前:解析并校验 JWT,构造 Authentication 放进 SecurityContextHolder;也可以直接使用 oauth2ResourceServer().jwt()
方法级权限 开启 @EnableMethodSecurity,使用 @PreAuthorize

微服务中常见的分工:网关负责校验令牌,把用户 ID 和角色放进请求头传给后端服务;后端服务信任网关传来的请求头,只做细粒度的权限判断。前提是后端服务不能被外部直接访问,网关也要删除客户端自己伪造的同名请求头。

4.104 Spring Cloud 是什么,和 Spring Boot 是什么关系,有哪些组件

Spring Boot 负责把单个服务快速搭起来;Spring Cloud 建立在 Spring Boot 之上,提供把多个服务组织成微服务系统所需的治理能力。Spring Cloud 依赖 Spring Boot,两者的版本必须按官方兼容表匹配,一般用 spring-cloud-dependencies 这个 BOM 统一管理版本。

能力 Netflix 时代 现在常用 国内常用(Spring Cloud Alibaba)
注册发现 Eureka Eureka、Consul Nacos
配置中心 Spring Cloud Config Config Nacos
远程调用 Feign OpenFeign OpenFeign、Dubbo
负载均衡 Ribbon Spring Cloud LoadBalancer 同左
熔断限流 Hystrix Resilience4j(经 Spring Cloud CircuitBreaker) Sentinel
网关 Zuul 1 Spring Cloud Gateway 同左
链路追踪 Sleuth + Zipkin Micrometer Tracing + Zipkin 等 SkyWalking
分布式事务 — — Seata
消息 — Spring Cloud Stream RocketMQ

Netflix 的 Hystrix、Ribbon、Zuul 1 已停止开发,从 Spring Cloud 2020.0 起被移除。

Spring Cloud 版本 对应 Spring Boot
Hoxton 2.2、2.3
2020.0 2.4、2.5
2021.0 2.6、2.7
2022.0 3.0、3.1
2023.0 3.2、3.3
2024.0 3.4
2025.0 3.5

准确的对应关系以 Spring Cloud 官网的兼容性表为准。

4.105 服务注册与发现是怎么工作的,Eureka、Nacos、ZooKeeper 有什么区别

服务提供者 ── 启动时注册(IP、端口、元数据)、定期发送心跳 ──→ 注册中心
服务消费者 ── 拉取或订阅服务列表,缓存在本地 ───────────────→ 注册中心
服务消费者 ── 从本地列表中按负载均衡策略选一个实例直接调用 ──→ 服务提供者

注册中心发现某个实例的心跳超时,就把它摘除,并通知消费者。消费者在本地缓存了服务列表,所以注册中心短时间不可用时,已有的调用仍然可以继续。

Eureka Nacos ZooKeeper Consul
CAP AP 临时实例 AP(自研 Distro 协议);持久实例 CP(Raft) CP(ZAB 协议) CP(Raft)
健康检查 客户端心跳 临时实例靠心跳;持久实例由服务端主动探测 会话与临时节点 多种主动检查
配置中心 无 有 可以自己实现 有 KV 存储
特点 自我保护模式:大量实例心跳同时丢失时,判断为网络问题,不再摘除实例 注册中心和配置中心合一,有控制台,支持命名空间、分组 Leader 选举期间不可用,不适合做大规模服务发现 多数据中心

为什么服务发现更适合 AP:注册中心短暂不可用、或者数据有短暂不一致,消费者用旧的服务列表大多数情况下仍然能调通;而 CP 系统在选举期间完全不可写,新实例注册不上、下线的实例摘不掉,影响更大。

ZooKeeper 做注册中心的问题:

  • 它是 CP 系统,Leader 选举期间不能写。
  • Watch 是一次性的,触发后要重新注册。服务列表变化时,大量客户端同时收到通知、同时重新拉取和注册,形成惊群。
  • 客户端的 GC 停顿可能让会话超时,临时节点被删除,健康的实例被误判为下线。
  • 写操作都要经过 Leader 并同步给多数节点,实例和接口很多时,注册数据的同步会成为瓶颈。Kafka 去掉 ZooKeeper、改用 KRaft,原因之一也是元数据的规模。
  • 每个 znode 的数据默认不超过 1 MB(jute.maxbuffer)。它是用来做协调的,不适合存放业务数据。

需要通用的协调服务(选主、分布式锁)时用 etcd 或 ZooKeeper;服务注册和配置中心用 Nacos、Consul。

4.106 Nacos 配置中心是怎么实现配置动态刷新的

版本 机制
Nacos 1.x 长轮询:客户端发起一个 HTTP 请求带上配置的 MD5,服务端如果没有变化就挂起这个请求最多 30 秒;期间配置一旦变化,立即返回变化的配置项,客户端再去拉取新内容
Nacos 2.x 改用 gRPC 长连接,服务端直接推送变更,连接数和延迟都更低

客户端在本地保存一份配置快照,Nacos 服务端不可用时可以用快照启动。

在 Spring 中刷新:

方式 说明
@ConfigurationProperties 绑定的配置 配置变更后自动重新绑定
@Value 注入的配置 所在的 Bean 要加 @RefreshScope。原理是这类 Bean 被代理,配置变更时销毁旧实例,下次访问时用新配置重新创建

注意:被刷新的 Bean 会重建,里面的状态会丢失;数据库连接池这类基础设施的配置,一般不适合动态刷新。

4.107 OpenFeign 是怎么工作的

OpenFeign 让调用远程 HTTP 接口像调用本地方法一样:

@FeignClient(name = "user-service", path = "/users")
public interface UserClient {
    @GetMapping("/{id}")
    UserDTO get(@PathVariable("id") Long id);
}

原理:

  1. @EnableFeignClients 扫描所有 @FeignClient 接口,为每个接口注册一个 FeignClientFactoryBean。
  2. 工厂用 JDK 动态代理为接口生成实现类。
  3. 调用方法时,代理根据方法上的 Spring MVC 注解(由 SpringMvcContract 解析)构造请求模板:URL、请求方法、参数、请求头。
  4. Encoder 把参数编码成请求体。
  5. 负载均衡器(Spring Cloud LoadBalancer)根据服务名 user-service 从注册中心的服务列表中选一个实例。
  6. 底层 HTTP 客户端发送请求。默认用 JDK 自带的 HttpURLConnection,没有连接池,生产中通常换成 Apache HttpClient 或 OkHttp。
  7. Decoder 把响应解码成返回对象,非 2xx 的响应交给 ErrorDecoder 处理。
配置要点 说明
超时 必须设置连接超时和读超时,不设置时一个慢的依赖会拖垮调用方
重试 默认不重试;开启时只对幂等的请求重试
熔断降级 配合 Sentinel 或 Resilience4j,fallback 或 fallbackFactory 指定降级逻辑
拦截器 实现 RequestInterceptor,统一加认证令牌、传递链路追踪 ID

4.108 负载均衡有哪些策略,Spring Cloud LoadBalancer 是怎么工作的

客户端负载均衡:消费者自己从服务列表里挑实例,不经过中间的负载均衡设备。

策略 说明
轮询(默认) 依次选择
随机 随机选择
加权 按权重分配,例如 Nacos 里配置的实例权重
最少活跃数、最短响应时间 优先选择负载最低的实例(Dubbo 支持)
一致性哈希 同一个键总是落到同一个实例,便于利用本地缓存
同区域优先 优先选择同机房、同可用区的实例

Spring Cloud LoadBalancer 从 ServiceInstanceListSupplier 获取实例列表(默认从注册中心获取,并带有缓存),再交给 ReactorLoadBalancer 选择,内置轮询和随机两种策略,可以自定义。它取代了已停止开发的 Ribbon。

4.109 熔断、降级、限流分别是什么,熔断器是怎么工作的

作用 例子
限流 控制进入系统的流量,超出部分直接拒绝或排队,保护自己 接口 QPS 超过 1000 就拒绝
熔断 下游持续出错或变慢时暂时停止调用它,快速失败,防止调用方被拖垮 库存服务错误率超过 50%,接下来 10 秒内不再调用
降级 失败或熔断时返回兜底结果,保证核心功能可用 推荐服务不可用时返回默认推荐列表

服务雪崩:一个下游变慢,调用方的线程都阻塞在等待它返回上,线程池被耗尽,调用方自己也无法响应,故障沿调用链向上蔓延。熔断、超时、隔离(每个下游使用单独的线程池或信号量)就是为了切断这条链。

熔断器的三种状态:

        失败率或慢调用比例超过阈值
关闭 ────────────────────────────→ 打开(直接失败,不调用下游)
 ↑                                    │
 │ 试探请求成功                        │ 等待一段时间
 │                                    ↓
 └──────────────────────────────── 半开(放行少量试探请求)
         试探请求失败 → 回到打开
Sentinel Resilience4j Hystrix
状态 阿里开源,国内常用 Spring Cloud 官方推荐 已停止开发
隔离方式 信号量(限制并发线程数) 信号量、线程池 线程池、信号量
限流 很丰富:QPS 或并发线程数、预热、排队匀速通过、关联资源限流、热点参数限流、系统自适应保护 支持 弱
熔断规则 慢调用比例、异常比例、异常数 失败率、慢调用比例 失败率
规则管理 有控制台,规则可以存到 Nacos 并动态推送 配置文件 配置文件

4.110 Spring Cloud Gateway 是怎么工作的

网关是所有外部请求的统一入口,负责公共逻辑,后端服务就不用各自实现一遍:按服务名动态路由和负载均衡、协议转换(例如 HTTP 转 Dubbo,见 4.117 节)、认证鉴权、限流熔断、参数校验、跨域、灰度发布、响应缓存和聚合、日志和监控、TLS 证书管理。

核心概念:

概念 说明
路由(Route) 由 ID、目标地址、一组断言、一组过滤器组成。目标地址写成 lb://user-service 时,通过负载均衡转发到这个服务的实例
断言(Predicate) 匹配请求的条件:路径、请求头、请求方法、查询参数、时间等
过滤器(Filter) 修改请求和响应:加请求头、去掉路径前缀、重试、限流(内置基于 Redis 的令牌桶限流过滤器)
全局过滤器(GlobalFilter) 对所有路由生效,常用来做统一认证
spring:
  cloud:
    gateway:
      routes:
        - id: user
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix=1

处理流程:请求进入 DispatcherHandler → RoutePredicateHandlerMapping 用断言找到匹配的路由 → FilteringWebHandler 把全局过滤器和路由过滤器按顺序组成过滤器链 → 前置处理后转发到后端服务 → 响应再反向经过过滤器链的后置处理。

技术基础:经典的 Spring Cloud Gateway 基于 Spring WebFlux + Reactor Netty,是非阻塞的响应式模型,少量线程就能处理大量连接。所以过滤器里不能写阻塞代码(例如同步查询数据库、用阻塞的 HTTP 客户端调用认证服务),否则会卡住 Netty 的事件循环线程。另外它不能和 spring-boot-starter-web(Servlet 栈)同时引入。较新的版本还提供了基于 Spring MVC 的 Gateway Server MVC,可以配合虚拟线程使用阻塞的写法。

对比 Zuul 1 Spring Cloud Gateway
模型 Servlet,阻塞,一个请求占一个线程 WebFlux,非阻塞
状态 已停止开发 官方推荐

4.111 微服务中的链路追踪是怎么实现的

一个请求经过多个服务时,用同一个 traceId 把整条调用链串起来,每一段调用是一个 span,记录自己的 spanId、父 spanId、开始时间、耗时、标签。

环节 做法
生成 入口(网关或第一个服务)生成 traceId
进程内传递 存在 ThreadLocal 里,并放入日志的 MDC,每行日志都带上 traceId
跨服务传递 放在 HTTP 请求头或 RPC 的附加信息里,例如 W3C 的 traceparent 头或 B3 头
跨线程传递 线程池、@Async、CompletableFuture 需要专门处理上下文的传递(TaskDecorator、TransmittableThreadLocal 等)
上报 span 异步批量上报给 Zipkin、Jaeger、SkyWalking 等后端,按 traceId 组装成调用树
方案 特点
Micrometer Tracing Spring Boot 3 的官方方案,替代 Sleuth,底层可以接 Brave 或 OpenTelemetry
SkyWalking 通过 Java Agent 修改字节码自动埋点,业务代码无需改动,国内常用
OpenTelemetry 厂商中立的标准,统一了链路、指标、日志

采样:全量记录开销太大,一般按比例采样,出错的请求和慢请求全部保留。

4.112 Dubbo 和 Spring Cloud OpenFeign 有什么区别

Dubbo OpenFeign
协议 基于 TCP 的二进制 RPC 协议(dubbo 协议);Dubbo 3 新增兼容 gRPC 的 Triple 协议 HTTP + JSON
性能 高:长连接、二进制序列化(Hessian2、Protobuf) 较低:HTTP 报文开销、JSON 序列化
调用方式 面向接口:提供方和消费方共享同一个接口定义 jar 面向 REST 接口:按 URL 约定
服务治理 内置丰富:多种负载均衡、集群容错(失败重试、快速失败、并行调用)、路由规则、权重调整 依赖 Spring Cloud 的其他组件
跨语言 Triple 协议支持;经典的 dubbo 协议主要用于 Java HTTP 天然跨语言
服务发现 Dubbo 3 支持应用级服务发现,注册数据量大幅下降 应用级

内部服务之间的调用量大、对性能要求高时选 Dubbo;需要对外提供接口、跨语言、团队熟悉 Spring Cloud 时选 OpenFeign。两者可以通过 Spring Cloud Alibaba 并存,注册中心统一用 Nacos。

4.113 微服务怎么做到优雅上下线

下线:

  1. 先从注册中心注销这个实例(或者把它的权重调为 0)。
  2. 等待消费者的服务列表缓存刷新,通常需要几秒,让新的请求不再发过来。
  3. 关闭 Web 服务器,停止接收新请求;正在处理的请求继续处理完。Spring Boot 中设置 server.shutdown=graceful 和 spring.lifecycle.timeout-per-shutdown-phase。
  4. 关闭线程池、消息消费者、数据库连接池等资源。

在 Kubernetes 中,可以在 preStop 钩子里先注销实例并等待几秒,再让进程收到 SIGTERM。

Dubbo 的优雅停机依靠 JVM 的 ShutdownHook(在 Spring 中随容器关闭触发):先从注册中心注销,不再接收新请求,等正在处理的请求完成(最长等待时间由 dubbo.application.shutwait 控制,默认 10 秒,实现相关),再关闭连接。kill -9 不会触发 ShutdownHook,所以发布脚本要先发 SIGTERM(kill -15),等待超时后才强杀。也可以在发布前通过 Dubbo 的 QoS 端口执行 offline 命令手动下线。

上线:

  1. 启动后先完成预热:加载缓存,建立连接,让 JIT 编译热点代码。
  2. 就绪探针通过后再接收流量,或者通过注册中心的权重从小到大逐步放量。

4.114 网关怎么做统一鉴权

在 Spring Cloud Gateway 中写一个 GlobalFilter,所有路由都会经过它:

@Component
public class AuthFilter implements GlobalFilter, Ordered {
    private static final List<String> WHITE_LIST = List.of("/api/auth/login", "/api/auth/register");

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest req = exchange.getRequest();
        String path = req.getPath().value();
        if (WHITE_LIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }
        String auth = req.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        Claims claims = jwtVerifier.verify(auth);          // 校验签名、过期时间;失败返回 null
        if (claims == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        // 去掉客户端可能伪造的用户头,再写入网关解析出的用户信息,传给后端服务
        ServerHttpRequest newReq = req.mutate()
                .headers(h -> h.remove("X-User-Id"))
                .header("X-User-Id", claims.getSubject())
                .build();
        return chain.filter(exchange.mutate().request(newReq).build());
    }

    @Override
    public int getOrder() { return -100; }               // 数字越小越先执行
}
步骤 说明
白名单 登录、注册、公开接口直接放行
校验令牌 验证签名和过期时间。网关只持有公钥(RS256),私钥只在认证服务里
黑名单 需要立即失效的令牌,到 Redis 里查 jti。网关是响应式的,要用 ReactiveRedisTemplate,不能用阻塞的客户端
粗粒度的权限 按「路径 + 方法」检查接口权限,权限数据缓存在网关的本地缓存里
透传用户信息 把用户 ID、租户 ID 等放进请求头传给后端,同时删除客户端传来的同名请求头,防止伪造
统一的错误响应 返回 401 或 403 的 JSON

后端服务通过拦截器从请求头读出用户信息,放进 ThreadLocal 供业务代码使用;服务之间用 Feign 调用时,用 RequestInterceptor 继续传递这些请求头。后端服务只能通过网关访问,不能把端口暴露给外部。

4.115 微服务的灰度发布怎么做

灰度发布:新版本先部署少量实例,只让一部分流量进来,观察没有问题再逐步扩大。

实现步骤:

  1. 给实例打标签:新版本的实例在 Nacos 注册时,元数据里带上 version=gray。
  2. 在入口决定一个请求是否走灰度,常见的规则:
    • 请求头带灰度标记,例如测试人员手动加上;
    • 用户 ID 在白名单里;
    • 用户 ID 哈希后取模,落在某个比例区间内,例如 5%;
    • 按地区、App 版本号。
  3. 在负载均衡时按标签选择实例:自定义 Spring Cloud LoadBalancer 的 ReactorServiceInstanceLoadBalancer,网关根据请求的灰度标记,只在 version=gray 的实例中选择;没有灰度实例时退回到正常实例。
  4. 全链路传递:网关把灰度标记放进请求头;后端服务调用下游时,Feign 拦截器继续带上这个请求头,下游服务的负载均衡器同样按标记选择实例。消息队列也要在消息属性里带上标记,灰度的消费者只消费灰度消息。
方式 说明
按比例灰度 简单,但同一个用户可能一会儿走新版本、一会儿走老版本。按用户 ID 哈希可以让同一用户始终走同一个版本
蓝绿发布 新旧两套完整的环境,一次性切换流量,回滚快,但资源翻倍
Kubernetes、服务网格 用 Istio 的 VirtualService 按权重或请求头路由,不用改应用代码

灰度期间要对比新旧版本的错误率、延迟、业务指标,出问题时把灰度比例调回 0 即可回滚。

4.116 Dubbo 的工作原理是什么

Dubbo 提供面向接口的远程调用、集群容错和负载均衡、服务注册与发现、运行时的流量调度和服务治理,每个环节都可以通过 SPI 扩展。

Provider 启动 ──注册──→ 注册中心(Nacos、ZooKeeper) ←──订阅── Consumer 启动
                                                          │ 拿到提供者列表并缓存在本地,变化时收到推送
Consumer 调用接口方法
  → 代理(Proxy)
  → 集群容错(Cluster):决定失败了怎么办
  → 路由(Router):按标签、条件过滤提供者
  → 负载均衡(LoadBalance):选一个提供者
  → 协议层:序列化请求,经 Netty 长连接发送
Provider 收到请求 → 反序列化 → 线程池中执行实现类 → 返回结果
集群容错策略 行为
Failover(默认) 失败后换一个提供者重试,默认重试 2 次。只适合幂等操作,写操作要改为不重试
Failfast 失败立即报错,适合非幂等的写操作
Failsafe 失败时忽略,适合写日志这类可有可无的调用
Failback 失败后记录下来,后台定时重发
Forking 并行调用多个提供者,一个成功就返回
Broadcast 逐个调用所有提供者,常用于通知所有实例刷新缓存

是否幂等最好在接口方法的注释里写明。接口级配置 retries = 0 作为默认值,确认幂等的方法再用方法级配置打开重试,这样新增的方法忘了配置时也不会被自动重试,见 9.4 节。

负载均衡策略 说明
加权随机(默认) 按权重随机
加权轮询 平滑的加权轮询
最少活跃调用 优先选择正在处理的请求最少的提供者,慢的提供者自然分到的请求少。普通轮询没有这个效果,请求会在慢的提供者上越积越多
一致性哈希 相同参数的请求总是落到同一个提供者
最短响应时间 优先选择平均响应时间短的提供者

协议和序列化:协议决定数据帧怎么划分、请求和响应怎么对应;序列化决定对象怎么变成字节。两者分开配置。

默认值 说明
协议 dubbo,端口 20880 基于 TCP 的私有二进制协议,每个提供者默认一条长连接,所有请求复用。帧头 16 字节:魔数 0xdabb、标志位(请求还是响应、是否需要返回、序列化方式的编号)、状态、8 字节的请求 ID(在一条连接上匹配请求和响应)、4 字节的数据长度。Dubbo 3 推荐使用 tri(Triple),但要显式配置,默认仍是 dubbo
序列化 hessian2 Dubbo 2.x 的默认值。Dubbo 3.2 起 dubbo 协议默认优先使用 Fastjson2,并和对端协商,对端不支持时回退到 Hessian2(以所用版本的文档为准)

Hessian2 这类序列化可以按数据里的类名反序列化出任意类型,历史上出过多个反序列化漏洞。Dubbo 3 加入了序列化类检查,只允许白名单里的类被反序列化,默认的检查级别随版本不同。

SPI 扩展机制:Dubbo 的协议、序列化、负载均衡、集群容错、注册中心都是扩展点。它自己实现了比 JDK 更强的 SPI:按名字加载实现,扩展之间可以自动注入,支持包装类(类似 AOP),还可以根据调用时 URL 里的参数选择具体实现(自适应扩展)。

Dubbo 3 的变化:

  • 应用级服务发现:注册中心只登记应用实例,不再为每个接口都登记一份,注册数据大幅减少。
  • Triple 协议:基于 HTTP/2,兼容 gRPC,跨语言,支持流式调用。

4.117 网关怎么把 HTTP 请求转成 Dubbo 调用,泛化调用是什么

正常的 Dubbo 调用要求消费方依赖提供方的接口 jar 包。网关要对接几十个服务,不可能把所有接口 jar 都打进来,接口一变网关就得重新发版。泛化调用解决了这个问题:不依赖接口类,只用接口名、方法名、参数类型和参数值发起调用,参数和返回值都用 Map 表示。

ReferenceConfig<GenericService> ref = new ReferenceConfig<>();
ref.setInterface("com.example.order.OrderService");   // 只需要接口名字符串
ref.setVersion("1.0.0");
ref.setGeneric("true");
GenericService svc = ref.get();                        // 很重,必须缓存复用

Map<String, Object> arg = Map.of("userId", 1001L, "skuId", 2002L, "count", 1);
Object result = svc.$invoke("createOrder",
        new String[] {"com.example.order.CreateOrderReq"},
        new Object[] {arg});                           // 返回的 POJO 会被转成 Map

网关的实现思路:

步骤 说明
路由配置 在配置中心或数据库里维护映射:HTTP 路径和方法 → Dubbo 接口名、方法名、版本、分组、参数类型
参数转换 把请求的 JSON 和路径参数转成 Map,按配置的参数类型组装
引用缓存 ReferenceConfig 的创建涉及订阅注册中心、建立连接,非常耗时,要用 ReferenceCache 缓存,按「接口 + 版本 + 分组」复用
结果转换 把返回的 Map 序列化成 JSON 响应
异常转换 把 Dubbo 的超时、无可用提供者、业务异常转换成对应的 HTTP 状态码
线程模型 Spring Cloud Gateway 是响应式的,要使用 Dubbo 的异步调用($invokeAsync),把返回的 CompletableFuture 转成 Mono,不能阻塞事件循环线程

开源项目 Apache ShenYu 提供了现成的 HTTP 转 Dubbo 插件。Dubbo 3 的 Triple 协议本身也能直接通过 HTTP 调用,可以减少对泛化调用的依赖。

4.118 Dubbo 支持哪些配置方式,优先级是怎样的

方式 写法 适用
XML Spring XML 里的 <dubbo:application>、<dubbo:service>、<dubbo:reference> 等标签 老项目
注解 @EnableDubbo、@DubboService、@DubboReference。这是 2.7.7 起的名字,之前叫 @Service、@Reference,和 Spring 的 @Service 重名 Spring 项目
Spring Boot 配置文件 application.yml 里的 dubbo.* 现在最常用,和注解一起使用
API ServiceConfig、ReferenceConfig、DubboBootstrap 不用 Spring 的场景;网关这类要在运行时动态创建引用的场景,见 4.117 节
dubbo.properties classpath 下的属性文件 兜底的默认值
外部化配置 存放在配置中心(Nacos、ZooKeeper、Apollo) 多个应用共享的全局配置
JVM 参数、环境变量 -Ddubbo.xxx=...、DUBBO_XXX 部署时临时覆盖

各种写法最终都转换成 ApplicationConfig、RegistryConfig、ProtocolConfig、ServiceConfig、ReferenceConfig、MethodConfig 这些配置对象。

按来源的优先级(从高到低,以 Dubbo 3 官方文档为准):JVM 的 -D 参数 > 环境变量 > 外部化配置 > 应用里的配置(注解、XML、API、Spring Boot 配置文件)> dubbo.properties。

按粒度的优先级:方法级 > 接口级 > 全局;同一级别里,消费者端 > 提供者端。以 timeout 为例:

优先级 配置位置
1 消费者的方法级:<dubbo:reference> 里的 <dubbo:method timeout>
2 提供者的方法级:<dubbo:service> 里的 <dubbo:method timeout>
3 消费者的接口级:<dubbo:reference timeout>
4 提供者的接口级:<dubbo:service timeout>
5 消费者的全局配置:<dubbo:consumer timeout>
6 提供者的全局配置:<dubbo:provider timeout>

提供者的配置随注册信息传给消费者,作为消费者的默认值。提供者最清楚自己的方法需要多长时间、是否幂等,所以超时和重试通常在提供者上按方法配置:

@DubboService(timeout = 1000, retries = 0,                         // 接口级默认不重试
        methods = {
                @Method(name = "getUser", timeout = 500, retries = 2),   // 幂等的查询允许重试
                @Method(name = "createUser", retries = 0)                // 非幂等的写操作不重试
        })
public class UserFacadeImpl implements UserFacade { }

运行时生效的治理规则:通过 Dubbo Admin 或配置中心下发,不需要重启。

规则 用途
动态配置(覆盖规则) 运行时修改超时、权重、重试次数,或者禁用某个提供者
条件路由 例如 host = 10.0.0.1 => host = 10.0.0.2,满足条件的请求只发给指定的提供者
标签路由 给提供者打上标签(例如 gray),带同样标签的请求路由过去,用于灰度发布、多环境隔离,见 4.115 节
脚本路由 用脚本编写复杂的路由逻辑

4.119 MyBatis 是什么,和 Hibernate、JPA 有什么区别

MyBatis 是半自动的 ORM 框架:SQL 由开发者自己写(在 XML 或注解里),MyBatis 负责参数绑定、执行 SQL、把结果映射成对象。

MyBatis Hibernate / JPA
SQL 手写,完全可控 框架根据对象关系自动生成
学习成本 低 高:缓存、懒加载、实体状态、关联关系都需要理解
复杂查询、SQL 调优 方便 难以控制生成的 SQL
数据库可移植性 差,SQL 和数据库方言绑定 好
开发效率 简单的增删改查要写很多重复的 SQL(MyBatis-Plus 弥补了这一点) 简单的增删改查几乎不用写 SQL

国内互联网公司多用 MyBatis 或 MyBatis-Plus,因为业务的 SQL 往往复杂,并且需要针对性能调优。

4.120 #{} 和 ${} 有什么区别

#{} ${}
处理方式 预编译占位符:SQL 中替换成 ?,再通过 PreparedStatement 设置参数 字符串直接拼接进 SQL
SQL 注入 不会,参数始终被当作值 会
字符串参数 自动按值处理(加引号、转义) 原样拼接
<!-- 安全 -->
SELECT * FROM user WHERE name = #{name}
<!-- 危险:name 传入 ' OR '1'='1 就能查出所有用户 -->
SELECT * FROM user WHERE name = '${name}'

预编译做了什么:MyBatis 解析 Mapper 时把 #{} 换成 ?(静态 SQL 在启动时就换好,含动态标签的 SQL 每次调用时先拼出来再换),执行时用 connection.prepareStatement 创建语句,再由 TypeHandler 按顺序绑定参数。数据库层面的预编译,是先对带 ? 的 SQL 做解析和语义检查,生成语句对象,之后每次只传参数执行。SQL 的结构在参数到达之前就确定了,参数只会被当作值。

是否真的在服务端预编译,取决于驱动(实现相关):

驱动 默认行为
MySQL Connector/J useServerPrepStmts=false,客户端预编译:驱动转义参数后拼成完整的 SQL 发给服务端,服务端看到的是普通查询。防注入的效果相同,但省不掉解析
MySQL,开启 useServerPrepStmts=true&cachePrepStmts=true 服务端预编译:先发 COM_STMT_PREPARE,之后用 COM_STMT_EXECUTE 以二进制格式传参数;同一个连接上相同的 SQL 复用语句
PostgreSQL JDBC 同一条语句执行到第 prepareThreshold 次(默认 5 次)以后,改用服务端预编译的语句

${} 只用于无法预编译的部分,例如动态的表名、列名、ORDER BY 后的排序字段。这时必须在代码里用白名单校验传入的值,只允许几个固定的取值。

模糊查询不要写 '%${name}%',应该写 CONCAT('%', #{name}, '%'),或者在代码里拼好百分号再用 #{} 传入。

4.121 Mapper 接口没有实现类,为什么能被调用

Mapper 接口的实现是 MyBatis 在运行时用 JDK 动态代理生成的。

  1. 启动时解析 XML 映射文件或注解,每条 SQL 生成一个 MappedStatement,以「命名空间 + SQL 的 id」为键存进 Configuration。XML 的命名空间要写成接口的全限定名,SQL 的 id 写成方法名。
  2. sqlSession.getMapper(UserMapper.class) 由 MapperProxyFactory 生成代理对象,调用处理器是 MapperProxy。
  3. 调用 userMapper.selectById(1) 时,MapperProxy 用「接口全名 + 方法名」找到对应的 MappedStatement,再根据 SQL 类型和返回值类型,调用 sqlSession.selectOne、selectList、insert 等方法执行。

所以Mapper 接口里的方法不能重载:同名方法会对应同一个 SQL 的 id。

在 Spring 中,@MapperScan 扫描接口,为每个 Mapper 注册一个 MapperFactoryBean,由它生成代理对象,再注入到 Service 里。

4.122 MyBatis 执行一条 SQL 的流程是怎样的

SqlSessionFactoryBuilder 解析配置文件和映射文件 → Configuration(包含所有 MappedStatement)
        ↓
SqlSessionFactory → SqlSession(对外的 API)
        ↓
Executor(执行器,负责缓存和事务)
        ↓
StatementHandler(创建 JDBC Statement,执行 SQL)
  ├── ParameterHandler:用 TypeHandler 把 Java 参数设置到 PreparedStatement 中
  └── ResultSetHandler:用 TypeHandler 把 ResultSet 的每一行映射成 Java 对象
组件 说明
Executor SimpleExecutor:每次都新建 Statement;ReuseExecutor:复用相同 SQL 的 Statement;BatchExecutor:批量执行;CachingExecutor:开启二级缓存时包在外层的装饰器
TypeHandler Java 类型和 JDBC 类型之间的转换,例如枚举、JSON 字段都可以自定义 TypeHandler
四大对象 Executor、StatementHandler、ParameterHandler、ResultSetHandler,也是插件能够拦截的对象,见 4.126 节

4.123 MyBatis 的一级缓存和二级缓存是什么

一级缓存 二级缓存
范围 同一个 SqlSession 同一个命名空间(一个 Mapper),跨 SqlSession
默认 开启 关闭,需要在映射文件里加 <cache/>
失效 执行增删改、提交、回滚、关闭 SqlSession 时清空 这个命名空间执行增删改时清空
生效时机 查询后立即生效 SqlSession 提交或关闭后才写入
要求 无 缓存的对象要实现 Serializable

在 Spring 中的表现:没有事务时,每次调用 Mapper 都会新建一个 SqlSession,用完即关,所以一级缓存基本不起作用;在同一个事务内,多次相同的查询才会命中一级缓存。

为什么生产中一般不用二级缓存:

问题 说明
多表关联的脏数据 A Mapper 的查询关联了 B 表,B 的 Mapper 更新了 B 表,只会清空 B 命名空间的缓存,A 的缓存里仍是旧数据
多实例不一致 缓存在本机内存里,多个服务实例之间互不知道对方的修改

需要缓存时,一般在业务层用 Redis 或 Caffeine,自己控制失效。

4.124 MyBatis 有哪些动态 SQL 标签

标签 作用
<if test="..."> 条件成立才拼接这段 SQL
<choose>、<when>、<otherwise> 多选一,相当于 switch
<where> 内部有条件时才加 WHERE,并去掉开头多余的 AND、OR
<set> 用于 UPDATE,自动加 SET 并去掉末尾多余的逗号
<trim> 自定义前缀、后缀,以及要去掉的开头或结尾的内容,<where>、<set> 都是它的特例
<foreach> 遍历集合,用于 IN (...) 和批量插入
<sql>、<include> 定义和复用 SQL 片段
<bind> 定义变量,例如把模糊查询的参数拼上百分号
<select id="search" resultType="User">
  SELECT id, name, age FROM user
  <where>
    <if test="name != null and name != ''">AND name LIKE CONCAT('%', #{name}, '%')</if>
    <if test="minAge != null">AND age &gt;= #{minAge}</if>
    <if test="ids != null and ids.size() > 0">
      AND id IN
      <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
    </if>
  </where>
</select>

test 里的表达式用 OGNL 语法。XML 中的 <、> 要写成 &lt;、&gt;,或者放在 <![CDATA[ ]]> 中。

4.125 resultType 和 resultMap 有什么区别,一对多查询怎么写,什么是 N+1 问题

resultType resultMap
映射方式 列名和属性名一致时自动映射(可以开启 mapUnderscoreToCamelCase,把下划线列名映射到驼峰属性) 显式定义每一列映射到哪个属性
适用 简单查询 列名和属性名不一致、需要关联查询

关联查询:

关系 标签
一对一 <association>
一对多 <collection>

两种写法:

写法 做法 问题
嵌套结果 一条 JOIN 查询返回所有数据,resultMap 负责把重复的主表行合并成一个对象,子表行放进集合 结果行数多时传输量大;分页时要注意 JOIN 之后行数变多
嵌套查询 先查主表,再对每一条主表记录执行一次子查询 N+1 问题:查 100 个订单,额外再执行 100 次查询订单明细

N+1 的解决办法:改用嵌套结果的 JOIN 查询;或者先查出主表,再用 IN 一次性查出所有子表数据,在代码里组装;嵌套查询可以开启懒加载(lazyLoadingEnabled),用到关联对象时才查询,但 N+1 本身仍然存在,只是推迟了。

4.126 MyBatis 插件是怎么实现的,PageHelper 分页的原理是什么

MyBatis 允许拦截四大对象的方法:Executor、StatementHandler、ParameterHandler、ResultSetHandler。

@Intercepts(@Signature(type = StatementHandler.class, method = "prepare",
        args = {Connection.class, Integer.class}))
public class SlowSqlInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.nanoTime();
        try {
            return invocation.proceed();
        } finally {
            // 记录耗时,超过阈值时打印 SQL
        }
    }
}

原理:创建这四类对象时,MyBatis 依次调用每个插件的 plugin 方法,用 JDK 动态代理把对象一层层包起来,形成拦截链。调用被拦截的方法时,先进入插件的 intercept,invocation.proceed() 再调用下一层。

常见的插件:分页、慢 SQL 统计、数据权限(自动追加过滤条件)、字段加解密、多租户(自动加租户 ID 条件)、自动填充创建时间和修改时间。

PageHelper 的原理:

  1. PageHelper.startPage(1, 10) 把分页参数放进 ThreadLocal。
  2. 插件拦截 Executor 的查询方法,从 ThreadLocal 取出分页参数。
  3. 先根据原 SQL 生成并执行一条 count 查询,得到总数。
  4. 再按数据库方言改写原 SQL,追加 LIMIT,执行查询。
  5. 查询结束后清除 ThreadLocal。

坑:startPage 只对紧接着的第一条查询生效。如果调用 startPage 之后因为某个分支没有执行查询,ThreadLocal 里的分页参数会残留,被这个线程之后的某个无关查询误用。所以 startPage 和查询之间不要有任何条件分支。

另外,深度分页(LIMIT 1000000, 10)要先扫描并丢弃前 100 万行,非常慢,要改用「记住上一页最后一条的 ID」的游标分页:WHERE id > ? ORDER BY id LIMIT 10。

4.127 MyBatis 怎么批量插入,怎么拿到自增主键

方式 做法 特点
<foreach> 拼接多行 VALUES 一条 SQL 插入多行 简单、快;SQL 的长度受 MySQL 的 max_allowed_packet 限制,要分批,例如每批 500 到 1000 行
ExecutorType.BATCH 循环调用单条插入,JDBC 攒成一批一起发送 需要在 MySQL 连接串中加 rewriteBatchedStatements=true,驱动才会把多条语句改写成多行 VALUES,否则仍然是逐条发送
循环调用单条插入 每条一次网络往返 最慢,不要用
<insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">
  INSERT INTO user (name, age) VALUES
  <foreach collection="list" item="u" separator=",">(#{u.name}, #{u.age})</foreach>
</insert>

获取自增主键:在 <insert> 上设置 useGeneratedKeys="true" 和 keyProperty="id",插入后自动回填到对象的 id 字段;MySQL 下批量插入也能回填。

4.128 MyBatis 和 Spring 是怎么整合的,SqlSession 为什么是线程安全的

组件 作用
SqlSessionFactoryBean 一个 FactoryBean,用 Spring 管理的数据源构建 SqlSessionFactory
@MapperScan / MapperScannerConfigurer 扫描 Mapper 接口,为每个接口注册一个 MapperFactoryBean,生成的代理对象可以直接注入
SqlSessionTemplate 注入到 Mapper 代理里的 SqlSession 实现

原生的 DefaultSqlSession 不是线程安全的,只能在一个线程里用,用完就要关闭。SqlSessionTemplate 是线程安全的,可以作为单例共享,原因是它自己并不执行 SQL,每次调用都转给一个代理:

  • 如果当前线程有 Spring 事务,就从 TransactionSynchronizationManager 取出绑定在这个事务上的 SqlSession,同一个事务里的所有 SQL 共用它,也就共用同一个数据库连接。
  • 如果没有事务,就新建一个 SqlSession,执行完立即提交并关闭。

所以 MyBatis 能直接参与 Spring 的 @Transactional。Spring Boot 中,这些都由 mybatis-spring-boot-starter 自动配置好。

4.129 MyBatis 是怎么拿到数据库连接的,常见的连接池有哪些

MyBatis 不管理连接,连接池也不知道 MyBatis 的存在,两者通过 Spring 的 DataSource 和事务管理衔接。

自动配置 创建的 Bean
Spring Boot 的 DataSourceAutoConfiguration HikariDataSource。按 HikariCP、Tomcat JDBC、DBCP2、Oracle UCP 的顺序检测 classpath,Spring Boot 2.0 起默认是 HikariCP
DataSourceTransactionManagerAutoConfiguration DataSourceTransactionManager,负责 @Transactional
MyBatis 的 MybatisAutoConfiguration SqlSessionFactory 和 SqlSessionTemplate,使用上面那个 DataSource,事务交给 Spring 管理(SpringManagedTransactionFactory)

执行 SQL 时,MyBatis 通过 DataSourceUtils.getConnection(dataSource) 获取连接:

没有事务 在 @Transactional 方法里
什么时候借出连接 每次调用 Mapper 方法时向连接池借 进入事务时由 DataSourceTransactionManager 借出,设置 autoCommit=false,绑定到当前线程
多次调用 Mapper 每次各借一个连接,各自自动提交 共用同一个连接
什么时候归还 每次调用结束 事务提交或回滚之后

连接池交出去的是代理对象,close() 只是把连接放回池里,不会关闭物理连接。由此得出:**事务持续多久,连接就被占用多久。**在事务里调用远程服务、做大量计算,连接会长时间闲置却不能给别人用,并发一高连接池就被耗尽。远程调用要放在事务之外。

常见的连接池:

连接池 特点 现状
HikariCP 快、轻量,默认参数合理 Spring Boot 2.0 起的默认连接池
Druid 阿里开源,内置监控页面、SQL 统计、慢 SQL 记录、SQL 防火墙(WallFilter)、密码加密 国内使用多。监控页面要加鉴权或只在内网开放
Tomcat JDBC Pool Tomcat 自带 Spring Boot 1.x 的默认连接池
Commons DBCP2 Apache 的老牌连接池 存量项目
C3P0 很老 基本淘汰
Oracle UCP 支持 Oracle RAC 节点故障时的快速切换 用 Oracle 时考虑
Agroal Red Hat 开发 Quarkus 的默认连接池
r2dbc-pool 响应式连接池 WebFlux 加 R2DBC
MyBatis 的 POOLED 数据源 简单实现 只在不用 Spring、单独使用 MyBatis 时才会用到

HikariCP 快的原因:借连接时先从当前线程最近用过的连接里找,再用 CAS 从共享列表里抢(ConcurrentBag),大多数情况下不加锁;用 FastList 代替 ArrayList 记录打开的 Statement;代理类在构建期用 Javassist 生成,运行时不需要反射。

HikariCP 的主要参数(默认值属于实现相关):

参数 默认值 说明
maximum-pool-size 10 最大连接数。不是越大越好,官方的经验公式是 CPU 核数 × 2 + 磁盘数
minimum-idle 等于 maximum-pool-size 官方建议不设置,让连接池保持固定大小
connection-timeout 30 秒 拿连接最多等多久。线上常调短,快速失败
max-lifetime 30 分钟 连接的最长寿命,要比 MySQL 的 wait_timeout 以及中间代理的空闲超时都短,否则会拿到已被对端关闭的连接
leak-detection-threshold 0,即关闭 连接被借出超过这个时间还没归还,就打印借出它的调用栈,用来排查连接泄漏

容量规划:所有实例的连接数加起来不能超过数据库的 max_connections(MySQL 默认 151)。实例很多时,比如上百个 Pod 或 Serverless 函数,在数据库前面加 ProxySQL、PgBouncer、云厂商的 RDS Proxy 这类代理层连接池,由它复用到数据库的连接。

4.130 MyBatis-Plus 提供了什么,使用时要注意什么

MyBatis-Plus 在 MyBatis 的基础上做增强,不改变原有的用法。

功能 说明
通用 CRUD Mapper 继承 BaseMapper<T>、Service 继承 IService<T>,单表增删改查不用写 SQL
条件构造器 LambdaQueryWrapper 用方法引用写条件,字段改名时编译器能发现
分页插件 配置 PaginationInnerInterceptor 后,传 Page 对象即可自动分页
逻辑删除 @TableLogic 字段,删除时改成更新标记,查询时自动过滤已删除的数据
乐观锁 @Version 字段,更新时自动带上 WHERE version = ? 并把版本号加一
自动填充 插入、更新时自动设置创建时间、修改时间、操作人
代码生成器 根据表结构生成实体类、Mapper、Service、Controller
List<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
        .eq(User::getStatus, 1)
        .ge(User::getAge, 18)
        .orderByDesc(User::getCreateTime));
注意点 说明
条件为空时的全表操作 update、delete 的条件意外为空就会变成全表更新、全表删除。可以开启防全表更新与删除的插件
逻辑删除和唯一索引 删除后再插入同样的数据会违反唯一索引,需要把删除标记字段也加进唯一索引,或者删除时把标记设为 ID 或时间戳
复杂查询 多表关联、复杂统计仍然写 XML,不要把复杂逻辑硬塞进条件构造器
批量操作 saveBatch 默认按批执行单条插入,要配合 rewriteBatchedStatements=true 才快

4.131 Mapper 方法有多个参数时怎么传

方式 写法 说明
@Param 注解(推荐) User find(@Param("name") String name, @Param("age") int age);,XML 中写 #{name}、#{age} 清晰
传一个对象 List<User> search(UserQuery q);,XML 中直接写 #{name}(对象的属性名) 参数多时推荐,便于扩展
传 Map XML 中用键名引用 类型不明确,可读性差,不推荐
不加注解直接写参数名 编译时要加 -parameters 选项保留参数名;Spring Boot 的父 POM 默认已经开启 依赖编译配置,容易出问题
按位置引用 #{arg0}、#{param1} 可读性差
坑 说明
集合参数 只传一个 List 且不加 @Param 时,<foreach> 里要写 collection="list"(数组写 array);加了 @Param("ids") 就写 collection="ids"
对象参数加了 @Param @Param("q") UserQuery q 之后,XML 中要写 #{q.name}
动态 SQL 里的参数名 <if test="name != null"> 中的 name 同样受上面的规则约束

4.132 枚举、JSON 这类字段怎么映射,TypeHandler 怎么写

TypeHandler 负责 Java 类型和 JDBC 类型之间的转换:设置 SQL 参数时把 Java 对象转成数据库的值,读取结果时再转回来。

内置的枚举处理器 存储内容 问题
EnumTypeHandler(默认) 枚举名称,例如 PAID 改枚举名会导致旧数据无法解析
EnumOrdinalTypeHandler 序号 ordinal() 调整枚举常量的顺序会导致数据错乱,不要用

通常存一个业务上的 code,写一个自定义的处理器:

@MappedTypes(OrderStatus.class)
public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus s, JdbcType t) throws SQLException {
        ps.setInt(i, s.code());
    }
    @Override
    public OrderStatus getNullableResult(ResultSet rs, String col) throws SQLException {
        int v = rs.getInt(col);
        return rs.wasNull() ? null : OrderStatus.of(v);
    }
    @Override
    public OrderStatus getNullableResult(ResultSet rs, int idx) throws SQLException {
        int v = rs.getInt(idx);
        return rs.wasNull() ? null : OrderStatus.of(v);
    }
    @Override
    public OrderStatus getNullableResult(CallableStatement cs, int idx) throws SQLException {
        int v = cs.getInt(idx);
        return cs.wasNull() ? null : OrderStatus.of(v);
    }
}

注册方式:在配置中指定 mybatis.type-handlers-package 扫描整个包;或者在 resultMap 的列上、#{status, typeHandler=...} 里单独指定。

JSON 字段:用一个处理器在写入时把对象序列化成 JSON 字符串,读取时反序列化。MyBatis-Plus 自带 JacksonTypeHandler,在实体字段上加 @TableField(typeHandler = JacksonTypeHandler.class),同时在类上加 @TableName(autoResultMap = true),否则查询结果不会经过这个处理器。