目录
- 1. 引言
- 2. Java 基础高频考点
- 3. 集合框架深入剖析
- 4. 并发编程核心问题
- 5. JVM 内存与垃圾回收
- 6. 常见设计模式面试题
- 7. 面试实战技巧与总结
1. 引言
本文面向准备 Java 面试的求职者,系统梳理高频八股文考点,从原理到实践逐一详解,帮助你在面试中从容应对。
2. Java 基础高频考点
2.1 equals 与 hashCode 的约定
equals 与 hashCode 是 Java 面试中的经典问题,核心在于理解两者之间的约定关系。equals 用于判断两个对象逻辑上是否相等,hashCode 用于生成对象的散列码,供 HashMap、HashSet 等散列集合使用。
两者必须遵循以下约定:如果两个对象通过 equals 比较相等,那么它们的 hashCode 必须相等;反之,hashCode 相等不代表 equals 相等(哈希冲突)。如果重写了 equals 却没有重写 hashCode,会导致对象放入 HashSet 或作为 HashMap 的 key 时出现逻辑错误。
从底层原理来看,散列集合(如 HashMap、HashSet)在存储元素时,会先调用对象的 hashCode 方法计算散列码,再通过散列函数映射到对应的桶(bucket)。当两个对象的 hashCode 不同时,它们必然被分配到不同的桶,因此 equals 方法根本不会被调用,查找效率为 O(1)。当两个对象的 hashCode 相同时,它们会被分配到同一个桶,此时才需要调用 equals 方法逐一比较,判断是否为同一个对象。这就是为什么 equals 相等的对象 hashCode 必须相等------否则 equals 相等的两个对象会被分配到不同桶,导致集合中出现逻辑上重复的元素,破坏集合的语义。
反过来,hashCode 相等不代表 equals 相等,因为不同对象可能计算出相同的散列码,这就是哈希冲突。哈希冲突是散列数据结构不可避免的现象,解决方式包括链地址法(链表 + 红黑树)和开放地址法等。Java 的 HashMap 采用链地址法,当冲突链表长度超过阈值 8 且数组容量大于等于 64 时,链表会转换为红黑树,将冲突时的查找复杂度从 O(n) 降为 O(log n)。
此外,hashCode 的生成质量直接影响散列集合的性能。如果 hashCode 实现不当(例如所有对象返回同一个固定值),会导致所有元素被分配到同一个桶,散列集合退化为链表,查找复杂度从 O(1) 恶化到 O(n)。因此,优秀的 hashCode 实现应当尽量让不同对象产生均匀分布的散列码。Java 标准库中 Objects.hash 方法正是基于此设计,它使用 31 作为乘数(31 是奇素数,且 JVM 对 31 * i 有移位减法的优化),将对象的各个字段组合生成散列码,兼顾了分布均匀性和计算效率。
示例代码:
java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
// 31 作为乘数,保证散列码分布均匀
return Objects.hash(name, age);
}
public static void main(String[] args) {
Set<Person> set = new HashSet<>();
set.add(new Person("Alice", 25));
set.add(new Person("Alice", 25));
// 正确重写 equals 和 hashCode 后,重复对象不会重复加入
System.out.println("集合大小: " + set.size()); // 输出 1
// 演示 hashCode 的作用:先比较散列码,再比较 equals
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);
System.out.println("p1.hashCode() == p2.hashCode(): " + (p1.hashCode() == p2.hashCode())); // true
System.out.println("p1.equals(p2): " + p1.equals(p2)); // true
}
}
2.2 String 的不可变性
String 在 Java 中被设计为不可变类,其底层使用 final 修饰的 char 数组(JDK 9 之后为 byte 数组)存储字符。一旦创建,String 对象的内容就无法被修改,任何看似修改的操作(如 concat、replace、substring)实际上都会生成新的 String 对象。
不可变性的好处包括:字符串常量池可以安全共享、作为 HashMap 的 key 时哈希值稳定、天然线程安全。这也是为什么频繁拼接字符串时应使用 StringBuilder 或 StringBuffer 的原因。
示例代码:
java
public class StringImmutableDemo {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = s1.concat(" World");
// s1 本身没有被修改,concat 返回新对象
System.out.println("s1: " + s1); // Hello
System.out.println("s2: " + s2); // Hello World
System.out.println("s1 == s2: " + (s1 == s2)); // false
// 字符串常量池:相同字面量复用同一对象
String a = "Java";
String b = "Java";
System.out.println("a == b: " + (a == b)); // true
// new 创建的对象不进入常量池
String c = new String("Java");
System.out.println("a == c: " + (a == c)); // false
}
}
2.3 异常体系
Java 的异常体系以 Throwable 为根,分为 Error 和 Exception 两大类。Error 表示 JVM 层面的严重错误(如 OutOfMemoryError、StackOverflowError),程序通常无法也不应处理。Exception 分为受检异常(Checked Exception)和非受检异常(Unchecked Exception)。
受检异常(如 IOException、SQLException)在编译期强制要求捕获或声明抛出;非受检异常(如 NullPointerException、IllegalArgumentException)继承自 RuntimeException,编译期不强制处理。面试中常问两者的区别以及何时使用自定义异常。
示例代码:
java
import java.io.IOException;
public class ExceptionDemo {
// 受检异常:必须声明或捕获
public static void readFile() throws IOException {
throw new IOException("文件不存在");
}
// 非受检异常:无需强制处理
public static int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为 0");
}
return a / b;
}
public static void main(String[] args) {
// 捕获受检异常
try {
readFile();
} catch (IOException e) {
System.out.println("捕获受检异常: " + e.getMessage());
}
// 非受检异常运行时抛出
try {
divide(10, 0);
} catch (IllegalArgumentException e) {
System.out.println("捕获非受检异常: " + e.getMessage());
}
}
}
2.4 泛型与类型擦除
泛型是 Java 面试的高频考点,核心在于理解类型擦除机制。Java 的泛型是编译期概念,编译器在编译时会进行类型检查,但生成的字节码中会擦除泛型类型信息,替换为原始类型(如 List<String> 擦除为 List)。
类型擦除带来的限制包括:无法创建泛型数组(如 new T\[\])、无法在运行时获取泛型类型参数、静态上下文无法引用类型参数。理解擦除机制有助于解释为什么 List<String> 和 List<Integer> 在运行时是同一个 Class 对象。
示例代码:
java
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
public class GenericDemo {
// 泛型方法:编译期类型检查,运行期擦除
public static <T> T getFirst(List<T> list) {
return list.get(0);
}
public static void main(String[] args) throws Exception {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// 运行时 Class 相同,证明类型被擦除
System.out.println("strings 的 Class: " + strings.getClass());
System.out.println("integers 的 Class: " + integers.getClass());
System.out.println("两者 Class 相同: " + (strings.getClass() == integers.getClass()));
// 通过反射绕过泛型检查(演示擦除)
List<Integer> list = new ArrayList<>();
Method method = list.getClass().getMethod("add", Object.class);
method.invoke(list, "字符串混入");
System.out.println("绕过泛型检查后: " + list);
}
}
3. 集合框架深入剖析
3.1 ArrayList 与 LinkedList 的区别
两者都是 List 接口的实现,但底层数据结构完全不同。
- ArrayList 基于动态数组,随机访问效率高(O(1)),但插入和删除需要移动元素,效率较低。
- LinkedList 基于双向链表,插入和删除效率高(O(1)),但随机访问需要遍历,效率为 O(n)。
- ArrayList 扩容时按 1.5 倍增长,LinkedList 没有容量限制。
- 实际开发中,查询多使用 ArrayList,增删多使用 LinkedList。
示例代码:
java
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class ListCompare {
public static void main(String[] args) {
// ArrayList:随机访问快
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
arrayList.add(i);
}
long start = System.nanoTime();
int value = arrayList.get(50000); // O(1)
long end = System.nanoTime();
System.out.println("ArrayList 随机访问耗时: " + (end - start) + " ns, 值: " + value);
// LinkedList:头部插入快
List<Integer> linkedList = new LinkedList<>();
start = System.nanoTime();
for (int i = 0; i < 100000; i++) {
linkedList.add(0, i); // 头部插入 O(1)
}
end = System.nanoTime();
System.out.println("LinkedList 头部插入 10 万次耗时: " + (end - start) + " ns");
}
}
3.2 HashMap 的实现原理
HashMap 是面试中的重中之重,几乎必考。
HashMap 底层采用数组 + 链表 + 红黑树的结构。当向 HashMap 中放入键值对时,先根据 key 的 hashCode 计算桶下标,若该位置没有元素则直接放入;若发生哈希冲突,则追加到链表尾部。当链表长度超过阈值 8 且数组长度大于等于 64 时,链表会转换为红黑树,将查询时间复杂度从 O(n) 降为 O(log n)。
HashMap 的默认初始容量为 16,负载因子为 0.75。当元素数量超过容量乘以负载因子时触发扩容,容量翻倍。扩容时需要对所有元素重新计算哈希并迁移,因此频繁扩容会影响性能。
示例代码:
java
import java.util.HashMap;
import java.util.Map;
public class HashMapDemo {
public static void main(String[] args) {
// 默认容量 16,负载因子 0.75
Map<String, Integer> map = new HashMap<>();
// 放入键值对,根据 hashCode 计算桶下标
map.put("Java", 1);
map.put("Python", 2);
map.put("Go", 3);
// 发生哈希冲突时追加到链表尾部
map.put("Java", 4); // 覆盖旧值
// 查询
System.out.println("Java 对应的值: " + map.get("Java"));
// 遍历
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
// 查看当前容量(实际容量为 2 的幂次)
System.out.println("当前元素个数: " + map.size());
}
}
3.3 ConcurrentHashMap 的线程安全机制
ConcurrentHashMap 是并发场景下的首选 Map 实现,其线程安全机制经历了版本演进。
- JDK 7 采用分段锁(Segment)设计,将数据分成多个段,每个段独立加锁,提高并发度。
- JDK 8 放弃了分段锁,改用 CAS + synchronized 实现。插入时若目标桶为空,使用 CAS 直接放入;若桶非空,则对桶头节点加 synchronized 锁。
- JDK 8 的 ConcurrentHashMap 同样支持链表转红黑树,且扩容时支持多线程协助迁移。
示例代码:
java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
// 并发场景下使用 ConcurrentHashMap
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 模拟多线程并发写入
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
final int threadId = i;
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
map.put("key-" + threadId + "-" + j, j);
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
// 验证写入结果
System.out.println("总写入元素个数: " + map.size());
System.out.println("预期元素个数: " + (10 * 1000));
}
}
3.4 HashSet 与 TreeSet 的区别
两者都是 Set 接口的实现,但底层和排序特性不同。
- HashSet 底层基于 HashMap,元素无序,存取效率高,时间复杂度为 O(1)。
- TreeSet 底层基于红黑树,元素有序(自然排序或自定义比较器),但存取效率为 O(log n)。
- HashSet 允许一个 null 元素,TreeSet 不允许 null(因为需要比较排序)。
示例代码:
java
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
public class SetCompare {
public static void main(String[] args) {
// HashSet:无序,允许一个 null
Set<String> hashSet = new HashSet<>();
hashSet.add("Banana");
hashSet.add("Apple");
hashSet.add("Cherry");
hashSet.add(null); // 允许一个 null
System.out.println("HashSet 元素(无序): " + hashSet);
// TreeSet:有序,不允许 null
Set<String> treeSet = new TreeSet<>();
treeSet.add("Banana");
treeSet.add("Apple");
treeSet.add("Cherry");
// treeSet.add(null); // 会抛出 NullPointerException
System.out.println("TreeSet 元素(自然排序): " + treeSet);
// 自定义比较器:按字符串长度排序
Set<String> customTreeSet = new TreeSet<>((a, b) -> a.length() - b.length());
customTreeSet.add("Java");
customTreeSet.add("Python");
customTreeSet.add("C");
System.out.println("TreeSet 按长度排序: " + customTreeSet);
}
}
4. 并发编程核心问题
4.1 线程的生命周期与状态转换
Java 线程共有六种状态,理解状态转换是并发编程的基础。
| 状态 | 说明 | 进入方式 |
|---|---|---|
| NEW | 新建状态 | 创建 Thread 对象后 |
| RUNNABLE | 可运行状态 | 调用 start() 后 |
| BLOCKED | 阻塞状态 | 等待获取锁 |
| WAITING | 等待状态 | 调用 wait()、join() 等 |
| TIMED_WAITING | 超时等待 | 调用 sleep()、wait(timeout) 等 |
| TERMINATED | 终止状态 | run() 执行完毕或异常退出 |
4.2 synchronized 与 ReentrantLock 的区别
两者都是 Java 中常用的锁机制,但实现方式和功能有差异。
- synchronized 是 JVM 层面的关键字,ReentrantLock 是 JDK 提供的类。
- synchronized 自动释放锁,ReentrantLock 需要手动 unlock,通常配合 try-finally 使用。
- ReentrantLock 支持公平锁、可中断获取锁、超时获取锁等高级功能,synchronized 不支持。
- JDK 6 之后 synchronized 引入偏向锁、轻量级锁等优化,性能与 ReentrantLock 差距缩小。
4.3 volatile 关键字的作用
volatile 是 Java 中轻量级的同步机制,主要解决可见性和有序性问题。
- 可见性:volatile 修饰的变量被修改后,会立即刷新到主内存,其他线程读取时能获取最新值。
- 有序性:volatile 通过内存屏障禁止指令重排序,保证程序执行顺序符合预期。
- volatile 不保证原子性,例如
count++操作在并发下仍可能丢失更新。 - 典型应用场景:状态标志位、单例模式的双重检查锁(DCL)。
4.4 线程池的核心参数与执行流程
线程池是面试高频考点,需要掌握核心参数和执行流程。
ThreadPoolExecutor 的核心参数包括:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲存活时间(keepAliveTime)、任务队列(workQueue)和拒绝策略(handler)。
执行流程如下:
- 提交任务后,若当前线程数小于核心线程数,创建新线程执行任务。
- 若线程数达到核心线程数,任务进入阻塞队列等待。
- 若队列已满且线程数小于最大线程数,创建新线程执行任务。
- 若线程数达到最大线程数且队列已满,执行拒绝策略。
常见的拒绝策略有:AbortPolicy(抛异常)、CallerRunsPolicy(调用者执行)、DiscardPolicy(丢弃)、DiscardOldestPolicy(丢弃最老任务)。
5. JVM 内存与垃圾回收
5.1 JVM 运行时内存区域划分
下图展示了 JVM 运行时内存区域的整体结构,清晰标注了各区域的位置关系以及线程共享与私有属性:
JVM 内存区域是面试必考内容,需要清晰掌握各区域的作用。
- 程序计数器:记录当前线程执行的字节码行号,线程私有。
- 虚拟机栈:存放局部变量、操作数栈、方法调用信息,线程私有,栈深不足抛 StackOverflowError。
- 本地方法栈:为 native 方法服务,线程私有。
- 堆:存放对象实例,所有线程共享,是 GC 的主要区域。
- 方法区:存放类信息、常量、静态变量,JDK 8 之后由元空间(Metaspace)实现。
5.2 垃圾回收算法
垃圾回收算法是 JVM 调优的基础,需要理解其原理和适用场景。
- 标记-清除算法:先标记可回收对象,再统一清除。缺点是产生内存碎片。
- 标记-复制算法:将内存分为两块,每次只使用一块,回收时将存活对象复制到另一块。适合新生代,无碎片但浪费空间。
- 标记-整理算法:标记后让存活对象向一端移动,直接清理边界外内存。适合老年代,无碎片但效率较低。
- 分代收集:新生代采用复制算法,老年代采用标记-整理或标记-清除算法。
5.3 垃圾收集器对比
常见的垃圾收集器包括 Serial、Parallel、CMS 和 G1,面试中常问它们的区别。
| 收集器 | 适用区域 | 特点 |
|---|---|---|
| Serial | 新生代 | 单线程,简单高效,适合单核环境 |
| Parallel | 新生代 | 多线程并行,追求高吞吐量 |
| CMS | 老年代 | 并发标记清除,低停顿,但产生碎片 |
| G1 | 整堆 | 分区式,可预测停顿时间,JDK 9+ 默认 |
5.4 类加载机制与双亲委派
类加载机制是 JVM 面试的重要考点。
类加载过程分为加载、验证、准备、解析和初始化五个阶段。加载阶段通过类的全限定名获取二进制字节流,验证阶段检查字节码合法性,准备阶段为静态变量分配内存并赋默认值,解析阶段将符号引用替换为直接引用,初始化阶段执行静态代码块和静态变量赋值。
双亲委派模型是指类加载器收到加载请求时,先委托父加载器加载,父加载器无法加载时才由自己加载。这样可以保证核心类库(如 Object 类)由 Bootstrap 加载器统一加载,避免核心类被篡改。
6. 常见设计模式面试题
6.1 单例模式的实现方式
单例模式是面试中出现频率最高的设计模式,需要掌握多种实现方式。
推荐使用双重检查锁(DCL)或静态内部类方式实现单例。DCL 实现如下:
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile 关键字保证 instance 的可见性和禁止指令重排序,避免拿到未初始化完成的对象。
6.2 工厂模式与策略模式的区别
工厂模式关注对象的创建,将创建逻辑封装在工厂类中,客户端无需关心具体实现类。策略模式关注算法的替换,将一组可互换的算法封装起来,客户端可以在运行时动态切换策略。
工厂模式解决的是「如何创建对象」的问题,策略模式解决的是「如何选择算法」的问题。两者可以组合使用,例如通过工厂创建策略对象。
7. 面试实战技巧与总结
7.1 回答问题的结构化表达
面试中回答八股文问题时,建议采用「是什么、为什么、怎么用」的结构化表达方式。先给出结论,再解释原理,最后结合实际场景说明应用方式。这样既能展示知识深度,又能体现工程思维。
7.2 高频追问与深度扩展
面试官往往会在基础问题之上进行追问,考察候选人的深度。例如问到 HashMap 时,可能继续追问红黑树的左旋右旋、扩容时的死循环问题、JDK 8 的优化点等。建议在准备时对每个高频考点准备一层深度扩展,做到「知其然,更知其所以然」。
7.3 复习路线建议
建议按照「基础语法 → 集合框架 → 并发编程 → JVM → 设计模式 → 框架源码」的顺序系统复习。每个模块先掌握核心概念,再通过手写代码加深理解,最后结合面试真题进行模拟练习。八股文只是面试的一部分,扎实的项目经验和清晰的表达能力同样重要。