Java List的理解

107 阅读25分钟

一、List 为何会出现?

在 Java 编程的世界里,数组是我们最先接触到的用于存储一系列相同元素的数据结构。数组在内存中占据一段连续的空间,通过索引能够快速访问元素,查询效率颇高。然而,它存在一个明显的局限性:数组的长度在创建时就必须确定,后续无法灵活更改。

在实际开发场景中,很多时候我们在编写代码阶段并不确切知晓要处理的数据量究竟有多少。比如,从文件中读取数据,或是接收用户的输入,数据的条数可能因人而异、因时而异。要是使用数组来存储,就不得不预先设定一个可能足够大的固定长度,这极易造成内存空间的浪费;而一旦预估不足,当数据量超出数组容量时,又会引发数组越界的异常,给程序带来稳定性隐患。

为了弥补数组的这一短板,List 应运而生。List 是 Java 集合框架中的一员,它最大的优势就在于能够动态地调整大小,以适应不同数量的数据存储需求,让我们在处理数据时无需再为长度问题而忧心忡忡。

二、List 家族大揭秘

image.png 在 Java 的世界里,List 可不是一个单打独斗的 “孤勇者”,它有着丰富多彩的 “家族成员”,每一个都有着独特的 “性格” 与 “本领”,能在不同的场景下大显身手。接下来,让我们揭开它们神秘的面纱。

1. ArrayList:动态数组的典范

ArrayList 是 Java 集合框架中最常用的 “明星成员” 之一,底层依托于数组来实现。它仿佛是一个拥有神奇魔法的容器,能够自动扩容。当你源源不断地向其中添加元素,一旦数量超出了当前数组的容量,它就会悄无声息地创建一个更大的新数组(在 Java 7 及之前,默认扩容为原来的 1.5 倍;Java 8 之后,会根据实际情况进行更智能的调整),并把旧数组中的元素逐一复制过去,整个过程如同一场井然有序的搬家行动。

优点:

  • 随机访问超高效:得益于数组元素在内存中连续存储的特性,通过索引访问元素的速度极快,时间复杂度为 O (1)。就好比你拥有一本精心编排页码的书籍,想要查找任何一页的内容,瞬间就能翻到。例如在开发一个电商系统,需要频繁根据商品 ID(索引)获取商品信息时,ArrayList 能让数据检索如闪电般迅速。
  • 查询与修改便捷:无论是查询指定位置的元素,还是修改某个索引处的元素值,都能轻松完成,代码写起来简洁明了,让开发者得心应手。

缺点:

  • 插入与删除的 “慢动作” :如果要在数组中间插入或删除一个元素,那就得大动干戈了。它后面的所有元素都得依次往后或往前挪动一位,就像在一列紧密排列的队伍中,要插入或请出一个人,大家都得跟着移动脚步,时间复杂度为 O (n),当数据量庞大时,这种操作的效率就显得捉襟见肘。
  • 扩容的 “甜蜜负担” :虽说自动扩容很贴心,但扩容过程本身是有成本的。新建数组、复制元素,这一系列操作会耗费时间与内存资源。要是频繁地进行大量元素添加,频繁触发扩容机制,程序的性能就可能会被拖慢,就像一辆频繁加油的汽车,总是会走走停停。

适用场景:

  • 频繁随机访问场景:诸如开发一个学生成绩管理系统,需要根据学号(索引)快速查询学生成绩;或是构建一个音乐播放列表,能迅速定位到用户想听的某一首歌曲,ArrayList 都是绝佳选择。
  • 数据量动态变化,增删操作较少:像记录每日网站的访问日志,日志数量一天天增加,但很少需要在中间插入或删除特定某天的日志,ArrayList 可以很好地应对这种场景,既能动态扩容,又能在查询时高效响应。

2. LinkedList:链表的灵动舞者

LinkedList 底层采用双向链表的数据结构,每个元素就像是链条上的一环,不仅包含自身的数据,还持有指向前一个元素和后一个元素的引用,如同人与人手牵手,组成一条灵活的链条。

