☰
Java集合框架:从ArrayList到HashMap理解Android开发
2026/10/10 18:49:00 网站建设 项目流程

学Java的人一旦打定主意往Android方向走,通常会经历一段“蜜月期”:基础语法会了,类和对象也会了,觉得自己离写出App只差一个官方文档。可真上手写第一个带列表的页面时,十个里有八个会卡在Adapter的数据源上——为什么这里要用List而不是数组?为什么要套一层泛型?为什么在循环里删个元素,程序说崩就崩?这一课,恰好就是Java基础过渡到Android开发的“分水岭”内容:集合框架。

这个系列前面的内容已经铺完了环境、语法、面向对象、异常处理这些地基。从第13课往后,要讲的都是Android开发时真正每天都会碰到的Java能力,集合框架是其中最绕不开的一层。这里不打算做源码逐行分析,那对准备入门的同学太劝退;我会按照Android实际开发的顺序来讲,把List、Set、Map、Iterator这些核心概念揉到真实场景里,说清楚为什么这么用、踩过什么坑。适合正在自学Java基础、准备进入Android开发,尤其适合停留在“只会ArrayList”阶段、想真正理解集合体系的人。

1. 为什么Java基础里,集合框架是Android开发的第一道门槛

1.1 一个“能跑但很别扭”的列表页

假设我们要做一个简单的通讯录列表,数据来源可能是本地数据库,也可能是服务器接口。很多初学者习惯这样写:直接在Adapter里new一个数组,把几条测试数据写死,界面上能显示,看起来好像“会做列表了”。可是等后端接口一接,十条变一百条,还要分页加载、下拉刷新,写死的数组马上就不够用了。

你说换数组?数组长度固定,先得知道总共有多少条,分页的时候根本预先不知道;你说用ArrayList?它天然支持动态追加,addAll就能把新一页的数据续上。Android里那个最经典的RecyclerView列表组件,Adapter的数据源几乎都是以List为容器的。getItemCount()要返回list.size(),onBindViewHolder里要按下标list.get(position)。可以说,集合不是Java课上的抽象习题,而是Android列表页的地基。

1.2 集合框架三大门派:先建坐标系,再记API

集合框架的根在java.util包里,整体分两个大家族。第一大家族的顶层接口是Collection,它只存单个元素,下面分化出两个常用子接口:List和Set。第二大家族是Map,它不继承Collection,专门存“键值对”。List的特点是“有序、可重复”,数组就属于“有序但长度不可变”的祖先;Set的特点是“不允许重复”;Map的特点是“一个钥匙开一扇门”。

我建议初学者别急着背类名,先记住这张表,后面遇到需求时先判断属于哪一类,再选具体实现:

接口特点生活中接近的东西Android里最常出现的地方
List有序、可重复、按下标访问排队叫号的队伍RecyclerView数据源、接口返回的JSON数组
Set不重复、实现类之间的顺序约束不同门禁系统里的指纹名单去重、过滤重复标签
Map键值映射、按key快速查找通讯录:姓名对应号码请求参数封装、JSON对象解析、Bundle传值

1.3 为什么说集合是Android开发的“中转站”

Android应用的数据流转路径大概是:网络层或数据库层拿回原始数据,经过解析和转换,交给Adapter展示;用户操作之后,数据又会被封装成新的请求参数发出去。这个闭环里,集合就是各个模块之间的“中转站”。服务端返回的JSON数组要装进List;提交表单参数要装进Map;本地缓存去重可能要维护一个Set。

如果你只把集合当成“放东西的盒子”,你会觉得它只是API之一;但如果把它当成数据流转的通道,你会发现集合框架是整个App开发的基建。很多人在后面学网络框架时搞不懂为什么回调里传的是List 而不是Bean数组,学Bundle时不知道它和Map有什么区别,根子都在这一课:集合的体系逻辑没理清。把这一层补上,后面很多问题会迎刃而解。

2. ArrayList与LinkedList的选型逻辑,不是死记的

2.1 ArrayList的底层和那根“默认容量10”的弦

很多人背过“ArrayList底层是数组”,但没细想过这句话意味着什么。new ArrayList<>()时,它内部先准备了一个Object[],默认容量是10。往里面add元素,如果没满就直接放到最后一个空位上;一旦满了,就会创建一个新数组,长度大约是原来的1.5倍,再把老数组里所有元素复制过去。所以add的均摊复杂度是O(1),但扩容那一次会有成批的拷贝开销,数据量越大越明显。

