主题
一、Java 基础(20 题)
1.1 ★★★★== 和 equals() 的区别是什么?为什么重写 equals() 时必须同时重写 hashCode()?如果不重写会导致什么问题?
📖 点击查看答案
== 与 equals() 的区别:
==:比较的是引用地址(基本类型比较值,引用类型比较内存地址)equals():默认实现也是比较地址(继承自 Object),但通常被重写为比较内容(如 String、Integer)
java
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false,不同对象
System.out.println(a.equals(b)); // true,内容相同为什么重写 equals 必须重写 hashCode:
- Java 规范:
equals相等的两个对象,hashCode必须相等;反之不成立 - 哈希容器(HashMap、HashSet)先通过
hashCode定位桶,再通过equals判断是否相等 - 如果只重写 equals 不重写 hashCode,两个"相等"的对象会有不同的 hashCode,会被放到不同的桶中
不重写会导致的问题:
java
class Person {
String name;
// 只重写了 equals,没重写 hashCode
public boolean equals(Object o) { return name.equals(((Person)o).name); }
}
Set<Person> set = new HashSet<>();
set.add(new Person("Tom"));
set.add(new Person("Tom"));
// set.size() == 2,明明逻辑相等却被当作两个对象,破坏集合语义- HashSet/HashMap 中出现"重复"键
- 无法正确查找已存在的 key
hashCode 推荐写法:
java
@Override
public int hashCode() {
return Objects.hash(name, age);
}1.2 ★★深拷贝和浅拷贝的区别是什么?如何实现深拷贝?
📖 点击查看答案
核心区别:
- 浅拷贝:只复制对象本身,不复制引用类型字段指向的对象(新旧对象共享内部引用对象)
- 深拷贝:递归复制对象及其所有引用对象,新旧对象完全独立
java
class Address { String city; }
class Person implements Cloneable {
String name;
Address address; // 引用类型
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 浅拷贝:address 仍指向同一对象
}
}浅拷贝的实现方式:
- 实现
Cloneable接口,重写clone()方法调用super.clone() - 拷贝构造方法(只复制基本类型和引用地址)
深拷贝的实现方式:
- 递归 clone:每个引用类型字段也实现 Cloneable 并在 clone 中递归调用
java
@Override
protected Person clone() throws CloneNotSupportedException {
Person p = (Person) super.clone();
p.address = (Address) address.clone(); // 深拷贝
return p;
}- 序列化方式(推荐):实现 Serializable,通过序列化/反序列化生成全新对象
java
@SuppressWarnings("unchecked")
public Person deepCopy() throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
return (Person) ois.readObject();
}- JSON 工具:如 Jackson
objectMapper.convertValue、GsontoJson/fromJson - 第三方库:如 Apache Commons Lang 的
SerializationUtils.clone()
1.3 ★★★final、finally、finalize 的区别是什么?final 修饰的变量、方法、类分别有什么影响?finally 代码块一定会执行吗?
📖 点击查看答案
三者毫无关系,仅是名字相似:
| 关键字 | 作用 |
|---|---|
| final | 修饰符,表示"不可变" |
| finally | 异常处理块,保证资源释放 |
| finalize | Object 方法,GC 回收前调用(已废弃) |
final 的三种用法:
- 修饰类:该类不能被继承(如 String、Integer)
- 修饰方法:该方法不能被子类重写
- 修饰变量:基本类型值不可变;引用类型引用不可变(但对象内部可变)
java
final int x = 10;
final List<String> list = new ArrayList<>();
list.add("a"); // OK,对象内部可变
// list = new ArrayList<>(); // 编译错误,引用不可变finally 不执行的情况:
- 在 try 块之前就异常退出(try 都没进入)
- 在 try 块中调用了
System.exit(0) - JVM 崩溃或被强制杀死
- 当前线程被"死锁"或被无限阻塞(理论不退出)
- 守护线程被 JVM 终止时
java
try {
System.exit(0);
} finally {
System.out.println("不会执行"); // 不会输出
}finalize(JDK 9 已废弃):
- GC 回收对象前由 GC 线程调用,执行时间不确定,可能导致复活对象
- 已被
Cleaner/try-with-resources取代 - 性能差、易导致内存泄漏
1.4 ★★★★接口(interface)和抽象类(abstract class)在设计层面有什么本质区别?JDK 8 之后接口的 default 方法解决了什么问题?
📖 点击查看答案
设计层面的本质区别:
- 抽象类:is-a 关系,表示"是什么",用于模板抽象(有状态、有构造器、有具体方法)
- 接口:can-do 关系,表示"能做什么",是行为契约(无状态、多实现)
语法层面区别:
| 维度 | 抽象类 | 接口 |
|---|---|---|
| 继承 | 单继承 | 多实现 |
| 成员变量 | 任意类型 | 默认 public static final |
| 方法 | 任意访问级别 | JDK 8 前 abstract;JDK 8+ 可有 default/static;JDK 9+ 可有 private |
| 构造器 | 有 | 无 |
| 状态 | 有实例字段 | 无实例字段 |
default 方法解决的问题:
- JDK 8 之前,给已有接口添加方法会破坏所有实现类
- default 方法允许接口提供默认实现,实现类可不重写
java
interface Vehicle {
default void start() {
System.out.println("启动");
}
static Vehicle create() { return new Car(); }
}- 典型应用:
java.util.Collection在 JDK 8 加入stream()、forEach()等 default 方法,兼容旧实现
default 方法冲突规则(多继承):
- 类优先于接口:父类方法覆盖接口 default
- 子接口优先于父接口
- 冲突时必须显式重写:
Interface.super.method()
java
class C implements A, B {
public void hello() { A.super.hello(); } // 显式选择
}选择建议:
- 描述本质类型用抽象类(如 AbstractList)
- 描述能力/契约用接口(如 Comparable、Iterable)
1.5 ★★★★String、StringBuilder、StringBuffer 三者的核心区别是什么?String 被设计为不可变的原因有哪些?
📖 点击查看答案
三者核心区别:
| 类 | 可变性 | 线程安全 | 性能 | 应用场景 |
|---|---|---|---|---|
| String | 不可变 | 安全(不可变) | 拼接慢 | 少量字符串、常量 |
| StringBuilder | 可变 | 不安全 | 最快 | 单线程拼接 |
| StringBuffer | 可变 | 安全(synchronized) | 较慢 | 多线程拼接 |
java
// String 拼接会创建多个对象
String s = "a" + "b" + "c"; // 编译期优化为 "abc"
String s2 = "";
for (int i = 0; i < 100; i++) s2 += i; // 每次循环都创建 StringBuilder 和新 String
// 推荐:显式使用 StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) sb.append(i);String 不可变的原因:
- 线程安全:不可变对象天然线程安全,可自由共享
- 支持字符串常量池:多个引用可指向同一常量池对象,节省内存
- hashCode 可缓存:String 的 hashCode 计算后缓存,作为 HashMap 的 key 时性能高
- 安全性:作为参数传递时不会被恶意修改(如类加载器、数据库 URL)
- 不可变才有 hashCode 缓存:作为 key 时不会出现"放进去后取不出"的问题
实现方式:
java
public final class String // final 防止子类破坏不可变
implements java.io.Serializable, Comparable<String>, CharSequence {
private final char[] value; // JDK 8;JDK 9+ 改为 byte[] + coder
}- 类声明
final - 字段
private final - 不提供任何修改内部数组的方法
- 构造时进行防御性拷贝
1.6 ★★自动装箱和拆箱的原理是什么?Integer 缓存池(-128~127)的原理是什么?
📖 点击查看答案
自动装箱与拆箱原理:
- 装箱:基本类型 → 包装类型,编译器自动调用
Integer.valueOf(int) - 拆箱:包装类型 → 基本类型,编译器自动调用
Integer.intValue()
java
Integer a = 100; // 编译为 Integer a = Integer.valueOf(100);
int b = a; // 编译为 int b = a.intValue();Integer 缓存池原理:
Integer.valueOf在-128 ~ 127范围内返回缓存对象(IntegerCache)- 超出范围则
new Integer()创建新对象
java
Integer a = 100, b = 100;
System.out.println(a == b); // true,命中缓存
Integer c = 200, d = 200;
System.out.println(c == d); // false,超出范围 new 了两个对象
System.out.println(c.equals(d)); // true缓存源码(IntegerCache):
java
private static class IntegerCache {
static final Integer[] cache;
static final int low = -128;
static final int high; // 默认 127,可通过 -XX:AutoBoxCacheMax 调整
static {
int h = 127;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++) cache[k] = new Integer(j++);
}
}其他包装类的缓存:
- Byte、Short、Long:
-128 ~ 127 - Character:
0 ~ 127 - Boolean:TRUE、FALSE 两个常量
注意坑点:
- 包装类型比较一定要用
equals(),不要用== - 自动拆箱遇到 null 会抛 NPE
- 大量装箱运算(如
Long sum = 0L; for...sum += i;)会反复创建对象,性能差
1.7 ★★★★Java 泛型中的类型擦除是什么意思?通配符 <? extends T> 和 <? super T> 的区别和使用场景是什么?
📖 点击查看答案
类型擦除:
- Java 泛型只在编译期检查类型安全,运行时所有泛型类型都被擦除为原始类型(Object 或上界)
List<String>和List<Integer>在运行时都是List
java
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true类型擦除带来的限制:
- 不能
new T()、new T[] - 不能用基本类型作泛型参数(要
Integer不能int) - 不能
instanceof List<String>,只能instanceof List - 静态方法/字段不能使用类的泛型参数
- 不能创建泛型数组
通配符 PECS 原则(Producer Extends, Consumer Super):
<? extends T>:上界通配符,能读不能写- 适合"生产者"场景:从集合读取数据
- 编译器只允许读出 T(实际是 T 或子类,可安全当 T 读),不允许写入(无法确定集合实际存的子类类型)
<? super T>:下界通配符,能写不能读- 适合"消费者"场景:往集合写入数据
- 允许写入 T 或其子类,但读取只能得到 Object
java
// 读取场景:把任意 Number 子类列表复制到目标
public static double sum(List<? extends Number> list) {
double s = 0;
for (Number n : list) s += n.doubleValue(); // 读安全
return s;
}
// 写入场景:向列表中添加 Integer
public static void addNumbers(List<? super Integer> list) {
list.add(1); list.add(2); // 写安全
}
// Collections.copy 的经典签名
public static <T> void copy(List<? super T> dest, List<? extends T> src) { ... }记忆口诀: 频繁往外读取用 extends,频繁插入用 super。
1.8 ★★★Java 反射机制的原理是什么?为什么说反射会影响性能?在高并发场景下使用反射有哪些注意事项?
📖 点击查看答案
反射原理:
- Class 对象是反射的入口,每个被加载的类在 JVM 中有唯一一个 Class 对象
- 通过 Class 对象可获取字段(Field)、方法(Method)、构造器(Constructor)
- JVM 在方法区存储类的元数据,反射 API 通过这些元数据动态操作类
java
Class<?> clazz = Class.forName("com.example.User");
Method m = clazz.getDeclaredMethod("setName", String.class);
Object obj = clazz.getDeclaredConstructor().newInstance();
m.setAccessible(true);
m.invoke(obj, "Tom");反射影响性能的原因:
- 方法查找开销:需要遍历方法表,按名字+参数匹配
- 参数装箱与类型检查:基本类型参数需要装箱
- 安全检查:每次调用都做
AccessController检查 - JIT 难以优化:方法调用通过 native invoke,无法内联
setAccessible(true)本身也有开销
高并发场景下的注意事项:
- 缓存反射元数据:避免每次调用都重新查找 Method/Field
java
private static final Method METHOD = getMethod(); // 类加载时缓存- 避免反射创建对象:改用工厂模式或预创建实例
- 关闭访问检查:
setAccessible(true)可跳过安全检查,提升性能 - 使用 MethodHandle(JDK 7+):接近原生调用性能
- 使用字节码增强:如 ASM、CGLIB、ByteBuddy 生成代理类
- 优先用注解处理器(编译期)代替运行时反射:如 Lombok、MapStruct
- 缓存 Constructor:避免反复
newInstance - 避免在 hot path 中反射:核心循环不要用反射
性能对比(量级):
- 直接调用:1x
- MethodHandle:~1.5x
- 缓存的 Method + setAccessible:~3x
- 未缓存的 Method:~30x
1.9 ★★★Java 动态代理和 CGLIB 代理的实现原理分别是什么?各自有什么限制?Spring 中是如何选择代理方式的?
📖 点击查看答案
JDK 动态代理:
- 基于接口实现,通过
Proxy.newProxyInstance生成实现指定接口的代理类 - 代理类继承
java.lang.reflect.Proxy,实现传入的所有接口 - 所有方法调用转发给
InvocationHandler.invoke
java
Object proxy = Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(p, method, args) -> {
System.out.println("before");
Object result = method.invoke(target, args);
System.out.println("after");
return result;
}
);- 限制:目标类必须实现至少一个接口;不能代理 final 方法
CGLIB 代理:
- 基于继承实现,通过 ASM 生成目标类的子类
- 子类重写非 final 方法,在方法中插入拦截逻辑(MethodInterceptor)
- FastClass 机制:通过索引直接调用原方法,避免反射
java
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Target.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
System.out.println("before");
Object result = proxy.invokeSuper(obj, args);
System.out.println("after");
return result;
});
Target proxy = (Target) enhancer.create();- 限制:不能代理 final 类、final 方法、private 方法;不能代理构造方法
Spring 的选择策略:
- 默认策略:Spring Boot 2.0+ 强制使用 CGLIB(
spring.aop.proxy-target-class=true) - Spring 5 之前:如果目标类实现接口,默认用 JDK 动态代理;否则用 CGLIB
- 可通过
@EnableAspectJAutoProxy(proxyTargetClass = true)强制使用 CGLIB @Configuration类强制用 CGLIB(保证 @Bean 方法返回同一实例,处理 Bean 间依赖)
两者对比:
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 原理 | 接口实现 | 继承子类 |
| 依赖 | JDK 内置 | 第三方 ASM |
| 限制 | 必须有接口 | 不能代理 final 类/方法 |
| 性能 | 创建快,调用稍慢 | 创建慢,调用快 |
1.10 ★★Java 注解(Annotation)的原理是什么?如何自定义一个注解?@Inherited 和 @Repeatable 的作用是什么?
📖 点击查看答案
注解原理:
- 注解本质是继承
java.lang.annotation.Annotation的接口 - 编译后生成动态代理类,运行时通过反射可读取
- 注解需要"元注解"来描述其行为(@Target、@Retention、@Documented 等)
元注解:
@Retention:保留策略(SOURCE / CLASS / RUNTIME)@Target:可标注位置(TYPE、FIELD、METHOD、PARAMETER 等)@Documented:是否出现在 Javadoc@Inherited:是否被子类继承@Repeatable:是否可重复标注
自定义注解示例:
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLog {
String value() default "";
boolean required() default true;
}@Inherited 的作用:
- 标注在类上的注解会被子类继承
- 只对类有效,对接口/方法/字段无效
java
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface InheritedAnno {}
@InheritedAnno
class Parent {}
class Child extends Parent {}
// Child.class.getAnnotation(InheritedAnno.class) != null@Repeatable 的作用(JDK 8+):
- 同一位置可多次标注同一注解
- 需要定义容器注解
java
@Repeatable(Schedules.class)
@Retention(RetentionPolicy.RUNTIME)
@interface Schedule {
String cron();
}
@Retention(RetentionPolicy.RUNTIME)
@interface Schedules {
Schedule[] value();
}
// 重复使用
@Schedule(cron = "0 0 * * *")
@Schedule(cron = "0 30 * * *")
class Task {}注解的处理方式:
- 编译期:注解处理器(APT)生成代码,如 Lombok、MapStruct
- 运行时:反射
getAnnotation(),如 Spring、JUnit - 字节码增强:ASM 修改字节码
1.11 ★★★★ArrayList 和 LinkedList 的底层实现区别是什么?ArrayList 扩容机制是怎样的?在什么场景下 LinkedList 的性能反而不如 ArrayList?
📖 点击查看答案
底层实现区别:
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 内存连续性 | 连续 | 不连续(每个节点有前后指针) |
| 随机访问 | O(1) | O(n) |
| 头部插入 | O(n) | O(1) |
| 中间插入 | O(n)(但要先 O(n) 定位) | O(n) |
| 内存占用 | 较小 | 较大(每个节点额外存两个指针) |
ArrayList 扩容机制:
- 初始容量默认 10(首次 add 时才创建数组)
- 扩容为原来的 1.5 倍:
newCapacity = oldCapacity + (oldCapacity >> 1) - 通过
Arrays.copyOf复制到新数组
java
private int newCapacity(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
if (newCapacity - minCapacity <= 0) newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0) return hugeCapacity(minCapacity);
return newCapacity;
}LinkedList 反而不如 ArrayList 的场景:
- 随机访问:
get(index)是 O(n) vs ArrayList 的 O(1) - 按索引删除:
remove(int)LinkedList 需 O(n) 遍历定位 - 中间插入:理论上 LinkedList 快,但实际上 ArrayList 整体复制内存连续,对 CPU 缓存友好;LinkedList 节点分散,访问慢
- 内存缓存友好性:ArrayList 连续内存,CPU cache 命中率高;LinkedList 节点分散,cache miss 多
- 频繁遍历:LinkedList 遍历时要反复跳转指针,慢于数组连续访问
- 大量元素:LinkedList 每个节点额外 ~48 字节开销(对象头+前后指针)
经验法则:
- 几乎所有业务场景下都用 ArrayList
- 需要队列/双端队列时用 ArrayDeque(比 LinkedList 更高效)
- 极少需要 LinkedList
1.12 ★★★★HashMap 的底层实现原理?JDK 1.7 和 1.8 有什么区别?put 操作的完整流程是怎样的?
📖 点击查看答案
底层结构:
- JDK 1.7:数组 + 链表(头插法)
- JDK 1.8:数组 + 链表 + 红黑树(尾插法,链表长度 ≥ 8 且数组长度 ≥ 64 时转红黑树;树节点 ≤ 6 时退化为链表)
核心字段:
java
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table; // 桶数组
int threshold; // 扩容阈值 = capacity * loadFactorJDK 1.7 vs 1.8 区别:
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 结构 | 数组+链表 | 数组+链表+红黑树 |
| 插入 | 头插法 | 尾插法 |
| 扩容 | 重新计算 hash | 原位置或原位置+oldCap |
| 并发问题 | 头插法会导致环形链表,死循环 | 不会死循环但仍线程不安全(数据丢失) |
| hash 计算 | 9 次扰动(位运算+移位) | 1 次扰动(高低16位异或) |
put 完整流程(JDK 1.8):
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// 1. 计算桶下标,若 table 为空或长度为 0,先 resize 扩容
// 2. 桶为空:直接 new Node 放入
// 3. 桶非空:
// a) 第一个节点 key 相等:记录 oldValue 准备覆盖
// b) 是 TreeNode:走红黑树插入
// c) 是链表:尾插法遍历,若长度达到 8 → treeifyBin(数组<64 时先扩容)
// 4. 覆盖旧值并返回
// 5. ++size > threshold:resize 扩容
}要点:
- 计算 hash:
(h = key.hashCode()) ^ (h >>> 16)让高位也参与下标运算 - 下标计算:
(n - 1) & hash(n 是 2 的幂,等价于 hash % n) - 初始化容量 16,扩容翻倍
- 链表转树条件:链表长度 ≥ 8 且 数组容量 ≥ 64;否则只扩容不转树
- 退化条件:树节点数 ≤ 6
扩容时的优化:
- 因为 n 是 2 的幂,扩容后元素要么在原位置,要么在
原位置 + oldCap - 只需检查 hash 的新增高位 bit 是否为 1:
java
if ((e.hash & oldCap) == 0) → 原位置
else → 原位置 + oldCap1.13 ★★★HashMap、Hashtable、ConcurrentHashMap 三者的区别是什么?
📖 点击查看答案
核心区别:
| 维度 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是 | 是 |
| 锁机制 | 无 | synchronized 锁整个对象 | JDK1.7 分段锁;1.8 CAS+synchronized |
| null key/value | 允许(1个 null key) | 不允许 | 不允许 |
| 初始容量 | 16 | 11 | 16(1.8) |
| 扩容 | 翻倍 | 翻倍+1 | 翻倍 |
| 父类 | AbstractMap | Dictionary | AbstractMap |
| 性能 | 最快 | 最慢 | 高并发下快 |
| 迭代器 | fail-fast | fail-fast | 弱一致性(安全失败) |
| 引入版本 | JDK 1.2 | JDK 1.0 | JDK 1.5 |
HashMap vs Hashtable:
- Hashtable 是线程安全的 HashMap,但所有方法都加了 synchronized,效率极低
- Hashtable 已被废弃,不应在新代码中使用
- 需要 synchronized 的 Map 可用
Collections.synchronizedMap(new HashMap<>())或ConcurrentHashMap
ConcurrentHashMap 的优势:
- 锁粒度细:1.7 用 ReentrantLock 分段锁,1.8 用 CAS + synchronized 锁单个桶
- 读操作完全无锁(基于 volatile)
- 支持 CFR 并发遍历
HashMap 允许 null:
java
map.put(null, "v"); // OK
map.put("k", null); // OK- ConcurrentHashMap 不允许 null 的原因:无法区分"key 不存在"和"value 是 null"(多线程下会有歧义)
fail-fast 机制:
- HashMap/Hashtable 迭代时若被结构性修改(modCount 变化),抛
ConcurrentModificationException - ConcurrentHashMap 迭代器是弱一致性的,不抛异常但可能不反映最新修改
1.14 ★★★★ConcurrentHashMap 在 JDK 1.7 和 1.8 中的实现有何不同?JDK 1.8 为什么放弃分段锁?size() 方法是如何保证线程安全的?
📖 点击查看答案
JDK 1.7 实现:分段锁(Segment)
- 结构:
Segment[]+HashEntry[],每个 Segment 是一个独立的小 HashMap - Segment 继承
ReentrantLock,每个 Segment 有一把锁 - 默认 16 个 Segment,并发度 16
- 读无锁(volatile),写锁 Segment
java
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
}JDK 1.8 实现:CAS + synchronized
- 结构:
Node[]数组 + 链表/红黑树(同 HashMap) - 锁粒度:从 Segment 细化到每个桶的头节点
- 写流程:
- 桶为空:CAS 插入(无锁)
- 桶非空:synchronized 锁住头节点,插入链表/树
- 正在扩容:协助迁移(
transfer)
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ...
for (Node<K,V>[] tab = table;;) {
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) break; // CAS 插入
} else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f); // 协助扩容
} else {
synchronized (f) { // 锁头节点
// 链表/树插入
}
}
}
}1.8 放弃分段锁的原因:
- 并发度更高:1.7 最多 16 个并发,1.8 锁粒度是桶级别,并发度 = 桶数
- 减少内存开销:Segment 占用额外内存
- 避免两次 hash:1.7 需要先 hash 定位 Segment,再 hash 定位桶
- 更好的扩展性:链表转红黑树后查询从 O(n) 到 O(logn)
- CAS 优化无竞争场景:空桶用 CAS 无锁插入,性能更高
- 支持并发扩容:多线程协助迁移数据
size() 的实现(1.8):
- 思路:先尝试无锁统计,失败再回退到加锁
- 用 baseCount + CounterCell[] 累加,借鉴 LongAdder 思想
- 写操作时通过
addCount累加:先 CAS baseCount,失败则分散到 CounterCell[] - size() = baseCount + sumCount()(遍历 CounterCell 求和)
java
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] as = counterCells;
long sum = baseCount;
if (as != null) {
for (CounterCell a : as) if (a != null) sum += a.value;
}
return sum;
}- size() 是弱一致性的,可能不完全准确
1.15 ★Java 集合快速失败(fail-fast)和安全失败(fail-safe)区别是什么?
📖 点击查看答案
fail-fast(快速失败):
- 在迭代过程中,如果集合被结构性修改(增删),立即抛
ConcurrentModificationException - 实现原理:维护
modCount字段,迭代器在 next() 时检查expectedModCount == modCount
java
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
for (Integer i : list) {
if (i == 2) list.remove(i); // 抛 ConcurrentModificationException
}- 代表:ArrayList、HashMap、Vector 等
java.util包下的集合 - 目的:尽早发现并发修改,避免不可预期的行为
fail-safe(安全失败):
迭代时对集合的副本进行遍历,不会被并发修改影响
不会抛 ConcurrentModificationException
缺点:无法反映迭代期间的修改,且需要额外内存复制
代表:
java.util.concurrent包下的并发集合CopyOnWriteArrayList:遍历的是底层数组的快照ConcurrentHashMap:弱一致性迭代器,遍历期间可看到部分最新修改CopyOnWriteArraySet
java
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(Arrays.asList(1, 2, 3));
for (Integer i : list) {
if (i == 2) list.remove(Integer.valueOf(2)); // 不抛异常
}正确删除元素的方式:
java
// 方式 1:迭代器的 remove
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
if (it.next() == 2) it.remove();
}
// 方式 2:JDK 8+ removeIf
list.removeIf(i -> i == 2);对比总结:
| 维度 | fail-fast | fail-safe |
|---|---|---|
| 异常 | 抛 CME | 不抛 |
| 实时性 | 实时检测 | 可能不反映最新修改 |
| 内存 | 不复制 | 可能复制副本 |
| 性能 | 高 | 略低(复制开销) |
| 典型集合 | ArrayList、HashMap | ConcurrentHashMap、CopyOnWriteArrayList |
1.16 ★★★Java 异常体系是怎样的?Error 和 Exception、受检异常和非受检异常的区别?在实际项目中如何选择和处理?
📖 点击查看答案
异常体系:
Throwable
├── Error(严重错误,程序无法处理)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception
├── RuntimeException(非受检,编译器不强制处理)
│ ├── NullPointerException
│ ├── ClassCastException
│ ├── IllegalArgumentException
│ └── ArrayIndexOutOfBoundsException
└── 其他 Exception(受检异常,编译器强制处理)
├── IOException
├── SQLException
└── ClassNotFoundExceptionError vs Exception:
- Error:JVM 系统级错误,程序不应尝试捕获(如 OOM、StackOverflow)
- Exception:程序可处理的异常
受检 vs 非受检异常:
| 类型 | 受检异常(Checked) | 非受检异常(Unchecked) |
|---|---|---|
| 父类 | Exception(非 RuntimeException) | RuntimeException |
| 处理 | 必须 try-catch 或 throws,否则编译失败 | 不强制处理 |
| 本质 | 可恢复的业务异常(如 IO 错误) | 编程错误(如 NPE) |
| 典型 | IOException、SQLException | NPE、ClassCastException |
项目中如何选择:
- 业务异常用受检异常或自定义异常:如
UserNotFoundException extends Exception - 编程错误用 RuntimeException:如参数校验失败抛
IllegalArgumentException - 第三方库的受检异常按业务处理:不要简单
throws Exception - 不要捕获 Throwable / Error:会掩盖 JVM 严重问题
- 避免忽略异常:禁止空 catch 块
java
// 反例
try { ... } catch (Exception e) { /* 空 */ }
// 正例:记录日志 + 包装为业务异常
try {
Files.readAllBytes(path);
} catch (IOException e) {
log.error("读取文件失败: {}", path, e);
throw new BusinessException("文件读取失败", e);
}处理原则:
- 能处理就处理,不能处理就向上抛
- 业务层包装为统一业务异常,由全局异常处理器返回标准响应
- 资源释放用 try-with-resources
- 不要用异常控制流程(性能差)
1.17 ★★★BIO、NIO、AIO 三种 I/O 模型的区别是什么?NIO 的三大核心组件有哪些?
📖 点击查看答案
三种 I/O 模型对比:
| 维度 | BIO | NIO | AIO |
|---|---|---|---|
| 通信方式 | 面向流 | 面向缓冲 | 面向缓冲 |
| 阻塞 | 阻塞 | 非阻塞(可配置) | 异步非阻塞 |
| 同步性 | 同步阻塞 | 同步非阻塞(多路复用) | 异步 |
| 模型 | 一个连接一个线程 | 多路复用,一个线程处理多个连接 | 回调通知 |
| 适用 | 连接数少、业务重 | 连接数多、轻业务 | 连接数极多 |
| JDK 版本 | 1.0 | 1.4 | 1.7+ |
BIO 示例:
java
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept(); // 阻塞
new Thread(() -> handle(client)).start(); // 一个连接一个线程
}NIO 三大核心组件:
Buffer(缓冲区):数据容器,本质是一块可读可写的内存
- 核心属性:capacity、position、limit、mark
- 关键方法:
flip()(写转读)、clear()、compact()
Channel(通道):双向数据传输通道,可读可写
- 主要实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel
- 与 Buffer 配合:
channel.read(buffer)/channel.write(buffer)
Selector(选择器):多路复用器,一个线程监控多个 Channel 的事件
- 事件类型:OP_ACCEPT、OP_READ、OP_WRITE、OP_CONNECT
java
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞到有事件
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) handleAccept(key);
else if (key.isReadable()) handleRead(key);
}
keys.clear();
}AIO(JDK 7+):
- 真正的异步 I/O:操作系统完成 I/O 后回调
CompletionHandler AsynchronousSocketChannel、AsynchronousServerSocketChannel
java
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {
server.accept(null, this); // 接受下一个
handle(ch);
}
public void failed(Throwable e, Void att) { ... }
});Netty 的选择:
- Linux 下 AIO 实现不成熟,Netty 主流使用 NIO(基于 epoll)
- Windows 下 AIO 性能优秀(IOCP),但服务器多用 Linux
1.18 ★★★Java 序列化机制中 serialVersionUID 的作用是什么?transient 关键字的作用是什么?Serializable 和 Externalizable 的区别?
📖 点击查看答案
serialVersionUID 的作用:
- 序列化时将类的
serialVersionUID写入字节流 - 反序列化时比对字节流中的 UID 与当前类的 UID
- 不一致则抛
InvalidClassException,防止版本不匹配导致反序列化错误
java
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// 若不显式声明,JVM 会根据类结构自动生成,类改动后变化 → 反序列化失败
}不显式声明的风险:
- 编译器自动生成基于类结构的 UID,类一改(加字段/改方法)UID 就变
- 不同 JVM 实现生成的 UID 算法可能不同
- 建议始终显式声明
transient 关键字:
- 标记的字段不参与序列化
- 反序列化后值为默认值(0、null、false)
- 用于敏感信息(密码)或可计算/临时字段
java
public class User implements Serializable {
private String name;
private transient String password; // 不序列化
}Serializable vs Externalizable:
| 维度 | Serializable | Externalizable |
|---|---|---|
| 接口 | 标记接口,无需实现方法 | 需实现 readExternal / writeExternal |
| 序列化 | 自动序列化所有非 transient 字段 | 手动指定序列化哪些字段 |
| 性能 | 较慢(反射 + 写类信息) | 较快(直接调用方法) |
| 安全 | 自动,但可能序列化敏感数据 | 精确控制,更安全 |
| 灵活性 | 低 | 高 |
java
public class User implements Externalizable {
private String name;
private transient int age; // Externalizable 下需要手动处理
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
// age 不写
}
public void readExternal(ObjectInput in) throws IOException {
this.name = in.readUTF();
}
}- Externalizable 必须有无参构造器(反序列化时先 new 再 readExternal)
序列化的替代方案:
- Java 原生序列化:性能差、不跨语言、有安全漏洞(反序列化 RCE)
- 推荐:JSON(Jackson、Gson)、Protobuf、Hessian、Kryo、Avro
1.19 ★★Lambda 表达式的实现原理是什么?什么是函数式接口?Stream 的核心操作有哪些?parallelStream 有什么风险?
📖 点击查看答案
Lambda 表达式原理:
- 编译期:Lambda 不会生成匿名内部类,而是在类中生成一个私有静态方法
lambda$方法名$序号 - 运行期:通过
invokedynamic指令 +LambdaMetafactory在首次调用时动态生成实现类 - 生成的类实现了目标函数式接口,方法体调用前面生成的私有静态方法
java
Runnable r = () -> System.out.println("hello");
// 编译后:生成 lambda$main$0() 方法,运行时通过 invokedynamic 绑定- 优点:避免匿名内部类的字节码膨胀,多次复用同一实现
函数式接口:
- 有且只有一个抽象方法的接口(default、static 方法不算)
- 用
@FunctionalInterface标注(非强制,但推荐) - Lambda 表达式的类型必须是函数式接口
java
@FunctionalInterface
interface MyFunction<T, R> {
R apply(T t);
}- JDK 内置函数式接口:
Function<T,R>:T → RPredicate<T>:T → booleanConsumer<T>:T → voidSupplier<T>:无参 → TBiFunction<T,U,R>、UnaryOperator<T>等
Stream 核心操作:
中间操作(惰性):
filter、map、flatMap、distinct、sorted、limit、skip、peek
终端操作(触发计算):
forEach、collect、reduce、count、min、max、anyMatch、findFirst
java
List<String> result = list.stream()
.filter(s -> s.length() > 3) // 中间
.map(String::toUpperCase) // 中间
.distinct() // 中间
.sorted() // 中间
.collect(Collectors.toList()); // 终端- 短路操作:
findFirst、findAny、anyMatch、limit找到结果立即结束
parallelStream 的风险:
- 线程安全:共享可变状态必须加锁或用并发集合
- 公共 ForkJoinPool:默认共享
ForkJoinPool.commonPool(),会阻塞其他并行流 - 顺序保证:
forEach不保证顺序,需要forEachOrdered - 拆分成本:ArrayList 易拆分,LinkedList 拆分效率低
- 装箱开销:基本类型应用
IntStream等,避免Stream<Integer> - 小数据量反优化:元素少时拆分+线程切换开销大于收益
java
// 反例
List<Integer> shared = new ArrayList<>();
list.parallelStream().forEach(shared::add); // 线程不安全!可能丢数据
// 正例:用 collect
List<Integer> result = list.parallelStream()
.filter(...)
.collect(Collectors.toList());- 适用场景:数据量大(万级以上)、CPU 密集型、操作无副作用
1.20 ★★JDK 8 到 JDK 21 之间有哪些重要新特性?Records、Sealed Classes、Pattern Matching、Virtual Thread 分别解决了什么问题?
📖 点击查看答案
JDK 各版本重要特性:
| 版本 | 重要特性 |
|---|---|
| 8 | Lambda、Stream、Optional、新日期 API、default 方法 |
| 9 | 模块化系统(Jigsaw)、JShell、集合工厂方法 List.of() |
| 10 | 局部变量类型推断 var |
| 11 | HTTP Client API(标准)、var 用于 Lambda |
| 14 | Switch 表达式(正式) |
| 16 | Records(正式)、Sealed Classes(预览) |
| 17 | LTS、Sealed Classes(正式)、Pattern Matching for instanceof |
| 19 | Virtual Thread(预览) |
| 21 | LTS、Virtual Thread(正式)、Pattern Matching for switch、Sequenced Collections |
Records(JDK 16):解决样板代码
- 一行代码定义不可变数据载体,自动生成构造器、getter、equals、hashCode、toString
java
public record Point(int x, int y) {}
// 等价于手写:final 字段 + 全参构造 + getter + equals + hashCode + toString- 适合 DTO、值对象;可加紧凑构造器做参数校验
Sealed Classes(JDK 17):限制继承层级
- 显式声明哪些类可以继承/实现
java
public sealed interface Shape permits Circle, Square, Triangle {}
final class Circle implements Shape {}
final class Square implements Shape {}
non-sealed class Triangle implements Shape {}- 配合 Pattern Matching 实现穷尽性检查
Pattern Matching:简化类型判断和转换
- instanceof 模式匹配(JDK 16):免去强制转换
java
if (obj instanceof String s) {
System.out.println(s.length()); // s 已是 String
}- Switch 模式匹配(JDK 21):
java
String desc = switch (shape) {
case Circle c -> "圆 r=" + c.radius();
case Square s -> "方 w=" + s.width();
case Triangle t -> "三角形";
};Virtual Thread(JDK 21):解决高并发线程成本
- 轻量级线程,由 JVM 调度,挂载在平台线程(载体线程)上
- 编写同步阻塞代码风格,获得异步非阻塞性能
- 数百万虚拟线程也无压力(每个约几 KB,平台线程约 1 MB)
java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
})
);
}- 解决问题:传统
Thread per request模型在百万连接下线程成本太高;异步编程(CompletableFuture)代码复杂难维护 - 注意:synchronized 会"pin"虚拟线程,建议用 ReentrantLock;不适合 CPU 密集型任务