写Java这么多年,我越来越觉得“String[]和List 的区别”这个问题,几乎能一杆子戳进每个Java开发者的知识盲区——不只是新手,很多工作三五年的老伙计在被突然问起时,也会愣一下,然后说“数组定长,List变长呗”,再往深一点就讲不透了。
但实际写业务代码的时候,这个选择几乎每天都在做:查询出来的结果集到底存成数组还是List?方法返回值你写成String[]还是List<String>?接口定义里用哪个更好扩展?这些问题如果只是背结论,写代码就会特别扭,总觉得差那么一口气。今天就从一个实际写代码的视角,把这两个东西掰开揉碎讲清楚,包括底层的存储差异、泛型的运行机制、日常开发中互相转换时踩过的坑,以及什么场景下选哪个更合适。
先说个结论放在前头:数组是语言层面提供的定长容器,List是JDK集合框架里定义好的接口,它只管“行为约定”,真正的存储逻辑在ArrayList、LinkedList这些实现类里。这个定位差异会带出一连串具体表现,下面一个一个展开。
1. 先搞清楚本质:数组和列表家族到底差在哪
1.1 定长与变长:一个终究要面对的分岔路
数组在Java里是一个比较“原生态”的东西,它直接由JVM支持。你写String[] arr = new String[10],在底层就会分配一块连续的内存空间,这块空间的大小在创建那一刻就被钉死了,之后没法改。你没法往一个长度为10的数组里硬塞第11个元素,也没法把一个长度为10的数组“缩”成9个。如果你确实想“扩容”,唯一的方式就是新建一个更大或更小的数组,然后把原数组的内容复制过去——这一步会带来额外的内存分配和数组复制开销,代码写得也很丑。
List就灵活得多。List<String>是一个接口,我们平时常用的ArrayList、LinkedList都是它的实现。以最常见的ArrayList为例,它内部其实还是用一个数组去存数据的,但它在“装满了”之后会自动扩容,通常的规则是创建一个原来容量1.5倍的新数组,然后把旧数据拷贝过去。这个过程对外不可见,你只管调用list.add("hello"),剩下的事情容器自己搞定。
所以第一条本质区别就很清楚了:数组是不变的,List是可变的。但这并不是说List一定更好——恰恰相反,在某些场景下,“不可变”反而是优势,这个放到后面选型部分详细说。
1.2 类型系统层面的差异:数组的“协变”与泛型的“不可变”
这一层很多人没仔细想过,但理解了之后,很多报错信息你一眼就能看穿。
Java的数组是“协变”的。意思是,如果String是Object的子类,那么String[]也被视为Object[]的子类,所以下面这段代码是可以通过编译的:
String[] strings = new String[10]; Object[] objects = strings; // 编译通过看起来挺灵活对不对?但这种灵活是有代价的。因为数组在运行时是“带类型”的,JVM知道这个数组实际存的是String而不是Object。所以当你试图往这个Object[]里放一个Integer的时候:
objects[0] = 123; // 编译期不会报错,运行期抛 ArrayStoreExceptionJVM会在运行期做一次“类型检查”,发现你要塞的类型和数组真正的元素类型不匹配,立刻抛出ArrayStoreException。这是数组为了“协变”付出的代价——把类型错误的发现推迟到了运行时。
而泛型(List这种)走的是另一条路:它是“不可变”的。List<String>并不是List<Object>的子类型,编译器根本不允许你把一个List<String>赋值给List<Object>。这样反而更安全,因为类型问题在编译期就挡住了,根本走不到运行时。从设计哲学上讲,数组把类型检查的部分责任交给了JVM运行时,而List把类型安全全部压在编译期,这是两种完全不同的思路。
提示:因为这一点,当你看到
List加不加泛型的代码混在一起时,会出现各种unchecked警告。遇到这类情况,标准做法是全部改成明确泛型类型,不要用裸List,否则你在编译期得到的保护全都会失效。
还有一个容易被忽略的点:数组在创建时必须明确元素类型,而泛型在运行期会被“擦除”。所以你不能直接创建泛型数组,比如new List<String>[10]这种写法在Java里是非法的。这也是为什么很多集合源码里,底层用到数组的地方都写得比较费劲,比如ArrayList内部其实就是Object[] elementData,取出来的时候再强转成T。
2. 日常开发里最直观的体验差异
2.1 你写代码时的手感:API丰富度对比
这个问题如果你去问一个写惯了Python的人,他可能会反过来问你:“Java的数组居然不能直接知道长度?居然不能用add添加元素?这也太原始了吧。”确实,数组在Java里更像一个“纯数据结构”,它只提供了length属性和下标访问的能力,其他任何操作——查找、插入、删除、是否包含某个元素、排序、截取子集——都需要你自己写循环或者借助java.util.Arrays这个工具类。
举个例子,你要判断一个数组里有没有某个字符串,只能这样:
for (String s : arr) { if ("target".equals(s)) { // 找到了 } }而用List的话,一个list.contains("target")就搞定了。如果是要找下标,数组还是得自己写循环,List直接list.indexOf("target")。如果你还要在指定位置插入、删除元素、批量移除、截取子列表、按条件过滤……数组的操作难度会越来越大,List搭配Stream API之后更是碾压级的优势:
List<String> filtered = list.stream() .filter(x -> x.startsWith("a")) .map(String::toUpperCase) .toList();这段代码用数组实现的话,至少得写十行循环再加一个临时列表来收集结果。
所以从“写起来爽不爽”这个维度,List几乎完胜数组。数组唯一在写代码上省心的地方就是创建和访问——new String[]{"a", "b"}或者arr[0],就这么简单直接,没有任何花活。
2.2 基本类型与包装类型:int[] 与 List 的距离
这个点很多人踩过坑却没细想。
数组可以直接存基本类型,所以你可以写出int[] numbers = new int[10];,它是真正的整数数组,里面每个元素都是实实在在的int,内存紧凑,没有额外的对象头开销。但List不行。泛型参数必须是引用类型,所以你想用List装整数只能写List<Integer>,而Integer是包装类型,每个元素都是一个独立的对象,会额外占用内存。
这就带来几个实际问题:
- 自动装箱(autoboxing):写
list.add(10)的时候,JDK默默把int装箱成Integer;读出来int x = list.get(0)时,又会拆箱。这种转换在数据量小的时候基本无感,但如果是几百万个元素的大批量处理,装箱拆箱的开销会非常明显。 - 内存占用差异:
int[]每个元素占4字节,Integer每个对象本身要占16字节左右(32位JVM或开启压缩指针的64位JVM上还不太一样,但肯定比基本类型大得多)。一百万个int的数组大概4MB,一百万个Integer的列表可能要二三十MB甚至更多。 - null的问题:
int[]里不可能出现null,而List<Integer>里每个元素都可能是null。你在取出来用之前不做空判断,拆箱的时候一个NullPointerException就甩你脸上。
所以涉及大量数值计算的场景,数组是有明显优势的。这也是为什么很多高性能计算、图像处理库内部都用数组而不是List。
3. 性能真相:到底差多少,差在哪
3.1 数组的底层优势:连续内存与随机访问
虽然现在JVM做了很多优化,JIT编译器也会对热点代码做各种“骚操作”,但在最基本的“随机访问”性能上,数组依然是标杆。
为什么?因为数组在内存里是一块完全连续的空间。你访问arr[i],JVM直接在当前数组对象的内存基地址上,加上i * 元素大小的偏移量,一步定位到目标内存。这个过程是直接寻址,没有任何中间层,时间复杂度是O(1),而且常数项极小。
ArrayList底层也是数组,所以它的get(int index)性能其实和数组是一样的,也是O(1),底层也是同样的地址计算逻辑。那List的劣势体现在哪?主要在于“附加操作”。
比如ArrayList每次调用get都要做一次范围检查,防止越界访问;调用add的时候要先检查容量够不够,不够还要走扩容流程。这些检查虽然成本很低,但在极高频次的调用下,累加起来就和纯数组拉开差距了。另外,遍历数组的时候,CPU的缓存命中率通常比遍历List更高,因为数组内存连续,迭代时能预取数据到缓存行里;而List内部虽然也是数组(ArrayList),但由于每次访问都要走方法调用和边界检查,至少在某些基准测试里会慢一点。
LinkedList就更不用说了,每个节点在内存里东一块西一块,随机访问是O(n),因为它得从头节点一个一个找过去。所以如果你要在List和数组之间做性能比较,正确的方式是拿“数组 vs ArrayList”比,而不是拿“数组 vs LinkedList”比——后者根本不是同一个量级的东西。
3.2 扩容机制的代价与收益
ArrayList的扩容机制,简单说就是“内容满了之后,申请一块更大的内存,把旧内容整体拷贝过去”。这个拷贝操作是O(n)的,如果业务代码在一循环里反复往一个大List里加数据,就会反复触发扩容和拷贝,性能会有明显波动。
有一段很经典的面试代码:
List<String> list = new ArrayList<>(); for (int i = 0; i < 1000000; i++) { list.add("item-" + i); }这段代码你到底让ArrayList扩容了多少次?JDK的默认初始容量是10,到第11个元素触发第一次扩容,变成15;到第16个元素触发第二次,变成22;再往后就是32、48、72、108……一直翻到能装下100万个。粗略估算,整个过程可能要触发几十次扩容,每次都要重新分配内存和搬运数据。
如果你提前知道大概需要多少条数据,一个典型的优化手段就是:
List<String> list = new ArrayList<>(1_000_000);一次性把初始容量设成足够大,这样后续就完全避开了扩容。这个优化在数据量上万之后效果非常明显。反过来,数组不存在这个问题,因为数组在创建时必须指定长度,这个“必须指定长度”的特性虽然死板,但好处是JVM可以一次性申请好全部内存,不用中途搬来搬去。
注意:扩容这个“搬数据”的动作是有代价的,但它的均摊复杂度仍然是O(1),也就是说大多数
add操作都是直接写入,只有少数add操作会触发拷贝。所以日常写代码,只要你没有极端的性能要求,直接new ArrayList<>()是没问题的,不用过于担心。
4. 选型指南:什么场景必须用数组,什么场景必须用List
4.1 用数组的典型场景
我在写业务代码的时候,以下场景会倾向用数组:
- 固定数量的元素集合。比如一周七天这种恒定结构,直接
String[] WEEKDAYS = {"周一", ..., "周日"},简单、稳定、不会被误改。 - 基本类型的大量数据。比如要从一个文件里读100万个整数做计算,用
int[]会比List<Integer>省很多内存,也快不少。 - 和外部系统交互的边界。很多第三方SDK、数据库驱动、网络框架的方法签名就是用数组定义的,比如
String[] split(String regex)的返回值、char[]这种密码处理,这时候你要传数据进去或者接返回值出来,自然就得按数组来写。 - 性能极其敏感的底层逻辑。比如线程池源码里的
Worker[]、消息队列里无锁环形队列的缓冲区,基本都是数组。
4.2 用List的典型场景
反过来,List的经典使用场景就太多了:
- 不确定长度的数据收集。业务数据大多是这种——你根本不知道这个接口会查出来多少条记录,用List正合适。
- 需要频繁增删改查。List接口直接提供了
add、remove、contains、indexOf等方法,不需要自己造轮子。 - 需要流式操作和函数式处理。过滤、去重、排序、分组、求和,Stream API基本都是基于Collection的,数组要享受这些便利,还得先转成List或者流对象。
- 方法之间的数据传递和接口边界。对外提供API时,返回
List<String>几乎总是优于返回String[]。因为如果你将来想把这个方法改成返回一个不可变列表、一个子列表视图,或者一个懒加载的流数据,List接口极具扩展性;数组一旦定死,想改就得动调用方的代码。
我自己的经验是:除非特别明确的“固定且访问密集”的场景,否则默认用List。数组在日常业务代码里更像一个“底层替代品”或者“性能敏感场景专用工具”,不应该成为首选。
5. 两者互转:绕不开的几个大坑
5.1 Arrays.asList的三大陷阱
数组转List,最常用的办法就是Arrays.asList(),但这个工具方法有三个大坑,我几乎每周都能在同事代码里看到:
坑一:返回的List不支持add和remove。很多人以为Arrays.asList()返回的是一个ArrayList,其实它返回的是java.util.Arrays$ArrayList——一个Arrays内部定义的私有静态类,底层仍然是那个固定长度的数组。你在这个“List”上调用add或remove,直接抛UnsupportedOperationException。这个异常在测试环境爆出来的频率极其高。
坑二:它不做防御性拷贝,直接引用了原数组。把数组转成List之后,如果你修改原数组的元素,List里的“元素”也会跟着变。因为List内部存的还是同一个数组的引用,它只是给数组套了一层List的壳。反之亦然。
坑三:泛型推断不够聪明。如果你传入的是一个基本类型数组int[],Arrays.asList()不会把它编译成List<Integer>,而是返回一个List<int[]>——一个包含单个数组对象的List。这是很多人在处理基本类型数组时遇到“明明转成了List,却取不到单个元素”的根本原因。
要用一个干净、独立、可增删的List,正确的姿势是:
List<String> list = new ArrayList<>(Arrays.asList(arr));或者用Java 8+的Stream方式:
List<String> list = Arrays.stream(arr).collect(Collectors.toList());5.2 List转数组的正确姿势
反过来,List转数组,走到Java 11之后可以写得很简洁:
String[] arr = list.toArray(new String[0]);以前很多人会纠结传new String[0]还是new String[list.size()],其实在JDK 6~8的版本里,传长度为0的小数组就够了,因为源码里会做一个优化,如果集合大小比传入的数组大,就会重新分配一个正确大小的数组。甚至Oracle做过一个基准测试,证明new String[0]比new String[list.size()]略快。到了Java 11,还可以直接用list.toArray(String[]::new),语义更清楚。
注意一个容易踩的坑:toArray()的无参版本返回的是Object[],不能直接强转成String[],因为返回运行期的数组类型是Object[]而不是String[],强转会报ClassCastException。所以规范写法一定是传入一个当前类型的空数组作为“模板”。
6. 高频实战场景踩坑记录
6.1 两个List取交集到底怎么写
合并热搜词里我看到有人问“java 获取两个list 交集”,这其实是个非常典型的日常需求。最直接的方式是:
List<String> list1 = new ArrayList<>(Arrays.asList("a", "b", "c")); List<String> list2 = new ArrayList<>(Arrays.asList("b", "c", "d")); list1.retainAll(list2);retainAll会把list1里不在list2中的元素全部移除,所以执行后list1变成["b", "c"],这就是交集。
但这里有几个注意点:
retainAll是在原始List上修改的,如果你还希望原列表保持不变,就得先拷贝一份再操作。- 如果List元素很多,
retainAll的时间复杂度是O(n*m)——它遍历第一个列表的每一个元素,然后在第二个列表里做线性搜索。如果数据量大,应该先把其中一个转成HashSet,用set.contains()的O(1)查找来加速。
List<String> result = list1.stream() .filter(new HashSet<>(list2)::contains) .collect(Collectors.toList());6.2 单个对象转List的几种方式对比
“java单个对象转list”这个热搜词,我自己平时也经常用到。比如你有一个User对象,想以列表形式传给一个统一处理方法。常见写法有三种:
// 方法一:Collections.singletonList List<User> users = Collections.singletonList(user); // 方法二:JDK 9+ 的 List.of List<User> users = List.of(user); // 方法三:new ArrayList 再 add List<User> users = new ArrayList<>(); users.add(user);前两种都是不可变List,不能再添加元素,但如果你的场景就是“只需要封装一个对象”,这两个写法非常简洁。唯一的坑是Collections.singletonList不接受null元素,如果user可能为null,就得用new ArrayList<>()方案或者Arrays.asList(user)——注意Arrays.asList允许null,但转出来的list同样不能增删。
6.3 List分组、排序等集合操作技巧
List还经常要做分组。以前没有Stream的时候,写一个“按某个字段对List分组”的代码,要循环+Map手动拼,大概是十几行。现在一行解决:
Map<Integer, List<User>> groupByAge = users.stream() .collect(Collectors.groupingBy(User::getAge));排序也极其常用。数组排序要Arrays.sort(arr),但List更灵活:
list.sort(Comparator.comparingInt(String::length)); // 或 list.stream().sorted(Comparator.reverseOrder()).toList();用List处理这类集合操作显然比数组顺手得多——这背后是Java集合框架在API设计上的沉淀,也是为什么绝大多数业务代码都在用List的原因。
6.4 StringBuffer/StringBuilder与List的衔接
热搜词里有 “stringbuffer转换为string”,顺带说一句:在做字符串拼接时,如果你发现自己需要用一个List<String>来攒中间值,再循环拼成一个长字符串,其实不如直接用StringBuilder或者StringJoiner高效。
List<String> parts = new ArrayList<>(); // 循环里往parts里塞了很多片段 StringBuilder sb = new StringBuilder(); for (String part : parts) { sb.append(part).append(","); } sb.deleteCharAt(sb.length() - 1); String result = sb.toString();但更简洁的是Java 8之后的写法:
String result = parts.stream().collect(Collectors.joining(","));这个Collectors.joining就是专门为“把List拼成String”设计的,内部实现用的就是StringJoiner,比自己写循环append要优雅得多,也不容易漏掉分隔符的边界处理。
如果你确实要从StringBuilder转换成String,直接sb.toString()就完事了。关于字符串和List之间的转换,还有一个高频操作是把一个字符串按分隔符拆成List:
List<String> items = Arrays.asList("a,b,c".split(","));这里的split返回的是String[],再用Arrays.asList套一层就是List。看似简单,但注意Arrays.asList的坑同样适用:这个List是不可变的,如果想对它做增删操作,放进去前最好先new ArrayList<>()包一层。
7. 常见问题排查速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
对Arrays.asList()返回值调用add/remove抛UnsupportedOperationException | 返回的是内部Arrays$ArrayList,底层仍是定长数组 | 用new ArrayList<>(Arrays.asList(...))包装一层 |
修改原数组后,Arrays.asList转出的List元素也跟着变 | 没有做防御性拷贝,List直接引用原数组 | 需要独立副本时用new ArrayList<>()包装或stream().collect(toList()) |
List.toArray()强转String[]抛ClassCastException | 无参toArray()返回的是Object[],运行期类型不对 | 用list.toArray(new String[0]) |
int[]传给Arrays.asList(),得到的List里只有一个元素 | 泛型无法装基本类型,int[]成了单个对象 | 先用Arrays.stream(arr).boxed()转成List<Integer> |
从List<Integer>取元素时莫名出现NullPointerException | 自动拆箱时遇到了null元素 | 取值前做空判断,或避免在List中放入null |
list.contains(str)慢得离谱 | List中途用了LinkedList,contains是O(n) | 改用HashSet存储,contains是O(1) |
list.subList(0, 5)后操作原List,子列表抛ConcurrentModificationException | subList返回的是一个视图,原List结构性修改会破坏视图 | 要么只操作子列表对象,要么new ArrayList<>(subList)创建独立副本 |
使用List.of()时传入了null元素,抛NullPointerException | List.of不允许null元素 | 允许null的话用Collections.singletonList或new ArrayList<>() |
这张表里的每个坑,我基本都踩过至少一次。有些是在线上环境被用户数据狠狠教训的,比如subList那一条——你写完List<String> firstFive = list.subList(0, 5),然后回头对list做了一次remove,再访问firstFive.size(),JVM直接给你来一个ConcurrentModificationException,说“你不要命了”。根本原因就是subList没拷贝数据,它只是一个“看窗口”,你动着原始数据,窗口整个就失效了。所以凡是subList出来的东西,只要后面还会用到原List,就老老实实再包一层new ArrayList<>()。
还有toArray那一条,以前有个经典写法是list.toArray(new String[list.size()]),被很多老教程推荐,说是避免了扩容。实际上JDK 6之后的实现里,传入的空数组是会被复用的,而且基准测试显示new String[0]反而更快——源码里有一个愚蠢的反射检查逻辑,传入非空数组时要额外做一次类型校验。所以你现在写代码,统一用list.toArray(new String[0])就行了,别带任何负担。
说实话,String[]和List<String>之间的取舍,本质上是“底层容器”和“业务接口”之间的权衡。数组贴近JVM,性能上限高,但API能力弱、灵活性差;List是站在数组肩膀上做出来的抽象层,牺牲了一点点性能和内存,换来了极其丰富的操作能力。我个人的写代码习惯是:方法返回值优先声明List<String>,方法内部做数据缓存时优先用String[]或者绕过容器直接用局部变量。数据在边界处互相转换,记住上面那些坑,问题就不大。
最后说一个在团队协作里特别实用的经验:String[]和List<String>在序列化框架(比如Jackson、Gson)里的表现并不完全一样,数组会被序列化成JSON数组,List也一样,看起来没区别。但反序列化回来的时候,如果目标是String[],遇到字段不存在或值为null,通常不会出问题;如果目标是List<String>,有些严格模式下可能直接给你一个null对象,后面遍历就炸。所以接收外部数据时,尽量用List并做好空值防御,返回数据给外部时,用List还是数组看接口文档约定,不要随手混用。这是我踩过多次线上问题之后总结出来的血泪教训,分享出来,希望大家少走点弯路。