面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)

写在前面,只是为了面试而准备的笔记,其实之前就有在分析HashMap源码,但是那篇只写了一部分,还躺在草稿箱,不过那篇写的感觉很舒服,等稳定下来想好好写。这篇写的不怎么满意,内容也只有一部分,做个存稿吧。
有错误请指出,谢谢

文章目录

  • HashMap
    • 数据结构
      • 为什么要引入红黑树?
      • 为什么是8?
      • 为什么是6?
    • 初始化
      • 为什么默认初始化长度为16?/为什么长度一定要是2的幂次?
        • 下标计算方法
        • 怎么计算容量(2的幂次数)?
        • 哈希算法 及 扰动函数
    • put
    • resize
      • 扩容条件
      • 扩容方式
      • 为什么HashMap是线程不安全的?
      • 为什么会遇到ConcurrentModificationException异常
      • 分析HashMap的containsKey方法的时间复杂度
    • 源码解读(部分)
      • jdk1.7
        • 构造函数
        • put
          • addEntry
        • get
          • getEntry / containsKey
        • resize
          • transfer
      • jdk1.8
        • 多出的两个阈值
        • 结点的引入
        • 构造函数
        • put
          • putVal
        • get
          • getNode / containsKey
        • resize
    • jdk1.7到1.8的主要变化
      • 数据结构改变
      • 链表节点插入
      • 扩容
        • 扩容条件减少
        • 扩容方式(针对链表而言)
        • 扩容下标计算
      • 简化了哈希算法

HashMap

数据结构

jdk1.7 数组 + 链表

jdk1.8 数组 + 链表 + 红黑树

当一个结点的链表长度大于8时,链表会转换成红黑树,提高查询效率,而链表长度小于6时又会退化成链表。

为什么要引入红黑树?

将查找的时间复杂度从o(n)提升到o(logn)。在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),加快检索速率。

为什么是8?

  1. trade-off,时间和空间的权衡。TreeNodes占用空间是普通Nodes的两倍,而链表很短时遍历速度也很快,因此需要达到一个阈值8才能够转换,当节点数变少6时,又会转回链表。
  2. 概率统计。源码注释里也写了,理想情况下,随机hashCode算法下所有节点的分布频率会遵循泊松分布,而链表长度达到8时的概率为0.00000006,趋近于零,因此这是根据概率统计决定的。

为什么是6?

emmmm还不知道。

初始化

我们知道,HashMap的默认初始化长度为16,而自定义长度则会取大于等于自定义长度的2的幂次数(例如输入27,最终长度为32)。

为什么默认初始化长度为16?/为什么长度一定要是2的幂次?

为了得到低位掩码从而计算下标,为了服务于从Key映射到index的Hash算法。

首先要知道HashMap的下标计算方法:

下标计算方法

为了增强散列性,使元素尽可能的分散开来,从而减少碰撞去使用链表/红黑树,HashMap的发明者采用了位运算的方式来实现一个均匀分布且高效率的函数来求下标。

index = HashCode(Key) & (Length - 1)

(先不讨论这个HashCode方法)

而只有当HashMap的长度为2的幂时,length-1 正好相当于一个低位掩码,所有二进制位都为1,才能将哈希值的高位全部归零,只保留低位值用来做数组下标访问,且保证范围在length内。

在这里插入图片描述

jdk1.7

static int indexFor(int h, int length) {
        return h & (length-1);
}

jdk1.8

i = (n - 1) & hash

怎么计算容量(2的幂次数)?

jdk1.7

private static int roundUpToPowerOf2(int number) {
	// assert number >= 0 : "number must be non-negative";
	return number >= MAXIMUM_CAPACITY
			? MAXIMUM_CAPACITY // 超出最大容量直接返回最大容量
			: (number > 1) ?
			// -1后左移一位,-1应该是为了number本身就是2的幂次数的情况考虑,否则会得到更大的2的幂次数
					Integer.highestOneBit((number - 1) << 1)
			: 1; // 小于等于1直接返回1
}

Integer.highestOneBit()

这个自己画一下就知道,通过右移和或运算返回i的二进制最高位,后面全补0,比如输入7返回8。每一次右移或运算都是为了得到一个纯1串,最后减去后面的1得到最高位为1后面补0的2的幂次数。

