面试准备-技能栈八股文大全

面试准备-技能栈八股文大全

本文档整理了480+道高频面试题,涵盖Java、Spring、MySQL、Redis、消息队列、容器化等核心技术栈。
每道题都包含详细答案、原理图解、源码分析、实战场景、常见误区和追问,适合面试前系统复习。

📚 目录

难度说明

  • 🟢 基础:校招必考,工作1-3年
  • 🟡 进阶:社招常见,工作3-5年
  • 🔴 高级:架构师级别,工作5年+
  • ⭐ 高频题:面试中出现频率>80%

Java基础与JVM

一、Java基础篇(30题)

🟢⭐ 1. 说说Java中的==和equals()的区别

面试官视角:考察对Java基础概念的理解,以及对引用类型和值类型的认知。

详细答案

==equals()是Java中两种不同的比较方式,它们的核心区别在于比较的内容不同:

1. ==运算符

  • 对于基本数据类型(byte、short、int、long、float、double、char、boolean),==比较的是值是否相等
  • 对于引用数据类型,==比较的是两个对象的内存地址是否相同,即是否指向同一个对象

2. equals()方法

  • equals()是Object类的方法,所有类都继承了这个方法
  • Object类中的默认实现就是使用==比较
  • 但很多类重写了equals()方法,如String、Integer等,改为比较对象的内容

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
内存模型:
栈区(Stack) 堆区(Heap)
+--------+ +-----------------+
| s1 ---------> | "Hello" | 地址: 0x100
+--------+ +-----------------+
| s2 ---------> | "Hello" | 地址: 0x200
+--------+ +-----------------+
| s3 ---------> | "Hello" | 地址: 0x100 (同s1)
+--------+ +-----------------+

s1 == s2 -> false (不同地址)
s1.equals(s2) -> true (内容相同)
s1 == s3 -> true (相同地址)

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Object类中的equals()默认实现
public boolean equals(Object obj) {
return (this == obj); // 默认比较地址
}

// String类重写的equals()
public boolean equals(Object anObject) {
if (this == anObject) {
return true; // 先比较地址,性能优化
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i]) // 逐字符比较
return false;
i++;
}
return true;
}
}
return false;
}

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 场景1:字符串比较陷阱
String s1 = new String("Hello");
String s2 = new String("Hello");
System.out.println(s1 == s2); // false,不同对象
System.out.println(s1.equals(s2)); // true,内容相同

// 场景2:字符串常量池
String s3 = "Hello";
String s4 = "Hello";
System.out.println(s3 == s4); // true,指向常量池同一对象

// 场景3:自定义类
class Person {
String name;
int age;
// 如果不重写equals,比较的就是地址
}
Person p1 = new Person("张三", 25);
Person p2 = new Person("张三", 25);
System.out.println(p1.equals(p2)); // false,应该重写equals

常见误区

  1. 误区1:认为equals()一定比较内容

    • 纠正:如果类没有重写equals(),默认还是比较地址
  2. 误区2:忽略字符串常量池的影响

    • 纠正:直接赋值的字符串字面量会放入常量池,相同内容共享同一对象
  3. 误区3:重写equals()后忘记重写hashCode()

    • 纠正:这会导致在HashMap等集合中出现问题

追问1:为什么重写equals()必须重写hashCode()?

答:这是Java的契约规定。如果两个对象通过equals()比较相等,那么它们的hashCode()必须返回相同的值。原因是:

  • HashMap、HashSet等集合使用hashCode()来确定对象的存储位置
  • 查找时先比较hashCode(),相同才调用equals()
  • 如果不重写hashCode(),即使equals()返回true,两个对象的hashCode()可能不同,导致在HashMap中被当作不同的键
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 正确的重写示例
class Person {
String name;
int 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() {
return Objects.hash(name, age); // 必须基于equals()中使用的字段
}
}

追问2:String的equals()为什么先比较地址?

答:这是一种性能优化。如果两个字符串引用指向同一个对象(地址相同),那么它们肯定相等,可以直接返回true,避免后续的逐字符比较,提高效率。这种优化在字符串常量池场景下特别有效。


🟢⭐ 2. Java中的String为什么设计成不可变的?

面试官视角:考察对String内部实现的理解,以及不可变性带来的优势。

详细答案

String被设计成不可变类(Immutable Class)有多个重要原因,这是一个经过深思熟虑的设计决策:

1. 安全性

  • String经常用作参数传递,如数据库连接URL、文件路径、网络连接等
  • 如果String可变,恶意代码可能在传递过程中修改其内容,造成安全隐患
  • 不可变保证了String作为参数传递时的线程安全

2. 字符串常量池优化

  • JVM维护了一个字符串常量池(String Pool)
  • 相同内容的字符串字面量共享同一个对象,节省内存
  • 只有不可变才能安全地共享,否则修改会影响所有引用

3. 线程安全

  • 不可变对象天生线程安全,多线程访问无需同步
  • 避免了同步带来的性能开销

4. hashCode缓存

  • String的hashCode()结果可以缓存,不需要每次重新计算
  • 这使得String作为HashMap的key时性能更优

5. 类加载器安全

  • 类加载器使用String作为类名参数
  • 不可变保证了类加载的安全性

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
String不可变性实现:

+-------------------+
| String对象 |
|-------------------|
| final char[] value| <- final修饰,引用不可变
| private int hash | <- hashCode缓存
|-------------------|
| 无setter方法 | <- 关键设计
+-------------------+

字符串常量池:
常量池(Heap)
+------------------+
| "Hello" (0x100) | <--- s1指向
+------------------+ s2也指向(共享)
| "World" (0x200) |
+------------------+

如果可变会怎样:
String s1 = "Hello";
String s2 = "Hello"; // 共享同一对象
s1.modify("Hi"); // 假设有这个方法
// s2的值也会变成"Hi",这不符合预期!

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// JDK 8的String类定义
public final class String // final类,不能被继承
implements java.io.Serializable, Comparable<String>, CharSequence {

// 核心存储结构:final修饰
private final char value[]; // JDK 8使用char数组

// hashCode缓存
private int hash; // Default to 0

// 没有任何修改value的public方法

// substring等方法都返回新的String对象
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 返回新对象,而不是修改原对象
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}

// concat也是返回新对象
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true); // 返回新String对象
}
}

// JDK 9+改用byte数组 + 编码标识,节省空间
// private final byte[] value;
// private final byte coder; // LATIN1 or UTF16

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 场景1:常量池优化
String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hel" + "lo"; // 编译期优化,等同于"Hello"
System.out.println(s1 == s2); // true,共享常量池对象
System.out.println(s1 == s3); // true

// 场景2:操作返回新对象
String s4 = "Hello";
String s5 = s4.toUpperCase();
System.out.println(s4); // "Hello",原对象未改变
System.out.println(s5); // "HELLO",新对象

// 场景3:安全性
public void processUrl(String url) {
// 可以安全地假设url不会被其他线程修改
new Thread(() -> {
connect(url); // 多线程环境下安全
}).start();
}

// 场景4:HashMap的key
Map<String, User> userMap = new HashMap<>();
String key = "user123";
userMap.put(key, user);
// key的hashCode会被缓存,查找效率高
// 不可变保证了key的稳定性

常见误区

  1. 误区1:认为String不可变是因为final修饰

    • 纠正:final只保证引用不变,不保证内容不变。String不可变是多重设计的结果
  2. 误区2:大量字符串拼接也用String

    • 纠正:应该使用StringBuilder或StringBuffer,避免创建大量临时对象
  3. 误区3:通过反射可以修改String的value数组,所以String可变

    • 纠正:虽然技术上可行,但这是不安全的hack,违背了设计契约

追问1:既然String不可变,为什么还有StringBuilder和StringBuffer?

答:正因为String不可变,每次修改都会创建新对象,在频繁修改字符串的场景下性能很差。例如:

1
2
3
4
5
6
7
8
9
10
11
12
// 性能差:创建10000个临时String对象
String s = "";
for (int i = 0; i < 10000; i++) {
s += i; // 每次循环创建新String对象
}

// 性能好:只有一个StringBuilder对象
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i); // 在原对象上修改
}
String result = sb.toString();

StringBuilder和StringBuffer内部使用可变的char数组,支持原地修改,适合频繁拼接场景。区别是StringBuffer是线程安全的(synchronized),StringBuilder不是。

追问2:JDK 9为什么把String的char[]改成byte[]?

答:这是Compact Strings优化。大多数String只包含Latin1字符(单字节),用char(2字节)存储浪费空间。JDK 9引入了coder字段标识编码方式:

  • LATIN1(0):每个字符1字节
  • UTF16(1):每个字符2字节

这样可以节省约50%的内存,提升性能。源码示例:

1
2
3
4
5
6
// JDK 9+
private final byte[] value;
private final byte coder; // LATIN1 or UTF16

static final byte LATIN1 = 0;
static final byte UTF16 = 1;

🟡⭐ 3. 深入理解Java中的四种引用类型

面试官视角:考察对JVM内存管理和垃圾回收机制的深入理解,以及在实际场景中的应用能力。

详细答案

Java提供了四种引用类型,用于更精细地控制对象的生命周期和垃圾回收行为:强引用、软引用、弱引用、虚引用。

1. 强引用(Strong Reference)

  • 最常见的引用类型,如 Object obj = new Object()
  • 只要强引用存在,GC永远不会回收被引用的对象
  • 即使内存不足抛出OOM,也不会回收强引用对象

2. 软引用(Soft Reference)

  • 使用SoftReference类实现
  • 在内存充足时,GC不会回收软引用对象
  • 在内存不足(即将OOM)时,GC会回收软引用对象
  • 适合实现内存敏感的缓存

3. 弱引用(Weak Reference)

  • 使用WeakReference类实现
  • 无论内存是否充足,GC时一旦发现弱引用对象,就会回收
  • 生命周期只能存活到下一次GC之前
  • 适合实现短期缓存、监听器管理等

4. 虚引用(Phantom Reference)

  • 使用PhantomReference类实现
  • 最弱的引用类型,形同虚设
  • 无法通过虚引用获取对象实例(get()永远返回null)
  • 必须配合引用队列(ReferenceQueue)使用
  • 用于跟踪对象被GC的状态,做清理工作

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GC回收策略:

内存充足时:
+-------------+ +-------------+ +-------------+ +-------------+
| 强引用对象 | --> | 软引用对象 | --> | 弱引用对象 | --> | 虚引用对象 |
| 不回收 | | 不回收 | | 可能回收 | | 跟踪回收 |
+-------------+ +-------------+ +-------------+ +-------------+

内存不足时(即将OOM):
+-------------+ +-------------+ +-------------+ +-------------+
| 强引用对象 | --> | 软引用对象 | --> | 弱引用对象 | --> | 虚引用对象 |
| 不回收 | | 回收 | | 回收 | | 跟踪回收 |
+-------------+ +-------------+ +-------------+ +-------------+

引用队列机制:
对象 --> Reference --> ReferenceQueue
|
| GC回收对象后
v
Reference进入队列,可以做清理工作

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 1. 软引用示例
public class SoftReferenceExample {
public static void main(String[] args) {
// 创建软引用
Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
obj = null; // 去掉强引用

// 可以通过get()获取对象
Object retrieved = softRef.get();
if (retrieved != null) {
System.out.println("对象还在");
} else {
System.out.println("对象已被回收");
}
}
}

// 2. 弱引用示例
public class WeakReferenceExample {
public static void main(String[] args) {
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null; // 去掉强引用

System.gc(); // 建议GC

// GC后弱引用对象大概率被回收
if (weakRef.get() == null) {
System.out.println("弱引用对象已被回收");
}
}
}

// 3. 虚引用示例
public class PhantomReferenceExample {
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

System.out.println(phantomRef.get()); // 永远返回null

obj = null;
System.gc();

// 等待对象被回收,引用进入队列
Thread.sleep(1000);
Reference<?> ref = queue.poll();
if (ref != null) {
System.out.println("对象已被回收,可以做清理工作");
// 这里可以做资源清理
}
}
}

// 4. Reference类的核心源码(简化版)
public abstract class Reference<T> {
private T referent; // 引用的对象
volatile ReferenceQueue<? super T> queue; // 引用队列

public T get() {
return this.referent;
}

public void clear() {
this.referent = null;
}
}

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 场景1:软引用实现内存敏感缓存
public class ImageCache {
private Map<String, SoftReference<Image>> cache = new HashMap<>();

public Image getImage(String path) {
SoftReference<Image> ref = cache.get(path);
if (ref != null) {
Image img = ref.get();
if (img != null) {
return img; // 缓存命中
}
}

// 缓存未命中或已被回收,重新加载
Image img = loadImage(path);
cache.put(path, new SoftReference<>(img));
return img;
}

// 内存不足时,软引用的Image会被自动回收,避免OOM
}

// 场景2:WeakHashMap实现自动清理的缓存
public class SessionManager {
// key是弱引用,当User对象没有其他引用时,会被自动清理
private WeakHashMap<User, Session> sessions = new WeakHashMap<>();

public void createSession(User user) {
sessions.put(user, new Session());
}

// 当User对象被回收后,对应的Session也会自动清理
}

// 场景3:ThreadLocal的内存泄漏问题(弱引用)
public class ThreadLocal<T> {
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k); // ThreadLocal作为key是弱引用
value = v;
}
}
}
}
// ThreadLocal作为key是弱引用,当外部没有强引用时会被回收
// 但value是强引用,如果不remove()会导致内存泄漏

// 场景4:虚引用实现堆外内存管理(DirectByteBuffer)
public class Cleaner extends PhantomReference<Object> {
private final Runnable thunk;

public void clean() {
if (remove(this)) {
try {
this.thunk.run(); // 执行清理任务
} catch (final Throwable var2) {
// 处理异常
}
}
}
}
// DirectByteBuffer使用Cleaner跟踪对象回收,释放堆外内存

常见误区

  1. 误区1:软引用和弱引用混淆

    • 纠正:软引用在内存不足时才回收,弱引用在GC时就回收
  2. 误区2:认为虚引用可以获取对象

    • 纠正:虚引用的get()永远返回null,只用于跟踪回收状态
  3. 误区3:不理解ThreadLocal的内存泄漏

    • 纠正:ThreadLocal的key是弱引用会被回收,但value是强引用不会回收,需要手动remove()
  4. 误区4:过度使用软引用缓存

    • 纠正:软引用的回收时机不确定,可能导致Full GC,影响性能

追问1:软引用什么时候被回收?

答:软引用的回收时机取决于GC算法和内存压力:

  1. G1 GC:当老年代占用超过InitiatingHeapOccupancyPercent阈值(默认45%)时,会清理软引用
  2. CMS GC:在即将发生OOM前清理软引用
  3. JVM参数:可以通过-XX:SoftRefLRUPolicyMSPerMB调整,表示每MB空闲堆空间软引用存活的毫秒数
