模块一:Java 基础与集合

Q1. 面试官:先聊聊 HashMap 吧,讲讲它的底层结构,还有 JDK 1.7 和 1.8 的区别。

我的回答: HashMap 在 JDK 1.7 中是基于数组 + 链表实现的。它的核心思想是利用 Hash 算法将 Key 映射到数组下标,如果发生 Hash 碰撞,就通过链表(拉链法)解决。在 1.7 中,插入数据采用的是头插法

到了 JDK 1.8,为了优化高冲突情况下的查询效率,结构变成了数组 + 链表 + 红黑树。当链表长度超过阈值(默认为 8)且数组长度大于 64 时,链表会转换为红黑树,将查询复杂度从 O(n) 降低到 O(log n)。此外,1.8 改用了尾插法,这主要解决了多线程扩容下可能产生的链表死循环问题。

😈 面试官追问: 为什么阈值选的是 8?为什么不直接全用红黑树?

我的追答: 这其实是一个空间和时间的权衡

  1. 资源开销:红黑树节点(TreeNode)的大小几乎是链表节点(Node)的两倍。如果数据量少,用红黑树有点杀鸡用牛刀,浪费内存。

  2. 统计学规律:源码注释中提到,在理想的随机 Hash 算法下,哈希冲突遵循泊松分布。在负载因子 0.75 的情况下,链表长度达到 8 的概率非常低(大约是千万分之一)。所以,只有在极其极端(比如 Hash 算法写得很烂或者受到攻击)的情况下,才会用到红黑树来兜底保证性能。


Q2. 面试官:那 ConcurrentHashMap 又是怎么保证线程安全的?

我的回答: JDK 1.7 和 1.8 的策略变化很大。

  • JDK 1.7 采用了**分段锁(Segment)**的设计。它把大数组切分成一个个 Segment(继承自 ReentrantLock),每个 Segment 守护一段 Hash 槽。这样不同 Segment 之间的数据操作是可以并发的,并发度取决于 Segment 的个数(默认 16)。

  • JDK 1.8 放弃了 Segment,采用了 CAS + synchronized 来保证并发安全。锁的粒度更细了,只锁住链表或红黑树的头节点。如果没有冲突,就用 CAS 插入;如果有冲突,就用 synchronized 锁住头节点进行操作。

😈 面试官追问: 为什么 1.8 要把 ReentrantLock 换成 synchronized?

我的追答: 主要有两点考虑:

  1. 内存占用:1.7 的 Segment 继承了 ReentrantLock,内部属性很多,每个 Segment 都需要实例化,内存开销大。而 1.8 直接操作 Node,更轻量。

  2. JVM 优化:在 JDK 1.6 之后,官方对 synchronized 做了大量优化(如偏向锁、轻量级锁、锁消除等)。在低竞争环境下,synchronized 的性能已经非常优秀,且未来 JVM 的优化重点也在 synchronized 上,而非 API 级别的 Lock。


Q3. 面试官:ArrayList 和 LinkedList 哪个好?都说 LinkedList 插入快,是这样吗?

我的回答: 这需要分场景。ArrayList 基于动态数组,支持 O(1) 的随机访问,但扩容和中间插入涉及到内存拷贝(System.arraycopy),代价较大。LinkedList 是双向链表,对首尾操作非常快。

关于“LinkedList 插入一定快”这个说法,其实不准确。 如果是在中间插入,LinkedList 需要先遍历找到位置(O(n)),这个指针跳转的过程对 CPU 缓存(Cache Locality)非常不友好。而 ArrayList 虽然需要移动元素,但现代 CPU 对连续内存的拷贝做了极大优化。实测中,除非数据量巨大且都在头部插入,否则 ArrayList 的综合性能往往优于 LinkedList。

😈 面试官追问: ArrayList 扩容机制了解吗?

我的追答: 默认初始容量是 10(懒加载,第一次 add 时才初始化)。当容量不足时,会扩容为原来的 1.5 倍oldCapacity + (oldCapacity >> 1))。扩容本质是创建一个新数组,然后把旧数据拷贝过去,所以如果能预估数据量,最好在构造时指定容量,避免频繁扩容带来的性能损耗。


Q4. 面试官:==equals 的区别?为什么重写 equals 必须重写 hashCode?

我的回答: == 比较的是栈内存中的值,对于基本类型是数值,对于引用类型是内存地址equals 默认行为和 == 一样,但通常会被重写用于比较对象的内容