优点:

  • 插入与删除的 “快闪侠” :在链表中插入或删除元素,只需轻松改变相邻节点间的引用指向即可,无需大规模移动元素。无论是在表头、表尾,还是链表中间的任意位置,进行增删操作都能迅速完成,时间复杂度为 O (1)。比如在开发一个即时通讯软件的聊天记录功能,当用户频繁发送、撤回消息时,LinkedList 能高效处理这些插入与删除操作,不会卡顿。
  • 动态性强:链表天生就具有良好的动态伸缩性,不需要像数组那样预先考虑容量问题,随时随地可以轻松添加或移除元素,就像一条可以自由变长变短的魔法绳索。

缺点:

  • 随机访问的 “慢性子” :若要访问链表中间的某个元素,由于没有像数组那样的连续内存地址,只能从表头或表尾开始,沿着链表节点逐个遍历,直到找到目标元素,时间复杂度为 O (n)。这就好比在一条没有标识牌的漫长街道上,寻找某一户人家,只能挨家挨户打听,效率自然比不上拥有明确门牌号(索引)的数组。
  • 额外内存开销:每个节点除了存储数据本身,还得额外存储前后节点的引用,相较于 ArrayList,在存储大量数据时,会占用更多的内存空间,仿佛一个旅行者背着更多的行囊,虽然灵活但略显沉重。

适用场景:

  • 频繁插入与删除场景:例如实现一个文本编辑器的撤销与重做功能,每次操作都是对链表头部进行插入或删除,LinkedList 能以极高的效率响应。或是开发一个游戏中的任务列表,玩家随时领取、放弃任务,链表结构可以轻松应对这种频繁变动。
  • 实现栈(Stack)与队列(Queue)数据结构:LinkedList 提供了便捷的方法来模拟栈(如 push、pop 操作,对应链表头部的添加与删除)和队列(如 offer、poll 操作,对应链表尾部的添加与删除)的行为,而且性能优异,是实现这些数据结构的首选。

3. Vector:元老级的线程安全守护者

Vector 是 Java 早期版本就存在的 “元老”,同样基于数组实现,并且从诞生之初就自带线程安全的 “光环”。它通过在每个方法上添加 synchronized 关键字,确保在多线程环境下,同一时刻只有一个线程能够访问和修改 Vector 中的数据,避免了数据竞争引发的混乱局面。

优点:

  • 线程安全无忧:在多线程并发访问的复杂环境中,不用担心数据被破坏或出现不一致的情况,如同给珍贵的宝藏加上了坚固的锁,能让多个线程有序地获取宝藏。例如在服务器端多线程处理多个客户端请求,同时对共享的用户列表进行操作时,Vector 能保证数据的完整性。
  • 动态扩容可靠:和 ArrayList 类似,当元素数量超出容量时,Vector 也能够自动扩容,以容纳更多的数据,为程序的持续运行提供保障。

缺点:

  • 性能的 “枷锁” :由于 synchronized 关键字的存在,使得每个操作都需要获取锁、释放锁,这在高并发场景下就成了沉重的负担,大量线程争用锁资源,导致频繁的上下文切换,极大地降低了程序的运行效率,如同繁华路口的交通信号灯,如果切换过于频繁,车辆通行就会变得缓慢。
  • 遍历性能的 “小遗憾” :虽然在多线程并发访问时,Vector 的遍历相对安全,但相较于一些现代的非线程安全集合类,在单线程遍历场景下,由于锁的存在,其性能仍略显逊色。

适用场景:

  • 多线程且对数据安全要求极高:比如在银行系统中,多个柜员同时操作客户账户信息列表,账户信息的准确性至关重要,不容半点差错,此时 Vector 就能凭借其线程安全性扛起重任。
  • 对遍历性能要求不高的多线程场景:像在一个简单的多线程日志记录系统中,多个线程向日志列表添加记录,偶尔遍历查看日志,对遍历的即时性要求不高,更注重数据的安全写入,Vector 就可以满足需求。

4. CopyOnWriteArrayList:读写分离的智慧先锋

CopyOnWriteArrayList 是 Java 并发包中的一员 “猛将”,它采用了一种极为巧妙的 “写时复制” 策略。当有线程要对列表进行修改操作(添加、删除、修改元素)时,它不会直接在原列表上动手,而是先复制一份原列表的副本,然后在副本上进行修改,待修改完成后,再将原列表的引用指向新的副本,就像是在绘制一幅重要画作时,先临摹一份,在临摹本上精心修改,确认无误后再替换掉原本。