1
2
3
4
5
// 软引用存活时间计算
long ms = SoftRefLRUPolicyMSPerMB * freeMB;
if (now - lastAccess > ms) {
// 回收软引用
}

追问2:为什么WeakHashMap能解决内存泄漏,而HashMap不能?

答:关键在于key的引用类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 普通HashMap
Map<User, Data> map = new HashMap<>();
User user = new User();
map.put(user, data);
user = null; // 外部引用断开
// 但map中还保存着对user的强引用,user不会被回收,导致内存泄漏

// WeakHashMap
Map<User, Data> map = new WeakHashMap<>();
User user = new User();
map.put(user, data);
user = null; // 外部引用断开
// map中的key是弱引用,user会被回收,对应的entry也会自动清理

WeakHashMap的Entry继承自WeakReference,当key没有外部强引用时,GC会回收key,然后WeakHashMap会清理对应的entry,避免内存泄漏。

追问3:DirectByteBuffer如何使用虚引用管理堆外内存?

答:DirectByteBuffer分配的是堆外内存(Native Memory),不受JVM GC管理。如果不释放会导致内存泄漏。虚引用的作用是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DirectByteBuffer extends MappedByteBuffer {
DirectByteBuffer(int cap) {
// 分配堆外内存
long base = unsafe.allocateMemory(size);

// 创建Cleaner(虚引用),关联清理任务
cleaner = Cleaner.create(this, new Deallocator(base, size));
}

private static class Deallocator implements Runnable {
public void run() {
unsafe.freeMemory(address); // 释放堆外内存
}
}
}

当DirectByteBuffer对象被GC回收时,虚引用会进入引用队列,触发Cleaner执行清理任务,释放堆外内存。这是一种finalizer机制的替代方案,性能更好。


🟡⭐ 4. 深入剖析Java泛型的实现机制

面试官视角:考察对泛型擦除、类型安全、泛型限制等核心概念的理解,以及对反射和泛型交互的认知。

详细答案

Java泛型是JDK 5引入的特性,用于提供编译时类型安全检查。但与C++模板不同,Java泛型使用**类型擦除(Type Erasure)**实现,这导致了一些独特的特性和限制。

核心概念

1. 类型擦除(Type Erasure)

  • 泛型信息只存在于编译期,运行时会被擦除
  • 擦除后替换为原始类型(Raw Type),通常是Object或第一个边界类型
  • 编译器自动插入类型转换代码

2. 泛型的本质

  • 编译期的语法糖,运行时不存在
  • 通过编译器检查保证类型安全
  • 避免了运行时的类型转换异常

3. 泛型擦除的原因

  • 保持与旧版本代码的兼容性(泛型前的代码)
  • 避免JVM的大规模改动
  • 减少类文件膨胀(不像C++为每个类型生成新类)

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
泛型擦除过程:

编译前(源代码):
+---------------------------+
| List<String> list = |
| new ArrayList<>(); |
| list.add("Hello"); |
| String s = list.get(0); |
+---------------------------+

编译后(字节码):
+---------------------------+
| List list = | <- 泛型擦除为List
| new ArrayList(); |
| list.add("Hello"); | <- 编译器检查类型
| String s = |
| (String)list.get(0); | <- 编译器插入强转
+---------------------------+

泛型边界擦除:
List<T> -> List (擦除为Object)
List<T extends Number> -> List (擦除为Number)
List<?> -> List (擦除为Object)

多个边界的擦除:
<T extends Comparable & Serializable>
-> 擦除为第一个边界 Comparable

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 1. 泛型类的定义和擦除
public class Box<T> {
private T value;

public void set(T value) {
this.value = value;
}

public T get() {
return value;
}
}

// 编译后等价于:
public class Box {
private Object value; // T被擦除为Object

public void set(Object value) {
this.value = value;
}

public Object get() {
return value;
}
}

// 使用时编译器插入强转:
Box<String> box = new Box<>();
box.set("Hello");
String s = box.get(); // 编译器插入 (String)box.get()

// 2. 带边界的泛型擦除
public class NumberBox<T extends Number> {
private T value;

public void set(T value) {
this.value = value;
}

public double doubleValue() {
return value.doubleValue(); // 可以调用Number的方法
}
}

// 编译后擦除为Number:
public class NumberBox {
private Number value; // 擦除为边界类型Number

public void set(Number value) {
this.value = value;
}

public double doubleValue() {
return value.doubleValue();
}
}

// 3. 泛型方法的桥接方法(Bridge Method)
class Node<T> {
public T data;

public void setData(T data) {
this.data = data;
}
}

class MyNode extends Node<Integer> {
@Override
public void setData(Integer data) { // 重写的是Integer版本
super.setData(data);
}
}

// 编译器生成桥接方法,保持多态性:
class MyNode extends Node {
public void setData(Integer data) {
super.setData(data);
}

// 桥接方法,保持父类方法签名
@Override
public void setData(Object data) { // 桥接方法
setData((Integer) data); // 委托给真正的方法
}
}

// 4. 泛型数组的限制
public class GenericArray<T> {
// private T[] array = new T[10]; // 编译错误!
// 因为擦除后变成 new Object[10],无法转换为T[]

// 正确做法1:使用Object数组
private Object[] array = new Object[10];

@SuppressWarnings("unchecked")
public T get(int index) {
return (T) array[index];
}

// 正确做法2:通过反射创建数组
private T[] array;

@SuppressWarnings("unchecked")
public GenericArray(Class<T> clazz, int size) {
array = (T[]) Array.newInstance(clazz, size);
}
}

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 场景1:泛型类型信息丢失
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // true
// 运行时都是ArrayList,泛型信息已擦除

// 场景2:不能基于泛型参数做instanceof判断
public <T> void check(Object obj) {
// if (obj instanceof T) { } // 编译错误
// if (obj instanceof List<String>) { } // 编译错误
if (obj instanceof List) { } // 正确,只能判断原始类型
}

// 场景3:泛型方法重载的限制
public class Example {
// 编译错误:擦除后方法签名相同
// public void print(List<String> list) { }
// public void print(List<Integer> list) { }

// 正确:方法签名不同
public void print(List<String> list) { }
public void print(Set<String> set) { }
}

// 场景4:通过反射获取泛型信息
public class GenericInfo {
private List<String> stringList;

public static void main(String[] args) throws Exception {
Field field = GenericInfo.class.getDeclaredField("stringList");

// 获取泛型类型
Type genericType = field.getGenericType();
if (genericType instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) genericType;
System.out.println(pt.getRawType()); // interface java.util.List
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
}
}
}
// 字段、方法参数、返回值的泛型信息会保存在Class文件中

// 场景5:通过子类获取父类泛型参数(常见框架技巧)
public abstract class BaseDao<T> {
private Class<T> entityClass;

@SuppressWarnings("unchecked")
public BaseDao() {
// 通过反射获取泛型参数的实际类型
Type type = getClass().getGenericSuperclass();
if (type instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) type;
entityClass = (Class<T>) pt.getActualTypeArguments()[0];
}
}

public void save(T entity) {
System.out.println("Saving: " + entityClass.getName());
}
}

public class UserDao extends BaseDao<User> {
// 构造时可以获取到User.class
}

// 使用:
UserDao userDao = new UserDao();
userDao.save(new User()); // 输出: Saving: com.example.User

常见误区

  1. 误区1:认为泛型在运行时存在

    • 纠正:泛型信息在运行时被擦除,只能通过反射从元数据中获取
  2. 误区2:认为List<String>List<Object>有继承关系

    • 纠正:泛型不支持协变,List<String>不是List<Object>的子类
  3. 误区3:试图创建泛型数组

    • 纠正:不能直接new T[],需要通过反射或使用Object[]
  4. 误区4:在静态方法/字段中使用类泛型参数

    • 纠正:静态成员属于类,类泛型参数属于实例,不能混用

追问1:为什么不能创建泛型数组?

答:这是类型擦除导致的安全问题。假设可以创建泛型数组:

1
2
3
4
5
6
7
// 假设可以创建泛型数组(实际不允许)
List<String>[] stringLists = new List<String>[1]; // 假设允许
Object[] objects = stringLists; // 数组协变,合法
objects[0] = new ArrayList<Integer>(); // 运行时只检查List,合法
String s = stringLists[0].get(0); // ClassCastException!

// 这就破坏了泛型的类型安全!

由于类型擦除,运行时数组只知道存储的是List,无法检查具体类型参数,导致类型安全问题。所以Java禁止创建泛型数组。

追问2:通配符?? extends T? super T的区别?

答:这是PECS原则(Producer Extends, Consumer Super):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. 无界通配符 <?>:表示任意类型,只读
List<?> list = new ArrayList<String>();
// list.add("Hello"); // 编译错误,不知道具体类型
Object obj = list.get(0); // 只能读取为Object

// 2. 上界通配符 <? extends T>:表示T或T的子类,生产者(只读)
List<? extends Number> numbers = new ArrayList<Integer>();
// numbers.add(1); // 编译错误,不知道具体类型
Number num = numbers.get(0); // 可以读取为Number

// 3. 下界通配符 <? super T>:表示T或T的父类,消费者(只写)
List<? super Integer> list = new ArrayList<Number>();
list.add(1); // 可以写入Integer或其子类
// Integer i = list.get(0); // 编译错误,只能读取为Object

// PECS示例:
public class Collections {
// 从src复制到dest
public static <T> void copy(
List<? super T> dest, // dest是消费者,接收T,用super
List<? extends T> src) { // src是生产者,提供T,用extends
for (T t : src) {
dest.add(t);
}
}
}

// 使用:
List<Number> numbers = new ArrayList<>();
List<Integer> integers = Arrays.asList(1, 2, 3);
Collections.copy(numbers, integers); // 合法

追问3:泛型的桥接方法是什么?

答:桥接方法是编译器生成的合成方法,用于保持泛型类继承时的多态性。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 父类
class Node<T> {
public void setData(T data) { }
}

// 子类
class MyNode extends Node<Integer> {
@Override
public void setData(Integer data) { } // 重写
}

// 问题:擦除后父类方法签名是 setData(Object)
// 而子类方法签名是 setData(Integer)
// 这不是重写,而是重载!会破坏多态性

// 解决:编译器生成桥接方法
class MyNode extends Node {
public void setData(Integer data) { } // 真正的方法

// 桥接方法,保持与父类签名一致
public void setData(Object data) { // synthetic bridge method
setData((Integer) data);
}
}

// 可以通过反射查看:
for (Method m : MyNode.class.getDeclaredMethods()) {
System.out.println(m + " isBridge=" + m.isBridge());
}
// 输出会显示桥接方法

二、JVM内存模型与GC(35题)

🟢⭐ 5. 详细说说JVM的内存结构

面试官视角:考察对JVM运行时数据区的理解,这是JVM相关问题的基础。

详细答案

JVM运行时内存结构分为线程共享和线程私有两大类,总共包含5个主要区域(JDK 8+)。

内存结构划分

线程私有(每个线程独立):

  1. 程序计数器(Program Counter Register)
  2. 虚拟机栈(VM Stack)
  3. 本地方法栈(Native Method Stack)

线程共享(所有线程共享):
4. 堆(Heap)
5. 方法区(Method Area) - JDK 8改为元空间(Metaspace)

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
JVM运行时数据区:

+------------------------------------------------------------------+
| JVM Process |
+------------------------------------------------------------------+
| 线程1 线程2 线程3 |
| +------------+ +------------+ +------------+ |
| | 程序计数器 | | 程序计数器 | | 程序计数器 | |
| +------------+ +------------+ +------------+ |
| | 虚拟机栈 | | 虚拟机栈 | | 虚拟机栈 | |
| | +-Stack--+ | | +-Stack--+ | | +-Stack--+ | |
| | | Frame1 | | | | Frame1 | | | | Frame1 | | |
| | | Frame2 | | | | Frame2 | | | | Frame2 | | |
| | +--------+ | | +--------+ | | +--------+ | |
| +------------+ +------------+ +------------+ |
| | 本地方法栈 | | 本地方法栈 | | 本地方法栈 | |
| +------------+ +------------+ +------------+ |
+------------------------------------------------------------------+
| 线程共享区域 |
| +------------------------------------------------------------+ |
| | 堆 (Heap) | |
| | +------------------+ +----------------------------------+ | |
| | | 新生代 (Young) | | 老年代 (Old/Tenured) | | |
| | | +------+------+ | | | | |
| | | | Eden |Survivor| | | | | |
| | | | | S0| S1| | | | | |
| | | +------+------+ | | | | |
| | +------------------+ +----------------------------------+ | |
| +------------------------------------------------------------+ |
| +------------------------------------------------------------+ |
| | 方法区 (Method Area / Metaspace) | |
| | 类信息、常量、静态变量、即时编译代码等 | |
| | +----------------+ +----------------------------------+ | |
| | | 运行时常量池 | | 类元数据 (Class Metadata) | |
| | +----------------+ +----------------------------------+ | |
| +------------------------------------------------------------+ |
+------------------------------------------------------------------+
| 直接内存 (Direct Memory) |
| NIO Buffer、DirectByteBuffer (不属于JVM运行时数据区) |
+------------------------------------------------------------------+

各区域详解

1. 程序计数器(Program Counter Register)

  • 当前线程执行的字节码行号指示器
  • 记录正在执行的虚拟机字节码指令地址
  • 如果执行Native方法,计数器值为空(Undefined)
  • 唯一不会出现OOM的内存区域
  • 线程切换后能恢复到正确的执行位置

2. 虚拟机栈(VM Stack)

  • 描述Java方法执行的内存模型
  • 每个方法执行时创建一个栈帧(Stack Frame)
  • 栈帧包含:局部变量表、操作数栈、动态链接、方法出口
  • 生命周期与线程相同
  • 异常:StackOverflowError(栈深度溢出)、OutOfMemoryError(扩展失败)

3. 本地方法栈(Native Method Stack)

  • 为Native方法服务(HotSpot将其与虚拟机栈合并)
  • 调用C/C++代码时使用
  • 也会抛出StackOverflowErrorOutOfMemoryError

4. 堆(Heap)

  • JVM最大的内存区域,所有线程共享
  • 存放对象实例和数组
  • GC的主要区域(也叫”GC堆”)
  • 分为新生代和老年代
  • 异常:OutOfMemoryError: Java heap space

5. 方法区(Method Area / Metaspace)

  • 存储已被加载的类信息、常量、静态变量、JIT编译后的代码
  • JDK 7前叫永久代(PermGen),在堆中
  • JDK 8改为元空间(Metaspace),使用本地内存
  • 异常:OutOfMemoryError: Metaspace

