HashMap数据结构简介
HashMap就是数据结构中的散列表,是以key,value的形式进行存储数据的,数组具有查找定位快,但是插入操作性能差,链表具有查找慢插入快速的特点,而HashMap可以说是这两种方式的一种折中。
HashMap采用数组与链表相结合的方式实现,如下图所示
*HashMap会根据存储实体key的值确定存放在那个数组的链表上
HashMap的特点
1. 可以存储null值(HashMap可以接受为null的键值(key)和值(value))
2. 非线程安全
3. 存储快查找快
HashMap的性能分析
HashMap要想充分利用数组与链表的性能达到比较高的性能,那么影响性能的因素有哪些?
- 哈希函数均匀
因为HashMap是通过hash函数来定位数组下标,进而确定对象存储位置的,最坏的情况是通过hash函数计算出的下标都为相同,那么HashMap就退化成链表了,最好的情况是都不相同那么就能达到O(1)的效率,所以hash计算出来冲突产生越多,那么查找效率就越低。冲突越少查找效率越高。 - 处理冲突的方法
既要有较高的查找性能,又要有较高的插入性能,那么冲突就无法避免,解决冲突的方式也决定了其性能的优劣。
HashMap是采用拉链法解决冲突的,如果key值的hash值计算出来冲突,那么就在数组相应下标的最后插入链表节点。
HashMap 从key到数组的下标
1.首先根据key调用hashCode方法生成hashcode
2.调用hash方法根据生成的hashCode生成hash值
3.利用hash值与数组table的size-1求余得到数组下标
注:求余就是为了使得得到的数组在数组区间内
hashCode的生成
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
hashcode的生成
String类的value是char[],char可以转换成UTF-8编码。譬如,’a’、’b’、’c’的UTF-8编码分别是97,98,99;“abc”根据上面的代码转换成公式就是:
h = 31 * 0 + 97 = 97;
h = 31 * 97 + 98 = 3105;
h = 31 * 3105 + 99 = 96354;
使用31作为乘数因子是因为它是一个比较合适大小的质数:如值过小,当参与计算hashcode的项数较少时会导致积过小;如为偶数,则相当于是左位移,当乘法溢出时会造成有规律的位信息丢失。而这两者都会导致重复率增加。
这样就得到了重复率较低的hashcode。
hash值计算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash值的计算算法如上所示:如果取到key不等于null,则取 (h = key.hashCode()) ^ (h >>> 16)。这里为什么要这样做?
示例.png你会发现只要二进制数后4位不变,前面的二进制位无论如何变化都会出现相同的结果。为了防止出现这种高位变化而低位不变导致的运算结果相同的情况,因此需要将高位的变化也加入进来,而将整数的二进制位上半部与下半部进行异或操作就可以得到这个效果。
从hashcode的设计到hash函数的处理都是为了降低重复率,使得数据均匀分布
为什么要使用与运算?
因为下标的计算公式就是hash值% tableSize,当tableSize是2的n次方(n≥1)的时候,等价于hash值 & (tableSize - 1)。
数组大小
先来看一看一个有意思的方法
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;
}
上面的方法就是数组大小生成的方法,永远生成的是一个2的幂的数。也就是说例如输入15 返回结果就是 16 。
这个方法乍一看不明所以,但是为什么它总能在给定一个值之后返回一个大于它或者等于它同时最接近它的2的幂?
我们看下下面这段二进制数据:
2的0次方2进制 0000 0001 十进制 1
2的1次方2进制 0000 0010 十进制 2
2的2次方2进制 0000 0100 十进制 4
2的3次方2进制 0000 1000 十进制 8
2的4次方2进制 0001 0000 十进制 16
我们发现临近的两个2的幂之间的高位是相邻的。比如说2的0次方最后一位是1,2的一次方倒数第二位是1,2的3次倒数第三位是1。
例如7要取到8 所以将
0000 0111
无符号右移
0000 0111 >>>1 等于 0000 0011
进行与操作 (只要存在1那么是1)
0000 0111 | 0000 0011 等于 0000 0111
n + 1(源码三目运算符)
0000 0111 + 0000 0001=0000 1000
给定数字为4最后通过右移变成8
0000 0100
无符号右移1
0000 0100 >>>1 等于 0000 0010
进行与操作 (只要存在1那么是1)
0000 0100 | 0000 0010 等于 0000 0110
接着因为已经有2位达到变成1的目的,所以接着就是移动2位
0000 0110 >>> 0000 0011
进行与操作 (只要存在1那么是1)
0000 0110 | 0000 0011 等于 0000 0111
n + 1(源码三目运算符)
0000 0111 + 0000 1000 =8
看到上面应该明白了是什么意思了吧?
其实就是借助两个2的幂之间的高位是相邻的的方式。然后通过无符号右移使得给定的数高位以后全变成1这样最后进行n+1就获取到了最小大于它的2的幂。
至于源码为什么到了16就不操作了,是因为int类型是占4个字节,每个字节8位,共32位。而向右移动16位后,可以从高位第一个出现1的位置开始向右连续32位为1,已经超越了int的最大值,所以不用在进行位移操作了,这也是代码中只是移动16位后就结束的原因。
等等上面的示例有问题,给定数字4为什么返回8,不是应该返回4吗?别急这就是为什么代码开始要下面这一行
int n = cap - 1;
如果不加这个那么就会使得结果是原来的两倍。
(1)容量选择
HashMap的初始容量是 1 << 4,也就是16。以后每次扩容都是size << 1,也就是扩容成原来容量的2倍。如此设计是因为 2^n-1(n≥1)的二进制表示总是为重复的1,方便进行求余运算。前文介绍过。
(2)装载因子
装填因子默认是0.75,也就是说如果数组容量为16,那么当key的数量大于12时,HashMap会进行扩容。
装填因子设计成0.75的目的是为了平衡时间和空间性能。过小会导致数组太过于稀疏,空间利用率不高;过大会导致冲突增大,时间复杂度会上升
HashMap的插入
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab 数组 p节点 n tab的长度 i插入的下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果当前的table为null或者说table的长度为0 则调用resize进行扩容初始化容量为16
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);
//如果数组下标对应的节点不为null
else {
Node<K,V> e;//临时节点保存新值
K k; //临时变量用于比较key
//如果头节点的hash与当前key计算出来的hash相等e赋值为旧节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果头结点是红黑树节点则将数据e保存为树节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//否则遍历链表的每一项
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;
}
//如果循环过程中发现新旧key的值相同,跳转:是否覆盖旧值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//是否覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果新值不为空且(允许修改旧值 或 旧值为空),覆盖旧节点的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//用于比较判断是否在遍历集合过程中有其它线程修改集合,详情请网上搜索fail-fast快速失败机制
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap的查找
public V get(Object key) {
Node<K,V> e;
//通过getNode方法获取Node对象,存在则返回value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果表不为null且表的长度大于0并且头结点不为null
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);
//否则遍历链表所有节点找到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;
}
网友评论