【问题标题】:Why does HashMap resize() again, when specifying a precise capacity?为什么 HashMap 在指定精确容量时再次调整大小()?
【发布时间】:2019-03-11 07:12:34
【问题描述】:

代码胜于雄辩,所以:

final int size = 100;
Map<Integer, String> m = new HashMap<>(size);
for (int i = 0; i < size; i++) m.put(i, String.valueOf(i));

为什么 HashMap 在内部调用 resize() 21 2 次!(感谢 Andreas 确定 JVM 在内部使用 HashMap,其中 19 21 cals 来自其他进程)

我的应用程序仍然不能接受两次resize() 调用。我需要对此进行优化。

如果我是一个新的 Java 开发人员,我对 HashMap 构造函数中“容量”的第一个直观猜测是,它是我(HashMap 的消费者)要放入的元素数量的容量。地图。但事实并非如此。

如果我想优化我对 HashMap 的使用,使其根本不需要调整自身大小,那么我需要足够深入地了解 HashMap 的内部结构,以准确了解 HashMap 存储桶数组需要有多稀疏。在我看来,这很奇怪。 HashMap 应该为您隐式执行此操作。这是 OOP 中封装的重点。

注意:我已经确认 resize() 是我的应用程序用例的瓶颈,所以我的目标是减少对 resize() 的调用次数。

问题:

如果我事先知道要放入地图的条目的确切数量。我选择什么容量来防止 任何 额外调用 resize() 操作?像size * 10 这样的东西?我还想了解一下为什么HashMap 是这样设计的。

编辑:我被问了很多为什么这种优化是必要的。我的应用程序在 hashmap.resize() 中花费了大量的 CPU 时间。我的应用程序使用的 hashmap 的初始化容量等于我们放入其中的元素数量。因此,如果我们可以减少 resize() 调用(通过选择更好的初始容量),那么我的应用程序性能就会提高。

【问题讨论】:

  • HashMap 应该只需要调整一次大小,从初始容量 128 到最终容量 256。您如何衡量在代码执行期间它调整大小的次数?
  • 在执行这段特定代码的过程中,您如何计算对 resize() 的 21 次调用仍不清楚。仅编译代码肯定不会显示循环导致实际HashMap 对象调整大小的次数。你用什么方法来计算调整大小的实际次数?
  • 被调用2次有什么问题? first = 第一次创作;第二 = 因为加载因子 0.75 - 如果您知道有多少元素,请将加载因子更改为 1.0(或将元素数量除以加载因子以获得初始容量)
  • 如果您不希望它调整大小两次(技术上一次),那么要么指定更大的初始大小(~134),要么使用更高的负载因子创建它,顺便说一句,这是如果不仔细考虑其文档中的描述,您不应进行调整。
  • 而且你不需要深入了解内部,你只需要阅读 API 文档。这种行为是其契约的一部分,它不仅仅是一个实现细节。

标签: java optimization data-structures hashmap cpu


【解决方案1】:

默认加载因子为0.75,即3/4,这意味着当100个值中的75个被添加时,内部哈希表将被调整大小。

仅供参考: resize() 只被调用了两次。一次是在添加第一个值时,一次是在它达到 75% 满时。

为了防止调整大小,您需要确保第 100 个值不会导致调整大小,即size &lt;= capacity * 0.75 aka size &lt;= capacity * 3/4 aka size * 4/3 &lt;= capacity,所以要确定:

capacity = size * 4/3 + 1

size = 100 表示capacity = 134

【讨论】:

  • Andreas,resize() 函数 is 被调用了 21 次。现在,也许函数在实际执行调整大小之前就退出了,我会再深入研究一下。
  • @Bobulous 正确。这是一种节省内存的优化,因为测试表明,创建集合对象时通常不会添加任何值,因此现在创建的集合占用的内存最少,并且在添加第一个值之前不会应用初始容量。
  • @JamesWierzba resize() 只被调用两次,如果你将 100 个元素添加到初始化为 100 的映射中。如果你创建一个只有代码中的小测试程序问题,请注意 JVM 启动会创建 other HashMap 对象,因此不要计算所有其他对象上的 resize() 调用。仅计算 m 实例上的调用。 --- 在 Java 10 上测试。您运行的是什么版本?
  • @Andreas -- 你是对的!我确认 HashMap 被 JVM 正在做的任何引导调用了 19 次,我的代码被调用了 2 次 :)
  • @JamesWierzba 是的,我运行了那个测试,看到了很多 resize(),但请注意调用堆栈不正确,所以将 trigger 断点放在第一行我的代码,将调用次数减少到 2。
【解决方案2】:

如有疑问,请阅读文档。 HashMap 的文档很好地解释了 initial capacityload-factor 的权衡。

