HashMap详解(基于JDK 1.8)
Posted truestoriesavici01
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap详解(基于JDK 1.8)相关的知识,希望对你有一定的参考价值。
HashMap详解(基于JDK 1.8)
目录
简介
Map
接口定义了映射关系,有四个常用实现类:HashMap
Hashtable
LinkedHashMap
TreeMap
HashMap
:- 根据键
key
的hashCode值存储数据. - 访问速度快,遍历速度较慢.
- 最多允许一条记录的键为null.
- 允许多条记录的值为null.
- 非线程安全.若要线程安全,可使用
synchronizedMap
或ConcurrentHashMap
.
- 根据键
Hashtable
:- 继承于
Dictionary
类. - 是线程安全的.
- 不推荐使用,需要线程安全>
ConcurrentHashMap
,不需要线程安全>HashMap
.
- 继承于
LinkedHashMap
:HashMap
的子类.- 保存了记录的插入顺序.
- 遍历顺序准从插入顺序.
TreeMap
:- 实现
SortedMap
接口. - 默认按照键值的升序排序.
- 遍历后的结果是排序后的结果.
- 使用时key必须实现Comparable接口.
- 实现
- 所有Map类型的类都要求key为不可变对象,保证创建后哈希值不会发生变化.
内部实现
存储结构
- 每个键值对是由自定义的内部类
Node<K,V>
保存,一个键值对对应一个结点. - 而这些键值对是使用数组+链表+红黑树的结构组织起来的.
结点Node<K,V>
的属性:
final int hash; // 对应的哈希值
final K key; // 键
V value; // 值
Node<K,V> next; // 指向下一个结点的指针
存储方式
- 使用一个数组
Node[] table
(哈希桶数组)来存放这些键值对. - 使用哈希表将这些键值对放在数组的对应位置.
- 为了解决冲突,采用链地址法(数组+链表):每个数组元素都是链表结构,键值对放在链表上.
- Hash碰撞概率小且哈希桶数组占用空间小==>好的Hash算法+扩容机制.
HashMap的一些字段
-
int threshold
-
final float loadFactor
-
int modCount
-
int size
-
哈希桶数组的初始化长度为16.
-
负载因子
loadFactor
的默认值为0.75. -
哈希桶数组所能容纳的最大键值对个数为
threshold
,threshold=哈希桶数组长度*负载因子
. -
当存储的键值对个数超过
threshold
,则需要扩容(扩大为原来的2倍). -
对时间效率要求高==>降低负载因子.
-
内存空间紧张==>增加负载因子(可大于1).
-
size
:HashMap中实际存在的键值对个数(哈希桶数组及其对应的链表). -
modCount
:记录内部结构发生变化的次数(增加,删除),修改某个key对应的value不属于结构变化. -
哈希桶数组的长度为2的幂次,目的:为了取模和扩容时优化.减少冲突==>定位时加入高位参与运算.
-
当链表长度太长(超过8),链表转换为红黑树.
具体方法(功能实现)
- 获取哈希桶数组索引位置
put
方法细节- 扩容过程
确定哈希桶数组索引位置
- 通过hash算法获得键值对对应的位置.
- hash算法:
- 取key的hashCode值.(
h=key.hashCode()
) - 高位运算.(
h^(h>>>16)
) - 取模运算.(JDK7)(
h&(length-1)
,等价于h%length
,但&运算效率高,length为哈希桶数组的长度)
- 取key的hashCode值.(
- 若key为null,则对应的hash值为0.
- JDK8是通过hashCode()的高16位和低16位异或实现.==>对于哈希桶数组大小较小时也能保证高低位都参与hash运算.
put方法流程
- 判断哈希桶数组
table
是否为空或null,若是,则resize()
进行扩容。 - 根据键key计算hash值,得到待插入的数组索引i。若为空,则直接插入。并跳转至步骤6;若非空,则向下执行。
- 判断哈希桶数组中
table[i]
的首个元素的key是否与待插入的key一致,若相同,则直接覆盖,否则向下执行。 - 判断
table[i]
是否为treeNode(即判断待插入的位置是使用红黑树还是链表保存键值对),若是红黑树,则在树中插入键值对。 - 若不是红黑树,则判断链表长度是否大于8,若大于,则将链表转为红黑树,在树中插入键值对。否则在链表中插入键值对。(遍历链表时,若发现key重复,则直接覆盖,从而完成插入)
- 插入成功后,判断实际存在的键值对数量
size
是否超过最大容量threshold
,若超过,则进行扩容。
注:
- JDK 1.7的插入是头插入,即:同一位置上的新键值对总会被放在链表的头部位置。
- JDK 1.8的插入是尾插入,则遍历链表,直到最后一个元素,将新的键值对放在链表的尾部。
扩容机制
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取旧桶的大小
int oldThr = threshold; // 获得旧的threshold
int newCap, newThr = 0;
if (oldCap > 0) { // 若旧桶的大小大于0
if (oldCap >= MAXIMUM_CAPACITY) { // 若旧的threshold大于2^29,则直接设为最大值,并返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) // 否则,若旧的threshold大于16,而加倍后仍然小于2^29
newThr = oldThr << 1; // double threshold // 则加倍
}
else if (oldThr > 0) // 若旧的threshold大于0,则threshold不进行改变
newCap = oldThr;
else { // 以上条件都不满足,则桶的大小设为16,threshold设为12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { // 计算新的桶大小对应的新的threshold
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 设置threshold,使之生效
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 创建新桶
if (oldTab != null) { // 若旧的桶非空,则需要将其中的键值对迁移到新桶中
for (int j = 0; j < oldCap; ++j) { // 对于哈希桶数组中的每个元素进行检查
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 若数组当前位置有键值对,则需要处理,否则直接到一个数组元素
oldTab[j] = null; //旧桶设为null,方便GC
if (e.next == null) // 若当前位置上只有一个元素,则直接将其迁移到新数组的新位置(经过hash计算)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 若当前位置是以红黑树方式存储键值对,则对红黑树分裂
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 若是链表,则将旧的链表分裂成两个新的链表
Node<K,V> loHead = null, loTail = null; // 此链表的键值存放在原来的位置
Node<K,V> hiHead = null, hiTail = null; // 此链表的键值对放在新位置(旧的位置+旧的桶大小)
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 存放在旧位置上的键值对
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 存放在新位置上的键值对
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { // 分裂的链表1放在旧的位置上
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { // 分裂的链表2放在新的位置上
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
JDK 1.8在扩容机制上的关键点:
- 扩容后是原来的两倍,所以一个键值对要么在原来位置,要么在(旧位置+旧的桶的容量).
- 所以在扩容时对元素进行迁移时,无需重新计算hash值,而是判断新容量非零的最高位上,键值对的hash值是0还是1:为0,则在原位置;为1,则在新位置。
- 减少了计算hash值的时间,并且可以认为该位上0,1分布是随机的,可以减少冲突。
- JDK 1.7在元素迁移时,迁移到新位置的元素与其在旧位置上的顺序相反(头插法导致原来的在前的元素先插入到新位置,位置在后的元素后插入,后插入的元素在链表前)
遍历方式
两种遍历HashMap的方式:
// 第一种方式:将key 和 value 同时取出
Iterator<Map.Entry<String,Integer>> entryIterator = map.entrySet().iterator();
while(entryIterator.hasNext()){
Map.Entry<String,Integer> next = entryIterator.next();
}
// 第二种方式:将key取出,若要value,还需通过key遍历一遍
Iterator<String> iterator = map.keySet().iterator();
while(iterator.hasNext()){
String key = iterator.next();
}
JDK1.8中的HashMap
- 使用数组+链表的方式.
- 默认大小16,负载因子0.75.
- 当hash冲突严重时,桶上的链表越来越长,查询效率越来越低,时间复杂度为O(N).
- 扩容方法
resize()
在并发执行时容易在桶上形成环形链表.
线程安全性
- HashMap不是线程安全的,多线程应用避免使用。
- 线程安全可使用ConcurrentHashMap。
- JDK 1.7中
resize()
可能导致环形链表从而产生死锁(执行resize()
方法的线程无法退出) - JDK 1.8进行了改进,是设置两个链表(局部变量),将旧链表上的键值对分别复制到两个新链表上,再将两个新链表迁移到新桶上。
- JDK 1.8不会因为扩容而导致死锁,但是存在其他原因导致的死锁
小结
- 扩容是一个特别耗性能的操作,所以当使用HashMap,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
- 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
- HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
- JDK1.8引入红黑树大程度优化了HashMap的性能。
参考:
以上是关于HashMap详解(基于JDK 1.8)的主要内容,如果未能解决你的问题,请参考以下文章
Java开发大牛用代码演示基于JDK1.6版本下的HashMap详解