【问题标题】:Map alternative for primitive values原始值的映射替代
【发布时间】:2017-06-03 05:41:06
【问题描述】:

我对我的应用程序进行了一些分析,结果之一是堆上大约 18% 的内存被 Double 类型的对象使用。原来这些对象是Maps 中的值,我不能使用原始类型。

我的理由是double 的原始类型比它的对象Double 消耗的内存更少。有没有办法拥有一个类似于数据结构的映射,它可以接受任何类型作为键和原始double 作为值?

主要操作是:

  • 插入(可能只有一次)
  • 查找(按关键字包含)
  • 检索(按键)
  • 迭代

我拥有的典型地图是:

  • HashMap<T, HashMap<NodeData<T>, Double>> graph
  • HashMap<Point2D, Boolean> onSea(虽然不是双精度值)
  • ConcurrentHashMap<Point2D, HashMap<Point2D, Double>>

全部用于 Java 8。

附录

我主要对能够解决这类地图的框架不感兴趣,而是对解决这些问题时必须考虑的事项感兴趣。如果您愿意,任何此类框架背后的概念/想法/方法是什么。或者解决方案也可能在另一个层面上,其中地图被替换为遵循某种模式的对象,就像@Ilmari Karonen 在他的回答中指出的那样。

【问题讨论】:

  • 地图中有多少条目?
  • 我使用多个以Double 作为值的地图。然而,最大的地图似乎包含超过 32,000 个元素。
  • 您使用哪种实现方式?您是否考虑过使用另一个?
  • “大约 18% 被类型的对象使用”总内存的 18%?堆内存?处理时间?没有上下文的数字是没有意义的。
  • @jpmc26:这是关于堆上的内存。澄清了问题。

标签: java java-8 primitive


【解决方案1】:

您正在寻找的是来自fastutil(具有小内存占用和快速访问和插入的集合框架)的Object2DoubleOpenHashMap,它提供了double getDouble(Object k)double put(K k, double v) 类型的方法。

例如:

// Create a Object2DoubleOpenHashMap instance
Object2DoubleMap<String> map = new Object2DoubleOpenHashMap<>();
// Put a new entry
map.put("foo", 12.50d);
// Access to the entry
double value = map.getDouble("foo");

Object2DoubleOpenHashMap 类是非线程安全的 Map 的实际实现,但是您仍然可以使用实用方法 Object2DoubleMaps.synchronize(Object2DoubleMap&lt;K&gt; m) 使其成为线程安全的,这要归功于装饰器。

然后创建代码将是:

// Create a thread safe Object2DoubleMap
Object2DoubleMap<String> map =  Object2DoubleMaps.synchronize(
    new Object2DoubleOpenHashMap<>()
);

