HashMap源码学习
特性
- HashMap继承了AbstractMap类,实现了Map、Cloneable、Serializable接口。
- HashMap无序,即不会记录数据插入顺序。
- HashMap线程不安全。
- HashMao最多允许一条数据的key为null
- HashMap的查找修改时间复杂度为O(1)
六个重要参数
- 数组初始化长度
DEFAULT_INITIAL_CAPACITY = 1 << 4(16) - 数组最大长度
MAXIMUM_CAPACITY = 1 << 30 - 加载因子
DEFAULT_LOAD_FACTOR = 0.75f - 链表转化为红黑树的阈值
TREEIFY_THRESHOLD = 8 - 红黑树转化为链表的阈值
UNTREEIFY_THRESHOLD = 6 - 转化为红黑树时数组的最小长度
MIN_TREEIFY_CAPACITY = 64
PS:当前map中存储的节点数 > 加载因子 × 数组长度时,进行扩容,扩容倍数为2。加载因子选择0.75是基于泊松分布的考虑。若选择1,那么map会很"拥挤",若选择0.5,那么map空闲位置会很多,浪费空间。
几个重要方法
基于jdk1.8
put()
首先,看代码,可见put内部调用了putVal()以及hash()这两个方法。
hash()
先来看hash()方法(一般称之为哈希函数):key为空的情况下,值为0。否则令h等于key的哈希值,将h与h右移16位后的数进行异或运算。 PS:这步操作是为了减少哈希碰撞的概率。我们知道哈希值是一个int类型的数值,4个字节,32个bit位,而数组初始化长度为16,那么在参与与运算(后面会提到,这里简单说明一下:key的哈希值与数组长度进行与运算计算key存储的下标)的只有哈希值的低位,高位是没有起到任何作用的,所以这里的优化思路就是让哈希值的高位也参与运算,进一步降低哈希碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
putVal()
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// Node节点(每一对k-v就是一个Node节点),tab-Node数组,也称为哈希桶,n为数组长度
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 若数组为空,先初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 前面提到的与运算,将数组长度-1后与key的哈希值进行与运算得到key要存放的数组下标,并令p为数组中的第一个元素
// 若p为空
if ((p = tab[i = (n - 1) & hash]) == null)
// 将当前要put的k-v存进来
tab[i] = newNode(hash, key, value, null);
// 若p不为空
else {
Node<K,V> e; K k;
// 如果节点p(首节点)的哈希值等于要存储节点的哈希值,并且储存节点的key.equals(p.key)两个条件都成立,说明p节点和要存储节点的key是一个对象
// 这里是基于效率的考虑,若两个key的哈希值不想等,则说明key不是一个对象,不需要再用equals判断(效率慢),若哈希值相等,才需要继续用equals判断!
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// p赋给e
e = p;
// 若p的key和存储节点的key不是一个对象,判断p是不是树节点(判断当前结构是不是红黑树)
else if (p instanceof TreeNode)
// 利用红黑树的方式添加元素
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 若p的key和存储节点的key不是一个对象,且当前结构是链表
else {
// binCount 用来记录链表长度,大于8时需要进行树化(且数组长度大于64)
for (int binCount = 0; ; ++binCount) {
// 若遍历到链表的尾部
if ((e = p.next) == null) {
// jdk1.8用的是尾插法
p.next = newNode(hash, key, value, null);
// 树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 遍历过程中会一一比对key是否是同一个对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// p = p.next,遍历链表
p = e;
}
}
// 到了这一步说明在遍历过程中存在key与要存储节点的key是同一个对象的情况
if (e != null) { // existing mapping for key
// 记录旧值的value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// modCount用来记录当前mao的所有元素个数
++modCount;
// size = The number of key-value mappings contained in this map.
if (++size > threshold)
// 扩容函数
resize();
afterNodeInsertion(evict);
return null;
}
resize()
扩容机制,暂留
treeifyBin().
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果数组容量没有达到默认长度(64),即使链表元素超过8,也不会变化红黑树,而是先进行数组扩容,若数组扩容(64)之后的链表长度大于8,再进行转换!
// MIN_TREEIFY_CAPACITY = 64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
get()
若key不存在返回null,存在直接返回value,内部调用了getNode()
getNode()
返回一个node
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判断数组是否为空,数组长度是否大于0,首元素是否为空,若有一个不满足,直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 若要get的key与首元素的key是同一个对象,那么直接返回首元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 首元素的下一个元素不能为空,否则返回null
if ((e = first.next) != null) {
// 树结构
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 遍历遍历,一一比对key,相同则返回元素
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
几个注意点
1. 初始化数组的长度必须是2的幂次方
创建HashMap时,可以赋一个初始化容量DEFAULT_INITIAL_CAPACITY ,默认是16,当你传递参数10的时候,他会默认创建一个最小的大于10的2^n级数(即16) 数组容量(n)大小取2的幂次方的目的在于,当(n-1)& key.hashCode() 时,结果均匀分布在数组的下标范围内。 举个例子:16-1=15,任何数字与15进行与运算的结果都在0~15之间,如果17-1=16,任何数字与16进行与运算的结果只有16或者0,其他下标永远无法取到!这样可以保证最大的程度的散列hash值,否则,当有一位是0时,不管hash值对应位是1还是0,与运算后的结果都是0,会造成散列结果的重复。
PS:此问题同扩容倍数为什么是2。 PS:为什么不是取余操作,因为取余操作的速度很慢,所以是与运算,因为与运算是基于byte的,速度很快。
2. 头插法 & 尾插法
jdk1.7用的是头插法,1.8因为考虑到要树化,所以用的尾插法(方便记录链表的节点数) PS:头插法效率更高,因为只需要直接修改节点的next指针为当前首元素即可。
3. 红黑树
为什么用红黑树
jdk1.8相较于1.7最大的改变就是在底层使用了红黑树,jdk1.7中用的是链表,我们知道链表的查找时间复杂度为O(n),效率很差,而红黑树的查找效率为O(logn)。
为什么不用BST(二叉搜索树)或AVL(二叉平衡树)
BST在极端情况下(节点1,2,3,4,…,)相当于链表,查找效率低 AVL相对于BST在树高上进行了优化,查询效率高,但是当添加或删除元素时,AVL为了保持平衡,可能需要大幅度调整树的结构(为了保持平衡,树要自旋)。因此使用红黑树。
红黑树的特性
- 节点非红即黑
- 根节点是黑色的空节点
- 不能有连续的红色节点
- 任一节点到根节点的任意路径上的黑色节点的个数相等(实现的是完美的黑色平衡)
PS:红黑树相对于AVL是一种折中的选择。红黑树既能提高插入和删除的效率,又能让树相对平衡从而有还不错的查询效率。
红黑树如何保持平衡
红黑树通过变色+旋转保证平衡
4. HashMap解决哈希冲突的手段
- 使用了拉链法(散列表)来链接拥有相同hash值的数据
- 使用了一次hash函数,两次扰动函数(一次异或运算,一次与运算)来降低哈希冲突的概率,使得数据分布更平均
PS:在JDK 1.7中,有4次位运算,5次异或运算(9次扰动),在JDK1.8中,只进行了1次位运算和1次异或运算(2次扰动)这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性。两次就已经达到了高位低位同时参与运算的目的;
5. 解决HashMap不安全问题
- ConcurrentHashMap(jdk1.7:segment分段锁;jdk1.8:node+cas+synchronized)
- Hashtable(synchronized)
- 使用Collections.synchronizedMap(Map)创建线程安全的map集合
个人理解,如有不对,欢迎批评指正^^
|