【问题标题】:Java: what is the overhead of using ConcurrentSkipList* when no concurrency is needed?Java:不需要并发时使用 ConcurrentSkipList* 的开销是多少?
【发布时间】:2011-11-03 02:32:38
【问题描述】:

在以迭代为主的场景中,我需要一个排序列表(与插入/删除相比,根本不是随机获取)。出于这个原因,我考虑使用与树相比的跳过列表(迭代器应该更快)。

问题是java6只有一个跳过列表的并发实现,所以我猜测在非并发场景中使用它是否有意义,或者开销是否使它成为一个错误的决定。

据我所知,ConcurrentSkipList* 基本上是基于 CAS 的无锁实现,因此它们不应该带来(太多)开销,但我想听听其他人的意见。

编辑:

经过一些微基准测试(在不同大小的 TreeSet、LinkedList、ConcurrentSkipList 和 ArrayList 上多次运行迭代)表明存在相当大的开销。 ConcurrentSkipList 确实将元素存储在内部的链表中,因此它在迭代时比 LinkedList 慢的唯一原因是由于上述开销。

【问题讨论】:

    标签: java performance data-structures collections concurrency


    【解决方案1】:

    如果不需要线程安全,我会说完全跳过package java.util.concurrent

    有趣的是,有时 ConcurrentSkipList 在相同的输入上比 TreeSet 慢,我还没有弄清楚原因。

    我的意思是,你看过 ConcurrentSkipListMap 的源代码吗? :-) 当我看着它时,我总是要微笑。这是 3000 行的一些我在 Java 中见过的最疯狂、最可怕但同时又最漂亮的代码。 (感谢 Doug Lea 和他的同事将所有并发工具与集合框架完美地集成在一起!)话虽如此,在现代 CPU 上,代码和算法的复杂性甚至都不再那么重要了。通常更重要的是让要迭代的数据位于内存中,以便 CPU 缓存可以更好地完成工作。

    所以最后我将用一个新的 addSorted() 方法包装 ArrayList,该方法对 ArrayList 进行排序插入。

    听起来不错。如果您真的需要从迭代中挤出每一滴性能,您也可以尝试直接迭代原始数组。在每次更改时重新填充它,例如通过调用TreeSet.toArray() 或生成它,然后使用Arrays.sort(T[], Comparator<? super T>) 对其进行就地排序。但是收益可能很小(如果 JIT 做得很好,甚至没有收益),因此可能不值得带来不便。

    【讨论】:

    • 关于 toArray() 的好主意,我也应该尝试一下。
    • +1 表示算法复杂性只对缓存太大的结构很重要。同样的道理,我并不热衷于使用 TreeSet.toArray()。构造一个新的 TreeSet很可能完全留在你的缓存中,但如果不是这样,你会从主内存中获得两倍的命中次数。
    【解决方案2】:

    根据在我公司使用的典型生产硬件上使用 Open JDK 6 进行测量,您可以预期跳过列表映射上的所有添加和查询操作所花费的时间大约是在树状图。

    例子:

    63 usec vs 31 usec 创建和添加 200 个条目。 在 200 个元素的映射上,get() 为 145 ns 与 77 ns。

    而且对于更小和更大的尺寸,这个比例并没有太大的变化。

    (此基准测试的代码最终将被共享,以便您自己查看和运行它;抱歉,我们还没有准备好这样做。)

    【讨论】:

    • 正如我所说,我可以想象会发生这种情况,事情是当我在迭代中得到改进时,我愿意在添加中获得略高的成本。
    • 好的,只是回答你提出的问题。
    【解决方案3】:

    你可以使用很多其他结构来做跳跃列表,它存在于 Concurrent 包中,因为并发数据结构要复杂得多,并且因为使用并发跳跃列表比使用其他并发数据结构来模仿成本更低跳过列表。

    在单线程世界中是不同的:您可以使用排序集、二叉树或自定义数据结构,它们的性能优于并发跳过列表。

    迭代树列表或跳过列表的复杂度总是 O(n),但是如果你使用链表或数组列表,你就会遇到插入问题:在正确的位置插入一个项目(排序链表)对于二叉树或跳过列表,插入的复杂度将是 O(n) 而不是 O(log n)。

    可以在TreeMap.keySet()中迭代,依次获取所有插入的key,不会那么慢。

    还有 TreeSet 类,可能是你需要的,但由于它只是 TreeMap 的包装,直接使用 TreeMap 会更快。

    【讨论】:

    • 好吧,问题是使用什么结构。我需要一些保持元素自然顺序的东西,比如 TreeSet,但 TreeSet 就迭代而言非常糟糕(链表将是完美的)。 LinkedHashSet 将是完美的,但它按插入顺序排序,而不是元素的自然排序。
    • 在链表维护顺序中插入一个项目是 O(n),所以它比较慢,一棵树需要 O(n log n)。迭代树比迭代列表慢,但没有那么慢,差异是恒定的。你确定一棵树还不够吗?它会比迭代并发跳过列表更快。
    • 从我的微基准测试来看,treeset 在迭代时似乎比 concurrentskiplist 慢了一点,但与 ArrayList 相比仍然太慢了(当然)。所以最后我将用一个新的 addSorted() 方法包装 ArrayList,该方法将排序插入到 ArrayList 中。在我的场景中插入和迭代之间的比率对迭代有很大的影响,所以没关系,而且数据集还是很小的。
    • 好:当然,当项目数量较少时,数组通常更快。当项目数量变大时,其他数据结构绝对更快 :) 感谢您分享您的报告。
    • 从什么时候开始插入二叉树O(n*log n)?
    【解决方案4】:

    在没有并发的情况下,使用平衡二叉搜索树通常效率更高。在 Java 中,这将是 TreeMap

    跳过列表通常保留用于并发编程,因为它们易于实现和多线程应用程序的速度。

    【讨论】:

    • 正如我在另一个答案中提到的,Tree* 迭代器不如链表高效,所以我怀疑在像我这样以迭代为主的场景中使用 Tree 是个好主意。
    【解决方案5】:

    您似乎很好地掌握了这里的权衡,所以我怀疑有人能给您一个明确的、有原则的答案。幸运的是,这很容易测试。

    我首先创建了一个简单的Iterator<String>,它在随机生成的字符串的有限列表上无限循环。 (即:在初始化时,它会从 c 个不同字符的池中生成一个包含 a 个长度为 b 的随机字符串的数组 _strings .第一次调用next()返回_strings[0],下一次调用返回_strings[1]……第(n+1)次调用再次返回_strings[0]。)这个迭代器返回的字符串是我在所有对 SortedSet<String>.add(...)SortedSet<String>remove(...) 的调用中使用的内容。

    然后我编写了一个测试方法,它接受一个空的SortedSet<String> 并循环 d 次。在每次迭代中,它添加 e 元素,然后删除 f 元素,然后迭代整个集合。 (作为一个完整性检查,它通过使用add()remove() 的返回值来跟踪集合的大小,并且当迭代整个集合时,它确保它找到了预期的元素数量。主要是我做到了这样循环体中就会有 something。)

    我认为我不需要解释我的main(...) 方法的作用。 :-)

    我尝试了各种参数的不同值,我发现有时ConcurrentSkipListSet<String> 表现更好,有时TreeSet<String> 表现更好,但差异永远不会超过两倍。一般来说,ConcurrentSkipListSet<String> 在以下情况下表现更好:

    • ab 和/或 c 相对较大。 (我的意思是,在我测试的范围内。我的 a 的范围是 1000 到 10000,我的 b 的范围是 3 到 10,我的 c em> 从 10 到 80。总体而言,生成的集合大小范围从大约 450 到正好 10000,模式为 666 和 6666,因为我通常使用 e=2‎f。)这表明ConcurrentSkipListSet<String>TreeSet<String> 处理更大的集合,和/或更昂贵的字符串比较。尝试使用旨在区分这两个因素的特定值,我得到的印象是ConcurrentSkipListSet<String> 在处理更大的集合时明显优于TreeSet<String>,而在更昂贵的字符串比较时稍微更少。 (这基本上是您所期望的;TreeSet<String> 的二叉树方法旨在进行绝对最少的比较次数。)
    • ef 很小;也就是说,当我每次迭代调用add(...)s 和remove(...)s 时只有少数次。 (这正是您所预测的。)确切的转换点取决于 abc,但只是初步近似, ConcurrentSkipListSet<String>e + f 小于 10 时表现更好,TreeSet<String>e + f 时表现更好 20 多岁。

    当然,这是在一台可能与您完全不同的机器上,使用可能与您完全不同的 JDK,并使用可能与您完全不同的非常人工的数据。我建议您运行自己的测试。由于Tree*ConcurrentSkipList* 都实现了Sorted*,因此您应该毫不费力地尝试两种方式的代码并查看您的发现。

    据我所知,ConcurrentSkipList* 基本上是基于 CAS 的无锁实现,因此它们不应该带来(太多)开销,[…]

    我的理解是,这将取决于机器。在某些系统上,可能无法实现无锁,在这种情况下,这些类将不得不使用锁。 (但由于您实际上不是多线程,即使锁也可能不会all那么昂贵。同步当然有开销,但它的主要成本是锁争用和强制单线程。这对你来说不是问题。同样,我认为你只需要测试并看看这两个版本的性能如何。)

    【讨论】:

    • 感谢您富有洞察力的回答。事实上,我进行了类似的测试,我的结果与你的完全一样。有趣的是,有时 ConcurrentSkipList 在相同的输入上比 TreeSet 慢,我还没有理清原因。
    【解决方案6】:

    如上所述,与 TreeMap 相比,SkipList 有很多开销,并且 TreeMap 迭代器不太适合您的用例,因为它只是重复调用方法 successor(),结果非常慢。
    因此,一种比前两种要快得多的替代方法是编写自己的 TreeMap 迭代器。实际上,我会完全转储 TreeMap,因为 3000 行代码比您可能需要的要大一些,只需使用您需要的方法编写一个干净的 AVL 树实现。基本的 AVL 逻辑只是一个 few hundred lines of code in any language 然后添加最适合您的情况的迭代器。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2016-12-28
      • 1970-01-01
      • 2021-07-26
      • 1970-01-01
      • 2015-09-18
      • 2017-02-20
      • 1970-01-01
      • 2013-01-18
      相关资源
      最近更新 更多