0
点赞
收藏
分享

微信扫一扫

一篇文章让你彻底知道面试被问到吐的HashMap底层原理

蒸熟的土豆 2022-01-05 阅读 103

前言

HashMap源码和底层原理,现在在所有的公司面试中都会问到。不知道你有没有,反正我是被问吐了,几乎五家四问。所以我就干脆直接给它整明白算了,不然每次面试总是糊弄面试官也不是个事。

看完这篇文章(可能有点枯燥,毕竟我也是第一次看源码),你绝对能和你心爱的面试官,少说也得斗个三百回合。

HashMap存储结构

在JDK1.7及之前,是用数组加链表的方式存储的。
在这里插入图片描述大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色
的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。

在jdk1.8及之后,是以数据加链表加红黑树存储的。
在这里插入图片描述但是,众所周知,当链表的长度特别长的时候,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,查询效率将直线下降,查询的时间复杂度为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

HashMap实现原理(常量剖析)

  1. HashMap实现了Map接口
    ![在这里插入图片描述](https://img-blog.csdnimg.cn/333ecc5a1fe64ae386a96504443d6d4a.png
  2. 默认的初始化容量为16,必须是2的n次幂
    在这里插入图片描述
  3. HashMap的最大容量为2的30次方
    在这里插入图片描述
  4. 默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
    多说一句,为什么设置 0.75 这个值呢,简单来说就是时间和空间的权衡。
    若小于0.75如0.5,则数组长度达到一半大小就需要扩容,空间使用率大大降低,
    若大于0.75如0.8,则会增大hash冲突的概率,影响查询效率。

    在这里插入图片描述
  5. 若链表中节点个数超过这个阈值8,就会转化为红黑树。
    当红黑树上的元素个数,减少到6个时,就退化为链表。
    说到这里了,不得不提问一句,为什么元素个数到6时,才会退化为链表,而不是7呢?
    这是因为啊,元素个数7它是一个缓冲区,你是红黑树的时候到6就是链表,你是链表的时候到8就是红黑树,但是你是7的时候,它有可能是链表还没有变成红黑树的时候,也有可能是红黑树没退化成链表的时候,为了防止来回转换,节省开销,所以才设成6呢。
    在这里插入图片描述
  6. 链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。这是为了避免,数组扩容和树化阈值之间的冲突。
    在这里插入图片描述

jdk1.8之后HashMap的插入详解

//put方法,会先调用一个hash()方法,得到当前key的一个hash值,
//用于确定当前key应该存放在数组的哪个下标位置
//这里的 hash方法,我们姑且先认为是key.hashCode(),其实不是的,一会儿细讲
public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}

//把hash值和当前的key,value传入进来
//这里onlyIfAbsent如果为true,表明不能修改已经存在的值,因此我们传入false
//evict只有在方法 afterNodeInsertion(boolean evict) { }用到,可以看到它是一个空实现,因此不用关注这个参数
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是否为空,如果空的话,会先调用resize扩容
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;
	//根据当前key的hash值找到它在数组中的下标,判断当前下标位置是否已经存在元素,
	//若没有,则把key、value包装成Node节点,直接添加到此位置。
	// i = (n - 1) & hash 是计算下标位置的,为什么这样算,后边讲
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else { 
		//如果当前位置已经有元素了,分为三种情况。
		Node<K,V> e; K k;
		//1.当前位置元素的hash值等于传过来的hash,并且他们的key值也相等,
		//则把p赋值给e,跳转到①处,后续需要做值的覆盖处理
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		//2.如果当前是红黑树结构,则把它加入到红黑树 
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		else {
		//3.说明此位置已存在元素,并且是普通链表结构,则采用尾插法,把新节点加入到链表尾部
			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;
				}
				//若在链表中找到了相同key的话,直接退出循环,跳转到①处
				if (e.hash == hash &&
					((k = e.key) == key || (key != null && key.equals(k))))
					break;
				p = e;
			}
		}
		//①
		//说明发生了碰撞,e代表的是旧值,因此节点位置不变,但是需要替换为新值
		if (e != null) { // existing mapping for key
			V oldValue = e.value;
			//用新值替换旧值,并返回旧值。
			if (!onlyIfAbsent || oldValue == null)
				e.value = value;
			//看方法名字即可知,这是在node被访问之后需要做的操作。其实此处是一个空实现,
			//只有在 LinkedHashMap才会实现,用于实现根据访问先后顺序对元素进行排序,hashmap不提供排序功能
			// Callbacks to allow LinkedHashMap post-actions
			//void afterNodeAccess(Node<K,V> p) { }
			afterNodeAccess(e);
			return oldValue;
		}
	}
	//fail-fast机制
	++modCount;
	//如果当前数组中的元素个数超过阈值,则扩容
	if (++size > threshold)
		resize();
	//同样的空实现
	afterNodeInsertion(evict);
	return null;
}