重写 hashCode 是为了遵守 Java 的通用约定,特别是在使用 HashMapHashSet 时。Map 判断 Key 是否重复的逻辑是:先比 hash 值,hash 值相同再比 equals。 如果只重写 equals,两个逻辑上相同的对象(比如两个 name="Tom" 的 Student 对象)会有不同的 hashCode。这会导致在 Map 中,明明是同一个 Key,却存了两份,或者存进去后取不出来(get 的时候算出了不同的 bucket 位置)。

😈 面试官追问: 如果两个对象 hashCode 相同,equals 一定为 true 吗?反过来呢?

我的追答:

  • hashCode 相同,equals 不一定为 true:这就是“哈希冲突”。

  • equals 为 true,hashCode 必须相同:这是 Map 正常工作的前提。


Q5. 面试官:讲讲 Java 的异常体系。你是怎么处理异常的?

我的回答: Java 异常的顶层是 Throwable,下面分为 Error(如 OOM、StackOverflow,通常无法恢复)和 Exception。 Exception 又分为 Checked Exception(受检异常,如 IOException,必须 try-catch 或 throws)和 Unchecked Exception(运行时异常,如 NullPointerException)。

在项目中,我通常会定义一个全局异常处理器(@RestControllerAdvice),拦截业务中抛出的自定义运行时异常,统一封装成 JSON 格式返回给前端。

😈 面试官追问: 你在 Transactional 事务中处理异常时,遇到过回滚失效的情况吗?

我的追答: 遇到过。Spring 的 @Transactional 默认只对 RuntimeException 和 Error 回滚。如果我的业务代码抛出了 Checked Exception(比如 SQLException 或自定义的非运行时异常),事务是不会回滚的。 解决方法是在注解上显示指定:@Transactional(rollbackFor = Exception.class)


模块二:Java 并发编程 (JUC)

Q6. 面试官:线程池用过吧?说说它的 7 个核心参数和工作流程。

我的回答: 常用的 ThreadPoolExecutor 有 7 个参数:

  1. corePoolSize(核心线程数)

  2. maximumPoolSize(最大线程数)

  3. keepAliveTime(非核心线程空闲存活时间)

  4. unit(时间单位)

  5. workQueue(阻塞队列)

  6. threadFactory(线程工厂,用于命名)

  7. handler(拒绝策略)

工作流程就像银行办业务: 任务来了,先看核心窗口(核心线程)满没满 -> 没满就创建线程执行;满了就让人去排队(放入阻塞队列); 如果队列也满了,就临时加开窗口(创建非核心线程)直到达到最大线程数; 如果窗口全开且队列也满了,大堂经理就会劝退(执行拒绝策略,默认是 AbortPolicy 抛异常)。

😈 面试官追问: 生产环境怎么设置 corePoolSize?

我的追答: 这不能拍脑袋定,主要看任务类型:

  • CPU 密集型(计算多):设置为 CPU核数 + 1,减少线程切换。

  • IO 密集型(读写数据库/调用接口多):因为线程大部分时间在等待 IO,可以设置得大一些,比如 2 * CPU核数,或者更精确的公式 CPU核数 * (1 + 等待时间/计算时间)。实际中通常会通过压测来微调。


Q7. 面试官:synchronized 和 ReentrantLock 有什么区别?

我的回答:

  1. 层面不同synchronized 是 JVM 关键字,C++ 实现的;ReentrantLock 是 JDK API,基于 AQS 实现的。

  2. 灵活性ReentrantLock 必须手动 lock()unlock()(要在 finally 中释放),而 synchronized 自动释放。但 Lock 支持可中断锁超时获取锁

  3. 高级功能ReentrantLock 支持公平锁(FairSync),且可以绑定多个 Condition 对象实现复杂的分组唤醒,而 synchronized 只有 wait/notify。

😈 面试官追问: 你说到公平锁,公平锁一定好吗?

我的追答: 不一定。公平锁严格按照 FIFO 排队,保证了不会有线程饿死,但吞吐量会下降。 因为强行排队意味着即使有资源,后来的线程也不能插队执行,必须挂起等待,这会带来大量的线程上下文切换开销。非公平锁虽然可能导致饥饿,但性能通常更好。


Q8. 面试官:volatile 关键字的作用是什么?它能保证线程安全吗?

我的回答: volatile 有两个核心作用:

  1. 内存可见性:基于 JMM 的 Lock 指令(或 MESI 协议),保证一个线程修改了变量,其他线程立刻从主内存看到最新值。

  2. 禁止指令重排:通过插入内存屏障,保证代码执行顺序。

但是,volatile 不能保证原子性。比如 i++ 操作,它包含“读-改-写”三步,多线程下只用 volatile 是不安全的。

