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

资讯详情

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

从电影系统实战出发,彻底讲透ArrayList与List<Map>容器用法

从电影系统实战出发,彻底讲透ArrayList与List<Map>容器用法 1. 为什么一个电影系统案例能把ArrayList彻底讲透先说说我为什么想写这个主题。教过几次Java入门也带过几个实习生发现一个很普遍的现象ArrayList的语法大家都会背无非就是add()、remove()、get()、size()但真到了要拿它做点实际东西的时候大多数人会卡住。一写综合案例要么脑子里只有数组要么不知道该怎么组织多条数据要么一迭代删除就崩溃。电影系统的综合案例我前后在教学和项目带人过程中用了不下十遍。原因很简单——电影管理天然就适合拿来练集合它有明确的增删改查业务上架电影、修改信息、下架电影、浏览列表、搜索电影。它有数据多样性片名是字符串、评分是浮点数、上映年份是整数甚至还有主演列表这种集合里套集合的场景。它有排序和筛选需求按评分排个榜、按年份筛一下这些都是ArrayList实战中最常遇到的问题。它有性能讨论点中间插入和删除、扩容时的数组复制都能在经营一个影库的语境里形象地解释清楚。这个案例做下来ArrayList的九成用法都会覆盖到——不只是API调用还包括数据结构选型、迭代陷阱、线程安全问题等进阶内容。这篇文章我不打算只贴代码我会把每一步的设计原因和踩过的坑都一起交代清楚。你跟着走完再遇到用集合存业务数据的需求就不会发怵了。适合看的读者主要是三类刚学完Java基础语法、正在啃集合框架的初学者准备面试、想用实际案例加深理解的学生以及带新人、需要给团队做内部分享的开发者。文章里我会用ListMapString, Object这种热词里经常出现的容器组合也会解释它到底是什么为什么实际项目中很常见。2. 数据结构选型为什么是ArrayList而不是数组或LinkedList2.1 数组的先天缺陷很多新手在存电影数据时第一反应是用数组String[] movieNames new String[100]; double[] movieScores new double[100];这种写法有一个直接的问题容量写死了。今天这周上了10部电影下周要加11部数组就装不下了。要么事先开一个超大数组浪费内存要么每次扩容都得手动写一段新建数组拷贝数据的样板代码。不信你可以试试手动扩容的感受String[] oldArray new String[10]; // ...存了10部电影 String[] newArray new String[20]; System.arraycopy(oldArray, 0, newArray, 0, oldArray.length); oldArray newArray;每个新增需求都要这么来一遍代码很快就会被拷贝逻辑淹没。而且数组还有一个隐蔽问题int[]存基本类型没问题但如果要动态删除中间某个位置的数据你得自己把后面的元素往前挪——这个挪的逻辑非常容易错。2.2 LinkedList为什么也不是首选可能有人会问那LinkedList呢它在头部插入、中间删除不是更“厉害”吗理论上确实如此LinkedList基于双向链表插入删除确实不需要大规模移动元素。但要注意几个现实问题第一LinkedList的内存开销更大。每个节点除了数据本身还要维护前驱和后继两个引用。100万条电影记录的影库多出来的引用开销很可观。第二日常业务里的删除绝大多数不是按索引删而是先查出来再删。比如删除评分低于5分的电影这个查询过程不管用什么List本质上都是遍历。ArrayList的遍历有CPU缓存局部性优势实际速度常常反而比LinkedList快。第三LinkedList的get(index)是O(n)级的哪怕取中间一个元素也要从头或从尾遍历而ArrayList是O(1)。电影系统里频繁要做翻到第20页看第20条记录这类操作ArrayList在这种场景下优势明显。所以结论很明确**没有特殊需求比如只做队列头尾操作业务数据存储几乎无脑选ArrayList就对了。**这也是为什么实际项目中new ArrayList()出现的频率碾压其他集合类。2.3 ArrayList的日常定位ArrayList的本质可以理解成一个会自己长大的数组。它用数组存数据当容量不够时会自动创建一个更大的新数组把旧数据搬过去。这个机制对使用者完全透明你只需要add()往里放就行机制层面的事它自己解决。我习惯把ArrayList比作电影院门口的伸缩围栏——平时有序排列入场人数多了就自动多拉开一段。扩展的结果是连续的物理空间所以随便报一个位置编号你都能快速找到那个位置的观众随机访问快。而LinkedList更像手拉手排成队的人你找第20个人得从队头挨个数过去随机访问慢但中间插队只需要两个人撒手再接上插入效率高。日常做业务系统90%以上的场景都是存一批数据、遍历一遍、按下标取一个。这些场景就是ArrayList的主场。电影系统更是如此——一部电影就是一条记录一整批电影就是集合这个映射关系非常直接。3. 容器设计ListMapString, Object这种写法到底是什么意思3.1 从一个电影到一堆电影的建模确定用ArrayList之后下一个问题就是集合里的每一条数据用什么类型来表示最常见的有两种方案方案一自定义一个Movie类public class Movie { private String title; private String director; private double score; private int year; // getter/setter/构造器 }然后写ListMovie movies new ArrayList();。方案二用Map表示单个电影MapString, Object movie new HashMap(); movie.put(title, 流浪地球2); movie.put(score, 8.3); movie.put(year, 2023);然后写ListMapString, Object movies new ArrayList();。实际开发中这两种方案都很常见。方案一安全、类型明确、面向对象是正规大型项目的首选。方案二灵活写起来快经常出现在从数据库直接查出一批记录然后继续处理的中间环节比如MyBatis查询结果的默认返回方式之一就是ListMapString, Object。3.2 逐层拆解这串魔鬼泛型热词里那句ListMapString, Object tree new ArrayList();初学者看到往往会懵。其实从右往左拆一点都不吓人Object什么都能装的基类数字、字符串、对象都可以放进去。MapString, Object一个键值对集合。键是String类型值是Object类型。put(title, 流浪地球2)就是把title这个键和对应值绑定在一起。ListMapString, Object一种有序列表每一个元素都是一个MapString, Object。new ArrayList()钻石语法编译器会根据左边声明的泛型自动推断右边类型不用再写一遍new ArrayListMapString, Object()。连起来理解一个按顺序存放的袋子袋子里的每个小盒子都装着若干带有字符串标签的东西。一个电影是一个小盒子盒子里的东西用字符串标签区分标着title的就是片名标着year的就是年份。整个影库就是一堆小盒子排成的长队。我在实际讲课时经常让学员在纸上画出这个三层结构。画完之后movies.get(0).get(title)这种代码的意图就非常直观了先取第0号电影再从这部电影里取标签为title的值。3.3 为什么用Map时建议避免这层结构被滥用Map虽然灵活但它的缺点也明显类型不安全、拼错key编译器发现不了。map.get(titel)这种拼写错误在编译期不会报错只有运行到后拿到null才反应过来。而如果用Movie类movie.getTitle()编译时就能检查出来。所以我个人的建议是练手和学习ArrayList机制两种方案都试一遍能加深对泛型和集合嵌套的理解。小型工具、报表、接口对接用ListMapString, Object完全没问题省事。核心业务代码多人维护的模块优先自定义类。清晰、可维护、不会出现一堆魔法字符串。这个案例里知识点是ArrayList我不想让自建类的代码喧宾夺主所以后面功能示例先以ListMapString, Object为主顺带展示如何切换到自定义对象。4. 核心功能实战上架、展示、查询、下架安排一个模拟场景你有一个电影库ArrayList作为存储骨架。现在要实现上架新电影查看全部电影按片名搜索按评分区间搜索修改电影信息下架电影六个功能。我们一段一段把它搭出来。4.1 初始化影库与上架功能首先定义一个全局影库初始容量可以给个合理值import java.util.*; public class MovieSystem { // 影库主容器 private static ListMapString, Object movieLibrary new ArrayList(20); public static void main(String[] args) { initData(); showAllMovies(); } private static void initData() { addMovie(流浪地球2, 郭帆, 8.3, 2023, 173); addMovie(星际穿越, 克里斯托弗·诺兰, 9.4, 2014, 169); addMovie(盗梦空间, 克里斯托弗·诺兰, 9.3, 2010, 148); addMovie(让子弹飞, 姜文, 9.0, 2010, 132); } private static void addMovie(String title, String director, double score, int year, int duration) { MapString, Object movie new HashMap(); movie.put(title, title); movie.put(director, director); movie.put(score, score); movie.put(year, year); movie.put(duration, duration); movieLibrary.add(movie); System.out.println(上映成功 title); } }很多初学者会问为什么new ArrayList(20)要传一个20这就是指定初始容量。ArrayList底层是数组默认新数组容量是10。如果你明确知道要存20部电影以上一开始就给足容量可以避免中途扩容时发生数组复制这是最基础但很实用的性能优化。当然不传参数也没错只是会多几次内部扩容而已。这里还有个小细节要注意HashMap不是按存入顺序保存数据的。movie.put(title, ...)先放还是后放在遍历Map时并不能保证先放的先看到。所以如果你严格需要一个有序的Map应该用LinkedHashMap。实际项目中打印单个电影信息时为了顺序好看我经常用LinkedHashMap替代默认的HashMap。4.2 遍历展示与增强for循环的内部原理展示全部电影是最常用到的遍历操作private static void showAllMovies() { System.out.println( 全部电影 ); for (int i 0; i movieLibrary.size(); i) { MapString, Object movie movieLibrary.get(i); System.out.printf(%d. 《%s》 导演:%s 评分:%.1f 年份:%d 时长:%d分钟%n, i 1, movie.get(title), movie.get(director), movie.get(score), movie.get(year), movie.get(duration)); } }也可以用增强for或者forEach简化for (MapString, Object movie : movieLibrary) { System.out.println(movie.get(title)); }增强for循环本质上是Java编译器帮你改写成迭代器Iterator的调用但写法上省略了显式创建迭代器的步骤。对于ArrayList这种实现了Iterable接口的类增强for是最直观的遍历方式。我把普通for get(index)和增强for都写出来是希望你能意识到ArrayList用普通for并不丢人。它支持随机访问get(i)效率极高很多场景下普通for反而更自然——你需要下标做编号时就该用普通for。而LinkedList用增强for会更稳妥因为它的get(i)是低效遍历。4.3 条件查询从模糊搜索到评分区间电影系统的查询通常分两类一类是精确匹配比如按片名找另一类是范围筛选比如按年份、评分区间。用List做筛选核心思路就一句话遍历每一条数据把符合条件的收集到一个新List里。这一个容器装不下就再开一个容器。private static ListMapString, Object searchByTitle(String keyword) { ListMapString, Object result new ArrayList(); for (MapString, Object movie : movieLibrary) { String title (String) movie.get(title); if (title ! null title.contains(keyword)) { result.add(movie); } } return result; } private static ListMapString, Object searchByScoreRange(double minScore, double maxScore) { ListMapString, Object result new ArrayList(); for (MapString, Object movie : movieLibrary) { double score (Double) movie.get(score); if (score minScore score maxScore) { result.add(movie); } } return result; }注意这里有个特别容易踩的坑movie.get(title)返回的是Object类型要比较字符串必须强转(String)。而movie.get(score)返回的是Object实际上里面装的是装箱后的Double强转时也不能写成(double)要写成(Double)再自动拆箱。初学者常常在这里报ClassCastException然后一脸茫然——其实核心就是Map把类型擦掉了取出来时你需要自己负责还原类型。如果不想处理这些类型转换就该回到自定义Movie类的方案让类型系统帮你兜底。查询结果的展示直接复用操作核心——把上一个方法返回的新List传进去遍历就行。这种方法返回一个List调用方再遍历它的模式在真实项目里是最最常见的Controller/Service数据流转方式。4.4 修改与删除先找到位置再操刀修改和删除都不能只靠值必须知道目标在List里的位置或者至少知道它的唯一标识。这是一个经常被忽略的思维转变ArrayList管理的不是无限流而是有索引的序列任何持久性改动都要落到索引上。模拟一个按片名精确匹配、更新评分的方法private static boolean updateScoreByTitle(String title, double newScore) { for (int i 0; i movieLibrary.size(); i) { MapString, Object movie movieLibrary.get(i); if (title.equals(movie.get(title))) { movie.put(score, newScore); // 直接修改Map内部对象 System.out.println(已更新《 title 》的评分为 newScore); return true; } } System.out.println(未找到电影 title); return false; }看起来代码里没有set(i, newValue)这种“替换元素”的调用而是直接movie.put(...)。这是Map元素的特别之处List里存的是Map的引用你取出来改Map的键值对List里的那个位置自然就变了。但如果改成movie new HashMap();那List感知不到因为引用被重新赋值了原数据还在List里。删除操作也有讲究private static boolean removeByTitle(String title) { for (int i 0; i movieLibrary.size(); i) { MapString, Object movie movieLibrary.get(i); if (title.equals(movie.get(title))) { movieLibrary.remove(i); System.out.println(已下架《 title 》); return true; } } return false; }找到之后remove(i)后面元素会自动前移索引9变成索引8索引8变成索引7如此往复。这是ArrayList最顺手的删除方式——不需要你手动搬数据但要注意删完之后后续所有索引都变了。5. 迭代删除的坑for循环为什么总出问题这个坑几乎每个写ArrayList的人都会撞上。打算删除评分低于7分的电影用最直觉的写法// 错误示范 for (int i 0; i movieLibrary.size(); i) { double score (Double) movieLibrary.get(i).get(score); if (score 7.0) { movieLibrary.remove(i); } }看起来逻辑没问题循环里判断满足就删。但实际执行时你可能会漏删甚至越界异常。为什么5.1 漏删与越界的根因罪魁祸首只有一个ArrayList删除元素时后面的元素会集体往前挪一位导致索引发生变化。举个例子列表里有5部电影下标从0到4。当删除下标1之后原来下标2的电影变成了下标1原本下标3的变成了下标2。于是在for循环里i已经走到1了删除后i变成2直接就跳过了刚刚从下标2挪到下标1的那部电影它还没被判断就漏过了。如果正好连续两部不合格电影挨在一起漏删几乎是必然。更极端的情况下如果删除发生在最后一个元素上i之后i等于size循环条件i size不满足就退出看起来没事但如果因为漏删导致后面该删的元素还在越往下走越乱最后可能出现IndexOutOfBoundsException。我见过一个真实的业务事故某后台批量删除标签用的就是这个错误写法结果每次只删掉一半数据运营手动补删了两小时。从那以后我凡是做批量删除一定会停下来想想索引补偿的问题。5.2 三种正确的删除姿势方法一倒序遍历。从最后一个元素往前删除删掉后面的位置不影响前面未处理的位置。for (int i movieLibrary.size() - 1; i 0; i--) { double score (Double) movieLibrary.get(i).get(score); if (score 7.0) { movieLibrary.remove(i); } }这是最直观的解决办法倒序时每次删除的是未处理过的末尾区域下标不会错乱。方法二迭代器删除。增强for循环里调用list.remove()会抛出ConcurrentModificationException但显式使用迭代器就没事——因为迭代器自带期望修改计数机制并且提供了安全的remove()方法。IteratorMapString, Object it movieLibrary.iterator(); while (it.hasNext()) { MapString, Object movie it.next(); double score (Double) movie.get(score); if (score 7.0) { it.remove(); } }// Java 8 更简洁的写法 movieLibrary.removeIf(movie - (Double) movie.get(score) 7.0);removeIf的底层就是迭代器加remove()。这可以说是日常开发中最推荐的方式因为你自己不用管索引语义也最清晰。方法三收集要删除的索引或对象最后统一处理。ListMapString, Object toRemove new ArrayList(); for (MapString, Object movie : movieLibrary) { if ((Double) movie.get(score) 7.0) { toRemove.add(movie); } } movieLibrary.removeAll(toRemove);这种方式适合删除规则复杂的时候先圈定一个待删除清单再一次性操作。5.3 为什么增强for会抛ConcurrentModificationException再说一个经典问题用增强for遍历在循环里调用movieLibrary.remove(...)为什么立刻抛异常不是漏删而是直接炸因为增强for编译后就是用迭代器在遍历。remove()会让ArrayList的modCount修改计数器加1而迭代器内部保存了一个expectedModCount每次next()都会比对这两个值。一旦发现被外部修改过立刻抛出ConcurrentModificationException。这个设计本质上是一个fail-fast机制——快速失败。它的存在不是为了让你绕开而是提醒你遍历过程中结构已经变了后续的行为不可预知。所以正确的做法不是侥幸绕过而是用迭代器自己的remove()它会让expectedModCount同步更新或者干脆倒序删除。6. 排序与统计ArrayList最容易被忽略的能力6.1 用Collections.sort给电影排行榜排序电影系统没有排行榜说不过去。ArrayList配合Collections.sort()可以轻松实现排序。排序的关键在于定义谁在前谁在后的规则。对于自定义对象可以让类实现Comparable接口重写compareToMovie a ...; Movie b ...; // 按评分降序 a.compareTo(b) 返回负数表示a排在b前面但更灵活的方式是使用Comparator。尤其当你的容器是ListMapString, Object时Comparator里的逻辑其实就是从Map中取字段来比较movieLibrary.sort((m1, m2) - { double score1 (Double) m1.get(score); double score2 (Double) m2.get(score); return Double.compare(score2, score1); // 降序 });List接口在Java 8之后自带sort方法直接传入Comparator。注意这里我用了Double.compare()而不是直接(int)(score2 - score1)因为double相减再强转会丢精度。比如8.1 - 8.2等于-0.09999999999999964强转成int直接就成0了排序就乱了。这种精度问题非常不值得调试。也可以写成方法引用的形式movieLibrary.sort(Comparator.comparingDouble(m - (Double) m.get(score)).reversed());如果希望按多种条件排序——比如先按年份降序年份相同再按评分降序可以串起来movieLibrary.sort(Comparator .comparingInt((MapString, Object m) - (Integer) m.get(year)).reversed() .thenComparingDouble(m - (Double) m.get(score)).reversed());这种链式写法的可读性比我早期手写一堆if else要好太多了。遇到多字段排序需求优先用Comparator链。6.2 统计型操作平均数、最值、分组排序之后一般伴随统计。比如统计所有电影的平均评分、最高分电影、最低分电影。这些在ArrayList上可以用循环搞定也可以用流式APIdouble avgScore movieLibrary.stream() .mapToDouble(m - (Double) m.get(score)) .average() .orElse(0.0); MapString, Object bestMovie movieLibrary.stream() .max(Comparator.comparingDouble(m - (Double) m.get(score))) .orElse(null);如果你还没学Stream用循环也一样能实现但你会发现Stream天然契合对整个集合做批量统计的场景代码量少一半。这也是为什么我建议学完ArrayList之后一定去补Lambda和Stream——它们是处理集合的得力工具尤其适合ListMapString, Object这种结构化容器。6.3 陷阱提醒sort会改变原Listsort和Collections.sort都会直接修改原List的顺序不是返回一个新List。这在业务上有时会有问题——比如你先把原列表展示给用户然后又排序给另一个用户看两个用户的数据就串了。如果不想影响原始顺序排序前要复制一份ListMapString, Object sortedList new ArrayList(movieLibrary); sortedList.sort(...);new ArrayList(movieLibrary)这种拷贝构造在你需要保留原始数据的时候极常用。它做的是浅拷贝——拷贝的是引用不是整个Map对象的深拷贝。对于本案例来说浅拷贝够了因为你改Map里的字段还是会互相影响但改List结构增删元素不会。7. 性能边界与生产级提醒7.1 扩容机制到底在暗地里做了什么很多教材会把扩容机制说得玄乎其实核心就三点ArrayList底层是Object[]数组默认初始容量10。当size达到容量上限要add时会计算新容量oldCapacity (oldCapacity 1)也就是原来容量的1.5倍。新数组创建后用Arrays.copyOf把旧元素一次性拷贝过去。所以你可以把扩容想象成一个小仓库货架满了你就租一个更大的仓库把货全搬过去再继续放。这个搬运过程是有成本的如果频繁扩容性能会明显下降。关键结论如果预先知道数据规模使用new ArrayList(n)指定容量是免费的性能优化。如果不知道也别慌1.5倍增长意味着扩容次数是O(log n)级别的绝大多数场景无需关心。只有在你每添加几十万条数据、且对响应时间极其敏感时扩容成本才需要认真考虑。7.2 中间的插入和删除为什么慢add(index, element)和remove(index)在最坏情况下需要移动一半元素。ArrayList在中间位置插入假设有100万条数据你要插到中间后面50万条都要往后挪一格。这个操作是O(n)的。比如电影系统的置顶推荐功能如果想把某部电影插到第0位movieLibrary.add(0, specialMovie);相当于后面所有电影都往后挪了一位。偶尔操作没感觉但如果你在一个循环里频繁往第0位插入数据性能会急剧恶化——这种情况你该考虑LinkedList或者使用ArrayDeque专门处理头尾操作。判断方法很简单如果业务特征是读多写少、按索引访问选ArrayList如果业务特征是频繁在头部或中间增删考虑LinkedList或其他专用数据结构。至少你需要知道存在这个权衡。7.3 线程安全问题单线程不当回事多线程就出事ArrayList在多线程环境下是不安全的。两个线程同时add可能导致数组越界、元素丢失或者读取到脏数据。这不是小概率事件而是并发场景下必然会出现或早或晚的问题。如果多线程安全地使用List我给出的策略从轻到重Collections.synchronizedList把所有方法加锁简单但锁粒度大适合读少写少的小场景。CopyOnWriteArrayList读多写少时最优解每次写操作都会复制一个新数组读操作不需要加锁适合缓存列表、配置列表等场景。自己加锁如果你的业务不是简单的单行操作而是先查再改的多步复合操作上面两种容器也帮不了你需要你自行加锁或者用并发工具类协调。我记得有次面试问到ArrayList和Vector有什么区别答案大家都知道——Vector方法加了synchronized线程安全但性能差。但实际开发中早就用CopyOnWriteArrayList或Collections.synchronizedList替代Vector了所以别再说为了线程安全用Vector这种过时答案了。7.4 转型建议从List到List实体类最后聊一个实战建议。看完这篇案例你用ListMapString, Object跑通了所有功能恭喜你迈出了重要一步。但回到真实项目我的建议是不要在核心业务层大面积使用List。理由前面也提过key拼写错误编译器不报错运行期才暴露。取值时到处强转代码噪音大。无法利用IDE的重构功能重构字段名时Map的字符串key不会跟着变。代码可读性差新同事看代码会一头雾水。正确的做法是数据的源头比如从数据库查询返回和展示层比如转成JSON返回给前端可以用Map过渡但核心的业务逻辑、服务层要封装成电影实体类等等。等你在本案例中学会了ArrayList的一切用法后可以尝试把所有功能重新用Movie实体类版本实现一遍。这将极大加深你对List和对象关系的理解也让你更接近真实项目的代码风格。
返回列表