这个机制带给实际开发的直接启发是:如果你能估算出数据规模,就不要空着构造,直接指定容量,比如:

// 分页接口一页固定20条,直接给初始容量,减少扩容 List<Contact> contactList = new ArrayList<>(20);

这个优化平时不起眼,但在快速滑动列表、频繁创建新Adapter数据源的场景里还是有意义的。写得早不如写得巧,少几次扩容往往比花哨的算法更实在。

2.2 LinkedList没有那么万能,Android里首选还是ArrayList

很多教程列过一张经典对比表:ArrayList查快增删慢,LinkedList增删快查慢。这张表没错,但很容易误导人。LinkedList的“增删快”是有条件的——如果是中间位置插入,它先要通过指针从头或尾遍历到那个位置,这个遍历本身就是O(n),并不比ArrayList的数组拷贝强到哪里去;只有在头部或尾部操作频繁时,它才有明显优势。而且LinkedList每个元素都是一个节点对象,需要额外存前后指针,内存占用比ArrayList大不少。

回到Android场景:手机内存本来就金贵,列表页的数据绝大多数是“装载数据→遍历展示→分页追加”,这是ArrayList的主场。RecyclerView的Adapter里几乎看不到有人用LinkedList,原因就在这里。倒不是说LinkedList一无是处,而是选型要看具体场景:如果真有一个“任务队列”需要频繁头尾插入删除,比如实现消息流里的事件队列,那时再考虑它不迟。

2.3 addAll和subList两个操作,很多人用错却不报错

先说addAll。分页加载的标准姿势是:新数据到达后,list.addAll(新数据),然后调用adapter.notifyItemRangeInserted(oldSize, newSize),让RecyclerView感知数据的增量变化。这个模式很基础但值得养成习惯——不要在每次刷新时创建一个新List来替换旧引用,那会让列表的滚动位置丢失,用户往下翻得好好的,突然被拉回顶部。

再说subList。List.subList(from, to)返回的不是一份拷贝,而是原List的“视图”。对subList做的修改会直接反映到原List上。如果在subList里add或remove,可能导致原List结构变化,之后再用原List遍历,很容易触发ConcurrentModificationException。想要独立副本,正确写法是new ArrayList<>(list.subList(from, to)),把视图里的内容拷一份出来。这个坑因为“不报错”反而最危险,等哪天某个角落悄悄改了原列表,排查起来很费劲。

3. HashMap的实战理解:从底层原理到Android里的高频应用

3.1 put一个键值对时,发生了什么:桶、哈希值、链表

HashMap并不是把键值对按顺序平铺存储,而是为每个key计算哈希值,决定它大概落在哪个“桶”里。具体过程:先得到key的hashCode(),做一次扰动,再对桶数组长度取模,定位到一个下标。如果这个下标的位置是空的,直接放进去;如果已经有其他key占了,就让它们通过链表连起来;链表太长时,JDK 8之后会把链表转成红黑树,避免极端情况下查询退化成O(n)。

这套机制带来两个直接结论。第一,HashMap的get和put平均复杂度是O(1),这是它比列表更适合“按key查”的原因。第二,HashMap完全不保证遍历顺序,你put进去的顺序和遍历出来的顺序可以是两码事。很多人第一次打印HashMap发现顺序乱了,以为代码写错,其实是没理解哈希定位。理解这一点,再看后面Binder、Bundle这些Java层数据结构的设计思路,也会顺很多。

3.2 Map在Android网络参数和数据解析中的真实地位

网络接口的请求参数很多是key=value形式,用Map来承载特别自然:

Map<String, String> params = new HashMap<>(); params.put("page", "1"); params.put("size", "20"); params.put("keyword", "手机");

网络框架拿到这个Map后,可以自由决定把它拼到URL的Query里,还是做进表单请求体。如果字段固定,更推荐定义成一个请求实体类,类型安全又少写put;但接口字段不固定、需要动态增删的场景,Map比实体类灵活得多。

另外,很多JSON解析库在解析字段名不固定的对象时,也会给出Map<String, Object>。比如接口返回一段动态属性,你用Map承载,遍历value时往往需要判断类型,这是“动态”要付出的代价。反过来,如果字段固定,还是尽早解析成实体类的List,否则在业务层写一堆类型判断,代码会迅速变得难看。

3.3 遍历Map的几种常见姿势,以及一个顺手的小习惯

遍历Map有三种常见写法:

// 同时拿key和value,推荐用entrySet for (Map.Entry<String, String> entry : map.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); } // 只要key,用keySet for (String key : map.keySet()) { } // lambda写法 map.forEach((key, value) -> { });

如果只想拿value,就用values()。很多教程提醒过:既拿key又拿value时别用keySet再get一次,因为那等于多做一次哈希查找,数据量大时有差异。不过说实话,Android应用里一个Map几十个元素是常态,这点性能差异根本感知不到。我自己的体会是,养成entrySet的习惯更重要,不是为了快,而是思路更直接——你本来就同时需要这对数据,何必拆开再凑回去。

4. Set与Queue:教程里常讲、真正用的时候容易懵的集合

4.1 去重为什么必须同时重写hashCode和equals

Set的核心承诺是“元素不重复”,但怎么判断两个对象是否相同?答案是先看hashCode,再看equals。HashSet内部其实就是一个HashMap的key集合。当你add一个元素,它先计算hashCode,找到对应的桶;如果桶里没这个hash族,就认为不重复;如果发现有同hash的元素,再用equals做精确比较。

这里藏着无数人踩过的坑:如果自定义实体类只重写了equals,没重写hashCode,那么两个内容完全相同的对象hashCode可能不同,会被分配到不同的桶里。HashSet会把“看起来一样”的两个对象都收进来,去重直接失效。反过来,只重写hashCode不重写equals,也可能把不同的对象误判到同一个桶里做比较。所以Java有一个硬规矩:重写equals必须同时重写hashCode。面试爱问,实战里更是直接决定你的去重逻辑到底灵不灵。

4.2 LinkedHashSet与TreeSet:去重之外,顺序也有讲究

HashSet不保证顺序。如果去重后还希望保留原始添加顺序,用LinkedHashSet。它在HashSet基础上多维护了一条链表记录插入先后关系,遍历顺序和插入顺序一致,性能略低但通常无感。TreeSet则完全不同,它不是靠hashCode找位置,而是根据元素自然顺序或你传入的Comparator排序存储,插入复杂度变成O(log n)。

Android端业务里TreeSet的使用频率确实不高——不是它弱,而是排序需求往往在数据库层或接口层就处理掉了,很少会有人专门把一堆Java对象塞进TreeSet来排序。但知道它存在、知道它适用场景,还是必要的。比如将来你写一个需要自动排序的优先级任务队列,TreeSet可能就是顺手的选择。

4.3 Queue接口:集合里那个讲“排队”的老实人

很多人学集合时把Queue排在最后,因为日常写界面好像用不上。但“先进先出”这个模型在Android里并不少见:日志上报链路先在内存里排队再逐个发到服务端,或者一个任务执行器按顺序消费任务。实现Queue接口的经典类是ArrayDeque、LinkedList和PriorityQueue,其中ArrayDeque做队列比LinkedList更省内存,且不允许null元素。

对初学者来说,不需要把Queue玩得多深,但要能认出“先进先出”场景和Queue的对应关系。真到需要时,你至少知道该往哪个方向查。集合框架最大的价值不是让你背类名,而是给你一套解决问题的形状:要列表有列表,要唯一有Set,要映射有Map,要排队有Queue。

5. 遍历背后的Iterator机制,是很多集合异常的根源

5.1 增强for循环是语法糖,幕后英雄是Iterator

很多初学者以为for (String s : list)就是一种普通循环语法。实际上,它编译后被改写成基于Iterator的遍历:先调用list.iterator()拿到迭代器,每次循环调用hasNext()判断还有没有元素,有就next()取出下一个。这个机制被刻意隐藏了,写起来很方便,但也正因为被隐藏,一出问题很多人就找不到原因。

所以排查集合相关异常,第一反应应该是“这里是不是在迭代过程中动结构了”,而不是“JDK是不是写错了”。理解Iterator的存在,是这一步的前提。

5.2 ConcurrentModificationException的完整排查链路

给你复现一条典型的报错路径。假设有段代码:

List<String> list = new ArrayList<>(Arrays.asList("apple", "banana", "cherry", "date")); for (String s : list) { if (s.startsWith("a")) { list.remove(s); } }

第一次循环取出“apple”,符合条件,remove执行成功。第二次循环调用next()时,JDK发现集合结构被改过了,直接抛ConcurrentModificationException。背后的原因:集合内部维护了一个modCount字段,每次add、remove、clear这类结构性操作都会加一;Iterator创建时记下一个expectedModCount,等于当时的modCount。之后每次调用next(),迭代器都会检查集合现在的modCount是否还等于expectedModCount,不一致就说明有人在迭代途中动了集合,于是果断抛异常。

