从我开始带新人算起,“Java数组的内存机制”这个话题几乎每年都会被重新翻出来。很多人觉得数组不就是“连续内存+下标访问”,再深入一点就是“越界会抛异常”,但真要动手排查一个内存异常或者优化一段大数据处理代码时,才发现自己对数组的理解完全不够。这篇文章我想把Java数组和它的内存机制讲透,包括数组到底是什么、new一个数组时底层发生了什么、多维数组为什么被人戏称“锯齿数组”、一个数组对象到底占用多少堆内存、以及数组在回收、逃逸分析和容器选型中容易被忽视的要点。内容会尽量贴合实际开发,既有原理也有可落地的建议。
1. 数组在JVM里的真实身份:它其实是个对象,不是普通容器
1.1 数组的底层类型:隐含的Object子类
很多初学者把数组理解为“一组变量的集合”,这个说法不能算错,但它掩盖了一个关键事实:在JVM眼中,数组就是一个对象,而且是一个特殊的、直接继承Object的引用类型。换句话说,int[]不是int的某种“连续写法”,它本身是一个类,每个数组实例都是这个类的一个对象。
这一点最直接的证据有三个。第一,数组可以调用Object类的方法,比如toString()和hashCode(),虽然默认实现基本没用,但你确实能调;第二,数组可以赋给Object引用,Object obj = new int[3]完全合法;第三,数组对象在堆中占用内存,受垃圾回收机制管理。
从class文件视角看,JVM规范里数组类型不是普通的Class,而是由虚拟机在运行时动态生成的。你用int[].class能拿到一个Class对象,但这个class文件并不是磁盘上存在的,而是JVM根据“数组维度+元素类型”即时合成的。同一个JVM进程里,int[].class只会有一个,它是全局的、被所有int[]实例共享的模板。
这意味着你可以用反射去做一些骚操作。比如:
int[] arr = new int[10]; Class<?> clazz = arr.getClass(); System.out.println(clazz.getName()); // 输出 [I System.out.println(clazz.getSuperclass()); // 输出 class java.lang.Object输出结果里那个[I就是数组类型在JVM内部的描述符,[表示数组维度,I表示int。如果是String[],对应描述符是[Ljava.lang.String;。如果你看到这两个符号出现在堆转储文件或者日志里,不要慌,这就是数组的表现形式。
1.2 栈上的引用变量与堆上的连续内存
数组对象分成了两大部分:栈上的引用变量和堆上的数组对象本体。
栈上的引用变量就是你在方法里写的那行int[] arr,它存的是一个“指向堆中某个数组对象的地址”,本质是一个引用;真正的数组数据,比如那10个int值,在堆内存中以连续方式排列。
这里有个很关键的推论:arr.length是数组实例字段吗?它不是存储在对象头旁边的一个普通实例字段,而是作为数组对象头的一部分保存的。也就是说,数组对象头部会有一个固定的4字节区域,记录数组长度。这也是为什么arr.length访问速度极快,因为它就在对象头里,不需要额外的字段解析。正因为长度存在对象头,数组一旦创建,容量就永远固定,这也是“数组不可变长”这个特性的底层原因。
如果你觉得“栈上的引用+堆上的对象”不好理解,可以类比成“通讯录里的一条联系人记录”和“这个人实际住址”。你手机里存的arr只是那条记录(引用),记录指向的住址才是堆里的数组本体。修改一个数组的内容,相当于“去住址上搬家”,但通讯录里那条记录本身没变。
1.3 为什么Java不让你用指针操作数组元素
刚接触数组时,我经常琢磨:Java为什么不给数组元素取地址?比如C语言里可以arr + i做指针运算,Java却只能用下标。原因有两层。
第一层是安全性的刻意设计。如果允许直接用地址偏移访问数组,意味着你可以在运行时越界读取任意堆内存,轻则数据错乱,重则造成安全漏洞。Java选择在字节码层面插入边界检查,访问越界时抛出ArrayIndexOutOfBoundsException,用少量性能开销换来内存访问的安全边界。
第二层是GC设计的要求。JVM的垃圾回收器会移动对象,比如复制算法、压缩式Full GC都会将存活对象从一块区域搬到另一块区域。对象一旦移动,对象的地址就会变化。如果开发者手里拿着“数组首元素的绝对地址”,GC一搬地址就失效了。所以JVM只允许你持有对象的引用,由虚拟机自身维护引用到实际地址的映射,这样GC移动对象后只需更新引用关系,用户代码完全无感。
这一点和大家熟悉的C/C++差异极大。我对从C转Java的人都会说一句:在Java里,你就把数组当成一个“知道自己有多长的、会自动检查下标的智能容器”,不要再想“首元素地址”这个事。
2. 一次new操作背后:数组在内存里的完整生命周期
2.1 new int[10]执行时发生了什么
很多人写了几年代码,从来没想过int[] arr = new int[10]这一行背后有哪些内存动作。我把过程拆开,大致是五步。
第一步,JVM解析数组类型[I,确认需要创建的是具备哪个描述符的数组对象。这一步发生在类加载和常量池解析阶段。第二步,在堆中分配一块连续内存,大小为“对象头 + 元素数组总空间”。第三步,对象头初始化,写入mark word、类型指针、数组长度。第四步,数组元素按JVM默认规则清零:int[]清零为0,boolean[]为false,Object[]为null。第五步,栈帧中创建引用变量arr,并把它指向这个堆对象。
其中第四步很容易被忽略。如果你读到的数组元素默认值全是0或null,不是巧合,而是JVM在分配后做了一次零值初始化。你可以理解为:每个数组在出生时都自带“清零”功能。
补一个冷门知识点:new int[10]在字节码中是newarray指令,而new int[3][4]这种多维数组的创建,第一个维度用newarray之后,后面的维度用的是anewarray指令,因为此时处理的不是基本类型,而是“数组类型”的引用。至于new String[3]这种创建“引用数组”的操作,字节码指令同样是anewarray。
2.2 静态初始化和动态初始化的不同开销
int[] a = {1,2,3}会在编译期直接把常量数据写入class文件的Code属性里(确切说是ConstantValue或初始化相关的字节码序列),运行时在方法里执行一份简短的“加载常量并填入数组”的指令。它和new int[3]; a[0]=1; a[1]=2; a[2]=3在字节码层面是有差异的,但前者的效率通常更高,因为它少了几次单独的astore和后续赋值语句的分支。
在实际项目中,可能有人会觉得静态初始化更快,就习惯把所有值写死。我的观点是,在绝大多数业务代码里,这个性能差异小到可以忽略,真正影响性能的是数组的容量设计和是否触发扩容、是否产生过多临时对象。与其纠结这两种初始化的纳秒级差异,不如把精力放到数组本身的容量预估上。我给团队定的简单原则是:知道具体值就写静态初始化,编译期能做的事不要拖到运行期做,但如果你需要从数据库或接口读数据,那初始化方式无从选择,别硬套。
2.3 引用传递的“坑”:方法里修改数组,外部一定会变
前面说了,数组引用在栈上,数组对象在堆上。于是在没有值传递和引用传递之争的Java里,数组传参给人造成的“错觉”非常典型。
来看这段代码:
public class ArrayDemo { public static void main(String[] args) { int[] arr = new int[3]; arr[0] = 10; change(arr); System.out.println(arr[0]); // 输出多少? } static void change(int[] a) { a[0] = 20; } }输出是20,因为a和arr是两个引用变量,但两个变量指向同一个堆数组对象,所以通过a修改元素,从arr这边也看得到。这究竟是“按引用传递”吗?严格说不是。Java只有按值传递,传递的是引用的拷贝,而不是对象本身。拷贝出来的第二个引用依然指向同一个对象,所以能修改对象内部;但如果在方法里执行a = new int[5];,只会让这个引用变量指向新对象,外部arr毫不知情。
这个“两个引用一个对象”的内存模型,也是数组和集合容器发生“并发修改”经常莫名生效的原因。两个线程各自持有一个数组的引用,虽然引用变量在各自线程栈上,但对象本体在堆共享区域,不加同步就能被两边看到修改。这里的复杂程度远超大多数人的直觉,建议在写多线程数组操作时,主动加锁或用并发容器,别幻想“各持各的引用就没冲突”。
3. 多维数组的内存布局:为什么Java二维数组不是真正的矩形
3.1 数组的数组:一维数组嵌套的真相
Java里的int[][] matrix = new int[3][4]从语义上可以当“3行4列的二维矩阵”用,但内存布局上,它并不是一块连续的3*4内存,而是一个长度为3的数组,里面每个元素都是“指向一个长度为4的int数组的引用”。
画成结构大约是这样:matrix引用指向一个“长度为3的int[]数组”,这个数组里存的是3个引用,第一个引用指向“长度为4的int数组”,第二个、第三个同理。所以你在堆里实际上创建了4个对象:1个外层数组 + 3个内层数组。
这会导致一个常见现象:打印每一行数组的逻辑地址,你会发现它们不一定连续,甚至顺序都不保证。这种布局在概念上是“数组中套数组”,官方资料里叫“数组的数组”。它带来了一个结果——Java二维数组的形状不一定是矩形。你可以写:
int[][] matrix = new int[3][]; matrix[0] = new int[4]; matrix[1] = new int[2]; matrix[2] = new int[9];第一行4个元素,第二行2个,第三行9个。这在C语言的二维数组中是不可想象的,在Java里完全合法。我把这种数组叫“锯齿数组”,它在表达不规则结构时非常有用,比如存储“每个用户拥有不同数量的标签”,用固定矩形数组会浪费大量内存。
3.2 锯齿数组的内存收益与遍历代价
锯齿数组节省内存的原因是显而易见的:你不必为每行开辟一样多的空间,只分配实际需要的容量。举个例子,一个系统里有三档用户,分别有3、50、200个标签,你用一个int[3][200]表示,每行都占200个int的空间,使用率只有(3+50+200)/(3*200)约42%。如果改用锯齿数组,使用率几乎100%。
代价是什么?访问matrix[a][b]需要做两次寻址。第一次根据a找到外层数组元素,得到内层数组引用;第二次根据b在内层数组里定位元素。连续遍历时,这种间接寻址会让CPU的缓存预取效果变差,尤其是内层数组很小的时候,一次内存访问很可能无法命中L1缓存。
所以我的建议是:如果你处理的是一个真正规整的矩阵,或者需要频繁做“按行列遍历”的矩阵运算,优先使用一维数组加手动索引映射,即int[] data = new int[rows * cols],访问元素时用data[row * cols + col]。这个替换在数据量达到百万级别时,性能差异非常明显。而锯齿数组更适合“每行数量不定”的业务数据载体,而非数值计算。
3.3 三维和更高维数组的膨胀问题
维度继续往上加时,对象数量会急剧膨胀。一个int[10][10][10]并不是创建一个可以装1000个int的连续内存,而是创建1个外层数组(长度10),10个中层数组,以及10*10=100个内层数组,总共111个数组对象。每个数组对象都有各自的对象头和引用间接层。
这种对象数量膨胀的影响,在堆内存充足时可能不明显,一旦涉及大量小数组和GC扫描,问题就出来了。每个数组对象都要在GC Roots扫描时被检查一遍,对象越多,GC压力越大。真有三维数组需求时,我的选择几乎是固定的:用下标换算摊平为一维数组,然后写一个带边界校验的工具方法。
4. 数组对象在堆里到底占多少内存:手算一次,记忆十年
4.1 对象头、数组长度和对齐填充
要真正理解数组在内存中占用的空间,就得看它的对象布局。在HotSpot虚拟机的普通对象模型中,一个普通对象包含三部分:对象头(mark word + 类型指针)、实例数据、对齐填充。数组对象额外在对象头里多了一个“数组长度”字段。
mark word在64位JVM中默认占8字节,用来存hashCode、GC分代年龄、锁状态等。类型指针在开启压缩指针(默认开启)时占4字节,关闭压缩指针时占8字节。数组长度固定是4字节。
所以一个数组对象的固定头大小,在64位JVM、默认开启压缩指针的情况下是:8(mark word) + 4(类型指针) + 4(数组长度) = 16字节。
之后是元素区的空间。int[]每个元素占4字节,long[]每个元素占8字节,Object[]每个元素存引用,在压缩指针开启时每个引用占4字节,关闭时占8字节。最后,HotSpot要求对象大小必须是8字节的整数倍,不足时进行对齐填充。
4.2 手动计算int[10]和Object[10]的实例大小
我们来算两个具体例子。假设64位JVM默认压缩指针开启。
int[10]:对象头16字节,加上10个int元素共40字节,总大小56字节,56不是8的倍数,对齐后变为56字节?不对,56除以8等于7,是整除的。实际上56正好是8的倍数,不需要填充。但如果计算int[11],对象头16 + 44 = 60,60不是8的倍数,对齐填充4字节,总占用64字节。
Object[10]:对象头16字节,元素区 10个引用 * 4字节 = 40字节,总大小也是56字节。如果存的是String[],每个元素占4字节,但String对象本身是另外的堆对象,这个数组里只保存它们的引用。
再看long[5]:对象头16 + 40 = 56字节,也是56。byte[0]就更有意思了:对象头16字节,没有元素,16正好是8的倍数,无需额外填充,所以一个空字节数组占16字节。
你觉得这一点能帮你省内存吗?当然能。当你用数组存大量小对象,比如一个Integer[100000],除了数组本身开销,还要注意每个Integer对象有16字节对象头+4字节int值+对齐填充,整体开销比int[100000]大得多。所以高性能内存敏感场景的第一个建议就是:用基本类型数组,不要用包装类型数组。
4.3 压缩指针、超大数组与内存计算的心得
压缩指针是HotSpot默认开启的优化,它用32位来压缩64位环境中的对象引用,从而减少引用占用的空间。默认堆小于32GB时都有效。一旦堆超过32GB,压缩指针会被关闭,引用回到8字节宽,内存占用随之上升。
这点在数组上影响尤其明显。同样是Object[100000000],开启压缩指针时引用区占用400MB,关闭时占用800MB。所以扩容JVM堆时,不是内存越大越好,跨过32GB这个门槛反而会让你看到引用内存翻倍的“惊喜”。
我在实际项目中遇到过这种场景:一个搜索引擎的倒排索引用大量整型数组存储文档ID,单个数组几十MB。启动参数里把堆从31GB调到40GB后,内存反而更紧张了。查来查去,发现关闭压缩指针导致所有数组引用的内存占用上升,最终不得不重新权衡堆大小和数据结构。这个教训让我对“数组大小”的敏感度高了很多。
关于数组上限,也有必要提一句:int[]的最大长度受到对象大小限制,实际接近Integer.MAX_VALUE - 8,但如果真的尝试创建这么大的数组,大概率先触发OOM,而不是报长度错误。理论值和实际可用内存之间的差距,往往比你想的大很多。
5. 边界检查、越界异常与逃逸分析:数组访问的隐藏性能博弈
5.1 为什么每个下标访问都要检查
Java数组的安全访问,是通过JVM在每次aaload、iaload、aastore、iastore等字节码指令执行时自动加入边界比较实现的。执行引擎会比较下标值与数组长度,如果下标小于0或者大于等于长度,就立刻抛出ArrayIndexOutOfBoundsException。
这个机制保证了你不能用下标访问到数组对象之外的内存,但也带来了每个元素访问的一次额外比较。对于一些超大循环来说,这个比较的累计开销不可忽略。现代JVM的做法是:在解释执行模式下老老实实检查;到了JIT编译阶段,编译器会做一些优化,比如证明循环变量始终在有效范围内时,直接省略边界检查。
这就是为什么你在写for (int i = 0; i < arr.length; i++)时看起来“没有检查”,其实JIT有可能帮你把检查优化掉了。但如果你写的是for (int i = 0; i < 100000; i++) arr[calc(i)],calc(i)的返回值编译器无法确定范围,边界检查就只能保留,性能自然差一些。
5.2 越界异常包的坑:负数和索引漂移
越界异常最常见的原因是循环边界没对齐。除了“下标超过长度”这种容易想到的情况,我还遇到过几类比较隐蔽的场景。
第一类是负下标。比如说用户输入了负数坐标,然后你直接拿去做数组下标,抛出来的也是ArrayIndexOutOfBoundsException。排查时很容易只盯着“数组是不是不够长”,忘了先校验负数。
第二类是索引漂移。两个线程同时读写同一个数组,A线程读到i=9时,B线程对数组进行了逻辑上的“删除”,把有效长度从10改成了8,A线程继续操作就意外越界。这种问题不是数组长度本身变了(数组长度不可变),而是业务逻辑的有效长度和数组实际长度不一致导致的。
第三类是length用错了对象。在嵌套数组里,matrix.length是外层长度,matrix[0].length才是内层长度。很多人遍历二维数组时,外层使用matrix[0].length,内层使用matrix.length,看起来都像“长度”,结果局部越界。
我处理这类问题时的标准动作很简单:先抓异常栈里是哪个方法、哪一行;然后看那一行是哪个数组在越界;最后问自己,这个数组预设长度是多少,写这段代码时假设的索引边界是否和实际一致。之前有几次线上问题折腾了很久,最后发现是“数据加载不完整导致数组后半段根本没填”,但数组长度本身是够的,真正的索引来源却有空洞,这需要单独在写入侧做防御。
5.3 逃逸分析下的数组优化:可能没你想的那么神
JVM的逃逸分析可以对“不逃逸”的对象做一些栈上分配和标量替换优化。比如一个方法里创建的数组,如果只是方法内部使用,没有被返回、没有传出去,理论上JIT可以把它的元素分配到寄存器和栈上,而不是堆上。
但实际情况是,数组因为长度动态、元素个数不确定,逃逸分析对数组的优化往往不如对普通对象效果好。数组对象一旦发生“容量不确定”或“元素动态赋值”,编译器很难进行完整的标量替换。因此,依赖JVM逃逸分析去“优化”大数组是一种不太可靠的策略。
真实的性能优化还是应该从数据结构和算法层面做,比如避免在热循环里新建数组、把多维数组合并为一维、用基本类型数组代替包装类型数组。JIT能做的事情是“锦上添花”,不是你写出低效代码的理由。
6. 数组什么时候被回收:可达性、泄漏与GC视角
6.1 引用消失不等于对象立刻消失
垃圾回收的第一原则是可达性分析。从GC Roots出发,能通过引用链到达的对象都是“存活对象”,不能到达的就是“可回收”的对象。数组对象作为堆对象,它的生死也完全由可达性决定。
我用日常语言解释一下:数组对象就像一间房子,引用变量就是通往房子的钥匙。只要还有任何一把有效钥匙能打开房子,GC就不会动它。当你把arr重新赋值、把它置为null,或者它所在的方法执行结束、栈帧弹出,钥匙没有了,房子才可能被回收。这里的“可能”很关键,因为回收的实际发生时间取决于JVM的GC触发策略,可能是毫秒后,也可能拖很久。
大数组回收不及时导致的“假内存泄漏”是实际开发中最常见的问题。比如你在一个循环里不断创建大数组,第一次循环的数组已经不再被引用,但其内存不会立刻被回收,如果后续循环还在持续申请新数组,最终可能在GC还没来得及处理前就把堆打满。
这不是内存泄漏本身,而是“内存分配速率超过回收速率”的瞬时压力问题。我以前排查过一个定时任务,每隔30秒加载一批数据到数组里处理,内存曲线图看起来像锯齿,但偶尔会突然飙到接近堆上限。后来发现,任务里有一个变量是“类级别的静态引用”,上一批数组一直挂在静态字段上没有被释放,下一批又累计,导致堆内存缓慢攀升,在GC触发时出现明显卡顿。把静态引用改成局部变量后,曲线就平滑了。
6.2 数组里的引用也会成为“GC Roots的延伸”
一个Object[]数组中保存的每个引用,都会成为GC扫描时需要展开的目标。数组对象本身可达时,它引用的每一个子对象都会被视为“间接可达”,无法被回收。
这带来一个隐性成本:数组越大、里面指向的对象越多,GC遍历时扫描路径就越长。比如持有一个String[10000]数组,里面每个String又引用一个char[],GC要同时检查这个外层数组、一万个String对象、一万个char[]对象。这类“数组挂引用树”非常考验GC设计与参数调优。
在内存敏感场景,我的原则是:能用基本类型数组,就不用对象引用数组。类如存时间戳用long[]而非Long[],存外键ID用long[]而非String[]。这不仅省内存,也减少了GC根扫描的引用层级。
6.3 大数组进入老年代的加速效应
默认情况下,JVM的新生代区域会优先承载刚创建的对象,包括数组对象。新生代采用复制收集算法,对象存活率低时效率很高。但大数组对象直接进入新生代,可能导致Survivor区装不下,最终提前晋升到老年代。
G1收集器有“大对象区”的概念,当数组大小超过一个Region的50%,它会被单独分配。这些大对象在新生代中无法被快速复制,往往会直接在老年代或Humongous区域分配,回收成本很高。如果你发现堆里有很多“巨型数组”,它们既不是缓慢累积的泄漏对象,也不是热数据,时常会和GC日志里的Humongous Allocation记录对得上。
一些可落地的建议是:业务系统尽量避免申请“很大且反复创建”的数组,比如每次请求都新建一个上百MB的缓冲数组;如果临时性大数组无法避免,尽量控制它的生命周期,让它在方法内尽快失去引用;数据库批量处理时,分批每批处理几万行,而不是一次性全量加载进一个大数组。
7. 数组和容器怎么选:从内存、性能到语义的取舍
7.1 数组与ArrayList:表面相似,内里不同
很多人认为ArrayList就是“自动扩容的数组”,这个理解没问题,但反过来容易被它迷惑。ArrayList的内部确实是用Object[]存储的,但它引入了容量、size、扩容、modCount等额外状态,导致它的对象持有链比裸数组更复杂。
从内存角度对比:ArrayList对象自身有对象头、elementData引用、size、modCount等字段,外部还有一个Object[]对象。你在里面存10个int,如果直接int[10]只要56字节;如果用ArrayList<Integer>,除了ArrayList对象开销、Object[]数组开销,还有10个Integer对象,每个Integer至少16字节。几百万数据下,差距是几十倍。
ArrayList并不是一无是处。它带来的动态扩容、增删便利、迭代器支持,在业务代码里价值极大。但从性能敏感的角度,它绝对不是“免费午餐”。我用一张简单的表来说明:
| 维度 | int[] | ArrayList |
|---|---|---|
| 内层元素存储 | 基本类型直接连续存储 | 引用指向包装对象,有对象头开销 |
| 内存占用 | 低 | 高,约5-20倍 |
| 扩容能力 | 不支持 | 支持自动扩容 |
| 随机访问 | 下标访问,最快 | get方法,内部还是数组下标,几乎一样 |
| 增删中间元素 | 手动搬移 | remove/add内部已实现搬移 |
| 适合场景 | 性能敏感、结构固定 | 业务动态数据、便利性优先 |
7.2 什么业务场景必须用数组
我在实际项目里总结出的数组“必须出场”场景有这几类。
第一类,IO缓冲。从文件、网络读取数据时,byte[]几乎是绕不开的底层结构。它高效、直接、可复用。我之前排查一个图片上传接口的性能问题,瓶颈之一就是每次请求都新建一个byte[8192]缓冲区,改成用一个线程本地复用的byte[]后,内存分配速率显著下降。
第二类,固定结构的高性能批量数据。比如报表统计、矩阵运算、排序汇总,如果数据结构固定,用基本类型数组可以达到极致的缓存友好。而ArrayList因为内部是引用类型,很难达到同样的连续读取效果。
第三类,与底层API对接。很多I/O操作、网络协议解析、位运算、加密解密都要求byte[]或int[]。你不可能让ArrayList<Byte>去对接read(byte[])方法。
7.3 数组的“不可变长”反而是一个优点
你可能会觉得数组不能变长是很麻烦的事,但在某些场景,这个约束反而是优点。固定长度的数组天然适合做“固定上限”的池化结构,比如线程池里的固定任务数组、环形缓冲区。因为你知道它能装多少,不会无意中因为扩容导致内存波动。
ArrayList的扩容策略是每次增长到原来的1.5倍,如果容量从10扩容到15、22、33……一路扩下去,元素多时很容易造成内存浪费。数组完全不会有这个问题,但也因此不要用它承载“数量不确定且增长频繁”的数据。
所以我的选择逻辑一般是这样的:数据量小且不确定时,用ArrayList开发效率高;数据量确定或只是临时缓冲时,用数组;数据量巨大且需要连续内存访问时,用一维基本类型数组;数据不规则但需要引用容器时,优先用ArrayList,但注意内存开销。
在这里要提醒一下,不要无缘无故在热路径上用Arrays.asList来转换数组。Arrays.asList返回的是固定大小的List视图,底层数组还是原来的数组,你无法增加元素,调用add会抛UnsupportedOperationException。许多人在这个坑里踩过:以为得到了一个方便操作的List,实际得到的只是一个“只读外壳”。
8. 一个实测案例与最后的个人体会
8.1 用JMH粗略对比数组和ArrayList
为了让大家对数组和ArrayList的性能差异有直观感受,我写过一个简单的JMH基准测试并跑过。场景是:创建包含100万个int的数据结构,然后做一次全量求和。
测试组一:
int[] arr = new int[1_000_000]; // 填充... long sum = 0; for (int i = 0; i < arr.length; i++) { sum += arr[i]; }测试组二:
ArrayList<Integer> list = new ArrayList<>(1_000_000); // 填充... long sum = 0; for (int i = 0; i < list.size(); i++) { sum += list.get(i); }跑出来的结果,在我的机器和默认JVM参数下,int[]版本大约是ArrayList<Integer>版本的3到5倍快,内存占用约是后者的十几分之一。这个倍率会因环境不同而变化,但趋势是稳定的:基本类型数组的连续存储模式,对CPU缓存和内存带宽都友好得多。
要注意,这不是说ArrayList是坏的,而是说它跟数组的适用场景不同。在高性能计算、海量数据统计、中间件底层实现里,数组是基石;在业务逻辑层,ArrayList提供的便利是自己实现的,没有人会为了求和“固定100万个数”去换ArrayList。
8.2 数组相关的两个隐蔽性能教训
讲两个亲身踩过的坑,都比较隐蔽。
第一个坑是“循环内取length”。有人说for (int i = 0; i < arr.length; i++)每次循环都会去读一次length,所以应该先用变量存下来。其实JIT大概率会把arr.length优化成循环不变量,这个操作对性能几乎没有影响。但如果你在循环体里修改了数组引用(比如重新new了一个数组),那就另当别论了。所以这个“坑”更多是伪优化,不必过度在意。
第二个坑才是我真正栽过的:“数组越界异常被日志吞掉”。当时在处理一个批量数据导入功能,每个数据块都放到byte[]里解析,异常日志里只写了“ArrayIndexOutOfBoundsException”,但没记录数组长度和当前下标。排查时根本不知道是哪个数据块有问题。后来我在所有数组解析入口统一封装了一个工具,把数组长度、当前操作下标和数据来源标识一起打进日志,问题定位速度立刻上来了。这个习惯我现在一直保持,尤其在生产环境。
8.3 理解数组内存机制的终极意义
学习数组内存机制,最大的收益不是让你炫耀“数组对象头有16字节”,而是让你在面对内存问题时,能建立一个正确的直觉:什么东西在栈上,什么东西在堆上,谁引用谁,谁占多少空间,谁在什么时候可能被回收。这个直觉能帮助你在设计数据结构时提前预判内存风险,而不是等到线上OOM才去查堆转储。
比如我看到一个“用List<Long>存储百万条时间戳”的设计,脑子里第一反应就是内存至少是long[]的几倍,然后才是GC压力。这种判断不是靠背诵,而是基于对数组对象、包装对象、引用层级这些底层机制的理解。地基打得牢一点,上面修多高的楼都不会慌张。
最后分享一个我一直在用的小技巧:写代码时,每个数组都问自己三个问题——这个数组长度是否确定?最大值是多少?里面的元素是基本类型还是引用类型?三个问题问完,80%的数组相关内存问题都能在设计阶段避开,剩下的,交给GC日志和堆转储慢慢查就好。