视频加载失败

ArrayList、线程池与 JVM:一次 Java 底层复盘

1916 字
10 分钟
ArrayList、线程池与 JVM:一次 Java 底层复盘

ArrayList 源码:容量 10 是”懒”出来的#

先看三个字段:

private static final int DEFAULT_CAPACITY = 10; // 默认容量 10
private static final Object[] EMPTY_ELEMENTDATA = {}; // 指定容量为 0 时用
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 无参构造用
transient Object[] elementData; // 真正装数据的数组

两个空数组长得一模一样,作用是区分「调用方显式给了 0」和「压根没给容量」,第一次 add 时好判断要不要按 10 来扩。

构造函数的两种情况:

// 无参构造:默认创建空集合,一个数组都没分配
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 指定初始容量:直接 new 一个 Object[]
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}

所以「初始容量 10」不是构造时就有的:无参 new 出来 size 是 0,elementData 指向共享空数组,第一次 add 才触发扩容,把数组撑到 10。

扩容的核心在 grow():

private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 原容量 + 右移一位(÷2) = 1.5 倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity; // 1.5 倍不够就用最小需求值
// ...
elementData = Arrays.copyOf(elementData, newCapacity); // 数组拷贝
}

右移一位就是除以 2,所以是 1.5 倍而不是 2 倍。扩容靠 Arrays.copyOf 把旧数组整个拷一遍,数据量大时开销不小——能预估规模就 new ArrayList<>(size),一步到位。

asList 和 toArray:一对正好相反的坑#

Arrays.asList 只是包装,底层没有拷贝,两边指向同一块内存:

Integer[] arr = {1, 2, 3};
List<Integer> list = Arrays.asList(arr);
arr[0] = 99;
System.out.println(list.get(0)); // 99 ← 改数组,list 跟着变
list.set(1, 100);
System.out.println(arr[1]); // 100 ← 改 list,数组也变
list.add(4); // UnsupportedOperationException

反过来,list.toArray() 是真拷贝,互不影响:

List<String> list = new ArrayList<>();
list.add("a");
Object[] array = list.toArray();
array[0] = "b";
System.out.println(list.get(0)); // a ← 拷贝出来的新数组,改了不影响原 list

一个共享引用,一个数组拷贝,方向别记反。顺带一个经典坑:Arrays.asList(new int[]{1, 2, 3}) 的 size 是 1——int[] 本身被当成了一个对象装进去,想按元素转得用 Integer[]。

线程池核心线程数怎么定#

业务特征线程数直觉
计算密集型CPU 核数 + 1CPU 一直在转,线程再多只会互相切换;+1 是为了偶发暂停时有人顶上
高并发、任务执行时间短CPU 核数 + 1任务切换本来就频繁,线程多了上下文切换的开销反而吃掉吞吐
IO 密集型CPU 核数 × 2 + 1大部分时间在等 IO,CPU 闲着,多线程正好填补等待空隙
并发不高、任务执行时间长按 IO / CPU 归到上面两条并发低,线程再多也跑不满,按任务类型定就够
并发高、业务执行时间长不在线程池瓶颈在架构:缓存、异步、MQ、拆服务

最后一条最容易被忽略:线程池参数调不出架构的账。

JVM 运行时数据区#

JVM 运行时数据区

线程私有

线程共享

程序计数器

虚拟机栈

本地方法栈

Java 堆

方法区

年轻代 Eden + s0 + s1

老年代

JVM 运行时数据区

线程私有

线程共享

程序计数器

虚拟机栈

本地方法栈

Java 堆

方法区

年轻代 Eden + s0 + s1

老年代

线程私有三件套:程序计数器、虚拟机栈、本地方法栈,随线程生、随线程灭。线程共享两块:堆和方法区。

堆里分年轻代(Eden + 两个 Survivor)和老年代。方法区在 1.7 由永久代实现(放在堆里),1.8 起换成了元空间——搬到本地内存,不再挤占堆。方法区存的是类的信息、运行时常量池这类元数据,虚拟机启动时创建,关闭时释放。

类加载器与双亲委派#

类加载器负责装载字节码文件,把字节码加载进 JVM。加载一个类时的委派流程:

loadClass("com.xxx.Foo")
→ 先问上级:parent.loadClass()
→ 上级还有上级,继续向上(直到 BootstrapClassLoader)
→ 一路都没加载过,才轮到自己 findClass()