优点:

  • 并发读性能卓越:由于读操作不需要加锁,多个线程可以同时并发地读取列表中的元素,不会因为写操作的阻塞而等待,在读取频繁的场景下,能极大地提升程序的响应速度,如同多条并行的高速公路,车辆(线程)可以畅快通行。
  • 线程安全的 “轻量级” 保障:通过 “写时复制” 机制,巧妙地避开了复杂的锁竞争,实现了一种相对高效的线程安全模式,让多线程编程变得更加轻松优雅。

缺点:

  • 内存占用的 “小烦恼” :因为写操作会复制一份数组,所以在写频繁的情况下,内存中会同时存在多个版本的数组副本,占用额外的内存空间。如果列表中的元素对象本身占用内存较大,频繁的写操作就可能引发内存紧张,甚至触发垃圾回收机制,影响程序的流畅性,就像家里囤积了过多暂时不用的物品,占用了宝贵的空间。
  • 数据一致性的 “延迟” :由于读操作读取的始终是旧版本的数组,在写操作完成后的一段时间内,可能读到的数据不是最新的,只能保证数据的最终一致性,而非实时一致性。例如在一个实时数据监控系统中,如果对数据的实时性要求极高,CopyOnWriteArrayList 可能就不太适用。

适用场景:

  • 读多写少的并发场景:比如在一个电商网站的商品详情页,大量用户同时浏览商品信息(读操作),而管理员偶尔更新商品描述等信息(写操作),此时 CopyOnWriteArrayList 既能保证用户快速获取商品信息,又能确保管理员操作的安全。
  • 数据一致性要求稍缓,读性能优先:像一些缓存数据的场景,允许在短时间内读到稍旧的数据,更注重读的高效性,以提升整体系统的吞吐量,那么 CopyOnWriteArrayList 就是不二之选。

三、各有千秋:List 成员优缺点剖析

image.png 在了解了各个 List 家族成员的独特本领之后,让我们把它们拉到一起,来一场全方位的 “优缺点大对决”,看看在不同的评判标准下,谁能脱颖而出。

随机访问性能

  • 冠军:ArrayList。得益于其底层数组连续存储的特性,通过索引访问元素的速度堪称 “闪电级别”,时间复杂度稳定在 O (1),就像精准定位的导航系统,无论数据量多大,都能瞬间直达目标。
  • 亚军:LinkedList。由于采用链表结构,随机访问时需要逐个节点遍历,时间复杂度为 O (n),在这一环节明显落后于 ArrayList,如同在没有地图指引的迷宫中寻找特定房间,效率较低。
  • 季军:CopyOnWriteArrayList。其底层也是数组,但读操作时为保证数据一致性,可能读到旧版本数据,不过随机访问性能仍接近 ArrayList,只是在并发写频繁时,因数组复制等操作会稍有延迟,可看作是一位偶尔 “脚步沉重” 的快速奔跑者。
  • 殿军:Vector。虽然同样基于数组实现,随机访问性能本应出色,但由于其线程安全机制带来的锁开销,在高并发多读场景下,访问速度会受到一定影响,好比繁华街道上因信号灯频繁切换而减速的车辆。

插入与删除性能

  • 冠军:LinkedList。链表结构让它在插入和删除元素时游刃有余,只需轻松调整相邻节点指针,无需大规模移动数据,表头、表尾或中间任意位置操作,时间复杂度均为 O (1),如同灵活穿梭的舞者,轻盈地进出队列。
  • 亚军:CopyOnWriteArrayList。写操作虽需复制数组,但如果是在列表末尾添加元素,不需要挪动大量已有元素,相较于 ArrayList 中间位置插入删除的高成本操作,它在部分场景下表现尚可,像是一位谨慎的画家,在副本上精心修改,不影响原画面大部分布局。
  • 季军:ArrayList。在数组中间插入或删除元素,后续元素必须依次移位,时间复杂度飙升至 O (n),不过在列表末尾进行增删操作相对高效,类似整理书架时,在末尾添加或移除一本书较为轻松,但若要在中间插入,就得大费周章挪动其他书籍。
  • 殿军:Vector。与 ArrayList 类似,插入删除中间元素成本高,且线程安全锁机制使得操作更为笨重,在高并发频繁增删场景下,容易陷入 “拥堵”,如同满载货物的货车在狭窄道路上艰难掉头。

