Java 8哈希表高内存使用

9

我使用哈希映射来存储强化学习算法的Q表。我的哈希映射应该存储15000000个条目。当我运行算法时,进程使用的内存超过了1000000K。但是根据我的计算,我预计它不会使用超过530000K的内存。我尝试编写一个示例,但仍然出现高内存使用情况。

public static void main(String[] args) {
    HashMap map = new HashMap<>(16_000_000, 1);
    for(int i = 0; i < 15_000_000; i++){
        map.put(i, i);
    }
}

我的内存计算:

每个条目集是32字节
容量为15000000
HashMap实例使用:32 * 大小 + 4 * 容量 内存 = (15000000 * 32 + 15000000 * 4) / 1024 = 527343.75K

我在内存计算中哪里出错了吗?


3
你从哪里得到32个字节?我会说那个数字要大得多。 - Jorn Vernee
1
这也取决于这是32位还是64位应用程序。 - Jorn Vernee
1
不要忘记你在HashMap中存储的对象所需的内存:30,000,000个Integer对象(15,000,000个键,15,000,000个值)需要额外的240 MB。 - Thomas Kläger
2个回答

9
在最好的情况下,我们假设一个字大小为32位/4字节(使用CompressedOops和CompressedClassesPointers)。那么,一个映射条目由两个JVM开销(klass指针和标记字)、键、值、哈希码和下一个指针组成,总共6个字,换句话说,24个字节。因此,拥有1500万个条目实例将消耗360 MB。
此外,还有保存条目的数组。HashMap使用的容量是2的幂,因此对于1500万个条目,数组大小至少为16777216,占用64 MiB。
然后,您有3000万个Integer实例。问题在于map.put(i, i)执行了两个装箱操作,虽然JVM鼓励在装箱时重用对象,但不要求这样做,在您的简单程序中,优化器干扰之前很可能不会发生重用。
准确地说,前128个Integer实例被重用,因为对于-128…+127范围内的值,共享是强制性的,但实现是通过在第一次使用时初始化整个缓存来实现的,所以在前128次迭代中,它不会创建两个实例,而是缓存由256个实例组成,即该数字的两倍,因此我们最终仍然有30,000,000个Integer实例总数。一个Integer实例至少包括两个JVM特定的字和实际的int值,这将使12个字节,但由于默认对齐方式,实际消耗的内存将是16个字节,可以被8整除。
因此,创建的30,000,000个Integer实例占用480MB。
这使得总计360MB + 64MiB + 480MB,超过了900MB,使得1GB堆大小完全合理。
但这就是分析工具的作用。运行程序后,我得到了

The used memory sorted by size

请注意,该工具仅报告对象的使用大小,即考虑JVM分配的总内存时所注意到的填充量之外,一个整型对象Integer的大小为12字节。

我在寻找完全不同的东西时偶然发现了这个问题,但是...首先,你的预测非常接近。jol将输出一个1.1GB的15_000_000条目映射。这里缺少的是,对于这么多条目,地图将由Node和TreeNode两者组成,它们之间的差异是32字节与56字节 - 仅针对它们的内部和引用,而不是深度大小。 - Eugene
@Eugene:OP的代码使用了一个初始容量为16_000_000的值,所以在将介于015_000_000之间的整数放入时,不应该出现哈希冲突的情况。因此,在这种特殊情况下,不应该有树节点存在。对于其他数据类型,情况可能会有所不同... - Holger
感谢您提供详细的答案! - Lev Levin

4

我有和你类似的需求,所以决定在这里分享我的想法。

1) 有一个非常好的工具可以实现这个功能:jol

2) 数组也是对象,在Java中每个对象都有两个额外的头部:标记和类,通常为4和8字节大小(这可以通过压缩指针进行调整,但不会进入细节)。

3) 这里需要注意地图的负载因子(因为它影响内部数组的调整大小)。以下是一个示例:

HashMap<Integer, Integer> map = new HashMap<>(16, 1);

for (int i = 0; i < 13; ++i) {
    map.put(i, i);
}

System.out.println(GraphLayout.parseInstance(map).toFootprint());

HashMap<Integer, Integer> map2 = new HashMap<>(16);

for (int i = 0; i < 13; ++i) {
    map2.put(i, i);
}

System.out.println(GraphLayout.parseInstance(map2).toFootprint());