public static int highestOneBit(int i) {
    // HD, Figure 3-1
    i |= (i >>  1);
    i |= (i >>  2);
    i |= (i >>  4);
    i |= (i >>  8);
    i |= (i >> 16);
    // 到这里,i的二进制应该是形如111111
    return i - (i >>> 1);
}

jdk1.8

原理相同又有点出入,1.7是i - (i >>> 1),1.8则是得到一个小于目标值的全1串再+1得到目标值。

举个栗子,比如输入自定义容量7 = (0111)2

  • 在1.7中调用highestOneBit(12 = (1100)2),通过一系列右移和或运算得到 i = (1111)2,减去(i>>>1) = (0111)2 后得到结果 (1000)2 = 8
  • 在1.8中调用tableSizeFor(7 = (0111)2),-1后通过一系列右移和或运算得到 n = (0111)2,+1后得到结果 (1000)2 = 8
/**
 * Returns a power of two size for the given target capacity.
 * 返回比已给目标容量大的2的幂次数,输入0返回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;
}

前面我们知道了下标的运算方式,是通过低位掩码与哈希值相与得到哈希值的低位来做下标,这就意味着这个哈希值的计算方式几乎决定了HashMap的效率(散列值、碰撞率、分散情况),所以哈希算法的实现很关键。扰动函数就是为此而引入。
(jdk1.7中首次引入扰动函数,但是共做了四次扰动,1.8做了优化只用了一次)

哈希算法 及 扰动函数

先看看源码的哈希算法实现

jdk1.7

final int hash(Object k) {
	int h = hashSeed;
	if (0 != h && k instanceof String) {
		return sun.misc.Hashing.stringHash32((String) k);
	}

	h ^= k.hashCode();

	// This function ensures that hashCodes that differ only by
	// constant multiples at each bit position have a bounded
	// number of collisions (approximately 8 at default load factor).
    // 这里是扰动函数
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);
}

jdk 1.8

static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可以看出扰动函数就是将key的哈希值h与右移16位后的h进行异或运算。

用一句话来概括扰动函数的作用就是:将h的hashCode右移16位并与自身相异或 相当于 使自己的高16位和低16位 相异或,得到的值既包含了自己高位的特性又包含了自己低位的特性,从而增加了之后得到的下标的不确定性,降低了碰撞的概率。

用一张图来演示这个扰动函数就是:图源

面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第1张图片

Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第2张图片
结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。这就是扰动函数的功效。

jdk1.7中还有个哈希种子,还没有仔细研究,先放着面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第3张图片

put

数组的第0个位置 固定放置key为null的value

  1. 通过扰动函数计算key的哈希值
  2. 如果哈希表为空,初始化
  3. 如果哈希表不为空,计算下标
    1. 如果table[i]为空,创建新节点存入
    2. 如果table[i]不为空,根据HashCode和key值在链表/红黑树中寻找目标位置并更新其value值
      1. 如果没有发生碰撞
        1. jdk1.7 将新节点插在当前链表头部的前面,然后再下移(头插法
        2. jdk1.8
          1. 如果是红黑树,调用红黑树的插入
          2. 如果是链表,遍历到链表尾部并插入新结点(尾插法),遍历的同时进行计数,当插入新节点后的计数达到阈值,就把链表转化为红黑树
      2. 如果发生碰撞,通过比较key的地址或者key的值(equals)遍历链表/红黑树获取旧值,覆盖后返回旧值
      3. 如果HashMap容量达到阈值initialCapacity*loadFactor,则进行扩容

jdk1.8 新增链表转红黑树的阈值,因此在插入的时候必须知道链表的长度,如果长度超出这个阈值就将其转化为红黑树,因此在插入式必须遍历链表得到链表长度,于是在jdk1.8里插入结点时选择直接插在链表尾部,反正都要遍历一次,这样还保证了在扩容的时候对元素进行transfer时链表的顺序不会像1.7一样倒转,也就不会出现死循环链表。

resize

扩容条件

jdk1.7 (size >= threshold) && (null != table[bucketIndex])

存放的键值对数超出阈值,并且新增结点要插入的地方不为空

jdk1.8 ++size > threshold

只要存放键值对数超出阈值就扩容

扩容方式

默认扩容16,原数组长度,构建一个原数组长度两倍的新数组,并调用transfer将原数组的数组通过重新计算哈希值得到下标再转移到新增数组。

jdk1.7调用indexFor方法重新计算下标,并采用跟插入结点时一致的方式(头插法)挨个移动结点

jdk1.8则是根据规律将原链表拆分为两组,分别记录两个头结点,移动时直接移动头结点

我们会发现 HashMap扩容后,原来的元素要么在原位置,要么在原位置+原数组长度 那个位置上

举个栗子来说:

原来的HashMap长度为4,table[2]上存放了A。现在要进行扩容,先创建了一个长度为8的新数组,现在要进行transfer,那么这个A要放到哪里呢?

我们先来根据他原本所在的位置2来倒推,我们知道index = HashCode(Key) & (Length - 1),那么就有
面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第4张图片

Hash(key) 可能为010,可能为110。

我们用新的长度(8 = (111)2)和这两个数分别再去通过Hash算法来计算新的下标会发现

  • 010 & 111 = 010 在原位置

  • 110 & 111 = 110 在原位置+4,当前下标+旧数组长度

在转移链表时,结点的转移和插入是一致的,jdk1.7将采用头插法(转移完后链表反转),jdk1.8在分解完链表后直接移动头结点

为什么HashMap是线程不安全的?

主要体现

  • jdk1.7中,当多线程操作同一map时,在扩容的时候会因链表反转发生循环链表或丢失数据的情况

  • jdk1.8中,当多线程操作同一map时,会发生数据覆盖的情况

    在put的时候,由于put的动作不是原子性的,线程A在计算好链表位置后,挂起,线程B正常执行put操作,之后线程A恢复,会直接替换掉线程b put的值 所以依然不是线程安全的

为什么会遇到ConcurrentModificationException异常

首先我们要知道,HashMap中有个属性modCount,用于记录当前map的修改次数,在对map进行put、remove、clear等操作时都会增加modCount。

他的作用体现在对map进行遍历的时候,我们知道HashMap不是线程安全的,当对其进行遍历的时候,会先把modCount赋给迭代器内部的expectedModCount属性。当我们对map进行迭代时,他会时时刻刻比较expectedModCount和modCount是否相等,如果不相等,则说明有其他的线程对同一map进行了修改操作,于是迭代器抛出ConcurrentModificationException异常。

这就是Fail-Fast 机制

在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一个最高级别处理错误。

(你也会看到源码中有

int expectedModCount;   // For fast-fail

分析HashMap的containsKey方法的时间复杂度

最好O(1),最坏O(n)

因为下标是通过key的哈希值与数组长度-1得到的,因此不用便利数组,可以直接访问table[i]

若table[i]命中,则时间复杂度为O(1)

若没有直接命中,则还需遍历链表/红黑树,时间复杂度为O(n)/O(logn)

源码解读(部分)

jdk1.7

构造函数

/** 参数为空的构造方法 */
public HashMap() {
	this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/** 自定义初始化容量的构造方法 */
public HashMap(int initialCapacity) {
	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
 * 构造一个空的HashMap
 *
 * @param  initialCapacity 初始容量
 * @param  loadFactor      负载因子
 * @throws IllegalArgumentException 初始容量/负载因子非法则抛出异常
 */
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); // 初始负载因子小于等于0或NaN,抛异常

	this.loadFactor = loadFactor; // 将负载因子赋值给全局变量
	threshold = initialCapacity; // 将初始化容量赋值给阈值
	init(); 
}

