文章目录
前言
HashMap对Java开发来说肯定是不陌生的了,而且面试基本逃不开‘谈谈你对HashMap或ConcurrentHashMaprentHashMap的理解’。因为HashMap的应用场景实在太多了,在存储数据方面我们可以把它当作一个缓存,即使我们存储的数据量十分之大,HashMap的扩容机制也能很好的帮我们解决按需扩容问题,其次不考虑哈希碰撞的情况下,它的存、取、删操作的时间复杂度均是O(1),这都是因为哈希表带来的高效。
基础知识
在开始之前需要对源码中的一些基本知识有所了解:
载入因子,决定了每一次扩容时的阈值。为0时导致无法扩容,无穷大又没意义,所以这两种情况都是不允许的。虽然这里定义了final类型却没有初始化,其实是没问题,因为只要在所有构造函数中对它赋值就不会报错了。
/**
* The load factor for the hash table.
*/
final float loadFactor;
相对当前容量{@link HashMap#capacity()}的阈值,超过该阈值就需要扩容,但是这个阈值不会超过允许的int的最大值2147483647
/**
* The next size value at which to resize (capacity * load factor).
*/
int threshold;
每当Hash表的大小超过了当前的阈值(threshold),会以2次幂的倍数扩大Hash表的容量(左移一位),并更新阈值为当前最大容量的loadFactor倍。所以Hash表的最大容量扩大规律为0,1,2,4,8,16…如此下去的。但并不是无尽头的扩大,对它的限制是:
// 二次幂扩容,所以这里只左移30位=1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* Returns a power of two size for the given target capacity.
* 这个算法是为了给Hash表确定一个2的次幂大小的容量
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
你以为理论上一个HashMap只能存储10个亿的数据吗?答案并非如此,因为这里面还涉及到哈希碰撞的情况。
哈希碰撞
static final int hash(Object key) {
int h;
// h >>> 16 = h的高16位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
其实就是通过对key取hashCode之后,用这个hashCode的高16位和低16异或(都为1时得0,其它情况都得1),这样就使得对一个key的哈希值的影响只取决于16位,所以哈希得分布会变得密集,也就是所谓得冲突。但这在速度和质量上是比较权衡的,合并了高16位后这16位将不会对索引产生影响。
/**
* 我们模拟一次索引计算
* table是一个表,你可以暂时把它当作一个哈希表(其实是一个节点表,之后再讲)
* 有一个key
*/
int size = table.size(); // 哈希表的大小,其实和目前HashMap的最大容量是一样的(先简化)
// 因为涉及到put方法时对容量的一些列处理
int hash = hash(key); // key的哈希值
int index = (size - 1) & hash; // key在哈希表中的索引
对二进制计算熟悉的应该看完这个模拟就明白是怎么一回事了,不懂得继续看:
首先可以确定的是这个hash必须是在已知大小为size的表中,那么它的索引就必须最大为(size-1);其次是与运算,比如我们用具体值来计算一遍:
size = 8;
// key1
hash1 = 128; // 二进制表示 [1000 0000]
index1 = (8 - 1) & 128 = 0111 & 1000 0000 = 0;
// key2
hash2 = 8; // 二进制表示 [1000]
index2 = (8 - 1) & 6 = 0111 & 1000 = 0;
这样看来key1和key2的就发生哈希碰撞了,因为出现同一个索引。
学过数据结构应该对链表不陌生吧,可以把一个数据看作一个节点Node:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 节点的哈希值
final K key; // 节点的key
V value; // 节点的值
Node<K,V> next; // 链表的下一个节点
......
}
对于同一个索引值的所有节点,会被放到一个链表中。这样我们的哈希表存储的其实就是一个个链表的其实节点而已,通过确定一个索引值可以找到其所在链表的其实节点,然后遍历该链表就可以找到想要的节点了。
这里有一个问题,如果冲突非常密集,导致同一个哈希值的链表长度很长,那查找、插入和删除节点就会比较耗时,针对这个问题,还有另外一种数据结构:树(二叉树/红黑树)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左叶子节点
TreeNode<K,V> right; // 右叶子节点
TreeNode<K,V> prev; // 前一个节点,删除当前节点时需要取消与prev的链接
boolean red; // 是否是红色节点
......
}
红黑树在查找、插入、删除节点的时间复杂度是比较平衡的,源码中会根据哈希冲突的情况选择普通的链表结构或者树结构,而且随着数据的插入、删除,这样的结构是动态变化的,比如这段源码:
/**
* If the current tree appears to have too few nodes,
* the bin is converted back to a plain bin. (The test triggers
* somewhere between 2 and 6 nodes, depending on tree structure).
* 如果节点数量很少(测试是2到6个,取决于树的结构),将变为普通结构。
*/
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
// 将树结构非树化,变为链表
tab[index] = first.untreeify(map); // too small
return;
}
无根节点、根节点无右子节点、根节点无左子节点、根节点的左子节点没有左子节点。这些情况都会被认为节点数量过少,没必要继续使用树结构。
构造HashMap
讲了这么多的细节,当然最后要看一下一个HashMap到底可以怎样被构造了:
// 1. 指定一个初始容量和载入因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 2. 指定一个初始容量,载入因子使用默认的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 3. 不指定数值,所有参数都使用默认的值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// 4. 通过已有的map初始化一个包含这个map的HashMap,但是它的键和值的类型要合法
public HashMap(Map<? extends K, ? extends V> map) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(map, false);
}
小结
这只是初步探索了一下HashMap的部分源码,要知道HashMap有2400多行代码,想要一下子就探索完是很难的。所以这里先做一个基础知识又是最重要的知识的了解,也是为后面进一步探索HashMap的put、get、replace、move等方法的最佳实践打个基础吧。
声明
目前除了需要继续这个JDK源码专题的探索,因为自己还是一个安卓开发者,接下来可能还会做一个安卓方面的专题(包括Kotlin、Flutter等)。