HashMap数据结构
底层使用了数组+链表。
如果,链表的长度大于等于8(TREEIFY_THRESHOLD)了,则将链表改为红黑树,这是Java8 的一个新的优化。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
当发生 哈希冲突(碰撞)的时候,HashMap 采用 拉链法 进行解决。
扩容开销很大(需要创建新数组、重新哈希、分配等等),与扩容相关的两个因素:
默认加载因子(DEFAULT_LOAD_FACTOR)是 0.75。HashMap 使用此值基本是平衡了性能和空间的取舍。
- 加载因子太大的话发生冲突的可能就会大,查找的效率反而变低
- 太小的话频繁 rehash,导致性能降低
加载因子决定了 HashMap 中的元素占有多少比例时扩容
初识容量(DEFAULT_INITIAL_CAPACITY)16。
HashMap扩容的时机:
容器中的元素数量 > 负载因此 * 容量,如果负载因子是0.75,容量是16,那么当容器中数量达到12 的时候就会扩容。
还有,如果某个链表长度达到了8,并且容量小于64(MIN_TREEIFY_CAPACITY),则也会用扩容代替红黑树。
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
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);
}
}
HashMap中的性能优化
HashMap 扩容的时候,不管是链表还是红黑树,都会对这些数据进行重新的散列计算,然后缩短他们的长度,优化性能。在进行散列计算的时候,会进一步优化性能,减少减一的操作,直接使用& 运算。
HashMap的重新Hash算法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
避免只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,可以避免哈希值分布不均匀。
而且,采用位运算效率更高。
HashMap 如何根据 hash 值找到数组中的对象
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) {
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;
}
关键的是其中的这一行:first = tab[(n - 1) & hash])
当n是2的指数时,上面的(n-1)&hash相当于hash%n,对于处理器来说,除法和求余比较慢,为了性能,使用了减法和按位与运算。
如何正确使用
无论我们如何设置初始容量,HashMap 都会将我们改成2的幂次方,也就是说,HashMap 的容量百分之百是 2的幂次方。
但是,请注意:如果我们预计插入7条数据,那么我们写入7,HashMap 会设置为 8,虽然是2的幂次方,但是,请注意,当我们放入第7条数据的时候,就会引起扩容,造成性能损失,所以,知晓了原理,我们以后在设置容量的时候还是自己算一下,比如放7条数据,我们还是都是设置成16,这样就不会扩容了。
非线程安全
HashMap 在 JDK 7 中并发扩容的时候是非常危险的,非常容易导致链表成环状。但 JDK 8 中已经修改了此bug。但还是不建议使用。强烈推荐并发容器 ConcurrentHashMap。
编码启示
如果参与中间件、基础架构开发,时刻追求性能是很有必要的。
HashMap如何根据指定容量设置阈值
得出最接近指定参数 cap 的 2 的 N 次方容量。假如你传入的是 5,返回的初始容量为 8 。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
可以自己举例验证,但是如果问怎么写出来的,我也不知道。只能膜拜。
二进制位运算规则参考:
<< : 左移运算符,num << 1,相当于num乘以2 低位补0
>> : 右移运算符,num >> 1,相当于num除以2 高位补0
>>> : 无符号右移,忽略符号位,空位都以0补齐
% : 模运算 取余
^ : 位异或 第一个操作数的第n位与第二个操作数的第n位相反,那么结果的第n为也为1,否则为0
& : 与运算 第一个操作数的第n位与第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0
| : 或运算 第一个操作数的第n位与第二个操作数的第n位 只要有一个是1,那么结果的第n为也为1,否则为0
~ : 非运算 操作数的第n位为1,那么结果的第n位为0,反之,也就是取反运算(一元操作符:只操作一个数)
参考文章:
深入理解-HashMap-put-方法(JDK-8逐行剖析)
Java 集合深入理解(16):HashMap 主要特点和关键方法源码解读