JAVA中的HashMap是如何工作的

译自how-does-a-hashmap-work-in-java
大部分JAVA开发人员使用Maps,尤其是HashMaps。哈希映射是一个简单但是强大的存储和获取数据的方式。但是有多少开发人员知道HashMap内部是如何工作的吗?几年以前,我读了大量的java.util.HashMap源代码 (先是JAVA7后是JAVA8),为了对这个基本的数据结构有深入的了解。在这篇帖子中,我会介绍java.util.HashMap的实现,呈现JAVA 8实现中新的东西,然后讨论性能,内存和一些使用HashMap的已知问题。

内部存储

JAVA的HashMap类实现了 M a p < K , V > 接口。这个接口的主要方法有:

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

HashMaps使用内部类来存储数据 E n t r y < K , V > 。这个Entry是一个简单的键值对,包含两个额外的数据:

  • 链接到另一个Entry的引用,因此HashMap可以像链表一样存储数据
  • 链的哈希值。这个哈希值被存储用来避免每次需要的时候每次都要计算它。

这是Entry在JAVA7的部分实现:

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

HashMap把数据存储在多个单个链表中。所有的链接被注册在Entry<K,V>[] array数组中,内部数据的默认大小是16。

图片描述了有空元素的HashMap实例的内部存储。每个Entry可以通过链表链接到另一个Entry。

所有哈希值相同的Entry都放到了同一个链表bucket中。不同哈希值的键也可能最终在同一个bucket。

当用户调用put(K key, V value) 或者get(Object key),哈希函数计算Entry所在桶的索引。 然后,函数迭代链表来查询使用键的equals()方法查询有相同key的Entry。

如果是get()方法,如果entry存在,函数会返回相关entry的值。

如果是put(K key, V value)方法,如果entry存在,函数会用新值替换它。否则它会创建一个新的entry(用参数中的键和值),在桶的头部位置。

桶的索引(链表)在map中通过3个步骤创建:

  • 它首先获取键的哈希码。
  • 为了避免根据键计算的坏的哈希函数(把所有数据放在内部数组的同一个索引),它会重新根据哈希码计算哈希值。
  • 它把重新哈希的哈希值,然后按用数组长度-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,mask的值将是16(size-1)。16的二进制表示是0…010000,因此任意生成的索引通过按位公式“H AND 16”将变成16或者0.这意味着长度17的数组仅会使用两个桶:索引0和索引16的桶,不是非常高效…

但是,如果你使用2的阶乘像16,按位索引公式是“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..0101111001000101000110010,相关的索引是0…00011 = 3

这就是为什么数组大小是2的阶乘的原因。这个机制对于开发者来说是透明的:如果他选择HashMap的大小是37,它会自动选择下一个2的阶乘37(64)来做为内部数组的大小。

自动改变大小

在获取索引之后,函数(get, put or remove)访问或者迭代相关的链表来查看给定的key是否有存在的Entry。如果不修改,这个机制可能会导致性能问题,因为函数需要迭代整个链表来查看entry是否存在。想象一下,内部数组的大小是默认的16,然后你需要存储2百万数据。最好的情况是,每个链表会有一个大小125 000的entries。因此,每次get(), remove() and put() 会导致125 000次迭代和操作。为了避免这种情况,HashMap有增加内部数组大小的能力为了保持很小的链表。

当你创建一个HashMap,你可以用下面的构造方法指定初始大小和扩容因子:

public HashMap(int initialCapacity, float loadFactor)

如果你不传参数,默认的初始大小是16,默认的扩容因子是0.75。初始大小代表了内部数组的大小。

每次你用put(…)在HashMap中增加一个新的键值对,函数会检查它是否需要增加内部数组的容量。为了这样做,HashMap存储了2个数据:

  • HashMap的大小size:它代表了HashMap中entries的数量。这个值会在每次Entry增加删除的时候更新。
  • 一个临界值threshold:它等价于(内部数组的容量)* 扩容因子,它会在每次改变大小之后更新。
    
    在增加新的Entry之前,put(…) 检查size是否大于threshold,来判断它是否需要重新创建一个两倍大小的数组。因为新数组的大小改变了,哈希函数(按位运算“hash(key) AND (sizeOfArray-1)”)也变了。因此,改变数组的大小创建了两倍多的桶(链表)并且所有已存在的entries**重新分配**进入到桶中(新的和旧的)。

