(译)JAVA HashMap工作原理

原标题:How does a HashMap work in JAVA
作者:Christophe
http://coding-geek.com/how-does-a-hashmap-work-in-java/

  大多数JAVA开发人员都在使用Maps,尤其是HashMaps。HashMap是一种简单并且有效的存取数据的方式。但是有多少人知道HashMap内部是如何工作的么?前段时间,为了深入理解这个基础的数据结构,我阅读了 java.util.HashMap大部分源码(先是Java 7中,然后是Java 8)。在这篇文章中,我将解释java.util.HashMap的实现,并介绍JAVA 8实现的新功能,并讨论使用HashMaps时的性能,内存和已知的一些问题。

Internal storage 内部存储

  Java HashMap 类实现Map<K,V>接口,这个接口的主要方法包括:

  • V put(K key, V value)
  • V get(Object key)
  • V remove(Object key)
  • Boolean containsKey(Object key)

  HashMaps使用一个内部类存储数据: Entry<K, V>. 这个条目就是个拥有两个额外数据的键值对。

  • 指向下一个Entry的引用。这样HashMap可以像简单列表一样存储多个条目
  • Key的哈希值,存储该值可以避免每次使用时重新计算哈希值

  这里是JAVA 7中Entry部分实现:

	static class Entry<K,V> implements Map.Entry<K,V> {
	        final K key;
	        V value;
	        Entry<K,V> next;
	        int hash;
	…
	}

  HashMap将数据存储到多个单向条目链表(也称为桶)中。所有的列表都必须在Entry(Entry<K,V>[] array)数组中注册。内部数组的默认容量为16
  下图显示了具有可为空条目数组的HashMap实例的内部存储。 每个条目都可以链接到另一个条目以形成链接列表。
在这里插入图片描述
  所有哈希值相同的key都存放到同一个链表中(桶),具有不同哈希值的key可以在同一个桶中结束。
  当用户调用put(K键,V值)或get(Object键)方法时,该函数计算条目所在桶的索引。 然后,函数遍历列表以查找具有相同key的Entry(使用key的equals()方法)。
  在get()的情况下,函数返回与条目关联的值(如果条目存在)。
  在put(K key,V value)的情况下,如果条目存在,则函数将其替换为新值,否则它将在单链接列表的头部创建新条目(来自参数中的键和值)。
Map通过3步生成桶(链表)的索引:

  • 首先,它先获取key的哈希值 它再次计算哈希值,防止当散列函数选取不好时,会造成将所有的数据全部映射到一个桶中的情况。
  • 它再次计算哈希值,防止当散列函数选取不好时,会造成将所有的数据全部映射到一个桶中的情况。
  • 使用二次计算的哈希值,并使用数组长度(减1)对其进行位掩码。 此操作确保索引不能大于数组的大小。 你可以将其视为计算优化的模数函数。

  以下是处理索引的JAVA 7和8源代码:

	// the "rehash" function in JAVA 7 that takes the hashcode of the key
	static int hash(int h) {
	    h ^= (h >>> 20) ^ (h >>> 12);
	    return h ^ (h >>> 7) ^ (h >>> 4);
	}
	// the "rehash" function in JAVA 8 that directly takes the key
	static final int hash(Object key) {
	    int h;
	    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	    }
	// the function that returns the index from the rehashed hash
	static int indexFor(int h, int length) {
	    return h & (length-1);
	}

  为了有效地工作,内部数组的大小需要是2的幂,让我们看看为什么。
  想象一下,数组大小为17,掩码值将为16(大小-1)。 16的二进制表示是0 … 010000,因此对于任何散列值H,使用按位公式“H AND 16”生成的索引将是16或0.这意味着大小为17的数组将仅用于 2个桶:索引0的那个和索引16的那个,所以效率不高…
  但是,如果现在采用像16这样的2的幂的大小,则按位索引公式为“H AND 15”。 15的二进制表示是0 … 001111,因此索引公式可以输出0到15的值,并且完全使用大小为16的数组。 例如:

  • 如果H = 952,其二进制表示为0…01110111000,相关索引为0 … 01000 = 8
  • 如果H = 1576,其二进制表示为0…011000101000,则相关索引为0 … 01000 = 8
  • 如果H = 12356146,其二进制表示为0…0101111001000101000110010,关联索引为0 … 00010 = 2
  • 如果H = 59843,其二进制表示为0…01110100111000011,关联索引为0 … 00011 = 3

  这就是数组大小为2的幂的原因。 这种机制对于开发人员是透明的:如果他选择大小为37的HashMap,Map将自动选择在37(64)之后最接近其2的整次幂数,用于决定内部数组的大小。

