Java 基础与集合 — 面试题篇
更新: 5/15/2026 字数: 0 字 时长: 0 分钟
Q1: HashMap 的 put 流程是怎样的?为什么用红黑树?
考察点:HashMap 底层数据结构、hash 计算、扩容机制
完整回答:
HashMap 在 JDK 1.8 中底层是数组 + 链表 + 红黑树。put 流程如下:
- 对 key 调用
hash()方法:(h = key.hashCode()) ^ (h >>> 16),高 16 位异或低 16 位做扰动,减少碰撞 - 用
(n-1) & hash定位桶索引 - 如果桶为空,直接创建新节点放入
- 如果桶不为空,判断第一个节点的 key 是否相同(hash 相等且 equals 为 true),相同则覆盖 value
- 如果是红黑树节点,调用
putTreeVal插入 - 否则遍历链表,尾插法插入新节点。如果链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树;数组长度 < 64 时优先扩容
- 插入后如果
size > threshold(容量 × 负载因子 0.75),触发 resize 扩容
用红黑树是因为链表过长时查找退化为 O(n),红黑树保证 O(log n)。阈值 8 是基于泊松分布计算的——正常 hash 分布下链表长度达到 8 的概率极低(约千万分之六)。
追问1:resize 扩容时节点怎么重新分配?
容量翻倍后,用 hash & oldCap 判断节点位置:
- 结果为 0 → 留在原索引
- 结果不为 0 → 移到原索引 + oldCap
这是因为扩容后 (newCap-1) & hash 比 (oldCap-1) & hash 多了一个高位 bit,这个 bit 正好是 oldCap 的位置。
追问2:HashMap 为什么线程不安全?
JDK 1.7 头插法并发扩容会导致链表成环(死循环)。JDK 1.8 改为尾插法解决了成环问题,但并发 put 仍可能丢数据——两个线程同时判断桶为空,都执行插入,后一个覆盖前一个。
加分点:提到 HashMap 允许 null key(hash 为 0),而 ConcurrentHashMap 不允许 null key/value。
Q2: ConcurrentHashMap 如何保证线程安全?JDK 1.7 和 1.8 有什么区别?
考察点:并发容器实现原理
完整回答:
JDK 1.7:分段锁(Segment)。将数据分成 16 个 Segment,每个 Segment 继承 ReentrantLock,是一个小 HashMap。不同 Segment 的操作互不影响,并发度 = Segment 数量。
JDK 1.8:抛弃 Segment,改用 CAS + synchronized,锁粒度细化到桶级别:
- 桶为空时,CAS 插入新节点(无锁)
- 桶非空时,synchronized 锁住桶的头节点,然后在链表/红黑树中操作
- 扩容时多线程协助 transfer,每个线程负责一段桶的迁移
追问1:size() 怎么计算的?
JDK 1.8 使用 baseCount + CounterCell[] 数组。put 成功后先 CAS 更新 baseCount,失败则更新随机一个 CounterCell。size() 时将 baseCount 和所有 CounterCell 的值求和。这个设计借鉴了 LongAdder 的思想,减少 CAS 竞争。
追问2:为什么不允许 null key/value?
多线程环境下,get(key) 返回 null 无法区分是"key 不存在"还是"value 就是 null"。单线程的 HashMap 可以用 containsKey 再确认,但并发环境下两次调用之间状态可能变化。
加分点:提到 JDK 1.8 的 transfer 方法支持多线程并发扩容,通过 transferIndex 分配任务段。
Q3: ArrayList 和 LinkedList 的区别?什么场景用哪个?
考察点:集合选型
完整回答:
ArrayList 底层是 Object 数组,LinkedList 底层是双向链表。
核心区别:
- 随机访问:ArrayList O(1),LinkedList O(n)
- 头部插入/删除:ArrayList O(n) 需要移动元素,LinkedList O(1)
- 尾部插入:ArrayList 均摊 O(1)(偶尔扩容),LinkedList O(1)
- 内存:ArrayList 紧凑连续,CPU 缓存友好;LinkedList 每个节点额外两个指针(prev/next),内存开销大且不连续
结论:绝大多数场景用 ArrayList。LinkedList 理论上头部插入快,但实际因为缓存不友好,在大多数基准测试中都不如 ArrayList。只有在需要频繁头部插入删除且不需要随机访问的场景(如实现队列)才考虑 LinkedList,但即便如此 ArrayDeque 通常更好。
追问:ArrayList 的 modCount 是什么?
modCount 记录结构性修改次数,用于快速失败(fail-fast)。迭代器创建时记录 expectedModCount,每次 next() 前检查两者是否一致,不一致则抛 ConcurrentModificationException。这不是线程安全机制,只是尽早发现并发修改的 bug。
Q4: Java 泛型的类型擦除是什么?有什么影响?
考察点:泛型原理
完整回答:
Java 泛型是编译期特性,编译后泛型信息被擦除——List<String> 和 List<Integer> 在运行时都是 List,类型参数被替换为上界(无界则为 Object)。
影响:
- 运行时无法获取泛型类型:
list instanceof List<String>编译报错 - 不能创建泛型数组:
new T[]不合法 - 不能用基本类型作为类型参数:
List<int>不行,必须List<Integer> - 可以通过反射绕过泛型检查
Gson 解析时需要 TypeToken 来保留泛型信息:new TypeToken<List<User>>(){}.getType()。原理是匿名内部类的父类泛型签名保存在字节码的 Signature 属性中,不会被擦除。
追问:PECS 原则是什么?
Producer Extends, Consumer Super:
- 只读取数据(生产者)用
<? extends T>:可以安全地读出 T 类型 - 只写入数据(消费者)用
<? super T>:可以安全地写入 T 类型 - 既读又写不用通配符
典型例子:Collections.copy(List<? super T> dest, List<? extends T> src)
加分点:Kotlin 用 out(协变,对应 extends)和 in(逆变,对应 super)在声明处指定型变,比 Java 的使用处型变更简洁。reified 内联泛型可以在运行时获取类型信息。
Q5: JVM 内存区域有哪些?各自存什么?哪些会 OOM?
考察点:JVM 内存模型
完整回答:
JVM 运行时数据区分为线程私有和线程共享两类:
线程私有:
- 程序计数器:当前线程执行的字节码行号,唯一不会 OOM 的区域
- 虚拟机栈:每个方法调用创建一个栈帧,包含局部变量表、操作数栈、动态链接、返回地址。递归过深会 StackOverflowError
- 本地方法栈:Native 方法调用,同样可能 StackOverflowError
线程共享:
- 堆:存放对象实例和数组,GC 的主要区域。分为新生代(Eden + S0 + S1)和老年代。对象分配不了就 OOM
- 方法区(JDK 8+ 用元空间实现,使用本地内存):存储类信息、常量池、静态变量。加载类过多会 OOM
追问:对象创建的过程?
- 类加载检查 → 2. 分配内存(指针碰撞或空闲列表,TLAB 保证线程安全)→ 3. 初始化零值 → 4. 设置对象头(Mark Word + 类型指针)→ 5. 执行构造方法
追问:四种引用类型?
- 强引用:
new Object(),不回收,宁可 OOM - 软引用:
SoftReference,内存不足时回收,适合缓存 - 弱引用:
WeakReference,下次 GC 就回收,Handler 防泄漏常用 - 虚引用:
PhantomReference,无法获取对象,仅用于回收通知
Q6: GC 算法有哪些?Android 的 GC 有什么特点?
考察点:垃圾回收
完整回答:
三种基础算法:
- 标记-清除:标记存活对象,清除其余。缺点是内存碎片
- 标记-复制:将存活对象复制到另一块区域。新生代用这个,Eden:S0:S1 = 8:1:1
- 标记-整理:标记后将存活对象向一端移动。老年代用这个,无碎片但移动开销大
常见收集器:
- CMS:以最短停顿为目标,四阶段(初始标记→并发标记→重新标记→并发清除),但有碎片问题
- G1:将堆划分为多个 Region,可预测停顿时间,Mixed GC 同时回收新生代和部分老年代
Android ART 的 CC(Concurrent Copying)收集器:
- 并发复制算法,几乎不需要暂停应用线程
- 使用读屏障(Read Barrier)而非写屏障
- 堆压缩在后台并发进行,减少碎片
- 针对移动设备优化,减少内存占用和停顿时间
追问:如何判断对象是否存活?
可达性分析:从 GC Roots 出发,沿引用链遍历,不可达的对象即为垃圾。GC Roots 包括:虚拟机栈中引用的对象、静态变量引用的对象、JNI 引用的对象等。
加分点:提到 Android 的 GC 日志分析,以及 ART 相比 Dalvik 的改进(AOT 编译、改进的 GC)。
Q7: String 为什么是不可变的?String、StringBuilder、StringBuffer 的区别?
考察点:String 原理
完整回答:
String 不可变的原因:
private final char[] value(JDK 9+ 改为byte[]),final 修饰且没有提供修改方法- String 类本身是 final 的,不能被继承
不可变的好处:
- 线程安全:多线程共享无需同步
- 哈希缓存:hashCode 只需计算一次,HashMap 的 key 常用 String
- 字符串常量池:相同内容的字符串共享同一对象,节省内存
- 安全性:网络连接、文件路径等用 String 不会被篡改
三者区别:
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 是(不可变) | 否 | 是(synchronized) |
| 性能 | 拼接慢(每次创建新对象) | 最快 | 较快 |
| 场景 | 少量字符串操作 | 单线程大量拼接 | 多线程大量拼接 |
追问:String s = new String("abc") 创建了几个对象?
最多 2 个:
- 如果常量池中没有 "abc",先在常量池创建一个
- 在堆中创建一个 String 对象,内部引用常量池的 char[]
String s = "abc" 只有 1 个(直接引用常量池)。
Q8: == 和 equals 的区别?为什么重写 equals 必须重写 hashCode?
考察点:Object 基础方法
完整回答:
==:基本类型比较值,引用类型比较内存地址equals:Object 默认实现就是==,String/Integer 等重写为比较内容
为什么重写 equals 必须重写 hashCode:
HashMap 查找时先用 hashCode 定位桶,再用 equals 比较 key。如果两个对象 equals 相等但 hashCode 不同,HashMap 会认为它们在不同的桶中,导致同一个 key 存了两份数据。
规约:
- equals 相等 → hashCode 必须相等
- hashCode 相等 → equals 不一定相等(哈希碰撞)
// ❌ 只重写 equals 不重写 hashCode
class User {
String name;
@Override
public boolean equals(Object o) {
return name.equals(((User) o).name);
}
// 没重写 hashCode!
}
Map<User, String> map = new HashMap<>();
map.put(new User("张三"), "value");
map.get(new User("张三")); // null!因为两个 User 对象 hashCode 不同Q9: Java 的 final、finally、finalize 区别?
考察点:Java 基础
完整回答:
- final:修饰类(不可继承)、方法(不可重写)、变量(不可重新赋值,引用类型内容仍可变)
- finally:try-catch-finally 中的清理代码块,无论是否异常都会执行(除非
System.exit()或线程被杀) - finalize:Object 的方法,GC 回收对象前调用。已废弃(Java 9+),不可靠(不保证执行时机),用
Cleaner或 try-with-resources 替代
Q10: 接口和抽象类的区别?Java 8 接口有什么变化?
考察点:面向对象
完整回答:
| 特性 | 接口 | 抽象类 |
|---|---|---|
| 实例化 | 不能 | 不能 |
| 多继承 | 可以实现多个接口 | 只能继承一个类 |
| 构造方法 | 没有 | 有 |
| 成员变量 | 只有 public static final 常量 | 可以有各种成员变量 |
| 方法 | Java 8 前只有抽象方法 | 可以有具体方法 |
Java 8 接口新增:
default方法:有默认实现,实现类可以不重写static方法:接口的静态工具方法
选择原则:
- 定义行为规范(能力)→ 接口(Comparable、Serializable)
- 定义共同基类(is-a 关系)→ 抽象类(Activity、Fragment)
Q11: SparseArray 和 ArrayMap 是什么?为什么 Android 推荐用它们替代 HashMap?
考察点:Android 特有集合
完整回答:
HashMap 在数据量小时(<1000)内存浪费严重:
- 每个 Entry 对象 32 字节开销
- 自动装箱(int → Integer)
- 默认容量 16,负载因子 0.75,空间利用率低
SparseArray:
- key 是 int(避免装箱),value 是 Object
- 两个数组:int[] keys(有序)+ Object[] values
- 二分查找 O(log n),但数据量小时比 HashMap 快(缓存友好)
- 删除时标记为 DELETED,延迟压缩
ArrayMap:
- key/value 都是 Object
- 两个数组:int[] hashes(有序)+ Object[] array(key-value 交替存储)
- 二分查找 hash 定位,然后线性查找 key
- 内存比 HashMap 省 2-3 倍
选择:
- key 是 int → SparseArray
- key 是 Object 且数据量 < 1000 → ArrayMap
- 数据量大或频繁增删 → HashMap
实习面试补充:Java 基础必须稳定回答的问题
实习面试更常从语言基础开始追问,不一定要求源码级深度,但要求概念准确、能结合代码说明。
Q12: == 和 equals() 的区别?
考察点:对象比较、字符串常量池
完整回答:
==比较的是两个变量的值。如果是基本类型,比较具体数值;如果是引用类型,比较对象地址。equals()是Object的方法,默认也是比较地址,但很多类会重写它,比如String会比较字符串内容。
String a = new String("abc");
String b = new String("abc");
System.out.println(a == b); // false,不是同一个对象
System.out.println(a.equals(b)); // true,内容相同追问:String 为什么有时 == 也是 true?
字符串字面量会进入字符串常量池:
String a = "abc";
String b = "abc";
System.out.println(a == b); // true,指向常量池中的同一个对象加分点:实际开发中比较字符串内容用 equals();为了避免空指针,可以写成 "abc".equals(str)。
Q13: Java 异常分为哪几类?开发中怎么处理?
考察点:异常体系、编码规范
完整回答:
Java 异常体系中,Throwable 下主要有两类:
Error:严重错误,通常程序无法处理,比如OutOfMemoryError、StackOverflowError。Exception:程序可以处理的异常。
Exception 又分为:
- 受检异常:编译期要求处理,比如
IOException。 - 运行时异常:继承自
RuntimeException,比如NullPointerException、IndexOutOfBoundsException、ClassCastException。
开发中不要用空的 catch 吞掉异常,至少要记录日志或转换成业务可理解的错误。
追问:finally 一定会执行吗?
大多数情况下会执行,但如果执行了 System.exit()、进程被杀、虚拟机崩溃,finally 不保证执行。
Q14: ArrayList 遍历时删除元素为什么容易出问题?
考察点:集合遍历、快速失败机制
完整回答:
使用增强 for 遍历 ArrayList 时,本质上使用的是 Iterator。如果遍历过程中直接调用 list.remove() 修改集合,会导致 modCount 和 expectedModCount 不一致,可能抛出 ConcurrentModificationException。
正确做法是使用迭代器的 remove():
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (item.isEmpty()) {
iterator.remove();
}
}加分点:如果只是过滤生成新列表,可以优先使用新集合承接结果,逻辑更清晰。
