在天地间有这样一只懒熊猫,做程序熊已经有五百年啦!在今天~ 突然开悟啦!想着以前使用HashMap时就直接new 一个对象,有时候别人还要跟我共用这个对象,有的人贪心不足蛇吞象还抢我对象!懒熊想不通呀?所有最近就一直思考这些问题?想这个对象我为什么要创建?而创建这个对象来自哪里?不然之后创建对象那也有这懒熊好受的?
为什么使用HashMap?
话说在HashMap未出来之前,天地间有三大巨兽分别是有着安全之王的Vector、查询之王 ArrayList、增删之王LinkedList。这三大巨兽平时没有什么对手,平时唯一的乐趣就是抢彼此的地盘,但是呀!因为彼此都有弱点,比如Vector就因为太安全而性能不太好;ArrayList查询快但是增删慢;LinkedList增删快但是查询慢。它们之间谁也奈何不了谁!于是有一天,创物主爸爸们看久了这三巨兽,现在看见他们打架就心里闹得慌!于是就想创造一个老大出来将它们都收服(拳头大就是硬道理),就想着综合一下他们的优点就创造出来了HashMap。
ArrayList(数组): 存储的物理结构是连续的,所以对内存分配要求较高。其结构决定了其访问效率非常高,时间复杂度为 O(1)。但影响了插入和删除的效率,时间复杂度为 O(n)。总结特点:寻址容易,插入和删除困难。
LinkedList(链表): 链表的存储是通过一条(或两条)引用链条串联起来的,所以数据可以离散存储,对存储空间要求比较低。这样的结构很方便进行数据的插入和删除,数据的变更最多只会影响其相邻的两个数据节点,其时间复杂度仅仅为 O(1)。但其结构同样造成了其访问效率比较低,时间复杂度为 O(n)。总结特点:寻址困难,插入和删除容易。
HashMap:
- 为了实现快速查找,HashMap 选择了Entry数组而不是链表。
- 为了快速索引查找,HashMap 引入Hash算法,将key映射成数组下标: key -> Index。
- 引入Hash算法会导致Hash 冲突。HashMap采用链地址法或开放地址法解决hash冲突。
- 链表存储过多的节点会导致了在链表上节点的查找性能的低。于是在jdk1.8中,HashMap 在链表长度超过 8 之后转而将链表转变成红黑树,将时间复杂度O(n)的查找效率提升至 O(log n)。
总结: HashMap 是一种快速的查找并且插入、删除性能都良好的一种 K/V键值对的数据结构。
HashMap是什么?
基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。 此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。
(1)基于哈希表的 Map 接口的实现 ==> 表示实现此map接口的类底层是使用散列表!
(2)此实现提供所有可选的映射操作 ==> 表示key/value具有映射关系
(3)此类不保证映射的顺序,特别是它不保证该顺序恒久不变 ==>因为 HashMap 的数据都是通过哈希函数换算分桶的,因此当数容积扩充后,其拷贝过程需要经历重新哈希和分桶的过程,所以不能保证插入顺序不变的
(4)如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低 ==> 这是对于HashMap性能的优化提升,这是比较重要的部分,将在下面分一个大类讲解。
HashMap性能分析
分析哈希表查询和修改效率的时候,主要是两个方面:一个是分桶方式要合理,而是数据量不能太大。
HashMap 是由数组+链表实现的,所以其扩充实际上是和数组一样成本巨大的,需要经历新建和拷贝的过程。而且HashMap 的数据都是通过哈希函数换算分桶的,因此当数容积扩充后,其拷贝过程需要经历重新哈希和分桶的过程,所以说 HashMap 不能保证插入顺序不变的问题,因为重新哈希的数据分桶时并不一定都能放到同一个桶里,因此其顺序自然就不能保证。
既然重建数组开销如此巨大,那么最理想的是我们能够预先知道 HashMap 存储的数据量,在实例化的时候,就通过初始容量参数 initialCapacity 设定其数组初始大小,在不设置初始容量的时候,HashMap 默认大小为 16。
现在大家可能会有一个疑问,既然 HashMap 是通过数组+链表来存储数据的,那么怎么会有数据满了需要扩充的情况呢?这就涉及到与 HashMap 性能息息相关的另一个参数——装载因子(loadFactor)。装载因子越接近1,链表会越长,查询效率可能越低;装载因子越接近0,扩容会快的发生,会消耗空间。
我们假设一种极端情况,如果数组的大小为 1,那么 HashMap 就退化成了一个链表,其查询时间复杂度也退化到了与链表相同的 O(n)。这种情况其实放大了哈希冲突对性能造成的破坏,如果哈希冲突严重,众多数据被分配到了同一只桶里,那么这个桶的查询效率就会退化,更接近于链表的查询效率。为了防止这种情况出现,HashMap 做了两方面的事情:
(1) 其哈希函数需要保证生成的值要尽可能随机且平均分布,这样是为了最小化避免冲突
(2) 构造函数提供了装载因子参数 loadFactor,默认为 0.75,意思为当数组容量为 n,已存储数据为 m,如果 n/m > 0.75,就会认定当前 HashMap 已满,需要进行重新扩容。
所以,在使用 HashMap 的时候建议:
- 在知道数据量的情况下,尽量指定 HashMap 的大小,避免其频繁扩容(使用 ArrayList 等基于数组的数据容器也是同理);
- 综合存储空间和查询、插入效率,调整装载因子,以达到更有利当前业务场景的性能。
顺着这个思路,如果数据量过于庞大,其实无论是对于扩容还是查询优化都难以做到更好的优化。因此在 JDK1.8 中,当链表元素数目到8个,同时HashMap的数组长度要大于64,链表才会转红黑树,否则都是做扩容。因为引入了红黑树,所以其基本操作就比较复杂了,比如 put 函数以前只需要找到对应的桶,并将查找当前 Key 是否已经存在,如果存在就替换,如果不存在就直接加。但到了 JDK1.8 以后,就需要判断当前桶中是链表还是树,如果是链表还需要判断插入数据之后需不需要转换为树。不过这些操作的时间复杂度都是常量级别的,所以插入时间复杂度还是 O(1)。
HashMap扩容机制?
在不同的JDK版本中,HashMap的扩容大小为原数组长度的两倍,但是不同JDK版本中满足扩容条件不同。接下来主要分JDK1.7和JDK1.8详细介绍:
JDK7版本及以前使用是:头插法
注:使用头插法在多线程扩容的时候可能会导致循环指向,从而在获取数据get()的时候陷入死循环,到是线程执行无法结束。这个Bug最开始由阿里的员工指出然后提交给官方,但是官方认为这是阿里员工的问题,不承认是一个Bug!而在JDK1.8时,官方将头插法改为尾插法(解决JDK1.7中死循环问题),HashMap结构改为数组+链表+红黑树
JDK1.7中扩容条件: Hashmap的扩容必须同时满足两个条件:
(1)当前数据存储的数量(即size())大小必须大于等于阈值(阈值 = 加载因子 * 容量);
(2)当前加入的数据是否发生了hash冲突。
JDK1.8中扩容条件: JDK1.8中扩容两个条件只需要满足一个条件:
(1)当前存入数据后,存储的数量大于阈值即发生扩容
(2)存入数据到某一条链表时,此时该链表数据个数大于8,且数组长度小于64即发生扩容
注: java7是在存入数据前进行判断是否扩容,而java8是在存入数据后再进行扩容的判断。
在JDK1.8中HashMap调用put方法后执行流程?
下面是put后的执行流程图:
1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点
8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表该索引位置不为空,则进行查找
Node<K,V> e; K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 用于LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 用于LinkedHashMap
return null;
}