java
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 反射是什么,有什么用,性能如何
反射是在运行时获取类的信息(字段、方法、构造函数、注解),并动态创建对象、调用方法、读写字段。
clazz ;
Object user ;
Method m ;
m.;
Field f ;
f.; // 访问 private 字段
f.;
获取 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 框架的客户端都靠它们实现。
UserService target ;
UserService proxy ,
);
proxy.;
| 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 实例,在类初始化时创建。枚举可以有字段、构造函数(只能是私有的)、方法,也可以实现接口,每个常量还可以有自己的方法实现。
枚举单例的优点:
| 优点 | 说明 |
|---|---|
| 线程安全 | 实例在类初始化时创建,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.; // 推荐
it ;
while
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
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;以及双重检查锁单例:
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 ;
lock.;
try finally
一般用 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 的非公平锁为例:
lock()先直接 CAS 把state从 0 改成 1,成功就把持有线程设为自己。- 失败时,如果持有者就是自己,
state加一(重入)。 - 否则包装成节点加入队尾并挂起。
unlock()把state减一,减到 0 时唤醒队列中的下一个节点,被唤醒的节点再去抢锁。
公平锁与非公平锁的区别只在于:公平锁抢锁前先检查队列里有没有人在排队(hasQueuedPredecessors),有就直接排队。非公平锁吞吐更高,因为刚释放锁时正在运行的线程可以直接拿到锁,省去唤醒排队线程的开销,代价是排队的线程可能等很久。
4.44 CAS 是什么,ABA 问题怎么解决,LongAdder 为什么比 AtomicLong 快
CAS(Compare And Swap):给定内存地址、期望值、新值,只有当前值等于期望值时才写入新值,整个过程是一条原子的 CPU 指令(x86 上是 lock cmpxchg)。Java 里通过 Unsafe 或 VarHandle 调用,原子类都基于它实现:
AtomicInteger counter ;
counter.; // 内部是「读当前值 → CAS 写入加一后的值 → 失败就重试」的循环
| 问题 | 说明 | 解决 |
|---|---|---|
| ABA | 值从 A 变成 B 又变回 A,CAS 认为没变过。对无锁链表、栈这类结构会造成错误 | AtomicStampedReference,同时比较值和版本号 |
| 自旋开销 | 竞争激烈时大量重试,浪费 CPU | 分散竞争(LongAdder),或改用锁 |
| 只能保证单个变量 | 多个变量要一起改时无能为力 | 合并成一个对象用 AtomicReference,或加锁 |
LongAdder:AtomicLong 让所有线程 CAS 同一个变量,竞争越激烈失败重试越多。LongAdder 维护一个 base 和一个 Cell 数组,竞争时不同的线程累加到不同的 Cell 上,求和时再把它们加起来。
代价是 sum() 不是精确的瞬时值。所以:高并发计数、统计用 LongAdder;需要精确读取、或者需要 CAS 语义(例如生成序号)时用 AtomicLong。
4.45 线程池的核心参数和执行流程是什么
;
提交任务后的处理顺序:
运行中的线程数 < 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 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 ;
for
latch.; // 设超时,避免永久等待
4.50 CompletableFuture 怎么用,有哪些坑
CompletableFuture 可以把异步任务串联、并联、组合起来,不用阻塞等待 Future.get()。
ExecutorService pool ; // 演示用,生产应显式创建线程池
userF ;
orderF ;
pageF ; // 异常或超时时降级
Page page ;
| 方法 | 作用 |
|---|---|
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 死锁是怎么产生的,怎么排查和避免
死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。最常见的情况是两个线程以相反的顺序获取两把锁。
排查:
jps找到进程号。jstack <pid>,输出末尾会直接报告Found one Java-level deadlock,并列出每个线程持有哪把锁、在等哪把锁。- 也可以用
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
| 注意点 | 说明 |
|---|---|
| 不要池化 | 创建成本很低,每个任务新建一个即可;要限制并发度时用 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
| 要点 | 说明 |
|---|---|
不要吞掉 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)。
工作窃取:每个工作线程有一个自己的双端队列。
| 操作 | 位置 |
|---|---|
| 线程把自己拆分出的子任务放进自己的队列 | 队头 |
| 线程从自己的队列取任务执行 | 队头,后进先出,最近拆出的任务数据还在缓存里 |
| 空闲线程从别的线程的队列偷任务 | 队尾,先进先出,偷到的通常是较大的任务,减少和队列主人的竞争 |
这样负载会自动在线程之间平衡,不需要一个全局的共享队列。
long sum ;
并行流 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 对象是怎么创建的,对象的内存布局是怎样的
创建过程:
- 类加载检查:检查类是否已加载,没有就先加载。
- 分配内存:堆内存规整时用「指针碰撞」,不规整时用「空闲列表」。为了避免多线程同时分配时的竞争,每个线程在 Eden 里预先分到一小块私有区域(TLAB),分配时只需移动自己的指针。
- 零值初始化:字段先设为 0、
null、false。 - 设置对象头:类型指针、哈希码、GC 分代年龄、锁信息。
- 执行构造函数
<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 杀掉 |
排查步骤:
- 启动参数预先加上
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path,OOM 时自动导出堆转储。 - 没有自动转储时,用
jmap -dump:live,format=b,file=heap.hprof <pid>或jcmd <pid> GC.heap_dump手动导出。注意live会先触发一次 Full GC。 - 用 MAT(Eclipse Memory Analyzer)或 VisualVM 打开,看「支配树」里占用内存最多的对象,再看它到 GC Roots 的引用链,就能找到是谁在持有它。
- 现场排查可以用 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 分析。
调优的思路:
- 先确认是 GC 的问题。 看 GC 日志里的停顿时间、频率、GC 耗时占总时间的比例。很多「性能问题」的根源是慢 SQL 或锁竞争。
- 先查代码和数据。 是否存在内存泄漏,是否一次加载了太多数据,是否频繁创建大对象。做法见 4.71 节。
- 再调参数。
- 先选合适的收集器:一般服务用 G1,延迟敏感的用 ZGC。
- 设置合理的堆大小,保证一次 Full GC 后老年代的占用不超过堆的一半左右。
- G1 一般只调停顿时间目标。
- 改一个参数、压测一次、对比一次。 每次只改一处,用数据验证效果。
常见现象和处理:
| 现象 | 常见原因 | 做法 |
|---|---|---|
| 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 容器就无法给它赋值,单元测试不方便,也容易掩盖循环依赖 |
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(默认 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
| 要点 | 说明 |
|---|---|
| 默认是同步的 | 监听器在发布者的线程里执行,监听器抛异常会影响发布者 |
| 异步执行 | 在监听方法上加 @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(跨源资源共享)的过程:
- 简单请求(
GET、POST加简单的请求头)直接发送,浏览器检查响应头Access-Control-Allow-Origin是否允许当前来源。 - 其他请求(例如带
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 的自动配置是怎么实现的
过程:
@EnableAutoConfiguration通过@Import引入AutoConfigurationImportSelector。- 它读取所有 jar 包中
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里列出的自动配置类。Spring Boot 2.7 引入这个文件;更早的版本写在META-INF/spring.factories里,Spring Boot 3.0 起不再从spring.factories读取自动配置类。 - 用条件注解过滤,只有条件满足的配置类才生效。
- 生效的配置类注册各自的 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 的步骤:
- 命名:官方 starter 叫
spring-boot-starter-xxx,第三方的应该叫xxx-spring-boot-starter。通常拆成xxx-spring-boot-autoconfigure(自动配置代码)和xxx-spring-boot-starter(只负责引入依赖)两个模块。 - 写配置属性类:
public record
- 写自动配置类:
- 在
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports中写上自动配置类的全限定名。 - 可选:加入
spring-boot-configuration-processor依赖,编译时生成配置元数据,用户在 IDE 里写配置时就有提示。
4.94 Spring Boot 的启动流程是怎样的
SpringApplication.run(App.class, args) 分两步:
一、创建 SpringApplication:
- 推断应用类型:普通应用、Servlet Web 应用,还是响应式 Web 应用。
- 从
spring.factories加载ApplicationContextInitializer和ApplicationListener。 - 推断启动类。
二、执行 run():
- 通知监听器「开始启动」。
- 准备
Environment:加载命令行参数、环境变量、配置文件。 - 打印 Banner。
- 根据应用类型创建
ApplicationContext。 - 准备上下文:执行
Initializer,把启动类注册为 Bean 定义。 - 刷新上下文
refresh(),这是核心:- 调用
BeanFactoryPostProcessor:解析配置类,执行组件扫描,处理自动配置,注册所有 Bean 定义。 - 注册
BeanPostProcessor。 onRefresh():创建内嵌的 Web 服务器。- 实例化所有非懒加载的单例 Bean,完成依赖注入、AOP 代理。
- 完成刷新:启动 Web 服务器,开始接收请求,发布刷新完成事件。
- 调用
- 调用所有
ApplicationRunner和CommandLineRunner。 - 发布「应用就绪」事件(
ApplicationReadyEvent)。
想在启动完成后执行初始化任务,用 ApplicationRunner 或监听 ApplicationReadyEvent。
4.95 配置文件的加载顺序和优先级是怎样的
同一个配置项出现在多个地方时,优先级从高到低(常用部分):
- 命令行参数,例如
--server.port=8081 - Java 系统属性,例如
-Dserver.port=8081 - 操作系统环境变量,例如
SERVER_PORT=8081 - jar 包外的
application-{profile}.yml - jar 包外的
application.yml - jar 包内的
application-{profile}.yml - jar 包内的
application.yml @PropertySource指定的文件- 代码中的默认值
规律是:外部的覆盖内部的,指定了 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
| 要点 | 说明 |
|---|---|
| 依赖 | 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 依赖的加载顺序
启动过程:
java -jar读取Main-Class,执行的是JarLauncher,而不是你的启动类。JarLauncher创建一个自定义的类加载器,它能读取「jar 里嵌套的 jar」,把BOOT-INF/classes和BOOT-INF/lib下的所有 jar 加入 classpath。- 用这个类加载器加载
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 接口像调用本地方法一样:
原理:
@EnableFeignClients扫描所有@FeignClient接口,为每个接口注册一个FeignClientFactoryBean。- 工厂用 JDK 动态代理为接口生成实现类。
- 调用方法时,代理根据方法上的 Spring MVC 注解(由
SpringMvcContract解析)构造请求模板:URL、请求方法、参数、请求头。 Encoder把参数编码成请求体。- 负载均衡器(Spring Cloud LoadBalancer)根据服务名
user-service从注册中心的服务列表中选一个实例。 - 底层 HTTP 客户端发送请求。默认用 JDK 自带的
HttpURLConnection,没有连接池,生产中通常换成 Apache HttpClient 或 OkHttp。 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 微服务怎么做到优雅上下线
下线:
- 先从注册中心注销这个实例(或者把它的权重调为 0)。
- 等待消费者的服务列表缓存刷新,通常需要几秒,让新的请求不再发过来。
- 关闭 Web 服务器,停止接收新请求;正在处理的请求继续处理完。Spring Boot 中设置
server.shutdown=graceful和spring.lifecycle.timeout-per-shutdown-phase。 - 关闭线程池、消息消费者、数据库连接池等资源。
在 Kubernetes 中,可以在 preStop 钩子里先注销实例并等待几秒,再让进程收到 SIGTERM。
Dubbo 的优雅停机依靠 JVM 的 ShutdownHook(在 Spring 中随容器关闭触发):先从注册中心注销,不再接收新请求,等正在处理的请求完成(最长等待时间由 dubbo.application.shutwait 控制,默认 10 秒,实现相关),再关闭连接。kill -9 不会触发 ShutdownHook,所以发布脚本要先发 SIGTERM(kill -15),等待超时后才强杀。也可以在发布前通过 Dubbo 的 QoS 端口执行 offline 命令手动下线。
上线:
- 启动后先完成预热:加载缓存,建立连接,让 JIT 编译热点代码。
- 就绪探针通过后再接收流量,或者通过注册中心的权重从小到大逐步放量。
4.114 网关怎么做统一鉴权
在 Spring Cloud Gateway 中写一个 GlobalFilter,所有路由都会经过它:
| 步骤 | 说明 |
|---|---|
| 白名单 | 登录、注册、公开接口直接放行 |
| 校验令牌 | 验证签名和过期时间。网关只持有公钥(RS256),私钥只在认证服务里 |
| 黑名单 | 需要立即失效的令牌,到 Redis 里查 jti。网关是响应式的,要用 ReactiveRedisTemplate,不能用阻塞的客户端 |
| 粗粒度的权限 | 按「路径 + 方法」检查接口权限,权限数据缓存在网关的本地缓存里 |
| 透传用户信息 | 把用户 ID、租户 ID 等放进请求头传给后端,同时删除客户端传来的同名请求头,防止伪造 |
| 统一的错误响应 | 返回 401 或 403 的 JSON |
后端服务通过拦截器从请求头读出用户信息,放进 ThreadLocal 供业务代码使用;服务之间用 Feign 调用时,用 RequestInterceptor 继续传递这些请求头。后端服务只能通过网关访问,不能把端口暴露给外部。
4.115 微服务的灰度发布怎么做
灰度发布:新版本先部署少量实例,只让一部分流量进来,观察没有问题再逐步扩大。
实现步骤:
- 给实例打标签:新版本的实例在 Nacos 注册时,元数据里带上
version=gray。 - 在入口决定一个请求是否走灰度,常见的规则:
- 请求头带灰度标记,例如测试人员手动加上;
- 用户 ID 在白名单里;
- 用户 ID 哈希后取模,落在某个比例区间内,例如 5%;
- 按地区、App 版本号。
- 在负载均衡时按标签选择实例:自定义 Spring Cloud LoadBalancer 的
ReactorServiceInstanceLoadBalancer,网关根据请求的灰度标记,只在version=gray的实例中选择;没有灰度实例时退回到正常实例。 - 全链路传递:网关把灰度标记放进请求头;后端服务调用下游时,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 表示。
ref ;
ref.; // 只需要接口名字符串
ref.;
ref.;
GenericService svc ; // 很重,必须缓存复用
arg ;
Object result ; // 返回的 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> |
提供者的配置随注册信息传给消费者,作为消费者的默认值。提供者最清楚自己的方法需要多长时间、是否幂等,所以超时和重试通常在提供者上按方法配置:
运行时生效的治理规则:通过 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 动态代理生成的。
- 启动时解析 XML 映射文件或注解,每条 SQL 生成一个
MappedStatement,以「命名空间 + SQL 的 id」为键存进Configuration。XML 的命名空间要写成接口的全限定名,SQL 的 id 写成方法名。 sqlSession.getMapper(UserMapper.class)由MapperProxyFactory生成代理对象,调用处理器是MapperProxy。- 调用
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, name, age FROM user
AND name LIKE CONCAT('%', #{name}, '%')
AND age >= #{minAge}
AND id IN
#{id}
test 里的表达式用 OGNL 语法。XML 中的 <、> 要写成 <、>,或者放在 <![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。
原理:创建这四类对象时,MyBatis 依次调用每个插件的 plugin 方法,用 JDK 动态代理把对象一层层包起来,形成拦截链。调用被拦截的方法时,先进入插件的 intercept,invocation.proceed() 再调用下一层。
常见的插件:分页、慢 SQL 统计、数据权限(自动追加过滤条件)、字段加解密、多租户(自动加租户 ID 条件)、自动填充创建时间和修改时间。
PageHelper 的原理:
PageHelper.startPage(1, 10)把分页参数放进ThreadLocal。- 插件拦截
Executor的查询方法,从ThreadLocal取出分页参数。 - 先根据原 SQL 生成并执行一条
count查询,得到总数。 - 再按数据库方言改写原 SQL,追加
LIMIT,执行查询。 - 查询结束后清除
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 INTO user (name, age) VALUES
(#{u.name}, #{u.age})
获取自增主键:在 <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 |
users ;
| 注意点 | 说明 |
|---|---|
| 条件为空时的全表操作 | 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,写一个自定义的处理器:
注册方式:在配置中指定 mybatis.type-handlers-package 扫描整个包;或者在 resultMap 的列上、#{status, typeHandler=...} 里单独指定。
JSON 字段:用一个处理器在写入时把对象序列化成 JSON 字符串,读取时反序列化。MyBatis-Plus 自带 JacksonTypeHandler,在实体字段上加 @TableField(typeHandler = JacksonTypeHandler.class),同时在类上加 @TableName(autoResultMap = true),否则查询结果不会经过这个处理器。
暂无评论,欢迎留下第一条评论。