内存占用

  • 冠军:ArrayList。仅存储元素本身,无需额外空间维护复杂的节点关系,内存利用较为高效,是存储大量数据时节省空间的能手,就像收纳达人,将物品紧凑摆放,不浪费一丝空间。
  • 亚军:Vector。同 ArrayList 底层结构相似,内存占用情况相近,只是在扩容机制上略有不同,偶尔因扩容策略可能多占用少许临时空间,但总体仍属于较为节省内存的阵营。
  • 季军:CopyOnWriteArrayList。写操作频繁时,因会同时存在多个数组副本,内存中驻留数据量增大,容易造成内存资源紧张,尤其元素对象本身占用大内存时,问题更凸显,仿佛家中囤放过多旧物,占用宝贵居住空间。
  • 殿军:LinkedList。每个节点除数据外,还需额外存储前后节点指针,数据量庞大时,这些指针占用的额外内存不容小觑,如同出行携带过多不必要的行李,增加负担。

线程安全

  • 冠军:Vector。作为老牌的线程安全容器,通过方法级别的 synchronized 关键字,为多线程并发访问保驾护航,数据完整性万无一失,如同银行金库,多重锁闭,确保资金安全。
  • 亚军:CopyOnWriteArrayList。采用 “写时复制” 巧妙策略,避开复杂锁竞争,实现高效轻量级线程安全,读操作无锁并发,在多线程读多写少场景表现卓越,宛如多人同时阅读不同副本书籍,互不干扰。
  • 季军:ArrayList 和 LinkedList。二者默认都非线程安全,在多线程环境下,若不额外采取同步措施,数据很容易陷入混乱,如同多个人同时无序修改一份文档,内容必然变得面目全非。但在单线程场景下,它们无需锁开销,性能得以充分发挥,是轻量级任务的首选。

四、实战场景:List 成员各显神通

image.png 纸上得来终觉浅,让我们把目光投向实际的开发场景,看看各个 List 家族成员是如何在不同的 “战场” 上大显身手,成为解决问题的得力干将。

1. 电商系统中的订单管理

在电商系统里,订单数据的管理至关重要。订单信息通常包括订单号、用户 ID、商品详情、下单时间等众多字段。当用户下单后,订单需要被快速记录并存储起来,后续商家可能要频繁查询某个订单的详情,用于发货、处理售后等操作;同时,系统也需要定期清理一些过期的无效订单。

  • 选用 ArrayList:存储订单信息列表,凭借其快速的随机访问特性,根据订单号(索引)能够瞬间定位到具体订单,让商家查询订单详情时高效便捷,极大提升客户服务响应速度。例如在订单查询页面,输入订单号后,系统能迅速从 ArrayList 中抓取对应订单并展示,用户无需长时间等待。
  • 搭配 LinkedList:对于订单的生命周期管理,比如新订单的插入(用户下单)、订单状态变更时的记录插入(如付款、发货等节点信息插入),以及过期订单的删除,LinkedList 的高效插入删除能力就派上用场了。它可以灵活地在表头(新订单进入)或表尾(过期订单清理)进行操作,确保订单流程的动态管理顺畅无阻。

2. 即时通讯软件的消息处理

即时通讯软件中,消息的收发与展示是核心功能。用户发送的文字、图片、语音等消息需要实时存储,并按照发送顺序展示,同时还要支持撤回、转发等操作。

  • 运用 LinkedList:作为消息存储的容器,完美契合消息频繁插入(新消息发送)与删除(消息撤回)的场景。无论是在聊天窗口快速展示最新消息,还是处理用户撤回操作,链表结构都能高效应对,保证聊天界面的流畅交互,让用户体验到即时通讯的 “即时性”。
  • 辅以 ArrayList(可选) :如果软件提供消息搜索功能,需要根据关键词快速定位历史消息,那么可以额外使用 ArrayList 来存储一份消息副本。利用 ArrayList 的快速随机访问优势,在搜索时能够快速遍历查找包含关键词的消息,提升搜索效率,满足用户查找历史信息的需求。

3. 服务器端的多线程任务调度