(os:所以其实构造函数最开始只是初始化了两个参数

关于负载因子:负载因子表示一个散列空间的使用程度,负载因子越大则散列表的装填程度越高,填入表中的元素越多,越容易发生冲突;负载因子越小则散列表的装填程度越低,填入表中的元素越少,越不易发生冲突,但是容易造成空间浪费。

关于最后的init(),在源码里只是个空方法,似乎是为了子类的反序列化而准备的,在hashmap构造之后、数组创建之前调用,jdk1.8以后就没有了,具体参考

put

public V put(K key, V value) {
	// 如果数组为空,初始化一个数组,长度为大于阈值的最小的2的幂次数
	if (table == EMPTY_TABLE) {
		inflateTable(threshold);
	}
	// 键为空,调用putForNullKey方法,因为空键固定放在0号位
	if (key == null)
		return putForNullKey(value);
	int hash = hash(key); // 计算哈希值
	int i = indexFor(hash, table.length); // 计算下标i
	// 键不为空
	// 遍历i号位的链表
	for (Entry<K,V> e = table[i]; e != null; e = e.next) {
		Object k;
		// 找到键为key的结点,判断条件:
		if (e.hash == hash // 1.key的哈希值相同
				&& ((k = e.key) == key || key.equals(k))) { // 2.key的值相同
			V oldValue = e.value;
			e.value = value; // 覆写
			e.recordAccess(this);
			return oldValue;
		}
	}
	// 不存在,插入
	modCount++;
	addEntry(hash, key, value, i);
	return null;
}
addEntry
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);
}

