AI相关项目
AI项目
自我介绍
面试官您好,我叫陈温鹏,就读于南京理工大学软件工程专业,学位是学硕,这次应聘的是 Java后端开发岗位。
我呢,研一的时候有过一段开源经历(Casbin社区一个线上实习),担任社区维护者,日常工作会负责处理社区日常issue,跟踪解决用户需求,修复bug及完善文档等;然后在研一暑假期间中选了中科院和 Casbin 社区联合举办的一个开源项目,主要的工作是完善社区整个大前端系统,包括 web,移动端功能完善,以及为社区开发了一款支持通用2FA的移动端app。除此之外,我还参与过国网经济研究院的一个项目,以及自己做过两个Java前后端项目。这几段项目实习经历锻炼了我文档阅读、编写的能力,并在代码规范、开发流程等技能上获得提升。
然后我呢,我评价觉得自己是一个学习能力很强的人,本科成绩前5%,获得过国家奖学金,辅修了英语双学位,硕士期间也获得过学业一等奖学金,可以比较快速的学习并适应新的环境和技术栈。
最后感谢 xxx 给我这次面试机会,我也十分希望能进入 xxx ,与公司共同成长进步!
工作经历
公司:阿里巴巴-菜鸟集团
时间:202506-至今
- 负责人事核心域(员工入离异、合同管理、审批流、组织人事主数据等)多个核心模块的迭代开发与稳定性维护,通过告警治理、热点接口缓存改造、链路压测与降级预案完善,保障核心人事链路在日均百万级调用下稳定运行,大促期间零故障。
- 负责薪酬域核心模块社保系统的稳定性维护与日常迭代,通过ISO合规性改造,支持海外准时发薪;同时参与薪酬AI质检平台一期模块的需求落地,通过集成AI自然语言解析、六大质检环节(提报/计算/调账/报表/薪资方案/其他)全覆盖、三大规则类型(条件逻辑校验/重复数据检测/一致性校验)体系化,大幅降低薪酬质检投入。
- 参与集团采购合规风控平台跨多仓改造,承担供应商围串标治理专项的标前布控开发
- 深度参与 CPO 域 AI Native 建设,参与 HR 智能助理 Agent 与薪酬 AI 质检平台两个核心场景从 0 到 1 落地;同时主动拥抱 AI 编程范式,将Vibe Coding/SDD深度融入日常研发流程,通过 AI 相关能力实现个人需求吞吐量翻倍,AI Coding 采纳率稳定维持在 90% 以上。
- 积极探索 AI 领域前沿技术并推动团队工程化沉淀,团队内最先部署 OpenClaw 并接入钉钉、沉淀为团队共享文档;2700 行代码复刻业界开源 OpenClaw 框架,覆盖 Agent Loop、Heartbeat、Workspace 契约文件、Skills 渐进式披露、Context Compaction、Multi-Agent Spawn 沙箱等核心原理;持续跟踪 AI 前沿动态(开源 Agent 框架、主流大模型版本迭代、业界落地案例等),在团队内定期输出技术分享与最佳实践沉淀。
x小蜜-企业级HR智能助理Agent
项目介绍:面向集团内部数万员工的HR领域Agent,以自然语言对话替代传统表单与人工咨询,覆盖知识问答、智能请假、证明开具、人才档案、智能问数等高频场景,日均对话数万次,显著降低HR工作量、提升用户服务体验。
技术栈:PandoraBoot、RAG、Function Calling、MCP、RPA、Prompting Engineering
- 多源 RAG 系统:线程池隔离 + CompletableFuture编排并发召回 4 套异构知识源(政策库/知识平台/协同文档/草稿态),单源超时 3s 熔断降级,Rerank重排后取Top-K注入上下文,保障召回相关性与主链路可用性。
- 两段式路由引擎:"规则前置拦截(本地策略模式匹配,零 LLM 调用) + LLM Function Routing" 两段式路由,保障LLM实时可用性;基于策略模式抽象 ToolExecutor,支撑 10+ 业务工具可插拔注册,新增技能零侵入主流程。
- RPA + 定时任务双链路知识同步:主链路通过 RPA 自动抓取协同文档变更增量同步至百炼向量库,Java 侧 SchedulerX 定时任务处理 RPA 无法覆盖的 FAQ 同步与文档删除场景,保障知识库实时性与完整性。
- 端到端流式体验:Dubbo Triple StreamObserver 实现服务端流式推送,基于 ReplayProcessor + 百炼智能体流式 API 做背压控制与中间态兜底(1.5s 无响应自动推送加载提示),首字延迟下降约 60%;智能体 AppId 不可用时自动降级到工作流 AppId,保障流式链路高可用。
- MCP Server 标准化对外开放:基于 MCP 协议暴露员工信息查询、知识召回、相似问匹配等标准 Tool,支持外部 Agent 通过统一协议编排调用,降低跨系统集成成本。
- Prompt 工程化与质量闭环:Prompt 与 Tool Schema 外置到配置中心,支持分钟级热更无需发版;离线 LLM-as-a-Judge 评估任务对答案打分回流,驱动 Prompt 与召回策略持续迭代。
1. 多源 RAG 系统
多源 RAG 系统:线程池隔离 + CompletableFuture编排并发召回 4 套异构知识源(政策库/知识平台/协同文档/草稿态),单源超时 3s 熔断降级,Rerank重排后取Top-K注入上下文,保障召回相关性与主链路可用性。
4 套知识源各有独立的 AbstractKnowledgeRetrievalStrategy 子类,通过模板方法定义标准流程(构建过滤标签 → 调用百炼 retrieve API → 文档鉴权),子类只需实现差异化逻辑。并发架构采用三层线程池隔离:外层 RAG_STRATEGY_POOL(core=30, max=50)驱动 4 个 Strategy 并发;内层 RAG_RETRIEVE_POOL(core=100, max=150)处理每个 Strategy 内多 query × 多知识库的百炼 API 调用;鉴权层 KNOWLEDGE_AUTH_POOL(core=200, max=300)处理每个知识切片的文档权限校验。三层必须分开,否则外层任务占满线程后内层任务排队,形成线程饥饿死锁。CompletableFuture 并发有一个关键陷阱:必须先 .collect(Collectors.toList()) 收集所有 Future,再 .stream().flatMap(f -> f.join()) 汇总结果——如果直接链式调用,由于 Stream 惰性求值会退化为串行。单源超时通过 CompletableFuture.allOf(futures).get(3, TimeUnit.SECONDS) 实现,超时后只收集已完成的鉴权结果,未完成的切片降级为无权限。Rerank 分两段:百炼内部先用 qwen3-rerank-hybrid 模型从 Top-100 收敛到 Top-20,应用层再用 qwen3-rerank 模型对文档名做 rerank,按 原召回分 × 0.7 + 文档名 rerank 分 × 0.3 加权融合后取 Top-K(由 Diamond 配置控制,默认 20)注入 Prompt。
【AbstractKnowledgeRetrievalStrategy是策略模式吗?】
是的,这是策略模式 + 模板方法模式的组合。AbstractKnowledgeRetrievalStrategy 是抽象策略基类,定义了 4 个抽象方法(buildFilterTags / retrieve / authSlice / getStrategyName),3 个子类分别实现不同知识库的召回策略:HrPolicyRetrievalStrategy(政策平台)、DingDocRetrievalStrategy(钉文档)、KnowledgePlatformRetrievalStrategy(知识平台)。调用方不关心具体用哪个策略,只通过父类引用统一调用——这是典型的策略模式。
【这个标准流程是SOP吗?】
更准确地说是模板方法模式(Template Method Pattern)。父类的 ragRetrieveKnowledgeSlice() 方法定义了固定的三步流程:①调用子类的 buildFilterTags() 获取标签 → ②调用子类的 retrieve() 召回知识 → ③调用父类统一的 authSliceList() 做文档鉴权。流程骨架由父类控制,可变步骤由子类实现——这就是模板方法模式。SOP(标准操作流程)是业务层面的概念,模板方法模式是它在代码层面的实现手段。
【知识切片的文档权限校验是什么意思?意思是文档切片后,召回的时候还需要根据谁有权限查看权限,来进行知识切片的权限校验吗?如果没文档权限,则没有这个知识切片的权限?是这样吗?】
是的,你的理解基本正确。RAG 召回的知识切片(chunk)来自不同的原始文档,而不同文档有不同的访问权限。鉴权层做的事情是:对每个召回的知识切片,根据它所属的原始文档,校验当前提问用户是否有权查看该文档。代码中 authSlice(workNo, empNo, docName) 就是传入工号和文档名进行权限校验。不同策略子类的鉴权方式不同:政策平台通过 DocumentOpenService 校验、钉文档通过钉钉 API 校验、知识平台通过 KnowledgeAuthenticateFacade 校验。如果用户对某文档没有权限,该文档下的所有知识切片都会被过滤掉,不会注入到 Prompt 中——防止通过 AI 助理绕过文档权限体系泄露敏感信息。
【三层线程池是否存在前后依赖关系?否则无法形成死锁】
是的,三层线程池存在严格的嵌套依赖关系,这正是必须分开的原因。调用链是:RAG_STRATEGY_POOL(外层)并发执行 3 个策略的 ragRetrieveKnowledgeSlice() → 每个策略内部用 RAG_RETRIEVE_POOL(内层)并发调用多个百炼 retrieve API → 召回结果返回后用 KNOWLEDGE_AUTH_POOL(最内层)并发做文档鉴权。如果三层共用一个线程池,外层 3 个策略任务占据了所有线程,内层的 retrieve 调用和鉴权调用就无法获得线程执行,但外层任务又在等内层结果——外层持有线程等内层、内层等线程被外层占着,形成经典的线程饥饿死锁。代码注释也明确写了"不能与 RAG_RETRIEVE_POOL 共用,否则会有线程饥饿死锁风险"。
【3秒熔断只是针对切片鉴权吗?还是针对整个召回过程?】
根据代码,3秒熔断是针对切片鉴权阶段的。在 AbstractKnowledgeRetrievalStrategy 的 collectAuthResults() 方法中,对所有鉴权 Future 设置 CompletableFuture.allOf(futures).get(3, TimeUnit.SECONDS) 总超时。超时后只收集已完成的鉴权结果,未完成的切片被视为无权限直接丢弃。之所以只针对鉴权阶段设超时,是因为鉴权需要调用外部权限服务(如钉钉API、DocumentOpenService),这些外部调用延迟不可控,是整个 RAG 链路中最容易成为瓶颈的环节。召回阶段(retrieve)的超时由百炼 SDK 自身的 HTTP 超时控制。
【3s熔断过程中,如果鉴权超时,是否可能会导致召回过程丢失了包含关键信息的切片,现有实现中有考虑这种情况吗?如果没有,你觉得后面可以做什么优化?】
是的,3s熔断确实可能导致丢失关键切片。 现有代码collectAuthResults()中,3秒超时后只收集已完成的鉴权结果,未完成的切片直接被视为"无权限"被丢弃——但实际上这些切片可能是有权限的,只是鉴权服务响应慢了。
现有实现没有特别的补偿机制,采用的是"可用性优先"策略:宁可丢失部分切片,也要保证主链路不卡死。这在大多数场景下是合理的——Rerank 从 Top-100 收敛到 Top-K(默认20),即使丢失几条鉴权超时的切片,剩余的切片通常仍能覆盖用户问题。
后续可做的优化方向:
① 二次异步补鉴权——超时被降级为"无权限"的切片,后台异步补做鉴权,结果写入缓存。下次同用户提问时,缓存中已有该文档的鉴权结果,避免重复超时。
② 分档超时——对不同文档类型设不同超时阈值(政策平台文档通常鉴权快,可设2s;钉文档鉴权链路更长,可设4s),而非一刀切3s。
③ 部分放行策略——鉴权超时的切片先标记为"未鉴权",如果 Rerank 后 Top-K 切片数量不足(如只有3条),则把"未鉴权"切片临时放行注入 Prompt,同时在回答末尾标注"以下内容未经权限校验,仅供参考"。
两段式路由引擎
两段式路由引擎:"规则前置拦截(本地策略模式匹配,零 LLM 调用) + LLM Function Routing" 两段式路由,保障LLM实时可用性;基于策略模式抽象 ToolExecutor,支撑 10+ 业务工具可插拔注册,新增技能零侵入主流程。
第一段规则前置拦截按固定优先级执行:先通过 SpecialCommandTool 匹配特殊指令(如"开启新话题"),再通过 SimilarQuestionTool 在 FAQ 库中做精确匹配,命中即返回、不走 LLM,保证确定性场景的低延迟(<50ms)。前置拦截器返回 null 表示"不处理",流程继续到第二段 LLM 路由。第二段 aiRouting() 并发执行 4 个 LLM 调用(实际含嵌套最多 6 个):技能路由(将工具列表序列化为 Prompt,用 qwen-flash 快速模型识别意图)、语言识别、问题重写(又并发 3 路:上下文重写 / 关键词同义词扩展 / 用户身份信息重写,多角度扩展提高 RAG 召回率)、知识库标签识别。这 4 个调用无数据依赖,并发执行将路由延迟从串行累加的 ~2s 压缩到 ~500ms。ToolExecutor 注册通过 Spring 自动收集:ToolExecutorRegistry 构造器注入 List<ToolExecutor>,过滤掉前置拦截器后按 operationId 建映射表。新增技能只需实现接口 + 加 @Component + 在 Diamond 配置工具描述,对主流程零侵入。路由失败时自动降级到知识问答(最安全的兜底技能)。每个 Tool 还支持圈人配置(crowdRuleId),通过人群校验决定该工具是否出现在路由 Prompt 中。
【怎么匹配特殊指令的?】
通过精确字符串匹配实现。代码中直接用 equals 判断用户输入是否为固定关键词,如"开启新话题".equals(userInput) || "new chat".equals(userInput)。匹配成功则调用 cxmSessionHelper.refreshSessionInfo() 刷新会话并发送提示卡片,返回 ExecutorResult.success("已开启新话题");不匹配则返回 null,表示"本拦截器不处理",流程继续往下走。这种设计非常轻量,无需 LLM 调用,延迟几乎为零。
【如何匹配相似问题的?】
调用 SimilarQuestionService.getSimilarQuestion(userInput, empNo) 进行匹配。内部逻辑是将用户输入与 FAQ 知识库中预设的标准问题做相似度匹配(通过数据库),如果匹配到相似度超过阈值的标准问题,直接返回预设答案(SimilarQuestionVO);未匹配到则返回 null,流程继续到 LLM 路由。这样做的好处是:对于高频标准问题(如"怎么查工资条""年假还剩几天"),可以绕过 LLM 直接给出确定性答案,延迟 <50ms 且答案准确率 100%。
【将用户输入与 FAQ 知识库中预设的标准问题做相似度匹配,具体是如何通过百炼的语义检索能力实现的?】
根据代码,SimilarQuestionServiceImpl.getSimilarQuestion() 的实现并非通过百炼语义检索,而是通过数据库精确匹配:similarQuestionMapper.queryByQuestion(removeSymbol(question))直接用用户输入的文本在数据库的 question 表中做精确查询。匹配到记录后,再通过 crowdRuleId 做圈人过滤,取 gmtModified 最新的一条返回。
也就是说,当前实现是关键词精确匹配而非语义相似度匹配。用户输入必须与预设的标准问题文本一致才能命中。这也解释了为什么项目介绍中说"精确匹配"——它确实就是精确匹配,不涉及百炼的语义检索。
如果未来需要升级为语义匹配,可以考虑:① 在百炼上为 FAQ 库单独建一个知识库索引,调用 retrieve API 做语义检索取 Top-1;② 或引入向量数据库(如 Milvus)对 FAQ 问题做 embedding 存储,用余弦相似度匹配。
【4个LLM调用详细说下】
aiRouting() 方法通过 CompletableFuture.supplyAsync() 并发启动 4 个独立的 LLM 调用:
- ① 技能路由(aiRoutingForTool):将当前用户可用的工具列表(经过圈人过滤后)序列化为 Prompt,用 qwen-flash 快速模型识别用户意图,返回匹配的 operationId。
- ② 语言识别(cxmLangDetector.detect):识别用户输入的语言(中文/英文等),后续用于多语言回答。
- ③ 问题重写(aiRoutingForUserInputRewrite):这一路内部又并发 3 个子 LLM 调用——上下文重写(结合历史对话补全指代和省略)、关键词同义词扩展(扩展搜索关键词提高召回率)、用户身份信息重写(结合员工工号/部门等信息补充问题上下文)。所以实际最多 6 个 LLM 调用。
- ④ 知识库标签识别(aiRoutingForKnowledgeBaseFilterCode):判断问题所属的知识库分类标签,用于 RAG 召回时做知识库过滤,提高召回精准度。
- 这 4 个调用之间无数据依赖,并发执行后通过 CompletableFuture.allOf().join() 等待全部完成,将路由总延迟从串行 ~2s 压缩到 ~500ms(取决于最慢的那一路)。
【这里ToolExecutor的介绍与解释我觉得太过于专业了,我都看不太懂了?或者你需要介绍更详细一点】
用大白话解释——ToolExecutor 就是一个"技能插件"的统一接口。每个业务技能(比如"查工资""查年假""查组织架构")都是一个实现了 ToolExecutor 接口的 Java 类。这个接口定义了几个关键方法:
getOperationId():返回技能的唯一标识(如 "queryPayslip"),相当于技能的"身份证号"execute():技能被选中后执行的核心逻辑isPreInterceptor():标记是否为前置拦截器(SpecialCommandTool / SimilarQuestionTool 返回 true)
ToolExecutorRegistry 是"技能注册中心"。Spring 启动时,会自动发现所有加了 @Service/@Component 注解的 ToolExecutor 实现类,收集成一个列表注入到 ToolExecutorRegistry 的构造器中。Registry 把前置拦截器过滤掉,剩下的按 operationId 放进一个 Map(字典)。当 AI 路由说"用户想查工资,对应 operationId=queryPayslip"时,Registry 就能从 Map 中快速找到对应的技能类去执行。
【详细介绍新增技能的流程?】
新增一个技能的完整流程分为代码侧和配置侧两步:
- 代码侧:① 创建一个新的 Java 类,加上 @Service 或 @Component 注解;② 实现 ToolExecutor 接口;③ getOperationId() 返回唯一标识(如 "queryAttendance");④ execute() 方法中编写业务逻辑(如调用考勤服务查询数据、组装回答)。Spring 容器启动时会自动扫描到这个类,ToolExecutorRegistry 构造器自动将它注册到映射表中。
- 配置侧:在 Diamond 配置中心的 AiAssistantConfigData.tools 列表中添加一条工具描述,包含 operationId(与代码中一致)、title(工具名称)、description(功能描述,供 AI 路由判断时参考)、exampleQueries(示例问题,帮助 AI 识别意图)、parameters(参数定义)、crowdRuleId(可选,圈人配置)。Diamond 配置推送后立即生效,新技能就出现在 AI 路由的工具列表中了。
- 全程不需要修改 CxmAiEngine 或任何主流程代码——这就是"零侵入"的含义。
【如何圈人的?】
每个 Tool 在 Diamond 配置中可以设置 crowdRuleId(人群规则 ID)。AI 路由构建工具列表时,通过 shouldIncludeTool() 方法逐个校验:如果 crowdRuleId 为空,则所有用户可见;如果配置了 crowdRuleId,则调用 CrowdWrapper.validateMatched(empNo, crowdRuleId) 向 HCM 人群服务发起校验,判断当前用户的工号是否命中该人群规则。只有校验通过的工具才会出现在路由 Prompt 的工具列表中,AI 才有机会选中它。这样就实现了按人群灰度开放技能——比如新技能可以先配置只对某个 BU 的员工可见,验证稳定后再去掉 crowdRuleId 全量开放。
RPA + 定时任务双链路知识同步
RPA + 定时任务双链路知识同步:主链路通过 RPA 自动抓取协同文档变更增量同步至百炼向量库,Java 侧 SchedulerX 定时任务处理 RPA 无法覆盖的 FAQ 同步与文档删除场景,保障知识库实时性与完整性。
双链路设计的原因是能力互补:RPA 擅长处理需要渲染/下载的普通文档(alidoc / pdf),但无法处理钉钉 FAQ 格式(able 表格)的导出和文档删除操作。Java 侧 DingDocSyncProcessor(SchedulerX 定时任务)专门处理这两个场景——dealDeleteTask() 循环取出删除任务调用百炼 API 删除文件,dealFaqTask() 将钉钉 FAQ 文档导出为临时文件后上传百炼。知识库变更检测由独立的 DingDocScanProcessor 扫描任务完成:遍历钉钉文档空间目录树生成快照,与百炼现有文件列表对比,生成变更日志(ADD / MODIFY / DELETE),检测维度包括文件名变化、修改时间 tag 变化、able 文件超过 30 天需强制刷新(因内部图片 URL 有过期风险)。扫描与同步解耦——扫描只生成变更日志,同步任务消费日志。百炼 API 有频率限制,通过 Guava RateLimiter 控制(查询 5 QPS、删除 10 QPS、上传 10 QPS)。上传时从文档路径提取圈人标签,注入为百炼文件 tag,实现基于标签的文档权限过滤。
【为什么RPA无法处理导出和文档删除操作?】
RPA(机器人流程自动化)本质是模拟人在浏览器/客户端上的点击操作来完成任务。它擅长的是有明确 UI 交互流程的操作(如打开文档页面→点击下载→保存文件)。但有两类操作 RPA 做不了:① FAQ 导出——钉钉 FAQ 文档是 able 表格格式,它没有像普通文档那样的"下载"按钮,需要通过钉钉开放平台 API 编程导出,RPA 无法模拟 API 调用;② 文档删除——删除百炼向量库中的文件需要调用百炼 API(bailianClient.deleteFile()),这是后端 API 操作而非前端 UI 操作,RPA 无法执行。所以这两个场景由 Java 定时任务(DingDocSyncProcessor)通过编程方式直接调用 API 处理。
【dealDeleteTask() 这个定时任务的具体流程?】
dealDeleteTask() 的具体流程:① 从数据库中查询状态为"待删除"的任务记录 → ② 循环取出每条删除任务 → ③ 调用 bailianClient.deleteFile(fileId) 通过百炼 API 删除向量库中的文件 → ④ 删除成功后更新数据库任务状态为 SUCCESS → ⑤ 如果删除失败则记录错误日志,任务状态标记为 FAILED,等待下次定时任务重试。整个过程受 Guava RateLimiter 控制(删除操作 10 QPS),防止瞬间大量删除触发百炼 API 限流。
【dealFaqTask() 这个定时任务的具体流程?】
dealFaqTask() 的具体流程:① 从数据库查询状态为"待同步"的 FAQ 任务记录 → ② 循环取出每条 FAQ 任务 → ③ 调用 aliDingClientHelper.exportFAQ() 通过钉钉开放平台 API 将 FAQ 文档(able 表格格式)导出为临时文件 → ④ 调用 uploadDingDoc() 将临时文件上传到百炼向量库,上传时从文档路径提取圈人标签注入为百炼文件 tag → ⑤ 上传成功后更新任务状态为 SUCCESS → ⑥ 清理本地临时文件。上传操作同样受 RateLimiter 控制(上传 10 QPS)。
【Guava是啥?】
Guava 是 Google 开源的 Java 核心工具库,提供了集合、缓存、并发、字符串处理等大量实用工具。RateLimiter 是 Guava 中的令牌桶限流器——它以固定速率往"桶"里放令牌,每次调用前先 acquire() 获取一个令牌,桶里没令牌就阻塞等待。代码中定义了三个 RateLimiter:RateLimiter.create(5.0)表示每秒放 5 个令牌(即查询限 5 QPS)、RateLimiter.create(10.0)表示每秒放 10 个令牌(即删除/上传限 10 QPS)。这样即使程序并发很高,实际对百炼 API 的调用频率也不会超过这些阈值,避免触发百炼的服务端限流返回 429 错误。
端到端流式体验
端到端流式体验:Dubbo Triple StreamObserver 实现服务端流式推送,基于 ReplayProcessor + 百炼智能体流式 API 做背压控制与中间态兜底(1.5s 无响应自动推送加载提示),首字延迟下降约 60%;智能体 AppId 不可用时自动降级到工作流 AppId,保障流式链路高可用。
HSF 接口使用 Triple 协议(基于 HTTP/2 的 gRPC 兼容协议)暴露服务端流式接口,声明为 void callStream(ChatRequest request, StreamObserver<ChatResponse> response)。内部核心是 ReplayProcessor(RxJava 热流)作为桥梁:上游百炼流式 API 产生的每一帧通过 Flowable 推入 ReplayProcessor,下游通过 processor.subscribe() 订阅并逐帧转发到 StreamObserver.onNext(),每帧实时更新钉钉卡片,用户体验类似 ChatGPT 逐字出现。选择 ReplayProcessor 而非 PublishProcessor 是因为它能缓存所有历史帧(带 1 分钟时间窗口),晚加入的订阅者可以回放历史数据,避免丢帧。百炼 API 设置 incrementalOutput=true 增量输出,每帧只传输新增内容减少带宽。1.5s 加载提示通过 Spring TaskScheduler 延迟调度实现:检查 finalResult(AtomicReference)是否仍为空,是则推送灰色加载文案,百炼第一帧到达后直接覆盖。RAG 兜底策略:如果 RAG 答案包含"暂时没有相关内容",通过 RxJava concatWith(Flowable.defer(...)) 无缝拼接一次通识 LLM 问答流,调用方完全透明。AppId 降级逻辑:优先使用百炼智能体 AppId,未配置或不可用时降级到工作流 AppId。
【介绍下Dubbo Triple StreamObserver】
Dubbo Triple 是 Dubbo 3.0 引入的新协议,基于 HTTP/2 实现,兼容 gRPC 协议。它最大的特点是支持服务端流式推送(Server Streaming)——传统 RPC 是请求-响应一对一,而 Triple 允许一次请求返回多次响应。StreamObserver是流式响应的接口,它有三个方法:onNext(data) 推送一帧数据、onError(e) 推送错误、onCompleted() 标记流结束。在代码中,CxmOpenClientImpl 用 @HSFProvider(protocols="tri") 声明 Triple 协议,callStream() 方法接收请求后,将 CxmAiEngine 返回的 ReplayProcessor 热流订阅到 StreamObserver 上:每产生一帧 ChatResponse 就调用 response.onNext() 推给调用方,流结束时调用 response.onCompleted()。这样调用方就能像 ChatGPT 一样逐字接收 AI 回答,而不是等全部生成完才一次性返回。
【1.5s加载提示是什么文案:正在加载中吗?实现的具体原理是什么?】
加载提示文案是"正在搜索知识..."(灰色样式)。实现原理是利用 Spring TaskScheduler 的延迟调度:在发起百炼 API 调用的同时,调用 taskScheduler.schedule(() -> { ... }, Instant.now().plusMillis(1500)) 注册一个 1.5 秒后执行的定时任务。这个任务执行时检查 finalResult(AtomicReference)是否仍为空(即百炼还没返回任何内容),如果为空则通过 cxmRobotClient.updateCardDateStream() 推送灰色加载文案到钉钉卡片。当百炼第一帧到达时,流式更新会直接覆盖掉加载文案,用户看到的效果就是:1.5 秒内如果 AI 没响应就先看到"正在搜索知识...",AI 开始输出后立即看到实际内容逐字出现。这种设计避免了用户在等待时看到空白页面的焦虑感。
【模拟下finalResult为空/不为空的情况,以及RAG兜底/不兜底的情况】
四种场景模拟如下:
- 场景A:finalResult 不为空 + RAG 不兜底(正常流程)
用户问"年假还剩几天"→ RAG 召回到相关政策切片 → 百炼流式 API 正常返回 → finalResult 被逐帧填充 → 1.5s 定时任务检查时 finalResult 已有值,不推送加载提示 → 用户直接看到逐字出现的答案"根据您的考勤记录,您的年假余额为..."- 场景B:finalResult 为空(超时/失败)
用户问"xxx"→ 百炼 API 调用卡住或失败 → 1.5s 后定时任务检查 finalResult 为空 → 推送"正在搜索知识..."加载文案 → 如果最终超时失败,走 answerWithFallback() 返回兜底内容- 场景C:finalResult 不为空 + RAG 兜底
用户问"今天天气怎么样"(知识库无相关内容)→ RAG 召回到的切片不相关 → 百炼正常返回但答案包含"暂时没有相关内容" → 系统检测到这个关键词 → 通过 RxJava concatWith(Flowable.defer(...)) 在当前流后面无缝拼接一次通识 LLM 问答流 → 用户看到的效果是先显示"暂时没有相关内容"然后自动接上通识回答(如"今天杭州天气晴,25°C...")- 场景D:finalResult 为空 + RAG 兜底
百炼 API 调用失败 → 1.5s 推送加载文案 → 最终走 fallback → 直接用通识 LLM 回答用户问题,跳过 RAG
【具体的AppId降级逻辑是什么】
代码中通过 determineAppId() 方法实现降级。优先读取 Diamond 配置中的 bailianAgentAppId(智能体 ID),如果该字段不为空且非空字符串则使用它;如果未配置或为空,则降级使用 bailianAppId(工作流 ID)。智能体 AppId 对应的是百炼平台上的"智能体应用"(功能更丰富,支持插件/工具调用),工作流 AppId 对应的是"工作流应用"(功能较基础,但更稳定)。这种设计保证了:正常情况下用功能更强的智能体,智能体不可用时自动退回到稳定的工作流,保障流式链路始终可用。
MCP Server 标准化
MCP Server 标准化对外开放:基于 MCP 协议暴露员工信息查询、知识召回、相似问匹配等标准 Tool,支持外部 Agent 通过统一协议编排调用,降低跨系统集成成本。
使用 @alibaba/mcp-lite 框架,通过 @Tool 注解声明 3 个 MCP Tool:getCxmEmployeeInfo(获取用户身份信息)、knowledgeRecall(知识检索,指定三个知识源并发召回,返回鉴权后的原始知识切片列表)、similarQuestion(相似问题匹配)。@McpContextAware 注解自动注入 MCP 上下文,通过 McpContext.getUser() 获取调用者身份。设计上只暴露原子能力,不包含 LLM 总结步骤——外部 Agent(如集团悟空平台)有自己的 System Prompt 和回答策略,Tool 内部做总结会限制调用方的二次推理能力。knowledgeRecall 内部复用 RecallServiceImpl 和 RAG 策略链,避免重复建设。会话标识加 wukong_ 前缀区分来源,便于后续的流量分析和问题排查。
【@alibaba/mcp-lite,简要介绍下这个框架的原理,以及怎么能通过@Tool注解声明MCP Tool】
MCP(Model Context Protocol)是 Anthropic 提出的一套标准协议,用于 LLM 与外部工具/数据源的标准化交互。@alibaba/mcp-lite 是阿里内部对 MCP 协议的轻量级 Java 实现框架。原理是:框架在 Spring 容器启动时扫描所有带 @Tool 注解的方法,自动将方法签名(方法名、参数列表、描述信息)转换为 MCP 协议定义的 Tool Schema(JSON 格式),注册到 MCP Server 中。当外部 Agent(如集团悟空平台)通过 MCP 协议发起 Tool 调用时,框架根据 tool name 路由到对应的 Java 方法,自动做参数反序列化、调用方法、序列化返回值。@McpContextAware 注解让框架自动注入 MCP 上下文对象,通过 McpContext.getUser() 可以拿到调用者身份信息。开发者只需在方法上加 @Tool(name="xxx", description="xxx") 注解,就完成了一个 MCP Tool 的声明,不需要手动编写协议层代码。
【标识
wukong_,这个会话标识是代码里写死的吗?还是调用MCP的一方传入的?】
代码里写死的。CxmMcpTool 类中定义了常量private static final String PLATFORM_SESSION_ID_PREFIX = "wukong_";,调用方通过 MCP 协议传入的 platformSessionId 会被代码自动拼接上这个前缀:String platformSessionIdWithPrefix = PLATFORM_SESSION_ID_PREFIX + platformSessionId;。这样做的目的是在会话存储和日志中,通过前缀快速区分流量来源——带 "wukong_" 前缀的会话来自悟空平台的 MCP 调用,不带前缀的来自钉钉原生入口,便于后续的流量统计、问题排查和分源分析。
Prompt 工程化与质量闭环
Prompt 工程化与质量闭环:Prompt 与 Tool Schema 外置到配置中心,支持分钟级热更无需发版;离线 LLM-as-a-Judge 评估任务对答案打分回流,驱动 Prompt 与召回策略持续迭代。
所有 Prompt 模板通过阿里 Diamond 配置中心管理,每个 Prompt 是一个独立的 @DiamondListener 类(如 PromptToolRouting、PromptRagAnswerInstruction 等,共 17 个独立 dataId),收到推送后更新 volatile 静态变量,调用方通过 buildPrompt() 做占位符替换获取最新模板。Tool Schema 同理,AiAssistantConfigData.tools 列表配置在 Diamond,包含 operationId、title、description、exampleQueries、parameters 和 crowdRuleId,热更新即可添加/修改/禁用工具。LLM-as-a-Judge 评估流程:SchedulerX 定时任务触发 CxmAnswerScoreServiceImpl.evaluateAnswerScore(),从云灵知识平台采集标准 Q&A 对作为评估基线;5 线程固定池并发处理——每条记录先用灰度环境的 AI 助理回答标准问题,再用 LLM(评分 Prompt 也外置在 Diamond)比对标准答案和 AI 助理答案给出分数(BigDecimal 存储);下一轮只重新评估 4 小时前且准确率低于 0.85 的记录,持续改进低分项。低分记录按类目聚合,针对性调整该类目的 RAG 召回策略或 Prompt 模板。
【为什么需要多个Prompt模板】
因为 AI 助理的不同环节需要完全不同的指令和输出格式,一个万能 Prompt 无法胜任。代码中有 17 个独立的 Prompt 模板,各司其职:
- PromptToolRouting:技能路由——告诉 LLM "这些是可用工具列表,判断用户问题属于哪个工具",输出格式是 operationId
- PromptQueryRewriteWithContext:上下文重写——告诉 LLM "结合历史对话补全指代和省略",输出格式是重写后的完整问题
- PromptQueryRewriteWithKeywords:关键词扩展——告诉 LLM "对问题做同义词扩展",输出格式是扩展后的关键词列表
- PromptQueryRewriteWithUserInfo:用户信息重写——告诉 LLM "结合员工信息补充问题"
- PromptKnowledgeFilterTag:知识库标签识别——告诉 LLM "判断问题属于哪个知识库分类"
- PromptRagAnswerInstruction:RAG 回答指令——告诉 LLM "根据以下知识片段回答用户问题"
- PromptLangDetect:语言识别——告诉 LLM "识别用户输入的语言"
- PromptEvaluateAnswerScore:答案评分——告诉 LLM "对比标准答案和 AI 答案打分"
- 每个 Prompt 的 System Prompt、输出契约、Few-shot 示例都不同,拆分成独立模板才能各自独立迭代优化,互不干扰。
【LLM-as-a-Judge这个评估具体的流程是什么?】
完整流程如下:① SchedulerX 定时任务触发 CxmAnswerScoreProcessor → ② 调用 CxmAnswerScoreServiceImpl.evaluateAnswerScore() → ③ 从数据库查询需要评估的记录(未评估过的 + 4小时前且低于0.85分的) → ④ 用 5 线程固定池并发处理每条记录:先调用灰度环境的 AI 助理(caixiaomiGrayAssistantClient.talk())用标准问题提问,获取 AI 的回答 → ⑤ 拼装评分 Prompt(PromptEvaluateAnswerScore.buildPrompt()),将标准答案和 AI 回答送入百炼 LLM 做对比评分 → ⑥ LLM 返回 0-1 之间的分数(BigDecimal 存储),写入 cxm_answer_score 表 → ⑦ 低分记录按知识类目聚合,反馈给运营人员针对性优化该类目的 Prompt 或召回策略。
【定时任务的触发频率?】
定时任务的具体触发频率在 SchedulerX 控制台配置,代码中没有硬编码 cron 表达式。项目通过 auto-config.xml 配置了 SchedulerX 2.0 的 groupId(cn-work)和 appKey,CxmAnswerScoreProcessor 实现 MapJobProcessor 接口作为任务处理器。触发频率由运维在 SchedulerX 控制台设置(通常是每天定时跑一次或每隔几小时跑一次),代码侧只负责实现任务逻辑。
【线程为5的线程池吗?为什么要这样设置线程池?maxPoolSize、corePoolSize是怎么设置的?】
是的,使用Executors.newFixedThreadPool(5)创建固定线程池。对于 FixedThreadPool,corePoolSize = maxPoolSize = 5,工作队列是无界的 LinkedBlockingQueue(容量 Integer.MAX_VALUE)。设置为 5 个线程的考虑:每个线程都要调用灰度环境的 AI 助理 + 再调一次百炼 LLM 评分,两次 LLM 调用每次耗时约 5-10 秒,5 个并发已经能提供足够的吞吐量(约每分钟处理 30-60 条),同时不会对灰度环境的 AI 助理造成过大压力。设太多线程会打爆灰度环境的并发限制,设太少则评估速度太慢。
【灰度环境和正式环境的AI助理的RAG召回策略等信息是用的相同的配置吗?如果不是,二者的同步策略是什么?】
灰度环境和正式环境使用不同的 Diamond 配置空间。Diamond 配置中心按环境隔离:daily(日常)、pre(预发/灰度)、online(正式)各有独立的配置集。代码通过 EnvUtil 判断当前运行环境加载对应配置。评估任务专门调用灰度环境的 AI 助理(caixiaomiGrayAssistantClient),是因为灰度环境通常部署了最新的 Prompt 和召回策略,评估的目的就是验证这些变更的效果。二者的同步策略是人工发布流程:先在灰度环境调整 Prompt/策略 → 用评估任务验证效果 → 评分达标后再手动将配置同步推送到正式环境的 Diamond。
【为什么只评估4小时前且准确率低于0.85的记录,这样设置的依据是什么?】
这两个条件各有其设计依据:
- 4小时前:避免重复评估刚处理过的记录。评估任务每次触发时,刚在上一轮被评过分的记录(即使分数低)不会立即被再次评估,需要等 4 小时。这个时间窗口留给运营人员查看低分记录、调整 Prompt 或召回策略,调整生效后下次评估才有意义。如果不设时间窗口,同一条低分记录会被反复评估、反复产出相同的低分,浪费 LLM 调用配额。
- 准确率低于 0.85:这是质量基线阈值。得分 ≥0.85 的记录被认为答案质量合格,无需持续关注;低于 0.85 的才是需要持续改进的"问题记录"。0.85 这个值是经验值——在实际评估中,0.85 分以上的回答用户体验较好,低于这个值通常意味着回答有明显错误或遗漏。此外,未评估过的记录(ai_answer 或 accuracy_rate 为 null)也会被选入评估。
面试深挖 Q&A
请介绍一下这个项目,你在其中主要负责什么工作?
A:这是面向集团内部数万员工的 HR 领域 Agent,以自然语言对话替代传统表单与人工咨询,覆盖知识问答、智能请假、证明开具等高频场景,日均对话数万次,显著降低 HR 工作量。该 Agent 系统包含多源 RAG 知识检索、两段式意图路由、端到端流式输出、MCP 标准化开放、Prompt 工程化与质量闭环等核心模块。整个系统采用 DDD 分层架构:hcm-ai-application 层承载 RAG 策略编排(如 AbstractKnowledgeRetrievalStrategy 及其 4 个子类)和工具执行器(ToolExecutor 接口及 10+ 实现);hcm-ai-infrastructure 层封装百炼 API 调用、流式客户端(CxmOpenClientImpl)、Guava RateLimiter 等外部依赖;hcm-ai-interfaces 层负责 HSF Triple 协议接口暴露、SchedulerX 定时任务(知识同步/评估)和 MCP Tool 声明;hcm-ai-domain 层定义领域对象和枚举(如 KnowledgeSourceEnum 4 种知识源枚举)。这种分层使得 RAG 策略变更不影响接口协议,基础设施切换(比如百炼 API 升级)不侵入业务逻辑,各层可独立演进。我主要参与了多源 RAG 系统的并发召回与 Rerank 重排、两段式路由引擎中的策略模式抽象与工具注册机制、MCP Server 标准化对外开放、以及 Prompt 外置热更与 LLM-as-a-Judge 离线评估体系的落地。
做项目中遇到的最大挑战是什么?
A:最大挑战是多源 RAG 链路中的并发安全与鉴权降级设计。具体来说有三个层次:
第一层:三层线程池嵌套导致的线程饥饿死锁。RAG 召回的调用链是 RAG_STRATEGY_POOL → RAG_RETRIEVE_POOL → KNOWLEDGE_AUTH_POOL,三层嵌套依赖。初期用了一个共享线程池,压测时发现当并发请求稍多,外层策略任务占满了所有线程,内层的百炼 API 调用和鉴权拿不到线程,但外层又在等内层结果——经典的线程饥饿死锁。解决方式是拆成三个独立线程池,按调用层级隔离,每层有自己独立的线程配额。核心判断依据是"谁等谁":外层等内层,就必须分开,否则必然死锁。
第二层:CompletableFuture + Stream 的惰性求值陷阱。并发召回初期写成了 stream().map(...supplyAsync...).flatMap(f -> f.join()),自以为并发了,实际测试发现和串行差不多慢。排查后发现 Java Stream 的惰性求值导致每个 Future 创建后立刻 join 阻塞,下一个 Future 根本没机会创建。改成先 collect(Collectors.toList()) 强制触发所有 Future 创建,再统一 join 收集结果,耗时从 ~8s 降到 ~2s。
第三层:鉴权超时降级的关键切片丢失问题。鉴权调用外部服务(钉钉 API、DocumentOpenService)延迟不可控,设置了 3s 总超时熔断。但超时后未完成的切片直接被视为"无权限"丢弃,可能导致高相关性切片丢失、回答质量下降。这是一个可用性与准确性的权衡——最终选择了"可用性优先"策略,因为 Rerank 从 Top-100 收敛到 Top-K(默认20),即使丢失几条鉴权超时的切片,剩余切片通常仍能覆盖问题。后续优化方向是二次异步补鉴权:超时降级的切片后台异步补做鉴权结果写入缓存,下次同用户提问时直接命中缓存。
多源 RAG 系统中,4 套异构知识源分别是什么?为什么要做多源而不是统一成一个向量库?
A:4 套知识源分别是:政策库(公司制度文档,权威性最高、更新频率最低)、知识平台已审核内容(经审核的标准知识条目)、知识平台未审核草稿态内容(时效性强但未经人工审核)、钉钉协同文档(业务团队实时编辑的工作文档)。之所以不统一成一个向量库,核心原因有三:一是数据来源和更新频率差异大,政策库可能数月更新一次但权威性最高需要高权重,钉钉文档可能每天变更但内容质量参差不齐,草稿态内容需要明确标注"未审核"提醒用户谨慎采信;二是鉴权模型不同,政策库按文档路径标签做圈人鉴权,知识平台按审核状态区分可见性,钉钉文档按知识库空间权限控制访问范围,统一存储会丢失这些细粒度权限语义——假设把所有文档混在一个向量库里,就无法在 retrieve 阶段通过 filterTags 做权限预过滤,只能召回后再鉴权,浪费大量无效召回;三是召回策略差异,每个知识源的 filterTags 构建逻辑不同(比如钉钉文档要按用户所属圈人标签过滤,而政策库需要按文档路径层级匹配),需要各自的 Strategy 子类实现差异化逻辑。代码上通过 AbstractKnowledgeRetrievalStrategy 模板方法抽象,定义了标准流程:buildFilterTags() 构建过滤条件 → 调用百炼 retrieve API 检索 → authSlice() 文档鉴权。子类只需实现这三个差异点即可接入新知识源,这种设计也方便独立调优每个知识源的召回参数(如 Top-K、相似度阈值),互不影响。面试中可以进一步讨论:这本质上是"领域驱动的知识分层"设计,每个知识源对应一种领域语境。
CompletableFuture 并发召回时,你提到要"先 collect 再 join",为什么?如果直接 stream().map(future -> future.join()) 会怎样?
A:如果写成 stream().map(strategy -> CompletableFuture.supplyAsync(...)).flatMap(f -> f.join().stream()),由于 Java Stream 的惰性求值特性,每个元素会走完整个 pipeline 再处理下一个——也就是说第一个 Future 创建后立刻 join 阻塞等待完成,然后才创建第二个 Future。这就退化成了串行执行,完全丧失了并发的意义。根本原因在于 Stream 的中间操作(map/flatMap)是惰性的,只有遇到终端操作(collect/forEach)才触发实际执行,而且默认是单线程顺序处理管线中的每个元素。正确做法是先 .collect(Collectors.toList()) 作为第一个终端操作,强制触发所有 Future 的创建——此时 4 个异步任务已经同时提交到线程池开始执行——再用第二个 .stream().flatMap(future -> future.join().stream()) 依次收集结果。这样总耗时约等于最慢的那个知识源(通常 1-2s),而非所有知识源的耗时之和(可能 4-8s)。代码注释里也明确标注了"先 collect 收集所有 Future,否则变成串行"。这个陷阱在 CompletableFuture + Stream 组合使用中非常常见,本质是开发者忽略了 Stream 的惰性语义。面试中如果被追问,可以进一步延伸:parallelStream 虽然能并行,但它使用 ForkJoinPool.commonPool,无法控制线程池隔离,且 commonPool 大小默认等于 CPU 核心数减一,对 IO 密集型任务严重不足。所以我们选择显式使用 CompletableFuture + 自定义线程池的方式,对并发度、超时、异常处理都有完全的控制权。
你提到用了三层线程池隔离,具体怎么设计的?为什么不用一个线程池?
A:三层线程池分别是:外层 RAG_STRATEGY_POOL(core=30, max=50)驱动 4 个 Strategy 并发召回;内层 RAG_RETRIEVE_POOL(core=100, max=150)处理每个 Strategy 内部多 query × 多知识库的并发百炼 API 调用;鉴权层 KNOWLEDGE_AUTH_POOL(core=200, max=300)处理每个知识切片的文档权限校验。不能共用一个线程池的根本原因是防止线程饥饿死锁(Thread Starvation Deadlock):假设只有一个 100 线程的池,外层 4 个 Strategy 占用 4 个线程后,每个 Strategy 内部又要提交 N 个 retrieve 子任务到同一个池。当并发请求稍多(比如 QPS=3 同时来了 3 个请求,占用 12 个外层线程,每个要提交 5-10 个内层任务),线程池很快被占满,新提交的子任务全部排队等待,但持有线程的外层任务又在 future.join() 等内层任务完成,形成循环等待——经典的死锁。三层隔离后,每层有独立的线程资源,外层等内层时不会阻塞内层的线程分配。线程池参数是基于 QPS=3 推导的:外层 4 个 Strategy × 3 QPS = 12 并发,core=30 留了 2.5 倍余量应对突发流量;内层每个 Strategy 可能并发 5-8 个百炼 API 调用(多 query × 多知识库组合),4 Strategy × 8 × 3 QPS ≈ 96,所以 core=100;鉴权层每个知识切片独立鉴权,Top-20 切片 × 4 知识源 × 3 QPS = 240,core=200 接近满负荷,max=300 兜底。所有线程池都配置了自定义的 ThreadFactory 设置线程名前缀(如 rag-strategy-),方便在线程 dump 和日志中快速定位问题。面试中可以延伸讨论:这种分层线程池模式与 Bulkhead Pattern(舱壁隔离模式)思想一致,核心原则是"不同优先级/不同生命周期的任务必须使用独立资源"。
单源超时 3s 熔断降级是怎么实现的?超时后未完成的 Future 怎么处理?
A:在 AbstractKnowledgeRetrievalStrategy.collectAuthResults() 中用 CompletableFuture.allOf(futures).get(3, TimeUnit.SECONDS) 设置鉴权阶段的总超时。catch 到 TimeoutException 后,遍历所有 Future,通过 isDone() 判断哪些已完成,只收集已完成的鉴权结果,未完成的切片标记为无权限(降级处理),相当于"宁可少展示、不可慢响应"。3 秒阈值的选取基于两个考量:一是用户体验——HR 咨询场景下,3 秒内首字不出会导致用户明显感知到卡顿;二是鉴权接口的 P99 延迟统计,正常情况下单次鉴权 200-500ms,3 秒足够覆盖绝大多数请求,超时意味着下游服务可能出了问题。未完成的 Future 本身不会被取消——CompletableFuture 不支持真正的中断(cancel(true) 只是设置中断标志,如果底层是 HTTP 调用并不会中断 Socket),它们会在后台继续执行直到完成,但结果被丢弃。这在当前场景下是可接受的,因为鉴权操作本身是幂等的读操作,不会有副作用,只是浪费了一些线程资源。如果要做得更精细,可以考虑在 OkHttp 层设置 per-call timeout 来真正中断网络 IO,但目前的 QPS 水平下不值得增加这层复杂度。超时降级后会在日志中记录哪些切片超时、原始 query 和知识源类型,方便后续排查是某个知识源普遍慢还是偶发抖动。
Rerank 重排的具体策略是什么?为什么要做两段 Rerank?
A:Rerank 分两段:第一段在百炼 retrieve API 内部,使用 qwen3-rerank-hybrid 模型对向量检索结果做初步排序,从 Top-100 收敛到 Top-20——这里选 hybrid 模型是因为它同时结合了向量语义相似度和稀疏词频匹配(类似 BM25),在 HR 领域存在大量专业术语(如"N+1 赔偿"、"竞业限制"、"试用期转正")的场景下,纯向量检索可能因语义相似但概念不同而产生误召回(如"年假"与"产假"语义接近但政策完全不同),hybrid 模式通过精确词匹配弥补了这个短板;第二段在应用层 SliceHelper.reRank() 中,用 qwen3-rerank 模型对文档名(而非正文)做 rerank,然后与原始召回分加权融合:最终得分 = 原召回分 × 0.7 + 文档名 rerank 分 × 0.3。做两段 Rerank 的必要性在于:第一段是在单个知识库内排序,每个知识源独立返回 Top-20;第二段是跨 4 个知识源的全局排序——4 个知识源各自返回的 Top-20 合并后可能有 80 条,需要统一标准重排取真正的 Top-K。为什么第二段不直接用正文 rerank 而是用文档名?因为正文 rerank 在第一段已经做过了,第二段的核心目的是引入"来源权威性"信号:文档名包含了知识来源、分类等元信息(如"公司差旅报销政策 V3.0"),文档名与 query 的匹配度能反映知识源的相关性维度。权重 0.7/0.3 是经过离线评测调优的经验值——正文语义相似度是主信号,文档名匹配是辅助信号,过高的文档名权重会导致标题党文档排名虚高。最终的 Top-K 数量由 Diamond 配置 knowledgeSliceTopK 控制(默认 20),注入到 RAG 答案生成的 Prompt 上下文中,这个值太小会丢失相关信息,太大会导致 Context 超长增加 Token 成本和推理延迟。
两段式路由引擎中,"规则前置拦截"和"LLM Function Routing"的分工标准是什么?
A:分工标准是确定性 vs 模糊意图。前置拦截器处理两类确定性场景:一是特殊指令(如"开启新话题"、"清除历史"等固定文本指令),通过 SpecialCommandTool 直接匹配关键词,零 LLM 调用,延迟 <5ms;二是相似问题精确匹配(FAQ),通过 SimilarQuestionTool 在预构建的 FAQ 库中查找高相似度匹配,命中后直接返回标准答案。这两类场景不需要 LLM 理解意图,本地规则就能判定,走 LLM 反而浪费时间和 Token。只有前置拦截器全部返回 null(表示"我不处理"),才进入 LLM Function Routing 阶段。架构上的关键设计:前置拦截器通过 isPreInterceptor() 方法标识自己,ToolExecutorRegistry 在构造映射表时会过滤掉所有 isPreInterceptor() == true 的实现——这意味着前置拦截器不会出现在 LLM 路由的工具列表 Prompt 中,LLM 完全不知道它们的存在。执行顺序通过 CxmAiEngine.executeToolForRobot() 中的硬编码保证:先按固定优先级依次执行前置拦截器,只有全部不命中才走 aiRouting()。这种设计的好处不仅是低延迟,还能防止 LLM 路由误判——比如用户说"开启新话题",如果交给 LLM 路由,它可能误识别为知识问答意图;前置拦截能保证 100% 的确定性命中率。面试中可以延伸讨论:这本质是一种分层决策架构,与推荐系统中"粗排 → 精排 → 重排"的思路类似——用低成本手段先过滤确定性 case,把不确定的留给高成本的 LLM。
LLM Function Routing 阶段并发了几个 LLM 调用?为什么要并发而不是串行?
A:并发了 4 个 LLM 调用:技能路由(将工具列表序列化为 Prompt,用 LLM 识别用户意图匹配哪个工具)、语言识别(判断用户输入是中文/英文/繁体,用于后续回答的语言适配)、问题重写(内部又嵌套并发 3 路:上下文重写——结合对话历史补全指代/省略、关键词+同义词扩展——增加召回覆盖面、用户身份信息重写——注入用户所属公司/部门等上下文)、知识库标签识别(判断该查哪个知识子库,用于 RAG 阶段的 filterTags 构建)。实际最多 6 个并发 LLM 调用(4 主路 + 问题重写内嵌 3 路,重写算 1 主路)。之所以并发,是因为这 4 个任务之间没有数据依赖——路由不需要等重写结果(路由基于原始 query,重写基于扩展后的 query 用于 RAG),语言识别不依赖标签识别。串行执行需要累加每个调用的延迟(每个约 200-500ms),总延迟可能达 2 秒以上,严重影响用户对首字响应的感知。并发执行后总延迟只取决于最慢的那个调用,通常在 500ms 以内。路由阶段统一使用 qwen-flash 模型(qwen 系列中速度最快的版本),选它而非 qwen-plus/qwen-max 的原因是:路由和分类任务的复杂度远低于生成任务,flash 模型的准确率与 plus 无明显差异(内部评测路由准确率均 >95%),但推理速度快 3-5 倍且 Token 成本低一个量级。如果某个并发 LLM 调用失败,不会阻塞整体——比如语言识别失败就默认中文,标签识别失败就查全部知识库,保证主链路的容错能力。
ToolExecutor 策略模式的注册机制是怎么实现的?新增一个技能需要改多少代码?
A:注册通过 Spring 自动依赖注入实现:ToolExecutorRegistry 构造器注入 List<ToolExecutor>,Spring 容器启动时自动收集所有实现了 ToolExecutor 接口且标注 @Component 的 Bean,注入到这个列表中。Registry 在初始化时做两件事:一是过滤掉所有 isPreInterceptor() == true 的实现(它们走前置拦截路径,不参与 LLM 路由);二是按 getOperationId() 建立 Map<String, ToolExecutor> 映射表,路由结果返回 operationId 后直接查表获取对应的执行器。新增技能分两步:代码侧只需新写一个类实现 ToolExecutor 接口(约定 getOperationId() 返回唯一标识、execute() 方法实现业务逻辑),加 @Component 注解即可在下次发版时自动注册——注意这里不需要修改 Registry 或 Engine 的任何代码,完全开闭原则;配置侧在 Diamond 的 AiAssistantConfigData.tools 列表中添加该工具的 JSON 描述(operationId、title、description、exampleQueries、parameters、crowdRuleId),LLM 路由 Prompt 会自动将新工具的描述序列化进去。配置是热更新的,意味着工具的描述文案、参数定义甚至圈人规则都可以在不发版的情况下调整。整个过程对 CxmAiEngine 主流程零侵入。目前已注册 10+ 工具,包括知识问答(兜底技能)、智能请假、拨打热线、智能问数、人才档案、智能证明、智能商保、假期余额、请假记录、调动等。面试深入点:可以讨论为什么用 Spring 自动注入而不是 SPI 机制——Spring 自动注入天然支持条件装配(@Conditional),未来某个技能要按环境/租户动态启停只需加注解,不需要改 Registry 逻辑。
Dubbo Triple StreamObserver 流式推送是怎么实现的?ReplayProcessor 起什么作用?
A:HSF 接口声明为 void callStream(ChatRequest request, StreamObserver<ChatResponse> response),使用 Triple 协议(基于 HTTP/2 的 gRPC 兼容协议)暴露服务端流式接口。选择 Triple 而非 WebSocket 或 SSE 的原因是:Triple 原生支持双向流、自带序列化(Protobuf/Hessian)、与公司 HSF 生态无缝集成,不需要额外搭建 WebSocket 网关。内部核心是 ReplayProcessor(RxJava 热流)作为桥梁连接上下游:上游百炼流式 API 通过 CxmOpenClientImpl 发起调用,设置 incrementalOutput=true 启用增量输出模式(每帧只传输新增文本而非全量累积),百炼返回的 Flowable 流每产生一帧就 push 到 ReplayProcessor;下游通过 processor.subscribe() 订阅,在回调中逐帧封装为 ChatResponse 并调用 StreamObserver.onNext() 推送到客户端。选择 ReplayProcessor 而非 PublishProcessor 的关键原因是:ReplayProcessor 带有 createWithTime(1, TimeUnit.MINUTES) 配置的时间窗口缓存,晚加入的订阅者可以回放窗口内的所有历史帧。这在我们的场景中至关重要——因为百炼流可能在 subscribe 之前就已经开始产生数据(异步调度的时序问题),PublishProcessor 会丢弃 subscribe 之前的帧,导致用户看到的回答缺少开头部分。1 分钟的时间窗口足够覆盖一次完整对话的流式输出周期。背压控制方面,Flowable 原生支持 Reactive Streams backpressure 规范,当下游消费速度慢于上游产生速度时会自动缓冲,避免数据丢失。
1.5s 无响应自动推送加载提示是怎么实现的?如果和第一帧数据竞争怎么办?
A:通过 Spring TaskScheduler 在请求发起时调度一个 1500ms 延迟任务。延迟任务执行时检查 finalResult(AtomicReference<String>)是否仍为初始空字符串,如果是,说明 1.5 秒内百炼还没返回任何内容,就推送一条灰色的"正在为您检索知识库..."加载提示到钉钉卡片,给用户以视觉反馈。当百炼第一帧数据到达时,会通过 updateCardDateStream() 更新同一张卡片的 content 字段,直接覆盖加载提示文案。为什么选 1.5 秒?这是基于用户体验研究的经验值——Web 端超过 1 秒无反馈用户开始焦虑,超过 3 秒用户可能放弃,1.5 秒在"避免过早显示加载提示(大部分请求 1 秒内出首字)"和"不让用户等太久"之间取了平衡。竞争问题的处理分三种情况分析:第一种,百炼在 1.5s 内返回了第一帧,finalResult 已通过 compareAndSet 被设为非空,延迟任务检查后直接跳过不推送——不存在竞争。第二种,百炼恰好在 1.5s 左右返回,延迟任务和数据帧几乎同时到达——由于 AtomicReference.compareAndSet 是 CAS 原子操作,只有一方能成功将空字符串设为非空,保证了状态转换的原子性;即使加载提示先推送了,数据帧紧随其后覆盖卡片内容,钉钉卡片更新是最终一致的(以最后一次更新为准),用户看到的短暂闪烁在实际网络延迟下几乎不可感知。第三种,百炼始终没有返回(如 API 超时),加载提示会持续展示直到超时兜底逻辑触发错误提示。这种设计的核心思路是"乐观并发"——不加锁、不互斥,通过原子变量 + 覆盖语义保证最终一致性。
MCP Server 暴露了哪些 Tool?为什么只暴露原子能力而不包含 LLM 总结?
A:暴露了 3 个 MCP Tool:getCxmEmployeeInfo(获取用户身份信息,包括工号、姓名、部门、职级等)、knowledgeRecall(知识检索,指定政策库/知识平台/协同文档三个知识源并发召回,返回鉴权后的原始知识切片列表)、similarQuestion(相似问题匹配,在 FAQ 库中查找最相似的标准问答对)。只暴露原子能力是刻意的设计选择,核心原因是"不越权":MCP 的定位是让外部 Agent(如集团悟空平台)编排调用,外部 Agent 有自己的 System Prompt、上下文窗口和回答策略——如果我们在 Tool 内部就做了 LLM 总结,外部 Agent 收到的就是一段被"加工"过的文本,无法基于原始知识切片做二次推理(如交叉引用多个切片)、格式定制(如生成表格对比)或引用溯源。把 LLM 总结留给调用方,保持了最大的灵活性,符合 MCP 协议"Tool 做能力、Agent 做决策"的哲学。实现细节上,使用 @alibaba/mcp-lite 框架,通过 @Tool 注解声明 MCP Tool,@McpContextAware 注解自动注入 MCP 上下文——McpContext.getUser() 获取调用者身份用于鉴权,不需要额外的身份参数透传。knowledgeRecall 内部复用 RecallServiceImpl 和 AbstractKnowledgeRetrievalStrategy,与自有对话入口共享同一套 RAG 链路,避免重复建设。会话标识加 wukong_ 前缀区分外部 MCP 调用和自有入口调用,便于后续的流量分析、问题排查和计费统计。面试延伸点:可以讨论 MCP 的 Tool vs Resource 原语选择——我们用 Tool 而非 Resource,因为知识检索是有参数的主动操作(需要传 query),不是被动的数据暴露。
Prompt 和 Tool Schema 外置到配置中心具体怎么做的?怎么保证热更新的安全性?
A:每个 Prompt 模板是一个独立的 @DiamondListener 类,通过阿里 Diamond 配置中心管理。比如 PromptToolRouting 监听 dataId com.cainiao.management.cnwork:prompt:toolsRouting,收到推送后更新 volatile 静态变量,调用方通过 buildPrompt() 静态方法获取最新模板做占位符替换(如 {tools}、{userInput}、{conversationHistory} 等)。目前有 17 个独立 Prompt dataId,涵盖路由、RAG 答案生成、问题重写、语言识别、标签识别、评分等各环节。为什么用 17 个独立 dataId 而不是一个大 JSON?因为不同 Prompt 的修改频率和负责人不同,分开管理可以独立灰度推送、独立回滚,减少变更影响面。volatile 关键字保证了多线程场景下的可见性——Diamond 回调线程更新变量后,业务请求线程能立即读到最新值,无需加锁。Tool Schema 同理,AiAssistantConfigData.tools 列表配置在 Diamond,每个工具是一个 JSON 对象,包含 operationId、title、description(给 LLM 看的自然语言描述)、exampleQueries(示例问题列表,用于路由 Prompt 的 few-shot)、parameters(参数定义)和 crowdRuleId(圈人规则 ID),热更新即可添加/修改/禁用工具。安全性方面,Diamond 本身有灰度推送(先推一台观察再全量)、版本回滚能力(每次推送保留历史版本);代码层面目前依赖 Diamond 的审计日志做变更追踪。这个项目未在应用侧做推送 lint 校验,是一个已知的改进点——在薪酬 AI 质检项目中我们补上了这个短板,推送前做长度下限 + 关键词白名单 lint,防止空 Prompt 上线导致全链路故障。
LLM-as-a-Judge 评估的具体流程是什么?怎么驱动 Prompt 迭代?
A:评估流程分三步:首先从云灵知识平台采集标准 Q&A 对(collectQAFromXl 方法),按类目分页拉取写入本地数据库作为评估基线——标准 Q&A 由 HR 业务方维护,每条包含标准问题、标准答案、所属类目(如"请假政策"、"入职流程"、"差旅报销"等);然后 SchedulerX 定时任务触发 CxmAnswerScoreServiceImpl.evaluateAnswerScore(),用 5 线程固定池(Executors.newFixedThreadPool(5))并发处理——每条记录先调用灰度环境的 AI 助理 callStream 接口获取对标准问题的回答(注意必须用灰度环境而非线上,避免评测流量污染真实用户数据),再用独立的 LLM 调用(评分 Prompt 也外置在 Diamond)比对标准答案和 AI 助理的实际答案,按相关性、完整性、准确性三个维度给出综合分数,分数用 BigDecimal 存储(避免浮点精度问题,便于后续做阈值比较)。最后分数写入数据库,下一轮评估时只重新评估"4 小时前评估且准确率低于 0.85"的记录——这个策略实现了"持续改进低分项"的效果:高分记录(>0.85)视为已达标不再重复评估(节省 LLM 调用成本),低分记录持续跟踪直到改善。驱动 Prompt 迭代的闭环是:低分记录按类目聚合后,分析哪个类目的平均准确率持续偏低——如果是"请假政策"类目低分,可能是 RAG 没有召回正确的请假政策文档,需要调整该类目的 filterTags 或知识库标签;如果是回答质量问题(信息正确但表述不好),则调整 RAG 答案生成的 Prompt 模板。5 线程的选择是权衡评估速度和灰度环境负载——太多并发会影响灰度环境的其他测试活动。
RAG 答案不满意时有什么兜底策略?
A:有两层兜底,形成"RAG 知识回答 → 通识 LLM 兜底 → 路由降级兜底"的三级容错体系。第一层是 RAG 到通识问答的无缝切换:在 answerWithFallback() 中,系统会监听 RAG 流式输出的累积内容,如果检测到包含"暂时没有相关内容"这类特定话术(说明 RAG 没有召回到有效知识),通过 RxJava 的 concatWith(Flowable.defer(...)) 无缝拼接一次通识 LLM 问答流——defer 的作用是延迟创建 Flowable,只有前序流触发了兜底条件才会实际发起通识 LLM 调用,不浪费资源。concatWith 保证前序流完成后再拼接后续流,对调用方来说感知不到流的切换,用户看到的是一个连续的回答。第二层是路由层面的降级:如果 aiRoutingForTool() 路由失败(LLM 调用超时/异常)、返回的 operationId 为空或在注册表中找不到对应执行器,自动降级到 caixiaomiKnowledgeQA——知识问答是最安全的兜底技能,即使知识库也没有相关内容,至少能通过第一层兜底给出通识回答而不是报错。还有一个隐含的第零层:AppId 降级——CxmOpenClientImpl 在调用百炼时优先使用智能体 AppId(支持更复杂的编排),如果未配置或调用失败,自动降级到工作流 AppId(功能简单但更稳定),保证了即使百炼智能体版本出问题,流式链路本身不会中断。面试中可以讨论这种分层降级设计的原则:每层降级都是有损的(信息质量递减),但保证了系统在各种异常场景下的可用性——宁可给用户一个"不够完美但有用"的回答,也不能返回空白页面或错误提示。
项目的 DDD 分层架构是怎么设计的?各层的具体职责和边界是什么?
A:项目按照 DDD 四层架构划分为四个 Maven 模块。hcm-ai-interfaces 是最外层的接口适配层,负责协议适配和对外暴露:包括 HSF Triple 流式接口(CxmStreamServiceImpl)、SchedulerX 定时任务(DingDocSyncProcessor 知识同步、CxmAnswerScoreServiceImpl 评估任务、DingDocScanProcessor 变更扫描)、以及 MCP Tool 声明(CxmMcpTool 3 个工具)。这一层的核心原则是"薄"——不包含业务逻辑,只做参数校验、协议转换和调用编排,比如接收 HSF 请求后组装 ChatRequest 对象交给 application 层处理。hcm-ai-application 是应用服务层,承载核心业务编排逻辑:RAG 策略(AbstractKnowledgeRetrievalStrategy 及 4 个子类的策略选择与并发编排)、工具执行器(ToolExecutorRegistry 注册 + ToolExecutor 10+ 实现的策略模式路由)、路由引擎(CxmAiEngine 两段式路由编排)、评估服务等。这一层协调多个领域对象和基础设施完成完整业务流程,但不直接操作外部系统。hcm-ai-infrastructure 是基础设施层,封装所有外部依赖的技术实现:百炼 API 封装(CxmOpenClientImpl 流式/非流式调用、retrieve 检索)、Guava RateLimiter(查询 5QPS/删除 10QPS/上传 10QPS)、钉钉 API 调用、Diamond 配置监听等。这一层实现 application 层定义的 Repository/Gateway 接口,上层通过接口依赖倒置、不直接依赖具体实现。hcm-ai-domain 是领域模型层,定义纯领域对象:KnowledgeSourceEnum(4 种知识源枚举)、KnowledgeSlice(知识切片值对象)、对话上下文等,没有任何技术依赖,是最稳定的内核。分层的实际收益举例:当百炼 API 从 V1 升级 V2 时,只需修改 infrastructure 层的 CxmOpenClientImpl,application 层和 interfaces 层完全不受影响;当新增一个 MCP Tool 时,只需在 interfaces 层加注解声明,复用 application 层已有的服务逻辑。
对话历史为什么用 XML 格式注入而不是用多轮 message?这样做有什么优势?
A:代码中 buildConversationMessage() 将历史对话包装成 XML 文本注入为单条 USER message,而不是将每轮对话拆成独立的 user/assistant message 交替传入。这样做的核心原因是防止角色混淆(Role Confusion)。如果将历史对话以标准的多轮 message 格式传入,LLM 会将历史中的 assistant 回复视为"自己之前真正说过的话",产生两个问题:一是当历史对话中包含错误信息或过时内容时,LLM 会倾向于保持与"自己之前说过的话"的一致性,即使当前轮次的 RAG 知识切片提供了更准确的信息,也不愿意自我纠正——这在 HR 政策场景中特别危险,政策可能随时更新;二是历史对话中的 assistant 回复可能包含格式化指令或特殊格式(如 markdown 表格、引用标签),这些内容被当作 assistant 角色的上下文时可能干扰当前轮次的输出格式。用 XML 格式(<conversation><turn role="user">...</turn><turn role="assistant">...</turn></conversation>)注入为单条 USER message 后,LLM 将整个对话历史视为"用户提供的参考资料"而非"自己的记忆",可以客观地审视历史内容并以当前 RAG 知识为准回答。XML 格式的选择是因为它的标签语法对 LLM 来说有清晰的结构边界(相比 JSON 或纯文本分隔符),LLM 能很好地理解 <turn role="user"> 和 <turn role="assistant"> 的语义。这种设计还有一个附带好处:单条 USER message 中包含所有历史,简化了 Prompt 模板的组装逻辑,不需要动态构建 message 数组。面试中可以进一步讨论:这种做法在 OpenAI 和 Anthropic 的官方最佳实践中都有类似建议——在需要"引用但不认同"历史对话时,用嵌入式文本而非角色消息。
圈人机制(crowdRuleId)在路由中是怎么起作用的?
A:圈人机制的作用是控制每个工具对特定人群的可见性——不是所有用户都能使用所有技能。在 Diamond 配置的 AiAssistantConfigData.tools 列表中,每个工具 JSON 对象包含一个 crowdRuleId 字段,关联到公司内部的人群圈选系统(类似 A/B 测试的受众圈选)。路由阶段的工作机制是这样的:在 CxmAiEngine.executeToolForRobot() 进入 LLM Function Routing 之前,系统会先遍历所有注册的工具配置,对每个配置了 crowdRuleId 的工具调用 crowdWrapper.validateMatched(crowdRuleId, userId) 校验当前用户是否在目标人群中。只有校验通过的工具才会被序列化进路由 Prompt——也就是说,不在某个工具圈人范围内的用户,LLM 在路由时根本看不到这个工具的存在,自然不会路由到它。这种设计的应用场景包括:新技能灰度上线(比如"智能证明"功能先对 IT 部门开放,验证稳定后再全量)、差异化能力(比如"智能问数"只对有数据权限的管理层开放)、临时下线(将 crowdRuleId 设为空集等价于禁用该工具)。相比在 ToolExecutor 内部做权限校验,圈人机制在路由前置过滤有两个优势:一是节省 Token——不展示不可用工具的描述,路由 Prompt 更短;二是避免用户困惑——如果 LLM 路由到一个用户无权使用的工具再报"无权限",用户体验很差。圈人规则的更新不需要发版,只需在人群圈选系统中修改规则,工具配置的 crowdRuleId 保持不变,实现了权限策略与代码的完全解耦。
问题重写 3 路并发具体是怎么设计的?对 RAG 召回率有多大提升?
A:问题重写通过 aiRoutingForUserInputRewrite() 实现,内部并发发起 3 路 LLM 调用,每路从不同角度扩展原始 query:第一路是上下文重写——结合对话历史补全指代和省略,比如用户第二轮问"那它的报销标准呢",上下文重写会扩展为"差旅机票的报销标准是什么",这对多轮对话场景的召回率提升最大;第二路是关键词+同义词扩展——提取 query 中的核心概念并生成同义词/近义词,比如"年假"扩展为"年假 年休假 带薪假期",增加召回覆盖面,对长尾专业术语场景贡献最大;第三路是用户身份信息重写——注入用户所属公司(如菜鸟/淘天/本地生活)、职级、城市等上下文信息,比如将"请假政策"重写为"菜鸟集团杭州P7员工的请假政策",让 RAG 检索能命中针对特定公司/地域的政策文档。3 路重写的结果都会作为独立的 query 输入到 RAG 检索阶段——也就是说,一个用户问题会变成 3-4 个检索 query(原始 + 3 路重写),每个 query 都会在 4 个知识源中检索,大幅增加了召回的覆盖面。3 路并发使用 CompletableFuture.supplyAsync() 提交到 LLM 调用线程池,由于三路之间无依赖关系,总耗时只取决于最慢的一路(通常 200-400ms)。在内部评测中,3 路重写相比不重写,RAG 召回率(Recall@20)从约 72% 提升到约 85%,提升约 13 个百分点。3 路而非更多路的原因是边际收益递减——增加第 4 路(如 query 分解)在评测中只额外提升 2%,但多一路 LLM 调用增加延迟和 Token 成本,性价比不划算。
百炼 retrieve API 的具体参数和调优经验是什么?
A:百炼 retrieve API 是 RAG 系统的核心检索接口,关键参数和调优经验如下。检索模型方面,使用 qwen3-rerank-hybrid 作为 rerank 模型,hybrid 意味着同时结合向量语义相似度和稀疏词频匹配(类似 BM25),在 HR 领域存在大量专业术语(如"N+1 赔偿"、"竞业限制"、"试用期转正")的场景下,纯向量检索可能因语义相似但概念不同而产生误召回(如"年假"与"产假"语义接近但政策完全不同),hybrid 模式通过精确词匹配弥补了这个短板。检索数量方面,设置向量检索 Top-100 → 经 rerank 模型重排后取 Top-20。Top-100 的选取逻辑是:向量检索的初始召回噪声较多,但要保证 rerank 模型有足够的候选集做精排——太小(如 Top-30)可能漏掉相关文档,太大(如 Top-500)会显著增加 rerank 延迟。Top-20 是 rerank 后保留的高质量切片数量,这些切片会进入应用层的二次 rerank。相似度阈值设为 0.2,低于此阈值的切片直接丢弃——0.2 是一个很宽松的阈值,目的是"宁可多召回一些不太相关的、也不能漏掉可能相关的",精排交给 rerank 模型处理。filterTags 参数支持在检索阶段做文档级过滤,我们按知识源类型注入不同标签:政策库按文档路径标签过滤(只看用户有权限的文档目录),钉钉文档按圈人标签过滤(只看用户所属团队的文档空间)。调优经验总结:阈值从默认的 0.5 下调到 0.2 后,召回率提升了约 15%,但准确率略有下降(从 88% 到 85%),通过第二段应用层 rerank 弥补了准确率损失;Top-100 的最佳值是通过离线评测网格搜索确定的(测试了 50/100/200 三档),100 在召回率和延迟之间取得了最佳平衡。
知识切片的鉴权是怎么做的?按文档名去重的优化是什么?
A:知识切片鉴权发生在 RAG 检索返回结果之后、注入 Prompt 之前,确保用户只能看到自己有权限访问的知识内容。具体实现在 AbstractKnowledgeRetrievalStrategy.collectAuthResults() 中:对 retrieve API 返回的每个知识切片,异步提交到 KNOWLEDGE_AUTH_POOL 线程池(core=200, max=300),调用对应知识源的鉴权接口(如钉钉文档空间权限校验、知识平台审核状态检查)。鉴权结果为布尔值——有权限的切片保留,无权限的过滤掉。核心优化是按文档名去重鉴权:一个文档可能被切成 5-10 个知识切片(chunk),如果对每个切片都独立鉴权,实际上对同一个文档的权限查了 5-10 次,而鉴权结果是相同的——权限是文档级别的,不是切片级别的。优化逻辑是:先按文档名(documentName)对切片分组,每组只取第一个切片调用鉴权接口,结果缓存后应用到同组所有切片——"单个文档只鉴权 1 次"。这个优化在实际场景中效果显著:一次 RAG 检索可能返回 80 条切片(4 知识源 × Top-20),如果来自 20 个不同文档,鉴权调用从 80 次降到 20 次,减少了 75% 的鉴权 API 调用量,直接降低了鉴权阶段的延迟和下游服务的压力。去重使用 LinkedHashMap 保证遍历顺序与原始召回分排序一致,避免去重后打乱 rerank 排序。鉴权结果不做跨请求缓存(如 Redis),因为文档权限可能随时变更(如员工调部门),使用过期缓存会导致权限泄漏的安全风险。超时处理方面,通过 CompletableFuture.allOf(futures).get(3, TimeUnit.SECONDS) 设置总超时,未完成的鉴权直接标记为无权限——安全降级,宁可少展示也不能泄漏未鉴权内容。
钉钉卡片流式更新的具体实现是什么?每帧如何渲染?
A:钉钉卡片流式更新的完整链路是:百炼流式 API 每产生一帧增量文本 → Flowable 推入 ReplayProcessor → 订阅回调中累积到 StringBuilder → 调用钉钉卡片 OpenAPI updateCardDateStream() 更新卡片内容。具体的帧处理逻辑:百炼 API 设置了 incrementalOutput=true,每帧只包含新增的文本片段(如"根据"、"公司差旅"、"政策规定"),订阅回调收到每帧后 append 到 StringBuilder,然后用完整的累积文本(sb.toString())调用卡片更新接口。钉钉互动卡片(Interactive Card)支持 markdown 渲染——卡片模板中定义了一个 content 变量,每次更新传入最新的累积文本,钉钉客户端自动做 markdown 到富文本的渲染。用户看到的效果类似 ChatGPT 的逐字出现:每隔 50-200ms(取决于百炼的输出速度和网络延迟)卡片内容刷新一次,文本逐渐变长。但并非每一帧都触发卡片更新——为了避免过高频率的 API 调用导致钉钉服务端限流,实际实现中有节流机制:只有当累积文本相比上次更新增加了一定长度(或距上次更新超过 200ms)才触发 API 调用。流式输出结束时(百炼返回 finish_reason=stop),触发最后一次卡片更新确保内容完整,同时更新卡片底部的操作按钮(如"有用/没用"反馈按钮从隐藏变为可见)。异常处理方面:如果某一帧更新失败(网络抖动),不影响后续帧——下一帧会带上完整的累积文本覆盖,天然具有自愈能力。如果整个流式过程异常中断(如百炼 API 超时),通过 onError 回调推送一条错误提示更新到卡片,避免卡片停留在半成品状态。
整个请求链路的延迟分布是什么样的?哪些环节是性能瓶颈?
A:一次完整的对话请求链路的延迟分布大致如下:前置拦截阶段 5-50ms(特殊指令匹配和 FAQ 精确匹配,命中即返回不走后续环节)→ LLM 路由阶段 300-500ms(4 路并发 LLM 调用取最慢值,qwen-flash 模型单次调用 200-400ms)→ RAG 检索阶段 1000-2500ms(4 知识源并发 retrieve + 鉴权 + 两段 Rerank)→ 流式生成阶段首字 500-1000ms + 逐字输出 3-8s(百炼智能体流式调用,取决于回答长度)。端到端首字延迟(用户发送问题到看到第一个字)约 2-3 秒,完整回答 5-12 秒。性能瓶颈分析:最大瓶颈是 RAG 检索阶段,占总延迟的 40-50%。其中百炼 retrieve API 单次调用 500-800ms(含向量检索 + rerank),4 知识源并发但需要等最慢的那个返回;鉴权阶段 500-1000ms,虽然已经做了按文档名去重优化,但当切片来自大量不同文档时鉴权调用仍然较多。第二大瓶颈是 LLM 路由阶段,虽然已经并发化到 500ms 以内,但如果某一路 LLM 调用抖动(偶发 800ms+),会拖慢整体。已做的优化包括:三层线程池隔离消除线程饥饿、问题重写 3 路并发化、RAG 检索 4 知识源并发化、单源 3s 超时熔断降级、路由使用 flash 快速模型、1.5s 加载提示改善感知体验。还在考虑的进一步优化方向:RAG 检索和 LLM 路由阶段做流水线重叠(路由结果出来后不等所有重写完成就开始部分知识源的检索)、百炼 retrieve 结果对热门问题做短时缓存(相同 query 5 分钟内复用)、鉴权结果做用户维度的会话级缓存(同一会话内用户权限不会变化)。
薪酬AI质检项目
项目介绍:面向集团薪酬全生命周期(薪资方案 / 提报 / 计算 / 调账 / 报表)的 AI 驱动质检平台,用 LLM 把自然语言批量解析为可执行的 Groovy DSL 规则脚本,配合强弱卡控双链路与异步执行引擎,把传统"人工抽检 + 发薪后修正"升级为"发薪前自动拦截"闭环,一期实现月人工抽检工时下降 ≥50%、质检通过率 ≥95%,规则复用率 ≥ 60%。
技术栈:PandoraBoot、Self-Refine、Groovy、Redis、TDDL、MySQL、Prompt Engineering
- Self-Refine 自洽循环(LLM-as-Generator × 编译器-as-Verifier):每条规则最多 3 轮"生成 → 预编译校验 → 失败反馈再生成",错误收敛为调用失败/格式异常/编译失败/超时四态,把幻觉约束在可被编译器证伪的边界内。
- Prompt 工程 SRE 化 + LLM 调用安全护栏:apiKey / model / systemPrompt 全外置配置中心分钟级热更,推送前做长度下限 + 关键词白名单 lint 防空 Prompt 上线;出栈日志三类正则脱敏(API Key 前缀 / 键值对 / Bearer Token)杜绝凭证落盘被检索。
- 异步质检执行引擎(多租户隔离 + 容灾 + 双层进度):容灾框架跨节点 Failover、同租户分布式锁限并发防打爆分片库;流式分页拉取数据后分批送入规则引擎并桶级并发执行;进度走 Tair-DB 双层,Tair 实时写给前端轮询、每 N 页持久化降 DB 压力,前端封顶 99 防误显 100。
- 推理成本 + 限流双控:规则解析显式关闭深度思考开关省 thinking token;信号量控异步并发、同步路径独立隔离;指数退避(1s/2s/4s)仅在限流类异常重试,业务异常 fail-fast 不浪费配额。
- 多 Agent 协同载体 + 跨仓 DDD 协同:异常分发接入协同表格作为"任务空间",复制模板后分页写入异常明细,由业务方通过工作流催办跟进;分库分表非分片键查询通过联合索引把候选数据从十万级收敛到万级。
Self-Refine 自洽循环
Self-Refine 自洽循环(LLM-as-Generator × 编译器-as-Verifier):每条规则最多 3 轮"生成 → 预编译校验 → 失败反馈再生成",错误收敛为调用失败/格式异常/编译失败/超时四态,把幻觉约束在可被编译器证伪的边界内。
核心实现在 RuleBatchParseExecutor.parseSingleInternal() 中,用 for (attemptNo = 1; attemptNo <= 3; attemptNo++) 硬编码 3 轮上限。每轮流程:获取信号量许可 → buildUserMessage() 拼装 Prompt(含规则上下文 + 字段字典 + 上一轮错误反馈)→ callBailianApi() 调百炼 SDK 非流式接口 → parseAiResponse() 校验 JSON 格式(必须含 parseSuccess、content、ruleName、checkType 等字段)→ tryPrecompile() 调 GroovyUtils.precompile() 做 Groovy 预编译语法校验。成功则返回,失败则记录 lastAiResponse / lastErrorType / lastErrorMessage,在下一轮 Prompt 中追加 [上次尝试反馈] 区块,让 LLM 看到自己的错误和编译器反馈定向修正。四种错误类型定义在 ParseErrorType 枚举中:AI_CALL(SDK 调用失败)、AI_FORMAT(返回非 JSON 或缺字段)、GROOVY_COMPILE(脚本语法错误)、TIMEOUT(HTTP 超时 90s)。如果 AI 主动判定不可解析(parseSuccess=false),直接跳出循环不浪费重试。字段字典按质检环节分路由:数据提报阶段从 Excel 模板表加载列定义,薪资计算阶段从薪酬项表按子串匹配过滤。AI 返回的 content 字段是 Groovy 脚本,Base64 编码后存入 PrRule.content。
【获取的信号量许可是什么?】
信号量(Semaphore)是 Java 并发包中的一种限流工具,可以理解为"停车场的车位"。Semaphore(10) 就是有 10 个车位的停车场。acquire()是"开车进去占一个车位"——如果有空位就直接进入,没有空位就排队等;release()是"开走了腾出一个车位"。在这里,信号量许可控制的是"同时有多少条规则在调用百炼 AI API"。批量解析 50 条规则时,不是 50 条同时发给 AI(会触发 API 限流),而是最多 10 条同时调用,第 11 条必须等前面某条完成释放许可后才能开始。这样既保证了并发效率,又不会超过 AI API 的并发限制。
【为什么是非流式接口?】
使用非流式接口(stream(false))而非流式接口,原因有三:① Self-Refine 需要完整响应——每条规则要经过"生成→Groovy预编译校验→失败则反馈再生成"的循环,必须拿到 AI 的完整 JSON 响应后才能做 Groovy 预编译校验(tryPrecompile()),流式接口是逐字返回的,无法在中途做完整性校验;② qwen3 深度思考兼容——显式设置enable_thinking=false后,非流式调用能确保 content 字段一次性返回完整内容,流式模式下可能出现 content 为空只返回 reasoning_content 的问题;③ 超时控制更简单——非流式调用在 90s 超时后直接抛异常触发 Self-Refine 重试,流式调用的超时处理更复杂。
【AI判定的是Json格式是否可解析吗?这是AI判断的吗?】
不是判断 JSON 格式是否可解析,而是 AI 判断这条业务规则是否能被转化为 Groovy 脚本。AI 返回的 JSON 中有一个 parseSuccess 字段,这是 Prompt 中要求 AI 必须输出的字段。当 AI 分析规则后认为该规则无法自动化(比如涉及跨表关联、跨期比对、需要外部数据源等超出 Groovy 脚本能力范围的场景),它会主动返回{"parseSuccess": false, "parseErrorMessage": "该规则涉及跨期数据比对,无法转化为单行校验脚本"}。后端 parseAiResponse() 解析到 parseSuccess=false 后,直接跳出 3 轮重试循环,返回"不可解析"结果给前端——因为这种情况重试也没用,规则本身就超出了自动化能力范围。
【Base64编码是相当于加密吗?】
不是加密,是编码。Base64 和加密是两个完全不同的概念:加密需要密钥,没有密钥无法还原;Base64 只是一种编码方式,任何人都可以用Base64.getDecoder().decode()还原原文,没有任何保密性。这里用 Base64 编码的原因是:Groovy 脚本中可能包含特殊字符(如引号"、换行符\n、花括号{}、大于小于号<>等),直接存入数据库字段或通过 JSON 传输时可能导致转义问题、SQL 注入风险或 JSON 解析错误。Base64 编码后,脚本内容变成纯粹的字母+数字+少量符号(A-Z, a-z, 0-9, +, /, =)的字符串,可以安全地在 JSON、数据库、HTTP 请求中传输存储。代码注释也说明了:"content 是 Base64 编码的 Groovy。下游 F4 入库 / 前端预览据此解码"。
Prompt 工程 SRE 化
Prompt 工程 SRE 化 + LLM 调用安全护栏:apiKey / model / systemPrompt 全外置配置中心分钟级热更,推送前做长度下限 + 关键词白名单 lint 防空 Prompt 上线;出栈日志三类正则脱敏(API Key 前缀 / 键值对 / Bearer Token)杜绝凭证落盘被检索。
两个 Diamond 配置类分别管理不同阶段:DiamondRuleGeneratorConfig(数据提报阶段)和 DiamondRuleGeneratorRuntimeConfig(薪资计算阶段,内含 salaryResultSystemPrompt 和 payeeListSystemPrompt 两个独立 Prompt)。callBailianApi() 方法按 stage + ruleType 路由选择对应的 systemPrompt。lint 校验在 Diamond 回调 received() 中实现:长度校验——systemPrompt 必须超过 1000 字符(有效 Prompt 含完整规则模板 + 输出契约 + 示例,不可能低于此阈值);关键词白名单——必须包含"输出契约"、"checkType"、"parseSuccess"等核心关键词,缺少任何一个都会导致解析失败。校验不通过时保留旧值不更新并打印告警。日志脱敏在 sanitizeForLog() 中用三类正则处理:sk-[A-Za-z0-9]{6,} 匹配百炼 API Key 前缀、(api[_-]?key)\s*[=:]\s*... 匹配通用键值对 Key、(authorization|bearer)\s*[:=]?... 匹配认证头。脱敏在百炼 API wrapper 层统一处理,对上层透明,保留 Key 前缀用于区分不同 Key。
【分钟级热更体现在哪方面?】
分钟级热更体现在三个方面:① apiKey 热更——当百炼 API Key 需要轮换(比如旧 Key 即将过期或被泄露)时,在 Diamond 配置中心修改 apiKey 字段,@DiamondListener回调received()方法被触发,自动更新 volatile 字段,下一次 AI 调用立即使用新 Key,无需重启服务或发版;② model 热更——需要切换模型(如从 qwen-plus 换到 qwen-max)时,修改 model 配置即刻生效;③ systemPrompt 热更——这是最频繁需要调整的,优化 Prompt 指令、修改输出契约、增加 Few-shot 示例等,都可以在 Diamond 推送后分钟级生效。"分钟级"指的是 Diamond 配置推送到所有服务节点生效的延迟通常在秒级到分钟级,远快于传统的"改代码→编译→部署"流程(通常需要 30 分钟以上)。同时received()中的 lint 校验(长度下限 + 关键词白名单)保证了不会误推空 Prompt 或残缺 Prompt 到生产环境。
异步质检执行引擎
异步质检执行引擎(多租户隔离 + 容灾 + 双层进度):容灾框架跨节点 Failover、同租户分布式锁限并发防打爆分片库;流式分页拉取数据后分批送入规则引擎并桶级并发执行;进度走 Tair-DB 双层,Tair 实时写给前端轮询、每 N 页持久化降 DB 压力,前端封顶 99 防误显 100。
核心类 QualityCheckAsyncTask 通过 @Failover(bizType = "QUALITY_CHECK_TASK", runMode = RunMode.NEW_THREAD) 注解接入容灾框架。任务参数类 QualityCheckParam 实现 ReliableCallParam<String> + Serializable,框架将参数持久化到 DB,节点宕机后其他存活节点自动从 DB 取出参数重新执行。多租户隔离分两层:TenantContext.call(tenant, ...) 包裹执行逻辑确保租户信息透传;每租户最多 2 个并发(MAX_CONCURRENT_PER_TENANT = 2),通过"DB 查 PROCESSING 任务数 + Redis 分布式锁 SET NX EX 30min"双重检查实现。分布式锁使用 Redis SET NX EX 原子加锁,Tair CAD(Compare And Delete)保证只有持锁者能释放。数据处理流程:分页拉取(PAGE_SIZE = 1000)→ 分批送入规则引擎(VALIDATE_BATCH_SIZE = 200)→ ValidationOption.setBucketNum(50) 50 桶并发执行。进度设计:Redis 层每页处理完立即 SET qc:progress:{taskId} {count} EX 3600,前端轮询接口优先读 Redis(延迟 <1ms);DB 层每 10 页持久化一次降写入压力;PROCESSING 状态优先 Redis fallback DB,最终状态以 DB 为准;前端封顶 99 防止进度先于最终状态到 100 造成误导。
【容灾框架是什么?作用是重试吗?还是?】
容灾框架(com.cainiao.cpo.failover)是阿里内部的跨节点任务容灾框架,作用不仅是重试,更核心的是跨节点 Failover(故障转移)。工作原理:① 任务参数类 QualityCheckParam 实现了 ReliableCallParam + Serializable 接口,框架在任务执行前将参数序列化持久化到数据库;② 如果当前执行节点宕机(如 JVM 崩溃、机器掉线),框架的健康检查机制会检测到该节点失联;③ 集群中其他存活节点自动从数据库取出未完成的任务参数,在新节点上重新执行;④RunMode.NEW_THREAD表示任务在独立线程中执行,隔离对主线程的影响。简单说:普通重试是"同一台机器再试一次",容灾框架是"这台机器挂了,另一台机器自动接手继续干",保证即使节点故障,质检任务也不会丢失。
【每个租户最多两个并发任务吗?这是考虑了性能?】
是的,每个租户最多 2 个并发质检任务。这是从多个维度考虑的:① 防止打爆分片库——质检任务涉及分页拉取 pr_base_data(可能十万级数据)、大量规则引擎校验、批量写入 validation_result,对数据库读写压力极大。一个租户如果不限制,可能连续发起多个质检任务同时跑,瞬间把该租户所在分片库的连接池和 IO 打满;② 多租户公平性——系统是多租户共享的,一个租户疯狂发任务不应该影响其他租户的正常使用;③ 资源隔离——2 个并发已经能覆盖正常业务场景(比如一个"数据提报阶段"质检 + 一个"薪资计算阶段"质检同时跑),超过 2 个通常是误操作或异常情况。并发控制通过"DB 查 PROCESSING 状态任务数 + Redis 分布式锁"双重检查实现,既保证准确性又保证性能。
【Tair CAD(Compare And Delete)具体是如何保证只有持有者才能释放锁的?】
分两步理解——加锁和释放锁:
- 加锁:使用
SET lockKey requestId NX EX 1800原子命令。NX 表示"仅当 key 不存在时才设置"(防止覆盖他人的锁),EX 1800 表示"30分钟自动过期"(防止死锁)。加锁成功时,锁的 value 是当前线程生成的唯一 requestId(UUID)。- 释放锁:使用 Tair 的
CAD(Compare And Delete)原子操作。cad(lockKey, requestId)的语义是:"只有当 lockKey 的当前值等于 requestId 时,才删除这个 key"。举个例子:线程 A 加锁时设置 value="uuid-A",线程 B 加锁时设置 value="uuid-B"。如果线程 A 执行完要释放锁,调用cad(lockKey, "uuid-A")——此时锁的值确实是 "uuid-A",匹配成功,删除锁,返回 1。但如果线程 A 因为超时,锁已经过期被线程 B 重新获取(value 变成了 "uuid-B"),线程 A 再调用cad(lockKey, "uuid-A")时,值不匹配,不会删除,返回 0。这就防止了"线程 A 误删线程 B 的锁"的经典分布式锁问题。CAD 是 Tair 提供的原子操作(对比+删除不可分割),比用 Lua 脚本实现的方案性能更好。
【为什么既用了Tair,也用了Redis,这两者有什么区别?为什么实现进度条不使用Tair?】
Tair 和 Redis 的关系:Tair 是阿里基于 Redis 开发的增强版 Redis,兼容所有 Redis 原生命令,同时扩展了 Tair 专属命令(如 CAD/CAS/EXSET 等)。在项目中两者共用同一个 JedisCluster 连接实例——代码中@Resource JedisCluster jedisCluster和@Resource TairStringCluster tairStringCluster连的是同一套 Tair 集群。所以"用了 Tair 也用了 Redis"不是用了两个不同的系统,而是对同一套 Tair 集群,分别用了 Redis 原生命令和 Tair 扩展命令。具体分工:
- Redis 原生命令(jedisCluster):用于加锁(
SET NX EX)和进度条(setex/get/del)——这些是标准 Redis 命令,所有 Redis 实例都支持- Tair 扩展命令(tairStringCluster):仅用于释放锁(
cad)——这是 Tair 独有的原子操作,普通 Redis 不支持- 为什么进度条不用 Tair 专属命令? 因为进度条只需要简单的
set/get/del操作,这些是 Redis 原生命令完全能胜任的,用 Tair 扩展命令没有任何额外收益。Tair 扩展命令的优势在于原子性的 CAS/CAD 操作,适合分布式锁这类需要"比对+操作一体化"的场景,进度条只是存取一个数字,不需要这种原子保障。
【什么叫桶并发执行?依据项目介绍下?为什么要设置为50】
"桶并发执行"是薪酬规则引擎内部的并行化机制。具体来说:质检任务分页拉取 1000 条数据后,分成 200 条一批送入规则引擎的validateByRules()方法。规则引擎内部将这 200 条数据分配到 50 个"桶(bucket)"中,每个桶约 4 条数据,50 个桶并行执行规则校验。可以理解为"把一批数据分成 50 份,50 个工人同时干活"。设置为 50 的考虑:① 充分利用多核 CPU——服务器通常有 16-64 核,50 个并发桶能充分利用多核并行能力;② 平衡吞吐与开销——桶数太少(如 5 个),并发度不够,200 条数据要串行处理很久;桶数太多(如 500 个),线程上下文切换开销大,反而降低效率;③ 与项目中其他质检任务(CalenderValidationTask)保持一致,统一配置便于运维和性能调优。
【什么叫每页?哪个数据的每页?是质检数据的每页?】
是的,"每页"指的是质检基础数据(pr_base_data)的每页。质检任务需要遍历所有符合条件的员工薪酬数据进行规则校验,数据量可能达到十万级,不可能一次全部加载到内存。所以采用分页拉取策略:PAGE_SIZE = 1000,每次从 DMC 远程服务查询 1000 条 pr_base_data 记录,校验完这 1000 条后再拉下一页的 1000 条,直到最后一页不足 1000 条为止。"每页处理完"就是指每处理完 1000 条数据后,立即更新 Redis 中的进度计数,让前端轮询时能看到实时进展。
推理成本 + 限流双控
推理成本 + 限流双控:规则解析显式关闭深度思考开关省 thinking token;信号量控异步并发、同步路径独立隔离;指数退避(1s/2s/4s)仅在限流类异常重试,业务异常 fail-fast 不浪费配额。
qwen3 系列模型默认开启深度思考(Deep Thinking),会在 reasoning_content 字段输出推理过程(3-5 倍额外 token),且不显式关闭时 content 字段可能为空导致解析失败。通过 ChatCompletionParam.builder().parameter("enable_thinking", false) 显式关闭,同时使用非流式调用确保一次性获取完整结果用于预编译校验。并发控制用两个独立信号量:批量解析(F4 功能)Semaphore(10) 最多 10 个并发 AI 调用,单条重生成(F5 功能)Semaphore(1) 独立许可,隔离原因是防止大批量挤占单条操作——50 条规则批量解析时不应阻塞另一用户的单条重生成。选信号量而非线程池是因为控制的是"同时等待 AI 响应的请求数"(AI API 有并发限制),颗粒度比线程池更精确。指数退避在 backoffIfRateLimited() 中实现:sleepMs = 2^(attemptNo-1) * 1000(1s / 2s / 4s),仅在 isRateLimitException() 检测到错误消息含 "429" / "rate limit" / "throttl" / "too many requests" 时触发,非限流的业务异常直接 fail-fast。HTTP 超时配置:connect 10s / write 30s / read 90s,通过 ConnectionOptions 注入 dashscope SDK 的 OkHttp 客户端。
【信号量具体是如何控制并发的?介绍下】
信号量(Semaphore)控制并发的原理可以用"游泳池限流"来理解:Semaphore(10) 就是一个只允许同时容纳 10 人的游泳池。具体流程:① 每条规则在调用百炼 AI 之前,先调用semaphore.acquire()——相当于"在门口取号排队",如果池子里不满 10 人就直接进入,如果已经满了就阻塞等待;② 调用百炼 AI 完成后(无论成功还是失败),在 finally 块中调用semaphore.release()——相当于"出来了腾出一个位置";③ 此时等待队列中的下一条规则被唤醒,进入池子开始调用 AI。选信号量而非线程池的原因是:这里控制的不是"有多少线程在工作",而是"有多少个 AI API 请求在同时等待响应"。线程可能在做其他事(比如拼装 Prompt、解析 JSON),但只有真正调用 AI API 时才需要限流。信号量的 acquire/release 可以精确地只包裹 API 调用这一段,颗粒度比线程池更细。
【补充一个简要的semaphore代码示例】
以下是项目中 Semaphore 的核心用法(简化版):
```java
// 批量解析:Semaphore(10) 控制最多 10 个并发 AI 调用
Semaphore semaphore = new Semaphore(10);
for (BatchParseRuleItemDTO item : rules) {
CompletableFuture.supplyAsync(() -> {
// ① acquire:占一个许可位,满 10 人则阻塞等待
semaphore.acquire();
try {
// ② 真正调用百炼 AI API(只有拿到许可才能调用)
String aiResponse = callBailianApi(userMessage);
return parseAiResponse(aiResponse);
} finally {
// ③ release:释放许可位,让等待队列中的下一个任务进入
semaphore.release();
}
}, threadPool);
}
// 单条重生成:Semaphore(1) 独立许可,防止大批量挤占单条操作
Semaphore singlePermit = new Semaphore(1);
singlePermit.acquire();
try {
String result = callBailianApi(userMessage);
} finally {
singlePermit.release();
}
```
关键设计点:acquire 在 AI API 调用之前,release 在 finally 中确保异常时也能释放——否则一条规则异常会导致许可永远不归还,后续规则全部阻塞。
【这个超时配置是调用百炼API的超时配置吗?还是质检模块前端访问后端的超时配置?】
是调用百炼 AI API 的 HTTP 超时配置,不是前端访问后端的超时。代码中通过ConnectionOptions.builder().connectTimeout(10s).readTimeout(90s).writeTimeout(30s).build()注入到 dashscope SDK 内部的 OkHttp 客户端。三个超时分别控制:connectTimeout 10s 是建立 TCP 连接的超时(网络不通时 10 秒快速失败);writeTimeout 30s 是向百炼 API 发送请求体的超时;readTimeout 90s 是等待百炼 AI 返回响应的超时(AI 生成 Groovy 脚本可能需要 20-30 秒,90s 覆盖正常长尾场景,同时防止真正挂死时无限等待)。代码注释说明:"dashscope SDK 默认不设上限,曾出现单条规则 AI 响应卡 2 分钟拖慢整批的情况。90s 既覆盖正常长尾(典型 20-30s),又能在真正挂死时尽快触发 Self-Refine 重试。"
多 Agent 协同载体
多 Agent 协同载体 + 跨仓 DDD 协同:异常分发接入协同表格作为"任务空间",复制模板后分页写入异常明细,由业务方通过工作流催办跟进;分库分表非分片键查询通过联合索引把候选数据从十万级收敛到万级。
异常分发完整流程在 QualityCheckAiSheetHandler.handle() 中:工号 → 钉钉 userId → unionId 三级转换 → createDocumentFromTemplate() 基于模板创建 AI 表格文档 → getAllSheets() 定位"发薪前异常审核-汇总"Sheet → 分页查 validation_result 表按 bizId 聚合异常规则 → 分页查 pr_base_data 仅保留有异常的记录 → AiSheetFieldMapper 映射 15 个字段 → 按 500 条分批 insertRecords() 写入(钉钉 API 单次上限)。选协同表格而非邮件/Excel 是因为支持多人实时协作和工作流催办,比静态附件效率高。跨仓架构:三仓(cn-payroll 主仓 / cn-payroll-dmc 数据管理中心 / cn-hrwork 工作台)独立 Git 仓库但同一部署单元。主仓通过 Class.forName() 反射加载 DMC 仓的 Handler 类,避免编译期依赖引入大量无关类定义、保持仓间解耦和独立发版节奏。调用链路:前端 → cn-hrwork Controller(HTTP)→ cn-payroll-dmc Gateway Facade(HSF)→ cn-payroll Core Facade(HSF)→ Application Service。分库分表优化:非分片键查询在 SQL 中始终带上分片键(租户 ID)作为第一条件,配合 (tenant_id, business_field) 联合索引,每个分片内从全表扫描变为索引查询,候选数据从十万级收敛到万级。
【这里哪里体现了多Agent协同载体?】
"多 Agent 协同载体"体现在钉钉 AI 表格(DingTalk Notable)作为多个角色/Agent 的协作平台。具体来说:① 质检 Agent(QualityCheckAiSheetHandler)自动将质检异常数据写入钉钉 AI 表格——基于模板创建文档、映射 15 个字段、按 500 条分批 insertRecords();② 业务方/HR通过同一个 AI 表格查看异常明细、标注处理结果、通过工作流催办跟进;③ 审批流 Agent可以基于表格中的异常数据发起审批流程。AI 表格在这里不只是数据展示工具,而是一个多方协作的"任务空间"——质检系统自动产出异常数据 → HR 在同一个文档中审核处理 → 工作流催办确保闭环。选协同表格而非邮件/Excel 附件,正是因为它支持多人实时协作、数据实时同步、可接入工作流自动化,是连接多个 Agent(自动化系统 + 人 + 工作流)的协同载体。
【这里是如何进行分库分表的?分片键又是什么?具体是如何将数据从十万级收敛到万级的?通过每个分片内部建立索引吗?没太明白。。。】
分三个层次来理解:
- 一、分库分表是什么?
当单表数据量太大(比如百万级以上),查询性能会急剧下降。分库分表就是把一张大表按某个规则拆成多张小表,分散到多个数据库实例上。比如 pr_base_data 表按"租户 ID"拆分——租户A的数据在分片库1,租户B的数据在分片库2,这样每个分片库只存一部分数据,查询压力分散。
- 二、分片键是什么?
分片键(Sharding Key)就是决定"这条数据该放在哪个分片库"的字段,在这个项目中分片键是租户 ID(tenant)。分库分表中间件(如阿里的 TDDL)根据 tenant 的值做哈希或取模,决定路由到哪个分片库。查询时如果 SQL 中带了分片键,中间件能精确路由到对应的分片库;如果不带分片键,中间件只能广播查询所有分片库再合并结果,性能极差。
- 三、如何从十万级收敛到万级? 分两步收敛:
第一步——分片路由收敛:SQL 中始终带上WHERE tenant_id = ?作为第一条件,分库分表中间件根据 tenant_id 精确路由到该租户所在的分片库(比如从 16 个分片库缩小到 1 个),数据量从全量百万级收敛到单分片的十万级。
第二步——联合索引收敛:在每个分片库内部,对(tenant_id, business_field)建了联合索引。查询WHERE tenant_id = ? AND bill_cycle = ? AND data_usage = ?时,数据库引擎通过联合索引快速定位到符合条件的记录,而不是扫描分片内的全部十万条数据。经过 tenant + bill_cycle + data_usage + employee_type 等多条件过滤后,候选数据从十万级收敛到万级。
简单总结:第一步靠分片路由"选对库",第二步靠联合索引"快速查",两步叠加实现了从全量百万级 → 单分片十万级 → 最终万级的三级收敛。
面试深挖 Q&A
请介绍一下这个项目,你在其中主要负责什么工作?
A:这是面向集团薪酬全生命周期的 AI 驱动质检平台,核心问题是传统薪酬质检完全依赖人工抽检——业务方在 Excel 中定义几十甚至上百条质检规则,由 HR 逐条核对发薪数据,效率低且容易遗漏,错误往往要等发薪后才发现,修正成本极高。我们的解决方案是用 LLM 把业务人员用自然语言描述的质检规则批量解析为可执行的 Groovy DSL 脚本,配合强弱卡控双链路(强卡控 STRONG 直接阻断发薪流程、弱卡控 WEAK 仅展示警告提示)与异步执行引擎,把"人工抽检 + 发薪后修正"升级为"发薪前自动拦截"闭环。一期覆盖六大质检环节(QualityCheckStepEnum 枚举定义:REPORT 数据提报、CALCULATE 薪资计算、ADJUST 调账、REPORT_FORM 报表、SALARY_PLAN 薪资方案、OTHER 其他),三大规则类型(PrRuleType 枚举定义:EXCEL_VALIDATOR 条件逻辑校验、SALARY_RESULT_VALIDATOR 薪资结果校验、PAYEE_LIST_VALIDATOR 算薪名单校验),规则通过 mapRuleType(stage, scope) 方法按质检环节和作用范围自动路由到对应类型。项目上线后实现月人工抽检工时下降 50% 以上,质检通过率 95% 以上,规则复用率 60% 以上。我主要负责五大核心模块:第一是 Self-Refine 自洽循环的设计实现,这是整个 AI 解析链路的核心——LLM 生成 Groovy 脚本后通过 GroovyClassLoader 预编译校验,失败时将编译器错误反馈注入下一轮 Prompt 让 LLM 定向修正,最多 3 轮收敛;第二是异步质检执行引擎,包含多租户隔离(每租户最多 2 个并发任务、TenantContext 透传)、基于 @Failover 注解的跨节点容灾、Redis-DB 双层进度追踪;第三是 Prompt 工程 SRE 化,将 apiKey / model / systemPrompt 全部外置到 Diamond 配置中心支持分钟级热更新,同时实现推送前 lint 校验(长度下限 + 关键词白名单)和出栈日志三类正则脱敏(API Key / 键值对 / Bearer Token);第四是推理成本与限流双控,显式关闭 qwen3 深度思考开关节省 3-5 倍 thinking token,用双信号量隔离批量(Semaphore(10))和单条(Semaphore(1))解析的并发,指数退避仅在限流异常触发;第五是协同表格 OpenAPI 集成的异常分发链路,通过钉钉 Notable API 将质检异常写入协同表格供业务方实时协作跟进。项目采用三仓协作架构:cn-payroll 主仓负责质检任务和异步引擎,cn-payroll-dmc 数据管理中心仓负责规则引擎和 AI 解析,cn-hrwork 工作台仓负责网关 Controller 转发。三仓独立 Git 仓库但同一部署单元,主仓通过 Class.forName() 反射加载 DMC 仓的 Handler 类保持编译期解耦和独立发版节奏。
做项目中遇到的最大挑战是什么?
A:最大挑战是异常分发链路中协同表格 OpenAPI 的对接与数据写入。具体来说有三个难点:
第一层:钉钉 Notable API 的分批写入与单次上限。钉钉协同表格的 insertRecords() API 单次最多写入 500 条记录,但一次质检任务可能产生数千条异常数据,必须按 500 条分批写入。每批写入前要先通过 getAllSheets() 定位目标 Sheet(按名称匹配"发薪前异常审核-汇总"),再调用写入接口。更棘手的是,如果 Sheet 中已有数据(比如上次质检的结果没清掉),需要先清空再写入,但 Notable API 没有"清空 Sheet"的原子操作,只能逐行删除或重建文档——我们选择了基于模板重建文档(createDocumentFromTemplate()),每次质检异常分发都创建全新的表格文档,避免历史数据干扰。
第二层:工号 → 钉钉 userId → unionId 的三级转换链路。质检异常数据中的员工标识是工号(empNo),但钉钉 Notable API 的文档创建和分享权限需要 unionId。中间必须先调 HCM 主数据服务将工号转为钉钉 userId,再调钉钉开放平台将 userId 转为 unionId。问题在于这两次转换都有一定的失败率(员工未绑定钉钉、数据同步延迟等),且每条异常记录都需要独立转换。我们通过 parallelStream() 并发转换 + 失败记录降级处理(跳过该条但不中断整批写入)解决了性能问题,最终写入成功率保持在 98% 以上。
第三层:跨仓反射加载与 Handler 解耦。异常分发链路涉及三个仓:cn-payroll 主仓发起质检任务 → cn-payroll-dmc 仓的 QualityCheckAiSheetHandler 执行表格写入 → cn-hrwork 仓的 Controller 接收前端请求。DMC 仓的 Handler 不能通过 Maven 依赖直接引入主仓(否则会把 DMC 仓大量无关类定义拉进编译期依赖),所以用了 Class.forName() 反射加载。反射加载本身不难,难点在于 Handler 内部依赖的 Spring Bean(如 JedisCluster、TairStringCluster、钉钉客户端)必须在 DMC 仓的 Spring 容器中正确注入,而主仓启动时不会自动初始化 DMC 仓的 Bean——必须确保 DMC 仓的 @Component 类被 Spring 扫描到,这涉及到三仓共享部署单元时的 ComponentScan 包路径配置,配置错误会导致反射加载的 Handler 内部 Bean 全部为 null,运行时 NullPointerException。
Self-Refine 自洽循环的具体流程是什么?为什么最多 3 轮?
A:每条规则的解析流程是一个最多 3 轮的"生成 → 校验 → 反馈"循环,核心实现在 RuleBatchParseExecutor.parseSingleInternal() 中,用 for (attemptNo = 1; attemptNo <= MAX_ATTEMPTS; attemptNo++) 硬编码循环,MAX_ATTEMPTS 常量值为 3。每轮流程分五步:第一步获取信号量许可控制并发——批量解析(F4 功能)走 Semaphore(10) 最多 10 个并发 AI 调用,单条重生成(F5 功能)走独立的 Semaphore(1),两者隔离防止批量任务挤占单条操作的响应;第二步 buildUserMessage() 拼装 User Message,包含规则上下文(质检环节 QualityCheckStepEnum、作用范围 scope、涉及字段列表、卡控规则原文、提醒文案)加上该环节对应的字段字典(数据提报阶段从 Excel 模板表加载列定义,薪资计算阶段从薪酬项表按子串匹配过滤),如果不是第一轮还会追加 [上次尝试反馈] 区块注入上一轮的错误信息;第三步 callBailianApi() 调用百炼 SDK 的非流式接口,传入 System Prompt(从 Diamond 配置中心按 stage + ruleType 路由选择,数据提报走 DiamondRuleGeneratorConfig,薪资计算走 DiamondRuleGeneratorRuntimeConfig 中的 salaryResultSystemPrompt 或 payeeListSystemPrompt)和拼装好的 User Message,显式设置 enable_thinking=false 关闭深度思考;第四步 parseAiResponse() 校验返回的 JSON 格式——AI 必须返回包含 parseSuccess、content(Groovy 脚本 Base64 编码)、ruleName、description、checkType(STRONG/WEAK)、isGlobal 等字段的合法 JSON,缺少任何必要字段都归类为 AI_FORMAT 错误;第五步 tryPrecompile() 调用 GroovyUtils.precompile() 用 GroovyClassLoader.parseClass() 做 Groovy 预编译语法校验,这一步是"编译器作为 Verifier"的核心——只有通过预编译的脚本才能写入规则库。预编译通过则循环成功返回,任何步骤失败则记录三个状态变量:lastAiResponse(AI 原始输出全文)、lastErrorType(ParseErrorType 四态之一)、lastErrorMessage(具体错误详情如"unexpected token at line 5"),注入到下一轮 Prompt 的 [上次尝试反馈] 区块。另有一个短路逻辑:若 AI 主动返回 parseSuccess=false(表示 AI 判定该规则无法解析为可执行脚本),直接跳出循环不浪费后续轮次。3 轮上限基于两方面考量:统计数据——第 1 轮成功率约 85%,加上第 2 轮错误修正累计达 95% 以上,第 3 轮边际提升仅 2-3 个百分点;成本分析——每多一轮约增加 2000-3000 个 token([上次尝试反馈] 注入和 AI 重新生成),3 轮以上成功率提升已不足以覆盖额外成本。选择 for 循环而非递归是因为循环更直观、状态变量(lastAiResponse 等)在循环体外声明每轮更新,递归在固定深度场景下无优势。面试中可以延伸对比 Reflexion 模式:Reflexion 有独立的 Reflection 步骤让 LLM 先分析错误原因再修正,我们的设计更简洁——直接把编译器错误作为 Feedback 注入,省去额外的反思调用,在有明确 Verifier(编译器)的场景下效率更高。
Self-Refine 中的错误反馈是怎么注入到下一轮 Prompt 的?与普通重试有什么区别?
A:核心区别在于"有状态反馈"vs"无状态重复"。普通重试(如 Spring Retry、Guava Retryer)只是在相同输入上重复执行相同的逻辑,每次调用的参数完全一样,依赖的是"非确定性操作偶然成功"(比如网络波动恢复、限流窗口过去);而 Self-Refine 的每一轮都会把上一轮的完整 AI 响应和具体错误信息注入到新的 Prompt 中,让 LLM 能看到自己上次生成了什么、错在哪里、编译器给出了什么反馈,从而做定向修正而非随机重试。具体实现在 buildUserMessage() 方法中:方法检测 lastAiResponse 和 lastErrorType 两个参数是否非空,如果是(说明不是第一轮),就在 User Message 末尾追加 [上次尝试反馈] 区块。这个区块的结构包含三个子字段:第一个是"上次输出",放入 AI 上一轮的原始响应全文(包括可能格式错误的 JSON),让 LLM 完整看到自己上次的生成结果;第二个是"错误类型",放入四态之一(AI_CALL / AI_FORMAT / GROOVY_COMPILE / TIMEOUT),帮助 LLM 判断问题出在哪个层面——比如看到 GROOVY_COMPILE 就知道是自己写的脚本语法有问题,而不是网络或格式问题;第三个是"错误信息",放入具体的错误详情——最有价值的是 GROOVY_COMPILE 类型的编译错误,包含具体行号、列号和原因描述(如 "unexpected token 'def' at line 5, column 12"),LLM 能据此精确定位并修正语法问题。这种设计不用 Spring Retry 的根本原因是标准重试框架的抽象模型是"调用 → 成功/失败 → 等待 → 再调用",没有在重试间传递和转化上下文状态的能力。要实现我们的需求,需要在每次失败后提取错误信息、格式化为 Prompt 文本、注入到下一次调用的输入中——这是业务逻辑而非基础设施逻辑,用重试框架的 @Recover 方法也无法优雅表达。从 AI 范式角度看,这就是 Self-Refine 论文定义的 Generator-Verifier-Feedback 三元组:LLM 是 Generator 负责生成 Groovy 脚本,Groovy 编译器是 Verifier 负责验证脚本正确性(编译器的判定是确定性的、零成本的,不像人类 review 有主观性),编译器的错误输出是 Feedback 回流到 Generator 驱动下一轮修正。这种"把编译器作为 Verifier"的思路特别适合代码生成场景——编译器是最精确、最低成本的代码正确性验证器。面试中可以延伸对比 Reflexion 模式:Reflexion 有独立的 Reflection 步骤(LLM 先分析错误原因再修正),我们的设计更简洁——直接把编译器错误作为 Feedback 注入,省去额外的反思调用,在有明确 Verifier 的场景下效率更高。
四种错误类型分别是什么?为什么要做这种分类?
A:四种错误类型定义在 ParseErrorType 枚举中,按失败阶段从外到内依次是:AI_CALL(百炼 SDK 调用层面的失败,包括网络异常、DNS 解析失败、鉴权错误、SDK 内部异常等,意味着请求根本没有到达模型);AI_FORMAT(AI 确实返回了结果,但格式不合预期——可能是返回了纯文本而非 JSON、JSON 缺少必要字段如 parseSuccess 或 content、content 字段为空、Base64 解码失败等,说明 Prompt 的输出契约约束不够严格或模型出现了幻觉);GROOVY_COMPILE(AI 返回的 JSON 格式正确、Groovy 脚本也成功提取,但 GroovyClassLoader.parseClass() 预编译失败——语法错误、引用了不存在的类或方法、括号不匹配等,这是最有价值的错误类型因为编译器的错误信息足够精确具体);TIMEOUT(单次 AI 调用超过 HTTP 超时设置 90 秒未返回,通常是因为模型负载过高或规则描述过于复杂导致生成时间过长)。分类设计服务于三个目的:第一是反馈策略差异化——GROOVY_COMPILE 的错误信息(含具体行号、列号和错误原因)对 LLM 最有可操作性,注入下一轮 Prompt 后 LLM 大概率能精确修正;AI_FORMAT 的错误信息也有一定价值(如"缺少 checkType 字段"),LLM 可在下一轮补上;而 AI_CALL 是基础设施问题,把网络错误反馈给 LLM 毫无意义,但仍值得重试因为下次网络可能恢复;TIMEOUT 类似,可能下次模型负载降低就能成功。第二是退避策略差异化——只有 isRateLimitException() 检测到限流类异常(错误消息包含 "429" / "rate limit" / "throttl" / "too many requests" 关键词)才触发指数退避(2^(attemptNo-1) * 1000 毫秒,即 1s / 2s / 4s),因为限流意味着短时间内再请求一定失败;其他类型的异常直接进入下一轮或 fail-fast,不做无意义等待。第三是可观测性——每种错误类型对应不同的监控指标和告警阈值,AI_CALL 告警阈值最低因为通常意味着大面积故障、GROOVY_COMPILE 比例突然上升可能说明 Prompt 模板被错误推送、TIMEOUT 比例上升提示需要扩容或降低并发。从生产数据看,GROOVY_COMPILE 最常见(约 60%),说明 LLM 在代码生成时的语法准确率还有提升空间;AI_FORMAT 约 20%,主要因为极少数边缘规则描述导致模型偏离 JSON 格式;AI_CALL 和 TIMEOUT 各约 10%。这些分布数据反向驱动 Prompt 优化——比如增加更多 Groovy 语法示例和常见错误避坑指引来降低 GROOVY_COMPILE 比例,增强输出契约中的 JSON Schema 描述来降低 AI_FORMAT 比例。
什么是 QLExpress?什么是 DSL 规则?项目中的规则引擎具体用的是什么?
A:QLExpress 是阿里开源的轻量级业务规则引擎,支持用类 Java 语法编写业务规则脚本,特点是上手门槛低、可动态加载执行,在集团内部很多业务系统中用于营销规则、风控规则等场景。DSL(Domain-Specific Language)是面向特定领域的专用语言,在这个项目中指的是业务人员用自然语言描述的质检规则被 LLM 转换后生成的可执行脚本代码。项目初版设计考虑过 QLExpress,但最终落地使用的是 Groovy 脚本引擎,原因有三方面:第一是 Groovy 语法与 Java 完全兼容,表达能力更强——质检规则可能涉及复杂的日期计算、字符串处理、集合操作,Groovy 可以直接使用所有 Java 类库(如 BigDecimal 精确计算、LocalDate 日期处理),而 QLExpress 的内置函数库有限,遇到复杂逻辑需要扩展操作符比较麻烦;第二是预编译校验能力——GroovyClassLoader.parseClass() 提供了完整的语法校验,编译错误信息包含精确的行号、列号和错误原因,这对 Self-Refine 循环中的"编译器验证"环节至关重要(编译器错误信息越精确,LLM 在下一轮修正的成功率越高),QLExpress 的语法检查能力相对弱,错误信息不够精确;第三是 LLM 的生成质量——主流 LLM(如 qwen3)对 Groovy/Java 语法的训练数据远多于 QLExpress 这种小众 DSL,生成正确 Groovy 代码的第一轮成功率(约 85%)显著高于 QLExpress 脚本。代码中 PrRule.execute() 按 language 字段路由执行,支持三种模式:GROOVY 路径调用 GroovyUtils.executeScript() 执行 Groovy 脚本,是 AI 生成规则的标准路径;JAVA 路径通过反射调用 Java 类的方法,用于早期手写的硬编码规则(上线初期业务紧急时直接写 Java 实现,后续逐步迁移到 Groovy);JPATH 路径用 JsonPath 表达式做简单的 JSON 字段提取和比较,用于最轻量的检查场景(如"字段 A 不为空"、"字段 B 的值在枚举列表中"),这类规则简单到不需要生成 Groovy 脚本。AI 生成的规则统一走 Groovy 路径,因为 Groovy 能表达所有复杂逻辑且有完善的预编译校验。如果面试官问为什么不用 JavaScript(Nashorn/GraalJS),原因是 JVM 上的 JavaScript 引擎性能和调试体验不如 Groovy,Java 互操作性更差(跨语言类型映射复杂),且团队技术栈以 Java 为主,Groovy 的学习成本最低。
Groovy 脚本引擎有什么安全风险?你们是怎么防护的?
A:Groovy 最大的安全风险是它可以执行任意 Java 代码——理论上一段恶意脚本可以调用 Runtime.exec() 执行系统命令、读写文件系统、通过反射突破沙箱、建立网络连接外传数据,甚至可以通过 System.exit() 直接杀死 JVM 进程。在我们的项目中,安全防护依赖三层策略:第一层是 AI 生成端的 Prompt 约束——System Prompt 中通过输出契约严格规定了脚本结构(必须是一个返回 Boolean 的闭包)和可用 API 范围(只允许使用白名单工具类的方法),同时给出了大量正确示例和明确的禁止清单(禁止 Runtime/ProcessBuilder/反射/System.exit 等),LLM 生成的脚本主要是 if/else 条件判断、字段取值比较、日期差值计算等安全操作。第二层是运行时上下文隔离——GroovyContextEnhancer 模块控制脚本运行时可以访问的对象白名单,只向脚本上下文注入 StringUtils(字符串处理)、PrDateUtils(日期计算)、LineMapUtil(行数据字段映射)等预定义的工具类,脚本通过这些受控入口访问业务数据,而非直接操作底层系统。注入方式是通过 Groovy 的 Binding 对象设置变量名到工具类实例的映射,脚本中只能通过绑定的变量名访问对应的工具类。第三层是编译期的结构校验——tryPrecompile() 阶段通过 GroovyClassLoader 编译脚本,虽然不做 AST 级别的安全扫描,但编译过程本身能排除语法错误的脚本。之所以没有用 Groovy 原生的 SecureASTCustomizer 做正式沙箱(它可以配置允许/禁止的语句类型、方法调用、import 白名单等),是因为我们的场景是 AI 生成的脚本而非用户直接输入——攻击面是"AI 意外生成危险代码"而非"用户恶意构造攻击脚本",Prompt 约束已经足够控制生成内容的安全性,加沙箱的复杂度(维护允许/禁止的 AST 节点白名单,且可能误拦正常的业务逻辑)带来的收益有限。此外,GroovyUtils 使用 Caffeine 缓存(容量 1024)按脚本内容的 MD5 缓存编译后的 ScriptingFunction,避免重复编译。这个缓存设计还解决了一个重要的安全隐患:Groovy 每次编译都会通过 GroovyClassLoader 创建新的 Class 对象,这些 Class 持有对 ClassLoader 的引用导致 MetaClass 元数据无法被 GC 回收,大量编译会导致 Metaspace 内存泄漏(OutOfMemoryError: Metaspace)。通过 MD5 去重 + Caffeine 缓存,同一脚本内容只编译一次,从根本上避免了这个问题。
异步质检执行引擎的多租户隔离是怎么实现的?
A:多租户隔离分为两层,从逻辑隔离到资源隔离逐层加固。第一层是租户上下文传递:通过 TenantContext.call(tenant, () -> { ... }) 包裹整个执行逻辑,这个方法会在当前线程的 ThreadLocal 中设置租户标识,确保后续所有操作(包括 HSF 跨进程调用)都能自动透传租户信息。在分库分表场景下,TDDL 的路由规则会读取 TenantContext 中的租户 ID 来选择对应的分库分片,所有 SQL 查询都会带上租户过滤条件,天然实现数据隔离。当使用 @Failover 容灾框架在新线程中执行任务时,需要特别注意 ThreadLocal 的传递——QualityCheckParam 中序列化存储了 tenantId,在新线程启动时通过 TenantContext.call() 重新设置,避免 ThreadLocal 丢失导致跨租户数据泄露或路由到错误的分库。第二层是租户级并发控制:每个租户最多同时执行 2 个质检任务(MAX_CONCURRENT_PER_TENANT = 2)。获取并发许可的逻辑是"双重检查"——第一步查 DB 中该租户 PROCESSING 状态的任务数,如果已达上限直接返回错误码,前端提示"当前有正在执行的质检任务,请稍候";第二步如果 DB 检查通过,尝试获取 Redis 分布式锁(SET NX EX 30min),锁的 key 格式包含 tenantId 和 taskId 保证任务级唯一。之所以需要双重检查而非只用 Redis 锁,是因为 DB 查询能感知到历史状态(包括 Failover 重新执行的任务),而 Redis 锁只反映当前时刻的加锁状态——如果只依赖 Redis 锁,当 Redis 发生主从切换丢失锁数据时,可能允许超出限制的并发任务启动。设置每租户 2 个并发上限的原因是:质检任务涉及大量分页查询(PAGE_SIZE=1000)和规则执行(VALIDATE_BATCH_SIZE=200、bucketNum=50),单个任务已经会产生显著的数据库读压力,不限制并发的话单租户的多个任务会打爆该租户所在的分库分片,影响同库其他租户的正常业务——薪酬系统是多租户共享分库分片的架构,一个租户的异常负载会级联影响其他租户。为什么是 2 而不是 1 或 3?1 个太严格,用户可能需要同时运行数据提报和薪资计算两个环节的质检;3 个在压测中观察到分片 DB 的 CPU 利用率超过 70% 阈值,存在影响在线业务的风险。如果面试官问"超出限制时用户怎么办"——前端会显示友好提示,告知当前有任务正在执行,可以等待完成或手动取消后重新发起。
跨节点 Failover 容灾框架是怎么工作的?
A:使用了公司内部的 cpo.failover 框架,通过 @Failover 注解声明在 QualityCheckAsyncTask 类上。核心机制分为三个阶段:任务提交阶段——任务参数类 QualityCheckParam 实现 ReliableCallParam<String> 和 Serializable 两个接口,前者定义了 getKey() 方法返回任务唯一标识(taskId),后者确保参数可序列化。框架在接收到任务调用时,先将序列化后的参数(包含 taskId、tenantId、qualityCheckStepEnum、bizId 等关键字段)持久化到 DB,同时记录执行节点的 IP 和时间戳。任务执行阶段——通过 RunMode.NEW_THREAD 配置在新线程中执行,与调用方线程解耦,调用方提交后立即返回不阻塞。选择 NEW_THREAD 而非 CALLER_THREAD 是因为质检任务可能运行数十分钟(取决于数据量和规则数),在调用方线程中执行会阻塞 HSF 线程池,影响其他 RPC 请求的处理。容灾恢复阶段——框架通过心跳机制感知节点存活状态,如果执行节点超过一定时间没有更新心跳(通常是节点宕机或 FullGC 假死),框架判定该节点失活,由集群中其他存活节点的 Failover 调度器从 DB 取出未完成的任务参数,反序列化后重新执行。这解决了长时间运行的质检任务在节点宕机时中断无法恢复的问题——没有容灾的话,一个运行了 20 分钟的任务因宕机中断,用户需要手动重新触发并等待再次执行。Failover 恢复与分布式锁的协同设计:原节点宕机后其持有的 Redis 锁会因 EX 过期自动释放(锁的 TTL 设置为 30 分钟),新节点重新执行时会获取新的锁。锁的续约通过 Tair CAS(Compare And Swap)原子操作实现——每隔一段时间(通常是 TTL 的 1/3)更新锁的过期时间,确保长时间运行的任务不会因锁过期被误释放。释放锁使用 Tair CAD(Compare And Delete),只有 lockValue 匹配的持锁者才能释放,防止因锁过期后被其他节点获取导致的误删。如果面试官问"原节点恢复后会不会重复执行"——框架通过 DB 状态标记保证幂等性,任务被新节点接管后状态更新为"已转移",原节点恢复后发现状态已变更不会重复执行。进度恢复方面,Failover 后新节点从 DB 中读取最近一次持久化的进度断点(每 10 页持久化一次),从断点位置继续执行而非从头开始,最多损失 10 页数据的重复处理,对最终结果无影响(规则校验是幂等操作)。
Redis-DB 双层进度的设计思路是什么?为什么不全用 Redis 或全用 DB?
A:双层设计的核心考量是"实时性 vs 持久性"的平衡,同时兼顾写入频率与系统压力。Redis 层承担实时进度的高频写入和快速读取:每页数据(PAGE_SIZE=1000)处理完后立即写入 Redis(SET qc:progress:{taskId} {count} EX 3600),key 按 taskId 唯一标识,value 是已处理的记录数,TTL 1 小时自动过期避免残留数据。前端通过轮询接口读取进度时优先查 Redis,响应延迟 < 1ms,对用户来说进度条几乎是实时更新的。DB 层承担进度的持久化和最终一致性保障:每处理 10 页(即 10000 条记录)才持久化一次,将当前处理进度写入质检任务表的 progress 字段。10 页这个阈值是在写入频率和数据安全之间的折中——假设每页处理耗时约 2 秒,10 页即 20 秒持久化一次,即使 Redis 故障最多丢失 20 秒的进度,Failover 重新执行时从 DB 记录的断点继续即可。全用 Redis 的问题有三个:一是 Redis 故障(如主从切换、内存淘汰)后进度数据丢失,任务重新执行时不知道从哪里继续,只能从头开始浪费大量计算;二是没有事务保障,任务状态(PROCESSING → SUCCESS/FAILED)和进度可能出现不一致;三是 Redis 数据不可追溯,无法事后分析任务执行的历史进度变化。全用 DB 的问题也有三个:一是写入频率过高——每页写一次 DB 意味着一个万级数据的质检任务会产生几十次 UPDATE,在分库分表场景下每次 UPDATE 需要先路由到正确分片再执行,高频更新对数据库压力大;二是查询延迟——数据库的 SELECT 延迟通常在 5-10ms,前端每秒轮询 2 次的话每次都打 DB 既浪费连接资源又造成可感知的卡顿;三是对在线核心业务的干扰——质检任务和在线薪酬业务共享分库分片,高频写进度会影响正常业务的 SQL 执行延迟。查询优先级设计:PROCESSING 状态的任务优先读 Redis(拿到实时进度),Redis 缺失时 fallback 到 DB(拿到最近一次持久化的进度);最终状态(SUCCESS/FAILED)以 DB 为准,因为 Redis 的进度只是过程数据、DB 的状态才是权威数据。前端额外做了封顶 99 的处理——即使 Redis 进度计数已经到达总数据量(所有页处理完),前端也只显示 99%,直到 DB 的任务状态字段更新为 SUCCESS 后才切换到 100% 完成态并展示结果。这是防止"进度条已到 100% 但还没有结果"的用户困惑,因为 Redis 进度先于 DB 最终状态到达可能有几秒的时间差(最后一页处理完后还需要做结果汇总、状态更新等收尾操作)。
Prompt 工程 SRE 化中,推送前的 lint 校验具体做了什么?为什么需要这一步?
A:lint 校验在 Diamond 配置回调 received() 方法中实现,是配置推送到达应用实例后、实际更新内存中 Prompt 变量之前的一道守门检查。具体做了两项检查:第一项是长度校验——systemPrompt 必须超过 1000 字符。这个阈值不是随意设定的,一个有效的规则解析 Prompt 至少包含以下组成部分:角色定义(约 100 字)、任务描述(约 200 字)、输出契约(定义 AI 必须返回的 JSON 结构,包含 parseSuccess / content / ruleName / checkType / description / isGlobal 等字段说明,约 300 字)、Groovy 脚本模板和示例(约 200 字)、字段引用说明和约束(约 200 字),加起来远超 1000 字符,低于这个阈值说明推送了空内容、残缺配置或完全错误的文本。第二项是关键词白名单校验——推送的 Prompt 必须包含"输出契约"、"checkType"、"parseSuccess"等核心关键词。这些关键词对应 Prompt 中定义 AI 输出格式的关键约定:缺少"输出契约"说明 JSON 格式定义部分被删除、缺少"checkType"会导致 AI 不输出强弱卡控类型使得 parseAiResponse() 解析时找不到该字段报 AI_FORMAT 错误、缺少"parseSuccess"会导致 AI 不输出解析成功标志使得系统无法判断 AI 是否认为规则可解析。两项校验任一不通过时保留旧值不更新(volatile 静态变量不赋新值,继续使用上一个版本的 Prompt),同时打印 WARN 级别告警日志,日志中包含推送内容的前 200 字方便快速定位问题原因。这一步的必要性来自实际运维经验:Prompt 是通过 Diamond 配置中心人工编辑推送的,存在多种操作风险——手误清空内容、粘贴时没有全选导致只粘贴了一半、从其他格式(如 Word、Confluence)粘贴时引入不可见字符导致 JSON 模板损坏、多人同时编辑覆盖彼此的修改等。没有 lint 兜底的话,一次错误推送会导致线上所有新的规则解析请求因 Prompt 缺陷而批量失败,影响面等同于 P0 故障——且这种故障很隐蔽,从 AI 调用的报错信息很难直接定位到"是 Prompt 被改坏了"。有了 lint 之后,错误推送被拦截在生效前,旧的正确 Prompt 继续生效,运维人员看到告警后修正再推送即可,对线上零影响。项目的两个 Diamond 配置类(DiamondRuleGeneratorConfig 管理数据提报阶段的 Prompt、DiamondRuleGeneratorRuntimeConfig 管理薪资计算阶段的 salaryResultSystemPrompt 和 payeeListSystemPrompt)各自独立做 lint 校验,确保每个 Prompt 模板都受保护。
日志脱敏的三类正则具体是什么?为什么要做出栈日志脱敏?
A:三类正则定义在 sanitizeForLog() 方法中,按匹配的凭证类型分类:第一类匹配百炼 API Key 前缀 sk-[A-Za-z0-9]{6,},替换为 sk-***,保留前缀 sk- 是为了在脱敏后的日志中仍能区分不同用途的 Key(比如不同环境或不同模型的 Key 前缀可能不同,保留前缀有助于问题定位);第二类匹配通用键值对形式的 API Key (api[_-]?key)\s*[=:]\s*[A-Za-z0-9_\-]{6,},替换为 $1=***,使用 $1 捕获组保留 key 名称(如 api_key 或 apiKey),只脱敏值部分,这样日志中仍能看到是哪个配置项、只是值被隐藏了;第三类匹配 Authorization/Bearer Token (authorization|bearer)\s*[:=]?\s*[A-Za-z0-9_\-\.]{8,},替换为 $1=***,覆盖 HTTP Authorization 头和 Bearer Token 两种常见的认证信息格式。三类正则的设计是逐步积累的——第一类覆盖百炼 SDK 特有的 Key 格式,第二类覆盖通用的 API Key 配置项,第三类覆盖 HTTP 认证头,三者组合基本覆盖了 AI 调用链路中可能泄露的所有凭证类型。做出栈日志脱敏的背景是:AI 调用的请求和响应日志中必然携带 API Key(在请求头或 URL 参数中)和认证信息,这些日志会被收集到公司的日志服务(SLS),并离线归档到 ODPS 数据仓库,被索引为全文搜索。这意味着如果不脱敏,任何有日志查看权限的员工都能通过搜索 "sk-" 前缀直接获取百炼 API Key——在大公司内部,日志权限通常比 API Key 权限宽泛得多(日志权限按应用维度授权,API Key 按团队维度管控),这就形成了权限绕过。更严重的是,日志数据通常会保留数月甚至数年,一旦凭证落盘就很难彻底清除——即使事后轮换了 Key,历史日志中的旧 Key 如果还有效就仍然是风险点。脱敏的注入点选择在百炼 API 的 wrapper 层(调用百炼 SDK 前后的日志记录方法),对上层业务代码完全透明——业务代码正常使用完整 Key 进行 API 调用,只有写日志时才触发脱敏。这种设计保证了脱敏逻辑的集中管理(只需维护一处正则),且不影响运行时功能。正则的编写有一个注意点:不能过于宽泛否则会误脱敏正常的业务数据(如 Base64 编码的 Groovy 脚本 content 字段),也不能过于狭窄否则新增凭证类型时需要补充正则。
深度思考开关是什么?为什么要显式关闭?
A:深度思考(Deep Thinking)是 qwen3 系列模型的特性,等同于 OpenAI o1/o3 的 Chain-of-Thought 推理模式。开启后模型会先在 reasoning_content 字段输出详细的推理过程(类似人类的"先想后答"),然后在 content 字段输出最终结果。qwen3 系列的一个关键行为差异是:深度思考默认开启,不显式关闭时 SDK 返回的 content 字段可能为空——所有推理内容都在 reasoning_content 中,而我们的 parseAiResponse() 解析逻辑只读取 content 字段,这会直接导致解析失败被归类为 AI_FORMAT 错误。显式关闭的方式是 ChatCompletionParam.builder().parameter("enable_thinking", false),通过 dashscope SDK 的 parameter 扩展字段传入。关闭深度思考的核心考量有三个:第一是 Token 成本——深度思考的 reasoning_content 通常是正式输出的 3-5 倍 token 量。以一条中等复杂度的质检规则为例,正式输出(JSON + Groovy 脚本)约 500-800 token,reasoning_content 可能达到 1500-3000 token。在批量解析 50 条规则、每条可能重试 3 轮的场景下,开启深度思考会额外消耗大量 token,成本增加数倍。规则解析是高频操作(业务方经常调整规则后重新批量生成),Token 成本持续累积影响项目整体预算。第二是 API 行为的确定性——关闭深度思考后 content 字段保证有值,reasoning_content 字段为空,解析逻辑简单可靠;开启深度思考时两个字段的内容分布在不同模型版本和不同推理复杂度下可能有差异,增加了不确定性和维护成本。第三是延迟——深度思考会增加模型的推理时间(需要先生成 reasoning 再生成 content),在已有 90 秒 HTTP 超时的约束下,延迟增加可能导致更多 TIMEOUT 错误,影响 Self-Refine 循环的整体耗时。同时使用非流式调用(stream(false))的原因是:规则解析需要一次性获取完整的 JSON 结果用于后续的 parseAiResponse() 格式校验和 tryPrecompile() 预编译——流式调用返回的每一帧是增量文本片段,需要在客户端拼接完成后才能做 JSON 解析和 Groovy 编译,增加了额外的拼接逻辑复杂度且没有用户体验上的收益(解析过程是后台异步执行的,用户不需要看到逐字输出)。如果面试官问"有没有考虑用深度思考来提高解析质量"——实测对比过,对于规则解析这种结构化输出任务,关闭深度思考的一轮生成质量(成功率 85%)和开启后的差异不大(88%),3% 的提升不足以覆盖 3-5 倍的 Token 成本,且 Self-Refine 循环已经能将最终成功率提升到 97% 以上。
信号量并发控制为什么要区分批量和单条两个独立许可?
A:批量解析(F4 功能,一次解析整个 Excel 中的所有规则,可能有 50+ 条)使用 Semaphore(10) 控制最多 10 个并发 AI 调用;单条重生成(F5 功能,用户对单条规则不满意重新生成)使用独立的 Semaphore(1)。两者用独立信号量隔离,核心原因是防止"大批量挤占小操作":假设共用一个 Semaphore(10),当用户 A 触发 F4 批量解析 50 条规则时,10 个许可会被前 10 条规则的并发调用全部占满,此时用户 B 想 F5 重新生成单条规则就必须排队等待,直到批量解析的某条规则完成释放许可。50 条规则在 10 并发下需要 5 轮才能完成(每轮最多 3 次 Self-Refine 重试),用户 B 可能要等待数十秒甚至数分钟,体验极差。独立信号量保证 F5 单条操作有自己的专属许可(Semaphore(1) 意味着同一时刻最多 1 个单条重生成请求在等待 AI 响应),不受批量任务影响,实现即时响应。Semaphore(1) 只允许 1 个并发而非更多,是因为 F5 是用户手动触发的低频操作,同一用户不太可能同时重生成多条规则,1 个许可够用且避免了单条操作过多消耗 AI API 配额。选择信号量而非线程池来控制并发的原因是语义精确度:我们要控制的是"同时等待 AI 响应的请求数"(百炼 API 有账户级别的并发限制,超出会返回 429 限流),而非"正在执行代码的线程数"。一个 AI 调用的生命周期是:获取信号量许可 → 发 HTTP 请求 → 等待 AI 响应(数秒到数十秒的 IO 等待)→ 解析响应 → 释放许可。真正占用"并发配额"的是 IO 等待阶段,线程池的大小控制的是"有多少线程在运行"而非"有多少请求在等待 IO",两者不等价——比如线程池大小为 10 但每个线程内部又异步提交了 AI 请求,实际并发可能超过 10。信号量直接控制"持有许可的数量",精确对应 AI API 的并发限制。许可获取使用 tryAcquire(timeout) 带超时而非阻塞的 acquire(),超时后抛出异常通知调用方"系统繁忙请稍后重试",避免无限等待。信号量使用非公平模式(默认),因为规则解析请求没有严格的先后顺序要求,非公平模式的吞吐量优于公平模式(减少了唤醒线程的上下文切换开销)。
协同表格 OpenAPI 是怎么用的?完整的异常分发流程是什么?
A:异常分发的完整流程在 QualityCheckAiSheetHandler.handle() 中实现,是质检任务执行完成后将异常结果推送给业务方跟进的链路。第一步是身份转换:将操作人的工号转换为钉钉 unionId,经过工号 → 钉钉 userId → unionId 的三级转换(工号到 userId 通过内部花名册 API、userId 到 unionId 通过钉钉开放平台 API)。之所以需要 unionId 而非直接用工号,是因为钉钉 Notable API 的文档权限体系基于 unionId 标识用户,工号是企业内部标识在钉钉体系中无法识别。第二步创建文档:调用钉钉 Notable API createDocumentFromTemplate() 基于预设模板创建新的 AI 表格文档。模板中预先定义了表头结构(工资月份、所属仓、姓名、工号、异常规则名称、异常详情等 15 个字段)、条件格式(如异常等级用不同颜色标记)和工作流催办配置,创建后新文档自动继承模板的所有格式和配置。第三步定位 Sheet:getAllSheets() 获取新文档中所有 Sheet,通过名称匹配找到"发薪前异常审核-汇总"表的 sheetId——模板中可能有多个 Sheet(如汇总表、明细表、说明表),需要精确定位数据写入的目标 Sheet。第四步聚合异常数据:分页查询质检校验结果表 validation_result,按员工 bizId 聚合异常规则——一个员工可能触发多条规则的异常,需要合并展示为一行记录,异常规则名称和详情用分隔符连接。第五步过滤有效记录:分页查询基础数据表 pr_base_data,仅保留存在异常的员工记录,过滤掉全部通过质检的数据。第六步字段映射:通过 AiSheetFieldMapper 将 Java 对象的 15 个字段映射为 AI 表格的记录格式,字段映射包含数据类型转换(如日期格式化为"yyyy-MM"、金额保留两位小数、枚举值到中文的转换如 STRONG→"强卡控")。第七步分批写入:按 500 条分批调用 insertRecords() 写入 AI 表格,500 是钉钉 Notable API 的单次 insert 上限,超过会返回参数错误。写入完成后,业务方会收到钉钉工作流通知,点击即可打开协同表格开始处理。选择协同表格而非邮件/Excel 附件的原因有三:一是协同表格支持多人实时编辑,HRBP 可以直接在表格中标记处理状态、添加备注、@相关人员;二是内置工作流催办能力,超时未处理的记录自动发送钉钉消息提醒;三是数据可追加更新——如果后续补充了新的质检结果,可以追加写入同一张表而非重新发送。如果面试官问"写入失败怎么办"——每一步都有异常捕获和日志记录,createDocumentFromTemplate 失败会重试 1 次,insertRecords 单批失败会记录失败批次的起止行号到日志供人工补录,不影响其他批次的写入。
跨仓 Facade 反射加载是怎么做的?为什么不直接 Maven 依赖?
A:在 QualityCheckServiceImpl.loadExportHandlerClass() 中,通过 Class.forName("com.cainiao.cn.payroll.dmc.xxx.QualityCheckAiSheetHandler") 反射加载 DMC 仓的 Handler 类。三个仓虽然是独立的 Git 仓库和 Maven 模块,但运行时部署在同一个 JVM 进程中(同一部署单元),所有仓的 class 文件最终被打包到同一个 fatjar 中,共享同一个 ClassLoader,所以 Class.forName() 能找到 DMC 仓的类。具体反射调用方式是 Class.forName() 获取 Class 对象 → getConstructor() 获取构造器 → newInstance() 创建实例 → 转型为公共接口类型调用方法。之所以不直接 Maven 依赖(即 cn-payroll-core 不通过 <dependency> 引入 cn-payroll-dmc-core),原因有四个层面:第一是编译期解耦——DMC 仓包含大量的 DO 对象(数据库实体类)、MyBatis Mapper 接口、规则引擎逻辑、AI 调用组件(百炼 SDK 集成、Prompt 管理等),如果主仓编译期依赖 DMC-core 模块,就会引入上百个无关的类定义,增加编译时间和 IDE 的索引负担。第二是发版节奏独立——三个仓的开发节奏不同,DMC 仓可能因为 AI 解析逻辑优化(如 Prompt 调优、新增规则类型支持)频繁发版,如果主仓有编译期依赖,DMC 的 API 变更会导致主仓编译失败,必须同步修改同步发版,在多团队协作中这种同步协调的沟通成本很高。反射加载只依赖类名字符串和公共接口,DMC 内部实现随意调整不影响主仓编译。第三是循环依赖避免——主仓和 DMC 仓之间有双向调用需求(主仓调 DMC 的 Handler 做异常分发、DMC 调主仓的 Service 查询任务状态),Maven 不允许循环依赖,虽然可以抽出 -api 模块解耦但增加了模块管理复杂度。反射加载天然无编译期依赖,绕过循环依赖问题。第四是 DDD 分层合理性——在 DDD 视角下,三个仓分别对应不同的限界上下文,上下文之间应通过防腐层或接口交互而非直接类引用。反射加载的代价是失去了编译期类型检查——如果 DMC 仓重命名了 Handler 类但没有同步修改主仓的反射字符串,运行时会报 ClassNotFoundException。为此做了两重保障:一是启动时的 smoke test 验证反射类可加载,应用启动阶段失败直接拒绝部署、发布流程自动回滚;二是集成测试覆盖完整的跨仓调用链路。完整调用链路是:前端 → cn-hrwork Controller(HTTP)→ cn-payroll-dmc Gateway Facade(HSF RPC)→ cn-payroll Core Facade(HSF RPC)→ Application Service。
分库分表场景下,非分片键查询是怎么优化的?
A:分库分表后,TDDL 中间件的路由规则基于分片键(通常是租户 ID 或 tenant_id + 业务 ID 的组合)决定数据存储在哪个分库的哪个分表中。如果查询条件不包含分片键,TDDL 会将 SQL 广播到所有分库的所有分表上执行然后合并结果——这就是全表扫描,在分库分表规模较大时(如 16 库 x 64 表 = 1024 个分片)性能极差。在质检场景中,某些查询需要按非分片键字段(如工资月份 salary_month、质检环节 check_step、任务 ID task_id)过滤数据,优化策略的核心原则是"始终在 SQL 的 WHERE 条件中带上分片键作为第一个条件"。具体做法:查询 SQL 写成 WHERE tenant_id = ? AND salary_month = ? AND check_step = ? 而非 WHERE salary_month = ? AND check_step = ?。有了 tenant_id 后 TDDL 能定位到具体的分库(按租户分库),虽然仍需扫描该分库内的所有分表(因为分表键可能不是 tenant_id),但候选分片数从全部 1024 大幅收敛。在每个分片内,配合 (tenant_id, salary_month) 或 (tenant_id, check_step) 联合索引,查询从全表扫描变为索引查询,把每分片的候选数据从十万级收敛到千级甚至百级。联合索引设计的关键是把分片键 tenant_id 放在最左列——即使分片键已经用于路由选库,在分片内的 SQL 执行中它仍然是索引的组成部分,能有效利用 B+ 树索引的最左前缀匹配原则。数据处理采用流式分页而非传统的 offset 分页:每页 PAGE_SIZE=1000,使用"上一页最后一条记录的主键 ID"作为下一页的起始条件(WHERE id > #{lastId} ORDER BY id LIMIT 1000),避免了 OFFSET N LIMIT M 在 N 很大时需要扫描并跳过前 N 行的深分页性能问题。每页 1000 条数据再分批送入规则引擎(VALIDATE_BATCH_SIZE=200),配合 ValidationOption.setBucketNum(50) 设置 50 个桶并发执行。50 个桶的含义是规则引擎内部将 200 条数据按 hash 分到 50 个桶中并发校验,每个桶内串行执行所有规则——这种设计比"每条规则并发"更高效,因为同一个桶内的数据共享规则上下文(如字段字典映射、GroovyContextEnhancer 注入的工具类实例),减少了重复的上下文初始化。分页查询与分批校验的组合保证了内存可控——任何时刻 JVM 堆中最多只有 1000 条待处理数据 + 200 条正在校验的数据,不会因大数据量导致 OOM。
强弱卡控在技术上是怎么区分的?业务上分别适用什么场景?
A:技术上通过规则元数据中的 checkType 字段区分,取值由常量定义为 STRONG(强卡控)或 WEAK(弱卡控)。AI 生成规则时会根据 Prompt 中的输出契约和规则原文语义判断卡控类型并写入返回 JSON 的 checkType 字段——System Prompt 中对两种类型的语义做了明确定义和示例:强卡控用于"违反后必须修正才能继续的硬约束",弱卡控用于"需要提醒但可以选择忽略的软提示"。AI 生成后用户也可以在规则编辑界面手动调整 checkType,覆盖 AI 的判定——因为有些规则的卡控强度取决于具体的业务上下文(比如同一条"金额超过阈值"规则在关键岗位可能是强卡控、在普通岗位可能是弱卡控),AI 无法完全感知这些上下文,人工校准保证了灵活性。执行时,校验框架在 PrRule.execute() 返回校验结果后,根据 checkType 决定后续行为:强卡控校验失败直接阻断流程,通过 HSF 返回阻断标识给前端,前端展示红色错误提示并将提交按钮置灰,用户必须修正数据使其通过所有强卡控规则后才能继续提交;弱卡控校验失败通过 HSF 返回提醒标识,前端展示黄色警告弹窗,列出所有未通过的弱卡控规则和对应的提醒文案,用户可以选择"返回修改"修正数据,也可以选择"确认继续"跳过警告直接提交。弱卡控的"确认继续"操作会记录审计日志(包含操作人、操作时间、被忽略的规则列表),便于事后追溯和合规审查。业务上,强卡控适用于合规性硬约束:比如"基本工资不能为负数"——负工资在任何场景下都是错误的;"社保基数不能低于当地最低标准"——低于最低标准违反劳动法规;"应发合计与各项明细之和不一致"——数值不平衡说明计算逻辑有 bug。这类规则违反后如果放行,轻则导致发薪错误需要退回重算(高成本且严重影响员工体验),重则引发法律纠纷和合规风险。弱卡控适用于经验性规则或异常提示:比如"绩效系数超过历史平均值 200%,请确认"——可能是正常的晋升调薪或超额完成目标;"本月调薪人数异常偏多"——可能是年度普调或组织架构调整;"新入职员工的工资低于 offer 约定"——可能是试用期折扣。这类情况可能是合理的业务操作,强制阻断会导致正常流程无法进行。从生产数据看,强卡控规则约占总规则数的 30%,弱卡控约占 70%;命中率方面强卡控因为阈值明确命中率较低(约 2-3%),弱卡控因为是经验判断命中率较高(约 15-20%),这个分布也验证了设计的合理性。
自然语言到 Groovy 脚本的转换具体是怎么做的?Prompt 里包含哪些关键信息?
A:自然语言到 Groovy 脚本的转换是整个 AI 质检平台的核心能力,由 RuleBatchParseExecutor 驱动,System Prompt 和 User Message 各承担不同职责。System Prompt 通过 Diamond 配置中心外置管理(按质检环节和规则类型路由选择:数据提报阶段走 DiamondRuleGeneratorConfig 中的 systemPrompt,薪资计算阶段走 DiamondRuleGeneratorRuntimeConfig 中的 salaryResultSystemPrompt 或 payeeListSystemPrompt),内容主要包含五个部分:第一是角色定义,声明 AI 是一个"薪酬质检规则脚本生成器",明确任务边界——只负责将自然语言规则转换为 Groovy 脚本,不做额外分析或解释;第二是输出契约,严格定义 AI 必须返回的 JSON 结构,包含 parseSuccess(是否成功解析,Boolean 类型)、content(Groovy 脚本的 Base64 编码,String 类型)、ruleName(规则名称)、description(规则描述)、checkType(STRONG/WEAK 强弱卡控类型)、isGlobal(是否全局规则)等字段,每个字段都有类型说明和示例值,这是 lint 校验中"必须包含输出契约/checkType/parseSuccess"关键词的来源——缺少任何字段定义都会导致 parseAiResponse() 解析失败;第三是 Groovy 脚本模板,规定了脚本的标准结构——必须是一个接收 context(Map 类型)参数的闭包,返回 Boolean 值(true 表示校验通过、false 表示不通过),示例展示了如何通过 context.get("fieldName") 获取业务数据、如何使用白名单工具类;第四是可用 API 清单和约束,列出脚本中可以使用的工具类和方法签名(如 StringUtils.isEmpty()、PrDateUtils.daysBetween()、LineMapUtil.getBigDecimal()),同时明确禁止 Runtime.exec()、System.exit()、反射等危险操作;第五是常见错误示例和避坑指引,列举 LLM 容易犯的 Groovy 语法错误(如空值未判断导致 NPE、字符串比较误用 == 而应该用 equals()、BigDecimal 比较误用 > 而应该用 compareTo()),帮助 LLM 在第一轮就生成更健壮的代码。User Message 由 buildUserMessage() 方法动态拼装,包含:规则上下文(质检环节、作用范围、涉及字段列表)、卡控规则原文(业务人员写的自然语言描述)、提醒文案(校验失败时展示的文字)、该环节对应的字段字典(告诉 AI 有哪些可用字段和含义)。字段字典是 Prompt 中最关键的动态部分——没有它 AI 只能猜测字段名,大概率生成不存在的字段引用。
规则执行的运行时上下文是什么?GroovyContextEnhancer 注入了哪些工具类?
A:规则执行的运行时上下文是一个 Map<String, Object> 结构的 context 对象,由 GroovyContextEnhancer 负责初始化和增强,作为 Groovy 脚本执行时的输入参数。context 中包含两类内容:第一类是业务数据——当前被校验的数据行(如一条员工的薪资记录),以 key-value 形式存放,key 是字段名(如 "basic_salary"、"employee_id"、"salary_month"),value 是字段值。在数据提报场景(EXCEL_VALIDATOR)下,每行数据来自 Excel 解析后的 Map<String, String> 行数据,key 对应 Excel 列名;在薪资计算场景(SALARY_RESULT_VALIDATOR)下,来自 pr_base_data 表查询结果的字段映射;在算薪名单场景(PAYEE_LIST_VALIDATOR)下,来自 pr_payee_list 表的人员属性数据。第二类是工具类实例——由 GroovyContextEnhancer 通过 Groovy 的 Binding 机制注入,脚本中可以直接通过变量名引用这些工具类。注入的白名单工具类包括:StringUtils——Apache Commons Lang3 的字符串工具类,提供 isEmpty / isBlank / isNotEmpty / equals / equalsIgnoreCase / contains / trim 等方法,是规则脚本中最高频使用的工具(几乎每条规则都需要做空值判断和字符串比较);PrDateUtils——项目自定义的日期工具类,封装了 daysBetween(计算两个日期的天数差)、monthsBetween(月数差)、parseDate(字符串解析为日期)、formatDate(日期格式化)、isAfter / isBefore(日期先后比较)等方法,薪酬规则中大量涉及入职日期、转正日期、工资月份等日期计算;LineMapUtil——行数据字段映射工具类,封装了从 context map 中按字段名安全获取值的逻辑,提供 getString / getBigDecimal / getInteger / getDate 等类型安全的访问方法(如 LineMapUtil.getBigDecimal(context, "basic_salary") 获取并转换为 BigDecimal),内部做了空值保护和类型转换异常捕获,避免脚本中直接做类型转换的空指针和 ClassCastException 风险;BigDecimal——Java 精确数值计算类,薪酬金额计算必须用 BigDecimal 而非 double/float 避免浮点精度问题(如 0.1 + 0.2 != 0.3),工具类注入让脚本可以直接 new BigDecimal("100.00") 而无需写 import 语句。白名单机制的设计意图是双重的:一是安全防护——只注入预审过的安全工具类,限制脚本的能力边界;二是提高 AI 生成质量——System Prompt 中列出了所有可用工具类的方法签名和使用示例,AI 只需从这个有限集合中选择方法组合,比从整个 Java 类库中搜索更容易生成正确代码。
Groovy 脚本的缓存机制是怎么设计的?为什么用 Caffeine + MD5?
A:Groovy 脚本的缓存机制在 GroovyUtils 工具类中实现,使用 Caffeine 缓存框架以脚本内容的 MD5 摘要为 key、编译后的 ScriptingFunction 为 value,缓存容量上限 1024 条。设计这个缓存有两个核心原因:第一是避免重复编译的性能开销——Groovy 的编译过程(通过 GroovyClassLoader.parseClass() 将脚本源码编译为 JVM 字节码)是一个相对重量级的操作,涉及词法分析、语法分析、AST 构建、字节码生成等步骤,单次编译耗时通常在 50-200ms。在质检执行场景中,同一条规则可能需要对上万条数据逐行执行——如果每次执行都重新编译脚本,万条数据 x 200ms = 2000 秒的纯编译开销完全不可接受。有了缓存后,每条规则只在首次执行时编译一次(cache miss),后续执行直接从缓存获取已编译的 ScriptingFunction 调用(cache hit),编译开销从 O(N) 降为 O(1)。第二个更关键的原因是避免 GroovyClassLoader 的 MetaClass 内存泄漏——这是 Groovy 的一个经典陷阱。每次 GroovyClassLoader.parseClass() 都会创建一个新的 Class 对象并注册到 Groovy 的 MetaClass 注册表(MetaClassRegistry)中,这些 Class 持有对 GroovyClassLoader 的强引用,而 GroovyClassLoader 又持有对所有已编译 Class 的引用,形成循环引用导致 GC 无法回收。如果不做缓存去重,对同一脚本的反复编译会不断创建新的 Class,Metaspace 内存持续增长直到 OutOfMemoryError: Metaspace。通过 MD5 去重,同一脚本内容只编译一次、复用同一个 Class 对象,从根本上避免了泄漏。选择 MD5 而非脚本全文作为缓存 key 的原因有二:一是性能——MD5 摘要固定 32 字符,HashMap 的 key 比较(equals)和 hashCode 计算效率远高于可能几百甚至上千字符的脚本全文;二是避免大 key——Caffeine 内部使用 ConcurrentHashMap,用全文作为 key 会占用大量堆内存,1024 条规则 x 平均每条 500 字符 = 约 1MB 额外内存仅用于存储 key。MD5 的碰撞概率在 2^128 量级,在我们最多 1024 条规则的场景下可以完全忽略。选择 Caffeine 而非 Guava Cache 的优势在于:Caffeine 使用 W-TinyLFU 淘汰算法(结合了 LRU 的快速响应和 LFU 的频率感知),在缓存命中率上显著优于纯 LRU。容量 1024 基于业务预估设定——目前活跃的质检规则总数约 500-800 条,1024 留了足够余量。如果未来规则数增长超过 1024,W-TinyLFU 淘汰策略会自动保留最常执行的热门规则、淘汰低频冷门规则,命中率下降可控。
六大质检环节和三大规则类型的对应关系是什么?
A:六大质检环节定义在 QualityCheckStepEnum 枚举中:REPORT(数据提报,HR 通过 Excel 上传员工薪资基础数据)、CALCULATE(薪资计算,系统根据薪酬规则自动计算各薪酬项金额)、ADJUST(调账,对已计算的薪资做手动调整修正)、REPORT_FORM(报表,生成对外提交的薪酬报表如个税申报表)、SALARY_PLAN(薪资方案,定义不同岗位序列的薪酬结构和计算规则)、OTHER(其他,不归入前五类的杂项校验)。三大规则类型定义在 PrRuleType 枚举中:EXCEL_VALIDATOR(条件逻辑校验——对结构化数据做行级的条件判断,如"基本工资不能为负数"、"入职日期不能晚于工资月份");SALARY_RESULT_VALIDATOR(薪资结果校验——对薪资计算结果做交叉校验,如"应发合计 = 各薪酬项之和"、"个税计算结果与预期差异不超过 1 元");PAYEE_LIST_VALIDATOR(算薪名单校验——对参与算薪的人员名单做完整性校验,如"所有在职员工都应出现在名单中"、"离职超过 N 个月的员工不应出现")。两者的映射关系通过 mapRuleType(stage, scope) 方法路由:REPORT 环节默认映射到 EXCEL_VALIDATOR,因为这个环节的质检对象是 Excel 上传的提报数据,规则需要逐行校验每条记录的字段值;CALCULATE 环节根据 scope 参数进一步细分——scope 指向薪资结果相关字段时映射到 SALARY_RESULT_VALIDATOR,scope 指向算薪名单时映射到 PAYEE_LIST_VALIDATOR,这种细分是因为薪资计算环节同时产出"计算结果"和"参与计算的人员名单"两类输出,需要不同的校验逻辑;ADJUST / REPORT_FORM / SALARY_PLAN / OTHER 四个环节根据具体业务场景灵活映射,通常走 EXCEL_VALIDATOR。这种映射设计的意义在于:不同规则类型的执行逻辑和数据来源完全不同——EXCEL_VALIDATOR 的数据来自 Excel 解析后的 Map<String, String> 行数据,通过 LineMapUtil 按列名取值;SALARY_RESULT_VALIDATOR 的数据来自薪资计算引擎的输出(pr_salary_result 表),字段是薪酬项编码;PAYEE_LIST_VALIDATOR 的数据来自算薪名单(pr_payee_list 表),字段是人员属性。对应地,每种类型使用不同的字段字典——EXCEL_VALIDATOR 从 pr_excel_template → pr_excel_sheet → pr_excel_cell 三级表加载 Excel 列定义,SALARY_RESULT_VALIDATOR 和 PAYEE_LIST_VALIDATOR 从 pr_salary_subject 薪酬项表按子串匹配过滤——不同的字典注入到 AI 解析 Prompt 中,确保 AI 针对不同类型生成引用正确字段的 Groovy 脚本。此外,不同规则类型的 System Prompt 也不同——DiamondRuleGeneratorConfig 管理数据提报阶段的 Prompt,DiamondRuleGeneratorRuntimeConfig 管理薪资计算阶段的 salaryResultSystemPrompt 和 payeeListSystemPrompt。
项目的三仓协作架构是怎么设计的?各仓的职责边界?
A:三仓协作架构是基于 DDD 限界上下文划分的,三个仓分别是 cn-payroll(主仓)、cn-payroll-dmc(数据管理中心仓)、cn-hrwork(HR 工作台仓),独立 Git 仓库、独立 Maven 模块、独立开发分支和 Code Review 流程,但运行时部署在同一个 JVM 进程中(同一部署单元,所有仓的产物打包为一个 fatjar)。各仓职责清晰分离:cn-payroll 主仓是质检执行的核心,对应"质检执行"限界上下文,包含质检任务管理(任务创建、状态流转 PENDING→PROCESSING→SUCCESS/FAILED、结果持久化到 validation_result 表)、异步执行引擎(QualityCheckAsyncTask + @Failover 容灾 + RunMode.NEW_THREAD 异步执行)、多租户隔离与并发控制(TenantContext 透传 + MAX_CONCURRENT_PER_TENANT=2 + 分布式锁)、进度追踪(Redis 实时进度 + DB 每 10 页持久化)、HSF Core Facade(对外暴露的核心服务接口)。cn-payroll-dmc 数据管理中心仓是 AI 解析和规则引擎的核心,对应"规则管理"限界上下文,包含 AI 解析链路(RuleBatchParseExecutor + Self-Refine 3 轮循环 + 百炼 SDK 集成 + 信号量并发控制)、规则引擎(PrRule.execute() 按 language 路由 + GroovyUtils 执行 + GroovyContextEnhancer 上下文增强 + Caffeine 缓存)、字段字典管理(从 Excel 模板表和薪酬项表加载不同环节的字段定义)、Diamond 配置管理(Prompt 外置 + lint 校验 + 日志脱敏)、协同表格集成(QualityCheckAiSheetHandler 异常分发)、HSF Gateway Facade(网关层 Facade,做参数校验、权限验证和路由后转发到主仓 Core Facade)。cn-hrwork 工作台仓是用户交互层,对应"用户交互"限界上下文,包含网关 Controller(接收前端 HTTP 请求,做登录认证和权限点校验后通过 HSF 调用 DMC 仓的 Gateway Facade)、前端页面的 BFF(Backend For Frontend)接口。调用链路是:前端 → cn-hrwork Controller(HTTP)→ cn-payroll-dmc Gateway Facade(HSF RPC)→ cn-payroll Core Facade(HSF RPC)→ Application Service。三仓间运行时交互有两种方式:一是 HSF RPC 调用用于服务间接口调用;二是 Class.forName() 反射加载用于主仓调 DMC 仓的 Handler 类(因为同一部署单元共享 ClassLoader)。三仓独立 Git 仓库的好处是开发团队可以独立迭代、各自保持清晰的代码边界;同一部署单元的好处是运行时调用走 JVM 内部方法调用而非网络 RPC,延迟更低且不存在跨进程调用的分布式事务问题。
分布式锁的加锁和释放为什么用不同的原语(Redis SET NX vs Tair CAD)?
A:加锁使用 Redis SET key value NX EX 1800(NX 表示 key 不存在时才设置、EX 1800 表示 30 分钟过期),释放使用 Tair CAD(Compare And Delete)命令,续约使用 Tair CAS(Compare And Swap)命令。三个操作使用不同原语的核心原因是保证分布式环境下的正确性,防止常见的锁安全问题。加锁用 SET NX EX 是标准做法——NX 保证互斥性(只有一个客户端能成功设置 key),EX 保证锁不会因持锁者宕机而永远无法释放(自动过期兜底)。value 存储的是 lockValue(通常是 UUID 或 requestId 的组合),用于后续释放和续约时验证身份。释放锁用 Tair CAD 而非直接 DEL 的原因是防止误删其他客户端的锁——这是分布式锁最经典的安全问题。考虑以下场景:客户端 A 加锁成功(lockValue=A),由于 FullGC 暂停或长时间计算导致锁超时过期(30 分钟 EX 到期),此时客户端 B 成功加锁(lockValue=B),然后客户端 A 恢复执行并调用 DEL 释放锁——此时 A 实际上删除了 B 的锁,导致锁失效、第三个客户端 C 也能加锁成功,两个客户端同时持锁违反了互斥性。CAD(Compare And Delete)命令是 Tair 扩展的原子操作,语义是"当且仅当 key 的当前 value 等于指定 value 时才删除"——客户端 A 调用 CAD 时传入 lockValue=A,由于 key 当前 value 已经是 B(客户端 B 的锁),比较不通过不会执行删除,保证了只有持锁者才能释放自己的锁。标准 Redis 中通常用 Lua 脚本实现相同语义(if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end),但 Tair CAD 是服务端原生命令,性能更好(省去了 Lua 解释执行的开销,减少了一次网络往返)且语义更清晰。续约用 Tair CAS 的原因类似——CAS 语义是"当且仅当 key 的当前 value 等于 old_value 时才更新为 new_value 并重置 TTL"。续约是在质检任务长时间运行时定期延长锁的过期时间(每隔 TTL/3 即约 10 分钟执行一次),防止任务还没执行完锁就过期了。使用 CAS 而非直接 EXPIRE key newTTL 更新 TTL,是为了在续约时也验证身份——如果锁因某种原因已被其他客户端持有,CAS 比较不通过不会错误地延长别人锁的 TTL。三个操作的安全性小结:SET NX EX 保证加锁的互斥性和自动过期防死锁、CAD 保证释放时的身份验证防止误删、CAS 保证续约时的身份验证防止误续。
规则解析的字段字典是怎么构建的?不同质检环节的字典来源不同?
A:字段字典是 AI 规则解析链路中最关键的动态上下文之一,它告诉 AI 当前质检环节有哪些可用字段、每个字段的代码名称(fieldName)和业务含义(displayName),AI 据此在生成的 Groovy 脚本中引用正确的字段名。没有字段字典的话,AI 只能根据规则原文猜测字段名(比如看到"基本工资"可能猜测字段名是 "basic_salary" 或 "baseSalary" 或 "jbgz"),大概率生成不存在的字段引用导致 context.get() 返回 null 进而运行时空指针。不同质检环节的字典来源确实不同,按数据来源分为两大类:数据提报阶段(REPORT 环节,规则类型为 EXCEL_VALIDATOR)的字段字典从 Excel 模板体系加载,采用三级表结构:pr_excel_template(模板表,存储模板 ID、名称和所属租户——不同租户有完全不同的 Excel 模板和列定义)→ pr_excel_sheet(Sheet 表,定义该模板中每个 Sheet 的名称和顺序——一个 Excel 可能有"员工基础信息"、"薪资明细"等多个 Sheet)→ pr_excel_cell(单元格定义表,存储每个 Sheet 中每列的字段名 fieldName、显示名 displayName、数据类型 dataType 如 STRING/NUMBER/DATE、是否必填 required 等属性)。构建过程是:根据当前规则关联的 Excel 模板 ID 查询 pr_excel_template 获取模板信息 → 获取模板下所有 Sheet 列表 → 获取规则涉及的 Sheet 中所有列定义 → 最终生成 List<FieldDefinition> 注入到 Prompt 的字段字典区块。三级表设计是因为不同租户有不同的 Excel 模板(集团内不同 BU 的薪酬结构和报表格式差异很大),同一模板中不同 Sheet 的列也完全不同,必须精确到 Sheet + 列级别才能给 AI 正确的字段信息。薪资计算阶段(CALCULATE 环节,规则类型为 SALARY_RESULT_VALIDATOR 或 PAYEE_LIST_VALIDATOR)的字段字典从 pr_salary_subject 薪酬项表加载。薪酬项表存储了所有薪酬项的编码(如 "base_salary"、"performance_bonus"、"social_insurance_personal")和名称(如"基本工资"、"绩效奖金"、"社保个人部分")。构建方式不是加载全量薪酬项(可能有上千个),而是按规则涉及字段做子串匹配过滤——比如规则原文提到"基本工资",就用"基本工资"作为关键词去匹配薪酬项名称包含这个子串的记录,只把相关的薪酬项注入 Prompt。这种过滤策略控制了 Prompt 长度(全量注入可能产生数千 token 的字段列表,不仅增加成本还会稀释 AI 对规则核心逻辑的注意力),且子串匹配在实践中准确率足够——AI 结合规则原文语义能判断应使用哪个字段。字段字典注入 Prompt 的格式是结构化列表,每条包含 fieldName 和 displayName,AI 通过 displayName 理解含义、通过 fieldName 生成代码引用。
预编译和真正执行有什么差异?通过编译但运行时失败的场景有哪些?
A:预编译(tryPrecompile() 调用 GroovyUtils.precompile())和真正执行(PrRule.execute() 调用 GroovyUtils.executeScript())是两个完全不同的阶段,发生在不同时间点、验证不同层面的正确性。预编译发生在 AI 解析阶段(规则生成时,Self-Refine 循环的第五步),通过 GroovyClassLoader.parseClass(scriptSource) 将 Groovy 源码编译为 JVM 字节码,验证的是静态语法正确性——括号是否匹配、关键字是否拼写正确、变量声明是否合法、方法调用的签名是否存在等。预编译通过只能说明脚本在语法层面是合法的 Groovy 代码,不保证运行时行为正确。真正执行发生在质检任务运行阶段(异步执行引擎处理数据时),通过从 Caffeine 缓存获取的 ScriptingFunction.call(context) 调用已编译的脚本并传入实际的业务数据上下文(包含字段值和工具类),验证的是运行时行为正确性——字段取值是否为 null、类型转换是否成功、业务逻辑是否按预期工作。通过编译但运行时失败的场景主要有以下几类:第一类是空指针异常(NullPointerException,最常见约占运行时错误的 60%)——脚本中写了 context.get("salary_month").equals("202601"),但实际数据中该字段缺失或值为 null,null.equals() 抛出 NPE。预编译不检查运行时数据值,Map.get() 的返回类型是 Object(可以是 null),编译器无法发现这类问题。正确写法应该是 Objects.equals(context.get("salary_month"), "202601") 或先判空。第二类是类型转换异常(NumberFormatException / ClassCastException)——脚本中写了 new BigDecimal(context.get("amount").toString()),但实际数据中 amount 字段的值包含非数字字符(如 "N/A" 或 "10,000" 含逗号),BigDecimal 构造器抛出 NumberFormatException。预编译只检查构造器签名合法,不检查运行时参数值。应该使用 LineMapUtil.getBigDecimal() 安全方法替代直接构造。第三类是字段名不匹配——脚本引用了 context.get("basic_salary") 但实际数据中的字段名是 basicSalary(驼峰 vs 下划线命名不一致),get 返回 null 导致后续逻辑失败。这类问题的根源通常是字段字典中的 fieldName 与实际数据源中的 key 存在格式差异。第四类是业务逻辑错误——脚本编译和执行都不报错,但校验结果不符合预期(如比较方向写反了、阈值引用错了字段)。第五类是集合操作异常——脚本对列表做索引访问(如 list.get(0))但列表为空抛出 IndexOutOfBoundsException。为应对这些运行时风险,系统设计了多重兜底:规则引擎对每条规则的执行做 try-catch 包裹,运行时异常不中断整个质检任务,只将该规则对该条数据标记为"执行异常"并记录异常堆栈;Self-Refine 的 Prompt 中通过"常见错误避坑"示例引导 AI 生成更健壮的代码(如始终先判空、使用 LineMapUtil 安全方法);字段字典的注入从源头降低字段名不匹配的概率。
专业技能
- 熟练掌握 Java 基础与集合(HashMap / ConcurrentHashMap 等底层原理)、并发编程(线程池、JUC 锁、CAS 与 JMM 内存模型、ThreadLocal 上下文传递)、JVM 内存区域与 G1 / CMS GC 调优;熟悉 Spring(IOC、AOP、SpringBoot自动装配等)和 MyBatis(动态 SQL、拦截器机制等);
- 熟练使用 MySQL(索引、事务、存储引擎、行/间隙锁与MVCC)和 Redis(数据类型、线程模型、持久化与过期淘汰策略),熟悉缓存穿透 / 击穿 / 雪崩防护与分布式锁实现;熟练使用企业级分布式中间件生态(RPC 框架、分库分表与流式分页、分布式缓存、消息队列异步解耦、分布式定时调度与跨节点容灾、配置中心分钟级热更、全链路 Trace、限流降级、离线数仓),并基于 DDD 分层完成多仓协同研发;熟悉计算机网络与操作系统等基础知识;
- 适应全栈研发模式,覆盖「页面 Schema 拖拽编排 → 网关接口发布与权限点配置 → 后端 Facade / Controller 接口」端到端链路,能独立交付从页面到底层数据的完整需求;
- 熟练使用 Vibe Coding、SDD等 AI 编程范式,AI Coding 采纳率稳定 90%+,了解 Harness Engineering 工程化理念;掌握 Prompt Engineering、Function Calling / Tool Use、A2A / Skills 渐进式披露等 LLM 核心能力与 Agent 协议生态,熟练在日常开发与运维中使用MCP、Skills等相关能力;
- 熟悉 ReAct、Reflexion等Agent主流架构模式,了解LangChain框架、Agent调试技巧、Token 成本调优、RAG等相关内容。
Java基础
Java优势
- 跨平台性(Write Once, Run Anywhere, WORA):Java的最大优势之一是它的跨平台性。Java程序可以在任何安装了JVM的平台上运行,无需修改源代码。这种特性极大地简化了应用程序的部署和维护过程。
- 面向对象编程(OOP):Java是一种纯面向对象的语言,支持封装、继承和多态等面向对象的基本特性。这使得Java代码更加模块化、易于理解和维护。
- 丰富的库和框架:Java拥有庞大的生态系统,包括大量的库和框架,如Spring、Hibernate、Apache Commons等,这些库和框架极大地简化了开发过程,提高了开发效率。
- 强大的安全性:Java平台提供了多种安全机制,如自动垃圾回收、内存管理、访问控制等,这些机制有助于防止常见的编程错误和安全问题。此外,Java还提供了加密、解密、签名等安全功能。
- 高性能:尽管Java是一种解释型语言,但通过即时编译器(JIT)技术,Java程序可以获得接近甚至超过本地编译代码的性能。此外,JVM的不断优化也提高了Java程序的执行效率。
- 多线程支持:Java内置了对多线程的支持,使得开发并发程序变得简单。Java提供了丰富的线程同步机制,如synchronized关键字、锁等,以确保线程安全。
- 广泛的社区支持:Java拥有庞大的开发者社区,这意味着你可以轻松找到关于Java的教程、文档、问题和解决方案。此外,社区还不断推动Java的发展,使其保持与时俱进。
- 大型企业支持:许多大型企业都使用Java作为其主要的开发语言,如Google、Amazon、Oracle等。这些企业的支持使得Java在技术和市场上都得到了广泛的认可和应用。
- 良好的可扩展性和可维护性:由于Java的面向对象特性和丰富的库支持,Java程序通常具有良好的可扩展性和可维护性。这使得Java成为开发大型、复杂系统的理想选择。
- 广泛的应用领域:Java不仅限于Web开发,还广泛应用于企业级应用、移动应用开发、大数据处理(Hadoop)、云计算(如AWS、Azure等)等多个领域。这种广泛的应用领域使得Java成为了一种非常有用的编程语言。
Java和C++的区别
Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但还是有很多不同的地方:
- Java 不提供指针来直接访问内存,程序内存更加安全,C++ 支持指针;
- Java 的类是单继承的,C++ 支持多继承; Java 的接口可以多继承;
- Java 有自动内存管理垃圾回收机制(GC),而C++ 没有垃圾回收机制,程序员需要手动释放无用内存;
- C++ 支持方法重载和操作符重载,Java 只支持方法重载(操作符重载增加了复杂性,与Java最初的设计思想不符)。
C 是面向过程的语言,C++ 既支持面向对象也支持面向过程,算是半面向对象语言,Java 是面向对象的语言。
移位运算符
<<:左移运算符,向左移若干位,高位丢弃,低位补零。x << 1,相当于 x 乘以 2(不溢出的情况下)。
>>:带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x >> 1 相当于 x 除以 2。
>>>:无符号右移,忽略符号位,空位都以 0 补齐。
使用 <<、 >> 和>>>转换成的指令码运行起来会更高效些。由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。移位操作符实际上支持的类型只有 int 和 long,编译器在对 short、byte、char 类型进行移位前,都会将其转换为int类型再操作。
如果移位的位数超过数值所占有的位数会怎样?
当 int 类型左移/右移位数大于等于 32 位操作时,会先 求余(%) 后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。也就是说:x<<42等同于x<<10,x>>42等同于x>>10,x>>>42等同于x>>>10。
基本数据类型/包装类型 及区别
8种基本数据类型
- 6 种数字类型:
- 4 种整数型:
byte、short、int、long - 2 种浮点型:
float、double
- 4 种整数型:
- 1 种字符类型:
char - 1 种布尔型:
boolean
注意:Java 里使用
long类型的数据一定要在数值后面加上L,否则将作为整型解析。
char a = 'h',char: 单引号,String a = "hello",String: 双引号。
8种包装类型
Byte、Short、Integer、Long、Float、Double、Character、Boolean。
区别
- 用途:基本类型用来定义常量和局部变量,包装类型可用于泛型,而基本类型不可以。
- 存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被
static修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,几乎所有对象实例都存在于堆中。 - 占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
- 默认值:成员变量包装类型不赋值就是
null,而基本类型有默认值且不是null。 - 比较方式:对于基本数据类型来说,
==比较的是值。对于包装数据类型来说,==比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用equals()方法。
基本数据类型/引用类型区别
- 存储位置:
- 基本类型:直接在栈(stack)内存中分配空间,存储的是实际的值,因此访问速度较快。
- 引用类型:在栈中存储对象的引用(地址),而实际的数据存储在堆(heap)内存中。引用类型包括类、接口、数组等。
- 内存开销:
- 基本类型:占用固定的内存空间,例如
int为4字节,float为4字节。 - 引用类型:因为包含了对象的引用和对象本身,占用的内存相对更大,且每个对象的内存大小不固定。
- 基本类型:占用固定的内存空间,例如
- 默认值:
- 基本类型:各类型都有默认值,如
int的默认值是0,boolean的默认值是false。 - 引用类型:默认值为
null,表示没有指向任何对象。
- 基本类型:各类型都有默认值,如
- 数据操作:
- 基本类型:存储的是值本身,直接对值进行操作。
- 引用类型:引用类型存储的是对象的地址,通过该地址可以访问和操作对象的成员变量和方法。
- 传值方式:
- 基本类型:在方法调用中传递的是值的副本,不会影响原变量。
- 引用类型:传递的是对象的引用,方法中对对象的操作会影响原对象。
- 线程安全性:
- 基本类型:因为其值不可变且存储在栈中,一般是线程安全的。
- 引用类型:堆内存中的对象需要考虑线程安全问题,因为多个线程可能共享同一对象。
为什么char不能存储全部的中文
- 字符集范围限制:Java中的
char类型使用16位Unicode编码,最多只能表示65536个字符。虽然中文字符大部分可以通过基本的Unicode平面(即\u0000到\uFFFF)表示,但并不是所有的中文字符都在这个范围内。 - 汉字数量大:中文字符的数量远远超过65536个,尤其是一些冷僻字和扩展字符集(例如扩展A、B、C、D区的汉字),这些字符超出了
char类型所能表示的范围。 - UTF-16编码和代理对(Surrogate Pair):为了表示超出基本多语言平面(BMP)范围的字符,Java使用UTF-16编码中的“代理对”机制。通过代理对,可以用两个
char来表示一个字符。这种方法允许编码更多的字符,但每个代理对仍然需要两个char,这意味着不能直接用一个char存储所有中文字符。
char类型本身无法存储所有中文字符,因为它只能表示基本多语言平面(BMP)中的字符,而更多的中文字符(如扩展区的字符)需要用UTF-16的代理对(由两个 char 组成)表示。如果要处理所有中文字符,应该使用 String 类型,它能够存储一个或多个 char(包括代理对)并正确处理所有Unicode字符。
包装类的缓存机制
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。Byte、Short、Integer、Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。
什么是自动拆装箱
Integer i = 10; //装箱
int n = i; //拆箱
- 装箱:将基本类型用它们对应的引用类型包装起来;
- 拆箱:将包装类型转换为基本数据类型;
从字节码来看,装箱调用包装类的 valueOf() 方法,拆箱调用 xxxValue() 方法。
如何解决浮点数运算的精度丢失问题
无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
局部变量/成员变量/静态变量
- 语法形式:成员变量可以被
public、private、static等修饰符所修饰,而局部变量不能被访问控制修饰符及static所修饰;但是,成员变量和局部变量都能被final所修饰。 - 存储方式:如果成员变量是使用
static修饰的,那么这个成员变量是属于类的,如果没有使用static修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 - 生存时间:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
- 默认值:成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被
final修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
静态变量是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,即使创建多个对象,静态变量只会被分配一次内存,这样可以节省内存。
封装/继承/多态
在Java中,封装、继承和多态是面向对象编程的三个核心特性。它们帮助开发者更好地组织代码,提高代码的可重用性、可维护性和可扩展性。以下是这三个概念的简要介绍:
封装
封装是指将对象的状态(属性)和行为(方法)绑定在一起,并隐藏对象的内部实现细节,只暴露必要的接口(方法)。通过封装,程序员可以控制对对象数据的访问和修改,从而实现数据保护和安全性。
实现封装的关键点:
- 私有化成员变量:使用
private访问修饰符将类的成员变量隐藏。 - 提供公有的访问方法:使用
public访问修饰符提供公共的getter和setter方法来访问和修改私有变量。
public class Person {
// 私有成员变量
private String name;
private int age;
// 公有的getter方法
public String getName() {
return name;
}
// 公有的setter方法
public void setName(String name) {
this.name = name;
}
// 公有的getter方法
public int getAge() {
return age;
}
// 公有的setter方法
public void setAge(int age) {
this.age = age;
}
}
继承
继承是指一个类可以继承另一个类的属性和方法,从而实现代码的复用。Java通过extends关键字来实现继承。子类继承父类后,可以直接使用父类的方法和变量,当然也可以重写(Override)父类的方法,或者添加新的方法和属性。
继承的关键点:
- 子类继承父类,自动拥有父类的成员变量和方法(除了
private成员)。 - 子类可以重写父类的方法来实现自己的版本。
- Java支持单继承,一个类只能继承一个直接父类。
class Animal {
void speak() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Dog barks");
}
}
多态
多态是指一个对象可以表现为多种类型。具体来说,子类对象可以赋值给父类的引用变量,调用方法时会根据对象的实际类型来执行相应的方法。多态可以通过方法重写和方法重载来实现。
多态的关键点:
- 方法重写:子类重新实现父类的方法,父类引用指向子类对象时,调用的是子类的重写方法。
- 方法重载:在同一个类中,方法名相同,但参数不同(可以是参数类型、个数或顺序不同),通过参数来区分不同的方法版本。
class Animal {
void sound() {
System.out.println("Animal sound");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("Bark");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("Meow");
}
}
public class Test {
public static void main(String[] args) {
Animal myAnimal = new Animal(); // Animal对象
Animal myDog = new Dog(); // Dog对象,但引用类型是Animal
Animal myCat = new Cat(); // Cat对象,但引用类型是Animal
myAnimal.sound(); // 输出 "Animal sound"
myDog.sound(); // 输出 "Bark"
myCat.sound(); // 输出 "Meow"
}
}
总结:
- 封装通过隐藏对象的内部实现细节,提供安全和接口访问。
- 继承通过父类共享属性和方法来实现代码复用,并能在子类中进一步扩展或修改。
- 多态使得同一个方法调用可以有不同的表现形式(不同的子类实现)。
这些特性使得Java面向对象的设计更加灵活和强大。
重载和重写
- 重载就是同名的方法能够根据输入数据的不同,做出不同的处理。重载发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
- 重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。
- 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
- 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
- 构造方法无法被重写
Java多态实现方式
在Java中,多态的实现方式主要有以下几种:
-
方法重载(Overloading)
- 实现方式:在同一个类中,方法名相同但参数列表不同。
- 多态类型:编译时多态。
- 说明:编译器在编译时根据参数列表的不同决定调用哪个方法,因此属于编译时多态。
-
方法重写(Overriding)
- 实现方式:子类重写父类的某个方法,即在子类中定义了与父类中方法签名相同的方法。
- 多态类型:运行时多态。
- 说明:在程序运行过程中,通过父类引用调用子类的实现,具体调用哪个方法在运行时动态绑定。
-
接口实现
- 实现方式:类实现接口并定义接口中声明的方法。
- 多态类型:运行时多态。
- 说明:接口多态是通过子类实现接口方法的具体实现来完成的。调用时,通过接口引用调用实现类的具体方法,也属于运行时多态。
- 编译时多态:方法重载。
- 运行时多态:方法重写、接口实现。
运行时多态是Java中更常用的多态形式,通常用于父类或接口的引用指向子类实例,从而实现灵活的调用。
面向对象和面向过程区别
两者的主要区别在于解决问题的方式不同:
- 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。
另外,面向对象开发的程序一般更易维护、易复用、易扩展。
接口和抽象类区别
共同点: 都不能被实例化
https://learn.skyofit.com/archives/351
区别
- 子类使用
extends关键字来继承抽象类,只能继承 1 个抽象类;子类使用关键字implements来实现接口,可以实现多个接口。 - 抽象类可以有构造方法,接口不能有构造方法。
- 抽象类允许有普通方法,接口中的方法默认是
public abstract类型(JDK8后允许使用default、static定义非抽象方法)。 - 抽象类允许有成员变量,接口中只允许有常量(默认是
public static final类型)。 - 抽象类中的抽象方法可以有访问修饰符(可以是
public、protected、private),接口中的抽象方法默认是public类型。 - 抽象类可以有
main方法,接口中不能有main方法。
接口只能继承接口,接口不能继承抽象类和普通类;抽象类也可以继承接口;抽象类单继承,接口多继承。
堆和栈的区别
https://blog.csdn.net/qq_44944221/article/details/126692973
- 栈:是运行时单位,代表逻辑,内含基本数据类型和堆中的对象引用,所在区域连续,没有碎片;
- 堆:是存储单元,代表着数据,可以被多个栈共享,所在区域不连续,有碎片;
区别:
- 功能不同:栈内存用来存储局部变量和方法调用,而堆内存用存储Java中的对象;无论是成员变量、局部变量、还是类变量他们指向的对象都存储在堆内存中;
- 共享性不同:栈是线程私有,而堆是线程共享;
- 异常错误不同:当内存不足时;栈抛出的是
StackOverFlowError异常,而堆抛出的是OutOfMemoryError; - 空间大小不同:堆空间大小远远大于栈的内存空间。
深拷贝/浅拷贝/引用拷贝
- 深拷贝:完全复制整个对象,包括这个对象所包含的内部对象。
- 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
- 引用拷贝:引用拷贝是两个不同的引用指向同一个对象。这不就是拷贝了一份引用吗哈哈哈?
Object 类
Object 类是一个特殊的类,是所有类的父类。主要提供了以下 11 个方法:
//native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
public final native Class<?> getClass()
//native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
public native int hashCode()
//用于比较两个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
public boolean equals(Object obj)
//native 方法,用于创建并返回当前对象的一份拷贝,默认是浅拷贝,深拷贝需要自己实现。
protected native Object clone() throws CloneNotSupportedException
//返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
public String toString()
//native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
public final native void notify()
//native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
public final native void notifyAll()
//native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
public final native void wait(long timeout) throws InterruptedException
//多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
public final void wait(long timeout, int nanos) throws InterruptedException
//跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
public final void wait() throws InterruptedException
// 实例被垃圾回收器回收的时候触发的操作
protected void finalize() throws Throwable { }
形参&实参
- 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
- 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
值传递&引用传递
- 值传递:方法接收的是实参值的拷贝,会创建副本。
- 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
Java 中只有值传递,C++ 中有值传递和引用传递。
==和equals()区别
==可以用来比较基本数据类型和引用数据类型- 基本数据类型:比较的是值是否相等
- 引用数据类型:比较的是引用地址是否相等
- Java只有值传递,不管是基本数据类型还是引用数据类型,比较的都是值,只是引用类型变量存的值是对象的地址。
equals()不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。- 如果没有重写
equals()方法, 子类调用Object类中的equals()方法,等价于通过“==”比较这两个对象,即比较的是两个对象的引用地址。 - 一般重写
equals()方法来比较两个对象中的属性是否相等;若属性相等,则返回true(即认为这两个对象相等)。
- 如果没有重写
hashCode()和equal()
hashCode() 的作用是获取哈希码(int 整数),也称为散列码。哈希码的作用是确定该对象在哈希表中的索引位置。hashCode() 定义在 Object 类中,意味着 Java 中的任何类都有 hashCode()。注意:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。
hashCode() 和 equals()都是用于比较两个对象是否相等。JDK 同时提供这两个方法,hashCode()方法可以大大减少equals()方法的调用次数,从而提高程序的性能。
- 如果两个对象的
hashCode值相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode值相等并且equals()方法也返回true,认为这两个对象相等。 - 如果两个对象的
hashCode值不相等,可以直接认为这两个对象不相等。
为什么重写 equals() 时必须重写 hashCode() 方法?
因为两个相等对象的 hashCode 值必须相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。
String/StringBuffer/StringBuilder
-
String:字符串常量,不可变,线程安全,适用于少量的字符串操作的情况。 -
StringBuffer:字符串变量(线程安全),适用于多线程下大量字符串操作的情况。 -
StringBuilder:字符串变量(非线程安全),适用于单线程下大量字符串操作的情况。 -
每次对
String类型进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String对象。 -
StringBuffer改变时会对本身进行操作,而不是生成新的对象并改变对象引用。 -
StringBuilder仅比StringBuffer高 10%~15% 左右的性能,但却线程不安全。
String 为什么不可变?
String类内保存字符串的char数组被final修饰且为私有的,且String类没有提供修改这个字符串的方法。String类被final修饰导致其不能被继承,进而避免了子类破坏String不可变。
字符串拼接使用+还是StringBuilder?
Java中“+”和“+=” 实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象。在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。
String.equals()/Object.equals()
String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
异常
异常的共同祖先是 Throwable 类,其有两个重要的子类:
Error: 程序无法处理的错误,不建议通过 catch 捕获。一般由 JVM 抛出,线程终止执行。
Exception: 程序本身可以处理的异常,可以通过 catch 来进行捕获。
Checked/Unchecked异常
运行时异常/非运行时异常
- 受检查异常(
Checked Exception)/非运行时异常:在编译过程中,若受检查异常没有被catch或者throws关键字处理的话,就没法通过编译。- 除了
RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常。常见的受检查异常有:ClassNotFoundException、FileNotFoundException、SQLException等。
- 除了
- 不受检查异常(
Unchecked Exception)/运行时异常(RuntimeException):在编译过程中,即使不处理也可以正常通过编译。RuntimeException及其子类属于不受检查异常。常见的不受检查异常有:NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException等。
如何自定义异常
可以通过继承Exception或RuntimeException来创建自己的异常类。自定义异常可以帮助你在特定场景下捕获错误并提供更有意义的错误信息。
自定义非运行时异常
自定义非运行时异常(Checked Exception),需要继承Exception类:
public class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message); // 调用父类的构造函数
}
}
使用示例:
public class Test {
public static void main(String[] args) {
try {
throw new MyCheckedException("This is a custom checked exception.");
} catch (MyCheckedException e) {
System.out.println(e.getMessage());
}
}
}
自定义运行时异常
自定义运行时异常(Unchecked Exception),需要继承RuntimeException类:
public class MyRuntimeException extends RuntimeException {
public MyRuntimeException(String message) {
super(message);
}
}
使用示例:
public class Test {
public static void main(String[] args) {
throw new MyRuntimeException("This is a custom runtime exception.");
}
}
自定义异常的一些注意事项
- 构造函数:通常你会在自定义异常中定义一个带有错误消息的构造函数,这样在抛出异常时可以提供详细的错误信息。
- Serializable:如果自定义异常需要在分布式系统中传输(如通过网络或存储到文件中),考虑让异常类实现
Serializable接口。 - 语义化:设计自定义异常时,确保异常的名字和类型能够清楚地表达出错误的语义,使得代码更具可读性和可维护性。
try-catch-finally
try:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
catch:用于处理 try 捕获到的异常。
finally:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
注意:不要在
finally语句块中使用return! 当try语句和finally语句中都有return语句时,try语句块中的return语句会被忽略。
泛型
- 使用泛型参数,可以增强代码的可读性以及稳定性。
- 泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
反射
反射是框架的灵魂,主要是因为它赋予了在运行时分析类以及执行类中方法的能力。通过反射可以获取任意一个类的所有属性和方法,还可以调用这些方法和属性。
- 优点:可以让代码更加灵活、为各种框架提供开箱即用的功能提供了便利。
- 缺点:在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
有一个第三方jar包,包里面有个类,类里面有个私有方法,怎么调用这个方法?
在 Java 中,如果需要调用一个类中的私有方法,可以使用 Java 的反射机制。反射允许在运行时动态访问类的属性和方法,即便它们是私有的。下面是调用私有方法的步骤:
- 获取类的
Class对象。 - 获取私有方法的
Method对象。 - 将该方法设置为可访问。
- 调用该方法。
import java.lang.reflect.Method;
public class ReflectPrivateMethod {
public static void main(String[] args) {
try {
// 1. 获取目标类的Class对象
Class<?> clazz = Class.forName("com.example.ThirdPartyClass");
// 2. 创建该类的实例(假设有无参构造方法)
Object instance = clazz.getDeclaredConstructor().newInstance();
// 3. 获取私有方法,假设该私有方法叫做 "privateMethod",并且有一个String参数
Method privateMethod = clazz.getDeclaredMethod("privateMethod", String.class);
// 4. 设置私有方法为可访问
privateMethod.setAccessible(true);
// 5. 调用私有方法,传递一个参数,假设返回类型是String
String result = (String) privateMethod.invoke(instance, "Hello, Reflection!");
// 6. 输出结果
System.out.println("Result: " + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
反射虽然灵活,但通常会破坏封装性,应尽量避免在生产代码中频繁使用,除非在测试或调试中有特殊需求。
注解
注解使用了反射,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
注解只有被解析之后才会生效,常见的解析方法有两种:
- 编译期直接扫描:编译器在编译代码的时候扫描对应的注解并处理,如某个方法使用
@Override注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 - 运行期通过反射处理:像框架中自带的注解(比如
Spring框架的@Value、@Component)都是通过反射来进行处理的。
序列化/反序列化
如果需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 序列化:将数据结构或对象转换成二进制字节流的过程
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
语法糖
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
Java 中最常用的语法糖主要有switch语句、自动拆装箱、for-each循环、try-with-resources 语法、lambda 表达式等。
Java设计模式
Java 设计模式是一种在软件开发中常用的解决特定问题的通用方案。设计模式可以提高代码的可维护性、可重用性和可扩展性。根据目的和使用场景,设计模式通常分为三大类:
- 创建型模式
- 工厂模式:工厂模式定义了一个用于创建对象的接口,但由子类决定实例化哪个类。它使得类的实例化延迟到子类。
- 单例模式:单例模式确保一个类只有一个实例,并提供一个全局访问点。
- 结构型模式
- 适配器模式:适配器模式将一个类的接口转换成客户希望的另一个接口,使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
- 装饰器模式:装饰者模式允许向一个现有的对象添加新的功能,同时又不改变其结构。
- 行为型模式
- 策略模式:策略模式定义了一系列算法,并将每一个算法封装起来,使它们可以相互替换。策略模式使得算法可独立于使用它的客户而变化。
- 观察者模式:观察者模式定义对象间的一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新。
JDK中用到哪些设计模式
- 单例模式(Singleton Pattern)
java.lang.Runtime#getRuntime():Runtime类确保只有一个实例存在,并提供了一个全局访问点。java.awt.Desktop#getDesktop():Desktop类也是一个单例模式的实现。
- 工厂模式(Factory Pattern)
java.util.Calendar#getInstance():根据不同的参数,返回不同的Calendar子类实例。java.sql.DriverManager#getConnection():根据提供的URL、用户名和密码返回不同类型的数据库连接。
- 抽象工厂模式(Abstract Factory Pattern)
javax.xml.parsers.DocumentBuilderFactory:用于创建不同类型的XML解析器。javax.xml.transform.TransformerFactory:用于创建不同类型的XML转换器。
- 建造者模式(Builder Pattern)
java.lang.StringBuilder和java.lang.StringBuffer:用于构建字符串。java.nio.ByteBuffer:用于构建字节缓冲区。
- 原型模式(Prototype Pattern)
java.lang.Object#clone():所有实现了Cloneable接口的类都可以使用clone方法来复制对象。
- 适配器模式(Adapter Pattern)
java.util.Arrays#asList():将数组转换为List。java.io.InputStreamReader和java.io.OutputStreamWriter:将字节流转换为字符流。
- 装饰器模式(Decorator Pattern)
java.io.BufferedInputStream和java.io.BufferedOutputStream:为现有的输入/输出流添加额外的功能。java.util.Collections#synchronizedList()、#unmodifiableList():为现有的集合添加额外的行为。
- 观察者模式(Observer Pattern)
java.util.Observer和java.util.Observable:用于实现观察者模式。javax.swing.event.EventListenerList:用于管理事件监听器。
- 策略模式(Strategy Pattern)
java.util.Comparator:用于定义比较两个对象的策略。javax.swing.text.Document#insertString():不同的插入策略。
- 责任链模式(Chain of Responsibility Pattern)
java.util.logging.Logger#log():通过不同的Handler处理日志记录请求。
- 状态模式(State Pattern)
javax.swing.JComponent#paint(Graphics):通过状态模式来管理组件的绘制状态。
- 模板方法模式(Template Method Pattern)
java.util.AbstractList、java.util.AbstractSet:这些抽象类定义了集合的一些模板方法,具体的子类来实现具体的方法。
这些设计模式的使用,使得JDK在设计和实现上更加灵活、可扩展且易于维护。这些模式不仅仅是代码结构的规范,它们还体现了面向对象设计的基本原则,如单一职责原则、开闭原则和依赖倒置原则。
单例模式
- 饿汉式:饿汉式单例模式在类加载时就完成实例化,线程安全,简单但可能会造成资源浪费。
- 懒汉式:懒汉式单例模式在第一次调用
getInstance方法时创建实例,线程不安全,需要额外处理同步。 - 线程安全的懒汉式
- 同步方法:在
getInstance方法上加synchronized关键字,保证线程安全,但是效率低。 - 双重检查锁定:在
getInstance方法内部进行双重检查,保证只有第一次调用时才会加锁,提高效率。
- 同步方法:在
- 静态内部类:利用静态内部类来实现懒加载和线程安全。
- 枚举:枚举实现单例模式是最简洁、安全的实现方式,可以防止反射和序列化攻击。
// 饿汉式
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
// 懒汉式
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
// 线程安全的懒汉式-同步方法
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
// 线程安全的懒汉式-双重检查锁定
public class Singleton {
// 单例模式中用于保存实例的字段,被声明为volatile,确保对该变量的写入操作会立即反映到所有线程中,这样可以防止可能发生的指令重排序问题。
private volatile static Singleton uniqueInstance;
// 私有的构造方法确保该类不能在外部被初始化,只能通过getUniqueInstance()方法获取实例
private Singleton() {
}
// 双重检查锁定的机制,实现对外提供的获取单例实例的方法。
public static Singleton getInstance() {
// 第一层检查:首先检查 uniqueInstance 是否为 null。如果不是 null,意味着实例已经被创建,则直接返回这个实例。
if (uniqueInstance == null) {
// 类对象加锁,表示进入同步代码前要获得 Singleton类 的锁
synchronized (Singleton.class) {
// 第二层检查:在同步代码块内再次检查 uniqueInstance 是否为 null。
// 这种双重检查是为了在等待锁的线程获取到锁后再次确认实例是否已经被创建,因为在等待锁的过程中可能有其他线程已经创建了实例。
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
public static void main(String[] args) {
System.out.println(getInstance());
}
}
// 静态内部类
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
// 枚举
public enum Singleton {
// 注意 上面不是 class 是 enum
INSTANCE;
public void someMethod() {
// do something
}
public static void main(String[] args) {
Singelton singleton = Singleton.INSTANCE;
singleton.someMethod();
}
}
内存溢出/内存泄露区别
- 内存溢出(Memory Overflow):内存溢出是指程序尝试使用超过其可用内存量的情况。这通常会导致程序崩溃或产生意外行为。内存溢出可以发生在堆栈或堆内存中。
- 常见原因:
- 递归太深:没有终止条件的递归函数调用会导致堆栈溢出。
- 内存分配过多:程序请求的内存超过了系统可用的物理内存或虚拟内存。
- 常见原因:
- 内存泄露(Memory Leak):内存泄漏是指程序在运行过程中,动态分配了内存但未能正确释放,导致这些内存不可达并且不能再被使用。内存泄漏会导致程序占用的内存越来越多,最终可能导致系统内存耗尽。
- 常见原因:
- 未释放内存:程序分配了内存但没有调用适当的释放函数(如
free)。 - 指针丢失:丢失对已分配内存的引用,使得内存无法被释放。
- 未释放内存:程序分配了内存但没有调用适当的释放函数(如
- 常见原因:
区别
- 内存溢出是指程序尝试使用超过其可用内存量的情况,而内存泄漏是指程序动态分配的内存未被正确释放,导致内存逐渐耗尽。
- 二者成因不同。内存溢出:递归太深、内存分配过多等。内存泄漏:未释放内存、指针丢失等。
- 二者影响不同。内存溢出:立即导致程序崩溃或异常。内存泄漏:程序占用的内存越来越多,最终可能导致系统性能下降或内存耗尽而崩溃。
Java如何排查内存泄露
在 Java 中,内存泄漏通常是指无用的对象仍然被引用,导致垃圾回收器无法回收这些对象,从而导致内存不断增长,最终可能导致内存耗尽。排查内存泄漏通常涉及以下步骤:
- 使用内存分析工具(如 JVM 工具)
- VisualVM:Java 自带的工具,可以查看应用的堆内存使用情况、线程活动和垃圾回收情况。
- Eclipse Memory Analyzer (MAT):强大的内存分析工具,用于分析 Java 堆转储,检测内存泄漏。
- JConsole:监控 JVM 的堆内存使用情况,能够实时观察应用的内存变化。
- YourKit:商业内存分析工具,功能强大,用户友好。
- 生成和分析堆转储:堆转储文件包含应用运行时所有对象的状态信息,是内存泄漏排查的重要依据。生成堆转储的方式有:
- 在 OOM(OutOfMemoryError)时自动生成转储文件。通过 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError实现。 - 手动生成堆转储:可以使用
jmap命令,如jmap -dump:live,format=b,file=heap_dump.hprof <pid>。 - 通过内存分析工具(如 VisualVM)生成堆转储。生成堆转储后,使用工具如 MAT 或 VisualVM 进行分析,查找哪些对象占用了大量内存,检查是否存在对象的生命周期比预期的长。
- 在 OOM(OutOfMemoryError)时自动生成转储文件。通过 JVM 参数
- 分析内存使用模式
- 内存使用率监控:通过 JConsole 或 VisualVM 监控堆内存使用情况,特别关注内存是否在 GC 后没有显著下降。
- GC 日志分析:开启 GC 日志(通过 JVM 参数
-Xlog:gc*),分析内存回收情况,查看是否有过多的 Full GC 发生,或内存回收效果不佳。 - 内存占用趋势分析:在应用运行一段时间后,查看内存占用是否在稳定增长。如果应用的内存使用在没有相应负载增长的情况下持续增加,可能是内存泄漏的迹象。
- 排查常见的内存泄漏模式:以下是一些常见的内存泄漏场景,排查时可以重点关注:
- 静态集合类:如
HashMap、List等存储了不再使用的对象,但未能及时清理。 - 未关闭的资源:未关闭的
InputStream、OutputStream、数据库连接、文件句柄等资源可能导致内存泄漏。 - 监听器和回调:未正确移除的事件监听器或回调函数可能导致对象无法被回收。
- ThreadLocal:使用不当的
ThreadLocal可能导致线程池中的线程持有大量对象,无法回收。
- 静态集合类:如
- 代码分析:如果通过工具找到了特定的类或对象占用了过多内存,回头查看代码,检查是否有如下问题:
- 对象的生命周期管理不当,导致被长期持有引用。
- 缓存(如 LRU Cache)没有及时清理。
- 使用
WeakReference、SoftReference或PhantomReference来管理不确定生命周期的对象。
- 基于工具的内存泄漏检测:有些工具可以直接帮助你分析和检测内存泄漏模式,例如:
- Eclipse MAT:可以帮助你生成泄漏报告,显示哪些对象可能导致了泄漏,哪些对象的引用链最深。
- YourKit:具有内存泄漏检测功能,能够自动检测潜在的泄漏对象。
- 模拟压力测试:在实际生产环境中,内存泄漏通常不会很快暴露。因此,可以通过模拟压力测试、长时间运行测试来发现内存泄漏。使用工具如
Apache JMeter对应用施加压力,观察内存占用随时间的变化。
final关键字
在Java中,final关键字用于定义常量或限制类、方法和变量的修改。
- 常量:
final可以用于定义常量。一旦为变量赋值后,final修饰的变量值不能再改变。例如: - 类:如果一个类被声明为
final,则它不能被继承。这意味着不能创建该类的子类。例如: - 方法:如果一个方法被声明为
final,则该方法不能在子类中被重写。这对于防止子类修改父类的行为非常有用。例如: - 局部变量:如果局部变量(在方法内声明的变量)被声明为
final,则在初始化后,它的值不能再被修改。例如:
this关键字
this 关键字用于指代当前对象的实例,主要用在以下几种情况:
-
引用当前对象的属性:当方法参数的名称与实例变量的名称相同时,可以使用
this来区分它们。class Person { private String name; public Person(String name) { this.name = name; // this.name 指的是类的属性,name 是构造器的参数 } } -
调用当前类的构造方法:
this()可以用于调用当前类中的另一个构造方法。class Person { private String name; private int age; public Person(String name) { this(name, 0); // 调用带有两个参数的构造方法 } public Person(String name, int age) { this.name = name; this.age = age; } } -
传递当前对象作为参数:
this可以作为参数传递给其他方法。class Person { public void introduce(Person person) { System.out.println("Hi, I am " + person); } public void introduceYourself() { introduce(this); // 将当前对象传递给 introduce 方法 } }
super关键字
super 关键字用于引用父类的成员,通常用于类的继承场景。常见用法有:
-
调用父类的构造方法:
super()用于在子类中调用父类的构造方法。必须是构造方法中的第一行。class Animal { public Animal(String name) { System.out.println("Animal: " + name); } } class Dog extends Animal { public Dog(String name) { super(name); // 调用父类的构造方法 System.out.println("Dog: " + name); } } -
访问父类的属性和方法:当子类与父类有同名的属性或方法时,可以使用
super来显式调用父类的成员。class Animal { public void makeSound() { System.out.println("Animal sound"); } } class Dog extends Animal { public void makeSound() { super.makeSound(); // 调用父类的方法 System.out.println("Dog barking"); } } -
调用父类的重载方法:在子类中可以使用
super调用父类的被重载方法。class Animal { public void eat() { System.out.println("Animal eats"); } } class Dog extends Animal { @Override public void eat() { super.eat(); // 调用父类的 eat 方法 System.out.println("Dog eats"); } }
Java集合
List/Map/Set/Queue区别
- List: 存储的元素是有序的、可重复的。
- Set: 存储的元素不可重复的。
- Queue: 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
- Map: 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
如何选用集合?
主要根据集合的特点来选择合适的集合。如:
- 需要根据键值获取到元素值时就选用
Map接口下的集合,需要排序时选择TreeMap,不需要排序时就选择HashMap,需要保证线程安全就选用ConcurrentHashMap。 - 只需要存放元素值时,就选择实现
Collection接口的集合,需要保证元素唯一时选择实现Set接口的集合比如TreeSet或HashSet,不需要就选择实现List接口的比如ArrayList或LinkedList,然后再根据实现这些接口的集合的特点来选用。
为什么要使用集合?
在实际开发中,存储的数据类型多种多样且数量不确定。相较于数组,Java 集合提供了更灵活、更有效的方法来存储多个数据对象,其优势在于集合的大小可变、支持泛型、具有内建算法等。总的来说,Java 集合提高了数据的存储和处理灵活性,可以更好地适应现代软件开发中多样化的数据需求,并支持高质量的代码编写。
List✅
ArrayList和Array区别
ArrayList 内部基于动态数组实现,比 Array(静态数组) 更加灵活:
ArrayList可动态扩容/缩容,Array创建后不能改变长度。ArrayList可使用泛型确保类型安全,Array则不可以。ArrayList中只能存对象,存基本类型数据时要用对应包装类(如Integer)。Array可直接存储基本类型数据,也可存储对象。ArrayList提供增删改查等 API 操作方法,如add()、remove()等。Array只是一个固定长度数组,只能按照下标访问元素,无动态添加、删除元素能力。ArrayList创建时不需指定大小,而Array创建时必须指定大小。
以无参数构造方法创建
ArrayList时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。之后每次扩容容量变为原来的 1.5 倍。int[] numbers = new int[5]; // Array ArrayList<Integer> numbers = new ArrayList<>(); // ArrayList
ArrayList可以一直扩容吗?
会,其会一直扩容,直至达到JVM内存限制。
ArrayList基于数组实现的,数组在内存中需要连续空间,即便有大量内存,也可能无法创建一个非常大的数组。
for循环里如何对ArrayList又删除又添加
当在遍历 ArrayList 的同时进行添加或删除操作时,ArrayList 的大小会动态变化,导致迭代过程中出现意外的行为。如果直接使用 for 循环(基于索引的遍历),一旦删除元素,ArrayList 的大小变化,迭代器或索引会失效。
解决:
- 对于删除,可以使用迭代器 Iterator 的
remove()方法在遍历过程中安全地删除元素。但 Iterator 只提供删除操作,不能直接添加元素。 - 对于添加,可以将待添加的元素存入临时列表,在循环结束后一次性添加到原列表中。
- 可以使用
CopyOnWriteArrayList。
ArrayList和LinkedList区别
ArrayList底层基于Object数组实现的,LinkedList是基于双向链表实现的(JDK1.6之前是循环链表,1.7改为双向链表)。ArrayList插入和删除元素的时间复杂度受元素位置的影响。add()方法默认在尾部添加元素为O(1),在指定位置插入元素或者删除元素时间复杂度为O(n),因为需要移动元素。LinkedList插入和删除元素的时间复杂度为O(1),因为只需要改变指针指向。如果要在指定位置插入或者删除元素,时间复杂度为O(n),需要先移动到指定位置再插入和删除。
- 二者都是线程不安全的。
ArrayList随机访问效率高(实现了RandomAccess()接口),LinkedList插入删除效率高。- 占用空间:
ArrayList主要体现在在列表的结尾会留一定的容量空间,LinkedList每个元素占用空间比前者大(要存放直接后继和直接前驱以及数据)。
项目中一般不会使用
LinkedList。
ArrayList和LinkedList插入/删除效率
ArrayList
- 头部插入/删除:O(n),需要移动元素。
- 指定位置插入/删除:O(n),需要移动元素。
- 尾部插入/删除:O(1),直接在尾部添加或删除元素。尾部插入时,当容量已到极限并需扩容时,需执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。
LinkedList
- 头部插入/删除:O(1),只需改变指针指向。
- 尾部插入/删除:O(1),只需改变指针指向。
- 指定位置插入/删除:O(n),需要先移动到指定位置再插入和删除。
CopyOnWriteArrayList
CopyOnWriteArrayList 是一种线程安全的集合类,适用于读操作远多于写操作的场景。它使用了一种叫做 Copy-On-Write(COW)的策略,即每次写操作(如添加、删除、更新元素)都会创建一个新的数组副本。这种策略在以下场景中非常有用:
- 读多写少:大部分操作都是读取数据,写操作相对较少。
- 不要求实时性:因为写操作需要创建数组副本,可能会有短暂的延迟。
其与ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的,写入操作也不会阻塞读取操作,只有写写才会互斥。
优点
- 线程安全:所有读操作不需要加锁,因为读操作只是读取一个不可变的数组副本。
- 简化并发控制:不需要显式的同步块或锁定机制。
- 迭代器安全:迭代器不会抛出
ConcurrentModificationException,因为迭代时是基于快照的。
缺点
- 内存开销大:每次写操作都会创建数组副本,可能会占用较多的内存。
- 写操作开销大:由于需要复制整个数组,写操作的时间复杂度为 O(n)。
- 读取旧数据:在高并发场景下,可能会读取到旧的数据。
适用场景
- 配置类数据:如系统配置参数、常量集合等。
- 事件监听器列表:如监听器的添加和删除较少,事件触发较频繁。
不适用场景
- 高并发写操作:如果有大量的写操作,那么
CopyOnWriteArrayList的性能会变差。 - 实时性要求高的场景:在需要读取最新数据的情况下,可能不适合使用。
结论
CopyOnWriteArrayList 适合用于读操作远多于写操作且不要求实时性数据更新的场景。对于高并发写操作或需要读取最新数据的场景,建议使用其他更合适的数据结构或并发容器,如 ConcurrentHashMap 或 ConcurrentLinkedQueue。
跳表SkipList
https://mp.weixin.qq.com/s?__biz=MzU0ODMyNDk0Mw==&mid=2247495510&idx=1&sn=7a9f174b2a5facd92ee0efccf712eecc&chksm=fb427c76cc35f560d0ce02d6b7ff2f3e28c0349434734a428b20dfa2c3366d6266b15eacb588&scene=27
https://javaguide.cn/database/redis/redis-skiplist.html#元素查询
跳表是一种可以进行二分查找的有序链表,其在有序链表的的基础上添加了多级索引,将增删改查的时间复杂度变为O(logn)。跳表的节点跟一般的链表不同,有val、next、down三个属性,多级索引其实就是多级链表,最下层的链表包含所有元素,上一层元素是下一层的一半,第k层的元素是2^k个。假设跳表元素最大为65536个,则其最大层数为16。
特性:
- 一个跳表有若干层链表组成;
- 每一层链表都是有序的;
- 跳表最下面一层的链表包含所有数据;
- 如果一个元素出现在某一次层,那么该层下面的所有层都必须包含该元素;
- 上一层的元素指向下层的元素必须是相同的;
- 头指针 head 指向最上面一层的第一个元素;
CRUD:
- 插入:每次插入新元素或者增添新索引的时候,进行一次随机运算,随机数值范围为 0-1 之间,如果随机值 > 0.5,则为当前元素添加更高一级的索引,这样可以保证生成一级索引的概率为50%,二级索引的概率为25%...
- 删除:删除时必须定位到当前跳表各层元素小于 要删除元素 的最大值,然后右移(next),看元素是否为要删除的元素,如果是的话删除,然后下沉(down),执行同样操作。
- 查询:查询从最顶层索引开始,每层定位到当前跳表各层元素小于 要查询的元素的最大值,然后下沉,右移,继续比较。
Map(重要)✅
HashMap/HashTable区别
HashMap是非线程安全的,HashTable是线程安全的(内部方法经过synchronized修饰)。- JDK1.8以后
HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间。Hashtable没有这样的机制。 HashTable中有锁,所以效率比HashMap低。HashMap可以有一个nullkey和多个nullvalue,而HashTable不允许有nullkey和nullvalue,否则会报错NullPointerException。- 初始容量大小和每次扩充容量大小的不同:
Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。- 创建时如果给定了容量初始值,
Hashtable会直接使用给定的大小,而HashMap总是使用 2 的初始容量的幂作为哈希表的大小。
HashTable已经被淘汰,不建议使用。
HashMaploadFactor负载因子默认为 0.75,即当 HashMap 中的元素个数超过容量的 75% 时,就会进行扩容操作。threshold = capacity * loadFactor
HashMap底层✅
JDK1.8 之前
底层是数组和链表。哈希冲突使用“拉链法”解决。
HashMap 通过 key 的 hashcode 经过扰动函数hash()处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过“拉链法”解决冲突。
- 扰动函数
hash()可以减少碰撞。 - “拉链法”:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就放一个链表的头部。若遇到哈希冲突,则将冲突的值加到链表中。
- “拉链法”中,数组的 index 就是key 的
hashcode通过扰动函数hash()计算出来的hash值,数组中存放的是链表的头结点。而链表中的每个节点中存放的是键值对。
JDK1.8 之后
底层还是数组和链表,当链表长度大于阈值(默认为8)时,调用 treeifyBin()方法,判断是否决定要将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会执行 resize() 方法数组扩容,而不是转换为红黑树),以减少搜索时间。
- 数组扩容后,原本在一个链表上的元素可能会分散到多个链表上,这样就会降低链表的长度,提高查询效率。
- 扩容步骤:创建一个长度是原来两倍的新数组,由于扩容前后key经过
hash()计算出来的hash值不变,但数组长度 n 变化,将原数组中的元素通过(n - 1) & hash重新计算 index 放入新数组中。
HashMap的put流程
- 计算
key的hash值。通过key的hashCode()方法返回的hashcode经过扰动函数hash()处理后得到hash值。 - 确定桶的位置。
HashMap内部使用一个桶数组存储键值对。通过hash值与数组的长度n进行取模操作((n - 1) & hash)找到相应的数组索引。 - 检查该位置是否已有元素。如果当前位置为空(
table[(n - 1) & hash] == null),则直接将键值对放入该位置。如果当前位置已经有元素,则需要处理哈希冲突。 - 处理哈希冲突。哈希冲突指的是不同的键经过哈希计算后,映射到了同一个数组位置。
- 拉链法:当多个键的哈希值映射到同一位置时,这些键值对以链表的形式存储在同一个桶中。
put()方法会遍历该链表,检查是否已经存在相同的键(通过equals()方法比较key的相等性)。如果找到相同的键,则更新其对应的值。如果未找到,则会在链表的末尾添加一个新节点。 - 红黑树:当冲突过多,链表长度超过阈值(默认为 8)时,
HashMap会将链表转换为红黑树,以提高查找和插入的效率。红黑树可以在O(log n)时间内进行查找和插入。如果桶中的结构是红黑树,put()方法会按照红黑树的插入规则将新的键值对插入。
- 拉链法:当多个键的哈希值映射到同一位置时,这些键值对以链表的形式存储在同一个桶中。
- 扩容检查:每次插入新的键值对时,都会检查负载因子(当前元素数与数组大小的比值)是否超过设定的阈值(默认0.75),如果超过则会进行扩容操作,将数组的大小扩展为原来的两倍,并重新分配已有的元素到新的桶中。
- 若有扩容,则扩容后重新分配元素。此时,再走一下2 3 4流程。
- 返回旧值。如果键已存在且值被替换,
put()方法会返回旧值。如果是插入新的键值对,则返回null。
HashMap为什么不支持高并发
HashMap为什么线程不安全
- JDK1.7及之前版本,存在多线程扩容死循环问题,如下:
- JDK1.7 和 JDK 1.8 都存在数据丢失问题。如下:
HashMap多线程数据丢失问题
在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。
例子:
线程 1,2 同时进行put操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
随后,线程 1 获得时间片,由于之前已经进行过hash碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
HashMap多线程扩容死循环问题
JDK1.7及之前版本的HashMap在多线程环境下扩容操作可能存在死循环问题。扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。
注意:不建议在多线程下使用
HashMap,会存在数据覆盖的问题,并发环境下,推荐使用ConcurrentHashMap。
HashMap扩容相关
HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。 创建时即便给定了容量初始值,HashMap 也总是使用 2 的初始容量的幂作为哈希表的大小。
HashTable已经被淘汰,不建议使用。
HashMaploadFactor负载因子默认为 0.75,即当 HashMap 中的元素个数超过容量的 75% 时,就会进行扩容操作。threshold = capacity * loadFactor
HashMap长度为啥是2的幂次方
key经过扰动函数hash()后得到 hash 值(取值范围是 [0, 2^32-1]),太大,用之前还要先做对数组的长度取余(%)运算,得到的余数对应的数组下标才是存放元素的位置。
“取余(%)操作中如果除数是 2 的幂次 则等价于 与其除数减一的与(&)操作(也就是说 hash % n == hash & (n - 1) 的前提是 n 是 2 的幂次)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
HashMap查询复杂度
HashMap的查询复杂度一般情况是 O(1),在最坏情况下是 O(logn)或 O(n)。
- 数组实现:
HashMap由数组+链表或红黑树实现,数组查询复杂度O(1)。 - 处理哈希冲突:在理想情况下,不同的键会被映射到不同的索引位置上。若发生哈希冲突,HashMap 采用拉链法或者红黑树,所有映射到同一位置的键值对会被存储在一个链表或红黑树中。
- 如果使用链表,查找复杂度最坏是O(n),但链表较短,查询时间仍然是常数时间。
- 当链表长度超过阈值(默认8),链表转为红黑树O(logn)。
影响HashMap的因素:
- 哈希函数的质量:好的哈希函数会尽量将不同的键均匀地分布到哈希表的各个位置,从而减少冲突。但是坏的就会导致大量哈希冲突,使得链表数量加长,查询效率降低。
- 负载因子:
HashMap通过负载因子来控制哈希表的扩容。负载因子越高,哈希表中的元素越多,冲突的概率就越大,从而可能降低查询效率。
红黑树简介
https://blog.csdn.net/cy973071263/article/details/122543826
红黑树是一种自平衡的二叉查找树,是一种高效的查找树。它可在 O(logN) 时间内完成查找、增加、删除等操作。红黑树的出现是为了应对二叉搜索树的极端情况(插入数据有序,则会退化成链表)。
特性:
- 节点是红色或黑色
- 根是黑色
- 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),
null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点 - 红色节点的子节点都是黑色
- 红色节点的父节点都是黑色
- 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点
- 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
ConcurrentHashMap/Hashtable区别
- 底层:JDK1.7的
ConcurrentHashMap采用分段的数组+链表 实现,JDK1.8跟HashMap1.8结构一样,数组+链表/红黑二叉树。Hashtable是数组+链表 实现。 - 实现线程安全的方式:
- JDK1.7
ConcurrentHashMap对整个桶数组进行分割分段(Segment,分段锁),Segment数组中每个元素都有一个锁,且每个元素包含一个HashEntry数组,其中每个HashEntry元素是一个链表,即一个Segment中守护一个HashEntry,多个Segment组成一个ConcurrentHashMap。当一个线程占用Segment锁访问其中的元素时,其他线程可以访问其他Segment。 - JDK1.8
ConcurrentHashMap摒弃Segment的概念,而是直接用Node数组+链表+红黑树 实现,并发控制使用synchronized和 CAS 来操作。(JDK1.6 以后synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap,虽然在 JDK1.8 中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable(同一把锁) :使用synchronized保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,会进入阻塞或轮询状态,如使用put添加元素,另一个线程不能使用put添加元素,也不能使用get,竞争会越来越激烈效率越低。
- JDK1.7
ConcurrentHashMap底层✅
JDK1.7ConcurrentHashMap 数据结构为Segment数组+HashEntry数组+链表。ConcurrentHashMap对整个桶数组进行分割分段(Segment,分段锁),Segment数组每个元素存放一个HashEntry数组,其中每个HashEntry元素是一个链表,即一个Segment 守护一个 HashEntry 数组里的元素(HashEntry结构类似于HashMap),当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。
Segment 继承了 ReentrantLock ,是可重入锁,Segment 的个数一旦初始化就不能改变,默认是 16,默认同时支持 16 个线程并发写。
JDK1.8ConcurrentHashMap取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,Node数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
JDK 1.7和1.8的ConcurrentHashMap实现有什么不同?
- 线程安全实现方式:JDK 1.7采用
Segment分段锁来保证安全,Segment继承自ReentrantLock。JDK1.8 放弃了Segment分段锁的设计,采用Node + CAS + synchronized保证线程安全,锁粒度更细,synchronized只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7采用拉链法,JDK1.8采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度:JDK 1.7最大并发度是
Segment的个数,默认是 16。JDK 1.8 最大并发度是Node数组的大小,并发度更大。
ConcurrentHashMap能保证复合操作的原子性吗?
ConcurrentHashMap是线程安全的,可以保证多个线程同时对它进行读写操作,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的HashMap多线程操作导致死循环问题。ConcurrentHashMap提供了一些原子性的复合操作,如putIfAbsent、compute、computeIfAbsent、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的key和value来计算一个新的value,并且将其更新到map中。
什么是LinkedHashMap
继承了 HashMap 的所有属性和方法,在 HashMap 基础上在各个节点之间维护一条双向链表,使得原本散列在不同 bucket 上的节点、链表、红黑树有序关联起来。具备如下特性:
- 支持遍历时会按照插入顺序有序进行迭代
- 支持按照元素访问顺序排序,适用于封装 LRU 缓存工具。
- 因为内部使用双向链表维护各个节点,所以遍历时的效率和元素个数成正比,相较于和容量成正比的
HashMap来说,迭代效率会高很多。
LinkedHashMap在HashMap的基础重写了afterNodeRemoval、afterNodeInsertion、afterNodeAccess方法。使之拥有顺序插入和访问有序的特性。
LinkedHashMap和HashMap的区别
- 最大区别在于迭代元素的顺序。
HashMap迭代元素的顺序是不确定的,而LinkedHashMap提供了按照插入顺序或访问顺序迭代元素的功能。 LinkedHashMap内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而HashMap则没有这个链表。因此,LinkedHashMap插入性能比HashMap略低,但提供了更多功能且迭代起来比HashMap更高效。
LinkedHashMap如何按照访问顺序迭代元素?
LinkedHashMap通过构造函数中的 accessOrder 参数指定按照访问顺序迭代元素。当 accessOrder 为 true 时,每访问一个元素,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。
如何实现LRU缓存
accessOrder=true- 继承
LinkedHashMap - 重写
removeEldestEntry方法。当链表大小超过容量时返回true,使得每次访问一个元素时,该元素会被移动到链表的末尾。一旦插入操作让removeEldestEntry返回true时,视为缓存已满,LinkedHashMap就会将链表首元素移除,由此实现 LRU 缓存。
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 1f, true);
this.capacity = capacity;
}
// 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素)
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
HashMap/TreeMap区别
HashMap是基于哈希表实现的,TreeMap是基于红黑树实现的。HashMap是无序的,TreeMap是有序的。HashMap的键值对允许有一个nullkey 和多个nullvalue,TreeMap不允许有nullkey,但允许有nullvalue。HashMap的查询、插入、删除操作的时间复杂度是 O(1),而TreeMap的时间复杂度是 O(logn)。
相比于HashMap,TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
Set✅
HashSet/LinkedHashSet/TreeSet
- 都是
Set接口的实现类,都保证元素唯一,且都不是线程安全的。 - 主要区别在于底层数据结构不同。
HashSet底层是哈希表(HashMap)。LinkedHashSet底层是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- 底层数据结构不同导致三者应用场景不同。
HashSet用于不需要保证元素插入和取出顺序的场景,LinkedHashSet用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet用于支持对元素自定义排序规则的场景。
HashSet如何检查重复
把对象加入HashSet时,先计算对象的hashcode值来判断对象加入的位置,同时会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,认为对象没有重复出现。但如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
在 JDK1.8 中,实际上无论HashSet中是否已经存在某元素,都会直接插入,只是会在add()方法的返回值处返回插入前是否存在相同元素。
Queue✅
Queue/Deque区别
Queue是单端队列,只能从一端插入元素,另一端删除元素,实现上遵循 先进先出(FIFO)规则。Deque是双端队列,在队列的两端均可以插入或删除元素,其还提供有push()等方法,可用于模拟栈。
ArrayDeque/LinkedList区别
ArrayDeque基于可变长数组 和双指针 来实现,而LinkedList通过链表来实现。ArrayDeque不能存储NULL数据,LinkedList能。ArrayDeque插入时可能存在扩容过程,不过均摊后的插入操作依然为 O(1)。虽然LinkedList不需要扩容,但每次插入数据时都要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。
PriorityQueue
PriorityQueue 中元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
- 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
- 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
- 是非线程安全的,且不支持存储
NULL和non-comparable的对象。 - 默认是小顶堆,但可以接收一个
Comparator作为构造参数,从而来自定义元素优先级的先后。
PriorityQueue在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。
BlockingQueue及其实现类
BlockingQueue(阻塞队列)是一个接口,其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。常用于生产者-消费者模型 中,生产者线程向队列中添加数据,消费者线程从队列中取出数据进行处理。
实现类
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口且不能插入null元素。SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。通常用于线程之间的直接传递数据。DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
ArrayBlockingQueue
put()、take():插入和删除元素时,如果队列已满或者为空,会阻塞等待。offer()、poll():插入和删除元素时,如果队列已满或者为空,不会阻塞,而是直接返回false或者null。- 并发控制采用可重入锁
ReentrantLock保证线程安全,插入/读取操作都需获取到锁才能进行,且支持公平/非公平两种方式的锁访问机制,默认是非公平锁。
ArrayBlockingQueue实现原理
ArrayBlockingQueue内部维护一个定长的数组用于存储元素。- 通过使用
ReentrantLock锁对象对读写操作进行同步,即通过锁机制来实现线程安全。 - 通过
Condition接口实现线程间的等待和唤醒操作。
线程间的等待和唤醒具体的实现:
- 当队列已满时,生产者线程会调用
notFull.await()方法让生产者进行等待,等待队列非满时插入(非满条件)。 - 当队列为空时,消费者线程会调用
notEmpty.await()方法让消费者进行等待,等待队列非空时消费(非空条件)。 - 当有新的元素被添加时,生产者线程会调用
notEmpty.signal()方法唤醒正在等待消费的消费者线程。 - 当队列中有元素被取出时,消费者线程会调用
notFull.signal()方法唤醒正在等待插入元素的生产者线程。
生产者消费者模型
阻塞队列就是典型的生产者-消费者模型:
- 当阻塞队列数据为空时,所有的消费者线程都会被阻塞,等待队列非空。
- 当生产者往队列里填充数据后,队列就会通知消费者队列非空,消费者此时就可以进来消费。
- 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。
- 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。
ArrayBlockingQueue/LinkedBlockingQueue区别
二者是Java并发中常用的两种阻塞队列实现,都是线程安全的。区别:
- 底层实现:
ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现。 - 是否有界:
ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue需要提前分配数组内存,而LinkedBlockingQueue则是动态分配链表节点内存。因此,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐渐占用内存空间。
DelayQueue底层
是线程安全的延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
底层
DelayQueue 底层用 PriorityQueue 存储元素,PriorityQueue 采用二叉小顶堆的思想确保值小的元素排在最前面,使得 DelayQueue 对于延迟任务优先级的管理非常方便。同时 DelayQueue 为了保证线程安全还用到了可重入锁 ReentrantLock,确保单位时间内只有一个线程可以操作延迟队列。最后,为了实现多线程之间等待和唤醒的交互效率还用到了 Condition接口,通过 Condition 的 await 和 signal 方法完成多线程之间的等待唤醒。
//可重入锁,实现线程安全的关键 private final transient ReentrantLock lock = new ReentrantLock(); //延迟队列底层存储数据的集合,确保元素按照到期时间升序排列 private final PriorityQueue<E> q = new PriorityQueue<E>(); //指向准备执行优先级最高的线程 private Thread leader = null; //实现多线程之间等待唤醒的交互 private final Condition available = lock.newCondition();
leader: 延迟队列的任务只有到期之后才会执行,对于没有到期的任务只有等待,为了确保优先级最高的任务到期后可以即刻被执行,用leader来管理延迟任务,只有leader所指向的线程才具备定时等待任务到期执行的权限,而其他那些优先级低的任务只能无限期等待,直到leader线程执行完手头的延迟任务后唤醒它。
available: 等待唤醒操作的交互是通过available实现的,假如一个线程尝试在空的DelayQueue获取任务时,available就将其放入等待队列中。直到有一个线程添加一个延迟任务后通过available的signal方法将其唤醒。
DelayQueue是否线程安全?
DelayQueue是线程安全的,它通过 ReentrantLock 实现了互斥访问和 Condition 实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。
DelayQueue使用场景
通常用于实现定时任务调度和缓存过期删除等场景。
- 在定时任务调度中,需要将需要执行的任务封装成延迟任务对象,并将其添加到
DelayQueue中,DelayQueue会自动按照剩余延迟时间进行升序排序(默认情况),以保证任务能够按照时间先后顺序执行。 - 缓存过期删除中,在数据被缓存到内存之后,可以将缓存的
key封装成一个延迟的删除任务,并将其添加到DelayQueue中,当数据过期时,拿到这个任务的key,将这个key从内存中移除。
DelayQueue中Delayed接口的作用是什么?
DelayQueue 中存放的元素必须实现 Delayed 接口,并且需要重写 getDelay()方法。Delayed接口定义了元素的剩余延迟时间(getDelay())和元素之间的比较规则(继承 Comparable 接口),否则 DelayQueue 无法得知当前任务剩余时长和任务优先级的比较。
DelayQueue/Timer/TimerTask区别
都可以用于实现定时任务调度,但实现方式不同。
DelayQueue基于优先级队列和堆排序算法,可以实现多个任务按照时间先后顺序执行;Timer/TimerTask基于单线程,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。DelayQueue支持动态添加/移除任务,Timer/TimerTask只能在创建时指定任务。
Java并发
Java内存模型JMM
JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
为什么需要Java内存模型
- Java是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
- JMM是 Java 定义的并发编程相关的一组规范,主要目的是为了简化多线程编程,增强程序可移植性的。开发者可以利用这些规范更方便地开发多线程程序。
- 对于Java开发者说,不需要了解底层原理,直接使用并发相关的一些关键字和类(比如
volatile、synchronized、各种Lock)即可开发出并发安全的程序。
Java内存区域和Java内存模型有什么区别
- Java 内存区域:是 Java 虚拟机管理的内存中的逻辑划分,包括堆、栈、方法区、本地方法栈、程序计数器等。
- Java 内存模型:是 Java 虚拟机规范中定义的一套规范,用于规范 Java 程序中多线程并发访问共享变量的行为。
JMM是如何抽象线程和主内存之间的关系
Java内存模型通过定义主内存和工作内存之间的关系,以及变量在两者之间的交互规则,确保了多线程环境下的内存可见性和一致性。JMM通过可见性、原子性和有序性等属性,以及happens-before规则,来保证多线程程序的正确性和可预测性。
主内存与工作内存
- 主内存:所有的变量(实例字段、静态字段和数组元素)都存储在主内存中,主内存是所有线程共享的内存区域。
- 工作内存:每个线程都有自己的工作内存,工作内存中存储了该线程从主内存中拷贝的变量副本。线程对变量的所有操作(读写等)都必须在工作内存中进行,不能直接操作主内存中的变量。
并发编程三大特性
可见性、原子性和有序性
- 可见性(Visibility):保证一个线程对变量的修改能够被其他线程及时看到。JMM通过内存屏障、
volatile关键字、synchronized块等手段来实现可见性。 - 原子性(Atomicity):确保操作的不可分割性,即某个操作一旦开始就不会被其他线程看到中间状态。基本的读写操作是原子性的,但更复杂的操作需要借助同步机制(如锁)来保证原子性。
- 有序性(Ordering):保证程序执行的顺序符合一定规则,避免指令重排序带来的问题。JMM定义了一系列的
happens-before规则,确保代码的执行顺序对多线程环境是可预测的。
JMM通过上面三种属性来确保线程之间的内存一致性。
happens-before规则
在JMM中,happens-before规则是一套关键的规则,用于保证多线程环境下的内存可见性和操作的顺序性。这些规则定义了哪些操作对其他线程是可见的,以及它们之间的顺序关系。happens-before规则包括以下几个方面:
- 程序顺序规则:在一个线程内,按照程序代码的顺序,前面的操作
happens-before于后续的任何操作。这是最基本的规则,确保了单线程内操作的顺序性。 volatile变量规则:对一个volatile变量的写操作happens-before于后续对这个volatile变量的读操作。这意味着,当一个线程修改了volatile变量的值,这个新值对于之后读取这个变量的其他线程是立即可见的。- 传递性:如果操作A
happens-before操作B,操作Bhappens-before操作C,那么可以推断出操作Ahappens-before操作C。这个规则是逻辑上的传递性,用于组合其他规则以推导更复杂的happens-before关系。 - 锁定规则:对一个锁的解锁操作
happens-before于后续对这个锁的加锁操作。这意味着,当一个线程释放了锁,其他线程才能获取该锁,从而访问被锁保护的资源,这保证了资源访问的顺序性和安全性。 - 线程启动规则:线程A调用线程B的
start()方法,那么线程A的start()调用happens-before于线程B中的任意操作。这确保了线程B能够看到线程A在启动线程B之前对共享变量的修改。 - 线程终止规则:线程A等待线程B通过
join()方法结束,那么线程B中的任意操作happens-before于线程A从join()方法调用返回。这确保了线程A能够看到线程B在其终止前对共享变量的所有修改。 - 线程中断规则(注意:这通常不是直接作为
happens-before规则的一部分,但中断操作也涉及可见性和顺序性问题):对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生。 - 对象终结规则:一个对象的初始化完成
happens-before于它的finalizer()方法的开始。
通过这些happens-before规则,Java内存模型确保了多线程程序中的内存可见性和操作的顺序性,从而提高了程序的正确性和可预测性。这些规则的实现通常依赖于底层的内存屏障(memory barrier)技术,来禁止编译器和处理器对操作的重排序,并确保操作的原子性和可见性。
volatile关键字✅
volatile 关键字可以保证变量的可见性和防止指令重排序,如果将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile原理
- 可见性保证
- volatile关键字修饰的变量,任何一个线程对其进行修改后,都会立刻写回主内存。
- 其他线程在读取这个变量时,会直接从主内存中读取,而不是从线程的本地缓存中读取。因此,volatile变量的修改对所有线程都是可见的。
- 禁止指令重排序
- 在编译和运行时,JVM会对指令进行优化,其中可能包括重排序,即改变代码中指令的执行顺序,以提高效率。
- 使用volatile修饰的变量,编译器和处理器在读写该变量时会添加内存屏障(Memory Barrier),防止指令重排序,从而确保对该变量的操作按程序中的顺序执行。
如何禁止指令重排序
volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
注意,
volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证,或者Atomic类
进程/线程/协程✅
https://blog.csdn.net/m0_60505735/article/details/131047046
https://blog.csdn.net/weixin_49199646/article/details/109210547
- 进程: 进程是程序的一次执行过程,是系统资源分配和运行程序的基本单位;一个进程在其执行的过程中可以产生多个线程。
- 线程: 线程是进程的一个执行单元,是任务调度和系统执行的最小单位;与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈。所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
- 协程: 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
进程与线程的区别
- 根本区别: 进程是操作系统资源分配和独立运行的最小单位;线程是任务调度和系统执行的最小单位。
- 地址空间区别: 每个进程都有独立的地址空间,一个进程崩溃不影响其它进程;一个进程中的多个线程共享该 进程的地址空间,一个线程的非法操作会使整个进程崩溃。
- 上下文切换开销区别: 每个进程有独立的代码和数据空间,进程之间上下文切换开销较大;线程组共享代码和数据空间,线程之间切换的开销较小。
进程与线程的联系
一个进程由共享空间(包括堆、代码区、数据区、进程空间和打开的文件描述符)和一个或多个线程组成,各个线程之间共享进程的内存空间。而一个标准的线程由线程ID、程序计数器PC、寄存器和栈组成。
进程与线程的选择
- 线程的创建或销毁的代价比进程小,需要频繁创建和销毁时应优先选用线程;
- 线程上下文切换的速度比进程快,需要大量计算时优先选用线程;
- 线程在CPU上的使用效率更高,需要多核分布时优先选用线程,需要多机分布时优先选用进程
- 线程的安全性、稳定性没有进程好,需要更稳定安全时优先使用进程。
综上,线程创建和销毁的代价低、上下文切换速度快、对系统资源占用小、对CPU的使用效率高,因此一般情况下优先选择线程进行高并发编程;但线程组的所有线程共用一个进程的内存空间,安全稳定性相对较差,若其中一个线程发生崩溃,可能会使整个进程,因此对安全稳定性要求较高时,需要优先选择进程进行高并发编程。
进程间的通信方式
进程间通信(Inter-Process Communication, IPC)是指在操作系统中不同进程之间传递数据或信息的机制。常见的进程间通信方式有多种,每种方式都有其独特的应用场景和优缺点。以下是一些主要的进程间通信方式:
- 管道(Pipes):
- 匿名管道(Anonymous Pipes):主要用于有亲缘关系的进程之间的通信,如父子进程。数据以字节流的形式在进程间传递。
- 命名管道(Named Pipes):支持在无亲缘关系的进程之间进行通信。命名管道存在于文件系统中,可以被不同的进程打开和使用。
- 消息队列(Message Queues):
- 允许进程通过发送和接收消息进行通信。消息队列提供了一种在进程间传递数据的有序方式,可以实现异步通信。
- 共享内存(Shared Memory):
- 进程共享一段内存空间,进程可以直接读写这段共享内存中的数据。共享内存是最快的一种通信方式,因为数据不需要在进程间复制,但需要额外的同步机制来避免并发访问问题。
- 信号量(Semaphores):
- 用于控制多个进程对共享资源的访问,通过信号量可以实现进程间的同步和互斥。
- 信号(Signals):
- 信号是一种有限的异步通知机制,用于通知进程某个事件的发生。进程可以捕捉和处理信号,从而实现简单的通信和控制。
- 套接字(Sockets):
- 套接字不仅支持同一台计算机上进程间的通信,也支持分布式网络中不同计算机上的进程间通信。常用于网络编程中。
- 文件系统(File System):
- 进程可以通过读写共享的文件进行通信。这种方式简单但效率较低,适用于需要持久化存储的场景。
- 内存映射文件(Memory-Mapped Files):
- 通过将文件映射到进程的地址空间,实现文件内容的共享和通信。与共享内存类似,但数据的持久化由文件系统提供。
线程间的通信方式
线程间的通信是指在同一个进程内,不同线程之间交换数据或信号的机制。常见的线程间通信方式包括以下几种:
- 共享内存:
- 全局变量:所有线程都可以访问和修改同一个全局变量。
- 静态变量:静态变量在进程的生命周期内只初始化一次,所有线程共享。
- 互斥锁(Mutex):
- 用于防止多个线程同时访问共享资源,从而避免数据竞争。
- 读写锁(RWLock):
- 允许多个线程同时读数据,但在写数据时需要独占锁,确保写操作的安全性。
- 信号量(Semaphore):
- 主要用于限制对共享资源的访问数量,可以控制同时访问资源的线程数。
- 条件变量(Condition Variable):
- 用于线程之间的等待通知机制,一个线程可以等待一个条件变量,而另一个线程可以通知该条件变量改变状态,从而唤醒等待的线程。
- 事件(Event):
- 线程可以等待一个事件,直到另一个线程设置该事件,从而实现线程之间的同步。
- 队列(Queue):
- 线程安全的队列,常用于生产者-消费者模型,一个线程放入数据,另一个线程取出数据。
- 管道(Pipe):
- 用于线程之间的数据传输,常见于一些操作系统提供的进程间通信机制中。
- 消息队列(Message Queue):
- 一种线程安全的队列,专门用于在多个线程之间传递消息。
- 信号(Signal):
- 一种用于通知线程某个事件发生的机制,通常用在异步事件处理。
为什么多个线程共享堆空间
堆内存的共享机制是 Java 内存模型(JMM)的一部分,旨在支持多线程编程。
- 资源利用的高效性:共享堆内存使得内存资源的使用更高效。另外如在生产者-消费者模式中,生产者线程将对象放入共享的队列中,而消费者线程则从该队列中取出对象进行处理。这就需要堆内存是共享的,以便这些对象可以在不同线程间传递和使用。
- 堆是所有线程共享的,栈才是每个线程私有的。
虚拟线程
虚拟线程在 Java 21 正式发布,这是一项重量级的更新。
虚拟线程(Virtual Thread)是 JDK 而不是操作系统实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。
在没有虚拟线程之前,JVM使用的是平台线程,平台线程与内核线程的对应关系在 Windows 和 Linux 等主流操作系统中是一对一的(Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一),这样会导致线程创建和销毁的开销较大,线程切换的开销也较大。
JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。
优点
- 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
- 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
- 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。
缺点
不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程。
Java线程和操作系统线程有何区别
现在的 Java 线程的本质其实就是操作系统的线程。
- JDK1.2之前Java线程基于绿色线程(
Green Threads)实现,即用户线程,由JVM自己实现线程调度,不依赖于操作系统的线程调度。 - JDK 1.2 及以后,Java 线程改为基于原生线程(
Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。
线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有三种: 一对一(一个用户线程对应一个内核线程)、 多对一、 多对多。
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。
如何创建线程
- 继承
Thread类并重写run()方法 - 实现
Runnable接口并重写run()方法 - 实现
Callable接口并重写call()方法 - 使用线程池
ThreadPoolExecutor创建线程 - 等等......
其实只有一种方式可以创建线程,那就是通过 new Thread().start() 创建。不管是哪种方式,最终还是依赖于new Thread().start()。
线程的生命周期和状态
Java 线程 6 种状态:
NEW: 初始状态,线程被创建出来但没有被调用 start() 。
RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
BLOCKED:阻塞状态,需要等待锁释放。
WAITING:线程执行wait()方法等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
TIME_WAITING:超时等待状态,调用sleep(long millis)可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
TERMINATED:执行完run()方法进入终止状态,表示该线程已经运行完毕。线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
线程上下文切换开销
线程在执行过程中会有自己的运行条件和状态(也称上下文),如程序计数器,栈信息等。 当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了
sleep()、wait()等。 - 时间片用完(操作系统防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死)。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
程序计数器为啥是私有的
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
- 如果执行的是
native方法,那么程序计数器记录的是undefined地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为啥是私有的
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的
native方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Thread.sleep()方法和Object.wait()方法对比
共同点:两者都可以暂停线程的执行。
区别:
sleep()方法没有释放锁,wait()方法释放锁。sleep()通常被用于暂停执行,wait()通常被用于线程间交互/通信。wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒(或者也可以使用wait(long timeout)超时后线程会自动苏醒)。sleep()是Thread类的静态本地方法,wait()则是Object类的本地方法。
为什么wait()方法不定义在Thread中
- 锁是对象级别的,每个对象都有一把锁。
wait()方法是对对象的监视器(锁)进行操作,释放的是对象的锁,而不是线程的锁。
为什么sleep()方法定义在Thread中
sleep()是线程控制方法,作用对象是线程本身。- 线程暂停执行,不涉及到对象类,也不需要获得对象锁。
sleep()如何控制当前线程沉睡
调用 Thread.sleep() 会作用于调用它的线程(当前线程)。它并不需要知道是哪一个线程调用的,因为它暂停的是当前线程。
能直接调用Thread类的run方法吗
可以但不建议。new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。
并发/并行区别
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行。
同步/异步区别
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
JUC
Java 并发工具包(JUC,Java Util Concurrent)是 Java 中提供的一套用于处理并发编程的工具类,位于 java.util.concurrent 包中。JUC 提供了多种并发工具和原语,使得开发者能够轻松构建多线程应用程序,并有效管理线程和并发任务。以下是 JUC 中的关键组件和使用方法:
- Executor框架
Executor框架提供了标准化的线程池管理机制,能够通过线程池管理线程的创建和销毁,避免手动管理线程带来的复杂性。
-
Executors 类:提供了用于创建线程池的工厂方法。
ExecutorService executorService = Executors.newFixedThreadPool(5); executorService.execute(() -> { // 执行任务 }); executorService.shutdown(); -
ExecutorService:提供了异步任务的执行管理,支持提交任务、获取任务结果、控制任务完成的生命周期等。
- Callable 和 Future
-
Callable:类似于
Runnable,但可以返回结果或抛出异常。 -
Future:代表异步任务的结果,可以通过
get()方法获取执行结果。Callable<Integer> task = () -> { // 计算任务 return 42; }; Future<Integer> future = executorService.submit(task); Integer result = future.get(); // 获取结果
- 锁机制
JUC 提供了比synchronized更灵活的锁机制。
-
ReentrantLock:可重入锁,支持手动锁定和解锁操作,支持公平锁和非公平锁。
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); } -
ReadWriteLock:读写锁,适用于读多写少的场景,允许多个线程并发读取,但只允许一个线程写。
ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); Lock readLock = readWriteLock.readLock(); Lock writeLock = readWriteLock.writeLock();
- 同步工具类
JUC 提供了多个用于线程同步的工具类:
-
CountDownLatch:用于控制一个或多个线程等待其他线程完成某些操作。
CountDownLatch latch = new CountDownLatch(3); new Thread(() -> { // 任务 latch.countDown(); // 每个任务结束时,调用countDown }).start(); latch.await(); // 等待所有任务结束 -
CyclicBarrier:让一组线程等待彼此到达一个共同点(栅栏),然后继续执行。
CyclicBarrier barrier = new CyclicBarrier(3); new Thread(() -> { // 任务 barrier.await(); // 等待其他线程到达 }).start(); -
Semaphore:信号量,控制对资源的访问权限。
Semaphore semaphore = new Semaphore(2); semaphore.acquire(); // 获取许可 // 访问资源 semaphore.release(); // 释放许可
- 并发集合
JUC 提供了一些线程安全的集合类:
-
ConcurrentHashMap:线程安全的
HashMap。ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); map.put("key", 1); Integer value = map.get("key"); -
BlockingQueue:线程安全的队列,常用于生产者-消费者模型。
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10); queue.put("message"); // 生产者 String msg = queue.take(); // 消费者
- 原子变量
JUC 提供了一些原子操作类,能够确保多线程下的操作是原子的:
- AtomicInteger、AtomicLong、AtomicReference 等,支持原子性更新操作。
AtomicInteger atomicInteger = new AtomicInteger(0); int value = atomicInteger.incrementAndGet(); // 原子性加1
JUC 的出现大大简化了 Java 多线程编程的复杂性,提供了丰富的工具类,开发者可以根据需求选择合适的并发机制。
多线程
如何理解线程安全和不安全
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性 和一致性 的描述。
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
为什么要使用多线程
- 线程切换和调度的成本远远小于进程。
- 单核时代:在单核时代多线程主要是提高单进程利用 CPU 和 IO 系统的效率。 当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU,从而提高 Java 进程利用系统资源的整体效率。
- 多核时代:多核时代多线程主要是提高进程利用多核 CPU 的能力。 创建多个线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高。
- 并发编程是当代互联网发展趋势(现代系统并发量是百万级甚至千万级)。
使用多线程风险
- 线程安全问题:多个线程访问共享资源时,可能会出现数据不一致的情况。
- 死锁:多个线程相互等待对方释放资源,导致所有线程都无法继续执行。
- 内存泄漏:线程未正确释放资源,导致内存泄漏。
- 上下文切换:线程频繁切换,会增加系统开销。
- ...
Java如何保证线程安全
- 同步代码块和方法:通过
synchronized关键字来实现同步代码块和方法,只有一个线程可以在任意时刻进入同步代码块或方法,避免多个线程同时访问共享资源而导致数据不一致的问题。 - 显式锁:
java.util.concurrent.locks包中的显式锁(如ReentrantLock),这些锁提供了比synchronized更加灵活的锁定机制。 - 原子变量类:
java.util.concurrent.atomic包中的原子变量类(如AtomicInteger、AtomicLong),这些类提供了一些原子操作,支持无锁的线程安全操作。可以避免多线程访问共享资源时的数据不一致问题。 - 并发集合: 如
ConcurrentHashMap、CopyOnWriteArrayList等,这些集合类内部实现了线程安全的操作。 volatile关键字用于声明一个变量在多个线程中是可见的,即当一个线程修改了该变量的值,其他线程立即可以看到修改后的值。- 并发工具类:
java.util.concurrent包提供了许多用于线程安全的工具类,如CountDownLatch、CyclicBarrier、Semaphore、Exchanger等。这些工具类可以帮助管理和协调多个线程之间的交互。
多线程实现方式
- 继承
Thread类
通过继承Thread类,并重写run方法来实现多线程。
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running...");
}
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
}
}
- 实现
Runnable接口
通过实现Runnable接口,并将其实例传递给Thread对象来实现多线程。
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable is running...");
}
public static void main(String[] args) {
Thread t1 = new Thread(new MyRunnable());
t1.start();
}
}
- 使用
Callable和Future
Callable接口和Future接口可以在任务完成时返回结果或抛出异常。
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 123;
}
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(new MyCallable());
try {
Integer result = future.get();
System.out.println("Result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
}
- 使用
ExecutorService
通过ExecutorService管理线程池,执行多线程任务。
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
executor.execute(new Runnable() {
@Override
public void run() {
System.out.println("Thread is running...");
}
});
}
executor.shutdown();
}
}
- 通过
ThreadPoolExecutor创建线程池
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("Executing: " + name + " by " + Thread.currentThread().getName());
try {
// 模拟任务执行时间
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Completed: " + name + " by " + Thread.currentThread().getName());
}
}
public class ThreadPoolExecutorExample {
public static void main(String[] args) {
// 创建ThreadPoolExecutor
ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
// 提交任务给线程池
for (int i = 1; i <= 10; i++) {
Task task = new Task("Task-" + i);
System.out.println("Submitting: " + task.name);
executor.execute(task);
}
// 关闭线程池
executor.shutdown();
try {
// 等待所有任务完成
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
高并发
高并发存在什么问题&Java如何解决
高并发问题
- 资源竞争:多个线程竞争同一资源(如内存、文件、数据库连接等),可能导致数据不一致、死锁等问题。
- 死锁:多个线程在等待对方释放资源,从而相互阻塞,导致程序无法继续执行。
- 上下文切换开销:线程之间的切换会消耗系统资源,频繁的上下文切换会降低系统性能。
- 线程安全:确保多个线程对共享资源的操作不会导致数据不一致的问题。
- 内存泄漏:大量创建和销毁线程,如果不合理管理,会导致内存泄漏。
- 饥饿和公平性:某些线程可能长期得不到资源,导致饥饿问题。如何确保线程公平地获取资源也是一个挑战。
- 负载均衡:在分布式系统中,如何将请求均衡地分配到不同的服务器,以避免某些服务器过载。
Java解决高并发问题
- 线程池(Thread Pool):通过线程池管理线程的创建和销毁,减少资源消耗,提高系统性能。
- 同步机制(Synchronization):使用
synchronized关键字或显示锁(如ReentrantLock)来确保线程安全,避免资源竞争。 - 并发工具类:如
ConcurrentHashMap、CopyOnWriteArrayList等,这些工具类可以帮助开发者更方便地实现线程间的协调和资源共享。 - 无锁算法(Lock-Free Algorithms):使用原子变量类(如
AtomicInteger、AtomicLong)来实现无锁并发,减少上下文切换开销,提高性能。 - CAS操作(Compare-And-Swap):CAS是一种无锁算法,通过比较和交换操作来实现原子性。Java中的
Unsafe类和Atomic包使用了CAS操作来实现无锁算法。 - Fork/Join框架:用于任务拆分和并行处理,适用于大规模数据并行处理。Java 7引入了
ForkJoinPool来支持这一框架。 - 异步编程(Asynchronous Programming):通过
CompletableFuture和异步方法调用,实现非阻塞的并发编程模型。
Java如何实现高并发
使用线程池
线程池可以有效管理线程的创建和销毁,减少资源消耗和上下文切换开销。Java中的ExecutorService和Executors类提供了便捷的线程池实现。
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(new Task());
}
executor.shutdown();
同步机制
使用synchronized关键字或者显式锁(如ReentrantLock)来确保线程安全,避免资源竞争和数据不一致问题。
public synchronized void safeMethod() {
// 线程安全代码
}
Lock lock = new ReentrantLock();
lock.lock();
try {
// 线程安全代码
} finally {
lock.unlock();
}
并发集合类
Java提供了线程安全的并发集合类,如ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等,这些集合类可以简化多线程编程。
原子变量类
使用原子变量类(如AtomicInteger、AtomicLong)来实现无锁并发,减少上下文切换,提高性能。
CAS操作
Java的Atomic包和Unsafe类使用了CAS(Compare-And-Swap)操作来实现无锁算法,从而提高并发性能。
AtomicInteger count = new AtomicInteger(0);
boolean updated = count.compareAndSet(0, 1);
异步编程
使用CompletableFuture和异步方法调用,实现非阻塞的并发编程模型。
CompletableFuture.supplyAsync(() -> {
// Async task
return "result";
}).thenAccept(result -> {
// Process result
});
使用非阻塞I/O
在网络编程中,使用NIO(Non-blocking I/O)实现高并发。NIO提供了选择器(Selector)、通道(Channel)和缓冲区(Buffer)等工具,可以实现高效的非阻塞I/O操作。
Fork/Join框架
ForkJoinPool用于任务拆分和并行处理,适用于大规模数据的并行计算。
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(new RecursiveTask<Void>() {
@Override
protected Void compute() {
// Task implementation
return null;
}
});
使用第三方并发框架
如Akka、RxJava、Vert.x等,它们提供了更加高级的并发和反应式编程模型。
针对转账高并发,在系统层面如何优化?
在处理转账的高并发情况时,系统层面的优化涉及多个方面,包括架构设计、数据库优化、缓存使用、分布式事务处理等。以下是具体的优化策略:
- 架构设计
- 水平扩展(Scaling Out):
- 集群化:将服务部署到多个服务器上,使用负载均衡器(如Nginx、HAProxy)分发请求,以应对大量并发请求。
- 微服务架构:将系统拆分为多个独立的微服务,每个微服务负责特定的功能模块,这样可以独立扩展和优化各个服务。
- 水平扩展(Scaling Out):
- 异步处理:
- 消息队列:使用消息队列(如RabbitMQ、Kafka)处理转账请求,异步处理可以减轻主系统的负载,提升系统的响应速度和稳定性。
- 事件驱动架构:通过事件驱动机制来处理转账请求,将转账操作分解成多个事件,以分布式的方式处理。
- 数据库优化
- 数据库分片:
- 分库分表:将数据库按照某种规则进行分片(如按用户ID、地域等),减小单个数据库的压力,提升整体处理能力。
- 读写分离:使用主从复制架构,将读操作分配到从库,写操作集中在主库,减少读操作对写操作的影响。
- 索引优化:
- 合理使用索引:为常用查询添加适当的索引,提升查询速度。
- 避免全表扫描:确保查询条件能够利用索引,避免全表扫描。
- 事务优化:
- 减小事务范围:尽量缩小事务的范围,减少锁的持有时间,提升并发处理能力。
- 乐观锁与悲观锁:根据业务场景选择合适的锁机制,避免不必要的锁争用。
- 数据库分片:
- 缓存使用
- 分布式缓存:
- 使用缓存系统:如Redis、Memcached,将频繁访问的数据缓存起来,减轻数据库压力。
- 缓存一致性:设计缓存更新策略,确保数据的一致性,如使用缓存失效、定时刷新等机制。
- 热点数据缓存:
- 热点账户缓存:对频繁进行转账操作的账户进行缓存,减少对数据库的直接访问。
- 事务结果缓存:将转账结果缓存一段时间,避免重复查询数据库。
- 分布式缓存:
- 分布式事务处理
- 两阶段提交(2PC):
- 分布式事务协调器:使用两阶段提交协议确保分布式系统中的数据一致性,尽管性能可能受影响,但在需要强一致性的场景下是必要的。
- TCC(Try-Confirm-Cancel)模式:
- 三阶段事务控制:分别进行预留资源(Try)、确认操作(Confirm)和取消操作(Cancel),通过分阶段的事务控制确保一致性和可靠性。
- 其他优化策略
- 限流与降级:
- 限流机制:使用令牌桶、漏桶算法等限流机制控制并发请求数量,防止系统过载。
- 降级策略:在高并发情况下,优先处理核心业务,非核心业务可以降级或延迟处理。
- 监控与报警:
- 实时监控:使用监控工具(如Prometheus、Grafana)监控系统性能指标(如QPS、响应时间、错误率等),及时发现并解决问题。
- 报警机制:设置关键指标的报警规则,出现异常情况时及时通知相关人员。
- 优化代码:
- 提高代码效率:优化业务逻辑,减少不必要的操作和冗余代码。
- 并发处理:使用合适的并发处理技术(如线程池、协程等)提升系统的并发处理能力。
- 限流与降级:
通过上述多种优化措施,可以有效提升系统在高并发情况下的处理能力和稳定性,确保转账操作的快速和可靠执行。
线程池✅
线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
为什么用线程池
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
在计算机领域中有很多池化技术,线程池、数据库连接池、内存池、对象池等等,都是为了减少每次获取资源的消耗,提高对资源的利用率。
创建线程池的方式
- 通过
ThreadPoolExecutor构造函数来创建 (推荐) 。 - 通过
Executors工具类来创建 (不推荐) 。通过这种方式可以创建出多种类型的线程池FixedThreadPool:固定大小的线程池。SingleThreadPool:只有一个线程的线程池。CachedThreadPool:可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。ScheduledThreadPool:定时任务的线程池。
为什么不推荐使用内置线程池
FixedThreadPool,SingleThreadPool,ScheduledThreadPool:使用的是无界的LinkedBlockingQueue,允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。CachedThreadPool:使用同步队列SynchronousQueue,允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量线程,从而导致 OOM。
ThreadPoolExecutor参数
3 个最重要的参数:
corePoolSize: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
其他常见参数:
keepAliveTime:线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime才会被回收销毁。unit:keepAliveTime参数的时间单位。threadFactory:executor创建新线程的时候会用到。handler:拒绝策略,当提交的任务过多而不能及时处理时,可以定制策略来处理任务。默认是AbortPolicy。
线程池的拒绝策略
AbortPolicy:直接抛出异常,阻止系统正常工作。是线程池的默认拒绝策略。CallerRunsPolicy:既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。DiscardOldestPolicy:丢弃最早的未处理的任务请求。DiscardPolicy:不处理新任务,直接丢弃掉。
CallerRunsPolicy策略风险及解决方案
风险
CallerRunsPolicy 保证任何一个任务请求都要被执行,但这非常耗时,且如果提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。
解决方案
- 使用
CallerRunsPolicy是希望所有任务都被执行,而暂时无法处理的任务又被保存在阻塞队列BlockingQueue中,这样在内存允许的情况下,可以增加阻塞队列BlockingQueue的大小以容纳更多的任务,同时调整线程池的maximumPoolSize(最大线程数)参数,这样可以提高任务处理速度,避免累计在BlockingQueue的任务过多导致内存用完。 - 使用
ThreadPoolExecutor的setRejectedExecutionHandler方法,自定义拒绝策略,比如将任务保存到数据库中,或者将任务保存到消息队列中,等待下次执行,或者使用Redis缓存任务。
线程池常用阻塞队列
LinkedBlockingQueue:基于链表的阻塞队列,大小默认为Integer.MAX_VALUE,即任务队列永远不会放满。FixedThreadPool和SingleThreadExector使用,前者只能创建核心线程数的线程,后者只能创建一个线程。SyncronousQueue:同步队列,1CachedThreadPool1使用。不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。CachedThreadPool的最大线程数是Integer.MAX_VALUE,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。DelayedWorkQueue:延迟阻塞队列,ScheduledThreadPool和SingleThreadScheduledExecutor使用。队列中的元素只有当其指定的延迟时间到了才能从队列中取出。内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。
线程池处理任务流程
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,并且任务队列没满,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,根据拒绝策略处理。
线程池中线程异常后销毁还是复用?
两种情况:
- 使用
execute()时,未捕获异常导致线程终止,线程池创建新线程替代; - 使用
ExecutorService.submit()时,异常被封装在Future中,线程继续复用。
线程池工作流程的公平性/非公平性
公平与非公平主要是指任务调度的策略,尤其在高并发场景下会对性能和任务处理的公平性产生影响。
-
公平模式:公平模式下,任务的执行顺序严格按照任务到达队列的顺序(FIFO,先入先出)处理。
-
特点:
- 严格顺序:线程池从队列中按任务到达的时间顺序分配任务。
- 公平性高:每个任务被执行的机会均等,避免“饿死”现象(某些任务长期得不到执行)。
- 性能稍低:在公平模式下,需要更多的机制保证顺序性,可能导致额外的上下文切换和调度开销。
-
适用场景:
- 对任务顺序有严格要求的场景:如交易处理、日志写入等。
- 对实时性要求不高的场景:如批量任务处理、非实时系统等。
-
非公平模式:非公平模式下,线程池允许任务插队,优先将任务分配给空闲线程,而不完全按照任务的到达顺序。
-
特点:
- 性能更高:线程池直接寻找可用线程执行任务,而无需严格排队,从而减少了调度开销。
- 可能出现不公平:有些任务可能会长时间得不到执行(即可能发生“饿死”现象)。
- 灵活性强:调度机制更加自由,适合高并发、对顺序要求不严格的场景。
-
适用场景
-
高吞吐量场景:如 Web 服务器请求处理、实时数据处理等。
-
对任务顺序要求不高的场景:如异步任务处理、分布式系统中的并行计算等。
Java 中的 ThreadPoolExecutor 默认采用非公平策略,除非使用自定义的任务队列或线程工厂。
- 公平性控制:线程池调度中并没有直接的公平模式控制,但可以通过配合锁(如
ReentrantLock的公平模式)或自定义任务队列来实现类似的公平性。 - 锁的公平与非公平:
ReentrantLock提供了明确的公平模式选择:- 公平锁:先等待的线程先获得锁。
- 非公平锁:当前线程尝试直接获得锁,如果失败再进入队列。
- 任务队列选择:
- FIFO 队列(如
LinkedBlockingQueue):天然具备公平性。 - 优先级队列(如
PriorityBlockingQueue):通过自定义排序规则,可以实现不同的公平或非公平策略。
- FIFO 队列(如
总结
公平模式注重顺序,适合对任务调度有明确规则要求的场景;非公平模式更注重效率,在高并发情况下能够提高资源利用率。实际使用时需根据业务需求权衡选择合适的模式。
如何设定线程池大小
注意,这里的线程池大小指的是最大线程数(
maximumPoolSize)。
- CPU 密集型任务(N+1): 将线程数设置为 N_cpu_cores(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
- I/O 密集型任务(2N): IO密集型任务在执行过程中会频繁地等待I/O操作完成(如读取文件、网络请求),这些操作通常不占用CPU,可以多配些线程。
- 线程池大小通常设置为
N_threads = N_cpu_cores * U_cpu * (1 + W/C)。N_cpu_cores是机器的CPU核心数。U_cpu是期望的CPU利用率,通常取值在 0.5 到 0.9 之间。W/C是等待时间与计算时间的比值,即W是线程在I/O操作上的等待时间,C是线程进行计算的时间。
- 线程池大小通常设置为
具体如何设置线程池大小
对于IO密集型任务,要估算 W/C,可以通过以下方法:
- 测量: 可以通过实际测试任务的执行时间来测量出
W和C。例如,测量I/O操作花费的时间W以及计算部分的时间C,并计算出它们的比值。 - 假设: 如果没有具体的测量,可以根据任务的性质做出合理的假设。例如,对于网络I/O密集型任务,
W/C通常会比较大,因为I/O操作可能会占据大部分时间。 - 调整和优化: 通过监控实际应用的性能表现来调整线程池大小,确保资源利用率最大化,同时避免资源过度争用(如线程过多导致上下文切换频繁)。
例子
假设一个系统有 8 个 CPU 核心(N_cpu_cores = 8),任务的 W/C = 5,且期望 CPU 利用率 U_cpu = 0.8,那么线程池大小可以设置为:
N_threads = 8 * 0.8 * (1 + 5) = 8 * 0.8 * 6 = 38.4 ≈ 38
如何设计一个根据任务优先级执行的线程池
- 使用
PriorityBlockingQueue作为任务队列,队列中的任务按照优先级顺序执行。 - 创建
PriorityBlockingQueue时传入一个Comparator对象来指定任务之间的排序规则(推荐)。
这样还存在一些问题:
PriorityBlockingQueue是无界的,可能堆积大量的请求,从而导致 OOM。- 解决方法:继承
PriorityBlockingQueue并重写一下offer方法(入队)的逻辑,当插入的元素数量超过指定值就返回false。
- 解决方法:继承
- 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
- 解决方法:优化设计,等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
单核CPU上运行多个线程效率一定会高吗
如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。
如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。
锁✅
Java 中,每个对象都有一个与之关联的监视器(monitor),也叫做“对象锁”或“监视器锁”。当线程想要执行对象的同步方法或同步代码块时,必须首先获得该对象的监视器锁。如果其他线程已经持有该锁,则新的线程必须等待,直到该锁被释放。
对锁的理解
锁是用于控制多个线程并发访问共享资源的机制,确保在多线程环境下数据的完整性和一致性。锁的核心是控制对共享资源的访问,以防止多个线程同时修改同一个资源。
锁的分类
-
公平锁:锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
-
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
-
悲观锁:总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统频繁的上下文切换,增加系统的性能开销。
- 悲观锁可能会存在死锁问题。
- 通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。
-
乐观锁:总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。
- 具体方法可以使用版本号机制或 CAS 算法,
AtomicInteger、LongAdder等都是乐观锁的实现。 - 高并发场景,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。
- 如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
LongAdder以空间换时间的方式就解决了大量重试问题。
- 通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量。
- 具体方法可以使用版本号机制或 CAS 算法,
-
可重入锁(Reentrant Lock)
- 可重入锁是一种允许同一个线程多次获取同一个锁的锁机制。Java中的
ReentrantLock类是其典型实现。可重入锁解决了递归调用中锁定的死锁问题。
- 可重入锁是一种允许同一个线程多次获取同一个锁的锁机制。Java中的
-
读写锁(Read-Write Lock)
- 读写锁是一种允许多个线程同时读取共享资源,但在写操作时只能有一个线程访问的锁机制。
ReentrantReadWriteLock是Java中的典型实现,适用于读多写少的场景,提升并发性能。
- 读写锁是一种允许多个线程同时读取共享资源,但在写操作时只能有一个线程访问的锁机制。
-
分段锁(Segmented Lock)
- 分段锁是一种细粒度锁机制,将资源分为多个段,每个段配有独立的锁,以提高并发性。Java中的
ConcurrentHashMap就使用了分段锁来提高并发效率。
- 分段锁是一种细粒度锁机制,将资源分为多个段,每个段配有独立的锁,以提高并发性。Java中的
-
轻量级锁(Lightweight Locking)
- 轻量级锁是在没有线程竞争的情况下,通过CAS(Compare-And-Swap)操作进行加锁。轻量级锁适用于竞争不激烈的场景,可以减少传统重量级锁(Monitor)带来的性能开销。
-
重量级锁(Heavyweight Locking)
- 重量级锁是Java中最传统的锁机制,通过操作系统的互斥量(Mutex)实现。当多个线程竞争同一个锁时,未获得锁的线程会被阻塞,并由操作系统负责调度,这种锁的开销较大。
-
自旋锁(Spin Lock)
- 自旋锁是一种不断循环检查锁状态的锁机制,而不是让线程进入阻塞状态。线程会在一段时间内一直尝试获取锁,直到成功或达到一定重试次数为止。适用于锁的持有时间较短的场景,避免线程切换的开销。
-
偏向锁(Biased Locking)
- 偏向锁是一种优化手段,旨在减少无竞争情况下的锁获取开销。当一个线程获取锁时,会偏向于该线程,后续该线程再次获取锁时无需进行同步操作。这种锁会在没有其他线程竞争的情况下发挥作用,但如果有其他线程试图获取该锁,偏向锁会被撤销。
Java中有哪些锁
synchronized关键字- 用于同步方法或同步代码块。
- 基于对象锁,每个对象都有一个监视器锁。
ReentrantLock- 在
java.util.concurrent.locks包中提供。 - 提供更灵活的锁机制,相比
synchronized,支持公平锁、非公平锁等特性。 - 支持可重入锁,即同一个线程可以多次获取同一个锁。
- 在
ReentrantReadWriteLock- 在
java.util.concurrent.locks包中提供。 - 提供读写锁的实现,读锁共享,写锁独占。
- 适用于读多写少的场景,提升并发性能。
- 在
Semaphore- 在
java.util.concurrent包中提供。 - 用于控制同时访问特定资源的线程数量。
- 在
Atomic变量- 在
java.util.concurrent.atomic包中提供。 - 提供了一些基本类型(如
AtomicInteger、AtomicLong)的原子操作,实现了无锁的线程安全。
- 在
CountDownLatch- 在
java.util.concurrent包中提供。 - 允许一个或多个线程等待一组操作完成。
- 在
CyclicBarrier- 在
java.util.concurrent包中提供。 - 使一组线程在到达一个共同的屏障点时被阻塞,直到所有线程都到达该点。
- 在
StampedLock- 在
java.util.concurrent.locks包中提供。 - 提供三种锁模式:写锁、悲观读锁和乐观读锁。
- 乐观读锁在某些情况下可以避免加锁操作,提升性能。
- 在
LockSupport- 提供基本的线程阻塞和唤醒机制。
- 通常用于构建更高级的同步器,如
Semaphore、CountDownLatch等。
Phaser- 在
java.util.concurrent包中提供。 - 允许多阶段的线程协作,每个阶段完成后继续下一阶段。
- 在
这些锁机制可以根据具体的应用场景选择,以实现高效、安全的并发控制。
锁升级原理了解吗
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
公平锁/非公平锁
- 公平锁:锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
可中断锁/不可中断锁
- 可中断锁:正在等待获取锁的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized是不可中断锁。
共享锁/独占锁
- 共享锁:一把锁可以被多个线程同时获得。
- 独占锁:一把锁只能被一个线程获得。
线程持有读锁还能获取写锁吗
- 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
- 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
读锁为什么不能升级为写锁
- 写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。
- 可能会有死锁问题发生。
- 举例:假设线程A、B都想要将持有的读锁升级为写锁。由于写锁是独占的,因此线程A在尝试升级写锁时,需要等待所有其他读锁(包括线程B持有的)被释放。同样,线程B也需要等待所有其他读锁(包括线程A持有的)被释放。这就形成了一个典型的死锁场景:线程A等待线程B释放读锁,而线程B又等待线程A释放读锁。两者都无法继续执行,都在等待对方释放资源。
乐观锁/悲观锁
- 悲观锁:总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统频繁的上下文切换,增加系统的性能开销。
- 悲观锁可能会存在死锁问题。
- 通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。
- 乐观锁:总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。
- 具体方法可以使用版本号机制或 CAS 算法,
AtomicInteger、LongAdder等都是乐观锁的实现。 - 高并发场景,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。
- 如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
LongAdder以空间换时间的方式就解决了大量重试问题。
- 通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量。
- 具体方法可以使用版本号机制或 CAS 算法,
如何实现乐观锁
- 版本号机制:在数据表中增加一个版本号字段,每次更新数据的时候,将版本号加一,更新的时候判断版本号是否一致,一致则更新成功,否则失败。
- CAS:如下。
CAS自旋锁
- CAS:
compare and swap,用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。是原子操作。- V:要更新的变量值(Var),E:预期值(Expected),N:拟写入的新值(New)。当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E = 1,N = 6,假设不存在 ABA 问题)。i(V) 与 1(E) 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6(N)。i(V) 与 1(E) 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
CAS算法存在问题
ABA问题
变量 V 初次读取的时候是 A 值,并且在使用 CAS算法 准备赋值的时候检查到它仍然是 A 值,不能说明它的值没有被其他线程修改过,因为在这段时间它的值可能被改为其他值(B),然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
ABA问题的解决思路是在变量前面追加上版本号或者时间戳。
循环时间长开销大
CAS 经常会用到自旋操作来进行重试,自旋时间长,如果 CAS 一直不成功,会导致 CPU 一直自旋,这样会消耗 CPU 资源。
只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效,这时可以使用AtomicReference。
synchronized关键字✅
synchronized 是一种同步锁。主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized底层原理
synchronized 关键字是通过对象内部的一个叫做监视器锁(monitor)来实现的。
synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。synchronized修饰的方法使用ACC_SYNCHRONIZED标识,JVM 通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
两者的本质都是对对象监视器 monitor 的获取。
JDK1.6之后synchronized的优化
在 Java 早期版本中,synchronized属于重量级锁,效率低下,但是在 Java 6 之后,synchronized引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,使得 synchronized 的效率大大提高。
如何使用synchronized
- 修饰实例方法:锁住当前实例对象。
synchronized void method() {
//业务代码
}
- 修饰静态方法:锁住当前类的 Class 对象。
synchronized static void method() {
//业务代码
}
- 修饰代码块:锁住括号里面的对象。
synchronized(object)表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)表示进入同步代码前要获得 给定Class的锁
synchronized(对象) {
//业务代码
}
synchronized(类.class) {
//业务代码
}
synchronized和ReentrantLock有什么区别?
两者都是可重入锁
synchronized是关键字,是内置的语言实现,ReentrantLock是一个类。synchronized是 JVM 实现的,ReentrantLock是 JDK 实现的(也就是 API 层面,需要lock()和unlock()方法配合try/finally语句块来完成)synchronized是不可中断锁。ReentrantLock是可中断锁。ReentrantLock提供一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()实现。
ReentrantLock比synchronized增加了一些高级功能:- 可实现公平锁:
ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。通过ReentrantLock(boolean fair)构造方法来指定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以,但需借助Condition接口与newCondition()方法。
- 可实现公平锁:
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁(一个类中有多个
synchronized修饰的方法)的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
ReentrantLock
ReentrantLock 是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock 里面有一个内部类 Sync,Sync 继承抽象类 AQS(AbstractQueuedSynchronizer,抽象队列同步器),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。
原理
ReentrantLock内部维护了一个使用volatile修饰(保证线程可见性)的state变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 锁队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
ReentrantReadWriteLock
是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁)。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。
如何自己设计一个悲观锁
使用synchronized关键字。
public class PessimisticLock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait(); // 如果已经被锁定,则等待
}
isLocked = true; // 获取锁
}
public synchronized void unlock() {
isLocked = false; // 释放锁
notify(); // 通知等待的线程
}
}
AQS核心思想
AQS(AbstractQueuedSynchronizer,抽象队列同步器) 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,使用基于CLH锁实现的一套线程阻塞等待以及被唤醒时锁分配机制。
以可重入互斥锁 ReentrantLock 为例,其内部维护了一个使用volatile修饰(保证线程可见性)的state变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 锁队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
CLH锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个CLH队列锁的一个结点(Node)来实现锁的分配。在CLH队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
AQS资源共享模式
AQS 支持两种资源共享方式:独占和共享。
Exclusive(独占,只有一个线程能执行,如ReentrantLock)Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)
也可以自定义同步器同时实现独占和共享,如
ReentrantReadWriteLock,读操作时多个线程可以同时进行,写操作时只能一个线程进行。
StampedLock
StampedLock 是 JDK 1.8 引入的性能更好的读写锁,没有实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁独立实现的。
提供三种访问模式:
- 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于
ReentrantReadWriteLock的写锁,不过这里的写锁是不可重入的。 - 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于
ReentrantReadWriteLock的读锁,不过这里的读锁是不可重入的。 - 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。(性能比
ReadWriteLock更好的原因)
死锁✅
死锁是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
产生死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何检测死锁
- 一般死锁可能会导致 CPU 使用率飙升,线程处于
BLOCKED状态,可以通过监控工具查看。 - jConsole: 可以检测死锁,查看线程的状态。
- jstack: 如有死锁,会输出
Found one Java-level deadlock:线程的状态信息
jps -l // 查找当前正在运行的 Java 进程及其对应的 PID
70191 DeadLockSample
jstack -l <PID> // jstack 命令将当前 Java 进程的线程堆栈信息输出到控制台
jconsole // 唤醒图形化界面,然后选择线程->检测死锁
如何预防和避免死锁
破坏死锁的产生的必要条件:
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
银行家算法
银行家算法通过预先判断系统是否处于安全状态来避免死锁的发生。它要求进程在申请资源时声明其最大需求量,并在分配资源前进行安全性检查。如果分配资源后系统仍处于安全状态,则进行资源分配;否则,拒绝分配资源并让进程等待。
安全状态:如果存在一个由系统中所有进程构成的安全序列(即每个进程都能按照某种顺序顺利执行完成,且每个进程在执行过程中所需资源都能得到满足),则系统处于安全状态。安全状态一定是没有死锁发生的。
不安全状态:不存在一个安全序列的状态称为不安全状态。不安全状态不一定导致死锁,但有可能发展为死锁。
ThreadLocal✅
ThreadLocal 是一个线程内部的数据存储类,可以在每个线程中创建一个变量副本,各个线程之间的数据互不干扰。可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal原理
ThreadLocal 通过 ThreadLocalMap 来实现线程内部的数据存储。ThreadLocalMap 是 ThreadLocal 的一个静态内部类,每个线程中都有一个 ThreadLocalMap,ThreadLocal 通过 get()、set() 方法访问 ThreadLocalMap。在一个线程中创造多个ThreadLocal对象,这个许多个ThreadLocal对象会被放到一个ThreadLocalMap中。
ThreadLocalMap可以理解为一个定制化的HashMap,key是ThreadLocal对象,value是存储的值。
可以存在这种情况: 在线程 1 中创建了两个ThreadLocal对象,在线程 1 中只有一个ThreadLocal对象。
ThreadLocal内存泄漏
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来就会出现 key 为 null 的 键值对。如果不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
其实ThreadLocalMap实现中已经考虑了内存泄漏问题,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。不过使用完 ThreadLocal方法后最好手动调用remove()方法。
HashMap的key和value都是强引用,因此不会存在弱引用导致的内存泄漏问题。
Semaphore
Semaphore 是一个计数信号量,用于控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。其中的state表示许可数(>=1),当一个线程调用 acquire() 方法时,会首先尝试获取一个许可,如果成功,该线程就可以继续执行,否则就会被阻塞。当一个线程调用 release() 方法时,会释放一个许可,这样就会唤醒一个被阻塞的线程。
Semaphores信号量原理
信号量(Semaphore)是一种用于进程间同步和互斥的机制。它通过维护一个计数器来管理多个进程对共享资源的访问。
Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,表示许可的数量。只有拿到许可证的线程才能执行。
- 调用
semaphore.acquire(),线程尝试获取许可证,如果state >= 0的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改state的值state=state-1。如果state<0的话,则表示许可证数量不足。此时会创建一个Node节点加入阻塞队列,挂起当前线程。 - 调用
semaphore.release(),线程尝试释放许可证,并使用 CAS 操作去修改state的值state=state+1。释放许可证成功之后,同时会唤醒阻塞队列中的一个线程。被唤醒的线程会重新尝试去修改state的值state=state-1,如果state>=0则获取令牌成功,否则重新进入阻塞队列,挂起线程。
异步调用Future类
Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,具体来说是这样的:当主线程执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时主线程做其他事情,不用等待耗时任务执行完成。等事情干完后,再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
Future类是一个泛型接口,主要包含下面几个方法:
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
// 取消任务执行,成功取消返回 true,否则返回 false
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否被取消
boolean isCancelled();
// 判断任务是否已经执行完成
boolean isDone();
// 获取任务执行结果
V get() throws InterruptedException, ExecutionException;
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
Callable和Future关系
Callable 用于定义可以返回结果的任务,Future 用于获取 Callable 任务的异步结果。两者常常结合使用,以便在并发编程中有效地管理任务执行和结果处理。
- 任务提交与执行:通常通过
ExecutorService提交Callable任务,submit方法返回一个Future对象。 - 异步结果获取:
Future对象可以用来获取Callable任务的返回结果或处理异常。
Runnable和Callable的区别
Runnable和Callable是两个用于并发编程的接口。
- 返回值
Runnable:不返回结果。Runnable的run()方法是无返回值的(void),所以无法通过它直接获取任务执行的结果。Callable:返回结果。Callable接口的call()方法返回一个泛型类型的值(可以是任意类型),通常与Future或FutureTask结合使用,以便在任务完成后获取其结果。
- 异常处理
Runnable:不能抛出受检异常(Checked Exception)。如果Runnable任务中有受检异常,需要在run()方法内捕获并处理。Callable:可以抛出受检异常。Callable的call()方法可以抛出异常,这使得它更适合需要处理异常的任务。
- 使用场景
Runnable:常用于那些不需要返回结果的简单任务,如执行某个后台任务、周期性任务等。Callable:适用于需要返回结果或可能抛出受检异常的任务,比如计算密集型任务、获取某些资源或执行某些带有条件判断的任务。
实现异步调用的多种方法
Future类:通过Future类的get()方法获取异步任务的执行结果。CompletableFuture类:Java 8 引入的CompletableFuture类,提供了更强大的方法链和回调机制,用于构建复杂的异步逻辑和并行操作。CompletionService类和Callable类:CompletionService是一个接口,它允许提交一组Callable任务,这些任务将异步执行,并且可以按照完成的顺序获取它们的结果。FutureTask类:FutureTask是Future的一个实现类,它实现了Runnable接口,可以作为Runnable被线程执行,也可以作为Future得到任务的执行结果。ExecutorService类:ExecutorService是一个接口,它是Executor的子接口,它提供了更丰富的线程池功能,可以提交任务、执行任务、关闭线程池等。ForkJoinPool、ScheduledExecutorService等。
CompletableFuture类有什么用?
CompletableFuture 类是 Java 8 中引入的一个增强版 Future,它不仅可以代表一个异步计算的结果,还提供了强大的方法链和回调机制,用于构建复杂的异步逻辑和并行操作。与 Future 相比,CompletableFuture 更加灵活和强大,支持函数式编程、异步任务编排组合等能力。
CompletableFuture类异步调用实现
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class AsyncExample {
public static void main(String[] args) {
// 创建一个CompletableFuture来执行异步任务
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 模拟一个长时间运行的任务
try {
Thread.sleep(2000); // 休眠2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
return "任务完成";
});
// 注册一个回调函数,当任务完成时获取结果
future.thenAccept(result -> {
System.out.println("异步任务结果: " + result);
});
// 主线程继续执行其他操作
System.out.println("主线程继续执行...");
// 阻塞主线程,直到异步任务完成(可选)
try {
// 这一步会阻塞主线程,直到异步任务完成
String result = future.get();
System.out.println("异步任务完成后获取的结果: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
CountDownLatch
CountDownLatch 是 Java 并发工具类,位于 java.util.concurrent 包中,常用于协调多个线程之间的同步。它通过一个计数器来控制线程的执行,线程可以等待其他线程完成某些操作后再继续执行。
构造方法
public CountDownLatch(int count)
count表示计数器的初始值,通常是需要等待的线程数量。
核心方法
-
await()
当前线程调用该方法后会进入等待状态,直到计数器的值变为 0 或线程被中断。public void await() throws InterruptedException -
countDown()
计数器的值减 1,表示某个线程已经完成了工作。当计数器变为 0 时,所有因调用await()而等待的线程将被唤醒继续执行。public void countDown() -
await(long timeout, TimeUnit unit)
带超时的等待。如果在指定时间内计数器没有变为 0,方法会返回false。public boolean await(long timeout, TimeUnit unit) throws InterruptedException
使用场景
- 等待多线程完成:主线程可以使用
await()方法等待多个工作线程完成任务。 - 控制任务执行顺序:可以确保某些任务在其他任务完成之后再执行。
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int threadCount = 3;
CountDownLatch latch = new CountDownLatch(threadCount);
// 启动3个线程
for (int i = 0; i < threadCount; i++) {
new Thread(new Worker(latch)).start();
}
// 主线程等待
latch.await();
System.out.println("所有工作线程已完成");
}
static class Worker implements Runnable {
private final CountDownLatch latch;
public Worker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
// 模拟任务执行
Thread.sleep((int)(Math.random() * 1000));
System.out.println(Thread.currentThread().getName() + " 完成任务");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 任务完成后减小计数器
latch.countDown();
}
}
}
}
在此示例中,主线程会等待 3 个工作线程执行完各自的任务后再继续。
JVM
内存区域
Java内存区域/Java虚拟机内存 被划分为多个部分,每个部分在Java应用程序运行时发挥不同的作用。
线程私有的内存区域包括:
- 程序计数器:
- 这是当前线程执行的字节码行号指示器。每个线程都有一个独立的程序计数器,指向下一条要执行的字节码指令。
- 如果正在执行的是本地方法,这个计数器是未指定值(
undefined)。
- Java虚拟机栈:
- Java虚拟机栈用于存储局部变量、操作数栈、中间结果等。
- 栈帧(Stack Frame)是虚拟机栈中的基本元素,每个方法调用对应一个栈帧。栈帧包括局部变量表、操作数栈和帧数据(如方法返回地址、动态链接信息等)。
- 本地方法栈:
- 本地方法栈与Java虚拟机栈类似,只不过它为本地方法(Native Methods)服务。
- 一般情况下,使用C语言等实现的本地方法的调用也会在本地方法栈中执行。
线程共享的内存区域包括:
- 堆:
- Java堆是所有线程共享的内存区域,用于存放对象实例及数组。所有对象实例及数组都在堆上分配。
- 堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”。
- 堆内存通常又被划分为年轻代(Young Generation)和老年代(Old Generation),其中年轻代进一步划分为Eden区、Survivor0区和Survivor1区。
- JDK1.8将字符串常量池从方法区中移除,放到堆中,这样可以避免字符串常量池占用过多的方法区内存。
- 方法区:
- 方法区也是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
- 方法区在JVM规范中是堆的一部分,但实际实现中常被单独划分,比如HotSpot虚拟机中的永久代(Permanent Generation,已在Java 8中被元空间Metaspace取代)。
- 运行时常量池:
- 运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。这些常量在类加载后被放入方法区的运行时常量池中。
- JDK1.7位于方法区,JDK1.8位于直接内存中的元空间。
本地内存:
- 直接内存:
- 直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
- JDK1.8之后加入了元空间,元空间是直接内存的一部分,用于存放类的元数据信息。
- JDK1.8将运行时常量池放到直接内存的元空间中,这样可以避免方法区内存溢出的问题。
内存模型与内存管理
- 内存模型(Java Memory Model, JMM):JMM定义了线程之间如何通过内存进行交互的规则,特别是如何确保操作的可见性、原子性和有序性。
- 垃圾收集(Garbage Collection, GC):Java通过垃圾收集机制自动管理堆内存,回收无用对象以释放空间。常见的垃圾收集器包括Serial、Parallel、CMS和G1等。
总结
Java内存区域划分的目的是为了提高性能和管理复杂性。程序计数器、Java虚拟机栈和本地方法栈是线程私有的,而堆和方法区是线程共享的。Java内存模型保证了多线程环境下的内存可见性和指令重排序的正确性,垃圾收集器则帮助管理堆内存,提升程序运行效率和稳定性。
程序计数器为啥是私有的
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
- 如果执行的是
native方法,那么程序计数器记录的是undefined地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为啥是私有的
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的
native方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
一个Java应用配了最大堆和最小堆都是2个G,但运行一段时间后通过top发现其内存已经占了4个G,什么原因?
Java应用使用的内存不仅仅包括堆内存,还可能涉及以下几个方面,会导致内存占用超过配置的堆大小:
- 非堆内存:
- Java虚拟机还会为方法区和代码缓存分配非堆内存,主要存储类信息、常量池、JIT编译后的代码等。
- 通过参数
-XX:MaxMetaspaceSize可以限制方法区的最大内存占用。
- 本地内存分配:
- Java应用可能会通过JNI(Java Native Interface)或本地库进行本地内存的分配,这些内存不计入堆内存,但依然占用系统内存。
- 如果使用了大量的JNI调用或本地资源,比如图形库、数据库驱动等,可能会导致额外的内存消耗。
- 线程栈:
- 每个Java线程都会有自己的栈内存,其大小可以通过
-Xss参数设置。大量线程或较大的栈大小会消耗更多内存。 - 如果应用中有很多线程,线程栈的内存消耗也会很显著。
- 每个Java线程都会有自己的栈内存,其大小可以通过
- 内存碎片:
- 在堆和非堆内存区域中,随着内存的分配和释放,可能会产生内存碎片。这些碎片化的内存可能会导致实际使用的内存远超分配给堆的大小。
建议的排查步骤
- 使用
jmap、jstat或jvisualvm工具来分析堆外内存的使用情况。 - 检查是否有大量线程创建,或者栈空间设置得较大。
- 使用
-XX:+PrintFlagsFinal来检查JVM参数的实际配置,看看是否有额外的非堆内存配置。
OOM和GC频繁
内存溢出(OutOfMemory,简称OOM)和GC频繁问题通常是由内存管理不当或资源消耗过大引发的。
常见OOM类型:
java.lang.OutOfMemoryError: Java heap space:堆内存溢出,通常由于对象过多或长时间未被释放。java.lang.OutOfMemoryError: PermGen space(JDK 8以前)或java.lang.OutOfMemoryError: Metaspace(JDK 8及以后):通常由类加载过多、动态代理生成类过多等引起。java.lang.OutOfMemoryError: GC overhead limit exceeded:通常是GC频繁发生,且每次回收的内存不足,表明应用内存压力较大。
排查OOM异常的方法:
- 查看堆转储文件(Heap Dump):
使用-XX:+HeapDumpOnOutOfMemoryError参数启动Java应用,当发生OOM时自动生成Heap Dump文件。可以使用工具如MAT(Memory Analyzer Tool)分析堆内存中对象的分布,查看是否有内存泄漏或者某类对象占用了大量内存。 - 分析代码中的内存泄漏:
检查是否存在对对象的错误持有(如静态集合长时间持有对象引用),或者对象生命周期不当管理,导致垃圾回收器无法及时回收内存。
排查GC频繁的问题:GC(垃圾回收)频繁,通常意味着Java应用的内存回收压力较大。
排查GC频繁的方法:
-
监控GC日志:开启GC日志,查看GC的频率和每次回收的效果。
- GC的频率是否过高。
- 每次GC回收的内存是否充足。
- 是否有Full GC频繁发生(通常Full GC是比较耗时的,且频繁发生时会影响应用性能)。
-
选择合适的GC算法:不同的GC算法适合不同类型的应用场景。
- Serial GC:单线程垃圾回收,适合单核服务器或对暂停不敏感的应用。
- Parallel GC:多线程垃圾回收,适合多核服务器。
- CMS(Concurrent Mark-Sweep)GC:适合低停顿需求的应用,但会增加内存碎片。
- G1(Garbage First)GC:JDK 9以后默认GC,适合低停顿且大堆内存的应用。
- 可以通过设置
-XX:+UseG1GC等参数来选择不同的GC算法,测试不同算法下GC性能的差异。
-
调整堆内存大小
合理设置堆内存大小,避免堆过小导致频繁GC,或堆过大导致Full GC时间过长。相关参数包括:-Xms:初始堆内存大小。-Xmx:最大堆内存大小。-XX:NewRatio:老年代与新生代的比例。-XX:SurvivorRatio:Eden区和Survivor区的比例。
-
排查应用层面问题
- 优化代码
- 避免不必要的对象创建,减少内存分配的压力。
- 合理使用缓存,防止内存中存放过多无效的对象。
- 确保定时清理无用的对象引用,避免内存泄漏。
- 优化数据库连接:检查是否有未关闭的数据库连接或其他IO资源,导致内存无法及时释放。
- 监控线程:检查线程池的使用情况,避免线程泄漏或创建过多线程导致内存压力。
- 监控工具的使用
- JVisualVM:可以实时监控Java应用的内存使用情况、GC活动和线程状态。
- jstat:可以查看GC相关的统计数据,帮助分析GC频率和效果。
- JConsole:可以通过JMX监控应用的内存、CPU、线程等信息。
- 调整JVM参数:通过调整JVM参数来优化内存和GC的表现。例如:
-XX:+UseStringDeduplication:减少重复字符串的内存占用(仅在G1 GC下有效)。-XX:+UseCompressedOops:在64位JVM中压缩对象指针,减少内存占用。
什么操作会触发堆的初始化
堆的初始化主要依赖于JVM的启动和对象的动态创建,通常这些操作会在程序的初始阶段就发生。
- JVM启动时初始化堆:
- 当Java程序启动时,JVM会根据启动参数(如
-Xmx和-Xms)为堆分配内存空间。JVM使用默认堆大小或者根据指定的堆大小来初始化堆。 -Xms:设置初始堆大小。-Xmx:设置堆的最大大小。
- 当Java程序启动时,JVM会根据启动参数(如
- 第一次创建对象时:
- 在程序运行时,当你第一次使用
new操作符创建对象时,堆空间会被分配并初始化。堆是用来存储动态分配的对象的,所以当你实例化一个对象(如new MyClass())时,堆会分配内存空间来存储该对象。
- 在程序运行时,当你第一次使用
- 类加载时触发静态字段初始化:
- Java中类的静态字段如果是对象引用(例如
static MyClass obj = new MyClass();),则会在类加载时触发堆的初始化。类加载的过程中,JVM会为这些静态字段分配堆空间。
- Java中类的静态字段如果是对象引用(例如
- 垃圾回收触发时:
- 虽然垃圾回收本身并不会直接初始化堆,但它会影响堆的管理。例如,当堆中的对象被回收时,JVM会根据当前的内存需求可能触发堆的扩展或收缩。垃圾回收可能会清理堆中不再使用的对象,从而影响堆的内存分配。
Java的各种变量存放在哪里
- 栈:
- 局部变量:方法中的基本数据类型(如
int,char,boolean等)和对象引用变量(如ArrayList的引用)都存储在栈中。栈的特点是遵循“后进先出”(LIFO)的原则,方法调用结束后栈帧会被销毁,栈上的数据随之消失。
- 局部变量:方法中的基本数据类型(如
- 堆:
- 对象:所有通过
new关键字创建的对象(如new ArrayList<>())都存储在堆中。堆内存用于动态分配内存空间,生命周期比栈长,直到没有任何引用指向它们时才会被垃圾回收器回收。
- 对象:所有通过
- 方法区:
- 类的元数据:存储类的结构信息,如类名、方法、字段、常量池、静态代码块等。方法区在JVM中也是堆的一部分。
- 常量池:
- 字符串常量和基本类型常量:例如
String s = "hello";中的字符串常量hello就存储在常量池中。常量池也是方法区的一部分。
- 字符串常量和基本类型常量:例如
ArrayList的存储
当你创建一个 ArrayList 时,例如 ArrayList<String> list = new ArrayList<>();:
list引用:存储在栈中。ArrayList对象本身:存储在堆中。这个对象包含实际存储元素的数组。- 数组中的元素:如果是对象类型(如
String),这些对象也存储在堆中。
final修饰的变量
final 修饰的变量根据它的具体类型(静态变量、实例变量、局部变量)存储位置会有所不同。
final修饰的静态变量:存储在方法区的静态存储区中。final修饰的实例变量:存储在堆内存中的实例对象中。final修饰的局部变量:如果是方法内部的局部变量,它将存储在栈内存中。final变量一旦被初始化后,其值不能再修改。对于基本类型,它存储的是值本身;而对于引用类型,它存储的是引用地址(即引用不可变,但对象的内部状态可以改变)。
所有的对象都存放在堆中吗
几乎所有通过 new 关键字创建的对象都存储在堆中。然而,有一些特殊情况可能会存储到到其他存储区域:
- 字符串常量池:
- 字符串字面量(如
"hello")存储在方法区的常量池中,不在堆中。如果你使用String s = "hello";,字符串"hello"就存储在常量池中,并且如果存在相同的字面量,Java不会再创建新的字符串对象,而是直接引用常量池中的现有对象。 - 如果使用
new String("hello"),则会在堆中创建一个新的String对象。
- 字符串字面量(如
- 栈上分配(逃逸分析):理论上,Java虚拟机可以通过逃逸分析技术将某些不会逃逸出方法的对象直接分配在栈上,而不是堆上。这样,当方法结束时,对象会随栈帧一起销毁。然而,这种优化是由JVM自动完成的,程序员无法直接控制。
- 方法区:
- 方法区中存储类的元数据和静态变量等,但不存储通过
new创建的对象。静态变量的值(如果是对象)会存储在堆中。
- 方法区中存储类的元数据和静态变量等,但不存储通过
成员变量/全局变量/局部变量存放在哪里
- 成员变量(实例变量)存储在堆内存中,每个对象都有自己的一份成员变量。
- 静态变量(类变量)存储在方法区(或元空间)中,是类级别的变量,共享给所有实例。
- 局部变量存储在栈内存中,属于方法调用帧的一部分。
垃圾回收机制✅
垃圾回收机制定义
垃圾回收(GC)是内存管理的核心组成部分,它负责自动回收不再使用的内存空间。在Java中,程序员不需要手动释放对象占用的内存,一旦对象不再被引用,垃圾回收器就会在适当的时机回收它们所占用的内存。这样可以避免内存泄漏和野指针,从而大大减轻了程序员的负担,也使得Java成为一个相对安全、易于开发的编程语言。
垃圾回收的基本步骤分两步:
- 查找堆内存中不再使用的对象(GC判断策略)
- 释放这些对象占用的内存(垃圾回收算法)
接下来我将从堆空间的结构、内存分配和回收原则、GC判断策略、垃圾回收算法、常见垃圾回收器这五大方面来介绍垃圾回收机制。
堆空间结构
垃圾回收主要发生在堆内存中,JDK1.7之前,堆通常被分为新生代、老年代和永久代三部分。JDK1.8之后,永久代被元空间取代,堆内存结构变为新生代(Eden,S0,S1)、老年代和元空间。
内存分配和回收原则
- 对象优先在新生代分配内存,新生代内存分为 Eden 区、Survivor0 区和 Survivor1 区。当 Eden 区内存不足时,虚拟机会触发一次 Minor GC,将 Eden 区中存活的对象复制到 Survivor0 区,然后清空 Eden 区。Survivor0 和 Survivor1 交替使用,当其中一个区域内存不足时,虚拟机会将存活的对象复制到另一个区域,然后清空原区域。当对象在新生代经历多次复制仍然存活时,会被晋升到老年代。
- 大对象直接进入老年代。如果对象的大小超过了新生代的某个阈值,虚拟机会直接将这个对象分配到老年代。
- 长期存活的对象将进入老年代。虚拟机会给每个对象定义一个年龄计数器,对象在新生代每经历一次 Minor GC,年龄加1。当对象的年龄达到一定阈值(默认 15)时,虚拟机会将这个对象晋升到老年代。
死亡对象判断策略
- 引用计数法:引用计数法是最简单的垃圾回收算法,它通过引用计数器来判断对象是否存活。当对象被引用时,计数器加1;当引用失效时,计数器减1。当计数器为0时,说明对象不再被引用,可以被回收。但引用计数法无法解决循环引用的问题,因此在实际应用中很少使用。
- 循环引用:两对象无外界引用,但因互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
- 可达性分析法:可达性分析法是现代垃圾回收算法的主流。它通过一系列的“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索过程所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是没有被引用,需要被回收。
- 这些对象可以用作 GC Roots:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
- 这些对象可以用作 GC Roots:
对象被标记为不可达,就代表一定会被回收吗?
不一定。真正宣告一个对象死亡至少要经过两次标记过程。
GC触发条件
垃圾回收主要基于以下两种情况触发:
- Young GC/Minor GC:年轻代的 Eden 区满时触发,回收年轻代的对象。
- Full GC/Major GC:老年代空间不足时或显式调用
System.gc()时触发,回收老年代和年轻代的对象。
垃圾回收算法
- 标记-清除算法:标记-清除算法是最基础的垃圾回收算法,分为标记和清除两个阶段。标记阶段遍历所有对象,标记出所有存活的对象;清除阶段清除所有未标记的对象。
- 存在两个问题: 1. 效率问题:标记和清除两个过程效率都不高。 2. 空间问题:标记清除后会产生大量不连续的内存碎片,导致大对象无法分配内存。
- 复制算法:复制算法将内存分为两块,每次只使用其中一块。当这一块内存用完后,将存活的对象复制到另一块内存中,然后清除当前内存。这样可以避免内存碎片化问题。这种算法实现简单,运行高效,不会产生内存碎片。
- 存在两个问题:1. 内存利用率低,只有一半的内存可以使用。2. 不适用于老年代,因为老年代存活对象较多,复制成本高。
- 标记-整理算法:标记-整理算法是根据老年代的特点提出的一种标记算法,它在标记阶段完成后,将存活的对象向一端移动,然后清除边界外的对象。这样可以避免内存碎片化问题。
- 问题:整理的过程效率也不高。
- 分代收集算法:分代收集算法是目前主流的垃圾回收算法,它根据对象存活周期的不同将内存划分为不同的区域,每个区域采用适合的垃圾回收算法。一般将堆内存划分为新生代和老年代,新生代使用标记-复制算法,老年代使用标记-整理算法。
垃圾回收器
默认收集器:
JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
JDK 9 ~ JDK20: G1(Garbage-First)收集器
- Serial 收集器:Serial 收集器是最基础的垃圾收集器,它是单线程的收集器,只会使用一个线程进行垃圾回收,在进行垃圾收集工作的时候必须暂停其他所有的工作线程("Stop The World"),直到它收集结束。Serial 收集器适用于单核处理器和小内存的环境。使用标记-清除(Mark-Sweep)和复制算法。
- ParNew 收集器:ParNew 收集器是 Serial 收集器的多线程版本,它可以使用多个线程进行垃圾回收。ParNew 收集器适用于多核处理器和多线程环境。使用复制算法。
- Parallel Scavenge 收集器:Parallel Scavenge 收集器是一种以获取最大吞吐量为目标的收集器,它使用多线程进行垃圾回收,可以充分利用多核处理器的优势。Parallel Scavenge 收集器适用于对吞吐量要求较高的应用。使用复制算法。
- Serial Old 收集器:Serial Old 收集器是 Serial 收集器的老年代版本,它使用单线程进行垃圾回收,适用于单核处理器和小内存的环境。使用标记-压缩算法。
- Parallel Old 收集器:Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,它使用多线程进行垃圾回收,适用于多核处理器和多线程环境。使用标记-压缩算法。
- CMS 收集器:CMS 收集器是一种以获取最短回收停顿时间为目标的收集器,它使用多线程进行垃圾回收,可以显著减少垃圾回收的停顿时间。CMS 收集器适用于对停顿时间要求较高的应用。使用标记-清除算法,主要目标是缩短垃圾回收的停顿时间,分为初始标记、并发标记、重新标记、并发清除四个阶段。
- G1 收集器:G1 收集器是一种面向服务端应用的垃圾收集器,它将堆内存划分为多个区域,每个区域可以根据垃圾回收的需要进行独立回收。G1 收集器适用于大内存、多核处理器和对停顿时间要求较高的应用。使用标记-清除和区域分代相结合的方式,堆内存被划分为多个区域,回收时首先处理垃圾最多的区域,以尽量缩短停顿时间。
- ZGC 收集器:ZGC 收集器是一种低延迟的垃圾收集器,它可以在几毫秒内完成垃圾回收,适用于对停顿时间要求极高的应用。使用标记-压缩算法。
为什么要进行垃圾回收
- 防止内存泄漏:手动管理内存容易导致内存泄漏,而GC可以自动回收不再使用的对象,防止内存泄漏的发生。
- 提高开发效率:程序员不再需要关心内存释放的问题,可以更加集中精力在业务逻辑的实现上。
- 系统性能和稳定性:通过有效的垃圾回收策略,可以保证系统的性能和稳定性。
CMS垃圾回收器流程
CMS(Concurrent Mark-Sweep)收集器是一种以最小化停顿时间为目标的垃圾收集器,适用于对响应时间要求较高的应用。它的垃圾清理流程分为四个主要阶段:
- 初始标记(Initial Mark)
- 目的:标记所有直接可达的对象(GC Roots直接引用的对象)。
- 特点:该阶段是“Stop The World”事件,需要暂停所有应用线程,但时间非常短,因为只标记与GC Roots直接关联的对象。
- 并发标记(Concurrent Mark)
- 目的:从初始标记阶段标记的对象开始,进行整个堆中的对象图遍历,标记出所有可达的对象。
- 特点:该阶段是并发的,不会暂停应用线程。应用程序可以继续运行,GC线程与应用线程并发执行。
- 重新标记(Remark)
- 目的:修正并发标记阶段由于应用程序继续运行而产生的标记变化,确保所有存活对象都被正确标记。
- 特点:这是一个“Stop The World”事件,需要短暂暂停应用线程,但时间比初始标记稍长,因为需要处理更多的标记变化。
- 并发清除(Concurrent Sweep)
- 目的:清理掉标记为不可达的对象,回收它们占用的内存。
- 特点:该阶段是并发的,和应用线程一起运行,不会影响应用的执行。
CMS收集器的特点
- 碎片化问题:由于CMS使用的是标记-清除算法,它不会对堆进行压缩,可能会导致内存碎片化,从而引发“浮动垃圾”问题(在并发清除阶段,新产生的垃圾无法立即清理)。
- 并发模式失效:如果在清理过程中,内存分配的速度快于垃圾回收的速度,CMS可能会触发“Concurrent Mode Failure”,此时会退回到Serial Old收集器进行垃圾回收,导致长时间的停顿。
优点与适用场景
- 优点:CMS最大的优点是可以显著减少应用的停顿时间,非常适合需要低延迟的应用场景。
- 适用场景:CMS适用于需要快速响应的服务端应用,例如高并发的Web服务、实时系统等。
Full/Young GC区别
Young GC(Minor GC)
- 作用范围:只回收 年轻代(Young Generation) 中的内存。
- 触发条件:当年轻代中的 Eden 区 填满时触发,回收 Eden 区中的无用对象,并将存活对象移动到 Survivor 区。
- 回收算法:一般使用 复制算法(Copying Algorithm),将存活对象从 Eden 区复制到 Survivor 区,或者将存活对象晋升到老年代(Old Generation)中。
- 执行速度:因为年轻代对象大多数是短生命周期的,所以回收速度较快,回收频繁。
- 影响:对应用的暂停时间较短(STW,Stop the World),回收效率高。
Full GC(Major GC)
- 作用范围:回收整个堆内存,包括 年轻代 和 老年代,以及 元空间(Metaspace)。
- 触发条件:
- 老年代空间不足。
- 显式调用
System.gc()。 - 为了腾出足够的空间将存活的年轻代对象晋升到老年代时,发现老年代空间不足。
- 永久代(PermGen)或元空间(Metaspace)溢出时。
- 老年代对象经过多次 Young GC 仍然存活,无法移动到 Survivor 区。
- 回收算法:通常使用 标记-清除(Mark-Sweep) 或 标记-整理(Mark-Compact) 算法,因为老年代中对象存活时间长,不适合复制算法。
- 执行速度:Full GC 通常比 Young GC 慢得多,因为涉及到更多的对象和整个堆内存的清理。
- 影响:Full GC 会导致较长的应用暂停时间,可能对性能敏感的应用造成较大的影响。
Full GC详细流程
Full GC 是对整个堆空间进行清理,包括年轻代和老年代,其过程相对复杂,通常分为以下几个步骤:
- 标记阶段
- 根可达性分析:Full GC 首先从一组称为 GC Roots 的对象开始进行根可达性分析。GC Roots 包括线程栈中的局部变量、静态变量、活动线程、类加载器等。
- 标记存活对象:遍历所有可达的对象,标记它们为活跃的,表示这些对象不能被回收。
- 清除阶段
- 回收不可达对象:标记阶段结束后,JVM 会清理所有没有被标记为存活的对象,释放这些对象所占用的内存。
- 问题:标记-清除阶段可能会导致内存碎片化,因为被清理的对象可能不连续。
- 整理阶段(仅在需要时执行)
- 整理内存空间:在一些回收器中(例如使用标记-整理算法的垃圾回收器),Full GC 不仅会清理无用的对象,还会将存活的对象整理到堆的一侧,以消除内存碎片。
- 对象压缩:存活对象会被压缩到堆的一端,腾出连续的内存空间,提高后续内存分配的效率。
- 元空间清理:Full GC 还会回收 元空间 中的类元数据,尤其是在类卸载后,JVM 需要回收被卸载类的相关元数据。
- 触发 Finalize 方法(如果有):如果对象有
finalize()方法且没有被标记为存活,那么 GC 在执行回收前会调用该对象的finalize()方法,给对象最后一次自救的机会。不过,在实际编程中应尽量避免使用finalize()方法,因为它会延迟对象的回收,并增加 GC 的复杂性。
Full GC 的优化建议
- 调优 GC 参数:通过合理配置堆大小、年轻代与老年代的比例、GC 回收器等,可以降低 Full GC 的频率。
- 减少老年代内存分配:减少对象长期存活的时间,避免对象过早晋升到老年代。例如,通过调节对象的晋升阈值。
- 使用适合的 GC 算法:如果应用对暂停时间敏感,建议使用 G1 GC 或 CMS GC,而不是 Full GC 次数较多的 Serial 或 Parallel GC。
Young GC详细流程
Young GC是 JVM 垃圾回收的一部分,专门负责回收堆内存中的 年轻代。年轻代中的对象大多是生命周期较短的新创建对象,因此 Young GC 的回收频率较高,通常在 Eden 区满时 触发。
Young GC 主要使用 复制算法,该算法具有高效的回收性能。其具体步骤如下:
- 标记存活对象
- 根可达性分析:Young GC 首先从 GC Roots(如栈帧中的引用、静态变量等)开始,标记所有从 GC Roots 可达的对象为存活对象。
- GC Roots 的引用遍历包括:
- 当前执行线程栈中的所有局部变量和方法参数。
- 各种类的静态成员。
- JNI 引用和当前执行线程。
- GC Roots 的引用遍历包括:
- 标记存活对象:遍历 GC Roots,找到所有直接或间接被引用的对象,将它们标记为存活对象。
- 根可达性分析:Young GC 首先从 GC Roots(如栈帧中的引用、静态变量等)开始,标记所有从 GC Roots 可达的对象为存活对象。
- 回收 Eden 区
- 清理 Eden 区:在完成标记后,Eden 区中没有被标记为存活的对象将被视为垃圾,所有这些对象的内存将被释放。
- 复制存活对象:Eden 区中被标记为存活的对象将被复制到其中一个 Survivor 区(S0 或 S1)。
- 处理 Survivor 区
- 复制 Survivor 区存活对象:处理上一次 GC 存放在当前 Survivor 区中的对象。这些对象也会被再次进行根可达性分析,并标记存活对象。存活的对象会被复制到另一个 Survivor 区(从 S0 到 S1,或从 S1 到 S0)。
- 晋升到老年代:如果某些对象经过多次 Young GC 后仍然存活,并且达到了设定的 晋升阈值,这些对象将会被移动到老年代。通常,晋升阈值是指对象在 Survivor 区中经过的 GC 次数,当次数达到阈值后,对象就会被晋升到老年代。
- 晋升阈值是可配置的,默认情况下,对象在 Survivor 区中经历 15 次 GC 后被晋升到老年代。可以通过 JVM 参数
-XX:MaxTenuringThreshold来调整这个值。
- 晋升阈值是可配置的,默认情况下,对象在 Survivor 区中经历 15 次 GC 后被晋升到老年代。可以通过 JVM 参数
- 切换 Survivor 区
- Survivor 区交替使用:在每次 Young GC 结束后,两个 Survivor 区会进行交换。当前的 Survivor 区 S0 中的存活对象会被复制到 S1,下次 GC 时,S1 就会成为复制目标区域,S0 则会被清空。
Young GC 调优
- Eden 和 Survivor 区比例:可以通过 JVM 参数
-XX:SurvivorRatio来调整 Eden 区与 Survivor 区的比例。默认情况下,Eden 区较大,两个 Survivor 区较小,这样可以减少对象在 Survivor 区之间来回复制的次数。 - 晋升阈值:可以通过
-XX:MaxTenuringThreshold来调整对象晋升到老年代的阈值。如果应用程序中对象的生命周期较长,可以适当提高晋升阈值,减少老年代的内存压力。 - 并行 GC:使用 Parallel GC 或 G1 GC 可以减少 STW 的时间,提高垃圾回收的效率。
强引用/软引用/弱引用/虚引用✅
https://blog.csdn.net/u013718071/article/details/134789666
Java中的引用类型主要分为强引用、软引用、弱引用和虚引用,它们之间的区别主要体现在垃圾回收的行为上。
- 强引用(Strong Reference):这是使用最普遍和默认的引用类型。如果一个对象具有强引用,那么垃圾回收器就永远不会回收它。当内存空间不足,Java虚拟机宁愿抛出
OutOfMemoryError错误,也不会回收这种对象。 - 软引用(Soft Reference):软引用是用来描述一些还有用但并非必需的对象。只有当JVM认为内存不足时,才会去剔除这些基于软引用的对象。在Java中,可以用
SoftReference类来实现软引用。 - 弱引用(Weak Reference):弱引用则是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收发生为止。当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在Java中,可以用
WeakReference类来实现弱引用。 - 虚引用(Phantom Reference):虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与其他几种引用的一个区别在于:虚引用必须和引用队列(
ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,将这个虚引用加入到与之关联的引用队列中。
以上四种引用类型,强度依次递减:强引用最强,虚引用最弱。在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
对象的创建过程(5步)
- 类加载检查。虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 初始化零值。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头。初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行
init方法。在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
对象在内存中的布局可以分为 3 块区域:
- 对象头(
Header):- 标记字段(
Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。 - 类型指针(
Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 标记字段(
- 实例数据(
Instance Data): 对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。 - 对齐填充(
Padding): 不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
对象的访问定位
建立对象就是为了使用对象,Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
- 句柄。如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
- 直接指针。如果使用直接指针访问,reference 中存储的直接就是对象的地址。
两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
如何判断一个类是无用的
类需要同时满足下面 3 个条件才能算是 “无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader已经被回收。 - 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以(不是必然)对满足上述 3 个条件的无用类进行回收
类的生命周期
加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。
其中验证、准备、解析 3 部分统称为连接。
类加载过程✅
分三步:加载、连接(验证、准备、解析)、初始化。
加载
加载使用类加载器完成。类加载器有很多种,当想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定。加载主要分下面三步:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
Class对象,作为方法区这些数据的访问入口。
加载和连接阶段的部分动作(如验证)是交叉进行的。加载还没结束,连接可能就已经开始了。
验证
连接的第一步,确保 Class 文件的字节流中包含的信息符合规范,并且不会危害虚拟机自身的安全。主要包括四个阶段:
- 文件格式验证:验证字节流是否符合 Class 文件格式规范。
- 元数据验证:对字节码描述的信息进行语义分析,以保证其符合 Java 语言规范。
- 如验证类是否有父类
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 如验证函数的参数是否正确
- 符号引用验证:确保解析动作能正确执行。
- 如验证该类要使用的其他类、方法是否存在。
准备
连接的第二步,准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区(Java 7 之前分配在永久代,Java 8 之后移动到堆中)中分配。
- 进行内存分配的仅包括类变量(被
static修饰)而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 - 初始值是数据类型的默认值,如 0、null 等。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。主要包括类或接口、字段、类方法、接口方法、方法类型等。
初始化
初始化阶段是执行初始化方法 <clinit>()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
在 JVM 生命周期内,由 JVM 自带的类加载器加载的类(如BootstrapClassLoader、ExtClassLoader、AppClassLoader )是不会被卸载的。但是由自定义的类加载器加载的类是可能被卸载的。
类加载器
类加载器是一个负责加载类的对象。每个 Java 类都有一个引用指向加载它的 ClassLoader。数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。
JVM内置三个重要的类加载器:
BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库。
ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
双亲委派模型
双亲委派模型是一种类加载器的层次结构,每个类加载器都有一个父类加载器。当一个类加载器收到加载类的请求时,它会先将请求委派给父类加载器,直到最顶层的类加载器。只有当父类加载器无法加载该类时,子类加载器才会尝试加载。
优点
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果需要打破双亲委派模型,需要自定义类加载器,并重写 loadClass 方法。
JVM相关命令
jstat -class # 监视类加载、卸载总数、总空间以及类装载所耗费的时间等信息。
jstat -gc # 监视Java堆内存的使用情况,包括新生代(Eden区、两个Survivor区)和老年代的使用量、垃圾收集次数及时间等信息。
jstack # 生成JVM当前时刻线程的快照,主要用于定位线程出现长时间停顿的原因如死锁、死循环等。
jmap -histo:live # 打印每个Java类存货对象的数量、内存大小以及类层次结构。为了计算存活对象,JVM会执行一次全堆的垃圾回收,以识别出不可达对象。
IO
IO(Input/Output)即输入/输出,数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。
IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
Stream流中的map和flatMap区别
map- 作用:
map函数用于对流中的每一个元素执行一个给定的函数,并将结果作为新的流返回。这意味着,如果原始流中有n个元素,那么处理后的流也将有n个元素,只不过这些元素是原始元素经过函数处理后的结果。 - 返回值:
map函数的返回值是一个新的流,这个流中的元素是原始流中每个元素经过函数转换后的结果。重要的是,如果函数返回的是一个集合或数组,那么这个集合或数组会被视为流中的一个单独元素。 - 应用场景:需要对流中的每个元素进行一对一的转换,并且不需要改变流中元素的数量时,可以使用
map。
- 作用:
flatMap- 作用:
flatMap函数也用于对流中的每一个元素执行一个给定的函数,但与map不同的是,该函数必须返回一个流。然后,flatMap会将所有这些流“扁平化”为一个单一的流。这意味着,如果原始流中的某个元素经过函数处理后产生了m个元素,那么这些元素都会被添加到最终的流中,从而导致最终流的元素数量可能与原始流不同。 - 返回值:
flatMap函数的返回值是一个扁平化后的新流,这个流包含了原始流中所有元素经过函数处理并扁平化后的结果。 - 应用场景:需要对流中的每个元素执行一对多的转换,或者需要处理嵌套集合(如二维数组或二维集合类)时,用
flatMap。它能够将嵌套的结构扁平化,从而方便地进行后续操作。
- 作用:
Java为什么要分字节流和字符流
Java 分为字符流和字节流是为了更好地处理不同类型的数据、简化程序编写和提高代码的可读性与可维护性。
- 字符流自动处理字符编码问题,非常适合文本数据;
- 字节流则适合处理所有类型的文件,特别是非文本数据如图片、视频、音频等。
字节流和字符流区别
- 字节流处理的基本单位是字节(8 bit),字符流处理的基本单位是字符(16 bit)。
- 字节流可以处理所有类型的数据,包括二进制文件(如图像、视频、音频)以及文本文件,字符流专门用于处理文本数据(字符数据),适合处理各种语言的文本文件。
- 字节流不进行字符编码转换,直接读写原始的二进制数据,字符流自动进行字符编码和解码,读写时可以处理不同的字符集(如UTF-8、UTF-16、ISO-8859-1等)。
IO设计模式总结
- 装饰器模式:可以在不改变原有对象的情况下拓展其功能。可以对原始类嵌套使用多个装饰器类。
- 适配器模式:主要用于接口互不兼容的类的协调工作,适配器分为对象适配器和类适配器,类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
- 工厂模式:定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。
- 观察者模式:定义了对象之间的一对多依赖,这样一来,当一个对象改变状态,依赖它的对象都会收到通知并自动更新。
装饰器模式和适配器模式区别
- 装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
- 适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作,当调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。
3种常见的IO模型
- BIO(
Blocking IO):同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。- 优点:编程简单,代码易于理解。
- 缺点:并发性能低,每个连接都需要独立的线程进行处理,线程资源消耗大。
- 适用场景:适用于客户端数量较少、并发量较低的场景。
- NIO(
Non-blocking IO):同步非阻塞 IO 模型中,应用程序发起 read 调用后,会立即返回,不会阻塞,应用程序需要不断轮询内核,直到数据准备好。轮询是通过Selector机制轮询通道上是否有事件发生,处理就绪的通道,能够高效地处理大量的连接。- 优点:单线程可以处理多个连接,提高并发性能。
- 缺点:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
- 适用场景:适用于高并发、需要处理大量连接的场景,比如聊天室、实时通信应用。
- AIO(
Asynchronous IO):异步非阻塞 IO 模型中,应用程序发起 read 调用后,不会阻塞,内核会在数据准备好后通知应用程序(通过回调机制实现)。- 优点:异步 IO 模型的优势在于 IO 操作完全由操作系统来完成,应用程序只需要在数据准备好时得到通知,不需要不断轮询内核。
- 缺点:AIO 是 Java 7 引入的,不太常用,且实现较为复杂。
- 适用场景:适用于高并发场景,减少了轮询的开销。
NIO 中的 Selector
Selector 是 NIO 的核心组件之一,用于检测多个 Channel 上的事件。它允许单个线程管理多个连接,从而避免了每个连接都占用一个线程的情况,提升了 I/O 操作的并发性能。
关键步骤包括:
- 创建 Selector:使用
Selector.open()创建一个选择器。 - 注册 Channel:将通道注册到
Selector上,使用channel.register(selector, ops),其中ops表示感兴趣的事件(如SelectionKey.OP_READ,SelectionKey.OP_WRITE)。 - 轮询事件:通过
selector.select()来轮询是否有通道准备好读写操作。 - 处理事件:当
Selector监听到事件后,处理就绪的通道。
这种方式减少了线程的阻塞时间,并能高效处理大量的并发请求。
select/poll/epoll
select、poll 和 epoll 是三种用于多路复用 I/O(I/O multiplexing)的机制,用于处理多个文件描述符(file descriptors, FD),使得应用程序可以同时监控多个 I/O事件(如读写操作)。这三者的主要区别在于它们的性能、支持的文件描述符数量和底层数据结构。
select
select 是最早的 I/O 多路复用机制,使用起来较为简单,但存在性能问题。
数据结构
select的核心数据结构是三个位图(bitmask),每个位图用来存储不同的文件描述符集合:fd_set readfds: 监控是否可以读取数据。fd_set writefds: 监控是否可以写入数据。fd_set exceptfds: 监控异常情况(如带外数据)。
fd_set 本质上是一个固定大小的数组,其中每一位表示一个文件描述符的状态。文件描述符的最大值由宏 FD_SETSIZE 定义,一般是 1024 个。
工作流程
- 应用程序通过设置
fd_set中感兴趣的文件描述符(FD_SET(fd, &readfds)等)来指定要监控的文件描述符。 - 调用
select函数,内核会在所有文件描述符上轮询,查看哪些文件描述符有事件发生。 - 当
select返回时,应用程序需要手动遍历所有文件描述符来判断哪个文件描述符已准备好。
缺点
- 性能问题:每次调用时,内核必须遍历所有文件描述符进行检查,复杂度为 O(n),当文件描述符数量多时性能下降明显。
- 文件描述符数量有限:由于
fd_set的大小是固定的,最多只能处理 1024 个文件描述符。
poll
poll 是 select 的改进版本,解决了一些 select 的限制。
数据结构
-
poll使用一个动态数组struct pollfd来表示文件描述符集合,每个元素都是一个结构体,包含了文件描述符以及感兴趣的事件和实际发生的事件:struct pollfd { int fd; // 文件描述符 short events; // 期望监听的事件 short revents; // 实际发生的事件 };
工作流程
- 应用程序填充
struct pollfd数组,指定需要监听的文件描述符及事件。 - 调用
poll函数,内核会在所有文件描述符上进行轮询,并更新结构体中的revents字段以指示哪些事件已发生。 poll返回后,应用程序需要遍历pollfd数组来查看具体哪些文件描述符发生了事件。
优点
- 支持更大数量的文件描述符:
poll没有fd_set的限制,文件描述符数量只受系统最大打开文件数的限制。
缺点
- 性能问题依然存在:
poll同样需要每次轮询所有的文件描述符,时间复杂度为 O(n)。 - 重复创建和销毁数组:每次调用
poll时都要重新创建和销毁pollfd数组,带来额外的开销。
epoll
epoll 是 Linux 提供的一种高效的 I/O 多路复用机制,针对 select 和 poll 的性能瓶颈进行了优化,特别适用于大量并发连接的场景。
数据结构
epoll使用了红黑树和就绪链表来组织文件描述符。- 红黑树:用于管理所有被监控的文件描述符。每次
epoll_ctl添加、删除或修改一个文件描述符时,文件描述符会被插入或从红黑树中删除,红黑树可以保证操作复杂度为 O(log n)。 - 就绪链表:用于存储已经准备就绪的文件描述符。当某个文件描述符发生事件时,内核将其加入就绪链表,并且无需再次遍历所有文件描述符。
- 红黑树:用于管理所有被监控的文件描述符。每次
工作流程
- 通过
epoll_create创建一个epoll实例。 - 使用
epoll_ctl将文件描述符添加到红黑树中,指定感兴趣的事件。 - 调用
epoll_wait时,内核会通过就绪链表直接返回发生事件的文件描述符,避免遍历所有文件描述符。 - 当文件描述符准备好时,它们会被放入就绪链表,
epoll_wait直接返回已就绪的文件描述符。
优点
- 高效:
epoll使用事件驱动模型,只需关心发生事件的文件描述符,避免了像select和poll那样对所有文件描述符进行轮询。红黑树和就绪链表的使用使得操作效率更高。 - 不限制文件描述符数量:
epoll同样支持大量的文件描述符,且性能不随文件描述符数量线性增长。
缺点
epoll只在 Linux 系统上可用,其他操作系统不支持。
三者对比
| 特性 | select |
poll |
epoll |
|---|---|---|---|
| 数据结构 | 位图数组 | 动态数组 | 红黑树 + 就绪链表 |
| 文件描述符限制 | 1024 (固定) | 无限制 | 无限制 |
| 时间复杂度 | O(n) | O(n) | O(1) (就绪事件) |
| 添加/删除文件描述符 | 不支持 | 不支持 | O(log n) |
| 性能 | 随文件描述符数量增加而下降 | 随文件描述符数量增加而下降 | 高效,适用于大量连接 |
epoll 是在高并发场景下更高效的解决方案,而 select 和 poll 在小规模的文件描述符监控场景下仍然可以使用。
暂存这里
面试怎么考查 AI Coding 能力
工作需要,跟候选人聊AICoding,总结一些心得:一看做品,二看他写过的最牛逼的 Prompt。
首先:最主要是看作品,让他讲完整的VibeCoding经历,讲 + 演示作品。没作品的话至少也要讲一个完整的 Case,而且要完完全全带入产品和PMO视角,因为AICoding代替了体力劳动,一定会有大量的脑力思考从代码细节中抽离出来到产品设计、架构、方法和理念上,这部分脑力活动占据整个YOLO过程的至少一大半以上,所以按理说要对自己作品的形态、理念、目标、演示和完整性这些信息描述很清晰才对。
再者:剩下一半的精力是被死磕模型和提示词消耗掉的,看候选人都触碰到过哪些大模型的能力边界,即某个难题让模型解决,一次不行,二次不行,三次四次,最后终于成功,怎么引导大模型从失败走向成功的,但凡有过这类触碰过模型极限的场景,很容易沉淀出方法和经验,这种“摸高”的编程体验很重要!
MiniClaw — OpenClaw 核心架构最小复刻
项目介绍:用 ~2,700 行 Python 复刻 OpenClaw(43 万行 TS,30w+ Star Agent 框架)核心架构,沉淀为 16 模块 / 11 项核心原理的可运行最小实现,支持 CLI / Discord / HTTP API 三端接入,配套 179 单测 + 22 集成测试。
技术栈:Python asyncio、Anthropic / OpenAI / 阿里百炼 SDK、EventBus、ReAct、Function Calling、Prompt Engineering
- 自主性三角:Heartbeat 周期觉察 +
HEARTBEAT_OK静默协议避免告警疲劳,Cron 精确定时调度,Hooks(EventBus + 12 种事件 + 优先级 + 错误隔离)事件驱动响应,让 Agent 从"问答 chatbot"升级为"无人输入也能干活"的角色。 - Hub-and-Spoke Gateway:统一协调 Channel / Router / Agent 生命周期 / Heartbeat / Cron,新增 Channel 或 Agent 零侵入;JSONL 持久化 + 启动自动恢复最近 20 轮对话,进程崩溃重启不丢上下文。
- Agent Loop + 多 LLM 抽象:实现 Brain → Hands → Brain 工具循环(最多 10 轮防死循环);Brain 抽象层屏蔽 Anthropic
tool_result嵌套与 OpenAI 独立tool角色的协议差异,上层统一Message/ToolCall数据结构。 - Workspace 契约文件 + 自动反思:将人格 / 身份 / 记忆外置为 SOUL.md / IDENTITY.md / MEMORY.md,按固定顺序注入 system prompt 利用 Recency Bias 保证规则优先级;每 N 轮 LLM-as-Reflector 提炼用户画像追加到 MEMORY.md,
NOTHING_TO_REMEMBER协议过滤无效记忆。 - Skills 渐进式披露 + Context Compaction:Skill 索引始终可见、命中后才注入完整 body,开局节省数千 token;历史超 70% 阈值时 LLM 摘要替换旧消息,并对
tool_call/tool_result配对做安全分割,杜绝压缩后协议错位。 - Multi-Agent Spawn + 安全沙箱:Agent 间独立 workspace / Brain / 上下文,可通过
spawn_agent委派子任务;spawn_depth到上限直接不注册 spawn 工具,比工具内报错更优雅地防递归爆炸;Hands 对文件操作做路径遍历校验,杜绝越权读写。

浙公网安备 33010602011771号