拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java中String[]和List<String>的本质区别与工程选型指南

Java中String[]和List<String>的本质区别与工程选型指南

写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; // 编译期不会报错,运行期抛 ArrayStoreException

JVM会在运行期做一次“类型检查”,发现你要塞的类型和数组真正的元素类型不匹配,立刻抛出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,子列表抛ConcurrentModificationExceptionsubList返回的是一个视图,原List结构性修改会破坏视图要么只操作子列表对象,要么new ArrayList<>(subList)创建独立副本
使用List.of()时传入了null元素,抛NullPointerExceptionList.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还是数组看接口文档约定,不要随手混用。这是我踩过多次线上问题之后总结出来的血泪教训,分享出来,希望大家少走点弯路。

返回列表