与 Java 中的 LinkedList 相比,缓存局部性如何影响 ArrayList 的性能?
我经常听说 ArrayList 在缓存局部性方面有优势,但我不太明白为什么。既然 Java 将对象作为引用存储在内存中,那么访问这两个列表中的元素是否都需要跳转到内存中的随机位置?
与 Java 中的 LinkedList 相比,缓存局部性如何影响 ArrayList 的性能?
我经常听说 ArrayList 在缓存局部性方面有优势,但我不太明白为什么。既然 Java 将对象作为引用存储在内存中,那么访问这两个列表中的元素是否都需要跳转到内存中的随机位置?
稍等一下,让我们首先说明一下 Java 的工作原理,因为如果没有这些知识,您就无法理解关于“一切皆链接”这些相互矛盾的说法。
在 Java 中,每个变量都是固定大小的,而且非常小。这非常方便,因为它允许我们做出一些宏大而笼统的声明,例如“每当调用一个方法时,每个参数都会被复制到你正在调用的方法中;该方法可以对这些副本进行任何操作,并且一旦该方法执行完毕,这些副本就会消失”。毕竟,如果你做出这样的声明,并且一个变量有 2GB 大,那么传递它会非常迅速地导致内存不足的错误。
但是,这如何实现呢?当然
List<String> list = enumerateEveryWordInTheCollectedWorksOfShakespeare();
不是“固定的、小尺寸的”。这就是第二部分要讨论的:在 Java 中,有原语,也就是以下硬编码的类型列表:
int
,,,,,,,,。 (这个列表一直都是固定的,永远不会改变,你无法创建自己的原语 ^ 1 )——每一个原语都是“固定的、小尺寸的”(具体来说long
,它们都是 64 位或更short
小的)。其他所有东西都是引用。一个指针。byte
double
float
char
boolean
当你写:
那么说“x 是 hello”是错误的。它不对。x 是对它的一个引用。
就像“Hello”是一栋房子(想想看:字符串可以任意长,可以是莎士比亚的全集,远非“固定长度”,而是小尺寸的),而 x 仅仅是地址簿中的一页。它是一条指示,告诉你如何到达那栋房子。有了这一页,如果你想知道房子是不是红色的,你就能做到——只要……去那里看看就行。你所需要的只是这一页。但你确实需要花费一些时间,可以这么说。
ArrayList
ArrayList 正是“链接列表”的确切含义:根据定义,你不能拥有一个原始值的数组列表(你可以拥有一个
List<Integer>
;Integer
它是带框的,即“引用”版本的int
。List<int>
在 Java 中是无效的),因此,它必须是一个引用列表。这意味着ArrayList
是一个地址簿。它是一个地址列表。该列表是“连续的”——也就是说,地址簿是完整的,并且按照您期望的顺序排列。如果您现在在地址簿的第 5 页,并且对第 6 页的地址感兴趣,那么我保证第 6 页就在附近。只需……翻过这一页,就在那里。保证。
ArrayList 的一个缺点是,它和实际的地址簿一样,大小是固定的。那么,当你的地址簿写满时会发生什么呢?ArrayList 的实现会施展一些神秘的魔法,让列表看起来好像没有“哎呀,它满了”的问题:它会购买一本新的、更大的地址簿,手动复制旧地址簿中的每个地址,迅速用这本新地址簿替换你的地址簿,然后将旧地址簿扔进垃圾桶,所有这一切都是其正常操作的一部分。调用方法与其说是对对象“做”某事,不如说是要求对象为你做这件事。ArrayList 是一个地址簿,它能够理解如何出去,购买一本新的更大的地址簿,将自己复制到新的地址簿中,将自己的意识转移到新的地址簿中,然后将旧的自己扔进垃圾桶。
LinkedList
LinkedList 就像一本地址簿,你可以把它撕下来,然后把它们散落在房间里的各个角落。你知道地址簿的第一页在哪里,但也就仅此而已了。幸运的是,每一页都列出了你隐藏下一页和上一页的位置。所以,假设你想去地址簿第五页的房子,因为你想知道它是什么颜色的,你找到第一页,上面写着“你把第二页塞到沙发后面了”,你找到第二页,上面写着“第三页在冰箱顶上”,你找到第三页,上面写着“第四页在桌子上”,第四页写着“第五页也在桌子上”,最后你就可以去那栋房子了。
这个过程真是太耗时了。但它有一个好处:你永远不需要买一本新的通讯录,再把旧的满满当当的抄过来。毕竟,有了这个散页系统,如果页数不够了,只要在房间里再放几张空白页就可以了。这虽然不是什么大优点,但我想也算是一个优点吧。
现在,如果你碰巧创建了一个链表,并立即填满了其中的前 500 页,那么很可能所有 500 页仍然堆放在你的桌子上,或多或少地按顺序排列。但因为你无法确定,所以即使情况如此,如果你想去第 250 页列出的房子,你仍然必须依次浏览每一页,而使用 arraylist 地址簿,你可以一次性直接跳到第 250 页。
但 LinkedList 并不要求你创建的时候就把它填满,如果时间一长,就会出现“页面乱七八糟”的情况。
...情况变得更糟!
我们链表地址簿的每一页实际上存储了三项内容,而不是一项。它存储了上一页的位置、下一页的位置,当然还有房屋的地址。每一项都是一个“固定的小东西”(引用)。但这三项加在一起——这可不是 Java 的工作原理,太多了。
因此,实际上 LinkedList 更像是“地址簿的每一页实际上是一本小小的地址簿,里面有 3 页,第一页解释前一个帖子在哪里,第一页解释下一个帖子在哪里,第一页是房子的地址”,这些迷你地址簿到处都是,还有一堆解释在哪里可以找到迷你地址簿的贴子。
现在回到 Java 的工作原理
LinkedList 由对第一个节点和最后一个节点的引用组成。节点是一个小对象,它包含对下一个节点、上一个节点以及列表中此条目所代表的对象的引用。例如,要遍历链表的前 10 个元素并全部打印出来,JVM 必须解析对链表的引用,从那里解析对第一个节点的引用,从那里解析对对象的引用并打印出来,然后解析对下一个节点的引用,解析对对象的引用并打印出来,解析对下一个节点的引用,依此类推。
每个小节点对象都是在你添加对象时创建的,除非你恰好一次性完成所有操作,否则这些节点对象会散落在堆的各个角落。当然,列表中包含的实际对象也可能散落在堆的各个角落,这取决于它们的创建时间。
相比之下,使用
ArrayList
,你只需保证对列表中包含的实际对象的引用是连续的。这些对象可能不是连续的,但至少对它们的引用是连续的。那么这意味着什么?
它可以归结为两个词,这就是作为一名 Java 程序员你必须了解的有关链表的全部内容。
LinkedList 不好。
就是这样。就是这样。LL 是正确答案的情况少之又少。ArrayList 通常更好,但并非总是如此;然而,在几乎所有可以想象到的用例中,其他一些集合变体都会比 LL 更好。
各种与语言无关的 LL 观点都声称它们可能适用于 LinkedList,但不适用于Java的观点。例如,“链表的优点是,给定列表中的一个对象,可以在其后快速插入一个对象”。这是错误的——给定链表中的一个条目,您无法在 Java 中“返回”到该链表的节点。本质上,在 Java 中开始享受 LL 好处的唯一方法是使用很少使用的
.listIterator()
方法,这种方法确实可以以 ArrayList 无法比拟的方式快速插入值。没有 listIterator,LL 唯一能做而 AL 做不到的事情就是“从开始和结束快速添加/删除”。但如果这就是您想要的,
ArrayDeque
那么使用“可以让您从任一端快速添加/删除的列表”会更加高效。[1] Valhalla 和 Panama 的诸多方面意味着这些声明很快就会需要大量附加说明。从 JDK24 开始,这一点是准确的。如果你知道 Valhalla 和 Panama 项目是什么——是的,改变即将到来。
是的,ArrayList 将引用存储在连续的数组中,从而加快了 CPU 缓存的顺序访问速度。LinkedList 节点分散在内存中,导致更多缓存未命中。
我将用两种方式来回答这个问题:一种是技术性的回答,另一种是简单易懂的例子。在回答这个问题之前,我必须做一些研究,简化问题总是对我有帮助,所以让我们先介绍一下缓存术语:
缓存:缓存就像一张专门的阅览桌,上面放着最常用的书籍。计算机不用每次都跑到书架深处,而是可以从阅览桌里抓取特定的书籍。
那么这与您的问题有何关系?
ArrayList将数据存储在阅读桌上的一行中。因此,当计算机阅读一本书时,它附近已经有下一本书了,这使得阅读速度非常快且高效。因此,顺序访问速度更快。
LinkedList就像一套散落的书,你必须按照注释找到下一本书。每当计算机需要一本新书时,它就必须穿过另一个书架。
现在你可能想要一个更“技术性”的答案,所以这里是:
在ArrayList中:
由于所有元素都在同一个缓存块中,访问一个元素时会自动将附近的元素加载到缓存中。这显然会使迭代速度更快,因为 CPU 一次可以检索更多元素。
在 LinkedList 中:
由于节点分散在内存中,访问一个节点并不能保证下一个节点就在附近。每次访问节点都涉及指针解引用,从而导致更高的缓存未命中率。
总而言之:当迭代 LinkedList 时,对象引用和下一个节点引用都需要解析,这通常会导致比 ArrayList 更多的缓存未命中,其中下一个元素只是内存中的下一个位置。
这篇文章对我有帮助:为什么缓存局部性对数组性能如此重要? Stack Overflow 上的这个讨论与此类似。它深入探讨了数组相对于链表的缓存局部性优势。