Auto resizing 自动扩容

  获取索引后,函数(get,put或remove)访问/迭代关联的链表以查看给定键是否存在现有Entry。 如果不进行修改,此机制可能会导致性能问题,因为函数需要遍历整个列表以查看条目是否存在。 想象一下,内部数组的大小是默认值(16),您需要存储2百万个值。 在最理想的情况下,每个链表的大小为125 000个条目(2/16百万)。 因此,每个get(),remove()和put()将导致125 000次迭代/操作。 为了避免这种情况,HashMap需要能够增加其内部数组的能力,以保持非常短的链表。
  创建HashMap时,可以使用以下构造函数指定初始大小和loadFactor:

	public HashMap(int initialCapacity, float loadFactor)

  如果未指定参数,则默认initialCapacity为16,默认loadFactor为0.75。 initialCapacity表示链接列表的内部数组的大小。
  每次使用put(…)在Map中添加新的键/值时,该函数都会检查是否需要增加内部数组的容量。 为此,Map存储了2个数据:

  • map的大小:它表示HashMap中的条目数。 每次添加或删除条目时都会更新此值。
  • 一个阈值:它等于(内部数组的容量)* loadFactor,并在每次调整内部数组大小后刷新。

   在添加新条目之前,put(…)检查Map大小是否大于阈值,如果是,它会重新创建一个两倍大小的新数组。 由于新数组的大小已更改,因此索引函数(返回按位操作“hash(key)AND(sizeOfArray-1)”)会发生变化。 因此,数组的扩容会创建两倍的桶(即链表),并将所有现有条目重新分配到桶(旧的和新创建的)中。
   扩容操作的目的是减小链接列表的大小,以便put(),remove()和get()方法的时间成本维持较低水平。 扩容后,其键具有相同散列的所有条目将保留在同一个存储桶中。 但是,在转换之后,原先在同一个桶中具有不同hash键的2个条目可能不在同一个桶中。

   该图显示了在调整内部数组大小之前和之后的表示。 在增加之前,为了获得条目E,Map必须遍历5个元素的列表。 调整大小后,同样的get()只迭代2个元素的链表,调整大小后get()快2倍!
   注意:HashMap只会增加内部数组的大小,它没有提供减少它的方法。

Thread Safety 线程安全

   如果你已经知道HashMaps,你知道这不是线程安全的,但为什么呢? 例如,假设你有一个Writer线程只往Map里面添加新的数据,一个Reader 线程从Map里面读数据,为什么他就不能工作呢?
   因为在自动扩容机制期间,如果线程尝试放入或获取对象,则map可能使用旧索引值,并且将找不到该条目所在的新存储桶。
   最糟糕的情况是2个线程同时放置数据,2个put()调用同时调整Map的大小。 由于两个线程同时修改链接列表,因此Map最终可能会在其链接列表中出现内部循环。 如果你尝试使用内部循环获取列表中的数据,则get()将永远不会结束。
   HashTable实现是一个线程安全的实现,可以防止出现这种情况。 但是,由于所有CRUD方法都是同步的,因此这种实现非常缓慢。 例如,如果线程1调用get(key1),则线程2调用get(key2),线程3调用get(key3),当这3个线程同时访问数据时,一次只有一个线程能够获取数据值。
   自JAVA 5以来,存在一个更安全的线程安全HashMap实现:ConcurrentHashMap。 只有桶是同步的,因此多个线程可以同时get(),remove()或put()数据,意味着不会同时访问同一个桶或扩容。 在多线程应用程序中最好使用ConcurrentHashMap。

Key immutability key不变性

   为什么Strings 和Integers 是HashMap的key的良好实现? 主要是因为它们是不变的! 如果你选择创建自己的Key类并且不保持其不可变,则可能会丢失HashMap中的数据。
   看看下面的用例:

  • 你有一个内部值为“1”的键
  • 你使用此键将对象存入HashMap中
  • HashMap根据Key的哈希码生成哈希值(因此从“1”开始)
  • Map将此哈希存储在新创建的Entry中
  • 你将键的内部值修改为“2”
  • key的哈希值被修改但HashMap不知道它(因为存储了旧的哈希值)
  • 你尝试使用修改后的key获取对象
  • Map计算key的新哈希值(因此从“2”开始)以查找条目所在的链表(桶)
    • 情况1:由于你修改了key,因此map会尝试在错误的存储桶中找到该条目,但找不到该条目
    • 情况2:幸运的是,修改后的key生成与旧key相同的桶。然后,映射遍历链表以查找具有相同key的条目。但是为了找到key,map首先比较哈希值,然后调用equals()比较。由于修改后的key与旧哈希值(存储在条目中)不一样,因此map将无法在链表中找到该条目。
        这是Java中的一个具体示例。 我在map中放了两个键值对,我修改了第一个键,然后尝试获取2个值。 只有第二个值从map返回,第一个值在HashMap中“丢失”:
public class MutableKeyTest {
 
    public static void main(String[] args) {
 
        class MyKey {
            Integer i;
 
            public void setI(Integer i) {
                this.i = i;
            }
 
            public MyKey(Integer i) {
                this.i = i;
            }
 
            @Override
            public int hashCode() {
                return i;
            }
 
            @Override
            public boolean equals(Object obj) {
                if (obj instanceof MyKey) {
                    return i.equals(((MyKey) obj).i);
                } else
                    return false;
            }
 
        }
 
        Map<MyKey, String> myMap = new HashMap<>();
        MyKey key1 = new MyKey(1);
        MyKey key2 = new MyKey(2);
 
        myMap.put(key1, "test " + 1);
        myMap.put(key2, "test " + 2);
 
        // modifying key1
        key1.setI(3);
 
        String test1 = myMap.get(key1);
        String test2 = myMap.get(key2);
 
        System.out.println("test1= " + test1 + " test2=" + test2);
 
    }
 
}

   输出为:“test1 = null test2 = test 2”。 正如所料,Map无法使用修改后的key1检索字符串1。

JAVA 8改进

   在JAVA 8中,HashMap的内部表示发生了很大的变化。实际上,JAVA 7中的实现需要1k行代码,而JAVA 8中的实现需要2k行。 除了链接的条目列表之外,我之前说过的大部分内容都是正确的。 在JAVA8中,你仍然有一个数组,但它现在存储的节点包含与条目完全相同的信息,因此也是链接列表:
   以下是JAVA 8中Node实现的一部分:

static class Node<K,V> implements Map.Entry<K,V> {
     final int hash;
     final K key;
     V value;
     Node<K,V> next;

   那么与JAVA 7的最大区别是什么? 好吧,节点可以扩展到TreeNodes。 TreeNode是一种红黑树结构,可以存储更多信息,以便它可以添加,删除或获取O(log(n))中的元素。
   仅供参考,这是存储在TreeNode中的数据的详尽列表

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    final int hash; // inherited from Node<K,V>
    final K key; // inherited from Node<K,V>
    V value; // inherited from Node<K,V>
    Node<K,V> next; // inherited from Node<K,V>
    Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;

  红黑树是自平衡二叉搜索树。 不管是新添加或删除节点,它们的内部机制确保它们的长度始终为log(n)。 使用这些树的主要优点是在许多数据位于内部表的相同索引(桶)中的情况下,树中的搜索将花费O(log(n)),而链表将花费O(n)。
  如你所见,树比链接列表占用更多空间(我们将在下一部分中讨论它)。
  通过继承,内部表可以包含Node(链表)和TreeNode(红黑树)。 Oracle根据以下规则来确定使用哪种数据结构:

  • 如果内部表中的给定索引(存储桶)有超过8个节点,则链表将转换为红黑树
  • 如果对于内部表中的给定索引(存储桶),节点少于6个,则将树转换为链接列表

在这里插入图片描述
  上面这张图显示了JAVA 8 HashMap的内部数组,其中包含两个树(在桶0处)和链接列表(在桶1,2和3处)。 Bucket 0是一棵树,因为它有超过8个节点。

Memory overhead 内存开销

JAVA 7

  使用HashMap需要以内存为代价。 在JAVA 7中,HashMap在条目中包装键值对。 一个条目有:

  • 对下一个条目的引用
  • 预先计算的哈希(整数)
  • 对key的引用
  • 对value的引用

  而且,JAVA 7 HashMap使用Entry的内部数组。 假设JAVA 7 HashMap包含N个元素且其内部数组具有容量CAPACITY,则额外的内存成本约为:
  sizeOf(整型)* N + sizeOf(引用)*(3 * N + C)
  这里:
  整型的大小为4个字节
  引用的大小取决于JVM / OS / Processor,但通常是4个字节。
  这意味着内存开销通常是16 * N + 4 * CAPACITY字节
  提醒:在Map自动扩容后,内部数组的CAPACITY等于N之后的下一个2的幂。
  注意:从JAVA 7开始,HashMap类有一个惰性初始化。 这意味着,即使你分配了HashMap,在第一次使用put()方法之前,内部数组条目(成本为4 * CAPACITY字节)也不会在内存中分配。

JAVA 8

