From 8ee6d680967af7f07635e8bccfbec5445fe443a6 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Wed, 28 Oct 2026 22:51:25 +0800 Subject: [PATCH 1/5] =?UTF-8?q?Update=20Java=20=E5=B9=B6=E5=8F=91.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充手写简化版线程池及其与 ThreadPoolExecutor 的对应关系(新增附录 A6),补充虚拟线程的链接;Daemon、sleep()、yield() 移到 Executor 之前。 --- notes/Java 并发.md | 176 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 33 deletions(-) diff --git a/notes/Java 并发.md b/notes/Java 并发.md index 8d56d80a..3400ae19 100644 --- a/notes/Java 并发.md +++ b/notes/Java 并发.md @@ -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 queue; + private final List 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) From baf04031cb5b46c58a8ff2d4da99c0cd920af20e Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Wed, 28 Oct 2026 22:51:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?Update=20Java=20=E8=99=9A=E6=8B=9F=E6=9C=BA?= =?UTF-8?q?.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 程序计数器在执行本地方法时的值改为未定义,补充 G1 下不用 -Xmn 固定新生代大小;默认收集器的段落移到 G1 末尾,内存溢出与泄漏移到 JVM 调优之前。 --- notes/Java 虚拟机.md | 81 ++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/notes/Java 虚拟机.md b/notes/Java 虚拟机.md index fa76f063..d2fc4b81 100644 --- a/notes/Java 虚拟机.md +++ b/notes/Java 虚拟机.md @@ -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 可以直接对新生代和老年代一起回收。

@@ -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) From 2f2a5f0f1498b0d2c45359a8d0f804487afa3e90 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Wed, 28 Oct 2026 22:51:25 +0800 Subject: [PATCH 3/5] =?UTF-8?q?Update=20Java=20=E5=AE=B9=E5=99=A8.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrayList 的序列化、HashMap 计算数组容量、ConcurrentHashMap 的 size 操作和 WeakHashMap 的缓存示例移到进阶篇,原处保留结论和链接;LinkedList 移到 ArrayList 之后,HashMap 扩容图改由脚本生成。 --- notes/Java 容器.md | 278 +++++++-------------------------------------- 1 file changed, 38 insertions(+), 240 deletions(-) diff --git a/notes/Java 容器.md b/notes/Java 容器.md index d221d974..0d36191e 100644 --- a/notes/Java 容器.md +++ b/notes/Java 容器.md @@ -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