源码分析与实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// 演示各内存区域的使用
public class MemoryDemo {
// 静态变量 -> 方法区(元空间)
private static int staticVar = 100;

// 常量 -> 方法区的运行时常量池
private static final String CONSTANT = "Hello";

// 实例变量会在对象创建时分配到堆
private int instanceVar;

public static void main(String[] args) {
// "main"线程开始执行
// 程序计数器:记录当前执行的字节码指令地址

// 局部变量 -> 虚拟机栈的栈帧中的局部变量表
int localVar = 10;

// 对象 -> 堆
// 引用 obj -> 虚拟机栈
MemoryDemo obj = new MemoryDemo();
obj.instanceVar = 20;

// 字符串字面量 -> 堆中的字符串常量池(JDK 7+)
String str = "Hello";

// 数组 -> 堆
int[] array = new int[100];

// 方法调用 -> 创建新的栈帧
obj.method(localVar);
}

public void method(int param) {
// 新的栈帧
// param参数 -> 局部变量表
int result = param * 2;

// 递归调用过深 -> StackOverflowError
// method(param);
}

// 演示内存溢出
public static void testOOM() {
// 堆溢出
// List<byte[]> list = new ArrayList<>();
// while (true) {
// list.add(new byte[1024 * 1024]); // OOM: Java heap space
// }

// 栈溢出
// recursiveCall(); // StackOverflowError

// 元空间溢出(动态生成大量类)
// while (true) {
// Enhancer enhancer = new Enhancer();
// enhancer.setSuperclass(Object.class);
// enhancer.setUseCache(false);
// enhancer.setCallback(...);
// enhancer.create(); // OOM: Metaspace
// }
}
}

// 栈帧结构示例
public class StackFrameDemo {
public int calculate(int a, int b) {
/*
栈帧结构:
+-------------------------+
| 方法返回地址 | <- 调用者的下一条指令地址
+-------------------------+
| 动态链接 | <- 指向运行时常量池的方法引用
+-------------------------+
| 操作数栈 | <- 临时存放操作数
| [empty] |
+-------------------------+
| 局部变量表 |
| [0] this | <- 实例方法的第一个位置是this
| [1] a = 5 | <- 参数a
| [2] b = 10 | <- 参数b
| [3] result | <- 局部变量
+-------------------------+
*/

int result = a + b; // 操作数栈:push a, push b, iadd, store result
return result;
}

public static void main(String[] args) {
StackFrameDemo demo = new StackFrameDemo();
int value = demo.calculate(5, 10);

// 虚拟机栈:
// +----------------+
// | calculate栈帧 | <- 当前栈顶
// +----------------+
// | main栈帧 |
// +----------------+
}
}

// 直接内存示例
public class DirectMemoryDemo {
public static void main(String[] args) {
// 使用直接内存(堆外内存)
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100); // 100MB

// 优点:减少数据拷贝,提高I/O性能
// 缺点:不受JVM GC管理,需要手动释放
// 参数:-XX:MaxDirectMemorySize=100M
}
}

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 场景1:分析对象的内存分配
public class ObjectAllocation {
class Inner {
int value;
}

public void allocate() {
// 小对象优先在Eden区分配
Object obj = new Object();

// 大对象直接进入老年代(-XX:PretenureSizeThreshold)
byte[] bigArray = new byte[5 * 1024 * 1024];

// 长期存活的对象进入老年代(-XX:MaxTenuringThreshold)
static Object longLived = new Object();
}
}

// 场景2:方法区(元空间)的使用
public class MetaspaceUsage {
// 类信息存储在元空间
static class MyClass {
// 静态变量的引用在元空间,对象在堆
static String staticField = "static";

// 常量在元空间的运行时常量池
static final String CONSTANT = "constant";

void method() {
// 方法字节码在元空间
}
}

public static void main(String[] args) {
// 动态加载类会占用元空间
ClassLoader classLoader = new MyClassLoader();
// 不断加载类可能导致 OOM: Metaspace
}
}

// 场景3:JVM参数配置
public class JVMParams {
/*
常用内存参数:

堆配置:
-Xms2g 初始堆大小
-Xmx4g 最大堆大小
-Xmn1g 新生代大小
-XX:SurvivorRatio=8 Eden:Survivor = 8:1:1
-XX:NewRatio=2 老年代:新生代 = 2:1

栈配置:
-Xss256k 每个线程的栈大小

元空间配置:
-XX:MetaspaceSize=128m 初始元空间大小
-XX:MaxMetaspaceSize=256m 最大元空间大小

直接内存配置:
-XX:MaxDirectMemorySize=1g

GC日志:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log
*/
}

常见误区

  1. 误区1:认为方法区就是永久代

    • 纠正:永久代是HotSpot对方法区的实现(JDK 7-),JDK 8改为元空间
  2. 误区2:认为所有对象都在堆上

    • 纠正:通过逃逸分析和标量替换,某些对象可能在栈上分配
  3. 误区3:认为栈只存基本类型

    • 纠正:栈存储局部变量表,包括基本类型值和对象引用
  4. 误区4:混淆堆和栈的职责

    • 纠正:栈管理方法调用和局部变量,堆管理对象实例

追问1:为什么JDK 8要用元空间替代永久代?

答:主要有以下原因:

  1. 避免OOM:永久代有固定大小上限(-XX:MaxPermSize),容易OOM。元空间使用本地内存,只受系统内存限制

  2. 简化GC:永久代需要Full GC才能回收,效率低。元空间简化了GC实现

  3. 融合HotSpot和JRockit:Oracle收购Sun后,需要融合两个JVM,JRockit从来没有永久代

  4. 类元数据的生命周期:类元数据的生命周期与类加载器一致,使用本地内存更合理

  5. 字符串常量池移出:JDK 7已将字符串常量池移到堆中,进一步减少永久代的作用

追问2:对象一定分配在堆上吗?

答:不一定。JVM通过逃逸分析优化,某些对象可能在栈上分配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 逃逸分析示例
public class EscapeAnalysis {
// 逃逸:对象被返回,逃逸到方法外 -> 堆分配
public User escape() {
User user = new User();
return user;
}

// 不逃逸:对象只在方法内使用 -> 栈分配(标量替换)
public void noEscape() {
User user = new User();
user.setName("张三");
System.out.println(user.getName());
// user不会逃逸,可能被优化为栈上分配
}

// 不逃逸:标量替换优化
public void scalarReplacement() {
Point point = new Point(1, 2);
int sum = point.x + point.y;

// 经过标量替换后,等价于:
// int x = 1;
// int y = 2;
// int sum = x + y;
// Point对象被拆解为标量,不需要创建对象
}
}

// 参数:
// -XX:+DoEscapeAnalysis 开启逃逸分析(JDK 8默认开启)
// -XX:+EliminateAllocations 开启标量替换

追问3:栈帧中的动态链接是什么?

答:动态链接是指向运行时常量池中该栈帧所属方法的引用,支持方法调用过程中的动态连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DynamicLinking {
public void methodA() {
methodB(); // 方法调用
}

public void methodB() {
System.out.println("B");
}
}

/*
字节码中的方法调用指令:
invokevirtual #2 // 指向常量池中methodB的符号引用

运行时:
1. 栈帧的动态链接指向常量池#2
2. 常量池#2存储methodB的符号引用
3. 第一次调用时解析为直接引用(方法的内存地址)
4. 后续调用直接使用解析后的直接引用(invokevirtual支持多态)

静态链接 vs 动态链接:
- 静态链接:编译期确定(invokestatic、invokespecial)
- 动态链接:运行时确定(invokevirtual、invokeinterface)
*/

🟡⭐ 6. 详细说说Java的垃圾回收机制

面试官视角:考察对GC算法、垃圾收集器、GC调优的综合理解,这是JVM最重要的知识点之一。

详细答案

Java的垃圾回收(Garbage Collection, GC)是自动内存管理机制,负责回收不再使用的对象,释放内存空间。

一、如何判断对象可以回收?

1. 引用计数法(Reference Counting)

  • 给对象添加引用计数器,引用+1,失效-1
  • 优点:实现简单,判定效率高
  • 缺点:无法解决循环引用问题
  • Java未采用此方法
1
2
3
4
循环引用问题:
objA.ref = objB;
objB.ref = objA;
// 即使没有外部引用,计数器仍不为0,无法回收

2. 可达性分析算法(Reachability Analysis)

  • Java采用的方法
  • 从GC Roots作为起点,向下搜索,路径称为引用链
  • 不可达的对象即为可回收对象

GC Roots包括

  • 虚拟机栈中引用的对象(局部变量)
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • 所有被同步锁持有的对象
  • JVM内部的引用(Class对象、异常对象、类加载器等)

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
可达性分析示例:

GC Roots:
[栈帧1] [栈帧2] [静态变量] [常量]
| | | |
v v v v
[objA] [objB] [objC] [objD]
| | |
v v +----+
[objE] [objF] |
| v
v [objG] [objH] [objI]
[objJ] |
v
[objK]

可达对象(存活):A, B, C, D, E, F, G, J, K
不可达对象(可回收):H, I

对象回收流程:
1. 第一次标记:不可达对象
2. 筛选:是否有必要执行finalize()
3. 第二次标记:finalize()中未重新关联到GC Roots的对象
4. 回收

二、垃圾回收算法

1. 标记-清除算法(Mark-Sweep)

过程

  • 标记阶段:标记所有需要回收的对象
  • 清除阶段:回收被标记的对象

优点:实现简单
缺点

  • 效率不高(两次遍历)
  • 产生内存碎片
1
2
3
4
标记-清除过程:
标记前:[obj1][____][obj2][____][obj3]
标记后:[obj1][XXXX][obj2][XXXX][obj3] (标记垃圾)
清除后:[obj1][ ][obj2][ ][obj3] (内存碎片)

2. 复制算法(Copying)

过程

  • 将内存分为两块(From和To)
  • 只使用From区域
  • GC时将存活对象复制到To区域
  • 清空From区域,交换From和To

优点

  • 无内存碎片
  • 效率高(只遍历存活对象)

缺点

  • 浪费一半内存
  • 存活对象多时效率低

应用:新生代(对象存活率低)

1
2
3
4
5
6
7
8
复制算法过程:
From区域:[obj1][garbage][obj2][garbage][obj3]
↓ (复制) ↓ ↓
To区域: [obj1][obj2][obj3][ ]

交换后:
ToFrom[obj1][obj2][obj3][可用空间 ]
FromTo[全部可用 ]

3. 标记-整理算法(Mark-Compact)

过程

  • 标记阶段:标记所有存活对象
  • 整理阶段:将存活对象向一端移动
  • 清除阶段:清除边界外的内存

优点

  • 无内存碎片
  • 不浪费空间

缺点

  • 效率较低(移动对象)
  • STW时间长

应用:老年代(对象存活率高)

1
2
3
标记-整理过程:
标记后:[obj1][XXXX][obj2][XXXX][obj3][XXXX]
整理后:[obj1][obj2][obj3][可用空间 ]

4. 分代收集算法(Generational Collection)

思想:根据对象存活周期将内存划分为新生代和老年代,采用不同算法。

新生代

  • 对象存活率低(朝生夕死)
  • 采用复制算法
  • 划分为Eden和两个Survivor(8:1:1)
  • Minor GC频繁,速度快

老年代

  • 对象存活率高
  • 采用标记-清除或标记-整理
  • Major GC/Full GC慢,但频率低
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
分代内存结构:
+------------------------------------------------------+
| 新生代 (Young Generation) | 老年代 (Old Gen) |
| +------------+-----+-----+ | |
| | Eden | S0 | S1 | | |
| | 8 | 1 | 1 | | |
| +------------+-----+-----+ | |
+------------------------------------------------------+

对象晋升流程:
1. 新对象在Eden分配
2. Eden满了触发Minor GC
3. 存活对象复制到S0
4. 下次GC,S0存活对象复制到S1(年龄+1)
5. 反复复制,年龄达到阈值(默认15)-> 晋升老年代
6. 大对象直接进入老年代

源码分析与实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// GC日志分析示例
public class GCDemo {
public static void main(String[] args) {
/*
JVM参数:
-Xms20m 初始堆20MB
-Xmx20m 最大堆20MB
-Xmn10m 新生代10MB
-XX:+PrintGCDetails 打印GC详情
-XX:SurvivorRatio=8 Eden:Survivor=8:1:1
*/

byte[] allocation1, allocation2, allocation3, allocation4;

// Eden区分配
allocation1 = new byte[2 * 1024 * 1024]; // 2MB
allocation2 = new byte[2 * 1024 * 1024]; // 2MB
allocation3 = new byte[2 * 1024 * 1024]; // 2MB

// 再分配4MB,Eden不足,触发Minor GC
allocation4 = new byte[4 * 1024 * 1024]; // 4MB

/*
GC日志示例:
[GC (Allocation Failure)
[PSYoungGen: 7127K->896K(9216K)]
7127K->6992K(19456K),
0.0032442 secs]

解读:
- GC原因:Allocation Failure(分配失败)
- PSYoungGen:新生代GC
- 7127K->896K(9216K):新生代从7127K降到896K,总容量9216K
- 7127K->6992K(19456K):整个堆从7127K到6992K,总容量19456K
- 0.0032442 secs:GC耗时3.2ms
*/
}
}

// 对象晋升示例
public class PromotionDemo {
private static final int _1MB = 1024 * 1024;

/*
JVM参数:
-Xms20m -Xmx20m -Xmn10m
-XX:+PrintGCDetails
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=1 年龄阈值为1
-XX:+PrintTenuringDistribution 打印年龄分布
*/

@SuppressWarnings("unused")
public static void testTenuringThreshold() {
byte[] allocation1, allocation2, allocation3;

allocation1 = new byte[_1MB / 4]; // 256KB
allocation2 = new byte[4 * _1MB]; // 4MB大对象,直接进老年代
allocation3 = new byte[4 * _1MB];
allocation3 = null;
allocation3 = new byte[4 * _1MB];

/*
输出:
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 671744 bytes, 671744 total

解读:
- age 1表示年龄为1的对象有671KB
- 达到MaxTenuringThreshold,下次GC会晋升到老年代
*/
}
}

// finalize()机制示例
public class FinalizeDemo {
public static FinalizeDemo SAVE_HOOK = null;

public void isAlive() {
System.out.println("I'm still alive!");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
// 自救:重新关联到GC Roots
FinalizeDemo.SAVE_HOOK = this;
}

public static void main(String[] args) throws Exception {
SAVE_HOOK = new FinalizeDemo();

// 第一次自救
SAVE_HOOK = null;
System.gc();
Thread.sleep(500); // finalize优先级低,等待执行

if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive(); // 输出:I'm still alive!
} else {
System.out.println("I'm dead!");
}

// 第二次无法自救
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);

if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("I'm dead!"); // 输出这个,finalize只执行一次
}
}
}

