Java 面试题
Java 面试测试你对核心语言特性、面向对象设计、并发理解和问题解决能力的深度。高级工程师不仅要了解语法,还要能分析性能、内存和最佳实践。本指南涵盖了最常见和最具挑战性的 Java 面试题。
Java 面试涵盖内容
核心 Java 与 OOP
继承、多态、封装、抽象、接口 vs 抽象类,以及对象生命周期。
集合框架
HashMap 内部机制、ConcurrentHashMap、ArrayList vs LinkedList、Comparator vs Comparable,以及 fail-fast 与 fail-safe 迭代器。
并发与多线程
线程生命周期、synchronized、volatile、锁(ReentrantLock、ReadWriteLock)、执行器、fork/join 和并发集合。
JVM 内部与性能
类加载、内存模型(堆、栈、元空间)、垃圾回收算法(G1、CMS、ZGC)、性能分析和调优。
Java 面试题示例
- 解释 Java 中抽象类和接口的区别。什么时候你会使用哪一个?好回答应覆盖
- 抽象类可以包含抽象方法和具体方法,可以有构造器、实例变量;接口的方法默认是 public abstract,Java 8 后可以有 default 和 static 方法。
- 一个类只能继承一个抽象类,但可以实现多个接口。
- 抽象类用于表示“is-a”关系,接口用于表示“can-do”能力。
- 当类之间共享状态或行为时用抽象类;当定义契约且可能被无关类实现时用接口。
- 接口有利于解耦和多重继承,抽象类有利于代码复用和模板方法模式。
查看范例答案
抽象类和接口都是 Java 实现抽象的方式,但区别明显。抽象类可以有构造函数、实例变量和非抽象方法,而接口在 Java 8 之前只能有抽象方法和常量,之后可以包含 default 和 static 方法。一个类只能继承一个抽象类,但可以实现多个接口。抽象类通常用于表示具有共同祖先的类层次结构,例如 Animal 作为抽象类,Dog 和 Cat 继承它,共享一些属性如 age。接口则用于定义跨类层次结构的契约,比如 Runnable 接口可以被任何需要线程执行的类实现。选择时,如果多个类共享状态或部分实现,使用抽象类;如果需要定义行为的规范且实现类可能不相关,使用接口。另外,接口更适应未来变化,因为可以添加 default 方法而不破坏现有实现。
- HashMap 内部如何工作?扩容如何影响性能?好回答应覆盖
- HashMap 基于数组 + 链表/红黑树实现,通过 key 的 hashCode 确定桶位。
- 插入时若发生哈希冲突,使用链地址法,当链表长度超过阈值(默认8)且数组长度≥64时转为红黑树。
- 扩容时容量翻倍,重新计算所有元素的桶位(rehash),这是一个耗时操作。
- 扩容发生在元素个数超过阈值(容量×负载因子,默认0.75)时。
- 扩容后元素分布更散列,减少冲突,但频繁扩容影响性能,合理初始化容量可避免。
查看范例答案
HashMap 内部维护一个 Node 数组,每个 Node 可以是链表或红黑树。当插入键值对时,先计算 key 的 hashCode,然后通过 (n-1) & hash 得到桶索引。如果该位置为空则直接插入,否则遍历链表或红黑树,若找到相同 key 则替换值,否则插入尾部。当链表长度超过 TREEIFY_THRESHOLD(8)且数组长度至少为 64 时,链表会转换为红黑树以提高查找效率。当元素数量超过 threshold(capacity * loadFactor,默认 0.75)时触发扩容,容量加倍,然后将所有元素重新散列到新数组中。扩容需要计算每个元素的桶位,复杂度 O(n),非常影响性能。因此,如果预估元素数量,应使用指定初始容量的构造器以减少扩容次数。另外,扩容后元素位置可能会改变,原来在同一链表的元素可能分散到不同桶中。
- 用 Java 编写一个线程安全的单例(双重检查锁定、Bill Pugh 方法)。好回答应覆盖
- 双重检查锁定模式在获取实例时先判断 null,再同步,再判断 null,并使用 volatile 禁止指令重排。
- Bill Pugh 方法利用静态内部类延迟加载,由 JVM 类加载机制保证线程安全。
- 双重检查锁定需要 JDK5+ 的 volatile 语义,否则可能出现部分构造对象。
- Bill Pugh 方法更简单,无需同步锁,性能更好,是推荐的单例实现方式。
- 两种方法都是懒加载,适用于资源密集型单例。
查看范例答案
双重检查锁定(DCL)和 Bill Pugh 都是实现线程安全懒加载单例的经典方法。DCL 在 getInstance() 中首先判断 instance 是否为 null,若不为 null 直接返回;若为 null 则进入同步块,在同步块内再次判断 null,最后创建实例。instance 必须用 volatile 修饰,否则由于指令重排序可能导致返回未完全构造的对象。Bill Pugh 方法使用静态内部类 SingletonHolder,内部持有 final static 实例,当 getInstance() 被调用时,JVM 才会加载内部类并初始化实例,由于类加载的锁机制,保证了线程安全且无需同步开销。DCL 在 JDK5 之前有缺陷,而 Bill Pugh 从 JVM 层面保证了安全性。实际开发中优先使用 Bill Pugh 方法,因为代码更简洁且性能更好。以下代码演示了两种实现。
参考代码java public class Singleton { // 双重检查锁定实现 private static volatile Singleton instanceDCL; private Singleton() {} public static Singleton getInstanceDCL() { if (instanceDCL == null) { synchronized (Singleton.class) { if (instanceDCL == null) { instanceDCL = new Singleton(); } } } return instanceDCL; } // Bill Pugh 实现 private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstanceBillPugh() { return SingletonHolder.INSTANCE; } } /* 时间复杂度:O(1)(均摊) 空间复杂度:O(1) */ - synchronized、ReentrantLock 和 ReadWriteLock 有什么区别?为每个提供一个用例。好回答应覆盖
- synchronized 是关键字,自动加解锁,支持重入,可作用于方法或代码块;ReentrantLock 是 API,提供更多功能如可中断、定时、公平锁。
- ReentrantLock 必须显式 unlock,通常配合 try-finally;ReadWriteLock 允许多个读线程同时读取,写线程独占。
- synchronized 适合简单同步场景,JVM 会优化锁升级;ReentrantLock 适合高级控制如尝试获取锁。
- ReadWriteLock 在读多写少的场景下提升并发性能,但要注意写线程饥饿问题。
- 选择时优先使用 synchronized,除非需要高级特性;ReadWriteLock 适用于读多写少的共享数据。
查看范例答案
synchronized 是 Java 内置的同步机制,使用简单,不需要手动释放锁,且 JVM 会进行锁优化(如偏向锁、轻量级锁)。ReentrantLock 是 java.util.concurrent.locks 包下的显式锁,提供更灵活的锁操作,例如 tryLock() 可以非阻塞尝试获取锁,lockInterruptibly() 允许在等待锁时响应中断。它支持公平锁(按请求顺序分配)和非公平锁(默认)。ReadWriteLock 接口的实现类 ReentrantReadWriteLock 包含读锁和写锁,读锁可以被多个线程同时持有,写锁独占,适合读多写少的场景,如缓存。synchronized 的典型用例是简单的互斥操作,例如计数器自增。ReentrantLock 适用于需要尝试获取锁或可中断的场景,比如处理线程池关闭。ReadWriteLock 适用于需要高并发读取且写入频率较低的数据结构,如配置缓存。但需注意,如果写操作频繁,读锁可能导致写线程饥饿,可考虑 StampedLock 作为替代。
- 描述 Java 内存模型。volatile 如何保证可见性?好回答应覆盖
- Java 内存模型定义了线程与主内存之间的抽象关系:每个线程有工作内存,存储变量副本。
- volatile 变量保证每次读写都直接操作主内存,禁止指令重排。
- 写 volatile 变量时,线程将工作内存中的值刷新到主内存;读时从主内存中读取最新值。
- volatile 不保证原子性,复合操作(如 i++)仍需加锁。
- volatile 的可见性通过内存屏障实现,可防止重排序,从而建立 happens-before 关系。
查看范例答案
Java 内存模型(JMM)规定了所有变量存储在主内存中,每个线程拥有自己的工作内存(包含变量的副本)。线程对变量的操作必须在工作内存中进行,不能直接读写主内存。不同线程间无法直接访问对方的工作内存,只能通过主内存传递值。volatile 关键字的作用是保证可见性和有序性:当一个 volatile 变量被写入时,JMM 会立即将线程工作内存中的该变量刷新到主内存;当读取 volatile 变量时,也会从主内存中重新读取最新的值。此外,volatile 通过内存屏障禁止了指令重排序,确保写操作之前的所有操作不会重排序到写之后,读操作之后的所有操作不会重排序到读之前。这样,一个线程对 volatile 变量的写,对于后续读取该变量的另一个线程是可见的,即建立了 happens-before 关系。但 volatile 并不保证原子性,例如 count++ 这种非原子操作仍然需要同步。
- 编写一个方法,使用 Java Streams 或不用 Streams 找出字符串中第一个不重复的字符。好回答应覆盖
- 使用 LinkedHashMap 可以保持插入顺序并统计字符出现次数。
- 基于 Java 8 Streams,先 Convert 字符流再过滤。
- 若不使用 Streams,可以用数组或 HashMap 计数后遍历字符串。
- 注意处理空字符串和无效输入。
- 时间复杂度 O(n),空间复杂度 O(字符集大小)。
查看范例答案
查找第一个不重复字符的经典方法是使用 HashMap 统计每个字符的出现次数,然后再次遍历字符串找到第一个计数为1的字符。若使用 Java 8 Streams,可先将字符串转成字符流,通过 groupingBy 计数,然后过滤出计数为1的字符,再通过 findFirst 返回。但注意 Streams 的 ordered 特性,使用 LinkedHashMap 可保留顺序。另一种方式是利用 LinkedHashMap 自己统计,因为它的迭代顺序是插入顺序。以下代码展示了两种实现:一种使用 LinkedHashMap 和传统循环,另一种使用 Streams。两种方法的时间复杂度均为 O(n),空间复杂度为 O(字符集大小),如 ASCII 字符集则为 128。
参考代码java import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; public class FirstNonRepeating { // 使用 LinkedHashMap public static Character firstNonRepeatingChar(String s) { if (s == null || s.isEmpty()) return null; Map<Character, Integer> countMap = new LinkedHashMap<>(); for (char c : s.toCharArray()) { countMap.put(c, countMap.getOrDefault(c, 0) + 1); } for (Map.Entry<Character, Integer> entry : countMap.entrySet()) { if (entry.getValue() == 1) { return entry.getKey(); } } return null; } // 使用 Streams public static Character firstNonRepeatingCharStream(String s) { if (s == null || s.isEmpty()) return null; // 统计出现次数,使用 LinkedHashMap 保持顺序 Map<Character, Long> countMap = s.chars() .mapToObj(c -> (char) c) .collect(Collectors.groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting())); Optional<Character> result = countMap.entrySet().stream() .filter(entry -> entry.getValue() == 1) .map(Map.Entry::getKey) .findFirst(); return result.orElse(null); } } /* 时间复杂度:O(n),n 为字符串长度 空间复杂度:O(字符集大小),如 ASCII 为 O(128) */ - Java 中的垃圾回收是如何工作的?比较 G1GC 和 ZGC。好回答应覆盖
- Java 垃圾回收自动管理堆内存,标记-清除、标记-复制、标记-整理等算法。
- G1GC 是分代收集器,将堆划分成 Region,通过停顿预测模型控制停顿时间。
- ZGC 是低延迟收集器,使用染色指针、读屏障等技术,几乎不停顿。
- G1GC 适合大堆且需可预测停顿,ZGC 适合超大堆且追求极低延迟。
- G1GC 的并发标记阶段可能产生浮动垃圾;ZGC 支持 TB 级堆,但占用更多 CPU。
查看范例答案
Java 垃圾回收(GC)通过自动回收不再被引用的对象来管理堆内存。常见的算法有标记-清除、标记-复制和标记-整理。G1GC(Garbage First)是 JDK 9 以后的默认收集器,它将堆划分为多个大小相等的 Region,并分代管理(年轻代和老年代逻辑上连续)。G1GC 通过跟踪每个 Region 的存活对象大小,优先回收垃圾最多的 Region,从而实现可预测的停顿时间。它采用并发标记阶段,但最终标记和清理阶段需要停顿。ZGC(Z Garbage Collector)是 JDK 11 引入的低延迟收集器,旨在将停顿时间控制在 10ms 以内。它使用染色指针(colored pointers)和读屏障(load barriers)实现并发处理,大部分工作与应用线程同时进行。ZGC 不支持分代,需要处理全堆,但支持从几百 MB 到 TB 级的堆。G1GC 适合需要平衡吞吐量和停顿时间的场景,例如大多数服务器应用。ZGC 适合对延迟极度敏感的应用,如实时系统、高频交易等。ZGC 的缺点是占用更多 CPU 资源且内存开销较大(染色指针需要额外地址空间)。
- 使用 wait-notify(或锁条件)实现一个自定义阻塞队列。好回答应覆盖
- 阻塞队列在队列满时 put 阻塞,空时 take 阻塞。
- 使用 ReentrantLock 和 Condition 实现更清晰。
- wait-notify 方式需注意 notifyAll 避免信号丢失。
- 实现需考虑中断处理和公平性。
- 生产和消费线程通过条件变量协作。
查看范例答案
自定义阻塞队列通常使用 ReentrantLock 和 Condition 实现。队列内部维护一个数组(循环缓冲区)和两个条件变量:notFull 和 notEmpty。put 操作在队列满时等待 notFull 条件,然后插入元素并 signal notEmpty。take 操作在队列空时等待 notEmpty 条件,然后取出元素并 signal notFull。也可以使用 wait/notify 方式,但需要同步块,且 notifyAll 避免信号丢失,但效率较低。Lock 和 Condition 方式更可控,可支持公平锁和中断。以下实现使用了 ReentrantLock,并处理了中断异常。注意:使用 while 循环检查条件以避免虚假唤醒。
参考代码java import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BoundedBlockingQueue<E> { private final Object[] items; private int takeIndex, putIndex, count; private final ReentrantLock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); public BoundedBlockingQueue(int capacity) { if (capacity <= 0) throw new IllegalArgumentException(); items = new Object[capacity]; } public void put(E e) throws InterruptedException { lock.lockInterruptibly(); try { while (count == items.length) { notFull.await(); // 队列满则等待 } items[putIndex] = e; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); // 唤醒取线程 } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lockInterruptibly(); try { while (count == 0) { notEmpty.await(); // 队列空则等待 } @SuppressWarnings("unchecked") E e = (E) items[takeIndex]; items[takeIndex] = null; // help GC if (++takeIndex == items.length) takeIndex = 0; count--; notFull.signal(); // 唤醒放线程 return e; } finally { lock.unlock(); } } } /* 时间复杂度:put 和 take 均为 O(1) 空间复杂度:O(capacity) */
如何准备
- 在白板或在线编辑器上练习编写干净、线程安全的代码。
- 理解 JVM 内部机制,能够分析内存和 GC 行为。
- 掌握集合框架:了解每种实现的权衡。
- 复习 Java 8+ 特性:流、Lambda、Optional、CompletableFuture 和新日期/时间 API。
- 准备好关于 'HashMap 与 ConcurrentHashMap 的区别' 的深入回答,包括内部细节。
常见问题
Java 8 特性在面试中重要吗?
是的,大多数面试默认了解 Lambda、流、Optional 和 CompletableFuture。它们在现代 Java 开发中被广泛使用。
我需要记住 JVM 调优参数吗?
你应该熟悉常见的 GC 参数,如 -XX:+UseG1GC、-Xms、-Xmx,并知道如何调整堆大小。理解原理比记忆更重要。
设计模式在 Java 面试中常见吗?
常见,尤其是单例、工厂、构建器和观察者模式。准备好实现线程安全的单例或用 Java 8 Lambda 解释策略模式。
fail-fast 和 fail-safe 迭代器有什么区别?
fail-fast 迭代器在迭代期间如果集合结构被修改会抛出 ConcurrentModificationException(例如 ArrayList)。fail-safe 迭代器操作的是副本(例如 ConcurrentHashMap、CopyOnWriteArrayList)。
我应该了解 Java 9-17 的特性吗?
是的,预计会有关于模块(Jigsaw)、密封类、记录、instanceof 的模式匹配和文本块的问题。至少要知道主要特性。
练习 Java 题目,即时获取 AI 反馈
上传简历,获得个性化模拟面试,并了解需要改进的地方——免费开始。