互联网大厂Java面试实录:谢飞机的“渡劫”之旅

一、 场景背景

地点:某一线互联网大厂 - 202会议室 人物:

  • 面试官(老张):技术专家,面过无数候选人,眼神犀利,擅长层层递进追问。
  • 候选人(谢飞机):某二线城市小厂Java开发,简历包装略显浮夸,基础尚可但深度不足,性格乐观且有点“头铁”。

二、 面试正文

第一轮:集合框架与并发基础

老张:(翻看着简历)谢飞机是吧?看你简历上写着“精通Java集合框架”,那我们先从最简单的开始。HashMap在JDK1.7和1.8中有什么区别?为什么1.8要引入红黑树?

谢飞机:(推了推眼镜,自信满满)张总好!这个我熟。1.7是数组加链表,1.8是数组加链表加红黑树。为什么加红黑树?因为……因为链表太长了查找慢啊,O(N)嘛!红黑树是O(logN),快如闪电!而且1.8里面,当链表长度超过8,并且数组长度超过64时,就会转成红黑树,防止哈希冲突导致的性能下降。

老张:(微微点头)嗯,回答得不错,基本点都踩中了。那如果链表长度到了8,但数组长度只有60呢?会转红黑树吗?

谢飞机:(愣了一下,挠头)呃……这个……我想想。既然数组短,说明容量不够,应该先扩容吧?不对,扩容是2倍。那就……不转?因为树化的阈值是8,但扩容阈值是0.75capacity。如果capacity是64,0.7564=48。哦!我懂了!如果数组长度不到64,即使链表长到8,也应该先扩容,而不是转红黑树!因为扩容带来的分散效果可能比树化更显著,而且树化是有开销的。

老张:(露出赞许的微笑)反应很快嘛!看来是真懂。那接着问,ArrayList和Vector的区别是什么?为什么现在推荐用CopyOnWriteArrayList?

谢飞机:ArrayList不是线程安全的,Vector是线程安全的,因为Vector的方法都加了synchronized。CopyOnWriteArrayList呢……它是“写入时复制”的思想。读的时候不加锁,写的时候复制一份新的数组,然后替换。这样解决了迭代器遍历时并发修改异常的问题,适合读多写少的场景。

老张:很好。那CopyOnWriteArrayList在什么情况下会出现数据不一致的问题?

谢飞机:(有点飘了)那就是……写完没来得及通知别人?不对,它是原子引用替换。应该是……当你在遍历它的时候,有人往里面加东西?

老张:具体说说。

谢飞机:哦!我知道了!Iterator获取的是旧数组的快照,所以遍历过程中即使有其他线程修改了列表,遍历出来的结果还是旧的,这就是“弱一致性”。如果业务强依赖实时数据,那就不能用它,得用Collections.synchronizedList或者ReentrantLock。

老张:(合上笔记本)这一轮表现不错,基础挺扎实。我们进入下一环节,聊聊多线程。


第二轮:JUC与线程池

老张:你简历里写了“熟悉JUC包”,那ThreadPoolExecutor的核心参数有哪些?请手动创建一个线程池并解释每个参数的含义。

谢飞机:(背公式一样)corePoolSize(核心线程数),maximumPoolSize(最大线程数),keepAliveTime(空闲线程存活时间),unit(时间单位),workQueue(任务队列),threadFactory(线程工厂),handler(拒绝策略)。

老张:好,那你解释一下,当提交一个任务时,线程池的处理流程是怎样的?

谢飞机:第一步,判断核心线程是否满了,没满就创建新线程执行;第二步,如果核心线程满了,就把任务丢进工作队列;第三步,如果队列也满了,就创建非核心线程执行;第四步,如果非核心线程也满了,就执行拒绝策略。

老张:(挑眉)顺序没错,但有个细节。如果队列是LinkedBlockingQueue(无界队列),会发生什么?

谢飞机:(脸色微变)呃……因为是无界的,所以第三步和第四步永远不会触发?也就是说,永远只用核心线程?如果核心线程不够,会不会导致OOM?

老张:准确说,是线程数永远保持在corePoolSize,如果任务堆积过多,虽然不会直接OOM(除非内存被任务对象占满),但会导致响应延迟极高,甚至假死。那如果我想限制最大并发数,应该怎么配置?

谢飞机:那就不能选无界队列了。应该选有界队列,比如ArrayBlockingQueue。这样当队列满了,线程数才会增加到maximumPoolSize。然后再配合合理的拒绝策略,比如CallerRunsPolicy,让调用者自己执行,起到背压作用。

