ArrayList 插入 1000 万条数据之后,我怀疑了 JVM
1. 从一次线上事故说起ArrayList 插入 1000 万条数据之后先说一个在团队里真实发生过的场景。业务上有一个需求服务启动时需要把数据库或外部文件里的一批数据加载到内存中供后续做内存过滤、批量比对和快速检索。数据量不算特别夸张大约一千万条。负责的同事写下了这样一段再普通不过的代码ListString rows new ArrayList(); for (int i 0; i 10_000_000; i) { rows.add(row- i); }这段代码从语法上看没有任何问题逻辑也极其清晰。但服务上线之后奇怪的事情发生了启动过程明显变慢CPU 使用率一路飙升日志里频繁出现 GC 停顿记录测试环境甚至在跑了几分钟后直接抛出了OutOfMemoryError: Java heap space。同事的第一反应非常典型“是不是 JVM 有 Bug是不是内存参数没有配置好是不是垃圾收集器选错了”坦白说很多人包括早年的我在遇到 OOM、频繁 Full GC、Stop The World 这类问题时都会本能地把锅甩给 JVM。毕竟 GC 日志里那一串串Pause Full (Allocation Failure)、Full GC的信息看起来确实非常“技术”也非常“吓人”。但当我们把问题一层层拆开真正看到 ArrayList 的源码、数组扩容机制、对象内存布局再结合 JVM 堆分区与 GC 行为之后才会发现一个有点反直觉的结论问题几乎从来都不在 JVM而在我们使用 ArrayList 的方式上。这篇文章就是想把这件看起来“很小”的事彻底讲透。我们会从一个 1000 万次add的小实验出发逐步深入下面这些问题ArrayList 底层到底是如何存储数据的size和capacity是不是一回事每次调用add时ArrayList 内部究竟发生了什么默认容量 10 又意味着什么插入 1000 万条数据ArrayList 到底扩容了多少次复制了多少数据这些数据到底占了 JVM 堆内存的多少对象头、引用、数组元素分别占多少字节为什么会出现 OOM、频繁 GC、STW症结到底在新生代、老年代还是在我们自己身上怎样用 jstat、jmap、MAT、Arthas 这些工具把问题坐实真正高效的正确写法是什么预估容量、ensureCapacity 到底能带来多大收益ArrayList 与 LinkedList、Stream、parallelStream 之间有哪些容易踩的坑多线程环境下ArrayList 的线程安全问题又该如何理解全文大约两万字建议收藏后慢慢阅读。哪怕你自认为已经很熟悉 ArrayList这篇文章也很可能会纠正你长期以来的几个误解。话不多说我们先把事故现场复现出来。2. 事故复盘先把问题复现出来在深入原理之前我们先写一个最小可复现程序看看在默认参数的 JVM 下向 ArrayList 插入 1000 万条数据究竟会发生什么。为了让现象更明显我们用-Xmx256m限制堆内存并打开 GC 日志。import java.util.ArrayList; import java.util.List; public class ArrayListTest { public static void main(String[] args) { long start System.currentTimeMillis(); ListString rows new ArrayList(); for (int i 0; i 10_000_000; i) { rows.add(row- i); } long cost System.currentTimeMillis() - start; System.out.println(size rows.size() , cost cost ms); } }假设我们用下面这种方式运行java -Xmx256m -Xms256m -XX:PrintGCDetails -XX:PrintGCDateStamps ArrayListTest在很多机器上看到的结局都是程序卡顿一段时间后抛出类似这样的异常Exception in thread main java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.ArrayList.grow(ArrayList.java:256) at java.util.ArrayList.add(ArrayList.java:463)这个异常堆栈非常有信息量。它直接告诉我们三件事OOM 发生在Arrays.copyOf也就是数组复制的时候。触发的源头是ArrayList.grow也就是扩容方法。入口是ArrayList.add最常见的添加操作。换句话说根本不是 JVM 本身坏了而是 ArrayList 在不断扩容、不断复制数组的过程中把本来可以容纳数据的堆内存硬生生“自己用完了”。要理解这一点我们必须先彻底弄懂 ArrayList 的底层结构。3. ArrayList 底层结构一个数组撑起的世界ArrayList 的底层实现极其朴素它内部持有一个Object数组所有添加进来的元素本质上都是被放进这个数组的槽位里。我们先看 JDK 8、JDK 11、JDK 17 中大同小异的两个核心字段声明public class ArrayListE extends AbstractListE implements ListE { private static final int DEFAULT_CAPACITY 10; transient Object[] elementData; private int size; }这里有两个人非常容易混淆的概念size当前 ArrayList 中实际存放的元素个数。用户调用size()拿到的就是这个值。capacity底层数组长度elementData.length当前底层数组能够容纳的元素数量上限。当一个 ArrayList 刚被new ArrayList()创建出来时在大多数现代 JDK 版本中elementData指向的是一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA此时容量可以理解为 0。只有在第一次真正调用add时才会把容量扩展到DEFAULT_CAPACITY 10。这种延迟初始化是为了节省那些“创建了却从不使用”的空集合的内存。我们用一张表来对比size和capacity的变化过程操作步骤实际元素 size底层数组容量 capacity是否发生扩容/复制new ArrayList()00共享空数组否第 1 次 add110是首次分配为 10第 2~10 次 add2~1010否容量足够第 11 次 add1115是从 10 扩容到 15第 16 次 add1622是从 15 扩容到 22可以看到随着size慢慢逼近capacityArrayList 就必须在某个时刻“搬家”申请一块更大的数组把旧数组里的所有元素复制过去。搬家这件事的成本正是我们后面要重点分析的性能与内存杀手。4. 扩容机制1.5 倍增长的真相当数组满了下一次add就会触发扩容。我们先看add(E e)与扩容相关的精简源码public boolean add(E e) { ensureCapacityInternal(size 1); elementData[size] e; return true; } private void ensureCapacityInternal(int minCapacity) { if (elementData DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount; if (minCapacity - elementData.length 0) { grow(minCapacity); } } private void grow(int minCapacity) { int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); if (newCapacity - minCapacity 0) { newCapacity minCapacity; } if (newCapacity - MAX_ARRAY_SIZE 0) { newCapacity hugeCapacity(minCapacity); } elementData Arrays.copyOf(elementData, newCapacity); }这段源码里最关键的扩容公式是int newCapacity oldCapacity (oldCapacity 1);oldCapacity 1相当于oldCapacity / 2所以新容量是旧容量的1.5 倍。换句话说每次扩容大约增长 50%而不是我们常听说的“翻倍”。这里有几个重要细节值得展开扩容不是翻倍。很多人误以为 ArrayList 每次容量翻倍其实早期 JDK 的做法和现在不同后来统一成了 1.5 倍。1.5 倍是在“扩容次数”和“浪费空间”之间的一种折中倍数越大扩容次数越少但尾部空闲空间越多倍数越小空间浪费少但搬家更频繁。扩容是一个整体复制操作。底层调用Arrays.copyOf其内部通常又是System.arraycopy这是一次原生内存拷贝。System.arraycopy本身已经很快但当数组足够大、拷贝足够频繁时总成本依然非常惊人。最小值保护。当用户一次性addAll大量数据要求的最小容量甚至超过 1.5 倍时会直接采用minCapacity避免不必要的多次扩容。这正好为后面讲优化埋下伏笔。我们不妨把 1000 万次add过程中容量序列的完整变化列出来。为了便于阅读下面截取关键节点扩容次数扩容前容量扩容后容量本次复制元素数101002101510315221542233225334933649734910244366244156246936962462016006524009716006525121548718232301215487309230100138451509230100从这张表可以非常直观地看出越到后面每次复制的数据量越大。当数组已经达到 923 万容量、想要继续塞第 923 万零 1 个元素时ArrayList 必须新申请一个 1384 万容量的数组然后把原来 923 万个引用逐个复制过去。而这一步往往就是压垮堆内存的最后一根稻草。5. 1000 万次 add 到底发生了什么把上一节的容量序列累加起来我们可以精确估算默认初始容量的 ArrayList 插入 1000 万条数据整个生命周期内会发生多少次扩容、复制多少元素。5.1 扩容次数从容量 10 开始每次乘以 1.5直到容量覆盖 1000 万。设扩容次数为 n则10 * 1.5^n 10,000,000 1.5^n 1,000,000 n log1.5(1,000,000)计算可得log1.5(1,000,000) ln(1,000,000) / ln(1.5) ≈ 13.8155 / 0.4055 ≈ 34.07也就是说大约需要 35 次左右扩容含首次从 0 到 10 的分配最终的底层数组容量落在约 1384 万远超实际需求的 1000 万。5.2 复制元素总量每次扩容都会把旧数组的全部size个元素复制到新数组。将所有扩容时刻的“旧元素数”求和就得到一个等比数列的总和。由于每次以 1.5 倍增长复制工作量的级数是10 15 22 ... 9,230,100等比数列求和的近似结果是S ≈ 最终容量 / (1.5 - 1) ≈ 13,845,150 / 0.5 ≈ 27,690,300也就是说为了最终存放 1000 万个元素ArrayList 在扩容过程中额外复制了大约 2700 万到 3000 万个引用。虽然每次System.arraycopy都是原生批量拷贝但 3000 万次引用搬运加上每一次扩容都可能触发新生代 GC这个开销已经不可忽略。5.3 内存中的双重账单更隐蔽的问题在于扩容的瞬间JVM 堆里同时存在两个大数组——旧数组和新数组。以最后一次扩容为例旧数组9,230,100 个引用约 35 MB按每个引用 4 字节估算。新数组13,845,150 个引用约 53 MB按每个引用 4 字节估算。在Arrays.copyOf复制完成、elementData指向新数组之后旧数组才会失去强引用成为垃圾等待回收。如果此时堆内存设置得很小或者还同时存在大量 String 对象占据内存这瞬间叠加的双重内存很容易直接击穿堆上限抛出 OOM。这也是为什么很多 OOM 异常栈都精确地停在Arrays.copyOf上。6. 内存去哪儿了对象头、引用、数组的精确计算要真正回答“1000 万条数据占多少内存”不能只凭感觉需要对 JVM 对象内存布局做一次精确拆解。我们以 64 位 JVM、默认开启指针压缩UseCompressedOops为例这也是绝大多数生产环境的默认配置。6.1 引用本身占多少在开启指针压缩的 64 位 JVM 中一个对象引用占4 字节。如果不开启指针压缩则占 8 字节。因此 1000 万个引用构成的elementData数组的数据区就是10,000,000 × 4 字节 40,000,000 字节 ≈ 38.1 MB6.2 数组对象本身还有额外开销elementData并不是只有数据区。它本身是一个 Java 数组对象JVM 会给它分配对象头Mark Word8 字节用于存储哈希码、GC 分代年龄、锁信息等。Klass Pointer开启指针压缩时 4 字节指向类元数据。数组长度4 字节记录数组元素个数。加上对象头数组对象总大小约为8 4 4 38.1 MB ≈ 38.1 MB 16 字节这 16 字节看似微不足道但在分析堆转储文件时理解这一点有助于看懂 MAT 中数组对象与数组元素引用的分割。6.3 真正的内存大头数组里的对象别忘记ArrayList 的数组里放的只是引用真正的数据对象本身也要占用内存。以String为例一个row-1000字符串至少包含String 对象本身对象头 12 字节开启指针压缩加上value数组引用 4 字节、hash4 字节等对齐后通常 24 字节左右。String 内部的byte[] valueJDK 9 之后或char[] valueJDK 8 及之前数组对象头加上每个字符 1 到 2 字节。每个字符串的平均额外开销可能在 40 到 60 字节左右。1000 万个 String粗略计算就是10,000,000 × 50 字节 ≈ 500 MB这还只是字符串本体。如果里面放的是Integer、Long或者自定义对象甚至把 1 MB 的大对象重复放 1000 万次内存会以更恐怖的速度增长。6.4 一个容易被忽略的事实ArrayList 只装了地址很多人拿到 MAT 的堆直方图时会看到Object[]占了 40 MBString占了几百 MB于是怀疑 ArrayList 浪费内存。其实这种怀疑只对了一半ArrayList 的Object[]确实占了 40 MB但它只是存储引用的地址簿真正的业务对象内存消耗是你放入的每个 String、每个 POJO 自己造成的。两者都要算但优化方式不同——减少数组扩容优化的是地址簿的频繁搬家复用对象或换用紧凑结构优化的是业务内容本身的体积。7. JVM 内存区域ArrayList 数据到底存在哪里了解了对象布局之后我们再从 JVM 内存区域的角度看问题。JVM 运行时数据区通常分为程序计数器线程私有记录字节码执行位置。虚拟机栈线程私有存放局部变量表、操作数栈等。本地方法栈为 native 方法服务。堆线程共享几乎所有对象实例和数组都在这里分配。方法区/元空间存储类信息、常量、静态变量等。ArrayList 里的数据属于对象和数组毫无疑问分配在堆上。在我们的实验代码中栈里只有一个rows局部变量它保存的是 ArrayList 对象在堆上的引用地址而 ArrayList 对象内部又持有elementData数组的引用数组元素又分别指向各自的 String 对象。这条引用链可以画成栈局部变量 rows │ ▼ 堆中 ArrayList 对象 │ elementData 引用 ▼ 堆中 Object[] 数组 │ 元素槽位一个个引用 ▼ 堆中 String 对象row-0、row-1、……、row-9999999所有环节都在堆上串联。所以当我们说“ArrayList 导致内存占用过高”时准确一点应该表述为ArrayList 及其元素对象共同占据了堆内存而 ArrayList 的扩容行为会反复制造大数组。理解了位置之后下一步就看 JVM 的 GC 如何与这些大数组互动。8. 为什么是 JVMOOM、GC 频繁、Stop The World回到开头的问题为什么大家会怀疑 JVM因为系统表现出的症状和 JVM 内存管理异常的症状非常像。我们来逐条拆解这些表象。8.1 OOMJava heap spaceOutOfMemoryError: Java heap space的直接含义是JVM 尝试在堆上分配对象时发现堆空间不够而且在多次 GC 之后依然无法腾出足够空间。但 OOM 的原因是分配需求大于可用空间而不是 JVM 分配逻辑出错。在我们的场景里分配需求包括大量 String 对象。不断增长的elementData数组。扩容瞬间新旧两个数组并存的额外峰值。当这些需求叠加超过-Xmx设定的上限时OOM 就发生了。它只是如实报告内存不够而不是判断失误。8.2 GC 频繁与 Stop The World在 1000 万次循环中我们持续创建 String 对象这些短生命周期对象的绝大多数会在下一次 GC 中被回收。于是新生代被快速填满Minor GC 被频繁触发。同时扩容产生的大数组是典型的大对象在分代收集器中大数组可能被直接分配进老年代或者引发晋升进而触发 Full GC。Full GC 一旦发生通常伴随较长的 Stop The World 停顿表现为CPU 飙升但业务逻辑进展缓慢。接口响应毛刺偶尔出现几秒甚至几十秒的卡顿。GC 日志中Pause Full (Allocation Failure)高频出现。表面看是 JVM 垃圾回收太频繁本质上是我们制造了远超必要的垃圾和远超必要的分配压力。8.3 CPU 飙升除了 GC 线程扩容拷贝本身也会消耗 CPU。当数组达到百万、千万级一次System.arraycopy要搬运几百万个引用虽然单次是纳秒级、字节级操作但乘以几千万次搬运量也会形成可观的 CPU 时间。若此时又叠加频繁 GCCPU 使用率自然居高不下。8.4 结论先行这些症状都像 JVM 的问题但根因却在应用代码层ArrayList 的无规划扩容加上海量临时对象再加上不合理的内存上限。JVM 只是忠实地执行了我们的代码并在资源耗尽时忠实地报了错。9. JVM 参数视角Xms、Xmx、新生代与老年代要彻底看清这个问题必须补上 JVM 堆内存结构与参数的知识。堆是 GC 管理的主要区域在传统的分代 GC 中它被划分为新生代和老年代新生代又分为 Eden 区、两个 Survivor 区通常 8:1:1。新对象绝大多数在 Eden 区分配经历多次 Minor GC 存活后被复制到 Survivor再经过一定次数后晋升到老年代。老年代存放长期存活的对象、大对象以及晋升对象。Full GC 主要发生在这里。常用参数有参数含义示例-Xms初始堆大小-Xms512m-Xmx最大堆大小-Xmx4g-Xmn新生代大小-Xmn1g-XX:NewRatio老年代与新生代比例-XX:NewRatio2-XX:SurvivorRatioEden 与 Survivor 比例-XX:SurvivorRatio8-XX:MaxTenuringThreshold对象晋升老年代的年龄阈值-XX:MaxTenuringThreshold15-XX:UseG1GC使用 G1 收集器JDK 9 默认-XX:PrintGCDetails打印 GC 详情用于排查回到 1000 万数据场景如果堆上限设为 256 MB按照前面的估算光是 1000 万个 String 对象就可能需要约 500 MB更别提数组和峰值内存。因此无论如何调 GC 参数只要堆上限低于实际数据量OOM 都无法避免。参数调优的前提是应用本身的内存需求是合理的如果代码本身在浪费内存调参治标不治本。反过来如果我们把堆调大比如-Xmx2g程序也许不 OOM 了但启动依然慢、GC 依然多。所以加内存只能止血不能解决根本问题。根本问题依然是有没有必要在 ArrayList 里反复搬家 3000 万次有没有办法让内存分配一次到位10. GC 视角Minor GC、Full GC 与数组扩容的纠缠接下来我们把 GC 的分类和 ArrayList 的行为对应起来。10.1 Minor GC新生代的复制回收Minor GC 发生在新生代 Eden 区满时。它会回收大量朝生夕灭的对象。在我们的循环里字符串row- i不断创建但后续基本都是长期存活的因为它们被 ArrayList 引用了所以它们不会在 Minor GC 中被回收反而会被复制到 Survivor最终晋升到老年代。这意味着我们在一轮轮 Minor GC 中反复复制这些其实会被长期保存的对象。如果新生代设置得过小对象还没活够就被迫在多轮 Minor GC 之间搬运复制开销会非常高。这是分代 GC 对大量长期存活对象天然不友好的典型场景。10.2 Full GC老年代的回收与停顿当老年代空间不足或者发生晋升失败就会触发 Full GC。Full GC 通常要扫描整个老年代时间长、停顿明显。在大数组扩容峰值中如果新数组直接进入老年代很可能在某一刻击穿老年代空间触发 Full GC。10.3 大对象如何影响 GC很多 JVM 实现都支持大对象直接进入老年代的策略目的是避免在新生代来回复制大对象。但直接进老年代也有代价如果这些大对象其实是短命的比如扩容产生的旧数组它们很快变成垃圾却不得不等待一次昂贵的 Full GC 才能被回收。这进一步恶化了停顿。10.4 结论ArrayList 的扩容会产生一类特殊对象大且生命周期错配的数组。它们在需要分配时是大对象在扩容完成后立即成为垃圾。这种对象无论放在新生代还是老年代都会给 GC 带来压力。唯一的根治方法是从源头上减少它们的产生次数。11. 性能对比实验不同初始容量的天壤之别理论讲得再多不如跑一遍实验。我们写四个测试方法分别对比默认构造new ArrayList()容量从 10 开始扩容。精确初始容量new ArrayList(10_000_000)。预估稍大容量new ArrayList(11_000_000)。先默认构造再在插入前调用ensureCapacity(10_000_000)。为了让差异更明显我们在每个循环里放入一个简单的Long对象并记录从 0 到 1000 万的总耗时和最终容量。import java.util.ArrayList; import java.util.List; public class CapacityBenchmark { static void run(String name, Listlt;Longgt; list) { long start System.nanoTime(); for (long i 0; i lt; 10_000_000; i) { list.add(i); } long cost (System.nanoTime() - start) / 1_000_000; int capacity reflectCapacity(list); System.out.printf(%-38s size%d capacity%d cost%d ms%n, name, list.size(), capacity, cost); } public static void main(String[] args) throws Exception { run(1. 默认容量, new ArrayListlt;gt;()); run(2. 精确初始容量, new ArrayListlt;gt;(10_000_000)); run(3. 预估稍大容量, new ArrayListlt;gt;(11_000_000)); Listlt;Longgt; ensure new ArrayListlt;gt;(); ensure.ensureCapacity(10_000_000); run(4. ensureCapacity 预留, ensure); } private static int reflectCapacity(Listlt;?gt; list) throws Exception { var field ArrayList.class.getDeclaredField(elementData); field.setAccessible(true); Object[] arr (Object[]) field.get(list); return arr.length; } }某次 64 位 JDK 17 的运行结果大致如下不同机器会有波动但趋势一致方案size最终 capacity耗时1. 默认容量10,000,00013,845,150约 1800 ms2. 精确初始容量10,000,00010,000,000约 850 ms3. 预估稍大容量10,000,00011,000,000约 820 ms4. ensureCapacity 预留10,000,00010,000,000约 840 ms从结果可以得出三条非常清晰的结论初始容量能显著降低耗时。相同数据量下预设容量比默认容量快一倍左右原因就是省去了 30 多次扩容和约 3000 万次引用复制。最终容量不等于实际元素数。默认方案最终容量 1384 万比实际 1000 万多出约 38.45%这部分就是永远用不上但已占位的尾部空间。ensureCapacity与构造器指定容量效果基本一致。二者本质上都让底层数组一次性分配到位。到这里我们已经用数据证明了预设容量的重要性。接下来我们把结论固化成可执行的编码规范。12. 正确姿势如何高效地插入 1000 万条数据综合前面的分析面对超大集合、数量已知或基本可预测的场景推荐以下实践。12.1 数量已知构造时就指定容量int expectedSize 10_000_000; ListString rows new ArrayList(expectedSize); for (int i 0; i expectedSize; i) { rows.add(row- i); }这是最简单、最直接、最高效的写法。数组一次分配到位全程零扩容。12.2 数量是估计值适度上浮或使用 ensureCapacity如果只能估算一个大概范围建议留出 10% 到 20% 余量避免后续少量扩容。例如预计 1000 万可以写ListString rows new ArrayList(11_000_000);或者先创建空列表在循环前调用ListString rows new ArrayList(); rows.ensureCapacity(10_000_000); for (String s : sourceList) { rows.add(s); }12.3 完全不知道数量使用 addAll如果数据来自另一个集合应优先使用addAll而不是在循环里逐个add。因为addAll内部会一次性判断目标容量减少扩容次数ListString rows new ArrayList(); rows.addAll(sourceList);从源码看addAll会先调用ensureCapacityInternal(size c.size())一次性把容量抬到足够大再批量复制避免了逐次扩容。12.4 数据源本身很大考虑分页、分批或换方案如果 1000 万只是必须全量驻留内存的要求那么应当认真评估是否真的需要全部放入内存能否用数据库分页、游标、延迟加载代替是否还有更省内存的容器或结构例如原始类型数组int[]、long[]它们避免了包装对象开销。对象是否可以复用或池化大量几乎相同的字符串能否用同一引用内存优化的顺序永远是先评估是否必要再选择合适结构最后才考虑扩容调优。12.5 一个反直觉的陷阱不要迷信大容量就是好预设容量虽好但如果预设得过于离谱也会浪费内存。比如你预计 1000 万却预留了 1 亿容量那么多出的 9000 万个引用槽位会白白占用约 343 MB按每个引用 4 字节而它们可能一直空着。因此容量预估要与真实数据规模尽量接近并适度留白。容量并非越大越好准确才是关键。13. ArrayList 与 LinkedList 的终极对比既然 ArrayList 扩容这么麻烦那换用 LinkedList 会不会更好很多初学者会有这样的疑问。要回答它我们需要理解二者的本质差异。13.1 结构差异ArrayList基于数组支持按索引 O(1) 随机访问尾部追加是 O(1)均摊除非扩容在中间插入、删除是 O(n)因为需要移动后续元素。LinkedList基于双向链表每个节点保存前后指针和元素引用按索引访问是 O(n)在已知节点位置的情况下头尾插入删除是 O(1)。13.2 在尾部插入 1000 万的场景下如果只是把 1000 万条数据追加到集合尾部LinkedList 每次add都只需创建一个节点并接在尾部全程没有扩容问题这在直觉上似乎更平滑。但实际问题在于LinkedList 每个节点有额外开销节点对象加上前指针、后指针、元素引用。一个节点比数组槽位重得多。创建 1000 万个零散节点对内存分配器和 GC 都是巨大压力容易造成内存碎片。在实际连续追加场景中LinkedList 的耗时通常比预设好容量的 ArrayList 更慢且内存占用往往更大。我们做一个简单对比实验ListLong arrayList new ArrayList(10_000_000); ListLong linkedList new LinkedList(); long t1 System.nanoTime(); for (long i 0; i 10_000_000; i) arrayList.add(i); long aCost (System.nanoTime() - t1) / 1_000_000; long t2 System.nanoTime(); for (long i 0; i 10_000_000; i) linkedList.add(i); long lCost (System.nanoTime() - t2) / 1_000_000; System.out.println(ArrayList 预分配尾部追加: aCost ms); System.out.println(LinkedList 尾部追加: lCost ms);多次运行的典型结果是预设容量的 ArrayList 显著快于 LinkedList。LinkedList 每次add都要new Node、维护指针还有对象头内存开销1000 万次下来并不轻。13.3 什么时候才用 LinkedListLinkedList 真正的优势场景非常具体需要在已知 Iterator 位置频繁做头尾或中间的插入删除并很少使用索引随机访问。例如双端队列的操作。大多数业务场景下ArrayList 仍然是默认选择关键是用好初始容量。14. Stream 与 parallelStream 的隐藏陷阱现代 Java 开发中Stream 使用频率很高。那么把 1000 万条数据通过 Stream 收集成 List是否会自动规避扩容问题答案是收集器会做优化但 parallelStream 另有危险。14.1 Collectors.toList 的容量策略stream.collect(Collectors.toList())内部通常先默认创建一个空 ArrayList在不知道 Stream 大小的情况下逐步增长。如果 Stream 本身没有已知大小属性sized stream收集器依然会经历多次扩容。好在 JDK 内部对部分流做了一定的容量预估优化但并不可靠。更稳妥的是自己预估并收集ListString rows sourceStream.collect( Collectors.toCollection(() - new ArrayList(10_000_000)));这样可以直接指定收集容器的初始容量减少扩容。14.2 parallelStream 的线程安全问题如果这样写ListString rows new ArrayList(); IntStream.range(0, 10_000_000) .parallel() .forEach(i - rows.add(row- i));这是非常危险的写法。多个线程同时向同一个非线程安全的 ArrayList 执行add会引发数据丢失两个线程可能同时读到同一个size互相覆盖。数组越界异常size不是原子操作扩容判断与写入之间可能被打断。NullPointerException或ArrayIndexOutOfBoundsException甚至静默的数据错乱。并行收集应该使用并发容器或收集器例如ListString rows IntStream.range(0, 10_000_000) .parallel() .mapToObj(i - row- i) .collect(Collectors.toList());这里的Collectors.toList在并行流下内部使用分块合并是线程安全的。注意虽然它返回的最终是 ArrayList或类似实现但收集过程由框架保证安全不要自己手动并发add。14.3 parallelStream 的性能误区很多人以为并行流一定更快。但对于大量独立小对象创建加收集这类 CPU 与内存密集型任务并行流可能因为线程切换、内存分配竞争而变得更慢甚至引发内存峰值。是否并行要根据任务类型、核数和数据规模实测决定不能想当然。15. 线程安全ArrayList 为什么不是线程安全的ArrayList 的官方文档明确写了它不是线程安全的。如果在多个线程中并发修改同一个 ArrayList又没有外部同步结果是不可预测的。理解这一点需要看add的两个关键步骤ensureCapacityInternal(size 1); // 步骤 1检查容量必要时扩容 elementData[size] e; // 步骤 2写入并 size 自增这两个步骤不是原子操作。多线程场景下可能出现的乱序如下线程 A 执行ensureCapacityInternal发现容量刚好够。线程 B 也执行ensureCapacityInternal也认为容量够。线程 A 执行elementData[size] e还没来得及size。线程 B 执行elementData[size] e写到了同一个下标覆盖了 A 的数据。两个线程都执行size最终期望 size 增加 2但数据却只有 1 条另一个丢失。这就是典型的竞态条件。此外迭代过程中如果另一个线程修改了集合modCount变化会导致ConcurrentModificationException。这个异常是 ArrayList 的快速失败fail-fast机制用于尽早暴露并发修改问题。15.1 需要线程安全时该怎么做使用Collections.synchronizedList(new ArrayList())为每个方法加锁但迭代时仍需手动同步。使用CopyOnWriteArrayList写入时复制整个数组适合读多写少的场景不适合大量写入。使用并发容器如ConcurrentLinkedQueue或把写入操作放在单一线程中完成。在 1000 万条数据的批量加载场景中如果确实需要并行读取数据源更好的做法是各线程写自己的局部 List最后一次性合并而不是共享一个 ArrayList 并发 add。16. 常见的错误写法与案例分析整理了这么多年 ArrayList 相关的问题后我把它最常见的坑归成以下几类几乎每一类都能在真实项目里找到原型。16.1 不预估容量直接裸 new// 不推荐数量已知却不指定容量 ListString rows new ArrayList(); for (int i 0; i 10_000_000; i) { rows.add(...); }后果约 35 次扩容约 3000 万次引用复制内存峰值高可能 OOM。16.2 预估容量严重偏小或偏大// 偏小1000 万数据却只预留 100 万仍会多次扩容 ListString rows new ArrayList(1_000_000); // 偏大1000 万数据预留 1 亿浪费大量内存 ListString rows new ArrayList(100_000_000);后果前者扩容优化效果有限后者尾部空间浪费巨大。容量预估要贴近真实值。16.3 循环里反复创建短生命周期的中间对象for (int i 0; i 10_000_000; i) { String temp heavyConvert(i); rows.add(temp); }如果heavyConvert每次创建大量临时对象即使 ArrayList 容量预分配好了新生代依然会被这些临时对象快速填满引发频繁 Minor GC。16.4 共享 ArrayList 做并行填充ListString rows new ArrayList(); data.parallelStream().forEach(d - rows.add(d)); // 错误后果数据丢失、越界异常、甚至 OOM。并行填充必须使用安全收集或分块合并。16.5 用 null 元素打补丁ListString rows new ArrayList(100); rows.set(99, last); // 运行时抛出 IndexOutOfBoundsException这个错误和本主题稍有不同但同样常见set只能替换已有元素不能用来越界填坑。初学者经常混淆add追加和set替换。16.6 误用 size 和 capacityListString rows new ArrayList(10_000_000); System.out.println(rows.size()); // 输出 0而不是 10,000,000new ArrayList(int)指定的是底层数组的容量而不是元素个数。刚创建时size依然为 0这也是第 3 节强调size与capacity区别的原因。17. JVM 调优工具实战jstat、jmap、MAT、Arthas理论分析之后我们还需要一套可以落地的排查工具。下面按由浅入深的顺序展示如何用工具证明 ArrayList 扩容导致的内存与 GC 问题。17.1 jstat实时查看 GC 与堆使用jstat是 JDK 自带的轻量级监控命令不需要可视化界面。-gcutil选项可以查看各内存区域使用率和 GC 次数jstat -gcutil pid 1000 20输出各列含义大致为新生代 Eden 使用率、Survivor0、Survivor1、老年代使用率、元空间使用率、Young GC 次数、Young GC 总耗时、Full GC 次数、Full GC 总耗时、总 GC 耗时。当看到Eden 使用率频繁在 100% 附近波动YGC 次数快速增长Old 区使用率持续上升就可以初步判断应用在持续制造大量新对象且存在对象不断晋升到老年代的情况。此时应进一步确认是不是集合扩容和大量临时对象导致。17.2 jmap查看堆中对象分布jmap -histo:live pid可以输出堆中存活对象的统计直方图按对象类型排序jmap -histo:live pid | head -30常见输出可能包含num #instances #bytes class name 1 10000000 400000000 java.lang.String 2 10000000 240000000 [C 3 1 55380560 [Ljava.lang.Object;说明 1000 万个 String 和它的字符数组占了约 500 MB而Object[]本身也占了几十 MB。数据一旦摆在眼前不是 JVM 的锅就非常直观了。17.3 生成堆转储并用 MAT 分析如果要深入分析引用链和对象保留情况可以生成 heap dumpjmap -dump:formatb,fileheap.hprof pid然后用 Eclipse Memory AnalyzerMAT打开重点查看Leak Suspects自动分析可疑大对象。Dominator Tree找到 ArrayList、Object[]、String 的支配关系。Histogram查看对象实例数和总字节数。通过引用链你可以清楚地看到ArrayList.elementData - Object[] - String.value这条链上堆了多少内存。MAT 还能帮你估算如果 ArrayList 初始容量合理可以节省多少空间。17.4 Arthas在线诊断不改代码不重启Arthas 是阿里开源的 Java 诊断利器适合生产环境在线排查。常用命令示例# 查看当前线程 CPU 耗时 thread -n 5 查看堆内存对象统计 vmtool --action getInstances --className java.lang.String --limit 5 监控某个方法的调用耗时 trace com.example.Demo loadData当程序卡住时可以用thread -n 5看最忙的线程往往能看到Arrays.copyOf或 GC 相关栈帧同时可以用内存命令确认 ArrayList 到底占了多少。Arthas 的价值在于无需重启、无需加日志非常适合定位线上内存与性能问题。17.5 一条完整的排查路径把这套工具串起来通常的排查顺序是用jstat判断是否 GC 频繁、内存增长异常。用jmap -histo看哪些对象实例数暴增。若怀疑引用滞留用jmap -dump生成堆快照交给 MAT 分析。生产环境可优先用 Arthas 在线 trace 和观察。回到代码检查 ArrayList 是否预分配容量、是否有大量临时对象、是否并发误用。工具只是放大镜真正要改的往往是那几行集合初始化代码。18. 总结ArrayList 使用的最佳实践清单到这里我们基本可以把“ArrayList 插入 1000 万条数据之后怀疑 JVM”这件事彻底讲清楚了。最后把全文浓缩成一份可以贴在工位上的最佳实践清单。18.1 关于初始化数量已知直接new ArrayList(size)。数量可估预留 10% 到 20% 余量或使用ensureCapacity。完全未知优先addAll让 JDK 自己估算容量。不要裸new ArrayList()后硬塞几百万条数据。18.2 关于容量与内存牢记size是元素个数capacity是底层数组长度。容量不是越大越好预留过大同样浪费内存。把 ArrayList 本身的内存与元素对象的内存分开核算。大量原始类型优先考虑int[]、long[]等避免包装对象开销。18.3 关于性能尾部追加是 ArrayList 的最优场景但仍要预分配容量。中间频繁插入删除才考虑 LinkedList。并行填充不要共享 ArrayList使用并发收集或分块合并。大规模处理前先评估是否真的需要全量驻留内存。18.4 关于线程安全ArrayList 非线程安全并发写必须外部同步。读多写少可用CopyOnWriteArrayList。需要通用线程安全包装时用Collections.synchronizedList但迭代仍需同步。18.5 关于 JVM 与排查OOM、GC 频繁、STW 往往是代码问题不是 JVM Bug。先看代码制造了多少垃圾和分配压力再谈调参。熟练使用jstat、jmap、MAT、Arthas 定位问题。堆大小要匹配真实数据量调大内存只是止损优化代码才是根治。18.6 最后一句话ArrayList 是 Java 世界中使用频率最高的容器之一它简单、高效却因为简单反而让很多人忽略了背后的数组与扩容机制。当 1000 万条数据把 JVM 压到 OOM、GC 停顿、CPU 飙升的时候请别急着怀疑 JVM——先看看那个new ArrayList()是不是可以写得更好。理解底层才能写出真正高性能、可维护的代码。