根据文档如果initCapacity = (maxEntries / loadFactor) + 1,那么在添加条目时不会发生重新哈希操作。在这种情况下,maxEntries 是您指定的 100loadFactor 将是 .75 的默认负载因子。

但除了设置初始大小以避免重新散列 (resize()) 之外,您还应该仔细阅读 HashMap 的文档以正确调整它,同时考虑初始容量和负载因子。

如果您更关心查找成本而不是空间,那么可以尝试使用较低的 loadFactors,例如 .5,或者如果您愿意,可以使用更低的值。在这种情况下,您将使用以下两个参数创建哈希映射:

final float loadFactor = 0.5;
final int maxEntries   = 100;
final int initCapacity = (int) maxEntries / loadFactor + 1;
new HashMap<>(initCapacity, loadFactor);

(强调我的)

HashMap 的实例有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中的桶数,初始容量只是哈希表创建时的容量。负载因子是哈希表在其容量自动增加之前允许达到的程度的度量。当哈希表中的条目数超过负载因子和当前容量的乘积时,对哈希表进行重新哈希(即重建内部数据结构),使哈希表具有大约两倍的桶数。
...
作为一般规则,默认负载系数 (.75) 在时间和空间成本之间提供了良好的折衷。较高的值会减少空间开销,但会增加查找成本(反映在 HashMap 类的大多数操作中,包括 get 和 put)。在设置其初始容量时,应考虑映射中的预期条目数及其负载因子,以尽量减少重新哈希操作的次数。 如果初始容量大于最大条目数除以负载因子,则不会发生重新哈希操作。

【讨论】:

  • 0.75 也是3/4,所以(maxEntries / 0.75) + 1 最好写成maxEntries * 4 / 3 + 1 (不需要括号),使用int 数学(无需演员表).
  • @Andreas 另一方面,Java 本身使用浮点数进行计算。
  • @MarkRotteveel 当然,但new HashMap&lt;&gt;(size * 4/3 + 1)new HashMap&lt;&gt;((int) (size / 0.75 + 1)) 更短/更简单,因为您不必投射。
  • @xtratic 不完全确定,但您似乎将 re-hash 与 re-size 混淆了,这些是不同的东西
  • @Eugene resize() 充当 Java 实现 HashMaprehash,据我所知。它可能不会计算新的哈希码,但它会重新分配元素,并且在文档和代码中它被视为重新哈希操作。
【解决方案3】:

这很容易证明:

private static <K, V> void debugResize(Map<K, V> map, K key, V value) throws Throwable {

    Field table = map.getClass().getDeclaredField("table");
    AccessibleObject.setAccessible(new Field[] { table }, true);
    Object[] nodes = ((Object[]) table.get(map));

    // first put
    if (nodes == null) {
        map.put(key, value);
        return;
    }

    map.put(key, value);

    Field field = map.getClass().getDeclaredField("table");
    AccessibleObject.setAccessible(new Field[] { field }, true);
    int x = ((Object[]) field.get(map)).length;
    if (nodes.length != x) {
        ++currentResizeCalls;
    }
}

还有一些用法:

static int currentResizeCalls = 0;

public static void main(String[] args) throws Throwable {

    int size = 100;
    Map<Integer, String> m = new HashMap<>(size);
    for (int i = 0; i < size; i++) {
        DeleteMe.debugResize(m, i, String.valueOf(i));
    }

    System.out.println(DeleteMe.currentResizeCalls);
}     

我只记录resize实际上调整大小所花费的时间,因为第一次调用正在初始化;如文件规定:

初始化或加倍表格大小


你的第二点更有趣。HashMap定义capacity,那么容量是什么?这并不是那么明显:

对于HashMapcapacity 是调整大小之前buckets 的数量,对于ConcurrentHashMap,它是执行调整大小之前的条目数。

因此,不要在内部调用resize,如果是HashMap,请使用公式:

(int)(1.0 + (long)initialCapacity / LOAD_FACTOR)

但这到目前为止并不理想,假设您想要 1024 条目而不调整大小,通过使用该公式您可以得到 1367 存储桶,内部四舍五入为 2 的幂,因此 2048 - 好吧,比你要求的要多得多。

对于CHM直接指定大小。在前面的代码中使用一个修改很容易证明:

 // use CHM instead of HashMap
 Map<Integer, String> m = new ConcurrentHashMap<>(size);

这将导致zero 调整大小实际上是数组的两倍。但有时甚至CHM 内部代码也是confusing 并且几乎不需要修补。

