HashMap你“啃”完了吗?

一、前言

HashMap在 Java 领域已经是一个老生常谈的知识点了,在面试和平时开发中基本上是一个很频繁的 API,这要得益于 Doug Lea 老爷子的操刀,设计的实在是太优秀了。

HashMap 最早出现是在 JDK 1.2 中,底层其实就是基于散列算法实现的,它允许 null 键和 null 值,在计算 hash 值的时候,null 键的 hash 值为 0。HashMap 并不保证键值对的顺序,可能由于某些操作导致顺序变化。它还是非线程安全的类,在多线程执行的时候可能导致问题。

接下来我们就从源码层面分析一下 HashMap,我们这里分析的主要是 JDK 1.8 中的 HashMap

二、源码剖析

1.构造方法

一般在使用 HashMap 的时候我们一般都是直接 new HashMap<>() 或者 new HashMap<>(10) 这两种方式,其实 HashMap 一共有四种构造方法,我们就逐个来分析一下。

(1)HashMap()

从源码中可以看到无参构造方法就是初始化了一个默认的初始加载因子 0.75。它默认的容量其实是在 put 元素的去实例化的,给了一个默认的初始容量 16,在 resize() 方法中,这个方法后续讲扩容的时候再详解。

(2)HashMap(int initialCapacity)

 这个有参构造函数只传入了一个初始容量,就是说明可以自己指定一个初始容量,它会调用下面 HashMap(int initialCapacity, float loadFactor) 这个有参构造方法,传入的加载因子是默认的 0.75。

(3)HashMap(int initialCapacity, float loadFactor)

这个有参构造传入了一个初始容量和一个初始加载因子,这里有三个 if 判断的逻辑,主要就是判断一下初始容量和加载因子的取值范围。初始容量的取值范围是 [0, 1 << 30]。

这里有一个 this.threshold = tableSizeFor(initialCapacity); 根据初始容量计算阈值的方法,这个方法里面的细节放到后面 初始化容量 这个小节单独讲解。

(4)HashMap(Map<? extends K, ? extends V> m)

 根据传入的 Map 来初始化一个 HashMap,用与指定Map相同的映射构造一个新的HashMap。创建HashMap时使用默认负载因子(0.75)和足够容纳指定Map中的映射的初始容量。

2.加载因子

从前面的初始化以及计算阈值的时候都提到一个加载因子,那么加载因子到底是什么呢?

static final float DEFAULT_LOAD_FACTOR = 0.75f;

在 HashMap 中加载因子主要是用来解决扩容的,加载因子决定了当数据量达到多少时进行扩容。因为 HashMap 就是一个散列算法的实现,那么他就会有 Hash 碰撞,选择一个合理的大小来扩容,其根本就是为了减少 Hash 碰撞。

这里默认的加载因子为什么是 0.75 ?

HashMap 中其实有两个参数影响其性能:初始容量和加载因子。

  • 初始容量:哈希表中桶的数量。
  • 加载因子:哈希表在扩容之前达到数量的一个度量。

当哈希表中的数据量达到 初始容量 * 加载因子 的时候,就会对该哈希表进行扩容,rehash操作。

这个问题可以从 HashMap 中顶部的注释中可以看到:

在注释中主要说明了是由于 泊松分布,0.75 是最适中的,其实总结来说:就是为了提高空间利用率和减少查询成本的一个折中。

如果加载因子越大,填满的元素越多,空间利用率越高,但冲突的概率就会越大;反之,如果加载因子越小,填满的元素越少,冲突的概率越小。

这里冲突的概率越大,查询的成本越高;反之,查询的效率越低。

所以这里就在空间利用率和查询成本中间找了一个折中,所以基于泊松分布,选取了一个 0.75。

3.初始化容量

在这里有这样一个问题:初始化 HashMap 传入 initialCapacity 是多少就是多少吗?

寻找比初始化容量大的 2 的幂次方最小值

在讲到初始化HashMap的时候有这样一段方法 this.threshold = tableSizeFor(initialCapacity);

  • threshold:阈值,在没有分配内存的时候,他的大小就是根据 tableSizeFor 计算出来的,也就是最终 HashMap 初始化容量的大小(具体的详解请看 resize 方法)。分配内存过后它的大小就是 capacity * load factor。
  • tableSizeFor方法:这个方法其实就是寻找比传入的初始值大的 2 的幂次方的最小值。比如初始的时候传的值是 10,那么这里计算出来的值应该就是 16(10 < 2^{4} = 16)。