// 引用类型与GC
public class ReferenceGCDemo {
public static void main(String[] args) {
// 强引用:GC永不回收
Object strongRef = new Object();

// 软引用:内存不足时回收
SoftReference<byte[]> softRef = new SoftReference<>(new byte[10 * 1024 * 1024]);
System.out.println(softRef.get()); // 有对象
// 模拟内存不足
try {
byte[] bytes = new byte[20 * 1024 * 1024];
} catch (OutOfMemoryError e) {
System.out.println(softRef.get()); // null,被回收了
}

// 弱引用:GC时回收
WeakReference<byte[]> weakRef = new WeakReference<>(new byte[1024]);
System.out.println(weakRef.get()); // 有对象
System.gc();
System.out.println(weakRef.get()); // null,被回收了

// 虚引用:跟踪回收
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
PhantomReference<byte[]> phantomRef =
new PhantomReference<>(new byte[1024], queue);
System.out.println(phantomRef.get()); // null,永远为null
}
}

常见误区

  1. 误区1:认为System.gc()一定会触发GC

    • 纠正:System.gc()只是建议JVM进行GC,不保证立即执行
  2. 误区2:认为对象一定在finalize()中被回收

    • 纠正:finalize()可以让对象”自救”,重新关联到GC Roots
  3. 误区3:认为Minor GC不会STW

    • 纠正:Minor GC也会STW,只是时间很短
  4. 误区4:新生代都是复制算法

    • 纠正:不同垃圾收集器实现不同,如G1使用分区的混合算法

追问1:Minor GC、Major GC、Full GC的区别?

答:

类型 作用区域 触发条件 特点
Minor GC 新生代 Eden区满 频繁、快速、STW时间短
Major GC 老年代 老年代满(CMS触发) 慢,部分收集器有此概念
Full GC 整堆+方法区 老年代满、空间分配担保失败、System.gc()等 最慢、STW时间最长
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 触发Full GC的场景
public class FullGCTrigger {
// 1. 老年代空间不足
// 大量大对象直接进入老年代

// 2. 空间分配担保失败
// Minor GC前检查老年代最大可用连续空间
// 小于新生代所有对象总空间,触发Full GC

// 3. Metaspace空间不足
// 动态加载大量类

// 4. System.gc()
// 显式调用,但不保证执行

// 5. CMS的Concurrent Mode Failure
// CMS回收速度赶不上对象晋升速度
}

追问2:什么是空间分配担保?

答:空间分配担保是在Minor GC前的一种安全检查机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
担保机制流程:

1. Minor GC前检查:
老年代最大可用连续空间 > 新生代所有对象总空间?

2. 如果是:
Minor GC是安全的,执行Minor GC

3. 如果否:
检查参数:-XX:HandlePromotionFailure(JDK 6 Update 24后废弃)

3.1 允许担保失败:
检查:老年代最大可用连续空间 > 历次晋升到老年代对象的平均大小?
- 是:冒险尝试Minor GC
- 否:Full GC

3.2 不允许担保失败:
直接Full GC

为什么需要担保?
- Minor GC前无法确定有多少对象存活
- 最坏情况是所有对象都存活,需要晋升到老年代
- 如果老年代空间不足,需要提前Full GC腾出空间
*/

追问3:为什么新生代要有两个Survivor区?

答:这是为了避免内存碎片和提高内存利用率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
假设只有一个Survivor:
[Eden] -> GC -> [Survivor] (90%满)
[Eden] -> GC -> [Survivor] (已满,新对象只能去老年代)
问题:Survivor很快就满,对象过早晋升到老年代

使用两个Survivor:
[Eden + S0] -> GC -> [S1] (复制算法,无碎片)
[Eden + S1] -> GC -> [S0] (乒乓复制)
优点:
1. 总有一个Survivor是空的,保证复制算法正常工作
2. 无内存碎片
3. 对象在Survivor间多次复制,年龄增长,合理晋升

为什么不是Eden:S0:S1 = 1:1:1
- 研究表明,98%的对象是朝生夕死的
- 8:1:1的比例可以最大化内存利用率
- 只浪费10%的新生代空间(一个Survivor总是空的)

🟡⭐ 7. 详细说说常见的垃圾收集器及其特点

面试官视角:考察对不同垃圾收集器的理解,以及在实际场景中如何选择和调优。

详细答案

垃圾收集器是GC算法的具体实现,不同收集器适用于不同场景。Java提供了多种收集器,从Serial到最新的ZGC。

垃圾收集器发展历程

1
2
串行时代 -> 并行时代 -> 并发时代 -> 低延迟时代
Serial -> Parallel -> CMS/G1 -> ZGC/Shenandoah

一、Serial收集器(串行收集器)

特点

  • 单线程收集,收集时必须暂停所有工作线程(STW)
  • 新生代使用复制算法,老年代使用标记-整理
  • 简单高效,Client模式下的默认收集器
  • 适合单核CPU、内存较小的环境

优点:简单高效,没有线程交互开销
缺点:STW时间长,用户体验差

1
2
3
4
Serial GC工作流程:
用户线程: ████████░░░░░░░░████████
GC线程: ────────██████──────────
(暂停) (GC) (恢复)

参数-XX:+UseSerialGC

二、ParNew收集器(并行收集器)

特点

  • Serial的多线程版本
  • 新生代使用复制算法,多线程并行
  • Server模式下与CMS配合使用的首选新生代收集器
  • 默认线程数等于CPU核心数

优点:多核环境下效率高
缺点:仍然需要STW

1
2
3
4
5
6
ParNew GC工作流程:
用户线程: ████████░░░░░░░░████████
GC线程1: ────────███───────────
GC线程2: ────────███───────────
GC线程3: ────────███───────────
(同时工作,减少STW时间)

参数-XX:+UseParNewGC-XX:ParallelGCThreads=N

三、Parallel Scavenge收集器(吞吐量优先)

特点

  • 新生代收集器,复制算法,并行多线程
  • 关注吞吐量(运行用户代码时间 / 总时间)
  • 提供自适应调节策略(GC Ergonomics)
  • JDK 8默认收集器

优点:高吞吐量,适合后台计算任务
缺点:不关注停顿时间,STW可能较长

参数

  • -XX:+UseParallelGC:使用Parallel Scavenge + Serial Old
  • -XX:+UseParallelOldGC:使用Parallel Scavenge + Parallel Old
  • -XX:MaxGCPauseMillis=N:最大GC停顿时间(毫秒)
  • -XX:GCTimeRatio=N:吞吐量大小(1/(1+N))
  • -XX:+UseAdaptiveSizePolicy:自适应调节策略

四、CMS收集器(Concurrent Mark Sweep,并发标记清除)

特点

  • 老年代收集器,关注最短停顿时间
  • 标记-清除算法,并发收集
  • 适合对响应时间要求高的应用(互联网应用)

工作流程

  1. 初始标记(STW):标记GC Roots直接关联的对象,速度快
  2. 并发标记:从GC Roots遍历对象图,耗时长,与用户线程并发
  3. 重新标记(STW):修正并发标记期间变动的对象,时间较短
  4. 并发清除:清除标记为可回收的对象,与用户线程并发
1
2
3
4
5
CMS GC工作流程:
阶段: 初始 并发标记 重新 并发清除 重置
用户线程: ████░░██████████████░░████████████████████
GC线程: ────██──────────────██──────────────────
(STW) (并发) (STW) (并发)

优点:并发收集,低停顿
缺点

  1. CPU资源敏感:并发阶段占用CPU,影响吞吐量
  2. 无法处理浮动垃圾:并发标记/清除时产生的新垃圾,下次GC才能回收
  3. 内存碎片:标记-清除算法产生碎片,可能导致Full GC
  4. Concurrent Mode Failure:并发清除时老年代空间不足,退化为Serial Old

参数

  • -XX:+UseConcMarkSweepGC:启用CMS
  • -XX:CMSInitiatingOccupancyFraction=N:老年代占用N%时触发CMS(默认68%)
  • -XX:+UseCMSCompactAtFullCollection:Full GC后进行碎片整理
  • -XX:CMSFullGCsBeforeCompaction=N:N次Full GC后进行整理

五、G1收集器(Garbage First) ⭐⭐

特点

  • JDK 9默认收集器,面向服务端应用
  • 整堆收集器(不区分新生代/老年代收集器)
  • 基于Region的内存布局
  • 可预测的停顿时间模型
  • 标记-整理算法,无内存碎片

内存布局

1
2
3
4
5
6
7
8
9
G1堆结构(分Region):
+---+---+---+---+---+---+---+---+
| E | E | S | O | O | H | E | O | E=Eden, S=Survivor
+---+---+---+---+---+---+---+---+ O=Old, H=Humongous(大对象)
| O | E | O | S | E | O | E | O |
+---+---+---+---+---+---+---+---+

每个Region大小:1MB-32MB(2的幂次)
总共2048Region

核心概念

  • Region:将堆划分为多个大小相等的独立区域
  • Humongous Region:存储大对象(超过Region 50%)
  • Remembered Set(RSet):记录Region间的引用关系
  • Collection Set(CSet):一次GC回收的Region集合

工作流程

  1. 初始标记(STW):标记GC Roots直接关联的对象
  2. 并发标记:遍历对象图,标记存活对象
  3. 最终标记(STW):处理并发标记期间变化的对象
  4. 筛选回收(STW):根据停顿时间目标,选择价值最大的Region回收
1
2
3
4
5
6
G1 Mixed GC流程:
Young GC: 回收所有Eden + Survivor
Mixed GC: 回收所有Young + 部分Old(价值最高的)

价值计算:(Region中垃圾量 / 回收时间)
优先回收垃圾最多、回收时间短的Region

优点

  1. 可预测停顿时间(-XX:MaxGCPauseMillis)
  2. 无内存碎片(整理算法)
  3. 适合大堆内存(>4GB)
  4. 并发与并行结合

缺点

  1. 内存占用高(RSet占用10%-20%堆空间)
  2. 额外执行负载高(维护RSet)
  3. 小堆内存下不如CMS

参数

  • -XX:+UseG1GC:启用G1
  • -XX:MaxGCPauseMillis=200:期望最大停顿时间(默认200ms)
  • -XX:G1HeapRegionSize=N:Region大小
  • -XX:InitiatingHeapOccupancyPercent=45:堆占用45%时启动并发标记

六、ZGC收集器(Z Garbage Collector) ⭐⭐⭐

特点

  • JDK 11引入,JDK 15转正
  • 超低延迟收集器,停顿时间<10ms
  • 支持TB级别堆内存
  • 并发收集,几乎全程并发
  • 基于Region,但动态大小
  • 使用着色指针(Colored Pointer)和读屏障(Load Barrier)

核心技术

  1. 着色指针(Colored Pointer)
1
2
3
4
5
6
7
64位对象指针布局:
+--------+--------+--------+--------+
| Unused | Marked | Remap | Object |
| 16 bit | 4 bit | 4 bit | 40 bit |
+--------+--------+--------+--------+

元数据存储在指针中,不在对象头
  1. 读屏障
  • 在从堆中读取对象引用时插入代码
  • 判断对象是否被移动,自动修正
  • 实现并发移动对象

工作流程

  1. 初始标记(STW,<1ms):标记GC Roots
  2. 并发标记:遍历对象图
  3. 再标记(STW,<1ms):处理标记队列
  4. 并发转移准备:选择要回收的Region
  5. 初始转移(STW,<1ms):转移GC Roots直接引用的对象
  6. 并发转移:转移其他对象

优点

  • 停顿时间极短(<10ms)
  • 支持超大堆内存(最大16TB)
  • 吞吐量损失小(<15%)

缺点

  • 内存占用高
  • 对CPU要求高(需要额外线程)

参数

  • -XX:+UseZGC:启用ZGC
  • -Xmx16g:设置最大堆(ZGC不需要-Xms)

七、垃圾收集器组合与选择

经典组合

1
2
3
4
5
6
新生代          老年代
Serial + Serial Old (单核、小内存)
ParNew + CMS (响应时间优先)
Parallel + Parallel Old (吞吐量优先)
G1 (不区分新老年代) (大堆、可预测停顿)
ZGC (不区分新老年代) (超低延迟)

选择建议

场景 推荐收集器 理由
单核CPU、内存<100MB Serial 简单高效
多核CPU、后台计算任务 Parallel 高吞吐量
互联网应用、响应时间敏感 G1/CMS 低停顿
大堆内存(>32GB) G1 可预测停顿
超低延迟要求(<10ms) ZGC/Shenandoah 极低停顿
金融交易系统 ZGC 停顿时间稳定

源码分析与实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// GC收集器对比测试
public class GCComparison {
/*
测试参数配置:

1. Serial GC:
-XX:+UseSerialGC -Xms1g -Xmx1g -XX:+PrintGCDetails

2. Parallel GC:
-XX:+UseParallelGC -Xms1g -Xmx1g -XX:+PrintGCDetails
-XX:MaxGCPauseMillis=100

3. CMS:
-XX:+UseConcMarkSweepGC -Xms1g -Xmx1g -XX:+PrintGCDetails
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly

4. G1:
-XX:+UseG1GC -Xms1g -Xmx1g -XX:+PrintGCDetails
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=4m

5. ZGC:
-XX:+UseZGC -Xms1g -Xmx1g -XX:+PrintGCDetails
*/

public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();

// 持续分配对象,观察GC行为
for (int i = 0; i < 1000; i++) {
byte[] bytes = new byte[1024 * 100]; // 100KB
list.add(bytes);

if (i % 100 == 0) {
System.out.println("Allocated: " + i + " objects");
}
}

// 清理一半对象
for (int i = 0; i < list.size() / 2; i++) {
list.remove(0);
}

System.gc(); // 建议GC

try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

// CMS Concurrent Mode Failure示例
public class CMSFailureDemo {
/*
参数:
-XX:+UseConcMarkSweepGC
-Xms20m -Xmx20m
-XX:CMSInitiatingOccupancyFraction=70
-XX:+PrintGCDetails
*/

public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();

// 快速分配对象,导致CMS回收速度跟不上
for (int i = 0; i < 100; i++) {
byte[] bytes = new byte[1024 * 200]; // 200KB
list.add(bytes);

// 模拟对象快速晋升到老年代
if (i % 10 == 0) {
System.gc();
}
}

/*
可能出现的日志:
[Full GC (Allocation Failure)
[CMS: 8192K->8192K(10240K), 0.0234567 secs]
18432K->18432K(20480K),
[Metaspace: 3456K->3456K(1056768K)],
0.0245678 secs]
[CMS Concurrent Mode Failure]

原因:
1. 老年代空间不足
2. CMS并发清理速度 < 对象晋升速度
3. 触发Full GC,退化为Serial Old
*/
}
}

