[{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/categories/ai/","section":"Categories","summary":"","title":"AI","type":"categories"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"AI","type":"tags"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/tags/llm/","section":"Tags","summary":"","title":"LLM","type":"tags"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/tags/prompt/","section":"Tags","summary":"","title":"Prompt","type":"tags"},{"content":" Prompt Engineering 实用技巧，我总结了这些 # 用大模型一年多了，写了不少 Prompt。从最开始\u0026quot;帮我写个 xxx\u0026quot;的大白话，到现在能让模型按我要的格式和风格精确输出，走了不少弯路。把真正实用的技巧整理一下。\n零样本和少样本提示 # 零样本（Zero-shot）：直接给指令，不带示例。\n请将以下英文翻译成中文：Hello, how are you? 简单任务零样本就够了。但稍微复杂点，模型可能理解不了你想要什么格式。\n少样本（Few-shot）：给几个示例让模型\u0026quot;学\u0026quot;你要的格式和风格。\n请对以下评论进行情感分类。 评论：这家店太好吃了，下次还来！ 分类：正面 评论：等了一个小时才上菜，再也不来了 分类：负面 评论：东西还行，但价格偏贵 分类： 有了示例，输出格式一下就稳定了。我的经验：能给示例就给示例，虽然多费点 Token，但输出质量提升明显。特别是需要特定格式的时候，少样本几乎是必须的。\n角色设定 # 在 System Prompt 里给模型一个角色身份：\n你是一个有10年经验的 Java 后端工程师，擅长 Spring Boot 和微服务架构。 回答简洁专业，附带代码示例。 设了角色之后回答会更有深度、更有针对性。我审查代码的时候喜欢用\u0026quot;你是一个严格的代码审查专家\u0026quot;，效果比随口说\u0026quot;看看这代码有没有问题\u0026quot;好太多。\n角色设定还有个好处：限定回答范围。设定\u0026quot;你是 Java 专家\u0026quot;，它就不太会扯到 Python 方案去。\n思维链（Chain of Thought） # 这是个很强的技巧。让模型一步步推理，而不是直接给最终答案。\n最简单的方式——加一句\u0026quot;请一步步思考\u0026quot;：\n一个水池有两个进水管和一个出水管。 A管每小时进3吨，B管每小时进5吨。 出水管每小时出2吨。水池容量20吨，多久能装满？ 请一步步思考。 模型会把推理过程展开写出来，准确率高很多。不加这句的话，复杂推理容易直接算错。\n更高级的是 Few-shot CoT——在示例里就展示推理过程：\n问题：小明有5个苹果，吃了2个，又买了3个，现在几个？ 思考：开始5个，吃了2个剩3个，买了3个，3+3=6。 答案：6个 问题：（你的实际问题） 我写算法题的时候经常让模型先分析思路再写代码。比直接让它生成代码靠谱很多，debug 了几次才总结出来的经验。\n结构化输出 # 如果需要程序解析模型的输出，结构化格式很关键：\n请分析以下文本的情感，以 JSON 格式返回： { \u0026#34;sentiment\u0026#34;: \u0026#34;正面/负面/中性\u0026#34;, \u0026#34;confidence\u0026#34;: 0.0-1.0, \u0026#34;keywords\u0026#34;: [\u0026#34;关键词1\u0026#34;, \u0026#34;关键词2\u0026#34;] } 文本：今天天气真好，心情也不错，就是午饭有点难吃。 模型通常能很好地遵循 JSON 格式。OpenAI 还支持 JSON Mode，强制输出合法 JSON。\n在 LangChain4j 里可以直接映射成 Java 对象：\nrecord SentimentResult(String sentiment, double confidence, List\u0026lt;String\u0026gt; keywords) {} interface Analyzer { @UserMessage(\u0026#34;分析以下文本的情感：{{text}}\u0026#34;) SentimentResult analyze(@V(\u0026#34;text\u0026#34;) String text); } 框架会自动处理 JSON 解析和对象映射，写起来很舒服。\n日常使用的实战技巧 # 分享几个我用下来觉得真好用的：\n给明确的限制条件。\u0026ldquo;用 Java 17 语法\u0026rdquo;、\u0026ldquo;不要用第三方库\u0026rdquo;、\u0026ldquo;代码控制在 50 行以内\u0026rdquo;。限制越具体，输出越符合预期。\n让模型先问你问题。需求不太明确的时候说\u0026quot;在开始之前，请先问我几个问题来确认需求\u0026quot;。模型会主动澄清不确定的点，最终输出质量更高。\n复杂任务分步走。别把一大段需求一股脑丢给它。先设计方案，确认后再实现，最后再审查。每一步质量都会更好。\n肯定句比否定句好使。\u0026ldquo;不要用递归\u0026quot;不如\u0026quot;请用迭代方式实现\u0026rdquo;。模型对肯定指令的遵循度更高。\n重要约束多强调。关键要求可以在 Prompt 开头和结尾各出现一次。模型对首尾的内容会更重视（跟注意力机制有关）。\n其实吧，Prompt Engineering 说到底就是跟模型沟通的技巧。我之前老觉得模型\u0026quot;不听话\u0026quot;，后来反思发现多半是我自己表达不够清楚。你的 Prompt 越精确，模型给的结果就越好。多试多调，慢慢就找到感觉了。\n","date":"March 10, 2026","externalUrl":null,"permalink":"/posts/prompt-engineering-tips/","section":"博客","summary":"Prompt Engineering 实用技巧，我总结了这些 # 用大模型一年多了，写了不少 Prompt。从最开始\"帮我写个 xxx\"的大白话，到现在能让模型按我要的格式和风格精确输出，走了不少弯路。把真正实用的技巧整理一下。\n零样本和少样本提示 # 零样本（Zero-shot）：直接给指令，不带示例。\n","title":"Prompt Engineering 技巧整理","type":"posts"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" 目前共有 26 篇文章，涵盖 Java 基础、Spring 生态、中间件和大模型等方向。 ","date":"March 10, 2026","externalUrl":null,"permalink":"/","section":"Weiming's Blog","summary":" 目前共有 26 篇文章，涵盖 Java 基础、Spring 生态、中间件和大模型等方向。 ","title":"Weiming's Blog","type":"page"},{"content":"","date":"March 10, 2026","externalUrl":null,"permalink":"/posts/","section":"博客","summary":"","title":"博客","type":"posts"},{"content":"","date":"January 20, 2026","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"January 20, 2026","externalUrl":null,"permalink":"/tags/spring-ai/","section":"Tags","summary":"","title":"Spring AI","type":"tags"},{"content":" Spring AI 初探：Spring 生态的 AI 新成员 # Spring AI 是什么 # Spring 官方终于下场做 AI 框架了。Spring AI 是 Spring 生态里的新成员，目标是让 Java/Spring 开发者能方便地集成各种 AI 能力。\n之前 Java 圈搞 AI 开发主要靠 LangChain4j，现在 Spring 官方也入局了，竞争挺有意思的。\nSpring AI 的风格很\u0026quot;Spring\u0026quot;：自动配置、Starter 依赖、注解驱动。用过 Spring Boot 的人上手会非常自然。\nChatClient：核心 API # ChatClient 是 Spring AI 最核心的接口，设计思路类似 RestTemplate / WebClient。\n先加依赖（以 OpenAI 为例）：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.ai\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-ai-openai-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; 配置：\nspring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini 使用起来：\n@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping(\u0026#34;/chat\u0026#34;) public String chat(@RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); } } 链式调用写起来很流畅：.prompt().user(message).call().content()。想要流式输出？把 .call() 换成 .stream() 就行。\nPrompt 模板 # Spring AI 支持用模板构造提示词：\n@GetMapping(\u0026#34;/translate\u0026#34;) public String translate(@RequestParam String text, @RequestParam String lang) { return chatClient.prompt() .system(\u0026#34;你是一个专业翻译\u0026#34;) .user(u -\u0026gt; u.text(\u0026#34;请将以下文本翻译成{lang}：{text}\u0026#34;) .param(\u0026#34;lang\u0026#34;, lang) .param(\u0026#34;text\u0026#34;, text)) .call() .content(); } 也能从 classpath 文件加载模板。Spring AI 用的模板引擎是 StringTemplate，变量语法跟 Spring 的 ${} 不同，用的是 {}，我第一次用的时候没注意到这个差别，debug 了好一会儿。\n无缝切换到 Ollama # 想用本地模型？换个 Starter 和配置就行：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.ai\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-ai-ollama-spring-boot-starter\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b 代码完全不动。ChatClient 的用法一模一样，只是底层换了模型提供商。这个抽象层做得确实好，典型的 Spring 设计哲学——面向接口编程。\n和 LangChain4j 对比 # 两个都玩了一段时间，聊聊对比。\nSpring AI 的好处：\n原生 Spring 生态，自动配置省心 ChatClient API 设计优雅，链式调用很流畅 切���模型后端只改配置，代码不动 长期来看跟 Spring 全家桶的整合会越来越深 LangChain4j 的好处：\n功能更全，AiServices、Tools、Agent 更成熟 版本更稳定，已经有不少生产案例 社区活跃，文档和示例丰富 不绑定 Spring，更灵活 如果项目是 Spring Boot 的，Spring AI 用着确实更顺手。但需要 RAG、Agent、Function Calling 这些高级功能的话，LangChain4j 目前更完善。\n我的判断：Spring AI 正式版出来之后，在 Spring 项目中大概率会成为主流选择。但现在还在早期��段，API 可能变动，生产环境要谨慎。\n小结 # Spring AI 给了 Java 开发者一个原生的 AI 集成方案。ChatClient + 自动配置的体验很好，切换不同模型后端也很方便。\n其实吧，Java 生态在 AI 这块虽然起步��� Python 晚，但 Spring AI 和 LangChain4j 都在快速发展。作为 Java 开发者，现在入场 AI 开发并不晚。工具已经够用了，剩下的就是找场景去实践。\n","date":"January 20, 2026","externalUrl":null,"permalink":"/posts/spring-ai-first-look/","section":"博客","summary":"Spring AI 初探：Spring 生态的 AI 新成员 # Spring AI 是什么 # Spring 官方终于下场做 AI 框架了。Spring AI 是 Spring 生态里的新成员，目标是让 Java/Spring 开发者能方便地集成各种 AI 能力。\n","title":"Spring AI 初探","type":"posts"},{"content":"","date":"December 5, 2025","externalUrl":null,"permalink":"/tags/ollama/","section":"Tags","summary":"","title":"Ollama","type":"tags"},{"content":" 用 Ollama 在本地跑大模型，真的可以 # Ollama 是什么 # 一直觉得跑大模型得有好几块 A100 才行，直到有人跟我说\u0026quot;试试 Ollama\u0026quot;。\nOllama 是一个在本地运行大模型的工具。一行命令安装，一行命令拉模型，一行命令跑起来。它管理 LLM 的方式跟 Docker 管容器一样简单，上手极快。\nMac、Linux、Windows 都支持。对 Apple Silicon 的 Mac 支持特别好，能利用统一内存来跑模型。\n安装和上手 # Mac 上安装：\nbrew install ollama 也可以去官网 ollama.com 下安装包。启动服务后拉个模型试试：\nollama serve ollama pull qwen2.5:7b ollama run qwen2.5:7b 就可以在终端直接跟模型聊天了。第一次跑通的时候我确实挺惊讶——自己笔记本就能跑 AI，不用花一分钱。\n推荐几个模型 # Ollama 上可选的模型很多，说几个我试过觉得不错的：\nqwen2.5:7b：通义千问，中文效果好，7B 大小在普通笔记本上跑得动 llama3.1:8b：Meta 出品，英文能力强，中文也还行 codellama:7b：专门做代码生成和补全的，写 Java 还不错 mistral:7b：法国 Mistral AI 的模型，综合性能不错 常用管理命令：\nollama list # 查看已下载的模型 ollama pull llama3.1 # 下载模型 ollama rm mistral # 删除模型 ollama show qwen2.5 # 查看模型详细信息 API 调用 # Ollama 启动后默认在 localhost:11434 提供 REST API，而且兼容 OpenAI 的接口格式。\ncurl http://localhost:11434/api/chat -d \u0026#39;{ \u0026#34;model\u0026#34;: \u0026#34;qwen2.5:7b\u0026#34;, \u0026#34;messages\u0026#34;: [{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;什么是 Spring Boot？\u0026#34;}], \u0026#34;stream\u0026#34;: false }\u0026#39; 兼容 OpenAI 格式这点很关键——很多现有工具和 SDK 只需要改个 base URL 就能接入，迁移成本极低。\n和 Java 集成 # 用 LangChain4j 接 Ollama 特别方便：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;dev.langchain4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;langchain4j-ollama\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.35.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ChatLanguageModel model = OllamaChatModel.builder() .baseUrl(\u0026#34;http://localhost:11434\u0026#34;) .modelName(\u0026#34;qwen2.5:7b\u0026#34;) .build(); String reply = model.generate(\u0026#34;用 Java 写一个快速排序\u0026#34;); 开发和测试阶段用 Ollama 本地模型，不花钱。上线切到 OpenAI 或其他云端 API，代码只需要换个 Model 实现。这个开发体验很舒服。\n我的电脑能跑什么模型？ # 这个大家最关心。粗略的参考（量化后，Q4）：\n模型大小 最低内存/显存 推荐配置 适合设备 1-3B 4GB 8GB 轻薄本 7-8B 8GB 16GB 普通笔记本 13-14B 16GB 32GB 高配笔记本 30-34B 32GB 64GB 高配台式机 70B 64GB+ 128GB+ 服务器/多卡 Ollama 拉下来的默认就是量化版本，会损失一点精度但大幅降低内存需求。\n我的 M2 MacBook Pro 16GB 实测：\n7B 模型很流畅，推理速度约 20-30 token/s 13B 勉强能跑，速度明显慢，偶尔卡顿 再大的就别想了 有 NVIDIA 独显的话，Ollama 支持 GPU 加速（需要 CUDA），同样的模型跑起来快好几倍。\n顺便说一句，模型参数量不完全决定效果。Qwen2.5 的 7B 在中文任务上，不见得比 Llama 的 13B 差。选模型还是得看具体需求。\n其实吧，现在本地跑大模型门槛已经很低了。16GB 内存的电脑就能玩起来。效果虽然比不上 GPT-4 或 Claude，但拿来学习实验、写小工具完全够用。不花钱不限量，想怎么折腾怎么折腾，我觉得这才是学习 AI 最好的方式。\n","date":"December 5, 2025","externalUrl":null,"permalink":"/posts/ollama-local-llm/","section":"博客","summary":"用 Ollama 在本地跑大模型，真的可以 # Ollama 是什么 # 一直觉得跑大模型得有好几块 A100 才行，直到有人跟我说\"试试 Ollama\"。\n","title":"Ollama 本地部署大模型","type":"posts"},{"content":"","date":"December 5, 2025","externalUrl":null,"permalink":"/tags/%E6%9C%AC%E5%9C%B0%E9%83%A8%E7%BD%B2/","section":"Tags","summary":"","title":"本地部署","type":"tags"},{"content":"","date":"October 15, 2025","externalUrl":null,"permalink":"/tags/rag/","section":"Tags","summary":"","title":"RAG","type":"tags"},{"content":" RAG 检索增强生成，让大模型不再胡说八道 # RAG 是什么 # RAG，Retrieval-Augmented Generation，检索增强生成。名字挺唬人，但思路很简单：先从你自己的知识库里检索相关内容，把检索结果塞进 Prompt 里一起给大模型，让模型基于这些内容来回答。\n用大白话说就是：给模型开卷考试。它自己可能不记得答案，但你给它参考资料，它就能答得靠谱了。\n为什么需要 RAG # 大模型天然有几个局限：\n知识有截止日期：你公司的内部文档、最新的业务数据它不知道 会胡说八道（幻觉）：编造看起来合理但实际错误的内容 微调成本高：要 GPU、要标注数据、要调参，门槛不低 RAG 好在不用微调模型。知识库更新了？改文档重新索引就行。回答有据可查，实现成本也低。\n我做课设想搞个基于学校课程文档的问答系统。一开始想微调，研究了半天发现太折腾了。后来改用 RAG 方案，效果还不错。\n文档切分：第一步就有讲究 # RAG 的第一步是把文档切成小块（Chunk）。为什么？模型上下文窗口有限，你不可能把整本书塞进去。而且检索粒度越小，匹配越精准。\n常见切分策略：\n按字符数切——简单但可能把句子切断 按段落切——保持语义完整 递归切分——先按大段切，太长的再往下拆 chunk size 一般设在 200-1000 Token 之间。太小信息不完整，太大检索不精准。记得加 overlap（重叠），比如每块重叠 50 Token，防止重要信息正好被切在边界上。\nDocumentSplitter splitter = DocumentSplitters.recursive(500, 50); List\u0026lt;TextSegment\u0026gt; segments = splitter.split(document); Embedding 和向量检索 # 切完之后，怎么从一堆 Chunk 里找到跟用户问题相关的？答案是向量检索。\nEmbedding 模型会把文本转成一个高维向量（比如 1536 维的浮点数数组）。语义相近的文本，转出来的向量也会接近。\n整个流程：\n把所有文档 Chunk 转成向量，存到向量数据库 用户提问时，把问题也转成向量 在向量数据库里找最相似的 K 个 Chunk 把这些 Chunk 跟用户问题一起交给 LLM 向量数据库现在选择很多：Milvus、Qdrant、Chroma、Pinecone。数据量小的话用内存存储就够了。\nEmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(\u0026#34;your-key\u0026#34;) .modelName(\u0026#34;text-embedding-3-small\u0026#34;) .build(); InMemoryEmbeddingStore\u0026lt;TextSegment\u0026gt; embeddingStore = new InMemoryEmbeddingStore\u0026lt;\u0026gt;(); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .documentSplitter(DocumentSplitters.recursive(500, 50)) .build(); ingestor.ingest(document); 用 LangChain4j 实现完整 RAG # 前面的准备工作做好了，把 RAG 串起来其实没几行：\nContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.7) .build(); interface Assistant { @SystemMessage(\u0026#34;基于以下参考资料回答用户问题，如果资料中没有相关信息就说不知道\u0026#34;) String chat(String question); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build(); String answer = assistant.chat(\u0026#34;期末考试范围是什么？\u0026#34;); maxResults(3) 表示最多检索 3 个文档块，minScore(0.7) 设了相似度阈值，不够相关的就过滤掉。LangChain4j 会自动把检索到的内容拼到 Prompt 里，你不用手动拼接。\n踩过的坑和经验 # 实际用下来几个教训：\n切分粒度很关键。我一开始 chunk size 设了 1000，检索老是匹配到无关内容。改成 300 加 50 overlap 之后效果明显好了。这个参数得根据你的文档特点调。\nEmbedding 模型选对很重要。OpenAI 的 text-embedding-3-small 对中文支持还行，但如果文档全是中文，可以试试 bge-large-zh 这类专门为中文优化的模型，效果会好不少。\n系统提示词里一定要加\u0026quot;不知道就说不知道\u0026quot;。不然检索不到相关内容的时候，模型照样编答案，RAG 就白搭了。\n向量数据库不用太纠结。数据量几千条用内存足够，上万条再考虑 Milvus 或 Qdrant。别一上来就搞个分布式向量库，杀鸡焉用牛刀。\n其实吧，RAG 是目前让大模型结合私有知识最实用的方案了。虽然也有局限——检索不到的信息还是答不了，但对大部分场景来说性价比很高，比微调划算多了。\n","date":"October 15, 2025","externalUrl":null,"permalink":"/posts/rag-practice/","section":"博客","summary":"RAG 检索增强生成，让大模型不再胡说八道 # RAG 是什么 # RAG，Retrieval-Augmented Generation，检索增强生成。名字挺唬人，但思路很简单：先从你自己的知识库里检索相关内容，把检索结果塞进 Prompt 里一起给大模型，让模型基于这些内容来回答。\n","title":"RAG 检索增强生成实践","type":"posts"},{"content":"","date":"October 15, 2025","externalUrl":null,"permalink":"/tags/%E5%90%91%E9%87%8F%E6%95%B0%E6%8D%AE%E5%BA%93/","section":"Tags","summary":"","title":"向量数据库","type":"tags"},{"content":"","date":"August 20, 2025","externalUrl":null,"permalink":"/tags/langchain4j/","section":"Tags","summary":"","title":"LangChain4j","type":"tags"},{"content":" LangChain4j 初体验：Java 也能玩 LLM # LangChain4j 是什么 # 说到 AI 开发，大家第一反应都是 Python。LangChain、LlamaIndex 清一色 Python 的。但我主力语言是 Java，难道就只能干看着？\nLangChain4j 就是来填这个坑的。它是 LangChain 的 Java 实现，提供了和 LLM 交互的各种能力：模型接入、Prompt 管理、Memory、RAG、Tools 调用等。GitHub 上 langchain4j/langchain4j，star 涨得挺快，说明 Java 圈对这个需求确实大。\n快速上手 # 建个 Spring Boot 项目，加依赖：\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;dev.langchain4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;langchain4j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.35.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;dev.langchain4j\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;langchain4j-open-ai\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;0.35.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 最简单的调用：\nChatLanguageModel model = OpenAiChatModel.builder() .apiKey(\u0026#34;your-api-key\u0026#34;) .modelName(\u0026#34;gpt-4o-mini\u0026#34;) .build(); String answer = model.generate(\u0026#34;Java 和 Go 哪个更适合后端开发？\u0026#34;); System.out.println(answer); 几行代码就能跟大模型对话了。第一次跑通的时候还挺兴奋的。\n也可以接 Ollama 跑本地模型，免费：\nChatLanguageModel model = OllamaChatModel.builder() .baseUrl(\u0026#34;http://localhost:11434\u0026#34;) .modelName(\u0026#34;qwen2.5:7b\u0026#34;) .build(); AiServices：最好用的特性 # 上面是最基础的字符串输入输出。实际开发中你需要更精细的控制。\nLangChain4j 的 AiServices 能把 LLM 调用包装成 Java 接口，这是我觉得最好用的特性：\npublic interface Assistant { @SystemMessage(\u0026#34;你是一个 Java 技术专家，回答简洁明了\u0026#34;) String chat(String userMessage); } Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .build(); String reply = assistant.chat(\u0026#34;Spring Boot 3 有什么新特性？\u0026#34;); 定义接口，框架帮你实现。很 Java 风格，IDE 补全也舒服。\n还能搞 Prompt 模板：\npublic interface Translator { @UserMessage(\u0026#34;请将以下文本翻译成{{language}}：{{text}}\u0026#34;) String translate(@V(\u0026#34;text\u0026#34;) String text, @V(\u0026#34;language\u0026#34;) String language); } 调用 translator.translate(\u0026quot;Hello World\u0026quot;, \u0026quot;中文\u0026quot;) 就行了，模板化之后复用方便。\nMemory：让模型记住上下文 # 默认模型是无状态的，每次调用独立。想要多轮对话的\u0026quot;记忆\u0026quot;，需要把历史消息传给模型。\nLangChain4j 提供了 ChatMemory，用起来很简单：\nChatMemory memory = MessageWindowChatMemory.withMaxMessages(20); Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .chatMemory(memory) .build(); assistant.chat(\u0026#34;我叫小明\u0026#34;); String reply = assistant.chat(\u0026#34;我叫什么？\u0026#34;); // 模型会回答\u0026#34;你叫小明\u0026#34; MessageWindowChatMemory 是滑动窗口策略，保留最近 N 条消息，超了就丢最早的。简单粗暴但大部分场景够用。\n还有 TokenWindowChatMemory，按 Token 数而不是消息条数来限制，适合需要精确控制上下文长度的场景。\n和 Python 版 LangChain 对比 # 用了一段时间，聊聊我的感受。\nLangChain4j 的好处：\n类型安全，接口定义清晰，重构不怕 AiServices 声明式调用很直观 和 Spring 生态集成顺畅 适合已有的 Java 企业项目 Python LangChain 的好处：\n生态成熟，社区大，教程多 新功能出得快，AI 社区 Python 是绝对主流 原型验证快，Notebook 里写几行就能跑 其实吧，如果你的项目是 Java 栈，用 LangChain4j 很自然。不用为了接 AI 单独搞一个 Python 微服务，省事很多。但想快速试验 AI 想法的话，Python 还是更方便。\n小结 # LangChain4j 让 Java 开发者也能方便地接入大模型。核心就几个东西：ChatLanguageModel 负责对话，AiServices 做接口级抽象，ChatMemory 管理上下文记忆。掌握这三个就能覆盖大部分场景了。\n我现在课程设计就在用 LangChain4j + Ollama 搞一个本地的智能问答系统，不花钱买 API，全在本地跑，挺有意思的。后面打算加 RAG，到时候再写一篇。\n","date":"August 20, 2025","externalUrl":null,"permalink":"/posts/langchain4j-getting-started/","section":"博客","summary":"LangChain4j 初体验：Java 也能玩 LLM # LangChain4j 是什么 # 说到 AI 开发，大家第一反应都是 Python。LangChain、LlamaIndex 清一色 Python 的。但我主力语言是 Java，难道就只能干看着？\n","title":"LangChain4j 入门","type":"posts"},{"content":" 大模型入门：到底什么是 LLM # Transformer 到底是什么 # 最近到处都在聊大模型、AI，感觉不了解点 LLM 都不好意思说自己学计算机的。但说实话，一开始我也是一头雾水。GPT、Transformer、Token，这些词满天飞，搜了好多资料才慢慢理出头绪。\nLLM，Large Language Model，大语言模型。说白了就是一个超大的神经网络，用海量文本训练出来的，学会了\u0026quot;语言\u0026quot;这件事。你给它一段文字，它能接着往下写——本质上就是在预测下一个 Token。\n底层架构叫 Transformer。2017 年 Google 那篇 \u0026ldquo;Attention is All You Need\u0026rdquo; 提出的，改变了整个 NLP 领域。\nTransformer 的核心是注意力机制（Attention）。不用纠结数学细节，可以这么理解：模型处理一个词的时候，会\u0026quot;看\u0026quot;句子里所有其他词，判断谁跟它关系更密切。比如\u0026quot;我去银行取钱\u0026quot;，处理\u0026quot;银行\u0026quot;时模型注意到\u0026quot;取钱\u0026quot;，就知道这是金融机构不是河岸。\n之前的 RNN/LSTM 按顺序一个词一个词处理，长文本又慢效果又差。Transformer 可以并行处理所有位置，效率高太多了。这也是大模型能做到几百亿参数的关键原因。\n预训练和微调 # 大模型训练分两个阶段。\n预训练：拿海量文本数据（网页、书籍、代码、论文……），让模型学习预测下一个词。就这么简单的目标，在足够大的数据量和模型规模上，涌现出了各种能力——写代码、翻译、推理，都是\u0026quot;学\u0026quot;出来的。\n微调：预训练完的模型像一个\u0026quot;什么都懂但不听话\u0026quot;的人。微调让它更符合人类期望。常见方法是 RLHF（基于人类反馈的强化学习）——人类给模型的回答打分，模型根据反馈调整行为。\nChatGPT 之所以好用，很大功劳在微调阶段。没经过微调的 base model，你问它问题它不会好好回答，只会一直\u0026quot;接龙\u0026quot;下去。\nToken 和上下文窗口 # 大模型不按\u0026quot;字\u0026quot;处理文本，而是按 Token。一个 Token 大概对应一个常见的词或子词片段。英文里一个单词通常 1-2 个 Token，中文一个字大概 1-2 个 Token。\n上下文窗口（Context Window）是模型一次能处理的最大 Token 数。GPT-4 是 128K，Claude 支持到 200K。窗口越大，模型能\u0026quot;看到\u0026quot;的内容越多。\n上下文窗口很重要——模型只能\u0026quot;看到\u0026quot;窗口以内的内容。跟它聊天聊久了，早期的对话超出窗口就被\u0026quot;遗忘\u0026quot;了。\nToken 数也直接关系到使用成本。API 按 Token 收费，input 和 output 分开计价。所以写 Prompt 的时候别废话太多，能省就省。\n常见模型对比 # 现在主流的大模型，简单聊一下各自的特点：\nGPT 系列（OpenAI）：GPT-4o 综合能力很强，几乎是目前的标杆。API 收费，国内直接访问不了。\nClaude 系列（Anthropic）：Claude 4 / Claude 3.5 Sonnet，长文本处理很强，写代码也好使。上下文窗口大是一个优势。\nLlama 系列（Meta）：开源模型的代表，Llama 3 出来之后效果相当不错。可以本地跑，适合研究和二次开发。\nQwen 系列（阿里通义千问）：国产开源模型里比较能打的。Qwen2.5 各种尺寸都有，7B 的小模型在笔记本上都能跑起来。\n我个人的使用感受：日常用 GPT 或 Claude 都行。想折腾本地部署就选 Llama 或 Qwen。具体选哪个看场景和预算。\n大模型能干什么，不能干什么 # 能干的事太多了：写代码、翻译、摘要总结、问答、头脑风暴、文案创作……我现在写作业都习惯先让大模型解释一下概念，比搜 CSDN 效率高多了。\n但短板也明显：\n会胡说八道（幻觉问题）：模型不是真\u0026quot;知道\u0026quot;答案，而是在\u0026quot;生成\u0026quot;看起来合理的文本。它可能编造不存在的论文、写出有 bug 的代码。用的时候一定要验证。 知识有截止日期：训练数据有时间限制，最近发生的事它不知道。 复杂推理容易翻车：简单逻辑还行，复杂数学或多步推理就容易出错。 没有真正的\u0026quot;理解\u0026quot;：它是统计模式匹配，不是真的理解世界。虽然表现看起来像懂了，但本质不同。 话说回来，大模型的进化速度太快了。半年前的短板可能现在已经改善了不少。作为 CS 学生，我觉得了解大模型是必要的，不管以后做不做 AI 方向。这东西正在改变整个软件行业的开发方式，不能视而不见。\n","date":"June 10, 2025","externalUrl":null,"permalink":"/posts/llm-introduction/","section":"博客","summary":"大模型入门：到底什么是 LLM # Transformer 到底是什么 # 最近到处都在聊大模型、AI，感觉不了解点 LLM 都不好意思说自己学计算机的。但说实话，一开始我也是一头雾水。GPT、Transformer、Token，这些词满天飞，搜了好多资料才慢慢理出头绪。\n","title":"大语言模型（LLM）入门","type":"posts"},{"content":"","date":"June 10, 2025","externalUrl":null,"permalink":"/tags/%E5%85%A5%E9%97%A8/","section":"Tags","summary":"","title":"入门","type":"tags"},{"content":"","date":"September 10, 2024","externalUrl":null,"permalink":"/categories/spring/","section":"Categories","summary":"","title":"Spring","type":"categories"},{"content":"","date":"September 10, 2024","externalUrl":null,"permalink":"/tags/spring-cloud/","section":"Tags","summary":"","title":"Spring Cloud","type":"tags"},{"content":" Spring Cloud 微服务入门，别被吓到 # 微服务到底是啥 # 第一次听\u0026quot;微服务\u0026quot;这个词的时候，我觉得好高大上。服务注册发现、熔断降级、链路追踪，一堆概念砸过来挺吓人的。但学了之后发现，核心思路其实没那么复杂。\n简单说，微服务就是把一个大应用拆成多个小服务，每个独立部署、独立运行。比如电商系统拆成用户服务、商品服务、订单服务、支付服务。\n为什么要拆？单体应用到后期改个小功能就得重新部署整个系统，代码耦合严重，团队协作也别扭。微服务之后，各团队负责自己的服务，独立开发独立上线。\n当然微服务不是银弹。小项目上微服务纯属自找麻烦，运维复杂度直接翻好几倍。但学习这套技术栈还是很有必要的，面试也经常问。\n注册中心：Nacos # 服务拆了之后，A 想调 B，怎么知道 B 在哪？IP 端口写死在配置里？那 B 有多个实例呢？地址变了呢？\n注册中心解决这个问题。每个服务启动时把自己的地址注册上去，其他服务去查就行了。\nNacos 是现在的主流选择（阿里开源），同时搞定服务注册和配置管理。\nspring: cloud: nacos: discovery: server-addr: localhost:8848 application: name: user-service 加上依赖、写好配置、启动，就注册好了。Nacos 控制台能看到所有注册的服务实例，很直观。\n我第一次跑通的时候还挺激动的，在控制台看到自己的服务出现了，有种打通\u0026quot;任督二脉\u0026quot;的感觉。\n网关：Gateway # 微服务拆了之后，前端调哪个服务？总不能让前端记一堆服务地址。网关就是统一入口，所有请求先过网关，由网关路由到对应服务。\nspring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** - id: order-service uri: lb://order-service predicates: - Path=/api/order/** /api/user/xxx 自动转发到 user-service，/api/order/xxx 转发到 order-service。lb:// 前缀表示从注册中心取地址，自带负载均衡。\n网关还能统一做鉴权、限流、日志。在网关校验 Token，下游服务就不用每个都重复写鉴权了。\n远程调用：OpenFeign # 服务之间调用怎么搞？手写 RestTemplate 拼 URL？太折腾了。OpenFeign 让远程调用像调本地方法一样。\n@FeignClient(\u0026#34;user-service\u0026#34;) public interface UserClient { @GetMapping(\u0026#34;/api/user/{id}\u0026#34;) User getUser(@PathVariable Long id); } @Service public class OrderService { @Autowired private UserClient userClient; public Order createOrder(Long userId) { User user = userClient.getUser(userId); // 像调本地方法一样 // 创建订单... } } OpenFeign 根据服务名从注册中心找地址，自动发 HTTP 请求，还自带负载均衡。第一次用的时候觉得真方便，声明式调用太舒服了。\n熔断降级：Sentinel # 微服务有个麻烦事：服务之间有依赖，一个挂了可能连带调用方也跟着挂，连锁反应下去整个系统就崩了——这叫\u0026quot;雪崩效应\u0026quot;。\nSentinel（同样阿里开源）就是应对这个的。当某个服务响应超时或错误率太高，Sentinel 自动熔断，不再发请求过去，直接走降级逻辑。\n@FeignClient(value = \u0026#34;user-service\u0026#34;, fallback = UserClientFallback.class) public interface UserClient { @GetMapping(\u0026#34;/api/user/{id}\u0026#34;) User getUser(@PathVariable Long id); } @Component public class UserClientFallback implements UserClient { @Override public User getUser(Long id) { return new User(id, \u0026#34;未知用户\u0026#34;); // 降级响应 } } user-service 挂了？没关系，返回\u0026quot;未知用户\u0026quot;，至少订单服务不会跟着挂。\nSentinel 还支持流量控制，限制每秒请求数防止服务被打爆。控制台可以实时监控、动态调规则，很好用。\n整体架构串一下 # 把组件串起来看：\n客户端 → Gateway(网关) → 路由到对应服务 ↓ Nacos(注册中心/配置中心) ↙ ↓ ↘ 用户服务 订单服务 商品服务 ↖ OpenFeign ↗ 互相调用 Sentinel 在每个服务里做熔断限流 一个请求的完整流程：\n客户端请求到 Gateway Gateway 根据路径路由到对应服务 服务从 Nacos 获取其他服务的地址 通过 OpenFeign 调用其他服务 Sentinel 监控每个调用，必要时熔断降级 Spring Cloud Alibaba 这套组件用起来还是挺顺手的。我之前跟着做了个小项目练手，搞了三个服务加一个网关，跑起来之后对微服务的理解清晰多了。\n其实吧，微服务组件远不止这些。配置中心（Nacos 也能干）、链路追踪（SkyWalking）、分布式事务（Seata）都是常见的。但入门的话，先把注册中心、网关、远程调用、熔断降级这四大件搞明白就够了，其他的用到再学不迟。\n","date":"September 10, 2024","externalUrl":null,"permalink":"/posts/spring-cloud-intro/","section":"博客","summary":"Spring Cloud 微服务入门，别被吓到 # 微服务到底是啥 # 第一次听\"微服务\"这个词的时候，我觉得好高大上。服务注册发现、熔断降级、链路追踪，一堆概念砸过来挺吓人的。但学了之后发现，核心思路其实没那么复杂。\n","title":"Spring Cloud 微服务入门","type":"posts"},{"content":"","date":"September 10, 2024","externalUrl":null,"permalink":"/tags/%E5%BE%AE%E6%9C%8D%E5%8A%A1/","section":"Tags","summary":"","title":"微服务","type":"tags"},{"content":"","date":"August 20, 2024","externalUrl":null,"permalink":"/tags/mvcc/","section":"Tags","summary":"","title":"MVCC","type":"tags"},{"content":"","date":"August 20, 2024","externalUrl":null,"permalink":"/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":" MySQL 事务和 MVCC，面试必问 # 事务和 MVCC 是 MySQL 面试里出场率最高的话题，没有之一。我准备秋招的时候翻来覆去看了好几遍，现在总算能说清楚了。\nACID 四大特性 # 事务四个特性，关键是理解怎么实现的：\nA（原子性）：要么全做要么全不做。靠 undo log，出错就回滚。 C（一致性）：数据从一个合法状态到另一个合法状态。这其实是目标，靠其他三个保证。 I（隔离性）：并发事务互不干扰。靠 MVCC + 锁。 D（持久性）：提交后数据不丢。靠 redo log。 面试光背定义没意思，面试官想听实现原理。比如为啥用 redo log？直接刷数据页是随机 IO，写 redo log 是顺序 IO，快得多。先写日志，宕机了根据 redo log 恢复就行。这叫 WAL（Write-Ahead Logging）。\n四种隔离级别 # 隔离级别 脏读 不可重复读 幻读 Read Uncommitted 可能 可能 可能 Read Committed (RC) 不可能 可能 可能 Repeatable Read (RR) 不可能 不可能 可能 Serializable 不可能 不可能 不可能 InnoDB 默认 Repeatable Read。有意思的是，InnoDB 的 RR 很大程度上也解决了幻读（通过 MVCC + 间隙锁）。\n脏读、不可重复读、幻读 # 用例子说最清楚。\n脏读：读到别的事务还没提交的数据。\n事务A: UPDATE account SET balance = 200 WHERE id = 1; (未提交) 事务B: SELECT balance FROM account WHERE id = 1; → 读到200 事务A: ROLLBACK; // 事务B读到的200是假的 不可重复读：同一个事务里两次读同一行，结果不一样。\n事务B: SELECT balance FROM account WHERE id = 1; → 100 事务A: UPDATE balance = 200 WHERE id = 1; COMMIT; 事务B: SELECT balance FROM account WHERE id = 1; → 200 幻读：同一个事务里两次范围查询，行数不一样。\n事务B: SELECT * FROM account WHERE balance \u0026gt; 100; → 3行 事务A: INSERT INTO account VALUES(4, 500); COMMIT; 事务B: SELECT * FROM account WHERE balance \u0026gt; 100; → 4行 脏读和不可重复读针对\u0026quot;同一行\u0026quot;，幻读针对\u0026quot;行数变化\u0026quot;。这个区分面试时要说清楚。\nMVCC 实现原理：undo log + ReadView # MVCC（多版本并发控制）是 InnoDB 实现 RC 和 RR 的核心。每行数据不只一个版本，而是有一条版本链。\nInnoDB 每行记录有两个隐藏字段：\ntrx_id：最后修改这行的事务 ID roll_pointer：指向 undo log 里的上一个版本 每次修改，旧版本写到 undo log，通过 roll_pointer 串成链：\n当前数据: {name: \u0026#34;张三\u0026#34;, trx_id: 300, roll_pointer → } ↓ undo log: {name: \u0026#34;李四\u0026#34;, trx_id: 200, roll_pointer → } ↓ undo log: {name: \u0026#34;王五\u0026#34;, trx_id: 100, roll_pointer → NULL} 有了版本链，关键问题是：当前事务应该看哪个版本？这就是 ReadView 干的事。\nReadView 是事务执行快照读（普通 SELECT）时生成的视图，包含：\ncreator_trx_id：当前事务 ID m_ids：生成时所有活跃（未提交）事务的 ID 列表 min_trx_id：活跃事务中最小的 ID max_trx_id：下一个要分配的事务 ID 判断规则：\ntrx_id == creator_trx_id → 自己改的，看得到 trx_id \u0026lt; min_trx_id → ReadView 之前就提交了，看得到 trx_id \u0026gt;= max_trx_id → ReadView 之后才出现，看不到 min_trx_id \u0026lt;= trx_id \u0026lt; max_trx_id → 看 trx_id 在不在 m_ids 里。在说明没提交，看不到；不在说明已提交，看得到 看不到就顺着 roll_pointer 找上一个版本，直到找到能看的。\nRC 和 RR 的区别就在 ReadView 的生成时机：\nRC：每次 SELECT 都生成新 ReadView，所以能看到其他事务新提交的数据 RR：只在事务第一次 SELECT 时生成，后续复用，所以一直看同一个快照 就这一个区别，决定了 RC 有不可重复读而 RR 没有。我觉得这是 MVCC 最精妙的地方。\n说到这里想起之前一个事。同事在 RR 级别下开了个长事务，先 SELECT 了一下，过了一会再 SELECT，发现数据\u0026quot;没更新\u0026quot;。他以为是缓存问题，折腾了半天才意识到是 MVCC——第一次 SELECT 就定了 ReadView，后面都用这个快照。\n快照读 vs 当前读 # 顺便提一下：\n快照读：普通 SELECT，走 MVCC，读历史版本 当前读：SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE，读最新数据并加锁 RR 下快照读通过 MVCC 解决不可重复读和幻读。当前读靠临键锁（Next-Key Lock = 行锁 + 间隙锁）防止幻读。\n小结 # 事务和 MVCC 核心就几个点：ACID 靠什么实现、ReadView 的判断规则、RC 和 RR 的 ReadView 生成时机。把这些理清楚了面试基本能应对。建议自己开两个 MySQL 终端模拟并发场景，实操一遍比看十遍博客管用。\n","date":"August 20, 2024","externalUrl":null,"permalink":"/posts/mysql-transaction-mvcc/","section":"博客","summary":"MySQL 事务和 MVCC，面试必问 # 事务和 MVCC 是 MySQL 面试里出场率最高的话题，没有之一。我准备秋招的时候翻来覆去看了好几遍，现在总算能说清楚了。\nACID 四大特性 # 事务四个特性，关键是理解怎么实现的：\n","title":"MySQL 事务与 MVCC","type":"posts"},{"content":"","date":"August 20, 2024","externalUrl":null,"permalink":"/tags/%E4%BA%8B%E5%8A%A1/","section":"Tags","summary":"","title":"事务","type":"tags"},{"content":"","date":"August 20, 2024","externalUrl":null,"permalink":"/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/","section":"Categories","summary":"","title":"数据库","type":"categories"},{"content":" MySQL 索引优化，别再全表扫描了 # 上学期数据库课的大作业，我写了个选课系统。数据量小的时候跑得飞快，一导入几万条测试数据就卡得不行。一查原因——全表扫描。那时候才真正意识到索引有多重要。\nB+树——索引的底层结构 # MySQL 的 InnoDB 引擎用 B+树组织索引。\nB+树的特点：\n非叶子节点只存 key，不存数据 叶子节点存所有数据，用链表串起来 查询都走到叶子节点，效率稳定 为啥不用红黑树？磁盘 IO 是瓶颈。B+树很矮很胖，三层就能存上千万行数据，查一条记录最多三次磁盘 IO。\n算一笔账：主键 bigint 8 字节，指针 6 字节，一个 16KB 的页存约 1170 个 key。叶子节点假设每行 1KB，一页 16 行。三层就是 1170 x 1170 x 16 约两千万行。\n聚簇索引 vs 非聚簇索引 # 聚簇索引（主键索引）：叶子节点直接存整行数据。一张表只能有一个。\n非聚簇索引（二级索引）：叶子节点存的是主键值。查到主键后还要去聚簇索引查完整数据——回表。\n-- name 上有索引 SELECT * FROM student WHERE name = \u0026#39;张三\u0026#39;; -- 1. name 索引树找到主键 id=1001 -- 2. 拿 id=1001 去聚簇索引查整行 → 回表 回表就是两次 B+树查找，数据量大时开销不小。\n最左匹配原则 # 联合索引 (a, b, c) 的匹配规则：\nWHERE a = 1 AND b = 2 AND c = 3 -- ✅ 都用上了 WHERE a = 1 AND b = 2 -- ✅ 用到 a, b WHERE a = 1 -- ✅ 用到 a WHERE b = 2 AND c = 3 -- ❌ 没 a，用不上 WHERE a = 1 AND c = 3 -- ⚠️ 只用到 a 原理也不难理解：联合索引的 B+树先按 a 排序，a 相同按 b 排，b 相同按 c 排。没有 a 的话 b 就是无序的，没法走索引。\n我之前建了个 (create_time, status) 的联合索引，查询条件是 WHERE status = 1，索引完全没走上。改成 (status, create_time) 就好了。建联合索引的时候，常查的、区分度高的字段放前面。\n还有一点：WHERE a = 1 AND b \u0026gt; 2 AND c = 3，a 和 b 能用索引，但 c 用不上。范围查询后面的字段走不了索引。\nexplain 怎么看 # 查询走没走索引，用 EXPLAIN：\nEXPLAIN SELECT * FROM student WHERE name = \u0026#39;张三\u0026#39;; 几个关键字段：\n字段 含义 type 访问类型，从好到坏：const \u0026gt; eq_ref \u0026gt; ref \u0026gt; range \u0026gt; index \u0026gt; ALL key 实际用的索引 rows 预估扫描行数 Extra 额外信息 type 是 ALL 就是全表扫描，得优化。ref 是等值查找，range 是范围查找，都还行。\nExtra 常见值：\nUsing index：覆盖索引，不回表 Using where：用了 WHERE 过滤 Using filesort：额外排序，尽量避免 Using temporary：临时表，尽量避免 我有个习惯：写完 SQL 就顺手 EXPLAIN 一下，花不了几秒钟，能提前发现很多问题。\n覆盖索引——少一次回表 # 查询需要的字段全在索引里，就不用回表了：\n-- 假设有联合索引 (name, age) SELECT name, age FROM student WHERE name = \u0026#39;张三\u0026#39;; -- 索引里有 name 和 age，不用回表 SELECT * FROM student WHERE name = \u0026#39;张三\u0026#39;; -- 需要所有字段，得回表 所以别动不动 SELECT *。只查需要的字段，配合联合索引，能省不少开销。\n我之前有个查询从 SELECT * 改成 SELECT id, name, status，加了个 (name, status) 的索引，查询时间从 200ms 降到 5ms。效果立竿见影。\n几个优化小建议 # 主键用自增 ID：顺序插入避免页分裂，UUID 做主键写入性能差很多 字符串字段考虑前缀索引：ALTER TABLE t ADD INDEX idx_name(name(10))，只索引前 10 个字符 避免索引失效的写法： 索引字段做函数操作：WHERE YEAR(create_time) = 2024 → 改成范围查询 隐式类型转换：字段是 varchar 条件写成 WHERE phone = 13800138000 → 加引号 LIKE 以 % 开头：WHERE name LIKE '%三' → 索引用不上 别建太多索引：每个索引都是一棵 B+树，占空间，增删改都要维护 小结 # 索引优化说白了就两件事：让查询走上索引，减少回表次数。EXPLAIN 是你最好的工具，写完 SQL 就跑一下。搞清楚这些原则，日常开发基本够用了。\n","date":"June 15, 2024","externalUrl":null,"permalink":"/posts/mysql-index-optimization/","section":"博客","summary":"MySQL 索引优化，别再全表扫描了 # 上学期数据库课的大作业，我写了个选课系统。数据量小的时候跑得飞快，一导入几万条测试数据就卡得不行。一查原因——全表扫描。那时候才真正意识到索引有多重要。\nB+树——索引的底层结构 # MySQL 的 InnoDB 引擎用 B+树组织索引。\n","title":"MySQL 索引优化","type":"posts"},{"content":"","date":"June 15, 2024","externalUrl":null,"permalink":"/tags/%E7%B4%A2%E5%BC%95/","section":"Tags","summary":"","title":"索引","type":"tags"},{"content":"","date":"June 15, 2024","externalUrl":null,"permalink":"/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/","section":"Tags","summary":"","title":"性能优化","type":"tags"},{"content":"","date":"May 15, 2024","externalUrl":null,"permalink":"/tags/%E7%AD%96%E7%95%A5%E6%A8%A1%E5%BC%8F/","section":"Tags","summary":"","title":"策略模式","type":"tags"},{"content":" 策略模式干掉 if-else，代码瞬间清爽 # if-else 地狱 # 你写过这种代码吗：\npublic double calculate(String type, double amount) { if (\u0026#34;NORMAL\u0026#34;.equals(type)) { return amount; } else if (\u0026#34;VIP\u0026#34;.equals(type)) { return amount * 0.9; } else if (\u0026#34;SVIP\u0026#34;.equals(type)) { return amount * 0.8; } else if (\u0026#34;EMPLOYEE\u0026#34;.equals(type)) { return amount * 0.7; } else if (\u0026#34;PARTNER\u0026#34;.equals(type)) { return amount * 0.6; } return amount; } 说实话，你的代码里有没有类似的？我之前实习的时候接手过一个项目，一个方法里二十多个 if-else，看得我头皮发麻。每次加个新类型就得在这坨代码里再加一个分支，改着改着就出 bug。\n这种代码的问题很明显：违反开闭原则，每次新增逻辑都要改已有代码。测试也麻烦，一个方法里分支太多，哪条路径没覆盖到都不好说。\n策略模式是什么 # 策略模式说白了就是：把每个 if 分支里的逻辑抽成单独的类，通过一个统一的接口来调用。\n核心三个角色：\n策略接口：定义算法规范 具体策略：每个类实现一种算法 上下文：持有策略引用，负责调用 // 策略接口 public interface PriceStrategy { double calculate(double amount); } // 具体策略 public class VipPriceStrategy implements PriceStrategy { @Override public double calculate(double amount) { return amount * 0.9; } } 就这么简单。你可能会想，这不是更啰嗦了吗？本来一个方法搞定的事，搞出一堆类。别急，往下看。\n用策略模式重构 # 搞个策略工厂，用 Map 把类型和策略对应起来：\npublic class PriceStrategyFactory { private static final Map\u0026lt;String, PriceStrategy\u0026gt; STRATEGY_MAP = new HashMap\u0026lt;\u0026gt;(); static { STRATEGY_MAP.put(\u0026#34;NORMAL\u0026#34;, amount -\u0026gt; amount); STRATEGY_MAP.put(\u0026#34;VIP\u0026#34;, amount -\u0026gt; amount * 0.9); STRATEGY_MAP.put(\u0026#34;SVIP\u0026#34;, amount -\u0026gt; amount * 0.8); STRATEGY_MAP.put(\u0026#34;EMPLOYEE\u0026#34;, amount -\u0026gt; amount * 0.7); STRATEGY_MAP.put(\u0026#34;PARTNER\u0026#34;, amount -\u0026gt; amount * 0.6); } public static PriceStrategy getStrategy(String type) { return STRATEGY_MAP.getOrDefault(type, amount -\u0026gt; amount); } } 调用的时候：\npublic double calculate(String type, double amount) { PriceStrategy strategy = PriceStrategyFactory.getStrategy(type); return strategy.calculate(amount); } 两行搞定，清爽多了吧。新加类型？往 Map 里加一条就行，原有逻辑完全不动。\n结合 Spring 玩得更花 # 在 Spring 项目里，策略模式可以用得更舒服。让 Spring 帮你自动收集策略 Bean。\npublic interface PriceStrategy { String getType(); // 每个策略声明自己处理什么类型 double calculate(double amount); } @Component public class VipPriceStrategy implements PriceStrategy { @Override public String getType() { return \u0026#34;VIP\u0026#34;; } @Override public double calculate(double amount) { return amount * 0.9; } } 然后搞个上下文，用 @Autowired 把所有策略注入进来：\n@Component public class PriceContext { private final Map\u0026lt;String, PriceStrategy\u0026gt; strategyMap; @Autowired public PriceContext(List\u0026lt;PriceStrategy\u0026gt; strategies) { strategyMap = strategies.stream() .collect(Collectors.toMap(PriceStrategy::getType, s -\u0026gt; s)); } public double calculate(String type, double amount) { PriceStrategy strategy = strategyMap.get(type); if (strategy == null) { throw new IllegalArgumentException(\u0026#34;未知类型: \u0026#34; + type); } return strategy.calculate(amount); } } 这样每次加新策略，只需要新建一个类加上 @Component，其他地方一行都不用改。Spring 会自动把所有 PriceStrategy 的实现收集到 List 里注入。\n我觉得这个写法是真的优雅。第一次看到的时候有种\u0026quot;原来还能这么玩\u0026quot;的感觉。\n实际业务案例 # 说个我做过的真实场景：消息通知。系统要支持短信、邮件、站内信、企微推送多种通知方式。\n最初的写法，你猜对了，又是 if-else：\nif (\u0026#34;SMS\u0026#34;.equals(channel)) { sendSms(message); } else if (\u0026#34;EMAIL\u0026#34;.equals(channel)) { sendEmail(message); } else if (\u0026#34;WECHAT_WORK\u0026#34;.equals(channel)) { sendWechatWork(message); } 用策略模式重构后：\npublic interface NotifyStrategy { String getChannel(); void send(Message message); } @Component public class SmsNotifyStrategy implements NotifyStrategy { @Override public String getChannel() { return \u0026#34;SMS\u0026#34;; } @Override public void send(Message message) { // 调用短信 SDK } } 后来产品说要加钉钉通知。我新建了一个 DingTalkNotifyStrategy，写完逻辑加上 @Component，结束。原来的代码一行没动。组里大佬 code review 的时候还夸了一句，开心了好久哈哈。\n什么时候该用，什么时候别用 # 也别什么地方都上策略模式。如果你的 if-else 就两三个分支，而且以后基本不会变，那没必要折腾。过度设计比代码臭味更可怕。\n适合用策略模式的场景：\n分支多，而且经常要新增 各分支逻辑比较复杂，不是一两行能搞定的 希望各分支能独立测试 团队多人开发，不想互相冲突 其实吧，设计模式这东西，看着简单，用对时机才是关键。我之前学的时候光记概念没啥感觉，后来真正写项目才体会到它的好处。策略模式算是最实用的设计模式之一了，日常开发中用到的频率很高，强烈建议掌握。\n","date":"May 15, 2024","externalUrl":null,"permalink":"/posts/design-pattern-strategy/","section":"博客","summary":"策略模式干掉 if-else，代码瞬间清爽 # if-else 地狱 # 你写过这种代码吗：\n","title":"策略模式替代 if-else","type":"posts"},{"content":"","date":"May 15, 2024","externalUrl":null,"permalink":"/categories/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/","section":"Categories","summary":"","title":"设计模式","type":"categories"},{"content":"","date":"May 15, 2024","externalUrl":null,"permalink":"/tags/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F/","section":"Tags","summary":"","title":"设计模式","type":"tags"},{"content":"","date":"April 10, 2024","externalUrl":null,"permalink":"/tags/redis/","section":"Tags","summary":"","title":"Redis","type":"tags"},{"content":" Redis 分布式锁踩坑记 # 分布式锁这个东西，面试必问，实际开发也经常用。我做课程项目的时候在这上面栽了好几个跟头，今天把踩过的坑总结一下。\n最简单的实现：SETNX # 分布式锁的核心思路很简单：大家抢同一个 key，谁抢到谁执行。\nSETNX lock:order:1001 \u0026#34;locked\u0026#34; DEL lock:order:1001 Java 代码：\nBoolean locked = redisTemplate.opsForValue() .setIfAbsent(\u0026#34;lock:order:\u0026#34; + orderId, \u0026#34;1\u0026#34;); if (Boolean.TRUE.equals(locked)) { try { doSomething(); } finally { redisTemplate.delete(\u0026#34;lock:order:\u0026#34; + orderId); } } 看起来没问题？坑大了。\n过期时间的坑 # 假设拿到锁之后服务器挂了，finally 里的 delete 没执行到——死锁。\n所以得加过期时间：\nBoolean locked = redisTemplate.opsForValue() .setIfAbsent(\u0026#34;lock:order:\u0026#34; + orderId, \u0026#34;1\u0026#34;, 30, TimeUnit.SECONDS); 注意一定要用 SET key value EX seconds NX 原子命令。别先 SETNX 再 EXPIRE，中间挂了还是死锁。我第一次写就犯了这个错。\n但过期时间带来新问题：业务执行时间超过锁的过期时间咋办？锁过期了，别的线程拿到锁，两个线程同时在跑。\n还有个更隐蔽的坑：A 的锁过期了，B 拿到锁，然后 A 执行完去 DEL，删的其实是 B 的锁。\n解决误删要给 value 加唯一标识，删前先检查：\nString requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(\u0026#34;lock:order:\u0026#34; + orderId, requestId, 30, TimeUnit.SECONDS); // Lua 脚本保证原子性 String script = \u0026#34;if redis.call(\u0026#39;get\u0026#39;, KEYS[1]) == ARGV[1] then \u0026#34; + \u0026#34;return redis.call(\u0026#39;del\u0026#39;, KEYS[1]) else return 0 end\u0026#34;; redisTemplate.execute(new DefaultRedisScript\u0026lt;\u0026gt;(script, Long.class), Collections.singletonList(\u0026#34;lock:order:\u0026#34; + orderId), requestId); 为啥用 Lua？因为 GET 和 DEL 不是原子的，Lua 脚本在 Redis 里原子执行。\nRedisson 和看门狗机制 # 上面那些坑自己处理太累了。用 Redisson：\nRLock lock = redissonClient.getLock(\u0026#34;lock:order:\u0026#34; + orderId); try { if (lock.tryLock(5, TimeUnit.SECONDS)) { doSomething(); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } Redisson 最牛的是看门狗（Watchdog）机制。不指定 leaseTime 的话，它会启动后台线程，每 10 秒检查一下（默认锁超时 30 秒的 1/3），还持有锁就自动续期。\n这完美解决了\u0026quot;业务时间超过锁超时\u0026quot;的问题。业务没完，锁一直续；进程挂了，看门狗跟着没了，锁自然过期释放。\n我踩过一个坑：tryLock 指定了 leaseTime 之后看门狗不工作了。后来才知道指定了 leaseTime 就是你自己管超时，Redisson 不启动看门狗。想要自动续期就别传 leaseTime。\nRedLock——争议挺大的方案 # 普通 Redis 分布式锁有个根本问题：主从切换可能丢锁。A 在 master 拿到锁，master 挂了还没同步到 slave，slave 升为 master，B 又能拿到锁了。\nRedis 作者 antirez 提出了 RedLock：准备 N 个（建议 5 个）独立 Redis 实例，在多数实例上拿到锁才算成功。\n听起来靠谱？分布式系统大佬 Martin Kleppmann 写文章怼了 RedLock，核心观点是：\n依赖时钟，但分布式系统里时钟不可靠（NTP 漂移、GC 停顿等） 要强一致性的锁，应该用 ZooKeeper 或 etcd 这种有共识算法的 只是效率优化的话，单实例 Redis 锁就够了 antirez 也写了回应，两人来回怼了一轮。我个人觉得 Martin 说得有道理。实际中也很少见有人用 RedLock，大部分场景 Redisson 单实例锁就够了。真对一致性要求极高，上 ZooKeeper。\n小结 # 分布式锁的演进路线：\nSETNX → 会死锁 SETNX + EXPIRE → 不原子 SET NX EX → 可能误删 SET NX EX + Lua 删除 → 可能超时 Redisson 看门狗 → 基本够用 RedLock → 有争议，慎用 日常开发直接上 Redisson。记住：别指定 leaseTime（让看门狗干活），unlock 前检查是不是当前线程的锁。\n","date":"April 10, 2024","externalUrl":null,"permalink":"/posts/redis-distributed-lock/","section":"博客","summary":"Redis 分布式锁踩坑记 # 分布式锁这个东西，面试必问，实际开发也经常用。我做课程项目的时候在这上面栽了好几个跟头，今天把踩过的坑总结一下。\n最简单的实现：SETNX # 分布式锁的核心思路很简单：大家抢同一个 key，谁抢到谁执行。\n","title":"Redis 分布式锁笔记","type":"posts"},{"content":"","date":"April 10, 2024","externalUrl":null,"permalink":"/tags/%E5%88%86%E5%B8%83%E5%BC%8F/","section":"Tags","summary":"","title":"分布式","type":"tags"},{"content":"","date":"April 10, 2024","externalUrl":null,"permalink":"/tags/%E9%94%81/","section":"Tags","summary":"","title":"锁","type":"tags"},{"content":"","date":"April 10, 2024","externalUrl":null,"permalink":"/categories/%E4%B8%AD%E9%97%B4%E4%BB%B6/","section":"Categories","summary":"","title":"中间件","type":"categories"},{"content":"","date":"March 5, 2024","externalUrl":null,"permalink":"/tags/mybatis/","section":"Tags","summary":"","title":"MyBatis","type":"tags"},{"content":" MyBatis 一条 SQL 的执行之旅 # 从 Mapper 接口说起 # 用 MyBatis 写代码，日常就是定义一个 Mapper 接口，写个 XML 或注解，然后调用方法就完事了。但你有没有想过，你调 userMapper.selectById(1) 的时候，MyBatis 背后到底干了啥？\n我之前一直觉得这东西就是个黑盒，SQL 进去，对象出来，不用管就好。直到有次面试被问\u0026quot;说说 MyBatis 的执行流程\u0026quot;，我直接愣住了。回来之后认真翻了源码，发现整个流程其实挺清晰的，今天来捋一捋。\n简单说，你调 Mapper 方法，其实调的是一个 JDK 动态代理对象。MyBatis 在启动的时候会给每个 Mapper 接口生成代理，拦截方法调用，然后转到 SqlSession 去执行。\n// 你以为你在调接口方法 User user = userMapper.selectById(1); // 实际上等价于 User user = sqlSession.selectOne(\u0026#34;com.example.mapper.UserMapper.selectById\u0026#34;, 1); 所以 Mapper 接口本身没有实现类，全靠代理。这个设计挺巧妙的，让你写代码的时候感觉像在调普通方法。\nSqlSession 是什么 # SqlSession 是 MyBatis 的核心接口，相当于一次数据库会话。它提供了 selectOne、selectList、insert、update、delete 这些方法。\n不过 SqlSession 自己不干活，它把事情委托给 Executor。你可以把 SqlSession 理解成一个门面，真正干活的是后面的 Executor。\npublic class DefaultSqlSession implements SqlSession { private final Executor executor; @Override public \u0026lt;T\u0026gt; T selectOne(String statement, Object parameter) { List\u0026lt;T\u0026gt; list = this.selectList(statement, parameter); if (list.size() == 1) { return list.get(0); } // ... } } 话说回来，如果你用的是 Spring + MyBatis，SqlSession 的创建和关闭都被 SqlSessionTemplate 管了，你基本不用操心。\nExecutor 执行器 # Executor 是真正执行 SQL 的组件。MyBatis 提供了三种 Executor：\nSimpleExecutor：每次执行都创建新的 Statement，最朴素的方式 ReuseExecutor：会复用 Statement，减少创建开销 BatchExecutor：批量执行，适合大量 insert/update 的场景 默认用的是 SimpleExecutor。Executor 拿到 MappedStatement（就是你 XML 里那条 SQL 的所有配置信息），然后交给 StatementHandler 去处理。\n// Executor 的核心方法 public \u0026lt;E\u0026gt; List\u0026lt;E\u0026gt; query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) { BoundSql boundSql = ms.getBoundSql(parameter); CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } 注意这里有个 CacheKey，后面讲缓存会用到。\nStatementHandler 和参数映射 # StatementHandler 负责创建 JDBC 的 Statement，设置参数，执行 SQL。\n参数设置这块是 ParameterHandler 干的。它会把你传的 Java 对象映射成 SQL 的参数。这里用到了 TypeHandler，比如把 Java 的 String 映射成 VARCHAR，把 Date 映射成 TIMESTAMP。\n// ParameterHandler 设置参数的核心逻辑 public void setParameters(PreparedStatement ps) { for (int i = 0; i \u0026lt; parameterMappings.size(); i++) { ParameterMapping parameterMapping = parameterMappings.get(i); Object value = // 从参数对象中取值 TypeHandler typeHandler = parameterMapping.getTypeHandler(); typeHandler.setParameter(ps, i + 1, value, parameterMapping.getJdbcType()); } } 我之前踩过一个坑：参数是 Map 的时候，XML 里的 #{key} 要和 Map 的 key 对上，不然就是 null。debug 了半天才发现是 key 写错了，大小写不一致。这种问题 MyBatis 不会报错，就默默给你传个 null，很坑。\n结果集映射 # SQL 执行完，ResultSetHandler 负责把 JDBC 的 ResultSet 映射成 Java 对象。\n这个过程大概是：\n根据 resultMap 或 resultType 确定目标类型 创建目标对象（通过反射） 遍历每一列，用 TypeHandler 把数据库类型转成 Java 类型 通过反射设置属性值 如果你用了 resultMap 还配了 association 和 collection，那就涉及嵌套查询或嵌套结果集映射，逻辑会复杂不少。\n顺便提一下，MyBatis 的自动映射（autoMapping）默认是开的，它会尝试把列名和属性名匹配。驼峰命名转换可以通过 mapUnderscoreToCamelCase 配置开启，这个基本是必开的，不然数据库的 user_name 映射不到 Java 的 userName。\n一级缓存和二级缓存 # MyBatis 有两级缓存，面试也爱问这个。\n一级缓存在 SqlSession 级别，默认开启。同一个 SqlSession 里，相同的查询只会执行一次 SQL，第二次直接从缓存拿。\n// 同一个 SqlSession 内 User user1 = sqlSession.selectOne(\u0026#34;selectById\u0026#34;, 1); // 走数据库 User user2 = sqlSession.selectOne(\u0026#34;selectById\u0026#34;, 1); // 走缓存 // user1 == user2 是 true，同一个对象 但是有个坑：在 Spring 里，默认每个方法调用都是新的 SqlSession，所以一级缓存其实没啥用。除非你在同一个事务里多次查询，才能命中。\n二级缓存在 namespace 级别（也就是 Mapper 级别），需要手动开启。多个 SqlSession 可以共享。\n\u0026lt;!-- 在 Mapper XML 里加这个就开了 --\u0026gt; \u0026lt;cache /\u0026gt; 二级缓存听起来不错，但实际项目里很少用。原因是它的粒度太粗——整个 namespace 共享一个缓存，一有更新操作就全部失效。而且多表关联查询的时候，更新了 A 表但 B 的 Mapper 缓存不会失效，容易出脏数据。\n其实吧，缓存这事别靠 MyBatis，老老实实用 Redis 更靠谱。\n整体流程串一下 # 把整个流程串起来：\n调用 Mapper 接口方法（动态代理拦截） SqlSession 接收调用 Executor 执行查询（先查缓存） StatementHandler 创建 Statement ParameterHandler 设置参数 执行 SQL ResultSetHandler 映射结果 返回 Java 对象 说到这里，MyBatis 的设计还是挺清晰的，每个组件职责分明。理解了这个流程，看源码也不会太懵。面试的时候把这条链路讲清楚，基本就 OK 了。\n有兴趣的话可以自己打断点跟一遍，从 MapperProxy.invoke() 开始，一路跟到 JDBC 执行，印象会深刻很多。\n","date":"March 5, 2024","externalUrl":null,"permalink":"/posts/mybatis-execution-flow/","section":"博客","summary":"MyBatis 一条 SQL 的执行之旅 # 从 Mapper 接口说起 # 用 MyBatis 写代码，日常就是定义一个 Mapper 接口，写个 XML 或注解，然后调用方法就完事了。但你有没有想过，你调 userMapper.selectById(1) 的时候，MyBatis 背后到底干了啥？\n","title":"MyBatis SQL 执行流程","type":"posts"},{"content":"","date":"March 5, 2024","externalUrl":null,"permalink":"/tags/orm/","section":"Tags","summary":"","title":"ORM","type":"tags"},{"content":"","date":"March 5, 2024","externalUrl":null,"permalink":"/categories/%E6%A1%86%E6%9E%B6/","section":"Categories","summary":"","title":"框架","type":"categories"},{"content":" Redis 五种数据结构，各有各的妙用 # Redis 谁都用过吧？但我发现很多人（包括之前的我）用 Redis 就只会 SET 和 GET，把它当个 key-value 缓存使。其实 Redis 有五种基本数据结构，每种都有自己的应用场景，选对了效率翻倍，选错了可能还不如不用。\nString——最简单但别小看它 # String 是最基础的数据类型。一个 key 对应一个 value，value 最大 512MB。\n常见用法：\nSET user:token:1001 \u0026#34;abc123\u0026#34; EX 3600 # 存 token，1小时过期 GET user:token:1001 # 取 token INCR article:view:2001 # 文章浏览量 +1 INCRBY inventory:sku:3001 -1 # 库存 -1 底层编码有三种：\nint：值是整数且小于 2^63，直接存数字 embstr：字符串长度 \u0026lt;= 44 字节，一次内存分配 raw：字符串长度 \u0026gt; 44 字节，两次内存分配 实际项目里 String 最常用的就是缓存和计数器。我之前做选课系统的时候用 INCR 做并发计数，原子操作不用加锁，很方便。\nHash——存对象就用它 # Hash 适合存结构化数据：\nHSET user:1001 name \u0026#34;张三\u0026#34; age 21 major \u0026#34;计算机\u0026#34; HGET user:1001 name # 取单个字段 HGETALL user:1001 # 取所有字段 你可能会想，用 String 存 JSON 也能存对象啊，为啥要用 Hash？\n区别在于：String 存 JSON 改一个字段得把整个 JSON 读出来、改完再写回去。Hash 可以直接 HSET 改单个字段，省带宽也省操作。\n底层编码：字段少且值短时用 ziplist（Redis 7.0 改成 listpack），省内存；字段多了自动转 hashtable。\n有个坑：HGETALL 在字段特别多的时候会阻塞 Redis，因为 Redis 是单线程的。字段多的话用 HSCAN 分批取。\nList——队列和栈都能搞 # List 底层是 quicklist，ziplist 和链表的混合体。\nLPUSH queue:email \u0026#34;msg1\u0026#34; \u0026#34;msg2\u0026#34; # 左边插入 RPOP queue:email # 右边弹出 → 队列 LPOP queue:email # 左边弹出 → 栈 BRPOP queue:email 30 # 阻塞弹出，最多等30秒 BRPOP 是做消息队列的关键。没消息的时候客户端阻塞等待，比轮询省资源多了。\n不过用 List 做消息队列有个问题：消息取出来就没了，消费者挂了消息就丢。生产环境还是用 Stream 或者专业的 MQ 比较靠谱。课程项目里用 List 做过简单的异步任务队列，demo 级别够用。\nSet——去重和交并集 # Set 里元素不重复，支持集合运算：\nSADD like:article:2001 \u0026#34;user:1001\u0026#34; \u0026#34;user:1002\u0026#34; # 点赞 SISMEMBER like:article:2001 \u0026#34;user:1001\u0026#34; # 是否点过赞 SCARD like:article:2001 # 点赞数 SINTER follow:1001 follow:1002 # 共同关注 SDIFF follow:1001 follow:1002 # 我关注ta没关注的 微博的共同关注、可能认识的人，底层就可以用 Set 的交集差集来算。\n底层编码：元素少且都是整数时用 intset，否则用 hashtable。\nZSet——排行榜神器 # ZSet 是我觉得 Redis 最有意思的数据结构。每个元素带个 score，按 score 排序：\nZADD ranking:game 1000 \u0026#34;player:A\u0026#34; 800 \u0026#34;player:B\u0026#34; 1200 \u0026#34;player:C\u0026#34; ZREVRANGE ranking:game 0 9 WITHSCORES # Top 10（分数从高到低） ZRANK ranking:game \u0026#34;player:B\u0026#34; # 排名（从0开始） ZINCRBY ranking:game 50 \u0026#34;player:B\u0026#34; # 加分 排行榜、热搜、延迟队列，都可以用 ZSet 搞定。\n底层编码：元素少时用 ziplist，多了用 skiplist（跳表）加 hashtable。跳表查找效率跟平衡二叉树差不多，但实现简单得多。Redis 选跳表而不是红黑树，据说是 antirez 觉得跳表代码更好写好调试。\n说到实际应用，我在做一个校园二手交易平台的时候，用 ZSet 做了\u0026quot;最近发布\u0026quot;功能。score 用时间戳，ZREVRANGEBYSCORE 按时间倒序取，比数据库 ORDER BY 快不少。\n选型总结 # 数据结构 适合场景 底层编码 String 缓存、计数器、分布式锁 int/embstr/raw Hash 对象属性、购物车 ziplist/hashtable List 消息队列、最新列表 quicklist Set 去重、社交关系、抽奖 intset/hashtable ZSet 排行榜、延迟队列、热搜 ziplist/skiplist+hashtable 选数据结构的时候想想你的查询模式。要精确查找用 Hash，要排序用 ZSet，要去重用 Set。别什么都用 String 塞 JSON，那是暴殄天物。\n","date":"February 20, 2024","externalUrl":null,"permalink":"/posts/redis-data-structures/","section":"博客","summary":"Redis 五种数据结构，各有各的妙用 # Redis 谁都用过吧？但我发现很多人（包括之前的我）用 Redis 就只会 SET 和 GET，把它当个 key-value 缓存使。其实 Redis 有五种基本数据结构，每种都有自己的应用场景，选对了效率翻倍，选错了可能还不如不用。\nString——最简单但别小看它 # String 是最基础的数据类型。一个 key 对应一个 value，value 最大 512MB。\n","title":"Redis 五种数据结构","type":"posts"},{"content":"","date":"February 20, 2024","externalUrl":null,"permalink":"/tags/%E7%BC%93%E5%AD%98/","section":"Tags","summary":"","title":"缓存","type":"tags"},{"content":"","date":"February 20, 2024","externalUrl":null,"permalink":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/","section":"Tags","summary":"","title":"数据结构","type":"tags"},{"content":" Stream API 用起来真香，但别乱用 # 自从学了 Stream API，写 Java 的风格变了不少。以前过滤加转换要写十几行 for 循环，现在一行链式搞定，确实香。\n但用了一段时间也踩了不少坑，特别是并行流和性能方面，今天来聊聊。\n基本操作过一遍 # Stream 操作分两种：中间操作（返回新 Stream，可以链式调用）和终端操作（触发计算，产出结果）。\nList\u0026lt;String\u0026gt; names = students.stream() .filter(s -\u0026gt; s.getScore() \u0026gt; 80) // 过滤 .map(Student::getName) // 转换 .sorted() // 排序 .distinct() // 去重 .limit(10) // 取前10 .collect(Collectors.toList()); // 收集结果 几个常用的终端操作：\n// 收集到 Map Map\u0026lt;Long, Student\u0026gt; map = students.stream() .collect(Collectors.toMap(Student::getId, s -\u0026gt; s)); // 分组 Map\u0026lt;String, List\u0026lt;Student\u0026gt;\u0026gt; grouped = students.stream() .collect(Collectors.groupingBy(Student::getClassName)); // 归约 int total = numbers.stream().reduce(0, Integer::sum); // 匹配和查找 boolean allMatch = stream.allMatch(predicate); Optional\u0026lt;T\u0026gt; first = stream.findFirst(); 几个实用的写法 # toMap key 冲突处理\nMap\u0026lt;String, Student\u0026gt; map = students.stream() .collect(Collectors.toMap(Student::getName, s -\u0026gt; s, (s1, s2) -\u0026gt; s1)); 不加第三个参数，key 重复直接抛 IllegalStateException。我被这个坑过——测试数据没重名的，上线后有重名数据直接炸了。\nflatMap 展开嵌套\nList\u0026lt;Student\u0026gt; allStudents = classes.stream() .flatMap(c -\u0026gt; c.getStudents().stream()) .collect(Collectors.toList()); map 是一对一转换，flatMap 是一对多再铺平。像拆嵌套盒子全摊开。\npeek 调试\nlist.stream() .filter(s -\u0026gt; s.length() \u0026gt; 3) .peek(s -\u0026gt; System.out.println(\u0026#34;过滤后: \u0026#34; + s)) .map(String::toUpperCase) .collect(Collectors.toList()); peek 不改变元素，适合链条中间插入 debug 输出。但别在生产代码里用 peek 做有副作用的事。\n并行流的坑 # .parallelStream() 看着美好，坑不少。\n坑一：共享可变状态\n// 错误示范 List\u0026lt;String\u0026gt; result = new ArrayList\u0026lt;\u0026gt;(); list.parallelStream().forEach(s -\u0026gt; result.add(s)); // 线程不安全！ ArrayList 不是线程安全的，多线程 add 会丢数据甚至抛异常。正确做法是用 collect。\n坑二：全局共享 ForkJoinPool\n并行流默认用 ForkJoinPool.commonPool()，所有并行流共享。某个流里任务很慢会拖累其他的。\n想用独立线程池：\nForkJoinPool customPool = new ForkJoinPool(4); customPool.submit(() -\u0026gt; list.parallelStream().forEach(s -\u0026gt; process(s)) ).get(); 坑三：数据量小反而更慢\n并行有线程调度开销。数据量几百几千的时候串行比并行快。我测过一个 200 元素的列表，parallel 反而慢了两倍。\n性能到底怎么样 # Stream 比 for 循环慢吗？看情况。\n简单操作（遍历、求和），差距很小。IntStream 这类原始类型流基本能跟 for 持平。\n复杂操作（多级 filter + map + collect），Stream 可能稍慢，因为有中间对象创建和函数调用开销。但通常在 10%-20% 以内。\n大数据量 + 并行流，Stream 可能比手写循环快，ForkJoinPool 能自动利用多核。\n实际开发中可读性比这点性能差异重要得多。除非你在写每秒调用几百万次的热路径，不用纠结这个。\n什么时候该用什么时候不该用 # 适合用：\n集合的过滤、转换、聚合 数据量不大，可读性优先 分组、统计场景（groupingBy、counting） 不太适合：\n循环中需要修改外部变量（Stream 不鼓励副作用） 逻辑复杂、lambda 嵌套很深（可读性反而变差） 需要 break/continue/return 的循环 需要用到下标的遍历 有个判断标准挺实用：Stream 链条超过 5-6 个操作，或者 lambda 逻辑超过 3 行，就该考虑拆成方法引用或回到 for 循环。可读性永远排第一位。\n","date":"January 18, 2024","externalUrl":null,"permalink":"/posts/java-stream-api/","section":"博客","summary":"Stream API 用起来真香，但别乱用 # 自从学了 Stream API，写 Java 的风格变了不少。以前过滤加转换要写十几行 for 循环，现在一行链式搞定，确实香。\n但用了一段时间也踩了不少坑，特别是并行流和性能方面，今天来聊聊。\n基本操作过一遍 # Stream 操作分两种：中间操作（返回新 Stream，可以链式调用）和终端操作（触发计算，产出结果）。\n","title":"Java Stream API 使用笔记","type":"posts"},{"content":"","date":"January 18, 2024","externalUrl":null,"permalink":"/categories/java%E5%9F%BA%E7%A1%80/","section":"Categories","summary":"","title":"Java基础","type":"categories"},{"content":"","date":"January 18, 2024","externalUrl":null,"permalink":"/tags/stream/","section":"Tags","summary":"","title":"Stream","type":"tags"},{"content":"","date":"January 18, 2024","externalUrl":null,"permalink":"/tags/%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BC%96%E7%A8%8B/","section":"Tags","summary":"","title":"函数式编程","type":"tags"},{"content":" 反射慢？慢多少你测过吗 # \u0026ldquo;反射很慢，尽量别用。\u0026ldquo;这话你肯定听过。但到底慢多少？慢在哪？大部分人说不清楚，我之前也说不清楚，干脆自己测了一下。\n怎么获取 Class 对象 # 反射的入口是 Class 对象，三种方式：\n// 方式1：编译期就确定了 Class\u0026lt;?\u0026gt; clazz = String.class; // 方式2：从已有对象获取 Class\u0026lt;?\u0026gt; clazz = \u0026#34;hello\u0026#34;.getClass(); // 方式3：运行时按类名加载 Class\u0026lt;?\u0026gt; clazz = Class.forName(\u0026#34;java.lang.String\u0026#34;); 方式 1 和 2 编译期就确定类型了，方式 3 运行时按名字找，最灵活也最慢。\n拿到 Class 之后就能获取方法、字段、构造器：\nMethod method = clazz.getDeclaredMethod(\u0026#34;substring\u0026#34;, int.class); Field[] fields = clazz.getDeclaredFields(); Object obj = clazz.getDeclaredConstructor().newInstance(); getDeclaredXxx 拿本类声明的（包括 private），getXxx 拿 public 的（包括继承的）。这个区别我之前搞混过，拿不到 private 字段 debug 了半天。\nMethod.invoke 干了啥 # Method method = clazz.getDeclaredMethod(\u0026#34;length\u0026#34;); Object result = method.invoke(\u0026#34;hello\u0026#34;); // 返回 5 private 方法要先 method.setAccessible(true) 开权限。\ninvoke 底层干了几件事：\n检查访问权限 检查参数类型匹配 调用次数不到 15 次时走 JNI（native 实现） 超过 15 次后 JVM 动态生成字节码类来做调用 前 15 次用 native 是因为生成字节码本身有成本，调用少的话不划算。阈值可以通过 -Dsun.reflect.inflationThreshold 调。\n实测到底慢多少 # 写了段简单的 benchmark：\npublic class ReflectionBenchmark { public static void main(String[] args) throws Exception { MyService service = new MyService(); Method method = MyService.class.getDeclaredMethod(\u0026#34;doSomething\u0026#34;); method.setAccessible(true); // 预热，让 JIT 充分优化 for (int i = 0; i \u0026lt; 100000; i++) { service.doSomething(); method.invoke(service); } int times = 10_000_000; long start = System.nanoTime(); for (int i = 0; i \u0026lt; times; i++) { service.doSomething(); } long directTime = System.nanoTime() - start; start = System.nanoTime(); for (int i = 0; i \u0026lt; times; i++) { method.invoke(service); } long reflectTime = System.nanoTime() - start; System.out.println(\u0026#34;直接调用: \u0026#34; + directTime / 1_000_000 + \u0026#34;ms\u0026#34;); System.out.println(\u0026#34;反射调用: \u0026#34; + reflectTime / 1_000_000 + \u0026#34;ms\u0026#34;); } } 我电脑上（JDK 11）跑了几次，反射大概比直接调用慢 3-5 倍。这是已经过了 15 次阈值、用上动态字节码之后的数据。\n3-5 倍听着吓人，但看绝对值——一千万次反射调用也就几十毫秒。你的业务逻辑、数据库查询花的时间比这大好几个数量级。\n为什么反射慢 # 几个原因：\n没法内联。 JIT 对直接调用可以做方法内联，把目标方法代码直接嵌到调用处。反射的目标方法不确定，JIT 做不了这个优化。\n装箱拆箱。 invoke 参数是 Object 数组，基本类型要装箱，返回值也是 Object，可能还要拆箱。\n权限检查。 每次 invoke 都检查访问权限。setAccessible(true) 之后能省掉这个开销。\n方法查找。 getDeclaredMethod 每次都在方法列表里搜索。所以 Method 对象一定要缓存复用，别每次调用都重新获取。\nSpring 里的反射 # 你可能觉得反射离业务代码很远，其实 Spring 到处在用。\n依赖注入：@Autowired 底层就是拿到 Field，setAccessible(true)，然后 field.set(bean, value)。\nAOP：JDK 动态代理的 InvocationHandler.invoke 最终通过 Method.invoke 调用目标方法。\nBean 创建：Spring 容器用反射调构造器创建 Bean 实例。\n注解扫描：@Controller、@Service 这些注解，启动时通过反射读取类上的注解信息。\nSpring 整个框架建立在反射之上。启动时用反射做初始化，运行时热路径尽量避开反射，这是一种合理的权衡。\n话说回来，如果你对性能有极致要求（比如写序列化框架），可以看看 MethodHandle 或者直接用字节码生成（ASM、Javassist 之类的）。但大部分业务代码，反射性能完全够用，别过早优化。\n","date":"October 25, 2023","externalUrl":null,"permalink":"/posts/java-reflection/","section":"博客","summary":"反射慢？慢多少你测过吗 # “反射很慢，尽量别用。“这话你肯定听过。但到底慢多少？慢在哪？大部分人说不清楚，我之前也说不清楚，干脆自己测了一下。\n怎么获取 Class 对象 # 反射的入口是 Class 对象，三种方式：\n","title":"Java 反射机制与性能","type":"posts"},{"content":"","date":"October 25, 2023","externalUrl":null,"permalink":"/tags/%E5%8F%8D%E5%B0%84/","section":"Tags","summary":"","title":"反射","type":"tags"},{"content":"","date":"September 5, 2023","externalUrl":null,"permalink":"/tags/spring-boot/","section":"Tags","summary":"","title":"Spring Boot","type":"tags"},{"content":" Spring Boot 自动配置，看完你就不慌了 # 用 Spring Boot 写项目的时候，你有没有想过：我就加了个 spring-boot-starter-web 依赖，Tomcat 怎么就自己跑起来了？数据源我就配了个 URL，连接池怎么就自动用上 HikariCP 了？\n这背后全是自动配置在搞事情。\n@SpringBootApplication 背后藏了什么 # 启动类上那个 @SpringBootApplication，其实是个组合注解：\n@SpringBootConfiguration // 本质就是 @Configuration @EnableAutoConfiguration // 关键！开启自动配置 @ComponentScan // 包扫描 public @interface SpringBootApplication { } 重点是 @EnableAutoConfiguration。它里面用了 @Import(AutoConfigurationImportSelector.class)，这个 Selector 负责加载所有自动配置类。\n怎么加载？读 META-INF/spring.factories 文件。Spring Boot 3.x 改成了 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports，但思路一样。\nspring.factories 是怎么回事 # 这个文件在每个 starter 的 jar 包里都有。去解压 spring-boot-autoconfigure 这个 jar，就能看到里面的 spring.factories，密密麻麻写了一堆配置类：\norg.springframework.boot.autoconfigure.EnableAutoConfiguration=\\ org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration,\\ org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\\ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\\ ... 启动的时候 Spring Boot 把这些类全加载进来。你可能想：这么多配置类全加载，不会很慢吗？别急，看下面。\n条件注解——按需加载的秘密 # 虽然列了上百个配置类，但不是每个都会生效。Spring Boot 用条件注解控制：\n注解 含义 @ConditionalOnClass classpath 里有某个类才生效 @ConditionalOnMissingClass classpath 里没某个类才生效 @ConditionalOnBean 容器里有某个 Bean 才生效 @ConditionalOnMissingBean 容器里没某个 Bean 才生效 @ConditionalOnProperty 配置文件里有某个属性才生效 举个例子，RedisAutoConfiguration 的源码大概这样：\n@Configuration @ConditionalOnClass(RedisOperations.class) @EnableConfigurationProperties(RedisProperties.class) public class RedisAutoConfiguration { @Bean @ConditionalOnMissingBean(name = \u0026#34;redisTemplate\u0026#34;) public RedisTemplate\u0026lt;Object, Object\u0026gt; redisTemplate( RedisConnectionFactory factory) { RedisTemplate\u0026lt;Object, Object\u0026gt; template = new RedisTemplate\u0026lt;\u0026gt;(); template.setConnectionFactory(factory); return template; } } @ConditionalOnClass(RedisOperations.class) 表示只有引了 Redis 依赖，这个配置类才生效。@ConditionalOnMissingBean 表示你自己定义了 redisTemplate 的话，Spring Boot 就不会再创建一个。\n这就是\u0026quot;约定大于配置\u0026quot;——默认值都配好了，你不满意再覆盖。\n我之前遇到一个问题，项目里明明引了 Redis 依赖，但 RedisTemplate 注入失败。查了半天，发现是 spring-boot-starter-data-redis 版本跟 Spring Boot 不匹配，导致 RedisOperations 类没加载进来。条件注解一判断这类不存在，整个配置直接跳过。报错信息还是 \u0026ldquo;No qualifying bean\u0026rdquo;，很容易往错误方向排查。\n手撸一个自定义 Starter # 理解了原理，自己写一个 Starter 也不难。我之前做课程项目的时候写过一个短信发送的 Starter，过程大概这样：\n1. 创建配置属性类\n@ConfigurationProperties(prefix = \u0026#34;sms\u0026#34;) public class SmsProperties { private String accessKey; private String secretKey; private String signName; // getter setter... } 2. 写核心服务类\npublic class SmsService { private final SmsProperties properties; public SmsService(SmsProperties properties) { this.properties = properties; } public void send(String phone, String code, Map\u0026lt;String, String\u0026gt; params) { // 调用短信 API 发送 } } 3. 写自动配置类\n@Configuration @ConditionalOnClass(SmsService.class) @EnableConfigurationProperties(SmsProperties.class) public class SmsAutoConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = \u0026#34;sms\u0026#34;, name = \u0026#34;access-key\u0026#34;) public SmsService smsService(SmsProperties properties) { return new SmsService(properties); } } 4. 注册到 spring.factories\norg.springframework.boot.autoconfigure.EnableAutoConfiguration=\\ com.example.sms.SmsAutoConfiguration 别人引你的 starter，只需要在 application.yml 里配上 accessKey，SmsService 就自动可用了。\ndebug 小技巧 # 想看哪些自动配置生效了、哪些没生效？两个办法：\n启动时加 --debug 参数，或者配置 debug=true，日志会打印 ConditionEvaluationReport 引入 spring-boot-actuator，访问 /actuator/conditions 端点 排查\u0026quot;为啥这个 Bean 没注入\u0026quot;的时候特别好使，我经常用第一种。\n小结 # Spring Boot 自动配置的核心链路：@EnableAutoConfiguration 触发 → 读 spring.factories 找配置类 → 条件注解控制生效。搞清楚这个，遇到 Bean 注入的问题就不慌了。\n","date":"September 5, 2023","externalUrl":null,"permalink":"/posts/springboot-auto-config/","section":"博客","summary":"Spring Boot 自动配置，看完你就不慌了 # 用 Spring Boot 写项目的时候，你有没有想过：我就加了个 spring-boot-starter-web 依赖，Tomcat 怎么就自己跑起来了？数据源我就配了个 URL，连接池怎么就自动用上 HikariCP 了？\n这背后全是自动配置在搞事情。\n@SpringBootApplication 背后藏了什么 # 启动类上那个 @SpringBootApplication，其实是个组合注解：\n","title":"Spring Boot 自动配置原理","type":"posts"},{"content":"","date":"September 5, 2023","externalUrl":null,"permalink":"/tags/%E8%87%AA%E5%8A%A8%E9%85%8D%E7%BD%AE/","section":"Tags","summary":"","title":"自动配置","type":"tags"},{"content":" Java 泛型这几个坑你肯定踩过 # Java 泛型这东西，看着简单用着顺手，直到踩坑才发现自己根本没搞懂。上周写作业一个泛型编译错误折腾了我一个多小时，气得把泛型重新学了一遍。\n类型擦除到底擦了啥 # Java 的泛型是假泛型。编译之后泛型信息就没了，全变成 Object（或上界类型），这叫类型擦除。\nList\u0026lt;String\u0026gt; 和 List\u0026lt;Integer\u0026gt;，到了 JVM 眼里都是 List：\nList\u0026lt;String\u0026gt; strList = new ArrayList\u0026lt;\u0026gt;(); List\u0026lt;Integer\u0026gt; intList = new ArrayList\u0026lt;\u0026gt;(); System.out.println(strList.getClass() == intList.getClass()); // true 泛型只在编译期做类型检查，运行时完全不知道 T 是啥。\n所以这些事都做不了：\nnew T(); // 不知道 T 是啥，没法 new new T[10]; // 不能创建泛型数组 T.class; // 拿不到 T 的 Class instanceof T; // 运行时不认识 T 想在泛型方法里创建实例？得把 Class\u0026lt;T\u0026gt; 传进去：\npublic \u0026lt;T\u0026gt; T create(Class\u0026lt;T\u0026gt; clazz) throws Exception { return clazz.newInstance(); } 本质就是擦除之后 T 没了，你得自己把类型信息带进来。\n通配符 ? extends 和 ? super # 这块是泛型里最让人头疼的部分。\n? extends T 表示\u0026quot;T 或 T 的子类\u0026quot;（上界通配符）：\nList\u0026lt;? extends Number\u0026gt; list = new ArrayList\u0026lt;Integer\u0026gt;(); // OK Number n = list.get(0); // OK list.add(1); // 编译错误！ 为什么不能 add？编译器只知道里面是\u0026quot;某种 Number 的子类\u0026quot;，不知道具体哪种。万一实际是 List\u0026lt;Double\u0026gt;，你放 Integer 就出事。编译器干脆不让你放。\n? super T 表示\u0026quot;T 或 T 的父类\u0026quot;（下界通配符）：\nList\u0026lt;? super Integer\u0026gt; list = new ArrayList\u0026lt;Number\u0026gt;(); // OK list.add(1); // OK Integer i = list.get(0); // 编译错误，只能当 Object 取 能 add 是因为不管实际是 Integer 的哪个父类，Integer 肯定能放进去。但取出来就只能当 Object 了。\nPECS 原则 # Producer Extends, Consumer Super。\n只读（生产者）用 ? extends T，只写（消费者）用 ? super T。\n经典例子是 Collections.copy：\npublic static \u0026lt;T\u0026gt; void copy(List\u0026lt;? super T\u0026gt; dest, List\u0026lt;? extends T\u0026gt; src) { // src 只读，用 extends // dest 只写，用 super } PECS 这个助记词挺好使的，面试直接甩出来一般就不会再深究了。\n泛型方法怎么写 # 泛型方法的 \u0026lt;T\u0026gt; 放在返回值前面：\npublic \u0026lt;T\u0026gt; T getFirst(List\u0026lt;T\u0026gt; list) { return list.get(0); } 这里的 T 跟类上的泛型参数没关系，是方法自己声明的。调用时编译器自动推断 T 的类型。\n常见场景是写通用工具方法：\npublic \u0026lt;T extends Comparable\u0026lt;T\u0026gt;\u0026gt; T max(T a, T b) { return a.compareTo(b) \u0026gt;= 0 ? a : b; } T extends Comparable\u0026lt;T\u0026gt; 限定 T 必须实现 Comparable，不然没法调 compareTo。\n我之前犯过一个错：把泛型方法和泛型类搞混了。泛型方法的 \u0026lt;T\u0026gt; 声明在方法上，普通类里也能写，不需要类本身是泛型的。\n一些容易翻车的细节 # 泛型数组\n不能 new T[10]，但可以声明泛型数组引用：\nList\u0026lt;String\u0026gt;[] array = new ArrayList[10]; // 可以，但有警告 原因是数组是协变的（String[] 是 Object[] 的子类），泛型是不变的（List\u0026lt;String\u0026gt; 不是 List\u0026lt;Object\u0026gt; 的子类）。允许 new 泛型数组的话类型安全就没法保证了。\n桥方法\n类型擦除后可能出现方法签名冲突，编译器会自动生成桥方法：\npublic class MyList implements Comparable\u0026lt;MyList\u0026gt; { public int compareTo(MyList o) { return 0; } // 编译器生成： // public int compareTo(Object o) { return compareTo((MyList) o); } } 日常不需要手动处理，但面试偶尔问到。\n通配符捕获\n有时候会碰到编译错误 \u0026ldquo;capture of ?\u0026quot;，通常是因为你试图把 ? 当具体类型用了。解决办法是写个辅助泛型方法把 ? 捕获成具体的类型参数。\n说实话泛型细节挺碎的，但日常用到的也就那几个套路。把类型擦除、PECS、泛型方法搞明白，基本够用了。\n","date":"August 12, 2023","externalUrl":null,"permalink":"/posts/java-generics/","section":"博客","summary":"Java 泛型这几个坑你肯定踩过 # Java 泛型这东西，看着简单用着顺手，直到踩坑才发现自己根本没搞懂。上周写作业一个泛型编译错误折腾了我一个多小时，气得把泛型重新学了一遍。\n类型擦除到底擦了啥 # Java 的泛型是假泛型。编译之后泛型信息就没了，全变成 Object（或上界类型），这叫类型擦除。\n","title":"Java 泛型常见问题","type":"posts"},{"content":"","date":"August 12, 2023","externalUrl":null,"permalink":"/tags/%E6%B3%9B%E5%9E%8B/","section":"Tags","summary":"","title":"泛型","type":"tags"},{"content":"","date":"July 20, 2023","externalUrl":null,"permalink":"/tags/jmm/","section":"Tags","summary":"","title":"JMM","type":"tags"},{"content":" volatile 到底能保证什么 # Java 内存模型简单说 # 聊 volatile 之前，得先说说 Java 内存模型（JMM）。放心，不讲太深，够理解 volatile 就行。\nJMM 规定了线程和内存之间的关系。简单理解：每个线程有自己的工作内存（你可以想象成 CPU 缓存），线程操作变量不是直接改主内存，而是先拷贝一份到工作内存，改完再写回去。\n这就埋下了隐患——线程 A 改了变量，线程 B 可能看不到，因为 B 还在用自己工作内存里的旧值。\n主内存: x = 0 线程A工作内存: x = 0 → x = 1 → 写回主内存 线程B工作内存: x = 0 → 还是0！没刷新！ 这就是可见性问题。\n可见性：volatile 的第一个保证 # 来看个经典例子：\npublic class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { new Thread(() -\u0026gt; { while (flag) { // 忙等待 } System.out.println(\u0026#34;线程退出\u0026#34;); }).start(); Thread.sleep(1000); flag = false; System.out.println(\u0026#34;已设置 flag = false\u0026#34;); } } 你觉得子线程能退出吗？答案是：不一定。JIT 编译器可能把 while(flag) 优化成 while(true)，因为它发现当前线程里没人改 flag。\n我当时学并发的时候写了这个 demo，在本地跑真的不退出，挺震撼的。加上 volatile 就好了：\nprivate static volatile boolean flag = true; volatile 保证了可见性：一个线程修改了 volatile 变量，其他线程能立刻看到最新值。\n有序性：volatile 的第二个保证 # Java 编译器和处理器为了优化性能，可能会对指令重排序。单线程下重排不影响结果（as-if-serial 语义），但多线程下就可能出事。\n经典例子：\nint a = 0; boolean flag = false; // 线程A a = 1; // 语句1 flag = true; // 语句2 // 线程B if (flag) { int b = a; // 期望 b = 1，但可能是 0 } 线程 A 里语句 1 和语句 2 可能被重排，先执行 flag = true，再执行 a = 1。线程 B 看到 flag 为 true 了，但 a 还是 0。\nvolatile 能禁止特定的重排序。对 volatile 变量的写操作，会保证写之前的操作不会被重排到写之后。\nhappens-before 规则 # JMM 用 happens-before 关系来描述操作间的内存可见性。如果 A happens-before B，那 A 的结果对 B 可见。\n跟 volatile 相关的规则：对一个 volatile 变量的写 happens-before 后续对它的读。\n加上传递性就够用了：\na = 1 happens-before flag = true（程序顺序规则） flag = true happens-before 线程 B 读 flag（volatile 规则） 所以 a = 1 happens-before 线程 B 读 a（传递性） happens-before 这个概念比较抽象，但理解它对搞懂并发很关键。\n底层实现：内存屏障 # volatile 在底层通过内存屏障（Memory Barrier）实现。\n写 volatile 时，JVM 在写之前插入 StoreStore 屏障，写之后插入 StoreLoad 屏障。读 volatile 时，读之后插入 LoadLoad 和 LoadStore 屏障。\nStoreStore：保证写 volatile 之前的写操作先完成 StoreLoad：保证写 volatile 对其他处理器可见（最重的屏障） LoadLoad：保证读 volatile 之后的读不会重排到前面 LoadStore：保证读 volatile 之后的写不会重排到前面 在 x86 架构上，其实只有 StoreLoad 需要真正的屏障指令，其他的硬件本身就保证了。所以 x86 上 volatile 的性能开销没那么夸张。\nDCL 单例中 volatile 的作用 # 双重检查锁单例为什么需要 volatile？来看代码：\npublic class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 关键 } } } return instance; } } instance = new Singleton() 在字节码层面大概三步：\n分配内存空间 调用构造方法初始化 把引用赋值给 instance 不加 volatile 的话，步骤 2 和 3 可能被重排成 3、2。线程 B 在第一个 if 判断时看到 instance 不为 null，直接返回了一个还没初始化完的对象。用了就 NPE 或者各种诡异 bug。\n加了 volatile，禁止了这个重排序，赋值一定在初始化之后。\n顺便说一句，这个坑在 Java 5 之前是没法靠 volatile 解决的。Java 5（JSR-133）增强了 volatile 语义之后，DCL 才真正安全了。\nvolatile 不能保证原子性 # 很多人以为 volatile 就是线程安全的，这是个常见误区。volatile 只保证可见性和有序性，不保证原子性。\nprivate static volatile int count = 0; // 10个线程各执行1000次 count++; // 这不是原子操作！ count++ 实际上是读-改-写三步。就算 volatile 保证每次读到最新值，两个线程还是可能同时读到同一个值，然后各自加 1 写回，等于少加了一次。\n要原子性，用 AtomicInteger 或者加锁：\nprivate static AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); // CAS，原子操作 总结一下 # volatile 能保证：\n可见性：修改后其他线程立刻可见 有序性：禁止指令重排 volatile 不能保证：\n原子性：复合操作还是不安全 适用场景：状态标志位、DCL 中防止重排序、一写多读。多写场景还是得上锁或用原子类。\n其实吧，volatile 这个关键字看着简单，背后涉及的东西不少。但只要抓住\u0026quot;可见性 + 有序性\u0026quot;这两个点，再理解 happens-before 和内存屏障，就差不多了。面试够用，写代码也不会踩坑。\n","date":"July 20, 2023","externalUrl":null,"permalink":"/posts/java-memory-model-volatile/","section":"博客","summary":"volatile 到底能保证什么 # Java 内存模型简单说 # 聊 volatile 之前，得先说说 Java 内存模型（JMM）。放心，不讲太深，够理解 volatile 就行。\n","title":"volatile 与 Java 内存模型","type":"posts"},{"content":"","date":"July 20, 2023","externalUrl":null,"permalink":"/tags/%E5%B9%B6%E5%8F%91/","section":"Tags","summary":"","title":"并发","type":"tags"},{"content":"","date":"June 15, 2023","externalUrl":null,"permalink":"/tags/aop/","section":"Tags","summary":"","title":"AOP","type":"tags"},{"content":"","date":"June 15, 2023","externalUrl":null,"permalink":"/tags/spring/","section":"Tags","summary":"","title":"Spring","type":"tags"},{"content":" AOP 这东西，说难不难说简单不简单 # AOP，面向切面编程。第一次听到这个词的时候我满脸问号——什么切面？切什么？\n后来我换了个理解方式就通了：你有一堆业务方法，想在每个方法执行前后都打个日志，怎么办？一个个方法里加 log.info()？那要是有 200 个方法呢？改到吐。AOP 就是帮你把这种\u0026quot;横切\u0026quot;的逻辑抽出来，统一处理。\n什么是 AOP，为什么需要它 # OOP 的核心是纵向的继承和封装，但有些逻辑是横向的——日志、事务、权限校验，跟具体业务没关系，但到处都要用。如果每个方法都写一遍，代码重复不说，后续改起来也要命。\nAOP 的思路是：你定义好在\u0026quot;哪些方法\u0026quot;的\u0026quot;什么时机\u0026quot;执行\u0026quot;什么逻辑\u0026quot;，框架帮你织入。业务代码完全不用动。\n动态代理：JDK vs CGLIB # AOP 的底层实现靠动态代理。Spring 用了两种：\nJDK 动态代理：基于接口。你的类必须实现一个接口，代理对象也实现这个接口，通过 InvocationHandler 拦截方法调用。\npublic class MyInvocationHandler implements InvocationHandler { private Object target; public MyInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(\u0026#34;方法执行前\u0026#34;); Object result = method.invoke(target, args); System.out.println(\u0026#34;方法执行后\u0026#34;); return result; } } CGLIB 动态代理：基于继承。不需要接口，直接继承目标类，重写方法来拦截。底层用 ASM 字节码框架。\n那 Spring 怎么选？目标类实现了接口就用 JDK 代理，没实现接口就用 CGLIB。不过 Spring Boot 2.x 之后默认全用 CGLIB 了，因为 JDK 代理有时候有类型转换的坑。\n我之前碰到过一个诡异的 bug：明明注入的是接口类型，强转成实现类就报 ClassCastException。原因就是 JDK 代理生成的代理类跟实现类没有继承关系，你当然转不了。换成 CGLIB 就好了。\n切面、切点、通知——概念别搞混 # 这几个概念刚学的时候容易绕，大白话说一下：\n切面（Aspect）：就是你写的那个类，里面定义了\u0026quot;在哪里做什么\u0026quot; 切点（Pointcut）：定义\u0026quot;在哪里\u0026quot;，用表达式匹配目标方法 通知（Advice）：定义\u0026quot;做什么\u0026quot;以及\u0026quot;什么时候做\u0026quot; 通知有五种类型：\n类型 注解 执行时机 前置通知 @Before 方法执行前 后置通知 @After 方法执行后（不管是否异常） 返回通知 @AfterReturning 方法正常返回后 异常通知 @AfterThrowing 方法抛异常后 环绕通知 @Around 包裹目标方法，最强大 一个完整的切面长这样：\n@Aspect @Component public class LogAspect { @Pointcut(\u0026#34;execution(* com.example.service.*.*(..))\u0026#34;) public void servicePointcut() {} @Around(\u0026#34;servicePointcut()\u0026#34;) public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); String methodName = pjp.getSignature().getName(); log.info(\u0026#34;开始执行: {}\u0026#34;, methodName); try { Object result = pjp.proceed(); log.info(\u0026#34;执行完成: {}, 耗时: {}ms\u0026#34;, methodName, System.currentTimeMillis() - start); return result; } catch (Throwable e) { log.error(\u0026#34;执行异常: {}\u0026#34;, methodName, e); throw e; } } } 切点表达式 execution(* com.example.service.*.*(..)) 初看挺唬人的。拆开看很简单：返回值任意、com.example.service 包下任意类的任意方法、参数任意。\n我之前犯过一个错：切点表达式写错了，结果整个项目的方法都被拦截了，启动巨慢。排查了半天才发现少写了一层包名。写切点表达式一定要小心，范围别搞太大。\n实际应用：日志、事务、权限 # 话说回来，AOP 在实际项目里最常见的场景有三个。\n1. 统一日志\n上面那个例子就是。方法执行时间、入参出参，用 AOP 统一记录，业务代码干干净净。\n2. 声明式事务\nSpring 的 @Transactional 就是 AOP 实现的。你加个注解，Spring 就在方法前开事务、正常返回就提交、抛异常就回滚。\n@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { accountDao.deduct(fromId, amount); accountDao.add(toId, amount); } 这里有个经典的坑：同一个类里方法 A 调方法 B，B 上的 @Transactional 不生效。为啥？因为 AOP 是通过代理对象拦截的，类内部调用走的是 this，不经过代理。解决办法是注入自己或者用 AopContext.currentProxy()。\n3. 权限校验\n自定义注解 @RequireAdmin，然后切面拦截带这个注解的方法，检查当前用户权限：\n@Aspect @Component public class AuthAspect { @Before(\u0026#34;@annotation(requireAdmin)\u0026#34;) public void checkAdmin(RequireAdmin requireAdmin) { User user = SecurityContextHolder.getContext().getUser(); if (!user.isAdmin()) { throw new AccessDeniedException(\u0026#34;需要管理员权限\u0026#34;); } } } 这种方式比在每个 Controller 方法里写 if 判断优雅多了。\n几个容易踩的坑 # private 方法 AOP 不生效：代理只能拦截 public 方法 final 类/方法 CGLIB 搞不定：CGLIB 基于继承，final 没法继承 @Transactional 的 self-invocation 问题：上面说了，类内部调用不走代理 切面执行顺序：多个切面用 @Order 控制，数值越小优先级越高 小结 # AOP 本质上就是动态代理加拦截器模式。理解了动态代理，AOP 就没啥神秘的了。日常开发用好 @Transactional 和自定义注解配合切面，能省不少事。记住那几个坑就行。\n","date":"June 15, 2023","externalUrl":null,"permalink":"/posts/spring-aop-explained/","section":"博客","summary":"AOP 这东西，说难不难说简单不简单 # AOP，面向切面编程。第一次听到这个词的时候我满脸问号——什么切面？切什么？\n后来我换了个理解方式就通了：你有一堆业务方法，想在每个方法执行前后都打个日志，怎么办？一个个方法里加 log.info()？那要是有 200 个方法呢？改到吐。AOP 就是帮你把这种\"横切\"的逻辑抽出来，统一处理。\n什么是 AOP，为什么需要它 # OOP 的核心是纵向的继承和封装，但有些逻辑是横向的——日志、事务、权限校验，跟具体业务没关系，但到处都要用。如果每个方法都写一遍，代码重复不说，后续改起来也要命。\n","title":"Spring AOP 原理","type":"posts"},{"content":"","date":"May 8, 2023","externalUrl":null,"permalink":"/tags/gc/","section":"Tags","summary":"","title":"GC","type":"tags"},{"content":"","date":"May 8, 2023","externalUrl":null,"permalink":"/tags/jvm/","section":"Tags","summary":"","title":"JVM","type":"tags"},{"content":" JVM 垃圾回收看这篇就够了 # JVM 垃圾回收是面试八股文里的重头戏，也是我觉得最难啃的一块。概念多、算法多、收集器也多，第一次看的时候感觉脑子不够用。这篇把我学到的整理一下，尽量讲人话。\n堆内存长什么样 # JVM 的堆分两大块：新生代（Young Generation）和老年代（Old Generation）。\n新生代又分三块：Eden 区和两个 Survivor 区（S0、S1），默认比例 8:1:1。\n为什么这么分？大部分对象都是\u0026quot;朝生夕死\u0026quot;的，活不过一次 GC。放在新生代用快速算法回收就行，少数长寿的晋升到老年代。\n对象一般在 Eden 区创建。Eden 满了触发 Minor GC，存活对象搬到 Survivor 区。每熬过一次 GC 年龄加 1，到阈值（默认 15）就晋升老年代。\n两个 Survivor 同一时刻只有一个在用。GC 时把 Eden 和正在用的 Survivor 里的存活对象一起复制到空的那个 Survivor，然后清空原来的区域。这是复制算法在新生代的应用。\n怎么判断对象该不该回收 # 主流方案是可达性分析。从一组叫 GC Roots 的对象出发，沿引用链往下找，找不到的就是垃圾。\nGC Roots 包括：\n虚拟机栈中引用的对象（局部变量） 类的静态变量引用的对象 常量引用的对象 JNI 引用的对象 同步锁持有的对象 你可能听过引用计数法——被引用加 1，引用没了减 1，归零就回收。听着简单，但有个致命问题：循环引用。A 引用 B、B 引用 A，计数永远不为 0，但都是垃圾。JVM 没用这个方案。\n三种回收算法 # 标记-清除（Mark-Sweep）\n先标记要回收的对象，然后清除。问题是清完之后碎片一堆，大对象找不到连续空间。\n复制算法（Copying）\n内存分两半，只用一半。GC 时把活的复制到另一半，这一半整个清空。没碎片，但浪费一半空间。新生代用的就是这个思路——因为大部分对象活不久，实际要复制的量很少，效率很高。\n标记-整理（Mark-Compact）\n先标记，然后把存活对象往一端挪，清掉边界外的内存。没碎片也不浪费空间，但移动对象有额外开销。老年代一般用这个。\nCMS 和 G1 怎么选 # 这两个是面试高频考点。\nCMS（Concurrent Mark Sweep） 追求低停顿，用标记-清除算法，大致四步：\n初始标记——STW，只标记 GC Roots 直接关联的对象，很快 并发标记——和用户线程一起跑，遍历整个引用链 重新标记——STW，修正并发标记期间的变动 并发清除——和用户线程一起跑 两次 STW 都很短，大部分工作并发完成，停顿时间低。\n但 CMS 有明显缺点：\n标记-清除会产生碎片 并发阶段占 CPU，吞吐量下降 \u0026ldquo;concurrent mode failure\u0026rdquo;：并发清除时老年代空间不够，退化成 Serial Old 做 Full GC，一停就很久 G1（Garbage First） 是 JDK9 后的默认收集器，思路跟 CMS 很不一样。\nG1 把堆切成很多大小相等的 Region（默认 2048 个），每个 Region 可以充当 Eden、Survivor 或 Old。不再是物理连续的分代，而是逻辑分代。\nG1 的核心思想：每次 GC 不回收所有垃圾，优先回收垃圾最多的 Region。\u0026ldquo;Garbage First\u0026quot;就是这个意思。在有限时间内回收尽可能多的空间。\nG1 的优势：\n可以设停顿时间目标（-XX:MaxGCPauseMillis），G1 会尽量满足 整体上是标记-整理，不产生碎片 大对象有专门的 Humongous Region 实际怎么选？JDK8 堆不大（几个 G 以内）用 CMS 还行。JDK9 以上直接 G1，基本不用纠结。堆特别大（几十 G）可以了解一下 ZGC。\n实际碰到的一件事 # 上学期课设的项目跑着跑着就卡一下，频率还挺高。一开始以为是网络问题，后来用 jstat -gcutil 看了下，发现 Full GC 特别频繁，每次停顿几百毫秒。\n原因是代码里有个循环不停创建大 byte 数组，直接分配到老年代了，老年代很快就满。\n解决方案也简单：byte 数组改成对象池复用，Full GC 频率立刻降下来了。\n这件事让我意识到，GC 问题往往不是调参能解决的，根源通常在代码层面。写代码的时候注意对象生命周期，比调一堆 JVM 参数有用得多。\n","date":"May 8, 2023","externalUrl":null,"permalink":"/posts/jvm-gc/","section":"博客","summary":"JVM 垃圾回收看这篇就够了 # JVM 垃圾回收是面试八股文里的重头戏，也是我觉得最难啃的一块。概念多、算法多、收集器也多，第一次看的时候感觉脑子不够用。这篇把我学到的整理一下，尽量讲人话。\n堆内存长什么样 # JVM 的堆分两大块：新生代（Young Generation）和老年代（Old Generation）。\n","title":"JVM 垃圾回收机制","type":"posts"},{"content":"","date":"April 15, 2023","externalUrl":null,"permalink":"/tags/tcp/","section":"Tags","summary":"","title":"TCP","type":"tags"},{"content":" TCP 三次握手和四次挥手，画个图就懂了 # 三次握手流程 # TCP 三次握手大概是计网面试被问得最多的点了。背归背，但搞懂\u0026quot;为什么\u0026quot;比记住流程更重要。\n先上个图：\n大白话描述一下：\n第一次握手：客户端发 SYN 包，带上初始序列号（ISN），\u0026ldquo;我想跟你建立连接\u0026rdquo;。客户端进入 SYN_SENT 状态。\n第二次握手：服务端回 SYN+ACK 包，带上自己的初始序列号，同时确认收到了客户端的 SYN。\u0026ldquo;收到了，我也想连你\u0026rdquo;。服务端进入 SYN_RCVD 状态。\n第三次握手：客户端再发 ACK 包确认。双方进入 ESTABLISHED 状态，连接建立。\n客户端 服务端 | | |------- SYN(seq=x) ----\u0026gt;| 第一次握手 | | |\u0026lt;-- SYN+ACK(seq=y,ack=x+1) --| 第二次握手 | | |------- ACK(ack=y+1) --\u0026gt;| 第三次握手 | | | 连接建立完成 | 为什么是三次，不是两次 # 面试最爱追问的地方。\n角度一：确认双方收发能力。三次握手后，双方都确认了\u0026quot;我能发、我能收、对方能发、对方能收\u0026quot;。只有两次的话，服务端没法确认客户端能不能收到它的消息。\n角度二（更根本）：防止历史连接。假设客户端发了一个 SYN，因网络延迟一直没到。客户端超时重发了新 SYN 并完成连接通信。后来那个迟到的旧 SYN 到了服务端。两次握手的话，服务端收到就建连了，但客户端压根不知道，白白浪费资源。有第三次握手，服务端得等 ACK，客户端不会理这个旧连接的 SYN-ACK，连接就不会误建。\n我当初看书时第一个角度好理解，第二个角度想了一会儿才明白。\n四次挥手流程 # 断连比建连多一次，因为 TCP 是全双工的，每个方向要单独关闭。\n客户端 服务端 | | |------- FIN(seq=u) ----\u0026gt;| 第一次挥手：\u0026#34;我发完了\u0026#34; | | |\u0026lt;------ ACK(ack=u+1) ---| 第二次挥手：\u0026#34;知道了\u0026#34; | | | (服务端可能还有数据要发) | | | |\u0026lt;------ FIN(seq=w) -----| 第三次挥手：\u0026#34;我也发完了\u0026#34; | | |------- ACK(ack=w+1) --\u0026gt;| 第四次挥手：\u0026#34;好的\u0026#34; | | | TIME_WAIT (2MSL) | | | 为什么不能三次搞定？因为服务端收到客户端 FIN 时，可能还有数据没发完。它先 ACK 表示\u0026quot;知道你要关了\u0026quot;，等数据发完再发自己的 FIN。\n当然如果服务端恰好没数据要发了，第二三次可以合并。实际抓包有时候确实能看到三次挥手。\nTIME_WAIT 到底干嘛的 # 主动关闭的一方在四次挥手后不是直接关闭，而是进入 TIME_WAIT 状态，等 2 个 MSL（报文最大存活时间，Linux 上通常 60 秒）才真正关闭。\n原因有两个：\n保证最后的 ACK 到达：如果 ACK 丢了，被动关闭方会重发 FIN。TIME_WAIT 状态的一方还能收到并重新发 ACK。不然被动方就卡在 LAST_ACK 了。\n让旧报文消亡：等 2MSL 确保网络中残留的老报文都超时丢弃了，不会干扰后续用同样端口建的新连接。\n我之前在服务器上跑 netstat 看到过几万个 TIME_WAIT，当时以为是连接泄露，查了半天才知道是正常现象。高并发短连接场景这个问题会比较突出，可以调内核参数 tcp_tw_reuse 来复用 TIME_WAIT 的端口。\n几个常见面试追问 # SYN 洪泛攻击是什么？\n攻击者发大量 SYN 但不回 ACK，服务端维护大量半连接（SYN_RCVD 状态），耗尽资源。防御手段：SYN Cookie，让服务端无需保存半连接状态就能验证第三次握手的合法性。\n第三次握手能带数据吗？\n可以。第三次握手时客户端已经确认连接建立了，带数据是安全的。前两次不行，因为连接还没建好。\n序列号为什么随机？\n防攻击。如果序列号可预测，攻击者能伪造 TCP 包。随机 ISN 大大增加了伪造难度。\n说到这里，TCP 连接管理看着简单，其实每个设计都有原因。建议用 Wireshark 自己抓个包看看，理论和实际结合起来印象深很多。我当时上计网课的时候抓了一次三次握手的包，对着看每个字段，比看书有用多了。\n","date":"April 15, 2023","externalUrl":null,"permalink":"/posts/tcp-handshake/","section":"博客","summary":"TCP 三次握手和四次挥手，画个图就懂了 # 三次握手流程 # TCP 三次握手大概是计网面试被问得最多的点了。背归背，但搞懂\"为什么\"比记住流程更重要。\n","title":"TCP 三次握手与四次挥手","type":"posts"},{"content":"","date":"April 15, 2023","externalUrl":null,"permalink":"/categories/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/","section":"Categories","summary":"","title":"计算机网络","type":"categories"},{"content":"","date":"April 15, 2023","externalUrl":null,"permalink":"/tags/%E7%BD%91%E7%BB%9C/","section":"Tags","summary":"","title":"网络","type":"tags"},{"content":"","date":"April 15, 2023","externalUrl":null,"permalink":"/tags/%E5%8D%8F%E8%AE%AE/","section":"Tags","summary":"","title":"协议","type":"tags"},{"content":"","date":"March 20, 2023","externalUrl":null,"permalink":"/tags/ioc/","section":"Tags","summary":"","title":"IOC","type":"tags"},{"content":" Spring IOC 到底干了啥 # 学 Spring 的时候，IOC（控制反转）这个词听了不下一百遍。老师讲、教程讲、面试题也讲。但我一开始真没搞懂这玩意到底在干嘛——不就是 new 一个对象吗，为啥要搞得这么复杂？\n后来写项目写多了才明白，Spring 的 IOC 容器本质上就是帮你管理对象的。你不用自己 new，它帮你 new，帮你组装，帮你销毁。听着简单，但里面门道挺多的。\nBean 生命周期——从出生到死亡 # 一个 Bean 从创建到被 GC 回收，中间经历了不少事。大致流程是这样：\n实例化（调构造方法） 属性赋值（依赖注入） 初始化（各种回调） 使用 销毁 其实吧，光说这几步看着挺简单，但 Spring 在中间插了一堆扩展点。比如 BeanPostProcessor，它能在初始化前后做一些操作。AOP 的代理对象就是在这一步生成的。\n我之前踩过一个坑：在构造方法里调另一个 Bean 的方法，结果拿到的是 null。debug 了半天才发现，构造方法执行的时候依赖注入还没开始呢！所以如果有初始化逻辑，老老实实用 @PostConstruct 或者实现 InitializingBean。\n@Component public class MyService { @Autowired private UserDao userDao; @PostConstruct public void init() { // 这里才能安全地用 userDao userDao.loadCache(); } } BeanFactory 和 ApplicationContext，到底用哪个 # 你可能会想，这俩都是 IOC 容器，有啥区别？\n简单说，BeanFactory 是爹，ApplicationContext 是儿子。ApplicationContext 继承了 BeanFactory，在它基础上加了一堆功能：\n国际化（MessageSource） 事件机制（ApplicationEventPublisher） 资源加载（ResourceLoader） AOP 支持 日常开发基本不会直接碰 BeanFactory，用 ApplicationContext 就对了。\n还有个重要区别：BeanFactory 是懒加载，用到 Bean 的时候才创建；ApplicationContext 默认饿加载，启动时就把单例 Bean 全创建好了。这也是为啥 Spring Boot 启动有时候比较慢——它在启动阶段把所有 Bean 都实例化了。\n依赖注入的几种方式 # Spring 的依赖注入有三种姿势：\n1. 字段注入（最偷懒的写法）\n@Component public class OrderService { @Autowired private UserService userService; } 写起来最爽，但 Spring 官方其实不推荐。为啥？因为它依赖反射，没法做 final 字段，而且单元测试的时候不好 mock。\n2. Setter 注入\n@Component public class OrderService { private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } } 好处是灵活，可以选择性注入。坏处是依赖关系不够明确。\n3. 构造器注入（官方推荐）\n@Component public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } } 构造器注入的好处：字段可以是 final 的，依赖关系一目了然，Spring 4.3 之后如果只有一个构造方法连 @Autowired 都不用写。配合 Lombok 的 @RequiredArgsConstructor 简直完美。\n说到这里，我个人项目里基本都用构造器注入了。一开始觉得麻烦，用习惯之后发现代码清爽多了。\n循环依赖怎么解决的 # 这是面试高频题。什么是循环依赖？就是 A 依赖 B，B 又依赖 A。\nSpring 用三级缓存来解决这个问题。说实话这部分我看源码看了好几遍才搞明白。\n三级缓存长这样：\n一级缓存（singletonObjects）：完全初始化好的 Bean 二级缓存（earlySingletonObjects）：提前暴露的 Bean，还没完成属性注入 三级缓存（singletonFactories）：Bean 的工厂对象，用来生成提前暴露的引用 流程大概是这样：创建 A 的时候，先把 A 的工厂放到三级缓存。然后发现 A 依赖 B，就去创建 B。创建 B 的时候发现 B 依赖 A，就去缓存里找。三级缓存里有 A 的工厂，调用工厂方法拿到 A 的早期引用（如果需要代理就返回代理对象），放到二级缓存。B 拿到 A 的引用后完成初始化，然后 A 也能完成初始化。\n有个细节：构造器注入的循环依赖 Spring 解决不了。因为三级缓存是在构造方法执行之后才放入的，构造器注入的时候对象还没创建出来，没法提前暴露。遇到这种情况要么改成 Setter 注入，要么用 @Lazy：\n@Component public class A { private final B b; public A(@Lazy B b) { this.b = b; // 这里注入的其实是 B 的代理对象 } } 顺便提一下，Spring Boot 2.6 之后默认禁止循环依赖了。官方态度很明确：循环依赖本身就是设计问题，你应该重构代码而不是依赖框架帮你兜底。\n小结 # IOC 容器用起来简单，加个注解就行。但面试官就喜欢问底层原理。Bean 生命周期、三级缓存这些东西，建议还是自己去翻一下源码，看过一遍印象会深很多。我当时就是在 AbstractAutowireCapableBeanFactory 的 doCreateBean 方法上打了个断点，一步一步跟下来的，比看十篇博客都管用。\n","date":"March 20, 2023","externalUrl":null,"permalink":"/posts/spring-ioc-container/","section":"博客","summary":"Spring IOC 到底干了啥 # 学 Spring 的时候，IOC（控制反转）这个词听了不下一百遍。老师讲、教程讲、面试题也讲。但我一开始真没搞懂这玩意到底在干嘛——不就是 new 一个对象吗，为啥要搞得这么复杂？\n后来写项目写多了才明白，Spring 的 IOC 容器本质上就是帮你管理对象的。你不用自己 new，它帮你 new，帮你组装，帮你销毁。听着简单，但里面门道挺多的。\nBean 生命周期——从出生到死亡 # 一个 Bean 从创建到被 GC 回收，中间经历了不少事。大致流程是这样：\n","title":"Spring IOC 容器原理","type":"posts"},{"content":" 线程池这玩意，用不好真的会出事 # 上周实验室项目出了个线上问题，排查了一下午发现是线程池配置不对导致的 OOM。这事让我意识到，线程池看着简单，用不好是真会出事的。\n为什么要用线程池 # 你可能觉得，new 一个 Thread 跑任务不就完了？能跑，但有几个问题：\n创建线程有开销，操作系统要分配栈内存、做系统调用 任务量大的时候无限创建线程，系统资源扛不住 线程太多 CPU 光忙着上下文切换，正经活干不了 线程池的思路很简单：提前创建好一批线程，任务来了从池子里拿线程执行，执行完放回去复用。\n核心参数搞清楚 # ThreadPoolExecutor 的构造方法有 7 个参数，面试最爱问：\npublic ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程存活时间 TimeUnit unit, // 时间单位 BlockingQueue\u0026lt;Runnable\u0026gt; workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 ) 任务提交的执行流程：\n当前线程数 \u0026lt; corePoolSize，直接创建新线程执行 当前线程数 \u0026gt;= corePoolSize，任务放进 workQueue 排队 workQueue 满了，且线程数 \u0026lt; maximumPoolSize，创建非核心线程 线程数到了 maximumPoolSize 队列也满了，触发拒绝策略 注意这个顺序——是队列满了才创建超过核心数的线程。我之前一直以为先涨线程数再放队列，看源码才发现搞反了。\n四种拒绝策略 # 线程池扛不住的时候，拒绝策略决定怎么处理新来的任务：\nAbortPolicy（默认）：直接抛 RejectedExecutionException。简单粗暴，至少你知道任务被拒了。 CallerRunsPolicy：让提交任务的线程自己跑。妙处在于调用方忙着执行就没空提交新的，天然限流。 DiscardPolicy：默默丢掉，不抛异常。很危险，你完全不知道任务丢了。 DiscardOldestPolicy：丢掉队列最老的任务，然后重试提交。 实际项目用得最多的是 AbortPolicy 和 CallerRunsPolicy。DiscardPolicy 千万别用，出了问题排查不出来。\n为什么阿里规约不让用 Executors # Executors 有几个快捷方法创建线程池，阿里规约明确禁止使用。看看为什么。\nnewFixedThreadPool 源码：\npublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue\u0026lt;Runnable\u0026gt;()); } LinkedBlockingQueue 默认容量 Integer.MAX_VALUE，无界队列。任务堆积起来直接 OOM。\nnewCachedThreadPool 更夸张：\nnew ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue\u0026lt;Runnable\u0026gt;()); 最大线程数 Integer.MAX_VALUE，理论上能开二十多亿个线程。\n正确做法是自己 new ThreadPoolExecutor，每个参数都想清楚再填。\n我踩过的坑 # 说个真实的事。实验室有个爬虫项目，学长写的代码用了 newCachedThreadPool，平时任务少没问题。有天我们跑大批量任务，几千个 URL 同时丢进去，线程数瞬间飙到几千，内存直接爆了。\n排查了一下午，一开始以为是内存泄漏，用 jmap dump 了堆内存，发现全是线程栈占的。这才反应过来是线程池的问题。\n后来改成了这样：\nThreadPoolExecutor executor = new ThreadPoolExecutor( 10, // 核心线程 10 个 20, // 最大 20 个 60L, TimeUnit.SECONDS, new ArrayBlockingQueue\u0026lt;\u0026gt;(500), // 有界队列，最多排 500 个任务 new ThreadPoolExecutor.CallerRunsPolicy() // 满了让调用方自己跑 ); 改完再没出过问题。CallerRunsPolicy 在这个场景特别合适——爬虫不怕慢一点，别 OOM 就行。\n实际怎么配参数 # 这个没有标准答案，看任务类型。\nCPU 密集型（计算多、IO 少）：核心线程数设成 CPU 核心数 + 1。多出来那一个是为了某个线程偶尔阻塞时 CPU 不至于闲着。\nIO 密集型（网络请求、数据库查询）：线程大部分时间在等 IO，可以多开。一般设 CPU 核心数 * 2，或者用公式 核心数 / (1 - 阻塞系数)。\n但说实话这些都是理论值，实际最靠谱的是压测。调不同参数看吞吐量和响应时间，找到合适的平衡点。\n还有个容易忽略的点：给线程起有意义的名字。用自定义 ThreadFactory：\nThreadFactory factory = r -\u0026gt; { Thread t = new Thread(r); t.setName(\u0026#34;crawler-pool-\u0026#34; + t.getId()); return t; }; 出问题时看线程 dump，一眼就能认出是哪个池子的线程。不然全是 pool-1-thread-1 这种默认名，排查的时候真的头大。\n","date":"February 10, 2023","externalUrl":null,"permalink":"/posts/java-thread-pool/","section":"博客","summary":"线程池这玩意，用不好真的会出事 # 上周实验室项目出了个线上问题，排查了一下午发现是线程池配置不对导致的 OOM。这事让我意识到，线程池看着简单，用不好是真会出事的。\n为什么要用线程池 # 你可能觉得，new 一个 Thread 跑任务不就完了？能跑，但有几个问题：\n","title":"Java 线程池原理与实践","type":"posts"},{"content":"","date":"February 10, 2023","externalUrl":null,"permalink":"/tags/%E7%BA%BF%E7%A8%8B%E6%B1%A0/","section":"Tags","summary":"","title":"线程池","type":"tags"},{"content":"","date":"December 10, 2022","externalUrl":null,"permalink":"/tags/%E5%8D%95%E4%BE%8B/","section":"Tags","summary":"","title":"单例","type":"tags"},{"content":" 单例模式的几种写法，你用哪种？ # 为什么需要单例 # 单例模式大概是最简单也最常被问到的设计模式了。面试必问，但你真的理解每种写法的区别吗？\n先说为什么需要单例。有些对象全局只需要一个就够了，比如线程池、数据库连接池、配置管理器。你不希望到处 new，浪费资源不说，状态不一致更头疼。\n单例的核心就两件事：构造方法私有化，提供一个全局访问点。听起来简单，但写法还挺多的，逐个来看。\n饿汉式 # 最简单直接的写法：\npublic class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } } 类加载的时候就创建实例了，所以叫\u0026quot;饿汉\u0026quot;——还没等你要，它就准备好了。\n优点：简单，线程安全（JVM 类加载机制保证的）。 缺点：不管用不用都会创建，如果对象很重，就有点浪费。\n说实话，大部分场景用饿汉式就够了。你的单例对象真的很重吗？多数时候并不是。\n懒汉式 # 既然饿汉式可能浪费，那就等需要的时候再创建：\npublic class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } 这就是懒汉式——用的时候才创建。\n但问题来了：多线程环境下不安全。两个线程同时判断 instance == null，都通过了，就会创建两个实例。\n加个 synchronized 能解决：\npublic static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } 但这样每次调用 getInstance 都要加锁，性能太差了。实例都创建好了还加锁，完全没必要。\n双重检查锁（DCL） # 为了解决懒汉式的性能问题，有了双重检查锁：\npublic class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查，不加锁 synchronized (Singleton.class) { if (instance == null) { // 第二次检查，加锁后再确认 instance = new Singleton(); } } } return instance; } } 第一次检查避免了不必要的加锁，第二次检查保证只创建一个实例。\n注意那个 volatile，不能少。为什么？因为 new Singleton() 实际分三步：分配内存、初始化对象、赋值引用。JVM 可能重排序，导致另一个线程拿到还没初始化完的对象。volatile 禁止重排序，保证安全。\n说到这里，DCL 面试出现频率超高。把为什么要两次检查、为什么要 volatile 讲清楚，面试官基本满意了。\n静态内部类 # public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } } 利用了 JVM 的类加载机制：内部类 Holder 在第一次被引用时才会加载，加载的时候创建实例。既实现了延迟加载，又保证了线程安全。\n我个人挺喜欢这种写法的，简洁优雅，不用操心并发问题。\n枚举实现 # 《Effective Java》里推荐的写法：\npublic enum Singleton { INSTANCE; public void doSomething() { // 业务逻辑 } } 就这么几行。枚举天生就是单例，JVM 保证的。而且枚举还能防止反射攻击和反序列化破坏单例——前面几种写法都做不到这点。\n你可能觉得这写法看着不像正常的类。确实，枚举做单例在国内项目里不太常见。但从安全性角度来说，它确实是最完美的。\n到底用哪个 # 整理一下对比：\n写法 延迟加载 线程安全 防反射 防反序列化 饿汉式 否 是 否 否 懒汉式+sync 是 是 否 否 DCL 是 是 否 否 静态内部类 是 是 否 否 枚举 否 是 是 是 我的建议：\n一般场景用饿汉式，简单不出错 需要延迟加载用静态内部类 面试重点讲DCL，把 volatile 那块讲明白 追求完美用枚举 其实吧，在 Spring 项目里，大部分单例需求直接用 Spring 的单例 Bean 就行了，你手写单例的机会真不多。但面试嘛，该会还是得会。\n我之前面试被追问\u0026quot;DCL 不加 volatile 会怎样\u0026quot;，当时答得磕磕绊绊的。后来花时间研究了 JMM，才真正搞明白。并发相关的东西，光背八股不够，得理解底层原理才行。\n","date":"December 10, 2022","externalUrl":null,"permalink":"/posts/singleton-patterns/","section":"博客","summary":"单例模式的几种写法，你用哪种？ # 为什么需要单例 # 单例模式大概是最简单也最常被问到的设计模式了。面试必问，但你真的理解每种写法的区别吗？\n先说为什么需要单例。有些对象全局只需要一个就够了，比如线程池、数据库连接池、配置管理器。你不希望到处 new，浪费资源不说，状态不一致更头疼。\n","title":"单例模式的几种写法","type":"posts"},{"content":" ConcurrentHashMap 到底怎么保证线程安全的 # 上篇写了 HashMap，有同学私信问我 ConcurrentHashMap 和 HashMap 有什么区别。其实吧，区别大了去了，JDK8 的 ConcurrentHashMap 基本是重写的，跟 JDK7 完全两个思路。\n花了差不多一周把源码理清楚，这篇来聊聊它到底怎么在并发场景下保证线程安全。\nJDK7 的 Segment 分段锁 # 先说 JDK7 的方案，面试还是会问。\nJDK7 的 ConcurrentHashMap 内部有一个 Segment 数组，每个 Segment 继承了 ReentrantLock，里面维护一个 HashEntry 数组。\n简单说就是把整个 Map 分成好几段，每段有自己的锁。操作某个 key 时，先定位到它所在的 Segment，再锁那个 Segment。不同 Segment 之间互不影响，可以并发操作。\n默认 16 个 Segment，理论上最多 16 个线程同时写入。\n这个设计比给整个 Map 加一把大锁好很多，但也有局限——Segment 个数初始化之后就固定了，而且结构比较复杂。\nJDK8 彻底重写了 # JDK8 抛弃了 Segment，结构改成跟 HashMap 一样的 Node\u0026lt;K,V\u0026gt;[] 数组。线程安全靠 CAS + synchronized，粒度细到了每个桶。\ntransient volatile Node\u0026lt;K,V\u0026gt;[] table; 注意 volatile。table 引用是 volatile 的，保证扩容时新数组对其他线程可见。每个 Node 的 val 和 next 也是 volatile 的，保证读操作不需要加锁。\n锁的粒度从 Segment（一段）缩小到了单个桶，灵活太多了。\nCAS + synchronized 怎么配合的 # 看 putVal 的源码，分几种情况：\n桶是空的——用 CAS 直接放新节点，不加锁。\nelse if ((f = tabAt(tab, i = (n - 1) \u0026amp; hash)) == null) { if (casTabAt(tab, i, null, new Node\u0026lt;K,V\u0026gt;(hash, key, value))) break; } CAS 失败说明别的线程抢先了，进入下一轮循环重试。\n正在扩容——当前线程会帮忙一起搬数据（后面讲）。\n桶不为空，没在扩容——用 synchronized 锁住头节点，遍历链表或红黑树做插入。\nsynchronized (f) { // 操作链表或红黑树 } 为什么空桶用 CAS，非空桶用 synchronized？空桶只需要一次原子写入，CAS 快且轻量。非空桶要遍历链表，操作复杂，CAS 搞不定，老老实实加锁更靠谱。\n顺便说一下，JDK8 的 synchronized 已经优化了很多（偏向锁、轻量级锁），性能不比 ReentrantLock 差。Doug Lea 选 synchronized 而不是显式锁，也是考虑到了这一点。\n并发扩容是怎么做的 # 这个是 ConcurrentHashMap 最妙的设计。HashMap 扩容是单线程干，ConcurrentHashMap 可以多线程一起搬。\n大致流程：\n某个线程发现需要扩容，创建一个 2 倍大小的新数组 nextTable。 把旧数组分成多段，每个线程认领一段来搬。每段最少 16 个桶。 某个桶搬完之后，在旧数组对应位置放一个 ForwardingNode。其他线程看到它就知道\u0026quot;这个桶搬完了\u0026quot;。 其他线程要 put 时发现正在扩容，它也会加入搬运，这就是 helpTransfer。 想想看，数组有 1024 个桶，一个线程搬多慢。让大家一起搬，速度快得多。\n我觉得这个设计理念特别好：不是让线程干等着，而是把等待的时间利用起来帮忙干活。\n和 Hashtable 比一下 # 面试经常问\u0026quot;为什么不用 Hashtable\u0026quot;。Hashtable 方案简单粗暴，给每个方法加 synchronized：\npublic synchronized V get(Object key) { ... } public synchronized V put(K key, V value) { ... } 任何时候只能有一个线程操作 Map，读也加锁，写也加锁，并发性能可想而知。\nConcurrentHashMap 的读操作基本不加锁（靠 volatile），写操作只锁一个桶。差距非常明显。\n话说回来，Collections.synchronizedMap() 也一样，本质上就是一把大锁包住所有操作，高并发场景别用。\n小结 # JDK7 到 JDK8，ConcurrentHashMap 从分段锁变成了 CAS + synchronized + 并发扩容，设计上精进了很多。面试问并发集合基本必考这块，把上面这些理清楚应该够用了。\n说实话这块源码比 HashMap 难读很多。建议先搞懂 HashMap 再来看 ConcurrentHashMap，不然容易直接劝退。\n","date":"November 20, 2022","externalUrl":null,"permalink":"/posts/concurrenthashmap-deep-dive/","section":"博客","summary":"ConcurrentHashMap 到底怎么保证线程安全的 # 上篇写了 HashMap，有同学私信问我 ConcurrentHashMap 和 HashMap 有什么区别。其实吧，区别大了去了，JDK8 的 ConcurrentHashMap 基本是重写的，跟 JDK7 完全两个思路。\n花了差不多一周把源码理清楚，这篇来聊聊它到底怎么在并发场景下保证线程安全。\nJDK7 的 Segment 分段锁 # 先说 JDK7 的方案，面试还是会问。\n","title":"ConcurrentHashMap 线程安全原理","type":"posts"},{"content":"","date":"November 20, 2022","externalUrl":null,"permalink":"/tags/%E9%9B%86%E5%90%88%E6%A1%86%E6%9E%B6/","section":"Tags","summary":"","title":"集合框架","type":"tags"},{"content":" HashMap 源码我读了三遍才看懂 # 大二下学期面试实习的时候，面试官问我 HashMap 的底层原理，我支支吾吾说了个\u0026quot;数组加链表\u0026quot;就说不下去了。回来之后就下定决心要把源码读一遍。结果读了三遍才算真正搞明白，这玩意比我想象的要精妙得多。\n先说说 HashMap 的内部结构 # 你打开 HashMap 的源码，会看到一个叫 Node\u0026lt;K,V\u0026gt;[] table 的数组。这就是 HashMap 的骨架。\n每个 Node 长这样：\nstatic class Node\u0026lt;K,V\u0026gt; implements Map.Entry\u0026lt;K,V\u0026gt; { final int hash; final K key; V value; Node\u0026lt;K,V\u0026gt; next; // 看到没，链表结构 } 本质就是：一个数组，数组的每个位置可以挂一条链表。你 put 一个 key-value 进去，先算 key 的 hash 值，定位到数组的某个下标，然后挂到那个位置的链表上。\nJDK8 加了个重要改动：当链表长度超过 8 的时候，链表会转成红黑树。为啥呢？链表查找是 O(n)，红黑树是 O(log n)，hash 冲突严重的时候性能差距很大。\n你可能会问，为什么阈值是 8？源码注释里写了，按泊松分布计算，链表长度达到 8 的概率大概是千万分之六。正常使用根本不会触发，这纯粹是个保底策略。\nput 一个元素到底经历了什么 # 来看 putVal 方法的核心逻辑，我把它拆开讲：\n算 hash。不是直接用 key.hashCode()，而是做了个扰动：(h = key.hashCode()) ^ (h \u0026gt;\u0026gt;\u0026gt; 16)。高 16 位和低 16 位异或一下，让 hash 分布更均匀。 定位下标。用 (n - 1) \u0026amp; hash 算出数组下标，n 是数组长度，后面会讲为啥要是 2 的幂。 如果那个位置是空的，直接放进去。 如果有东西，看 key 是不是一样的，一样就覆盖 value。 不一样的话，看当前节点是不是 TreeNode（红黑树节点），是的话走红黑树的插入逻辑。 不是树节点就遍历链表，找到末尾插入。插入之后看链表长度有没有到 8，到了就转红黑树。 我之前踩过一个坑：以为 HashMap 的链表是头插法，结果 debug 了半天发现 JDK8 改成尾插法了。JDK7 确实是头插的，但头插法在多线程 resize 的时候会形成环链表，导致死循环。这个 bug 挺经典的，面试也爱问。\n// JDK8 尾插法的关键代码 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount \u0026gt;= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } // ... } 扩容机制 resize # 当元素数量超过 capacity * loadFactor（默认 16 * 0.75 = 12）的时候，就会触发扩容。扩容就是把数组长度翻倍，然后重新分配所有元素的位置。\n这里有个很巧妙的设计。扩容之后，每个元素要么留在原来的位置，要么移动到\u0026quot;原位置 + 旧容量\u0026quot;的位置。不需要重新算 hash。\n假设旧容量是 16（二进制 10000），新容量 32（二进制 100000）。原来用 hash \u0026amp; 01111 算下标，现在用 hash \u0026amp; 11111。就多看了一个 bit，那个 bit 是 0 就留在原地，是 1 就移到新位置。\n所以源码里只需要 (e.hash \u0026amp; oldCap) == 0 这一个判断就搞定了。这种位运算技巧真的很优雅。\n顺便提一下，扩容是个挺重的操作，所有元素都要重新分配。如果你事先知道要放多少元素，用 new HashMap\u0026lt;\u0026gt;(expectedSize) 指定初始容量，能避免好几次扩容。阿里巴巴开发规约也是这么推荐的。\n为什么容量一定要是 2 的幂次 # 这个问题面试高频出现，原因有两个：\n第一，定位数组下标用的是 (n - 1) \u0026amp; hash，而不是 hash % n。当 n 是 2 的幂的时候这两个等价，但位运算比取模快得多。\n第二，扩容的时候可以用上面说的那个技巧，只看多出来的那一位是 0 还是 1。如果容量不是 2 的幂，这个优化就没法做。\n你可能会想：那我 new HashMap\u0026lt;\u0026gt;(7) 会怎样？HashMap 会自动帮你找到大于等于 7 的最小 2 次幂，也就是 8。看这段代码：\nstatic final int tableSizeFor(int cap) { int n = cap - 1; n |= n \u0026gt;\u0026gt;\u0026gt; 1; n |= n \u0026gt;\u0026gt;\u0026gt; 2; n |= n \u0026gt;\u0026gt;\u0026gt; 4; n |= n \u0026gt;\u0026gt;\u0026gt; 8; n |= n \u0026gt;\u0026gt;\u0026gt; 16; return (n \u0026lt; 0) ? 1 : (n \u0026gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } 效果就是把最高位的 1 后面所有位都变成 1，再加 1，得到最近的 2 次幂。看着费解，但思路其实很简单。\n几个面试爱问的小问题 # HashMap 线程安全吗？ 不安全。多线程同时 put 可能丢数据，JDK7 还可能死循环。要线程安全就用 ConcurrentHashMap。\nkey 可以是 null 吗？ 可以，null 的 hash 是 0，固定放在数组下标 0 的位置。\n为什么重写 equals 必须重写 hashCode？ 因为 HashMap 先用 hashCode 定位桶，再用 equals 比较 key。如果两个对象 equals 返回 true 但 hashCode 不同，HashMap 会把它们放到不同的桶里，get 的时候就找不到了。我之前拿自定义对象做 key 的时候踩过这个坑，get 出来一直是 null，debug 了好久才发现。\n最后说两句 # HashMap 的源码其实不算长，核心方法就 putVal、getNode、resize 几个。但里面的设计思路真的值得品味——位运算的使用、红黑树的引入、扩容的优化，每个细节都有道理。\n建议自己打开 IDE 跟着 debug 一遍，比看任何博客都有用。\n","date":"September 15, 2022","externalUrl":null,"permalink":"/posts/hashmap-source-code/","section":"博客","summary":"HashMap 源码我读了三遍才看懂 # 大二下学期面试实习的时候，面试官问我 HashMap 的底层原理，我支支吾吾说了个\"数组加链表\"就说不下去了。回来之后就下定决心要把源码读一遍。结果读了三遍才算真正搞明白，这玩意比我想象的要精妙得多。\n先说说 HashMap 的内部结构 # 你打开 HashMap 的源码，会看到一个叫 Node\u003cK,V\u003e[] table 的数组。这就是 HashMap 的骨架。\n","title":"HashMap 源码笔记","type":"posts"},{"content":"","date":"September 15, 2022","externalUrl":null,"permalink":"/tags/%E6%BA%90%E7%A0%81/","section":"Tags","summary":"","title":"源码","type":"tags"},{"content":" Hi # 我是 Weiming，一名大三在读学生，主要写 Java。\n平时折腾 Spring Boot、Redis、MySQL 这些，最近对大模型很感兴趣，在研究 LangChain4j 和 Spring AI。\n这个博客主要记录我学习过程中的一些笔记和踩坑经历，希望对你也有帮助。\n技术栈 # Java / Spring Boot / Spring Cloud MySQL / Redis / Kafka Docker / K8s Python（写点脚本和 AI 相关的东西） 联系方式 # GitHub: Hu-Weiming ","externalUrl":null,"permalink":"/about/","section":"Weiming's Blog","summary":"about","title":"关于我","type":"page"}]