Java 开发面试复盘 - 杭州 2025
一、某建筑公司
1. 有没有创建过项目?创建项目的思路是怎样的?
整体:首先是分析业务,做技术选型,搭建项目的骨架,然后添加功能,制定好任务排期,最后将代码打成 Docker 镜像,部署到云上。
1) 技术选型。
以我们的项目为例,开发框架选择了 Spring Boot,相较于 SSM,它的优势在于自动化配置、起步依赖(起步依赖的优势在于解决版本冲突)。
数据库框架选择了 JPA + Hibernate(选择理由见下文)。
数据库选择 MySQL,缓存选择 Redis,消息队列用的 RabbitMQ(可靠 + 易用)。
2) 功能设计(领域驱动设计)。
代码分层:我设计了 Controller、Manager、Service、Repository 四个层级。
Controller 层用来定义接口;Manager 层用来协调多个 Service 或调用外部接口;Service 层是领域层,负责实现单一、完整的业务逻辑;Repository 层负责做数据库操作。
3) 服务部署。
通过流水线触发构建过程,并使用 Dockerfile 将应用打包成镜像。
由于我们的镜像仓库和云平台处于不同的网络环境,所以需要以镜像文件为媒介,将其加载到云平台所在的环境,再推送到云平台。
在云平台上创建工作负载,然后运行镜像,对外提供服务。
为什么要选择 JPA + Hibernate 而不是 MyBatis Plus?
JPA + Hibernate 是一种 ORM(对象关系映射)框架,开发者操作的是 Java 对象,框架负责将其自动转换为数据库操作。更契合 DDD 的思想。
MyBatis Plus 是基于 SQL 的半自动化框架,需要编写和维护大量的 XML 或 SQL 语句。
其他原因:团队成员更熟悉 JPA + Hibernate,学习成本较低。
2. 在什么场景下用过分布式锁?锁的是什么?
在之前的职业生涯中,我参与过一个证券综合金融服务平台的开发,我们组主要负责融资融券交易模块。
融资融券交易和普通证券交易的区别在于,它属于信用交易,用户可以凭借自己的信用,从证券公司那边获得资金或者证券来做交易。
证券公司的资金和证券不是无限的,它有一个总的额度,所有的用户要共用这个总额度,这个总额度叫作核心头寸。
业务层面上,为了更好地管理和隔离风险,核心头寸被划分成了多个业务头寸,每个业务头寸可以被多个用户同时操作。换言之,核心头寸和业务头寸之间是一对多的关系,业务头寸和用户之间也是一对多的关系。
当位于多个分片上的用户同时对同一个业务头寸进行操作时,需要加分布式锁。
分布式锁锁的是业务头寸 ID。(不同头寸之间的操作是并行的,理论上只要券商划分的业务头寸足够多,大多数交易请求都不会相互阻塞)
分布式锁的实现用的是 Redisson 锁(带看门狗机制)。
【模拟】业务头寸的持久化问题。
对于融资融券业务头寸这种跨分片共享、高频访问、对一致性要求很高的共享资源,会放在分布式缓存(Redis)中。
业务头寸最终也要同步存入数据库(Oracle)中,目的是 保障数据不丢失、系统重启或新增节点时加载到 Redis 中。
在高并发场景下,同步策略为:(高性能、高吞吐量、最终一致性)
1) 交易场景下,交易服务获取分布式锁后,对 Redis 中的头寸数据进行查询和扣减,一旦 Redis 写入成功,即可认为交易成功,释放分布式锁。
2) 头寸数据更新后,通过 RabbitMQ 发送一个头寸更新的消息,将头寸的变动同步给 Oracle。(头寸更新消息设计:头寸ID + 最新的头寸值 + 时间戳)
补充:并不是头寸数据每次发生变化都要同步给 Oracle,同步的三个场景:
1) 每 30 秒自动同步一次。
2) 在交易高峰期,当待同步的、不同的头寸 ID 数量积累到 1000 个(数量阈值触发)时触发。
3) 关键事件指定触发(如日终清算)。
【模拟】放在分布式缓存中的数据,大家都原子性地修改不就好了吗?为什么还需要加分布式锁?
业务逻辑通常是一个多步操作:读-校验-写(R-C-W),必须保证整体的原子性。Redis 原子操作只能保证数字加减的原子性,无法在执行加减操作前进行复杂的业务校验。
分布式锁的作用是把 R-C-W 看作一个不可分割的整体,强制串行化执行。
3. 怎么排查死锁?
现象:CPU 飙升、QPS 暴跌、大量线程阻塞。
1) 排查数据库行级锁死锁。
登录数据库,查看数据库的死锁日志(Show Engine InnoDB Status),排查是否发生了数据库行级锁死锁。
如果发生了数据库死锁,数据库会自动进行回滚,打破循环等待,但工程师仍然需要彻底消除数据库死锁问题。
2) 排查应用内部死锁。
根据监控系统显示的异常 CPU,锁定故障服务器。然后登录故障服务器,使用 Arthas(thread -b)排查 Java 内部的循环等待死锁。
如果发生了应用内部死锁,临时解决方案是重启服务器释放占用的 CPU 和内存资源,根本解决方案是修改代码。
3) 排查分布式锁。
分布式锁死锁可能有两种情况:锁未释放和跨资源多锁死锁。
由于 Redisson 有看门狗机制,大概率不会发生锁未释放,除非看门狗失效或程序崩溃。
排查跨资源多锁死锁,首先用 Arthas 查看阻塞线程的堆栈信息(thread -n 10),确定线程卡在获取什么资源的过程中,然后做代码层面的根因分析,重构代码解决。
预防死锁的核心原则:制定并严格遵守统一的资源获取顺序,比如 Redis 客户锁 -> Redis 头寸锁 -> DB 行锁
4. 垃圾收集器,G1 和 CMS 有什么区别?G1 的表现总是优于 CMS 吗?
两者的区别就略过了。
G1 的表现是否总是优于 CMS?结论:G1 的内存开销和 CPU 开销都比较大,在小堆和低延迟场景表现不佳。
5. 假如 MySQL 中有两张表,AB 和 CD。联表查询时,通过 B 和 C 两个字段连接,where 条件中的查询字段是 A 和 D,两张表要怎么创建索引?
面试官想考察的点有两个,一个是使用覆盖索引避免回表查询,另一个是最左前缀原则,因此创建的索引是 AB 和 DC。(面试官的关键错误认识是,把 B 和 C 两个连接字段等同于了两个查询结果字段)
实际上跟 AI 讨论后得到结论,驱动表的等值查询字段优先于连接字段,被驱动表的连接字段优先于等值查询字段,所以创建的索引应该是 AB 和 CD。
6. 有没有看过 netty 源码?什么是零拷贝?
我没有看过,不太了解。
7. 一个算法问题,把一个数组里的数据,构建成一个多叉树,时间复杂度要求 O(n)。
我没听懂这个问题,面试完搜了下,面试官的意思应该是创建一个文件目录树形结构,每个节点有一个 id、一个 parentId 和一个 children 列表。
核心思路是,进行两次 O(n) 的遍历:
1) 第一次遍历,构建 HashMap。
把所有的节点存入 HashMap,实现 O(1) 级别的节点查找。
2) 第二次遍历,构建树形结构。
再次遍历所有节点,通过 HashMap 找到其父节点(O(1) 时间复杂度),然后将当前节点添加到父节点的子节点列表中。
8. 有没有运维经验?
有构建镜像、容器化部署、创建云上工作负载等经验。
二、某国际互联网金融公司(AI 岗位)
1. 是否了解 RAG?
RAG:检索增强生成。它的核心思想是,当接收到一个问题时,先从知识库中找到与问题最相关的信息,然后将问题和相关信息一起打包,生成 prompt,交给大模型,大模型会根据 prompt 生成回答。它的优势在于,回答更准确、更深入特定领域、相对于微调大模型的成本更低、可溯源。
2. 对分布式锁有什么了解?
在单个服务节点,可以使用 synchronized 锁或者 Lock 锁保证多线程之间的同步,然而在高并发场景下,多个服务进程同时访问一个共享资源时,需要加分布式锁,保证在任意时刻,只有一个服务进程能够获得锁并操作共享资源,保证数据一致性。
分布式锁可以使用 Redis 的哈希(Hash)数据结构实现:
Key:分布式锁的名称,可自定义,如 "myLock";
Value:Hash 数据结构,示例:{"uid": 锁的唯一标识符, "count": 重入的次数}。
同时,必须给分布式锁设置超时时间(TTL),超时时间可以设置为业务平均执行时长的 3~5 倍。
【模拟】为什么要设置超时时间?
对于分布式锁,上锁的代码必须放在 try 块中,解锁的代码必须放在 finally 块中。
如果在任务执行期间服务节点挂了,finally 块中的解锁代码不一定会执行。
所以必须给分布式锁设置超时时间(TTL),防止解锁失败后死锁。
【模拟】怎么保证某个线程设置的分布式锁不会被其他线程解锁?
加锁时,通常会设置一个密语,在解锁时只有对上密语才能解锁(这个密语就是锁结构中的 锁的唯一标识符)。
这个密语可以是一个 UUID,也可以是 锁的持有者的唯一标识(可使用 服务节点 ID + 线程 ID)。
【模拟】分布式锁正在使用时超时了怎么办?
为了防止这件事发生,可以启动一个守护线程,定期检查锁是否仍然存在,如果存在,自动刷新锁的过期时间,实现锁的自动续命。
【模拟】实际开发过程中使用的分布式锁。
在实际开发过程中,使用了 Redisson 分布式锁。
Redisson 分布式锁内部实现了可重入。
Redisson 分布式锁的看门狗机制,实现了锁的自动续命。
3. 怎么排查和解决死锁?
见上文。
4. 如果一个请求处理得很慢要怎么解决?
首先要做打桩测试,找出时间都花在哪里了,然后再做针对性的处理。
如果是数据库查询比较慢,可以考虑 SQL 调优,或者将热点数据保存到缓存中。
如果是数据量太大导致的,可以考虑做读写分离或者分库分表。
如果是循环次数太多,可以考虑做多线程。
如果是某个算法运算比较慢,可以针对性地考虑如何降低时间复杂度。
如果是业务流程太长,可以考虑将一些非实时的操作提出来做异步处理。
5. 对高并发的了解。
高并发问题的本质是如何处理同一时间打到服务器上的海量请求。
流量入口处,做好负载均衡。
使用缓存层,快速消化读请求。
服务层将应用拆分为无状态的微服务,保证服务的可扩展性和高可用性。
数据层实现读写分离和分库分表。
6. 对 AI 的前沿技术有什么了解?
以下是 AI 的总结:
目前 AI 领域的前沿技术主要集中在大模型(LLM/Foundation Models) 的迭代、多模态的融合、以及如何让 AI 应用更可靠。
RAG 架构 和 AI Agent 是目前最具工程价值的前沿方向。RAG 解决了 LLM 的实际落地瓶颈(知识时效性和准确性),而 Agent 则代表了我们未来会如何构建 AI 驱动的应用,是提高业务自动化水平的关键。
三、某互联网大厂
1. 讲一个你之前做过的项目,你在里边都做了什么?
讲的是微博评论情感分析系统。
目的:开发一个 AI 项目,打通 AI 应用的端到端全链路。
需求分析:训练一个文本情感分析模型,然后实现 Java 工程化,开发一个微博评论的情感分析接口(积极、消极、中性)。考虑到做单条评论的情感分析,模型分析无论从效率方面还是准确性方面,都比不上人力分析,为了使系统更有实际应用价值,所以要再开发一个批量情感分析接口,上传 excel、csv、txt 文档,读取评论列表并进行分析,最终得到一个整体的舆情分析结论。
技术学习:以看网课的形式,在 AI 工具(Gemini)的帮助下,快速补充机器学习的知识。
模型训练:首先是收集评论数据,我在网上收集了 4 万条无打标数据和 1.4 万条打标数据,分别用来做模型预训练和微调。(这里我问过 AI 助手,使用 4 万条无打标数据进行预训练,再用 1.4 万条打标数据做微调,和直接使用 5.4 万条打标数据做微调,哪个效果更好,AI 助手说先预训练的效果更好,因为这样可以让模型先学习微博评论的语言风格,再针对性地进行情感分析训练)预训练的基模型我用的是 BERT 模型(选的是一个用全词掩码方法训出来的模型 hfl/chinese-bert-wwm-ext),用 transformers 里的 Trainer 工具完成预训练,把预训练的结果模型保存下来,开始做微调。微调的时候,主要的超参数有学习率、批次大小和训练轮次,我主要通过调整训练轮次来保证训练效果,比方说设置的训练轮次是 5,在第 3 次或第 4 次的测试准确率最高,前两次可能会欠拟合,最后一次可能会过拟合,在做微调的时候,总是保存测试准确率最高的训练模型。最终的测试准确率大概在 78% 左右。微调完成后,还会做 bad case 分析,用测试数据跑一下模型推理,把分类错误的结果按照置信率排一下,然后逐个分析。比如有一个 case,它本身是客观地讲了一件负面的事情,打标结果是消极,模型判定是中性,置信率很高,这种情况可以认为模型判断是正确的,是数据打标有问题;还有的情况是,评论本身的感情比较细腻,模型给了一个错误的分类,但置信率比较低,这种情况也是可以接受的,因为模型对预测结果也没有信心,对这种情况,在做舆情分析的时候可以适当地降低权重。
Java 工程化:把训练好的模型导出为 ONNX 格式,然后在 Java 代码中加载模型,提供评论情感分析的能力,然后做了两个 REST 接口,一个是评论的情感分析接口,一个是批量分析接口。
如果你的模型在线上投入使用了,然后出现了 bad case,你要怎么处理?
我觉得还是得继续训练模型,用更多的数据进行训练。
但训练模型是个比较缓慢的过程,根据之前的项目经验,我觉得可以在情感分析之前做一个 FAQ 问答,把 bad case 存到向量库里,在做评论的情感分析之前,先查一下有没有这条 FAQ 记录,如果有,就可以快速返回结果。
FAQ 问答不是说我必须一字不差才能查到之前存的 case,在存的时候,会先用 embedding 模型把评论转成向量,然后在查的时候,也会把评论转成向量,然后去向量库中匹配相似度较高的记录,只要相似度(余弦近似度)超过某个阈值,就可以认为是相似的评论。
你说的 FAQ,是一个临时解决的办法,用这个办法你有了缓冲时间,那你怎么彻底解决出现的 bad case?
使用 FAQ 问答积累的 bad case 来训练模型。
2. 你在前面做过的证券金融服务平台,应该对高并发,以及数据一致性要求很高吧?
简单讲了下业务流程。
你说你们的用户数据是根据用户 ID 保存在多个分片上的,那分片在进行扩展的时候,怎么把用户数据扩展到新的分片上?
在扩展分片的时候需要做数据迁移,关键和难点在于迁移过程中,如何保证业务不中断。
使用 Hash 分片的可扩展性很差,一旦分片数量发生变化,几乎所有的用户数据都需要迁移。但我们觉得,数据迁移是小概率事件,可以接受在某个集中的时间窗口期,来完成迁移工作。
如果真的需要做数据迁移,分三个步骤:
1) 开启双写:将所有的写请求,同时写入旧分片和新分片,所有的读请求仍然打到旧分片上。
2) 异步全量迁移:启动后台程序,计算所有用户数据的新位置,并将需要移动的数据从旧分片复制到新分片(分片数每次扩展都乘二,所有用户数据要么在原分片,要么在同一个新分片)。在这个过程中,双写保证了新数据不会丢失。
3) 路由切换:所有数据迁移完成并且校验无误后,瞬间切换(原子切换)路由逻辑,所有读写请求基于新的分片数执行。
3. 你之前做的 xx 项目是做什么的?你在里边是干啥的?
讲了下业务流程,以及我负责的业务。
4. 手撕代码:有一个图书馆,里边有一个图书管理员,管理了 5 本书(5 本书没有任何区别),你要模拟有 10 个读者同时来借书,每个读者拿到书后,保持 2 ~ 4 秒后还书;没有借到书的读者开始等待,等到有书后,由管理员提醒等待的读者借书。
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;/*** 图书馆类:管理图书资源和借还书逻辑*/
class Library {// 5 本书,使用 Semaphore 来限制并发访问的数量private final Semaphore books;private final int totalBooks;public Library(int numBooks) {this.totalBooks = numBooks;// 初始化信号量,许可证数量等于书的数量this.books = new Semaphore(numBooks);System.out.println("图书馆开放,共有 " + numBooks + " 本书。");}/*** 读者借书的逻辑* @param readerName 读者的名称*/public void borrowBook(String readerName) {System.out.println(readerName + " 尝试借书...");try {// 尝试获取一个许可证,如果许可证数量为0(书都被借出),则读者等待。// 这一步自动处理了“没有借到书的读者开始等待”的需求。books.acquire(); // 成功获取许可证,表示借到书long availableBooks = books.availablePermits();System.out.println("✅ " + readerName + " 成功借到书!(剩余书本: " + availableBooks + ")");} catch (InterruptedException e) {Thread.currentThread().interrupt();System.out.println(readerName + " 借书等待被中断。");}}/*** 读者还书的逻辑* @param readerName 读者的名称*/public void returnBook(String readerName) {// 归还许可证,表示书被归还。// 这一步会自动唤醒一个在 books.acquire() 处等待的线程(读者)。books.release();long availableBooks = books.availablePermits();System.out.println("⬅️ " + readerName + " 归还了书。 (当前书本: " + availableBooks + ")");}
}/*** 读者类:模拟读者的行为*/
class Reader implements Runnable {private final String name;private final Library library;public Reader(String name, Library library) {this.name = name;this.library = library;}@Overridepublic void run() {// 1. 尝试借书library.borrowBook(name);// 2. 只有借到书的读者才会执行下面的阅读和还书操作// 延迟阅读时间try {// 随机生成 2000 ms (2秒) 到 4000 ms (4秒) 的阅读时间int readTime = ThreadLocalRandom.current().nextInt(2000, 4001); System.out.println(" ▶️ " + name + " 开始阅读,预计阅读 " + (readTime / 1000.0) + " 秒...");Thread.sleep(readTime);System.out.println(" ⏹️ " + name + " 阅读完毕。");} catch (InterruptedException e) {Thread.currentThread().interrupt();} finally {// 3. 还书library.returnBook(name);}}
}public class LibrarySimulation {private static final int TOTAL_BOOKS = 5;private static final int TOTAL_READERS = 10;public static void main(String[] args) {Library library = new Library(TOTAL_BOOKS);// 使用线程池来模拟 10 个读者同时来借书ExecutorService executor = Executors.newFixedThreadPool(TOTAL_READERS);for (int i = 1; i <= TOTAL_READERS; i++) {Reader reader = new Reader("读者-" + i, library);executor.execute(reader); // 提交任务给线程池}// 关闭线程池,等待所有任务完成executor.shutdown();// try {// // 等待所有任务最多 30 秒// if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {// System.out.println("\n部分读者未能在规定时间内完成借还书,强制关闭。");// } else {// System.out.println("\n所有读者都已完成借还书过程。");// }// } catch (InterruptedException e) {// executor.shutdownNow();// }}
}