二
Java 并发编程
- CyclicBarrier、Semaphore、Exchanger 的作用与区别
- CyclicBarrier 和 CountDownLatch 的区别
- 线程池的七个核心参数与工作流程
- 四种常见线程池的特点与使用风险
RAG 企业知识库
- 文档如何存入向量数据库
- RAG 文档切分粒度如何确定
- RAG 如何缓解文档切片造成的语义割裂
Spring 框架
- Spring Bean 的生命周期
- Spring Bean 的作用域
- Spring 三级缓存与循环依赖
一、Java 并发编程
01. CyclicBarrier、Semaphore、Exchanger 分别是什么?
面试口述版
这三个都是 JUC 里的并发工具类,不过解决的问题不同。我一般把它们理解为:CyclicBarrier 等线程到齐、Semaphore 控制并发数量、Exchanger 负责两个线程交换数据。
CyclicBarrier 是循环屏障。比如三个线程并行处理数据,每个线程做完一个阶段后调用 await(),先到的线程等待,直到三个线程都到达屏障,才继续进入下一阶段。正常完成一轮后,屏障还能重复使用。
Semaphore 是信号量,内部维护许可证。比如某个接口只允许三个请求同时执行,就可以创建三个许可;进入业务前调用 acquire() 获取,执行后调用 release() 归还,限制同时访问资源的线程数量。
Exchanger 用于两个线程交换数据。双方各自调用 exchange(),成功配对后拿到对方的数据,适合两个线程在特定同步点协作的场景。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 三者都是 JUC 并发工具:CyclicBarrier 等线程到齐;Semaphore 控制许可数;Exchanger 交换两线程数据。 |
| ② 怎么实现 | CyclicBarrier 按参与方计数放行;Semaphore 获取和归还许可;Exchanger 在配对点交换双方传入的对象。 |
| ③ 怎么使用 | await:分阶段协作;acquire/release:限制同时调用外部接口的数量;exchange:双线程交换缓冲区。 |
| ④ 注意什么 | 屏障可能 broken;许可只在成功获取后释放;交换双方没配对时可能长时间等待。 |
关键词记忆卡: 等到齐 → 许可证 → 两两交换 → await/acquire/exchange → 异常与超时。
面试官追问与参考回答
- CyclicBarrier 有线程超时或被中断会怎样?
答: 当前等待阶段的屏障通常会被打破。超时方可能抛 TimeoutException,被中断的线程抛 InterruptedException,其他等待线程会收到 BrokenBarrierException。后续需要协调恢复,必要时重建屏障,而不是认为它能无条件复用。 - Semaphore 为什么要在 finally 中释放许可?
答: 业务执行中可能抛异常,finally 能避免许可泄漏。但只有成功 acquire 或 tryAcquire 之后才释放,不能获取失败也执行 release,否则会错误增加许可。 - Exchanger 和 BlockingQueue 有什么区别?
答: Exchanger 要求两个线程在交换点配对并互换数据;BlockingQueue 则是生产者放入、消费者取出,两者不需要同时到达。普通生产消费更常使用 BlockingQueue。
02. CyclicBarrier 和 CountDownLatch 有什么区别?
面试口述版
这两个工具都能让线程等待,不过侧重点不同。CountDownLatch 更像等待一组任务完成;CyclicBarrier 更像一组线程在某个阶段互相等待。
比如三个异步任务结束后,主线程才能汇总结果,就可以创建计数为 3 的 CountDownLatch:任务完成时调用 countDown(),主线程调用 await() 等待。计数降为零后,等待结束,这个计数不能再重新设置。
如果是三个工作线程需要分阶段执行任务,每个线程都在阶段末尾调用 CyclicBarrier 的 await(),全部到齐后一起进入下一阶段,这种场景更适合 CyclicBarrier。它在正常完成同步后可以重复使用。
另外,CountDownLatch 的等待方和减计数方不必是同一批线程;CyclicBarrier 通常是参与协作的线程自己互相等待。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | CountDownLatch 等计数归零;CyclicBarrier 等参与线程到齐。 |
| ② 怎么实现 | Latch 由工作方 countDown、等待方 await;Barrier 通常各参与者都 await,最后一个到达后放行。 |
| ③ 怎么使用 | 主线程等并行任务全部完成用 Latch;多个工作线程分阶段同步用 Barrier。 |
| ④ 注意什么 | Latch 计数不可重置;Barrier 正常完成后可循环使用,但可能发生超时、异常和屏障损坏。 |
关键词记忆卡: 等计数归零 → 等人数到齐 → countDown/await → 一次性/可循环 → 阶段同步。
面试官追问与参考回答
- CountDownLatch 能重置计数吗?
答: 不能。计数到零后,后续 await 会直接通过。需要下一轮等待时,通常创建新的 CountDownLatch。 - CyclicBarrier 少一个线程一直不来怎么办?
答: 无超时的 await 会继续等待;实际使用可以设置超时,并处理 TimeoutException、BrokenBarrierException 等异常,防止无限等待。 - barrierAction 在什么时候执行?
答: 指定数量的参与线程到达后,由最后一个到达屏障的线程在放行其他线程之前执行一次,可以用于阶段汇总。如果动作抛异常,屏障会进入损坏状态。
03. 什么是线程池?七个核心参数和工作流程是什么?
面试口述版
线程池就是统一管理、复用工作线程来执行任务的机制。它不只是避免频繁创建和销毁线程,还能控制并发规模、管理任务排队,并在无法承接新任务时执行拒绝策略。
ThreadPoolExecutor 有七个核心参数:
corePoolSize:核心线程数。maximumPoolSize:最大线程数。keepAliveTime:空闲线程存活时间。unit:存活时间单位。workQueue:任务等待队列。threadFactory:线程创建工厂。handler:拒绝策略。
工作流程可以记成:先核心线程、再工作队列、队列放不下再扩容、最后触发拒绝策略。
具体来说,任务提交时,如果当前工作线程数小于核心线程数,优先创建线程执行;达到核心数后,先尝试入队。入队失败且未达到最大线程数时,继续创建线程;如果无法入队也无法扩容,就触发拒绝策略。线程池关闭后也可能拒绝新任务。
任务减少后,超过核心数的工作线程如果空闲超过存活时间,通常会被回收;核心线程默认保留,除非允许核心线程超时。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 复用并统一管理工作线程,限制资源消耗、管理任务队列。 |
| ② 怎么实现 | corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler;先核心、再队列、再扩容、最后拒绝。 |
| ③ 怎么使用 | 根据任务 CPU/IO 特性、下游承载和压测结果设置线程数、队列容量、拒绝策略与监控。 |
| ④ 注意什么 | 无界队列可能使线程池不扩容;非核心线程需空闲超时才回收;核心线程默认保留;关闭后也可能拒绝任务。 |
关键词记忆卡: 线程复用 → 七参数 → 核心/队列/扩容/拒绝 → 有界队列 → 空闲回收。
面试官追问与参考回答
- 四种常见拒绝策略是什么?
答: AbortPolicy 直接抛异常(默认);CallerRunsPolicy 在适当条件下由提交线程执行,形成一定背压;DiscardPolicy 丢弃当前任务;DiscardOldestPolicy 尝试丢弃队列中等待最久的任务并重试。能否丢任务必须根据业务决定。 - 核心数 10、最大数 200,为什么可能始终只有 10 个线程?
答: 如果使用近似无界的工作队列,达到核心数后任务通常直接入队,队列不满就不会走新增非核心线程的路径。 - 线程池参数怎么设置?
答: 先区分 CPU 密集和 IO 密集任务,再结合 CPU、内存、任务耗时、请求量和下游接口容量做压测,确定线程数、队列上限、超时、拒绝处理与监控。没有对所有业务通用的倍数公式。 - execute 和 submit 有何区别?
答: execute 提交 Runnable、不返回任务结果;submit 可以返回 Future,用于获取结果、等待完成或检查执行异常。submit 中的任务异常通常封装在 Future 中,需要主动观察。
04. 常见的四种线程池有什么特点?
面试口述版
如果从 Executors 的常见工厂方法来讲,我会记住四种:固定、灵活、串行、定时。
newFixedThreadPool 是固定大小线程池,同时执行的工作线程数有固定上限,额外任务进入队列等待。但它默认使用无界队列,任务积压过多可能带来内存压力。
newCachedThreadPool 会尽量复用空闲线程,没有可复用线程时可以继续创建。适合提交量可控、执行较短的任务;如果短时间提交大量任务,线程数可能快速增长,消耗系统资源。
newSingleThreadExecutor 在同一时间只使用一个工作线程执行任务,适合需要串行执行的场景;但吞吐量有限,默认队列也可能积压。
newScheduledThreadPool 支持延迟执行、固定频率或固定延迟的周期任务,适合调度场景,但不保证严格的实时精度。
实际业务中,我更倾向于明确线程数、队列容量、线程工厂和拒绝策略,根据系统承载能力配置线程池。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | Fixed 固定线程数;Cached 按需创建与复用;Single 串行处理;Scheduled 延迟或周期执行。 |
| ② 怎么实现 | Fixed 和 Single 默认排队;Cached 缺少业务上限约束;Scheduled 根据调度时间安排任务。 |
| ③ 怎么使用 | 受控并发、短任务且提交量受控、串行任务、定时任务。生产业务通常明确配置线程池参数。 |
| ④ 注意什么 | 无界队列可能积压;Cached 可能创建大量线程;Scheduled 不是严格实时调度器。 |
关键词记忆卡: Fixed 固定 → Cached 灵活 → Single 串行 → Scheduled 定时 → 队列和线程上限。
面试官追问与参考回答
- 为什么很多团队不直接用 Executors 默认工厂?
答: 某些工厂使用无界队列,某些缺少合适的线程数上限,高峰期容易积压任务或耗尽资源。明确配置最大线程数、队列容量和拒绝策略,更容易与业务容量匹配;并非工厂方法本身一概不能使用。 - scheduleAtFixedRate 和 scheduleWithFixedDelay 有什么区别?
答: FixedRate 按计划的固定周期安排执行;FixedDelay 则在上一次任务完成后再等待设定间隔。执行很慢时,FixedRate 的后续执行会被延后,但同一个周期任务不会因此自动并行重叠。 - 虚拟线程执行器和固定大小线程池一样吗?
答: 不一样。Java 21 的 newVirtualThreadPerTaskExecutor 通常每个任务创建一个虚拟线程,不是复用固定数量平台工作线程的传统线程池。即使使用虚拟线程,也要单独限制数据库连接和外部服务调用数量。
二、RAG 企业知识库
05. 一篇文档是怎么存进向量数据库的?
面试口述版
这个问题我通常按文档解析、清洗、切分、向量化、入库这条流程来理解。
首先把 PDF、Word、Markdown 等文件解析成可处理的文本和结构化信息。扫描件可能需要 OCR,表格还需要尽量保留表头与行列关系。解析后做必要的清洗,比如去掉重复页眉、页脚和乱码。
接着按标题、段落或 Token 长度把文档切成多个 chunk,再把每个 chunk 交给 Embedding 模型生成向量。
最后把向量及其关联信息保存起来。除了向量,通常还需要保留 chunk 文本或文本引用,以及文档 ID、章节、页码、版本、权限等 metadata。这样后续既能做相似度检索,也能过滤权限、追踪来源。
查询时,将用户问题转成兼容的向量,检索相关片段,必要时重排序,再将检索内容提供给大模型组织回答。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 把文档转成可检索的 chunk 和向量,并建立内容与来源的关联。 |
| ② 怎么实现 | 文件解析/OCR → 清洗 → 切分 → Embedding → 保存向量与关联信息。 |
| ③ 怎么使用 | 问题向量化后相似度检索;按 metadata 过滤;必要时重排,再将相关文本交给模型回答。 |
| ④ 注意什么 | 权限、版本、页码要可追溯;原始文件可独立存储;更新时避免新旧 chunk 混检。 |
关键词记忆卡: 解析清洗 → Chunk → Embedding → 向量+Metadata → 检索追溯。
面试官追问与参考回答
- 为什么保存 metadata?
答: 用文档 ID、版本、章节、页码、部门权限等字段做过滤、去重、来源引用和权限控制。只存向量很难完成这些业务需求。 - 文档更新后怎么办?
答: 根据文档 ID 和版本识别旧 chunk,重新解析变化内容并更新向量索引,按方案替换或删除旧记录。需要避免旧版与新版同时被错误召回,并同步更新权限元数据。 - PDF 里的图片和表格怎么处理?
答: 扫描内容使用 OCR;表格尽量保留表头、行列关系;图表可结合 OCR、视觉模型和描述生成可检索内容,并保存页码或原图引用。普通文本抽取不一定能完整理解架构图。
06. RAG 文档切分粒度怎么确定?
面试口述版
我认为切分粒度没有固定的万能值,本质上要在检索精度与上下文完整性之间做平衡。
切得太大,一个 chunk 可能包含多个主题,检索匹配不够聚焦,还会占用更多上下文;切得太小,虽然容易定位局部信息,但可能丢掉前后条件,chunk 数量也会增加。
实际落地时,我会先根据文档类型选择策略。普通文本可以先以几百到一千左右 Token 做实验基线,适当设置 Overlap;制度文档尽量保留标题和段落结构;代码按类或方法切,表格要保留表头与内容的对应关系。
如果小片段召回准确但上下文不够,可以考虑父子切分:小块负责检索,命中后返回关联的大块。最终用真实问题评估正确片段的召回效果、答案质量、耗时和成本,再迭代参数。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 每个 chunk 有多大;要平衡检索精度和上下文完整性。 |
| ② 怎么实现 | 大块信息完整但主题不聚焦;小块定位更细却容易丢条件和背景。 |
| ③ 怎么使用 | 按标题段落、代码结构、表格结构切分;选基线尺寸与 Overlap;必要时父子切分。 |
| ④ 注意什么 | 没有通用 Token 大小;用真实问题测试召回、答案质量、延迟与成本。 |
关键词记忆卡: 大块完整 → 小块精准 → 文档结构 → Overlap/父子切分 → 评估迭代。
面试官追问与参考回答
- 怎么判断切分效果?
答: 准备一组有依据的真实问题,观察正确片段是否进入 Top-K、最终回答是否准确且有来源,同时评估响应延迟、Token 消耗和存储成本。 - 为什么不全部切成 100 Token?
答: 太小可能把主语、适用条件和例外情况切开。比如只检索到“2 小时内响应”,却漏掉“仅企业客户适用”,模型容易给出不完整的结论。 - 父子切分是什么?
答: 预先建立小片段与包含它的大片段的对应关系。检索时用小片段精准定位,再按关联 ID 取回大段文本补足上下文。
07. RAG 如何缓解文档切片造成的语义割裂?
面试口述版
我一般从两个阶段考虑:切分时尽量保持完整语义,检索命中后补足缺失的上下文。
切分阶段可以使用 Overlap,让相邻 chunk 有一部分重叠,降低句子跨越边界时的信息丢失;还可以尽量按照标题、段落、完整句子等自然边界切分,而不是机械地按固定字符数截断。
检索阶段则可以采用句子窗口或父子切分。比如命中一个小片段后,再取它前后的句子,或者根据关联 ID 找到对应的大块,将更完整的语境交给模型。
对于离开上下文就难以理解的 chunk,还可以在建立索引前补充必要的章节背景说明。最终还是要用实际检索效果判断,因为过多重叠也会带来重复索引、重复召回和成本增加。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 文本被分到不同 chunk,导致条件、定义或上下文丢失。 |
| ② 怎么实现 | 切分时按语义边界和 Overlap 减少割裂;检索后用窗口、父子块补上下文。 |
| ③ 怎么使用 | 标题/段落切分、句子窗口、父子检索、必要的上下文化背景说明。 |
| ④ 注意什么 | Overlap 过大会重复索引与召回;自动补背景必须忠实原文;效果依赖评估。 |
关键词记忆卡: 语义边界 → Overlap → 句子窗口 → 父子切分 → Contextual Retrieval。
面试官追问与参考回答
- Overlap 越大越好吗?
答: 不是。重叠能降低边界信息丢失,但太大可能重复向量化同一内容,增加成本、制造重复召回,还会占用模型上下文。 - 句子窗口和父子切分差别是什么?
答: 句子窗口在命中句子后动态取前后若干句;父子切分则先建立小块与大块的关联,命中小块后取它所属的大块。前者按邻近位置补,后者按预设结构补。 - Contextual Retrieval 是什么?
答: 在建立检索索引前,给独立的 chunk 补充忠实原文的简短背景,比如所属章节或主题,减少片段被抽离后“这项规定”指代不明的问题。补充内容不能凭空编造。
三、Spring 框架
08. Spring Bean 的生命周期是什么?
面试口述版
Bean 的生命周期就是一个 Bean 从 Spring 容器创建、初始化、投入使用,到最终销毁的全过程。
主线可以记成:实例化 → 属性赋值和依赖注入 → Aware 回调 → 初始化前处理 → 初始化 → 初始化后处理 → 使用 → 销毁。
具体来说,Spring 先创建 Bean 实例,再注入依赖;如果 Bean 实现了相关 Aware 接口,容器会在对应时机提供 Bean 名称或容器信息。初始化前后,BeanPostProcessor 可以插入扩展逻辑;初始化回调常见的有 @PostConstruct、afterPropertiesSet() 和自定义 init-method。
初始化完成后,Bean 对外提供服务。对于需要 AOP 增强的 Bean,最终对外使用的可能是代理对象。容器关闭时,针对由容器负责管理销毁的 Bean,会执行 @PreDestroy、destroy() 等回调来释放资源。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 从容器创建 Bean,到初始化、使用、销毁的过程。 |
| ② 怎么实现 | 实例化 → 属性注入 → Aware → 初始化前处理 → 初始化回调 → 初始化后处理 → 使用 → 销毁。 |
| ③ 怎么使用 | @PostConstruct、InitializingBean、init-method、BeanPostProcessor、@PreDestroy。 |
| ④ 注意什么 | AOP 可能返回代理;prototype 的销毁通常不由容器自动完整管理。 |
关键词记忆卡: 实例化 → 依赖注入 → 前后置处理 → 初始化回调 → 使用与销毁。
面试官追问与参考回答
- @PostConstruct、afterPropertiesSet、init-method 的顺序是什么?
答: 通常先执行 @PostConstruct,再执行 InitializingBean.afterPropertiesSet,最后是自定义 init-method。@PostConstruct 一般通过初始化前置处理机制触发。 - BeanPostProcessor 有什么用?
答: 它允许 Spring 在 Bean 初始化前后扩展处理,例如解析注解、检查对象或进行代理增强。AOP 的部分代理创建机制也依赖相关后置处理器。 - 所有 Bean 都会在容器关闭时执行销毁方法吗?
答: 不能这么说。singleton 通常由容器管理销毁回调;prototype Bean 创建后一般交给使用方,容器通常不会自动执行其完整销毁流程。
09. Spring Bean 有哪些作用域?
面试口述版
Bean 的作用域决定了 Spring 在什么范围内创建、复用和管理同一个 Bean 实例。
最常见的是 singleton 和 prototype。singleton 是默认作用域,同一个容器中的同一个 Bean 定义通常只有一个实例,所以很多无状态 Service 都使用它;prototype 则是在每次向容器请求时创建新的实例。
在 Web 应用中,还有 request、session、application 和 websocket 等作用域,分别与 HTTP 请求、会话、ServletContext 和 WebSocket 会话关联。
这里特别要注意:Spring 的 singleton 不是整个 JVM 全局唯一,也不自动保证线程安全。如果单例 Service 保存了多个请求共享的可变状态,仍然可能出现并发问题。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 决定一个 Bean 实例在什么范围内创建、共享和管理。 |
| ② 怎么实现 | singleton 容器内共享;prototype 每次获取新建;request、session、application、websocket 与 Web 上下文关联。 |
| ③ 怎么使用 | 无状态 Service 常用 singleton;确需不同实例时用 prototype;请求/会话状态考虑 Web scope。 |
| ④ 注意什么 | singleton 不代表全 JVM 唯一或线程安全;singleton 注入的 prototype 不会每次方法调用自动新建。 |
关键词记忆卡: singleton → prototype → Web scopes → 线程安全 → 按需获取。
面试官追问与参考回答
- singleton Bean 一定线程安全吗?
答: 不一定。无状态的单例 Service 通常比较容易共享;但如果把不同请求的数据放在可变成员变量里,多个线程可能发生数据覆盖或竞态。 - singleton 中注入 prototype,是否每次调用都新建?
答: 普通依赖注入通常在 singleton 创建时完成,所以那份 prototype 依赖通常固定下来。需要每次新建时可以按需使用 ObjectProvider、@Lookup 等机制。 - request 作用域在普通非 Web 容器中可以直接使用吗?
答: 通常不可以。它依赖当前 HTTP 请求上下文,需要相应的 Web 环境和 scope 支持。
10. 什么是 Spring 的三级缓存?为什么需要三级?
面试口述版
Spring 三级缓存主要与单例 Bean 的早期引用、特定情况下的循环依赖处理有关。比如 A 通过属性注入依赖 B,B 又依赖 A,Spring 可以在 A 尚未完全初始化时提前暴露 A 的引用,让 B 有机会完成创建。
三个缓存分别是:
- 一级
singletonObjects: 保存完成创建的单例 Bean。 - 二级
earlySingletonObjects: 保存已经生成的早期单例引用。 - 三级
singletonFactories: 保存用于按需获取早期引用的ObjectFactory。
典型过程是:Spring 先实例化 A,把获取 A 早期引用的工厂放进三级缓存;A 需要 B,于是创建 B;B 又需要 A,就可以从缓存获取 A 的早期引用,从而完成依赖注入。随后 B 完成创建,A 继续完成初始化,最终完整的单例进入一级缓存。
第三级缓存的价值不仅是“多存一层”,更重要的是提供按需创建早期引用的机会。涉及 AOP 时,这个早期引用可能需要是代理对象,避免依赖方拿到原始对象,而容器最终使用代理对象。
但这不代表所有循环依赖都能解决。构造器互相依赖、prototype 循环依赖,以及配置禁止相关处理时,不能简单依靠三级缓存解决。实际项目应该优先消除不合理的循环依赖。
回答骨架:四问总结
| 回答维度 | 本题关键点 |
|---|---|
| ① 是什么 | 通过提前暴露特定单例 Bean 的早期引用,支持部分属性注入循环依赖。 |
| ② 怎么实现 | singletonObjects 存成品;earlySingletonObjects 存早期引用;singletonFactories 存获取早期引用的工厂。先创建 A、暴露工厂,创建 B 时取 A 的早期引用,最后完成 A。 |
| ③ 怎么使用 | 三级工厂允许按需获取早期对象或代理引用,二级缓存已获取的早期引用,一级最终保存完整单例。 |
| ④ 注意什么 | 构造器循环依赖、prototype 循环依赖及禁止循环引用的配置下不能简单依赖这套机制;设计上应避免循环依赖。 |
关键词记忆卡: 一级成品 → 二级早期引用 → 三级工厂 → A/B 循环 → AOP 早期代理。
面试官追问与参考回答
- 为什么需要三级缓存,两级不行吗?
答: 对最简单的原始对象相互引用,两层可能足够;但涉及 AOP 时,Spring 需要在合适时机通过工厂按需生成或取得早期代理引用,再缓存已生成的引用。三级缓存提供了这种延迟获取能力,避免过早暴露不合适的原始对象。 - 为什么构造器循环依赖通常解决不了?
答: 创建 A 的构造器先需要 B,创建 B 的构造器又先需要 A,此时 A 连实例都没有完成,无法提前暴露可用引用,因此无法走属性注入式的早期引用流程。 - 三个缓存通常按什么顺序查找?
答: 先查一级的完整单例;如果 Bean 正在创建,再尝试二级的早期引用;允许提前引用且二级没有时再使用三级工厂。取得早期引用后通常转存二级,最终创建完成后进入一级并清理早期缓存。
