柏楚电子Java技术一面:从集合到消息队列的三十五连问
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 自我介绍。
- 两个栈实现队列怎么做?
- 从 n 个数里面找到第 k 大的数,你会怎么做?
- 那它跟快排相比,时间复杂度是多少?
- 实习经历。
- 你有些很具体的数据,像检测成功率从 78% 提升到 99.2%,这些是怎么测出来的?
- 你在做项目的过程中,遇到过哪些印象深刻的困难?当时又是怎么解决的?
- IOC 和 AOP 的理解。
@Async你应该是用过的吧?这个注解加在任何方法上都会生效吗?- MySQL 的事务隔离级别有哪几种?
- Spring 应用里面事务的处理和隔离级别的处理分别是什么样的?
- 实习或学习中做过哪些索引优化?当时怎么做的、遇到了什么问题?
- 现在建了一个 a、b、c 三个字段的联合索引,查询条件是 a 和 c 的等值查询,会走索引吗?
- 索引失效的场景有哪些?
- 我们一般说 Redis 是单线程的,所以经常用来做并发控制。Redis 是单线程的,这句话怎么理解?它完全是单线程吗?
- 你之前主要用 Redis 的什么数据类型?
- 我要在 Redis 里面记录最新的操作时间,在高并发场景下,我的调用方式是每次处理时入口先 get 一下这个 key,然后再 set 这个 key,这个操作正确吗?
- Exchange 这个概念可以解释一下是什么吗?
- 消息投递有 3 种可靠级别 QOS,你知道是哪 3 种吗?
- RabbitMQ 支持延迟消息吗?
- JVM 启动程序时会有堆内存的参数,如果我指定最大堆内存是 2 个 G,服务的内存占用会超过 2 个 G 吗?
- 线程和进程的区别?
- 死锁产生的条件?
- 谈谈你在开发经验里用到过多线程的场景。
- 线程池的核心参数有哪几个?
- 核心线程数的设置策略一般是什么样的?
- ThreadLocal 的实现原理是什么样的?
- 计算机网络的七层架构还记得是哪七层吗?
- HTTP 和 HTTPS 是哪一层?
- HTTPS 和 HTTP 相比的主要区别是什么?
- 你也写过一些 Spring Cloud 或者分布式的代码,有去关注过分布式事务这一块吗?
- Seata 还有哪几种模式?
- 之前都是互联网实习经验,我们公司是工业软件开发方向的,为什么想投这方面?
- 能够接受出差驻场吗?
- 反问。
《参考解析》
两个栈实现队列:准备 stackIn 和 stackOut 两个栈。入队一律压入 stackIn。出队时看 stackOut 是否为空:不为空直接弹栈顶;为空则把 stackIn 里的元素全部依次弹出并压入 stackOut,然后再弹 stackOut 的栈顶。核心是「只在 stackOut 为空时才搬运」,这样每个元素最多被搬一次,均摊时间复杂度 O(1),而不是每次出队都倒一次导致 O(n)。用两个队列实现栈则相反:入栈时压入非空队列,出栈时把非空队列除最后一个元素外全部搬到另一个空队列,再弹出剩下的那个,出栈复杂度 O(n)。
找第 k 大:三条路,各有适用场景。排序后取下标是 O(n log n),简单但不必要。快速选择(QuickSelect)用快排的 partition 思想,每轮只递归包含目标下标的那一侧,期望时间 O(n),最坏 O(n²)(数组已有序且每次都取端点做 pivot 时),工程上通过随机化 pivot 或三数取中把最坏情形压到几乎不出现,也可以用 median-of-medians 保证严格 O(n) 但常数很大。维护大小为 k 的小顶堆是 O(n log k),它真正的价值在于处理数据流或数据放不进内存的场景——只需要一次遍历、O(k) 空间。面试回答时把「快排 O(n log n) vs 快速选择期望 O(n)」的差别讲清,并补充最坏情形与随机化,比只报一个复杂度更完整。
IOC 与 AOP:IOC(控制反转)是把对象的创建与依赖装配交给容器,代码里不再 new 依赖而是声明需要什么,容器通过构造器注入、setter 注入或字段注入把实例送进来;它带来的直接好处是解耦与可测试(可以注入 mock)。DI 是 IOC 的实现方式,Spring 的 ApplicationContext 就是容器。AOP(面向切面)是把横切关注点(日志、事务、鉴权、缓存、重试)从业务代码里抽出来,用代理在方法调用的前后织入增强逻辑;Spring AOP 默认基于运行时动态代理——目标类实现了接口就用 JDK 动态代理,没有接口就用 CGLIB 生成子类。两者配合的关键点是:@Transactional、@Async、@Cacheable 这些注解本质上都是 AOP 增强,所以它们都受代理机制的约束。
@Async 什么情况下不生效:这是 AOP 代理机制的必考追问,失效场景有六种。一是同类内部调用——this.method() 绕过了代理对象,注解不生效;二是方法不是 public(CGLIB 也无法增强 private/final 方法);三是方法被 final 或 static 修饰;四是没加 @EnableAsync;五是类没有被 Spring 管理(自己 new 出来的对象);六是返回值类型不对——@Async 方法应当返回 void、Future 或 CompletableFuture,返回普通对象时调用方拿不到异步结果。另外两个工程上的坑:默认使用 SimpleAsyncTaskExecutor,它不复用线程、每次新建,生产上必须自定义 ThreadPoolTaskExecutor(否则高并发下会创建海量线程);异步方法抛出的异常不会传给调用方,需要配 AsyncUncaughtExceptionHandler 记录。修复同类内部调用失效的常见做法是把方法拆到另一个 Bean、注入自身代理,或用 AopContext.currentProxy()。
联合索引只查 a 和 c 会走索引吗:会走这个索引,但只能用上最左的 a 列做定位,c 用不上——因为最左前缀原则要求条件在索引里连续,中间缺了 b,索引树在 (a, b, c) 上的有序性对 c 不成立,无法用 c 做范围或等值定位。MySQL 5.6 之后的索引条件下推(ICP)可以把 c 的过滤条件下推到存储引擎层,在已按 a 命中的索引记录上先判断 c、不合格的就不回表,从而减少回表次数,但这仍是在 a 命中的范围内逐条扫描,不是精确定位。优化办法有三种:把 c 提到 b 前面(改成 (a, c, b) 或 (a, c))如果该查询是主流;为这个查询单独建一个 (a, c) 的辅助索引;或者如果只需要 a、c 两列,让索引覆盖查询(把 c 加进索引)从而免去回表。补充判断依据:EXPLAIN 里看 key 有没有命中、key_len 用了多少字节(能反推用到了几列)、以及 Extra 里是否出现 Using index condition。
索引失效场景:一是违反最左前缀(跳过前导列、或范围条件后面的列用不上);二是在索引列上做运算或调用函数(WHERE YEAR(created_at) = 2026、WHERE id + 1 = 5);三是隐式类型转换(字符串列用数字查、或 varchar 列传数字,都会把列包成函数);四是以 % 开头的 LIKE;五是 OR 连接的两侧不全在索引上;六是 !=、NOT IN、IS NOT NULL 这类否定条件在区分度低时被优化器放弃;七是返回行数占比过高(比如超过 20%~30%)时优化器判断全表扫描更便宜;八是统计信息过期导致估算失准(ANALYZE TABLE 可修)。要强调的是「失效」不等于「一定不走索引」——最终由优化器的成本估算决定,所以结论要用 EXPLAIN 验证,不要凭记忆下判断。
Redis「单线程」怎么理解:准确的说法是「命令的执行是单线程的」——所有客户端命令在一条主线程上串行执行,因此单个命令天然原子、不需要加锁,这也是它常被用来做并发控制(INCR、SETNX、Lua 脚本)的原因。但 Redis 进程本身完全不是单线程:它用 IO 多路复用(epoll)在一个线程里管理大量连接,同时有若干后台线程处理耗时操作——关闭文件描述符、AOF 刷盘(fsync)、异步释放大对象(UNLINK、lazy free)都是后台线程在做;Redis 6.0 起网络读写也可以配置成多线程(io-threads),只是命令执行仍然串行。要补一句影响:正因为命令串行,任何一条慢命令(KEYS、大 key 的 HGETALL、SORT)都会阻塞所有其他请求,所以生产上禁用 KEYS、用 SCAN 替代,并监控慢查询。
高并发下先 get 再 set 记录最新操作时间:不正确。GET + SET 是两条独立命令,中间存在竞态窗口:请求 A 和 B 都读到旧值,各自算出新值再写回,后写的会覆盖先写的,结果可能保存的是较早的那个时间——「最新操作时间」这个语义在高并发下就丢了。正确做法取决于真实语义:如果只是记录「最近一次操作的时间戳」,直接 SET key now 即可(单条命令原子,后到者覆盖,结果天然是最后到达的那次),根本不需要先 GET;如果要实现的是「只在比当前值更大时才更新」(CAS 语义),有两条路——用 Lua 脚本把读比较写合成一次原子执行(Redis 执行 Lua 时不会插入其他命令),或者用 WATCH + MULTI 做乐观锁(注意 WATCH 在并发冲突时需要重试)。这类题目的考点是「多命令组合的原子性」,凡是「先读再判断再写」的模式,都要问一句能不能用单命令或 Lua 化简。
Exchange 是什么:Exchange 是 AMQP 协议里的交换机,负责决定消息路由到哪些队列,它是生产者和队列之间的解耦层——生产者只把消息发给 Exchange 并带一个 routing key,不关心最终进了哪个队列。常见类型四种:Direct(routing key 精确匹配)、Topic(routing key 按 * 与 # 通配匹配)、Fanout(广播到所有绑定队列,忽略 routing key)、Headers(按消息头匹配,很少用)。Exchange 与 Queue 之间通过 binding 建立关系,一套 Exchange 可以绑定多个队列实现一对多分发。要理解的还有「默认交换机」:名字为空字符串的 Direct Exchange,会以队列名作为 routing key 自动把消息投到同名队列。另外 RabbitMQ 有「mandatory 标志 + 返回回调」机制处理「消息路由不到任何队列」的情况,否则这类消息会被静默丢弃。
消息投递的三种可靠级别:At most once(最多一次):消费方自动确认(autoAck=true)或不做任何确认,消息投递后不管有没有处理成功都不再重发,可能丢消息。At least once(至少一次):手动确认(autoAck=false),处理成功才 ack,失败或超时则重新入队或重投,保证不丢但可能重复——这也是业务必须幂等的根源。Exactly once(恰好一次):一次且仅一次,AMQP 里没有原生的原子投递保证,只能靠「至少一次投递 + 消费端幂等去重」来等效实现,或在单个事务范围内用本地事务保证。响应时最好补一句:生产端也要可靠性——publisher confirm 加持久化(Exchange、Queue、消息都设 durable)才能保证消息进了 Broker 不丢;端到端的可靠性永远是生产端 confirm、Broker 持久化、消费端手动 ack 三段一起做的结果。
RabbitMQ 的延迟消息:原生不支持延迟级别(那是 RocketMQ 的内建能力),RabbitMQ 有两条实现路径。一是 TTL + 死信队列:给消息或队列设 TTL,消息过期后成为死信被投到配置的 DLX(死信交换机),再路由到真正消费的队列,从而实现延迟消费;要注意的是队列级 TTL 只在队头生效、存在队头阻塞(前一条没过期,后面的即使过期也不会被投递),所以顺序敏感的场景要用消息级 TTL。二是安装 rabbitmq-delayed-message-exchange 插件,声明 x-delayed-type 类型的交换机,消息头上带 x-delay 毫秒数,由插件在内部用 Erlang 定时器实现,精度和性能都更好,代价是需要装插件、且延迟消息数量大时对内存有压力。工程上还要考虑延迟消息的可观测性(积压监控)与幂等,因为延迟投递失败重试时会重复。
最大堆 2G,进程内存会超过 2G 吗:会,而且通常明显超过。-Xmx2g 只限制 Java 堆,进程的常驻内存(RSS)还要加上:元空间(Metaspace,存类元数据,默认无上限、受本地内存限制,加载大量类或频繁生成代理类时会持续增长)、线程栈(每个线程约 1M,几百个线程就是几百 M)、直接内存(DirectByteBuffer、Netty 的堆外缓冲,受 -XX:MaxDirectMemorySize 约束且默认约等于堆大小)、JIT 编译产物(CodeCache,默认几十到几百 M)、GC 自身需要的结构(G1 的卡表、标记位图)、以及 JVM 加载的本地库。所以线上排查「容器被 OOM Kill」时不能只看堆:堆占用不高但 RSS 涨,最常见的三个原因是元空间泄漏(动态代理、反射、Groovy 脚本反复生成类)、直接内存泄漏(Netty 未释放 ByteBuf)、以及线程数失控。对应的排查工具是 jcmd VM.metaspace、jcmd VM.native_memory(需开启 NMT)、以及 pmap 对比。
线程池参数与核心线程数设置:七个核心参数——corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。执行顺序要背准:先看当前线程数是否小于核心数,是就新建核心线程;否则尝试入队;队列满了再看是否小于最大线程数,是就新建非核心线程;再满则执行拒绝策略。拒绝策略四种:AbortPolicy(抛异常,默认)、CallerRunsPolicy(提交线程自己执行,天然背压,生产常用)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的)。核心线程数的经验公式:CPU 密集型取「核数 + 1」(+1 是为了在偶发缺页或上下文切换时补位);IO 密集型取「核数 × (1 + 平均等待时间 / 平均计算时间)」,粗略起点是核数的 2 倍。工程约束比公式更重要:队列必须有界(无界队列会让最大线程数形同虚设、并在洪峰时 OOM)、线程池要按业务隔离并命名(便于排查)、必须监控活跃线程数/队列长度/拒绝次数。
ThreadLocal 的原理与内存泄漏:每个 Thread 内部持有一个 ThreadLocalMap,key 是 ThreadLocal 对象本身(弱引用),value 是实际存的值(强引用),所以数据是「以线程为容器」存放的,天然线程隔离。set/get 都是操作当前线程自己的那张 Map。内存泄漏的链条是:线程池里的线程长期存活,ThreadLocal 对象在业务代码里被置空后,Map 中 Entry 的 key 被 GC 回收变成 null,但 value 仍被 Entry 强引用着,于是这块内存永远无法回收,反复发生就会 OOM。虽然 get/set 过程中会顺带清理部分 key 为 null 的 Entry(启发式清理),但不可靠。唯一正确的解法是在 finally 里显式 remove(),实践中用拦截器或 Filter 统一清理。InheritableThreadLocal 能把值传给子线程,但线程池里的线程是复用且创建时机不确定的,靠它传递请求上下文会出错,正确做法是用阿里 TransmittableThreadLocal 或显式传参。
七层架构与 HTTP/HTTPS 的层次:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层(OSI 七层)。TCP/IP 四层模型里后三层合并成应用层,所以是网络接口层、网络层、传输层、应用层。HTTP 和 HTTPS 都属于应用层;它们依赖的 TLS/SSL 在传输层之上、应用层之下,通常被归为「表示层/会话层」的职责(实际实现上是介于传输层与应用层之间的独立层次),而 TCP 属于传输层、IP 属于网络层——很多笔试题就是拿「TCP/IP 属于传输层」这个错误选项设坑。HTTPS 相比 HTTP 多的是 HTTP 加 TLS:默认端口从 80 变 443,传输内容被加密(对称加密保护数据、非对称加密协商密钥、证书验证服务端身份),同时提供完整性与身份认证;代价是握手增加 RTT(TLS 1.2 完整握手 2-RTT,TLS 1.3 降到 1-RTT,会话复用可 0-RTT)。
分布式事务与 Seata 模式:Seata 有四种模式。AT 是默认模式,对业务无侵入:拦截 SQL 自动生成执行前镜像(before image)与执行后镜像(after image),放进 undo_log,一阶段提交本地事务并注册分支,二阶段全局提交就异步删日志、全局回滚就用 before image 生成反向 SQL 补偿;适用于大多数业务,但要求所有参与方是支持 ACID 的关系库、且 SQL 可被解析。TCC 需要业务自己实现 Try(预留资源)、Confirm(确认)、Cancel(取消)三个方法,侵入性强但性能与可控性最好,适合资金、库存这类需要严格预留的场景;难点是空回滚、悬挂、幂等三件套必须自己处理。Saga 面向长流程,把事务拆成一串本地事务,失败时按反序执行补偿动作,适合跨系统、耗时长的业务,没有隔离性保证(要用语义锁或状态机约束)。XA 基于数据库原生 XA 协议,强一致但性能差、且要求数据库支持,实际用得少。选型的判断是「一致性要求 × 业务侵入能接受到什么程度 × 是否有跨异构系统」。另外要能说出为什么不用「一条大事务」:跨库事务会长时间持锁、放大故障面,绝大多数场景用最终一致(本地消息表、事务消息、对账补偿)即可。