老张:思路清晰。那讲讲ThreadLocal吧,它的内存泄漏问题怎么产生的?如何避免?

谢飞机:ThreadLocalMap的Key是ThreadLocal的弱引用,Value是强引用。当ThreadLocal没有外部强引用时,Key会被GC回收,变成null,但Value还在,这就造成了内存泄漏。避免方法就是在finally块里调用remove(),清除Entry。

老张:那为什么Key要设计成弱引用?

谢飞机:(卡壳)这……为了……方便GC?

老张:是为了防止ThreadLocal对象本身无法被回收。如果是强引用,只要线程不死,ThreadLocal就不会被回收,哪怕代码里已经没用了。弱引用可以让Key在GC时快速失效,虽然Value还在,但至少Key不占地方,而且我们可以及时remove()清理Value。

谢飞机:(擦汗)原来如此,受教了受教了。

老张:最后一题,CountDownLatch和CyclicBarrier的区别是什么?

谢飞机:CountDownLatch是一次性的,计数减到0就释放所有等待线程,之后不能重置。CyclicBarrier是可以循环使用的,到达屏障后互相等待,可以重置计数,常用于多线程计算结果汇总。

老张:好,第二轮结束。你去那边喝口水,我们聊聊数据库和缓存。


第三轮:MySQL、Redis与架构思维

老张:(回来坐下)假设你的业务系统中,有一张订单表,数据量达到了5000万,查询变慢。你会怎么优化?

谢飞机:首先,加索引!最左前缀原则,联合索引。其次,查询语句优化,避免SELECT *,只查需要的字段。还有,分页优化,大页码用延迟关联。

老张:如果加了索引还是慢,可能是覆盖索引没命中,或者回表太多。那如果索引也建不了了呢?比如模糊查询like '%abc%'?

谢飞机:那就……没法用索引了。只能上ES?或者分库分表?

老张:分库分表是手段,但前提是你要解决路由问题。那在应用层,除了分库分表,还有什么办法减少单表压力?

谢飞机:冷热数据分离!把历史订单归档到另一张表或数据库中,只查近半年的数据。还有,读写分离,主库写,从库读。

老张:合理。那说到读写分离,主从延迟怎么解决?

谢飞机:(自信)这个我知道!关键业务,比如支付成功后的状态查询,强制走主库!或者,给从库加缓存,但要注意一致性。

老张:好,那聊聊Redis。为什么Redis这么快?

谢飞机:基于内存操作,单线程模型(IO多路复用),高效的数据结构,比如SDS、跳表等。

老张:单线程模型真的没有并发安全问题吗?

谢飞机:Redis命令是串行执行的,所以对于单个命令来说是原子性的。但是,如果涉及多个命令,比如先get再set,就不是原子的了。这时候要用Lua脚本或者MULTI/EXEC事务。

老张:那Redis过期策略和内存淘汰策略有什么区别?

谢飞机:过期策略是键值对了就删,有三种:定期删除、惰性删除、定时删除(其实Redis主要是前两种)。内存淘汰策略是当内存满了,新写入数据时,根据配置淘汰旧数据,比如LRU、LFU、Random等。

老张:如果我用的是volatile-lru,是什么意思?

谢飞机:只对设置了过期时间的键进行LRU淘汰,未设置过期时间的键不被淘汰。

老张:不错。最后一个场景题:如果你要用Redis实现分布式锁,你会怎么做?有什么潜在问题?

谢飞机:用SETNX命令,加上EXPIRE设置超时时间。潜在问题是,如果设置键和设置过期时间不是原子的,可能设置了键但没设过期时间,导致死锁。所以要用SET key value NX EX seconds。

老张:那如果业务执行时间超过了锁的过期时间怎么办?

谢飞机:(苦笑)那就……锁提前释放了,其他线程抢到了锁,造成并发问题。这时候需要看门狗机制,比如Redisson,自动续期。

老张:(微笑点头)好,今天的面试就到这里。你的基础很扎实,逻辑也很清晰。我们会综合评估,HR会在三天内联系你通知结果。

谢飞机:(长舒一口气)谢谢张总!我一定好好准备,期待好消息!


三、 面试复盘与技术详解

以下是对上述面试中涉及的核心技术点的详细解析,供各位求职者学习参考。

1. HashMap 深入解析

核心区别:

  • JDK 1.7:数组 + 链表。哈希冲突时,采用头插法插入链表。
  • JDK 1.8:数组 + 链表 + 红黑树。哈希冲突时,采用尾插法插入链表。当链表长度 > 8 且数组长度 >= 64 时,链表转换为红黑树;当红黑树节点数 < 6 时,退化回链表。