总结来说

  1. 判断table是否为空,如果空的话,会先调用resize扩容;
  2. 根据当前key的 hash 值,通过 (n - 1) & hash计算应当存放在数组中的下标 index ;
  3. 查看 table[index] 是否存在数据,没有数据就构造一个 Node 节点存放在 table[index] 中;
  4. 存在数据,说明发生了 hash 冲突,继续判断 key 是否相等,如果相等,用新的 value 替换原数据(这里onlyIfAbsent 为 false);
  5. 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创建树型节点插入红黑树中;
  6. 如果不是树型节点,则采用尾插法,把新节点加入到链表尾部;判断链表长度是否大于 8, 大于的话链表转换为红黑树;
  7. 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

get方法详解

public V get(Object key) {
	Node<K,V> e;
	//如果节点为空,则返回null,否则返回节点的value。这也说明,hashMap是支持value为null的。
	//因此,我们就明白了,为什么hashMap支持Key和value都为null
	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;
	//首先要确保数组不能为空,然后取到当前hash值计算出来的下标位置的第一个元素
	if ((tab = table) != null && (n = tab.length) > 0 &&
		(first = tab[(n - 1) & hash]) != null) {
		//若hash值和key都相等,则说明我们要找的就是第一个元素,直接返回
		if (first.hash == hash && // always check first node
			((k = first.key) == key || (key != null && key.equals(k))))
			return first;
		//如果不是的话,就遍历当前链表(或红黑树)
		if ((e = first.next) != null) {
			//如果是红黑树结构,则找到当前key所在的节点位置
			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;
}

这里,get(key)方法时获取key的hash值,计算hash&(n-1)得到在链表数组中的位置first=tab[hash&(n-1)],先判断first的key是否与参数key相等,若相等的话,说明我们要找的就是第一个元素,直接返回。

不等就根据不同的存储结构来做操作,如果是红黑树结构,则找到当前key所在的节点位置,如果是链表,遍历后面的链表找到相同的key值返回对应的Value值即可。

HashMap的扩容机制resize()

构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16),如果Node[]数组中的元素达到(填充比*Node.length)重新调整HashMap大小 变为原来2倍大小,扩容很耗时。

其实在上边 put 方法中,我们会发现,当数组为空的时候,会调用 resize 方法,当数组的 size 大于阈值的时候,也会调用 resize方法。

final Node<K,V>[] resize() {
	//旧数组
	Node<K,V>[] oldTab = table;
	//旧数组的容量
	int oldCap = (oldTab == null) ? 0 : oldTab.length;
	//旧数组的扩容阈值,注意看,这里取的是当前对象的 threshold 值,下边的第2种情况会用到。
	int oldThr = threshold;
	//初始化新数组的容量和阈值,分三种情况讨论。
	int newCap, newThr = 0;
	//1.当旧数组的容量大于0时,说明在这之前肯定调用过 resize扩容过一次,才会导致旧容量不为0。
	//为什么这样说呢,之前我在 tableSizeFor 卖了个关子,需要注意的是,它返回的值是赋给了 threshold 而不是 capacity。
	//我们在这之前,压根就没有在任何地方看到过,它给 capacity 赋初始值。
	if (oldCap > 0) {
		//容量达到了最大值
		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
	}
	//2.到这里,说明 oldCap <= 0,并且 oldThr(threshold) > 0,这就是 map 初始化的时候,第一次调用 resize的情况
	//而 oldThr的值等于 threshold,此时的 threshold 是通过 tableSizeFor 方法得到的一个2的n次幂的值(我们以16为例)。
	//因此,需要把 oldThr 的值,也就是 threshold ,赋值给新数组的容量 newCap,以保证数组的容量是2的n次幂。
	//所以我们可以得出结论,当map第一次 put 元素的时候,就会走到这个分支,把数组的容量设置为正确的值(2的n次幂)
	//但是,此时 threshold 的值也是2的n次幂,这不对啊,它应该是数组的容量乘以加载因子才对。别着急,这个会在③处理。
	else if (oldThr > 0) // initial capacity was placed in threshold
		newCap = oldThr;
	//3.到这里,说明 oldCap 和 oldThr 都是小于等于0的。也说明我们的map是通过默认无参构造来创建的,
	//于是,数组的容量和阈值都取默认值就可以了,即 16 和 12。
	else {               // zero initial threshold signifies using defaults
		newCap = DEFAULT_INITIAL_CAPACITY;
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	}
	//③ 这里就是处理第2种情况,因为只有这种情况 newThr 才为0,
	//因此计算 newThr(用 newCap即16 乘以加载因子 0.75,得到 12) ,并把它赋值给 threshold
	if (newThr == 0) {
		float ft = (float)newCap * loadFactor;
		newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
				  (int)ft : Integer.MAX_VALUE);
	}
	//赋予 threshold 正确的值,表示数组下次需要扩容的阈值(此时就把原来的 16 修正为了 12)。
	threshold = newThr;
	@SuppressWarnings({"rawtypes","unchecked"})
		//我们可以发现,在构造函数时,并没有创建数组,在第一次调用put方法,导致resize的时候,才会把数组创建出来。这是为了延迟加载,提高效率。
		Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
	table = newTab;
	//如果原来的数组不为空,那么我们就需要把原来数组中的元素重新分配到新的数组中
	//如果是第2种情况,由于是第一次调用resize,此时数组肯定是空的,因此也就不需要重新分配元素。
	if (oldTab != null) {
		//遍历旧数组
		for (int j = 0; j < oldCap; ++j) {
			Node<K,V> e;
			//取到当前下标的第一个元素,如果存在,则分三种情况重新分配位置
			if ((e = oldTab[j]) != null) {
				oldTab[j] = null;
				//1.如果当前元素的下一个元素为空,则说明此处只有一个元素
				//则直接用它的hash()值和新数组的容量取模就可以了,得到新的下标位置。
				if (e.next == null)
					newTab[e.hash & (newCap - 1)] = e;
				//2.如果是红黑树结构,则拆分红黑树,必要时有可能退化为链表
				else if (e instanceof TreeNode)
					((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
				//3.到这里说明,这是一个长度大于 1 的普通链表,则需要计算并
				//判断当前位置的链表是否需要移动到新的位置
				else { // preserve order
					// loHead 和 loTail 分别代表链表旧位置的头尾节点
					Node<K,V> loHead = null, loTail = null;
					// hiHead 和 hiTail 分别代表链表移动到新位置的头尾节点
					Node<K,V> hiHead = null, hiTail = null;
					Node<K,V> next;
					do {
						next = e.next;
						//如果当前元素的hash值和oldCap做与运算为0,则原位置不变
						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;
}

扩容发生的条件

  • 当 HashMap 中键值对 key-value 个数大于阀值的时候(注意不是什么桶或数组的占用情况),就会以2倍的capacity对oldtable进行resize操作

  • HashMap 为空或者数组长度为 0

  • 升级成红黑树时,数组长度小于64,会进行一次扩容代替升级

jdk1.8使用红黑树的改进

在java jdk8中对HashMap的源码进行了优化,在jdk7中,HashMap处理“碰撞”的时候,都是采用链表来存储,当碰撞的结点很多时,查询时间是O(n)。

在jdk8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点较少时,采用链表存储,当较大时(>8个),采用红黑树(特点是查询时间是O(logn))存储(有一个阀值控制,大于阀值(8个),将链表存储转换成红黑树存储)。

你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。

如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。

随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。

JDK1.8HashMap的红黑树是这样解决的:

如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。

HashMap为什么是线程不安全的

(我理解的)

jdk1.7会发生死循环,数据丢失,数据覆盖这些问题,主要是因为扩容时是在transfer函数中完成数据迁移的,entry数组使用的是头插法(死循环,数据丢失)。

jdk1.8 是在resize函数中完成了数据迁移,并且采用了尾插法,解决了死循环、数据丢失,但是仍会有数据覆盖的问题。

HashMap的遍历

方法1:使用For-Each迭代entries

Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for(Map.Entry<Integer, Integer> entry : map.entrySet()){
	System.out.println("key = " + entry.getKey() + ", value = " + entry.getValue())
}

方法2:使用For-Each迭代keys和values

Map<Integer, Integer> map = new HashMap<Integer, Integer>();
 
//iterating over keys only
for (Integer key : map.keySet()) {
	System.out.println("Key = " + key);
}
 
//iterating over values only
for (Integer value : map.values()) {
	System.out.println("Value = " + value);
}

方法3:使用iterator迭代遍历

使用泛型:
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator();
while (entries.hasNext()) {
	Map.Entry<Integer, Integer> entry = entries.next();
	System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
}
不使用泛型:
Map map = new HashMap();
Iterator entries = map.entrySet().iterator();
while (entries.hasNext()) {
	Map.Entry entry = (Map.Entry) entries.next();
	Integer key = (Integer)entry.getKey();
	Integer value = (Integer)entry.getValue();
	System.out.println("Key = " + key + ", Value = " + value);
}

详解jdk1.7使用头插法链表会形成死循环

因为JDK1.7是采用的头插法,transfer 这个方法在多线程环境下有可能会使链表形成环状,以至于后面当插入进去,查询的时候会死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。

下面上代码,

void transfer(Entry[] newTable) {
      //保存旧数组的引用
      Entry[] src = table; 
      //新数组的长度               
      int newCapacity = newTable.length;
  		//开始转移整个table包括table上链表的数据
      for (int j = 0; j < src.length; j++) { 
          Entry<K,V> e = src[j];           
        	//e不等于空,说明下面有链表
          if (e != null) {
            	//环节1
              src[j] = null; 
              do {
                	//保存下一个节点,防止断链
                	//代码1处
                 Entry<K,V> next = e.next; 
                 int i = indexFor(e.hash, newCapacity); 
								//使用头插法插入
								//代码2处
                 e.next = newTable[i]; 
                 //代码3处
                 newTable[i] = e;  
                 //代码4处
                 e = next;             
             } while (e != null);
         }
     }
 }

比如现在,有两个线程都执行 transfer 方法。每个线程都会在它们自己的工作内存生成一个newTable 的数组,用于存储变化后的链表,它们互不影响(这里互不影响,指的是两个新数组本身互不影响)。但是,需要注意的是,它们操作的数据却是同一份。

我们假设,原数组容量只有2,其中一条链表上有两个元素 A,B,如下图:
在这里插入图片描述假设线程一执行到了上图1中所指的代码1处,恰好 CPU 时间片到了,线程被挂起,不能继续执行了。 记住此时,线程一中记录的 e = A , e.next = B。

然后线程二正常执行,扩容后的数组长度为 4, 假设 A,B两个元素又碰撞到了同一个桶中。然后,通过几次 while 循环后,采用头插法,最终呈现的结构如下:
在这里插入图片描述此时,线程一解挂,继续往下执行。注意,此时线程一,记录的还是 e = A,e.next = B,因为它还未感知到最新的变化。

我们主要关注图1中标注的①②③④处的变量变化:

第一次循环结束后,线程一新数组的结构如下图:
在这里插入图片描述然后,由于 e=B,不为空,进入第二次循环。

第二次循环结束后,线程一新数组的结构如下图:
在这里插入图片描述
此时,由于 e=A,不为空,继续循环。

第三次循环结束后,看下图,A的指向由 null ,改为指向为 B,因此 A 和 B 之间成环。
在这里插入图片描述
到这里,可能就有人问,说了这么多,成环了又怎样?

其实到最后它还调用了getEntry()方法。
在这里插入图片描述
可以看到查找元素时,只要 e 不为空,就会一直循环查找下去。若有某个元素 C 的 hash 值也落在了和 A,B元素同一个桶中,则会由于, A,B互相指向,e.next 永远不为空,就会形成死循环。

至此,写了元旦两天,也就会(但还不是完全会)这么点东西,全写上了。是不是觉得实力大增,给面试官答出来个七七八八,工资最少也得涨个100块。

部分参考:面试官再问你 HashMap 底层原理,就把这篇文章甩给他看

举报

相关推荐

0 条评论