get

public V get(Object key) {
	// key为空,调用getForNullKey方法,因为空键固定放在0号位
	if (key == null)
		return getForNullKey();
	// 得到key所在的结点
	Entry<K,V> entry = getEntry(key);
	// 结点为空直接返回null,反之获取对应val
	return null == entry ? null : entry.getValue();
}
getEntry / containsKey
public boolean containsKey(Object key) {
	return getEntry(key) != null;
}
final Entry<K,V> getEntry(Object key) {
	// 空map返回null
	if (size == 0) {
		return null;
	}

	int hash = (key == null) ? 0 : hash(key); // 得到key的哈希值
	// 计算key对应下标i,并遍历i号位的链表,找到key值对应的结点
	for (Entry<K,V> e = table[indexFor(hash, table.length)];
	     e != null;
	     e = e.next) {
		Object k;
        // 找到键为key的结点,判断条件:
		if (e.hash == hash && // 1.key的哈希值相同
				((k = e.key) == key || (key != null && key.equals(k)))) // 2.key的值相同
			return e;
	}
	// 找不到就返回null
	return null;
}

resize

/**
 * Rehashes the contents of this map into a new array with a
 * larger capacity.  This method is called automatically when the
 * number of keys in this map reaches its threshold.
 * 把当前map中的元素翻新到新的更大的新map中。当前map中的键值对达到阈值就会触发扩容方法。
 *
 * If current capacity is MAXIMUM_CAPACITY, this method does not
 * resize the map, but sets threshold to Integer.MAX_VALUE.
 * This has the effect of preventing future calls.
 * 如果当前容量已经达到最大容量,map将不会进行扩容,而是将阈值提到Integer.MAX_VALUE,从而达到以后不会再调用扩容方法的效果
 *
 * @param newCapacity the new capacity, MUST be a power of two;
 *        must be greater than current capacity unless current
 *        capacity is MAXIMUM_CAPACITY (in which case value
 *        is irrelevant).
 *        新的容量,必须是2的幂次,必须比当前容量大,除非当前容量已经达到最大容量
 */
void resize(int newCapacity) {
	Entry[] oldTable = table; // 当前数组
	int oldCapacity = oldTable.length; // 当前容量
	// 已经达到最大容量的情况下,将阈值升到Integer.MAX_VALUE,能够有效阻止日后调用扩容
	if (oldCapacity == MAXIMUM_CAPACITY) {
		threshold = Integer.MAX_VALUE;
		return;
	}

	Entry[] newTable = new Entry[newCapacity]; // 创建新数组
	transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 将旧数组中的元素全部转移到新数组
	table = newTable; // 新数组
	threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); // 重新计算阈值
}
transfer
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; // 指针记录e.next
			// 如果需要,重新计算哈希值
			if (rehash) {
				e.hash = null == e.key ? 0 : hash(e.key);
			}
			int i = indexFor(e.hash, newCapacity); // 重新计算下标
			// 头插法
			e.next = newTable[i]; // 将e插入到table[i]头部之前
			newTable[i] = e; // 将链表下移
			e = next; // 指向下一个要移动的结点
		}
	}
}

jdk1.8

多出的两个阈值

static final int TREEIFY_THRESHOLD = 8; // 链表树化阈值
static final int UNTREEIFY_THRESHOLD = 6; // 树链表化阈值

结点的引入

Node

构造函数

和1.7几乎相同,不做注释,两点不同的是

  • 移除了钩子函数init()
  • 1.7是直接将自定义容量赋给threshold,在inflateTable时才计算数组长度,而1.8是直接计算数组长度赋给threshold
