目录
HashMap 底层数据结构
HashMap 的底层数据结构是数组+链表的结合体,也叫哈希表结构,它们的优缺点如下:
1、数组结构: 存储空间连续、空间复杂度高
- 优点: 由于数组是连续空间存储的,所以能够随机访问,并且读取查询速度快;
- 确定: 插入和删除数据效率低,因为插入数据时要对数据进行移动,且大小不固定不易动态扩展。
2、链表结构: 存储空间分散、空间复杂度小
- 优点: 插入和删除速度快,内存利用率高,没有固定大小,扩展灵活;
- 缺点: 不能随机查找,查询效率低(每次都是从第一个开始遍历);
3、哈希表结构:
- 数组+链表的结构结合了数组和链表的优点,不仅查询和修改效率高,插入和删除的效率也高(原因在于查询是在数组中完成的,二增删是在链表上完成的)。
HashMap 的数组元素也被称之为桶(bucket),每个桶都允许为空(null),桶下面就挂载着存储节点数据的链表,这个节点数据结构在 Java7 叫 Entry 在 Java8 中叫 Node,节点数据则以 key-value
键值对的形式存在。
4、红黑树:
在 Java8 中引入了红黑树的设计,当链表上的节点数超过 TREEIFY_THRESHOLD = 8,并且桶的数量不小于 64 时,链表会转为红黑树。引入红黑树的目的是避免在最极端的情况下链表变得很长很长,在查询的时候,效率会非常慢。
- 链表查询时需要遍历全部元素才行,时间复杂度为 O(n);
- 红黑树查询时的访问性能近似于折半查找,时间复杂度为 O( log(n) )
下面是一些红黑树的特点:
- 1、每个节点要么是红色,要么是黑色,但根节点永远是黑色的;
- 2、每个红色节点的两个子节点一定都是黑色;
- 3、红色节点不能连续(红色节点的孩子和父亲都不能是红色);
- 4、从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
- 5、所有最末尾的叶节点都是是黑色的);
HashMap 的整体数据结构图如下:
HashMap 数据插入和读取原理
1、数据插入原理
在 Java8 之前(比如 Java7)插入节点数据时使用的是头插法,也就是新来的值取代桶的位置而作为新的链表头,原有的值就顺推到链表中去。但在 Java8 之后,使用的是尾插法,我们以 Java8 为例进行讲解,下面是 Node 节点的源码:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
......
}
从源码可以看出,节点对象只包含一个指向下一个节点 “指针
”,所以形成的是一个单项链表,具体数据插入原理流程如下:
- 首先,HashMap 会将 (Key,Value) 值封装到 Node 节点对象中;
- 然后调用 Key 对象的 hashCode() 方法得到 hashCode 值;
- 接着,通过哈希算法(散列算法)对 hashCode 值进行转换计算得到哈希值(散列值),然后对哈希值进行基于数组长度的索引计算
(n - 1) & hash
(n 为数组长度),得到数组元素的下标,即桶的位置,而计算哈希值的过程我们称之为哈希; - 得到桶的位置之后,如果该位置元素为空的话就将节点数据添加到这个位置上,如果目标位置已有链表数据的话,此时,就会将 Key 和链表上每个节点的 Key 进行 equals() 查找目标 Key 值是否存在,如果不存在则将节点数据挂载到链表尾部,如果存在则覆盖节点的 Value 值。
重点注意:
- 在插入数据前,HashMap 会先判断包含元素的桶的数量是否达到临界值(Threshold),如果数量达到了临界值则会先进行扩容(Resize);
- 在插入节点数据到链表尾部时,HashMap 会先判断链表长度是否大于 8,如果链表长度小于 8 则直接插入数据;如果链表长度大于 8 的话,会有两种情况:
- 一种是当前桶的数量小于 64,则对数组进行扩容,并对所有包含元素的桶重新计算哈希值,这个过程称之为重哈希(rehash);
- 另一种则是当前桶数量不小于 64,此时则是将当前节点链表转换成红黑树,并将节点数据插入到数当中。
- 由于 equals 方法默认比较的是两个对象的内存地址,所以存入 HashMap 的 Key 对象部分要重写 equals() 方法。
知识点: 当通过计算得到桶的位置并且目标位置已有元素时,我们称之为哈希冲突,链表的存在就是为了解决这种情况的。
附上 Java8 的哈希算法源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
其实现原理是通过 高16位异或低16位 实现的。主要是从速度、功效和质量来考虑的,减少系统的开销,保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变,尽可能的减少哈希冲突。
2、数据读取原理
- 首先调用 Key 对象的 hashCode() 方法得到 hashCode 值;
- 然后通过哈希算法计算出哈希值,再通过下标算法
(n - 1) & hash
得到数组下标,即桶的位置; - 得到桶的位置之后,判断位置上是否为空,如果为空则返回 null,如果不为空遍历节点链表或树,对每个节点的 Key 进行 equals 比较查找,如果查找不到则返回 null,否则返回节点对应 Value 值。
HashMap 扩容机制
从上面可以知道,HashMap 的数组容量是有限的,当到达一定的数量(临界值)时就会进行扩容,也就是 resize(),而影响扩容的因素有两个:
Capacity
:HashMap 当前容量大小;LoadFactor
:负载因子,默认值为 0.75;
而这个临界值大小的计算方法为:capacity * loadFactor
。
打个比方,如果当前的容量大小为 100,当存进元素数量超过 100 * 0.75 = 75 时,HashMap 就会判断需要进行扩容了,其扩容分为两个步骤:
- 扩容: 创建一个新的 bucket 空数组,长度是原数组的 2 倍,即扩容 2 倍(在 Java8 中);
- 重哈希: 遍历旧的节点数据,把所有节点数据重新 hash 到新数组中。
为什们扩容后要重新 hash 呢?这是因为数组长度扩大以后,Hash 的规则也随之改变:
// bucket索引公式
index = hash & (length - 1);
我们从 hash 公式就可以看出,当数组长度 length 发生变化时,计算得到的桶的下标也发生了变化。
HashMap 初始化容量
1、默认初始值
当我们创建 HashMap 对象时,如果不指定初始值的话,比如:
Map<String, Object> map = new HashMap<>();
那么,HashMap 会自动设置一个默认的初始容量,具体值是多少我们可以从 HashMap 的源码中查看:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
从源码中我们可以看到初始化大小是 16。
2、HashMap 要不要设置初始值
我们从 HashMap 的扩容机制可以知道,当达到扩容条件:HashMap 元素个数超过临界值(threshold,threshold = loadFactor * capacity
)时就会自动扩容。
所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap 会发生多次扩容,而 HashMap 中的扩容机制决定了每次扩容都需要重建 hash 表,是非常影响性能的。
因此,建议在创建 HashMap 对象时指定初始化容量。
3、HashMap 初始值设置多少合适
既然要指定初始值大小,那到底指定多少合适呢?有人可能会想到,我准备放多少个元素就设置成多少,比如准备放7个元素,那就 new HashMap(7)
。
这种方式是不正确的,而且以上方式创建出来的 Map 容量也不是 7。
因为,当我们使用 HashMap(int initialCapacity) 来初始化容量的时候,HashMap 并不会使用我们传进来的 initialCapacity 直接作为初始容量,而是:JDK 会默认帮我们计算一个相对合理的值,这个合理的值就是找到一个比用户传入的值大的 2 的幂值。
也就是说,当我们 new HashMap(7) 创建 HashMap 的时候,JDK 会通过计算,帮我们创建一个容量为 8 的 Map;当我们 new HashMap(9) 创建 HashMap 的时候,JDK 会通过计算,帮我们创建一个容量为 16 的 Map。
但这个相对合理的值并不是非常合理,因为它只是简单机械的计算出一个大于这个数的 2 的幂,并没有考虑到 loadFactor 这个负载因子。
也就是说,如果我们设置的默认值是 7,经过 JDK 处理之后,HashMap 的容量会被设置成 8,但是,这个 HashMap 在元素个数达到临界值 8 * 0.75 = 6 的时候就会进行一次扩容,这明显不是我们想要的结果。
所以结合 HashMap 的扩容机制和 JDK 的默认计算值,我们可以得到初始值的计算方法:
// expectedSize 就是期望存储元素的个数
int size = (int) ((float) expectedSize / 0.75F + 1.0F);
比如,我们计划向 HashMap 中放入 7 个元素的时候,我们通过 expectedSize / 0.75F + 1.0F 计算,7/0.75 + 1 = 10 ,10 经过 JDK 处理之后,会被设置成 16,这就大大的减少了扩容的几率。
这个算法在 guava
工具类中也有实现,开发的时候,可以直接通过 Maps 类创建一个HashMap:
Map<String, String> map = Maps.newHashMapWithExpectedSize(7);
其底层源码实现如下:
public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
return new HashMap(capacity(expectedSize));
}
static int capacity(int expectedSize) {
if (expectedSize < 3) {
CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");
return expectedSize + 1;
} else {
return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : 2147483647;
}
}
以上的操作是一种用内存换性能的做法,真正使用的时候,要考虑到内存的影响。但是,大多数情况下,我们还是认为内存是一种比较富裕的资源。
HashMap 相关疑问
1、为什们 Java8 之后改成尾插法
在 Java8 之前,由于 resize 的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上,一旦几个线程都调整完成,就可能出现环形链表,如果这个时候去取值,就会出现无限循环(Infinite Loop)。
但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,而且因为Java8之后链表有红黑树的部分,并且源代码已经多了很多 if else
的逻辑判断了,红黑树的引入巧妙的将原本 O(n) 的时间复杂度降低到了 O(logn)。
2、HashMap 是线程安全的吗
不是,虽然 Java8 将头插法改成了尾插法后避免了在扩容时的死循环问题,但 HashMap 仍然不是线程安全的。并发环境下想要线程安全的话应该使用 concurrentHashMap
。