【讨论】:

    【解决方案4】:

    这里有很多精彩的答案。我非常感谢您的贡献。

    我决定不重新发明这个轮子,因为看来 google 已经解决了这个问题。

    我将使用来自Google's guava library的实用方法Maps.newHashMapWithExpectedSize(int)

    【讨论】:

    • 你看下执行了吗?这不是魔法return (int) ((float) expectedSize / 0.75F + 1.0F);
    • @Eugene 取决于你对魔法的定义,我想。如果负载因子或任何其他实现细节发生变化,我希望谷歌库能够解决这个问题。我不必。所以这是另一个好处
    • 当然,如果你一直在升级番石榴并且他们一直跟上内部细节;但当然是你的选择,绝对
    • @JamesWierzba 错了。首先,如果开发人员使用了别人的代码(即使是 Guava),他将对自己编写的代码负责(因为他可以自己编写但不能使用别人的代码)。其次,尽管 Java 具有向后兼容性,但 HashMap 的内部结构在 Oracle JDK 7 和 Oracle JDK 8 中非常不同,我没有看到 Guava 为 JDK 8+ 提供了不同版本的 Maps#newHashMapWithExpectedSize。跨度>
    • @JamesWierzba 第三,该方法的 Javadoc 声明“不能广泛保证这种行为,但对于 OpenJDK 1.7 来说,这是正确的。也不能保证方法不会无意中过大返回的映射。”此外,在 Oracle JDK 8 中,+ 1.0F 实际上没有必要。如果expectedSize12,则该方法给出容量为32 但不是16HashMap16 * 0.75 = 12 并且仅当添加了 13th 条目时,HashMap 将自身调整为 32 的容量。
    【解决方案5】:
    • 调整大小是哈希图保持低负载因子的重要组成部分。

    • 负载因子需要较低,因为当 hashmap 的存储桶达到最大值时,hashmap 的散列函数将不可避免地开始发生冲突。如果您的条目每次都散列到占用的存储桶,则冲突可能从第二个条目本身开始。


    但是,在您的特定情况下,冲突不是问题,只有 hashmap 的大小调整才是问题。

    Hashmap 通常以 0.75(= 3/4)的负载因子调整大小。使用此信息,您可以设置一个 4/3 倍于您需要存储的条目计数的哈希图。


    关于您对打破封装的异议:

    我同意你的观点这是值得商榷的。

    您可以说,如果capacity 表示不会发生调整大小的条目数,而不是可以存储在哈希图中的最大可能条目数,那会更好 - 我倾向于同意你。

    但是其他人也可能会争论为什么哈希图占用的空间比它被指示保留的空间多。

    这个问题的解决方案在于 Java 的领域。 Java 可以提供两个构造函数,它们足够明确地说明它们将要做什么,然后开发人员可以在哈希图的初始化期间进行选择。

    【讨论】:

      【解决方案6】:

      Java 有很多发行版和版本,所以你必须自己测试它。

      • 在 Oracle JDK 7 中,HashMap 给出了不可预测的调整大小结果,因为它有 2 发生调整大小的条件 ((size &gt;= threshold) &amp;&amp; (null != table[bucketIndex]))。首先,大小必须为&gt;= 阈值(上限 * 负载因子)。其次,当前桶必须已经有一个条目,这意味着冲突。
        • 运行下面的代码,自己看看。
          • 当容量为4 时,将在放入5th 条目时调整大小。
          • 当容量为8 时,将在放入9th 条目时调整大小。
          • 当容量为16 时,将在放入16th 条目时调整大小(不是17th)。
          • 当容量为32 时,将在放入32th 条目时调整大小(不是33th)。
          • 当容量为64 时,将在放入64th 条目时调整大小(不是65th)。
      • 在 Oracle JDK 8 中,当大小为 &gt; 阈值(容量 * 负载因子)时,HashMap 会调整大小。
        • 容量为16,默认负载因子为0.75,在放置13th 条目时会调整大小(到32 的容量)。
        • 运行下面的代码,自己看看。
          • 当容量为4 时,将在放入4th 条目时调整大小。
          • 当容量为8 时,将在放置7th 条目时调整大小。
          • 当容量为16 时,将在放入13th 条目时调整大小。
          • 当容量为32 时,将在放入25th 条目时调整大小。
          • 当容量为64 时,将在放入49th 条目时调整大小。
      public class HashMapTest {
      
          public static void main(String[] args) {
              int cap = 4;
              int size = 64;
              Map<Integer, String> map = new HashMap<>(cap);
      
              for (int i=1; i<=size; i++) {
                  map.put(i, i+"");
                  print(map);
              }
          }
      
          public static void print(Map map) {
              try {
                  Class<?> mapType = map.getClass();
                  Method capacity = mapType.getDeclaredMethod("capacity");
                  capacity.setAccessible(true);
                  System.out.println("capacity : " + capacity.invoke(map) + "    size : " + map.size());
              } catch (Exception e) {
                  e.printStackTrace();
              }
          }
      }
      

      【讨论】:

        猜你喜欢
        • 2020-08-18
        • 2015-02-07
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多