计算阈值大小的tableSizeFor:

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;
}

这其中 MAXIMUM_CAPACITY = 1 << 30 HashMap容量的最大值。从上面的代码中可以看到里面有 5 个右移的操作,这主要是为了把二进制中的每个位置都置为 1,变成了 2 的幂次方减一,方便计算或运算。

我们来看一下传入初始值 10 的一整个计算过程:

 根据以上过程就可以看到最终的结果就是比传入的初始值大的 2 的幂次方的最小值

4.插入

 上面就是 HashMap 插入数据的一个整体流程,具体的步骤如下:

  1. 根据传入 key 计算哈希值(哈希扰动)。
  2. 判断 table 是否为空或者长度是否为0,如果是则进行初始化扩容。
  3. 根据哈希值计算下标,如果对应下标没有存放数据,就直接插入,否则则需要判断 key 是否已经存在,存在则覆盖,不存在则继续判断。
  4. 判断 tab[i = (n - 1) & hash] 是否为树节点,是则向树种插入节点,否则先链表中插入节点。
  5. 循环判断链表,如果节点的下一个节点是空,就判断链表长度大于等于8,并且桶数组元素大于64的话,链表就会转为红黑树(treeifyBin(tab, hash););如果节点不为空就会判断 key 是否相同,相同就覆盖,不相同就继续循环。
  6. 最后所有元素都处理完成后,就会判断容量是否超过了阈值,超过的话就要执行扩容的操作。
// HashMap put方法
public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
	Node<K,V>[] tab; Node<K,V> p; int n, i;
	// 初始化数组 table,在执行构造方法的时候并没有初始化数组,等到插入数据的时候才初始化
	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);
	else {
		Node<K,V> e; K k;
		// 如果键的值以及节点 hash 值等于链表中的第一个键值对节点时,就把 e 指向该键值对
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		// 判断节点是否是树节点,是的话就调用红黑树的插入方法
		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;
				}
				// 如果当前链表包含要插入的键值对,就会跳出循环
				if (e.hash == hash &&
					((k = e.key) == key || (key != null && key.equals(k))))
					break;
				p = e;
			}
		}
		// 如果要插入的键值对在 HashMap 中,如果在就覆盖
		if (e != null) { // existing mapping for key
			V oldValue = e.value;
			if (!onlyIfAbsent || oldValue == null)
				e.value = value;
			afterNodeAccess(e);
			return oldValue;
		}
	}
	++modCount;
	// 键值对数量超过阈值时,则进行扩容
	if (++size > threshold)
		resize();
	afterNodeInsertion(evict);
	return null;
}

(1)扩容

根据上面 put 方法可以看出来,HashMap 是由数组,链表和红黑树实现的,数组的容量大小是由初始化的时候决定的。当数组里面的元素容量达到阈值(初始化容量 * 加载因子)后,就需要扩容来存放更多的数据。

final Node<K,V>[] resize() {
	Node<K,V>[] oldTab = table;
	// 第一次插入的时候,这个时候 table 没有初始化,后面就要初始化 table
	int oldCap = (oldTab == null) ? 0 : oldTab.length;
	int oldThr = threshold;
	int newCap, newThr = 0;
	// 容量大于0表示已经初始化过
	if (oldCap > 0) {
		// 如果容量达到最大1 << 30则不再扩容
		if (oldCap >= MAXIMUM_CAPACITY) {
			threshold = Integer.MAX_VALUE;
			return oldTab;
		}
		// 按旧容量和阈值的2倍计算新容量和阀值
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				 oldCap >= DEFAULT_INITIAL_CAPACITY)
			newThr = oldThr << 1; // double threshold
	}
	else if (oldThr > 0) // initial capacity was placed in threshold
	    // 初始化时,将 threshold 的值赋值给 newCap,
        // HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
		newCap = oldThr;
	else {               // zero initial threshold signifies using defaults
	    // 调用无参构造方法时,数组桶数组容量为默认容量 1 << 4; aka 16
        // 阈值;是默认容量与负载因子的乘积,0.75
		newCap = DEFAULT_INITIAL_CAPACITY;
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	}
	// newThr为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"})
	// 初始化数组桶,用于存放key
	Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
	table = newTab;
	// 如果是第一次 put 的时候调用 resize,oldTab就等于 null,这个时候初始化完数组通后,就直接返回
	if (oldTab != null) {
		// 如果旧数组桶,oldCap有值,则遍历将键值映射到新数组桶中
		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
					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) {
							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;
					}
				}
			}
		}
	}
	return newTab;
}