服务器端常常要面对海量的并发请求,比如多个客户端同时上传文件、请求数据等。服务器需要将这些任务排队,依次分配线程资源进行处理,同时要确保任务列表的线程安全,防止数据混乱。

  • 启用 Vector:用于存储任务队列,在多线程环境下,其线程安全特性确保不同线程对任务列表的添加、删除操作不会相互干扰,保证任务调度的稳定性。就像机场的航班起降调度系统,多个调度员(线程)同时操作航班任务列表(任务队列)时,Vector 能保障信息准确无误,避免航班冲突。
  • 考虑 CopyOnWriteArrayList(特定场景) :若任务列表的读操作极为频繁(如监控线程需要不断查看任务队列状态,但很少修改),而写操作较少(偶尔新增任务或标记任务完成),CopyOnWriteArrayList 的优势就凸显出来了。它能让多个监控线程同时无锁并发读取任务列表,极大提升系统的并发性能,避免因锁竞争导致的性能瓶颈。

五、探索 List 的实现逻辑

image.png 知其然,更要知其所以然,接下来让我们深入底层,一探究竟这些 List 家族成员是如何施展 “魔法”,实现各自强大功能的。

1. ArrayList 的底层魔法

ArrayList 的底层核心是一个数组,名为elementData,用于实实在在地存储元素。同时,有一个size变量,它就像一个 “管家”,精准记录着当前数组中已经存放的元素个数。

当创建一个 ArrayList 实例时,如果没有指定初始容量,在 Java 7 及以前版本,它会默认创建一个容量为 10 的空数组;Java 8 之后,初始化为一个空数组,等首次添加元素时再进行扩容操作。随着元素不断被添加进来,一旦size达到了当前数组容量,就到了扩容的关键时刻。扩容操作就像是给房子 “扩建”,它会创建一个新的更大的数组(Java 7 及之前,新容量通常为原容量的 1.5 倍;Java 8 往后,扩容策略更为智能,会综合考虑当前数组大小和要添加的元素数量等因素),接着使用Arrays.copyOf()方法,将旧数组中的元素逐一复制到新数组中,最后让elementData指向新数组,完成这场 “乔迁新居” 的壮举,如此一来,ArrayList 就能持续容纳更多元素,动态适应数据的增长需求。

2. LinkedList 的灵动之源

LinkedList 的底层构建在双向链表之上,每个节点(由内部类Node表示)就如同链条上的一环,环环相扣。Node类包含三个关键部分:item用于存放真正的数据元素,prev指向链表中的前一个节点,next指向后一个节点。

在链表头部插入元素时,只需创建一个新节点,让其next指向原头部节点,同时将原头部节点的prev指向新节点,再把链表的first引用更新为新节点,整个过程一气呵成,时间复杂度低至 O (1)。在中间或尾部插入元素,原理类似,通过调整相邻节点间的指针指向,轻松完成插入操作,无需像数组那样挪动大量元素。删除操作亦是如此,只需断开待删节点与前后节点的连接,让前后节点重新 “牵手”,待删节点就会被 JVM 的垃圾回收机制自动清理,高效便捷,这便是 LinkedList 在频繁增删场景下表现卓越的底层秘诀。

3. Vector 的稳健根基

Vector 的底层同样依托数组实现,与 ArrayList 类似,也有一个用于存储元素的数组和记录元素个数的变量。但它的特别之处在于,从诞生之初就被赋予了线程安全的特性,这是通过在每一个可能修改集合结构的方法(如add、remove、set等)上添加synchronized关键字来实现的。

当多个线程并发访问 Vector 时,同一时刻只有一个线程能够获取到方法的锁,进入方法内部执行操作,其他线程则需在外等待,避免了多个线程同时修改数据导致的混乱局面,确保了数据的一致性与完整性。不过,这种线程安全机制在高并发场景下,由于线程频繁地争用锁资源、上下文切换开销大,会使得性能受到一定影响,就像高峰期拥堵在一把锁前的人群,通行效率大打折扣。

4. CopyOnWriteArrayList 的巧思妙想

CopyOnWriteArrayList 采用了一种极为精妙的 “写时复制” 策略来保障线程安全与高效读写。其底层同样是基于数组存储数据,核心成员变量包括存放元素的数组array以及用于记录数组修改次数的lock(用于实现轻量级的锁机制,辅助写操作的同步)。

