1、HashMap存取是无序的
2、键和值可以为null,但是键
3、键的位置是唯一的
4、JDK1.7HashMap采用的是数据结构是:数组+链表
5、JDK1.8则采用的是:数组+链表+红黑树
说到这里,我们就来看一下HashMap1.7的数组和链表是什么呢。
他底层使用类似这样子的数组(这个数组put的时候才会创建出来,默认长度16),数组的每个节点是一个Entry(在jdk1.8名称变成了Node),Entry代码如下
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
//键、值、指向下一个node的指针、hash值。
因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
比如我现在put(”rose“,123)
put方法首先会根据key值得出一个hash值,然后在根据hash&length-1得出数组下标,假如我们的得出的index是2,如下图
这时候我们就成功把元素插入进来,那如果我们再put(”harden“,234),哈希本身就存在概率性,就是”rose“和”harden“我们都去hash有一定的概率会一样,这个时候就产生了所谓的哈希碰撞,hashMap处理哈希碰撞他采用了是拉链法,也就是在该位置形成一个链表并且使用了头插法。
我都知道1.7使用的是头插法(同一位置上新元素总会被放在链表的头部位置),那么1.8使用的是尾插法,那个使用头插法会出现什么样的问题呢?
因为1.7在resize中transfer的时候转移到新数组的链表的顺序和原数组的顺序是相反的,如果在多线程的情况下,可能会造成循环链表,那个我们如果这时候去get的话,那就悲剧了。
这里我们提到了扩容那我们就来谈一下扩容的过程吧。
有两个因素:
Capacity:HashMap当前长度(默认的长度是16)。
LoadFactor:负载因子,默认值0.75f。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
那我们就一个一个来分析一下
我们来解决第一个问题,首先我们知道16是2的4次幂,读过源码的小伙伴都知道hashMap要求容量必须为 2 次幂,其实这个设计是为了方便去取数组的下标,我们都知道源码中是采用int i = indexFor(hash, table.length);来取数组下标的,这个算法其实和取余hash%length是一样的意思,可以让数组的位置更加均匀得分配(我们都知道,二进制中2次方数只占一个bit,而我们把length-1后和hash进行与操作得出的就是hash值本身,这样的设计不是很巧妙吗)
如果数组长度不是2的n次幂,计算出的索引特别容易相同,也就是容易发生hash碰撞,导致数组空间分配不均匀,链表或红黑树(1.8才有)过长,这样就很影响效率了。
最后取16作为初始化容量,也是考虑了性能的问题,如果取太小了数组频繁扩容,扩容是非常耗性能的,取太大的话有浪费内存。
我们上面提到了hash&length-1和hash%length是一样的,然而hashMap用hash&length-1是一个位运算,我们知道计算机进行位运算比对十进制数计算的效率更高,所以此处也是考虑了性能问题;
接下来我们来解决第二个问题
首先我们要了解LoadFactor负载因子其实是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,LoadFactor太大导致查找元素的效率低,太小导致数组利用率低,存放的数据会很分散,所以既兼顾数组利用率,又考虑链表不要太多,就选择了0.75。
JDK1.7采用的是数组+链表,即使hash函数取得再好,也很难达到元素百分百均匀分布,加入链表有n个元素,则时间复杂度为O(n),则完全是去了优势,针对这种情况,JDK1.8中引入的红黑树(查询的时间复杂度为O(log n))来优化查询的问题,链表不断变长,肯定会对查询性能有一定的影响,这时候才需要转化成树。
看过源码都知道,链表长度超过8并且数数组的长度大于等于64才会把链表转化成红黑树,那么为什么是8呢?
因为在随机哈希码下,链表节点的频率服从泊松分布,链表长度达到8的概率已经很小了,官方计算出来的值大约为0.0000006,而且红黑树节点(TresNode)占用空间是普通Node的两倍,所以选择8不是随便决定的,而是根据概率统计得来的,考虑了时间和空间的权衡。
根据以上代码,我们可以看出当size > = threshold(阈值)且null != table[bucketIndex](当前插入的位置不为空,这个条件就只有1.7JDK有)时就会进行扩容。
size表示的是整个hashMap的元素的个数,相当于(k-v键值对)的实时数量,而不是数组长度。
threshold = Capacity * LoadFactor
扩容:创建一个新的Entry空数组,长度是原数组的2倍。
转移数组:遍历原Entry数组,看过源码的都知道在transfer里面有个rehash的逻辑,但是这个逻辑在一个情况下才会去走,不走这个逻辑情况下,重新算出的index只有两个位置,一种是原来的index,一种是原来的index+旧数组的容量。
transfer(newTable, initHashSeedAsNeeded(newCapacity));
/**
* Initialize the hashing mask value. We defer initialization until we
* really need it.
*/
final boolean initHashSeedAsNeeded(int capacity) {
// hashSeed降低hash碰撞的hash种子,初始值为0
boolean currentAltHashing = hashSeed != 0;
//ALTERNATIVE_HASHING_THRESHOLD: 当map的capacity容量大于这个值的时候并满足其他条件时候进行重新hash
boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
//TODO 异或操作,二者满足一个条件即可rehash
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
// 更新hashseed的值
hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0;
}
return switching;
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
通过逐个&&(length-1),然后进行一次一次的转移。
当链表大度大于8 && 数组长度<最大数组长度64 会进行扩容
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
static final int MIN_TREEIFY_CAPACITY = 64;
当个数大于阈值也会进行扩容
if (++size > threshold)
resize();
在1.7HashMap的基础上发现,索引扩容后只有两种情况,哪两种呢?
一种是原索引不变,另一种是原索引+旧数组容量,在源码中的体现。
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
使用loHead和hiHead记录,一次性进行转移。
最后我画了个思维图。
1、 jdk1.7 : 数组+链表 jdk1.8 : 数组+链表+红黑树
2、 jdk1.7 hash算法更复杂,散列性更高,查询效率更高
Jdk1.8中增加了红黑树,查询效率得到了保障,所以可以简化hash算法,毕竟hash算法消耗cpu。
3、 jdk1.7有根据hashseed(哈希总值)是否变化进行重新hash,jdk1.8没这过程。
4、 扩容条件不同:jdk1.7扩容条件是1、数组长度大于阈值2、有产生hash碰撞。
Jdk1.8的扩容条件是1、数组长度大于阈值 || 2、链表长度大于8且数组长度<64。
5、 扩容转移不同:jdk1.7是一个一个进行转移。Jdk1.8 是判断hash值与上旧容量,哪些为0,哪些不为0,索引为原索引+旧数组容量,然后一次性转移。
6、jdk1.7采用尾插法,在扩容时候会导致循环链表问题,导致数据丢失。
Jdk1.8 采用尾插法解决这个问题。
7、jdk1.8新增api,putIfAbsent(key,value),在源代码中if(!onlyIfAbsent)体现。