/**
 * Constructs an empty HashMap with the specified initial
 * capacity and load factor.
 *
 * @param  initialCapacity the initial capacity
 * @param  loadFactor      the load factor
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
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);
}

put

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 * 以给定的键值对寻找是否有相同的键值,如果有,覆写。
 *
 * @param key key with which the specified value is to be associated 键
 * @param value value to be associated with the specified key 值
 *
 * @return the previous value associated with key, or
 *         null if there was no mapping for key.
 *         (A null return can also indicate that the map
 *         previously associated null with key.)
 *         有相同的key,则返回旧值;没有就返回null
 *         返回值同时可以作map中是否包含该key值的判断
 */
public V put(K key, V value) {
	// 计算哈希值开始找
	// onlyIfAbsent: false <=> 允许覆写
	// evict: true <=> 插入结点后是否允许操作(看了下应该是给子类LinkedHashMap用的
	return putVal(hash(key), key, value, false, true);
}
putVal
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,构造table数组
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;
	// 计算下标i,数组i号位的结点p为空,直接插入新结点
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else {
		// i号位不为空,遍历
		Node<K,V> e; K k;
		// 头结点是要找的结点(key的哈希值或key的值相同),e指向p
		if (p.hash == hash && // key的哈希值相同
				((k = p.key) == key || (key != null && key.equals(k)))) // 或 key值相同
			e = p;
		// p是树节点,调用红黑树的插入方法
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		// p是链表结点
		else {
			// 遍历链表,同时计数binCount,用于判断是否该树化链表
			for (int binCount = 0; ; ++binCount) {
				// 遍历到尾结点
				if ((e = p.next) == null) {
					// 插入结点
					p.next = newNode(hash, key, value, null);
					// 判断是否超出阈值,是则树化
                    // -1是因为除去了数组内的头结点
					if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
						treeifyBin(tab, hash);
					break;
				}
				// 找到目标结点,赋值给e
				if (e.hash == hash &&
						((k = e.key) == key || (key != null && key.equals(k))))
					break;
				p = e;
			}
		}
		// 找到目标结点,覆写(如果允许的话 或 旧值为null)并返回旧值
		if (e != null) { // existing mapping for key
			V oldValue = e.value;
			if (!onlyIfAbsent || oldValue == null)
				e.value = value;
			afterNodeAccess(e);
			return oldValue;
		}
	}
	++modCount; // 操作次数++
	// 键值对数量增加并判断是否该扩容(与1.7相比判断条件少了
	if (++size > threshold)
		resize();
	afterNodeInsertion(evict);
	return null;
}

注意两点

  • 先插入结点,再判断是否需要树化
  • 关于树化的阈值判断,模拟一下会知道,其实是当链表长度超出8才会转红黑树。
    面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第5张图片

get

public V get(Object key) {
	Node<K,V> e;
    // 计算哈希值寻找结点,有就返回val,没有就返回null
	return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode / containsKey
public boolean containsKey(Object key) {
   return getNode(hash(key), key) != null;
}

os:1.8真的很喜欢把赋值语句放在条件判断里…

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 // 数组长度大于0
			&& (first = tab[(n - 1) & hash]) != null) { // 计算下标i,i号位不为空
		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);
			// 如果不是,那就是链表结构,那就遍历寻找
			do {
				// 找到了就返回
				if (e.hash == hash &&
						((k = e.key) == key || (key != null && key.equals(k))))
					return e;
			} while ((e = e.next) != null);
		}
	}
	// 找不到就返回null
	return null;
}

resize

建议先把前面的扩容方式计算新下标的思路看一下

大概意思就是根据高位是0还是1来确定最后存的下标位置,0就保持原位,1就向后移一个旧长度的位置,这个结果和重新计算下标的结果是一致的
面试准备——jdk1.7与jdk1.8的HashMap(只重点分析了哈希算法、下标计算、get、put、resize)_第6张图片

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 * 初始化或翻倍数组。如果为空,初始化数组。
 *
 * @return the table
 */