【讨论】:

    【解决方案2】:

    【讨论】:

    • 看起来对内存消耗的影响并不像我想象的那么显着。
    • fastutil、Koloboke 和 Eclipse 集合通常比 Trove 和 HPPC 更好(通过最有用的指标,如正确性、速度、完整性、API 等)。
    【解决方案3】:

    Eclipse Collectionsobjectprimitive maps 并且两者都有 Mutable 和 Immutable 版本。

    MutableObjectDoubleMap<String> doubleMap = ObjectDoubleMaps.mutable.empty();
    doubleMap.put("1", 1.0d);
    doubleMap.put("2", 2.0d);
    
    MutableObjectBooleanMap<String> booleanMap = ObjectBooleanMaps.mutable.empty();
    booleanMap.put("ok", true);
    
    ImmutableObjectDoubleMap<String> immutableMap = doubleMap.toImmutable();
    Assert.assertEquals(doubleMap, immutableMap);
    

    MutableMap 可以用作 Eclipse Collections 中 ImmutableMap 的工厂,方法是调用 toImmutable,就像我在上面的示例中所做的那样。可变映射和不可变映射共享一个共同的父接口,在上面的MutableObjectDoubleMapImmutableObjectDoubleMap 的情况下,它被命名为ObjectDoubleMap

    Eclipse Collections 还为库中的所有可变容器提供了同步且不可修改的版本。以下代码将为您提供一个环绕原始地图的同步视图。

    MutableObjectDoubleMap<String> doubleMap = 
            ObjectDoubleMaps.mutable.<String>empty().asSynchronized();
    doubleMap.put("1", 1.0d);
    doubleMap.put("2", 2.0d);
    
    MutableObjectBooleanMap<String> booleanMap = 
            ObjectBooleanMaps.mutable.<String>empty().asSynchronized();
    booleanMap.put("ok", true);
    

    这张大型地图的性能比较是几年前发布的。

    Large HashMap overview: JDK, FastUtil, Goldman Sachs, HPPC, Koloboke, Trove – January 2015 version

    GS Collections 已被迁移到 Eclipse Foundation,现在是 Eclipse Collections。

    注意:我是 Eclipse Collections 的提交者。

    【讨论】:

    • 我假设可变映射变体不是线程安全的,对吧? product page 上显示的关于映射的内存使用比较:用于该比较的映射是原始键和值还是混合类型?
    • 正确,可变映射不是线程安全的。通过调用 asSynchronized(),还有可用于可变原始映射的同步包装器。使用这些会有所取舍,并且某些方法可能需要通过同步块显式保护。
    • 产品页面的内存对比是JDK HashMap和EC UnifiedMap两者都是Map
    【解决方案4】:

    其他人已经建议了几个原始值映射的第三方实现。为了完整起见,我想提一些您可能想要考虑的完全摆脱地图的方法。这些解决方案并不总是可行的,但当它们可行时,它们通常会比任何地图都更快且内存效率更高。

    备选方案 1:使用普通的旧数组。

    一个简单的double[] 数组可能没有精美的地图那么性感,但在紧凑性和访问速度方面几乎没有什么能比得上它。

    当然,数组有很多限制:它们的大小是固定的(尽管您总是可以创建一个新数组并将旧数组的内容复制到其中),并且它们的键只能是小的正整数,为了提高效率,应该是相当密集的(即使用的键的总数应该是最高键值的相当大的一部分)。但是,如果您的键恰好是这种情况,或者您可以安排这种情况,原始值数组可能会非常有效。

    特别是,如果您可以为每个键对象分配一个唯一的小整数 ID,那么您可以使用该 ID 作为数组的索引。同样,如果您已经将对象存储在一个数组中(例如,作为一些更复杂的数据结构的一部分)并通过索引查找它们,那么您可以简单地使用相同的索引来查找另一个数组中的任何其他元数据值。

    如果您实现了某种冲突处理机制,您甚至可以免除 ID 唯一性要求,但此时您已经在实现自己的哈希表的路上了。在某些情况下,可能实际上是有意义的,但通常此时使用现有的第三方实现可能更容易。

    备选方案 2:自定义您的对象。

    与其维护从关键对象到原始值的映射,为什么不把这些值变成对象本身的属性呢?毕竟,这就是面向对象编程的全部内容——将相关数据分组为有意义的对象。

    例如,与其维护HashMap&lt;Point2D, Boolean&gt; onSea,不如直接给您的点一个布尔值onSea 属性?当然,您需要为此定义自己的自定义点类,但是如果您愿意,没有理由不能让它扩展标准的Point2D 类,以便您可以将自定义点传递给任何方法需要Point2D

    同样,这种方法可能并不总是直接有效,例如如果您需要使用无法修改的类(但请参见下文),或者您要存储的值与多个对象相关联(如在您的 ConcurrentHashMap&lt;Point2D, HashMap&lt;Point2D, Double&gt;&gt; 中)。

    但是,对于后一种情况,您仍然可以通过适当地重新设计数据表示来解决问题。例如,您可以定义一个Edge 类,而不是将加权图表示为Map&lt;Node, Map&lt;Node, Double&gt;&gt;,如下所示:

    class Edge {
        Node a, b;
        double weight;
    }
    

    然后将Edge[](或Vector&lt;Edge&gt;)属性添加到包含连接到该节点的任何边的每个节点。

    备选方案 3:将多张地图合二为一。

    如果您有多个具有相同键的映射,并且不能像上面建议的那样将值转换为键对象的新属性,请考虑将它们分组到单个元数据类中,并创建一个从键到该对象的单个映射班级。例如,考虑定义一个元数据类,而不是 Map&lt;Item, Double&gt; accessFrequencyMap&lt;Item, Long&gt; creationTime,例如:

    class ItemMetadata {
        double accessFrequency;
        long creationTime;
    }
    

    并有一个Map&lt;Item, ItemMetadata&gt; 来存储所有元数据值。这比拥有多个地图更节省内存,并且还可以通过避免冗余地图查找来节省时间。

    在某些情况下,为方便起见,您可能还希望在每个元数据对象中包含对其相应主对象的引用,以便您可以通过对元数据对象的单个引用来访问两者。这很自然地进入...

    备选方案 4:使用装饰器。

    作为前两种选择的组合,如果您不能直接将额外的元数据属性添加到关键对象中,请考虑使用可以保存额外值的decorators 包装它们。因此,例如,您可以简单地执行以下操作,而不是直接创建您自己的带有额外属性的点类:

    class PointWrapper {
        Point2D point;
        boolean onSea;
        // ...
    }
    

    如果您愿意,您甚至可以通过实现方法转发将这个包装器变成一个成熟的装饰器,但即使只是一个简单的“愚蠢”包装器也可能足以满足多种用途。

    如果您可以安排仅存储和使用包装器,则此方法最有用,这样您就无需查找对应于未包装对象的包装器。当然,如果您确实需要偶尔这样做(例如,因为您只接收来自其他代码的解包对象),那么您可以使用单个 Map&lt;Point2D, PointWrapper&gt; 来执行此操作,但是您实际上又回到了之前的选择.

    【讨论】:

    • 谢谢。这最初是我正在寻找的答案。至少在基础对象只是一个引用的情况下,装饰器方法看起来很有前途。在这种情况下,可自定义的对象会增加额外的内存开销。
    【解决方案5】:

    为了更好地估计这些不同的库是如何相互叠加的,我整理了一个小型基准来检查以下各项的性能:

    • 300'000 次插入的总时间
    • 检查地图中包含 1000 个样本的收容措施的平均时间
    • 数据结构的内存大小 我查看了类似Map 的结构,它以String 作为键,double 作为值。检查的框架是Eclipse CollectionHPPCTroveFastUtil,以及用于比较的HashMapConcurrentHashMap

    简而言之,结果如下:

    Filling in 300000 into the JDK HashMap took 107ms
    Filling in 300000 into the JDK ConcurrentHashMap took 152ms
    Filling in 300000 into the Eclipse map took 107ms
    Filling in 300000 into the Trove map took 855ms
    Filling in 300000 into the HPPC map took 93ms
    Filling in 300000 into the FastUtil map took 163ms
    1000 lookups average in JDK HashMap took: 550ns
    1000 lookups average in JDK Concurrent HashMap took: 748ns
    1000 lookups average in Eclipse Map took: 894ns
    1000 lookups average in Trove Map took: 1033ns
    1000 lookups average in HPPC Map took: 523ns
    1000 lookups average in FastUtil Map took: 680ns
    JDK HashMap:            43'809'895B
    JDK Concurrent HashMap: 43'653'740B => save  0.36%
    Eclipse Map:            35'755'084B => save 18.39%
    Trove Map:              32'147'798B => save 26.62%
    HPPC Map:               27'366'533B => save 37.53%
    FastUtil Map:           31'560'889B => save 27.96%
    

    有关所有详细信息以及测试应用程序,请查看我的blog entry

    【讨论】:

    • 您的基准测试非常有问题:没有预热,在同一个 VM 中测试所有变体,测量时间太短,OSR。相信我,Java 基准测试比预期的要困难得多。看看JMH
    • @maaartinus 我知道这些缺点,所以结果并不那么可靠,但它们指向了某个方向。
    • 偏离两倍或更多也就不足为奇了。每个containsKey 调用有两个System.nanoTime 调用,这很有趣,因为前者并不比后者快多少。交错所有调用很有趣,因为优化器可能决定内联一些调用(但不是所有调用都存在内联限制)。同样对于寄存器分配等。您的结果确实指向某个方向,但可能是相反的方向。
    • @maaartinus。在查看时间安排时,为确保他们确定所做的测试还不足以得出结论。但是我主要关心的是内存使用情况,并且差异更加明显。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-02-17
    • 2023-04-01
    • 1970-01-01
    • 1970-01-01
    • 2019-08-16
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多