调整大小操作的目的在于减少链表的大小,因此put(), remove()和get()等方法的时间复杂度会保持低的值。调整大小后所有键的哈希值相同的entries将会呆在相同的桶中。但是,之前在同一个bucket但哈希值不同的2个entries在转化之后可能会到不同的桶中。

resizing_of_java_hashmap

图片展示了在调整大小之前和之后的内部数组的表现。在增加之前,要获取Entry E,HashMap需要迭代链表的5个元素。在改变大小之后,相同的get()只需要迭代链表的2个元素,get()方法在调整大小之后快了两倍!

记住:HashMap只会增加内部数组的大小,它不会提供减小数组的方法。

线程安全
如果你早就了解HashMaps,你知道它不是线程安全的,但是为什么呢?例如假设你有一个写线程只是往Map中写数据,一个读线程只是从Map中读数据,为什么它不工作呢?

因为在自动调整大小机制中,如果线程尝试写或者获取一个对象,map会使用老的索引值,然后找不到entry所在的新桶。

最差的场景是当两个线程同时插入数据,而且两个put()同时调整Map大小。因为两个线程同时修改了链表,Map最终会在它的一个链表中内循环。如果你尝试获取内循环的内循环链表上的数据,get()永远也不会结束。

HashTable实现是线程安全的可以避免这种情况。但是会很慢,因为所有的CRUD方法的实现都是同步的。例如,如果线程1调用get(key1),线程2调用get(key2),然后线程3调用get(key3),同时只有一个线程可以获取数值,虽然它们三个都可以同时访问数据。

一个更明智的线程安全的HashMap实现存在于自从JAVA5:ConcurrentHashMap。只有桶被同步,因此多个线程可以同时get(), remove() or put()数据,如果它不访问相同的桶或者调整内部数组的大小。在多线程的环境下使用这种实现会更好

键不变性

为什么字符串和整数是HashMap键的好的实现?主要是因为它们是不可变的!如果你选择创建你自己的键类,不让它不可变,你可能会丢失数据。

让我们看下下面的用例:

  • 有一个键内部值是”1”。
  • 你用这个键把一个对象放到这个HashMap中。
  • HashMap根据这个键的哈希码生成了一个哈希值。
  • Map存储在新建的Entry中存储这个哈希值。
  • 你把键的内部值改成了”2”。
  • 键的哈希值变了但是HashMap不知道(因为存的是老的哈希值)。
  • 你用修改后的键来尝试获取对象
  • map计算键新的哈希值来查找entry在哪个链表中
    • 案例1:由于你修改了键,map尝试在错误的桶中查找entry,找不到
    • 案例2:幸运的,修改后的键和老的键生成了相同的桶。map然后迭代这个链表来找相同键的entry。为了找到键,map首先比较了哈希值,然后调用键的equals()方法。由于老的键和新的键没有相同的哈希值(存储在entry中),map找不到链表中的键。下面是一个具体的Java例子。我把2个键值对放到Map中,修改了第一个键然后尝试获取2个值。只有第二个值从map中返回了,第一个值丢失了:
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提高

JAVA8中的HashMap的内部表示方式改了。事实上,JAVA7的实现花费了1k行代码然而JAVA8的实现花了2k行代码。我之前说的基本上是对的,除了entries的链表。在JAVA8,Map还有数组,但是它现在存储Nodes包含和Entries相同的信息,因此也是链表:
这是部分JAVA 8Node的实现:

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

因此,它和JAVA7有什么大不同吗?Nodes可以被扩展成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个结点,树会转换成链表

图片展示了JAVA8 HashMap内部表既有树(桶0)和链表(桶1,2和3)。桶0是树因为它有超过8个元素。

内存开销

JAVA7

使用HashMap会带来内存开销。在JAVA7,HashMap在Entries中包装键值对。一个Entry有:

  • 指向下一个entry的索引
  • 一个计算好的哈希值(整型)
  • 键的引用
  • 值的引用

此外,JAVA7 HashMap使用Entry的内部数据。假设一个JAVA7 HashMap包含N个元素,然后内部数组容量是CAPACITY,额外的内存开销大约是:

sizeOf(integer)* N + sizeOf(reference)* (3*N+C)

假设:
* 整型的大小等于4个字节
* 引用的大小依赖于JVM/OS/Processor但是通常是4个字节。