这部分扩容的源码中主要包含如下:

  1. 如果是在第一次 put 的时候调用 resize() ,那么就会初始化桶数组进行返回。
  2. 扩容时计算出新的newCap、newThr,这是两个单词的缩写,一个是Capacity ,另一个是阀Threshold。
  3. newCap用于创新的数组桶 new Node[newCap];
  4. 随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。

在扩容中有一个最直接的问题,就是需要把元素拆分到新的数组中。在 JDK 1.8 中扩容可以不用重新计算每一个元素的 hash 值,而 JDK 1.7 中则是需要重新计算,这在 1.8 的时候得到了很大的优化。

来看一个例子,使用一些随机的字符串用 HashMap 中计算 hash 值的方法来计算他们分别在16位长度和32位长度数组下的索引分配情况,看哪些数据被重新路由到了新的地址:

public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("张三");
        list.add("李四");
        list.add("王五");
        list.add("赵六");
        list.add("曹操");
        list.add("刘备");
        list.add("吕布");
        list.add("周瑜");
        list.add("张飞");
        list.add("高兴");
        for (String key : list) {
            int hash = key.hashCode() ^ (key.hashCode() >>> 16);
            System.out.println("字符串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \t\tIdx(32):" + ((32 - 1) & hash));
        }
    }

字符串:张三     Idx(16):2         Idx(32):2
字符串:李四     Idx(16):1         Idx(32):1
字符串:王五     Idx(16):7         Idx(32):7
字符串:赵六     Idx(16):9         Idx(32):9
字符串:曹操     Idx(16):8         Idx(32):24
字符串:刘备     Idx(16):5         Idx(32):5
字符串:吕布     Idx(16):4         Idx(32):4
字符串:周瑜     Idx(16):14         Idx(32):30
字符串:张飞     Idx(16):2         Idx(32):18
字符串:高兴     Idx(16):15         Idx(32):15

从上面的结果可以看出在 16 位的情况下,{张三,张飞} 这两个是在同一个索引位置下面的,而他们在 32 位的情况下,{张飞} 的位置就进行了转移。

如图上所示就展示了 HashMap 从容量 16 扩容到 32 的时候,元素的一个转移过程。

从图中也可以看到重新计算 hash 值和使用 e.hash & oldCap == 0 来判断是否移位的结果是一样的。

(2)链表转红黑树

HashMap 在 JDK 1.8 之前采用的数组加链表的方式,如果链表的长度太长,查找数据的时间复杂度就会达到 O(n) ,链表越长性能就会越差。所以在 JDK 1.8 的时候,当链表长度大于 8 ,并且数组容量大于 64 的时候,会把链表优化为自平衡的红黑树,这时查找数据的时间复杂度会近似于 O(logn),这样采取多种数据结构来整体提升 HashMap 的效率。

 通过这张图可以看到链表树化的一个过程,链表转换为红黑树后还是保留了原来链表的顺序,接下来就看一下 HashMap 源码中树化的一个过程:

final void treeifyBin(Node<K,V>[] tab, int hash) {
	int n, index; Node<K,V> e;
	// 判断桶数组容量是否小于 64
	if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
		resize();
	else if ((e = tab[index = (n - 1) & hash]) != null) {
		// hd:头部,tl:尾部
		TreeNode<K,V> hd = null, tl = null;
		do {
			// 将普通节点转换为树节点,但此时还不是红黑树,也就是说还不一定平衡
			TreeNode<K,V> p = replacementTreeNode(e, null);
			if (tl == null)
				hd = p;
			else {
				p.prev = tl;
				tl.next = p;
			}
			tl = p;
		} while ((e = e.next) != null);
		if ((tab[index] = hd) != null)
			// 转红黑树操作,这里需要循环比较,染色、旋转
			hd.treeify(tab);
	}
}

