jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
数组使用一段连续存储单元存储数据,查询快,通过下标定位(时间复杂度O(1)),增删慢,涉及元素移动(平均复杂度为O(n));
当产生hash碰撞时,数据会挂载(尾插),形成链表。链表空间上不连续,逻辑上连续,增删元素快,只需处理节点间的引用,时间复杂度为O(1),查询慢,需要遍历所有节点逐一查找,时间复杂度为O(n);
因此当链表过长时,查询耗时严重,这时我们引入了红黑树,它查找、插入、删除的平均时间复杂度为O(log(n))
需要了解的属性:
HashMap<String, String> map = new HashMap<>(11);
map.put("name", "Orcas");
System.out.println(map.get("name"));
可以看出,构造方法其实是对初始容量和加载因子进行设置,初始容量必须是2的指数次幂。
而真正数组的初始化时在 put 方法中。
// 默认构造函数,加载因子为默认值 0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 包含另一个Map的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// 指定了初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定了初始容量和加载因子,会对参数进行校验
// 初始容量不能为负数,不能大于最大容量 1 << 30
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor()方法会将我们传入的容量设置为大于并最接近的2^N,例如之前传入的初始容量为11,通过tableSizeFor(11),则返回16
/**
* 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;
}
当我们调用put方法添加元素时,实际是调用了其内部的putVal方法
第一个参数需要对key求hash值,为了减少hash碰撞。
hash 方法是为了防止一些实现比较差的 hashCode() 方法,减少碰撞,尽可能使元素散列地存储。
hash = key的hash code(散列码)值的高低16位做异或运算
static final int hash(Object key) {
int h;
// >>> 16 无符号右移16位,空位补0,超出32位的部分去掉
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
第一步其实就是对数组进行初始化;
如果定位到的数组位置没有元素就直接插入;
如果定位到的数组位置有元素就要与插入的key比较,如果key相同就直接覆盖,如果key不相同,就判断p是否是一个树节点,如果是就调用e = ((TreeNode
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 0.0 判断当前数组是否已经初始化
if ((tab = table) == null || (n = tab.length) == 0)
// 若未初始化,调用resize方法进行初始化(默认长度为16)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中
if ((p = tab[i = (n - 1) & hash]) == null)
// 1.0 如果桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
tab[i] = newNode(hash, key, value, null);
// 2.0 若桶中已经存在元素
else {
Node<K,V> e; K k;
// 2.1 如果与第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 2.2 将第一个元素赋值给e,用e来记录
e = p;
// 3.0 如果节点为红黑树结点
else if (p instanceof TreeNode)
// 3.1 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 4.0 其余情况则为链表结点
else {
// 4.1 在链表最末插入结点(jdk1.7采用头插法,容易造成死循环)
// 遍历当前链表
for (int binCount = 0; ; ++binCount) {
// p.next为空表明处于链表的尾部
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值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 5.0 e有记录,表示在桶中找到的元素的key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e(第一个元素)的value
V oldValue = e.value;
// onlyIfAbsent if true, don't change existing value
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调,将元素添加到链表的最后
afterNodeAccess(e);
// 返回旧值
// map.put(1, "Orcas");
// String oldVal = map.put(1, "Fish");
// => Orcas
return oldValue;
}
}
// 记录是发生内部结构变化的次数
++modCount;
// 每次put一个元素++size,当实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
index是通过 (n - 1) & hash
计算得出,即(数组长度-1)的二进制与之前求出的hash值做与运算。
因为要保证key的值尽量不重复,key的hashcode值的高低16位做异或运算(得出hash值)再与数组的长度-1的二进制做与运算,得出index。
如果数组长度的二进制是0的话,key的hashcode值的每一位无论是1还是0做与运算都是0,这样重复的概率就变大,因此必须都是1。而
1111+1=1 0000 就是2^4
1 1111+1=10 0000 2^5,结果都是2的N次方
2^N - 1可以使二进制每位都是1
默认加载因子 0.75 在时间和空间开销上给出了一个较好的折衷。
如果加载因子过大,空间开销少了,但会导致查找元素效率低;而过小则会导致空间开销大,数组利用率低,分布稀疏。
具体的数值是通过牛顿二项式定理,求得了极限值(key接近无限大时) :log2 ≈ 0.693 (这个是在Stack Overflow找到的答案)
而又因为阈值 threshold = capacity * loadFactor,是一个 int 类型,这个加载因子 * 2的指数次幂需要得到一个整数, 0.75 * 16 = 12 (这个是个人推测)
扩容会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,非常耗时,因此需要尽量避免rehash。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; // table如果为空,oldCap长度设置为0
int oldThr = threshold;
int newCap, newThr = 0;
// 1.0 旧数组中有元素,说明已初始化过,调用resize()是进行扩容的
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 1.1 旧数组长度大于最大容量2^30,则将阈值设置为Integer的最大值
threshold = Integer.MAX_VALUE;
// 但数据大小不变
return oldTab;
}
// 1.2 旧数组双倍扩容后小于最大容量 并且 旧数组大于默认的初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 将阈值threshold*2得到新的阈值
newThr = oldThr << 1;
}
// 2.0
// 旧阈值=threshold大于0
// 说明使用的构造方法是HashMap(int initialCapacity, float loadFactor)
// 该方法中 this.threshold = tableSizeFor(initialCapacity);
// tableSizeFor方法返回的是数组的容量(2^N),例如initialCapacity是1000,那么得到的threshold就是1024,这里threshold就等于数组的容量
else if (oldThr > 0)
// 容量设置为阈值threshold
newCap = oldThr;
else {
// 3.0 阈值为初始化时的0,oldCap为空,即创建数组时无参,调用resize()是为了初始化为默认值
// 将新的长度设置为默认的初始化长度,即16
newCap = DEFAULT_INITIAL_CAPACITY;
// 负载因子0.75*数组长度16=12 新阈值为12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.0 如果新阈值为0,根据负载因子设置新阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 扩容的阈值(可能是以上几种情况之一得到的)
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建一个长度为newCap的新的Node数组
table = newTab;
// 如果旧的数组中有数据,则将数组复制到新的数组中
if (oldTab != null) {
// 循环遍历旧数组,将有元素的节点进行复制
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 旧数组有元素的节点
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 数组
if (e.next == null)
// 重新计算hash值确定元素的位置
newTab[e.hash & (newCap - 1)] = e;
// 红黑树
else if (e instanceof TreeNode)
// 将原本的二叉树结构拆分组成新的红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表
else { // preserve order
// jdk1.8中 旧链表迁移新链表 链表元素相对位置没有变化; 实际是对对象的内存地址进行操作
// jdk1.7中 旧链表迁移新链表 如果在新表的数组索引位置相同,则链表元素会倒置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// hash值与旧的长度做与运算用于判断元素的在数组中的位置是否需要移动
/**
* 举例:
* (e.hash & oldCap) == 1
* e.hash & (oldCap - 1) e.hash & (newCap - 1) e.hash & oldCap
* ...0101 0010 ...0101 0010 ...0101 0010
* & 0 1111 1 1111 1 0000
* 0 0010 1 0010 1 0000
*
* (e.hash & oldCap) == 0
* e.hash & (oldCap - 1) e.hash & (newCap - 1) e.hash & oldCap
* ...0100 0010 ...0100 0010 ...0100 0010
* & 0 1111 1 1111 1 0000
* 0 0010 0 0010 0 0000
*/
// 总结:
// 数组的长度为2^N,即高位为1,其余为0,计算e.hash & oldCap只需看oldCap最高位1所对应的hash位
// 因为newCap进行了双倍扩容,即将oldCap左移一位,那么oldCap-1相当于newCap-1右移一位,右移后高位补0,与运算只能得到0。
// 如果(e.hash & oldCap) == 0,hash值需要与运算的那一位为0,那么oldCap - 1与newCap - 1的高位都是0,其余位又是相同的,表明旧元素与新元素计算出的位置相同。
// 同理,当其 == 1 时,oldCap-1高位为0,newCap-1高位为1,其余位相同,计算出的新元素的位置比旧元素位置多了2^N,即得出新元素的下标=旧下标+oldCap
// 如果为0,元素位置在扩容后数组中的位置没有发生改变
if ((e.hash & oldCap) == 0) {
if (loTail == null)
// 首位
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 不为0,元素位置在扩容后数组中的位置发生了改变,新的下标位置是原下标位置+原数组长
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;
}
}
}
}
}
return newTab;
}
public V get(Object key) {
Node<K,V> e;
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;
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) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
jdk1.8的hash方法比1.7性能有所提升;
jdk1.7链表采用头插法,头插法比较快,但容易造成死循环;jdk1.8是尾插法;
jdk1.8中 旧链表迁移新链表 链表元素相对位置没有变化; 实际是对对象的内存地址进行操作,jdk1.7中 旧链表迁移新链表 如果在新表的数组索引位置相同,则链表元素会倒置
其实这篇文章原本是讲IDEA破-解的,最近监管严被挂了= =。
于是就替换了一份笔记…