Skip to content

Java 基础与集合 — 面试题篇

更新: 5/15/2026 字数: 0 字 时长: 0 分钟


Q1: HashMap 的 put 流程是怎样的?为什么用红黑树?

考察点:HashMap 底层数据结构、hash 计算、扩容机制

完整回答

HashMap 在 JDK 1.8 中底层是数组 + 链表 + 红黑树。put 流程如下:

  1. 对 key 调用 hash() 方法:(h = key.hashCode()) ^ (h >>> 16),高 16 位异或低 16 位做扰动,减少碰撞
  2. (n-1) & hash 定位桶索引
  3. 如果桶为空,直接创建新节点放入
  4. 如果桶不为空,判断第一个节点的 key 是否相同(hash 相等且 equals 为 true),相同则覆盖 value
  5. 如果是红黑树节点,调用 putTreeVal 插入
  6. 否则遍历链表,尾插法插入新节点。如果链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树;数组长度 < 64 时优先扩容
  7. 插入后如果 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)。

影响:

  1. 运行时无法获取泛型类型:list instanceof List<String> 编译报错
  2. 不能创建泛型数组:new T[] 不合法
  3. 不能用基本类型作为类型参数:List<int> 不行,必须 List<Integer>
  4. 可以通过反射绕过泛型检查

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

追问:对象创建的过程?

  1. 类加载检查 → 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 不可变的原因:

  1. private final char[] value(JDK 9+ 改为 byte[]),final 修饰且没有提供修改方法
  2. String 类本身是 final 的,不能被继承

不可变的好处:

  • 线程安全:多线程共享无需同步
  • 哈希缓存:hashCode 只需计算一次,HashMap 的 key 常用 String
  • 字符串常量池:相同内容的字符串共享同一对象,节省内存
  • 安全性:网络连接、文件路径等用 String 不会被篡改

三者区别:

特性StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全是(不可变)是(synchronized)
性能拼接慢(每次创建新对象)最快较快
场景少量字符串操作单线程大量拼接多线程大量拼接

追问:String s = new String("abc") 创建了几个对象?

最多 2 个:

  1. 如果常量池中没有 "abc",先在常量池创建一个
  2. 在堆中创建一个 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 不一定相等(哈希碰撞)
java
// ❌ 只重写 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 会比较字符串内容。
java
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?

字符串字面量会进入字符串常量池:

java
String a = "abc";
String b = "abc";
System.out.println(a == b); // true,指向常量池中的同一个对象

加分点:实际开发中比较字符串内容用 equals();为了避免空指针,可以写成 "abc".equals(str)


Q13: Java 异常分为哪几类?开发中怎么处理?

考察点:异常体系、编码规范

完整回答

Java 异常体系中,Throwable 下主要有两类:

  • Error:严重错误,通常程序无法处理,比如 OutOfMemoryErrorStackOverflowError
  • Exception:程序可以处理的异常。

Exception 又分为:

  • 受检异常:编译期要求处理,比如 IOException
  • 运行时异常:继承自 RuntimeException,比如 NullPointerExceptionIndexOutOfBoundsExceptionClassCastException

开发中不要用空的 catch 吞掉异常,至少要记录日志或转换成业务可理解的错误。

追问:finally 一定会执行吗?

大多数情况下会执行,但如果执行了 System.exit()、进程被杀、虚拟机崩溃,finally 不保证执行。


Q14: ArrayList 遍历时删除元素为什么容易出问题?

考察点:集合遍历、快速失败机制

完整回答

使用增强 for 遍历 ArrayList 时,本质上使用的是 Iterator。如果遍历过程中直接调用 list.remove() 修改集合,会导致 modCountexpectedModCount 不一致,可能抛出 ConcurrentModificationException

正确做法是使用迭代器的 remove()

java
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (item.isEmpty()) {
        iterator.remove();
    }
}

加分点:如果只是过滤生成新列表,可以优先使用新集合承接结果,逻辑更清晰。