这部分源码包含主要内容如下:

  1. 链表树化的条件有两点;链表长度大于等于8、桶容量大于64,否则只是扩容,不会树化。
  2. 链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序tl.next = p,这主要方便后续树转链表和拆分更方便。
  3. 链表转换为树后,会转红黑树。

(3)红黑树转链表

在下次扩容的时候也会判断是否是树节点,然后进行红黑树转链表的操作。

因为在链表转红黑树的时候记录了链表的顺序,这时候转换的时候就会相对简单。

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
	...
	...
	if (loHead != null) {
		if (lc <= UNTREEIFY_THRESHOLD)
			tab[index] = loHead.untreeify(map);
		...
		...
	}
	if (hiHead != null) {
		if (hc <= UNTREEIFY_THRESHOLD)
			tab[index + bit] = hiHead.untreeify(map);
		...
		...
	}
}


final Node<K,V> untreeify(HashMap<K,V> map) {
	Node<K,V> hd = null, tl = null;
	// 遍历TreeNode
	for (Node<K,V> q = this; q != null; q = q.next) {
		// TreeNode替换Node
		Node<K,V> p = map.replacementNode(q, null);
		if (tl == null)
			hd = p;
		else
			tl.next = p;
		tl = p;
	}
	return hd;
}

5.查找

get 方法比较简单,我们直接上源码:

public V get(Object key) {
	Node<K,V> e;
	// 和插入一样需要计算key的hash值
	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;
	// 判断桶数组是否为空和长度值是否等于0
	if ((tab = table) != null && (n = tab.length) > 0 &&
		(first = tab[(n - 1) & hash]) != null) {
		// 计算下标,哈希值与数组长度-1
		if (first.hash == hash && // always check first node
			((k = first.key) == key || (key != null && key.equals(k))))
			return first;
		if ((e = first.next) != null) {
			// TreeNode 节点直接调用红黑树的查找方法,时间复杂度O(logn)
			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);
		}
	}
	return null;
}

这里面主要包含:

  1. 根据 key 计算 hash 值。
  2. 判断桶数组是否已经初始化或者里面是否有元素。
  3. 根据 (n - 1) & hash 计算数组下标。
  4. 确定了数组的下标接下来就是判断是红黑树还是链表,然后执行相应的查找和遍历操作了。

6.删除

删除的逻辑也挺简单的,直接上源码:

public V remove(Object key) {
	Node<K,V> e;
	// 一样是计算根据keyhash值
	return (e = removeNode(hash(key), key, null, false, true)) == null ?
		null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
	Node<K,V>[] tab; Node<K,V> p; int n, index;
	// 定位桶数组中的下标位置,index = (n - 1) & hash
	if ((tab = table) != null && (n = tab.length) > 0 &&
		(p = tab[index = (n - 1) & hash]) != null) {
		Node<K,V> node = null, e; K k; V v;
		// 如果键的值与链表第一个节点相等,则将 node 指向该节点
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			node = p;
		else if ((e = p.next) != null) {
			// 树节点,调用红黑树的查找方法,定位节点。
			if (p instanceof TreeNode)
				node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
			else {
				// 遍历链表,找到待删除节点
				do {
					if (e.hash == hash &&
						((k = e.key) == key ||
						 (key != null && key.equals(k)))) {
						node = e;
						break;
					}
					p = e;
				} while ((e = e.next) != null);
			}
		}
		// 删除节点,以及红黑树需要修复,因为删除后会破坏平衡性。链表的删除更加简单。
		if (node != null && (!matchValue || (v = node.value) == value ||
							 (value != null && value.equals(v)))) {
			if (node instanceof TreeNode)
				((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
			else if (node == p)
				tab[index] = node.next;
			else
				p.next = node.next;
			++modCount;
			--size;
			afterNodeRemoval(node);
			return node;
		}
	}
	return null;
}

删除的逻辑很简单,直接看代码里面的注释就行。

7.遍历

遍历这里就大致讲一下使用方法,具体的源码请自己分析

KeySet

for (Object key : hashMap.keySet()) {
	System.out.print(key + " ");
}

EntrySet

for (Map.Entry<Object, Object> entry : hashMap.entrySet()) {
	System.out.print(entry + " ");
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值