这个的输出结果不同(只显示相关行):
 1        80        80   [Ljava.util.HashMap$Node; // first case
 1       144       144   [Ljava.util.HashMap$Node; // second case

看第二种情况,由于后备数组的大小是两倍大 (32个条目),因此大小比第一种情况要大。你只能在16大小的数组中放置12个条目,因为默认负载因子为0.75:16 * 0.75 = 12。 为什么是144? 数学很简单:一个数组是一个对象,因此:8+4字节用于头部。加上32 * 4字节用于引用 = 140字节。由于内存对齐为8字节,还有4字节用于填充,总共为144字节。
4) 条目被存储在映射中的Node或TreeNode中(Node为32字节,TreeNode为56字节)。由于只使用整数,所以只会有Nodes,因为不应该有哈希冲突。可能会有冲突,但这并不意味着某个数组条目将被转换为TreeNode,因为有一个阈值。我们可以轻松地证明只会有Nodes:
 public static void main(String[] args) {

    Map<Integer, List<Integer>> map = IntStream.range(0, 15_000_000).boxed()
            .collect(Collectors.groupingBy(WillThereBeTreeNodes::hash)); // WillThereBeTreeNodes - current class name

    System.out.println(map.size());

}

private static int hash(Integer key) {
    int h = 0;
    return (h = key.hashCode()) ^ h >>> 16;
} 

这将得到15_000_000的结果,没有合并,因此也没有哈希冲突。
5) 当您创建Integer对象时,会有一个对象池(范围从-127到128-这也可以调整,但为了简单起见,我们不要调整)。
6) Integer是一个对象,因此它有12字节的头和4字节的实际int值。
考虑到这一点,让我们尝试查看15_000_000个条目的输出(由于您使用负载因子为1,因此无需创建内部容量为16_000_000)。这需要很长时间,请耐心等待。我还给了它一个
-Xmx12G和-Xms12G
HashMap<Integer, Integer> map = new HashMap<>(15_000_000, 1);

for (int i = 0; i < 15_000_000; ++i) {
    map.put(i, i);
}

System.out.println(GraphLayout.parseInstance(map).toFootprint());

以下是jol说的话:

    java.util.HashMap@9629756d footprint:
     COUNT       AVG       SUM   DESCRIPTION
         1  67108880  67108880   [Ljava.util.HashMap$Node;
  29999872        16 479997952   java.lang.Integer
         1        48        48   java.util.HashMap
  15000000        32 480000000   java.util.HashMap$Node
  44999874           1027106880   (total)

让我们从底层开始。

哈希表的总占用空间大小为:1027106880字节或1,027 MB

节点实例是每个条目所在的包装类。它的大小为32字节;由于有1500万个条目,因此有以下行:

 15000000        32 480000000   java.util.HashMap$Node

为什么是32字节?它存储了哈希码(4字节)、键引用(4字节)、值引用(4字节)、下一个节点引用(4字节)、12字节的头部和4字节的填充,总共32字节。

 1        48        48   java.util.HashMap

一个HashMap实例 - 48字节用于其内部。

如果你真的想知道为什么是48字节:

    System.out.println(ClassLayout.parseClass(HashMap.class).toPrintable());


java.util.HashMap object internals:
 OFFSET  SIZE         TYPE DESCRIPTION                               VALUE
  0    12              (object header)                           N/A
 12     4          Set AbstractMap.keySet                        N/A
 16     4   Collection AbstractMap.values                        N/A
 20     4          int HashMap.size                              N/A
 24     4          int HashMap.modCount                          N/A
 28     4          int HashMap.threshold                         N/A
 32     4        float HashMap.loadFactor                        N/A
 36     4       Node[] HashMap.table                             N/A
 40     4          Set HashMap.entrySet                          N/A
 44     4              (loss due to the next object alignment)
 Instance size: 48 bytes
 Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

接下来是整数实例:

 29999872        16 479997952   java.lang.Integer

有3000万个整数对象(减去缓存在池中的128个)

 1  67108880  67108880   [Ljava.util.HashMap$Node;

我们有1500万个条目,但HashMap的内部数组大小是2的幂次方,也就是16,777,216个4字节引用。

16_777_216 * 4 = 67_108_864 + 12 bytes header + 4 padding = 67108880

谢谢,这帮助我理解了! - Lev Levin
请注意,您的Stream代码可以简化为IntStream.range(0, 15).boxed() .collect(Collectors.groupingBy(WillThereBeTreeNodes::hash) - Holger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接