刚学 Java 集合框架的时候,我被这几个基础问题卡了很久

我刚开始学 Java 集合框架的时候,脑子里是一团浆糊。

ArrayList、LinkedList、HashMap、HashSet、TreeMap……一堆名字砸过来,每个看起来都能存东西,但好像又不太一样。更别说还有 Collection 和 Collections 这种长得差不多、实际两码事的东西。

那时我在想:这么多东西,我到底要从哪开始?

后来我发现,其实不需要一口气全搞明白。集合框架是有套路的——先搞清楚那几个最基础的概念,后面那些实现类自然就能串起来。

这篇就是我当初最希望有人能跟我讲清楚的东西。

数组和集合,到底差在哪

学集合之前,Java 里存数据最基础的方式是数组。我们最开始写代码的时候,大概都写过类似的东西:

String[]names=newString[10];

看起来没什么问题对吧?但很快你会发现,这个长度 10 是定死的。数据超过 10 个存不进去,少于 10 个又浪费空间。更难受的是,你一开始根本不知道到底要存多少个。

我印象很深,有次写个小程序要记录用户输入的单词,想先声明一个足够大的数组,但又怕太小会溢出,最后给了一个 1000 的容量。后来一想,这不就是瞎蒙嘛。

这就是数组最大的问题:长度固定,不灵活

集合就是来解决这个的。它像个能自动伸缩的容器,你往里放东西它会自己长大,拿出去它也不闹。用 ArrayList 的话,上面那段代码就变成了:

List<String>names=newArrayList<>();

想加几个加几个,不用提前算。

除了长度,还有两个区别我当时也觉得挺有意思的。

一个是数组啥都能装,基本类型也好、对象也好,都能往里塞:

int[]numbers=newint[5];

但集合就不行。你不能写ArrayList<int>,只能写ArrayList<Integer>。这个后面细说。

另一个是访问方式。数组用下标直接取,names[0]就行。集合得通过迭代器或者一些工具方法——当然这只是写法上的区别,不是啥大问题。

所以如果让我总结,数组像个定制的储物柜,每个格子大小固定、位置固定。集合像个编织袋,能撑能缩,装啥都行,就是拿东西的时候得翻一翻。

日常开发里,除了那种确定长度、频繁按索引访问的场景,大部分情况下集合用起来更顺手。

Collection 和 Collections,我被这个名字骗过

我第一次看到这两个名字的时候,以为 Collection 是接口,Collections 是它下面的某个实现类。

错了。

Collection 是接口。它是所有集合类(List、Set、Queue 这些)的"老祖宗",定义了一组通用的方法,比如 add、remove、isEmpty、size。不管是 ArrayList 还是 HashSet,它们都实现了这个接口,所以你才能用一样的方法往里面加东西、删东西。

Collections 是工具类。注意有个 s 在尾巴上。它是一堆静态方法的集合,用来操作集合的:排序、查找、打乱顺序、反转、包装成不可变的……它本身不是集合,是个工具箱。

我当时怎么搞清楚这个的?是我写了一段代码:

Collections.sort(list);

然后回去找 Collection 接口,发现里面根本没有 sort 方法。顺着往下翻才看到 Collections 这个类。这俩名字就差一个字母,但一个是"我要继承/实现的规范",一个是"我可以调用的工具"。

知道这个之后,很多困惑就解开了。每次看到一个新类,先问自己:它是 Collection 接口的实现类(比如 ArrayList、HashSet),还是 Collections 这个工具类提供的功能(比如排序、不可变集合包装)?

搞清楚这个分类,集合框架就不会那么乱了。

遍历一个集合,有多少种方式

讲遍历之前,先说说我一开始犯过的傻。我曾以为遍历只有一种方式,就是 for 循环按索引取。后来发现根本不是这么回事——Java 集合的遍历方式多得超乎想象。我当时把几个常见的方式都试了一遍,每个都有自己的使用场景。

普通 for 循环

这个只对 List 有用,因为 List 有索引:

for(inti=0;i<list.size();i++){Stringitem=list.get(i);}

这种写法的好处是你能控制索引。但 Set 和 Map 没有索引,这就用不了了。

增强 for 循环(for-each)

这个当时让我觉得挺神奇的——不用管索引,直接把每个元素拿出来:

for(Stringitem:list){System.out.println(item);}

大部分需要遍历所有元素的时候,我都用这个。简洁、直观、不容易错。

Iterator 迭代器

这个我一开始不理解:既然 for-each 已经能遍历了,为什么还要迭代器?

后来遇到一个问题就知道了:我想在遍历的过程中删掉某些元素

用 for-each 这么写是会报错的:

for(Stringitem:list){if(item.equals("xxx")){list.remove(item);// 并发修改异常}}

但用 Iterator 就可以:

Iterator<String>it=list.iterator();while(it.hasNext()){Stringitem=it.next();if(item.equals("xxx")){it.remove();}}

原理是:Iterator 的 remove 方法会同步更新集合的修改状态,不会触发并发修改检查。当时搞明白这个的时候,有一种"哦原来是这样"的感觉。

ListIterator 列表迭代器

Iterator 的升级版。它除了能往后走(next),还能往前走(previous)。而且可以在遍历的时候添加和修改元素。

当时我写了一个小程序,需要在列表中间插入几个元素,ListIterator 帮了大忙:

ListIterator<String>lit=list.listIterator();while(lit.hasNext()){Stringitem=lit.next();if(item.equals("某个条件")){lit.add("新元素");// 在当前迭代位置插入}}

不过实际开发中用的不算多,知道有这么个东西就行,真需要了再回来翻。

forEach 方法(Java 8)

JDK 8 之后,集合可以直接调 forEach 方法,传一个 Lambda 表达式进去:

list.forEach(item->System.out.println(item));

我当时看到这个写法觉得挺酷的,代码一下子短了好多。不过一开始 Lambda 看着不太习惯,写多了就好了。

Stream API

这个说实话我学了一段时间才真正敢在代码里用。Stream 不只是遍历,它提供了一套"流水线"操作:你可以过滤、映射、排序、收集,用链式调用串起来。

list.stream().filter(item->item.length()>3).sorted().collect(Collectors.toList());

最开始我觉得这写法很怪,习惯了之后发现有些场景确实用起来很舒服,尤其是需要对集合做多步处理的时候。

所以总结一下这 6 种方式:普通 for 和 for-each 最常用;需要边遍历边删就用 Iterator;需要双向遍历用 ListIterator;喜欢函数式风格就用 forEach 和 Stream。没有哪个是最好的,看场景选。

为什么 ArrayList<int> 会报错

我学集合早期被编译报错卡住过的一个问题是:为什么不能new ArrayList<int>()

上网查了之后大概明白了——Java 的泛型是编译时的"假把式"。编译完之后,泛型的类型信息会被擦除掉(官方叫"类型擦除")。

什么意思呢?你写ArrayList<String>ArrayList<Integer>,在编译后的字节码里它俩都是ArrayList<Object>。String 和 Integer 是引用类型,擦掉之后变成 Object,没问题。但 int 是基本类型,不是对象,擦掉之后没法变成 Object。

解决方案就是用包装类。

Java 为每个基本类型都准备了一个包装类:int → Integer、char → Character、double → Double、boolean → Boolean……以此类推。

而且 JDK 5 之后,Java 有自动装箱和自动拆箱机制。你写:

List<Integer>list=newArrayList<>();list.add(10);// 自动装箱:int → Integerintn=list.get(0);// 自动拆箱:Integer → int

表面上你写的是 int,实际上编译器偷偷帮你转了。代码看起来没啥区别,但如果你追求性能,大量的装箱拆箱是有开销的。当然,这是后面才会关心的事了。刚开始学,知道泛型不能用基本类型、用包装类就行。

List 和数组互相转换,踩过的坑

日常开发里经常需要在数组和 List 之间倒腾。看起来简单,但稍不注意就容易踩坑。

List 转数组

String[]arr=list.toArray(newString[0]);

JDK 1.8 之后官方推荐传一个长度为 0 的数组,性能更好。原理是 toArray 方法内部会判断:如果传进来的数组长度不够,就反射创建一个新的。传 0 长度的数组,它会直接创建一个恰好够用的新数组返回。

数组转 List

这个坑更多一些。最直接的方式是Arrays.asList()

List<String>list=Arrays.asList(arr);

看起来一切正常对吧?但如果你后面写了list.add("xxx"),就等着抛异常吧。

Arrays.asList 返回的 List 是不可变的。不能 add,不能 remove。它只是把数组包装成了一个"看起来像 List"的东西,底层还是那个数组。

我当时第一次遇到这个问题的时候,对着报错看了半天,心想:这不是 List 吗?怎么不能加元素?

解决办法是包一层:

List<String>list=newArrayList<>(Arrays.asList(arr));

这样才是真正的可变 ArrayList。

还有另一个坑是基本类型数组。如果你写:

int[]arr={1,2,3};List<int[]>list=Arrays.asList(arr);// 注意泛型是 int[],不是 Integer

它不会得到一个有三个元素的 List,而是得到一个有一个元素的 List——这个元素就是整个 int 数组。因为泛型不支持 int,所以 Arrays.asList 把整个 int[] 当成了一个对象。

解决方案有两种。一种是用 Integer 包装类的数组:

Integer[]arr={1,2,3};List<Integer>list=Arrays.asList(arr);// 正常了

另一种是用 Stream:

int[]arr={1,2,3};List<Integer>list=Arrays.stream(arr).boxed().collect(Collectors.toList());

这个坑我第一次踩到的时候真的有点无语——心想"这也行?"后来只能说,记住就好,基本类型在集合的世界里是个特殊的存在。

最后说几句

写这篇的时候我一直想着当初学集合框架的我自己。那时候看到那么多类名、接口、方法,真的很想有人能告诉我:先搞清楚这几块基础——数组 vs 集合、Collection vs Collections、怎么遍历、类型的问题、转换的问题——后面的具体实现类学起来会顺畅很多。

我就是按这个路子过来的。先打基础,再看细节。ArrayList 和 LinkedList 有啥区别、HashMap 的哈希冲突怎么解决的、TreeMap 为什么有序……这些是下一篇的事。先把地基踩实了,后面走起来才不晃。

而且说实话,学的时候觉得"哇这么多东西怎么记得住",写代码写多了就会发现常用的也就那几个。新手不用慌,慢慢来,搞懂一个算一个。