// G1调优示例
public class G1TuningDemo {
/*
G1调优参数:

基础配置:
-XX:+UseG1GC
-Xms4g -Xmx4g
-XX:MaxGCPauseMillis=200

高级配置:
-XX:G1HeapRegionSize=4m Region大小
-XX:G1NewSizePercent=5 新生代最小占比
-XX:G1MaxNewSizePercent=60 新生代最大占比
-XX:G1ReservePercent=10 保留空间比例
-XX:InitiatingHeapOccupancyPercent=45 并发标记阈值
-XX:ConcGCThreads=2 并发GC线程数
-XX:ParallelGCThreads=8 并行GC线程数

日志配置:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintAdaptiveSizePolicy
-Xloggc:gc.log
*/

public static void main(String[] args) {
// 模拟大堆应用
Map<String, byte[]> cache = new ConcurrentHashMap<>();

for (int i = 0; i < 10000; i++) {
String key = "key_" + i;
byte[] value = new byte[1024 * 10]; // 10KB
cache.put(key, value);

// 定期清理
if (i % 1000 == 0 && i > 0) {
cache.clear();
System.out.println("Cleared cache at: " + i);
}
}
}
}

// ZGC使用示例
public class ZGCDemo {
/*
ZGC参数配置:

-XX:+UseZGC
-Xms16g -Xmx16g
-XX:ConcGCThreads=4
-XX:ZCollectionInterval=120 最小GC间隔(秒)
-XX:ZAllocationSpikeTolerance=2 分配速率容忍度
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintGCDetails
-Xlog:gc*:file=gc.log

适用场景:
1. 大堆内存(>32GB)
2. 低延迟要求(<10ms)
3. 吞吐量损失可接受(<15%)
*/

public static void main(String[] args) throws InterruptedException {
List<byte[]> list = new ArrayList<>();

while (true) {
// 持续分配
for (int i = 0; i < 1000; i++) {
byte[] bytes = new byte[1024 * 100];
list.add(bytes);
}

// 定期清理
if (list.size() > 10000) {
list.subList(0, 5000).clear();
}

Thread.sleep(10);
}
}
}

常见误区

  1. 误区1:认为CMS没有STW

    • 纠正:CMS的初始标记和重新标记阶段仍需STW,只是时间很短
  2. 误区2:G1适合所有场景

    • 纠正:小堆内存(<4GB)下,G1不如CMS或Parallel
  3. 误区3:MaxGCPauseMillis设置越小越好

    • 纠正:设置过小会导致GC频繁,吞吐量下降
  4. 误区4:认为ZGC没有性能损耗

    • 纠正:ZGC会降低约15%的吞吐量,需要权衡

追问1:CMS的Concurrent Mode Failure如何避免?

答:Concurrent Mode Failure发生在CMS并发清理时老年代空间不足,解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
1. 降低触发阈值,提前GC
-XX:CMSInitiatingOccupancyFraction=70 (默认92,太高)
-XX:+UseCMSInitiatingOccupancyOnly (只用这个阈值)

2. 增大老年代空间
-Xmx8g (增大堆内存)
-XX:NewRatio=2 (调整新老年代比例)

3. 减少对象晋升到老年代
-XX:MaxTenuringThreshold=15 (增大晋升年龄)
-XX:PretenureSizeThreshold=1m (大对象阈值)

4. 优化代码,减少对象创建
- 使用对象池
- 减少大对象分配
- 及时清理无用引用

5. 考虑使用G1替代CMS
G1的Mixed GC可以更灵活地回收老年代
*/

追问2:G1的RSet是什么,为什么需要它?

答:RSet(Remembered Set)是G1用来记录Region间引用关系的数据结构,解决跨Region引用问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
问题背景:
G1将堆分为多个Region,回收某个Region时,需要判断其中对象是否存活
如果其他Region有引用指向这个Region,就需要扫描所有Region(效率低)

RSet的作用:
每个Region维护一个RSet,记录"谁引用了我"
回收Region时,只需扫描它的RSet,不用扫描整个堆

RSet结构:
Region A的RSet:
+-----------------------+
| Region B -> Obj1 | B中有对象引用A
| Region D -> Obj5 | D中有对象引用A
+-----------------------+

更新时机:
通过写屏障(Write Barrier)拦截引用赋值操作
obj1.field = obj2; // 如果obj1和obj2在不同Region,更新RSet

代价:
- 空间开销:RSet占用10%-20%堆空间
- 时间开销:每次引用赋值都要更新RSet
- 这是G1实现可预测停顿的代价
*/

追问3:ZGC的着色指针和读屏障如何实现并发移动对象?

答:这是ZGC最核心的技术,实现几乎全程并发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/*
着色指针(Colored Pointer):
64位对象指针:
+--------+--------+--------+--------+
| Unused | Marked0| Marked1| Remap | Object Address
| 16 bit | 1 bit | 1 bit | 1 bit | 43 bit
+--------+--------+--------+--------+

三色标记:
- Marked0/Marked1:标记阶段使用(双缓冲)
- Remap:是否需要重定位

工作流程:

1. 并发标记阶段:
扫描对象,设置Marked位

2. 并发转移阶段:
将对象从from-region复制到to-region
但不立即更新所有引用(这是关键!)

3. 读屏障(Load Barrier):
每次从堆中读取对象引用时:

Object obj = field.ref;

编译器自动插入代码:
if (ref.needRemap()) { // 检查Remap位
ref = forwardingTable[ref]; // 查转发表,获取新地址
field.ref = ref; // 更新引用(自愈)
}
return ref;

优势:
- 并发移动对象,不需要STW
- 引用自愈:首次访问时自动更新
- 停顿时间不受堆大小和对象数量影响

示例:
线程A正在移动对象obj1: 0x1000 -> 0x2000
线程B访问obj1:
1. 读取引用:0x1000
2. 读屏障检测到需要remap
3. 查转发表:0x1000 -> 0x2000
4. 更新本地引用为0x2000
5. 返回正确的对象

无需等待所有引用更新完成,GC即可继续!
*/

🟢⭐ 8. 说说Java类加载机制

面试官视角:考察对类生命周期、双亲委派模型、类加载器的理解,以及热部署等实际应用。

详细答案

Java类加载机制是指将.class文件加载到JVM内存,并转换为Class对象的过程。这是Java动态语言特性的基础。

一、类的生命周期

类从被加载到JVM内存开始,到卸载出内存为止,完整生命周期包括7个阶段:

1
2
3
4
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
(Loading -> Linking(Verification -> Preparation -> Resolution) -> Initialization -> Using -> Unloading)

其中验证、准备、解析统称为连接(Linking)

1. 加载(Loading)

JVM需要完成以下工作:

  • 通过类的全限定名获取类的二进制字节流
  • 将字节流代表的静态结构转换为方法区的运行时数据结构
  • 在内存中生成代表这个类的java.lang.Class对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 类加载的触发时机:
// 1. new关键字实例化对象
User user = new User();

// 2. 访问静态字段或方法
User.staticField;
User.staticMethod();

// 3. 反射调用
Class.forName("com.example.User");

// 4. 初始化子类时,会先加载父类
class Child extends Parent { } // 加载Child时先加载Parent

// 5. JVM启动时加载的主类
public static void main(String[] args) { }

2. 验证(Verification)

确保Class文件的字节流符合JVM规范,保证安全性:

  • 文件格式验证:魔数(0xCAFEBABE)、版本号等
  • 元数据验证:语义分析,如类是否有父类、final类是否被继承等
  • 字节码验证:数据流和控制流分析,确保程序语义合法
  • 符号引用验证:确保解析动作能正常执行

3. 准备(Preparation)

为类的静态变量分配内存并设置初始值(零值):

1
2
3
4
5
6
7
8
9
public class PrepareDemo {
private static int value = 123; // 准备阶段:value = 0
// 初始化阶段:value = 123

private static final int CONSTANT = 456; // 准备阶段:CONSTANT = 456
// final常量在准备阶段直接赋值

private int instanceVar = 789; // 实例变量在对象创建时分配,不在准备阶段
}

4. 解析(Resolution)

将常量池中的符号引用替换为直接引用:

  • 符号引用:用字符串描述,如com/example/User
  • 直接引用:直接指向目标的指针、偏移量或句柄

5. 初始化(Initialization)

执行类构造器<clinit>()方法,这是类加载的最后一步:

  • <clinit>()由编译器自动生成,包含所有静态变量赋值和静态代码块
  • <clinit>()保证父类的<clinit>()先执行
  • JVM保证<clinit>()在多线程环境下正确同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class InitDemo {
static {
System.out.println("静态代码块1");
}

private static int a = 1;

static {
System.out.println("静态代码块2");
a = 2;
}

private static int b = func();

private static int func() {
System.out.println("静态方法");
return 3;
}

// 编译器生成的<clinit>()等价于:
// static <clinit>() {
// System.out.println("静态代码块1");
// a = 1;
// System.out.println("静态代码块2");
// a = 2;
// b = func();
// }
}

二、类加载器(ClassLoader)

Java提供了三层类加载器,以及可以自定义类加载器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
类加载器层次:

Bootstrap ClassLoader (C++实现)
加载<JAVA_HOME>/lib

|
Extension ClassLoader
加载<JAVA_HOME>/lib/ext

|
Application ClassLoader
加载classpath

|
Custom ClassLoader
自定义加载

1. 启动类加载器(Bootstrap ClassLoader)

  • C++实现,JVM的一部分
  • 加载<JAVA_HOME>/lib目录的核心类库(rt.jar等)
  • 无法被Java程序直接引用

2. 扩展类加载器(Extension ClassLoader)

  • Java实现,sun.misc.Launcher$ExtClassLoader
  • 加载<JAVA_HOME>/lib/ext目录的类库
  • 开发者可以直接使用

3. 应用程序类加载器(Application ClassLoader)

  • Java实现,sun.misc.Launcher$AppClassLoader
  • 加载classpath上的类库
  • 程序默认的类加载器

三、双亲委派模型(Parent Delegation Model) ⭐⭐

工作原理
当类加载器收到类加载请求时:

  1. 不会自己先加载,而是委派给父加载器
  2. 父加载器无法加载时,才由子加载器加载
  3. 最终委派到Bootstrap ClassLoader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
双亲委派流程:

自定义ClassLoader
(委派)
AppClassLoader
(委派)
ExtClassLoader
(委派)
BootstrapClassLoader
| (尝试加载)
(加载失败)
ExtClassLoader
| (尝试加载)
(加载失败)
AppClassLoader
| (尝试加载)
(加载失败)
自定义ClassLoader
| (自己加载)

优势

  1. 避免类的重复加载:父加载器已加载的类,子加载器不会再加载
  2. 保护核心类库:防止核心API被篡改(如自定义java.lang.String会加载失败)

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// ClassLoader.loadClass()源码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 委派给父加载器
c = parent.loadClass(name, false);
} else {
// 3. 父加载器为null,委派给Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载
}

if (c == null) {
// 4. 父加载器无法加载,自己加载
long t1 = System.nanoTime();
c = findClass(name); // 调用子类重写的findClass

// 统计信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
// 5. 解析类
resolveClass(c);
}
return c;
}
}

// 自定义类加载器
public class MyClassLoader extends ClassLoader {
private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 1. 读取类文件字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}

// 2. 调用defineClass将字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
} catch (IOException e) {
throw new ClassNotFoundException("Could not load class: " + name, e);
}
}

private byte[] loadClassData(String className) throws IOException {
// 将类名转换为文件路径
String fileName = classPath + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";

// 读取文件
try (InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = ins.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
}
}
}

// 使用自定义类加载器
public class CustomClassLoaderDemo {
public static void main(String[] args) throws Exception {
MyClassLoader loader = new MyClassLoader("D:/classes");

// 加载类
Class<?> clazz = loader.loadClass("com.example.MyClass");

// 创建实例
Object obj = clazz.newInstance();

// 调用方法(通过反射)
Method method = clazz.getMethod("sayHello");
method.invoke(obj);

// 验证类加载器
System.out.println("ClassLoader: " + clazz.getClassLoader());
System.out.println("Parent: " + clazz.getClassLoader().getParent());
}
}

实战场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// 场景1:打破双亲委派模型 - SPI机制
// JDBC驱动加载
public class JDBCDemo {
/*
问题:
DriverManager由Bootstrap ClassLoader加载(rt.jar)
但驱动实现类(如MySQL Driver)在classpath,由App ClassLoader加载
Bootstrap ClassLoader无法加载App ClassLoader的类!

解决:线程上下文类加载器(Thread Context ClassLoader)
*/

public static void main(String[] args) throws Exception {
// DriverManager使用Thread.currentThread().getContextClassLoader()
// 获取App ClassLoader来加载驱动
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/db", "user", "password");

// DriverManager源码:
// ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(
// Driver.class,
// Thread.currentThread().getContextClassLoader() // 使用线程上下文加载器
// );
}
}

// 场景2:热部署 - Tomcat类加载器
/*
Tomcat类加载器结构:

Bootstrap ClassLoader

Extension ClassLoader

System ClassLoader

Common ClassLoader (Tomcat共享类)
↑ ↑
Catalina Shared (Tomcat专用 / Web应用共享)

WebApp ClassLoader (每个Web应用独立)

Jsp ClassLoader (每个JSP独立,支持热加载)

特点:
1. WebApp ClassLoader打破双亲委派:
优先加载WEB-INF/classes和WEB-INF/lib
避免不同应用的类冲突

2. Jsp ClassLoader支持热加载:
JSP修改后,废弃旧的ClassLoader,创建新的加载新JSP
*/

// 场景3:OSGi - 完全打破双亲委派
/*
OSGi实现模块化:
- 每个Bundle有自己的ClassLoader
- 类查找流程:
1. 检查是否是java.*开头(委派给父加载器)
2. 检查是否在Import-Package中(委派给导出该包的Bundle)
3. 查找自己的Bundle ClassPath
4. 检查是否是Fragment Bundle

完全不遵循双亲委派,实现模块间的隔离和依赖管理
*/

// 场景4:类隔离 - 解决jar包冲突
public class ClassIsolationDemo {
public static void main(String[] args) throws Exception {
// 项目依赖两个版本的库:fastjson 1.2.47 和 1.2.68
// 使用不同的ClassLoader隔离

MyClassLoader loader1 = new MyClassLoader("lib/fastjson-1.2.47.jar");
MyClassLoader loader2 = new MyClassLoader("lib/fastjson-1.2.68.jar");

Class<?> class1 = loader1.loadClass("com.alibaba.fastjson.JSON");
Class<?> class2 = loader2.loadClass("com.alibaba.fastjson.JSON");

System.out.println(class1 == class2); // false,不同ClassLoader加载的是不同的类

// 注意:类的唯一性 = 类本身 + 加载它的ClassLoader
}
}

