前言
HashMap 在我们的实际开发中是非常频繁使用到的一个类,也非常的清楚它保存的是一组键值对。不过,HashMap 在面试当中也是非常高频面试的一个点。经过我的了解及研究,我也开始写一遍日志,来展现我对 HashMap 的一些看法。
在了解 HashMap 之前,来聊聊哈希表是什么吧。
了解哈希表
哈希表,再也非常熟悉不过的一种数据结构了。
它是根据关键码值来直接进行访问的数据结构,也就是说,它通过把关键码值映射到表中一个位置,来访问当前位置的内容,以加快查找的速度。这个映射函数叫做散列函数,存放记录的容器叫做散列表(哈希表)。
码值:可以理解为键的 HashCode 经过哈希算法计算所得元素的位置
一般来说,存放记录的容器是数组。因为数组查询的效率很高,时间复杂度为 O(1) ,随机性也是非常强的。当然,用链表也是可以的,只不过查询的效率低,时间复杂度为 O(n)。
我并不认同,哈希表就是“数组 + 链表”的结合体,因为我觉得使用链表只是解决哈希冲突的一种解决办法而已。这个问题到后面会提及到。
哈希算法
哈希算法并没有固定的一种算法,哈希算法它是一种思想,目的就是算出元素所在的位置。同时,更好的算法让元素在散列的时候尽量变得均匀起来。
在 Java 中的 HashMap ,它是通过 ( 哈希表的长度 - 1 ) & 哈希值来计算的,也就是通过与运算来算出元素要放的位置。
哈希冲突
产生:通过哈希算法得到的下标,被另一个元素所占用,就是哈希冲突了。
解决办法:开放寻址法、再散列法、链地址法、建立一个公共溢出区。
基本原理
在 JDK 1.7 中,它的 HashMap 是通过“数组 + 链表”来实现的。但是到了 1.8 之后,它的实现方式是通过“数组 + 链表 + 红黑树”实现的。
扩容
这是 HashMap 的哈希表,通过 HashMap 的中的与运算来得到要放置的位置。但是给哈希表分配的长度始终是 2 的 n 次幂来分配空间。
并且它是通过数组的形式来保存的。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
但是,我们在创建 HashMap 的时候并没有指定哈希表的长度,原因是这里面默认创建了一个长度为 16 的哈希表。这个再 HashMap 的常量中、无参的构造方法中都有非常明确的描述。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
然而,无参构造方法中并没有直接初始化这个值。
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
在 HashMap 集合中添加一条数据的时候,会调用 resize 方法,才给它进行扩容。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//此处省略......
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//此处省略......
return newTab;
}
此时新的容量就是 DEFAULT_INITIAL_CAPACITY
,也就是 16,同时也计算出阈值,阈值是什么?阈值就是临界值,计算方法是:阈值 = 容量 * 负载因子
。就比如,当哈希表的容量为 16 时,阈值为 12 ,当哈希表中元素的个数扩容,它将会将原来的容量扩大到两倍。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
默认的负载因子为 0.75。
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
为什么会取 0.75,因为此时的空间利用率比较高,而且避免了相当多的哈希冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
添加键值对
在向 HashMap 中添加第一组键值对,它是如何添加的呢?
1、从调用 HashMap 的 put 方法开始,它实际上调用的是 putVal 方法,putVal 共有 5 个参数,分别是,获取 key 的哈希值、key、value、这个键是否存在和是否插入到后结点。
最后一个参数是给 LinkedHashMap(基于链表实现的 HashMap)所使用
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
2、初始化容量,一开始会判断 Node 数组是否为 null。开始肯定是 null,所以肯定会调用 resize 方法初始化容量,且值为 16。
初始化容量完成之后,然后再去利用与运算得到数组的下标,调用 newNode方法 创建出来的 Node 对象需要放入到数组的哪个位置。而这个位置则通过哈希算法来得到的,在上面也提及到计算方法。 i = (n - 1) & hash
。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断表是否为 null 或者表的长度是否为 0
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 此处省略很多代码......
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
3、第一个元素成功的添加进来了,添加完成之后给 HashMap 中键值对的个数 +1 ,如果 HashMap 中键值对的个数超过了阈值,那么将会触发扩容,扩容到原来的两倍。
不可避免的冲突
HashMap 按与运算求得的下标位置是会造成哈希冲突的,它的解决办法就是通过链地址法(拉链法)的方式来解决。所以这也是为什么 HashMap 底层数据结构包括数组和链表。
所以 HashMap 可以想象成是古代皇帝所戴的帽子(冕旒)。数组就是冕,链表就是帘子。
假如哈希表中的第 3 个元素已经存在了一个 A 元素,然而另外一个元素 B 得到的下标也是 3 ,此时通过尾插法的形式,将 B 元素插入到 A 的后面。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断表是否为 null 或者表的长度是否为 0,代码略
// 判断当前下标的元素是否存在,不存在的代码已经省略
else {
Node<K,V> e; K k;
// 这段代码拿到的当前的节点以及当前节点的 key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断节点的类型是否为树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//////// 这是尾插法的关键代码部分 /////////
for (int binCount = 0; ; ++binCount) {
// 如果当前元素的下一个节点是空,则插入一个新的节点。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果数量超过了8,则将链表进行树化,即转成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
// 此处省略很多代码......
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
变成红黑树的条件
当链表的长度变为 8 时,此时链表就转换成红黑树。
static final int TREEIFY_THRESHOLD = 8;
当红黑树的元素个数减为 6 时,此时红黑树又会降成链表。
static final int UNTREEIFY_THRESHOLD = 6;
获取值
获取值使用的是 get 方法进行获取,它的获取流程其实和 put 差不多。
首先进行与运算,与运算完成之后得到的数组下标,比较得到的 key 是否为传入的 key,如果是的话就返回出来,如果不是,在链表或者红黑树中寻找。找到了就返回出去,没找到就返回 null。
在调用 get 方法时,其实是去调用 getNode 方法。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判断表是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判断 key 是否为传入的 key 以及哈希值是否相同。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果下一个节点不为空的话,有可能是链表,也有可能是红黑树。
if ((e = first.next) != null) {
// 如果节点是树类型,就在树中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
红黑树
下次接着写吧,我先学学它。💫💫
请勿发布违反中国大陆地区法律的言论,请勿人身攻击、谩骂、侮辱和煽动式的语言。