你们都知道,HashMap的是key-value(键值对)组成的,这个key既能够是基本数据类型对象,如Integer,Float,同时也能够是本身编写的对象,那么问题来了,这个做为key的对象是否可以改变呢?或者说key可否是一个可变的对象?若是能够该HashMap会怎么样?html
可变对象java
可变对象是指建立后自身状态能改变的对象。换句话说,可变对象是该对象在建立后它的哈希值(由类的hashCode()方法能够得出哈希值)可能被改变。面试
为了能直观的看出哈希值的改变,下面编写了一个类,同时重写了该类的hashCode()方法和它的equals()方法【至于为何要重写equals方法能够看博客:http://www.cnblogs.com/0201zcr/p/4769108.html】,在查找和添加(put方法)的时候都会用到equals方法。算法
在下面的代码中,对象MutableKey的键在建立时变量 i=10 j=20,哈希值是1291。shell
而后咱们改变实例的变量值,该对象的键 i 和 j 从10和20分别改变成30和40。如今Key的哈希值已经变成1931。数组
显然,这个对象的键在建立后发生了改变。因此类MutableKey是可变的。安全
让咱们看看下面的示例代码:数据结构
public class MutableKey { private int i; private int j; public MutableKey(int i, int j) { this.i = i; this.j = j; } public final int getI() { return i; } public final void setI(int i) { this.i = i; } public final int getJ() { return j; } public final void setJ(int j) { this.j = j; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + i; result = prime * result + j; return result; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (!(obj instanceof MutableKey)) { return false; } MutableKey other = (MutableKey) obj; if (i != other.i) { return false; } if (j != other.j) { return false; } return true; } }
测试:多线程
public class MutableDemo { public static void main(String[] args) { // Object created MutableKey key = new MutableKey(10, 20); System.out.println("Hash code: " + key.hashCode()); // Object State is changed after object creation. key.setI(30); key.setJ(40); System.out.println("Hash code: " + key.hashCode()); } }
结果:ide
Hash code: 1291
Hash code: 1931
只要MutableKey 对象的成员变量i或者j改变了,那么该对象的哈希值改变了,因此该对象是一个可变的对象。
HashMap如何存储键值对
HashMap底层是使用Entry对象数组存储的,而Entry是一个单项的链表。当调用一个put()方法将一个键值对添加进来是,先使用hash()函数获取该对象的hash值,而后调用indexFor方法查找到该对象在数组中应该存储的下标,假如该位置为空,就将value值插入,若是该下标出不为空,则要遍历该下标上面的对象,使用equals方法进行判断,若是遇到equals()方法返回真的则进行替换,不然将其插入,源码详解可看:http://www.cnblogs.com/0201zcr/p/4769108.html。
查找时只须要查询经过key值获取获取hash值,而后找到其下标,遍历该下标下面的Entry对象便可查找到value。【具体看下面源码及其解释】
若是HashMap Key的哈希值在存储键值对后发生改变,Map可能再也查找不到这个Entry了。
public V get(Object key) { // 若是 key 是 null,调用 getForNullKey 取出对应的 value if (key == null) return getForNullKey(); // 根据该 key 的 hashCode 值计算它的 hash 码 int hash = hash(key.hashCode()); // 直接取出 table 数组中指定索引处的值, for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; // 搜索该 Entry 链的下一个 Entr e = e.next) // ① { Object k; // 若是该 Entry 的 key 与被搜索 key 相同 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; }
上面是HashMap的get()方法源码,经过上面咱们能够知道,若是 HashMap 的每一个 bucket 里只有一个 Entry 时,HashMap 能够根据索引、快速地取出该 bucket 里的 Entry;在发生“Hash 冲突”的状况下,单个 bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每一个 Entry,直到找到想搜索的 Entry 为止——若是刚好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最先放入该 bucket 中),那系统必须循环到最后才能找到该元素。
同时咱们也看到,判断是否找到该对象,咱们还须要判断他的哈希值是否相同,假如哈希值不相同,根本就找不到咱们要找的值。
若是Key对象是可变的,那么Key的哈希值就可能改变。在HashMap中可变对象做为Key会形成数据丢失。
下面的例子将会向你展现HashMap中有可变对象做为Key带来的问题。
import java.util.HashMap; import java.util.Map; public class MutableDemo1 { public static void main(String[] args) { // HashMap Map<MutableKey, String> map = new HashMap<>(); // Object created MutableKey key = new MutableKey(10, 20); // Insert entry. map.put(key, "Robin"); // This line will print 'Robin' System.out.println(map.get(key)); // Object State is changed after object creation. // i.e. Object hash code will be changed. key.setI(30); // This line will print null as Map would be unable to retrieve the // entry. System.out.println(map.get(key)); } }
输出:
Robin null
如何解决
在HashMap中使用不可变对象。在HashMap中,使用String、Integer等不可变类型用做Key是很是明智的。
咱们也能定义属于本身的不可变类。
若是可变对象在HashMap中被用做键,那就要当心在改变对象状态的时候,不要改变它的哈希值了。咱们只须要保证成员变量的改变能保证该对象的哈希值不变便可。
在下面的Employee示例类中,哈希值是用实例变量id来计算的。一旦Employee的对象被建立,id的值就不能再改变。只有name能够改变,但name不能用来计算哈希值。因此,一旦Employee对象被建立,它的哈希值不会改变。因此Employee在HashMap中用做Key是安全的。
import java.util.HashMap; import java.util.Map; public class MutableSafeKeyDemo { public static void main(String[] args) { Employee emp = new Employee(2); emp.setName("Robin"); // Put object in HashMap. Map<Employee, String> map = new HashMap<>(); map.put(emp, "Showbasky"); System.out.println(map.get(emp)); // Change Employee name. Change in 'name' has no effect // on hash code. emp.setName("Lily"); System.out.println(map.get(emp)); } } class Employee { // It is specified while object creation. // Cannot be changed once object is created. No setter for this field. private int id; private String name; public Employee(final int id) { this.id = id; } public final String getName() { return name; } public final void setName(final String name) { this.name = name; } public int getId() { return id; } // Hash code depends only on 'id' which cannot be // changed once object is created. So hash code will not change // on object's state change @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + id; return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Employee other = (Employee) obj; if (id != other.id) return false; return true; } }
输出
Showbasky Showbasky
致谢:感谢您的耐心阅读!
本文翻译自 Coding Geek, 原文地址。英文水平有限,有些地方翻译得不太精确
绝大多数Java开发者都在使用Map类,尤为是HashMap。HashMap是一种简单易用且强大的存取数据的方法。可是,有多少人知道HashMap内部是如何工做的?几天前,为了对这个基本的数据结构有深刻的了解,我阅读大量的HashMap源码(开始是Java7,而后是Java8)。在这篇文章里,我会解释HashMap的实现,介绍Java8的新实现,聊一聊性能,内存,还有使用HashMap时已知的一些问题。
HashMap 类实现了Map<k,v>
接口,这个接口的基本主要方法有:
V put(K key, V value)
V get(Object key)
V remove(Object key)
Boolean containsKey(Object key)
HashMap使用了内部类Entry<k,v>
来存储数据,这个类是一个带有两个额外数据的简单 键-值对 结构:
Entry<k,v>
的引用,这样HashMap能够像单独的链表同样存储数据hash
值,表明了key的哈希值,避免了HashMap每次须要的时候再来计算下面是Java7里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数组里(Entry<K,V>[] array
),而且这个内部数组默认容量是16。
下面的图片展现了一个HashMap实例的内部存储,一个可为null的Entry数组,每个Entry均可以连接到另外一个Entry来造成一个链表:
全部具备相同哈希值的key都会放到同一个链表里,具备不一样哈希值的key最终也有可能在同一个链表里。
当调用 put(K key, V value)
或者get(Object key)
这些方法时,会先计算这个Entry应该存放的链表在内部数组中的索引(index),而后方法会迭代整个链表来寻找具备相同key的Entry(使用key的 equals()
方法)
get()
方法,会返回这个Entry关联的value值(若是Entry存在)put(K key, V value)
方法,若是Entry存在则重置value值,若是不存在,则以key,value参数构造一个Entry并插入到链表的头部。
获取链表在数组内的索引经过三个步骤肯定:
下面是Java7 和 Java8处理索引的源代码:
// 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(size-1),16的二进制表示是 0…010000
,那么对于任何哈希值H经过位运算H AND 16
获得的索引就只会是16或者0,这意味着17大小的链表数组只会使用到两个:索引为0的和索引为16的,很是浪费。
可是,若是你取2的指数大小例如16,位运算是 H AND 15
,15的二进制表示是 0…001111
, 那么取索引的运算就会输出0~15之间的值,大小16的数据就能彻底使用到。举例:
H = 952
二进制表示为 0..0111011 1000, 相关的索引就是 0…01000 = 8
H = 1576
二进制表示为 0..01100010 1000, 相关的索引就是 0…01000 = 8
H = 12356146
二进制表示为 010111100100010100011 0010, 相关的索引就是 0…00010 = 2
H = 59843
二进制表示为 0111010011100 0011, 相关的索引就是 0…00011 = 3
这就是为何数组的大小必须是2的指数大小,这个机制对开发人员是透明的,若是选择了一个37大小的HashMap,那么Map会自动选择37以后的一个2的指数大小(64)来作为内部数组的容量。
咱们获取到索引以后,函数(put,get或者remove) 访问/迭代 关联的链表,检查是否有指定key对应的Entry。 不作改动的话,这个机制会带来性能问题,由于这个函数会遍历整个链表来检查Entry是否存在。
想象一下若是内部数组大小是初始值16,咱们有两百万条数据须要存储,最好的状况下, 每一个链表里平均有 125 000个数据(2000000/16).所以,每一个get(),remove(),put()
会致使125 000个迭代或者操做。为了不出现这种状况,HashMap会自动调整它的内部数组大小来保持每一个链表尽量的短。
当你建立一个HashMap时,你能够指定一个初始化大小和一个载入因数:
public HashMap(int initialCapacity, float loadFactor)
若是不指定参数,缺省的initialCapacity
是16,loadFactor
是0.75,initialCapacity
即表明了Map内部数组的大小。
每次当你调用put()
方法加入一个新的Entry时,这个方法会检测是否须要增长内部数组大小,所以map存储了两个数据:
添加一个新Entry时,put函数会检查 map的大小 是否大于阈值 ,若是大于,则会建立一个双倍大小的数组,当新数组的大小改变,索引计算函数(返回 哈希值 & (数组大小-1) 的位运算)也会跟着改变。所以,数组的从新调整新建了两倍数量的链表,而且 从新分发现有的Entry到这些数组内(注:原文括号有下面一句补充,暂时不明白是什么意思。看HashMap的源代码,是全部的数据分发到新的数组内,旧的直接弃用)
(the old ones and the newly created).
自动调整的目的是减小链表的长度从而减少 put(),remove(),get()
等函数的时间开销,全部具备相同哈希值的Entry在从新调整大小后还会在同一个链表内,原来在同一个链表内具备不一样哈希值的Entry则有可能不在同一个链表内了。
上面这个图展现了一个HashMap自动调整先后的状况,在调整前,为了拿到Entry E,必需要迭代5次,调整后,只须要两次。速度快了两倍!
注意:HashMap只会增长内部数组的大小,没有提供方法变小。
若是你已经了解过HashMap,你知道它不是线程安全的,可是有没有想过为何?
想象一下这种场景:你有一个写线程只往Map里写新数据,还有一个读线程只往里读数据,为何不能很好的运做?
由于在从新调整内部数组大小的时候,若是线程正在写或者取对象,Map可能会使用调整前的索引,这样就找不到调整后的Entry所在的位置了。
最坏的状况是:两个线程同时往里面放数据,同时调用了调整内部数组大小的方法。当两个线程都在修改链表时,Map其中的某个链表可能会陷入一个内部循环,若是你试图在这个链表里取数据时,可能会永远取不到值。
HashTable 为了不这种状况,作了线程安全的实现。可是,全部的CRUD方法都是 同步阻塞的,因此会很慢。例如,线程1调用get(key1)
,线程2调用get(key2)
,线程3调用get(key3)
,同一时间只会有一个线程能拿到值,即便他们原本能够同时获取这三个值。
其实从Java5开始就有一个更高效的线程安全的HashMap的实现了:ConcurrentHashMap。只有链表是同步阻塞的,所以多线程能够同时get,put,或者remove数据,只要没有访问同一个链表或者从新调整内部数组大小就行。在多线程应用里,使用这种实现显然会更好。
为何字符串和整数是HashMap的Key的一种很好的实现呢? 大可能是由于他们的不变性。若是你选择本身新建一个Key类而且不保证它的不变性的话,在HashMap里面可能就会丢失数据,让咱们来看下面一种使用状况:
这里有一个具体的例子,我存了两个键值对到Map里,我修改了第一个key而且试图拿出这两个值,只有第二个值有返回,第一个值已经丢失在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找不回第一个字符串。
Java8里,HashMap的内部表示已经改变了不少了。的确,Java7里HashMap的实现有1K行代码,而Java8里有2K。我前面所说的大部分都是真的,除了Entry链表。在Java8里,仍然存在一个内部数组不过里面存储的都是节点(Node),可是节点包含的信息和Entry彻底同样,由于也能够看作链表,下面是Java8里节点实现的部分代码:
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)继承。树节点是一种红黑树的数据结构,存储了更多信息,可让你以O(log(n))
的算法复杂度新增,删除或者是获取一个元素。
下面是一个树节点内存储的数据的详细列表供参考:
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))
的时间复杂度,而链表会花费log(n)
。
如你所见,树比链表占用了更多的空间(咱们稍后会谈到这个)。
经过继承,内部表能够包含 节点(链表) 和 树节点(红黑树)两种节点。Oracle经过下面的规则,决定同时使用这两种数据结构:
上图展现了一个Java8 HashMap的内部数组的结构,具备树(桶0),和链表(桶1,2,3) ,桶0由于有超过8个节点因此结构是树。
使用HashMap会带来必定的内存开销,在Java7里,一个HashMap用Entry包含了 许多键值对,一个Entry里会有:
此外,Java7里 HashMap使用一个 Entry的内部数组。假设 一个HashMap包含了N个元素,内部数组容量是 C, 额外内存开销约为:
sizeOf(integer) * N + sizeOf(reference) * (3 * N +C)
小贴士:从JAVA7起,HashMap类初始化的方法是懒惰的,这意味着即便你分配了一个HashMap,内部Entry数组在内存里也不会分配到空间( 4 * 数组大小 个字节),直到你调用第一个put()方法
java8的实现里,获取内存用量变得稍微复杂了一点。由于 Entry 和 树节点包含的数据是同样的,可是树节点会多6个引用和1个布尔值。
若是所有都是 普通链表节点,那么内存用量和java7同样。
若是所有都是 树节点,内存用量变成:N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )
在大多数标准的 JVM里,这个式子等于 44 * N + 4 * CAPACITY
字节
最好的状况下,get/put方法只有 O(1)的时间复杂度。可是,若是你不关心key的哈希函数,调用put/get/方法可能会很是慢。
put/get的良好性能取决于如何分配数据到内部数组不一样的索引。若是key的哈希函数设计不良,你会获得一个倾斜的HashMap(和内部数组大小无关)。全部在最长链表上的put/get会很是慢,由于会遍历整个链表。最坏的状况下(全部数据都在同一个索引下), 时间复杂度是O(n).
下面是一个例子,第一个图片展现了一个倾斜HashMap,第二个图则是一个平衡的HashMap:
这个倾斜HashMap在索引0上的get/put很是耗时,获取Entry K会进行6次迭代
在这个平衡HashMap内,获取Entry K只要进行3次迭代。这两个HashMap存储的数据量相同,内部数组大小也同样。惟一的区别,就是分发数据的key的哈希函数。
下面是一个极端的例子,我建立了一个哈希函数,把两百万的数据都放到同一个数组索引下:
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())); } }
在个人机器上(core 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上运行,第一个和第二个测试的性能甚至还会更差(java7的复杂度是 O(n),java8是 O(log(n)))
当你使用HashMap时,你须要找到一个哈希函数,能够 把key分发到尽可能多的索引上,为了作到这一点,你须要避免哈希碰撞。字符串是不错的一种key,由于它有 很不错的哈希函数。整数作key也不错,由于它的哈希函数就是自己的值。
若是你须要存储大量数据,你应该在建立HashMap时设置一个接近你预期值的初始化大小。若是你不这么作,map会用默认的 16数组大小和0.75的 载入因数。 前面11个put会很快可是第12个(16*0.75)会建立一个容量为32的新数组,第13~23个put也会很快可是第24个会再次建立一个双倍大小的数组。这个内部重设大小的操做会出如今第48次,96次,192次……。在数据量较小时,这个操做很快,可是当数据量增大时,这个操做会费时数秒到数分钟不等。经过指定预期初始化大小,你能够避免这些操做开销。
可是这也有一个弊端,若是你设置了一个很大的数组大小像 2^28
而你只用了2^26
,你会浪费掉大量的内存(这个例子里大约是 2^30 字节)
对于简单的使用,你不须要知道HashMap是如何工做的,由于你感受不出 O(1)、O(n)、O(log(n))的区别。可是了解这种最经常使用的数据结果的底层机制老是有好处的,况且,对于java开发者来讲,这是一个很典型的面试问题。在大数据量时,知道它是若是工做的,知道哈希函数的重要性 就变得很是重要了。
但愿这篇文章能帮助你加深对HashMap实现细节的了解。