final Node<K,V>[] resize() {
	Node<K,V>[] oldTab = table; // 暂存旧table数组
	int oldCap = (oldTab == null) ? 0 : oldTab.length; // 暂存旧容量,如果是初始化调用扩容方法,旧容量为空,赋0
	int oldThr = threshold; // 暂存旧阈值
	int newCap, newThr = 0;
	// 旧容量不为0
	if (oldCap > 0) {
		// 旧容量达到最大容量,不再扩容,将阈值提高至Integer.MAX_VALUE,有效阻止以后再度出现达到阈值的情况
		if (oldCap >= MAXIMUM_CAPACITY) {
			threshold = Integer.MAX_VALUE;
			return oldTab;
		}
		// 左移翻倍容量和阈值(前提是不超过最大容量并且旧容量需要超出默认容量
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				oldCap >= DEFAULT_INITIAL_CAPACITY)
			newThr = oldThr << 1; // double threshold
	}
	// 旧容量为0,即table数组还未创建
	// 为带参构造方法使用HashMap(int initialCapacity)及HashMap(int initialCapacity, float loadFactor)
	// 因为初始化容量一开始是被存放在threshold中的
	else if (oldThr > 0) // initial capacity was placed in threshold
		newCap = oldThr;
	// 旧容量为0,即table数组还未创建
	// 为无参构造方法使用HashMap(),使用默认值
	else {               // zero initial threshold signifies using defaults
		newCap = DEFAULT_INITIAL_CAPACITY;
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 初始化阈值
	}
    // 如果新阈值为0
    // 一种情况是使用了带参构造方法(else if (oldThr > 0))
    // 另一种是旧容量未达到默认容量大小或翻倍后超出最大容量(else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
    //						oldCap >= DEFAULT_INITIAL_CAPACITY))
    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];
	table = newTab; // 赋给全局变量
	// 这里开始实施转移,transfer
	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) // 尾结点,直接转移
					newTab[e.hash & (newCap - 1)] = e;
				else if (e instanceof TreeNode) // 树节点,调用树的split方法
					((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
				// 链表结点,保留之前的顺序(尾插法
				else { // preserve order
					// 第二层,链表遍历
					// (我们以该链表原本位置在010(2),旧容量为100(4)举例子,分析已知原本的哈希值可能为010,可能为110

					// head记录头结点,tail记录尾结点
					Node<K,V> loHead = null, loTail = null; // lo表示low表示0,记录要转移的结点
					Node<K,V> hiHead = null, hiTail = null; // hi表示high表示1,记录保留在原位置的结点
					Node<K,V> next;
					do {
						next = e.next;
						if ((e.hash & oldCap) == 0) { // 高位为0(010
							if (loTail == null)
								loHead = e;
							else
								loTail.next = e;
							loTail = e;
						}
						else { // 高位为1(110
							if (hiTail == null)
								hiHead = e;
							else
								hiTail.next = e;
							hiTail = e;
						}
					} while ((e = next) != null);
					// 高位为0的链表,保持原位置
					if (loTail != null) {
						loTail.next = null;
						newTab[j] = loHead;
					}
					// 高位为1的链表,转移阵地
					if (hiTail != null) {
						hiTail.next = null;
						newTab[j + oldCap] = hiHead;
					}
				}
			}
		}
	}
	// 返回新数组
	return newTab;
}

jdk1.7到1.8的主要变化

  1. 数据结构改变

    1. jdk1.7,数组+链表
    2. jdk1.8,数组+链表+红黑树
  2. 链表节点插入

    1. jdk1.7,头插法,再下移
    2. jdk1.8,尾插法
  3. 扩容

    1. 扩容条件减少

      1. jdk1.7,达到阈值并且新增结点发生碰撞才扩容,也就是说,如果达到阈值后,新增节点插入位置为空,则先不扩容
      2. jdk1.8,只要达到阈值就扩容
    2. 扩容方式(针对链表而言)

      1. jdk1.7,采取和插入时一致的方式,头插法,再下移
      2. jdk1.8,拆分链表后直接移动头结点
    3. 扩容下标计算

      1. jdk1.7,重新计算
      2. jdk1.8,根据高位二进制决定,为0则下标不变,为1则往后移动一个旧数组长度的距离
  4. 简化了哈希算法

    1. jdk1.7,使用了哈希种子,使用了四次扰动函数
    2. jdk1.8,使用一次扰动函数

你可能感兴趣的:(java)