Java 中 List 和 Map 有什么区别?它们分别有哪些常用实现类?
List 与 Map 属于 Java 集合框架里两大核心分支,二者存储模型、元素特征、索引方式存在本质差异,面试关键点要分清 List 属于单列集合,Map 属于双列集合,加分点是不能只罗列类名,要讲清不同实现类底层差异和业务选型依据。记忆可以采用模型类比记忆法,把 List 类比成排队队列,每个元素按顺序依次摆放;Map 类比成字典,每一条记录由键和值成对组成,查内容依靠字典的词条。搭配对比表格记忆法,脑海构建一张对比表格,从存储单元、是否允许重复、索引依据三个维度快速区分二者。
List 是 Collection 接口下的子接口,属于单列集合,每次存储只存入单个对象元素。集合内元素有序,元素存入顺序和遍历取出顺序保持一致,集合允许存放重复元素,支持通过整数下标索引访问元素,下标从 0 开始。开发中常用实现类包含 ArrayList、LinkedList、Vector。ArrayList 底层基于动态 Object 数组实现,初始容量 10,扩容按照 1.5 倍扩容,随机访问效率很高,插入删除中间位置元素需要数组拷贝,性能偏弱,线程不安全,绝大多数业务查询遍历场景优先选用。LinkedList 底层双向链表,不需要连续内存,随机索引需要遍历链表,查询慢,头部尾部增删元素效率高,线程不安全,适合频繁首尾增删的场景。Vector 底层数组,扩容 2 倍,所有方法加 synchronized,线程安全,性能差,现在业务开发极少使用。
Map 不属于 Collection 体系,是双列集合,存储单元是键值对,每一组数据包含 key 与 value 两个对象。Map 的 key 不允许重复,如果存入相同 key,新 value 会直接覆盖旧的 value,value 允许重复。Map 不使用数字下标访问,依靠 key 获取对应的 value。常用实现类有 HashMap、TreeMap、LinkedHashMap、Hashtable。HashMap 底层哈希表,JDK8 采用数组加链表加红黑树,key 允许 null,线程不安全,日常开发使用最多。TreeMap 底层红黑树,key 会自动排序,key 不能为 null。LinkedHashMap 继承 HashMap,额外维护双向链表,可以保留元素插入顺序。Hashtable 线程安全,key 不允许 null,性能较差,已经基本被淘汰。
// List简单示例
List<String> arrayList = new ArrayList<>();
arrayList.add("java");
arrayList.add("spring");
String element = arrayList.get(0);
// Map简单示例
Map<String,Integer> hashMap = new HashMap<>();
hashMap.put("age",22);
Integer value = hashMap.get("age");
二者核心差异,List 存储单个元素,依靠数字下标定位,元素可重复有序;Map 存储键值对,依靠 key 定位,key 不可重复。业务选型的时候,如果只需要保存一组对象,按顺序存取,优先使用 List;需要根据唯一标识映射对应数据,做键值映射查找,就选择 Map。很多开发新人容易混淆,错误把 Map 放入 Collection 循环,面试的时候要明确说明 Map 和 Collection 没有继承关系,迭代 Map 需要获取 keySet 或者 entrySet,不能直接遍历。
HashMap、HashSet 和 TreeSet 的底层数据结构与实现原理分别是什么?
面试关键点,必须清楚 HashSet 底层就是包装 HashMap,没有自己独立存储结构,TreeSet 底层依托 TreeMap,面试加分点是讲出 JDK7 和 JDK8 中 HashMap 底层改动,链表转红黑树阈值,以及红黑树退回链表阈值。记忆法采用溯源记忆法,记住 Set 实现类大多依托对应 Map 实现,Set 存元素相当于 Map 的 key,value 固定是一个常量对象;搭配阈值数字锚定记忆法,记住链表长度大于 8 转红黑树,元素数量小于 6 退化为链表。
HashMap,JDK7 版本底层是数组加单向链表,当哈希冲突发生,新元素挂载链表头部。JDK8 做重大优化,底层为数组 + 单向链表 + 红黑树。存储流程,调用 key 的 hashCode 方法计算哈希值,经过扰动函数做二次哈希,减少哈希冲突,计算数组下标。数组位置为空,直接存放节点;下标位置不为空,判断 key 是否完全相等,相等直接覆盖旧值;key 不相等,链表长度小于 8,新增节点挂载链表尾部;链表节点达到 8,同时数组容量大于等于 64,链表转换红黑树;如果数组容量不足 64,优先触发数组扩容,不会转树。当红黑树节点数量降低到 6,红黑树退化成单向链表。HashMap 负载因子默认 0.75,当集合元素达到数组容量乘以负载因子,执行扩容,数组长度扩大两倍,重新对所有元素做哈希重定位。HashMap 允许 key 和 value 为 null,线程不安全,并发场景会出现死链、数据丢失。
HashSet,表面上是存储单个元素的 Set 集合,底层完全依赖 HashMap,HashSet 内部维护一个 HashMap 实例,调用 add 方法存入元素,就是把存入的元素作为 HashMap 的 key,value 统一使用一个静态 Object 常量对象。HashSet 保证元素不重复,本质复用 HashMap key 不可重复特性。调用 add 的时候,如果 HashMap 中该 key 已经存在,put 方法返回旧对象,HashSet 的 add 直接返回 false,元素添加失败。HashSet 不保证元素存储顺序,允许存入 null 元素,线程不安全。HashSet 没有额外存储逻辑,所有增删查操作全部委托 HashMap 完成。
TreeSet 底层基于 TreeMap,TreeMap 底层数据结构是红黑树。TreeSet 存入的元素会自动按照自然顺序排序,或者使用自定义比较器完成排序。存入元素时,把存入对象当做 TreeMap 的 key,value 同样使用固定常量对象。红黑树是自平衡二叉查找树,会自动维护节点平衡,保证增删查时间复杂度 O (logn)。存入的元素必须实现 Comparable 接口,或者构造 TreeSet 传入 Comparator 比较器,否则运行抛出类型转换异常。TreeSet 不允许存入 null 元素,元素不重复,线程不安全。
// HashSet底层原理演示,value是固定常量PRESENT
HashSet<String> hashSet = new HashSet<>();
hashSet.add("demo");
// TreeSet自定义比较器示例
TreeSet<Integer> treeSet = new TreeSet<>((o1,o2)-> o2-o1);
treeSet.add(1);
treeSet.add(3);
开发中容易踩坑,存入自定义对象到 HashSet,没有重写 hashCode 和 equals 方法,会导致相同业务对象重复存入集合。存入 TreeSet 的自定义对象,没有实现比较逻辑,直接运行报错,面试的时候主动点出这两处坑点,提升回答质量。
数组和链表这两种数据结构有什么区别?各自的优缺点及适用场景是什么?
面试关键点,区分逻辑结构和物理内存结构,数组内存连续,链表内存分散;随机访问、增删操作的时间复杂度是核心。面试加分点,不能只背诵优缺点,结合 Java 集合 ArrayList、LinkedList 源码场景举例,说明为什么 ArrayList 不适合中间频繁删除。记忆法使用内存图像记忆法,脑海想象数组是一整块连续格子,链表是分散小节点靠指针串联;搭配复杂度口诀记忆法,数组查 O (1),中间增删 O (n);链表查 O (n),已知节点增删 O (1)。
数组,在内存中占用一段连续的存储空间,每个元素拥有下标,通过下标可以直接定位内存地址,完成随机访问。Java 中数组分为基本类型数组和对象数组,数组初始化完成,长度固定,无法动态扩容,ArrayList 的动态扩容本质是创建更大的新数组,复制旧数组全部元素。数组优点,支持随机访问,读取指定下标元素性能极高,时间复杂度 O (1),内存紧凑,没有额外存储指针,内存开销小。数组缺点,长度固定,初始化之后不能直接扩展;如果在数组中间位置插入或者删除元素,后面所有元素都需要移动,元素数量很大的时候性能开销高;如果申请过大数组,内存找不到连续大块内存,会出现内存分配失败。适用场景,业务中大量查询读取,很少在中间做插入删除;元素数量大致可以预估;需要高频随机下标访问。比如 ArrayList 底层动态数组,报表批量数据存储。
链表,内存节点分散存储,不需要连续内存空间,每一个节点保存实际数据,同时保存指向下一个节点的引用。Java LinkedList 使用双向链表,每个节点除数据外,保存前驱节点引用和后继节点引用。链表没有固定长度,新增节点直接分配内存,不需要整体拷贝。链表优点,新增删除操作只需要修改节点内部引用指针,不需要移动大量数据;没有容量限制,内存碎片化空间可以充分利用。链表缺点,不支持随机访问,如果要访问指定位置元素,必须从头节点开始遍历,时间复杂度 O (n);每个节点需要额外存储引用指针,内存占用高于数组。适用场景,频繁头部、中间、尾部插入删除;无法预估存储元素总量;极少随机下标读取。比如消息队列,任务排队处理。
| 对比维度 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续内存空间 | 分散不连续内存,依靠引用串联 |
| 随机访问 | 支持 O (1) | 不支持 O (n) |
| 中间增删 | 需要移动大量元素 O (n) | 仅修改引用 O (1) |
| 内存开销 | 只存储数据,开销小 | 每个节点额外保存指针,开销大 |
| 长度特性 | 固定长度,扩容需要拷贝 | 动态分配,无固定长度 |
//数组示例
int[] arr = new int[10];
arr[0] = 100;
//链表节点简单模拟
class Node{
int data;
Node next;
}
开发易错点,很多同学误以为 LinkedList 查询删除效率全部很高,如果调用 get (index),会从头遍历定位节点,性能依旧很差,只有拿到目标节点引用直接操作,链表增删优势才可以体现。面试回答可以补充这个实际坑点。
请谈谈你对数组、链表、队列和哈希表的理解,并解释什么是哈希冲突以及常见的解决方法。
面试关键点,分清四种数据结构定位,数组侧重随机访问,链表侧重动态增删,队列侧重先进先出,哈希表侧重 key 快速查找;哈希冲突的本质,以及两种主流解决方案。面试加分点,结合 Java 集合对应实现类讲解,说明 JDK8 HashMap 对哈希冲突处理的优化。记忆法采用用途归类记忆法,记住数组读快改中间慢,链表动态增删,队列排队,哈希表高速查找;冲突处理用场景联想记忆法,开放寻址想象座位被占就找下一个空位,链地址想象同一个座位挂多条纸条。
数组,一段连续内存,依靠下标访问元素,元素存储位置固定。优势随机读取速度快;劣势中间插入删除需要移动大量元素,长度固定。Java 中 ArrayList 底层基于数组实现,适合大量读取,元素数量可控场景。
链表,由分散节点组成,节点存储数据和指向其他节点的引用,不需要连续内存。单向链表只保存后继引用,双向链表同时保存前驱、后继引用。插入删除仅修改引用,不需要移动数据;但是访问元素需要遍历,随机访问性能差。LinkedList 底层双向链表,适合频繁头尾增删场景。
队列,属于逻辑抽象数据结构,遵循先进先出规则,元素从队尾入队,队头出队。队列可以基于数组实现,也可以基于链表实现。数组实现队列会存在假溢出问题,一般使用循环数组队列优化;链表实现队列天然动态扩容。Java 中 ArrayDeque 基于数组实现双端队列,LinkedList 也可以作为队列使用。队列大量用于消息处理、任务调度、线程池任务排队,生产者消费者模型。
哈希表,核心目标实现根据 key 快速完成查找、插入、删除,理想时间复杂度 O (1)。底层一般数组作为基础载体,通过哈希函数把 key 转换为数组下标,直接定位存储位置。哈希函数输入 key,输出数组索引。理想状态不同 key 算出不同下标,直接存取;但现实中不同 key 经过哈希函数计算,得到相同数组下标,这个现象就是哈希冲突。哈希冲突无法完全消除,只能降低冲突概率,冲突会造成性能下降。
哈希冲突常见解决方法,第一种链地址法,也叫拉链法。数组每一个下标位置不直接存元素,存放一条链表,发生冲突的全部元素挂载到同一个下标对应的链表上。JDK HashMap 使用链地址法,JDK8 中链表长度超过阈值,链表升级红黑树,降低长链表遍历开销。第二种开放寻址法,所有数据全部放在数组当中,冲突发生就按照既定规则向后寻找数组下一个空位存放元素,细分线性探测、二次探测。开放寻址对数组容量要求高,不能填满,负载因子不能过高,ThreadLocalMap 底层使用开放寻址法。除此之外还有再哈希法,准备多个哈希函数,冲突就调用下一个哈希函数计算下标;建立公共溢出区,冲突元素全部放到额外开辟溢出数组。
//简单模拟哈希函数
public int hashFunc(Object key,int arrayLength){
return (key.hashCode() ^ (key.hashCode() >>>16)) % arrayLength;
}
面试要区分链地址法和开放寻址法的取舍,链地址适合数据量大,冲突多场景;开放寻址适合数据量小,内存紧张场景。同时需要说明,哈希函数扰动的目的,是把高位特征混合到低位,减少哈希冲突。很多面试者容易错误描述哈希冲突,说成 hashCode 相同一定冲突,需要纠正,hashCode 相同会冲突,hashCode 不同,经过取模之后下标一样,同样会发生哈希冲突。
请介绍一下快速排序算法的核心思想,并说明它的最好情况时间复杂度和最坏情况时间复杂度分别是多少?
面试关键点,分治思想、基准元素选择、分区逻辑;最好最坏时间复杂度产生的条件,空间复杂度,不稳定排序定义。面试加分点,讲清楚最坏场景如何优化,和 Arrays.sort 底层实现关联。记忆法采用分治流程记忆法,记住选基准,分区,递归左右子数组;场景锚定记忆法,最好基准每次分割均匀,最坏数组已经有序,选端点作为基准。
快速排序核心基于分治思想,整体流程分为分区与递归两个主要环节。首先从待排序数组中挑选一个元素当做基准元素,也叫 pivot。分区操作会遍历数组,把小于基准的元素全部移动到基准左侧,大于基准的元素移动到基准右侧,分区结束之后,基准元素就落在它最终排序完成的正确位置。基准元素左边、右边各自形成两组独立的子数组,子数组继续重复执行挑选基准、分区的逻辑,递归处理,直到子数组元素数量等于 1,单个元素天然有序,递归结束,整个数组完成排序。
快速排序最好情况,每一次选取的基准元素,都可以刚好把数组分割成两个长度几乎相等的子数组。每次分区遍历数组代价是 O (n),递归深度是 logn,整体最好时间复杂度 O (nlogn)。这种均衡分割的场景,递归树高度最低,算法执行效率最高。
快速排序最坏情况,每次选出来的基准,是数组当中最大值或者最小值,分区之后,一边子数组没有元素,另一边子数组只比原数组少一个元素。比如原始数组已经完全升序,每次固定选择数组第一个元素作为基准,就会触发最坏情况。分区遍历依旧 O (n),递归深度退化成 n 层,最坏时间复杂度 O (n²)。
快速排序属于不稳定排序,相等元素经过排序之后,相对先后顺序可能发生改变。空间复杂度主要来自递归调用栈,最好 O (logn),最坏 O (n)。可以通过基准选取策略优化最坏情况,比如三数取中法,取数组左端、右端、中间位置三个元素,选择三者中位数作为基准,大幅降低遇到有序数组触发最坏复杂度的概率。Java 中 Arrays.sort 针对基本数据类型,使用双轴快速排序,属于快速排序的改良版本,使用两个基准元素,把数组划分三段,进一步优化性能,规避普通快排缺陷。
public static void quickSort(int[] arr,int left,int right){
if(left >= right){
return;
}
int pivot = arr[left];
int i = left;
int j = right;
while(i < j){
while(i<j && arr[j] >= pivot){
j--;
}
arr[i] = arr[j];
while(i<j && arr[i] <= pivot){
i++;
}
arr[j] = arr[i];
}
arr[i] = pivot;
quickSort(arr,left,i-1);
quickSort(arr,i+1,right);
}
面试回答的时候,需要主动说明不稳定排序的含义,很多面试者容易忽略。同时可以补充,快排不需要额外开辟大量数组空间,属于原地排序,只消耗递归栈内存。很多面试者会混淆平均复杂度,快速排序平均时间复杂度也是 O (nlogn),只有极端数据场景才会退化 O (n²)。业务开发中几乎不会手写快排,但是面试官考察分治算法理解,考察对复杂度边界条件的认知,回答时把最坏情况触发条件讲清楚,会明显拉开和普通候选人差距。
二叉树的前序遍历、中序遍历和后序遍历分别适用于什么业务或算法场景?
面试关键点需要牢牢记住三种遍历的访问顺序,前序为根节点、左子树、右子树,中序为左子树、根节点、右子树,后序为左子树、右子树、根节点,不能颠倒左右子树访问顺序,面试加分点不能只输出遍历顺序,要结合真实业务场景,区分普通二叉树和二叉搜索树的特性差异,同时说明递归遍历与迭代遍历的取舍。记忆法采用动作顺序记忆法,把根节点想象成核心操作,前序先操作根,中序中间操作根,后序最后操作根;搭配场景绑定记忆法,把序列化绑定前序,二叉搜索树排序绑定中序,资源释放绑定后序,一一对应场景。
前序遍历优先访问根节点,之后递归访问左子树,最后递归访问右子树。典型业务场景第一个是二叉树序列化,把树结构转为字符串持久化存储,重建树的时候需要先拿到根节点信息,前序遍历最先输出根节点,解析字符串可以直接构造根对象,再递归还原左右子树,JSON 树形菜单导出、部门组织树导出很多底层会采用前序遍历逻辑。第二个场景复制二叉树,克隆一棵完整二叉树,必须先创建根节点对象,再复制左子树,再复制右子树,和前序遍历执行顺序完全契合。第三个场景获取树的深度,计算二叉树最大深度,先处理根,再向下遍历左右子树统计层级。前序遍历也会用于目录树遍历,遍历文件系统目录,先访问当前文件夹,再遍历文件夹内部子目录和文件,就是典型前序思想。
中序遍历先访问左子树,再访问根节点,最后访问右子树。对于二叉搜索树,中序遍历输出结果是严格升序序列,这是中序遍历最核心的算法价值,这也是面试高频考察点。业务场景第一个,二叉搜索树排序输出,不需要额外排序算法,直接中序遍历就可以拿到有序数据,数据库部分索引结构基于 B 树,范围查询逻辑就借鉴中序遍历的思路。第二个场景验证二叉搜索树合法性,通过中序遍历判断遍历序列是否严格递增,以此校验树是否满足二叉搜索树定义。第三个场景表达式解析,二叉表达式树,中序遍历可以还原出原始中缀算术表达式,也就是我们日常书写的加减乘除算式。中序遍历还可以用于有序集合范围筛选,遍历二叉搜索树,取出指定区间内的全部节点数据。
后序遍历先访问左子树,再访问右子树,最后访问根节点。根节点处理放在左右子树全部处理完成之后。典型业务场景第一个资源释放、内存回收,销毁一棵树的时候,必须先销毁全部左子节点、右子节点,最后销毁根节点,如果先销毁根,子树节点就会丢失引用,造成内存泄漏,C/C++ 手动释放树结构内存普遍使用后序遍历。第二个计算子树统计信息,比如统计每颗子树的节点数量、子树权重,根节点的统计值依赖左右子树计算完成之后才能得出。第三个删除目录,操作系统删除文件夹,需要先删除文件夹内部全部子文件、子目录,最后删除当前文件夹本体,逻辑和后序遍历完全匹配。还有二叉树求后序序列化,以及判断二叉树是否为完全二叉树的部分辅助逻辑。
//递归三种遍历简易示例
class TreeNode{
int val;
TreeNode left;
TreeNode right;
}
//前序遍历
public void preOrder(TreeNode root){
if(root == null) return;
System.out.println(root.val);
preOrder(root.left);
preOrder(root.right);
}
//中序遍历
public void inOrder(TreeNode root){
if(root == null) return;
inOrder(root.left);
System.out.println(root.val);
inOrder(root.right);
}
//后序遍历
public void postOrder(TreeNode root){
if(root == null) return;
postOrder(root.left);
postOrder(root.right);
System.out.println(root.val);
}
面试容易踩坑,很多候选人只背诵遍历顺序,讲不出落地场景,同时要区分,递归遍历存在栈溢出风险,数据量大的业务场景需要手动用栈实现迭代版遍历。二叉搜索树只有中序遍历可以得到有序序列,前序、后序无法保证有序,这点面试作答需要明确。
请从操作系统的角度阐述进程和线程的区别,并进一步说明进程、线程、协程三者的区别以及协程的适用场景。
面试关键点,进程是资源分配最小单位,线程是 CPU 调度最小单位,协程属于用户态轻量级调度单元。面试加分点,区分内核态与用户态,讲清上下文切换开销差异,不能只罗列概念,结合 Java、Go 语言的实际实现来辅助说明。记忆法使用层级类比记忆法,进程类比独立办公室,拥有完整办公资源;线程类比办公室内员工,共享办公室资源;协程类比同一个员工手上多个任务,手动切换任务;搭配开销梯度记忆法,进程切换开销最大,线程次之,协程切换开销最小。
从操作系统内核视角,进程是操作系统资源分配的最小单元。操作系统创建进程的时候,会为进程分配独立虚拟地址空间、文件描述符、信号处理器、堆内存,每个进程拥有完整独立资源,进程之间相互隔离,一个进程崩溃,在没有特殊机制情况下,不会影响其他进程。进程之间通信成本很高,没有共享内存,需要管道、socket、消息队列等 IPC 进程间通信手段完成数据交互。进程上下文切换,操作系统需要替换页表,刷新 TLB 缓存,保存全套寄存器、内存映射信息,切换开销非常大。
线程隶属于进程,一个进程内部可以存在多个线程,线程是操作系统 CPU 调度的最小单元。同一个进程内部所有线程共享进程虚拟地址空间、堆、文件句柄,线程只拥有自己私有的栈、程序计数器、局部寄存器。线程之间可以直接读写共享内存,通信简单,但也带来线程安全问题。线程切换属于内核态切换,CPU 需要保存线程寄存器上下文,但是不需要替换虚拟内存页表,切换开销小于进程,但是依旧存在内核态用户态的切换损耗。一个线程崩溃,整个所属进程会直接崩溃,所有线程全部终止。
协程又叫做用户态轻量级线程,协程调度完全运行在用户空间,操作系统内核完全感知不到协程的存在,内核只识别承载协程的线程。一个线程可以承载多个协程,协程之间共享线程栈以外的资源,协程切换不需要陷入操作系统内核,不需要系统调用,只需要在用户态保存少量寄存器上下文,切换开销远小于内核线程。协程调度分为对称与非对称协程,主流非对称协程,依靠代码主动让出执行权,属于协作式调度,协程不会被操作系统强制抢占,如果某一个协程执行死循环,不主动让出,同一个线程内其余协程全部得不到执行机会。线程是抢占式调度,操作系统内核可以强制剥夺线程 CPU 时间片。
| 对比维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度主体 | 操作系统内核 | 操作系统内核 | 用户态程序,内核无感知 |
| 资源归属 | 拥有整套独立资源 | 共享进程资源,自有栈与寄存器 | 共享线程资源,极小私有上下文 |
| 上下文切换开销 | 最大,更换页表刷新缓存 | 中等,内核态切换,不换页表 | 极小,纯用户态操作,无系统调用 |
| 隔离性 | 完全隔离,进程崩溃互不影响 | 进程内共享,一个线程崩溃整个进程崩溃 | 线程内共享,协程异常可捕获,不影响线程 |
| 调度方式 | 抢占式 | 抢占式 | 协作式,需要主动 yield 让出 CPU |
协程的适用场景,第一高 IO 密集型业务,网络请求、数据库读写、文件 IO,大量时间程序处于等待状态,协程在 IO 阻塞的时候主动让出执行权,同一个线程可以承载上万协程,不需要创建大量操作系统线程,降低内存和调度开销,Go 语言 goroutine 就是协程实现,高并发网络服务大量使用。第二海量并发连接场景,网关、长连接服务,如果使用线程,上万连接就要上万个线程,线程栈内存占用巨大,协程可以少量线程承载大量连接。协程不适合 CPU 密集型计算,因为协程协作调度,如果一个协程占用 CPU 疯狂运算不主动让出,其余协程会被阻塞,CPU 密集场景更适合使用多线程多核并行。Java 本身没有原生协程,Java21 引入虚拟线程,本质属于 JVM 层面的轻量级线程,和传统协程设计思路有相似之处。
//Java虚拟线程简单示例,JDK21及以上
public void virtualThreadDemo(){
Thread vt = Thread.ofVirtual().name("virtual-thread-1").start(() -> {
System.out.println("虚拟线程执行");
});
}
面试作答的时候要区分,协程不是操作系统原生概念,是应用层实现,很多面试者容易错误认为协程由操作系统调度,这个点需要规避。同时要讲清楚协程协作式调度的短板,CPU 密集场景不适用,这是面试区分候选人深浅的关键点。
线程在 Java 中有哪些状态?这些状态之间是如何流转的?
面试关键点,牢记 Java 线程定义的 6 种状态,注意区分 Java 线程状态和操作系统内核线程状态不是同一套概念,不能混淆。面试加分点,讲清楚 wait、sleep、notify、LockSupport.park 对状态的影响,区分阻塞和等待状态的不同触发条件。记忆法采用生命周期流程记忆法,新建就绪运行阻塞等待终止顺着生命周期梳理;搭配触发条件锚定记忆法,记住 synchronized 进入 BLOCKED,wait 进入 WAITING,sleep (time) 进入 TIMED_WAITING。
Java 线程在 Thread.State 枚举中一共定义 6 种状态,分别是新建、就绪、运行、阻塞、限时等待、终止。新建状态,new 出 Thread 对象之后,还没有调用 start 方法,此时仅仅是 JVM 层面的 Java 对象,操作系统内核还没有创建真实操作系统线程。就绪状态,调用 start () 方法之后,操作系统线程被创建,线程具备执行资格,等待 CPU 分配时间片,此时线程状态为 RUNNABLE,Java 的 RUNNABLE 同时包含操作系统层面就绪和运行两种状态,只要线程没有被阻塞挂起,拿到 CPU 就在运行,没拿到 CPU 就在就绪,统一标记 RUNNABLE。很多面试者会错误拆分就绪和运行两个枚举,Java 枚举中没有拆分,这是高频面试坑。
阻塞状态 BLOCKED,线程在等待获取 synchronized 内置锁,当线程想去执行 synchronized 代码块,锁已经被别的线程占有,线程就进入 BLOCKED 阻塞状态。只有拿到 synchronized 锁之后,才回到 RUNNABLE 状态。注意 Lock 接口的 lock () 方法不会进入 BLOCKED 状态,Lock 底层使用 AQS,线程会进入 WAITING 状态,这点是高频易错点。
无限等待 WAITING,线程主动放弃 CPU,需要其他线程主动唤醒才能恢复执行。触发条件,调用 Object.wait () 无参方法,调用 Thread.join () 无超时版本,调用 LockSupport.park ()。处于 WAITING 的线程不会分配 CPU 时间片,需要其他线程执行 notify、notifyAll,或者被 join 的线程执行完毕,LockSupport.unpark 唤醒,线程才回到 RUNNABLE。
限时等待 TIMED_WAITING,带时间限制的等待,时间到自动唤醒,也可以被其他线程提前唤醒。触发条件 Thread.sleep (long),Object.wait (long),Thread.join (long),LockSupport.parkNanos、parkUntil。时间到期或者收到唤醒信号,线程回到 RUNNABLE。
终止 TERMINATED,线程执行完毕 run 方法正常退出,或者运行过程抛出未捕获异常终止,线程生命周期结束。一旦线程进入 TERMINATED,不能再次调用 start,重复调用 start 直接抛出非法线程状态异常。
线程流转完整过程,new Thread 得到 NEW 新建状态,调用 start () 进入 RUNNABLE。CPU 分配时间片,线程执行业务逻辑。当竞争 synchronized 锁失败,流转到 BLOCKED,获取锁成功回到 RUNNABLE。执行 wait ()、join ()、park () 流转 WAITING,收到唤醒信号回到 RUNNABLE。执行 sleep、带超时 wait 流转 TIMED_WAITING,超时或者被唤醒回到 RUNNABLE。run 方法执行完成或者异常,流转 TERMINATED 终止。
//演示线程状态流转关键代码片段
Thread t = new Thread(() -> {
synchronized (Thread.currentThread()){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//此时NEW
t.start();
//此时RUNNABLE
面试的时候必须明确,IO 阻塞的时候,Java 线程依旧是 RUNNABLE 状态,虽然操作系统层面线程阻塞,但是 JVM 的线程状态枚举不会体现 IO 阻塞,这是很多面试者容易答错的考点。BLOCKED 状态只和 synchronized 锁竞争相关,Lock 锁不会产生 BLOCKED 状态。
Java 中保证线程安全主要可以使用哪些关键字?这些关键字在底层分别做了什么?
面试关键点,核心关键字 synchronized、volatile,明确 volatile 不能保证原子性。面试加分点,讲解 synchronized 偏向锁、轻量级锁、重量级锁升级过程,区分对象头 Mark Word 存储内容。记忆法采用能力矩阵记忆法,volatile 保证可见性、禁止重排序,不保证原子;synchronized 同时保证可见性、原子性、有序性;搭配锁升级流程记忆法,无锁→偏向锁→轻量级锁→重量级锁,只能升级不能降级。
Java 中用于保障线程安全的关键字主要为 synchronized 和 volatile,二者能力边界完全不同,不能互相替代。除关键字之外还有 Lock、Atomic 原子类等工具,但是不属于关键字范畴。
synchronized 是 Java 内置同步关键字,可以修饰实例方法、静态方法、代码块。底层依托对象头 Mark Word 存储锁标记位。JDK1.6 做锁优化,引入偏向锁、轻量级锁、重量级锁,锁只能升级,不能降级。当 synchronized 修饰代码块,会生成 monitorenter、monitorexit 两条字节码指令。monitorenter 获取对象监视器锁,monitorexit 释放监视器锁,无论正常执行还是异常退出,都会执行 monitorexit,保证锁一定会释放。偏向锁适用于单线程频繁获取锁,Mark Word 记录线程 ID,后续获取锁不需要 CAS 操作;当出现锁竞争,偏向锁升级轻量级锁,依靠 CAS 自旋尝试获取锁;自旋多次获取失败,升级重量级锁,向操作系统申请互斥锁,竞争失败线程进入阻塞队列。synchronized 可以同时保障原子性、可见性、有序性。原子性保证同步代码块内指令不会被线程切分;可见性,释放锁的时候把本地缓存数据刷新回主内存,获取锁的时候清空本地缓存,从主内存读取最新数据;有序性依靠内存屏障禁止指令重排序。
volatile 关键字,只能修饰成员变量,不能修饰方法、局部变量。底层不涉及锁,不会阻塞线程。volatile 会在读写该变量时插入内存屏障。写 volatile 变量,在写操作之后插入写屏障,强制把 CPU 高速缓存中的修改刷新到主内存;读 volatile 变量,在读操作之前插入读屏障,强制从主内存读取最新值,放弃 CPU 缓存内部旧副本。volatile 两大能力,保证变量可见性,禁止指令重排序。volatile 不保证原子性,i++ 这类复合操作,分为读取、计算、回写三步,即便变量加 volatile,多线程并发依旧会出现线程安全问题。
//synchronized示例
public synchronized void syncMethod(){
}
public void syncBlock(){
synchronized (this){
}
}
//volatile示例
private volatile boolean flag;
面试高频坑,很多候选人误以为 volatile 可以解决计数类线程安全问题,作答的时候要主动举例 i++ 说明为什么 volatile 无法保证原子性。synchronized 会产生线程阻塞,volatile 不会阻塞,二者性能场景也有差异。
在 Java 中,哪个关键字可以保证多线程环境下共享变量的可见性(即一个线程修改后其他线程立即可见)?它的实现原理是什么?
面试关键点确定关键字是 volatile,必须区分可见性、原子性、有序性三者边界,面试加分点讲清楚 Java 内存模型,内存屏障类型,happens‑before 规则,同时举出 volatile 典型使用场景和不能处理的场景。记忆法采用内存屏障记忆法,写后加写屏障,读前加读屏障;搭配边界锚定记忆法,记住 volatile = 可见性 + 禁止重排序,不等于原子性。
Java 中提供 volatile 关键字来实现共享变量多线程可见性,volatile 仅作用于类的成员变量,局部变量无法使用 volatile 修饰。Java 内存模型中每个线程拥有自己的工作内存,对应 CPU 高速缓存,线程操作变量,会先从主内存拷贝副本到线程本地工作内存,读写操作都在本地副本完成,再异步同步回主内存。不加 volatile 的时候,一个线程修改副本,可能长时间不会同步到主内存,其他线程读取到的依旧是旧副本,就出现可见性问题。
volatile 底层依靠 CPU 硬件层面的内存屏障指令实现,JVM 编译之后会针对 volatile 变量读写插入对应的内存屏障。当执行 volatile 变量写操作,JVM 会在写指令之后插入 StoreStore 屏障、StoreLoad 屏障。StoreStore 屏障保证前面所有写操作全部完成,再执行 volatile 变量写;StoreLoad 屏障强制把 CPU 缓存的数据刷新写入主内存,同时让其他 CPU 缓存行对应数据失效。当执行 volatile 变量读操作,JVM 会在读指令之前插入 LoadLoad 屏障、LoadStore 屏障,保证 volatile 变量读取之前,后续所有读写操作不能重排序到读之前,并且强制从主内存加载最新变量值,不再使用线程本地缓存副本。
volatile 除了保证可见性,还具备禁止指令重排序的能力。CPU 和 JVM 会做指令重排序优化,在不改变单线程执行结果前提下调整指令执行顺序,重排序会破坏多线程逻辑。volatile 通过内存屏障,限制 volatile 变量读写和前后指令之间的重排序规则,volatile 写之前的指令不能重排到写之后,volatile 读之后指令不能重排到读之前。
volatile 不提供原子性保障,只保障单次变量读写操作可见。比如 i++ 操作,拆分为读取 i 的值,执行自增运算,写回 i,这三步不是原子操作,多线程并发下即便 i 被 volatile 修饰,依旧会出现数据错乱。volatile 适合状态标记位场景,布尔开关标记,一个线程修改开关,其他线程感知开关状态,是 volatile 经典使用场景。双重检查锁 DCL 实现单例模式,成员变量必须加 volatile,防止对象实例化指令重排序导致拿到半初始化对象。
//volatile开关示例
private volatile boolean stopFlag = false;
public void stopTask(){
stopFlag = true;
}
public void runTask(){
while (!stopFlag){
}
}
//DCL单例示例
public class Singleton{
private static volatile Singleton instance;
private Singleton(){}
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
面试作答时需要区分,synchronized 同样可以实现可见性,但是题目专门问保证变量可见性的关键字,标准答案是 volatile。同时需要讲清楚 happens‑before 规则,volatile 写 happens‑before 后续 volatile 读,这是 JMM 层面保障可见性的规则。很多面试者只讲刷新主内存,忽略缓存失效逻辑,其他 CPU 缓存行失效,别的线程读取才会重新拉取主内存数据,这是完整原理中不可缺少的部分。
synchronized 和 ReentrantLock 有什么区别?
面试关键点要抓住底层实现、锁特性、功能灵活性、锁升级机制几个核心维度,不能只罗列表面 API 差异,面试加分点要讲清 JDK 版本演进背景,synchronized 在 JDK1.6 锁优化前后的变化,还要说明二者性能对比的真实场景,纠正 ReentrantLock 性能一定优于 synchronized 的错误认知。记忆法采用对照类比记忆法,把 synchronized 比作语言内置的固定门锁,功能固定开箱即用;ReentrantLock 比作可配置的智能锁,支持丰富扩展能力;搭配维度拆解记忆法,从底层、可中断、公平锁、条件变量、释放方式五个维度快速回忆差异。
synchronized 是 Java 语言层面的关键字,属于内置锁,底层依赖对象头 Mark Word 以及 monitor 监视器对象,JDK1.6 之后实现偏向锁、轻量级锁、重量级锁的锁升级机制,锁只能向上升级,不能够降级。使用方式分为修饰实例方法、静态方法、代码块,不需要手动编写加锁释放逻辑,进入同步区域自动获取锁,代码执行完成或者抛出异常时,JVM 会自动释放锁,不会出现锁忘记释放造成永久死锁的问题。synchronized 不支持锁中断,当线程阻塞等待 synchronized 锁的时候,调用线程 interrupt 方法无法中断锁等待;不支持公平锁,只能是非公平锁模式;没有条件变量,要实现等待通知只能依靠对象的 wait、notify、notifyAll 方法。
ReentrantLock 是 JDK 并发包下的 API 实现锁,属于应用层锁,底层基于 AQS 抽象队列同步器实现,依靠 CAS 操作以及双向等待队列完成锁逻辑,是显式锁,开发人员必须手动调用 lock 方法获取锁,调用 unlock 方法释放锁,unlock 必须放在 finally 代码块中,一旦忘记释放锁,会直接造成其他线程永久阻塞。ReentrantLock 支持可中断获取锁,调用 lockInterruptibly 方法,线程等待锁的过程中可以响应中断抛出中断异常;支持公平锁与非公平锁两种模式,构造函数传入布尔值 true 开启公平锁,按照线程等待顺序分配锁资源;可以绑定多个 Condition 条件变量,一个锁可以生成多个等待队列,实现精细化线程等待唤醒,相比 synchronized 只有一套对象等待集合更加灵活。二者都属于可重入锁,同一个线程可以多次获取同一把锁,不会自己把自己阻塞。
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层级 | Java 语言关键字,JVM 底层实现 | JDK 代码层面,基于 AQS 实现 |
| 锁释放 | JVM 自动释放,异常自动解锁 | 必须手动 unlock,需要 finally 包裹 |
| 锁中断 | 不支持中断锁等待 | 支持 lockInterruptibly 可中断获取锁 |
| 公平锁 | 仅非公平锁 | 支持公平锁、非公平锁切换 |
| 条件等待 | 仅支持 wait/notify,单等待队列 | 多个 Condition,多等待队列 |
| 锁降级升级 | JDK1.6 支持锁升级,不可降级 | 无锁升级机制,依靠 AQS 队列 |
//synchronized示例
public void syncDemo(){
synchronized (this){
System.out.println("内置锁执行");
}
}
//ReentrantLock示例
ReentrantLock lock = new ReentrantLock(true);
public void lockDemo(){
lock.lock();
try{
System.out.println("显式锁执行");
}finally {
lock.unlock();
}
}
性能层面,JDK1.6 之前 synchronized 重量级锁性能很差,ReentrantLock 性能优势明显;JDK1.6 引入偏向锁轻量级锁优化后,多数场景下二者性能差距极小,低竞争场景 synchronized 表现甚至更优。业务选型,简单同步场景优先使用 synchronized,代码简洁不容易出错;需要公平锁、锁中断、多条件等待的复杂并发场景,选用 ReentrantLock。面试高频坑,很多候选人会说 ReentrantLock 性能一定更好,实际要区分 JDK 版本以及锁竞争激烈程度,高并发大量锁竞争场景 ReentrantLock 会更有优势。另外 ReentrantLock 还提供 tryLock 方法,可以尝试非阻塞获取锁,获取不到直接返回布尔值,不会阻塞线程,该能力是 synchronized 不具备的。
什么是悲观锁和乐观锁?请结合 synchronized 谈谈它们的应用与区别。
面试关键点掌握悲观锁、乐观锁的核心设计思想,悲观锁假设冲突一定会发生,乐观锁假设冲突很少发生,synchronized 属于典型悲观锁实现,面试加分点需要举出数据库层面以及 Java 内存层面的实现案例,说明乐观锁的常见实现手段,同时讲清楚两种锁各自的缺陷。记忆法采用假想预判记忆法,悲观锁默认一定会打架,提前上锁保护资源;乐观锁默认不会打架,操作完成再校验是否冲突;搭配场景绑定记忆法,高冲突用悲观锁,低冲突用乐观锁。
悲观锁的核心思想,在访问共享资源之前,预先认为并发冲突大概率会发生,所以在操作资源之前就直接加锁,其他线程访问该资源的时候会被阻塞,只有持有锁的线程完成操作释放锁,其他线程才可以继续执行。悲观锁会造成线程阻塞、上下文切换开销。Java 中 synchronized 就是典型悲观锁,当线程竞争 synchronized 锁,竞争失败的线程直接阻塞挂起,等待锁释放之后再被唤醒执行。数据库中 select ... for update 也是悲观锁,读取数据的时候直接给数据行加行锁,其他事务修改该行就会被阻塞。悲观锁适合并发冲突频繁、写操作占比很高的业务场景,冲突多的时候反复重试的代价会大于阻塞等待的代价。
乐观锁核心思想,预先假设并发冲突发生概率很低,操作共享资源的时候不会加锁,线程直接执行业务逻辑,在提交修改的时刻才检测是否发生并发冲突,如果没有冲突就提交成功;如果检测到已经被其他线程修改,就放弃本次操作,进行重试或者抛出异常。乐观锁不会阻塞线程,不会产生内核态的线程阻塞开销,但是存在重试逻辑,冲突多的时候会大量循环重试消耗 CPU。乐观锁没有语言关键字,依靠业务逻辑实现,常见两种实现,版本号机制以及 CAS 自旋。数据库的版本号字段,更新时携带版本条件,更新行数为 0 代表已经被别人修改;Java 中 Atomic 系列原子类底层使用 CAS 指令实现乐观锁。
synchronized 属于悲观锁,只要进入同步块就获取排他锁,其他线程全部阻塞。synchronized JDK1.6 的轻量级锁底层使用 CAS,这里需要区分,轻量级锁只是锁优化手段,整体 synchronized 依旧属于悲观锁,轻量级锁自旋失败之后依旧会膨胀为重量级锁阻塞线程,它没有乐观锁失败重试提交的业务逻辑。很多面试者容易混淆轻量级锁和乐观锁,作答的时候需要明确区分。
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心假设 | 并发冲突一定会发生 | 并发冲突很少发生 |
| 加锁时机 | 操作资源前直接上锁 | 全程不加锁,提交时校验冲突 |
| 线程状态 | 冲突时线程阻塞 | 冲突时不阻塞,执行重试 |
| 典型实现 | synchronized、数据库 for update | CAS、数据库版本号 |
| 开销来源 | 线程阻塞、上下文切换 | 冲突时循环重试消耗 CPU |
//CAS模拟乐观锁,AtomicInteger底层CAS
AtomicInteger atomicNum = new AtomicInteger(0);
public void optimisticOperate(){
atomicNum.incrementAndGet();
}
//synchronized悲观锁示例
private int num = 0;
public synchronized void pessimisticOperate(){
num++;
}
悲观锁的弊端,大量线程竞争会出现大量阻塞,带来线程调度开销,极端场景会出现死锁;乐观锁弊端,冲突频繁的时候大量重试,CPU 占用飙升,同时无法处理长时间业务事务,重试次数过多业务就不可用。业务场景选型,写多冲突高,优先悲观锁;读多写少冲突概率低,优先乐观锁。数据库业务中,订单扣减库存,如果并发修改量巨大,悲观锁更稳定;基础配置数据更新频率很低,使用版本号乐观锁更合适。
请分别介绍互斥锁、自旋锁、乐观锁和悲观锁的概念,并说明它们之间的关系与适用场景。
面试关键点分清四类锁的分类维度,悲观锁乐观锁是并发冲突的设计思想;互斥锁自旋锁是线程等待的实现方式,二者属于不同分类维度,不能并列等同。面试加分点理清概念之间交叉关系,悲观锁可以使用互斥或者自旋实现,乐观锁不涉及锁等待。记忆法使用分类维度记忆法,第一维度看待冲突:悲观、乐观;第二维度看等待方式:互斥、自旋;搭配关系图谱记忆法,悲观锁可以是互斥锁也可以是自旋锁,乐观锁无等待逻辑。
互斥锁,也叫排他锁,当一个线程获取锁之后,其他竞争锁的线程直接被操作系统挂起阻塞,让出 CPU 时间片,进入内核等待队列,锁释放之后操作系统再唤醒被阻塞线程。线程阻塞会发生用户态切换内核态,存在上下文切换开销。synchronized 重量级锁就是互斥锁,ReentrantLock 竞争激烈升级 AQS 阻塞队列之后也是互斥锁。互斥锁等待的时候线程不占用 CPU,适合锁持有时间比较长的场景,锁持有时间久,线程阻塞挂起的开销可以被摊薄。
自旋锁,线程获取锁失败的时候,不会把线程阻塞挂起,线程在 CPU 上空循环不断重试尝试获取锁,一直循环直到拿到锁。自旋锁线程一直处于运行状态,持续占用 CPU,不需要内核态切换,拿到锁的速度快。自旋锁适合锁持有时间很短的场景,如果锁持有时间很长,自旋会持续消耗 CPU 资源,造成 CPU 空转浪费。JDK 中 synchronized 轻量级锁就是自旋锁,AQS 内部也有自适应自旋逻辑。自旋锁有隐患,多核 CPU 才适合自旋,单核 CPU 自旋会卡死,单核 CPU 自旋线程占满 CPU,持有锁的线程得不到时间片释放锁。
悲观锁是一种并发控制思想,默认并发冲突大概率会出现,访问资源前提前加锁保护资源。悲观锁底层等待实现可以采用互斥锁,也可以采用自旋锁。synchronized 整体属于悲观锁,轻量级阶段使用自旋锁,竞争加剧升级重量级互斥锁。悲观锁牺牲并发性能换取数据安全,适合写操作多、锁持有时间长、冲突频繁的业务。
乐观锁是并发控制思想,假设冲突很少发生,不加锁访问资源,提交阶段校验冲突,冲突则重试或者报错。乐观锁不会产生锁等待,不存在互斥或者自旋逻辑,依靠 CAS、版本号实现。Atomic 原子类、数据库版本号更新都是乐观锁。乐观锁适合读多写少,冲突概率低的场景。
四类锁存在交叉包含的关系,悲观锁是思想,互斥锁、自旋锁是锁等待实现手段。悲观锁可以选择互斥实现,也可以选择自旋实现;乐观锁不使用锁等待机制,没有互斥自旋概念。自旋锁不等于乐观锁,自旋锁依旧是加锁逻辑,属于悲观锁的实现方式,很多面试者容易把自旋锁归为乐观锁,这是高频错误点。
| 锁类型 | 核心逻辑 | CPU 消耗特点 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 获取失败线程阻塞挂起 | 等待时释放 CPU,切换有开销 | 锁持有时间长,竞争激烈 |
| 自旋锁 | 获取失败循环重试抢锁 | 等待持续占用 CPU | 锁持有时间极短,多核环境 |
| 悲观锁 | 预先加锁防止冲突 | 取决于底层是互斥或者自旋 | 写多冲突频繁 |
| 乐观锁 | 不加锁,提交校验冲突 | 冲突发生时消耗 CPU 做重试 | 读多写少,冲突概率低 |
//简单自旋锁模拟
public class SpinLock{
private AtomicReference<Thread> owner = new AtomicReference<>();
public void lock(){
while(!owner.compareAndSet(null,Thread.currentThread())){
}
}
public void unlock(){
owner.compareAndSet(Thread.currentThread(),null);
}
}
自旋锁会带来 CPU 空转,JDK 自旋采用自适应自旋,根据上一次锁获取成功耗时,动态调整自旋循环次数,不会无限自旋。互斥锁会带来线程上下文切换,切换开销成本很高。实际生产中很少自己手写自旋锁,都是依赖 JDK 内置同步组件。乐观锁无法解决 ABA 问题,使用 CAS 的时候需要额外考虑版本标记规避 ABA 问题。
死锁产生的必要条件有哪些?在实际开发中应该如何预防和避免死锁?
面试关键点牢记死锁四个必要条件,四个条件必须全部同时满足才会产生死锁,只要破坏任意一个条件就可以消除死锁。面试加分点,除了理论方案,结合真实 Java 开发代码案例,给出实际业务编码层面的实践,同时说明死锁检测手段。记忆法采用四条件口诀记忆法,互斥、占有且等待、不可剥夺、循环等待;搭配破坏式记忆法,每一个预防方案对应破坏其中某一个必要条件。
死锁是多线程并发场景下,多个线程互相持有对方需要的锁资源,所有线程全部阻塞,都不肯释放自身已经持有的锁,所有线程都无法继续向下执行,程序永久卡死。死锁发生必须同时满足四个必要条件。第一个互斥条件,资源属于排他访问,同一时刻只能一个线程持有资源,synchronized 锁、ReentrantLock 都属于互斥资源。第二个占有且等待条件,线程已经持有至少一个锁资源,同时继续申请获取其他锁资源,不会释放自己手上已经拿到的锁。第三个不可剥夺条件,已经被线程占有的锁资源,不能够被其他线程强制抢占,只能由持有锁的线程主动释放。第四个循环等待条件,线程之间形成环形锁依赖,线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1,形成闭环等待。四个条件缺一不可,缺少任意一个条件,死锁就无法形成。
实际开发预防死锁,核心思路就是破坏四个必要条件中的任意一条。破坏互斥条件,尽量减少使用排他互斥锁,优先使用乐观锁,CAS、版本号机制,资源不需要互斥访问,从根源消除死锁产生基础。但很多业务场景必须要互斥锁,该手段存在局限性。
破坏占有且等待条件,线程一次性申请所有需要的锁资源。线程在执行业务之前,把业务流程中需要用到的全部锁一次性获取完成,拿到全部锁之后才执行业务逻辑,如果不能拿到全部锁,就不占有任何锁,不会出现拿着一部分锁再去申请其他锁的情况。缺点是业务复杂的时候,一次性获取多把锁编码难度上升,会降低并发度。
破坏不可剥夺条件,允许锁被抢占。Java 内置 synchronized 不支持抢占,ReentrantLock 提供 tryLock 超时机制,线程获取锁设置超时时间,如果指定时间拿不到锁,主动释放自己手上已经获取的全部锁,隔一段时间再重试,主动放弃资源,打破不可剥夺条件。
破坏循环等待条件,这是工程实践最常用的方案。给所有锁资源定义全局固定的获取顺序,所有线程无论业务逻辑,申请多把锁的时候,必须严格按照统一固定顺序获取锁,释放锁按照逆序释放。不会出现 A 拿锁 1 等锁 2,B 拿锁 2 等锁 1 的环形依赖。
除了破坏四大条件之外,工程层面还有其他辅助手段。锁超时,使用 tryLock 设置超时时间,避免无限等待。业务层面尽量减少一个线程同时持有多把锁,尽量简化锁粒度,一个同步块内部只持有一把锁,从源头避免多锁竞争。上线之后可以使用 JDK 工具排查死锁,jstack 命令导出线程堆栈,jconsole 可视化工具,都可以检测出死锁线程,定位死锁的锁对象与代码位置。
//模拟死锁代码
Object lockA = new Object();
Object lockB = new Object();
new Thread(()->{
synchronized (lockA){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
synchronized (lockB){
}
}
}).start();
new Thread(()->{
synchronized (lockB){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
synchronized (lockA){
}
}
}).start();
面试作答要说明,死锁是很难复现的 bug,压力下才容易触发,开发阶段很难发现,上线之后才暴露。很多开发人员会忽略锁的获取顺序,业务迭代新增锁之后,无意间制造循环等待。同时区分死锁和活锁,活锁线程不会阻塞,一直循环重试,但是无法完成业务,和死锁现象不一样。
请介绍一下线程池的工作原理。
面试关键点,牢记线程池的核心组成:核心线程、阻塞队列、最大线程、拒绝策略,以及任务提交完整执行流程。面试加分点,讲清楚各个参数之间的逻辑关系,区分核心线程空闲存活逻辑,非核心线程空闲回收逻辑,结合实际业务说明参数设置思路。记忆法采用流程顺序记忆法,任务提交依次判断核心线程、阻塞队列、最大线程、拒绝策略;组件拆分记忆法,线程池由线程集合、阻塞队列、拒绝策略、线程工厂四部分组成。
Java 线程池 ThreadPoolExecutor,核心目的是复用线程对象,避免频繁创建销毁线程带来的系统开销,同时管控并发线程数量,防止无限创建线程耗尽系统内存。线程池内部包含四大基础组件,线程工厂用于创建线程,可以自定义线程名称、守护线程属性;工作线程集合,分为核心线程与非核心线程;任务阻塞队列,存放等待执行的任务;拒绝策略,当任务无法被处理的时候执行兜底逻辑。构造方法的核心入参包含核心线程数、最大线程数、空闲非核心线程存活时间、阻塞队列、线程工厂、拒绝策略。
当外部调用 execute 提交任务,线程池会按照固定流程执行任务。第一步判断当前运行的工作线程数量,是否小于核心线程数,如果条件成立,直接新建核心线程执行提交的任务,即便其他核心线程处于空闲状态,依旧会新建线程。第二步,如果当前线程数已经等于核心线程数,就尝试把任务放入阻塞队列。如果队列未满,任务入队等待已有线程空闲后消费队列任务。第三步,如果阻塞队列已经存满,此时判断当前总工作线程数是否小于最大线程数,如果成立,新建非核心线程执行任务。第四步,如果总线程数已经达到最大线程数,队列也已经满,任务无法处理,触发拒绝策略执行兜底逻辑。
核心线程默认不会被回收,即使长时间空闲也保留在线程池中,设置 allowCoreThreadTimeOut 参数开启之后,核心线程空闲超过超时时间也会被回收。非核心线程空闲达到 keepAliveTime 时间,会被回收销毁,释放系统资源。线程池中的工作线程是循环运行的,线程执行完一个任务之后,不会直接退出,会循环从阻塞队列获取下一个任务继续执行,以此实现线程复用。当队列没有任务,线程就阻塞在队列 take 方法上等待新任务到来。
JDK 内置四种拒绝策略,AbortPolicy 直接抛出运行时异常,线程池默认策略;DiscardPolicy 直接丢弃任务,不抛出任何异常;DiscardOldestPolicy 丢弃队列队头最老的任务,提交当前新任务;CallerRunsPolicy 把任务交给提交任务的调用线程直接执行。
//线程池基础示例
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2,
5,
30L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
pool.execute(()->{
System.out.println("线程池任务执行");
});
线程池在刚被创建时,里面会立即初始化并存在线程吗?为什么?
面试关键点需要分清 ThreadPoolExecutor 默认行为与预创建方法的区别,普通构造实例不会提前创建线程,面试加分点要讲明白 prestartAllCoreThreads 这类预启动 API 的作用,还要结合源码层面任务驱动的设计思路,区分业务上懒加载的优势。记忆法采用懒加载联想记忆法,把线程池类比成工厂,工厂建好不会立刻雇佣工人,有订单来了才招聘工人;搭配 API 锚定记忆法,记住普通构造懒加载,prestart 系列方法才主动创建核心线程。
使用 ThreadPoolExecutor 构造方法直接创建线程池对象的时候,线程池内部不会立刻初始化任何工作线程,线程集合是空的。线程池对象仅仅完成内部成员变量初始化,保存核心线程数、最大线程数、阻塞队列、拒绝策略等配置参数,操作系统层面的线程不会被创建。只有当调用 execute 提交第一个任务之后,线程池才会根据规则去创建工作线程。这种设计属于懒加载机制,线程资源属于操作系统稀缺资源,线程创建会涉及内核对象分配、栈内存分配,会消耗 CPU 和内存,如果线程池初始化就批量创建大量线程,即便线程池后续一直没有任务,这些线程也会持续占用系统资源,造成资源浪费。服务启动的时候如果一次性初始化大量线程,还会拉长应用启动耗时。
JDK 提供专门的方法可以打破懒加载的默认行为,prestartCoreThread 方法会预启动一个核心线程,prestartAllCoreThreads 方法会一次性把全部核心线程预先创建完成。调用这两个方法之后,线程池即使还没有任何任务提交,内部也会存在已经就绪的工作线程。很多业务场景会使用这两个 API,比如高并发网关服务,服务启动阶段就把核心线程全部初始化完成,避免第一个任务到来的时候,创建线程带来的延迟,消除首次任务的耗时抖动。很多面试者会混淆 Executors 工具类创建的线程池,Executors 底层依旧调用 ThreadPoolExecutor 构造函数,同样遵守懒加载规则,刚实例化完毕内部没有线程。
这里需要区分核心线程不会被回收,是指线程创建完成之后空闲会驻留,不是线程池初始化就直接生成核心线程。核心线程驻留和线程预创建是两个独立概念。线程对象和线程池对象是完全不同的对象,new Thread 只是创建 Java 对象,不调用 start 不会创建操作系统线程,ThreadPoolExecutor 内部工作线程也是同样逻辑,只有执行线程 start 之后,操作系统线程才真正生成。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
3,
10,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
//刚创建完成,工作线程数量为0
System.out.println(pool.getPoolSize());
//主动预启动全部核心线程
pool.prestartAllCoreThreads();
//预启动之后,工作线程数量等于核心线程数
System.out.println(pool.getPoolSize());
pool.execute(() -> {
System.out.println("执行业务任务");
});
面试作答的时候要区分 getPoolSize 返回的是当前存活工作线程数量,不是配置的核心线程数。很多候选人会错误认为设置核心线程数为 3,线程池创建完就有 3 个线程,这是高频误区。同时要说明懒加载的弊端,流量突发第一个请求会触发线程创建,带来额外耗时,所以对延迟敏感的服务,需要主动调用预启动方法。懒加载是默认优化,但不是强制规则,JDK 提供 API 允许开发者改变这个行为。
线程池中的线程在执行任务时抛出未捕获异常,会发生什么情况?应该如何处理?
面试关键点分清 execute 提交任务和 submit 提交任务,异常表现完全不一样,这是面试最高频考点。面试加分点要讲清楚底层源码逻辑,线程的退出流程,以及不同异常处理方案的取舍。记忆法采用提交方式对比记忆法,execute 直接抛出异常,工作线程销毁重建;submit 捕获异常存入 Future;搭配方案归类记忆法,任务内部 try‑catch、线程工厂设置异常处理器、Future 获取异常。
使用 execute 方法提交 Runnable 任务,任务执行过程抛出未捕获异常,异常不会被线程池内部捕获,异常会向上抛出,工作线程的 run 方法终止,当前这条工作线程直接退出销毁。线程池检测到工作线程消亡,如果当前存活线程数量小于核心线程数,线程池会新建一条工作线程补充到线程池当中,保证线程池维持配置的核心线程数量。控制台可以直接打印异常堆栈信息。线程池不会因此停止工作,只是销毁旧线程,生成新线程继续处理后续任务。
使用 submit 提交任务,不管是 Runnable 还是 Callable,线程池会把任务包装成 RunnableFuture 对象,任务内部抛出的异常会被捕获保存到 Future 对象内部,不会向外抛出,控制台看不到异常堆栈。调用 get 方法获取返回值的时候,异常会被包装成 ExecutionException 向外抛出。如果业务代码永远不调用 Future 的 get 方法,异常会被静默吞噬,没有任何日志输出,业务问题很难排查,这是生产环境非常隐蔽的坑。
ThreadPoolExecutor pool = new ThreadPoolExecutor(2,4,10,TimeUnit.SECONDS,new LinkedBlockingQueue<>());
//execute提交,异常会打印堆栈,线程销毁重建
pool.execute(() -> {
int a = 1/0;
});
//submit提交,异常被保存,不调用get看不到报错
Future<?> future = pool.submit(() -> {
int a = 1/0;
});
处理异常一共有几种可行方案。第一种,在任务的业务代码内部使用 try‑catch 捕获全部异常,在 catch 块打印日志,记录上下文信息,这是生产环境最推荐的方案,可控性最强,能够拿到业务参数,方便定位问题。第二种,自定义 ThreadFactory,创建线程的时候,给线程设置 UncaughtExceptionHandler 未捕获异常处理器,当线程出现未捕获异常,会回调处理器的方法,在这里打印日志。该方式只对 execute 提交的任务生效,submit 提交的任务异常不会走到这个处理器,因为 submit 已经把异常捕获封装进 Future。第三种,submit 提交任务,业务代码必须调用 Future.get,捕获 ExecutionException,拿到原始异常信息。第四种,继承 ThreadPoolExecutor 重写 afterExecute 钩子方法,afterExecute 会在每一个任务执行结束之后执行,不管任务正常完成还是抛出异常,都可以在这里做统一异常处理,同样 submit 包装的异常不会传入 afterExecute。
面试作答要讲清坑点,很多开发人员使用 submit 之后,忘记调用 get,异常被无声吃掉,线上业务出现故障没有任何报错。同时要说明线程销毁重建带来的开销,如果任务频繁抛出异常,线程池会不停销毁创建线程,频繁操作系统线程,带来性能损耗。不能依靠 UncaughtExceptionHandler 作为 submit 任务的异常处理手段。
在日常开发中,哪些场景会用到线程池?使用线程池的好处是什么?
面试关键点,需要结合后端真实业务场景,不能只讲抽象概念,区分 IO 密集场景与 CPU 密集场景,面试加分点,同时说明滥用线程池带来的风险,不同业务如何选择线程池参数。记忆法采用业务场景归类记忆法,异步处理、批量任务、IO 并发、定时任务;搭配收益要点记忆法,复用线程、管控并发、解耦异步、统一管理。
后端开发当中第一个典型场景,异步任务处理。主线程不需要等待任务完成,把耗时操作交给线程池异步执行。比如用户下单之后发送短信、推送消息、记录操作日志,主线完成下单返回前端,消息推送交给线程池异步执行,提升接口响应速度,避免接口因为第三方服务慢而超时。第二个场景批量处理任务,大批量数据导入导出,数据库批量查询处理,文件批量解析,循环当中提交任务到线程池,并发处理多条数据,缩短整体处理耗时。第三个场景 IO 密集并发场景,调用第三方 HTTP 接口、RPC 调用、数据库查询、Redis 操作,大量时间线程处于 IO 等待,使用线程池并发发起 IO 请求,充分利用等待时间,提升吞吐量。第四个场景定时、周期任务,定时清理过期数据、定时统计报表,ScheduledThreadPoolExecutor 实现定时调度。第五个场景网关服务,接收大量客户端请求,把业务处理任务提交线程池,隔离网络 IO 线程和业务处理线程。
使用线程池带来的好处,第一实现线程复用,线程创建销毁属于重量级操作,会分配内核资源、栈内存,频繁创建销毁线程会带来大量 CPU 开销。线程池中的线程执行完任务之后不会销毁,循环从队列获取下一个任务,重复利用已有线程,降低系统开销。第二管控并发数量,如果不使用线程池,每来一个任务就 new Thread,高并发下会创建成千上万线程,线程占用堆外栈内存,操作系统线程调度压力飙升,会出现 OOM、系统卡顿。线程池可以限定最大线程数,控制并发上限,保护服务器资源。第三任务解耦,业务主线程与实际执行任务的线程分离开,主线程快速返回,任务异步执行,优化接口响应时间。第四统一管理,线程池可以统一维护任务队列、拒绝策略、线程命名,方便日志排查,监控任务数量、活跃线程数,支持优雅关闭,方便服务停机的时候等待任务执行完毕。
业务开发中也存在线程池的错误使用场景,禁止在循环内部每一次循环创建一个新的线程池实例,会造成资源泄漏;禁止使用 Executors 快捷创建线程池,FixedThreadPool 无界队列,任务量巨大时队列无限膨胀造成 OOM,CachedThreadPool 最大线程数无上限,流量突增创建大量线程耗尽内存。生产环境建议直接使用 ThreadPoolExecutor 构造函数手动指定队列大小、最大线程数、拒绝策略。
//异步日志记录示例
ThreadPoolExecutor businessPool = new ThreadPoolExecutor(
4,
8,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
//主线业务
public void userLogin(Long userId){
//执行业务逻辑
//异步记录登录日志
businessPool.execute(() -> {
saveLoginLog(userId);
});
}
public void saveLoginLog(Long userId){
//数据库写入日志
}
IO 密集业务,线程池线程数可以设置大一些,CPU 密集业务线程数不宜超过 CPU 核心数。面试可以补充,线程池不是万能,不是所有任务都适合多线程,任务本身执行时间很短,线程调度开销会大于业务执行开销,这种场景使用线程池收益很低。
Java 堆内存分为哪几个区域?对象在这些区域之间是如何分配和流转的?
面试关键点区分 JDK8 前后堆内存变化,JDK8 取消永久代,元空间移到本地内存,堆分为新生代、老年代。面试加分点,讲清对象优先分配、大对象直接进入老年代、对象晋升阈值,动态年龄判断规则。记忆法采用生命周期流转记忆法,新生对象分配新生代 Eden,MinorGC 之后存活进入 Survivor,多次 GC 晋升老年代;搭配分区图像记忆法,新生代分为 Eden 区、Survivor0、Survivor1,两个 Survivor 区始终一个使用一个空闲。
JDK8 及以后版本,Java 堆分为新生代和老年代两大块,元空间不在堆内,属于本地内存。新生代划分为 Eden 区、SurvivorFrom、SurvivorTo,默认比例 Eden:Survivor = 8:1:1,同一时刻只有一块 Survivor 区处于使用状态,另一块保持空白。新生代主要存放生命周期短的对象,老年代存放生命周期长、体积大的对象。
对象分配流程,绝大多数新创建的对象优先分配到 Eden 区。当 Eden 区内存占满,触发 MinorGC,新生代垃圾回收。GC 的时候把 Eden 区和正在使用的 Survivor 区当中存活对象复制到空白 Survivor 区,清空 Eden 和原 Survivor。每经历一次 MinorGC 存活下来的对象,对象年龄加 1。对象年龄达到晋升阈值,默认 15,对象晋升到老年代。JVM 具备动态年龄判断机制,Survivor 区当中相同年龄对象总和大于 Survivor 总容量一半,大于等于该年龄的对象直接晋升老年代,不需要等到 15 岁。
大对象直接分配到老年代,超过‑XX:PretenureSizeThreshold 设置阈值的对象,跳过新生代,直接分配到老年代,避免大对象在新生代反复复制,大对象比如长数组。还有一种情况,新生代存活对象太多,Survivor 放不下,对象直接进入老年代,叫做分配担保。
对象流转完整路径,新对象分配 Eden 区;Eden 满触发 MinorGC,存活对象复制到空闲 Survivor;对象在两个 Survivor 之间来回拷贝,年龄不断增长;年龄达标晋升老年代;老年代空间不足触发 MajorGC 或者 FullGC 回收老年代对象。对象不会在老年代再回到新生代,流转是单向的。
JDK7 及更早版本存在永久代,永久代存放类信息、常量,属于堆的一部分,JDK8 移除永久代,类元信息放到元空间,元空间使用操作系统本地内存,字符串池移到堆中。
//简单对象创建示例
class User{
private Long id;
private String name;
}
//新建对象优先分配Eden
User user = new User();
面试高频坑,很多面试者错误把元空间算作堆内存,JDK8 元空间不属于堆。同时动态年龄判断是面试加分点,不是必须等到 15 岁才晋升。分配担保机制,MinorGC 的时候,如果存活对象大于 Survivor 容量,直接把对象送入老年代。
| 堆区域 | 作用 | 存放对象特征 |
|---|---|---|
| Eden 区 | 新对象主要分配位置 | 刚刚创建的短期对象 |
| Survivor 区 | 存放经过 MinorGC 存活对象 | 存活时间较短,还未晋升老年代对象 |
| 老年代 | 长期存活对象存放区 | 高龄对象、大对象 |
请介绍一下常见的垃圾回收策略(算法)及其适用场景。
面试关键点掌握标记清除、标记复制、标记整理三种基础垃圾回收算法,分清各自优缺点,同时分清分代收集是回收策略,不是基础算法。面试加分点,结合 JVM 各个收集器对应底层算法,说明每种算法的代价。记忆法采用操作流程记忆法,标记清除:标记垃圾,直接清除;标记复制:标记存活,复制到新空间;标记整理:标记存活,移动压缩;搭配优缺点锚定记忆法,清除产生碎片,复制浪费内存,整理移动对象开销大。
标记清除算法,分为两个阶段,标记阶段遍历堆,标记所有已经死亡的垃圾对象;清除阶段,扫描整个堆,回收标记为垃圾的对象内存,回收之后内存不会做压缩整理。该算法实现简单,不需要移动对象。缺点是回收完成之后会产生大量不连续内存碎片,如果后续需要分配大对象,没有连续内存空间,即使总空闲内存充足,也会分配失败触发 GC。适合存活对象少的场景,老年代部分收集器会使用该算法。
标记复制算法,把内存空间划分成大小相等两块区域,一块工作区,一块备用区。GC 的时候标记所有存活对象,把存活对象全部复制拷贝到备用区域,拷贝完成之后,直接清空整个原来工作区,交换两块区域角色。该算法不会产生内存碎片,分配对象只需要指针偏移,分配速度快。缺点,需要预留一半内存作为备用空间,内存利用率只有 50%。适合存活对象数量少的场景,新生代对象大部分朝生夕死,MinorGC 存活对象占比很低,所以新生代普遍采用标记复制算法,HotSpot 新生代并不是严格对半分,采用 Eden 加两块 Survivor,内存利用率更高。
标记整理算法,分为标记阶段和整理阶段。标记阶段找出全部存活对象;整理阶段,把所有存活对象向内存一端移动,紧凑排列,移动完成之后,边界之外全部空间统一回收。标记整理不会产生内存碎片,内存利用率高。缺点是需要移动大量存活对象,修改对象引用地址,STW 停顿时间很长,对象越多开销越大。适合存活对象多的老年代。
分代收集不属于基础垃圾回收算法,是工程层面的回收策略。根据对象生命周期长短,把堆划分新生代、老年代,不同代使用不同算法。新生代对象大量消亡,使用标记复制;老年代存活对象多,使用标记清除或者标记整理。
除了基础算法,还有增量收集、并发收集。并发收集,GC 线程和业务用户线程大部分时间并发执行,减少 STW 停顿时间,现代 G1、ZGC 收集器都是并发回收思路。
//JVM启动参数示例,仅供理解,不是业务代码
//-XX:+UseSerialGC 串行收集器,新生代标记复制,老年代标记整理
//-XX:+UseG1GC G1收集器,整体标记整理,局部标记复制
标记清除最大痛点内存碎片;标记复制痛点内存空间浪费;标记整理痛点移动对象带来长时间 STW。Serial 收集器单线程回收,新生代标记复制,老年代标记整理;Parallel Scavenge 并行收集器,多线程执行回收;CMS 收集器底层主要使用标记清除,会产生内存碎片;G1 整体是标记整理,局部 Region 使用标记复制;ZGC 是并发整理,几乎没有 STW。
面试作答要区分,算法是底层逻辑,收集器是算法的工程实现。CMS 使用标记清除,所以会产生碎片,需要定期做碎片压缩;G1 通过 Region 划分,兼顾吞吐量和停顿时间。很多面试者混淆算法和收集器概念,作答的时候要明确区分。三种基础算法没有绝对好坏,根据对象存活比例选择对应的算法。
JVM 调优常用的命令有哪些?
面试关键点要分清 JDK 自带命令行工具,区分 Jps、jstat、jmap、jhat、jstack、jinfo 各自职责,区分 JDK8 与 JDK9+jcmd 统一工具,面试加分点,要讲清楚每个工具常用参数,线上排查的使用顺序,区分 dump 堆快照、线程栈、GC 统计信息不同工具的分工。记忆法采用排查流程记忆法,先 jps 找进程 ID,jstat 看 GC 实时指标,jstack 看线程,jmap 导出堆快照,jhat 分析堆文件;搭配功能分类记忆法,进程定位、GC 监控、线程分析、堆内存分析、虚拟机信息五大类。
jps 是 JVM 进程状态工具,是排查的第一步,用来查找 Java 应用进程 PID。jps 直接输出本机所有 Java 进程的简单信息;jps‑l 输出主类全限定名以及 jar 包完整路径;jps‑v 输出 JVM 启动参数;jps‑m 输出 main 方法传入的参数。线上服务器多 Java 进程,首先通过 jps 拿到目标进程 PID,后续所有命令都依赖 PID。jps 底层依赖 HotSpot 的 Attach 机制,需要注意权限问题,同一个操作系统用户才能访问对应 Java 进程。
jstat 是 JVM 运行状态统计工具,可以实时采样输出 GC、类加载、编译等运行指标,是线上观察 GC 变化最常用工具。jstat‑gc pid 间隔毫秒 打印次数,持续输出 GC 统计,包含新生代老年代容量、已使用大小、MinorGC、FullGC 次数与耗时。jstat‑gccapacity 查看各个代内存容量;jstat‑gccause 输出最近一次 GC 发生的原因;jstat‑compiler 查看 JIT 编译情况。jstat 属于轻量级工具,不需要暂停业务进程,适合线上实时观察 GC 趋势,不会对业务造成很大影响。
jstack 线程堆栈分析工具,jstack pid 输出线程快照,打印所有线程调用栈。用来排查死锁、死循环、线程阻塞、CPU 飙高、线程池异常。jstack pid > thread.txt 把堆栈输出到文件,方便后续分析。可以看到 BLOCKED、WAITING、TIMED_WAITING 状态线程,识别死锁、锁等待。线上 CPU100% 故障,拿到 PID 之后,再用操作系统命令定位高消耗线程,结合 jstack 堆栈定位业务代码。jstack 执行过程会触发一次短暂 STW,生产尽量不要频繁执行。
jmap 堆内存映射工具,用于查看堆配置、导出堆 dump 快照。jmap‑heap pid 查看堆整体配置,新生代老年代大小,使用的垃圾收集器;jmap‑histo pid 打印对象统计,输出各个类对象实例数量、占用内存大小,可以快速发现大对象;jmap‑dump:format=b,file=xxx.hprof pid 导出完整堆转储文件。hprof 文件是二进制堆快照,导出的时候会发生 STW,如果堆内存很大,几十 GB,dump 会造成业务长时间停顿,线上要避开业务高峰执行。dump 文件拿到之后下载到本地,使用 MAT 工具分析,不建议直接使用 jhat 在服务器分析。
jhat 是堆快照分析工具,解析 hprof 文件,启动简易 web 服务查看堆对象,功能简陋,实际生产很少直接使用,更多使用 Eclipse MAT 工具本地分析 dump 文件。
jinfo 查看虚拟机配置信息,jinfo pid 打印全部 JVM 系统属性与启动参数;jinfo‑flags pid 查看进程启动时设置的 JVM 参数,可以确认线上实际生效参数,核对配置是否正确。
JDK9 之后推出 jcmd 统一命令,整合上面绝大多数工具能力,一条命令可以完成进程列表、GC 统计、线程 dump、堆 dump,向后兼容旧工具。
# 示例命令
jps -l
jstat -gc 1234 1000 10
jstack 1234 > thread.log
jmap -dump:format=b,file=heap.hprof 1234
jinfo -flags 1234
面试高频坑,很多候选人分不清 jstat 是实时监控,jmap dump 会 STW,大堆 dump 会造成业务卡顿,线上不能随意执行。排查顺序一般先 jps 拿 PID,jstat 观察 GC 指标变化,CPU 线程异常用 jstack,内存泄漏 OOM 再 dump 堆快照。生产环境可以配置 JVM 参数‑XX:+HeapDumpOnOutOfMemoryError,OOM 的时候自动导出堆快照,避免故障现场丢失。
如果线上环境频繁出现 GC(垃圾回收),应该如何定位和排查问题?
面试关键点区分频繁 MinorGC、频繁 MajorGC/FullGC 两种现象,按照由外到内的排查流程,先确认现象,收集指标,获取现场,定位代码,验证修复。面试加分点,区分内存泄漏、内存设置不合理、大对象、业务流量突增几种根因,还要讲清楚各个工具分别对应解决什么问题。记忆法采用分步排查记忆法,确认现象→收集监控→抓取现场快照→定位根因→修复验证;搭配故障归类记忆法,MinorGC 频繁优先看新生代;FullGC 频繁优先看老年代。
第一步确认 GC 现象,区分是频繁 MinorGC 还是频繁 FullGC。通过监控平台或者 jstat‑gc 观察指标。频繁 MinorGC 表现为 YGC 次数快速上涨,YGC 耗时不高,但是发生频率极高;频繁 FullGC 表现为 FGC 次数上涨,STW 停顿时间长,CPU 抖动,接口响应变慢。同时观察堆内存各个代使用率,新生代快速占满,还是老年代快速上涨。同时记录 GC 日志,线上务必开启 GC 日志输出,JVM 参数输出 GC 时间、各个代占用、GC 原因,GC 日志是第一手依据。
第二步收集基础系统指标,操作系统层面 CPU、内存、磁盘 IO。确认服务器物理内存是否充足,是否存在 swap 交换,swap 开启会严重拖慢 GC 速度。查看业务 QPS,确认是否流量突增,业务请求量上涨,创建大量临时对象,会造成 GC 频繁,区分是业务正常流量还是代码 bug。
第三步抓取运行现场。如果是线程、CPU 波动,使用 jstack 导出线程堆栈,排查是否存在死循环,大量线程疯狂创建对象。如果怀疑内存问题,观察老年代持续上涨,准备抓取堆 dump。堆很大的时候 dump 会 STW,尽量选择业务低峰,也可以配置 OOM 自动 dump。拿到 hprof 堆快照,下载到本地使用 MAT 分析。MAT 可以查看对象占用排行,找出大对象、泄漏对象,查看对象引用链,定位是谁持有对象没有释放。
然后进行根因分析。频繁 MinorGC,大概率短生命周期对象过多,接口循环创建大量对象,集合没有及时清理,字符串操作过多。对象很快填满 Eden 区,不断触发 YGC。可以看 GC 日志新对象分配速率,查看业务代码是否循环内创建对象。
频繁 FullGC 常见几类原因。第一内存泄漏,对象本应该回收,但是存在无效引用,对象持续晋升到老年代,老年代不断被占满。MAT 中看对象引用链,找出无意持有对象的集合、静态集合没有清理,线程池线程持有任务引用。第二大对象,业务一次性生成超大数组,大报文,直接进入老年代,快速耗尽老年代。第三堆内存设置过小,‑Xmx 设置太低,业务正常对象就把堆占满。第四元空间耗尽,类动态加载过多,元空间不足触发 FullGC。第五 System.gc 代码手动调用,人为触发 FullGC。第六分配担保,新生代大量对象晋升老年代。
定位代码之后,进行修复验证。修复完成,线上灰度发布,持续观察 GC 指标,GC 次数、GC 耗时、各代内存占用,确认问题消失。
#开启GC日志JVM参数示例
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:+HeapDumpToPath=/data
面试作答要说明,不要上来直接 dump 堆,大堆 dump 会造成业务卡顿,优先看监控和 GC 日志。很多线上 GC 问题不是内存泄漏,只是堆参数配置不合理,或者业务流量上涨。区分内存泄漏与内存溢出,泄漏是对象无法回收,OOM 是内存不够分配。同时注意,部分第三方框架、中间件也会造成对象异常增长,排查的时候不能只看业务代码。
什么是数据库事务?事务的 ACID 特性具体指什么?MySQL 底层是如何保证 ACID 的?
面试关键点掌握事务定义,ACID 四个特性含义,区分每个特性对应的 MySQL 底层技术,InnoDB 引擎,MyISAM 不支持事务。面试加分点,区分 undo log、redo log、MVCC、锁分别负责哪一部分特性,不要混淆。记忆法采用 ACID 单词记忆法,原子性 Atomic、一致性 Consistency、隔离性 Isolation、持久性 Durability;搭配技术绑定记忆法,undo log 负责原子性,redo log 负责持久性,锁 + MVCC 负责隔离性,原子隔离持久共同保障一致性。
数据库事务,是一组数据库操作的逻辑单元,这一组 DML 操作,要么全部成功执行提交,要么全部失败回滚,不会出现部分成功部分失败的中间状态。比如转账业务,A 扣钱,B 加钱,两个操作封装在同一个事务,不能出现 A 扣钱成功 B 加钱失败的情况。MySQL 中只有 InnoDB 存储引擎支持完整事务,MyISAM 不支持事务。
ACID 分别代表原子性、一致性、隔离性、持久性。原子性,事务内所有操作,全部成功或者全部失败回滚,不存在中间状态。一致性,事务执行前后数据完整性约束不被破坏,数据从一个合法状态转变到另一个合法状态,一致性是最终目标,依靠原子性、隔离性、持久性共同保障。隔离性,多个事务并发执行,事务之间互相不可见,避免并发带来脏读、不可重复读、幻读。持久性,事务一旦提交成功,修改的数据永久生效,即使数据库宕机断电,数据也不会丢失。
MySQL InnoDB 底层如何保障 ACID。原子性依靠 undo log 回滚日志实现。undo log 记录事务修改之前的数据快照。事务执行过程中,数据修改同时写入 undo log。事务提交,undo log 保留,等待后台 purge 线程清理。如果事务执行失败回滚,InnoDB 读取 undo log 里修改前的数据,反向操作,把数据恢复成事务执行之前状态,实现全部回滚,保障原子性。undo log 是物理逻辑日志,记录数据变更前版本。
持久性依靠 redo log 重做日志实现。磁盘随机写速度很慢,如果每次修改直接刷磁盘数据文件,性能很差。事务修改数据,先修改内存缓冲池页,同时把变更记录写入 redo log 日志文件,redo log 顺序写入磁盘,速度很快。事务提交的时候,只需要保证 redo log 落盘,就算提交成功,不需要立刻把内存数据页刷回磁盘数据文件。数据库宕机重启,读取 redo log,把还没有刷入数据文件的变更重做恢复,保证已经提交事务的数据不会丢失,实现持久性。
隔离性依靠锁机制与 MVCC 多版本并发控制共同实现。写操作使用行锁,阻止其他事务同时修改同一行数据;MVCC 通过 undo log 生成数据历史版本,读操作读取历史快照版本,读写不阻塞,实现不同隔离级别。
一致性是业务目标,没有单独一套日志直接实现一致性。原子性、隔离性、持久性技术底层保障,再加上数据库约束主键、唯一索引、外键,共同保证数据一致性。业务代码层面也需要保障一致性,数据库不能完全包揽一致性。
| ACID 特性 | 含义 | InnoDB 底层实现 |
|---|---|---|
| 原子性 | 事务要么全成功要么全回滚 | undo log 回滚日志 |
| 一致性 | 数据始终处于合法完整状态 | 原子 + 隔离 + 持久 + 约束共同保障 |
| 隔离性 | 并发事务之间互相隔离 | 行锁 + MVCC 多版本控制 |
| 持久性 | 提交后修改永久生效 | redo log 重做日志 |
-- MySQL事务示例
start transaction;
update account set balance = balance -100 where id=1;
update account set balance = balance +100 where id=2;
commit;
-- 异常执行 rollback;
面试高频坑,很多候选人误以为一致性由某一个日志实现,一致性是结果,不是由单一组件实现。undo log 负责回滚,redo log 负责宕机恢复,不要搞混两者职责。MyISAM 没有 undo、redo,不支持事务。
MySQL 中有哪些事务隔离级别?它们之间的区别是什么?MySQL 分别是如何实现这些隔离级别的?
面试关键点记住四个隔离级别读未提交、读已提交、可重复读、串行化,MySQL InnoDB 默认隔离级别是可重复读,InnoDB 在可重复读级别通过 MVCC + 间隙锁解决幻读。面试加分点区分脏读、不可重复读、幻读现象,区分 RC 与 RR 级别 MVCC 实现差异,快照读、当前读概念。记忆法采用级别顺序记忆法,读未提交→读已提交→可重复读→串行化,隔离程度越来越高,并发性能逐步下降;搭配问题对应记忆法,读未提交出现脏读;读已提交解决脏读,存在不可重复读;可重复读解决脏读不可重复读,InnoDB 解决幻读;串行化全部问题解决。
MySQL 标准 SQL 定义四个事务隔离级别,从低到高依次是读未提交,读已提交,可重复读,串行化。隔离级别越高,并发性能越低,数据一致性越强。InnoDB 引擎默认隔离级别是可重复读。
读未提交,一个事务可以读取到另一个事务没有提交的数据。会产生脏读,A 事务修改数据未提交,B 事务读到未提交脏数据,如果 A 回滚,B 读到无效数据。实际业务几乎不会使用这个级别。
读已提交,只能读到其他事务已经提交的数据,解决脏读问题。但是会出现不可重复读,同一个事务内,同一条 SQL,前后两次读取,中间被其他事务提交修改,两次读取结果不一样。
可重复读,同一个事务内,多次读取同一行,读取到的数据保持一致,不受其他事务提交修改影响,解决脏读、不可重复读。标准 SQL 定义下会出现幻读,InnoDB 通过 MVCC + 临键锁解决幻读。
串行化,最高隔离级别,所有读写操作全部加共享锁,事务串行执行,完全杜绝脏读、不可重复读、幻读,并发性能极差,极少在生产业务使用。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 (标准 SQL) | InnoDB 是否解决幻读 |
|---|---|---|---|---|
| 读未提交 | 存在 | 存在 | 存在 | 否 |
| 读已提交 | 不存在 | 存在 | 存在 | 否 |
| 可重复读 | 不存在 | 不存在 | 存在 | 是(临键锁) |
| 串行化 | 不存在 | 不存在 | 不存在 | 是 |
InnoDB 实现隔离级别依靠两套机制,MVCC 多版本并发控制用于快照读,行锁、临键锁用于当前读。快照读就是普通 select 语句,不加锁,读取 undo log 生成历史版本快照;当前读是 select ... for update、update、delete、insert,读取最新数据,会加行锁。
MVCC 实现原理,每行隐藏 DB_TRX_ID 记录最后修改事务 ID,DB_ROLL_PTR 指向 undo log 历史版本。事务开启生成 read view 读视图。读视图保存当前活跃未提交事务集合。RC 读已提交,每一次 select 都会生成新 read view;RR 可重复读,事务第一次 select 的时候生成 read view,整个事务复用同一个 read view,所以同一个事务多次读取结果不变,实现可重复读。
RC 级别每次查询生成新视图,可以读到别的事务已经提交的最新数据,会出现不可重复读。RR 复用同一个 read view,保证多次快照读结果不变。MVCC 只针对快照读,解决快照读的幻读问题;对于当前读,InnoDB 在 RR 隔离级别使用临键锁,临键锁由行锁 + 间隙锁组成,锁住数据行以及行之间间隙,阻止其他事务插入新数据,杜绝幻读。串行化隔离级别,普通 select 语句隐式转换为 select share mode,全部加共享锁,读写互相阻塞。
-- 查看当前隔离级别
show variables like 'transaction_isolation';
-- 设置会话隔离级别
set session transaction isolation level read committed;
面试高频坑,很多候选人说 MySQL RR 级别不能解决幻读,标准 SQL 理论 RR 存在幻读,InnoDB 通过临键锁在 RR 下解决幻读,这点要讲清楚。区分快照读不加锁,当前读加锁。RC 隔离级别没有间隙锁,只有行锁。
数据库中数据的一致性是如何保证的?
面试关键点,数据库一致性分为事务内部一致性、分布式多库一致性,InnoDB 本地事务依靠 ACID,分布式依靠 CAP、BASE、最终一致性方案。面试加分点,区分强一致性与最终一致性,数据库原生约束、锁、MVCC、redo/undo、分布式事务方案。记忆法采用分层记忆法,单机层:约束 + undo+redo + 锁 + MVCC;应用层:业务代码;分布式层:2PC、TCC、本地消息表等;一致性模型记忆法,强一致性、最终一致性。
单机数据库内部一致性,指事务执行前后,数据满足业务规则,约束不会被破坏。数据库层面有多层保障手段。第一数据库内置约束,主键约束保证唯一,非空约束,唯一索引,外键约束,check 约束。在写入数据的时候校验,不符合规则直接拒绝写入,从底层保证单条记录合法性。
第二事务 ACID 机制,undo log 保障原子性,redo log 保障持久性,锁与 MVCC 保障隔离性。原子性保证事务不会半截提交,隔离性避免并发事务互相干扰,持久性保证提交结果不会丢失,三者共同推导出一致性。如果没有原子性,事务部分提交,数据直接不一致;没有隔离性,并发读写产生脏读,数据错乱。
第三锁机制,行锁、表锁、临键锁,并发写的时候互斥访问同一行,避免多事务同时修改同一数据,造成数据覆盖错乱。MVCC 保证读的一致性,快照读读到符合事务隔离级别的版本,读到合法数据。redo log、undo log 保证宕机故障场景,不会出现数据损坏,故障恢复之后数据依旧满足一致性。
仅仅依靠数据库本身还不足以完全保证一致性,很多业务逻辑数据库无法感知,需要业务应用层配合。例如转账业务,余额不能为负数,数据库没有余额负数约束,业务代码需要做判断,扣减之前校验余额,这部分一致性由业务代码实现。
分布式场景,数据分散在多个数据库实例,多个服务,单机事务不再生效,一致性分为强一致性和最终一致性。强一致性要求所有节点数据瞬间全部一致,写操作完成之后,任意节点读取都拿到最新数据。分布式强一致性方案,2PC 两阶段提交,第一阶段协调者询问所有参与者是否可以提交,参与者执行事务预提交,写 redo log 不提交;第二阶段全部参与者返回成功,协调者通知全部提交;任意参与者失败,全部回滚。2PC 缺点阻塞,协调者故障会卡住,性能差。
最终一致性,不要求瞬间全部节点一致,允许短暂时间窗口数据不一致,经过一定时间,所有节点数据最终达成一致,BASE 理论。常见实现方案 TCC、本地消息表、可靠消息队列事务。TCC 分为 Try、Confirm、Cancel 三个阶段,业务代码手动编写预留资源、确认、回滚逻辑;本地消息表,业务和消息记录在同一个本地事务,消息后续异步重试完成远端更新;可靠 MQ,半消息机制,实现分布式事件,异步补偿。
同时还需要补偿机制,定时任务校对数据,比对多库数据差异,发现不一致进行修复,处理网络超时、消息丢失带来的数据不一致。
-- 数据库约束示例
create table account(
id bigint primary key auto_increment,
user_name varchar(32) not null,
balance decimal(10,2),
unique key uk_username(user_name)
);
面试作答要区分,单机事务一致性和分布式一致性是两个不同范畴。很多面试者只讲 ACID,忽略业务代码约束和分布式场景。数据库只能保证它能感知到的规则,业务逻辑层面的约束需要业务代码实现。分布式场景,强一致性性能代价很高,绝大多数互联网业务选择最终一致性。还要区分一致性和隔离性概念,隔离是手段,一致性是最终目标。
请解释数据库索引的最左匹配原则,并举例说明。
面试关键点要理解联合索引底层存储有序特性,最左前缀匹配的触发条件,区分等值查询、范围查询对索引生效的影响,面试加分点要讲清楚失效场景,以及 MySQL 优化器可以调整条件顺序,不等于打破最左匹配。记忆法采用前缀联想记忆法,联合索引相当于复合字典,优先匹配最左边的字段;搭配范围截断记忆法,遇到范围查询之后,后面的列索引失效。
最左匹配原则针对联合索引(复合索引),联合索引是把多个字段组合在一起建立 B + 树索引,索引内部数据按照索引字段顺序排序,先按第一个字段排序,第一个字段相同再按第二个字段排序,依次往后。最左匹配原则指查询条件要匹配联合索引的最左侧连续字段,索引才可以生效;如果跳过左边的字段,直接使用后面的字段做查询,索引无法正常使用。当查询过程中遇到范围查询(>、<、between),范围条件右侧的字段将无法走索引。
最左匹配不是要求 where 条件书写顺序和索引字段顺序完全一致,MySQL 优化器会自动调整 where 条件的顺序,只要条件中包含索引最左侧连续字段,就可以命中索引。真正决定索引是否生效的是索引定义的字段顺序,不是 SQL 书写顺序。
举示例,建立联合索引 idx_a_b_c(a,b,c),索引定义顺序 a、b、c。 可以命中索引的场景:where a=?;where a=? and b=?;where a=? and b=? and c=?;where b=? and a=?,SQL 条件顺序调换,优化器重排后依旧命中索引。 无法完整命中索引场景:where b=?;where c=?;where b=? and c=?,直接跳过最左列 a,索引失效,走全表扫描。
范围查询截断示例:where a=1 and b>2 and c=3,a 做等值,b 做范围,b 之后的 c 不再使用索引。a、b 可以走索引过滤,c 只能在返回结果集中做内存过滤,c 字段不会参与索引检索。
-- 创建联合索引
create index idx_a_b_c on test_table(a,b,c);
-- 正常走联合索引
select * from test_table where a=1 and b=2 and c=3;
-- 条件顺序调换,优化器调整,依旧命中索引
select * from test_table where b=2 and a=1;
-- 跳过最左a,索引失效
select * from test_table where b=2 and c=3;
-- b是范围查询,c无法使用索引
select * from test_table where a=1 and b>2 and c=3;
业务设计联合索引的时候,需要把等值查询字段放在索引靠前位置,范围查询字段放在联合索引的靠后位置,避免截断后面字段。很多开发会踩坑,where 条件字段都存在,但是跳过最左列,导致索引失效。同时区分,最左匹配是 B + 树联合索引的特性,哈希索引不存在最左匹配原则。还要注意,like 前缀模糊查询like 'xxx%'可以走索引,like '%xxx'后缀模糊无法走索引,也是最左前缀的延伸。
MySQL 的索引底层为什么使用 B+ 树?与二叉树、Hash 等结构相比有什么优势?
面试关键点要掌握 B 树、B + 树核心差异,磁盘 IO 访问逻辑,页的概念,对比二叉搜索树、平衡二叉树、红黑树、哈希索引的优缺点。面试加分点,要讲清磁盘 IO 次数是数据库索引选型的核心,B + 树所有数据都在叶子节点,非叶子节点只存键值。记忆法采用 IO 次数记忆法,索引核心目标:减少磁盘 IO;对比维度记忆法,二叉树树高过高,Hash 不支持范围排序,B + 树矮胖,范围、排序、等值都支持。
MySQL InnoDB 索引底层采用 B + 树,数据库索引数据存储在磁盘,磁盘 IO 是性能最大瓶颈,每访问一次节点就产生一次磁盘 IO。索引结构的核心目标就是尽可能降低树的高度,减少 IO 次数。
二叉搜索树,极端情况下会退化成链表,树高等于数据行数,查询一条数据需要大量 IO,完全不适合数据库。平衡二叉树 AVL、红黑树,树高是 log₂N,数据量大的时候树依旧很高,1000 万条数据,树高约 24,一次查询需要 24 次磁盘 IO,性能很差。二叉树每个节点只存一条键,节点分叉少,树纵向太高。
Hash 索引,通过哈希函数计算 key 哈希值,直接定位数据。等值查询速度极快,O (1)。但是短板非常明显,不支持范围查询、不支持排序、不支持前缀模糊查询;存在哈希冲突;无法做联合索引的最左匹配。业务中只有 Memory 引擎支持 Hash 索引,InnoDB 不提供原生 Hash 索引。
B 树是多路平衡查找树,每个节点可以存放多个 key 和数据,节点多路分叉,树变得矮胖,树高度大幅降低。B 树每个节点既存索引键,又存储真实数据,查询的时候,命中节点就可以返回数据。缺点,范围查询效率差,数据分散在各个节点,范围扫描需要跨节点跳转。
B + 树在 B 树基础上进一步改造。第一,非叶子节点只存储索引键,不存储真实行数据,一页可以存放更多索引键,树的高度进一步压缩,千万级数据 B + 树一般树高只有 2‑3 层,查询最多 2‑3 次磁盘 IO。第二,全部真实行数据都保存在叶子节点。第三,叶子节点之间通过双向链表串联,有序串联在一起。范围查询、order by 排序、limit 分页,直接遍历叶子链表即可,不需要回溯上层节点,范围扫描性能极强。
InnoDB 主键索引,叶子节点保存完整行数据;二级索引叶子节点保存主键值,回表再拿主键查询完整行。
| 数据结构 | 等值查询 | 范围查询 | 排序 | 树高问题 |
|---|---|---|---|---|
| 二叉树 / 红黑树 | 快 | 支持 | 支持 | 数据量大树太高,IO 多 |
| Hash 索引 | 极快 | 不支持 | 不支持 | 哈希冲突,无有序性 |
| B 树 | 较快 | 一般 | 一般 | 数据分散,范围扫描开销大 |
| B + 树 | 快 | 优秀 | 优秀 | 树矮,叶子链表,适合磁盘 |
面试高频坑,区分 B 树和 B + 树,很多面试者混淆两者。B + 树非叶子节点不存数据,这是关键。磁盘按页读取,一页 16KB,B + 树非叶子节点可以存放大量索引 key,压低树高。Hash 索引适合等值,但是不适合 MySQL 绝大多数业务场景,MySQL 绝大多数业务都有排序、范围查询需求,所以 B + 树成为首选。
在创建和设计数据库(表)时,通常需要考虑哪些因素和步骤?
面试关键点,从业务分析、字段设计、主键选择、索引设计、范式与反范式、存储引擎选择,兼容性、扩展预留,后续运维多个维度梳理。面试加分点,区分范式与反范式使用场景,避免过度范式带来多表关联,也要避免过度冗余。记忆法采用由业务到落地记忆法:业务梳理→表结构设计→字段约束→主键索引→范式权衡→存储引擎→测试评审。
第一步梳理业务需求,梳理实体、实体属性,实体之间关系,一对一、一对多、多对多。确认业务读写比例,读多写少还是写多读少,预估未来数据量级,预估数据增长速度,预判是否未来需要分库分表。明确查询场景,哪些字段经常做 where 过滤,哪些字段排序分组,哪些只做存储几乎不查询。多对多业务需要设计中间关联表。
第二步选择存储引擎,绝大多数业务选用 InnoDB,支持事务、行锁、崩溃恢复;MyISAM 不支持事务,表锁,现在业务极少使用。设置字符集与排序规则,推荐 utf8mb4,支持完整 emoji,避免字符乱码。
第三步字段设计,选择合适的数据类型,尽量占用存储空间小的类型。能用 tinyint 不用 int,能用 int 不用 bigint;字符串 varchar 设置合理长度,不要全部设置 varchar (255);时间优先 datetime,不建议用字符串存储时间;金额使用 decimal,禁止 float、double 浮点类型,浮点会出现精度丢失。字段设置非空,尽量不允许 null,null 会影响索引效率,语义模糊,给默认值。字段命名统一,避免关键字。预留扩展字段,业务迭代预留少量字段,不要大量预留无用字段。
第四步主键设计,优先自增主键,有序写入,减少索引页分裂;业务无合适业务主键,不要使用业务字段当主键,业务字段会更新。禁止使用字符串、UUID 当主键,随机 UUID 会造成主键索引页分裂,写入性能下降。
第五步范式与反范式权衡。三范式减少数据冗余,避免更新异常,实体信息拆分多张表。但是过度范式会带来大量 join 关联查询,多表 join 性能下降。读多写少场景适当反范式,增加冗余字段,减少 join,用空间换时间。例如订单表冗余用户名,避免每次查询关联用户表。
第六步索引设计,根据业务查询 SQL 建立联合索引,遵守最左匹配原则。索引不是越多越好,索引提升查询,降低写入性能。避免建立无效索引,重复索引。大文本字段不建立索引。唯一业务字段建立唯一索引,保证数据唯一性。
第七步约束设计,主键、唯一索引、非空约束,根据业务选择是否外键,互联网业务大多应用层实现外键逻辑,数据库不使用外键,避免锁等待。
第八步评估扩展性,预估单表数据上限,单表尽量控制千万以内,超量提前规划分库分表。评估读写压力,是否需要读写分离。设计完成之后,评审 SQL 执行计划,验证索引是否生效,做压测验证。
-- 表设计示例
create table `order_info`(
id bigint auto_increment comment '主键',
user_id bigint not null comment '用户id',
order_no varchar(64) not null comment '订单号',
total_amount decimal(12,2) not null comment '订单金额',
create_time datetime not null comment '创建时间',
primary key(id),
unique key uk_order_no(order_no),
index idx_userid_createtime(user_id,create_time)
)engine = innodb default charset=utf8mb4 comment='订单表';
面试作答可以补充,禁止 select *,表设计就要明确需要哪些字段;大字段 text、blob 独立拆分到扩展表,避免主表行过大,影响缓存效率。同时考虑运维,字段注释完整,方便后续维护。
在分库分表场景下,如果商家也需要查询订单信息,应该如何设计实现(例如通过维护路由表或重写分片算法)?
面试关键点,普通订单分片一般按照用户 ID 分片,商家查询订单属于跨分片查询,需要讲清楚几种主流方案:全局表、冗余同步、路由表、复合分片、中间件、异构索引,对比各个方案优缺点。面试加分点,说明业务选型权衡,数据一致性、性能、开发成本。记忆法采用方案归类记忆法:冗余副本、路由表、复合分片、查询中间件;取舍记忆法,以空间换查询性能,以开发复杂度换一致性。
常规订单分库分表,大部分业务按照用户 ID 做分片键,用户查询自己的订单,根据用户 ID 路由,直接定位分片,性能很高。但是商家查询属于另一个维度,商家要查询属于自己店铺的全部订单,商家 ID 不是分片键,会触发全分片扫描,需要遍历所有分库分表,性能很差。解决该多维度查询问题,有多种工程实现方案。
第一种维护独立异构索引表(路由表方案)。主订单按照 user_id 分片,额外建立一张商家订单索引表,这张表以商家 ID 作为分片键。每新增、更新订单,业务同时写入主订单表与商家索引表,索引表存储订单 id、商家 id、user_id、分片信息。商家查询订单的时候,先查询商家索引分片,拿到订单 ID 集合,再拿着订单 ID 去对应的主分片查询完整订单数据。索引表可以只保存查询条件需要的字段,不保存全部订单大字段。数据更新需要双写,业务要处理双写失败,可使用 MQ 异步重试保证最终一致性。该方案优点,查询性能好;缺点,双写带来开发复杂度,存在短暂数据不一致。
第二种数据冗余副本。建立一套独立的订单分片集群,这套集群分片键使用商家 ID。原始订单数据通过 binlog 同步,把订单数据同步到商家维度分片集群。用户端操作走用户分片集群;商家后台查询全部走商家分片集群。两套集群物理隔离,读写分离。优点查询性能最优,业务代码简单;缺点双倍存储,数据同步延迟,成本高,适合商家查询流量非常大的业务。
第三种复合分片算法。分片键使用复合字段,user_id+merchant_id,中间件支持复合分片算法。但是这种方案很难同时满足两个维度的路由,往往一个维度命中,另一个维度依旧要广播,适用性有限。
第四种全局路由表。单独建立一张路由表,存储 order_id 对应 user_id、merchant_id、目标库表位置。无论用户还是商家查询,先查路由表拿到分片位置,再访问对应分片。路由表可以做分片。缺点每次查询多一次 DB 访问,路由表会成为热点。适合订单 ID 作为查询条件的场景,不适合商家分页批量查询。
第五种引入搜索引擎同步数据。把订单数据同步到 Elasticsearch,所有商家维度的复杂查询、分页、筛选全部走 ES。数据库只承担写入,复杂多维度查询交给搜索引擎。这是互联网业务非常常用方案。数据库保证写入,ES 承担多维度检索。缺点数据同步延迟,无法做到强一致。
第六种中间件全分片广播,不推荐。商家查询直接让 sharding‑sphere 遍历全部分表聚合结果,数据量大的时候性能极差,只适合小数据量后台管理,不能用于高并发商家接口。
//伪代码,双写维护商家索引表示例
public void createOrder(OrderDTO dto){
//主库写入用户维度订单
orderMapper.insert(dto);
//写入商家索引表
merchantOrderIndexMapper.insert(buildIndex(dto));
//发送MQ,异步补偿,防止双写失败
mqProducer.send(dto);
}
业务选型,并发高的商家查询接口优先 ES 或者异构索引表;商家后台低频查询,允许一定延迟,可以使用 binlog 同步副本;不建议直接走全分片广播。同时要处理分页问题,跨分片分页不能直接 limit offset,需要游标分页。所有双写方案都要考虑失败补偿,依靠 MQ 实现最终一致性,很难做到强一致。
Redis 为什么性能这么高(为什么快)?
面试关键点梳理 Redis 高性能的多层原因:内存存储、IO 多路复用、单线程模型、高效数据结构、C 语言实现、虚拟内存机制。面试加分点,讲清单线程不是 CPU 多核,单线程避免线程切换开销,但是 CPU 密集操作会阻塞 Redis。记忆法分层记忆法:存储层面(内存)、网络模型(IO 多路复用)、执行模型(单线程)、底层编码(C + 高效数据结构)。
Redis 性能极高,单机可以达到十万级 QPS,核心由多个层面共同决定。
第一,数据全部存放内存。Redis 绝大多数操作针对内存,内存访问速度远高于磁盘,不需要磁盘 IO。数据库 MySQL 大量操作需要磁盘读写,磁盘 IO 是巨大性能瓶颈。Redis 数据持久化 RDB/AOF,持久化是后台线程执行,不阻塞主业务线程。
第二,采用 IO 多路复用网络模型。Redis 使用 epoll(Linux),单个线程监听大量 socket 连接。不需要为每一个客户端连接创建独立线程。少量线程就可以处理成千上万客户端请求。IO 多路复用可以同时监听多个文件描述符,哪个连接就绪就处理哪个,避免多线程大量创建销毁、上下文切换开销。
第三,核心命令执行采用单线程模型。Redis 处理业务命令的主线程是单线程,所有数据读写命令排队串行执行。避免多线程带来锁竞争、线程切换、CPU 上下文切换开销。不需要处理复杂的并发锁,内部数据结构不需要加锁。注意,Redis 单线程指命令执行主线程,持久化、异步删除、集群数据同步是由额外后台线程执行。单线程带来一个限制,如果执行慢命令 keys、flushall,会阻塞整个 Redis,生产环境禁止使用慢命令。Redis6 以后引入多线程 IO,网络读写多线程,但是命令执行依旧单线程。
第四,底层使用 C 语言开发。C 语言贴近操作系统,没有 JVM、虚拟机额外开销,内存控制精细,内存拷贝少。Redis 对内存做大量优化,简单动态字符串 SDS、压缩列表、哈希表、跳表,都是专门定制的高效数据结构,减少内存碎片,提升运算效率。
第五,减少内存拷贝。使用 sendfile 系统调用,RDB 文件传输减少用户态内核态拷贝。Redis 很多操作尽量避免多余内存复制。
第六,协议简单。Redis 使用 RESP 简单文本协议,解析开销很小,协议简单,网络数据包体积小。
需要纠正一个误区,很多人认为 Redis 快是因为多线程,恰恰相反,核心业务逻辑单线程是高性能重要原因。单线程无法利用多核 CPU,部署的时候一台机器可以部署多个 Redis 实例,利用多核。
# 简单测试redis qps
redis-benchmark -n 100000
同时要说明 Redis 性能不是无限高,大 key、慢命令会阻塞主线程,性能急剧下降。持久化 AOF 刷盘策略配置不当也会影响性能。Redis 高性能是内存 + IO 多路复用 + 单线程无锁 + 高效数据结构共同带来,不是单一因素。Redis6 的 IO 多线程,只是把网络数据包读写交给多线程,真正执行命令逻辑依旧单线程,锁竞争问题依然不存在。
如果 Redis 中存在大量已过期的 Key,应该如何安全高效地删除?直接使用 del 或 keys 命令会有什么问题?
面试关键点需要掌握 Redis 过期 key 三种删除策略,区分 keys、del、scan、unlink,惰性删除、定期删除,生产环境清理大量过期 key 的实操方案,面试加分点讲清大 key 删除阻塞问题,unlink 后台释放内存,避免主线程阻塞。记忆法采用策略分类记忆法:惰性删除、定期删除、主动清理;风险命令记忆法:keys 全局遍历阻塞,del 删除大 key 阻塞主线程。
Redis 对于过期 key 默认有两套过期回收机制,惰性删除和定期删除。惰性删除,key 被访问的时候才判断是否过期,如果过期直接删除。缺点,如果 key 过期之后永远不再被访问,就会一直驻留在内存,占用内存空间。定期删除,Redis 主线程每隔一段时间,随机抽取一部分设置了过期时间的 key,检查过期状态并删除。定期删除不会扫描全部 key,只是抽样检测,因此依然会遗留大量过期 key 残留在内存。当大量 key 过期,仅仅依靠 Redis 内部自动机制,不能快速释放内存,需要业务主动安全清理。
keys 命令会遍历 Redis 全部 key,匹配模式,属于 O (n) 操作。Redis 主线程单线程执行,执行 keys 期间,所有其他命令全部阻塞,线上高并发环境执行 keys 会造成整个 Redis 服务不可用,业务大量超时,生产环境严禁使用 keys。
del 命令,删除普通小 key 速度很快;如果目标是大 key,例如包含几万元素的 hash、list、set,del 会在主线程同步释放内存,大量元素循环回收,主线程阻塞,造成业务请求卡顿。
安全高效清理大量过期 key 的多种方案。第一使用 scan 迭代遍历,scan 不会阻塞 Redis,游标迭代分批获取 key,每次取一小批,业务程序遍历,判断 TTL,对已经过期的 key 执行 unlink。unlink 和 del 接口用法一致,区别在于 unlink 会把内存释放操作丢到后台子线程执行,主线程只做元数据删除,真正回收内存异步执行,不会阻塞主线程,适合删除大 key。scan+unlink 是线上最常用的主动清理方案。scan 不能一次性拿到全部 key,存在迭代过程中新产生的数据,适合后台定时任务慢慢清理。
第二,配置 maxmemory‑policy 淘汰策略。当内存达到 maxmemory 上限,触发淘汰策略,例如 volatile‑ttl,优先淘汰设置过期时间、剩余 ttl 最小的 key,被动清理过期 key,适合允许部分 key 被淘汰的缓存业务,不适合存储业务。
第三,Redis4.0 以上开启 lazy‑free 相关配置,lazyfree‑lazy‑expire,过期 key 过期之后后台线程回收内存,减少主线程压力。
第四,业务层面规避,设置合理过期时间,避免一次性大批量写入相同过期时间的 key,防止缓存雪崩,大量 key 同一时刻集中过期,加重定期删除压力。
第五,如果是可以接受短暂停的离线维护场景,可以使用 redis‑cli --scan 配合管道批量 unlink,不要使用 keys。
# 危险,禁止线上执行
keys "prefix:*"
# 安全,scan迭代,配合unlink删除过期key
redis-cli --scan --pattern "prefix:*" | xargs -L1 redis-cli unlink
面试作答要区分,scan 只是迭代找出 key,不会删除;unlink 解决大 key 删除阻塞,但是小 key 场景 unlink 和 del 差异不大。不能指望定期删除把全部过期 key 清理干净,抽样机制注定会残留过期 key。大量集中过期 key,会出现内存居高不下,即便 key 已经过期,内存不会立刻下降。另外不要使用 flushdb、flushall 线上清理,这两个命令也会阻塞,Redis4.0 支持 flushdb async 异步清空。
在浏览器中输入 URL 到页面最终展示,整个过程发生了什么?
面试关键点完整梳理从输入回车、DNS 解析、TCP 三次握手、HTTPS TLS 握手、HTTP 请求、服务器处理、返回响应、浏览器解析渲染、TCP 四次挥手整条链路,面试加分点区分重定向、缓存、DNS 缓存、强缓存协商缓存、浏览器渲染流水线。记忆法采用流程链条记忆法:输入 url→DNS 解析→TCP 连接→TLS 握手→HTTP 请求→服务处理→返回报文→浏览器解析渲染→页面展示。
用户在浏览器地址栏输入 URL 按下回车,浏览器首先判断输入内容,如果是域名就走网络请求,如果是搜索关键词直接跳转搜索引擎。浏览器先查看自身缓存,浏览器 DNS 缓存,操作系统 hosts 文件,操作系统 DNS 缓存,没有命中就向 DNS 服务器发起域名解析。DNS 解析将域名转换为服务器 IP 地址,中间经过本地 DNS、根域名服务器、顶级域名服务器、权威域名服务器,拿到目标服务器 IP。
拿到 IP 之后浏览器发起 TCP 连接,TCP 三次握手建立连接。第一次客户端发送 SYN 报文;第二次服务端回复 SYN+ACK;第三次客户端回复 ACK,TCP 连接建立。如果是 HTTPS,TCP 连接完成之后执行 TLS 握手,协商加密套件,交换证书,校验证书合法性,协商会话密钥,后续所有 HTTP 数据全部加密传输。
浏览器组装 HTTP 请求报文,包含请求行、请求头、Cookie、请求体,通过已经建立好的 TCP 通道发送 HTTP 请求。请求到达服务器,中间可能经过 Nginx 反向代理、负载均衡,转发到后端应用服务。后端服务执行业务逻辑,查询数据库,拼装页面数据,构造 HTTP 响应报文返回,响应包含状态码、响应头、cookie、HTML 文档内容。
数据通过网络回传到浏览器,浏览器接收 HTTP 响应,首先处理 HTTP 缓存相关响应头,判断是否命中强缓存、协商缓存。如果返回 3xx 重定向,浏览器会重新发起新的 URL 请求。拿到 HTML 文本之后开始解析渲染流程。浏览器解析 HTML 构建 DOM 树,解析 CSS 构建 CSSOM 树,DOM 和 CSSOM 合并生成渲染树。渲染树只包含页面需要渲染的节点,隐藏元素不会加入渲染树。之后进行布局 Layout,计算每个元素在视口中的位置大小;再进行绘制 Paint,把像素绘制到图层;部分浏览器会做合成 Composite,把多个图层合并输出到屏幕,页面展示给用户。
页面展示之后,HTML 中外部资源,js、css、图片会继续发起网络请求加载。JS 脚本会阻塞 DOM 解析,浏览器遇到 script 标签会暂停解析 DOM,下载执行 JS,JS 可以修改 DOM 和 CSSOM。页面加载完成之后,关闭 TCP 连接执行四次挥手。
整个链路中还有很多细节,浏览器会维护连接池,HTTP/1.1 开启 keep‑alive,连接复用,避免频繁三次握手四次挥手;HTTP2 多路复用,同一个连接并发多个请求。浏览器也会做预解析、预连接优化。
#简单HTTP请求报文示例
GET /index.html HTTP/1.1
Host: www.example.com
User‑Agent: Mozilla/5.0
Cookie: xxx=xxx
面试高频坑,很多人只讲网络部分,忽略浏览器渲染 DOM、CSSOM、布局绘制合成阶段。区分 DNS 查询各级服务器,HTTPS 是 TLS 在 TCP 之上,不是 HTTP 之上新协议。JS 会阻塞解析,defer、async 属性可以改变 JS 加载执行时机。
HTTP 协议常见的状态码有哪些?分别代表什么含义?
面试关键点掌握五大分类 1xx~5xx,高频状态码 200、206、301、302、304、400、401、403、404、405、413、429、500、502、503、504,区分 301 永久重定向和 302 临时重定向,304 协商缓存,面试加分点区分 502 和 503、504 的差异。记忆法分类记忆法:1 信息,2 成功,3 重定向,4 客户端错误,5 服务端错误。
HTTP 状态码分为 5 大类,1xx 属于信息提示,2xx 请求成功,3xx 重定向,4xx 客户端请求错误,5xx 服务端错误。
1xx 信息性状态码,服务端收到请求,还需要客户端继续操作。100 Continue,客户端可以继续发送请求体,大文件上传场景使用;101 Switching Protocols,协议切换,例如 HTTP 升级为 WebSocket。
2xx 请求成功类。200 OK,请求正常处理完成,返回对应数据。201 Created,资源创建成功,POST 新建资源返回。204 No Content,处理成功,没有返回响应体,删除接口常用。206 Partial Content,范围请求,断点续传,返回部分资源。
3xx 重定向,资源位置发生变化,客户端需要重新发起请求。301 Moved Permanently,永久重定向,资源永久迁移,浏览器会缓存重定向地址。302 Found 临时重定向,资源临时变动,不缓存跳转地址。303 See Other,使用 GET 访问新地址。304 Not Modified,协商缓存命中,资源没有修改,服务端不返回资源实体,客户端直接读取本地缓存。307 临时重定向,保持请求方法,POST 请求依旧 POST 跳转,302 早期会把 POST 转为 GET。
4xx 客户端错误,请求报文存在问题。400 Bad Request,请求报文语法错误,参数格式错误。401 Unauthorized,未认证,缺少身份凭证,需要登录。403 Forbidden,权限不足,服务器理解请求,拒绝访问资源。404 Not Found,访问资源不存在,URL 错误。405 Method Not Allowed,请求方法不允许,接口只支持 GET,客户端发送 POST。406 Not Acceptable,返回内容无法匹配客户端 Accept 头。413 Payload Too Large,请求体过大。429 Too Many Requests,请求频率超限,限流返回。
5xx 服务端内部错误。500 Internal Server Error,服务器执行发生未知异常,业务代码报错。502 Bad Gateway,网关错误,网关 / 反向代理收到后端服务无效响应,后端服务崩溃、进程挂掉。503 Service Unavailable,服务不可用,服务器过载、停机维护,暂时无法处理请求,可以稍后重试。504 Gateway Timeout,网关超时,代理等待后端服务响应超时,后端处理时间过长没有返回结果。
| 分类 | 状态码 | 含义 |
|---|---|---|
| 1xx | 100/101 | 信息,继续请求 / 切换协议 |
| 2xx | 200/201/204/206 | 成功、创建成功、无内容、断点续传 |
| 3xx | 301/302/304 | 永久重定向、临时重定向、缓存命中 |
| 4xx | 400/401/403/404/405/429 | 参数错、未登录、无权限、资源不存在、方法错误、限流 |
| 5xx | 500/502/503/504 | 服务异常、网关坏响应、服务不可用、网关超时 |
面试高频坑,区分 502 后端挂了;503 服务过载维护;504 后端处理超时。304 不是报错,是协商缓存命中。401 是没有登录凭证,403 是登录了但是没有访问权限。
请简单介绍一下 HTTP 协议的基本概念和主要特点。
面试关键点,HTTP 超文本传输协议,应用层协议,基于 TCP,无连接、无状态,请求‑响应模型,报文结构,面试加分点讲 HTTP/1.1、HTTP2、HTTP3 关键演进,无状态带来的问题由 Cookie、Session、Token 解决。记忆法核心特征记忆法:应用层、请求响应、无连接、无状态、基于 TCP;报文记忆法:请求行、请求头、请求体;响应行、响应头、响应体。
HTTP 全称超文本传输协议,属于 OSI 七层模型当中的应用层协议,用于客户端和服务端之间传输超文本数据,网页、图片、接口数据都依靠 HTTP 传输。HTTP 默认基于 TCP 协议,HTTP3 基于 UDP。通信模式是请求‑响应模式,客户端发起请求,服务端返回响应,一问一答。
HTTP 报文分为请求报文和响应报文。请求报文包含请求行、请求头、请求体;请求行存放请求方法、URL、HTTP 版本;请求头存放各种元数据,User‑Agent、Cookie、Content‑Type;请求体存放 POST 提交的数据。响应报文包含响应行、响应头、响应体;响应行存放状态码、版本;响应头返回缓存、cookie、编码信息;响应体返回 HTML、JSON 业务数据。
HTTP 主要特点。第一无连接,HTTP/1.0 默认每次请求建立 TCP 连接,请求完成断开连接。HTTP/1.1 引入 keep‑alive 长连接,一个 TCP 连接可以传输多次 HTTP 请求响应,连接复用。无连接指 HTTP 协议本身不维护会话连接,不等于 TCP 连接。
第二无状态。协议本身不会记住客户端的身份信息,每一次请求互相独立,服务器无法区分两次请求来自同一个用户。服务端不会保存上一次请求的上下文。无状态简化服务器设计,但是业务需要会话,于是引入 Cookie、Session、Token 机制,客户端携带标识,弥补无状态缺陷。
第三灵活可扩展。可以通过请求头响应头扩展自定义字段,支持多种数据类型,文本、JSON、图片、二进制,Content‑Type 标记数据格式。
第四基于 B/S 模式,浏览器客户端和服务端通信。
HTTP 各个版本演进,HTTP/1.0 短连接,性能差;HTTP/1.1 支持 keep‑alive 长连接,管线化请求,增加缓存头,是长期广泛使用版本;HTTP2 二进制帧,多路复用,头部压缩,服务端推送,同一个 TCP 连接并发多个请求,解决 1.1 队头阻塞;HTTP3 基于 QUIC (UDP),彻底解决 TCP 队头阻塞,0‑RTT 握手,弱网环境表现更好。
HTTP 本身传输明文,不安全,HTTPS 在 TCP 与 HTTP 中间增加 TLS 加密层,数据加密传输,防止窃听篡改。
#http请求报文示例
POST /api/login HTTP/1.1
Host: demo.com
Content‑Type: application/json
{"username":"test","password":"123456"}
面试作答要区分无状态和长连接概念,很多面试者混淆两者。keep‑alive 是 TCP 层面连接复用,没有改变 HTTP 无状态特性。无状态只是协议不记忆用户,业务依靠 Cookie、Token 实现会话跟踪。HTTP 是应用层协议,依赖下层传输层 TCP/UDP。
TCP 和 UDP 有什么区别?分别适用于哪些场景?
面试关键点,掌握连接特性、可靠性、报文、拥塞控制、流量控制,对比表格,区分 TCP 三次握手四次挥手,UDP 无连接,面试加分点讲队头阻塞,QUIC 基于 UDP 实现可靠传输。记忆法对比记忆法:TCP 面向连接可靠;UDP 无连接不可靠;场景记忆法:TCP 文件传输网页;UDP 直播、语音、DNS。
TCP 是面向连接的可靠传输协议,UDP 是无连接不可靠传输协议,二者都属于传输层协议。
连接特性,TCP 通信之前必须建立连接,三次握手;通信结束四次挥手释放连接,通信双方保存连接状态。UDP 不需要建立连接,发送方直接发送数据包,没有握手挥手,不需要维护连接状态,服务端可以接收大量客户端,资源开销小。
可靠性,TCP 提供可靠传输,通过序列号、确认应答、超时重传保证数据不丢失;滑动窗口流量控制,防止发送方发送太快接收方处理不过来;拥塞控制,根据网络状况调整发送速率,避免网络拥塞;数据按序到达,乱序报文会重新排序。UDP 不保证可靠,没有重传、确认机制,数据包可能丢失、乱序、重复,交付上层应用,可靠性交给业务层处理。
报文结构,TCP 报文头复杂,20 字节起步,有序号、确认号、窗口、标志位;UDP 头部非常简短,只有 8 字节,开销很小。
流量与拥塞控制,TCP 自带流量控制、拥塞控制;UDP 没有任何控制机制,应用想发多少就发多少,网络差的时候大量丢包。
数据边界,TCP 是字节流,没有数据包边界,流模式,应用层需要自己处理报文边界;UDP 是数据报,保留报文边界,一次发送一个完整 UDP 报文。
性能开销,TCP 头部大,握手挥手,重传逻辑,CPU 开销大;UDP 头部极简,没有复杂逻辑,延迟低,开销小。
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接,三次握手四次挥手 | 无连接,无需握手 |
| 可靠性 | 可靠传输,不丢包、有序 | 不可靠,丢包乱序可能发生 |
| 控制机制 | 流量控制、拥塞控制 | 无流量拥塞控制 |
| 报文模式 | 字节流 | 数据报 |
| 头部开销 | 大 | 很小 8 字节 |
| 延迟 | 较高 | 低延迟 |
TCP 适用场景,对数据完整性要求高,允许一定延迟。HTTP、HTTPS、数据库访问、文件下载、接口调用,转账业务,需要保证数据完整送达,不能丢数据。
UDP 适用场景,允许少量丢包,追求低延迟。音视频通话、直播、游戏实时对战、DNS 查询。视频直播丢少量数据包只会轻微花屏,不需要重传;重传反而带来更大延迟。QUIC 协议 (HTTP3) 基于 UDP,在 UDP 之上自己实现一套可靠传输,兼顾 UDP 低延迟和 TCP 的可靠性。
面试高频坑,UDP 不可靠不等于 UDP 不能实现可靠,业务层可以在 UDP 之上自己做序号、重传,实现可靠 UDP。TCP 的字节流没有边界,业务开发很容易出现粘包拆包问题。TCP 适合准确性优先;UDP 适合速度优先。很多人误以为 UDP 一定比 TCP 快,网络质量良好时差距不大,弱网环境两者差异明显。
请描述 TCP 的三次握手和四次挥手过程,并介绍计算机网络的分层模型(如 OSI 或 TCP/IP 模型)。
面试关键点掌握三次握手、四次挥手每个报文标志位 SYN、ACK、FIN,区分握手和挥手次数差异根源是 TCP 是全双工;熟练区分 OSI 七层模型与 TCP/IP 四层模型,每层职责。面试加分点说明为什么握手三次而不是两次,为什么挥手需要四次。记忆法事件记忆法:握手 SYN‑SYN+ACK‑ACK;挥手 FIN‑ACK‑FIN‑ACK;分层记忆法,OSI 从上层到下层:应用、表示、会话、传输、网络、数据链路、物理。
TCP 是面向连接的全双工传输协议,通信开始执行三次握手建立连接,通信结束执行四次挥手释放连接。
三次握手。客户端主动发起连接。第一次握手,客户端发送 SYN 报文,序列号 seq=x,客户端进入 SYN‑SENT 状态,告知服务端我要建立连接。第二次握手,服务端收到 SYN,回复 SYN+ACK 报文,ACK 确认号 ack=x+1,同时服务端自身序列号 seq=y,服务端进入 SYN‑RCVD 状态。ACK 确认是对客户端 SYN 的确认,SYN 代表服务端也要建立连接。第三次握手,客户端收到 SYN+ACK,回复 ACK 报文,ack=y+1,客户端和服务端双双进入 ESTABLISHED 连接已建立状态,握手完成,可以传输业务数据。 握手只需要三次,原因是验证双方收发能力都正常。两次握手无法阻止历史过期无效连接报文建立连接,会造成资源浪费。
四次挥手,TCP 全双工,双方可以独立关闭自己方向的数据发送,一个方向关闭,另一个方向还可以继续传数据,因此需要四次。假设客户端主动关闭。第一次挥手,客户端发送 FIN 报文,seq=u,客户端进入 FIN‑WAIT‑1,表示客户端不再发送数据。第二次挥手,服务端收到 FIN,回复 ACK,ack=u+1,服务端进入 CLOSE‑WAIT,此时客户端到服务端方向通道关闭,但是服务端依然可以向客户端发送数据。第三次挥手,服务端把剩余数据全部发送完毕之后,发送 FIN 报文,seq=v,服务端进入 LAST‑ACK。第四次挥手,客户端收到 FIN,回复 ACK,ack=v+1,客户端进入 TIME‑WAIT,等待 2MSLS 之后关闭;服务端收到 ACK 直接进入 CLOSED。TIME‑WAIT 等待是为了保证最后 ACK 报文丢失时可以重传,处理残留网络报文。
OSI 七层网络模型从上至下:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。 应用层:面向应用程序,HTTP、FTP、DNS,业务数据; 表示层:数据编解码、加密解密,数据格式转换; 会话层:建立维护会话; 传输层:端到端传输,TCP、UDP; 网络层:路由寻址,IP 协议,跨网段转发; 数据链路层:帧封装,MAC 地址,相邻节点传输; 物理层:比特流,网线、光信号,硬件信号。
TCP/IP 四层模型,工程实际使用,对 OSI 做合并:应用层、传输层、网络层、网络接口层。应用层合并 OSI 的应用、表示、会话三层;传输层 TCP/UDP;网络层 IP;网络接口层对应数据链路层 + 物理层。
| OSI 七层 | TCP/IP 四层 | 典型协议 |
|---|---|---|
| 应用层、表示层、会话层 | 应用层 | HTTP、DNS、FTP |
| 传输层 | 传输层 | TCP、UDP |
| 网络层 | 网络层 | IP、ICMP |
| 数据链路层、物理层 | 网络接口层 | 以太网协议 |
面试高频坑,四次挥手不能合并成三次,因为收到客户端 FIN 之后,服务端可能还有业务数据需要发送,ACK 应答和 FIN 不能合并。TIME‑WAIT 是主动关闭方状态,不是被动关闭方。
请介绍一下 TCP 的拥塞控制机制。
面试关键点掌握四个核心阶段:慢启动、拥塞避免、快重传、快恢复;分清拥塞窗口 cwnd,接收窗口 rwnd。面试加分点区分流量控制和拥塞控制,流量控制解决收发双方速率匹配,拥塞控制解决整个网络链路压力。记忆法阶段记忆法:慢启动指数增长;拥塞避免线性增长;丢包触发快重传;快恢复调整窗口。
TCP 拥塞控制目的,防止发送方发送数据包过多,超出网络链路承载能力,造成网络拥塞,大量丢包、延迟升高。流量控制是接收方告诉发送方自己缓冲区剩余大小 rwnd,防止发送方把接收方缓冲区打满;拥塞窗口 cwnd,代表网络层面允许发送的数据量,发送方实际发送窗口取 min (rwnd,cwnd)。拥塞控制有四个核心算法。
慢启动。连接刚建立初期,拥塞窗口 cwnd 很小。每收到一个 ACK 确认,cwnd += MSS,窗口指数增长。RTT 一轮,窗口翻倍。慢启动不是慢,而是窗口从小到大慢慢扩大。慢启动有慢启动阈值 ssthresh,当 cwnd 达到 ssthresh,退出慢启动阶段,进入拥塞避免。
拥塞避免。cwnd 不再指数暴涨,每经过一个 RTT 往返时间,cwnd 增加一个 MSS,窗口线性缓慢上涨。随着窗口不断增大,网络出现拥塞,发生丢包。丢包有两种检测方式:超时重传,或者收到三次重复 ACK。
快重传。接收方收到失序报文,立刻发送重复 ACK。发送方连续收到 3 个相同重复 ACK,判定发生报文丢失,不等超时计时器到期,直接重传丢失报文,这就是快重传。避免超时等待带来的长时间卡顿。
快恢复。收到三次重复 ACK,进入快恢复算法。ssthresh 设置为 cwnd/2;cwnd = ssthresh;之后进入拥塞避免,不再执行慢启动。如果是超时重传判定拥塞,说明网络情况严重,ssthresh = cwnd/2,cwnd 重置为 1,重新走慢启动。
简单区分两种丢包处理。三次重复 ACK,属于轻度拥塞,执行快恢复,窗口减半;超时重传代表网络严重拥塞,窗口直接降到 1,重新慢启动。
MSS:最大报文段,TCP单次最大数据字节
cwnd:拥塞窗口
rwnd:接收窗口,来自接收方通告
ssthresh:慢启动阈值
举一个简单流程例子:连接建立,cwnd=1,ssthresh=16。慢启动 cwnd 指数增长,1‑2‑4‑8‑16,到达 ssthresh,切换拥塞避免,每轮 RTT+1,17、18。此时网络出现丢包,收到三次重复 ACK,ssthresh=18/2=9,cwnd=9,进入拥塞避免继续缓慢上涨。
面试高频坑,流量控制 rwnd 保护接收端;拥塞控制 cwnd 保护整个网络链路。慢启动是指数增长,不是线性增长。超时重传和三次冗余 ACK 对应两套不同处理逻辑。现代 Linux 还有 BBR 拥塞算法,不再单纯依靠丢包判断拥塞,依靠带宽和延迟评估,适合高带宽网络。
HTTPS 为什么能保证通信安全?
面试关键点 HTTPS = HTTP + TLS/SSL,安全能力来自 TLS 层,核心解决窃听、篡改、冒充三大风险;掌握非对称加密、对称加密、数字证书、数字签名作用。面试加分点讲清楚证书 CA 的作用,中间人攻击如何被证书机制防御。记忆法风险记忆法:防窃听(加密)、防篡改(摘要)、防冒充(证书签名校验)。
HTTP 传输全部明文,网络链路中间网关、路由器都可以抓取报文,会面临三类安全威胁:窃听,第三方窃取通信内容;篡改,中间人修改传输报文;冒充,伪装成服务器欺骗客户端。HTTPS 在 HTTP 与 TCP 中间增加 TLS 安全层,通过一整套密码学机制抵御这三类风险。
第一防窃听:传输数据加密。握手阶段协商会话密钥,后续业务报文全部对称加密传输。即使网络数据包被抓包,没有密钥无法解密原始业务内容,第三方看不到明文。
第二防篡改:报文摘要校验。传输报文计算哈希摘要,随同加密数据一起发送。接收端解密之后重新计算摘要,对比,如果报文中途被篡改,摘要值不一致,直接丢弃报文。防止中间人修改请求响应数据。
第三防身份冒充:数字证书 + CA 签名,验证服务器真实身份。服务器会把 CA 颁发的数字证书发送给客户端。证书包含服务器公钥、域名信息、CA 机构对证书的数字签名。客户端本地预置各大 CA 根证书,使用根证书公钥校验服务器证书签名。如果证书被篡改、伪造,签名校验失败,浏览器直接告警拒绝访问。杜绝中间人冒充真实服务器。
完整 TLS 握手流程。服务端持有证书(包含服务端公钥,CA 签名)。客户端发起握手,协商加密套件;服务端返回数字证书;客户端校验证书合法性,确认服务器身份。客户端生成预主密钥,使用服务器公钥加密预主密钥发送给服务端。服务端使用自己私钥解密得到预主密钥。双方基于预主密钥衍生出会话对称密钥。之后所有 HTTP 数据全部使用这套对称密钥加密传输。
数字签名原理:CA 机构用自己私钥对服务器信息哈希摘要加密,生成签名放在证书内。客户端使用 CA 公钥解密签名,对比本地摘要,确认证书没有被篡改,服务器身份可信。
单纯使用非对称加密不能抵御中间人攻击。中间人拦截服务端公钥,替换成自己伪造公钥,客户端无感知。数字证书体系解决这个问题,公钥必须经过可信 CA 签名,客户端校验签名,中间人伪造证书无法通过校验。
面试高频坑,HTTPS 不是 HTTP 加上独立协议,是 TLS 封装 HTTP。HTTPS 不能保证业务逻辑安全,只保证传输链路安全,业务接口权限、参数校验仍然需要业务代码实现。证书只校验服务端身份,客户端身份默认不校验,双向 HTTPS 才校验客户端证书。
在 HTTPS 握手完成后,为什么后续的对话会改用对称加密?
面试关键点区分非对称加密、对称加密性能差异,非对称加密只用来交换密钥,业务数据全部对称加密。面试加分点讲非对称加密计算开销,大报文场景性能劣势。记忆法性能记忆法:非对称加密慢,适合小数据(密钥);对称加密快,适合大量业务报文。
HTTPS 握手阶段使用非对称加密算法,主要完成两件事,校验服务器证书身份,加密传输预主密钥。握手结束之后,所有业务 HTTP 报文全部使用对称加密,核心根源是两者性能差距巨大。
非对称加密,依靠公钥私钥数学配对,大数模幂运算,CPU 计算开销很高。加密解密速度慢,适合加密少量短小数据,不适合加密大体积业务报文。如果所有 HTTP 业务数据全部使用非对称加密,一次网页请求大量图片、HTML、接口报文,CPU 会被加密解密占满,QPS 急剧下降,性能完全不可接受。非对称加密适合加密几百字节的密钥,不适合 MB 级别的业务数据。
对称加密,同一个密钥用来加密和解密,算法运算简单,CPU 开销小,加密解密速度非常快,适合大批量数据流加密。AES 就是典型对称加密算法,可以高效处理网页、接口返回的大段数据。
但是对称加密存在密钥分发难题,通信双方如何安全拿到同一个密钥。网络直接传输密钥会被窃听。所以 TLS 握手巧妙结合两种加密:非对称加密只负责安全传递很短的预主密钥,不在业务阶段使用;双方拿到预主密钥之后,各自推导生成会话对称密钥,后续全部业务流量使用高性能对称加密。
一次完整逻辑:客户端生成预主密钥,用服务器公钥(非对称)加密发给服务端;服务端私钥解密拿到预主密钥。客户端与服务端根据预主密钥、随机数,各自计算出完全相同的会话密钥。之后 HTTP 请求响应全部用会话密钥做 AES 对称加密传输。
会话密钥是本次 TLS 会话临时生成,每次 HTTPS 连接都会生成全新会话密钥,即使某一次会话密钥泄露,不会影响历史或者未来其他连接。TLS 会话复用还可以省略部分握手步骤,进一步优化性能。
面试高频坑,不要混淆概念,不是公钥加密业务数据,公钥只加密密钥。非对称加密算法不是用来加密网页内容,只是用来安全交换密钥。
什么是编码和解码?请结合网络传输或音视频处理的场景简单说明。
面试关键点理解编码:原始信息转换为便于存储传输的二进制格式;解码:二进制还原原始信息;区分字符编码、音视频编解码,网络传输场景。面试加分点区分编码加密,两者完全不同,编码是格式转换,不需要密钥;加密是安全变换,需要密钥。记忆法方向记忆法:编码 原始→二进制;解码 二进制→原始;场景记忆法:字符编码、网络报文、音视频编解码。
编码,将原始的逻辑信息(文字、图像、声音)转换为二进制字节流,适配存储介质、网络传输通道。解码,接收方拿到二进制字节流,反向处理,还原出原始可读信息。编码和解码成对出现,使用同一套规则。编码目的是压缩体积、统一格式、适配硬件传输通道,编码不等于加密,编码不需要密钥,规则公开;加密目的是安全,需要密钥。
字符编码场景。原始中文文字,计算机底层只有二进制,需要编码。UTF‑8 编码,把汉字转换为多字节二进制;网络 HTTP 传输,请求体 JSON 中文,按照 UTF‑8 编码变成字节流在 TCP 网络传输。接收端拿到字节流,执行 UTF‑8 解码,还原成中文字符串。如果两端编码格式不一致,例如发送方 UTF‑8,接收方用 GBK 解码,就出现乱码。
网络传输报文场景。应用层业务数据,需要逐层封装编码。HTTP 报文,按照 HTTP 协议格式编码,加上请求行、请求头,交给传输层 TCP,TCP 增加 TCP 头部编码;网络层增加 IP 头部编码;链路层封装帧头部。每一层都执行编码封装,把上层数据作为载荷,增加本层协议头部。接收端反向逐层解码剥掉头部,向上交付原始 HTTP 报文。
音视频处理场景,原始采集的音视频数据体积巨大。摄像头采集原始 YUV 图像,麦克风采集 PCM 裸音频。原始裸数据体积巨大,无法网络直播传输。H.264/H.265 视频编码,对原始画面做帧内、帧间压缩,去除画面冗余信息,编码输出体积很小的码流,推送到流媒体服务器。播放器收到网络二进制码流,执行 H.264 解码,还原 YUV 原始图像,再渲染屏幕。音频 AAC 编码,把 PCM 原始音频编码压缩,播放器 AAC 解码还原音频。
举一个简单 Java 代码示例,字符串编码解码。
String text = "测试文字";
//编码:字符串转为字节数组
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
//解码:字节数组还原字符串
String decodeStr = new String(bytes, StandardCharsets.UTF_8);
直播场景,原始 1080P 裸 YUV 视频一秒几百 MB,编码 H264 之后压缩到几 MB 每秒,才可以互联网实时传输。如果没有编码,带宽完全无法承受。
面试高频坑,很多人混淆编码和加密。编码公开规则,目的存储传输;加密需要密钥,目的防泄露。乱码本质,编码和解码使用两套不同规则。音视频编解码,核心是压缩,去除冗余数据,降低带宽占用。
Cookie 和 Session 有什么区别?它们是如何配合工作的?
面试关键点分清 Cookie 存储在客户端,Session 存储在服务端,理解二者配合完整流程,区分生命周期、安全性、存储容量,面试加分点讲 JWT 与传统 Cookie‑Session 的差异,以及 Cookie 跨域、HttpOnly 属性。记忆法位置记忆法:Cookie 客户端;Session 服务端;联动记忆法:SessionId 存放于 Cookie 完成会话绑定。
Cookie 是 HTTP 协议规范,数据保存在浏览器客户端本地,由服务端通过 Set‑Cookie 响应头下发给浏览器,浏览器会把 Cookie 保存,后续同域名请求自动携带 Cookie 放到请求头 Cookie 字段发送给服务端。Cookie 有大小限制,单个 Cookie 一般 4KB,一个域名下 Cookie 数量有限。Cookie 可以设置过期时间,分为会话 Cookie(浏览器关闭就销毁)和持久 Cookie(写入磁盘,到指定时间失效)。Cookie 可以配置 HttpOnly,禁止 JS 读取,防止 XSS 窃取;Secure 只在 HTTPS 下传输;Domain、Path 控制携带 Cookie 的域名路径。
Session 属于服务端会话技术,数据保存在服务器内存、Redis 等存储介质。服务端为每个用户会话生成唯一字符串 SessionId,用户的会话数据存放在服务端,SessionId 用来标识哪一个客户端对应哪一份会话数据。服务端 Session 可以设置超时时间,超时之后销毁会话数据。
二者核心区别。存储位置,Cookie 存浏览器客户端;Session 存服务端。安全性,Cookie 存客户端,可以被篡改窃取;Session 数据在服务端,相对安全。容量,Cookie 容量很小;Session 可以存储大量会话数据。压力,Cookie 不占用服务器资源;大量用户会话会消耗服务器内存,如果多实例部署,原生内存 Session 存在会话共享问题。依赖关系,Session 的实现通常依赖 Cookie 存放 SessionId,当然也可以把 SessionId 放到 URL 参数实现,但是不推荐。
配合工作完整流程。用户第一次访问 Web 服务,浏览器没有任何 Cookie。服务端接收到请求,创建 Session 对象,生成唯一 SessionId。服务端通过 Set‑Cookie 响应头,把 SessionId 写入 Cookie 返回浏览器。浏览器保存该 Cookie。用户后续访问同一域名,浏览器自动携带这个 Cookie,把 SessionId 带给服务端。服务端拿到请求头中的 SessionId,根据这个 Id 找到服务端保存的 Session 会话对象,读取用户登录状态、用户信息。用户退出登录,服务端销毁 Session,同时通知浏览器清除 Cookie。
如果浏览器禁用 Cookie,传统模式下 Session 无法正常工作,此时需要把 SessionId 附加到 URL 后面传递,这种方式有安全风险,URL 会出现在日志。分布式环境下,单机内存 Session 无法多机器共享,需要把 Session 迁移到 Redis 实现会话共享。
//SpringBoot中使用session示例
@GetMapping("/login")
public void login(HttpServletRequest request){
HttpSession session = request.getSession();
session.setAttribute("userId",1001);
}
@GetMapping("/userInfo")
public Object info(HttpServletRequest request){
HttpSession session = request.getSession(false);
return session.getAttribute("userId");
}
面试高频坑,Session 不是 HTTP 协议标准,是 Web 框架实现的机制;Cookie 是 HTTP 标准。很多面试者误以为 Session 一定依赖 Cookie,实际上可以 URL 传 id,只是主流方案走 Cookie。Cookie 里面只存放 SessionId,不要存放敏感业务数据。
登录拦截器(权限拦截器)的实现原理是什么?
面试关键点分清 SpringMVC 拦截器 Interceptor 执行时机,和 Filter 过滤器的区别;完整登录校验流程,preHandle 返回 true 放行 false 拦截。面试加分点区分拦截器、过滤器、AOP 各自生效层级。记忆法流程记忆法:请求进入 DispatcherServlet 之后,Controller 执行之前执行 preHandle;返回 true 放行,false 拦截。
SpringMVC 拦截器 Interceptor,作用于 SpringMVC 框架内部,只拦截 Controller 控制器请求,静态资源默认不经过拦截器。拦截器包含三个核心方法。preHandle,在 Controller 执行之前调用;postHandle,Controller 执行完成,视图渲染之前执行;afterCompletion,整个请求处理完毕,视图渲染完成之后执行。登录权限拦截核心逻辑写在 preHandle 方法。
登录拦截器完整实现原理。客户端发起请求,请求经过 Tomcat,Filter 过滤器执行完成之后,进入 DispatcherServlet 前端控制器。DispatcherServlet 匹配请求找到对应的 Controller 处理器,在调用 Controller 方法之前,执行注册好的拦截器链,依次执行每个拦截器 preHandle。
登录拦截器 preHandle 内部逻辑。首先排除不需要拦截的路径,登录接口、注册接口、静态资源、验证码接口直接放行。其他接口从 request 中获取 Session 或者从 Token 解析用户身份信息。如果没有拿到登录身份,说明未登录,preHandle 返回 false,代表请求被拦截,不会进入 Controller,直接返回未登录 JSON 或者跳转登录页面。如果身份校验通过,preHandle 返回 true,请求继续向下流转执行 Controller 业务代码。
postHandle 可以用来对 Model 数据做加工;afterCompletion 可以做请求结束资源清理,记录日志。拦截器可以配置多个,形成拦截器链,按注册顺序执行 preHandle,逆序执行 afterCompletion。
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//放行登录接口
String uri = request.getRequestURI();
if(uri.contains("/login")){
return true;
}
//从session获取登录用户
Object loginUser = request.getSession().getAttribute("loginUser");
if(loginUser == null){
response.setContentType("application/json;charset=utf‑8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
return false;
}
return true;
}
}
之后需要将拦截器注册进 SpringMVC 配置。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor).addPathPatterns("/**");
}
}
需要区分 Filter 过滤器和 Interceptor 拦截器。Filter 属于 Servlet 规范,在 DispatcherServlet 之前执行,可以拦截所有请求包括静态资源;Interceptor 属于 SpringMVC 框架,DispatcherServlet 之后,Controller 之前。过滤器拿不到 Controller 处理器信息,适合编码、跨域这类操作;拦截器可以拿到处理器对象,适合登录、权限校验。拦截器不能拦截静态资源,如果静态资源需要鉴权,需要使用 Filter。
面试加分,前后端分离项目不再依赖 Session,请求头携带 Token,拦截器从 request header 拿到 token,解析校验 token,完成登录拦截。拦截器不能捕获 Controller 抛出的异常,异常会进入全局异常处理器。
在 Web 请求的处理流程中,如果需要在 HTTP 请求或响应的 Header 中增加自定义字段,通常应该在哪个环节(如过滤器、拦截器或网关)实现?为什么?
面试关键点理清 Servlet Filter、SpringMVC Interceptor、网关的执行先后顺序:Filter 在最外层,网关在应用服务最前面;Interceptor 在 DispatcherServlet 内部。面试加分点讲响应头修改时机问题,拦截器 postHandle 阶段 response 已经提交,修改 Header 失效。记忆法顺序记忆法:网关→Filter→DispatcherServlet→Interceptor→Controller;响应头修改越早越好。
请求 Header 增加自定义字段:网关、Filter 都可以实现。响应 Header 增加自定义字段,优先网关或者 Servlet Filter,不建议 SpringMVC 拦截器 Interceptor。
完整 Web 请求处理顺序。外部请求首先到达网关(Spring Cloud Gateway/Nginx),网关处理完成转发到后端 Tomcat。Tomcat 接收请求,执行 Servlet Filter 过滤器链;Filter 执行完毕,请求交给 DispatcherServlet;DispatcherServlet 执行 Interceptor 拦截器链 preHandle;执行 Controller;Controller 返回之后执行拦截器 postHandle;视图渲染;执行 afterCompletion;response 输出报文返回。
请求 Header 修改场景。网关可以在转发下游服务之前修改请求头,新增自定义 Header,下游服务直接读取。进入 Tomcat 之后,Filter 中可以包装 HttpServletRequest,装饰器模式,重新封装 request 对象,添加自定义请求头。Interceptor 拦截器不能修改原生 request 的 header,原生 HttpServletRequest 不支持直接 setHeader,想要修改请求头必须使用包装类,实现比较麻烦。
响应 Header 修改场景。响应头必须在 response 提交之前设置。response 一旦 commit,HTTP 头部已经输出到网络流,后续代码设置 header 完全无效。SpringMVC 拦截器 postHandle 执行时机是 Controller 执行完成,但视图渲染还没完成。很多场景 Controller 返回 JSON,消息转换器完成输出,response 已经提交,此时 postHandle 里面设置 response.setHeader 不会生效。afterCompletion 阶段 response 已经完全输出,修改 Header 彻底无效。所以拦截器不适合做响应 Header 新增。
Servlet Filter 可以在 doFilter 调用 chain.doFilter 前后处理。调用 chain.doFilter 之前,修改 request;chain.doFilter 返回之后,response 还未提交的时候,设置响应 Header。Filter 处于 Servlet 层,response 还没有输出,修改 Header 稳定有效。
如果项目有网关,最优方案放在网关层。网关作为流量入口,统一添加自定义请求、响应 Header,所有下游微服务不需要重复编码,统一处理,Nginx、SpringCloud Gateway 都支持添加 Header。适合鉴权透传、traceId 埋点这类全局 Header。
小结:全局统一 Header 优先网关;应用内部 Header 处理选择 Servlet Filter;尽量避免 SpringMVC 拦截器处理 Header。拦截器更适合权限校验、日志记录,不适合修改 HTTP 报文头部。
//Filter增加响应头示例
@Component
public class CustomHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse resp = (HttpServletResponse) servletResponse;
//在放行之前设置响应头
resp.setHeader("custom‑trace‑id","123456");
filterChain.doFilter(servletRequest,servletResponse);
}
}
面试高频坑,很多同学会在 Interceptor 的 postHandle 写 setHeader,测试有时候生效有时候失效。当返回 JSON,使用 @ResponseBody,消息转换器会直接把数据写入 response 输出流,response 提交,postHandle 里设置 header 就失效。视图渲染返回页面的场景 postHandle 有可能生效,行为不稳定,业务上不推荐。
操作系统中内存管理主要有哪些方式?
面试关键点区分物理内存、虚拟内存;分段、分页、段页式;内存分配算法;交换;内存映射。面试加分点讲分页中的页表、快表 TLB,缺页中断。记忆法分类记忆法:地址管理(分段、分页、段页式);内存分配;虚拟内存换入换出;内存映射 mmap。
操作系统内存管理核心目标:高效利用物理内存,隔离进程地址空间,让进程使用比物理内存更大的地址空间。分为几个主要模块,地址空间管理、内存分配回收、虚拟内存置换、内存映射。
分段式内存管理。把进程逻辑地址空间按照程序逻辑模块划分为多个段,代码段、数据段、堆栈段。每个段拥有独立段号,段内偏移。每个段有段基址、段限长。CPU 通过段表,逻辑地址 = 段号 + 段偏移,映射物理内存地址。分段的好处符合程序逻辑,段之间相互隔离;缺点段长度大小不一,内存产生外部碎片,内存利用率下降。
分页式内存管理。把物理内存切分成固定大小页框,进程逻辑地址切分成同样大小页面。页面大小固定,常见 4KB。操作系统维护页表,保存页面到物理页框映射关系。逻辑地址分为页号 + 页内偏移。CPU 拿到页号查询页表,拿到物理页框号,拼接页内偏移得到真实物理地址。分页不会产生外部碎片,只会产生页内少量内部碎片。页表存在内存,每次访问内存需要两次内存访问,一次查页表,一次访问数据,性能低下。CPU 加入 TLB 快表,缓存页表项,加速地址转换。页面不在物理内存触发缺页中断,操作系统加载磁盘页面到内存。
段页式管理。结合分段与分页。程序先按逻辑分为段,每个段内部再分页。逻辑地址分为段号、页号、页内偏移。兼顾分段逻辑隔离优势,同时拥有分页无外部碎片的优点,现代操作系统大多采用段页式。
内存分配算法。操作系统为进程分配物理内存,有多种经典算法。首次适应,从内存头部遍历,找到第一个足够大空闲块分配;循环首次适应,从上一次分配位置向后查找;最佳适应,挑选最小能满足需求空闲块;最坏适应,挑选最大空闲块分割。不同算法碎片表现不同。
虚拟内存与交换 Swap。进程逻辑地址空间远大于物理内存,部分页面放在磁盘 Swap 分区。物理内存紧张时,操作系统把暂时不使用的页面换出到磁盘 Swap;进程访问不在内存页面,触发缺页中断,将页面换入内存。页面置换算法,FIFO、LRU 最近最少使用,选择哪些页面换出。Linux 操作系统还有页缓存机制,磁盘文件缓存到内存。
内存映射 mmap。把磁盘文件映射到进程虚拟地址空间,进程操作内存等价读写文件,减少用户态内核态拷贝,提升 IO 性能。
| 内存管理方式 | 优点 | 缺点 |
|---|---|---|
| 分段 | 逻辑隔离,段保护 | 外部碎片 |
| 分页 | 无外部碎片,管理简单 | 页表占用内存,内部碎片 |
| 段页式 | 兼顾逻辑与内存效率 | 管理开销大 |
面试高频坑,虚拟内存不是磁盘充当内存,是逻辑地址映射机制。Swap 交换分区性能很低,大量 swap 会造成系统卡顿。TLB 快表缺失会带来性能下降。Linux 中 malloc 分配内存不一定立刻分配物理内存,只是分配虚拟地址,写的时候触发缺页才分配物理内存。
什么是幂等性?在消息消费或接口调用的场景中,如何保证幂等?
面试关键点幂等定义:同一个请求多次执行,产生效果和执行一次完全一样。区分接口调用、MQ 消息消费两大场景,多种实现方案,各方案优缺点。面试加分点区分重试产生重复,业务天然不幂等场景,例如扣款。记忆法概念记忆法:多次调用结果等价一次调用;方案记忆法:唯一标识、数据库唯一索引、状态机、token、业务锁。
幂等性指,同一个业务请求,重复提交、重复调用多次,系统执行之后产生的业务效果,与只执行一次完全一致。不会因为重复请求造成数据错乱、重复扣款、重复创建订单。网络抖动、超时重试、MQ 消息重复投递,都会带来重复请求,生产环境必须做幂等。很多业务操作天然不幂等,例如余额扣减,执行两次扣两次钱,必须人工实现幂等。
接口调用场景,HTTP 接口、RPC 接口,客户端超时,客户端重试,导致同一请求多次送达服务端。消息消费场景,MQ 至少一次投递,消息可能重复消费,一条消息多次投递到消费者。
保证幂等的主流方案。
第一,全局唯一业务标识。业务生成全局唯一业务 ID,客户端请求携带该 id;MQ 消息把业务 id 放到消息体。处理业务之前,查询该业务 ID 是否已经处理。已经处理直接返回成功,不再执行业务;未处理执行业务逻辑。可以使用 Redis 记录已经处理完成的 id;或者数据库建立一张处理记录表,存储业务 id。缺点需要维护一张记录表,存储压力大。
第二,数据库唯一索引。利用数据库唯一约束,业务唯一字段建立唯一索引。重复插入相同数据,数据库抛出唯一冲突异常,业务捕获异常视为已经处理成功。适合新增类业务,创建订单,创建流水。注意只适合插入场景,更新场景不适用。
第三,数据库状态机。业务数据有明确状态流转。例如订单状态:待支付、已支付、已取消。执行支付业务,更新条件带上状态判断,update order set status='已支付' where id=xxx and status='待支付'。只有原始状态匹配才执行更新,受影响行数为 0 代表已经处理,直接跳过。状态机适合更新业务,数据库行锁保证原子性,不需要额外记录表,业务常用。
第四,Token 令牌机制,适合前端接口。请求业务之前,先获取唯一 Token;提交业务请求带上 Token;服务端校验 Token 并且删除 Token。Token 只能使用一次,重复请求 Token 已经失效,拒绝执行业务。适合前端提交表单,防止重复提交。
第五,乐观锁。版本号 version,更新携带版本条件 update table set num=num‑1,version=version+1 where id=xx and version=1。更新返回 0,代表已经处理,放弃业务逻辑。适合并发更新场景。
第六,Redis 防重。业务 ID 存入 Redis,设置过期时间。处理前判断 key 是否存在,存在直接返回;不存在执行业务,写入 Redis 标记。注意 Redis 宕机丢失数据风险,适合允许少量重复的业务,不能用于资金类强一致性场景。
//状态机幂等示例,消费订单支付消息
@Transactional
public void consumePayMsg(Long orderId){
//条件更新,只有待支付才执行
int rows = orderMapper.updateStatus(orderId,OrderStatus.PAY_WAIT,OrderStatus.PAY_SUCCESS);
if(rows == 0){
//已经处理,直接返回,不执行业务
return;
}
//执行后续业务逻辑,发通知、扣库存
}
面试高频坑,MQ 消息不能依靠 offset,重启消费者 offset 回退依然重复消费,必须业务层幂等。幂不等同事务,幂等解决重复执行问题,事务解决原子性问题。资金类业务优先数据库方案,不要单纯依赖 Redis,Redis 丢失会造成重复业务。很多同学会混淆防重复和锁,幂等允许重复请求到达,只是不重复执行业务;锁是阻止并发同时执行。
在用户支付场景中,用户第一次支付后因网络波动后端未收到支付通知,前端显示支付失败;此时用户再次发起支付,如何保证不会重复支付?
面试关键点要完整还原业务场景:用户实际已经扣款,但是我方业务系统没有收到支付回调,前端提示失败,用户二次点击支付;区分商户侧业务幂等、支付侧幂等、状态机、支付网关幂等号,区分用户重复提交和第三方回调重复。面试加分点区分两种风险,一是用户重复发起支付请求,二是第三方支付回调重复推送;同时讲资金安全,不能出现两笔扣款。记忆法记忆:业务幂等号 + 订单状态机 + 支付网关幂等能力 + 回调幂等校验。
该场景属于典型的分布式超时问题,真实资金已经在第三方支付完成,但是我方业务系统没有收到支付结果通知,前端展示支付失败,用户再次点击支付按钮。这里存在两类风险,第一,用户再次发起支付请求,调用支付网关,造成用户账户被二次扣款;第二,后续第三方支付回调延迟到达,我方业务需要正确处理回调,避免重复更新订单。整套方案需要客户端、业务服务、支付网关、数据库多层配合。
首先订单层使用订单状态机做基础防护。订单拥有明确状态:待支付、已支付、已取消。当订单状态已经是已支付,业务层直接拒绝发起新的支付请求。用户再次点击支付,接口首先查询订单状态,如果已经是已支付,直接返回提示 "该订单已完成支付",不再调用第三方支付接口。但该方案存在漏洞:网络波动回调还没有到达,数据库订单状态依旧是待支付,状态机拦截不会生效,此时用户再次发起支付,依然会调用支付网关,所以仅仅依靠本地订单状态机不足以解决问题。
第二,调用第三方支付网关时传递商户幂等号(商户订单号)。第三方支付平台普遍支持幂等能力,同一个商户订单号重复调用统一下单接口,支付网关不会生成第二笔真实扣款单,直接返回上一次已经生成的支付凭证,不会产生第二次扣款。商户订单号必须全局唯一,和业务订单一一绑定,同一个业务订单无论多少次触发统一下单,传给支付网关的商户订单号保持不变。第三方平台识别重复幂等号,不会创建第二笔支付流水,从支付侧拦截重复扣款。
第三,前端层面做交互防护。用户点击支付按钮之后,前端置灰按钮,防止短时间多次点击。但前端限制不可靠,用户可以绕过前端,直接调用接口,只能作为辅助手段,不能作为安全依赖。
第四,支付回调的幂等校验。无论同步回调还是异步通知,第三方可能重复推送支付结果。我方收到回调,先根据商户订单号查询订单状态,如果订单已经是已支付,直接返回回调成功,不再执行业务逻辑。只有订单处于待支付状态,才执行更新订单、扣库存等后续业务。更新 SQL 必须带上状态条件,update order set status='已支付',pay_time=now() where order_no=xxx and status='待支付',数据库行锁保证原子性,返回受影响行数为 0,代表已经处理完成。
第五,补偿机制处理网络异常。如果一直没有收到回调,需要定时轮询第三方支付查询接口,主动查询该订单真实支付状态,同步更新本地订单状态,解决回调丢失的问题。不能完全依赖第三方回调通知。
完整业务流程:用户下单生成订单,状态待支付;用户点击支付,使用该订单编号作为商户幂等号调用支付网关;网络波动,我方没有收到回调,订单状态不变,前端提示失败;用户再次点击支付,后端再次调用统一下单,传入同一个商户幂等号,支付网关返回上一笔的支付信息,不会新建扣款;后续回调到达,执行带状态条件更新;定时任务兜底查询支付状态。
同时要区分两种异常分支,一种是第三方真实扣款成功,我方未收到通知;另一种是第一笔支付并未扣款,用户二次支付正常执行。整套方案中,支付网关幂等号是防止用户重复扣款的关键,数据库状态机处理回调重复,定时查询做兜底补偿。资金业务绝对不能只依赖 Redis 做幂等,Redis 宕机、数据丢失会引发资损,核心逻辑必须下沉数据库。
//发起支付伪代码
public String createPay(Long orderId){
Order order = orderMapper.selectById(orderId);
if(!OrderStatus.WAIT_PAY.equals(order.getStatus())){
throw new BusinessException("订单状态不允许发起支付");
}
//使用业务订单号作为支付网关幂等号,多次调用不变
String merchantOrderNo = order.getOrderNo();
PayRequest req = new PayRequest();
req.setMerchantOrderNo(merchantOrderNo);
//调用第三方支付网关,同一个幂等号不会生成多笔扣款
PayResponse resp = payGateway.createOrder(req);
return resp.getPayUrl();
}
//支付回调处理伪代码
@Transactional
public void payCallback(String merchantOrderNo){
Order order = orderMapper.selectByOrderNo(merchantOrderNo);
//状态机条件更新,只有待支付才更新
int row = orderMapper.updatePayStatus(merchantOrderNo,OrderStatus.WAIT_PAY,OrderStatus.PAY_SUCCESS);
if(row == 0){
//已经处理,直接返回成功给第三方
return;
}
//执行业务:扣库存、发消息
}
面试高频坑,很多人只回答数据库状态机,忽略支付网关幂等号。当回调还没到达,数据库订单还处于待支付,本地状态机拦不住重复调用支付接口,如果没有网关幂等,会产生多笔扣款。前端按钮置灰只是用户体验优化,不能作为安全保障。定时轮询查询支付状态是必不可少的兜底,解决回调丢失网络故障。
如果面临大量用户同时提交订单的高并发场景,应该如何保证系统的处理效率?
面试关键点高并发下单的完整优化链路:前端限流、网关层、削峰队列、库存预扣、数据库优化、锁策略、缓存、分库分表、异步化;区分流量削峰和业务防超卖,分清同步和异步下单两种模式。面试加分点区分真实业务,秒杀场景和普通电商下单差异,同时点明各种方案带来的问题,比如异步下单带来订单状态查询复杂度。记忆法分层记忆:前端→网关→应用层→缓存→消息队列→数据库;核心思路:拦截无效流量、异步削峰、减少 DB 压力、锁粒度尽可能小。
大量用户同时提交订单,会瞬间产生巨大流量,如果全部请求直接打到数据库,数据库 CPU、连接数、IO 会打满,出现数据库卡顿、请求超时,系统整体雪崩。整体优化从流量入口逐层向下做控制,层层过滤无效流量,降低数据库压力。
前端层优化。页面按钮防重复提交,按钮置灰;页面静态化,商品页面走 CDN 缓存;前端做简单限流,短时间禁止重复提交。前端防护只能阻挡普通用户,攻击者可以绕过,仅作为辅助。
网关层限流与过滤。Nginx 或者 Spring Cloud Gateway 作为流量入口,实现限流,令牌桶、漏桶算法,限制每秒请求总量;过滤非法请求,拦截恶意爬虫请求;做灰度、熔断,异常流量直接拒绝,不转发到后端业务服务。网关把无效流量挡在最外层,避免请求到达应用服务。
应用层,使用消息队列实现异步削峰,这是高并发下单最核心手段。大量并发请求不要同步直接操作数据库。用户提交下单请求,服务完成参数校验,库存预校验,把下单消息投递到 MQ,立刻返回提示 "订单正在处理中,请稍后查询订单状态",快速释放连接。消费者消费 MQ 消息,真正执行业务逻辑,创建订单、扣库存。消息队列起到削峰填谷,瞬间洪峰流量被队列缓存,消费者按照数据库可承受速率消费,保护数据库。异步下单带来用户体验变化,前端不能直接返回下单成功,需要轮询查询订单接口获取最终结果。
缓存优化。热点商品库存放入 Redis,先在 Redis 做库存预扣,拦截大部分库存不足的请求。Redis 判断库存不足直接返回,不用落到数据库。这里要注意 Redis 预扣库存和数据库库存一致性问题,需要补偿机制;同时处理 Redis 击穿穿透。不要把全部压力交给数据库判断库存。
锁策略优化,避免大锁。禁止使用 JVM 本地锁,分布式场景无效。不使用全局大锁,使用商品维度分布式锁 ,不同商品互不阻塞。数据库层面优先乐观锁,update stock set count=count‑1,version=version+1 where goods_id=xx and count>0 and version=xx;高并发下乐观锁大量版本冲突会重试,QPS 过高时,改用数据库行锁,select for update,锁住对应商品库存记录,锁粒度是单条记录,不会锁住整张表。
数据库层面优化。订单表做分库分表,按照用户 ID 或者订单号分片,单表数据量控制,降低单表索引压力。建立合适索引,订单查询字段建立索引,避免慢 SQL。数据库读写分离,订单查询走从库,创建订单写主库。事务尽可能缩小事务范围,事务里面不要执行远程调用,减少行锁持有时间。
业务裁剪与流量隔离。热点商品单独部署资源,和普通业务流量隔离;限制单个用户短时间下单频次,防止单用户刷流量;做好降级,非核心业务异步化,例如短信通知、积分发放、日志记录放到 MQ 异步处理,不要放在下单主流程。
还要处理超卖问题,Redis 预扣库存和 DB 库存双校验,防止 Redis 和数据库不一致导致超卖。同时考虑消息可靠性,MQ 消息不能丢失,做好消息重试、幂等,防止重复消费生成重复订单。
普通电商下单和秒杀场景存在区别,秒杀场景流量更大,会在 Redis 层过滤绝大多数无库存请求,只有少量请求进入 MQ 和数据库。
//数据库乐观锁扣库存示例
@Transactional
public boolean deductStock(Long goodsId,int num){
int affect = stockMapper.deduct(goodsId,num);
if(affect <= 0){
return false;
}
//创建订单
return true;
}
面试高频坑,消息队列只是削峰,不能提高数据库最大处理能力,只是把瞬间流量变成平缓流量。异步下单会带来业务复杂度提升,需要处理订单创建失败的场景,前端要轮询订单状态。Redis 预扣库存必须要有兜底,不能只依赖 Redis,防止 Redis 宕机造成超卖。不要使用全局锁,全局锁会变成串行,并发完全丧失。
高并发场景下,为什么 MySQL 插入数据会出现问题?
面试关键点从锁、索引、事务、IO、自增主键、行锁表锁、MVCC、binlog、redo/undo 日志、热点行冲突多角度分析;区分并发插入不同故障现象:慢、锁等待超时、死锁、CPU 飙升。面试加分点区分普通插入、insert ... select、唯一索引冲突、自增锁。记忆法原因归类:索引开销、锁冲突、日志刷盘压力、主键热点、事务过大、SQL 写法问题。
高并发大量插入数据,MySQL 会出现多种异常现象:插入响应变慢、锁等待超时、死锁、CPU 飙升、IO 打满、大量线程阻塞,吞吐量上不去。产生问题的原因可以分为锁机制、索引开销、日志刷盘、主键热点、事务、特殊 SQL 写法几大类。
第一,索引带来开销。一张表索引越多,插入代价越高。插入一条数据,不仅写入数据,还要更新所有 B + 树索引。每一个索引都要维护 B + 树叶子节点,页分裂。如果一张表有大量索引,并发插入,会频繁产生索引页分裂,磁盘随机 IO 暴涨,CPU 消耗升高,插入性能急剧下降。尤其是二级索引较多的订单表,高并发插入压力很大。
第二,自增主键的自增锁冲突。MySQL Innodb,自增主键会有自增锁。旧版本自增锁是表级锁,每一次插入都锁住整张表的自增,并发插入会串行,性能极差。新版本 InnoDB 优化自增锁,分为多种模式,批量插入会持有更大粒度自增锁。大量并发同时插入,会出现自增锁竞争,线程互相等待,出现锁等待。如果主键选择非自增,比如 UUID 作为主键,随机值会造成 B + 树叶子节点随机写入,大量页分裂,IO 压力巨大,插入性能暴跌。
第三,锁冲突、唯一索引冲突。并发插入,如果存在唯一索引,多条记录唯一键重复,会触发唯一索引锁等待。多个事务同时插入相同唯一字段,一个事务持有锁,其他事务阻塞等待,出现锁等待超时。另外 insert ... select 这种语句,会对源表加共享锁,高并发场景极易锁等待甚至死锁。
第四,Redo Log、Undo Log、Binlog 日志刷盘压力。InnoDB 每一次插入,需要写 undo 日志用于事务回滚,写 redo 日志保证崩溃恢复,事务提交刷 binlog。大量并发插入,产生海量日志。磁盘 IO 能力成为瓶颈。如果 innodb_flush_log_at_trx_commit=1,每次事务提交都刷盘,大量并发插入会产生大量磁盘 fsync,磁盘 IO 打满,插入延迟变高。binlog 双一配置保证数据安全,但是高并发下 IO 压力大。
第五,事务设计不合理。如果业务把大量逻辑放在同一个事务,事务执行时间很长,插入行之后事务迟迟不提交,行锁长时间持有,其他并发插入的相关记录就会阻塞。长事务还会造成 undo 日志不能回收,表空间膨胀。批量循环单条插入,频繁开启关闭事务,也会带来巨大开销。
第六,MVCC 与 undo 链压力。大量并发插入会产生大量 undo log 版本链,大量事务运行,旧版本数据不能被清理,占用存储空间,同时影响查询性能。
第七,分区、表碎片。频繁大量插入,页分裂会产生大量碎片,页利用率下降,相同数据占用更多磁盘,IO 效率下降。
高并发插入常见的优化手段。主键使用有序自增,避免 UUID 随机主键;减少不必要索引;批量插入,多条数据合并 insert,减少事务次数;合理调整 innodb_flush_log_at_trx_commit,业务允许场景降低刷盘频率;避免长事务;避免 insert select;拆分大表,分库分表分散写入压力;业务上做合并,削峰,不要洪峰直接打 MySQL。
面试高频坑,很多人误以为插入不会加锁,实际上插入会加记录锁,唯一索引冲突会产生 gap 锁,会出现锁等待。UUID 主键插入性能差的根源是 B + 树索引随机 IO,不是 UUID 本身。大量索引对插入性能伤害很大,查询友好的索引,就是插入的负担。innodb 的自增锁不是表锁,分不同模式,高并发批量插入依然会竞争。
请谈谈你对面向对象编程的理解,并分别说明封装、继承和多态的概念与作用。
面向对象编程是一种程序设计思想,它把现实世界中的事物抽象成程序中的类,事物本身是对象,对象包含属性(数据)与行为(方法)。程序不再聚焦一步步执行的流程,而是聚焦对象之间交互。面向对象追求高内聚、低耦合,提升代码复用性、可维护性、可扩展性,更贴合人类看待现实事物的思维方式。面向对象三大特性为封装、继承、多态。
封装。将对象的属性和行为包装到类的内部,对外隐藏内部实现细节,只暴露安全的访问接口。Java 中通过访问修饰符 private、protected、public 实现,成员变量私有化,对外提供 get/set 方法访问。封装的作用是隔离内部细节,外部不能随意修改对象内部状态,保证对象数据完整性;内部实现修改时,只要对外接口不变,调用方代码无需改动,降低耦合。例如用户类把 password 设置为 private,外部不能直接赋值,setPassword 内部可以增加密码格式校验逻辑。
继承。一个类可以复用另一个类的属性和方法,子类(派生类)继承父类(基类),子类拥有父类非私有成员,同时子类可以扩展自己独有的属性和方法。Java 使用 extends 关键字,只支持单继承,一个类只能直接继承一个父类,可以实现多个接口。继承的核心作用是代码复用,公共逻辑抽提到父类,子类不需要重复编写;同时建立类与类之间的层级关系,便于做统一抽象。继承也存在弊端,父类修改会影响全部子类,带来强耦合,业务开发中优先使用组合而非继承。
多态。同一个行为,不同对象表现出不同执行效果。Java 多态依靠方法重写、父类引用指向子类对象实现。父类引用接收不同子类实例,调用同一个方法,执行的是子类重写后的逻辑。多态作用是解耦,面向父类 / 接口编程,新增子类不需要修改原有调用代码,符合开闭原则,对扩展开放,对修改关闭。例如 Animal 父类,Dog、Cat 子类重写 speak 方法,Animal animal = new Dog(); animal.speak();执行狗叫;换成 Cat 实例就执行猫叫。
//封装
class User{
private String password;
public void setPassword(String pwd){
if(pwd.length()<6){
throw new RuntimeException("密码长度不足");
}
this.password = pwd;
}
}
//继承
class Animal{
public void speak(){}
}
class Dog extends Animal{
@Override
public void speak(){
System.out.println("汪汪汪");
}
}
//多态
public class Test{
public static void main(String[] args) {
Animal a = new Dog();
a.speak();
}
}
封装控制数据访问安全;继承实现代码复用;多态实现程序扩展。三者配合,构建可扩展的业务模型。实际开发中,继承不要滥用,很多场景使用组合class A{ private B b;}替代继承,降低类之间耦合。
Java 语言有哪些主要特点?面向过程编程和面向对象编程有什么区别?
Java 主要特点。第一,跨平台、一次编写到处运行。Java 源码编译成与操作系统无关的字节码 class 文件,字节码运行在 JVM 虚拟机之上,不同操作系统安装对应 JVM,同一套 class 可以在 Windows、Linux 运行,实现跨平台。第二,面向对象,完整支持封装、继承、多态,适合大型业务系统开发。第三,垃圾回收 GC,自动管理内存,开发人员不需要手动申请释放内存,减少内存泄漏、野指针问题,但开发者依然需要关注内存使用。第四,强类型静态语言,编译期做类型检查,编译阶段就能发现部分类型错误。第五,丰富生态,海量开源框架,Spring、MyBatis、中间件客户端,企业级开发生态成熟。第六,异常处理机制,使用 try‑catch‑finally 捕获处理异常,把业务逻辑和错误处理分开。第七,支持多线程,内置线程 API,支持并发编程。第八,安全性,字节码校验,安全管理器,适合网络环境程序。
面向过程编程 POP,核心是步骤、函数 。把程序拆解成一个个函数,按照业务执行顺序调用函数,数据和函数互相分离。关注点是 "怎么做",按流程拆解步骤。典型代表 C 语言。面向对象 OOP,核心是对象、类,把数据和行为封装到对象中,关注点 "谁来做",对象之间交互完成业务。
两者核心区别。 思维角度:面向过程聚焦流程步骤,把业务拆成一步一步函数;面向对象抽象现实事物为对象,依靠对象协作完成业务。 代码组织:面向过程数据和方法分离;面向对象数据与行为封装在类内部。 复用与扩展:面向过程复用依靠函数调用,扩展新业务往往修改原有流程;面向对象依靠继承、多态、接口,新增类即可扩展,对原有代码改动少。 耦合程度:面向过程大型项目流程交织,耦合高;面向对象合理设计下可以做到低耦合。 适用场景:面向过程适合小型简单程序,算法工具;面向对象适合大型复杂业务系统。
举简单例子,做一个人吃饭业务。面向过程,写函数createPerson()、cook()、eat(),按顺序调用。面向对象,创建 Person 类,拥有 eat () 方法,Food 类,person.eat (food),由对象行为完成。
| 对比维度 | 面向过程 | 面向对象 |
|---|---|---|
| 核心单元 | 函数(过程) | 类、对象 |
| 关注点 | 步骤流程 | 对象与对象交互 |
| 数据与行为 | 分离 | 封装在一起 |
| 扩展能力 | 新增业务常修改原有流程 | 依靠多态、接口扩展,少改旧代码 |
| 耦合 | 大型项目耦合较高 | 合理设计可以做到低耦合 |
Java 并不是纯粹只做面向对象,依然保留面向过程特性,比如静态方法,写一段 main 方法顺序执行代码,就是面向过程写法。业务开发中,两种思想会混合使用。
方法重写(Override)和方法重载(Overload)有什么区别?
重载 Overload,重写 Override 是 Java 两个完全不同概念,名称很容易混淆。
方法重载,在同一个类里面 ,多个方法拥有相同方法名,不同参数列表。参数列表不同指参数个数、参数类型、参数顺序不同。返回值、访问修饰符不参与重载判断,仅仅返回值不一样不能构成重载。重载属于编译期行为,编译阶段编译器根据调用传入实参,确定调用哪一个方法。重载目的,同一个业务动作,支持多种入参形式,方便调用。
方法重写 Override,发生在父子类之间。子类定义和父类方法签名完全一致的方法,方法名、参数列表完全相同。子类重写父类方法,实现子类自己的业务逻辑。重写遵循规则:子类访问权限不能比父类更严格;父类抛出受检异常,子类重写方法抛出异常不能比父类范围更大;父类 private 私有方法不能被重写。重写是运行期行为,运行时根据对象实际类型,执行子类重写后的方法,是实现多态的基础。
public class Demo {
//重载:同一个类,同名不同参数
public void say(){
System.out.println("无参");
}
public void say(String msg){
System.out.println(msg);
}
}
class Father{
public void hello(){
System.out.println("父类hello");
}
}
class Son extends Father{
//重写:子类覆盖父类方法
@Override
public void hello() {
System.out.println("子类hello");
}
}
核心区别表格。
| 对比维度 | 重载 Overload | 重写 Override |
|---|---|---|
| 发生位置 | 同一个类内部 | 父子继承关系的类之间 |
| 方法签名 | 方法名相同,参数列表必须不同 | 方法名、参数列表完全一致 |
| 返回值 | 返回值不参与判断,仅返回值不同不能构成重载 | 返回值要求协变,Java5 之后支持子类返回子类对象 |
| 修饰符 | 无限制 | 子类权限不能比父类更严格 |
| 阶段 | 编译期静态绑定,编译确定调用哪个方法 | 运行期动态绑定,运行看对象实际类型 |
| 注解 | 不需要注解,可选 | 建议加 @Override,编译校验语法正确性 |
| 作用 | 一个方法名适配多种入参,方便调用 | 子类重定义父类逻辑,实现多态 |
常见坑点。仅仅修改返回值,不能构成重载;private 方法不会被重写,子类写同名 private 方法属于新方法,不是重写;静态方法可以同名,属于静态方法隐藏,不是重写。@Override 注解作用,帮助编译器校验语法,写错签名直接编译报错。
什么是 Java 反射机制?它在实际开发中有哪些典型应用场景?
Java 反射机制,程序在运行时,获取类的全部信息,并且实例化对象、调用方法、读写成员变量。正常编码是编译期确定类和方法;反射是运行期动态解析 class 字节码,不需要编译期就知道类的完整信息。Java 中 Class 对象是入口,每一个 Java 类加载之后都会生成唯一 Class 实例,通过 Class 可以拿到构造器、方法 Method、字段 Field、注解 Annotation。
反射可以做到:运行时获取类名、父类、接口;获取所有成员变量,读取修改对象字段;获取构造方法,动态创建对象;获取方法,动态调用方法;读取类、方法、字段上的注解。反射打破封装,可以访问 private 私有成员,带来一定安全风险,同时反射相比直接调用有性能损耗。
典型应用场景。 第一,主流框架底层实现,Spring IOC 容器。Spring 读取配置或者注解,拿到类全限定名,利用反射实例化 Bean 对象,调用 setter 完成依赖注入。编写业务代码的时候,我们不需要 new 对象,框架底层依靠反射创建实例。 第二,ORM 框架 MyBatis。MyBatis 的 Mapper 接口,没有实现类,运行时生成代理对象,通过反射调用实体类 set/get 方法,把数据库查询结果映射到实体对象属性上。ResultSet 结果集转 Java 实体,核心就是反射读取字段。 第三,单元测试 JUnit。JUnit 扫描测试类,通过反射识别 @Test 注解的方法,动态执行测试方法。 第四,序列化与反序列化。JSON 工具 Fastjson、Jackson,把 JSON 字符串转 Java 对象,运行时反射调用构造器创建对象,反射给字段赋值;把对象序列化为 JSON,反射读取对象字段取值。 第五,动态代理。JDK 动态代理,底层依靠反射调用被代理对象的方法,AOP 功能底层依赖。 第六,通用工具类。比如对象拷贝工具,反射遍历两个对象字段,同名字段互相赋值。
//反射简单示例
public class User {
private String name;
public User(){}
public void sayHello(){
System.out.println("hello " + name);
}
}
public class ReflectDemo {
public static void main(String[] args) throws Exception {
//获取Class对象
Class<User> clazz = User.class;
//反射实例化对象
User user = clazz.newInstance();
//获取私有字段,赋值
Field nameField = clazz.getDeclaredField("name");
nameField.setAccessible(true);
nameField.set(user,"张三");
//反射调用方法
Method method = clazz.getDeclaredMethod("sayHello");
method.invoke(user);
}
}
反射的缺点:有性能开销,相比直接 new、直接调用方法慢;破坏封装,可以访问 private;编译期无法校验,写错类名、方法名,编译不报错,运行抛出异常。业务代码尽量不要大量手写反射,优先使用框架封装好的 API。Java8 之后提供 MethodHandle,性能优于传统反射。
使用 Lambda 表达式或 Stream API 对集合进行多次操作(如遍历、排序、过滤等)时,可能会遇到什么问题?请结合 Stream 只能被消费一次、延迟执行等特性说明原因。
Stream 是 Java8 引入流式处理,配合 Lambda 表达式,对集合做过滤、映射、排序、聚合。Stream 分为中间操作、终端操作。中间操作 filter、map、sorted 是延迟执行 ,调用中间操作不会立刻执行计算,只是记录操作流水线;只有执行终端操作(collect、forEach、count),才会触发流水线真正执行。Stream 流只能被消费一次 ,一旦执行终端操作,流就关闭,再次使用这个流会抛出IllegalStateException: stream has already been operated upon or closed。
常见问题一:同一个 Stream 对象多次调用终端操作,直接报错。
List<Integer> list = Arrays.asList(1,2,3,4);
Stream<Integer> stream = list.stream().filter(x->x>1);
stream.count();
stream.forEach(System.out::println); //抛出异常,流已经被消费关闭
原因:终端操作执行完毕,流流水线被消费完毕,流状态标记为已关闭。Stream 不是存储数据容器,它只是数据操作管道,数据来源是集合,流本身不存储元素。不能把 Stream 保存为变量多次复用。想要多次处理,需要每次重新从集合获取新的 stream。
常见问题二:误以为中间操作会立刻执行,调试打印看不到中间输出。
list.stream().filter(x->{
System.out.println(x);
return x>2;
});
//没有终端操作,filter内部打印完全不会执行
原因:中间操作延迟执行,只是组装处理流水线,没有触发计算。缺少终端操作,整个流水线逻辑不会运行。很多新手误以为调用 filter 就会遍历集合,实际不会执行。
常见问题三:多次中间操作带来性能误区,误以为多次 filter、map 会多次遍历集合。很多同学担心多次中间操作会多次循环原集合。实际上 Stream 会把所有中间操作组装成一条流水线,终端触发之后只遍历一次数据源,所有 filter、map 在一次遍历内部完成,不会多次遍历集合。但是如果错误在每次中间操作调用 collect,每一次 collect 都是终端操作,会多次遍历集合。
常见问题四:Lambda 内部不能修改外部局部变量,只能使用 final 或者 effectively‑final 变量。Lambda 捕获局部变量,语法限制,外部局部变量不允许重新赋值;成员变量不受该限制。
常见问题五:并行 Stream 并行流使用不当带来线程安全问题。list.parallelStream(),并行流内部使用 ForkJoinPool,集合如果是非线程安全,并发修改会异常;同时并行流默认使用公共线程池,业务中耗时 IO 操作不建议使用并行流,会占用公共 ForkJoin 线程池,影响整个应用其他并行任务。
常见问题六:Stream 操作过程中不要操作原集合数据源。在 filter/map 中 add/remove 原集合,会触发并发修改异常。
//正确写法,每次重新获取流
long cnt = list.stream().filter(x->x>1).count();
list.stream().filter(x->x>1).forEach(System.out::println);
面试关键点总结:
- Stream 不是数据容器,是操作管道,只能消费一次 ,终端操作执行完毕流关闭,不能重复使用该流对象;多次操作集合,每次都要
集合.stream()生成全新流。 - 中间操作延迟执行,只记录流水线,没有终端操作,逻辑完全不执行。
- 多个中间操作只会遍历数据源一次;但每一次 collect/forEach/count 都是终端,会重新遍历数据源。
- 并行流默认公共 ForkJoin 线程池,IO 密集场景慎用,适合 CPU 密集运算。
- Lambda 捕获局部变量要求 effectively‑final,不能对局部变量重新赋值。
开发中不要把 Stream 赋值给成员变量或者局部变量反复复用;每次处理直接链式调用,从集合生成流,一连串中间操作,紧跟着终端操作,写完一条完整流水线。