一、Java 基础与集合
本章包含 Java 基础语法、面向对象特性以及集合框架底层的核心面试考点。
1. == 与 equals 的区别
在 Java 中,对于数据类型的比对有不同的底层逻辑。
== 运算符
- 基本数据类型:比较的是存储的数值是否相等。
- 引用数据类型:比较的是两个对象在堆内存中的物理地址(即是否指向同一个内存指针)。
equals 方法
- 它是
java.lang.Object中的一个实例方法,默认实现采用==进行物理地址比较。 - 绝大多数核心类(如
String、Integer等)都重写了该方法,将其改造为内容逻辑等价性的比较。
hashCode 与 equals 的核心契约
如果两个对象根据 equals 方法判定是相等的,那么它们的 hashCode 方法必须产生相同的整数结果。
在哈希表(如 HashMap、HashSet)中,定位元素位置的计算公式如下:
int index = (n - 1) & (hash(key.hashCode()));
若重写了 equals 而没有重写 hashCode,会导致:
- 逻辑相等的两个对象产生不同的哈希值。
- 存入哈希容器时,可能被路由到不同的桶(Bucket)中,从而造成容器中出现逻辑重复的键,违反 Map 唯一键约束。
2. String、StringBuilder、StringBuffer 的区别
三者在可变性、线程安全以及性能上存在较大差异。
可变性
String类的底层char[](JDK 9 之后为byte[])由private final修饰,一旦初始化便不可修改。任何拼接或裁剪操作都会生成新的String对象。StringBuilder与StringBuffer均继承自AbstractStringBuilder,其底层的字符数组没有final修饰,是可变的,支持就地修改。
线程安全性
String由于其不可变性,是天然线程安全的。StringBuffer几乎所有关键修改方法均加了synchronized关键字,是线程安全的,适用于多线程高并发写入场景。StringBuilder没有添加锁同步,是非线程安全的,适用于单线程拼接场景。
性能对比
- 在单线程大量拼接字符的场景中,性能排序为
StringBuilder>StringBuffer>String。
字符串常量池演进
- 在 JDK 6 及以前,字符串常量池(String Table)位于**方法区(PermGen)**中,容量较小,容易引发
java.lang.OutOfMemoryError: PermGen space。 - 自 JDK 7 起,字符串常量池被移到了 Java 堆(Heap) 中。
- 转移原因:堆内存是垃圾回收器(GC)的核心工作区。将常量池移入堆中,使得未被引用的字符串常量的内存空间可以被 GC 及时回收,大幅降低了由于大量拼接、动态生成字符串导致的 OOM 风险。
3. HashMap 底层原理
HashMap 底层使用数组+链表+红黑树的结构。
为什么链表长度大于等于 8 且数组长度大于等于 64 才树化
- 数学统计学依据:根据概率论中的泊松分布(Poisson Distribution),哈希表中同一个桶内发生冲突的概率随着节点数的增加而急剧递减。当负载因子为 0.75 时,同一个桶内链表长度达到 8 的概率仅为 0.00000006,这属于极小概率事件。
- 防止树化冲突的平衡:红黑树的节点所占内存是普通链表节点(
Node)的两倍,且插入时需要进行左旋、右旋和变色来维持平衡,在节点较少时,红黑树的查找效率并不比链表有明显优势。 - 64 的限制:当数组容量小于 64 时,如果频繁发生哈希冲突,
HashMap优先选择对数组进行扩容(Resize)而非树化,因为扩容能直接打散哈希冲突的分布。只有数组长度大于等于 64 且某条链表长度大于等于 8 时,才正式将链表转化为红黑树。
为什么容量必须是 2 的幂
-
加快定位计算:如果容量
n是 2 的幂,那么(n - 1)的二进制表示所有低位全部为 1(例如 16 的n-1为二进制1111)。此时计算索引:int index = hash & (n - 1);该位运算与数学上的取模运算
hash % n等价,但位运算的执行速度远快于除法取模。 -
减少碰撞几率:若
n不是 2 的幂,(n - 1)的二进制中某些位将为 0。在进行位与运算时,不管hash对应位是 0 还是 1,结果相应位永远为 0,这会导致数组中某些索引位置永远无法被分配到,造成严重的哈希碰撞与空间浪费。
4. ConcurrentHashMap 在 JDK 7 和 JDK 8 的实现差异
两者在并发控制的粒度与内部数据结构上发生了彻底的改变。
JDK 7:Segment 分段锁
- 结构:采用
Segment数组,每个Segment内部包含一个HashEntry数组。Segment继承自ReentrantLock。 - 锁粒度:锁的粒度在
Segment级别。最大并发度等于Segment的数量(默认 16)。在同一个Segment下的写入操作仍需要竞争同一把锁,并发性能受限。
JDK 8:CAS + synchronized 锁桶头
- 结构:取消了
Segment数组,直接采用一个Node数组(桶数组),内部节点在特定条件下树化为TreeNode。 - 并发控制:
- 当放入元素时,如果对应的桶头节点为
null,使用无锁的 CAS(Compare And Swap)进行乐观写入。 - 如果发生冲突,则使用
synchronized对当前的桶头节点进行加锁。
- 当放入元素时,如果对应的桶头节点为
- 优势:
- 锁粒度细化到了每个具体的数组槽(桶)。
- 最大并发度直接等同于数组的长度,极大地提升了高并发读写性能。
- 不再使用
ReentrantLock,转而使用 JVM 层面持续被优化的synchronized,锁空间开销变小。
5. ArrayList 扩容机制与 fail-fast 机制
ArrayList 是基于动态数组实现的非线程安全集合。
扩容机制
-
当向
ArrayList中添加元素且容量不足时,会触发扩容。 -
扩容的核心方法是
grow(int minCapacity)。 -
扩容倍数:新容量为旧容量的 1.5 倍。
int newCapacity = oldCapacity + (oldCapacity >> 1); -
扩容计算完毕后,通过
Arrays.copyOf(elementData, newCapacity)分配新数组,并将旧数据拷贝到新数组中。
fail-fast 机制
- 概念:它是 Java 集合框架中的一种快速失败机制。当使用迭代器(Iterator)遍历集合时,如果集合结构在遍历过程中被修改(添加或删除元素),遍历将立刻抛出
ConcurrentModificationException。 - 实现原理:
ArrayList内部有一个类成员变量modCount,记录集合结构被修改的次数。- 当通过
iterator()获取迭代器时,迭代器内部会保存一个expectedModCount = modCount。 - 在迭代器的
next()或remove()方法执行时,会校验modCount == expectedModCount。如果两者不一致,说明遍历期间有其他线程或当前线程使用集合原生方法修改了结构,于是立刻抛出异常。 - 避坑防范:如果在遍历中需要删除元素,必须使用
Iterator.remove()方法而非ArrayList.remove()。
6. 接口与抽象类的区别
接口与抽象类是 Java 中多态和抽象的重要表现形式。
结构与语义区别
- 继承限制:一个类只能继承(
extends)一个抽象类,但可以实现(implements)多个接口。 - 成员变量:接口中的成员变量默认是
public static final的,而抽象类中可以定义任意权限修饰符的普通成员变量。 - 设计意图:抽象类是对事物本质的抽象(属于
is-a关系),用于代码复用;接口是对行为规范的抽象(属于like-a关系),用于定义契约。
Java 8 接口 default/static 方法的引入背景
在 Java 8 之前,接口一旦发布,若对其增加新方法,所有的实现类都必须强制重写,这会导致向后兼容性极差(破坏了既有生态中的大量类)。
为了解决这一痛点,Java 8 引入了 default(默认方法)和 static(静态方法):
default方法:允许在接口中编写带有具体实现体的方法,且不强制要求实现类重写。它优雅地解决了接口升级时向下兼容性问题,使得集合类如Collection能直接获得stream()、forEach()等新特性。static方法:可以直接通过接口名调用,充当了工具方法,避免了额外创建无状态辅助工具类。
7. 深拷贝与浅拷贝及 Optional 的正确用法
内存数据的复制形式与空安全编排是开发中的常见考点。
深拷贝与浅拷贝
- 浅拷贝(Shallow Copy):新对象创建后,只复制了原对象的物理值或引用地址。如果对象属性是引用类型,新旧对象将指向同一个堆内存中的子对象,修改子对象属性会互相影响。
- 深拷贝(Deep Copy):不仅创建新对象,而且递归地为原对象中所有的引用类属性创建全新的子对象实例。新旧对象在内存上完全隔离,互不干扰。
- 深拷贝实现方案:
- 重写
clone()方法,并在其中手动对其引用类型属性进行二次克隆。 - 使用序列化与反序列化(例如 Java 本生序列化,Jackson / Gson 转换)。
- 重写
Optional 避坑指南
Optional 的核心初衷是作为方法返回类型,用于显式提示调用者可能为空,从而消除防御性的 null 判断,而不是用来完全替代 null 指针。
-
不推荐用法:
- 不要将
Optional作为类的属性,它未实现Serializable接口。 - 不要将
Optional作为方法参数,这会增加调用方的包装开销,使代码变得冗余。 - 避免直接调用
get(),否则在无值时仍会抛出NoSuchElementException,这违背了防空指针的设计初衷。
- 不要将
-
推荐优雅写法:
// 结合 map, flatMap, filter 进行流式降噪String name = Optional.ofNullable(user).map(User::getName).filter(n -> n.length() > 2).orElse("Guest");
8. 异常体系与 try-with-resources 原理
Java 异常分类及资源关闭的内部糖衣。
Checked Exception 与 Unchecked Exception
- Checked 异常(受检异常):继承自
Exception但不属于RuntimeException(如IOException、SQLException)。编译器在编译阶段强制要求开发者使用try-catch进行捕获或者在方法签名上声明throws,用于预防可恢复的外部环境异常。 - Unchecked 异常(非受检异常):继承自
RuntimeException(如NullPointerException、IndexOutOfBoundsException)。编译器不强制进行异常处理,多为代码层面的逻辑错误,应当通过健壮性编码进行规避。
try-with-resources 原理
Java 7 引入的 try-with-resources 是一颗语法糖,任何实现了 java.lang.AutoCloseable 接口的资源类都可以放入 try() 中。
编译后,JVM 会自动将其翻译为传统的 try-catch-finally 结构,并在 finally 块中调用该资源的 close() 方法。
异常抑制(Suppressed Exceptions)机制
如果在 try 块内抛出了业务异常,而在 finally 关闭资源时也抛出了 IOException,传统的 finally 块关闭资源可能会覆盖并丢弃主业务异常。
而 try-with-resources 编译后的代码会保留主异常,并调用主异常的 addSuppressed(Throwable exception) 方法将资源释放时的二级异常挂载在主异常链中,开发人员在捕获主异常时可以通过 getSuppressed() 获取所有被抑制的异常信息,利于问题定位。