这个设计其实很聪明。如果放任边遍历边删,删除一个元素后,后面所有元素的下标都变了,迭代器继续往下走,轻则跳过元素,重则访问到错误位置。与其让结果不可预期,不如直接告诉你“集合被并发修改了”。这里的“并发”不一定指多线程,更准确的理解是“迭代过程中发生了结构性修改”。

正确删除要用迭代器自己的remove:

Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.startsWith("a")) { it.remove(); } }

iterator.remove()会同步维护expectedModCount,所以不会引发异常。

5.3 普通for循环删除也有坑:下标前移陷阱

有人会绕开增强for,改用普通for循环list.remove(i),结果发现某些元素没被删干净。原因很简单:删除下标i的元素后,后面的元素全部前移一位,原来的i+1变成了新的i,而循环变量i还在继续递增,等于跳过了一个元素。要修就得在删除后i--,非常容易出错。所以,涉及删除元素的遍历,首选Iterator或者JDK 8以后提供的removeIf:

list.removeIf(s -> s.startsWith("a"));

这样代码更简洁,意图也更明确,不用手写循环去绕下标。

6. 数组与集合互转、多线程访问:Android项目里最容易出细节问题的两个点

6.1 Arrays.asList返回的并不是“自由身”

数组转集合,初学者最爱用Arrays.asList,但经常掉进一个坑:这个方法的返回值是Arrays的一个内部类,虽然实现了List接口,却直接复用传入的数组作为底层存储,而且不支持add/remove等结构性修改。你在asList的结果上调用add,会直接抛UnsupportedOperationException。

如果你需要的是真正可变的ArrayList,就在外面再包一层:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

反过来,集合转数组时,推荐带上类型参数:

String[] array = list.toArray(new String[0]);

虽然传一个空数组看起来有点怪,但JDK实现会按这个数组的类型去分配并填充,能保证返回的确是String[],而不是Object[]。直接调list.toArray()虽然也能跑,返回的却是Object[],后面再强转String[]大概率ClassCastException。

6.2 泛型配合集合:List 比裸List“能打”在哪

集合里装的东西如果不带泛型,就叫裸类型。裸List取出来的元素全是Object,要用具体类型得自己强转;一旦集合里混进别的类型,运行时才炸,编译期完全没感觉。写上List 等于在编译期给集合加了一道安检门,放错类型就别想编译通过。

这也是为什么Android网络回调、数据模型里到处都是List 、Map<String, Object>。泛型不是让代码显得高级,而是让集合真正成为一个“装什么都有约束”的容器。顺便提一下,new ArrayList ()里的泛型在Java 7之后可以简写成new ArrayList<>(),这个空尖括号叫菱形语法,和接口回调那套逻辑是一脉相承的:让编译器去推导类型。

6.3 多线程同时访问一个集合,千万别裸奔

最后一个细节,新手容易忽略。Android主线程负责UI,子线程负责耗时任务。如果子线程正在往一个ArrayList里写数据,主线程同时遍历它来刷新界面,由于ArrayList不是线程安全的,可能出现数据不一致,严重时直接抛异常或者界面错乱。解决思路上有三条常见路线:

  • 用Collections.synchronizedList(new ArrayList<>()),给集合的每个方法加锁,简单但并发度一般;
  • 读多写少的场景用CopyOnWriteArrayList,写操作会复制底层数组,读操作不加锁,适合数据变化不频繁但读取频繁的场景;
  • 如果只是“子线程算好结果,主线程拿来展示”,更推荐直接传递一个新的不可变集合引用,而不是两个线程去改同一个集合。

我自己写消息类页面时,通常会维护一个当前数据列表的引用,子线程处理完数据后生成一个全新的List交给主线程,主线程替换引用并刷新。这样既避开并发修改,也让数据快照的语义非常清晰。

这一课学完,我建议你做一个自测:试着用集合设计一个简单的通讯录App数据层——联系人用List装,标签用Set去重,分页请求参数用Map封装,再想想遍历删除时该选哪种方式。能顺畅完成这些,集合框架就算真正进入你的武器库了。我自己当年学集合时,一度以为背下所有API就够用,后来发现最值钱的反而是那些“为什么不用另一个”的时刻。先把工具用熟,再回头读源码,这条路走起来更稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询