目录
一、 HashMap的数据结构
1.7版本与1.8版本数据结构的区别
1.7版本使用的数据结构是数组 + 链表的形式。对于新增的节点使用的是头插法,新增的节点增加在离桶最近的地方。
1.8版本使用的是 数组 + 链表/红黑树的形式。新增节点使用的是尾插法,新增的节点在链表的尾部。当链表的长度>=8时,会转换为红黑树结构。
二、HashMap的功能实现源码解析
1. hash方法
如果没有指明HashMap的初始化大小值,则其默认初始化大小是16。
当我们有一个新的值被put方法放入HashMap时,它应该在0~15之间有一个具体的位置。那么应该用什么方法确定它的位置呢?
我们常想到的就是用随机取模的方法来做,Random(16).nextInt(),简单粗暴。但是如果我们对同一个key比如"hello",反复地放入同一个HashMap,则其每次的位置都是随机的且位置不同,这样对于查找并不方便,最好用一种与key本身带有某种关系的算法,同一个key往往放在同一个位置。我们看下HashMap的源码是怎样确定key的位置的。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
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); //确定存放的位置
else {
....
}
}
源码的算法是 tab[i = (n - 1) & hash]
tab是HashMap内部的Node数组,tab[i]就是第i个的位置。 i 的取值是(n-1) & hash。
n -1 是数组的长度 - 1, 那么hash是什么呢?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
它的计算原理是: hashCode ^ (hashCode >>> 16)
每一个对象都有一个hashCode,是一个32位的int值。 算式的意思是将它的高16位与低16位相异或。
为什么要用异或?
我们用了hashCode的高16位与低16位来进行运算,我们当然可以取与(&)运算或者或( | )运算,但是这样的结果与异或(^)相比较一下,就可以发现任意两个数X和Y进行(&)或( | )运算之后,每一位取0和1 的概率都是不一样的,也就是说某个操作数(即高16位或者低16位)被赋予的权重是不一样的,这就会使hash计算后分布不够均匀。而异或(^)运算则没有这个问题。
HashMap数组的长度n必须为2的整数次方。这个n值为什么要这么规定?
tab[i] 中位置 i 的位置是 (n-1) & hash。 我们明白了hash的计算结果,是一个16位的值。
n为2的整数次方,比如说16,则n-1 = 15,换算成二进制就是 00001111。
长度必须为2的整数次方的原因就是:
(1) n-1 与 hash相与,最大值为15,最小值为0,其结果值分布在0 ~ 15之间,完美契合座标范围。
(2)n-1的二进制,其值全部为1,可以采样到hashCode后面所有位的值。如果n-1中间有某几位为0,则该位与(&)的结果一定是0,取不到值,则tab[] 数组某些位置就会为空,永远也不会被存放值,造成内存浪费。
2. 由链表改为红黑树
当链表的长度 >= TREEIFY_THRESHOLD (=8)时,就会将链表改为红黑树。因为链表查找的时间复杂度为O(n),而红黑树的查找时间复杂度为O(logn)。 (红黑树的内容在数据结构篇中查看)
当红黑树的节点 <= UNTREEIFY_THRESHOLD (=6)时,又会从红黑树转换为链表。
3.扩容
随着Node数组存放的数据越来越多,达到 0.75 *f (f为Node数组的长度)时,就会对HashMap进行扩容。
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; //如果到了 1 << 30,则扩容到Integer.MAX_VALUE
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 扩容一倍
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // 初始时用默认值16
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
...
}
如果是首次初始化则初始化为16,如果 >= 2^30则扩容为Integer.MAX_VALUE,其他情况则扩容为原来的2倍。
4. 扩容后的新位置
扩容的过程中,容量变为原来的2倍,原Node数组中的节点位置,就需要计算新的 hash & (n-1)来确定。
新的位置只可能在两个位置:
- 原来的位置
- [原下标 + 原容量] 的位置
这个很容易理解:
下标就是 hash & (n-1), hash没有变, 只有 n 扩大为原来的2倍,则 n-1的二进制就是比原来的最左侧多了一个 1 。那么计算 hash & (n-1),如果最左侧的1上hash码为0,则为原来的数;如果最左侧1上hash码为1,则为原来的数 + 原来的容量。
5. 搬家
当HashMap扩容之后,hashMap的各个Node节点都要移动到新的位置上去。这个过程再去增删改查一定是不安全的,因此就需要先禁止这些操作,等到各Node节点存放到新的位置之后才能操作。
三、 怎样将HashMap升级为线程安全的
与HashTable相比较,HashTable为啥是线程安全的?
因为HashTable的几乎所有方法都加上了synchronized 关键字。
public synchronized V put(K key, V value) {
...
}
这样是实现了安全性,但是性能也肯定被降低了,算是牺牲了性能来保证了安全性。
1. HashMap为啥线程不安全呢?
我们来看一下HashMap中有哪些操作。
(1) 对于一个普通的put操作,步骤有:
hash(key)
数组初始化
将该key/value值存放入某个位置
(2)扩容
数组扩容
移动数据
这些步骤除了hash(key) 之外,其他都是线程不安全的。这就是HashMap线程不安全的原因。
2. HashMap应该怎样实现线程安全呢?
除了像HashTable那样low地为每个方法用synchronized修改,我们可以根据每个步骤来对其进行优化。
(1) 对于一个普通的put/remove 操作,步骤有:
hash(key) --------- 线程安全
数组初始化 -------- 线程不安全,只能有一个线程在处理,可以用CAS来解决。
将该key/value值存放入某个位置 -------- 线程不安全,插入时如果为null则用CAS 解决, 如果不为null,可以使用synchronized(i i为数组下标)的方式,尽量减小锁的粒度。
(2)扩容
数组扩容 ------- 线程不安全,只能由一个线程操作,用CAS解决。
移动数据 ------- 线程不安全,必须禁止其他的增删改查操作,之后借鉴ConcurrentHash中的方式,将每一个桶将由不同的线程去负责搬运它们的位置,将锁的粒度减小到单个桶的范围。
此外其他的方法都要仿照这种形式进行改造。所以对多线程来说,还是要用 ConcurrentHash 作为更好的选择。