本文最后更新于27 天前,其中的信息可能已经过时,如有错误请发送邮件到kirasu@qq.com
01-面向对象
一、面向对象 vs 面向过程
- 面向过程将任务拆解成一系列步骤,每个步骤对应一个函数,按顺序执行。
- 面向对象则先分析需求中有哪些参与者(对象),再明确每个对象各自需要做什么。
- 面向过程更注重事情的步骤和顺序,面向对象更注重需求中的对象及其职责划分。
- 面向过程直接高效,想到什么就做什么;面向对象需要先分析再设计,有一个分析过程。
- 面向对象更易于复用、扩展和维护,而面向过程在性能方面更有优势。
二、封装
- 封装的意义在于明确标识出允许外部使用的成员,内部细节对外部调用者透明。
- 封装让外部调用者只需知道方法怎么调用,无需关心内部具体实现。
- Java 中封装的经典场景之一:JavaBean 将属性私有化,通过 get/set 方法对外提供访问入口。
- JavaBean 封装可保证属性的赋值和获取逻辑(如命名规则)由类本身控制,防止外部随意修改。
- 封装的经典场景之二:使用 ORM 框架(如 MyBatis)时,无需关心连接建立、SQL 执行等细节,直接调用方法即可,框架封装了底层操作。
三、继承
- 继承是指子类通过 extends 关键字继承父类,复用父类的方法并可以做出自己的改变或扩展。
- 继承将多个子类的共性内容抽取到父类中,子类只需关注自己个性化的部分,达到代码复用、减少冗余的目的。
四、多态
- 多态的三个条件:存在继承关系、子类重写父类方法、父类引用指向子类对象。
- 多态的含义:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。
- 多态的好处:更换子类实现时,只需修改 new 的对象,调用方的代码无需改动,利于程序维护和扩展。
- 多态的弊端:无法调用子类特有的、非重写父类的方法,因为调用的方法必须在父类中有定义。
五、面试回答要点
- 回答“什么是面向对象”时,既要讲清面向对象与面向过程的区别,也要涵盖封装、继承、多态三大特性,否则只算答对了一小半。
02-JDK、JRE、JVM区别和联系
- JDK 全称是 Java Development Kit(Java 开发工具包),是提供给开发人员使用的。
- JRE 全称是 Java Runtime Environment(Java 运行时环境),是提供给仅需运行 Java 程序的普通用户使用的。
- JVM 是 Java 虚拟机,用于将 .class 文件解释成机器码,供操作系统执行。
- JDK 安装包中实际上已经包含了完整的 JRE。
- JRE 的核心包含两个目录:bin 目录(即 JVM)和 lib 目录(存放 Java 核心类库,如 rt.jar)。
- JDK = JRE + 开发工具(如 javac、java、jconsole、jdb 等)。
- 三者的包含关系为:JDK 包含 JRE,JRE 包含 JVM。
- Java 源文件(.java)先由 javac 编译成 .class 字节码文件,再由 JVM 解释执行。
- JVM 拥有 Windows、Linux 等多个操作系统版本,这是 Java 实现”一次编译,到处运行”的根本原因。
- “一次编译,到处运行”指的是同一份 .class 文件可以在不同操作系统的 JVM 上运行,而不是 JVM 本身可以到处运行。
- JVM 在解释 .class 文件时,会借助 lib 目录中的核心类库将字节码翻译成机器码,再映射并调用操作系统接口,最终让程序运行起来
03-==和equals
==用于基本数据类型时,比较的是变量值;用于引用类型时,比较的是引用地址(即堆中对象的地址)。- Java 中基本数据类型的变量直接在栈中分配内存。
- 引用类型的变量(如
String)在栈中存储的是指向堆中对象的引用地址。 - 用
new String()创建字符串时,对象在堆中分配内存,每次new都会生成一个新的内存地址。 equals()方法在Object类中的默认实现,本质上也是使用==比较引用地址。equals()方法可以被重写,实际开发中通常都会重写以实现自定义的比较逻辑。String类已经重写了equals()方法,比较的是字符串中的每一个字符是否完全相等。- 通过字面量方式(
String s = "hello")定义的字符串,会在常量池中分配内存。 - 通过
new String("hello")方式定义的字符串,则在堆中分配内存。 - 由已有字符串变量直接赋值(
String s3 = s2)传递的是引用地址,所以两个变量指向同一块内存。 - 用
==比较字面量字符串和new出来的字符串时,因为一个在常量池、一个在堆中,地址不同,结果为false。 - 用
==比较两个new出来的字符串,因地址不同,结果也为false。 - 因为
String重写了equals(),所以只要字符串内容相同,equals()比较结果一定为true,不受创建方式影响。
04-final
一、final 的作用
final关键字在 Java 中的含义是”最终的”,可以用来修饰类、方法和变量。final修饰类时,该类不能被继承(即不能有子类)。final修饰方法时,该方法不能被子类重写(覆盖),但可以存在重载(参数列表不同)。final修饰变量时,该变量一旦被赋值就不能再更改。final修饰普通成员变量时,必须在声明时、普通代码块中或构造器中完成初始化。final修饰静态成员变量(类变量)时,必须在声明时或静态代码块中完成初始化。final修饰局部变量时,系统不会为其默认初始化,必须由程序员显式赋值,且在使用前必须完成赋值(不要求声明时立即赋值)。final修饰基本类型变量时,该变量的数值一旦初始化后不能再修改。final修饰引用类型变量时,该变量初始化后不能再指向另一个对象(=不能再使用),但对象内部的属性值可以修改。
二、为什么局部内部类和匿名内部类只能访问 final 的局部变量
- 局部内部类和匿名内部类编译后会生成独立的 class 文件(如外部类名$1.class),与外部类属同一级别,不会随外部方法执行结束而被销毁。
- 外部方法执行结束后,其局部变量本应被回收,但内部类可能仍在运行(如内部类中启动了线程),此时内部类需要继续访问该变量。
- 为了解决上述矛盾,JVM 会将局部变量的值复制一份,作为内部类的成员变量保存,相当于延长了局部变量的生命周期。
- 但如果局部变量允许被修改,那么外部变量的值和内部类中复制的值可能不一致,导致程序逻辑错误。
- 为保证外部局部变量与内部类中复制的拷贝值始终一致,必须给该局部变量加上
final修饰,使其值不可变。 - 加
final是一种妥协方案:通过禁止修改局部变量,确保内部类中保存的拷贝与原始值永远保持一致,从而解决生命周期不一致带来的问题。
05-String、StringBuffer、 StringBuilder区别及使用场景
String由final修饰,是不可变类,每次对字符串进行操作(如拼接)都会生成一个新的对象。StringBuffer和StringBuilder都是在原对象上进行操作,不会生成新的对象。- 如果经常需要改变字符串内容,应优先使用
StringBuffer或StringBuilder,避免String频繁创建对象导致内存浪费。 StringBuffer是线程安全的,因为其每个方法都加了synchronized修饰。StringBuilder是线程不安全的,其方法没有加synchronized。- 线程安全与否,需要同时满足三个条件:多线程环境、共享变量、结果不受并发影响。
StringBuilder因为不需要加锁,性能比StringBuffer更好。String因为每次操作都创建新对象,性能在三者中最差。- 回答使用场景时,不应说”不考虑线程安全就用
StringBuilder,考虑线程安全就用StringBuffer“,因为实际开发中不可能不考虑安全和性能。 - 正确回答:所有场景中应优先使用
StringBuilder,只有当变量是共享变量且处于多线程环境下、需要保证结果正确时,才使用StringBuffer。 - 如果字符串不需要频繁修改,直接使用
String即可。
06-重载和重写的区别
- 重载(Overload)发生在同一个类中,重写(Override)发生在父子类之间。
- 重载要求方法名相同,参数列表不同(包括参数个数、类型或顺序不同)。
- 重载对方法的返回值和访问修饰符没有要求,可以不同。
- 仅返回值不同而参数列表相同的方法,不是重载,编译会直接报错。
- 重写要求子类方法与父类方法的方法名和参数列表必须完全一致。
- 重写时,子类方法的返回值类型范围可以小于等于父类方法的返回值类型。
- 重写时,子类方法抛出的异常范围可以小于等于父类方法抛出的异常范围。
- 重写时,子类方法的访问修饰符范围必须大于等于父类方法的访问修饰符范围。
- 父类中
private修饰的方法,子类无法重写(因为不可见,禁止重写)。 - 回答重载与重写的区别时,除了基本定义,还应涵盖返回值、异常、访问修饰符等细节,才能答得完善。
07-接口和抽象类
一、基础语法层面(初级程序员回答)
- 抽象类中除了抽象方法外,还可以有具体实现的方法(普通成员函数);而接口中的方法在 JDK 8 之前必须全部是抽象的。
- 抽象类只能单继承,接口可以多实现。
- 抽象类中的成员变量可以是任意类型;接口中的成员变量默认且只能是
public static final的常量(写不写修饰符都一样)。
二、设计目的层面(中高级程序员回答)
- 接口的设计目的是对类的行为进行约束,只规定类”能做什么”(有哪些方法),不关心具体如何实现。
- 抽象类的设计目的是代码复用,将多个子类的共性内容抽取出来形成父类。
- 抽象类的存在逻辑是”先有子类,再有父类”——先有多个子类,再将共性抽取成抽象父类。
- 抽象类不允许实例化,因为其中可能存在未实现的方法,调用时无法执行。
- 抽象类表达的是 “is-a”(是什么) 的关系,如”宝马是一台车”(BMW is a Car)。
- 接口表达的是 “like-a”(像什么) 的关系,如”鸟像一个飞行器”(Bird is like a Aircraft),但鸟本身不是飞行器。
- 抽象类是对事物本质的抽象,接口是对事物行为的约束。
- 当关注一个事物的本质时,使用抽象类;当只关注某个类能做什么(某个方法的功能)时,使用接口。
- 抽象类的功能比接口更强大(可以有实现方法),但定义抽象类的代价更高,因为只能单继承,需将所有子类的共性都编写在一个继承体系中。
- 接口在设计阶段更容易使用(多实现、低耦合),有助于降低设计难度;抽象类的设计难度相对更高。
08-List和Set
List是有序的,按对象进入的顺序保存元素;Set是无序的(不保证插入顺序,部分实现如LinkedHashSet可维护顺序)。List允许重复元素;Set不允许重复元素。List允许存放多个null元素;Set只允许存放一个null元素。List除了使用迭代器遍历外,还可以通过get(int index)方法进行下标访问(随机访问)。Set没有提供下标访问的方法,只能使用迭代器逐个遍历所有元素。- 回答该问题时,除了”有序/无序、可重复/不可重复”外,还应补充
null元素数量和遍历方式的差异,才能答得全面。
09-hashcode和equals
一、基本概念
equals()是 Java 提供给程序员用来定义两个对象”何时相等”的对比规则,可以被重写。equals()如果不做重写,默认使用Object中的实现,本质就是==,比较的是栈中的引用地址。hashCode()是用于获取对象的哈希码(散列码),定义在Object中,是一个native方法,返回一个int型整数。- 哈希码的作用是作为哈希表中的索引,通过索引可以快速定位对象在堆中的存储位置,实现快速检索。
二、HashSet 如何检查重复(hashCode 与 equals 的协作机制)
- 向
HashSet添加元素时,先根据hashCode()计算出哈希值,定位到哈希表中对应的索引位置。 - 如果该位置为空,说明没有重复元素,直接放入。
- 如果该位置已有元素,说明发生了哈希冲突,此时再调用
equals()判断两个对象是否真正相等。 - 不同的对象有可能计算出相同的
hashCode,这是散列算法本身的特性决定的。 - 如果
hashCode相同但equals()判断为不相等,则会通过重新散列(如加盐)计算新位置,确保元素不重复。 - 如果
hashCode不同,则直接放入,无需调用equals(),大大减少了equals()的执行次数。 hashCode()的散列算法性能远高于equals()的内存地址对比,因此这套机制能大幅提升程序执行速度。
三、必须遵守的约定(面试回答要点)
- 如果两个对象相等(
equals()返回true),则它们的hashCode()必须相同。 - 两个对象
equals()相等,分别调用equals()必然返回true。 - 两个对象有相同的
hashCode,它们不一定相等(哈希冲突)。 - 如果重写了
equals(),则必须重写hashCode(),否则在HashSet、HashMap等散列集合中会出现逻辑错误。 - 如果没有重写
hashCode(),Object默认实现会为每个对象生成独特的索引值,即使两个对象的成员数据完全相同,它们也不会被认为相等。 - 如果希望两个对象在成员数据相同时被视为相等,就必须同时重写
hashCode()和equals()。
10-ArrayList和LinkedList区别
一、底层数据结构
ArrayList底层是基于动态数组实现的(本质是由普通数组演化而来),LinkedList底层是基于双向链表实现的。- 数组在内存中是连续存储的,因此申请内存时必须有一块连续的空间;链表在内存中是分散存储的,可以利用碎片空间。
二、访问性能差异
ArrayList适合下标访问(随机访问),因为数组是连续存储且元素类型一致,通过”元素长度 × 下标”即可快速定位。LinkedList没有下标访问优势,需要根据指针逐个节点遍历才能找到目标元素,因此遍历效率较低。
三、插入与删除性能的真正分析
- 链表插入/删除只需断开并重新建立链路,不需要移动元素,因此操作本身很快。
- 数组插入/删除(非尾部)需要将后续元素整体拷贝移动,元素越多性能越低。
- 数组尾部插入(尾插法)不需要移动元素,性能很高。
ArrayList的扩容机制:当容量不足时,会新建一个容量更大的数组,将老数组数据拷贝到新数组,再添加新元素,老数组被回收。ArrayList可以通过构造器指定初始容量来减少扩容次数,避免不必要的性能开销。- 如果
ArrayList指定了合适的初始容量并采用尾插法,其插入性能甚至可能超过LinkedList。
四、LinkedList 的额外性能消耗
LinkedList每插入一个元素都会创建一个Node对象来维护该元素,大量插入时对象创建消耗很大。ArrayList直接存储元素本身,不需要额外创建节点对象。
五、LinkedList 的使用注意点
- 遍历
LinkedList时应使用迭代器(iterator),不应使用for循环配合get(i),因为get(i)是下标访问,链表需要逐个遍历,效率极低。 - 使用
LinkedList的indexOf()方法时,如果元素不存在,会遍历整个列表后才返回 -1,性能消耗大。 - 大部分场景下优先推荐使用
ArrayList,LinkedList有很多使用上的不便之处。
六、面试回答总结
- 回答该题时,除了”数组查询快插入慢、链表插入快查询慢”的常识外,还应涵盖内存结构、扩容机制、对象创建消耗、遍历方式差异等细节,才能体现扎实的 Java 基础。
11-HashMap和HashTable的区别及底层实现
一、HashMap 与 HashTable 的区别
HashTable中的每个方法都加了synchronized修饰,是线程安全的;HashMap没有加锁,是线程不安全的。HashTable因全部方法加锁,效率较低,目前已不推荐使用,取而代之的是ConcurrentHashMap。HashMap允许key和value为null,HashTable不允许key或value为null。
二、HashMap 的底层数据结构(JDK 1.8)
HashMap底层采用数组 + 链表(或红黑树)实现,内部元素以Node节点形式存在。- 当链表长度达到 8 且数组长度超过 64 时,链表会转化为红黑树,以提升查询效率。
- 当红黑树节点数量减少到低于 6 时,红黑树会退化为链表。
三、HashMap 的存数据(put)流程
- 存入元素时,先计算
key的hashCode,再经过二次哈希,最后对数组长度取模,得到数组下标。 - 如果该数组下标位置为空,直接放入该位置(此时无链表)。
- 如果该位置已有元素(哈希冲突),则调用
equals()判断两个 key 是否相等:相等则覆盖旧值,不等则形成链表挂在数组该位置下。 - 如果
key为null,直接存放到数组下标为 0 的位置(因为null无法进行哈希计算)。
四、HashMap 的取数据(get)流程
- 根据
key计算哈希值定位到数组下标,若该位置是链表,则逐个遍历节点,通过equals()比较找到对应的 key 并返回其 value。 - 链表过长时查询效率低(线性遍历),这是 JDK 8 引入红黑树进行优化的原因。
五、HashMap 的扩容机制
HashMap底层数组是静态数组,长度固定,当元素数量超过阈值时会触发扩容。- 扩容时新建一个容量更大的数组,将老数组中的数据重新计算位置并迁移到新数组,再将新元素放入。
- 扩容因子的作用:根据当前容量和负载因子计算扩容阈值,控制扩容时机以平衡空间和性能。
12-ConcurrentHashMap原理简述,jdk7和jdk8
一、ConcurrentHashMap 整体概述
ConcurrentHashMap是线程安全版本的HashMap,比HashTable效率更高,因此实际开发中优先使用它来保证线程安全。HashTable使用全局锁(所有方法加synchronized),而ConcurrentHashMap采用更细粒度的锁机制来提升并发性能。
二、JDK 1.7 中的 ConcurrentHashMap 原理
- JDK 1.7 中底层采用 Segment + HashEntry 的结构,
Segment继承自ReentrantLock,一个Segment包含一个HashEntry数组,每个HashEntry又是一个链表(数组加链表)。 ConcurrentHashMap中维护了多个Segment(段),每个Segment独立加锁,这就是分段锁机制。- 分段锁的核心思想:根据
key定位到对应的Segment,只锁定该Segment,其他Segment不受影响,可正常并发访问。 - 并发度即为
Segment的数量,决定了可以同时支持多少个线程并发操作。 - 数组扩容时只影响当前
Segment,其他Segment不受影响,可正常读写,提升了整体性能。 - 元素查询需要两次哈希:第一次哈希定位到
Segment,第二次哈希定位到该Segment中数组的下标(链表头部)。 get()读取操作无需加锁,通过volatile保证可见性,不会读到脏数据。
三、JDK 1.8 中的 ConcurrentHashMap 原理
- JDK 1.8 中不再使用
Segment和ReentrantLock,改用 synchronized + CAS 的组合来实现线程安全。 - 数据结构变为 数组 + 链表 + 红黑树(与 JDK 1.8 的
HashMap一致),链表过长时转化为红黑树以优化查询效率。 - 锁的粒度更细:只锁定链表或红黑树的头节点(head node),不影响其他节点的读写。
- 查找、替换、赋值等操作优先使用 CAS(乐观锁),性能远高于
synchronized和ReentrantLock。 - 在扩容、哈希冲突等 CAS 无法保证安全的场景下,才使用
synchronized加锁。 get()读取操作同样无锁,通过volatile修饰value和next等字段保证可见性,确保不会读到脏数据。- 数组本身也使用
volatile修饰,扩容时能让读线程及时感知,避免读到脏数据。 - 扩容时会阻塞所有读写操作,但扩容本身是并发进行的。
四、JDK 1.7 与 JDK 1.8 的主要区别总结
- 锁机制不同:JDK 1.7 使用
ReentrantLock(分段锁),JDK 1.8 使用synchronized + CAS。 - 数据结构不同:JDK 1.7 为 Segment + HashEntry 数组 + 链表,JDK 1.8 为 Node 数组 + 链表 + 红黑树。
- 锁粒度不同:JDK 1.7 锁的是整个
Segment(一个段包含多个链表),JDK 1.8 锁的是单个链表或红黑树的头节点,锁粒度更细,并发度更高。 - JDK 1.8 采用 CAS 乐观锁,在并发不激烈时性能远优于
ReentrantLock和synchronized。
13-如何实现一个IOC容器
- 面试问“如何实现一个 IOC 容器”,实际是考察对 Spring 中 IOC 容器原理的理解,只需说出大体思路即可,不需要面面俱到。
- 第一步:编写一个配置文件,在配置文件中指定包扫描路径。
- 第二步:根据包扫描路径,递归读取该路径下所有的
.class文件。 - 第三步:通过反射解析这些
.class文件,找出需要交给 IOC 容器管理的类(根据自定义注解如@Controller、@Service、@Repository等来识别)。 - 第四步:定义一个
Map作为 IOC 容器,将符合条件的类实例化后存入该Map中(key 为类名或 bean 名称,value 为实例对象)。 - 第五步:遍历 IOC 容器中的实例,检查每个实例是否依赖其他类的实例,如果有则进行递归的依赖注入。
- 更详细的描述可以包括:定义自定义注解(如
@Autowired用于依赖注入、@Value用于读取配置文件等)。 - 更复杂的实现还会涉及循环依赖的处理、接口注入等问题,但面试时简单回答上述几步即可,避免给自己挖坑。
14-什么是字节码,作用是什么
- Java 程序并不是直接运行在操作系统上的,而是运行在 JVM(Java 虚拟机)之上,因此必须先安装 JDK/JRE 才能运行 Java 程序。
- 字节码(Bytecode)就是 JVM 能够理解的代码,即
.class文件,它不面向任何特定的处理器或操作系统,只面向虚拟机。 - Java 源文件(
.java)先通过javac编译器编译成字节码(.class文件),再由 JVM 中的解释器将字节码翻译成特定系统的机器码并执行。 - 不同操作系统的解释器实现不同(Windows 版、Linux 版),但 JVM 对外提供的接口是一致的,因此同一份字节码可以在不同平台上运行。
- 字节码带来的最大好处是跨平台(一次编译,到处运行):字节码无需重新编译即可在不同平台的 JVM 上运行。
- 字节码的另一个好处是提升执行效率:传统的解释型语言是”解释一句执行一句”,编译过程占用运行时间;而 Java 将编译和解释拆成两步,编译在开发阶段提前完成,不占用程序运行时间,执行效率更高。
- Java 既不是纯编译型语言,也不是纯解释型语言,而是编译与解释并存的语言。
15-java类加载器有哪些
一、JDK 自带的三个类加载器
- JDK 自带三个类加载器:
Bootstrap ClassLoader(启动类加载器)、Extension ClassLoader(扩展类加载器)、Application ClassLoader(应用程序类加载器,也称系统类加载器)。 Bootstrap ClassLoader是顶层类加载器,负责加载JAVA_HOME/lib目录下的核心类库(如rt.jar)。Extension ClassLoader负责加载JAVA_HOME/lib/ext目录下的扩展类库。Application ClassLoader负责加载classpath路径下的类文件,包括程序员自己写的代码和引入的第三方依赖 jar 包。Application ClassLoader是默认的系统类加载器,可通过ClassLoader.getSystemClassLoader()获取。
二、类加载器之间的关系
- 类加载器之间的”父类”关系不是继承关系,而是通过每个类加载器内部的
parent属性维护的。 Bootstrap ClassLoader是Extension ClassLoader的父类加载器,Extension ClassLoader是Application ClassLoader的父类加载器。
三、自定义类加载器
- 自定义类加载器需要继承
ClassLoader类,并重写相关方法。 - 如果需要符合 Java 本身的双亲委派机制,自定义类加载器应将
parent指定为Application ClassLoader。
四、线程上下文类加载器
Application ClassLoader同时也是线程上下文类加载器,它贯穿于三层类加载器之间,每一层都可以访问它。
16-双亲委派模型
一、双亲委派模型的核心流程
- 双亲委派模型包含两个方向的动作:向上委派(查找缓存)和向下查找(查找加载路径)。
- 当一个类加载器收到类加载请求时,不会立即自己去加载,而是先向上委派给父类加载器去查找缓存。
- 向上委派会一直持续到顶层的
Bootstrap ClassLoader,逐级查找各自缓存中是否已加载过该类。 - 如果缓存中找到该类,直接返回,不再重复加载。
- 如果顶层
Bootstrap ClassLoader的缓存和加载路径中都没有找到该类,则开始向下查找,逐级到各个加载器的加载路径中查找。 - 向下查找会一直持续到最初发起加载请求的类加载器为止。
二、双亲委派模型的好处
- 保证安全性:防止核心类库被恶意替换。例如,用户自定义的
java.lang.String类不会被加载,因为向上委派到顶层时发现该类已在rt.jar中加载过。 - 在 Java 中,所有以
java.开头的自定义类都不允许被加载,这是 JDK 源码中写死的安全限制。 - 避免类的重复加载:通过向上委派机制,确保同一个类在整个 JVM 中只被加载一次。
- JVM 中唯一确定一个类的方式是 “类的全限定类名 + 加载该类的类加载器”,两者共同作为唯一标识。
- 即使同一个
.class文件,被不同的类加载器加载,在 JVM 中也会被视为两个不同的类。
17-java中的异常体系
- Java 中所有异常类的顶级父类是
Throwable。 Throwable有两个直接子类:Exception和Error。Error是程序无法处理的错误(如OutOfMemoryError),一旦出现程序必然崩溃终止。Exception是程序员可以处理、可以避免的错误,不会导致整个程序终止,只会导致当前线程执行失败。Exception又分为两大类:RuntimeException(运行时异常)和CheckedException(检查异常,也称编译时异常)。CheckedException在编译阶段就会被编译器检测到,导致编译不通过(如语法错误、类型赋值错误),现代 IDE 通常会在编码时直接提示。RuntimeException发生在程序运行过程中(如空指针异常、数组下标越界),只会导致当前线程执行失败,不影响其他线程。- 运行时异常是程序员需要主动去处理和避免的(如通过
try-catch或代码逻辑预防)。 - 程序员接触最多的是
RuntimeException,自定义异常时通常也选择继承RuntimeException。
18-GC如何判断对象可以被回收
一、判断对象可回收的两种算法
- 引用计数法:每个对象维护一个引用计数器,新增引用时加一,释放引用时减一,计数为零时表示可被回收。
- 引用计数法存在循环引用缺陷(如 A 引用 B、B 引用 A),导致两个对象计数器永远不为零,无法被回收。
- 引用计数法的优点是效率高,只需简单操作计数器即可判断,但 Java 并未采用此方法。
- Java 采用可达性分析来判断对象是否可回收:从
GC Root对象向下搜索,能搜索到的对象存活,搜索不到的对象可被回收。
二、GC Root 的对象类型
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,如方法中
new出来的局部变量,当方法处于活动栈帧时该对象即为GC Root。 - 方法区中类的静态属性(
static修饰的类属性)引用的对象。 - 方法区中的常量(
static final修饰的常量)引用的对象。 - 本地方法栈中 JNI(Native 方法)引用的对象,由 JVM 自动调用,不可被回收。
三、可达性分析的两轮标记与 finalize() 机制
- 可达性分析并非一次就回收对象,而是经过两次标记。
- 第一次标记:可达性分析发现对象到
GC Root不可达时,进行第一次标记。 - 第二次标记:虚拟机将对象放入
F-Queue队列,判断该对象是否覆盖了finalize()方法。 - 如果对象没有覆盖
finalize()方法,第二次标记后直接回收。 - 如果对象覆盖了
finalize()方法且该方法尚未执行,则会执行finalize()方法(且finalize()只会执行一次)。 - 在
finalize()方法中,如果对象重新与GC Root建立引用关系,则对象复活,不会被回收;否则最终被回收。 finalize()方法运行代价高且不推荐使用,实际开发中基本不会覆盖它。
19-线程的生命周期及状态
- 线程的生命周期和线程状态本质上是同一个问题,回答时需将状态与生命周期阶段对应起来。
- Java 线程通常分为五种状态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Terminated)。
- 新建状态:使用
new关键字创建了一个线程对象,但尚未调用start()方法。 - 就绪状态:调用了线程的
start()方法后,线程进入可运行状态,等待 CPU 分配时间片。 - 运行状态:线程获得了 CPU 时间片,正在执行
run()方法中的程序代码。 - 死亡状态:线程的
run()方法执行完毕,或因异常退出,线程生命周期结束。 - 阻塞状态:线程因某种原因放弃了 CPU 使用权,暂时停止运行,待条件满足后重新进入就绪状态。
- 等待阻塞:线程执行了
Object.wait()方法,释放所有占用的资源(包括锁),进入等待池,需其他线程调用notify()或notifyAll()才能唤醒。 - 同步阻塞:线程在竞争
synchronized锁时未能抢到锁,被 JVM 放入锁池中等待。 - 其他阻塞:线程执行
Thread.sleep()、Thread.join()或发起 I/O 请求时进入阻塞状态,待 sleep 超时、join 线程终止或 I/O 完成后重新转入就绪状态。 - 注意:
wait()是Object类的方法,sleep()是Thread类的方法,两者所属类不同。
20-sleep、 wait、join、 yield
一、前置概念:锁池与等待池
- 锁池:所有需要竞争同步锁的线程,如果没抢到锁,就会被 JVM 放入锁池中等待。
- 等待池:线程在执行
synchronized同步代码块时调用了wait(),会释放锁并进入等待池,等待池中的线程不会去竞争锁。 notify()会从等待池中随机选一个线程移到锁池中,notifyAll()会将等待池中所有线程移到锁池中,使其重新参与锁竞争。
二、sleep() 与 wait() 的六点区别
- 所属类不同:
sleep()是Thread类的静态本地方法,wait()是Object类的本地方法。 - 是否释放锁不同:
sleep()不会释放锁,带着锁进入冻结状态;wait()会释放锁,并进入等待池。 - 是否依赖同步器不同:
sleep()不依赖synchronized,可在任何地方调用;wait()必须与synchronized配套使用。 - 是否需要唤醒不同:
sleep()超时后自动唤醒;无参wait()需要被notify()或notifyAll()唤醒。 - 使用目的不同:
sleep()用于当前线程休眠或轮询暂停;wait()用于多线程之间的通信。 - 上下文切换不同:
sleep()一定会让出 CPU 并强制切换上下文;wait()不一定会切换上下文,因为可能马上被唤醒并重新获取 CPU。
三、yield() 的理解
yield()执行后,线程从运行状态进入就绪状态(不是阻塞状态),释放 CPU 执行权但保留执行资格,下次 CPU 调度时可能继续运行。
四、join() 的理解
join()是让当前线程进入阻塞状态,等待被调用的线程执行完毕后再继续执行当前线程。join()的典型用法:在 B 线程中调用 A 线程的join(),则 B 线程阻塞,直到 A 线程执行完,B 才继续执行。- 使用
join()前必须先调用start()启动线程,否则无效。 - 示例:在
main线程中调用t1.join(),则main线程会阻塞,直到t1执行完才会打印main的内容。
21-对线程安全的理解
- 所谓的”线程安全”,本质上指的是内存安全,因为线程本身没有安不安全之说,安全与否取决于内存是否被正确访问。
- 线程安全的专业定义:当多个线程访问同一个对象(堆中的共享内存),无需额外的同步控制就能获得正确的结果,则该对象是线程安全的。
- “正确的结果”指的是多线程执行的结果与单线程执行的结果一致,即达到预期效果。
- 堆(Heap) 是 JVM 管理的内存中最大的一块,在虚拟机启动时创建,被所有线程共享,所有对象实例和数组都在堆中分配。
- 在操作系统层面,堆是进程初始化时分配的内存空间,进程内的所有线程共享该堆。
- 正是因为堆是多个线程共享的内存区域,当多线程并发操作堆中的同一对象时,就可能出现结果不一致的问题,这就是线程安全问题的根源。
- 栈(Stack) 是每个线程独有的内存空间,保存线程的运行状态和局部变量,在线程启动时初始化。
- 每个线程的栈互相独立,因此栈是线程安全的,不存在多线程并发访问的问题。
- 操作系统在切换线程时会自动切换对应的栈空间,栈的分配和释放无需程序员显式操作,方法执行完毕栈帧自动弹出并回收。
- 主流操作系统都是多任务的,每个进程只能访问分配给自己的内存空间,这是操作系统自身保证的安全隔离。
22-Thread和Runnable
Thread是一个类,Runnable是一个接口,Thread本身实现了Runnable接口。- 两者的核心区别是:Java 只能单继承但可以多实现,因此用
Runnable更灵活,而Thread受限于单继承。 - 网络上流传的”
Thread对共享变量操作不友好,Runnable更友好”的说法是错误的,两者对共享资源的操作能力没有本质区别,问题在于使用方式。 - 错误示例:用两个独立的
Thread实例各持有一个独立的ticket变量(非 static),每个实例各有 5 张票,结果自然会卖出两倍票数。 - 正确示例:如果让多个
Thread共用同一个Runnable实例,则所有线程共享同一个ticket变量,结果才正确。 - 真正保证线程安全的方式是使用
static变量加synchronized锁,而不是依赖选择Thread还是Runnable。 - 使用上的建议:如果只是简单地执行一个任务,用
Runnable更简单轻量;如果需要复杂的线程操作(如丰富的 API 功能),用Thread更合适。 - 无论用哪种方式,最终都必须将
Runnable实例传给Thread对象才能启动线程。
23-说说你对守护线程的理解
- Java 中只有两种线程:守护线程(Daemon Thread)和用户线程(User Thread,也称非守护线程)。
- 守护线程是为所有的非守护线程(用户线程)提供服务的,它是整个 JVM 中所有用户线程的”保姆”,而不是专属于某一个线程。
- 守护线程的生死无关紧要:当所有用户线程全部结束时,JVM 会直接终止守护线程,不管它是否执行完毕。
- 守护线程不能用于执行重要的业务逻辑(如文件操作、数据库更新、IO 读写等),因为它随时可能被中断,导致操作不完整。
- 守护线程的典型应用场景是垃圾回收(GC)线程,当所有用户线程退出、不再产生垃圾时,GC 线程也会自动终止。
- 守护线程适合用于需要在程序结束时能够立刻关闭的后台支持服务。
- 设置守护线程的方法:调用
thread.setDaemon(true),但必须在start()方法之前调用,否则会抛出IllegalThreadStateException。 - 守护线程中创建的子线程默认也是守护线程。
- Java 自带的线程池(如
ThreadPoolExecutor)会将传入的守护线程转换为用户线程,因此使用线程池时无法通过设置守护线程来实现后台运行。
24-ThreadLocal的原理的使用场景
一、ThreadLocal 的数据结构
- 每个
Thread对象内部都有一个ThreadLocalMap类型的成员变量(名为threadLocals),用于存储该线程所有的ThreadLocal数据。 ThreadLocalMap是ThreadLocal的内部类,其本质是一个类似于 HashMap 的容器。ThreadLocalMap中存储的是Entry(键值对),其中 key 是ThreadLocal对象本身,value 是我们要存储的线程变量值。Entry继承自WeakReference(弱引用),key(即ThreadLocal对象)是弱引用的。
二、ThreadLocal 的 set() 和 get() 原理
- 执行
set()方法时,先获取当前线程对象,再从当前线程中取出ThreadLocalMap,然后以当前ThreadLocal对象为 key,将值存入 Map 中。 - 执行
get()方法时,同样先获取当前线程对象,取出ThreadLocalMap,再以当前ThreadLocal对象为 key 获取对应的值。 - 每个线程都有自己的
ThreadLocalMap,线程之间互不影响,因此ThreadLocal天然是线程安全的,无需同步机制。
三、ThreadLocal 的弱引用与内存泄漏
Entry中的 key 是弱引用,当ThreadLocal对象没有强引用指向它时,会被垃圾回收器回收,此时 key 变为null。- 但如果线程一直存活(如线程池中的线程),key 为
null的Entry无法被自动清除,可能导致内存泄漏,因此使用完ThreadLocal后应主动调用remove()方法。
四、ThreadLocal 的使用场景
- 跨层传递参数:在 Controller → Service → DAO 等多层之间传递参数时,使用
ThreadLocal可以避免每个方法都显式加形参,简化代码。 - 线程间数据隔离:只有当前线程能访问自己
ThreadLocalMap中的数据,其他线程无法访问,实现数据隔离。 - 事务管理:Spring 框架将数据库连接(JDBC Connection)和事务信息存储在
ThreadLocal中,因为事务是与当前线程绑定的,同一线程中的各个方法都可以方便地获取连接。 - 类似场景还包括:数据库连接、Session 会话等线程级别的资源,都适合放在
ThreadLocal中管理。
25-ThreadLocal内存泄露问题,如何避免
一、内存泄漏的概念
- 内存泄漏:不再被使用的对象占用的内存无法被 JVM 回收,这种现象称为内存泄漏。
- 单次内存泄漏影响不大,但持续累积最终会导致 OOM(内存溢出)。
二、强引用与弱引用的区别
- 强引用:通过
new关键字创建的对象都是强引用,JVM 宁愿抛出 OOM 也不会回收强引用对象,除非手动将对象赋值为null。 - 弱引用:通过
WeakReference创建的对象为弱引用,只要 GC 执行,无论内存是否充足,弱引用对象都会被回收。
三、ThreadLocal 内存泄漏的原因
ThreadLocalMap中的Entry的 key(即ThreadLocal对象)是弱引用,value 是强引用。- 当外部对
ThreadLocal对象的强引用被置为null后,GC 会回收 key(弱引用),此时 key 变为null,但 value 仍然以强引用方式存在于Entry中。 - 当 key 为
null时,value 无法被访问到,但如果当前线程一直存活(如线程池中的线程),value 的强引用链就一直存在,导致无法回收,这就是ThreadLocal内存泄漏的根源。 - 只要线程不结束,
ThreadLocalMap就会一直存活,其内部的nullkey 对应的 value 就无法被回收。
四、为什么 key 设计为弱引用
- 如果 key 设计为强引用,即使外部将
ThreadLocal置为null,ThreadLocalMap仍然强引用着它,ThreadLocal永远无法回收,内存泄漏更严重。 - 使用弱引用后,key 可以被 GC 回收变为
null,只要后续调用set()、get()或remove()方法,ThreadLocalMap会自动清理nullkey 对应的 value。
五、如何避免 ThreadLocal 内存泄漏
- 每次使用完
ThreadLocal后必须手动调用remove()方法,清除当前线程中的ThreadLocalMap数据,这是最核心的避免方式。 - 将
ThreadLocal变量定义为private static,使其持有强引用,不会轻易被 GC 回收,可以随时通过它访问并清理对应的 value。 - 综合以上两点:将
ThreadLocal定义为private static,并在每次使用后主动调用remove()方法,即可有效避免内存泄漏。
26-并发、并行、串行
- 串行:多个任务按顺序一个接一个执行,前一个任务未完成,后一个任务只能等待。
- 并行:多个任务在同一时间点同时执行,互不干扰(通常依赖于多核 CPU)。
- 并发:多个任务交替执行,任务之间可以相互穿插,但在同一时间点上只有一个任务在运行。
- 并发与串行的区别:串行是一个任务彻底执行完才执行下一个;并发是任务交替执行,执行到一半可以切换到另一个任务再切回来。
- 对于单核 CPU 而言,并发本质上是串行执行,只是通过时间片轮转实现了交替运行。
27-并发三大特性
一、原子性(Atomicity)
- 原子性:一个操作或多个操作要么全部执行且执行过程不被中断,要么全部不执行,是不可分割的整体。
- 以
i++为例,它实际对应四步操作:①从主存读取 i 到工作内存 → ②CPU 执行 i+1 运算 → ③将结果写回工作内存 → ④将工作内存的值刷回主存(第④步由操作系统决定时机)。 - 如果不保证原子性,CPU 可能在执行
i++的中间步骤时发生线程切换,导致多个线程各自读到相同的初始值,最终结果错误(如两个线程各加一次,结果却只加了一次)。 - 保证原子性意味着执行
i++的前三步操作时,CPU 不会发生线程切换,必须全部执行完才允许切换。 i++在并发下线程不安全,就是因为其操作分多步执行,无法保证原子性。
二、可见性(Visibility)
- 可见性:当一个线程修改了共享变量的值,其他线程能够立即看到修改后的最新值,而不是读到过期的缓存副本。
- 操作系统底层通过总线锁定和MESI(缓存一致性协议) 来保证可见性。
- 缓存一致性协议保证:当一个线程将修改后的值写回工作内存和主存时,其他线程工作内存中的对应缓存行会立即失效。
- 仅保证可见性仍不能保证线程安全:因为其他线程虽然能看到变量已被修改,但可能已经在自己的缓存中完成了旧值的运算,结果仍可能错误。
- 在
i++场景中,必须同时保证原子性和可见性(即把四步操作合并为一个不可中断的整体),才能实现线程安全。
三、原子性 + 可见性的实践体现
AtomicInteger类通过volatile(保证可见性和有序性)+ CAS(保证原子性) 的组合,实现了i++的线程安全。
四、有序性(Ordering)
- 有序性:指程序执行顺序可能和代码编写顺序不一致,JVM 和 CPU 为了优化性能会对指令进行指令重排。
- 指令重排的原则:在单线程环境下,重排后不会改变程序的执行结果(即 as-if-serial 语义)。
- 指令重排在单线程下没有问题,但在多线程下可能产生问题:一个线程执行了部分操作(如将 flag 设为 true),另一个线程立即读取该 flag 并继续执行,但前一个线程的后续操作(如 a=2)尚未完成,导致读取到错误的数据。
- 保证有序性的方式:使用
synchronized锁(可同时保证三大特性),或使用volatile关键字禁止指令重排。
五、三大特性总结
synchronized可以同时保证原子性、可见性和有序性三大特性。volatile可以保证可见性和有序性,但不能保证原子性。final关键字也能保证可见性(一旦赋值后所有线程可见)。
28-为什么使用线程池,参数解释
一、为什么要使用线程池
- 降低资源消耗:线程的创建和销毁都是耗时的操作,线程池复用已创建的线程,避免反复创建和销毁。
- 提高响应速度:任务到达时无需等待线程创建,可以直接使用池中已有线程立即执行。
- 提高线程的可管理性:线程是稀缺资源,线程池能统一管理、监控和调优线程,避免无限制地
new线程导致系统资源耗尽。
二、线程池的核心参数
- corePoolSize(核心线程数):线程池中常驻的线程数量,核心线程一经创建不会主动销毁,随线程池生命周期存在。
- maximumPoolSize(最大线程数):线程池允许创建的最大线程数量,是线程总数的上限。
- keepAliveTime(空闲存活时间):当线程数超过核心线程数时,多余线程空闲时间超过该值会被回收,使线程数回落到核心线程数。
- unit(时间单位):
keepAliveTime的时间单位(如秒、毫秒等)。 - workQueue(任务队列):用于存放等待执行的任务。当核心线程都在忙时,新任务会先进入队列排队,而不是立即创建新线程。
- 线程池的创建逻辑:当任务到达时,先由核心线程处理;核心线程满后,任务进入队列;队列满后,才创建新线程直到达到最大线程数。
- threadFactory(线程工厂):用于创建线程的工厂接口,可以自定义线程名称、优先级、是否为守护线程等属性。
- 默认的线程工厂创建的线程属于同一个线程组、拥有相同的优先级,且都不是守护线程。
- RejectedExecutionHandler(拒绝策略):当线程池已关闭或线程数已达最大值且队列已满时,新任务会被拒绝执行。
三、触发拒绝策略的两种情况
- 线程池已关闭(调用了
shutdown()等方法)后,继续提交任务会被拒绝。 - 线程数已达到最大线程数且任务队列也已满,无法再处理新任务时,新任务会被拒绝。
29-线程池处理流程
- 线程池接收一个任务后,首先判断核心线程数(corePoolSize)是否已满。
- 如果核心线程数未满,则直接创建一个核心线程来执行该任务。
- 如果核心线程数已满,则判断任务队列(workQueue)是否已满。
- 如果任务队列未满,则将任务放入队列中等待,由核心线程空闲时从队列中取任务执行。
- 如果任务队列已满,则判断最大线程数(maximumPoolSize)是否已达到。
- 如果最大线程数未达到,则创建一个临时线程来执行该任务(注意:这是临时线程,而非核心线程)。
- 临时线程执行完任务后,如果空闲时间超过了
keepAliveTime(空闲存活时间),则会被回收销毁。 - 如果任务队列已满且最大线程数也已达到,则触发拒绝策略(如抛出异常或丢弃任务)。
- 整个处理流程可以概括为:核心线程 → 任务队列 → 临时线程(最大线程) → 拒绝策略,按顺序逐级判断。
30-线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程
一、阻塞队列的作用
- 普通队列只能作为有限长度的缓冲容器,当队列满了时,新任务无法放入且会丢失。
- 阻塞队列在队列满了时,新任务会阻塞等待,而不是直接丢失,直到队列有空间为止。
- 阻塞队列可以帮助线程池自动阻塞和唤醒线程:当队列中没有任务时,核心线程会被阻塞并释放 CPU 资源;当有新任务入队时,线程会被自动唤醒去执行任务。
- 使用阻塞队列,程序员无需手动调用
sleep()或wait()来控制线程的阻塞和唤醒,阻塞队列本身已封装好这些功能。 - 阻塞队列的核心作用概括为:保存任务 + 自动阻塞/唤醒线程。
二、为什么先放入队列,而不是先创建最大线程
- 创建线程是一个重量级操作,需要获取全局锁,会阻塞其他线程,影响整体性能。
- 线程池的设计初衷就是避免频繁创建和销毁线程,如果每来一个任务就创建新线程,处理完又销毁,就违背了这一初衷。
- 最大线程数存在的意义是应对业务高峰期(峰值流量),而不是处理常规流量。
- 将任务先放入队列,让核心线程慢慢处理,可以避免在非高峰期频繁创建和回收临时线程,降低资源消耗。
- 类比企业用工:先有正式员工(核心线程)处理常规任务,任务积压时先排队等待(任务队列),只有积压到超出承受极限时才招外包(创建临时线程),高峰期过后外包自然淘汰(临时线程回收)。
- 先放队列而不是先创建最大线程,核心逻辑是:队列操作代价小,线程创建代价大,用低成本的方式应对常规积压,用高成本的方式应对峰值压力。
31-线程池线程复用的原理
- 线程池实现了线程与任务的解耦,线程不再与特定任务绑定,同一个线程可以执行不同的任务。
- 传统方式中,每个
Thread对象创建时就绑定了一个Runnable任务,线程与任务是一一对应的关系。 - 线程池复用的核心原理是:线程池对
Thread进行了封装,让每个工作线程执行一个循环任务(不断循环检查)。 - 在这个循环中,工作线程不断地从阻塞队列中获取新任务,如果有任务则执行,没有则阻塞等待。
- 线程池执行任务时,调用的不是
Thread.start()(开启新线程异步执行),而是直接调用任务的run()方法(作为普通方法同步执行)。 - 如果调用
start(),相当于在线程池的线程内部又创建了子线程,违背了线程池复用线程的初衷。 - 通过直接调用
run()方法,固定数量的工作线程可以不断执行不同任务的run()方法,从而实现线程复用。 - 线程池中的工作线程本身与业务任务无关,它们只负责从队列中取任务并调用其
run()方法,这就是线程复用的本质。
32-spring是什么
- Spring 是一个轻量级、开源的企业级应用框架,本质上是一个容器框架,用于装 Java Bean(管理对象的生命周期)。
- Spring 的核心是 IoC(控制反转)+ AOP(面向切面编程),这也是大多数人心中对 Spring 的本质认知。
- 从大小和开销两方面来看,Spring 都是轻量级的,对代码的侵入性极低,对比早期的 EJB 重量级框架优势明显。
- Spring 通过 IoC(控制反转) 实现对象之间的松耦合,降低了代码的耦合度。
- Spring 通过 AOP(面向切面编程) 实现业务逻辑与系统级服务(如日志、事务)的分离,支持内聚性开发。
- Spring 作为容器,统一管理对象的整个生命周期(从创建到销毁),开发者无需手动
new对象和赋值。 - Spring 被誉为 “万能胶”,可以轻松整合其他框架(如 MyBatis、Redis、Struts 等),让企业应用开发更快更简洁。
33-对Aop的理解
- 在传统开发中,每个业务接口除了实现自身核心功能(如查询订单)外,还需要重复编写日志、异常处理、事务管理等非核心但必须的代码,导致大量代码冗余。
- OOP(面向对象编程)处理需求时,需要分析有哪些参与者(对象)以及每个对象有哪些方法和职责。
- 如果完全按 OOP 方式处理,日志代码会分散在各个业务模块中(订单接口写一份、支付接口写一份),不利于代码复用。
- AOP(面向切面编程) 将日志、事务、安全等交叉业务逻辑封装成一个切面,然后将切面自动注入到目标业务对象中。
- 使用 AOP 后,开发者只需定义好切面,符合切面规则的业务方法就会自动被增强(在方法执行前后额外增加日志、事务等行为),无需在每个接口中手动重复编写。
- AOP 的本质是对某个对象或方法的功能进行增强,在不修改原有代码的情况下,在方法执行前后额外添加操作。
- AOP 解决了 OOP 中横切性关注点(如日志、事务)重复代码的问题,实现了关注点的模块化和代码复用。
34-对IOC的理解
一、IoC 的本质是容器
- IoC(控制反转) 本质上是一个容器,通常可以理解为一个
Map,用于存放各种 Java 对象(Bean)。 - 项目启动时,Spring 会读取配置文件(XML)或扫描注解,将需要管理的类实例化并存入 IoC 容器中。
二、控制反转(IoC 的核心概念)
- 在没有 IoC 容器时,如果对象 A 依赖对象 B,A 需要自己主动去
new或获取 B 的实例,控制权在 A 自己手中。 - 引入 IoC 容器后,对象 A 和对象 B 不再直接关联,而是都依赖于 IoC 容器,A 不再主动创建 B。
- 当 A 需要 B 时,IoC 容器会主动创建(或获取)B 的实例并注入给 A,A 获取依赖对象的过程由主动变成被动。
- 对象获取依赖的控制权从程序自身转移到了 IoC 容器,这就是”控制反转”的含义——控制权颠倒过来了。
- IoC 容器成为系统的核心,所有需要管理的对象都放入其中,对象与对象之间通过容器间接关联。
三、依赖注入(DI)——实现 IoC 的方法
- 依赖注入(DI) 是实现 IoC 的具体手段,在容器运行期间动态地将依赖关系注入到对象中。
- 依赖注入的本质:获得依赖对象的过程从对象自身管理变为由 IoC 容器主动注入。
- 理解 IoC 需要从三个角度把握:容器概念(IoC 是什么)、控制反转(为什么叫反转)、依赖注入(如何实现反转),三者缺一不可。
35-BeanFactory和ApplicationContext有么区别
ApplicationContext是BeanFactory的子接口,继承自BeanFactory并进行了功能扩展。ApplicationContext比BeanFactory多出的功能包括:支持国际化(继承MessageSource)、统一资源文件访问方式(Resource接口)、支持在监听器中注册 Bean 事件、支持同时加载多个配置文件、支持多个上下文层次结构(如父子容器)。BeanFactory采用延迟加载(懒加载)方式:只有在调用getBean()时才会实例化对应的 Bean。BeanFactory的延迟加载导致配置错误只能在运行时被发现,不利于启动时的异常检测。ApplicationContext在容器启动时一次性创建所有 Bean(预加载),因此启动时就能发现配置错误,有利于提前排查问题。ApplicationContext的缺点是启动较慢、占用内存较多(因为提前创建了所有 Bean),但运行时速度更快。BeanFactory通常以编程方式创建(在代码中手动创建),而ApplicationContext还支持以声明式方式创建(如使用ClassPathXmlApplicationContext等)。- 两者都支持
BeanPostProcessor和BeanFactoryPostProcessor(后置处理器),但BeanFactory需要手动注册,而ApplicationContext会自动注册。
36-简述spring bean的生命周期
- Spring Bean 的生命周期第一步是解析类,扫描包路径(如
@ComponentScan)得到BeanDefinition(Bean 的定义信息)。 - 如果 Bean 有多个构造方法,Spring 会进行推断构造方法,确定使用哪个构造器来实例化对象。
- 确定构造方法后,Spring 通过反射实例化得到一个 Bean 对象(此时对象已创建,但属性未填充)。
- 实例化完成后,Spring 进行属性填充(依赖注入),解析
@Autowired等注解,将依赖的属性注入到 Bean 中。 - 属性填充完成后,Spring 会回调
Aware接口(如BeanNameAware、BeanFactoryAware等),这是 Spring 提供的一个重要扩展点。 - 接着调用
BeanPostProcessor的初始化前方法(postProcessBeforeInitialization)。 - 然后调用 Bean 的初始化方法(如
@PostConstruct或实现InitializingBean接口的afterPropertiesSet()方法)。 - 初始化方法执行后,调用
BeanPostProcessor的初始化后方法(postProcessAfterInitialization),在此阶段 Spring 会完成 AOP 动态代理的创建。 - 如果 Bean 是单例的,Spring 会将其放入单例池(Singleton Pool)中,供后续使用。
- Bean 放入单例池后,进入使用阶段,即程序运行期正常使用该 Bean。
- 当容器关闭时,Spring 会调用 Bean 的销毁方法(如
@PreDestroy或实现DisposableBean接口的destroy()方法)。 - 整个生命周期的简要流程可概括为:解析类 → 实例化 → 属性填充 → Aware 回调 → 初始化前处理 → 初始化 → 初始化后处理(AOP)→ 放入单例池 → 使用 → 销毁。
37-spring支持的bean作用域
singleton(默认):每个 Spring 容器中只有一个 Bean 实例,生命周期与 IoC 容器一致,在第一次被注入时创建。prototype(原型):每次获取(getBean())或注入都会创建一个新的实例,容器中可以有多个实例。request:每个 HTTP 请求会创建一个 Bean 实例,请求结束后该实例被回收,仅在 Web 应用中有效。session:每个 HTTP Session 中有一个 Bean 实例,Session 失效时实例随之销毁,仅在 Web 应用中有效。application:Bean 定义在ServletContext中,在整个 Web 应用生命周期内只有一个单例实例,可以跨多个 Spring 容器共享。websocket:Bean 定义在 WebSocket 的生命周期中,在整个 WebSocket 会话期间只有一个单例实例。globalSession:全局 Session 作用域,仅与 Portlet 应用相关,目前基本已不再使用,了解即可。
38-Spring框架中的单例Bean是线程安全的么
- Spring 框架中的单例 Bean 默认不是线程安全的,Spring 本身并未对单例 Bean 进行多线程安全封装处理。
- 单例 Bean 是否线程安全,取决于它是否有状态(即是否存储了可变数据)。
- 无状态 Bean(如只依赖其他 Service 或 DAO,不存储数据)是安全的,不存在线程安全问题。
- 有状态 Bean(如类中定义了全局变量存储数据,例如
count计数器)是线程不安全的,多线程访问时会出现并发问题。 - Spring 设计之初默认 Controller、Service、DAO 应该是无状态的,不应该在其中存储可变的共享数据。
- 如果需要存储线程级别的数据,可以使用
ThreadLocal来实现线程间数据隔离(如 Spring 事务管理器用ThreadLocal管理数据库连接)。 - 如果确实需要全局共享变量(如
static变量),开发者必须自行加锁(如synchronized或 CAS)来保证线程安全。 - 总结结论:Spring 单例 Bean 本身不安全,线程安全需要开发者自行保证,不要依赖 Spring 框架。
39-spring框架中使用了哪些设计模式及应用场景
- 简单工厂模式:
BeanFactory根据传入的唯一标识(如getBean("beanName"))动态决定创建并返回哪个产品类实例。 - 工厂方法模式:实现
FactoryBean接口的 Bean,通过重写getObject()方法自定义返回的对象,返回的可以不是该类的实例。 - 单例模式:Spring 中默认创建的 Bean 都是单例的,每个容器中只有一个实例。
- 适配器模式:Spring MVC 中的
HandlerAdapter针对不同类型的 Controller(如实现 Controller 接口、使用@Controller注解、Servlet 等)提供对应的适配器,统一调用方式。 - 装饰器模式:Spring 中类名带有
Wrapper或Decorator后缀的类,均使用了装饰器模式(如BeanWrapper)。 - 动态代理模式:Spring AOP 底层通过动态代理实现,为目标对象生成代理对象以增强功能。
- 观察者模式:Spring 的事件驱动模型(ApplicationListener、ApplicationEvent)使用了观察者模式,实现事件发布与监听。
- 策略模式:Spring 的
Resource接口针对不同类型的资源文件(如ClassPathResource、FileSystemResource)采用不同的访问策略。
40-spring事务的实现方式原理以及隔离级别
一、Spring 事务的两种实现方式
- 编程式事务:程序员在代码中手动调用 Spring 提供的 API 来控制事务的开启(begin)、提交(commit)和回滚(rollback)。
- 声明式事务(
@Transactional):通过注解方式实现事务管理,Spring 会为加了该注解的类生成代理对象并放入 IoC 容器,由代理对象控制事务。 - 声明式事务的底层原理:代理对象在执行目标方法前,将数据库的自动提交(
autoCommit)设置为false(改为手动提交),然后执行业务逻辑。 - 如果业务逻辑正常执行完毕,代理对象提交事务;如果抛出异常,则回滚事务。
- 异常回滚的前提是异常必须抛出,不能
catch后吞掉,否则代理对象感知不到异常,不会回滚。 - 默认情况下(不配置
rollbackFor),Spring 只会对RuntimeException和Error进行回滚,受检异常(Exception)默认不回滚。
二、Spring 事务的隔离级别
- Spring 的事务隔离级别本质上就是数据库的隔离级别,Spring 在数据库基础上增加了一个”默认级别”。
- 四种经典隔离级别(从低到高):READ_UNCOMMITTED(未提交读)、READ_COMMITTED(提交读,RC)、REPEATABLE_READ(可重复读,RR)、SERIALIZABLE(可串行化)。
- 隔离级别越高,数据一致性越好,但并发性能越低;隔离级别越低,并发性能越高。
- 如果同时配置了数据库隔离级别和 Spring 隔离级别,以 Spring 配置的为准,它会覆盖数据库的默认隔离级别。
- 如果 Spring 配置的隔离级别数据库不支持,最终效果取决于具体使用的数据库(不同数据库处理方式不同)。
- Oracle 的默认隔离级别是
READ_COMMITTED(RC),MySQL 的默认隔离级别是REPEATABLE_READ(RR)。
41-spring的事务传播机制
一、事务传播机制的概念
- 事务传播机制定义了多个事务方法相互调用时,事务如何在方法间传播和协调。
- 例如方法 A 调用方法 B,两者各自可能有事务,事务传播机制决定了 A 和 B 的事务是合并为一个、各自独立、还是嵌套等关系。
二、七种事务传播行为
REQUIRED(默认):如果当前存在事务,则加入该事务;如果当前没有事务,则新建一个事务。A 有事务则 B 加入 A,A 无事务则 B 新建。SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式运行。MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常(必须以事务方式运行)。REQUIRES_NEW:始终新建一个独立事务。如果当前存在事务,则将当前事务挂起,A 和 B 各自拥有独立的事务,互不影响。NOT_SUPPORTED:始终以非事务方式运行。如果当前存在事务,则挂起该事务,B 不以事务方式执行。NEVER:必须以非事务方式运行。如果当前存在事务,则抛出异常(不允许存在事务)。NESTED(嵌套事务):如果当前存在事务,则开启一个嵌套事务(子事务);如果当前没有事务,则新建一个事务。
三、嵌套事务(NESTED)与 REQUIRES_NEW、REQUIRED 的区别
REQUIRES_NEW与原有事务无关,各自开启独立事务;而NESTED是嵌套在原有事务中的子事务,与父事务有关联。NESTED中:父事务回滚时,子事务也会跟着回滚(父影响子)。NESTED中:子事务回滚时,父事务不一定回滚,取决于父事务是否捕获并处理了子事务的异常(如果catch吃掉异常,父事务不受影响)。REQUIRED中 A 和 B 是同一个事务,B 回滚时 A 必然回滚;NESTED中 B 回滚时 A 不一定回滚,给了父事务更多控制权。
42-spring事务什么时候会失效
- 自调用(内部调用):在同一个类中,通过
this(对象本身)调用被@Transactional修饰的方法时,不会走代理对象,事务失效(解决方法:改用注入的代理对象来调用)。 - 方法不是
public的:Spring 声明式事务默认只支持public方法,如果用在private、protected或默认访问修饰符的方法上,事务失效(除非开启aspectj代理模式)。 - 数据库不支持事务:Spring 事务本质上是数据库事务的封装,如果数据库引擎不支持事务(如 MySQL 的 MyISAM 引擎),事务必然失效。
- 类未被 Spring 管理:类上没有
@Service、@Component等注解,未被扫描并放入 IoC 容器,仅加@Transactional无效。 - 异常被吞掉(catch 后未抛出):事务回滚依赖于异常被抛出,如果业务代码中
catch了异常却没有重新抛出,事务感知不到异常,不会回滚。
43-什么的是bean的自动装配,有哪些方式
一、手动装配 vs 自动装配
- 手动装配:在 XML 配置中通过
<property>的ref属性或value属性,手动为 Bean 的属性赋值或注入依赖。 - 自动装配:在 XML 中通过
<bean>标签的autowire属性指定装配模式,Spring 会自动完成依赖注入,无需手动写ref。
二、Spring XML 配置中的四种自动装配方式
byName:Spring 根据属性名(如position)到 IoC 容器中查找 id 与该属性名相同的 Bean,通过 setter 方法注入。byType:Spring 根据属性的类型到 IoC 容器中查找匹配的 Bean,通过 setter 方法注入;如果该类型有多个 Bean,默认报错,可用@Qualifier指定具体哪一个。constructor:类似于byType,但应用于构造器的参数注入,Spring 根据构造器参数类型匹配并注入对应的 Bean。autodetect:Spring 自动检测——如果类有默认构造器且构造器可注入,则采用constructor方式;否则采用byType方式(实际是前两种的组合)。
三、注解方式自动装配
@Autowired:注解方式实现自动装配,可以在字段、setter 方法或构造器上使用,是 Spring 现在主流的装配方式。
44-spring、springmvc、 springboot区别
一、Spring 是什么
- Spring 是一个 IoC 容器,用于管理 Bean,通过依赖注入(DI)实现控制反转(IoC),降低代码耦合度。
- Spring 提供了 AOP(面向切面编程) 机制,将日志、异常等重复性横切逻辑封装成切面,自动注入到业务方法中,弥补了 OOP 的代码重复问题。
- Spring 可以方便地整合各种框架(如 MyBatis、Redis 等),被誉为”万能胶”。
二、Spring MVC 是什么
- Spring MVC 是 Spring 提供的一个 Web 框架,用于接收和处理 HTTP 请求,是 Spring 框架的一部分。
- Spring MVC 的核心是
DispatcherServlet(前端控制器),负责统一接收请求。 - Spring MVC 定义了完整的路由策略(URL 到处理器的映射)以及适配器机制(
HandlerAdapter)来执行不同的处理器,并将结果通过视图解析器渲染成视图返回给前端。
三、Spring Boot 是什么
- Spring Boot 是 Spring 家族提供的快速开发工具包,本质上就是 Spring + Spring MVC,并没有引入全新的功能。
- Spring Boot 的核心理念是约定大于配置:采用大量默认配置,开发者只需覆盖需要自定义的部分,从而大幅简化配置。
- Spring Boot 提供了 Starter(起步依赖) 机制,整合 MyBatis、Redis 等组件时只需引入对应的 Starter 包即可”开箱即用”,无需手动配置 Bean。
四、三者的核心区别总结
- Spring 是容器框架(IoC + AOP);Spring MVC 是 Spring 的Web 框架;Spring Boot 是基于 Spring 和 Spring MVC 的快速开发工具包,简化了配置和整合流程。
45-springmvc工作流程
一、Spring MVC 工作流程(步骤)
- 用户发送请求到前端控制器
DispatcherServlet(本质是一个 Servlet,负责统一接收请求)。 DispatcherServlet调用HandlerMapping(处理器映射器),根据 URL 查找对应的处理器(Handler)。HandlerMapping维护 URL 到处理器的映射关系,遍历所有映射器找到匹配的处理器后返回给DispatcherServlet。DispatcherServlet调用HandlerAdapter(处理器适配器),通过适配器模式执行具体的处理器。- 不同的处理器类型(如实现
Controller接口、使用@RequestMapping注解、Servlet 等)对应不同的适配器,适配器通过support()方法判断是否支持该处理器,然后调用handle()执行业务逻辑。 - 处理器执行业务逻辑后,返回一个
ModelAndView对象给DispatcherServlet。 DispatcherServlet将ModelAndView传给ViewResolver(视图解析器),解析成具体的视图(如 JSP)。- 解析出视图后,
DispatcherServlet进行视图渲染(如使用 JSP 引擎),最终将渲染结果响应给前端用户。
二、关键组件总结
HandlerMapping(处理器映射器):负责维护 URL 到处理器的映射关系,支持多种实现方式(XML 配置、注解@RequestMapping等)。HandlerAdapter(处理器适配器):采用适配器模式,屏蔽不同处理器类型的执行差异,统一调用方式。ViewResolver(视图解析器):负责将ModelAndView解析为具体的视图(如 JSP、JSON 等),并完成视图渲染。- Spring MVC 的核心设计模式:前端控制器模式(
DispatcherServlet)+ 适配器模式(HandlerAdapter)。
46-springmvc的九大组件
一、概念澄清
- Spring MVC 中的九大组件指的是
DispatcherServlet中定义的九个核心组件,不包括DispatcherServlet本身(它是前端控制器)。
二、九大组件详解
HandlerMapping(处理器映射器):维护 URL 到处理器的映射关系(类似于一个 Map),根据请求 URL 找到对应的Handler(处理器)。HandlerAdapter(处理器适配器):采用适配器模式,为不同类型的处理器(实现Controller接口、@RequestMapping注解方法、Servlet 等)提供统一的执行方式。- 适配器通过
support()方法判断是否支持当前处理器类型,然后通过handle()方法执行具体的业务逻辑。 ViewResolver(视图解析器):将处理器返回的ModelAndView解析为具体的视图(如 JSP),并完成视图渲染。RequestToViewNameTranslator(请求到视图名称转换器):当处理器没有返回ModelAndView(如void返回值)时,从请求中提取视图名称进行渲染。LocaleResolver(国际化解析器):用于处理国际化,在视图渲染时根据不同的语言环境(如中文、英文)加载对应的国际化资源。ThemeResolver(主题解析器):用于处理主题样式,在视图渲染时根据不同的主题(如 CSS、图片)加载对应的风格资源。MultipartResolver(文件上传解析器):用于处理文件上传请求,将上传的文件封装成MultipartFile对象供开发者使用。FlashMapManager(Flash 属性管理器):用于在重定向(Redirect)场景下传递参数,存储临时的 Flash 属性数据。
三、重点把握
- 九大组件中最核心的是前两个:
HandlerMapping和HandlerAdapter,是面试中的重点;其余组件在现代前后端分离项目中已较少涉及。
47-springboot自动配置原理
一、Spring Boot 自动配置的入口
- Spring Boot 自动配置的核心入口是
@SpringBootApplication注解,它是一个复合注解,包含了三个核心注解:@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan。 @SpringBootConfiguration等同于@Configuration,标识该类为配置类。@ComponentScan用于定义包扫描路径,扫描并注册 Bean。
二、自动配置的核心:@EnableAutoConfiguration
@EnableAutoConfiguration是自动配置最核心的注解,它通过@Import导入了一个关键类:AutoConfigurationImportSelector。AutoConfigurationImportSelector中的selectImports()方法返回一个字符串数组,数组中存放的是需要加载到 IoC 容器中的配置类的全限定类名。- 这些配置类的全限定类名来源于
META-INF/spring.factories文件,读取其中的EnableAutoConfiguration键(key)对应的所有值(配置类全路径)。
三、SPI 机制与自动加载
- Spring Boot 通过 Spring SPI 机制(
SpringFactoriesLoader.loadFactoryNames())读取spring.factories文件中的配置类,将其加载为 Bean。 - 这些被加载的配置类通常使用
@Configuration加@Bean定义了各种需要自动装配的 Bean(如 MyBatis、Redis 等框架的整合配置)。
四、@AutoConfigurationPackage 的作用
@EnableAutoConfiguration还通过@AutoConfigurationPackage导入AutoConfigurationPackages.Registrar,用于保存包扫描路径,供框架内部其他组件(如 JPA)查询使用。
五、整体流程归纳
- 整个自动配置流程为:
@SpringBootApplication→@EnableAutoConfiguration→@Import(AutoConfigurationImportSelector)→ 读取spring.factories中的配置类 → 加载到 IoC 容器 → 完成自动装配。 - 各个 Starter(如 MyBatis、Redis)在自己的 jar 包中提供
META-INF/spring.factories文件,并定义@Configuration+@Bean的配置类,Spring Boot 启动时自动加载,实现”开箱即用”。
48-如何理解springboot的starter
- Starter 是 Spring Boot 中的一站式依赖包,封装了某个框架(如 MyBatis、Redis)所需的所有依赖和自动配置,开发者只需引入一个 Starter 即可使用。
- 在传统 Spring + Spring MVC 中,引入 MyBatis 需要手动在 XML 中配置
SqlSessionFactory、DataSource等多个 Bean,配置繁琐。 - 使用 Starter 后,这些 Bean 的定义由框架本身完成,开发者无需再手动编写 XML 配置。
- Starter 的实现方式:在 Starter 包中编写一个
@Configuration配置类,在类中使用@Bean定义所有需要装配的 Bean。 - 配置类默认不在业务代码的扫描路径下,需要通过 Spring SPI 机制使其生效。
- 配置类的全限定类名被放入
META-INF/spring.factories文件中,Spring Boot 启动时会自动读取该文件并加载配置类到 IoC 容器。 - Spring Boot 通过 Spring SPI 机制(
SpringFactoriesLoader)加载spring.factories中的配置类,这是 Starter 生效的核心原理。 - Starter 除了封装配置类外,还会在自身的
pom.xml中管理传递依赖,开发者只需引入一个 Starter 包,无需关心其他间接依赖。 - 开发者引入 Starter 后,只需在
application.properties或application.yml中配置必要的个性化参数(如数据库 URL),其他使用默认值即可。 - Starter 机制简化了框架整合,让开发者专注于业务代码,极大提升了开发效率。
49-什么是嵌入式服务器,为什么使用嵌入式服务器
- 嵌入式服务器是指将 Web 服务器(如 Tomcat、Jetty)以 jar 包形式内嵌在应用程序中,无需单独下载和部署。
- 在传统 Spring + Spring MVC 开发中,需要单独下载 Tomcat 中间件,将应用打成 war 包部署到 Tomcat 的
webapps目录下,并手动启动服务器。 - 使用 Spring Boot 后,Tomcat 以 jar 包依赖的形式存在于项目中,无需单独部署,应用本身就是一个可执行的 jar 包。
- 使用嵌入式服务器时,只需在操作系统上安装 JVM,执行
java -jar命令即可直接运行应用,无需额外安装 Web 服务器。 - Spring Boot 的
@SpringBootApplication中的 main 方法启动时,会自动启动内嵌的 Tomcat,并加载 Spring MVC 容器,使应用正常运行并接收请求。 - 嵌入式服务器的优势:简化部署流程,从”下载 Tomcat → 打 war 包 → 部署 → 启动服务器”变为”打 jar 包 →
java -jar直接运行”。
50-mybatis的优缺点
一、MyBatis 的优点
- 基于 SQL 编程,灵活性强:SQL 语句由开发者自定义,不会对应用程序和数据库现有设计造成任何影响。
- SQL 与代码解耦:SQL 语句写在 XML 文件中,与 Java 代码分离,便于维护和复用。
- 大幅减少代码量:与传统 JDBC 相比,消除了创建连接、释放连接等大量冗余代码,代码量至少减少 50% 以上。
- 数据库兼容性好:MyBatis 底层基于 JDBC,只要数据库支持 JDBC,MyBatis 就能支持。
- 与 Spring 集成良好:MyBatis 提供了与 Spring 的兼容包,对 Spring 框架相当友好。
- 提供对象关系映射标签:支持对象属性与数据库字段之间的映射关系配置,方便维护 ORM 关系。
二、MyBatis 的缺点
- SQL 编写工作量大:当字段较多或关联表较多时,SQL 语句会变得很长,映射字段配置繁琐。
- 对程序员 SQL 水平要求高:需要写出高性能 SQL,对开发者的 SQL 能力有一定门槛。
- 数据库迁移成本高:SQL 语句本身依赖于具体数据库(如 Oracle 和 MySQL 的函数写法不同),切换数据库时需要修改大量 SQL 语句,数据库一致性较差。
51-mybatis和hibernate的对比
一、核心概念定位
- Hibernate 是典型的 ORM(对象关系映射)框架,通过操作 Java 对象来操作数据库,自动生成 SQL。
- MyBatis 不是完整的 ORM 框架,它是一个 SQL 映射框架,需要开发者自己编写 SQL 语句。
- 国内(包括中国、韩国、日本)MyBatis 使用率高于 Hibernate,而欧美国家 Hibernate 占据统治地位。
二、国内外选择差异的原因
- 国内开发普遍采用面向表结构编程(先设计表,再根据表结构写增删改查),而非面向对象编程,因此 MyBatis 更符合这种开发模式。
- 国外更注重面向对象编程,设计时从对象角度出发,表设计服务于对象模型,因此 Hibernate 更受青睐。
- 国内互联网业务变更频繁、迭代快速,面向表结构编程灵活性更高、开发速度更快,这也是 MyBatis 在国内流行的原因之一。
三、开发速度与学习成本对比
- 学习成本:MyBatis 入门更简单,Hibernate 学习难度更大,掌握成本更高。
- 开发速度(简单场景):如果项目以简单增删改查为主,Hibernate 开发效率更快(基本 SQL 已封装好,无需手写)。
- 开发速度(复杂场景):如果项目包含大量复杂查询、批量操作、多表关联,MyBatis 更灵活,开发效率更高。
四、代码工作量对比
- 简单 SQL 场景:Hibernate 代码量更少;复杂 SQL 场景:MyBatis 需要手写 XML、接口、ResultMap 等,代码量较多。
五、SQL 优化能力
- SQL 优化:MyBatis 优势更大,因为 SQL 由开发者手写,可控性强;Hibernate 自动生成的 SQL 不灵活,优化难度高。
六、对象管理能力
- 对象管理:Hibernate 更完善,提供了对象状态管理功能(如游离态、持久态等);MyBatis 本身不提供对象管理机制。
七、缓存机制对比
- 两者都支持一级缓存和二级缓存,都可以使用第三方缓存或自定义缓存实现。
- Hibernate 的缓存机制更强大,对脏数据有提示;MyBatis 的二级缓存使用要求较高,容易出错,实际开发中较少使用。
八、其他差异
- 数据库无关性:Hibernate 更好,切换数据库时无需修改代码;MyBatis 需要针对不同数据库编写不同的 SQL 语句。
- 框架重量:Hibernate 功能强大但较重;MyBatis 功能相对简单但轻量。
52-#{}和${}的区别
#{}是占位符(预编译),${}是字符串替换(拼接符),两者的作用完全不同。#{}会使用预编译方式(PreparedStatement),将传入的值作为参数替换,并自动添加单引号包裹。#{}的预编译机制能有效防止 SQL 注入,提升系统安全性。${}是纯粹的字符串拼接,将传入的值直接替换到 SQL 中,不会添加单引号,也不会预编译。${}存在SQL 注入风险,应当谨慎使用,通常仅在需要动态传入表名、列名等场景下使用。
53-mybatis插件运行原理及开发流程
一、MyBatis 插件的本质
- MyBatis 的插件本质上就是拦截器(Interceptor),基于 JDK 动态代理实现方法增强(在目标方法执行前后插入自定义逻辑)。
- MyBatis 的拦截器只能拦截四个核心接口的方法,灵活性有限,不像 Spring 的拦截器可以拦截任意方法。
二、可拦截的四个核心接口
ParameterHandler:负责将 Java 类型参数转换为数据库支持的 JDBC 类型,处理 SQL 中的参数。ResultSetHandler:负责处理 JDBC 结果集(ResultSet),将数据库查询结果映射为 Java 对象。StatementHandler:负责设置 SQL 参数,并将结果集通过ResultSetHandler进行映射转换。Executor:MyBatis 的调度核心,负责生成 SQL 语句、管理查询缓存,是最重要的拦截目标。
三、插件运行原理
- MyBatis 插件通过 JDK 动态代理为目标接口(上述四个接口之一)生成代理对象,在调用目标方法时触发拦截逻辑。
- 拦截器的核心方法是
intercept(),通过Invocation参数可以获取目标对象、目标方法及参数。 - 通过
invocation.proceed()方法区分前拦截(在proceed()前写逻辑)和后拦截(在proceed()后写逻辑)。
四、如何编写一个 MyBatis 插件
- 编写一个类实现
Interceptor接口,重写intercept()方法编写自定义拦截逻辑。 - 在类上添加
@Intercepts注解,并通过@Signature指定要拦截的接口、方法名和参数类型(因为方法有重载,需通过参数列表唯一确定)。 - 如果需要与 Spring 整合,在类上添加
@Component注解将拦截器放入 IoC 容器,或通过 MyBatis 配置文件手动配置。
54-索引的基本原理
- 索引的用途:快速查找具有特定值的记录,避免全表扫描(就像字典的拼音或笔画检索帮助快速找到某个字)。
- 索引的核心原理是将无序的数据变为有序的查询结构,原始数据在内存或磁盘中是无序存放的,索引为其建立有序的查找路径。
- 哈希索引的原理:对查询条件(如 ID)进行哈希算法得到一个哈希值,在哈希表中找到对应的内存地址,直接定位数据,时间复杂度接近 O(1)。
- B-Tree/B+Tree 索引的原理:将数据组织成一棵平衡树结构,通过树形查找快速定位数据,是关系型数据库中最常用的索引结构。
- 索引的底层实现本质上是一张倒排表(或称索引表),存储了排序后的关键字及其对应的数据地址(地址链)。
- 查询时,先在索引表中查找目标关键字,获取对应的数据地址(地址链),再通过地址直接取出数据,避免全表扫描。
- 无论哪种数据结构的索引(哈希索引、B-Tree 索引等),其最终目的和基本原理都是一致的:用有序结构加速无序数据的查找。
55-mysql聚簇和非聚簇索引的区别
一、基本概念
- 聚集索引(Clustered Index):索引和数据存储在一起,数据行的物理存放顺序与索引顺序一致,叶子节点存储的是完整的数据行。
- 非聚集索引(Non-Clustered Index):索引和数据分开存储,叶子节点存储的是指向数据行的地址(或主键值),而非数据本身。
- InnoDB 中的聚集索引和非聚集索引底层都使用 B+Tree 数据结构,但叶子节点存储的内容不同。
- InnoDB 表一定有聚集索引:默认以主键作为聚集索引;如果没有主键,则使用第一个唯一索引;如果也没有唯一索引,则使用数据库内部隐藏的行 ID。
- MyISAM 存储引擎没有聚集索引,所有索引都是非聚集索引,索引和数据完全分离。
二、聚集索引的优势与劣势
- 优势一:查询效率高,通过聚集索引查询可直接获取数据行,无需回表(相比非聚集索引需二次查找)。
- 优势二:范围查询效率高,因为数据按顺序组织,索引相邻的数据在物理上也相邻,适合范围查询和排序。
- 劣势一:维护代价高,插入新数据或更新主键时,为保证顺序可能需要移动数据,导致页分裂、产生碎片,性能开销大。
- 劣势二:不适用于无序主键,若使用 UUID 或随机 ID 作为主键,每次插入数据位置随机,频繁导致索引移动,查询效率甚至可能低于全表扫描。
- 劣势三:主键过大会导致辅助索引膨胀,因为 InnoDB 的辅助索引叶子节点存储的是主键值,主键越大,所有辅助索引占用的空间也越大。
三、非聚集索引(辅助索引)的特点
- 辅助索引是在聚集索引之上创建的索引,在 InnoDB 中,辅助索引叶子节点存储的是主键值,而非数据地址,查询时需要先查辅助索引找到主键,再回聚集索引查数据(回表)。
- 覆盖索引:如果查询只需要索引列本身(不需要其他字段),则无需回表,可直接从辅助索引中获取数据,效率较高。
- 非聚集索引的劣势:查询时通常需要二次查找(先找索引,再根据主键或地址找数据),相对于聚集索引效率略低。
四、InnoDB vs MyISAM 在索引方面的差异
- InnoDB 使用聚集索引(数据与索引在一起),表空间较大;MyISAM 使用非聚集索引(索引与数据分离),索引和数据的表空间都相对较小。
- 全表扫描:MyISAM 表较小,全表扫描比 InnoDB 有优势;COUNT(*):MyISAM 专门维护了行数计数器,不带条件的
COUNT(*)比 InnoDB 更快(InnoDB 需要逐行统计)。
56-mysql索引结构,各自的优劣
一、MySQL 中索引的两种主要数据结构
- MySQL 中常见的索引数据结构有 B+Tree(B+树) 和 Hash(哈希表),具体使用哪种与存储引擎有关。
- InnoDB 默认使用 B+Tree 索引,这也是 MySQL 中最广泛使用的索引结构。
- Hash 索引底层是一张哈希表,仅适合等值查询(单条记录查询,如
id = ?),在等值查询场景下性能极高(只需一次哈希计算即可定位)。
二、B+Tree 索引的特点
- B+Tree 是一棵平衡的多叉树,从根节点到每个叶子节点的高度差不超过 1,检索效率稳定,不会出现大幅波动。
- B+Tree 的叶子节点存储了完整数据行(聚集索引)或数据地址(非聚集索引),且叶子节点之间通过双向指针相互连接,形成有序链表。
- 叶子节点按照索引值从小到大依次排列,非常适合范围查询(如
BETWEEN、>、<)和排序。 - 由于叶子节点间有指针连接,进行范围查询时可以左右快速移动,无需回溯到父节点,效率极高。
- B+Tree 的检索性能平均且稳定,不像普通 B-Tree 那样波动较大。
三、Hash 索引的特点与限制
- Hash 索引通过一次哈希算法将 key 映射为哈希值,直接定位到数据地址,单条等值查询速度极快,优于 B+Tree。
- Hash 索引不支持范围查询,因为哈希后的值是无序的,无法利用顺序进行范围定位。
- Hash 索引无法利用索引完成排序,因为哈希表本身没有顺序结构。
- Hash 索引不支持联合索引的最左匹配原则,因为联合索引多个字段会被映射为同一个哈希值,无法拆分。
- Hash 索引不适合
LIKE模糊查询,因为模糊查询本质上依赖于顺序匹配。 - 当有大量重复键值导致哈希冲突(哈希碰撞)时,Hash 索引需要遍历冲突链表,效率会大幅下降。
四、使用场景总结
- Hash 索引适用场景:仅适合单条等值查询,场景限制严格。
- B+Tree 索引适用场景:覆盖范围查询、排序、联合索引、模糊查询等绝大部分场景,是 MySQL 索引的通用选择。
57-索引的设计原则
一、索引设计的核心原则
- 索引设计的根本目标:让查询更快、占用空间更小,所有具体规则都应服务于这两点。
二、适合建索引的场景(8条)
- 索引列必须出现在查询条件中:索引列应出现在
WHERE子句或连接条件(ON)中,否则索引无意义。 - 数据量较小的表不需要建索引:表数据本身不多时,额外维护索引反而增加开销,没有收益。
- 优先使用短索引:索引字段越短,占用的索引空间越小。对于长字符串,可使用前缀索引(截取前几个字符作为索引)在空间和效率间折中。
- 不要过度索引:索引并非越多越好,每个索引都会占用磁盘空间并降低写操作(插入、更新、删除)的性能。
- 有外键的字段必须建立索引:外键列在关联查询中频繁使用,建立索引可提升连接查询效率。
- 尽量扩展已有索引而非新建:如果已有索引,可通过建立联合索引来覆盖更多字段,避免重复创建。
- 区分度高的列适合建索引:索引值能查到的数据量越少越好,区分度高(如唯一性字段)的列索引效果更好。
三、不适合建索引的场景(4条)
- 频繁更新的字段不适合建索引:更新字段时需要同步维护索引表,增加额外开销,影响写性能。
- 区分度低(重复值多)的列不适合建索引:如性别字段(只有男/女),查询会返回大量数据,索引效果差。
- 查询中很少涉及的列不适合建索引:不常在
WHERE或连接条件中出现的列,建索引无意义。 TEXT、IMAGE、BIT等大字段类型不适合建索引:这些数据类型对索引不友好(TEXT的全文索引是另一概念)。
58-mysql锁的类型有哪些
一、MySQL 锁的分类维度
- MySQL 中的锁可以从三个维度分类:按锁的属性(共享锁/排他锁)、按锁的粒度(行锁/表锁/页锁等)、按锁的状态(意向共享锁/意向排他锁),同一把锁可以同时属于多个维度。
二、按锁的属性分类
- 共享锁(S 锁,读锁):加了共享锁的数据,其他事务只能再加共享锁,不能加排他锁,用于支持并发读取。
- 排他锁(X 锁,写锁):加了排他锁的数据,其他事务既不能加共享锁也不能加排他锁,只能等锁释放。
三、按锁的粒度分类
- 表锁:锁定整张表,加锁简单但冲突概率高,并发度低。
- 行锁:锁定一行或多行记录,加锁较复杂但冲突概率低,并发度高。
- 记录锁(Record Lock):行锁的一种,精准锁定某一条记录,只会在命中唯一索引时触发。
- 页锁(Page Lock):粒度介于行锁和表锁之间,锁定相邻的一组记录,加锁效率和冲突概率介于两者之间。
- 间隙锁(Gap Lock):行锁的一种,锁定一个区间(如锁住 ID 为 2 和 3 的间隙),不锁定具体记录,用于防止幻读。
- 临键锁(Next-Key Lock):行锁的一种,是记录锁 + 间隙锁的组合,锁定记录本身以及记录前的间隙(左闭右闭区间),是 InnoDB 默认的行锁实现。
四、按锁的状态分类(意向锁)
- 意向共享锁(IS 锁):事务准备对某行加共享锁时,先在表级别加意向共享锁,表示”有人要读某些行”。
- 意向排他锁(IX 锁):事务准备对某行加排他锁时,先在表级别加意向排他锁,表示”有人要写某些行”。
- 意向锁的作用是提高加锁效率:当事务要对整张表加表锁时,只需检查表上是否有意向锁,无需逐行遍历检查是否有行锁。
五、补充说明
- 不同存储引擎默认使用的锁粒度不同:InnoDB 默认行锁,MyISAM 默认表锁。
- 锁的加锁行为对开发者通常是透明的(由存储引擎自动决定),但 MySQL 也提供了显式加锁的语法。
59-mysql执行计划怎么看
一、执行计划的概念与查看方式
- 执行计划是 MySQL 告诉你它会如何执行你的 SQL 语句(包括查询顺序、索引使用方式、预估结果行数等),是 SQL 优化的核心依据。
- 查看执行计划的方式:在 SQL 语句前加上
EXPLAIN关键字即可打印执行计划。
二、执行计划各字段含义
id:代表每一个SELECT查询的编号,id 值越大,该查询越优先执行。select_type:表示查询的类型(简单查询、子查询、关联查询等)。table:当前查询所对应的表名。partitions:如果使用了分区表,显示分区信息。possible_keys:查询时可能使用的索引列表(不代表一定会用)。key:查询时实际使用的索引名称(这是重点关注的字段)。key_len:联合索引中实际命中的索引长度,用于判断联合索引中命中了几个字段。ref:显示索引的哪一列被使用了,如果是常量等值查询(如id = 1),显示为const。rows:预估需要扫描的数据行数,扫描行数越少,执行计划越优。filtered(百分比):rows × filtered表示最终返回的行数,该值越高越好(扫描的行越有效)。Extra:额外信息,如Using index(覆盖索引)、Using filesort(文件排序)、Using temporary(临时表)等。
三、Extra 字段的关键值
Using index(覆盖索引):查询所需数据全部从索引中获取,无需回表,性能高。Using filesort:排序未走索引,需要额外排序,性能差,应尽量避免。Using temporary:使用了临时表(常见于排序或分组),性能差,应尽量避免。
四、type 字段——执行计划最核心的指标
type字段表示索引的访问类型,是衡量 SQL 性能的最核心指标,性能从高到低排序为:system>const>eq_ref>ref>range>index>ALL。
五、各 type 值的含义
const:通过索引一次命中,匹配一行数据(如id = 1等值查询主键/唯一索引),性能极高。system:const的特殊情况,表中只有一行记录(如系统表),性能最高。eq_ref:使用了唯一索引进行关联查询,每个索引值只匹配一条记录,性能高。ref:使用了非唯一索引进行查询,可能匹配多条记录,性能中等。range:范围查询(如BETWEEN、>、<),扫描多个索引值,性能一般。index:遍历整个索引树(全索引扫描),比全表扫描好一点,但仍较低效。ALL:全表扫描,未走任何索引,性能最差,应尽量避免。
六、总结
- 查看执行计划的优化思路:优先关注
type是否达到ref或以上,避免ALL和index;关注Extra中是否有Using filesort或Using temporary;关注rows扫描行数是否过大,综合判断 SQL 是否需要优化。
61-怎么处理慢查询
一、慢查询的排查与分析
- 使用主键进行简单查询通常不会出现慢查询,如果仍然慢,说明数据量过大,SQL 本身已无优化空间。
- 复杂查询(多表关联、嵌套子查询等)需要在测试环境中先测试耗时并查看执行计划,定位性能瓶颈。
- 在生产环境中,慢查询通常由 DBA(数据库管理员)监控发现并反馈给开发人员处理(小公司可能由开发自己负责)。
二、慢查询的三种常见原因
- 原因一:索引未命中——字段上虽然有索引,但查询语句写法导致索引失效(如使用了函数、隐式类型转换、
LIKE '%xxx'等)。 - 原因二:查询了不需要的列——如使用
SELECT *查询了全部列,而实际只需要其中几列,增加了 I/O 开销。 - 原因三:数据量过大——表本身数据量巨大,即使走索引也无法满足性能要求。
三、针对不同原因的优化策略
- 针对原因二:从业务角度分析,去掉
SELECT *等多余列,只查询必要的字段。 - 针对原因一:通过
EXPLAIN分析执行计划,查看type、key等字段,确认索引是否命中以及是否使用了效率较高的索引类型。 - 针对原因三:当 SQL 本身已无优化空间且数据量过大时,需要考虑分库分表(水平拆分或垂直拆分)来分散数据压力。
62-ACID靠什么保证的
一、ACID 与日志的对应关系
- 原子性(Atomicity):由
undo log(回滚日志) 保证,记录了需要回滚的日志信息,事务回滚时根据undo log撤销已执行成功的 SQL 操作。 - 一致性(Consistency):由其他三大特性(原子性、隔离性、持久性) 共同保证,同时还需要应用程序代码本身来保证业务数据的一致性(如账户余额不足时不允许转账)。
- 隔离性(Isolation):由
MVCC(多版本并发控制) 保证,通过不同版本的数据来实现事务隔离。 - 持久性(Durability):由
redo log(重做日志) + 内存 保证,数据修改时同时写入内存和redo log,宕机后可通过redo log恢复。
二、InnoDB 的两种关键日志
redo log:记录了数据修改后的状态(物理日志),用于保证持久性,宕机时可通过它恢复已提交但未写入磁盘的数据。undo log:记录了事务回滚所需的信息(逻辑日志,即反向操作的 SQL),用于保证原子性,事务回滚时撤销已执行的操作。
三、redo log 与 binlog 的协同机制
binlog(归档日志) 是 MySQL Server 级别的日志,用于主从复制和数据恢复,从库通过执行binlog中的 SQL 来同步主库数据。- InnoDB 事务提交的流程:先写
redo log(进入prepare状态)→ 再写binlog并持久化 → 最后在redo log中写入commit标记,事务才算真正提交成功。 - 一个事务是否真正提交成功,取决于
redo log中是否有commit标记,以此保证redo log和binlog的数据一致性。 redo log最终刷盘(写入磁盘)是在操作系统空闲时异步进行的,并非写完立即刷盘。
63-什么是MVCC
一、MVCC 的基本概念
- MVCC(多版本并发控制) 是一种通过保存数据的多个版本来实现读写不冲突的机制,不同事务可以看到自己特定版本的数据。
- MySQL 中 MVCC 仅在
READ COMMITTED(RC,已提交读)和REPEATABLE READ(RR,可重复读)两种隔离级别下生效,READ UNCOMMITTED和SERIALIZABLE不兼容 MVCC。
二、MVCC 的两大核心组件
- 版本链(Version Chain):由
undo log维护,每次修改数据时,旧版本存入undo log,并通过roll_ptr(回滚指针)串联成链,新版本指向旧版本。 read view(读视图):事务执行查询时生成的一个快照,维护了当前系统中所有未提交的活动事务 ID 列表(按大小排序的数组)。
三、InnoDB 聚集索引中的两个隐藏列
trx_id(事务 ID):记录最近一次修改该索引记录的事务 ID,MySQL 全局递增分配。roll_ptr(回滚指针):指向undo log中该记录的上一个旧版本,用于构建版本链。
四、read view 的可见性判断规则
- 查询数据时,先获取当前记录版本中的
trx_id,然后与read view中的活动事务 ID 数组进行比较,判断该版本是否可见。 - 规则一:如果记录的
trx_id小于read view中的最小 ID(在左侧),说明该事务已提交,该版本可见。 - 规则二:如果记录的
trx_id大于read view中的最大 ID(在右侧),说明该事务在read view生成之后才开启,该版本不可见。 - 规则三:如果记录的
trx_id恰好落在read view的活动事务 ID 数组中(在中间),说明该事务尚未提交,该版本不可见。 - 如果当前版本不可见,则通过
roll_ptr沿版本链回溯到上一个版本,重复上述判断,直到找到可见的版本或版本链结束。
五、RC 与 RR 隔离级别下 MVCC 的区别
- RC(已提交读):每次查询都重新生成一个独立的
read view,因此前后两次查询可能读到不同的数据(不可重复读)。 - RR(可重复读):只生成一次
read view,后续查询复用该read view,因此前后两次查询读到的数据一致(可重复读)。 - RR 级别复用
read view减少了重复生成的性能开销,这也是 MySQL 默认使用 RR 隔离级别(而非 Oracle 的 RC)的原因之一。
60-事务的基本特性和隔离级别
一、ACID 四大特性
- 原子性(Atomicity):事务中的所有操作要么全部成功提交,要么全部失败回滚,不可分割(如转账中 A 减钱和 B 加钱必须同时完成或同时撤销)。
- 一致性(Consistency):事务的最终目标,数据必须从一个一致状态转变为另一个一致状态(如转账后总金额不变),需要数据库约束 + 应用程序代码共同保证。
- 隔离性(Isolation):多个事务并发执行时,彼此互不干扰,一个事务的执行不应受其他事务的影响,由锁和 MVCC 共同实现。
- 持久性(Durability):事务一旦提交,其所做的修改必须永久保存到数据库中,即使系统崩溃也不会丢失(通过
redo log保证)。 - 一致性是最终目的,原子性、隔离性、持久性是手段——通过保证其他三大特性来达成一致性。
二、事务的四大隔离级别
- 读未提交(READ UNCOMMITTED,RU):一个事务可以读取另一个事务尚未提交的修改,会出现脏读问题,几乎不被使用。
- 脏读:读到其他事务尚未提交的数据,该数据可能被回滚,读到的其实是”不存在”的错误数据。
- 读已提交(READ COMMITTED,RC):一个事务只能读取其他事务已提交的数据,解决了脏读,但会出现不可重复读问题。
- 不可重复读:同一事务中,前后两次查询同一数据,得到的结果不一致(因其他事务已提交了修改),Oracle 默认采用此级别。
- 可重复读(REPEATABLE READ,RR):同一事务中,前后多次查询同一数据,结果始终一致,解决了不可重复读,但会出现幻读问题,MySQL 默认采用此级别。
- 幻读:事务中前后两次查询同一范围的数据,第二次查询多出了之前不存在的行(因其他事务插入了新数据)。
- 串行化(SERIALIZABLE):最高的隔离级别,强制事务串行执行,对读取的每一行数据都加锁,解决了脏读、不可重复读、幻读所有问题,但并发性能最差。
三、各隔离级别的问题与保证
- 读未提交:未解决任何并发问题,允许脏读、不可重复读、幻读。
- 读已提交:解决脏读,仍存在不可重复读和幻读。
- 可重复读:解决脏读和不可重复读,仍存在幻读(MySQL 可通过间隙锁部分解决)。
- 串行化:解决脏读、不可重复读、幻读,但性能最低。
四、隔离级别与 MVCC 的关系
- RC 级别下,每次查询都重新生成
read view,因此能读到其他事务已提交的最新数据,但导致不可重复读。 - RR 级别下,事务开始时只生成一次
read view,后续查询复用该read view,因此读到的数据始终一致。 - 幻读的产生原因:
read view对查询操作有效,但新增的插入数据不受read view限制,可能导致同一范围查询结果行数变化。
五、如何处理隔离级别不适用的问题
- 如果当前隔离级别不适合业务场景,开发者可以通过手动加锁(如
SELECT ... FOR UPDATE或LOCK IN SHARE MODE)在应用程序中自行控制并发行为,或调整数据库的隔离级别配置。
64-mysql主从同步原理
一、MySQL 主从同步的三个核心线程
- MySQL 主从同步涉及三个线程:主库上的
Log Dump线程,从库上的I/O线程和SQL线程。
二、主从同步的基本流程
binlog(二进制日志) 是主从复制的基础,主库的所有数据变更(结构修改 + 数据更新)都会记录到binlog中。- 当
binlog有变动时,主库的Log Dump线程会读取binlog内容并发送给从库。 - 从库的
I/O线程接收主库发来的binlog内容,并将其写入从库本地的relay log(中继日志) 中。 - 从库的
SQL线程读取relay log中的内容,并在从库中重放执行,最终实现主从数据一致。 - 主从同步采用增量同步方式,通过
binlog文件名 + 偏移量(position)记录同步进度,每次只同步增量部分。
三、主从同步的三种复制方式
- 异步复制(默认):主库写完
binlog后直接返回客户端成功,不等待从库确认,性能最高,但主库宕机时可能丢失未同步的数据。 - 全同步复制:主库等待所有从库都完成
relay log的执行后才返回客户端,数据最安全,但性能最低。 - 半同步复制:主库等待至少一个从库确认收到并写入
relay log后即可返回,是全同步和异步之间的折中方案,在性能和数据安全间取得平衡。
65-简述MyISAM和InnoDB的区别
一、事务支持
- MyISAM 不支持事务,每次查询都是原子操作;InnoDB 支持事务,并提供 ACID 四大特性及四种隔离级别。
二、锁粒度
- MyISAM 支持表级锁(表锁),每次操作都会锁定整张表,并发写入性能差。
- InnoDB 支持行级锁(行锁),锁定粒度更细,支持并发写操作,并发性能更高。
- MyISAM 的表锁机制导致读写相互阻塞,InnoDB 的行锁机制支持更高的并发度。
三、外键约束
- MyISAM 不支持外键约束;InnoDB 支持外键约束,适合需要数据完整性约束的场景。
四、COUNT(*) 性能
- MyISAM 单独维护了表的总行数变量,执行
SELECT COUNT(*) FROM table时直接返回,速度极快。 - InnoDB 没有维护总行数,执行
COUNT(*)需要全表扫描逐行统计,效率较低。
五、文件存储结构
- MyISAM 一个表对应三个文件:
.frm(表结构)、.MYD(数据文件)、.MYI(索引文件),索引和数据分离存储。 - InnoDB 采用聚集索引(主键索引的叶子节点直接存储完整数据行),数据和索引存储在一起;辅助索引的叶子节点存储主键值,查询时需回表。
- MyISAM 的索引文件存储的是数据行的地址(指针),查询时需要先查索引文件再根据地址去数据文件中找数据,涉及两次 I/O 操作。
- MyISAM 的主索引和辅助索引结构相同,没有本质区别;InnoDB 的辅助索引需要先查主键 ID,再回聚集索引查数据(回表)。
六、表空间管理
- MyISAM 每个表的文件独立,表大小受操作系统文件大小限制。
- InnoDB 默认使用共享表空间(一个或多个文件),也可以配置为独立表空间(每个表一个文件),表大小受操作系统限制(通常约 2GB 或更大,取决于配置)。
七、主键设计建议
- InnoDB 建议使用自增主键(AUTO_INCREMENT),因为聚集索引按主键顺序存储数据,自增主键能避免页分裂,提升插入性能。
八、典型应用场景
- MyISAM 适合读多写少、对事务要求不高的查询密集型场景(如数据仓库、统计报表)。
- InnoDB 适合写操作频繁、需要事务支持和数据完整性的 OLTP 场景(如电商订单、金融系统),是现在 MySQL 的主流默认引擎。
66-简述mysql中索引类型及对数据库的性能的影响
一、MySQL 中的索引类型
- 普通索引(INDEX):最基本的索引类型,允许索引列包含重复值。
- 唯一索引(UNIQUE INDEX):不允许索引列有重复值,每行值必须唯一。
- 主键索引(PRIMARY KEY):特殊的唯一索引,每张表只能有一个主键。在 InnoDB 中,主键索引是聚集索引(数据和索引存储在一起);在 MyISAM 中,主键索引与普通唯一索引存储结构相同(索引和数据分离)。
- 联合索引:基于多个字段组合创建的索引(如
INDEX(a, b)),覆盖多列查询场景。 - 全文索引(FULLTEXT):主要用于文本检索,应对
LIKE模糊查询效率低下的问题,但 MySQL 的全文索引功能相对鸡肋,生产环境中通常使用 Elasticsearch(ES)等专业搜索引擎替代。
二、索引对数据库性能的正面影响
- 索引能大幅提高查询效率,通过索引快速定位数据行,避免全表扫描。
- 索引能配合 MySQL 的优化器(Optimizer) 生成更优的执行计划,进一步提升系统性能。
三、索引对数据库性能的负面影响(为什么不能滥用)
- 降低写入性能:插入、更新、删除数据时,不仅要操作数据文件,还要同步维护所有索引文件(如 B+Tree 的节点调整),增加额外开销。
- 占用额外存储空间:每个索引都需要独立的物理空间存储,特别是 InnoDB 的非聚集索引(辅助索引),每建一个索引就多一棵 B+Tree。
- 聚集索引变更成本高:如果主键(聚集索引)发生变更,所有辅助索引中存储的主键值都需要同步更新,维护代价昂贵。
- 因此,索引并非越多越好,需要根据实际查询场景合理设计,在查询效率和写入/存储成本之间取得平衡。
67-RDB和AOF机制
一、RDB(Redis DataBase)持久化
- RDB 是在指定的时间间隔内,将内存中的数据集以快照(Snapshot) 方式写入磁盘,生成一个
.rdb文件。 - RDB 通过
fork()子进程来完成快照写入,主进程继续处理 Redis 命令,避免 I/O 阻塞主线程,实现性能最大化。 - RDB 文件是二进制的压缩文件,只有一个
.rdb文件,备份和容灾非常方便。 - RDB 的优点:文件紧凑、恢复速度快(直接加载快照文件)、性能高(主进程不参与 I/O)。
- RDB 的缺点一:数据安全性低,因为是定时快照,两次快照之间宕机会丢失这段时间的所有数据。
- RDB 的缺点二:当数据集很大时,
fork()子进程可能长时间占用 CPU,导致服务器短暂停止服务。
二、AOF(Append Only File)持久化
- AOF 以日志形式记录服务器处理的每一个写/删除操作(查询不记录),以文本方式存储,类似 MySQL 的
binlog。 - AOF 提供了三种同步策略:每秒同步(everysec)、每修改同步(always)、不同步(no),安全性从高到低,性能从低到高。
- AOF 的优点一:数据安全性高,尤其是
always策略能保证每个操作都同步到磁盘,最多丢失一条命令。 - AOF 的优点二:使用
append模式追加写入,即使宕机也不会影响已写入的内容,且提供了redis-check-aof工具修复损坏的文件。 - AOF 通过
rewrite(重写)机制合并冗余命令,压缩 AOF 文件大小,避免文件无限膨胀。 - AOF 的缺点一:文件体积大,记录了大量操作命令,占用磁盘空间大。
- AOF 的缺点二:恢复速度慢,因为需要重新执行所有操作命令,而非直接加载快照。
- AOF 的缺点三:运行效率低于 RDB,因为每次写操作都要记录日志(即使异步,仍有一定开销)。
三、RDB 与 AOF 的对比总结
- RDB 侧重性能,AOF 侧重数据安全:RDB 性能高但可能丢数据,AOF 数据更安全但性能开销大。
- 两种持久化方式可以同时开启,如果同时开启,Redis 在重启恢复数据时优先加载 AOF 文件(因为 AOF 数据更完整)。
68-Redis的过期键的删除策略
一、三种常见的过期键删除策略
- 定时删除:为每个设置了过期时间的 key 创建一个定时器,时间到了立即删除,内存最友好但 CPU 开销最大(Redis 未采用)。
- 惰性删除:访问 key 时才判断是否过期,过期则删除,CPU 最友好但内存最不友好(过期 key 会一直占用内存直到被访问)。
- 定期删除:每隔固定时间扫描一定数量的 key,清除其中已过期的 key,是定时删除和惰性删除的折中方案。
二、Redis 实际采用的策略
- Redis 同时采用了 惰性删除 + 定期删除 两种策略,两者配合使用,兼顾 CPU 和内存。
- Redis 的定期删除并非一次性扫描所有 key,而是每次扫描一定数量的 key,并通过调整扫描时间间隔和数量,在 CPU 和内存之间取得平衡。
- 由于 Redis 同时使用两种策略,过期 key 不一定会被立即删除,在下次定期扫描或被访问之前仍可能占用内存。
69-Redis线程模型,单线程为什么快
一、Redis 的线程模型
- Redis 的线程模型基于 Reactor 模式(响应式模式),实现了一个文件事件处理器(File Event Handler),该处理器是单线程的。
- 文件事件处理器包含四个核心部分:多个 Socket、I/O 多路复用器(I/O Multiplexing)、文件事件分派器(Dispatcher)、事件处理器。
- I/O 多路复用器负责监听多个 Socket 上的事件(连接事件、读事件、写事件等),并将产生事件的 Socket 放入一个队列中排队。
- 文件事件分派器从队列中依次取出 Socket,将其分派给对应的事件处理器进行处理。
- 一个 Socket 的事件处理完成后,I/O 多路复用器才会处理队列中的下一个 Socket 事件,整个流程是串行执行的。
二、Redis 单线程为什么这么快
- 纯内存操作:Redis 的所有数据操作都在内存中完成,内存读写速度极快,是 Redis 高性能的根本原因。
- I/O 多路复用机制:单线程通过 I/O 多路复用同时监听多个 Socket 事件,实现了非阻塞 I/O,一个线程即可处理大量并发连接。
- 主进程不进行磁盘 I/O:Redis 的持久化(RDB/AOF)通过
fork()子进程进行,主进程只处理命令,不参与磁盘 I/O,避免阻塞。 - 避免线程上下文切换开销:单线程无需像多线程那样频繁切换 CPU 上下文,节省了线程调度和栈切换的性能消耗。
- 单线程适合每个请求处理时间极短、请求量极大的场景(Redis 操作就是简单的读写 key-value,无复杂业务逻辑),类似 Nginx 的事件驱动模型。
70-缓存雪崩、缓存穿透、缓存击穿
一、缓存雪崩
- 缓存雪崩:缓存中大量数据在同一时间点集体失效(或缓存服务重启),导致大量请求直接落到数据库,造成数据库压力骤增甚至宕机。
- 解决方案一:设置缓存过期时间时添加随机值,让过期时间分散,避免大面积同时失效。
- 解决方案二:为缓存数据增加缓存标记,失效时立即主动更新,快速重建缓存。
- 解决方案三:系统启动前进行缓存预热,先将热点数据加载到缓存中再对外提供服务。
- 解决方案四:使用互斥锁(分布式锁) ,当缓存失效时只允许一个线程去查数据库并重建缓存,其他线程等待或自旋。
二、缓存穿透
- 缓存穿透:请求查询的数据在缓存和数据库中都不存在(如恶意攻击查询不存在的 ID),每次请求都会穿透缓存直接访问数据库。
- 解决方案一:在接口层增加参数校验,对明显不存在的请求(如无效 ID、非法格式)直接拦截,不访问数据库。
- 解决方案二:缓存空值(缓存
null):当数据库中也查不到时,将一个空值或占位符缓存起来(设置较短过期时间),后续相同 key 的请求被缓存拦截。 - 解决方案三:使用布隆过滤器(Bloom Filter),将所有可能存在的数据 key 存入布隆过滤器,一定不存在的数据在过滤器层就被拦截,存在的数据才允许查询缓存和数据库。
三、缓存击穿
- 缓存击穿:某个热点 key 的缓存恰好失效(或过期),而对该 key 的并发请求量极大,所有请求瞬间全部落到数据库上。
- 解决方案一:对热点 key 设置永不过期(不设过期时间),由后台程序异步更新缓存内容。
- 解决方案二:加互斥锁(分布式锁),当缓存失效时只允许一个线程去查数据库并重建缓存,其他线程自旋等待或返回旧值,避免大量请求同时穿透到数据库。
四、三者对比总结
- 缓存雪崩:大量 key 同时失效,压力分散但量大;缓存穿透:查询不存在的数据,请求直穿数据库;缓存击穿:单个热点 key 失效,压力集中但仅针对一个 key。三者概念、原因和解决方案各不相同。
71-简述redis事务实现
一、Redis 事务与 MySQL 事务的核心区别
- Redis 事务不支持回滚(rollback),这与 MySQL 事务不同,但 Redis 通过”命令要么全部执行要么全部不执行”来保证原子性语义。
- Redis 事务同样可以保证 ACID 四大特性:原子性、一致性、隔离性、持久性(通过 RDB/AOF 保证持久性)。
- Redis 的隔离性天然实现:由于 Redis 是单线程模型,事务执行时不存在并发事务干扰问题。
- Redis 提供了
WATCH命令(乐观锁机制)来监控事务中的 key,若 key 在事务执行前被修改,事务会被取消。
二、Redis 事务的三个执行阶段
- 第一阶段:事务开始(
MULTI):执行MULTI命令,Redis 将该客户端的标志位设为REDIS_MULTI,表示进入事务模式。 - 第二阶段:命令入队:在
MULTI之后、EXEC之前输入的命令(除MULTI、EXEC、WATCH、DISCARD外)不会立即执行,而是进入一个先进先出(FIFO)的队列中排队。 - 第三阶段:事务执行(
EXEC):执行EXEC命令,Redis 检查事务标志是否有效,若有效则依次执行队列中的所有命令。
三、Redis 事务的错误处理机制
- 语法错误(命令本身错误):Redis 会检测到并关闭事务标志,导致整个事务被取消,所有命令都不执行。
- 执行时错误(逻辑错误,如对字符串执行哈希操作):Redis 不会回滚,会继续执行队列中剩余的命令,由程序员负责避免此类错误。
- Redis 仅检查命令的语法正确性,不检查命令执行时的逻辑正确性,这是为了保持高性能而做出的设计取舍。
四、Redis 事务相关命令
WATCH key [key ...]:乐观锁,监控指定的 key,若事务执行前这些 key 被其他客户端修改,事务会被取消。MULTI:开启一个事务,客户端进入事务模式。EXEC:执行事务队列中的所有命令,执行完毕后退出事务模式。DISCARD:清空事务队列,放弃事务执行,退出事务模式。UNWATCH:取消对所有 key 的监控。
72-redis集群方案
一、Redis 集群的三种主流方案
- Redis 集群主要有三种方案:哨兵模式(Sentinel)、Redis Cluster(服务端分片)、客户端分片(Client Sharding)。
二、哨兵模式(Sentinel)
- 哨兵模式基于主从复制架构,是主从模式的升级,用于实现 Redis 的高可用(HA)。
- 哨兵的核心功能:集群监控(监控主从节点是否正常)、消息通知(故障时通知管理员)、故障转移(主节点宕机时自动完成主从切换)、配置中心(切换后通知客户端新的主节点地址)。
- 哨兵本身也是分布式的,支持集群部署(通常至少部署 3 个实例),避免哨兵自身单点故障。
- 哨兵判断主节点是否下线分为主观下线和客观下线,需要多数哨兵同意才触发故障转移。
- 哨兵模式不能保证数据零丢失(主从同步是异步的),只能保证高可用。
三、Redis Cluster(服务端分片)
- Redis Cluster 是 Redis 3.0 版本开始提供的服务端分片方案,采用哈希槽(Slot) 概念,总共 16384 个槽位,均匀分布在集群各节点上。
- 数据分片逻辑由服务端决定:对 key 进行哈希计算后对 16384 取模,确定该 key 落在哪个槽位,再由槽位所在的节点提供服务。
- 集群节点互为主从:每个节点既是某些槽位的主节点,又是另一些槽位所属主节点的从节点,数据同步是主从异步复制,不保证强一致性。
- 客户端只需连接集群中任意一个可用节点,该节点会将请求自动转发到正确的节点。
- 扩缩容时,只需在节点间迁移槽位即可实现动态扩容/缩容,对业务透明。
- 每个 Redis Cluster 节点需要开放两个端口:一个供客户端访问(如 6379),另一个(端口号 + 10000,如 16379)用于集群总线(Cluster Bus) 通信(节点间心跳、故障检测、配置更新等)。
- 集群总线采用二进制协议(Gossip 协议),通信效率高、占用带宽少。
- 优点:无中心化架构(多主多从)、支持动态扩容、客户端无需连接所有节点。
- 缺点:只能使用
db 0(不支持多库)、不支持批量操作、节点间需要迁移槽位操作较复杂。
四、客户端分片(Client Sharding)
- 客户端分片:由客户端通过哈希算法自行决定 key 存储到哪个 Redis 节点,节点之间互不感知、相互独立。
- 优点:每个节点独立运行,扩展灵活(加节点无需节点间数据迁移),运维简单。
- 缺点:不支持动态增删节点(客户端需感知节点列表变化并更新配置)、连接不能共享(不同节点独立连接,资源占用大)、服务端拓扑变化时每个客户端都需要调整。
五、三种方案的对比总结
- 哨兵模式:主从 + 高可用,适合中小规模,不支持分片扩展。
- Redis Cluster:服务端分片,适合大规模数据,动态扩缩容,是官方推荐方案。
- 客户端分片:客户端控制数据分布,适合对灵活性要求高的场景,但运维成本较高。
73-redis主从复制的核心原理
一、主从复制的两种基本类型
- 全量复制:将主节点的全部数据(RDB 快照文件)完整拷贝到从节点,通常在第一次建立主从关系或部分复制无法进行时触发。
- 增量复制(部分复制):仅同步主节点上从上次断开后新增的数据,通过网络传输复制积压缓冲区中的增量数据,效率远高于全量复制。
二、全量复制的执行过程
- 主节点执行
bgsave命令,fork 一个子进程生成 RDB 快照文件(全量数据快照),这个过程消耗 CPU 和磁盘 I/O。 - 主节点通过网络将 RDB 文件发送给从节点,消耗网络带宽。
- 从节点收到 RDB 文件后,清空自身所有旧数据,然后加载新的 RDB 文件,加载过程会阻塞从节点,无法响应客户端请求。
- 如果从节点开启了 AOF 持久化,加载 RDB 后还需执行
bgrewriteaof重写 AOF 文件,带来额外开销。
三、增量复制的三个核心概念
- 复制偏移量(Replication Offset):主从节点各自维护的同步进度标记(字节偏移量),用于标识数据同步的位置,主从偏移量不一致时需增量或全量同步。
- 复制积压缓冲区(Replication Backlog):主节点在内存中维护的一个固定长度、先进先出(FIFO)的队列,缓存最近写入的数据,用于增量复制时提供给从节点。
- 服务器运行 ID(Run ID):每个 Redis 节点启动时生成的唯一标识符,用于主从节点间的身份识别和校验。
四、增量复制的触发条件与判断逻辑
- 从节点向主节点发送
PSYNC命令时,附带自己保存的主节点 Run ID 和复制偏移量(offset)。 - 主节点收到
PSYNC后,先判断从节点上报的 Run ID 是否与自己的 Run ID 一致:- 一致:说明之前同步过,继续判断 offset 是否在复制积压缓冲区范围内,若在则进行增量复制。
- 不一致:说明主节点已变更(如发生了故障切换),只能进行全量复制。
- 即使 Run ID 一致,如果从节点的 offset 已经超出了复制积压缓冲区的范围(缓冲区溢出导致数据被挤掉),也只能进行全量复制。
五、PSYNC 命令的两种场景
- 第一次复制:从节点发送
PSYNC ? -1(无 Run ID、offset 为 -1),主节点直接返回FULLRESYNC,触发全量复制。 - 非第一次复制:从节点发送
PSYNC <runid> <offset>,主节点根据 Run ID 和 offset 判断后决定执行增量复制或全量复制。
74-CAP理论,BASE理论
一、CAP 理论概述
- CAP 理论是分布式系统的理论基础,定义了分布式系统在面临网络分区时必须做出的权衡。
- CAP 分别代表三个核心特性:C(Consistency,一致性)、A(Availability,可用性)、P(Partition Tolerance,分区容错性)。
二、CAP 三者的含义
- 一致性(C):分布式系统中所有节点在同一时间的数据完全一致,即强一致性——客户端在任何节点都能读到最新写入的数据。
- 一致性从两个角度看:客户端指并发访问时如何获取最新数据;服务端指数据更新后如何复制到所有节点。
- 可用性(A):整个集群在任何时候都能正常响应客户端请求,不能出现超时或操作失败,即使部分节点发生故障。
- 分区容错性(P):当网络中断、节点间通信失败(网络分区)时,整个集群仍然能够对外提供服务。
- 分区容错性是分布式系统的必备能力,因为网络故障在分布式环境中不可避免。
三、CAP 的取舍:P 是必选项,C 和 A 二选一
- CAP 理论的核心结论:当发生网络分区(P)时,必须在一致性(C)和可用性(A)之间二选一,无法同时保证三者。
- 选择 CP(放弃可用性):发生网络分区时暂停对外服务,等待数据恢复一致后再提供服务,保证强一致性但牺牲可用性。
- 选择 AP(放弃强一致性):发生网络分区时继续对外提供可用服务,但允许各节点数据不一致,保证可用性但牺牲强一致性。
四、BASE 理论——CAP 的妥协方案
- BASE 理论是对 CAP 理论的一种妥协和扩展,在分布式系统中实践中普遍采用。
- 基本可用(Basically Available):系统出现故障时,允许响应时间延长(如 0.5 秒变 3 秒)或部分非核心功能降级,但整体服务依然可用。
- 软状态(Soft State):允许系统在数据同步过程中存在中间状态,即不同节点间的数据短时间内可以不一致。
- 最终一致性(Eventually Consistent):系统不要求数据实时一致,但保证经过一段时间后所有节点数据最终会达到一致状态。
- BASE 理论本质上是对 CAP 中一致性(C)的弱化,从”强一致性”降为”最终一致性”,以换取更高的可用性,是实际工程中分布式系统的主流设计思路。
75-负载均衡算法、类型
一、常见的负载均衡算法(6种)
- 轮询法(Round Robin):请求按顺序依次分配给集群中的每台服务器,循环往复,实现均匀分发。
- 随机法(Random):通过随机算法将请求随机分配到某一台服务器上。
- 源地址哈希法(IP Hash):根据客户端 IP 地址进行哈希计算,再对服务器数量取模,将同一 IP 的请求固定分配到同一台服务器(常用于会话保持)。
- 加权轮询法(Weighted Round Robin):在轮询基础上为不同服务器设置权重,性能高的服务器权重更大,分到更多请求。
- 加权随机法(Weighted Random):在随机分发基础上为服务器设置权重,权重高的服务器被随机分配到的概率更大。
- 最小连接数法(Least Connections):将新请求分配给当前活跃连接数最少的服务器,能更均衡地利用资源,避免某些节点过载。
二、负载均衡的三种类型
- DNS 负载均衡:通过 DNS 解析将同一个域名解析到不同的 IP 地址,实现最基本的负载分发,效率极高且对请求链路几乎没有性能损耗。
- 硬件负载均衡:通过专用硬件设备(如 F5、A10)实现负载均衡,性能高但成本昂贵,一般用于大型企业。
- 软件负载均衡:通过软件实现负载均衡,常见的有 Nginx、HAProxy、LVS,是开发人员和运维人员最常接触的类型。
三、四层负载均衡(LVS)与七层负载均衡(Nginx/HAProxy)的区别
- 四层负载均衡(如 LVS) 工作在传输层(TCP/UDP),只根据目标 IP 和端口进行转发,不关心请求内容,修改目标 IP 后直接转发,性能极高。
- 七层负载均衡(如 Nginx、HAProxy) 工作在应用层(HTTP/HTTPS),需要解析 URL、HTTP 头等请求内容,根据业务规则(如 URL 路径)进行路由分发。
- 七层负载均衡需要与客户端和服务端分别维护长连接,还要解析 HTTP 报文,因此性能低于四层负载均衡,但功能更强大(支持 URL 路由、会话保持、内容缓存等)。
76-分布式架构下,Session共享有什么方案
一、Session 共享问题的产生背景
- 在单体应用中,Session 存储在服务器本地内存中,每个 HTTP 请求携带 Cookie 中的
SessionID即可关联服务端 Session 数据。 - 在分布式集群环境下,用户首次请求落在节点 A(登录信息存于 A 的本地内存),第二次请求被负载均衡到节点 B,B 的本地内存中没有该 Session,导致用户被要求重新登录——这就是 Session 共享问题。
二、Session 共享的 5 种解决方案
- 方案一:无状态服务(抛弃 Session):不使用服务端 Session,采用 JWT(JSON Web Token) 或 Token 机制,将用户状态信息存储在客户端,服务端不保存状态,天然支持分布式。
- 方案二:存入 Cookie(客户端存储):将 Session 数据直接存到客户端 Cookie 中,服务端不存状态,请求自带数据,但存在安全风险(数据暴露在客户端)。
- 方案三:服务器间 Session 同步:各节点之间通过同步机制将 Session 数据复制到所有服务器,每台服务器都保存全量 Session 信息,但占用资源多且存在同步延迟/失败问题。
- 方案四:IP 绑定策略(会话粘滞):在负载均衡层(如 Nginx)根据客户端 IP 进行哈希,将同一 IP 的请求始终路由到同一台服务器(如
ip_hash),绕开共享问题,但牺牲了负载均衡的均匀性,且节点宕机会丢失该节点上的所有 Session。 - 方案五:使用中间件集中存储(推荐):将 Session 统一存储到Redis、Memcached 等外部缓存/数据库中,所有服务节点读写同一个中间件,实现 Session 共享。
三、中间件存储方案的优势
- 扩展性好:Redis 等中间件支持水平扩展(集群),扩容方便。
- 服务重启不丢失 Session:中间件有持久化机制(如 Redis 的 RDB/AOF),服务节点重启不影响 Session 数据。
- 跨平台跨语言:不同语言(Java、Python、Node.js 等)都可以访问同一个 Redis,解决了多语言异构系统的 Session 共享问题。
- 跨端兼容:PC 端、手机 App 端可以通过同一个中间件共享用户登录状态。
77-简述你对RPC、RMI的理解
一、RPC(远程过程调用)的概念
- RPC(Remote Procedure Call,远程过程调用):让开发者像调用本地方法一样调用远程服务器上的函数或方法,屏蔽底层的网络通信细节。
- RPC 可以基于不同协议实现,如基于 HTTP(应用层协议,如
HttpClient)或基于 TCP/UDP(传输层协议,如 Dubbo 的 RPC 协议),基于 TCP 的性能通常高于基于 HTTP。 - RPC 本身与语言无关,调用方和被调用方可以用不同编程语言实现。
二、RMI(Java 远程方法调用)的概念
- RMI(Remote Method Invocation) 是 Java 对 RPC 的一种专有实现,要求调用方和被调用方都必须运行在 JVM 上(即双方都必须是 Java 应用)。
- RMI 的核心规范要求:远程对象必须实现
java.rmi.Remote接口(标记接口),并继承java.rmi.server.UnicastRemoteObject类,才能被远程调用。 - RMI 通过 JVM 注册表(Registry) 进行服务注册与发现,服务端将远程对象绑定到注册表,客户端从注册表查找并获取远程对象的存根(Stub)。
- RMI 底层通过 Socket + 代理(Proxy) 实现网络通信,客户端获取的实际上是远程对象的代理对象(存根 Stub),调用代理对象方法时,底层自动完成网络请求和服务端方法调用。
- 使用 RMI 前,需要启动 JVM 自带的注册表服务(
rmiregistry命令),服务端向注册表注册服务,客户端从注册表查找服务。
三、RPC 与 RMI 的关系总结
- RPC 是概念/规范,RMI 是 Java 对这一概念的一种具体实现;RMI 也可以被理解为Java 的 RPC 实现版本。
- RMI 与通用 RPC 的最大区别:RMI 与 Java 语言强绑定,要求两端都是 Java 环境;而 RPC 是语言无关的(如 gRPC、Thrift 支持多语言)。
78-分布式id生成方案
一、UUID
- UUID(通用唯一标识符):由时间戳 + 时钟序列(计数器)+ 机器识别码(如 MAC 地址)三部分综合生成,保证全局唯一性。
- 优点:生成性能极高(本地生成,无网络消耗)、使用简单(JDK 自带 API)。
- 缺点:无序(不适合数据库聚集索引)、字符串存储空间大、ID 无业务含义、可能泄露机器信息(如 MAC 地址)。
二、数据库自增 ID
- 数据库自增 ID:利用数据库的
AUTO_INCREMENT作为分布式 ID 生成源,单点数据库可保证唯一性。 - 单点风险:可通过数据库主从/多主集群解决,但多主部署时需为每个节点设置不同的起始值和相同的步长(步长必须大于节点数),否则会生成重复 ID。
- 缺点:系统扩容困难(调整步长复杂)、数据库易成性能瓶颈、ID 生成请求频繁时数据库压力大。
三、号段模式(美团 Leaf Segment)
- 号段模式(Segment):一次从数据库获取一批 ID(如 100 个)缓存在本地,用完再取下一批,大幅降低数据库访问频率。
- 优点:扩展灵活(支持按业务字段独立分配号段)、降低了数据库并发压力。
- 缺点:多个节点同时申请号段时仍会给数据库带来压力、号段长度固定(业务量突增时需人工调整)。
四、双 Buffer 号段模式(美团 Leaf 优化版)
- 双 Buffer 优化:维护当前号段和下一个预缓存号段两个 Buffer,当前号段消耗到 10% 时异步准备下一个号段,当前号段用完后无缝切换,将数据库请求与 ID 消费解耦。
- 优点:减少数据库查询次数、降低对数据库的依赖、平滑应对高并发。
- 缺点:号段长度固定(需可配置)、当号段跨度较大且未消费完时节点宕机会造成 ID 空洞(浪费部分 ID)。
五、Redis / MongoDB 生成 ID
- 使用 Redis(
INCR命令)或 MongoDB 等中间件替代数据库生成 ID,利用其高吞吐量特性缓解性能瓶颈,实现方式与数据库自增类似。
六、雪花算法(Snowflake)
- 雪花算法生成一个 64 位的 Long 型 ID,位分配为:1 位符号位(固定 0)+ 41 位时间戳 + 10 位机器 ID(Worker ID)+ 12 位序列号。
- 优点:趋势递增(时间戳在高位,整体有序)、性能极高(本地生成,无网络开销)、位数可灵活调整以适应不同场景。
- 缺点:存在时钟回拨问题(机器时间被人为回调时可能生成重复 ID)。
- 雪花算法的容量受限于序列号位数(12 位 = 每毫秒每台机器最多 4096 个 ID),而非时间戳位数。
- 雪花算法生成的是趋势递增(按时间大致递增),而非数据库自增那样的连续递增(1,2,3,4…)。
79-分布式锁解决方案
一、分布式锁的核心概念
- 分布式锁解决的问题与本地锁(如
synchronized)本质相同:保证一段代码在同一时间只被一个线程执行,但分布式锁需要在多个独立节点间协调,必须依赖第三方中间件来实现。
二、方案一:基于数据库实现分布式锁
- 利用数据库的唯一约束(主键或唯一索引):多个节点尝试插入同一条记录,插入成功的节点获得锁,插入失败的则未获得锁。
- 优点:实现简单,无需额外引入中间件。
- 缺点:非阻塞(失败后不会自动等待,需手动自旋循环);不可重入(同一线程递归获取锁会因插入失败而阻塞);存在死锁风险(持有锁的节点宕机后记录残留,需手动清理);不支持自动过期(需自己写定时任务清理过期记录);数据库易成性能瓶颈。
三、方案二:基于 ZooKeeper 实现分布式锁
- ZooKeeper 利用临时顺序节点实现分布式锁:多个客户端在相同路径下创建临时顺序节点,序号最小的节点获得锁。
- 优点:客户端宕机时临时节点自动删除(无死锁问题);通过 Watcher 机制监听前一个节点,实现顺序唤醒,避免羊群效应。
- 缺点:引入 ZK 组件增加运维复杂度,性能比 Redis 略低。
四、方案三:基于 Redis 实现分布式锁(最常用)
- Redis 通过
SETNX命令(SET if Not eXists) 实现分布式锁:key 不存在时设置成功获得锁,存在则获取失败。 - Redis 天生适合做分布式锁,因为其处理网络请求是单线程的,天然保证原子性。
五、Redis 分布式锁的关键问题与演进
- 早期版本问题:
SETNX和EXPIRE(设置过期时间)是两个独立命令,非原子操作,可能导致设置过期时间失败而造成死锁。 - 高版本改进:使用
SET key value NX EX timeout将加锁和设置过期时间合并为一个原子命令。 - 锁超时与误删问题:任务执行时间超过锁过期时间时,锁被自动释放,其他线程获得锁,原线程执行完后可能误删别人的锁。
- 解决方案(防误删):在 value 中存入线程唯一标识(如 UUID 或线程 ID),释放锁时先验证 value 是否为自己,匹配才删除。
六、Redis 分布式锁的高级问题
- 锁续期问题:任务未完成但锁已过期,需要自动续期。可通过
Redisson客户端的 Watch Dog 机制实现,定期检查并延长锁过期时间。 - 可重入锁:需要类似 Java
AQS的实现,在 value 中存储线程标识和计数器(count),每次重入 count+1,释放时 count-1 至 0 才真正释放。 - 集群数据一致性问题:Redis 主从异步复制时,若锁在主节点写入成功但尚未同步到从节点时主节点宕机,锁会丢失。
七、RedLock(红锁)方案
- RedLock 算法:在多个 Redis 独立节点(非主从,通常 >= 3 个)上同时申请锁,当半数以上节点(N/2+1)返回成功时才算获得锁,解决了 Redis 主从异步复制导致锁丢失的问题。
- RedLock 是一种思想/算法,
Redisson客户端已提供了完整实现,无需开发者自行编码。
80-分布式事务解决方案
一、分布式事务的产生场景
- 分布式事务有两种产生场景:同一应用内操作多个数据源(如一个 Service 中操作两个不同数据库)和跨应用/跨节点调用(如服务 A 调用服务 B,分别操作各自的数据库)。
- 本地事务(如
@Transactional)只能保证单个数据库的事务,无法保证跨数据源或跨服务的原子性。
二、XA 规范与 XA 事务模型
- XA 规范是分布式事务的行业标准,要求数据库实现该规范才能参与分布式事务(MySQL 的 InnoDB 支持 XA)。
- XA 事务模型中包含四个角色:TM(事务管理器,协调者)、RM(资源管理器,即数据库)、AP(应用程序,发起事务的客户端)。
- TM 为每个分布式事务分配全局唯一的 TX ID(事务 ID),所有参与者通过该 ID 识别同一个事务。
三、两阶段提交(2PC)
- 两阶段提交(2PC) 是 XA 规范默认的分布式事务协议,分两个阶段:准备阶段和提交/回滚阶段。
- 第一阶段(准备阶段):所有参与者各自执行本地事务但不提交,然后向 TM 汇报准备状态(
OK或FAIL)。 - 第二阶段(提交/回滚阶段):TM 根据所有参与者的状态统一决策——全部返回
OK则发送COMMIT,任一返回FAIL则发送ROLLBACK。 - 2PC 的缺点:TM 存在单点故障(TM 宕机后所有参与者陷入阻塞等待);同步阻塞(参与者持有锁等待 TM 决策,响应时间长);网络异常时可能出现部分节点已提交、部分未提交的不一致情况。
四、三阶段提交(3PC)
- 三阶段提交(3PC) 是 2PC 的优化版本,将 2PC 的第一阶段拆分为两步:
CanCommit(询问能否提交) →PreCommit(预提交,执行事务) →DoCommit(正式提交/回滚)。 - 3PC 引入了参与者的超时机制,TM 宕机后参与者超时自动提交,避免无限阻塞,提高了可用性。
- 3PC 仍无法根本解决一致性问题,网络异常时仍可能出现部分节点提交、部分未提交的不一致情况,且响应时间长的性能问题依然存在。
五、TCC(补偿事务)
- TCC(Try-Confirm-Cancel) 是一种业务层面的补偿事务方案,不依赖数据库的提交/回滚机制,所有回滚逻辑由开发者通过代码实现。
- TCC 三个方法:Try(预留/检查资源)、Confirm(确认提交,真正执行业务)、Cancel(补偿回滚,反向操作)。
- TCC 与 2PC/3PC 的本质区别:2PC/3PC 依赖数据库的
COMMIT/ROLLBACK,而 TCC 是业务代码层面的补偿,数据库事务正常提交,回滚通过反向业务操作实现。 - TCC 的缺点:开发者需要为每个业务操作编写 Try、Confirm、Cancel 三个实现,代码量大,业务侵入性强。
六、事务消息(MQ 最终一致性)
- 事务消息方案通过消息队列(RocketMQ 等支持)实现分布式事务的最终一致性:发送方将事务消息发送到 MQ(对消费者不可见)→ 执行本地事务 → 根据本地事务结果提交或回滚消息。
- 若本地事务成功,发送方将消息变为可见,消费者消费消息执行自身业务;若消费者处理失败,可通过重试机制保证最终成功。
- 若本地事务失败,发送方回滚消息(删除/取消),消费者不会消费该消息。
- 事务消息的核心思想是最终一致性,而非强一致性,通过消息重试和补偿机制保证事务最终成功。
81-如何实现接口募等性
一、幂等性的概念
- 接口幂等性:同一个接口使用相同的参数多次调用,产生的最终结果与调用一次的结果一致,不会对数据产生额外的副作用。
- 查询操作天然是幂等的(
SELECT多次执行结果一致),幂等性主要针对写操作(INSERT、UPDATE、DELETE)在高并发或重复请求场景下的问题。 - 幂等性要解决的问题:防止因网络重试、前端重复点击、消息重复消费等导致的数据重复插入或状态异常。
二、幂等性的常见实现方案
- 方案一:数据库唯一约束(唯一ID/唯一索引):在数据库表中为业务唯一字段(如手机号、订单号)设置唯一约束,重复插入时数据库报错拒绝,保证同一条数据只会被插入一次。
- 方案二:服务端 Token 机制:服务端先向前端发放一个唯一 Token(设置有效期),前端请求时携带 Token,服务端校验 Token 是否存在且有效(校验后立即删除),同一请求只能成功处理一次,后续重复请求被拒绝。
- 方案三:去重表:专门建一张去重表,将请求的唯一标识(如业务 ID + 接口名)存入去重表并设置唯一约束,每次请求先检查去重表,已存在则拒绝,不存在则允许执行并记录。
- 方案四:乐观锁(版本号控制):在数据表中增加
version字段,更新时WHERE version = 旧版本号,同时将version加 1,只有版本号匹配时更新才成功,后续重复请求因版本号已变而失败。 - 方案五:状态机(状态前置校验):为业务数据定义有限状态(如订单:待支付→已支付→已发货),每个状态只能由特定前置状态转移而来,重复请求因状态已变更而无法再次执行相同操作。
82-简述ZAB协议
一、ZAB 协议概述
- ZAB 协议是 ZooKeeper 专属的原子广播协议,全称 ZooKeeper Atomic Broadcast,用于实现分布式数据的一致性,支持崩溃恢复。
- ZAB 协议包含两种基本模式:消息广播(正常状态)和崩溃恢复(异常状态),ZooKeeper 集群始终处于这两种模式之一。
二、消息广播模式
- 所有写操作都由 Leader 节点处理:客户端连接 Follower 时,Follower 会将写请求路由到 Leader。
- Leader 将客户端的写请求封装为一个事务(Proposal),广播给集群中所有 Follower 节点。
- Leader 等待 Follower 的反馈,当收到半数以上(过半数)Follower 的确认后,才将事务真正提交并写入 ZooKeeper 的数据树中。
- 消息广播模式类似于 2PC(两阶段提交):先广播事务,待过半确认后再广播提交。
三、崩溃恢复模式
- 在以下三种情况下进入崩溃恢复模式:集群初始化启动时、Leader 崩溃或宕机、Leader 与超过半数节点断联。
- 崩溃恢复模式下,集群无法提供写操作,ZooKeeper 进入选举状态(无主状态),重新选举新的 Leader。
- 新 Leader 选举产生后,先与集群中过半数 Follower 进行数据同步,使数据达到一致,然后退出恢复模式,进入消息广播模式。
- 崩溃恢复机制保证了 ZooKeeper 的强一致性(CP 特性),但牺牲了可用性(A),因为在恢复期间集群不可写。
四、ZAB 协议的三个核心概念
- 三种节点状态:
FOLLOWING(从节点,只读和服从 Leader)、LEADING(主节点,协调事务)、ELECTION/LOOKING(选举状态,集群无主)。 zxid(事务 ID):ZAB 协议中的 64 位事务编号,高 32 位为纪元(epoch),低 32 位为单调递增计数器,每处理一个客户端事务请求计数器加 1。zxid的作用:每个事务(包括每一次 Leader 选举)都有唯一编号,低 32 位可判断事务处理顺序,高 32 位标识 Leader 任期。- 每次新 Leader 选举产生后,新 Leader 会读取本地日志中最大的
zxid,将其高 32 位(epoch)+1 作为新纪元,低 32 位归零重新递增。
83-zk的数据模型和节点类型
一、ZooKeeper 的数据模型(ZNode)
- ZooKeeper 的数据模型是一个树形结构(多叉树),类似于 Windows 文件系统的目录结构,每个节点称为 ZNode。
- ZNode 兼具文件和目录两种特性:可以作为路径标识(类似目录),也可以存储数据(类似文件),但每个节点存储的数据大小限制为 1MB。
- ZNode 的唯一标识是全路径(如
/app/user/123),路径必须以/开头,且没有相对路径的概念。 - ZNode 支持原子性读写:读操作一次获取该节点所有数据和元信息,写操作一次替换该节点所有数据。
- 每个 ZNode 都有自己的 ACL(访问控制列表),用于控制不同用户对节点的增删改查权限。
- ZooKeeper 的数据会通过日志文件和快照文件持久化到磁盘,保证宕机后可恢复。
二、ZNode 的节点类型
- 持久节点(Persistent):创建后一直存在,即使创建它的客户端与 ZK 服务器断开连接,节点也不会被删除,数据会持久化到磁盘。
- 临时节点(Ephemeral):节点的生命周期与创建它的客户端会话绑定,客户端会话断开后节点会被 ZK 服务器自动删除(用于实现分布式锁)。
- 有序节点(Sequential):不是一种独立节点类型,而是在持久或临时节点基础上增加顺序特性,ZK 会自动在节点名后追加一个单调递增的序号(如
/node-0000000001)。 - 因此 ZNode 共有四种类型:持久节点、持久有序节点、临时节点、临时有序节点。
三、临时节点在分布式锁中的应用
- 临时节点天然解决了死锁问题:客户端创建临时节点获得锁,若客户端崩溃,会话断开,ZK 自动删除该临时节点,锁自动释放,其他客户端可继续竞争。
84-简述zk的命名服务、配置管理、集群管理
一、ZooKeeper 的三大核心功能概述
- ZooKeeper 的三种主要使用场景是:命名服务(Name Service)、配置管理(Configuration Management) 和 集群管理(Cluster Management)。
二、命名服务
- 命名服务:通过一个逻辑名称(服务名)获取对应的资源地址(如 IP + 端口),类似于注册中心的服务发现功能。
- ZooKeeper 利用 ZNode 路径的全局唯一性,将服务名作为节点路径,将服务地址(URL)作为节点数据存储,客户端通过服务名即可获取地址进行调用。
三、配置管理
- 配置管理:将分布式系统中各节点的配置信息(如数据库连接、FTP 地址、端口等)集中存储在 ZooKeeper 的某个 ZNode 中,实现配置的统一管理。
- 配置变更时,只需修改 ZooKeeper 中对应节点的数据,ZooKeeper 通过 Watcher 监听机制 自动通知所有订阅该节点的客户端,客户端收到通知后拉取最新配置,无需逐个节点手动修改。
四、集群管理
- 集群管理:包括集群监控(监控集群中机器是否在线)和集群控制(动态增删集群节点)。
- 集群管理利用 ZooKeeper 的临时节点(Ephemeral Node) 特性:每台机器在 ZooKeeper 中创建一个临时节点表示其在线状态,若机器宕机或断连,会话失效,临时节点被自动删除,实现自动下线。
- 集群监控通过 Watcher 机制监听这些临时节点的变化,节点新增或删除时,所有订阅者会收到通知,从而实现集群成员的实时感知。
85-讲下Zookeeper watch机制
一、Watch 机制的概念
- Watch 机制是 ZooKeeper 的一种事件监听机制:客户端可以在某个 ZNode 上注册一个 Watcher(监听器),当该 ZNode 发生创建、修改、删除等变化时,ZooKeeper 服务端会通知客户端。
- 客户端收到通知后,只知道该节点有事件发生,但不知道具体事件内容,需要客户端主动重新查询服务端获取最新数据或事件详情。
二、Watch 的两大特性
- 一次性(One-time Trigger):Watcher 被触发后会自动从服务端移除,客户端若需继续监听,必须重新注册。设计为一次性的目的是减轻服务端压力,避免无效通知。
- 轻量级(Lightweight):Watcher 通知只告知客户端”节点发生了变化”,不携带变化的具体内容或数据,客户端需自行查询,目的是节省带宽和服务端资源。
三、Watch 机制中的角色与流程
- Watch 涉及三个角色:客户端主线程、客户端 Watcher Manager(监听管理器)、ZooKeeper 服务端。
- 客户端注册 Watcher:客户端设置 Watch 时,会将 Watcher 信息存储在客户端的 Watcher Manager 中,同时在服务端注册(服务端维护 ZNode 到客户端连接信息的映射)。
- 服务端触发通知:当 ZNode 发生变化时,服务端生成一个事件(Event),通过之前保存的 Socket 连接信息,将事件发送给所有注册了该节点 Watcher 的客户端。
- 客户端回调处理:客户端收到通知后,Watcher Manager 会触发对应的回调方法,回调是串行同步的,保证同一客户端的事件按顺序依次处理,不会并发乱序。
86-zk和eureka的区别
一、CAP 理论视角下的核心区别
- ZooKeeper 遵循 CP 原则:保证强一致性(Consistency) 和分区容错性(Partition Tolerance),但在崩溃恢复期间暂时牺牲可用性(Availability)。
- Eureka 遵循 AP 原则:保证高可用性(Availability) 和分区容错性(Partition Tolerance),但放弃强一致性,允许不同节点间的数据暂时不一致。
二、ZooKeeper 的 CP 特性
- ZooKeeper 采用主从架构:Leader 负责所有写请求,Follower 负责读请求,写请求统一路由到 Leader,保证全局强一致性。
- ZK 在网络分区或 Leader 宕机时会进入崩溃恢复模式进行 Leader 选举,期间整个集群不可写(不可用),体现了 CP 的设计选择。
三、Eureka 的 AP 特性
- Eureka 采用对等(Peer-to-Peer)架构:所有节点平等,没有主从之分,任一节点挂掉不影响其他节点正常工作。
- 客户端向某台 Eureka 节点注册失败时会自动切换到其他可用节点,只要有至少一台 Eureka 存活,注册服务就能继续对外提供服务。
- Eureka 不保证强一致性:网络分区时,不同节点间的服务列表可能不一致,客户端可能从不同节点读到不同的服务地址。
- Eureka 的自我保护机制:当某节点检测到 85% 以上的服务心跳丢失时,会认为是自己网络有问题,不会主动剔除这些服务,防止误删大量正常服务。
- Eureka 客户端会缓存本地服务列表,即使所有 Eureka 服务端全部宕机,客户端仍可继续使用本地缓存进行服务调用,为集群恢复争取时间。
87-Spring Cloud和Dubbo的区别
一、底层协议不同
- Spring Cloud 默认基于 HTTP 协议(应用层),接口为 RESTful API 风格。
- Dubbo 默认基于 TCP 协议(传输层),实现 RPC 调用,由于 TCP 层比 HTTP 层更底层,Dubbo 的性能通常高于 Spring Cloud。
- Spring Cloud 也可以切换为 gRPC 等协议,但默认场景下 Dubbo 的网络传输效率更高。
二、注册中心选择不同(CAP 倾向不同)
- Spring Cloud 默认使用 Eureka 作为注册中心(遵循 AP:高可用,弱一致性),集群中只要有一个节点存活,注册服务就能继续工作。
- Dubbo 默认使用 ZooKeeper 作为注册中心(遵循 CP:强一致性,弱可用),网络分区或 Leader 选举期间集群不可用。
- Eureka 提供客户端缓存机制,即使所有 Eureka 节点宕机,客户端仍可继续使用本地缓存服务列表;ZooKeeper 则在崩溃恢复期间不可写。
三、服务模型定义不同
- Dubbo 将一个接口定义为一个服务(服务粒度较细),一个应用内可包含多个服务接口。
- Spring Cloud 将一个应用定义为一个服务(服务粒度较粗),每个微服务是一个独立的应用。
- 由于服务粒度不同,Dubbo 更常被称为”分布式服务框架”,而 Spring Cloud 更符合”微服务架构”的定位。
四、功能定位不同
- Spring Cloud 是一个完整的微服务生态,提供了微服务架构的整套解决方案(服务发现、配置管理、熔断降级、网关、链路追踪等),开箱即用。
- Dubbo 专注于服务治理和服务调用,仅解决了微服务架构中的”服务间通信”这一个环节,其他功能(如熔断)需自行集成第三方组件。
- 在 Spring Cloud 中集成 Hystrix 等服务熔断组件非常简单(引入依赖即可),而在 Dubbo 中集成相同功能需要更多的编码和配置工作。
88-什么是Hystrix?简述实现机制
一、Hystrix 的概念与作用
- Hystrix 是 Spring Cloud 中的分布式容错框架,核心作用是阻止故障的连锁反应(服务雪崩),实现熔断、降级和资源隔离。
- 在微服务链式调用中(如 A → B → C),若某个服务压力暴增或故障,Hystrix 可通过熔断快速切断故障链路,防止整个系统被拖垮。
- 快速失败(Fail Fast):当非核心服务执行时间长或资源占用高时,直接返回预定义的 Fallback(降级响应),而不真正执行业务逻辑,实现优雅降级。
二、Hystrix 的两种资源隔离机制
- 线程池隔离(Thread Isolation):每个被调用的依赖服务分配一个独立的线程池,A 服务调用 B 使用线程池 1,调用 C 使用线程池 2,线程池 1 满了也不会影响线程池 2 的请求,隔离效果好但消耗较多线程资源。
- 信号量隔离(Semaphore Isolation):通过信号量(并发计数器)控制并发请求数,请求需先获取信号量才能发起调用,超过信号量阈值的请求直接进入 Fallback 降级逻辑,资源消耗小但隔离性不如线程池隔离。
三、熔断与降级的实现机制
- 使用 Hystrix 时,需继承
HystrixCommand类,将对外部系统的调用细节封装在命令中(典型的命令模式),每个命令运行在独立的线程池中。 - Hystrix 会统计每个依赖调用的成功、失败、超时、拒绝次数,当错误比例或请求量达到配置的阈值时,断路器(Circuit Breaker)打开(Open),后续所有请求直接进入 Fallback 降级逻辑,不再真正调用目标服务。
- 断路器打开后,经过配置的时间窗口,Hystrix 会允许少量请求通过(半开状态),若请求成功则关闭断路器恢复正常,若仍失败则继续保持打开状态。
- 熔断是对故障服务的快速切断,降级是熔断后返回预设的友好响应(Fallback),两者配合实现服务自我保护。
89-springcloud核心组件及其作用
一、服务注册与发现(Eureka)
- Eureka 是 Spring Cloud 中的服务注册与发现组件,所有微服务节点启动时向 Eureka Server 注册自己的 IP、端口、服务名等信息。
- Eureka Server 维护一个双层 Map:第一层 key 为服务名,第二层 key 为实例 ID,value 为该实例的地址信息。
- 服务调用方通过服务名而非 IP+端口调用目标服务,Eureka Client 自动从注册中心获取目标服务的实际地址。
- Eureka Client 会本地缓存服务列表,即使 Eureka Server 全部宕机,仍可使用本地缓存继续调用。
- Eureka Server 各节点相互注册,只要集群中至少有一个节点存活,整个集群就能继续提供服务(AP 设计,高可用优先)。
- Eureka 通过心跳机制监控服务健康状态,超时未收到心跳的服务会被自动从注册表中剔除。
二、客户端负载均衡(Ribbon)
- Ribbon 是 Spring Cloud 中的客户端负载均衡组件,当调用方从 Eureka 获取到某服务的多个实例地址时,Ribbon 负责从中选择一台发起请求。
- Ribbon 支持多种负载均衡策略(轮询、随机、权重等),且无需在代码中硬编码 IP 和端口,通过服务名调用即可。
三、声明式服务调用(Feign)
- Feign 是基于动态代理的声明式 HTTP 客户端,开发者只需定义一个接口并加上
@FeignClient注解,即可像调用本地方法一样调用远程服务。 - Feign 底层封装了 Ribbon 的负载均衡逻辑和 HTTP 请求细节,开发者无需手动编写
RestTemplate调用代码,大幅简化服务间调用。
四、断路器(Hystrix)
- Hystrix 是 Spring Cloud 中的断路器组件,用于实现熔断、降级和资源隔离,防止微服务调用链中的单点故障引发服务雪崩。
- 每个 Hystrix Command 运行在独立的线程池中(线程隔离),统计接口的超时、失败次数,达到阈值后自动打开断路器,后续请求直接进入 Fallback 降级逻辑。
- 降级(Fallback)可返回友好的提示信息,或将请求记录到日志/MQ 进行异步处理,实现优雅降级,避免直接报错。
五、服务网关(Gateway / Zuul)
- 服务网关是 Spring Cloud 中的统一入口组件,所有外部请求(如前端、App)通过网关进入微服务系统。
- 网关本身也作为一个微服务节点注册到 Eureka,因此外部请求进入网关后,网关可以走 Ribbon + Feign + Hystrix 等完整的微服务调用链路。
- 网关解决了非微服务节点(如前端应用)无法直接使用 Spring Cloud 服务治理能力的问题,统一了内外请求的路由与管理。
90-Dubbo的整体架构设计及分层
一、Dubbo 的五个核心角色
- 注册中心(Registry):所有服务提供者和消费者在此注册和发现服务地址(通常使用 ZooKeeper)。
- 服务提供者(Provider):暴露服务的节点,启动时将自己的接口信息(服务名、IP、端口等)注册到注册中心。
- 服务消费者(Consumer):调用远程服务的节点,启动时从注册中心拉取服务列表,发起远程调用。
- 监控中心(Monitor):统计服务的调用次数和调用时间,用于监控和告警。
- 容器(Container):服务运行的容器,负责加载和启动服务提供者。
二、Dubbo 的调用流程
- 启动阶段:容器启动并加载 Provider,Provider 启动后向注册中心注册自己提供的服务(以接口为服务维度)。
- 服务发现:Consumer 启动时向注册中心拉取服务列表,获取 Provider 的地址信息。
- 心跳与变更通知:Provider 与注册中心之间维持长连接心跳,超时未响应则 Provider 被剔除;注册中心通过长连接将服务列表变更(新增/下线)实时推送给 Consumer。
- 负载均衡调用:Consumer 从注册中心获取某服务的多个实例后,通过负载均衡算法(轮询、加权轮询等)选择其中一个实例发起 RPC 调用。
- 监控上报:Consumer 将每次调用的次数和耗时信息上报给 Monitor 监控中心。
三、Dubbo 的分层架构(10层)
- 接口服务层(Service):面向开发者的业务接口和实现层(即业务代码)。
- 配置层(Config):以
ServiceConfig和ReferenceConfig为核心,管理 Dubbo 的配置信息,可通过 ZooKeeper 实现配置中心功能。 - 服务代理层(Proxy):为服务提供者和消费者生成动态代理对象,封装 RPC 调用细节,让远程调用像本地调用一样透明。
- 服务注册层(Registry):负责服务的注册与发现(对应注册中心角色)。
- 路由层(Cluster/Router):负责负载均衡,从注册中心获取服务列表后,根据算法选择目标实例。
- 监控层(Monitor):统计和上报 RPC 调用次数及耗时(对应监控中心角色)。
- 远程调用层(Protocol):封装 RPC 协议的调用细节,Dubbo 默认基于 TCP 协议实现。
- 信息交换层(Exchange):封装请求-响应模式,支持同步转异步,负责信息的实际交换。
- 网络传输层(Transport):抽象了 Netty 和 Mina 等网络通信框架,对上层提供统一接口,上层代码无感知。
- 数据序列化层(Serialize):将 Java 对象序列化为传输格式(如 JSON、Hessian 等),可灵活切换,对上层透明。
91-简述RabbitMQ的架构设计
一、RabbitMQ 的核心角色
- Broker:RabbitMQ 的服务节点(消息中间件服务器),负责接收、存储和转发消息。
- Producer(生产者):消息的发送方,将消息发送到交换机(Exchange),而非直接发送到队列。
- Consumer(消费者):消息的接收方,从队列中订阅并消费消息。
- Queue(队列):RabbitMQ 内部用于存储消息的对象,由生产者或管理员创建,多个消费者可以订阅同一个队列。
- Exchange(交换机):消息路由的核心组件,生产者将消息发送到交换机,交换机根据路由规则将消息分发到一个或多个队列。
- Binding(绑定):Exchange 和 Queue 之间的关联关系(多对多),通过 Binding Key 建立映射。
- Routing Key(路由键):生产者在发送消息时指定的一个字符串,Exchange 根据 Routing Key 与 Binding Key 的匹配规则决定将消息路由到哪些队列。
- Channel(信道):在 TCP 长连接中复用的虚拟连接(类似光缆中的光纤),一个 TCP 连接可包含多个 Channel,Channel 之间互相隔离,减少频繁建立 TCP 连接的开销。
二、RabbitMQ 的消息流转流程
- 生产者将消息发送到 Exchange,并指定 Routing Key;Exchange 根据自身类型和 Routing Key 匹配 Binding Key,将消息路由到一个或多个 Queue;消费者从 Queue 中拉取并消费消息。
三、四种交换机类型及路由规则
- Fanout(扇形交换机):忽略 Routing Key,将消息广播到所有与该 Exchange 绑定的 Queue(一对多广播)。
- Direct(直连交换机):Routing Key 必须与 Binding Key 完全匹配,消息被路由到唯一匹配的 Queue(点对点)。
- Topic(主题交换机):支持通配符匹配(
*匹配一个单词,#匹配零或多个单词),一个 Routing Key 可匹配多个 Binding Key,实现一对多模式匹配广播。 - Headers(头交换机):根据消息的头部属性(Headers)而非 Routing Key 进行匹配,性能较差,使用较少。
四、关键特性总结
- 队列消息分发:多个消费者订阅同一个 Queue 时,消息采用轮询(负载均衡)方式分发给其中一个消费者,不会广播给所有消费者(点对点消费模式)。
- 广播实现:要实现消息广播给多个消费者,需通过 Exchange 将同一条消息路由到多个不同 Queue,每个消费者监听不同的 Queue。
- RabbitMQ 基于 AMQP 协议(高级消息队列协议) 实现,Exchange、Queue、Binding、Channel 等都是 AMQP 协议定义的标准概念。
92-RabbitMQ如何确保消息发送? 消息接收?
一、发送方确认机制(Producer Confirm)
- 生产者将信道(Channel)设置为
confirm模式后,该信道上发布的每条消息都会被分配一个唯一 ID。 - 消息成功被投递到队列(若为持久化消息还需写入磁盘)后,RabbitMQ 会通过回调
ConfirmCallback接口 的confirm()方法,向生产者发送确认(ACK),并携带该消息的唯一 ID。 - 若消息因内部错误、路由失败等原因未能成功投递,RabbitMQ 会通过回调
ReturnCallback接口 发送未确认(NACK)通知给生产者。 - 生产者的确认机制是异步的:生产者发送消息后无需等待确认即可继续发送下一条消息,ACK/NACK 回调在后续异步触发。
二、消费方确认机制(Consumer Ack)
- 消费者通过声明队列时设置
autoAck(自动确认) 参数控制确认行为:autoAck=true表示消息投递给消费者后立即自动确认并删除;autoAck=false表示手动确认,消费者需显式返回 ACK 后 RabbitMQ 才会删除消息。 - 手动确认模式下,若消费者不返回 ACK,RabbitMQ 不会继续投递下一条消息给该消费者(相当于实现了消费端限流),且 RabbitMQ 对此没有超时机制。
- 手动确认模式适用于需要保证业务最终一致性的场景(如结合 Spring 事务):若业务逻辑执行失败并回滚,消息不应被确认删除,需保证事务与 ACK 的一致性。
- 若消费者未返回 ACK 但连接断开,RabbitMQ 会认为该消费者已宕机,将消息重新投递给其他消费者,此时可能产生重复消费。
- 重复消费场景下,RabbitMQ 本身无法保证数据最终一致性,需由业务方通过幂等性设计(如根据消息 ID 去重)来保证数据最终一致。
93-Rabbitmq事务消息
一、RabbitMQ 事务消息的概念与适用场景
- RabbitMQ 的事务消息(通过
txSelect()、txCommit()、txRollback()实现)可保证消息发送与生产者本地业务逻辑在同一个事务中:若本地逻辑失败回滚,已发送的消息也可被回滚撤回。 - 事务消息确保了生产者端的原子性:消息发送成功与否与本地业务执行结果保持一致,适用于需要严格 ACID 保证的场景(如消息发出后消费者可能立即回调查询生产者数据,必须保证数据一致性)。
二、RabbitMQ 事务消息的实现原理
- 开启事务后,生产者发送的消息不会直接进入目标队列,而是先存入一个临时队列(事务队列),消费者此时无法消费到这些消息。
- 只有执行
txCommit()提交事务后,RabbitMQ 才会将消息从临时队列真正移动到目标队列,消费者才能消费到该消息。 - 若执行
txRollback()回滚事务,临时队列中的消息被丢弃,目标队列中不会出现该消息,实现了消息的”撤回”。
三、事务消息 vs Confirm 确认机制(性能对比)
- Confirm 模式(异步确认):消息发送后生产者无需等待确认即可继续发送,RabbitMQ 异步回调通知结果,性能远高于事务消息。
- 事务消息:每次开启事务、发送消息、提交/回滚都需要与服务端通信,连接次数多、性能极低,吞吐量可能下降数倍甚至数十倍。
- 在两者都可满足业务需求的场景下,应优先选择 Confirm 模式(高性能)而非事务消息(低性能)。
四、事务消息与消费端手动 ACK 的结合
- 消费端设置
autoAck=false(手动确认)时,可以在业务逻辑中同时开启本地事务,只有当事务提交成功后,RabbitMQ 才会真正确认并删除消息。 - 若消费端事务回滚,即使代码中调用了
basicAck(),RabbitMQ 也不会删除消息,消息仍会留在队列中(以事务提交为准,而非 ACK 调用为准)。 - 若消费端使用
autoAck=true(自动确认),则不支持事务,消息投递后立即被确认删除,后续业务回滚无法撤回已消费的消息。
五、事务消息的错误处理
- 事务提交前若发生异常,RabbitMQ 会抛出
IOException,生产者可通过try-catch捕获异常并调用txRollback()进行回滚,或决定是否重发消息。****
94-RabbitMQ死信队列、延时队列
一、死信队列(Dead Letter Queue, DLQ)的概念
- 死信队列是用于存放”死信消息”的普通队列,本质是一个普通队列,但被配置为接收异常消息(如超时、被拒绝、队列溢出)。
- 消息变为死信的三种情况:①消费者返回
basic.nack或basic.reject且requeue=false(不重新入队);②消息在队列中的存活时间超过设置的 TTL(过期);③队列长度达到上限,新消息无法入队。 - 死信队列通过为普通队列配置
x-dead-letter-exchange(死信交换机) 属性来指定,当消息变为死信时,RabbitMQ 会自动将其路由到该交换机,进而进入对应的死信队列。 - 死信交换机是普通交换机(可以是 Direct、Fanout、Topic 等类型),多个业务队列可共用同一个死信交换机,通过不同的 Routing Key 区分路由到不同的死信队列。
- 死信队列的作用是对异常消息做补偿处理(如记录日志、人工干预、重新路由等),避免消息被直接丢弃。
二、延时队列(Delayed Queue)的概念
- 延时队列是指消息发送后不立即被消费者消费,而是等待指定的时间后才被消费的队列。
- RabbitMQ 自身不直接提供延时队列功能,但可通过 TTL(消息过期时间)+ 死信队列 的组合来实现延时队列。
- 实现原理:消息先发送到设置了 TTL 的普通队列(消费者不监听该队列),TTL 到期后消息变为死信,自动进入绑定的死信队列,消费者监听死信队列即可消费到延时到期的消息。
- TTL 可设置在消息本身(每条消息独立)或队列上(该队列所有消息统一 TTL),若两者同时设置,以较小的值为准(先到先过期)。
三、延时队列的实现流程
- 实际消费流程:生产者发送消息到普通队列(TTL队列)→ 消息在该队列等待 TTL 超时 → 超时后消息被投递到死信队列 → 消费者监听死信队列并消费消息,从而实现延时消费的效果。
95-简述kafka架构设计
一、Kafka 的核心角色
- Broker:Kafka 的服务端节点(一个 Broker 即为一个 Kafka 服务器),多个 Broker 组成 Kafka 集群。
- Producer(生产者):消息发送方,将消息发送到指定的 Topic。
- Consumer(消费者):消息消费方,从 Topic 中拉取消息进行消费。
- Consumer Group(消费者组):多个消费者可以组成一个组,组内每个消费者负责消费不同的 Partition,同一个 Partition 中的消息只会发给该组中的一个消费者(组内负载均衡)。
- Topic(主题):消息的逻辑分类(类似于队列),生产者和消费者面向同一个 Topic 进行生产和消费。
- Partition(分区):Topic 的物理拆分单元,一个 Topic 可分成多个 Partition,分布在不同的 Broker 上,是实现 Kafka 高吞吐量的核心设计。
- 副本(Replica):每个 Partition 可以有多个副本(主从架构),Leader 负责读写,Follower 仅从 Leader 同步数据,Leader 故障时自动选举新 Leader。
二、Partition 的设计优势
- 一个 Topic 被拆分为多个 Partition 并分布在不同 Broker 上,通过并行读写极大地提高了吞吐量,消费者组中的多个消费者可分别消费不同的 Partition,消费能力随 Partition 数量线性扩展。
三、ZooKeeper 在 Kafka 中的作用
- Broker 注册:每个 Broker 启动时在 ZooKeeper 上创建一个临时节点,若 Broker 宕机,临时节点被删除,生产者可感知到该节点下线。
- Topic 与 Partition 关系维护:Topic 与 Partition 的对应关系以及 Partition 分布在哪些 Broker 上,均由 ZooKeeper 维护。
- 消费者与 Partition 的分配:消费者组中每个消费者消费哪个 Partition 的分配关系由 ZooKeeper 协调,消费者宕机时 ZK 负责重新分配。
- Leader 选举:当 Partition 的 Leader 副本宕机时,ZooKeeper 负责从 Follower 中重新选举新的 Leader。
- 消费偏移量(Offset)存储:每个消费者的消费进度(Offset)存储在 ZooKeeper 中(早期版本),消费者重启后可从中获取上次消费位置,继续消费。
96-Kafka消息丢失的场景及解决方案
一、Kafka 消息丢失的三大场景
- Kafka 消息可能丢失的场景分为三部分:生产者端(发送)、Broker 端(存储)、消费者端(消费)。
二、生产者端消息丢失与解决方案
acks=0:生产者发送消息后不等待 Broker 任何确认,性能最高,但若发送失败消息直接丢失。acks=1:Leader 写入成功后即返回确认,若消息尚未同步到 Follower 时 Leader 宕机,选举后新 Leader 无此消息,仍会丢失。- 解决方案:设置
acks=all(或-1),要求 ISR(In-Sync Replica,与 Leader 保持同步的副本列表)中所有副本都确认写入后才算成功,避免 Leader 单点故障导致数据丢失。 min.insync.replicas(最小同步副本数):配合acks=all使用,指定 ISR 中最少要有多少个副本确认写入,建议设置为 2,确保至少一个 Follower 同步成功,该参数仅在acks=all时生效。unclean.leader.election.enable=false:禁止从 ISR 之外的 Follower(OSR,Out-of-Sync Replica,数据落后的副本)中选举 Leader,防止选举出数据不完整的 Leader 导致已确认消息丢失。- 生产者端还需配置重试机制(
retries > 1),发送失败时自动重试,捕获异常后自行处理。
三、Broker 端消息丢失与解决方案
- 消息写入 Broker 时先缓存在操作系统的 Page Cache(页缓存)中,尚未刷盘(flush)时若机器宕机,缓存数据丢失。
- 解决方案:减小刷盘间隔(
flush.messages和flush.ms参数),或依赖副本同步机制——即使单节点刷盘失败,只要 ISR 中有其他副本已同步,数据仍不丢失。
四、消费者端消息丢失与解决方案
- 先提交 Offset,再处理业务逻辑:若提交 Offset 后业务处理失败,该消息已标记为已消费,后续无法再次消费,导致消息丢失。
- 解决方案:采用先处理业务逻辑,后提交 Offset 的顺序,确保业务成功后再提交消费进度,防止业务失败时消息被误认为已消费。
- 先处理后提交的副作用:可能导致重复消费(业务处理完成后提交 Offset 前消费者宕机),需通过接口幂等性设计(如消息 ID 去重)来保证业务最终一致性。
五、总结:性能与可靠性的权衡
- 以上保证消息不丢失的配置(
acks=all、min.insync.replicas=2、禁止从 OSR 选举等)会显著降低 Kafka 的吞吐量,需要在高性能与高可靠性之间根据业务场景做取舍。
97-Kafka是pull?push?优劣势分析
一、Kafka 采用 Pull 模式
- Kafka 消费者采用 Pull(拉取)模式,即消费者主动向 Broker 拉取消息,而非 Broker 主动推送。
二、Pull 模式的优点
- 消费者自主控制消费速率:Pull 模式可根据消费者的处理能力决定拉取速度和批量大小(一次拉 1 条或多条),避免消费端过载。
- 支持灵活的提交方式:消费者可手动提交 Offset,实现不同的传输语义(至少一次、最多一次、精准一次)。
三、Pull 模式的缺点与解决方案
- 空轮询问题:当 Broker 中没有新消息时,消费者仍会不断发起拉取请求,形成空轮询(忙等待),消耗 CPU 资源。
- 解决方案:设置拉取超时参数(如
fetch.max.wait.ms),若队列为空则让消费者阻塞等待,释放 CPU,或设置最小拉取条数(如fetch.min.bytes),数据量不足时阻塞等待至满足条件。
四、Push 模式的特点与对比
- Push(推送)模式由 Broker 主动将消息推送给消费者,Broker 可感知是否有消息,因此不会造成消费者的空轮询。
- Push 模式的缺陷:Broker 不了解消费者的处理能力,持续推送可能导致消费端积压、超时、网络拥塞,甚至触发拒绝服务。
98-Kafka中zk的作用
一、Broker 管理与注册
/brokers/ids节点(临时节点):存储集群中所有 Broker 的 ID,每个 Broker 启动时注册自己的节点,Broker 宕机后临时节点自动删除,生产者可通过该节点感知集群节点状态。- 每个 Broker ID 节点下存储该 Broker 的物理地址(IP + 端口)、版本、启动时间等元数据信息。
二、Topic 与 Partition 关系维护
/brokers/topics节点:存储集群中所有 Topic 信息,每个 Topic 节点下保存该 Topic 的所有 Partition 信息,用于维护 Topic 与 Partition 的对应关系及 Partition 分布在哪些 Broker 上。
三、Partition 的 Leader 与 ISR 维护
- 每个 Partition 节点下有一个
/state节点(临时节点),存储当前 Partition 的 Leader 副本的 Broker ID,Leader 变更时该节点内容同步更新。 /state节点中还维护该 Partition 的 ISR(In-Sync Replica,可靠副本列表),ISR 中的副本与 Leader 数据完全同步,用于 Leader 选举和acks=all时的确认机制。- 当 Leader 宕机时,ZooKeeper 从该 Partition 的 ISR 列表中选取一个 Follower 提升为新 Leader。
四、Consumer Group 管理与 Offset 存储
- ZooKeeper 中维护 Consumer Group 与 Partition 的分配关系:记录每个消费者组中哪个 Consumer 正在消费哪个 Partition,消费者宕机时触发 Rebalance,ZooKeeper 参与协调重新分配。
- 消费进度(Offset)存储(老版本):每个 Consumer 消费到的 Offset 位置存储在 ZooKeeper 中,消费者重启后可从中读取上次消费位置继续消费(新版本中 Offset 已改存到 Kafka 内部的
__consumer_offsetsTopic 中)。
五、客户端如何获取 ZooKeeper 元数据
- 客户端配置的是 Broker 地址而非 ZooKeeper 地址,客户端不直接连接 ZooKeeper,而是通过连接的 Broker 获取集群元数据,Broker 再与 ZooKeeper 交互。
六、版本说明
- 以上功能基于旧版本 Kafka,高版本(Kafka 2.8+)已逐渐将 ZooKeeper 移除,改为使用 Kafka Raft 协议(KRaft)自管理元数据,面试时可提此点展示技术敏感度。
99-Kafka中高性能的原因分析
一、顺序读写(Sequential I/O)
- Kafka 采用顺序追加写入(Append-Only)的方式,消息直接写入磁盘文件,磁头无需频繁寻道,顺序读写速度可接近内存随机访问。
- Kafka 将数据直接写入磁盘(通过 Page Cache,而非先存内存再异步刷盘),因此消息堆积能力远强于基于内存的 MQ(磁盘容量远大于内存)。
- Kafka 对消息的消费和删除也基于顺序操作:消费时顺序读取,删除时以 Segment(分段文件) 为单位整体删除,避免逐条删除产生碎片,提高删除效率。
- Partition 是逻辑概念,物理存储上由多个 Segment 文件组成,每个 Partition 对应磁盘上的多个文件段。
二、零拷贝(Zero Copy)
- 传统数据读取流程:磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡,经历4 次数据拷贝和多次 CPU 上下文切换,性能开销大。
- 零拷贝技术:操作系统直接将数据从磁盘文件发送到网卡(Socket),跳过用户态缓冲区,省去内核态与用户态之间的数据拷贝及上下文切换,大幅提升 I/O 性能。
- Kafka 利用操作系统提供的零拷贝(
sendfile系统调用)机制,实现数据从磁盘到网卡的直接传输,这是 Kafka 高吞吐量的关键技术之一。
三、Page Cache(页缓存)机制
- Kafka 写入数据时,先写入操作系统的 Page Cache(页缓存),而非直接写入磁盘,由操作系统决定何时异步刷盘(flush)。
- Kafka 数据读写主要依赖 Page Cache,不依赖 JVM 堆内存存储消息,避免了 GC(垃圾回收)带来的性能影响,同时可利用操作系统级别的缓存管理能力。
- 当生产者和消费者的速率相匹配时,消息可能在 Page Cache 中直接交换数据,完全无需磁盘 I/O,读写性能进一步提升。
100-简述kafka的rebalance机制
一、Rebalance 的概念
- Rebalance(再平衡) 是 Kafka 消费者组将 Partition 与 Consumer 的分配关系进行重新调整的过程,发生时 Partition 的读写会被阻塞直到 Rebalance 完成。
- Rebalance 的目的是在消费者组成员变化或订阅的 Topic/Partition 变化时,重新分配 Partition,保证每个 Partition 被组内某个 Consumer 消费。
二、触发 Rebalance 的四种场景
- 消费者组成员数量变化:组内有 Consumer 加入或退出(主动离开或宕机)。
- 消费者消费超时:Consumer 处理消息时间过长,未及时提交 Offset,协调者认为其已失活。
- 订阅的 Topic 数量变化:Consumer Group 新增或取消订阅了某个 Topic。
- 订阅的 Topic 的 Partition 数量变化:Topic 的分区数被手动增加或减少。
三、Rebalance 的执行流程
- 协调者(Coordinator):每个 Consumer Group 对应一个 Coordinator(通常是 Group 内某个 Partition 的 Leader 副本所在的 Broker),负责监控组内消费者的心跳和消费超时。
- 触发阶段:Coordinator 通过心跳响应的方式通知所有 Consumer 进入 Rebalance 状态,Consumer 收到通知后停止消费并重新向 Coordinator 请求加入 Consumer Group。
- 分配阶段:Coordinator 收集所有存活消费者,从中选举一个 Leader Consumer(不是 Broker),由该 Leader 负责制定 Partition 分配方案。
- 同步阶段:Leader Consumer 将分配方案封装成
SyncGroup请求发送给 Coordinator,Coordinator 通过心跳响应将分配方案下发给组内所有 Consumer,各 Consumer 按方案开始消费指定 Partition。
四、Rebalance 导致的问题与 Generation 机制
- Rebalance 带来的问题:超时消费者在 Rebalance 完成后可能重新提交 Offset,导致消息被重复提交或消费进度混乱。
- 解决方案——Generation(代际)机制:每次 Rebalance 时 Coordinator 生成一个递增的 Generation ID,Consumer 提交 Offset 时必须附带当前的 Generation ID,Coordinator 只接受最新的 Generation ID 的 Offset 提交,拒绝旧 Generation 的提交,避免重复消费或进度错乱。
