今天学习了HashMap数据结构,这里记录一下学习笔记,有错误,还望指出。
文章目录
前言
大家都知道HashMap在JDK1.7和1.8中数据结构发生了比较大的变化和优化这里主要围绕这两个版本分析。
具体结构为:
1.7-hashtable = 数组(基础) + 链表
(>=)1.8 = 数组 + 链表 + 红黑树
HashMap重要成员变量
进入HashMap类中可以看到它自身定义的一些非常重要的变量意思分别是:
- DEFAULT_INITIAL_CAPACITY = 1 << 4; Hash表默认初始容量
- MAXIMUM_CAPACITY = 1 << 30; 最大Hash表容量
- DEFAULT_LOAD_FACTOR = 0.75f;默认加载因子
- TREEIFY_THRESHOLD = 8;链表转红黑树阈值
- UNTREEIFY_THRESHOLD = 6;红黑树转链表阈值
- MIN_TREEIFY_CAPACITY = 64;链表转红黑树时hash表最小容量阈值,达不到优先扩容。
为什么说默认初始容量必须是2的指数幂?
可以看到DEFAULT_INITIAL_CAPACITY 上面写了一段注释
The default initial capacity - MUST be a power of two.
这个答案可以从构造方法中找到:
initialCapacity为我们传的初始大小this(initialCapacity, DEFAULT_LOAD_FACTOR);调用它自身重载方法,我们可以从它重载方法中看到tableSizeFor(initialCapacity);
方法的具体内容:
注释已经写明:对于给定的目标容量,返回大小为2的幂。
这个方法会强行将非2的指数次幂的数值转化成2的指数次幂
当然肯定不是来者不拒啦,有以下几个要求:
- 必须最接近size
- 必须>=size
- 是2的指数次幂
这里举个例子假如我传入11那么满足上面的要求是多少?
16
为什么一定要转成2的指数次幂?
计算索引:
h =
0001 0101 0111 0010 1111
0001 0101 0000 0010 0000
16
0
0000 0000 0000 0000 1111 16-1=15
0000 0000 0000 0000 1010
0-15
我们先对 Key 做哈希运算,得到它的哈希值,然后根据哈希值值,按照 HashMap 的寻址算法得到 index 值,也就是对应应该放入数组的位置;
index = hashcode & (length -1)
假如说发生哈希冲突,那么就会通过拉链法来处理;
为什么容量必须是 2 的指数幂就是因为由上述的寻址算法要求的上面可以看到0-16和0-15。转换二进制的差别
当 HashMap 的容量为 2 指数幂时,那么length -1 后,十进制换算成二进制你会发现每一位都是1。
比如我们假设容量 length=16,则 length-1后为15,转换为二进制是 11111。这样当 hashcode 和它进行与运算(两个同时为1,结果为1,否则为0)的时候,可以得到( 0 ~ 15 )范围内的每一个值。
Hash计算index为什么采用位运算方法?
hashmap的get,put操作时间复杂度O(1)
假设采用取模计算:
key.hashCode % 16 = table.lenth = [0-15] = index = 3;
array[index] = value;
数组所有的元素位是否能够100%被利用起来?
不一定,hash碰撞,引入链表结构解决hash冲突,采用头部插入链表法,链表时间复杂度O(n)一下子就变的复杂多了
还有最重要的一点:
效率:位运算>%
位算是计算机最底层运算不需要像乘除那样转换后再计算,这里给两个方法可以去跑一下进行对比。
@Test
public void test1() {
int number = 100 * 1000;//分别取值10万、100万、1000万、1亿
int a = 1;
long start = System.currentTimeMillis();
for(int i = number; i > 0 ; i++) {
a = a & i;
}
long end = System.currentTimeMillis();
System.out.println("位运算耗时: " + (end - start));
}
@Test
public void test2() {
int number = 10000 * 10;//分别取值10万、100万、1000万、1亿
int a = 1;
long start = System.currentTimeMillis();
for(int i = number; i > 0; i++) {
a %= i;
}
long end = System.currentTimeMillis();
System.out.println("取模运算耗时: " + (end - start));
}
bit位运算:1815ms
mod取模运算:22282
效率差10倍
hash扩容,有个加载因子?loadfactor = 0.75为什么是0.75
牛顿二项式:基于空间与时间的折中考虑0.5,java在0.5-1巧妙选取了0.75作为加载因子。
容量>=64才会链表转红黑树,否则优先扩容。
treeifyBin方法中会判断容量是否容量>=64。
为什么链表长度>8,链表9的时候转红黑树?
可以看到if判断前再次p.next = newNode(hash, key, value, null); 也就是说当链表长度8在new一个节点其实就是9了。
扩容死锁问题分析
Jdk7-扩容
死锁问题核心在于下面代码,多线程扩容导致形成的链表环!
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;//第五行
}
}
}
去掉了一些冗余的代码, 层次结构更加清晰了。
- 第一行:记录oldhash表中e.next
- 第二行:rehash计算出数组的位置(hash表中桶的位置)
- 第三行:e要插入链表的头部, 所以要先将e.next指向new hash表中的第一个 元素
- 第四行:将e放入到new hash表的头部
- 第五行: 转移e到下一个节点, 继续循环下去
单线程扩容
假设:hash算法就是简单的key与length(数组长度)求余。hash表长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key%table.length)的话都应该碰撞到table[1]上。
扩容:hash表长度会扩容为4重新hash,key=3 会落到table[3]上(3%4=3), 当前 e.next为key(7), 继续while循环重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞, 这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句 中代码)当前e.next为key(5), 继续while循环重新hash,key=5 会落到table[1]上 (5%4=3), 当前e.next为null, 跳出while循环,resize结束。
多线程扩容
下面就是多线程同时put的情况了, 然后同时进入transfer方法中:假设这里有两个线程同时执行了put()操作,并进入了transfer()环节
while(null != e) {
Entry<K,V> next = e.next;//第一行,线程1执行到此被调度挂起
int i = indexFor(e.hash, newCapacity);//第二行
e.next = newTable[i];//第三行
newTable[i] = e;//第四行
e = next;//第五行
}
那么此时状态为:
从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程 2 rehash 后,就指向了线程2 rehash 后的链表。
然后线程1被唤醒了:
- 执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因 为新 Hash 表为空,所以e.next = null,
- 执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
- 执行e = next,将 e 指向 next,所以新的 e 是 key(7)
然后该执行 key(3)的 next 节点 key(7)了:
4. 现在的 e 节点是 key(7),首先执行Entry<K,V> next = e.next,那么 next 就是 key(3)了
5. 执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)
6. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
7. 执行e = next,将 e 指向 next,所以新的 e 是 key(3)
此时状态为:
然后又该执行 key(7)的 next 节点 key(3)了:
- 现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null
- 执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
- 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
- 执行e = next,将 e 指向 next,所以新的 e 是 key(7)
这时候的状态如图所示:
很明显,环形链表出现了。
Jdk8-扩容
Java8 HashMap扩容跳过了Jdk7扩容的坑,对源码进行了优化,采用高低位拆分转移方式,避免了链表环的产生。
扩容前:
扩容后:
resize()截取扩容重要代码
final Node<K,V>[] resize() {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
//yangguo.hashcode & 16 = 0,用低位指针
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
//yangguo.hashcode & 16 》 0 高位指针
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;,移到新的数组上的同样的index位置
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //index 3+16 = 19
}
完全绕开rehash,要满足高低位移动,必须数组容量是2的幂次方,这也是为什么数组容量必须是2的幂次方。
由于Jdk8引入了新的数据结构,所以put方法过程也有了一定改进,其过程大致如下。
一切伟大的行动和思想,都有一个微不足道的开始。