  使用JAVA 8实现,获取内存使用变得有点复杂,因为Node可以包含与Entry相同的数据或相同的数据加上6个引用和布尔值(如果它是TreeNode)。
  如果所有节点都只是节点,则JAVA 8 HashMap的内存消耗与JAVA 7 HashMap相同。
  如果所有节点都是TreeNodes,则JAVA 8 HashMap的内存消耗将变为:
  N * sizeOf(整型)+ N * sizeOf(布尔值)+ sizeOf(引用)*(9 * N +容量)
  在大多数标准JVM中,它等于44 * N + 4 * CAPACITY字节

性能问题

倾斜的HashMap对比均衡的HashMap

  在最好的情况下,get()和put()方法的时间复杂度成本为O(1)。 但是,如果你不处理key的哈希函数,你最终可能会调用非常慢的put()和get()。 put()和get的良好性能取决于将数据重新分配到内部数组(桶)的不同索引中。如果你key的哈希函数设计不合理,你将有一个偏斜重新分区(无论内部数组的容量有多大)。 所有使用最大链接列表的put()和get()都会很慢,因为它们需要迭代整个列表。 在最坏的情况下(如果大多数数据都在相同的存储区中),最终可能会出现O(n)时间复杂度。
  下面是一个视觉示例。 第一张图片显示了倾斜的HashMap,第二张图片显示了平衡的HashMap。
在这里插入图片描述
  在这个倾斜的HashMap的情况下,对桶0的get()/ put()操作是昂贵的。 获得Entry K需要迭代6次
在这里插入图片描述
  在这个平衡良好的HashMap的情况下,获得Entry K将迭代3次。 两个HashMaps都存储相同数量的数据并具有相同的内部数组大小。 唯一的区别是分配桶中条目的哈希(key)函数。
  这是JAVA中的一个极端示例,我创建了一个哈希函数,将所有数据放在同一个桶中,然后我添加了200万个元素。

public class Test {
 
    public static void main(String[] args) {
 
        class MyKey {
            Integer i;
            public MyKey(Integer i){
                this.i =i;
            }
 
            @Override
            public int hashCode() {
                return 1;
            }
 
            @Override
            public boolean equals(Object obj) {
            …
            }
 
        }
        Date begin = new Date();
        Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
        for (int i=0;i<2_000_000;i++){
            myMap.put( new MyKey(i), "test "+i);
        }
 
        Date end = new Date();
        System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
    }
}

  在我核心为i5-2500k @ 3.6Ghz机器上(java 8u40)需要超过45分钟(我在45分钟后停止了这个过程)。
  现在,如果我运行相同的代码,但这次我使用以下哈希函数

    @Override
    public int hashCode() {
        int key = 2097152-1;
        return key+2097152*i;
}

  它需要46秒,这明显更好! 此哈希函数具有比前一个更好的重新分区,因此put()调用更快。
  如果我使用以下散列函数运行相同的代码,该函数提供更好的散列重新分区。

@Override
public int hashCode() {
return i;
}

  现在只需要2秒
  我希望你意识到哈希函数的重要性。 如果在JAVA 7上运行相同的测试,那么第一和第二种情况的结果会更糟(因为JAVA 7中的put的时间复杂度是O(n),JAVA 8中为 O(log(n))
  使用HashMap时,您需要为键找到一个哈希函数,将键扩展到尽可能多的桶中。 为此,你需要避免哈希冲突。 String对象是一个很好的key,因为它具有良好的散列函数。 整数也很好,因为它们的哈希码是它们自己的值。

Resizing overhead 扩容开销

  如果需要存储大量数据,则应创建初始容量接近预期容量的HashMap。
  如果不这样做,Map将采用默认大小16,factorLoad为0.75。 第11个新创建put()将非常快,但第12个(16 * 0.75)将重新创建一个新的内部数组(及其相关的链表/树)。新的容量为32.第13到第23会很快,但第24(32 * 0.75)将重新创建(再次)昂贵的新的内部数组,使内部数组的大小加倍。 自动扩容将出现在put()的第48,第96,第192,…调用中。 容量较低时,内部数组的完全重新创建是快速的,但是容量较大时它可能需要几秒到几分钟。 通过初始设置预期的大小,您可以避免这些昂贵的操作。
   但是有一个缺点:如果你设置一个非常高的数组大小,如2 ^ 28,而你的数组中只使用2 ^ 26个桶,你将浪费大量内存(在这种情况下约为2 ^ 30字节)。

结论

   对于简单的用例,您不需要知道HashMaps如何工作,因为您将看不到O(1)和O(n)或O(log(n))操作之间的区别。 但是,理解最常用数据结构之一的底层机制总是有好处的。 而且,对于java开发人员来说,这是一个典型的面试问题。
   在大容量时,了解它是如何工作以及理解key的散列函数的重要性变得很重要。
   我希望这篇文章能帮助您深入了解HashMap的实现。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值