HashMap的主干是Entry数组。Entry是HashMap的一个静态内部类,包含一个键值对,和一个指向下一个Entry的引用。
总结:HashMap是数组+链表的结构。数组是HashMap的主体,链表解决Hash冲突。
如果当前数组的位置不含链表,那么查找和添加等操作很快,仅需要一次寻址。若定位的位置包含链表,对于添加操作,遍历链表,存在即覆盖,否则新增;对于查找,仍需要遍历链表,然后通过key对象的equals方法逐一比较。所以HashMap中链表出现越少,性能越好。
它有几个重要的字段:size记录mapkey-value的数量,threadhold记录要扩容的阈值,loadFactor,代表负载因子,默认0.75,即到达容量的0.75时扩容。modcount记录被修改的次数(HashMap线程非安全的,在HashMap迭代时,如果其他线程参与导致结构变化,需要抛出异常)
transient int size;
int threadhold;
final float loadFactor;
transient int modCount;
HashMap默认初始容量16,负载因子0.75.
HashMap是put的时候才真正的构建table数组。
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
HashMap的hash函数
/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀*/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
找数组下标 h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为index=2。位运算对计算机来说,性能更高一些(HashMap中有大量位运算)。
1.8之前的扩容:当发生hash冲突并且size大于threadhold是就要发生数组扩容,扩容是,新建一个长度是两倍之前数组长度的数组,再将所有的元素传输过去,扩容后新数组长度变为两倍,扩容消耗资源。
1.8之后的hashmap 使用红黑树来进行性能优化。当链表超过8,转换为红黑树。转为红黑树节点后,链表的结构还存在,通过 next 属性维持,红黑树节点在进行操作时都会维护链表的结构,并不是转为红黑树节点,链表结构就不存在了。
JDK 1.8 对 HashMap 进行了比较大的优化,底层实现由之前的 “数组+链表” 改为 “数组+链表+红黑树”
1.8的hashmap和之前的hashmap求hash值的方法不一样。1.8的 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
进一步降低hash冲突。 低位结合了高位参与计算,目的是降低hash冲突的概率。
1.8的扩容:扩容后,节点重 hash 为什么只可能分布在 “原索引位置” 与 “原索引 + oldCap 位置” ?
由于是2的次幂的长度。(len - 1) & hash只有多出来的一位不同,若为0 原索引位置。若为1 原索引+ oldCap位置。
问:为何HashMap的数组长度一定是2的次幂?
1、因为2的次幂的二进制 len - 1的最高位为0,低位为1。在进行查找和插入时,通过(len - 1)& hash的值,直接对hash值阶段,保证不越界。
2、如果扩容后,len-1由原来的 0111111变为01111111.这样 求得到新的hash值只有一位和原来不一样。大大减少了之前散列情况好的数据重新调整位置。
3、减少hash冲突,(len- 1) & hash = 2 ; len - 1 : 01111;此时之后当hash == 00010的时候求值才为2. 如果不满足2的次幂就会有很多种低位的情况等于2. 减少可hash冲突。
问:重写equals方法需同时重写hashCode方法
如果不被重写(原生)的hashCode和equals是什么样的?
1 . 不被重写(原生)的hashCode值是根据内存地址换算出来的一个值。
2 . 不被重写(原生)的equals方法是严格判断一个对象是否相等的方法(object1 == object2)。
我们先来看一下Object.hashCode的通用约定(摘自《Effective Java》第45页)
在一个应用程序执行期间,如果一个对象的equals方法做比较所用到的信息没有被修改的话,那么,对该对象调用hashCode方法多次,它必须始终如一地返回 同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不同,即这个应用程序这次执行返回的整数与下一次执行返回的整数可以不一致。
如果两个对象根据equals(Object)方法是相等的,那么调用这两个对象中任一个对象的hashCode方法必须产生同样的整数结果。
如果两个对象根据equals(Object)方法是不相等的,那么调用这两个对象中任一个对象的hashCode方法,不要求必须产生不同的整数结果。然而,程序员应该意识到这样的事实,对于不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。
如果只重写了equals方法而没有重写hashCode方法的话,则会违反约定的第二条:相等的对象必须具有相等的散列码(hashCode)
object对象中的 public boolean equals(Object obj),对于任何非空引用值 x 和 y,当且仅当 x 和 y 引用同一个对象时,此方法才返回 true;
注意:当此方法被重写时,通常有必要重写 hashCode 方法,以维护 hashCode 方法的常规协定,该协定声明相等对象必须具有相等的哈希码。如下:
(1)当obj1.equals(obj2)为true时,obj1.hashCode() == obj2.hashCode()必须为true
(2)当obj1.hashCode() == obj2.hashCode()为false时,obj1.equals(obj2)必须为false
问:Hashmap与hashtable的区别?
1、hashmap线程不安全, hashtable线程安全
2、hashmap初始容量为16,hashtable为11
3、hashmap扩容 * 2; hashtable是 * 2 + 1;
4、hashmap的hash是重新计算过的,hashtable直接用的hashtable
5、hashmap可以放入key,value为null。但是hashtable不可以。
6、hashmap继承自abstractMap类。而hashtable继承自Dictionary类
7、hashmap去掉了hashtable中的contains方法
总结1.8的HashMap总结
- HashMap 的底层是个 Node 数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
- 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到 key 的 hashCode 值;2)将 hashCode 的高位参与运算,重新计算 hash 值;3)将计算出来的 hash 值与 “table.length - 1” 进行 & 运算。
- HashMap 的默认初始容量(capacity)是 16,capacity 必须为 2 的幂次方;默认负载因子(load factor)是 0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
- HashMap 在触发扩容后,阈值会变为原来的 2 倍,并且会对所有节点进行重 hash 分布,重 hash 分布后节点的新分布位置只可能有两个:“原索引位置” 或 “原索引+oldCap位置”。例如 capacity 为16,索引位置 5 的节点扩容后,只可能分布在新表 “索引位置5” 和 “索引位置21(5+16)”。
- 导致 HashMap 扩容后,同一个索引位置的节点重 hash 最多分布在两个位置的根本原因是:1)table的长度始终为 2 的 n 次方;2)索引位置的计算方法为 “(table.length - 1) & hash”。HashMap 扩容是一个比较耗时的操作,定义 HashMap 时尽量给个接近的初始容量值。
- HashMap 有 threshold 属性和 loadFactor 属性,但是没有 capacity 属性。初始化时,如果传了初始化容量值,该值是存在 threshold 变量,并且 Node 数组是在第一次 put 时才会进行初始化,初始化时会将此时的 threshold 值作为新表的 capacity 值,然后用 capacity 和 loadFactor 计算新表的真正 threshold 值。
- 当同一个索引位置的节点在增加后达到 9 个时,并且此时数组的长度大于等于 64,则会触发链表节点(Node)转红黑树节点(TreeNode),转成红黑树节点后,其实链表的结构还存在,通过 next 属性维持。链表节点转红黑树节点的具体方法为源码中的 treeifyBin 方法。而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。
- 当同一个索引位置的节点在移除后达到 6 个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的 untreeify 方法。
- HashMap 在 JDK 1.8 之后不再有死循环的问题,JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
- HashMap 是非线程安全的,在并发场景下使用 ConcurrentHashMap 来代替。