HashMap 的源码分析

本文详细介绍了Java中的位运算符及应用场景,并深入探讨了HashMap的工作原理,包括其哈希函数设计、扩容机制等内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一.java中的位运算符

在具体分析之前,先补充点基础知识
1.1 算术位运算符
**<< :代表左移 << 3 左移三位,即本来数值 乘于 2^3; 左移低位补0**
public void test(){
   int x = 4;
   System.out.println(Integer.toBinaryString(x)); //100
   int y = 4<<2;
   System.out.println(Integer.toBinaryString(y)); //10000
   System.out.println(y);   //16 = 4*2^2
}

>>:代表右移 >> 3 右移三位, 即本来数值处于2^3; 右移,最高符号位不变,其余位补0

public void test(){
   int x = 16;
   System.out.println(Integer.toBinaryString(x)); //10000
   int y = 16>>2;
   System.out.println(Integer.toBinaryString(y)); //100
   System.out.println(y);   //4 = 16/2^2
}

>>>:代表无符号右移 任何值都会移动。没有最高位作为符号位一说了。

public void test(){
   // 为正数时,无符号右移
   int x = 16;
   System.out.println(Integer.toBinaryString(x)); //10000
   int y = 16>>>2;
   System.out.println(Integer.toBinaryString(y)); //100
   System.out.println(y);   //4

   // 为负数时,无符号右移
   int x = -16;
   System.out.println(Integer.toBinaryString(x)); //11111111111111111111111111110000
   int y = -16>>>2;
   System.out.println(Integer.toBinaryString(y)); //00111111111111111111111111111100
   System.out.println(y);   //1073741820
}

由上面的代码可知:当一个数为正数时,>> 和 >>> 作用是一样的,也可以作为除于2 来表示。但当一个数为负数时,>> 和 >>> 就不能等价了。来分析一下上面的代码:

  • System.out.println(Integer.toBinaryString(-16)); //11111111111111111111111111110000
    为什么-16的二进制码这样表示,在计算机中是这样表示的呢?
    在计算机中,数据的存储和计算都是采用补码的形式,这样做的好处是在计算机中,加减都能变成加法: A-B=A+(-B补码)。因此-16的原码是1000/0000/0000/0000/0000/0000/0001/0000 它的补码按照规则:从低位开始,一直到第一个为1的位数,保留这个1,之后除符号位,所有的位数取反。 因此补码就如上所示。

  • int y = -16>>>2; 即无符号右移4位,因此二进制形式变成了
    0011/1111/1111/1111/1111/1111/1111/1100
    最高位符号位发生了改变,右移高位补0,所以直接变成了正数了。

可以看出来,>>>的作用并不是乘除,最典型的应用就是获取 int 类型的符号位。
通过这个式子 int y = (x>>>31) & 1 来获取符号位,如果 y = 1,负数,y = 0,正数。

1.2 逻辑位运算符
***& 与:对二进制每一位进行逻辑与运算, 都为 1 才为 1。***
1100 & 0101 = 0100

与位运算的典型应用如下:

  • 将数据清零 1101 & 0000 = 0000

  • 获取数据特定位,如,获取 101010 的低4位
    101010 & (16-1) = 101010 & 1111 = 1010

  • 保留数据特定位,如,保存 10110101 的 低3位
    10110101 & 00000100 = 00000010

| 或:对二进制每一位进行逻辑或运算,有一个为 1 就为1 。

1100 | 0101 = 1101
或运算的运用不多,主要是对特定位置 1
如,把 11010100 的 低三位置 1
11010100 | 0x7 = 11010111

^ 异或 : 也叫半加法,即加了不进位 。相同为0,不同为1.

1010 ^ 1011 = 0001

异或的性质:

n ^ 0 = n;      //任何数和0异或,为他本身;
n ^ n = 0;     // 任何数和自己异或,为0;

典型应用:

  • 不交换也可以两个数互换:
 a = a ^ b; 
 b = b ^ a;  //b = b ^ a ^ b = a
 a = a ^ b;  //a = a ^ b ^ a = b
  • 排除一个数组中出现次数为奇数的数字:
public int getOdd(int[] arr){
	int x = 0;
	for(int i = 0; i < arr.length; i++){
		x ^= arr[i];
	}
	return x;
}
  • 将指定位取反
// 将第四位取反
 1100101 ^ 0xf = 1101010
  • 将内容加密解密
假设一篇文章 ,将所有字符都和一个密码psw 异或,加密;
然后再异或一次,就可以还原,解密。

位运算符的优先级: 优先级由高到低
~ << >> >>> & ^ |

二. 哈希函数:

####2.1 概念:
Hash,也可以叫做散列,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。
####2.2 哈希函数的实现和讨论
hash转换一般是一种压缩映射,这里以哈希表的实现来进一步解释这句话:
这里写图片描述
整个图是是HashMap的实现,下面会详细分析,但这并不是散列表,散列表只是图中左边那个有着固定长度的数组,而后面的链表是为了解决hash冲突而产生的。也就是被压缩成的固定长度,它的长度才是经过hash函数之后得到的值。

在看到这个图的时候,脑海中想一下,什么样的hash函数才能称作好的hash函数呢?

  1. 首先数据肯定得最好能均匀排列
  2. hash转换的效率要高

先来看一个最简单的hash法:取余法

// m为table的长度
public int hash(int key){
	return key % m;
}

关键在于 m 的取值,最好是素数,这种设计能最大可能让数据均匀分布在数据表中。来实际证明一下,对0~20 进行hash
表1 : m = 6

012345
012345
67891011
121314151617
181920

表2 : m = 7

0123456
0123456
78910111213
14151617181920

通过两个表对比,不是都分布的很均匀吗?
但是,要记住一点原始数据不大会是真正的随机的,可能有某些规律,比如大部分是偶数,这时候如果HASH数组容量是偶数,容易使原始数据HASH后不会均匀分布。
比如 2 4 6 8 10 12这6个数,如果对 6 取余

012345
24
6810
12

得到 2 4 0 /2 4 0 只会得到3种HASH值,冲突会很多。

如果对 7 取余

0123456
246
81012

得到 2 4 6 1 3 5 得到6种HASH值,没有冲突。

这就是取余法的取素数的好处,因为素数除了1,只有它本身能被整除。

key % m 这种简单的形式,会造成原始数据经过hash后,依然相邻,所以有一种改进方法。

(a * key + b)% m

三.HashMap的分析:

终于到这里了-。- 由于 java8 对于HashMap的改动非常大,这里就以 java8 的源码来分析。

####3.1变量定义部分:

/* HashMap 继承的是AbstractMap ,而HashTable 继承的是 Dictionary ,HashTable 在java8 中基本不使用了*/
public class HashMap<K,V> extends AbstractMap<K,V>
		implements Map<K,V>, Cloneable, Serializable {

	private static final long serialVersionUID = 362498820763181265L;

	/**
	 * 默认的HashMap中散列表的长度,必须是2的指数倍(这里非常重要,因为这和HashTable中的哈希函数设计有关)
	 */
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
	/**
	 * 最大的容量。
	 * MUST be a power of two <= 1<<30.
	 */
	static final int MAXIMUM_CAPACITY = 1 << 30;

	/**
	 * 默认的加载因子。 用来计算阀值的
	 */
	static final float DEFAULT_LOAD_FACTOR = 0.75f;

	/**
	 * The bin count threshold for using a tree rather than list
	 * java8 之后,如果 HashMap 中元素较多,那么 HashMap 中的原来链表阶段,
	 * 就会变成红黑树。 这里只默认的红黑树的阀值。
	 */
	static final int TREEIFY_THRESHOLD = 8;

	/**
	 * 默认的链表阀值。
	 */
	static final int UNTREEIFY_THRESHOLD = 6;

	/**
	 * The smallest table capacity for which bins may be treeified.
	 * 当容量超过 64 之后,链表结构就变成红黑树结果。
	 * 这就是java8 的改变。
	 */
	static final int MIN_TREEIFY_CAPACITY = 64;

	/**
	 * 当为链表时,采用Node节点,红黑树采用 TreeNode 节点
	 */
	static class Node<K,V> implements Map.Entry<K,V> {
		final int hash;
		final K key;
		V value;
		java.util.HashMap.Node<K,V> next;
		/* 获取 Node 的hash值,这里采用的是将 key 和 value 的hash值 异或混合*/
		public final int hashCode() {
			// 异或,相同都为0.
			return Objects.hashCode(key) ^ Objects.hashCode(value);
		}
	}

####3.2 put方法:

public V put(K key, V value) {
		// 最终调用的putVal,并且传了一个 hash(key) 过去
		return putVal(hash(key), key, value, false, true);
	}

    /**
	 * 为了避免碰撞采取的一种新的 hash 策略
	 * 这里就用到了前面提到了 无符号右移 ,hash(key) 
	 * 本质是,把高16位和低16位混合。 这种处理方式叫做“扰动函数”
	 */
	static final int hash(Object key) {
		int h;
		return (key == null) ? 0 
		: (h = key.hashCode()) ^ (h >>> 16);
	}
	
	final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
		java.util.HashMap.Node<K,V>[] tab;
		java.util.HashMap.Node<K,V> p;
		int n, i;
		if ((tab = table) == null || (n = tab.length) == 0)       // 获取 散列表 tab 的长度。
			n = (tab = resize()).length;
		/**
		 *  这里出现了一个 (n-1) & hash 是一个非常巧妙的处理方式,
		 *  hash 为 key 经过 hashCode() 处理过 再经过 
		 *  hash() 处理后的值。 n 为 tab 的长度。
		 *  又因为 n = 2 ^ m ,则 n-1 化为二进制代表 m 位都是 1
		 *  如: 16 = 2 ^ 4 ,则 15 的二进制是 1111
		 *  前面有提到 & 有截取特定位数的能力。
		 *  这里(n - 1) & hash 就是截取了hash值的低4位。
		 *  
		 */
		if ((p = tab[i = (n - 1) & hash]) == null)
			tab[i] = newNode(hash, key, value, null);
		else {
			java.util.HashMap.Node<K,V> e; K k;
			// 这里是比较 要添加的对象 是否和在 table 中的 p 的key值是一样的。
			if (p.hash == hash &&
					((k = p.key) == key || (key != null && key.equals(k))))
				e = p;
			// 如果节点是TreeNode 的话说明已经转化成红黑树
			else if (p instanceof java.util.HashMap.TreeNode)
				e = ((java.util.HashMap.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);
						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;
				}
			}
			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;
	}

总结:hashMap 中的 hash 函数的设计步骤如下:

  1. 将 key 调用 Object 自带的hashCode() 方法,获取初始hash值。
    h = key.hashCode()

  2. 将初始hash值的高16位和低16位混合。
    h ^ (h >>> 16);

  3. 截取相应位数的值
    (n - 1) & hash

| code |说明|
| ------------- |
| 0010/0010/1001/0010/0111/1010/1000/0001 | h=key.hashCode()
| 0000/0000/0000/0000/0010/0010/1001/0010 | h >>> 16
| 0010/0010/1001/0010/0101/1000/0001/0011 | hash=h ^ h >>> 16
| 0011 | (2 ^ 4 - 1) & hash

通过上表可以看出来最后取到 0011 = 3 ,这里有个细节就是散列表的长度为 2 ^ m ,那么就取低 m 位。这样hash值的变化最大不过散列表的长度。可推出 当 n = 2 ^ m 的时候
hash % n = (n - 1) & hash

####3.3 散列表扩容方法
一般来说,在使用hashMap的时候,要大概估算一下 hash表的大小,且一般为 2 的幂方,因为hash扩容是一个非常损耗性能的行为。HashMap 在两种情况下会产生扩容:

  • 散列表初始值为 0 的时候

  • 散列表的个数超过阀值的时候

来看一下其中的扩容方法:

final java.util.HashMap.Node<K,V>[] resize() {
		// 得到旧表
		java.util.HashMap.Node<K,V>[] oldTab = table;
		// 旧表的大小,旧表为空 那么 =0 ; 否则等于 oldTab.length;
		int oldCap = (oldTab == null) ? 0 : oldTab.length;
		// 旧的阀值
		int oldThr = threshold;
		int newCap, newThr = 0;
		if (oldCap > 0) {   // 如果旧表长度大于0
			if (oldCap >= MAXIMUM_CAPACITY) {  // 再次判断旧表是否大于 最大容量 2^30 ,
				// 如果大于,那么 把阀值定为 2^31-1,不会再扩容了,因为后面的扩容
				// 策略会使得 长度为 2^31 ,溢出了。
				threshold = Integer.MAX_VALUE;
				return oldTab;
			}
			// 如果表不大于最大容量,那么就把表长度扩大两倍
			else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
					oldCap >= DEFAULT_INITIAL_CAPACITY)
				newThr = oldThr << 1; // double threshold   // 新的阀值也扩大两倍
		}
		// 下面是初始状态 即 oldCap = 0 的状态
		else if (oldThr > 0) // initial capacity was placed in threshold
			newCap = oldThr;
		else {               // zero initial threshold signifies using defaults
			newCap = DEFAULT_INITIAL_CAPACITY;
			newThr = (int)(DEFAULT_LOAD_FACTOR * 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"})
		java.util.HashMap.Node<K,V>[] newTab = (java.util.HashMap.Node<K,V>[])new java.util.HashMap.Node[newCap];
		table = newTab;
		// 如果旧表不为空,说明有数据要转移。
		if (oldTab != null) {
			for (int j = 0; j < oldCap; ++j) {
				java.util.HashMap.Node<K,V> e;
					// 把旧表的值赋给 e , 把 e 作为临时变量,进行操作
				// 如果 e 不为空,就把e赋值给 新表
				if ((e = oldTab[j]) != null) {
					oldTab[j] = null;
					if (e.next == null)
						// 给新表赋值的时候,需要重新计算hash值,但这里有一个
						// 非常巧妙的地方,依然是用 原来的hash值 和 数组长度 &
						// 如果初始值是 16 ,那就是截取 4位 ,而扩展一倍,那么就
						// 截取5位,以此作为 新的hash 值。
						newTab[e.hash & (newCap - 1)] = e;
					else if (e instanceof java.util.HashMap.TreeNode)
						((java.util.HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
					else { // preserve order
						java.util.HashMap.Node<K,V> loHead = null, loTail = null;
						java.util.HashMap.Node<K,V> hiHead = null, hiTail = null;
						java.util.HashMap.Node<K,V> next;
						do {
						// 这里是节点为链表时的节点复制
					}
				}
			}
		}
		return newTab;
	}

最后一个问题:
那么,为什么hashMap 没有采用前面的取余法,没有采用素数作为散列表的长度呢?
首先一个好的hash函数,必须兼顾均匀性 和 效率高,还有一点是安全性(比如MD5函数),取余法确实简单实用,做到了均匀性,但是在效率性上非常的低,安全性也不高。 在计算机中取模运算是效率非常低的,hashmap中实质也是采用了取余法,但是这里利用了 hash % n = (n - 1) & hash ,将取模运算变成了位运算,而这里不用 素数 作为散列表长度是因为要满足 n = 2 ^ m ,而素数带来的均匀性,也因为扰动函数的加入变得满足了。

总结:

  1. table的初始化时机是什么时候,初始化容量是多少? 阈值是多少?实际能容下多少元素。

    hashMap采用懒加载的方式初始化,在put元素的时候,如果table没有初始化,那么先进行初始化,如果没有设置初始化容量,那么初始化容量 = 16, 阈值 = 12,实际能容下12个。

  2. 什么时候触发扩容?扩容之后的容量是多少,阈值是多少?

    当添加元素后,元素个数大于阈值后,会触发扩容; 扩容之后容量为原来容量的两倍。阈值为容量 * 加载因子。

  3. table的容量为啥是2的n次幂等?求索引的时候为什么是:h&(length-1)?

    这和hashmap所用的散列函数有关,hashmap的hash = hashcode ^ hascode >>>16 & (length-1) 正好截取的值,不会超过散列表的容量。

  4. 为什么加载因子默认是0.75,并且不推荐我们修改?

    hashmap这种散列表结构,如果表中空闲位置越多,越不容易发生hash冲突,为了保证散列表的性能,因此总会报证表中有空闲位置,以此来降低hash冲突的可能性。提高插入和查找的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值