这意味着开销大通常是(16*N+4*CAPACITY)个字节。

记住:通常Map会自动调整大小,内部数组的CAPACITY等于 2 ( n + 1 )

注意:自从JAVA7,HashMap类会懒初始化。这意味着尽管你分配了HashMap,内部entry数组(开销是4*CAPACITY字节)可能没有在内存中分配,直到你第一次使用put()方法。

JAVA8

在JAVA8的实现中,要获取内存使用量变得有点复杂了,因为一个Node可以包含和Entry相同的数据或者相同的数据*6个引用加一个布尔值(如果是TreeNode)。

如果所有的节点都只是Nodes,JAVA8 HashMap的内存消耗和Java7的一样。如果所有的节点都是TreeNodes,JAVA8的内存消耗就变成:

N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )

在大多数标准的JVM中,它等于44 * N + 4 * CAPACITY字节。

性能问题

歪的HashMap和很好平衡的HashMap

在大部分情况下,get()和put()方法有O(1)的时间复杂度。但是,如果你不使用key的哈希函数,你可能会导致非常慢的put()和get()调用。put()和get()的好性能依赖于重新分配数据到不同的索引。如果键的哈希函数设计的很差,你可能会有歪的重新分配。所有的put()和get()都会慢,因为它们需要迭代整个entries列表。最差的场景(如果大多数数据都在相同的桶),你可能最终会是O(n)/O(log(n))的时间复杂度。
下面是一张图。第一张图展示了歪曲的HashMap,第二张图是一个平衡的HashMap。
skewed_java_hashmap

在这种情况下,歪曲的HashMap在桶0上的get()/put()操作是昂贵的。获取Entry K会花费6次操作。
well_balanced_java_hashmapIn

均衡的HashMap的情况下,获取Entry K会花费3次迭代。两个HashMap都存储相同数据的数据,有相同大小的内部数组大小。唯一的不两只是键的哈希函数,在桶之间分配的entries。

这里有个极端的JAVA例子,我创建了一个哈希函数并把所有的数据都放到相同的桶下,然后我增加了2百万元素:

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,它花费了超过45分钟用java 8u40(45分钟的时候我停止了线程)。

现在,我跑相同的代码,但是这次使用下面的哈希函数。

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

它花费了46秒,好多了!这个哈希函数比之前的那个有更好的重新分配,因此put()调用会更快。

然后如果用下面的哈希函数来运行相同的代码,它会产生更好的哈希重新分配。

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

它花费了2秒。

我希望你意识到哈希函数有多重要。如果在JAVA7上跑相同的代码,案例1和案例2的结果可能会更差。因为JAVA7的时间复杂度是O(n),JAVA8的时间复杂度是O(log(n))。

当使用HashMap,你需要为你的键找一个哈希函数可以把键分散到最可能的桶。为了这么做,你需要避免哈希冲突。字符串对象是一个好的键因为它有好的哈希函数。整型也是好的因为它们的哈希值是它们自己。

调整大小的开销

如果你需要存储很多的数据,你应该创建你的HashMap用接近你期望量的初始容量。

如果你不这么做,Map的大小默认是16,扩展因子是0.75。执行11的put()方法会很快,但是执行第12个(16*0.75)的时候会创建一个新的内部数组容量是32(用它关联的链表和树)。第13个和23个会比较快但是第24个(32*0.75)会再次重新创建。内部的改变大小操作会在第48个,96个,192个调用put()的时候发生。在总量较小的时候,重建的速度还是比较快的,但是如果对于大容量的的情况下,它会花费几秒到几分钟。通过初始化预期的大小,你可以避免这些耗时的操作。

但是这里有个缺点:如果你设置了很大的数组大小像 2 2 8 ,但是你只在数组中只使用 2 2 6 个桶,你会浪费很多内存(大约 2 3 0 个字节)。

总结

对于简单的例子,你可以不需要知道HashMap是如何工作的,因为O(1)和O(n)或者O(log(n)) 没有区别。但最好还是了解这个最经常使用的数据结构背后的机制。此外,对于Java开发人员来说,这是个经典的面试题。

在大容量的情况下了解它是怎么工作和键的哈希函数的重要性就变得很重要了。

我希望这篇文章可以帮你对HashMap的实现有更深入的了解。

猜你喜欢

转载自blog.csdn.net/myxiaoribeng/article/details/80364710