扩容机制:

  • 默认初始容量16,加载因子0.75。当元素个数 > 16 * 0.75 = 12 时,扩容为2倍。
  • 扩容时需要重新hash,将节点分配到 newCap >> 1 或 newCap 两个位置,JDK 1.8 优化了扩容算法,无需重新计算hash值。

线程安全性:

  • HashMap 是非线程安全的。多线程环境下建议使用 ConcurrentHashMap。

2. 线程池 ThreadPoolExecutor 实战

核心参数:

  • corePoolSize:核心线程数,线程池维持的最小线程数。
  • maximumPoolSize:最大线程数,允许创建的线程总数。
  • keepAliveTime & unit:非核心线程的空闲存活时间。
  • workQueue:任务阻塞队列,如 ArrayBlockingQueue, LinkedBlockingQueue。
  • threadFactory:线程工厂,用于定制线程命名、优先级等。
  • handler:拒绝策略,AbortPolicy(抛异常)、CallerRunsPolicy(调用者执行)、DiscardPolicy(丢弃)、DiscardOldestPolicy(丢弃最老任务)。

工作流程:

  1. 新任务提交,若当前线程数 < corePoolSize,创建核心线程执行。
  2. 若核心线程已满,任务放入 workQueue。
  3. 若队列已满,且线程数 < maximumPoolSize,创建非核心线程执行。
  4. 若队列已满且线程数达到 maximumPoolSize,执行拒绝策略。

最佳实践:

  • 不要使用 Executors 创建线程池(FixedThreadPool, CachedThreadPool 等),因为 LinkedBlockingQueue 无界可能导致 OOM。
  • 建议手动 new ThreadPoolExecutor,并根据业务场景(CPU密集型/IO密集型)合理设置核心线程数。

3. ThreadLocal 内存泄漏

原理:

  • ThreadLocalMap 的 Entry 继承自 WeakReference,Key 是 ThreadLocal 实例,Value 是我们存储的对象。
  • Key 是弱引用,当 ThreadLocal 没有外部强引用时,GC 时会回收 Key,导致 Entry 的 Key 为 null。
  • Value 是强引用,只要线程不销毁,Value 就无法被回收,造成内存泄漏。

解决方案:

  • 每次使用完 ThreadLocal,务必在 finally 块中调用 threadLocal.remove()

4. MySQL 优化策略

索引优化:

  • 遵循最左前缀法则,避免索引失效。
  • 使用覆盖索引,减少回表。
  • 区分度低的字段不宜作为联合索引的前导列。

查询优化:

  • 避免 SELECT *
  • 大分页优化:使用延迟关联 LIMIT 1000000, 10 改为 JOIN (SELECT id FROM table LIMIT 1000000, 10) t ON ...

架构优化:

  • 读写分离:缓解读压力,但需注意主从延迟。关键业务强制读主库或使用 Canal 同步到本地缓存。
  • 分库分表:ShardingSphere 等中间件解决水平扩展问题。
  • 冷热分离:归档历史数据,提升热点数据查询效率。

5. Redis 核心概念

高性能原因:

  • 纯内存操作。
  • 单线程模型(IO多路复用epoll),避免了上下文切换和竞争消耗。
  • 高效的数据结构(SDS、跳表、压缩列表等)。

持久化:

  • RDB:快照方式,恢复快,但可能丢失最后一次快照后的数据。
  • AOF:日志追加方式,数据安全性高,但文件大,恢复慢。
  • 通常建议两者结合使用。

分布式锁:

  • 基础实现:SETNX key value EX seconds(原子操作)。
  • 进阶实现:Redlock 算法(多节点互斥),或 Redisson 客户端(看门狗机制自动续期,解决业务执行超时问题)。

6. 常见面试题总结

  • ArrayList vs Vector vs CopyOnWriteArrayList:线程安全与否,读写性能差异,适用场景(随机访问快,插入删除慢)。
  • CountDownLatch vs CyclicBarrier:一次性和可循环,N个线程互相等待 vs N个线程等待一个事件。
  • Spring Bean 生命周期:实例化 -> 属性填充 -> 初始化 -> 使用 -> 销毁。
  • Spring Boot 自动装配原理:@EnableAutoConfiguration -> spring.factories -> @ConditionalOnXxx 条件注解。

结语: 面试不仅是技术的考核,更是逻辑思维和问题解决能力的体现。谢飞机虽然偶尔紧张,但凭借扎实的基础和灵活的应变,最终赢得了面试官的认可。希望每一位Java开发者都能像谢飞机一样,既要有硬实力,也要有幽默感,从容应对大厂面试!

更多推荐