Merge pull request #1499 from CyC2018/update-java-notes-3

Update Java notes
This commit is contained in:
CyC2018
2026-10-28 22:54:06 +08:00
committed by GitHub
5 changed files with 529 additions and 313 deletions
+255
View File
@@ -0,0 +1,255 @@
# Java 容器 - 进阶
<!-- GFM-TOC -->
* [Java 容器 - 进阶](#java-容器---进阶)
* [三、源码分析](#三源码分析)
* [ArrayList](#arraylist)
* [HashMap](#hashmap)
* [ConcurrentHashMap](#concurrenthashmap)
* [WeakHashMap](#weakhashmap)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
## 三、源码分析
以下源码基于 JDK 1.8,ConcurrentHashMap 的 size 操作基于 JDK 1.7。
### ArrayList
#### 序列化
主线见[Java 容器 · ArrayList 的序列化](Java%20容器.md#4-序列化)。
ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。
保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化。
```java
transient Object[] elementData; // non-private to simplify nested class access
```
ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。
```java
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
// Read in size, and any hidden stuff
s.defaultReadObject();
// Read in capacity
s.readInt(); // ignored
if (size > 0) {
// be like clone(), allocate array based upon size not capacity
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
```
```java
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
```
序列化时需要使用 ObjectOutputStream 的 writeObject() 将对象转换为字节流并输出。而 writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的 writeObject() 来实现序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理类似。
```java
ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
```
### HashMap
#### 计算数组容量
主线见[Java 容器 · 计算数组容量](Java%20容器.md#7-计算数组容量)。
HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。
先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:
```text
mask |= mask >> 1 11011000
mask |= mask >> 2 11111110
mask |= mask >> 4 11111111
```
mask+1 是大于原始数字的最小的 2 的 n 次方。
```text
num 10010000
mask+1 100000000
```
以下是 HashMap 中计算数组容量的代码:
```java
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
```
### ConcurrentHashMap
#### size 操作
这是 JDK 1.7 的实现,主线见[Java 容器 · size 操作](Java%20容器.md#2-size-操作),JDK 1.8 的计数方式见[JDK 1.8 的改动](Java%20容器.md#3-jdk-18-的改动)。
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
```java
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
```
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
```java
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
```
### WeakHashMap
#### ConcurrentCache
主线见[Java 容器 · WeakHashMap](Java%20容器.md#weakhashmap)。
Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。
ConcurrentCache 采取的是分代缓存:
- 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
- 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收;
- 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收;
- 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
```java
public final class ConcurrentCache<K, V> {
private final int size;
private final Map<K, V> eden;
private final Map<K, V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}
public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}
```
## 参考资料
- [JDK 8 源码 java.util](https://github.com/openjdk/jdk8u/tree/master/jdk/src/share/classes/java/util)
- [JDK 21 源码:ArrayList.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/util/ArrayList.java)
- [JDK 21 源码:HashMap.java](https://github.com/openjdk/jdk21u/blob/master/src/java.base/share/classes/java/util/HashMap.java)
- [探索 ConcurrentHashMap 高并发性的实现机制](https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/)
- [The principle of ConcurrentHashMap analysis](http://www.programering.com/a/MDO3QDNwATM.html)
+38 -240
View File
@@ -9,9 +9,9 @@
* [适配器模式](#适配器模式)
* [三、源码分析](#三源码分析)
* [ArrayList](#arraylist)
* [LinkedList](#linkedlist)
* [Vector](#vector)
* [CopyOnWriteArrayList](#copyonwritearraylist)
* [LinkedList](#linkedlist)
* [HashMap](#hashmap)
* [ConcurrentHashMap](#concurrenthashmap)
* [LinkedHashMap](#linkedhashmap)
@@ -200,74 +200,13 @@ public E remove(int index) {
#### 4. 序列化
ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。
保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化。
```java
transient Object[] elementData; // non-private to simplify nested class access
```
ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。
```java
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
// Read in size, and any hidden stuff
s.defaultReadObject();
// Read in capacity
s.readInt(); // ignored
if (size > 0) {
// be like clone(), allocate array based upon size not capacity
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
```
```java
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
```
序列化时需要使用 ObjectOutputStream 的 writeObject() 将对象转换为字节流并输出。而 writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的 writeObject() 来实现序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理类似。
```java
ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
```
保存元素的数组 elementData 用 transient 修饰,不参与默认的序列化。ArrayList 实现了 writeObject() 和 readObject(),只序列化前 size 个实际存放的元素,数组中空闲的位置不写出;反序列化时按 size 而不是原来的容量分配数组。源码见[进阶:序列化](Java%20容器%20-%20进阶.md#序列化)。
#### 5. Fail-Fast
modCount 用来记录 ArrayList 结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。
在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationException。代码参考上节序列化中的 writeObject() 方法。
在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationException。代码参考[进阶:序列化](Java%20容器%20-%20进阶.md#序列化)中的 writeObject() 方法。
最常见的情况是在 foreach 循环中删除元素。foreach 编译后使用迭代器,list.remove() 改变了 modCount,迭代器下一次调用 next() 时就会抛出异常。应该使用迭代器自己的 remove(),或者 removeIf():
@@ -285,6 +224,38 @@ list.removeIf(s -> s.equals("a"));
```
### LinkedList
#### 1. 概览
基于双向链表实现,使用 Node 存储链表节点信息。
```java
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
```
每个链表存储了 first 和 last 指针:
```java
transient Node<E> first;
transient Node<E> last;
```
<div align="center"> <img src="pics/jcol-linkedlist.png" width="760px"/> </div><br>
#### 2. 与 ArrayList 的比较
ArrayList 基于动态数组实现,LinkedList 基于双向链表实现。ArrayList 和 LinkedList 的区别可以归结为数组和链表的区别:
- 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
- 链表不支持随机访问,但插入删除只需要改变指针。
需要注意的是,链表在中间插入前要先遍历到插入位置,这一步也是 O(N) 的,而且要逐个访问分散在内存中的节点;数组移动元素用的是 System.arraycopy(),一次复制一整块连续内存。例如在 10 万个元素的中间插入 1 万次,ArrayList 约 50 ms,LinkedList 约 0.8~1 秒;只有在头部插入时 LinkedList 才明显更快(JDK 21 实测,见[附录 A1](#a1-arraylist-的扩容与常见陷阱))。所以大多数场景下应该使用 ArrayList,需要在头尾频繁插入删除时使用 ArrayDeque 或 LinkedList。
### Vector
#### 1. 同步
@@ -425,38 +396,6 @@ CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读
所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。
### LinkedList
#### 1. 概览
基于双向链表实现,使用 Node 存储链表节点信息。
```java
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
```
每个链表存储了 first 和 last 指针:
```java
transient Node<E> first;
transient Node<E> last;
```
<div align="center"> <img src="pics/jcol-linkedlist.png" width="760px"/> </div><br>
#### 2. 与 ArrayList 的比较
ArrayList 基于动态数组实现,LinkedList 基于双向链表实现。ArrayList 和 LinkedList 的区别可以归结为数组和链表的区别:
- 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
- 链表不支持随机访问,但插入删除只需要改变指针。
需要注意的是,链表在中间插入前要先遍历到插入位置,这一步也是 O(N) 的,而且要逐个访问分散在内存中的节点;数组移动元素用的是 System.arraycopy(),一次复制一整块连续内存。例如在 10 万个元素的中间插入 1 万次,ArrayList 约 50 ms,LinkedList 约 0.8~1 秒;只有在头部插入时 LinkedList 才明显更快(JDK 21 实测,见[附录 A1](#a1-arraylist-的扩容与常见陷阱))。所以大多数场景下应该使用 ArrayList,需要在头尾频繁插入删除时使用 ArrayDeque 或 LinkedList。
### HashMap
为了便于理解,以下源码分析以 JDK 1.7 为主。JDK 1.8 主要有以下变化,JDK 21 与 JDK 1.8 相同:
@@ -811,36 +750,7 @@ JDK 1.8 的 resize() 就利用了这个性质:遍历旧桶中的链表,按 (
#### 7. 计算数组容量
HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。
先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:
```text
mask |= mask >> 1 11011000
mask |= mask >> 2 11111110
mask |= mask >> 4 11111111
```
mask+1 是大于原始数字的最小的 2 的 n 次方。
```text
num 10010000
mask+1 100000000
```
以下是 HashMap 中计算数组容量的代码:
```java
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
```
HashMap 构造函数允许传入的容量不是 2 的 n 次方,tableSizeFor() 会把它向上取为大于等于它的最小的 2 的 n 次方,例如传入 17 时容量为 32,传入 16 时仍为 16。这个计算只用移位和或运算,过程见[进阶:计算数组容量](Java%20容器%20-%20进阶.md#计算数组容量)。
#### 8. 链表转红黑树
@@ -920,75 +830,7 @@ static final int DEFAULT_CONCURRENCY_LEVEL = 16;
#### 2. size 操作
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
```java
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
```
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
```java
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
```
每个 Segment 用 count 记录自己的键值对个数,size() 要把所有 Segment 的 count 加起来。它先不加锁统计,相邻两次统计时各 Segment 的 modCount 之和相同,就认为结果正确;不加锁尝试 3 次仍不一致,再对所有 Segment 加锁后统计。源码见[进阶:size 操作](Java%20容器%20-%20进阶.md#size-操作)。
#### 3. JDK 1.8 的改动
@@ -1153,51 +995,7 @@ WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>
```
#### ConcurrentCache
Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。
ConcurrentCache 采取的是分代缓存:
- 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
- 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收;
- 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收;
- 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
```java
public final class ConcurrentCache<K, V> {
private final int size;
private final Map<K, V> eden;
private final Map<K, V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}
public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}
```
Tomcat 的 ConcurrentCache 就是这样用的:常用的对象放在 ConcurrentHashMap 中,不常用的对象放在 WeakHashMap 中,由垃圾收集器回收,实现见[进阶:ConcurrentCache](Java%20容器%20-%20进阶.md#concurrentcache)。
## 附录:实测
+143 -33
View File
@@ -7,11 +7,11 @@
* [继承 Thread 类](#继承-thread-类)
* [实现接口 VS 继承 Thread](#实现接口-vs-继承-thread)
* [二、基础线程机制](#二基础线程机制)
* [Executor](#executor)
* [ThreadPoolExecutor](#threadpoolexecutor)
* [Daemon](#daemon)
* [sleep()](#sleep)
* [yield()](#yield)
* [Executor](#executor)
* [ThreadPoolExecutor](#threadpoolexecutor)
* [三、中断](#三中断)
* [InterruptedException](#interruptedexception)
* [interrupted()](#interrupted)
@@ -82,6 +82,8 @@
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以理解为任务是通过线程驱动从而执行的。
这里的线程都是平台线程,每个对应一个操作系统线程。JDK 21 的虚拟线程由 JVM 调度到少量平台线程上运行,见[协程与虚拟线程](计算机操作系统%20-%20进程管理.md#5-协程)。
### 实现 Runnable 接口
需要实现接口中的 run() 方法。
@@ -157,6 +159,49 @@ public static void main(String[] args) {
## 二、基础线程机制
### Daemon
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main() 属于非守护线程。
在线程启动之前使用 setDaemon() 方法可以将一个线程设置为守护线程。
```java
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
```
### sleep()
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
```java
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
```
### yield()
对静态方法 Thread.yield() 的调用表示当前线程愿意让出 CPU,可以切换给其它线程来执行。该方法只是对线程调度器的一个提示,调度器可以忽略它,Javadoc 也说明很少有适合使用它的场合,主要用于调试和测试。
```java
public void run() {
Thread.yield();
}
```
### Executor
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
@@ -249,48 +294,62 @@ for (int i = 1; i <= 7; i++) {
估算值只是起点,最终要通过压测确定。上线后监控活跃线程数、队列长度和拒绝次数,必要时调用 setCorePoolSize()、setMaximumPoolSize() 在运行时调整。
### Daemon
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main() 属于非守护线程。
在线程启动之前使用 setDaemon() 方法可以将一个线程设置为守护线程。
线程池的核心结构并不复杂,常被要求手写一个简化版:固定数量的工作线程循环从阻塞队列中取任务执行,队列满了或者线程池已经关闭时拒绝新任务。
```java
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
```
public class SimplePool {
private final BlockingQueue<Runnable> queue;
private final List<Thread> workers = new ArrayList<>();
private volatile boolean shutdown = false;
### sleep()
public SimplePool(int threads, int capacity) {
queue = new ArrayBlockingQueue<>(capacity);
for (int i = 0; i < threads; i++) {
Thread t = new Thread(() -> {
// 关闭后也要把队列中剩下的任务执行完再退出
while (!shutdown || !queue.isEmpty()) {
Runnable task;
try {
task = queue.poll(100, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
return;
}
if (task == null) {
continue;
}
try {
task.run();
} catch (RuntimeException e) {
// 一个任务抛出异常,不能让工作线程退出
e.printStackTrace();
}
}
}, "pool-" + i);
workers.add(t);
t.start();
}
}
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
public void execute(Runnable task) {
if (shutdown || !queue.offer(task)) {
throw new RejectedExecutionException("线程池已关闭或队列已满");
}
}
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
```java
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
public void shutdown() {
shutdown = true;
}
}
```
### yield()
例如 2 个线程执行 4 个任务,其中任务 2 抛出异常,其余 3 个任务仍由这两个线程执行完;关闭之后再提交会被拒绝(JDK 21 实测,见[附录 A6](#a6-简化版线程池))。
对静态方法 Thread.yield() 的调用表示当前线程愿意让出 CPU,可以切换给其它线程来执行。该方法只是对线程调度器的一个提示,调度器可以忽略它,Javadoc 也说明很少有适合使用它的场合,主要用于调试和测试。
ThreadPoolExecutor 的结构与它相同,多做了以下几件事:
```java
public void run() {
Thread.yield();
}
```
- 线程不是一开始就全部创建,而是按前面的提交顺序按需创建;
- 工作线程在 getTask() 中取任务:线程数超过核心线程数时用 poll(keepAliveTime) 等待,超时就退出,否则用 take() 一直等待;
- 运行状态和线程数保存在同一个 AtomicInteger(ctl)中,两者可以在一次 CAS 中一起修改;
- 用 execute() 提交的任务抛出异常时,执行它的线程退出,线程池再补一个新线程。
## 三、中断
@@ -2033,6 +2092,57 @@ for (String u : new String[]{"alice", "bob"}) {
线程池中读到:alice
```
### A6 简化版线程池
环境:JDK 21.0.13,Apple M1 Pro。正文中的 SimplePool 加上 awaitTermination()(依次 join 所有工作线程),用下面的代码运行 3 次:
```java
SimplePool pool = new SimplePool(2, 10);
for (int i = 1; i <= 4; i++) {
int id = i;
pool.execute(() -> {
if (id == 2) {
throw new IllegalStateException("任务 2 出错");
}
System.out.println("任务 " + id + " 由 " + Thread.currentThread().getName() + " 执行");
});
}
pool.shutdown();
try {
pool.execute(() -> {});
} catch (RejectedExecutionException e) {
System.out.println("关闭后提交:" + e.getMessage());
}
pool.awaitTermination();
System.out.println("全部完成");
```
```text
== run 1
java.lang.IllegalStateException: 任务 2 出错
关闭后提交:线程池已关闭或队列已满
任务 3 由 pool-0 执行
任务 1 由 pool-1 执行
任务 4 由 pool-0 执行
全部完成
== run 2
java.lang.IllegalStateException: 任务 2 出错
关闭后提交:线程池已关闭或队列已满
任务 1 由 pool-0 执行
任务 4 由 pool-0 执行
任务 3 由 pool-1 执行
全部完成
== run 3
java.lang.IllegalStateException: 任务 2 出错
关闭后提交:线程池已关闭或队列已满
任务 3 由 pool-1 执行
任务 4 由 pool-1 执行
任务 1 由 pool-0 执行
全部完成
```
异常的堆栈只保留了第一行。任务执行的先后和线程的分配每次不同,但每次都是 3 个任务完成、关闭后的提交被拒绝。ThreadPoolExecutor 的 ctl、getTask() 中的 poll/take 见 JDK 21 源码 java/util/concurrent/ThreadPoolExecutor.java。
## 参考资料
- [Object (Java SE 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html)
+41 -40
View File
@@ -29,8 +29,8 @@
* [双亲委派模型](#双亲委派模型)
* [自定义类加载器实现](#自定义类加载器实现)
* [五、线上问题排查](#五线上问题排查)
* [JVM 调优](#jvm-调优)
* [内存溢出与内存泄漏](#内存溢出与内存泄漏)
* [JVM 调优](#jvm-调优)
* [CPU 占用过高](#cpu-占用过高)
* [死锁](#死锁)
* [附录:实测](#附录实测)
@@ -54,7 +54,7 @@
### 程序计数器
记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。
记录正在执行的虚拟机字节码指令的地址。如果正在执行的是本地方法,它的值是未定义的(JVM 规范 2.5.1 节)。
### Java 虚拟机栈
@@ -438,10 +438,6 @@ 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>
@@ -482,6 +478,10 @@ Region 不区分固定的用途,一个 Region 回收后可以再作为 Eden、
G1 停顿的次数更多,但每次都很短,适合对响应时间敏感的服务。
没有指定收集器时,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)。
#### 8. ZGC 收集器
ZGC 的目标是让停顿时间不随堆的大小增长,JDK 15 起可以用于生产(JEP 377),JDK 21 加入了分代模式(JEP 439),JDK 23 起分代成为默认模式(JEP 474),JDK 24 移除了不分代的模式(JEP 490)。
@@ -551,7 +551,7 @@ G1 的新生代回收和混合回收都要在停顿中把存活对象复制到
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。需要注意的是,使用 G1 时不要用 -Xmn 固定新生代的大小,因为 G1 正是靠调整新生代的大小来满足停顿时间目标的(Oracle GC 调优指南)。
#### 3. 空间分配担保失败
@@ -887,39 +887,6 @@ public class FileSystemClassLoader extends ClassLoader {
## 五、线上问题排查
### JVM 调优
调优之前先明确目标:在线服务关心最长停顿时间,批处理任务关心吞吐量。大多数情况下先使用默认的 G1,开启 GC 日志观察,确实有问题再调整参数;线上的 GC 问题大多是代码造成的,例如内存泄漏、一次查询加载了太多数据,只调参数解决不了。
常用的参数:
| 参数 | 作用 |
| --- | --- |
| -Xms、-Xmx | 堆的初始大小和最大值,线上通常设为相同,避免运行中扩容和收缩 |
| -XX:MaxRAMPercentage | 不指定 -Xmx 时,最大堆占物理内存(容器中为容器的内存限制)的比例,默认 25% |
| -Xss | 每个线程的栈大小 |
| -XX:MaxMetaspaceSize | 元空间的上限,默认不限制 |
| -XX:+UseG1GC、-XX:+UseZGC | 选择垃圾收集器,JDK 9 起默认 G1 |
| -XX:MaxGCPauseMillis | G1 的停顿时间目标,默认 200 ms |
| -Xlog:gc*:file=gc.log | 输出 GC 日志(JDK 9 起;JDK 8 使用 -XX:+PrintGCDetails -Xloggc:gc.log) |
| -XX:+HeapDumpOnOutOfMemoryError | 内存溢出时保存堆转储 |
例如一个程序长期持有约 60 MB 的对象,同时不断创建短命的对象,用 G1 分别以 72 MB 和 256 MB 的堆运行(JDK 21 实测,见[附录 A4](#a4-堆大小对-g1-的影响)):
| 堆大小 | 新生代回收 | Full GC | 停顿总时长 | 最长停顿 | 运行时间 |
| --- | --- | --- | --- | --- | --- |
| 72 MB | 454 次 | 6 次 | 168.1 ms | 5.88 ms | 398 ms |
| 256 MB | 43 次 | 0 次 | 42.7 ms | 1.22 ms | 317 ms |
堆太小时,存活的对象几乎占满了老年代,日志中反复出现 Pause Full (G1 Compaction Pause) 71M->27M(72M):每次 Full GC 后仍有一半以上的空间被占用,很快又满了。
线上频繁 Full GC 的排查思路:
1. 用 jstat -gcutil 观察老年代使用率(O 列)和 Full GC 次数(FGC 列);
2. Full GC 之后老年代使用率仍然很高,并且越来越高,说明对象一直无法回收,导出堆转储查找内存泄漏;
3. Full GC 之后老年代能降下来,说明堆对于正常的负载偏小,或者有大对象、大量对象过早进入老年代,可以增大堆、调整新生代大小,或者从代码上减少一次创建的数据量;
4. 元空间不足也会触发 Full GC,日志中的原因为 Metadata GC Threshold,检查是否在动态生成大量的类。
### 内存溢出与内存泄漏
OutOfMemoryError 的详细信息指明了是哪一块内存不足,常见的有:
@@ -964,6 +931,39 @@ Dumping heap to java_pid24607.hprof ...
Heap dump file created [19154891 bytes in 0.007 secs]
```
### JVM 调优
调优之前先明确目标:在线服务关心最长停顿时间,批处理任务关心吞吐量。大多数情况下先使用默认的 G1,开启 GC 日志观察,确实有问题再调整参数;线上的 GC 问题大多是代码造成的,例如内存泄漏、一次查询加载了太多数据,只调参数解决不了。
常用的参数:
| 参数 | 作用 |
| --- | --- |
| -Xms、-Xmx | 堆的初始大小和最大值,线上通常设为相同,避免运行中扩容和收缩 |
| -XX:MaxRAMPercentage | 不指定 -Xmx 时,最大堆占物理内存(容器中为容器的内存限制)的比例,默认 25% |
| -Xss | 每个线程的栈大小 |
| -XX:MaxMetaspaceSize | 元空间的上限,默认不限制 |
| -XX:+UseG1GC、-XX:+UseZGC | 选择垃圾收集器,JDK 9 起默认 G1 |
| -XX:MaxGCPauseMillis | G1 的停顿时间目标,默认 200 ms |
| -Xlog:gc*:file=gc.log | 输出 GC 日志(JDK 9 起;JDK 8 使用 -XX:+PrintGCDetails -Xloggc:gc.log) |
| -XX:+HeapDumpOnOutOfMemoryError | 内存溢出时保存堆转储 |
例如一个程序长期持有约 60 MB 的对象,同时不断创建短命的对象,用 G1 分别以 72 MB 和 256 MB 的堆运行(JDK 21 实测,见[附录 A4](#a4-堆大小对-g1-的影响)):
| 堆大小 | 新生代回收 | Full GC | 停顿总时长 | 最长停顿 | 运行时间 |
| --- | --- | --- | --- | --- | --- |
| 72 MB | 454 次 | 6 次 | 168.1 ms | 5.88 ms | 398 ms |
| 256 MB | 43 次 | 0 次 | 42.7 ms | 1.22 ms | 317 ms |
堆太小时,存活的对象几乎占满了老年代,日志中反复出现 Pause Full (G1 Compaction Pause) 71M->27M(72M):每次 Full GC 后仍有一半以上的空间被占用,很快又满了。
线上频繁 Full GC 的排查思路:
1. 用 jstat -gcutil 观察老年代使用率(O 列)和 Full GC 次数(FGC 列);
2. Full GC 之后老年代使用率仍然很高,并且越来越高,说明对象一直无法回收,导出堆转储查找内存泄漏;
3. Full GC 之后老年代能降下来,说明堆对于正常的负载偏小,或者有大对象、大量对象过早进入老年代,可以增大堆、调整新生代大小,或者从代码上减少一次创建的数据量;
4. 元空间不足也会触发 Full GC,日志中的原因为 Metadata GC Threshold,检查是否在动态生成大量的类。
### CPU 占用过高
1. 用 top 找到 CPU 占用高的 Java 进程;
@@ -1205,6 +1205,7 @@ $ grep -c "unloading class Plugin" unload.log
- [Guide to WeakHashMap in Java](http://www.baeldung.com/java-weakhashmap)
- [Tomcat example source code file (ConcurrentCache.java)](https://alvinalexander.com/java/jwarehouse/apache-tomcat-6.0.16/java/org/apache/el/util/ConcurrentCache.java.shtml)
- [Garbage-First (G1) Garbage Collector | Java SE 17 GC Tuning Guide](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
- [Garbage-First Garbage Collector Tuning | Java SE 21 GC Tuning Guide](https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-garbage-collector-tuning.html)
- [The Z Garbage Collector | Java SE 21 GC Tuning Guide](https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector2.html)
- [JEP 439: Generational ZGC](https://openjdk.org/jeps/439)
- [JEP 490: ZGC: Remove the Non-Generational Mode](https://openjdk.org/jeps/490)
+52
View File
@@ -15,6 +15,7 @@
* [三、设计原则](#三设计原则)
* [S.O.L.I.D](#solid)
* [其他常见原则](#其他常见原则)
* [附录:实测](#附录实测)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -135,6 +136,25 @@ Wind is playing...
Percussion is playing...
```
编译器并不知道 instrument 实际指向哪个类,循环中的 instrument.play() 被编译成一条 invokevirtual 指令,参数是符号引用 Instrument.play:()V(javap 输出,见[附录 A1](#a1-方法调用的字节码))。运行时 JVM 才根据对象的实际类型选择方法:从对象所属的类开始,沿继承链向上找第一个签名相同的方法(JVM 规范 invokevirtual 一节)。这个过程称为动态分派。
每次调用都沿继承链查找太慢,HotSpot 为每个类维护一张虚方法表(vtable),按固定的槽位存放可被覆盖的方法的入口:子类的表先复制父类的表,覆盖了的方法替换对应槽位的入口,新增的方法追加在后面。play() 在 Instrument、Wind、Percussion 三张表中是同一个槽位,分别指向三个类自己的实现,调用时按槽位取出入口即可。通过接口调用的方法使用 invokeinterface,HotSpot 为它另外维护接口方法表(itable)。
重载则在编译时就确定了:编译器根据参数的静态类型选择重载的版本,写进字节码的符号引用中,称为静态分派。例如:
```java
static void show(Instrument i) { System.out.println("show(Instrument)"); }
static void show(Wind w) { System.out.println("show(Wind)"); }
Instrument i = new Wind();
show(i); // show(Instrument)
show((Wind) i); // show(Wind)
```
i 实际指向 Wind 对象,但它的静态类型是 Instrument,第一次调用编译成 show:(LInstrument;)V,运行时不会再改变。
不能被覆盖的方法只有一个实现,调用目标在编译时就能确定:静态方法用 invokestatic,构造方法用 invokespecial;private 和 final 方法虽然也用 invokevirtual 调用(JDK 21 javac 的输出),JVM 同样能直接确定调用目标。
## 二、类图
每张类图后附有对应的 [PlantUML](https://www.planttext.com/) 代码,更多语法及使用请参考:http://plantuml.com/ 。
@@ -362,9 +382,41 @@ Vehicle .. N
包之间的依赖关系都应该是稳定方向依赖的,包要依赖的包要比自己更具有稳定性。
## 附录:实测
### A1 方法调用的字节码
环境:JDK 21.0.13。在正文 Music 类的基础上加入两个重载的 show() 方法、一个 private 方法和一个 final 方法,运行后用 javap -c 查看 main() 中的调用指令(省略了 println 等无关的行):
```java
Instrument i = new Wind();
show(i);
show((Wind) i);
Music m = new Music();
m.secret(); // private void secret()
m.fixed(); // public final void fixed()
```
```text
Wind is playing...
Percussion is playing...
show(Instrument)
show(Wind)
63: invokevirtual #54 // Method Instrument.play:()V
73: invokespecial #28 // Method Wind."<init>":()V
78: invokestatic #57 // Method show:(LInstrument;)V
85: invokestatic #63 // Method show:(LWind;)V
92: invokespecial #66 // Method "<init>":()V
97: invokevirtual #67 // Method secret:()V
101: invokevirtual #70 // Method fixed:()V
```
## 参考资料
- Java 编程思想
- [The Java Virtual Machine Specification, Java SE 21 Edition:invokevirtual](https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-6.html#jvms-6.5.invokevirtual)
- 周志明. 深入理解 Java 虚拟机:第 3 版 [M]. 机械工业出版社, 2019. 第 8 章“方法调用”。
- 敏捷软件开发:原则、模式与实践
- [面向对象设计的 SOLID 原则](http://www.cnblogs.com/shanyou/archive/2009/09/21/1570716.html)
- [看懂 UML 类图和时序图](http://design-patterns.readthedocs.io/zh_CN/latest/read_uml.html#generalization)