Java集合框架:从ArrayList到HashMap理解Android开发
发布时间:2026/10/10 18:49:01 锦皓数字建站

学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条直接给初始容量减少扩容 ListContact 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网络参数和数据解析中的真实地位网络接口的请求参数很多是keyvalue形式用Map来承载特别自然MapString, String params new HashMap(); params.put(page, 1); params.put(size, 20); params.put(keyword, 手机);网络框架拿到这个Map后可以自由决定把它拼到URL的Query里还是做进表单请求体。如果字段固定更推荐定义成一个请求实体类类型安全又少写put但接口字段不固定、需要动态增删的场景Map比实体类灵活得多。另外很多JSON解析库在解析字段名不固定的对象时也会给出MapString, Object。比如接口返回一段动态属性你用Map承载遍历value时往往需要判断类型这是“动态”要付出的代价。反过来如果字段固定还是尽早解析成实体类的List否则在业务层写一堆类型判断代码会迅速变得难看。3.3 遍历Map的几种常见姿势以及一个顺手的小习惯遍历Map有三种常见写法// 同时拿key和value推荐用entrySet for (Map.EntryString, 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和equalsSet的核心承诺是“元素不重复”但怎么判断两个对象是否相同答案是先看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的完整排查链路给你复现一条典型的报错路径。假设有段代码ListString 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不一致就说明有人在迭代途中动了集合于是果断抛异常。这个设计其实很聪明。如果放任边遍历边删删除一个元素后后面所有元素的下标都变了迭代器继续往下走轻则跳过元素重则访问到错误位置。与其让结果不可预期不如直接告诉你“集合被并发修改了”。这里的“并发”不一定指多线程更准确的理解是“迭代过程中发生了结构性修改”。正确删除要用迭代器自己的removeIteratorString 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的元素后后面的元素全部前移一位原来的i1变成了新的i而循环变量i还在继续递增等于跳过了一个元素。要修就得在删除后i--非常容易出错。所以涉及删除元素的遍历首选Iterator或者JDK 8以后提供的removeIflist.removeIf(s - s.startsWith(a));这样代码更简洁意图也更明确不用手写循环去绕下标。6. 数组与集合互转、多线程访问Android项目里最容易出细节问题的两个点6.1 Arrays.asList返回的并不是“自由身”数组转集合初学者最爱用Arrays.asList但经常掉进一个坑这个方法的返回值是Arrays的一个内部类虽然实现了List接口却直接复用传入的数组作为底层存储而且不支持add/remove等结构性修改。你在asList的结果上调用add会直接抛UnsupportedOperationException。如果你需要的是真正可变的ArrayList就在外面再包一层ListString 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 、MapString, Object。泛型不是让代码显得高级而是让集合真正成为一个“装什么都有约束”的容器。顺便提一下new ArrayList ()里的泛型在Java 7之后可以简写成new ArrayList()这个空尖括号叫菱形语法和接口回调那套逻辑是一脉相承的让编译器去推导类型。6.3 多线程同时访问一个集合千万别裸奔最后一个细节新手容易忽略。Android主线程负责UI子线程负责耗时任务。如果子线程正在往一个ArrayList里写数据主线程同时遍历它来刷新界面由于ArrayList不是线程安全的可能出现数据不一致严重时直接抛异常或者界面错乱。解决思路上有三条常见路线用Collections.synchronizedList(new ArrayList())给集合的每个方法加锁简单但并发度一般读多写少的场景用CopyOnWriteArrayList写操作会复制底层数组读操作不加锁适合数据变化不频繁但读取频繁的场景如果只是“子线程算好结果主线程拿来展示”更推荐直接传递一个新的不可变集合引用而不是两个线程去改同一个集合。我自己写消息类页面时通常会维护一个当前数据列表的引用子线程处理完数据后生成一个全新的List交给主线程主线程替换引用并刷新。这样既避开并发修改也让数据快照的语义非常清晰。这一课学完我建议你做一个自测试着用集合设计一个简单的通讯录App数据层——联系人用List装标签用Set去重分页请求参数用Map封装再想想遍历删除时该选哪种方式。能顺畅完成这些集合框架就算真正进入你的武器库了。我自己当年学集合时一度以为背下所有API就够用后来发现最值钱的反而是那些“为什么不用另一个”的时刻。先把工具用熟再回头读源码这条路走起来更稳。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。