当有线程发起写操作(添加、删除、修改元素)时,它不会直接对原数组动手,而是先通过ReentrantLock加锁,确保同一时间只有一个写线程在工作。接着,复制一份原数组的副本,在副本上进行修改操作,就像一位画家在临摹的画布上精心创作,不用担心影响原作。修改完成后,再将原数组的引用指向新的副本,最后释放锁,让其他线程能够感知到数据的更新。而读操作则无需加锁,多个线程可以同时并发地读取原数组中的数据,畅行无阻,极大提升了读的性能,充分发挥了读写分离的优势,巧妙化解了高并发读写场景下的难题。

六、注意事项:避坑指南

在使用 List 的征程中,看似一马平川,实则暗藏玄机,有诸多 “坑洼” 需要我们小心绕行。

首先,务必警惕并发修改异常(ConcurrentModificationException)。当我们使用迭代器遍历 List 时,如果与此同时,另一个线程(或者同一线程中的其他代码部分)对该 List 进行结构上的修改(比如添加、删除元素),就会触发这个异常。这就好比多个人同时对一份文档进行编辑,一人正在逐行阅读,其他人却随意增删段落,必然导致混乱。解决之道在于,如果是单线程场景,尽量使用迭代器自身的修改方法,如Iterator的remove方法,它能够在遍历的同时安全地删除元素;若是多线程环境,则需要考虑使用线程安全的 List 实现类,如CopyOnWriteArrayList,或者对操作进行加锁同步处理,确保读写有序。

其次,扩容机制既是 ArrayList 的 “救命稻草”,也可能成为性能的 “绊脚石”。如前所述,ArrayList 在元素数量达到数组容量上限时会扩容,默认扩容为原容量的 1.5 倍(Java 7 及之前,Java 8 之后有更智能策略)。但频繁的扩容操作会耗费大量时间与内存,因为涉及新建数组、复制元素等过程。所以,若事先知晓要存储的数据量大致范围,在创建 ArrayList 时,通过构造函数指定合适的初始容量,就能避免不必要的扩容开销,让程序 “轻装上阵”,跑得更快更稳。

再者,关于Arrays.asList方法,它看似能便捷地将数组转换为 List,实则隐藏陷阱。它返回的实际上是Arrays类的内部类ArrayList,并非我们日常使用的java.util.ArrayList。这个内部类继承自AbstractList,并未完全实现add、remove等修改集合结构的方法,一旦调用,就会抛出UnsupportedOperationException异常。倘若只是想将数组作为只读的列表使用,Arrays.asList倒也无妨;但要是后续有增删操作需求,就得 “另谋高就”,比如将其转换为java.util.ArrayList,即new ArrayList<>(Arrays.asList(arr)),确保操作顺畅无阻。

还有subList方法,它返回的是原列表的一个 “视图”,而非独立副本。这意味着,对原列表的结构性修改(如添加、删除元素),在遍历子列表时可能引发ConcurrentModificationException异常;反之,对子列表的修改,同样会影响原列表。为避免这种 “牵一发而动全身” 的情况,若需对子列表进行独立操作,建议使用new ArrayList<>(list.subList(fromIndex, toIndex))创建一个全新的列表副本,让两者互不干扰,各自安好。

最后,当自定义类的对象存入 List 时,若要使用contains、indexOf等查找方法,一定要记得重写类的equals和hashCode方法。因为 List 默认使用equals方法比较对象引用,若不重写,即便对象内容相同,也可能因引用不同而查找失败,导致程序逻辑出错,就像拿着错误的钥匙,永远打不开正确的门。

七、总结

在 Java 编程的漫漫长路上,List 家族无疑是我们手中的一大利器。从而弥补数组固定长度的短板,为不同场景提供多样化的数据存储与操作方案,ArrayList、LinkedList、Vector、CopyOnWriteArrayList 等各显神通,它们在随机访问、插入删除、内存占用、线程安全等多个维度各有优劣。通过深入了解它们的实现逻辑、适用场景以及注意事项,我们能够在面对实际开发中的各种复杂需求时,精准选择最合适的工具,编写出更加高效、健壮的代码。编程之路,学无止境,愿大家多在实践中探索,让 List 家族更好地为我们的程序服务。