这个机制叫双亲委派模型,两个作用:

  1. 唯一性:同一个类只会被加载一次,不会出现两份 java.lang.String;
  2. 安全:核心类库永远由 Bootstrap 加载,自己写一个同名 String 也不会被用上,类库 API 没法被顶替。

什么算垃圾#

引用计数法:对象被引用一次计数 +1,引用失效 -1,归零就回收。实现简单,但循环引用直接让它失效——a 引用 b、b 引用 a,外部引用全断,两边计数也永远是 1,永远回收不掉。

JVM 实际用的是可达性分析:从 GC Roots 出发,能到达的对象就是活的,到不了的全是垃圾。

GC Roots 通常是这几类:

  • 虚拟机栈中引用的对象(栈帧里的局部变量);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 JNI 引用的对象。

三大回收算法#

算法思路优点缺点典型去处
标记-清除可达性分析标记存活对象,直接清掉未标记的快碎片化严重,内存不连续老年代(CMS)
标记-整理标记后把存活对象往一端挪,清掉边界外的无碎片移动对象成本高老年代(Parallel Old)
复制内存分两块,存活对象复制到另一块,原块整体清空无碎片、垃圾越多效率越高牺牲一块空间,存活对象多时不划算新生代

新生代用复制算法不是没道理:大部分对象朝生夕死,真正活着要搬家的没几个,配上 Eden : s0 : s1 = 8 : 1 : 1 的比例,一次只浪费十分之一的内存。

JVM 调优#

Tomcat 部署改 bin/catalina.sh 里的 JAVA_OPTS;jar 包部署直接写在启动命令里:

java -Xms4g -Xmx4g -Xss1m -XX:SurvivorRatio=8 -XX:+UseParallelGC -XX:+UseParallelOldGC -jar app.jar

常用参数:

参数作用默认值
-Xms初始堆大小物理内存的 1/64
-Xmx最大堆大小物理内存的 1/4
-Xss单个线程的虚拟机栈大小一般 1M
-XXEden 与两个 Survivor 区的比例8(即 8:1<1>)
-XX:+UseParallelGC新生代并行收集器—
-XX:+UseParallelOldGC老年代并行收集器—

实践里常见把 -Xms 和 -Xmx 设成一样,堆不用反复伸缩,省掉动态扩容的抖动。

排查工具一行一个:

jps -l # 列出 Java 进程,拿到 pid
jstack <pid> # 打线程栈,查死锁、CPU 飙高
jmap -heap <pid> # 看堆的使用概况
jmap -dump:format=b,file=heap.hprof <pid> # 导出堆快照

jhat 也能分析快照,但 JDK 9 之后就被移除了,现在多用 VisualVM 或 MAT。可视化工具:jconsole、VisualVM。

重点速记#

  • ArrayList 容量 10 是懒加载:无参构造只是空数组,第一次 add 才扩到 10
  • 扩容 1.5 倍:oldCapacity + (oldCapacity >> 1),靠 Arrays.copyOf 拷贝
  • asList 是包装、共享原数组(set 互通、add 抛异常);toArray 是拷贝,互不影响
  • CPU 密集 / 高并发短任务:核数 + 1;IO 密集:核数 × 2 + 1;高并发长业务看架构,别指望线程池
  • 线程私有:程序计数器、虚拟机栈、本地方法栈;线程共享:堆、方法区
  • 1.7 永久代在堆里,1.8 换成元空间(本地内存)
  • 双亲委派:先向上委托,上级不加载才自己来;保唯一、保安全
  • 引用计数死于循环引用,JVM 用可达性分析 + GC Roots
  • 新生代复制算法(8:1<1>),老年代标记-清除 / 标记-整理
  • 调优参数:-Xms/-Xmx(堆)、-Xss(栈)、-XX(Eden 与 S 区比例)

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
ArrayList、线程池与 JVM:一次 Java 底层复盘
https://ryuki.icu/posts/arraylist-threadpool-jvm-notes/
作者
RK
发布于
2026-09-03
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
RK
Long may the sunshine
公告
Long may the sunshine
分类
标签
最新动态
站点天气
天气加载中…
站点统计
文章
27
分类
6
标签
59
总字数
39,139
篝火已燃
0 天
最后活动
0 天前
每日一签