// 场景5:类的卸载
public class ClassUnloadDemo {
/*
类卸载的条件(同时满足):
1. 该类的所有实例都已被GC
2. 加载该类的ClassLoader已被GC
3. 该类的java.lang.Class对象没有被引用

BootstrapClassLoader、ExtClassLoader、AppClassLoader加载的类
几乎不会被卸载(这些ClassLoader的生命周期与JVM一致)

只有自定义ClassLoader加载的类可能被卸载
*/

public static void main(String[] args) throws Exception {
MyClassLoader loader = new MyClassLoader("D:/classes");
Class<?> clazz = loader.loadClass("com.example.MyClass");
Object obj = clazz.newInstance();

// 使用完毕
obj = null;
clazz = null;
loader = null; // 释放ClassLoader

System.gc(); // 建议GC,可能会卸载MyClass

// 通过-verbose:class查看类加载/卸载日志
// [Unloading class com.example.MyClass]
}
}

常见误区

  1. 误区1:认为双亲委派模型不可打破

    • 纠正:可以重写loadClass()方法打破,但需要谨慎
  2. 误区2:Class.forName()和ClassLoader.loadClass()相同

    • 纠正:Class.forName()会执行类的初始化,loadClass()不会
  3. 误区3:同一个类只会被加载一次

    • 纠正:不同ClassLoader可以加载同一个类,产生不同的Class对象
  4. 误区4:认为父类加载器是子类加载器的父类

    • 纠正:是逻辑上的父子关系(组合),不是继承关系

追问1:为什么要打破双亲委派模型?

答:某些场景下双亲委派模型无法满足需求:

  1. SPI机制:父加载器需要加载子加载器路径中的类(如JDBC驱动)

  2. 热部署:需要重新加载类而不影响其他模块

  3. 代码隔离:不同模块使用不同版本的库(如Tomcat的WebApp)

  4. 插件化架构:动态加载/卸载插件(如OSGi、Eclipse)

  5. 实现方式

    • 重写loadClass()方法,改变委派逻辑
    • 使用线程上下文类加载器
    • 使用自定义类加载器架构

追问2:Class.forName()和ClassLoader.loadClass()的区别?

答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Class.forName() - 会初始化类
Class<?> clazz1 = Class.forName("com.example.MyClass");
// 等价于:
Class<?> clazz1 = Class.forName("com.example.MyClass", true,
Thread.currentThread().getContextClassLoader());
// 第二个参数true表示初始化类(执行<clinit>)

// ClassLoader.loadClass() - 不会初始化类
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> clazz2 = loader.loadClass("com.example.MyClass");
// 只执行加载、验证、准备、解析,不执行初始化

// 区别示例:
public class InitTest {
static {
System.out.println("InitTest被初始化");
}
}

Class.forName("InitTest"); // 输出:InitTest被初始化
loader.loadClass("InitTest"); // 无输出

// 应用场景:
// JDBC驱动注册需要初始化:
Class.forName("com.mysql.jdbc.Driver"); // 触发static块注册驱动

// Spring懒加载不需要初始化:
loader.loadClass("com.example.Bean"); // 延迟初始化

追问3:如何实现热部署/热加载?

答:热部署的核心是废弃旧的ClassLoader,创建新的ClassLoader加载新版本的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class HotDeployDemo {
private static MyClassLoader loader;
private static Class<?> clazz;
private static long lastModified;

public static void main(String[] args) throws Exception {
String className = "com.example.HotClass";
String classPath = "D:/classes";

while (true) {
long currentModified = getClassFileLastModified(className, classPath);

// 检测文件是否修改
if (currentModified != lastModified) {
System.out.println("检测到类文件变化,重新加载...");

// 1. 废弃旧的ClassLoader(和它加载的所有类)
loader = null;
clazz = null;
System.gc(); // 建议GC卸载旧类

// 2. 创建新的ClassLoader
loader = new MyClassLoader(classPath);

// 3. 加载新版本的类
clazz = loader.loadClass(className);

lastModified = currentModified;
}

// 4. 使用新版本的类
Object obj = clazz.newInstance();
Method method = clazz.getMethod("execute");
method.invoke(obj);

Thread.sleep(5000); // 每5秒检测一次
}
}

private static long getClassFileLastModified(String className, String classPath) {
String filePath = classPath + File.separator
+ className.replace('.', File.separatorChar) + ".class";
return new File(filePath).lastModified();
}
}

/*
热部署vs热加载:

热部署(Hot Deployment):
- 重启应用或模块
- 更彻底,可以修改类结构
- 示例:Tomcat重启Web应用

热加载(Hot Swap):
- 不重启,动态替换类
- 限制较多,通常只能修改方法体
- 示例:JVM的HotSwap、JRebel
- 使用JVMTI(JVM Tool Interface)实现

IDEA/Eclipse的Debug热加载:
在断点处修改代码,重新编译,JVM自动替换方法体
限制:不能修改类结构(字段、方法签名)
*/

三、并发编程(35题)

🟢⭐ 9. 说说synchronized的实现原理

面试官视角:考察对Java锁机制的理解,synchronized是并发编程的基础。

详细答案

synchronized是Java提供的内置锁机制,用于实现线程同步,保证同一时刻只有一个线程执行被synchronized修饰的代码块或方法。

一、使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedDemo {
// 1. 修饰实例方法 - 锁是this对象
public synchronized void method1() {
// 同步代码
}

// 2. 修饰静态方法 - 锁是Class对象
public static synchronized void method2() {
// 同步代码
}

// 3. 修饰代码块 - 锁是指定对象
public void method3() {
synchronized (this) {
// 同步代码
}
}

private final Object lock = new Object();
public void method4() {
synchronized (lock) {
// 同步代码
}
}
}

二、底层实现原理

1. 字节码层面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class BytecodeDemo {
public void syncBlock() {
synchronized (this) {
System.out.println("sync block");
}
}
}

// 字节码(javap -c BytecodeDemo):
public void syncBlock();
Code:
0: aload_0 // 加载this
1: dup // 复制栈顶(两次this)
2: astore_1 // 存储引用到局部变量
3: monitorenter // 进入监视器(获取锁)
4: getstatic #2 // System.out
7: ldc #3 // "sync block"
9: invokevirtual #4 // println
12: aload_1 // 加载锁对象
13: monitorexit // 退出监视器(释放锁)
14: goto 22
17: astore_2 // 异常处理
18: aload_1
19: monitorexit // 异常时也要释放锁
20: aload_2
21: athrow
22: return

// 关键指令:
// monitorenter:获取对象的监视器锁
// monitorexit:释放对象的监视器锁(正常和异常都会执行)

2. 对象头结构

synchronized的锁信息存储在Java对象头中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Java对象内存布局:
+-------------------+
| 对象头 (Header) |
+-------------------+
| 实例数据 (Data) |
+-------------------+
| 对齐填充 (Padding)|
+-------------------+

对象头结构(64位JVM):
+----------------------------------------------------+
| Mark Word (8 bytes) | Klass Pointer (8 bytes) |
+----------------------------------------------------+
| 锁状态、GC信息、hashCode | 指向类元数据的指针 |
+----------------------------------------------------+

Mark Word的不同状态(64位):
+----------------------------------------------------------------------+
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
| | unused | hashcode |unused | age | biased| lock |
+----------------------------------------------------------------------+
| 无锁 | 未使用 | hashcode | 0 | age | 0 | 01 |
| 偏向锁 | ThreadID | Epoch | 0 | age | 1 | 01 |
| 轻量级锁 | 指向栈中Lock Record的指针 | 00 |
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
| GC标记 | 空 | 11 |
+----------------------------------------------------------------------+

锁标志位(lock):
00 - 轻量级锁
01 - 无锁或偏向锁(根据biased位区分)
10 - 重量级锁
11 - GC标记

三、锁升级机制 ⭐⭐

synchronized采用锁升级策略,从偏向锁 -> 轻量级锁 -> 重量级锁,避免不必要的性能开销。

1
2
3
4
锁升级流程:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
(只升级不降级)

1. 偏向锁(Biased Locking)

思想:大多数情况下,锁不存在多线程竞争,且总是由同一线程多次获取。

原理

  • 第一个线程获取锁时,在对象头记录线程ID
  • 该线程再次进入,只需检查线程ID是否匹配,无需CAS操作
  • 其他线程竞争时,撤销偏向锁,升级为轻量级锁
1
2
3
4
5
6
7
// 偏向锁流程
1. 检查对象头的Mark Word是否为可偏向状态(biased=1, lock=01
2. 如果是,检查ThreadID是否为当前线程:
- 是:直接进入同步块
- 否:通过CAS尝试将ThreadID改为当前线程
- 成功:获得偏向锁
- 失败:说明有竞争,撤销偏向锁,升级为轻量级锁

优点:只有第一次获取锁需要CAS,后续无需任何同步操作,性能极高
缺点:如果有多线程竞争,偏向锁的撤销代价较大

参数

  • -XX:+UseBiasedLocking:开启偏向锁(JDK 6-14默认开启,JDK 15废弃)
  • -XX:BiasedLockingStartupDelay=0:JVM启动后立即开启偏向锁

2. 轻量级锁(Lightweight Locking)

思想:线程交替执行同步块,不存在实际竞争。

原理

  • 线程在栈帧中创建Lock Record,存储对象头的Mark Word拷贝
  • 使用CAS将对象头的Mark Word替换为指向Lock Record的指针
  • 获取失败则自旋重试,自旋一定次数后升级为重量级锁
1
2
3
4
5
6
7
8
// 轻量级锁流程
1. 在当前线程的栈帧中创建Lock Record
2. 将对象头的Mark Word复制到Lock Record中(Displaced Mark Word)
3. 使用CAS尝试将对象头的Mark Word替换为指向Lock Record的指针
- 成功:获得轻量级锁
- 失败:检查Mark Word是否指向当前线程的栈帧
- 是:重入锁
- 否:有其他线程竞争,膨胀为重量级锁

优点:避免重量级锁的开销,适合线程交替执行的场景
缺点:自旋消耗CPU,不适合锁持有时间长或并发度高的场景

3. 重量级锁(Heavyweight Locking)

思想:基于操作系统的互斥量(Mutex)实现,竞争失败的线程阻塞。

原理

  • 对象头的Mark Word指向Monitor对象
  • Monitor包含Owner、EntryList、WaitSet等
  • 线程竞争锁失败后进入EntryList阻塞,等待被唤醒
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Monitor结构:
+----------------------------+
| Monitor |
+----------------------------+
| Owner: 持有锁的线程 |
| EntryList: 等待锁的线程队列 |
| WaitSet: wait()的线程队列 |
| count: 重入次数 |
+----------------------------+

线程状态转换:
新建线程 -> EntryList(阻塞) -> Owner(运行)
↑ ↓
+-- wait() --> WaitSet(等待)
notify()

优点:支持阻塞/唤醒机制,避免空自旋浪费CPU
缺点:需要用户态和内核态切换,开销大(10-100倍于无锁操作)

源码分析与实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// 锁升级演示
public class LockUpgradeDemo {
private static final Object lock = new Object();

public static void main(String[] args) throws Exception {
// JVM参数:
// -XX:+UseBiasedLocking
// -XX:BiasedLockingStartupDelay=0
// -XX:+PrintBiasedLockingStatistics

// 1. 偏向锁
System.out.println("=== 偏向锁 ===");
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}

// 2. 轻量级锁(多线程交替执行)
System.out.println("=== 轻量级锁 ===");
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("T1: " + ClassLayout.parseInstance(lock).toPrintable());
}
});
t1.start();
t1.join();

// 主线程再次获取
synchronized (lock) {
System.out.println("Main: " + ClassLayout.parseInstance(lock).toPrintable());
}

// 3. 重量级锁(多线程竞争)
System.out.println("=== 重量级锁 ===");
Thread t2 = new Thread(() -> {
synchronized (lock) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

Thread t3 = new Thread(() -> {
synchronized (lock) {
System.out.println("T3: " + ClassLayout.parseInstance(lock).toPrintable());
}
});

t2.start();
Thread.sleep(100); // 确保t2先获取锁
t3.start(); // t3竞争,升级为重量级锁

t2.join();
t3.join();
}
}

// 锁消除优化
public class LockEliminationDemo {
/*
JIT编译器会进行逃逸分析,如果锁对象不会逃逸到方法外,
编译器会消除锁,提升性能。

参数:-XX:+EliminateLocks(默认开启)
*/

public void method() {
Object lock = new Object(); // 局部变量,不逃逸
synchronized (lock) {
// JIT会消除这个锁
// 因为lock不可能被其他线程访问
}
}

// 经典案例:StringBuffer
public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // 局部变量
sb.append(s1); // append方法有synchronized
sb.append(s2); // 但JIT会消除锁
return sb.toString();
}
}

// 锁粗化优化
public class LockCoarseningDemo {
/*
如果一系列操作反复对同一个对象加锁/解锁,
JIT会将锁的范围扩大,减少加锁/解锁次数。

参数:-XX:+EliminateLocks
*/

private final Object lock = new Object();

public void method() {
// 优化前:
synchronized (lock) {
// 操作1
}
synchronized (lock) {
// 操作2
}
synchronized (lock) {
// 操作3
}

// 优化后(JIT自动):
// synchronized (lock) {
// // 操作1
// // 操作2
// // 操作3
// }
}
}

// 自适应自旋
public class AdaptiveSpinningDemo {
/*
JDK 6引入自适应自旋:
- 如果自旋成功,下次自旋次数增加
- 如果自旋失败,下次自旋次数减少
- 动态调整,平衡CPU消耗和响应时间

参数:
-XX:+UseSpinning(JDK 6默认开启,JDK 7+废弃,永久开启)
-XX:PreBlockSpin=10(JDK 6的固定自旋次数)
*/

private final Object lock = new Object();

public void method() {
synchronized (lock) {
// 如果竞争,会先自旋,而不是立即阻塞
// 自旋次数动态调整
}
}
}

常见误区

  1. 误区1:认为synchronized性能很差

    • 纠正:JDK 6后锁优化(偏向锁、轻量级锁),性能大幅提升,某些场景不逊于ReentrantLock
  2. 误区2:认为锁会降级

    • 纠正:锁只能升级,不能降级(偏向锁->轻量级锁->重量级锁)
  3. 误区3:认为synchronized不可中断

    • 纠正:确实不可中断,但可以通过wait/notify配合实现类似效果
  4. 误区4:滥用synchronized(this)

    • 纠正:可能导致死锁或性能问题,建议使用私有final Object lock

追问1:synchronized和ReentrantLock的区别?

答:

特性 synchronized ReentrantLock
实现层面 JVM内置关键字,C++实现 JDK层面的类,Java实现
锁类型 非公平锁(JDK 6后优化) 可选公平锁/非公平锁
可中断 不可中断 lockInterruptibly()可中断
超时获取锁 不支持 tryLock(timeout)支持
条件变量 单个wait/notify 多个Condition
锁释放 自动释放(异常也会释放) 手动释放,finally中unlock
性能 JDK 6后性能接近 高并发下略优
使用难度 简单,不易出错 复杂,需手动释放
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 选择建议:
// 1. 优先使用synchronized(简单、安全、JIT优化好)
// 2. 需要高级功能时使用ReentrantLock:
// - 可中断锁
// - 超时获取锁
// - 公平锁
// - 多个条件变量