😈 面试官追问: 那单例模式的双重检查锁(DCL)为什么要加 volatile?

我的追答: 为了防止对象初始化过程中的指令重排instance = new Singleton() 这行代码其实分三步:

  1. 分配内存。

  2. 初始化对象。

  3. 将 instance 指向内存地址。 如果没有 volatile,编译器可能重排为 1->3->2。此时另一个线程进来判断 instance != null,但拿到的其实是一个尚未初始化完成的半成品对象,导致程序报错。


Q9. 面试官:聊聊 AQS 吧,它是怎么实现的?

我的回答: AQS(AbstractQueuedSynchronizer)是 JUC 的基石。它的核心是一个 volatile int state(代表共享资源的状态)和一个 CLH 双向队列(用于存放等待锁的线程)。

以 ReentrantLock 为例: 线程尝试用 CAS 修改 state(从 0 改为 1)。

  • 如果你改成功了,就拿到了锁。

  • 如果失败了,就被封装成一个 Node 节点,加入到 CLH 队列的尾部,并阻塞自己(LockSupport.park)。

  • 当前面的线程释放锁时,会唤醒队列头部的线程去尝试获取锁。

😈 面试官追问: AQS 的 state 在 CountDownLatch 中是怎么用的?

我的追答: 在 ReentrantLock 中 state 代表锁的持有次数(可重入)。 但在 CountDownLatch 中,state 代表倒计数的数值

  • await() 方法会阻塞,直到 state 变为 0。

  • countDown() 方法每次通过 CAS 将 state 减 1。


Q10. 面试官:ThreadLocal 了解吗?为什么说它会内存泄漏?

我的回答: ThreadLocal 提供了线程隔离的变量。每个线程内部都有一个 ThreadLocalMap,Key 是 ThreadLocal 对象本身,Value 是我们存的值。

内存泄漏的原因在于: ThreadLocalMapKey 是弱引用(WeakReference),而 Value 是强引用。 如果 ThreadLocal 对象在外部被回收了(因为是弱引用,GC 时 Key 会变成 null),但只要线程还活着(比如线程池中的线程),Value 就会一直存在,且无法被访问到(因为 Key 没了)。这就是一条由于引用链未断开导致的内存泄漏。

😈 面试官追问: 那怎么解决这个问题?

我的追答: 最稳妥的办法是:用完即删。 在代码中,使用完 ThreadLocal 后,必须手动调用 remove() 方法。这通常在拦截器的 afterCompletion 或者 finally 代码块中处理。


Q11. 面试官:什么是 CAS?什么是 ABA 问题?

我的回答: CAS(Compare And Swap)是一种乐观锁机制,包含三个操作数:内存值 V、预期原值 A、新值 B。只有当 V == A 时,才把 V 更新为 B。

ABA 问题是:线程 1 准备把 A 改成 B。此时线程 2 进来,先把 A 改成了 C,又改回了 A。 线程 1 再进行 CAS 检查时,发现值还是 A,就认为没被改过,操作成功。但这在某些场景(如栈结构)下是会有问题的。

😈 面试官追问: 怎么解决 ABA?

我的追答:版本号。 类似于数据库乐观锁,每次修改不仅更新值,还更新版本号。1A -> 2B -> 3A。 JDK 提供了 AtomicStampedReference 类,它内部维护了一个 Pair(引用 + 版本号/时间戳),检查时同时对比这两个值。


Q12. 面试官:最后问个锁升级,synchronized 是怎么升级的?

我的回答: JDK 1.6 之后,synchronized 不再是一上来就是重量级锁,而是有一个升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  1. 偏向锁:假设只有一个线程在访问,Mark Word 记录线程 ID,下次这个线程来直接放行。

  2. 轻量级锁:当有第二个线程来竞争时,偏向锁撤销,升级为轻量级锁。线程通过自旋(CAS)尝试获取锁,不阻塞。

  3. 重量级锁:当自旋超过一定次数(或者多个线程同时竞争),为了避免 CPU 空转,锁会膨胀为重量级锁,将等待线程挂起(阻塞),交给操作系统调度。

😈 面试官追问: 偏向锁在现在的 JDK 版本中有什么变化吗?

我的追答: 这是个好问题。实际上,在 JDK 15 之后,偏向锁默认被废弃了(Deprecated)。 因为在现代高并发应用中,锁竞争是非常普遍的,偏向锁的撤销(Revoke)操作需要等待全局安全点(Safe Point),成本很高,反而可能拖慢性能。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