mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-30 07:33:45 +00:00
Merge pull request #1490 from CyC2018/update-java-notes
Update Java notes
This commit is contained in:
+160
-1
@@ -44,6 +44,8 @@
|
||||
* [A3 泛型的类型擦除](#a3-泛型的类型擦除)
|
||||
* [A4 反射与注解](#a4-反射与注解)
|
||||
* [A5 Lambda 与 Stream](#a5-lambda-与-stream)
|
||||
* [A6 finally 的执行顺序与返回值](#a6-finally-的执行顺序与返回值)
|
||||
* [A7 hashCode 的生成与比较顺序](#a7-hashcode-的生成与比较顺序)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -686,6 +688,8 @@ set.add(e2);
|
||||
System.out.println(set.size()); // 2
|
||||
```
|
||||
|
||||
这是因为 HashMap 查找键时先用 hashCode() 确定桶下标,在桶中先比较哈希值,哈希值相等时才用 == 或 equals() 判断。e1 和 e2 的哈希值不同,多半落在不同的桶中,即使落在同一个桶,也会因为哈希值不同而被跳过,equals() 根本没有机会被调用(JDK 21 实测,见[附录 A7](#a7-hashcode-的生成与比较顺序))。HashSet 内部就是一个 HashMap,元素作为键存放。
|
||||
|
||||
理想的哈希函数应当具有均匀性,即不相等的对象应当均匀分布到所有可能的哈希值上。这就要求了哈希函数要把所有域的值都考虑进来。可以将每个域都当成 R 进制的某一位,然后组成一个 R 进制的整数。
|
||||
|
||||
R 一般取 31,因为它是一个奇素数,如果是偶数的话,当出现乘法溢出,信息就会丢失,因为与 2 相乘相当于向左移一位,最左边的位丢失。并且一个数与 31 相乘可以转换成移位和减法:`31*x == (x<<5)-x`,虚拟机可以自动进行这个优化。
|
||||
@@ -703,6 +707,8 @@ public int hashCode() {
|
||||
|
||||
实际编写时可以直接调用 Objects.hash(x, y, z),它按同样的方式计算,只是初始值为 1。
|
||||
|
||||
没有覆盖时,Object 的 hashCode() 并不是对象的内存地址。HotSpot 在第一次调用时用当前线程的 xorshift 随机数生成器得到一个 31 位的值,写入对象头的 Mark Word(见 [对象的创建与内存布局](Java%20虚拟机.md#对象的创建与内存布局)),之后对象即使被 GC 移动,哈希值也不变。生成方式由 -XX:hashCode 选择,JDK 21 的默认值为 5,即上面的随机数方案,改为 2 时所有对象的哈希值都是 1(见[附录 A7](#a7-hashcode-的生成与比较顺序))。
|
||||
|
||||
### toString()
|
||||
|
||||
默认返回 ToStringExample@4554617c 这种形式,其中 @ 后面的数值为哈希码的无符号十六进制表示。
|
||||
@@ -1316,6 +1322,38 @@ Throwable 可以用来表示任何可以作为异常抛出的类,分为两种
|
||||
|
||||
<div align="center"> <img src="pics/java-exceptions.png" width="760px"/> </div><br>
|
||||
|
||||
finally 块无论是否发生异常、try 或 catch 中是否执行了 return,都会在离开 try 语句之前执行,通常用来释放锁、关闭资源。finally 与 final、finalize() 只是名字相近:final 是修饰符,见 [final](#final);finalize() 是 Object 的方法,对象被回收前可能被调用,JDK 9 起已经废弃,见 [finalize()](Java%20虚拟机.md#4-finalize)。
|
||||
|
||||
try、catch、finally 的执行顺序和返回值有以下规则(JDK 21 实测,见[附录 A6](#a6-finally-的执行顺序与返回值)):
|
||||
|
||||
- try 中发生异常时,跳过 try 中剩下的语句,执行匹配的 catch,再执行 finally;catch 中有 return 时,先计算返回值,执行完 finally 再返回;
|
||||
- 返回值在执行 finally 之前就已经保存:返回基本类型时,finally 修改这个变量不影响返回值;返回引用时保存的是引用,finally 修改它指向的对象,调用方能看到修改;
|
||||
- finally 中的 return 会覆盖 try、catch 中的返回值,还会吞掉 try 中抛出的异常,用 -Xlint:finally 编译时会给出警告,不应该这样写;
|
||||
- finally 不执行的情况:执行到 finally 之前调用了 System.exit()、进程被 kill -9 或 JVM 崩溃;JVM 退出时仍在运行的守护线程也不会执行 finally。
|
||||
|
||||
```java
|
||||
static int primitive() {
|
||||
int x = 1;
|
||||
try {
|
||||
return x; // 返回值 1 先被保存起来
|
||||
} finally {
|
||||
x = 2; // 修改的是局部变量 x,返回 1
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> reference() {
|
||||
List<String> list = new ArrayList<>();
|
||||
try {
|
||||
list.add("try");
|
||||
return list; // 保存的是引用
|
||||
} finally {
|
||||
list.add("finally"); // 修改的是同一个对象,返回 [try, finally]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
编译器把 finally 中的代码复制到 try 和每个 catch 的出口处。return x 编译后先把 x 的值复制到另一个局部变量槽,再执行复制过来的 finally 代码,最后从那个槽中取出值返回,所以 finally 修改 x 不影响返回值。
|
||||
|
||||
## 九、泛型
|
||||
|
||||
泛型把类型作为参数,让编译器在编译时检查类型。例如 List\<String\> 中只能放入字符串,取出时也不需要强制类型转换:
|
||||
@@ -1329,7 +1367,7 @@ public class Box<T> {
|
||||
}
|
||||
```
|
||||
|
||||
Java 的泛型通过类型擦除实现:类型参数只在编译时检查,编译后都替换为它的上界(没有声明上界时为 Object),在取出元素的地方由编译器插入强制类型转换。所以 List\<String\> 和 List\<Integer\> 在运行时是同一个类:
|
||||
Java 的泛型通过类型擦除实现:类型参数只在编译时检查,编译后都替换为它的上界(没有声明上界时为 Object),在取出元素的地方由编译器插入强制类型转换。所以 List\<String\> 和 List\<Integer\> 在运行时是同一个类。需要注意的是,类、字段和方法声明中的泛型信息仍然以 Signature 属性保存在 class 文件中,可以通过反射读取(例如 getGenericSuperclass()),被擦除的是对象实例和局部变量上的类型参数:
|
||||
|
||||
```java
|
||||
List<String> a = new ArrayList<>();
|
||||
@@ -1420,6 +1458,14 @@ Thread.ofVirtual().start(() -> System.out.println(Thread.currentThread().isVirtu
|
||||
|
||||
Lambda 表达式不会生成匿名内部类的 class 文件。Lambda 的方法体被编译成当前类的私有静态方法 lambda$main$0,调用处是一条 invokedynamic 指令,第一次执行时由 LambdaMetafactory 在运行时生成实现函数式接口的类(javap 实测,见[附录 A5](#a5-lambda-与-stream))。
|
||||
|
||||
常用的中间操作中,filter 按条件保留元素,map 把每个元素变成另一个元素,flatMap 把每个元素变成一个流,再把这些流连接成一个流,常用于展开嵌套的集合:
|
||||
|
||||
```java
|
||||
List<List<String>> groups = List.of(List.of("redis", "mysql"), List.of("kafka"));
|
||||
groups.stream().map(List::size).toList(); // [2, 1]
|
||||
groups.stream().flatMap(List::stream).toList(); // [redis, mysql, kafka]
|
||||
```
|
||||
|
||||
Stream 是惰性求值的:filter、map 等中间操作只是记录下来,遇到 collect、findFirst 等终止操作才开始执行,并且每个元素依次走完整条流水线。上例改为 findFirst() 时,只处理了第一个元素 redis 就返回了,后面的 mysql、kafka 都没有被处理。
|
||||
|
||||
### Java 与 C++ 的区别
|
||||
@@ -1602,6 +1648,116 @@ $ javap -c -p Lam(节选)
|
||||
private static void lambda$main$0(java.lang.String);
|
||||
```
|
||||
|
||||
正文中 map 与 flatMap 的例子:
|
||||
|
||||
```text
|
||||
[2, 1]
|
||||
[redis, mysql, kafka]
|
||||
```
|
||||
|
||||
### A6 finally 的执行顺序与返回值
|
||||
|
||||
正文中的 primitive() 和 reference(),以及以下几个方法:
|
||||
|
||||
```java
|
||||
static int pay() {
|
||||
try {
|
||||
System.out.println("try");
|
||||
Integer.parseInt("x"); // 抛出 NumberFormatException
|
||||
return 1;
|
||||
} catch (NumberFormatException e) {
|
||||
System.out.println("catch");
|
||||
return 2;
|
||||
} finally {
|
||||
System.out.println("finally");
|
||||
}
|
||||
}
|
||||
|
||||
static int override() {
|
||||
try {
|
||||
throw new IllegalStateException("库存不足");
|
||||
} finally {
|
||||
return -1; // 覆盖了异常
|
||||
}
|
||||
}
|
||||
|
||||
// main 中
|
||||
try {
|
||||
System.out.println("try");
|
||||
System.exit(0);
|
||||
} finally {
|
||||
System.out.println("finally");
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
$ javac -Xlint:finally Fin.java
|
||||
Fin.java:28: 警告: [finally] finally 子句无法正常完成
|
||||
$ java Order
|
||||
try
|
||||
catch
|
||||
finally
|
||||
返回 2
|
||||
$ java Fin
|
||||
primitive(): 1
|
||||
reference(): [try, finally]
|
||||
override(): -1
|
||||
try
|
||||
$ javap -c -p Fin(primitive() 节选)
|
||||
0: iconst_1
|
||||
1: istore_0
|
||||
2: iload_0
|
||||
3: istore_1
|
||||
4: iconst_2
|
||||
5: istore_0
|
||||
6: iload_1
|
||||
7: ireturn
|
||||
8: astore_2
|
||||
9: iconst_2
|
||||
10: istore_0
|
||||
11: aload_2
|
||||
12: athrow
|
||||
```
|
||||
|
||||
System.exit(0) 之后的 finally 没有输出。字节码中,0~7 是正常返回的路径:x = 1 存入槽 0,返回值复制到槽 1,执行 finally 中的 x = 2,再返回槽 1 中的 1;8~12 是发生异常时的路径,其中 9~10 是同一段 finally 代码的另一份副本。
|
||||
|
||||
### A7 hashCode 的生成与比较顺序
|
||||
|
||||
```java
|
||||
Object a = new Object(), b = new Object();
|
||||
System.out.println(a.hashCode() + " " + b.hashCode() + " " + a.hashCode());
|
||||
```
|
||||
|
||||
```text
|
||||
$ java Hash
|
||||
1829164700 2018699554 1829164700
|
||||
$ java -XX:+UnlockExperimentalVMOptions -XX:hashCode=2 Hash
|
||||
1 1 1
|
||||
$ java -XX:+UnlockExperimentalVMOptions -XX:+PrintFlagsFinal -version | grep -w hashCode
|
||||
intx hashCode = 5 {experimental} {default}
|
||||
```
|
||||
|
||||
Key 的 hashCode() 返回 id,hashCode() 和 equals() 被调用时打印一行。容量为 16 时,id 为 1 和 17 的键落在同一个桶中:
|
||||
|
||||
```java
|
||||
Map<Key, String> map = new HashMap<>();
|
||||
map.put(new Key(1), "a");
|
||||
map.get(new Key(1));
|
||||
map.get(new Key(17));
|
||||
```
|
||||
|
||||
```text
|
||||
put(new Key(1))
|
||||
hashCode 被调用
|
||||
get(new Key(1))
|
||||
hashCode 被调用
|
||||
equals 被调用
|
||||
结果 a
|
||||
get(new Key(17))
|
||||
hashCode 被调用
|
||||
结果 null
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Eckel B. Java 编程思想 [M]. 机械工业出版社, 2002.
|
||||
@@ -1612,6 +1768,9 @@ $ javap -c -p Lam(节选)
|
||||
- [What’s New in Python 3.14](https://docs.python.org/3/whatsnew/3.14.html)
|
||||
- [A Guide to the Go Garbage Collector](https://go.dev/doc/gc-guide)
|
||||
- [JLS 5.1.7: Boxing Conversion](https://docs.oracle.com/javase/specs/jls/se21/html/jls-5.html#jls-5.1.7)
|
||||
- [JLS 14.20.2: Execution of try-finally and try-catch-finally](https://docs.oracle.com/javase/specs/jls/se21/html/jls-14.html#jls-14.20.2)
|
||||
- [Java SE 21 API: Runtime](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Runtime.html)
|
||||
- [HotSpot 源码:synchronizer.cpp 中的 get_next_hash()](https://github.com/openjdk/jdk21u/blob/master/src/hotspot/share/runtime/synchronizer.cpp)
|
||||
- [JLS 4.6: Type Erasure](https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.6)
|
||||
- [JEP 254: Compact Strings](https://openjdk.org/jeps/254)
|
||||
- [JEP 361: Switch Expressions](https://openjdk.org/jeps/361)
|
||||
|
||||
+110
-1
@@ -19,6 +19,8 @@
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 ArrayList 的扩容与常见陷阱](#a1-arraylist-的扩容与常见陷阱)
|
||||
* [A2 HashMap 的并发写入](#a2-hashmap-的并发写入)
|
||||
* [A3 ArrayList 的并发写入](#a3-arraylist-的并发写入)
|
||||
* [A4 HashMap 链表转红黑树](#a4-hashmap-链表转红黑树)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -57,6 +59,14 @@
|
||||
- Hashtable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 Hashtable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全。ConcurrentHashMap 的效率更高,因为它不会锁住整个表:JDK 1.7 使用分段锁,JDK 1.8 只锁住一个桶;
|
||||
- LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。
|
||||
|
||||
上面的 ArrayList、LinkedList、HashMap 等常用容器都不是线程安全的。需要在多个线程之间共享时,线程安全的容器主要有以下三类:
|
||||
|
||||
- 同步容器:Vector、Hashtable,以及用 Collections.synchronizedList()、synchronizedMap() 包装出来的容器,所有方法共用一把锁,同一时刻只有一个线程能访问;
|
||||
- 写时复制容器:CopyOnWriteArrayList、CopyOnWriteArraySet,写操作复制整个数组,读操作不加锁,适合读多写少的场景;
|
||||
- 并发容器:ConcurrentHashMap、ConcurrentSkipListMap(按键排序)、ConcurrentLinkedQueue,以及 ArrayBlockingQueue 等阻塞队列(见 [BlockingQueue](Java%20并发.md#blockingqueue)),用 CAS 和更细粒度的锁让多个线程同时访问。
|
||||
|
||||
只在一个线程中使用的容器,例如方法中的局部变量,不需要线程安全,直接使用 ArrayList、HashMap,没有加锁的开销。
|
||||
|
||||
|
||||
## 二、容器中的设计模式
|
||||
|
||||
@@ -345,6 +355,14 @@ public Vector() {
|
||||
|
||||
#### 4. 替代方案
|
||||
|
||||
ArrayList 不是线程安全的。JDK 21 的 add() 先读出 size 记为 s,容量不够时扩容,然后写入 elementData[s],最后把 size 设为 s + 1,这几步之间没有同步:
|
||||
|
||||
- 两个线程读到同一个 s,写入同一个位置,一个元素被覆盖,size 也只增加了 1;
|
||||
- 一个线程扩容后换成了新数组,另一个线程仍然写入旧数组,这次写入丢失,新数组中的这个位置留下 null;
|
||||
- 一个线程读到的 size 已被其它线程增加到超过它手中旧数组的长度,写入时抛出 ArrayIndexOutOfBoundsException。
|
||||
|
||||
例如 8 个线程各添加 10 万个元素,30 轮中 size 在 15 万~58 万之间,大多数轮次中出现了上百到数万个 null,有 3 轮抛出了 ArrayIndexOutOfBoundsException;同样的操作用 Collections.synchronizedList() 每次都是 80 万(JDK 21 实测,见[附录 A3](#a3-arraylist-的并发写入))。
|
||||
|
||||
可以使用 `Collections.synchronizedList();` 得到一个线程安全的 ArrayList。
|
||||
|
||||
```java
|
||||
@@ -534,6 +552,13 @@ map.put("K3", "V3");
|
||||
|
||||
<div align="center"> <img src="pics/jcol-chaining.png" width="760px"/> </div><br>
|
||||
|
||||
拉链法之外,解决哈希冲突的常见方法还有:
|
||||
|
||||
- 开放定址法:冲突时按固定的规则在数组中寻找下一个空位,例如线性探测依次检查下一个位置。JDK 中的 ThreadLocalMap 和 IdentityHashMap 使用线性探测;
|
||||
- 再哈希法:冲突时换一个哈希函数重新计算位置。
|
||||
|
||||
开放定址法的元素都存放在数组中,没有链表节点的开销,但装载因子必须较低,否则探测序列会很长;删除时也不能直接把位置置空,否则会截断其它元素的探测序列,ThreadLocalMap 删除元素后要把后面同一段中的元素重新放置。HashMap 采用拉链法,装载因子可以达到 0.75,删除只需修改链表,冲突严重时还可以把链表转为红黑树。
|
||||
|
||||
#### 3. put 操作
|
||||
|
||||
```java
|
||||
@@ -819,7 +844,15 @@ static final int tableSizeFor(int cap) {
|
||||
|
||||
#### 8. 链表转红黑树
|
||||
|
||||
从 JDK 1.8 开始,向一个桶中添加元素时,如果链表长度已经达到 8(TREEIFY_THRESHOLD),并且数组长度不小于 64(MIN_TREEIFY_CAPACITY),就会将链表转换为红黑树,否则只进行扩容。树中的节点数减少到 6(UNTREEIFY_THRESHOLD)时会重新转换为链表。
|
||||
从 JDK 1.8 开始,向一个桶中添加元素时,如果链表中已经有 8 个节点(TREEIFY_THRESHOLD),会先检查数组长度:小于 64(MIN_TREEIFY_CAPACITY)时只进行扩容,否则把链表转换为红黑树。例如所有键的 hashCode() 都相同时,放入第 9 个键时容量从 16 扩为 32,第 10 个键时扩为 64,第 11 个键时才转为红黑树(JDK 21 实测,见[附录 A4](#a4-hashmap-链表转红黑树))。数组较小时,冲突多半是桶太少造成的,扩容就能把链表拆开,所以先扩容。
|
||||
|
||||
引入红黑树是为了限制最坏情况:hashCode() 分布很差,或者被人故意构造成相同的值时,大量键落在同一个桶中,在链表中查找退化为 O(N),红黑树则为 O(logN)。
|
||||
|
||||
阈值取 8,是因为 hashCode() 足够随机时,一个桶中很难积累 8 个节点:装载因子为 0.75 时,桶中的节点数近似服从参数为 0.5 的泊松分布,一个桶中恰好有 8 个节点的概率约为 0.00000006(HashMap 源码注释)。TreeNode 的大小约为普通节点的两倍,正常情况下用不到它,也就不必付出这部分内存。
|
||||
|
||||
红黑树转回链表的阈值是 6 而不是 8,两个阈值之间留出余量,避免节点数在 8 附近增减时反复转换。需要注意的是,6 只用于扩容时拆分树:拆分后的节点数不超过 6 就转回链表。删除节点时按树的形状判断,根节点缺少左子节点或右子节点,或者根的左子节点没有左子节点时,转回链表,例如上面的例子再放入第 12 个键,然后逐个删除,剩 3 个键时才转回链表。
|
||||
|
||||
选择红黑树而不是 AVL 树,是因为红黑树只要求大致平衡,插入和删除时需要的旋转更少,适合桶中元素不断增删的场景;代价是红黑树的高度比 AVL 树稍高,查找时多比较的次数很少。B+ 树为磁盘设计,用很大的节点减少读取磁盘的次数,不适合内存中只有几个节点的桶。
|
||||
|
||||
#### 9. 与 Hashtable 的比较
|
||||
|
||||
@@ -1243,6 +1276,79 @@ run 0: size=263620 stuckThreads=4
|
||||
putter-4 at java.base/java.util.HashMap$TreeNode.treeify(HashMap.java:2107) / java.base/java.util.HashMap$TreeNode.split(HashMap.java:2339)
|
||||
```
|
||||
|
||||
### A3 ArrayList 的并发写入
|
||||
|
||||
8 个线程各向同一个 ArrayList 添加 10 万个元素,捕获 add() 抛出的异常,结束后统计 size 和 null 元素的个数,共 30 轮:
|
||||
|
||||
```java
|
||||
for (int t = 0; t < 8; t++) {
|
||||
ts[t] = new Thread(() -> {
|
||||
for (int i = 0; i < 100_000; i++) {
|
||||
try { list.add(i); } catch (Exception e) { errors.incrementAndGet(); }
|
||||
}
|
||||
});
|
||||
ts[t].start();
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
round 0: size=155642,null 元素 5540,异常 1 次 [ArrayIndexOutOfBoundsException]
|
||||
round 1: size=160142,null 元素 768,异常 0 次 []
|
||||
round 2: size=295557,null 元素 40378,异常 0 次 []
|
||||
……
|
||||
round 19: size=584393,null 元素 0,异常 0 次 []
|
||||
round 21: size=379448,null 元素 17821,异常 1 次 [ArrayIndexOutOfBoundsException]
|
||||
round 24: size=373831,null 元素 5434,异常 1 次 [ArrayIndexOutOfBoundsException]
|
||||
round 29: size=541149,null 元素 91379,异常 0 次 []
|
||||
synchronizedList: size=800000
|
||||
```
|
||||
|
||||
### A4 HashMap 链表转红黑树
|
||||
|
||||
Key 的 hashCode() 都返回 42,并实现了 Comparable。每次放入或删除后,用反射查看数组的长度和 42 号桶中头节点的类型,运行时需要加上 --add-opens java.base/java.util=ALL-UNNAMED:
|
||||
|
||||
```java
|
||||
record Key(int id) implements Comparable<Key> {
|
||||
@Override public int hashCode() { return 42; }
|
||||
@Override public int compareTo(Key o) { return Integer.compare(id, o.id); }
|
||||
}
|
||||
|
||||
HashMap<Key, Integer> map = new HashMap<>();
|
||||
for (int i = 1; i <= 12; i++) map.put(new Key(i), i); // 每次放入后打印
|
||||
for (int i = 12; i >= 1; i--) map.remove(new Key(i)); // 每次删除后打印
|
||||
```
|
||||
|
||||
只打印了发生变化的行:
|
||||
|
||||
```text
|
||||
放入第 1 个键后:容量 16,桶中节点类型 Node
|
||||
放入第 9 个键后:容量 32,桶中节点类型 Node
|
||||
放入第 10 个键后:容量 64,桶中节点类型 Node
|
||||
放入第 11 个键后:容量 64,桶中节点类型 TreeNode
|
||||
删除后剩 3 个键:容量 64,桶中节点类型 Node
|
||||
```
|
||||
|
||||
扩容拆分树:容量 64 时放入 10 个哈希值为 42 或 106 的键(各 5 个),它们都在 42 号桶中,组成红黑树;再放入其它键触发扩容,容量变为 128 后它们分到 42 和 106 两个桶,每个桶 5 个节点,都转回了链表:
|
||||
|
||||
```text
|
||||
容量 64:42 号桶 TreeNode
|
||||
扩容到 128 后:42 号桶 Node,106 号桶 Node
|
||||
```
|
||||
|
||||
源码注释中的泊松分布概率 e<sup>-0.5</sup> × 0.5<sup>k</sup> / k!,按 k = 0~8 计算:
|
||||
|
||||
```text
|
||||
0 0.60653066
|
||||
1 0.30326533
|
||||
2 0.07581633
|
||||
3 0.01263606
|
||||
4 0.00157951
|
||||
5 0.00015795
|
||||
6 0.00001316
|
||||
7 0.00000094
|
||||
8 0.00000006
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Eckel B. Java 编程思想 [M]. 机械工业出版社, 2002.
|
||||
@@ -1255,6 +1361,9 @@ run 0: size=263620 stuckThreads=4
|
||||
- [HashMap 相关面试题及其解答](https://www.jianshu.com/p/75adf47958a7)
|
||||
- [Java 集合细节(二):asList 的缺陷](http://wiki.jikexueyuan.com/project/java-enhancement/java-thirtysix.html)
|
||||
- [Java Collection Framework – The LinkedList Class](https://javaconceptoftheday.com/java-collection-framework-linkedlist-class/)
|
||||
- [JDK 21 源码:HashMap.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/util/HashMap.java)
|
||||
- [JDK 21 源码:ArrayList.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/util/ArrayList.java)
|
||||
- [JDK 21 源码:ThreadLocal.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/lang/ThreadLocal.java)
|
||||
- [JDK 8 源码 java.util](https://github.com/openjdk/jdk8u/tree/master/jdk/src/share/classes/java/util)
|
||||
- [Java SE 21 API: Arrays.asList](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Arrays.html#asList(T...))
|
||||
- [Java SE 21 API: ArrayDeque](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html)
|
||||
|
||||
@@ -64,6 +64,9 @@
|
||||
* [附录:实测](#附录实测)
|
||||
* [A1 三种锁在读多写少时的耗时](#a1-三种锁在读多写少时的耗时)
|
||||
* [A2 公平锁与非公平锁](#a2-公平锁与非公平锁)
|
||||
* [A3 volatile 与循环条件](#a3-volatile-与循环条件)
|
||||
* [A4 AtomicLong 与 LongAdder](#a4-atomiclong-与-longadder)
|
||||
* [A5 InheritableThreadLocal 与线程池](#a5-inheritablethreadlocal-与线程池)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -573,6 +576,25 @@ public static void main(String[] args) {
|
||||
|
||||
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
|
||||
|
||||
synchronized 代码块编译后是一对 monitorenter 和 monitorexit 指令,编译器还会生成一个异常处理器,抛出异常时也执行 monitorexit,保证锁一定会被释放;synchronized 方法没有这两条指令,而是在方法的访问标志中设置 ACC_SYNCHRONIZED,由 JVM 在调用和返回时加锁和解锁。下面是 JDK 21 的 javap 输出(有删节):
|
||||
|
||||
```text
|
||||
public void inc(); // synchronized (this) { count++; }
|
||||
3: monitorenter
|
||||
... // count++
|
||||
15: monitorexit
|
||||
16: goto 24
|
||||
19: astore_2 // 异常处理器:出现异常时也释放锁
|
||||
20: aload_1
|
||||
21: monitorexit
|
||||
23: athrow
|
||||
|
||||
public synchronized void dec();
|
||||
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
|
||||
```
|
||||
|
||||
发生竞争、锁膨胀为重量级锁之后,HotSpot 用 ObjectMonitor 管理这把锁:_owner 指向持有锁的线程,_recursions 记录重入的次数;抢不到锁的线程进入 _cxq、_EntryList 队列并阻塞,调用 wait() 的线程进入 _WaitSet,被 notify() 之后再回到队列中重新竞争锁。ReentrantLock 则是用 Java 代码基于 AQS 实现的,见 [七、J.U.C - AQS](#七juc---aqs)。
|
||||
|
||||
#### 2. 性能
|
||||
|
||||
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
|
||||
@@ -1441,12 +1463,26 @@ public static void main(String[] args) throws InterruptedException {
|
||||
|
||||
对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。
|
||||
|
||||
没有可见性保证时,即时编译器可以只读取一次字段,之后一直使用缓存的值(JLS 17.3)。例如下面的循环,stop 不加 volatile 时,主线程把它改为 true 之后,worker 线程仍然一直循环;加上 volatile 后,每次循环都重新读取 stop,worker 很快结束(JDK 21 各运行 3 次,结果相同,见[附录 A3](#a3-volatile-与循环条件)):
|
||||
|
||||
```java
|
||||
static boolean stop; // 改为 static volatile boolean stop 后循环能结束
|
||||
|
||||
new Thread(() -> { while (!stop) { } }).start();
|
||||
Thread.sleep(1000);
|
||||
stop = true;
|
||||
```
|
||||
|
||||
这也是 volatile 最常见的用法:一个线程写、其它线程读的状态标志。count++ 这类读-改-写操作仍然要用锁或者原子类。
|
||||
|
||||
#### 3. 有序性
|
||||
|
||||
有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
|
||||
|
||||
volatile 关键字通过添加内存屏障的方式来禁止重排序,即重排序时不能把后面的指令放到内存屏障之前。
|
||||
|
||||
以 x86 为例,处理器本身只会把写操作之后的读操作提前执行,其它重排序都不会发生。因此 HotSpot 只在 volatile 写之后插入一条带 lock 前缀的空操作(lock addl $0, [rsp - C])作为 StoreLoad 屏障:它之前的写入对其它处理器可见之后,后面的读操作才能执行。
|
||||
|
||||
也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
|
||||
|
||||
### 先行发生原则
|
||||
@@ -1590,6 +1626,10 @@ public final int getAndAddInt(Object o, long offset, int delta) {
|
||||
|
||||
JDK 8 中这段代码在 sun.misc.Unsafe 中,CAS 方法名为 compareAndSwapInt(),逻辑相同。
|
||||
|
||||
冲突激烈时,CAS 的代价会明显上升:很多线程同时更新同一个变量,这个变量所在的缓存行要在 CPU 核心之间来回传递,用 CAS 循环更新时失败重试的次数也越来越多。8 个线程各对同一个 AtomicLong 累加 1000 万次,共 8000 万次,耗时 3.5~5.1 秒;单线程累加 1000 万次约 70 毫秒,按此计算 8000 万次也只要约 0.56 秒(JDK 21 实测,见[附录 A4](#a4-atomiclong-与-longadder))。
|
||||
|
||||
LongAdder 把计数分散到多个 Cell 中,不同线程更新不同的 Cell,调用 sum() 时再把它们加起来,同样的 8 线程累加只用了 0.10~0.12 秒。代价是 sum() 返回的不是某一时刻的原子快照,所以它适合统计请求数这类计数,不适合需要根据当前值做判断的场景。
|
||||
|
||||
#### 3. ABA
|
||||
|
||||
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
|
||||
@@ -1766,6 +1806,8 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
子线程读不到父线程设置的 ThreadLocal 值。InheritableThreadLocal 会在创建子线程时把父线程的值复制过去,但只在创建时复制一次,而线程池中的线程创建之后会被反复使用。例如主线程先后设置 alice 和 bob,每次都向只有一个线程的线程池提交任务,两次读到的都是 alice(JDK 21 实测,见[附录 A5](#a5-inheritablethreadlocal-与线程池))。需要把上下文传给线程池中的任务时,可以在提交任务时显式传递,或者使用阿里开源的 TransmittableThreadLocal,它在提交任务时捕获当前的值,执行任务前设置,执行后恢复。
|
||||
|
||||
#### 3. 可重入代码(Reentrant Code)
|
||||
|
||||
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
|
||||
@@ -1924,6 +1966,73 @@ fair=false count=1600000 55 ms
|
||||
fair=true count=1600000 3019 ms
|
||||
```
|
||||
|
||||
### A3 volatile 与循环条件
|
||||
|
||||
JDK 21.0.13,Apple M1 Pro。worker 线程循环读取 stop 并计数,主线程 1 秒后把 stop 改为 true,再等待 worker 3 秒;不加参数时 stop 是普通字段,加参数 v 时读写 volatile 字段:
|
||||
|
||||
```text
|
||||
$ java StopFlag (运行 3 次)
|
||||
普通变量:主线程置为 true 3 秒后,worker 仍在运行
|
||||
普通变量:主线程置为 true 3 秒后,worker 仍在运行
|
||||
普通变量:主线程置为 true 3 秒后,worker 仍在运行
|
||||
$ java StopFlag v (运行 3 次)
|
||||
worker 结束,循环 1762145299 次
|
||||
volatile:主线程置为 true 3 秒后,worker 已结束
|
||||
worker 结束,循环 1745928341 次
|
||||
volatile:主线程置为 true 3 秒后,worker 已结束
|
||||
worker 结束,循环 1746228300 次
|
||||
volatile:主线程置为 true 3 秒后,worker 已结束
|
||||
```
|
||||
|
||||
### A4 AtomicLong 与 LongAdder
|
||||
|
||||
JDK 21.0.13,Apple M1 Pro(10 核)。每个线程累加 1000 万次,每种线程数运行 3 轮:
|
||||
|
||||
```java
|
||||
static long run(Runnable inc, int threads, int n) throws Exception {
|
||||
Thread[] ts = new Thread[threads];
|
||||
long t0 = System.nanoTime();
|
||||
for (int i = 0; i < threads; i++) {
|
||||
ts[i] = new Thread(() -> { for (int j = 0; j < n; j++) inc.run(); });
|
||||
ts[i].start();
|
||||
}
|
||||
for (Thread t : ts) t.join();
|
||||
return (System.nanoTime() - t0) / 1_000_000;
|
||||
}
|
||||
|
||||
// run(atomicLong::incrementAndGet, threads, 10_000_000) 与 run(longAdder::increment, threads, 10_000_000)
|
||||
```
|
||||
|
||||
```text
|
||||
线程 1 第 1 轮:AtomicLong 74 ms(结果 10000000),LongAdder 74 ms(结果 10000000)
|
||||
线程 1 第 2 轮:AtomicLong 70 ms(结果 10000000),LongAdder 71 ms(结果 10000000)
|
||||
线程 1 第 3 轮:AtomicLong 70 ms(结果 10000000),LongAdder 73 ms(结果 10000000)
|
||||
线程 8 第 1 轮:AtomicLong 3986 ms(结果 80000000),LongAdder 103 ms(结果 80000000)
|
||||
线程 8 第 2 轮:AtomicLong 5111 ms(结果 80000000),LongAdder 124 ms(结果 80000000)
|
||||
线程 8 第 3 轮:AtomicLong 3485 ms(结果 80000000),LongAdder 118 ms(结果 80000000)
|
||||
```
|
||||
|
||||
### A5 InheritableThreadLocal 与线程池
|
||||
|
||||
```java
|
||||
static final InheritableThreadLocal<String> USER = new InheritableThreadLocal<>();
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(1);
|
||||
for (String u : new String[]{"alice", "bob"}) {
|
||||
USER.set(u);
|
||||
new Thread(() -> System.out.println("新建线程中读到:" + USER.get())).start();
|
||||
Thread.sleep(100);
|
||||
pool.submit(() -> System.out.println("线程池中读到:" + USER.get())).get();
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
新建线程中读到:alice
|
||||
线程池中读到:alice
|
||||
新建线程中读到:bob
|
||||
线程池中读到:alice
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Object (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)
|
||||
@@ -1948,3 +2057,8 @@ fair=true count=1600000 3019 ms
|
||||
- [markOop.hpp | OpenJDK jdk8u](https://github.com/openjdk/jdk8u/blob/master/hotspot/src/share/vm/oops/markOop.hpp)
|
||||
- [JDK 23 Release Notes: Change LockingMode Default from LM_LEGACY to LM_LIGHTWEIGHT](https://www.oracle.com/java/technologies/javase/23-relnote-issues.html)
|
||||
- [Thread.State (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.State.html)
|
||||
- [The Java Language Specification, Java SE 21 Edition: Chapter 17. Threads and Locks](https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html)
|
||||
- [LongAdder (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/atomic/LongAdder.html)
|
||||
- [objectMonitor.hpp | OpenJDK jdk21u](https://github.com/openjdk/jdk21u/blob/master/src/hotspot/share/runtime/objectMonitor.hpp)
|
||||
- [assembler_x86.cpp | OpenJDK jdk21u](https://github.com/openjdk/jdk21u/blob/master/src/hotspot/cpu/x86/assembler_x86.cpp)
|
||||
- [TransmittableThreadLocal](https://github.com/alibaba/transmittable-thread-local)
|
||||
|
||||
+178
-10
@@ -38,6 +38,10 @@
|
||||
* [A2 逃逸分析](#a2-逃逸分析)
|
||||
* [A3 不同收集器的停顿](#a3-不同收集器的停顿)
|
||||
* [A4 堆大小对 G1 的影响](#a4-堆大小对-g1-的影响)
|
||||
* [A5 ThreadLocalMap 的弱引用](#a5-threadlocalmap-的弱引用)
|
||||
* [A6 默认的收集器与 G1 的 Region 大小](#a6-默认的收集器与-g1-的-region-大小)
|
||||
* [A7 打破双亲委派](#a7-打破双亲委派)
|
||||
* [A8 元空间溢出](#a8-元空间溢出)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
@@ -132,7 +136,7 @@ void pay(long orderId) {
|
||||
}
|
||||
```
|
||||
|
||||
方法返回后,栈帧中的 amount 和 order 立即消失;Order 对象如果没有被其它地方引用,会在之后的 GC 中被回收。操作系统中进程地址空间的栈和堆也是类似的划分:栈由编译器生成的代码自动分配和释放,堆由程序调用 malloc、free 管理。
|
||||
方法返回后,栈帧中的 amount 和 order 立即消失;Order 对象如果没有被其它地方引用,会在之后的 GC 中被回收。操作系统中进程地址空间的栈和堆也是类似的划分:栈由编译器生成的代码自动分配和释放,函数返回时局部变量就被释放,不会泄漏;堆由程序调用 malloc、free 管理,申请后忘记 free 就是内存泄漏。
|
||||
|
||||
需要注意的是,对象不一定都在堆中创建。JIT 编译时会做逃逸分析,如果一个对象不会离开创建它的方法(没有被返回、没有赋给字段、没有传给其它线程),HotSpot 会把它拆成几个局部变量,这称为标量替换,堆中不再创建这个对象。例如在循环中创建 2 亿个只在方法内部使用的 Point 对象,默认没有发生 GC,耗时约 130 ms;加上 -XX:-DoEscapeAnalysis 关闭逃逸分析后,发生了 13 次 GC,耗时约 335 ms(JDK 21 实测,见[附录 A2](#a2-逃逸分析))。
|
||||
|
||||
@@ -172,7 +176,7 @@ Instance size: 32 bytes
|
||||
- 实例数据:字段不按声明的顺序排列,虚拟机会重排以减少空隙,例如 userId 被放进了对象头后面 4 字节的空位;
|
||||
- 对齐填充:对象的大小必须是 8 字节的整数倍,new Object() 只有 12 字节的对象头,要补齐到 16 字节。
|
||||
|
||||
关闭压缩指针(-XX:-UseCompressedOops -XX:-UseCompressedClassPointers)后,类型指针和 note 引用都变成 8 字节,Order 从 32 字节变成 40 字节。压缩指针用 32 位存放对象地址,因为对象按 8 字节对齐,地址的低 3 位总是 0,可以省略,所以最多能表示 2<sup>32</sup> × 8 = 32 GB 的堆。堆超过 32 GB 时压缩指针自动关闭,所有引用都变大,这是堆的大小通常不超过 32 GB 的原因。
|
||||
关闭压缩指针(-XX:-UseCompressedOops -XX:-UseCompressedClassPointers)后,类型指针和 note 引用都变成 8 字节,Order 从 32 字节变成 40 字节。压缩指针用 32 位存放对象地址,因为对象按 8 字节对齐,地址的低 3 位总是 0,可以省略,所以最多能表示 2<sup>32</sup> × 8 = 32 GB 的堆。最大堆达到 32 GB 时,对象引用的压缩自动关闭,所有引用都变成 8 字节(JDK 21 中 -Xmx31g 时 UseCompressedOops 为 true,-Xmx32g 时为 false,类型指针的压缩不受影响),这是堆的大小通常小于 32 GB 的原因。
|
||||
|
||||
## 二、垃圾收集
|
||||
|
||||
@@ -209,12 +213,15 @@ public class Test {
|
||||
|
||||
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
|
||||
|
||||
Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:
|
||||
Java 虚拟机使用该算法来判断对象是否可被回收。
|
||||
|
||||
- 虚拟机栈中局部变量表中引用的对象;
|
||||
GC Roots 是堆外指向堆中对象的引用,程序不经过其它对象就能直接访问到它们,GC 无法判断这些引用以后还会不会被使用,只能认为它们引用的对象都存活。GC Roots 一般包含以下内容:
|
||||
|
||||
- 虚拟机栈中局部变量表中引用的对象,即正在执行的方法的参数和局部变量;
|
||||
- 本地方法栈中 JNI 中引用的对象;
|
||||
- 方法区中类静态属性引用的对象;
|
||||
- 方法区中的常量引用的对象。
|
||||
- 方法区中类静态属性引用的对象,类没有被卸载时,程序随时可以通过类名访问它们;
|
||||
- 方法区中的常量引用的对象,例如字符串常量池中的字符串;
|
||||
- 被 synchronized 持有的锁对象、已经启动且没有结束的线程,以及虚拟机内部的引用,例如系统类加载器、常驻的异常对象。
|
||||
|
||||
<div align="center"> <img src="pics/jvm-gc-roots.png" width="760px"/> </div><br>
|
||||
|
||||
@@ -269,7 +276,7 @@ obj = null; // 使对象只被软引用关联
|
||||
|
||||
#### 3. 弱引用
|
||||
|
||||
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾收集发生之前。
|
||||
只被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾收集发生之前。
|
||||
|
||||
使用 WeakReference 类来创建弱引用。
|
||||
|
||||
@@ -279,6 +286,12 @@ WeakReference<Object> wf = new WeakReference<Object>(obj);
|
||||
obj = null;
|
||||
```
|
||||
|
||||
ThreadLocal 就用到了弱引用。每个线程的 ThreadLocalMap 中,Entry 继承自 WeakReference,key(ThreadLocal 对象)是弱引用,value 是强引用。如果 key 是强引用,业务代码不再使用某个 ThreadLocal 后,它仍然被线程的 Map 引用着,线程池中的线程一直存活,这个 ThreadLocal 对象和它的 value 都无法回收。
|
||||
|
||||
key 改为弱引用后,ThreadLocal 对象在下一次 GC 时被回收,Entry 的 key 变为 null。之后这个线程再调用任意 ThreadLocal 的 set()、get()、remove() 时,会顺带清除遇到的一部分 key 为 null 的 Entry,value 才能被回收。在此之前 value 一直被强引用,所以用完之后仍然要调用 remove(),见 [线程本地存储](Java%20并发.md#2-线程本地存储thread-local-storage)。
|
||||
|
||||
例如向 ThreadLocal 放入一个 10 MB 的数组,再把 ThreadLocal 变量置为 null:GC 之后 Entry 的 key 变为 null,value 还在;当前线程再通过其它 ThreadLocal 调用 set(),第 2 次调用之后这个 Entry 才被清除(JDK 21 实测,见[附录 A5](#a5-threadlocalmap-的弱引用))。
|
||||
|
||||
#### 4. 虚引用
|
||||
|
||||
又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
|
||||
@@ -425,6 +438,10 @@ CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
|
||||
|
||||
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。JDK 9 起 G1 成为默认的垃圾收集器(JEP 248),CMS 在 JDK 14 中被移除(JEP 363)。
|
||||
|
||||
没有指定收集器时,JVM 按机器的配置选择:JDK 8 在服务器级的机器(至少 2 个处理器、约 2 GB 以上的物理内存)上默认使用 Parallel Scavenge 加 Parallel Old,否则使用 Serial;JDK 9 起把服务器级机器上的默认值改为 G1。
|
||||
|
||||
所以 JDK 21 在只有 1 个处理器的环境中,例如只分配了 1 个 CPU 的容器,默认使用的仍是 Serial(见[附录 A6](#a6-默认的收集器与-g1-的-region-大小))。JDK 27 起在所有环境中都默认使用 G1(JEP 523)。
|
||||
|
||||
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
|
||||
|
||||
<div align="center"> <img src="pics/gc-generations.png" width="740px"/> </div><br>
|
||||
@@ -433,6 +450,10 @@ G1 把堆划分成多个大小相等的独立区域(Region),新生代和
|
||||
|
||||
<div align="center"> <img src="pics/gc-g1-regions.png" width="740px"/> </div><br>
|
||||
|
||||
-XX:G1HeapRegionSize 设置的是每个 Region 的大小,取值为 2 的幂,范围为 1 MB~512 MB(JDK 17 及以前最大为 32 MB);Region 的数量由堆的大小决定。没有指定时,JVM 用最大堆除以 2048,向上取到 2 的幂,并限制在 1 MB~32 MB 之间,最大堆在 2 GB~64 GB 之间时 Region 约有 2048 个。例如 -Xmx4g 时 Region 为 2 MB,-Xmx16g 时为 8 MB,-Xmx64g 时为 32 MB(JDK 21 实测,见[附录 A6](#a6-默认的收集器与-g1-的-region-大小))。
|
||||
|
||||
Region 不区分固定的用途,一个 Region 回收后可以再作为 Eden、Survivor 或老年代使用;大小达到 Region 一半的对象是 Humongous 对象,直接分配在一组连续的 Region 中,见 [大对象直接进入老年代](#2-大对象直接进入老年代)。
|
||||
|
||||
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾收集。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 的回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
|
||||
|
||||
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
|
||||
@@ -479,7 +500,7 @@ G1 的新生代回收和混合回收都要在停顿中把存活对象复制到
|
||||
| 128 MB | 125 | 0.4 ms | 0.008 ms | 41 次,共 93.5 ms,最长 6.1 ms |
|
||||
| 512 MB | 46 | 0.2 ms | 0.009 ms | 0 次 |
|
||||
|
||||
ZGC 的停顿都在 0.01 ms 以内,比 G1 短两个数量级。但堆只有 128 MB 时,程序分配内存的速度超过了并发回收的速度,没有空闲的内存可用,分配内存的线程只能等待回收完成,日志中记为 Allocation Stall,对用户来说与停顿无异。所以使用 ZGC 要给堆留出足够的余量,代价是占用更多的内存,读屏障也会让吞吐量略有下降。
|
||||
这个程序中 ZGC 的停顿都在 0.01 ms 以内,比 G1 短两个数量级,官方给出的是停顿不超过 1 毫秒。但堆只有 128 MB 时,程序分配内存的速度超过了并发回收的速度,没有空闲的内存可用,分配内存的线程只能等待回收完成,日志中记为 Allocation Stall,对用户来说与停顿无异。所以使用 ZGC 要给堆留出足够的余量,代价是占用更多的内存,读屏障也会让吞吐量略有下降。
|
||||
|
||||
## 三、内存分配与回收策略
|
||||
|
||||
@@ -785,6 +806,36 @@ public abstract class ClassLoader {
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 打破双亲委派
|
||||
|
||||
双亲委派的逻辑写在 loadClass() 中,只是一种约定。自定义类加载器重写 loadClass(),先自己加载,找不到再交给父加载器,就打破了双亲委派:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
|
||||
synchronized (getClassLoadingLock(name)) {
|
||||
Class<?> c = findLoadedClass(name);
|
||||
if (c == null && !name.startsWith("java.")) {
|
||||
try {
|
||||
c = findClass(name); // 先在自己的目录中查找
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
}
|
||||
}
|
||||
if (c == null) c = super.loadClass(name, false); // 找不到再走双亲委派
|
||||
if (resolve) resolveClass(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需要打破双亲委派的常见场景:
|
||||
|
||||
- SPI:JDBC 的 DriverManager 由平台类加载器加载(JDK 8 中为启动类加载器),而 MySQL 驱动等实现类在 classpath 上,由应用类加载器加载,父加载器看不到子加载器中的类。DriverManager 通过 ServiceLoader 查找驱动,ServiceLoader 使用线程上下文类加载器(Thread.getContextClassLoader(),默认就是应用类加载器)加载实现类,相当于父加载器委托子加载器加载类;
|
||||
- Tomcat:每个 Web 应用有自己的 WebappClassLoader,先在本应用的 /WEB-INF/classes 和 /WEB-INF/lib 中查找,找不到再交给父加载器,这样同一个 Tomcat 中的两个应用可以使用同一个库的不同版本;JDK 的核心类和 Servlet 等规范接口仍然先交给父加载器;
|
||||
- 热部署:同一个类加载器不能重复定义同一个类,修改后的类要用新的类加载器加载,旧的加载器连同它加载的类一起丢弃,OSGi 等模块系统也为每个模块使用单独的加载器。
|
||||
|
||||
例如两个上面这样的类加载器分别从 v1、v2 目录加载同名的 Greeter 类,在同一个 JVM 中得到两个不同的 Class 对象,分别输出 Greeter v1 和 Greeter v2。打破双亲委派也不能替换 JDK 的核心类,defineClass() 拒绝定义 java. 开头的包中的类,抛出 SecurityException: Prohibited package name: java.lang(JDK 21 实测,见[附录 A7](#a7-打破双亲委派))。
|
||||
|
||||
### 自定义类加载器实现
|
||||
|
||||
以下代码中的 FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。
|
||||
@@ -875,12 +926,14 @@ OutOfMemoryError 的详细信息指明了是哪一块内存不足,常见的有
|
||||
|
||||
- Java heap space:堆中无法再分配对象,可能是堆设置得太小,也可能是内存泄漏;
|
||||
- GC overhead limit exceeded:GC 占用了约 98% 的时间,却只回收了不到 2% 的堆,说明存活的对象几乎占满了堆;
|
||||
- Metaspace:加载的类太多,元空间超过了 MaxMetaspaceSize,常见于运行时动态生成大量的类。
|
||||
- Metaspace:加载的类太多,元空间超过了 MaxMetaspaceSize,常见于运行时动态生成大量的类,例如 CGLIB 生成代理类、Groovy 脚本每次执行都编译出新类,以及热部署时旧的类加载器仍被引用,它加载的类无法卸载。
|
||||
|
||||
例如每次都创建一个新的类加载器重新定义 Plugin 类,并把得到的 Class 对象保存在列表中,-XX:MaxMetaspaceSize=16m 时定义了约 2600 个类后抛出 OutOfMemoryError: Metaspace;不保存 Class 对象时,10 万次定义中有约 9.8 万个类被卸载,没有溢出(JDK 21 实测,见[附录 A8](#a8-元空间溢出))。类只有在它的类加载器被回收后才能卸载,见 [方法区的回收](#3-方法区的回收)。
|
||||
|
||||
内存泄漏指的是对象已经不再使用,但仍然被引用着,GC 无法回收,堆内存持续增长,最终导致内存溢出。常见原因:
|
||||
|
||||
- 静态集合只添加不清理,例如用 static Map 做缓存却没有淘汰策略;
|
||||
- 线程池中的线程使用 ThreadLocal 后没有调用 remove(),线程一直存活,值也一直无法被回收;
|
||||
- 线程池中的线程使用 ThreadLocal 后没有调用 remove(),线程一直存活,值也一直无法被回收,见 [弱引用](#3-弱引用);
|
||||
- 连接、流等资源没有关闭;
|
||||
- 注册的监听器、回调没有注销。
|
||||
|
||||
@@ -926,6 +979,8 @@ Heap dump file created [19154891 bytes in 0.007 secs]
|
||||
at Busy.lambda$main$0(Busy.java:3)
|
||||
```
|
||||
|
||||
需要注意的是,Linux 的平均负载(load average,top 和 uptime 显示的三个数)统计的不只是正在运行和等待 CPU 的任务(R 状态),还包括不可中断睡眠的任务(D 状态,通常在等待磁盘 I/O)。所以 CPU 利用率低而负载很高时,通常是大量线程阻塞在 I/O 上,例如磁盘读写过慢、NFS 挂载无响应。这时 top 中的 wa(等待 I/O 的时间占比)和 vmstat 中的 b 列(阻塞在 I/O 上的进程数)偏高,可以用 ps -eLo stat,pid,tid,comm 找出状态为 D 的线程。
|
||||
|
||||
### 死锁
|
||||
|
||||
jstack 会自动检测死锁,在输出的末尾给出互相等待的线程,以及每个线程持有和等待的锁:
|
||||
@@ -1028,6 +1083,109 @@ $ java -Xmx256m -Xms256m -XX:+UseG1GC -Xlog:gc:file=tune-256m.log Tune
|
||||
G1 256m:停顿 43 次(Young 43,Full 0),共 42.7 ms,最长 1.223 ms
|
||||
```
|
||||
|
||||
### A5 ThreadLocalMap 的弱引用
|
||||
|
||||
用反射在当前线程的 ThreadLocalMap 中找到 value 为 session 的 Entry,运行时需要加上 --add-opens java.base/java.lang=ALL-UNNAMED:
|
||||
|
||||
```java
|
||||
byte[] session = new byte[10 * 1024 * 1024];
|
||||
ThreadLocal<byte[]> tl = new ThreadLocal<>();
|
||||
tl.set(session);
|
||||
tl = null; // 业务代码不再引用这个 ThreadLocal
|
||||
System.gc();
|
||||
for (int i = 1; i <= 64; i++) {
|
||||
new ThreadLocal<>().set(i); // 其它 ThreadLocal 的 set,每次之后检查 Entry,被清除时停止
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
set 之后: Entry 还在,key=java.lang.ThreadLocal@31befd9f,value 还被引用
|
||||
GC 之后: Entry 还在,key=null,value 还被引用
|
||||
第 2 次 set 后:Entry 已被清除
|
||||
```
|
||||
|
||||
运行 3 次,都是在第 2 次 set() 之后被清除。
|
||||
|
||||
### A6 默认的收集器与 G1 的 Region 大小
|
||||
|
||||
本机有 10 个处理器,-XX:ActiveProcessorCount=1 让 JVM 认为只有 1 个处理器:
|
||||
|
||||
```text
|
||||
$ java -XX:+PrintFlagsFinal -version | grep -E "Use(Serial|Parallel|G1|Z)GC " | awk '$4=="true"{print $2}'
|
||||
UseG1GC
|
||||
$ java -XX:ActiveProcessorCount=1 -XX:+PrintFlagsFinal -version | grep -E "Use(Serial|Parallel|G1|Z)GC " | awk '$4=="true"{print $2}'
|
||||
UseSerialGC
|
||||
```
|
||||
|
||||
不同最大堆下的 G1HeapRegionSize(字节):
|
||||
|
||||
```text
|
||||
-Xmx128m: 1048576
|
||||
-Xmx1g: 1048576
|
||||
-Xmx4g: 2097152
|
||||
-Xmx16g: 8388608
|
||||
-Xmx64g: 33554432
|
||||
$ java -Xmx1g -XX:G1HeapRegionSize=64m -XX:+PrintFlagsFinal -version | grep -w G1HeapRegionSize
|
||||
size_t G1HeapRegionSize = 67108864 {product} {command line}
|
||||
$ java -Xmx1g -XX:G1HeapRegionSize=3m -XX:+PrintFlagsFinal -version | grep -w G1HeapRegionSize
|
||||
size_t G1HeapRegionSize = 4194304 {product} {command line, ergonomic}
|
||||
$ java -Xmx4g -Xlog:gc+init -version
|
||||
[0.005s][info][gc,init] Heap Region Size: 2M
|
||||
```
|
||||
|
||||
指定 3m 时向上取为 4 MB。
|
||||
|
||||
### A7 打破双亲委派
|
||||
|
||||
DemoDriver 实现了 java.sql.Driver,在静态语句块中向 DriverManager 注册自己,并在 META-INF/services/java.sql.Driver 中登记;v1、v2 目录中各有一个 Greeter.class,hello() 分别返回 Greeter v1 和 Greeter v2;ChildFirstLoader 的 loadClass() 即正文中的代码:
|
||||
|
||||
```java
|
||||
System.out.println("DriverManager 的加载器: " + DriverManager.class.getClassLoader());
|
||||
System.out.println("线程上下文类加载器: " + Thread.currentThread().getContextClassLoader());
|
||||
Driver d = DriverManager.getDriver("jdbc:demo:orders");
|
||||
System.out.println("找到的驱动 " + d.getClass().getName() + ",加载器: " + d.getClass().getClassLoader());
|
||||
|
||||
ClassLoader app = BreakDemo.class.getClassLoader();
|
||||
Class<?> g1 = new ChildFirstLoader(Path.of("../v1"), app).loadClass("Greeter");
|
||||
Class<?> g2 = new ChildFirstLoader(Path.of("../v2"), app).loadClass("Greeter");
|
||||
|
||||
// 用一个普通的类加载器把 Greeter 的字节码定义为 java.lang.Greeter
|
||||
new ClassLoader(app) {
|
||||
Class<?> def() { return defineClass("java.lang.Greeter", b, 0, b.length); }
|
||||
}.def();
|
||||
```
|
||||
|
||||
```text
|
||||
DriverManager 的加载器: jdk.internal.loader.ClassLoaders$PlatformClassLoader@55f96302
|
||||
线程上下文类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5
|
||||
找到的驱动 DemoDriver,加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5
|
||||
Greeter v1 / Greeter v2,g1 == g2: false
|
||||
java.lang.SecurityException: Prohibited package name: java.lang
|
||||
```
|
||||
|
||||
### A8 元空间溢出
|
||||
|
||||
```java
|
||||
static class OneShotLoader extends ClassLoader {
|
||||
Class<?> define(byte[] b) { return defineClass("Plugin", b, 0, b.length); }
|
||||
}
|
||||
|
||||
byte[] bytes = Files.readAllBytes(Path.of("Plugin.class"));
|
||||
List<Class<?>> loaded = new ArrayList<>();
|
||||
while (true) loaded.add(new OneShotLoader().define(bytes)); // MetaLeak:一直持有,类无法卸载
|
||||
|
||||
for (int i = 0; i < 100_000; i++) new OneShotLoader().define(bytes); // MetaNoLeak:不持有
|
||||
```
|
||||
|
||||
```text
|
||||
$ java -XX:MaxMetaspaceSize=16m MetaLeak
|
||||
加载了 2639 个 Plugin 类后:Metaspace
|
||||
$ java -XX:MaxMetaspaceSize=16m -Xlog:class+unload=info:file=unload.log MetaNoLeak
|
||||
加载了 100000 个 Plugin 类,没有溢出
|
||||
$ grep -c "unloading class Plugin" unload.log
|
||||
97643
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Troubleshooting Memory Leaks (Java SE 21)](https://docs.oracle.com/en/java/javase/21/troubleshoot/troubleshooting-memory-leaks.html)
|
||||
@@ -1053,3 +1211,13 @@ G1 256m:停顿 43 次(Young 43,Full 0),共 42.7 ms,最长 1.223 ms
|
||||
- [JEP 421: Deprecate Finalization for Removal](https://openjdk.org/jeps/421)
|
||||
- [JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector](https://openjdk.org/jeps/363)
|
||||
- [JEP 379: Shenandoah: A Low-Pause-Time Garbage Collector (Production)](https://openjdk.org/jeps/379)
|
||||
- [JEP 248: Make G1 the Default Garbage Collector](https://openjdk.org/jeps/248)
|
||||
- [JEP 523: Make G1 the Default Garbage Collector in All Environments](https://openjdk.org/jeps/523)
|
||||
- [Ergonomics | Java SE 8 HotSpot Virtual Machine Garbage Collection Tuning Guide](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html)
|
||||
- [JDK 18 Release Notes: Allow G1 Heap Regions up to 512MB](https://www.oracle.com/java/technologies/javase/18-relnote-issues.html)
|
||||
- [Eclipse Memory Analyzer: Garbage Collection Roots](https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/concepts/gcroots.html)
|
||||
- [Apache Tomcat 10.1: Class Loader How-To](https://tomcat.apache.org/tomcat-10.1-doc/class-loader-howto.html)
|
||||
- [JDK 21 源码:ThreadLocal.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/lang/ThreadLocal.java)
|
||||
- [JDK 21 源码:ServiceLoader.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/util/ServiceLoader.java)
|
||||
- [proc_loadavg(5) — Linux manual page](https://man7.org/linux/man-pages/man5/proc_loadavg.5.html)
|
||||
- [vmstat(8) — Linux manual page](https://man7.org/linux/man-pages/man8/vmstat.8.html)
|
||||
|
||||
Reference in New Issue
Block a user