// ReentrantLock示例
ReentrantLock lock = new ReentrantLock(true); // 公平锁
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) { // 超时获取
try {
// 临界区
} finally {
lock.unlock(); // 必须手动释放
}
}
} catch (InterruptedException e) {
// 可中断
}

追问2:什么是锁消除和锁粗化?

答:这是JIT编译器的两种锁优化技术:

锁消除(Lock Elimination)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 示例1:局部变量的锁
public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // 局部变量,不逃逸
sb.append(s1); // StringBuffer.append()有synchronized
sb.append(s2); // 但编译器会消除这些锁
return sb.toString();
}

// 原理:
// 1. 逃逸分析:sb不会逃逸到方法外
// 2. 不可能被其他线程访问
// 3. 消除synchronized,提升性能

// 示例2:对比
public String concat2(String s1, String s2) {
StringBuilder sb = new StringBuilder(); // 无锁版本
sb.append(s1); // 性能与上面经过锁消除优化的StringBuffer相同
sb.append(s2);
return sb.toString();
}

锁粗化(Lock Coarsening)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 示例:循环中的锁
public void append(List<String> list) {
// 优化前:
for (String s : list) {
synchronized (lock) { // 每次循环都加锁/解锁
sb.append(s);
}
}

// 优化后(JIT自动):
synchronized (lock) { // 将锁移到循环外
for (String s : list) {
sb.append(s);
}
}
}

// 原理:
// 1. 检测到连续的加锁/解锁操作
// 2. 将锁的范围扩大,减少开销
// 3. 权衡:锁粗化后持有锁的时间变长

追问3:为什么JDK 15废弃了偏向锁?

答:偏向锁被废弃的原因:

  1. 现代应用多线程化:偏向锁假设锁总是由单线程访问,但现代应用广泛使用线程池、并发框架,这个假设不再成立

  2. 维护成本高:偏向锁的撤销过程复杂,需要STW,反而影响性能

  3. 收益递减:JDK内部很多API(如ConcurrentHashMap)已经改用无锁算法,偏向锁的应用场景变少

  4. 轻量级锁足够好:自适应自旋优化后,轻量级锁的性能已经很好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
偏向锁撤销的代价:

1. 撤销单个对象的偏向锁:
- 需要等待全局安全点(Safe Point)
- 遍历所有线程栈,检查是否有该对象的Lock Record
- 修改对象头,升级为轻量级锁或无锁状态

2. 批量撤销(Bulk Revoke):
- 如果某个类的对象频繁发生偏向锁撤销
- JVM会禁用该类的偏向锁
- 避免反复撤销的开销

3. 批量重偏向(Bulk Rebias):
- 如果对象先被线程1访问,后被线程2访问
- 不立即撤销,而是重偏向到线程2
- 通过Epoch机制实现

这些机制的复杂度和开销,是废弃偏向锁的主要原因。
*/

🟡⭐ 10. 深入理解volatile关键字

面试官视角:考察对Java内存模型、可见性、有序性的理解,volatile是并发编程的重要知识点。

详细答案

volatile是Java提供的轻量级同步机制,保证变量的可见性有序性,但不保证原子性。

一、volatile的三大特性

1. 可见性(Visibility)

  • 一个线程修改volatile变量,其他线程立即可见
  • 通过内存屏障实现,强制从主内存读取/写入

2. 有序性(Ordering)

  • 禁止指令重排序优化
  • 保证volatile变量前后的代码不会被重排序

3. 不保证原子性(Non-Atomic)

  • volatile不能保证复合操作的原子性
  • i++不是原子操作,需要使用synchronized或Atomic类

原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Java内存模型(JMM):

线程1 主内存 线程2
+--------+ +--------+ +--------+
| 工作内存 | | volatile| | 工作内存 |
| count=0| <--读--- | count | ---读--> | count=0|
+--------+ +--------+ +--------+
||
| 写count=1 | | 读到最新值
| | | count=1
+------写回主内存-----+ |

volatile保证:
1. 写操作立即刷新到主内存
2. 读操作从主内存读取最新值
3. 禁止指令重排序

普通变量(无volatile):
- 线程工作内存与主内存不同步
- 可能读到过期数据
- 指令可能被重排序

二、volatile的实现原理

1. 内存屏障(Memory Barrier)

volatile通过插入内存屏障指令来实现可见性和有序性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
内存屏障类型:

LoadLoad屏障: Load1; LoadLoad; Load2
确保Load1数据加载先于Load2

StoreStore屏障: Store1; StoreStore; Store2
确保Store1数据对其他处理器可见先于Store2

LoadStore屏障: Load1; LoadStore; Store2
确保Load1数据加载先于Store2刷新

StoreLoad屏障: Store1; StoreLoad; Load2
确保Store1数据对所有处理器可见先于Load2加载
开销最大的屏障

volatile写插入的屏障:
... 普通写 ...
StoreStore屏障
volatile
StoreLoad屏障

volatile读插入的屏障:
volatile
LoadLoad屏障
LoadStore屏障
... 普通读 ...

2. 汇编层面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java代码
public class VolatileDemo {
private volatile int count = 0;

public void increase() {
count++;
}
}

// 编译后的汇编代码(x86)
0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp); // lock前缀指令

// lock前缀的作用:
// 1. 将当前处理器缓存行的数据写回主内存
// 2. 使其他处理器的缓存行无效(MESI协议)
// 3. 提供内存屏障功能,禁止重排序

3. MESI缓存一致性协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MESI协议保证多核CPU缓存一致性:

状态:
M (Modified): 已修改,与主内存不一致,独占
E (Exclusive): 独占,与主内存一致
S (Shared): 共享,多个CPU缓存都有副本
I (Invalid): 无效,需要从主内存重新加载

volatile的读写:
1. CPU1写volatile变量:
- 缓存行变为M状态
- 通过总线通知其他CPU
- 其他CPU的缓存行变为I状态

2. CPU2读volatile变量:
- 检测到缓存行为I状态
- 从CPU1的缓存或主内存读取最新值
- 缓存行变为S状态

三、volatile的应用场景

场景1:状态标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class StatusFlag {
private volatile boolean flag = false;

// 线程1:等待flag变为true
public void waitForFlag() {
while (!flag) {
// 等待
}
// flag变为true后执行
doSomething();
}

// 线程2:修改flag
public void setFlag() {
doSomeWork();
flag = true; // volatile保证立即可见
}
}

场景2:双重检查锁定(Double-Checked Locking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Singleton {
// volatile防止指令重排序导致的问题
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 = new Singleton() 包含三个步骤:
1. 分配内存空间
2. 初始化对象
3. 将instance指向内存地址

可能的指令重排序:
1. 分配内存空间
2. 将instance指向内存地址(此时对象未初始化!)
3. 初始化对象

线程A执行到步骤2,线程B判断instance != null,
直接返回未初始化的对象,导致错误!

volatile禁止重排序,确保步骤2和3不会颠倒
*/

场景3:读写锁的状态标识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class VolatileExample {
private volatile int state = 0;

// 读线程
public void reader() {
int localState = state; // volatile读
// 使用localState
}

// 写线程
public void writer() {
// 准备数据
state = 1; // volatile写,保证之前的操作对读线程可见
}
}

场景4:一次性安全发布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SafePublication {
private volatile Map<String, String> config;

public void init() {
Map<String, String> temp = new HashMap<>();
temp.put("key1", "value1");
temp.put("key2", "value2");
// 完全初始化后再赋值给volatile变量
config = temp; // 一次性发布,保证其他线程看到完整的Map
}

public String get(String key) {
Map<String, String> localConfig = config;
return localConfig != null ? localConfig.get(key) : null;
}
}

源码分析与实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// 演示volatile的可见性
public class VisibilityDemo {
// 不加volatile
private boolean flag = false;

public void thread1() {
while (!flag) {
// 可能一直循环,即使thread2修改了flag
// 因为flag被缓存在CPU缓存中
}
System.out.println("线程1结束");
}

public void thread2() {
flag = true;
System.out.println("线程2修改flag为true");
}

public static void main(String[] args) {
VisibilityDemo demo = new VisibilityDemo();

new Thread(() -> demo.thread1()).start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

new Thread(() -> demo.thread2()).start();

// 可能的结果:
// 线程2修改flag为true
// (线程1永远不会输出"线程1结束")

// 解决:将flag声明为volatile
}
}

// 演示volatile不保证原子性
public class AtomicityDemo {
private volatile int count = 0;

public void increase() {
count++; // 不是原子操作!
}

public static void main(String[] args) throws InterruptedException {
AtomicityDemo demo = new AtomicityDemo();

// 创建10个线程,每个线程执行1000次increase
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
demo.increase();
}
});
threads[i].start();
}

// 等待所有线程执行完毕
for (Thread thread : threads) {
thread.join();
}

System.out.println("count = " + demo.count);
// 期望:10000
// 实际:小于10000(如9856)

// 原因:count++ 包含三个操作:
// 1. 读取count
// 2. count + 1
// 3. 写回count
// volatile只保证单个读/写的原子性,不保证复合操作的原子性

// 解决方案:
// 1. 使用synchronized
// 2. 使用AtomicInteger
}
}

// count++的字节码分析
public void increase() {
count++;
}

// 字节码:
// 0: aload_0
// 1: dup
// 2: getfield #2 // 读取count
// 5: iconst_1
// 6: iadd // +1
// 7: putfield #2 // 写回count
// 10: return

// 并发执行可能的情况:
// 线程A:读取count=0
// 线程B:读取count=0
// 线程A:计算0+1=1
// 线程B:计算0+1=1
// 线程A:写回count=1
// 线程B:写回count=1
// 结果:两次increase,count只增加了1

// volatile的happens-before规则
public class HappensBeforeDemo {
private int a = 0;
private volatile boolean flag = false;

// 线程1
public void writer() {
a = 1; // 操作1
flag = true; // 操作2(volatile写)
}

// 线程2
public void reader() {
if (flag) { // 操作3(volatile读)
int i = a; // 操作4
System.out.println(i); // 保证输出1
}
}

/*
happens-before规则:
1. 程序顺序规则:操作1 happens-before 操作2
2. volatile规则:操作2 happens-before 操作3
3. 程序顺序规则:操作3 happens-before 操作4
4. 传递性:操作1 happens-before 操作4

结论:线程2读到flag=true时,必然能看到a=1
volatile具有"传递可见性"
*/
}

// volatile vs synchronized
public class ComparisonDemo {
private volatile int volatileCount = 0;
private int syncCount = 0;

// volatile:只保证可见性和有序性
public void volatileIncrease() {
volatileCount++; // 线程不安全
}

// synchronized:保证原子性、可见性、有序性
public synchronized void syncIncrease() {
syncCount++; // 线程安全
}

// 正确使用volatile
public void correctVolatileUsage() {
// 适用场景:
// 1. 单一线程写,多个线程读
// 2. 写操作不依赖当前值
// 3. 变量独立,不与其他状态变量构成不变约束
}
}

// AtomicInteger的正确使用
public class AtomicDemo {
private AtomicInteger count = new AtomicInteger(0);

public void increase() {
count.incrementAndGet(); // 原子操作
}

// AtomicInteger内部使用volatile + CAS
// private volatile int value;
//
// public final int incrementAndGet() {
// return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
// }
}

常见误区

  1. 误区1:认为volatile可以替代synchronized

    • 纠正:volatile不保证原子性,复合操作必须用synchronized或Atomic类
  2. 误区2:认为volatile变量的所有操作都是原子的

    • 纠正:只有单个读/写是原子的,i++等复合操作不是原子的
  3. 误区3:过度使用volatile

    • 纠正:volatile有性能开销(内存屏障、缓存失效),只在必要时使用
  4. 误区4:认为volatile可以保证线程安全

    • 纠正:只有在特定场景下(如状态标志)才安全,一般情况需要配合其他机制

追问1:volatile的性能开销有多大?

答:volatile的性能开销主要来自:

  1. 内存屏障指令:禁止CPU优化,影响指令流水线
  2. 缓存失效:写操作导致其他CPU缓存失效,增加缓存未命中率
  3. 禁止JIT优化:编译器不能做某些优化(如寄存器缓存)

性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 基准测试
public class VolatilePerformance {
private int normalVar = 0;
private volatile int volatileVar = 0;

// 普通变量读写:约1-2纳秒
public void normalReadWrite() {
int temp = normalVar;
normalVar = temp + 1;
}

// volatile变量读写:约10-20纳秒
public void volatileReadWrite() {
int temp = volatileVar;
volatileVar = temp + 1;
}

// synchronized:约100-200纳秒(无竞争时)
public synchronized void syncReadWrite() {
int temp = normalVar;
normalVar = temp + 1;
}
}

/*
性能排序:
普通变量 < volatile < synchronized(无竞争) < synchronized(有竞争)

结论:
- volatile的开销约为普通变量的10倍
- 但远小于synchronized(约1/10)
- 在适用场景下,volatile是高性能的选择
*/

追问2:为什么双重检查锁定必须用volatile?

答:这是经典的指令重排序问题,详细分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class DCLAnalysis {
private static Singleton instance; // 没有volatile

public static Singleton getInstance() {
if (instance == null) { // 检查1
synchronized (Singleton.class) {
if (instance == null) { // 检查2
instance = new Singleton(); // 问题所在
}
}
}
return instance;
}
}

/*
问题分析:

instance = new Singleton() 的执行步骤:

正常顺序:
1. memory = allocate() // 分配内存
2. ctorInstance(memory) // 初始化对象
3. instance = memory // 设置instance指向内存

重排序后:
1. memory = allocate() // 分配内存
2. instance = memory // 设置instance指向内存(此时对象未初始化!)
3. ctorInstance(memory) // 初始化对象

并发场景:
时刻1:线程A执行步骤1、2,instance != null(但对象未初始化)
时刻2:线程B执行检查1,发现instance != null,直接返回
时刻3:线程B使用instance,访问未初始化的字段,出错!

解决:
private static volatile Singleton instance; // 添加volatile

volatile的作用:
1. 禁止步骤2和3的重排序
2. 保证instance的写操作对其他线程可见
3. 确保线程B看到instance != null时,对象已完全初始化
*/

追问3:volatile能否保证long/double的原子性?

答:这涉及到JVM规范中的一个特殊问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
问题背景:
JVM规范允许将long/double的读写分为两个32位操作
即"非原子性的64位操作"

非volatile的long/double:
线程A:写long值 0x0000000100000002
- 低32位:0x00000002
- 高32位:0x00000001

线程B:读long值
- 可能读到:0x0000000000000002(只读到低32位)
- 或者:0x0000000100000000(只读到高32位)
- 结果是错误的混合值!

volatile的long/double:
private volatile long value;

JVM保证:
1. volatile的long/double读写是原子的
2. 不会出现"字撕裂"问题
3. 一次性读写完整的64位

实践中:
- 现代64位JVM基本都保证long/double读写的原子性
- 但JVM规范不强制要求
- 为了可移植性和规范性,应该使用volatile

结论:
volatile long/double -> 保证原子性 + 可见性 + 有序性
非volatile long/double -> 不保证原子性(JVM规范允许)
*/

public class LongAtomicityDemo {
private long nonVolatileLong = 0L;
private volatile long volatileLong = 0L;

// 潜在问题
public void writerNonVolatile() {
nonVolatileLong = 0x0123456789ABCDEFL;
// 可能被拆分为两次32位写操作
}

public long readerNonVolatile() {
return nonVolatileLong;
// 可能读到不一致的值
}

// 安全
public void writerVolatile() {
volatileLong = 0x0123456789ABCDEFL;
// 保证原子性写入
}

public long readerVolatile() {
return volatileLong;
// 保证原子性读取
}
}

Spring全家桶

一、Spring核心(30题)

🟢⭐ 11. 说说Spring的IoC容器原理

面试官视角:考察对Spring核心机制的理解,IoC是Spring的基石。

详细答案

IoC(Inversion of Control,控制反转)是Spring的核心思想,将对象的创建和依赖关系的管理从应用代码中分离出来,交给IoC容器负责。

一、什么是IoC?

传统方式

1
2
3
4
5
6
7
8
9
// 对象自己创建依赖
public class UserService {
private UserDao userDao = new UserDaoImpl(); // 硬编码依赖

public void addUser(User user) {
userDao.save(user);
}
}
// 问题:耦合度高,难以测试和扩展

IoC方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 依赖由外部注入
public class UserService {
private UserDao userDao; // 不自己创建

// 通过构造器、Setter或字段注入
public UserService(UserDao userDao) {
this.userDao = userDao;
}

public void addUser(User user) {
userDao.save(user);
}
}
// 优点:低耦合,易测试,易扩展

控制反转的含义

  • 控制:对象的创建、依赖关系的管理
  • 反转:从应用代码转移到IoC容器

依赖注入(DI):IoC的实现方式,通过注入方式提供依赖对象。

二、Spring IoC容器架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IoC容器层次结构:

BeanFactory(顶层接口)

ListableBeanFactory HierarchicalBeanFactory
↑ ↑
+--------ApplicationContext--------+

+------------+------------+
↑ ↑
ConfigurableApplicationContext WebApplicationContext

ClassPathXmlApplicationContext
AnnotationConfigApplicationContext

1. BeanFactory

  • 最基础的IoC容器,提供基本的依赖注入功能
  • 延迟加载:调用getBean()时才创建对象
  • 适合资源受限的环境

2. ApplicationContext

  • BeanFactory的子接口,提供更多企业级功能
  • 立即加载:启动时创建所有单例Bean
  • 额外功能:
    • 国际化(MessageSource)
    • 事件发布(ApplicationEventPublisher)
    • 资源访问(ResourceLoader)
    • AOP集成

常用实现类

  • ClassPathXmlApplicationContext:从classpath加载XML配置
  • FileSystemXmlApplicationContext:从文件系统加载XML配置
  • AnnotationConfigApplicationContext:基于注解的配置
  • WebApplicationContext:Web应用的IoC容器

三、Bean的生命周期 ⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Bean生命周期完整流程:

1. 实例化(Instantiation)

2. 属性赋值(Populate Properties)

3. 初始化前(BeanPostProcessor.postProcessBeforeInitialization)

4. 初始化(Initialization)
- 调用@PostConstruct方法
- 调用InitializingBean.afterPropertiesSet()
- 调用init-method指定的方法

5. 初始化后(BeanPostProcessor.postProcessAfterInitialization)

6. Bean就绪,可以使用

7. 销毁前(@PreDestroy)

8. 销毁(Destruction)
- 调用DisposableBean.destroy()
- 调用destroy-method指定的方法

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// AbstractAutowireCapableBeanFactory.doCreateBean()
protected Object doCreateBean(String beanName, RootBeanDefinition mbd, Object[] args) {
// 1. 实例化Bean
BeanWrapper instanceWrapper = createBeanInstance(beanName, mbd, args);
Object bean = instanceWrapper.getWrappedInstance();

// 2. 属性赋值(依赖注入)
populateBean(beanName, mbd, instanceWrapper);

// 3. 初始化Bean
Object exposedObject = initializeBean(beanName, bean, mbd);

return exposedObject;
}

// 初始化Bean的详细流程
protected Object initializeBean(String beanName, Object bean, RootBeanDefinition mbd) {
// 3.1 Aware接口回调
invokeAwareMethods(beanName, bean);

// 3.2 BeanPostProcessor前置处理
Object wrappedBean = applyBeanPostProcessorsBeforeInitialization(bean, beanName);

// 3.3 执行初始化方法
invokeInitMethods(beanName, wrappedBean, mbd);

// 3.4 BeanPostProcessor后置处理(AOP在这里生成代理)
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);

return wrappedBean;
}

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@Component
public class LifecycleBean implements BeanNameAware, BeanFactoryAware,
ApplicationContextAware, InitializingBean, DisposableBean {

private String name;

public LifecycleBean() {
System.out.println("1. 构造器:实例化Bean");
}

public void setName(String name) {
this.name = name;
System.out.println("2. 属性赋值:setName()");
}

@Override
public void setBeanName(String name) {
System.out.println("3. BeanNameAware:setBeanName()");
}

@Override
public void setBeanFactory(BeanFactory beanFactory) {
System.out.println("4. BeanFactoryAware:setBeanFactory()");
}

@Override
public void setApplicationContext(ApplicationContext applicationContext) {
System.out.println("5. ApplicationContextAware:setApplicationContext()");
}

@PostConstruct
public void postConstruct() {
System.out.println("6. @PostConstruct:postConstruct()");
}

@Override
public void afterPropertiesSet() {
System.out.println("7. InitializingBean:afterPropertiesSet()");
}

// @Bean(initMethod = "initMethod")
public void initMethod() {
System.out.println("8. init-method:initMethod()");
}

@PreDestroy
public void preDestroy() {
System.out.println("9. @PreDestroy:preDestroy()");
}

@Override
public void destroy() {
System.out.println("10. DisposableBean:destroy()");
}

// @Bean(destroyMethod = "destroyMethod")
public void destroyMethod() {
System.out.println("11. destroy-method:destroyMethod()");
}
}

// 自定义BeanPostProcessor
@Component
public class MyBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (bean instanceof LifecycleBean) {
System.out.println("BeanPostProcessor:postProcessBeforeInitialization()");
}
return bean;
}

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof LifecycleBean) {
System.out.println("BeanPostProcessor:postProcessAfterInitialization()");
}
return bean;
}
}

// 输出顺序:
// 1. 构造器:实例化Bean
// 2. 属性赋值:setName()
// 3. BeanNameAware:setBeanName()
// 4. BeanFactoryAware:setBeanFactory()
// 5. ApplicationContextAware:setApplicationContext()
// BeanPostProcessor:postProcessBeforeInitialization()
// 6. @PostConstruct:postConstruct()
// 7. InitializingBean:afterPropertiesSet()
// 8. init-method:initMethod()
// BeanPostProcessor:postProcessAfterInitialization()
// ...(Bean使用中)...
// 9. @PreDestroy:preDestroy()
// 10. DisposableBean:destroy()
// 11. destroy-method:destroyMethod()

四、依赖注入方式

1. 构造器注入(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class UserService {
private final UserDao userDao; // final保证不可变

// Spring 4.3+:单个构造器可以省略@Autowired
public UserService(UserDao userDao) {
this.userDao = userDao;
}
}

// 优点:
// 1. 依赖不可变(final)
// 2. 依赖不为null(编译期检查)
// 3. 完全初始化后的对象
// 4. 避免循环依赖(编译期发现)

2. Setter注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class UserService {
private UserDao userDao;

@Autowired
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
}

// 优点:
// 1. 可选依赖(required = false)
// 2. 可重新配置
// 缺点:
// 1. 依赖可能为null
// 2. 对象可能处于不完全初始化状态

3. 字段注入(不推荐)

1
2
3
4
5
6
7
8
9
10
11
@Service
public class UserService {
@Autowired
private UserDao userDao; // 直接注入字段
}

// 缺点:
// 1. 不能使用final
// 2. 难以进行单元测试(需要反射)
// 3. 违反封装原则
// 4. 隐藏了依赖关系

五、循环依赖问题

什么是循环依赖?

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
}

@Service
public class ServiceB {
@Autowired
private ServiceA serviceA;
}
// A依赖B,B依赖A,形成循环

Spring如何解决?

使用三级缓存机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// DefaultSingletonBeanRegistry.java

// 一级缓存:完全初始化的Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>();

// 二级缓存:早期暴露的Bean(未完全初始化)
private final Map<String, Object> earlySingletonObjects = new HashMap<>();

// 三级缓存:Bean工厂
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>();

// 获取Bean的流程
protected Object getSingleton(String beanName) {
// 1. 从一级缓存获取
Object singletonObject = singletonObjects.get(beanName);
if (singletonObject == null) {
// 2. 从二级缓存获取
singletonObject = earlySingletonObjects.get(beanName);
if (singletonObject == null) {
// 3. 从三级缓存获取
ObjectFactory<?> singletonFactory = singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
// 放入二级缓存,移除三级缓存
earlySingletonObjects.put(beanName, singletonObject);
singletonFactories.remove(beanName);
}
}
}
return singletonObject;
}

循环依赖解决过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1. 创建ServiceA:
- 实例化ServiceA(构造器)
- 将ServiceA的ObjectFactory放入三级缓存
- 填充属性,发现需要ServiceB

2. 创建ServiceB:
- 实例化ServiceB(构造器)
- 将ServiceB的ObjectFactory放入三级缓存
- 填充属性,发现需要ServiceA

3. 再次获取ServiceA:
- 从三级缓存获取ServiceA的ObjectFactory
- 调用getObject(),得到ServiceA的早期引用
- 将ServiceA放入二级缓存,移除三级缓存
- ServiceB注入ServiceA的早期引用

4. ServiceB初始化完成:
- ServiceB放入一级缓存
- 移除二级、三级缓存中的ServiceB

5. ServiceA继续初始化:
- ServiceA注入完全初始化的ServiceB
- ServiceA初始化完成,放入一级缓存
- 移除二级、三级缓存中的ServiceA

哪些循环依赖无法解决?

  1. 构造器循环依赖:无法解决,因为Bean实例化时就需要依赖
1
2
3
4
5
6
7
8
9
10
@Service
public class ServiceA {
public ServiceA(ServiceB serviceB) { } // 构造器注入
}

@Service
public class ServiceB {
public ServiceB(ServiceA serviceA) { } // 构造器注入
}
// 抛出BeanCurrentlyInCreationException
  1. prototype作用域的循环依赖:无法解决,因为不缓存prototype Bean

  2. @Async等代理对象的循环依赖:可能失败,取决于代理时机

常见误区

  1. 误区1:认为IoC就是DI

    • 纠正:DI是IoC的一种实现方式,IoC是更广泛的概念
  2. 误区2:认为ApplicationContext就是BeanFactory

    • 纠正:ApplicationContext是BeanFactory的子接口,功能更强大
  3. 误区3:过度使用字段注入

    • 纠正:应优先使用构造器注入,保证依赖不可变
  4. 误区4:认为循环依赖都能解决

    • 纠正:只有单例的setter/字段注入才能解决,构造器和prototype不行

追问1:三级缓存的作用是什么,为什么需要三级?

答:

为什么不用两级缓存?

理论上两级缓存可以解决循环依赖:

  • 一级:完整Bean
  • 二级:半成品Bean

但Spring需要支持AOP代理,问题来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 假设只有两级缓存
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
}

@Service
@Async // 需要AOP代理
public class ServiceB {
@Autowired
private ServiceA serviceA;
}

// 流程:
// 1. 创建ServiceA,放入二级缓存(原始对象)
// 2. 创建ServiceB,需要ServiceA,从二级缓存获取原始对象
// 3. ServiceB初始化完成,创建代理对象
// 4. ServiceA注入的是ServiceB的原始对象,不是代理对象!
// 结果:@Async失效,因为没有调用代理对象

三级缓存的作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 三级缓存存储ObjectFactory
singletonFactories.put(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));

// getEarlyBeanReference()会调用BeanPostProcessor
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
exposedObject = ((SmartInstantiationAwareBeanPostProcessor) bp)
.getEarlyBeanReference(exposedObject, beanName);
// AOP在这里创建代理对象
}
}
return exposedObject;
}

// 正确流程:
// 1. 创建ServiceA,三级缓存存储ObjectFactory
// 2. 创建ServiceB,需要ServiceA
// 3. 调用ObjectFactory.getObject() -> getEarlyBeanReference()
// 4. 如果ServiceA需要代理,返回代理对象;否则返回原始对象
// 5. ServiceB注入的是正确的对象(代理或原始)

// 总结:
// 三级缓存 = 延迟代理对象的创建
// 只有在循环依赖时才创建早期代理对象
// 没有循环依赖时,代理对象在初始化后创建(更合理)

追问2:BeanFactory和FactoryBean的区别?

答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// BeanFactory:IoC容器的顶层接口
BeanFactory factory = new ClassPathXmlApplicationContext("beans.xml");
Object bean = factory.getBean("myBean");

// FactoryBean:创建Bean的工厂Bean
public interface FactoryBean<T> {
T getObject() throws Exception; // 返回创建的对象
Class<?> getObjectType(); // 返回对象类型
boolean isSingleton(); // 是否单例
}

// 示例:创建复杂对象
@Component
public class CarFactoryBean implements FactoryBean<Car> {
@Override
public Car getObject() throws Exception {
Car car = new Car();
car.setBrand("BMW");
car.setPrice(500000);
// 复杂的初始化逻辑
return car;
}

@Override
public Class<?> getObjectType() {
return Car.class;
}

@Override
public boolean isSingleton() {
return true;
}
}

// 使用:
@Autowired
private Car car; // 注入的是Car,不是CarFactoryBean

// 如果要获取FactoryBean本身:
Object factoryBean = context.getBean("&carFactoryBean"); // 加&前缀

// 区别:
// BeanFactory:Spring IoC容器,管理Bean
// FactoryBean:特殊的Bean,用于创建其他Bean

PLACEHOLDER_FOR_MORE_CONTENT_3


面试准备-技能栈八股文大全
https://whyalwaysme.lol/2026/09/01/面试准备-技能栈八股文大全/
作者
Cassiur
发布于
2026年9月1日
许可协议