源码解析之HashMap实现原理

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/pihailailou/article/details/82053420

目录

一,写在前面

二,栗子

三,HashMap设计思路

四,边界变量

五,put方法

六,resize方法

七,get方法

八,关于HashMap实现原理的问答题


一,写在前面

在日常开发中,HashMap因其可以存储键值对的特点经常被使用,仅仅知道如何使用HashMap是远远不够的。以知其然知其所以然的钻研态度,本篇文章将以图文,源码的方式去解析HashMap的实现原理。


二,栗子

首先咱们来看一段代码,比较简单,就不多解释啦~

代码如下:

import java.util.HashMap;

public class Test {

	public static void main(String[] args) {
		HashMap<Person, Integer> map = new HashMap<Person, Integer>();
		map.put(new Person(5, "bryant"), 8);
		map.put(new Person(3, "james"), 23);
		
		System.out.println(map.get(new Person(5, "kobe")));//8
		System.out.println(map.get(new Person(3, "lebron")));//23
	}
}

class Person {
	private int _id;
	private String name;
	public Person(int _id, String name) {
		super();
		this._id = _id;
		this.name = name;
	}
	
	@Override
	public int hashCode() {
		return new Integer(_id).hashCode();
	}
	
	@Override
	public boolean equals(Object obj) {
		if (obj == null) return false;
		return this._id == ((Person)obj)._id;
	}
	
}

定义了一个Person类,里面有两个字段_id,name,分别重写hashCode,equals方法,都是_id相同则返回true。

打印结果如下:

8
23

对上述代码做一下简单的修改,删掉hashCode方法,打印结果如下:

null
null

想必大家都知道一个这样的知识点:在重写一个类的equals方法时,需要去重写hashCode方法。那么为啥需要重写hashCode方法呢?在下面的HashMap的原理解析中,就可以很好回答这个问题。之所以展示一个上述的栗子,是为了让读者有兴趣跟着笔者的脚步,去一步步探索HashMap内部实现的奥秘。


三,HashMap设计思路

为了让大家更好的理解HashMap的实现原理,下面会先介绍其设计思路。阅读下面的内容,对照下图会更易从整体上理解HashMap的设计思路。

为了实现高效的查询,插入,删除元素,HashMap底层采用数组+链表+红黑树的数据结构。

数组的特点:查询操作效率较高,根据索引查询只需要一次,但插入和删除操作效率较低,会移动整个数组。

链表的特点:查询操作效率较低,需要遍历整个链表,但插入删除的效率较高,只需要改变其next引用即可。

为了高效的执行查询,插入和删除操作,HashMap采用了数组+链表配合使用的方式,并在一定条件下将链表转化为红黑树(会面会讲到)。我们知道在put一个键值对时,包含有key,value两个数据,在HashMap中提供了Node类来封装键值对的数据。

Node类源码如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //哈希函数的值
        final K key;    //key
        V value;        //value
        Node<K,V> next; //链表结构上的下一个元素

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

    // ...
}

查看Node的源码可知,是一个典型的链表结构,并实现了Entry接口,Entry是Map集合里一个内部接口。

上面提到HashMap中采用了数组的数据结构,因为它里面维护了一个数组table,源码如下:

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

HashMap的构造函数中并没有初始化table数组,那么它是在哪里初始化的呢,下面会一一解答。table数组第一次初始化默认容量是16,在调用put方法存放一个键值对时,会做如下操作:

  1. 首先会调用哈希函数去计算key对应的hash;
  2. 然后执行位运算hash&(table.length-1),得到结点Node在数组中的存储位置index;
  3. 若数组的index位置没有结点,则直接将该结点存入数组;若该index位置有结点,又分如下两种情况:
  • 该位置存放着一个链表(见上面的设计图),在链表结构上插入元素,若key相同则替换其value值,不插入新的结点
  • 该位置存放着一个红黑树(见上面的设计图),在红黑树上插入元素,若key相同则替换其value值,不插入新的结点

 

链表存在的目的?

在步骤1中,调用哈希函数去计算key对应的hash,有可能存在多个不同的对象hash的值相同,也叫“哈希碰撞”,“哈希冲突”。在出现哈希冲突时,多个key对应的存储位置index是相同的,链表的next引用就是解决这种情况的。

红黑树存在的目的?

如果咱们要查询的结点刚在链表的最下面,那么每次都需要遍历完整个链表,在链表的长度比较短的时候还可以。若任由链表长度无限的增加下去,势必会使查询操作的效率大大降低。因此,在HashMap底层规定当链表的结点数大于8时,会将链表转化为红黑树。

红黑树是二叉树的一种,它有左子树小于根结点,右子树大于根结点等特点。红黑树的查询,插入删除操作都比较高效,其层级比链表少方便查询。关于红黑树的具体介绍,可参考文章初恋红黑树

HashMap中规定,在红黑树的结点个数小于6个时,会将红黑树转化为链表结构。

 

位运算hash&(table.length-1)的原因?

hash变量是调用hash函数得到的值,查看hash函数的源码:

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

如果key是null,则返回0;如果不为0,首先计算出key的hashCode值,再执行hashCode值的低16位和高16位的异或运算。

从设计原则上来说,更多的使用数组的空间,不管是查询,插入删除都是很方便的,只需要根据Key对应的index值,并执行相关操作即可。越少的出现哈希冲突,链表的长度越短,数组的空间越被充分利用,HashMap操作数据的效率越高。

那么如何减少哈希冲突呢?

那么需要key对应的存储位置index尽可能的不同。

首先调用hash函数,将key的hashCode值的低16位于高16位进行异或运算,充分的使用hashCode的32个二进制数据进行运算(int是4个字节),得到变量hash。

然后执行位运算hash&(table.length-1),由于数组长度是16,那么table.length-1是15,二进制表示:1111。我们思考这样的一个问题,当数组是16时,hash变量与的是1111,最后会得到hash变量最低4位的值,其范围是0~15。当数组是15时,hash变量与的是1110,那么不管hash变量的最低1位是0或1,得到的值都是0。也就是说,1010,1011与上1110都是1010,两个不同的hash变量有得到同一个存储位置index的可能,这样会更大概率出现哈希冲突。因此,HashMap在设计数组的初始长度为16,数组的扩容也是乘以2。

小结:hash函数利用key的hashCode的高16位和低16位的异或运算,减少了哈希冲突。设计数组长度是16,在执行hash&(table.length-1)运算时,减少了哈希冲突。减少了哈希冲突,充分利用数组空间,HashMap的查询,插入和删除操作会更高效。


四,边界变量

    /**
     * The default initial capacity - MUST be a power of two.
     */
    //数组的初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    //允许的数组的最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //当链表的长度大于8,转化为红黑树
    static final int TREEIFY_THRESHOLD = 8;

    //当红黑树的结点个数小于6,转化为链表
    static final int UNTREEIFY_THRESHOLD = 6;

在HashMap中有一个threshold变量,threshold=数组的大小*加载因子。当集合中的结点个数大于threshold时,会进行数组扩容。


五,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; //临时变量,指向table数组
		Node<K, V> p;	  //临时变量,执行数组中位置为i的结点
		int n, i;		  //n,临时变量,记录数组长度; i,临时变量,记录Node的存储位置
		if ((tab = table) == null || (n = tab.length) == 0)
			n = (tab = resize()).length; //resize方法初始化数组
		if ((p = tab[i = (n - 1) & hash]) == null) //数组里没有结点
			tab[i] = newNode(hash, key, value, null);
		else {
			Node<K, V> e;  //临时变量,存储key相同情况下的结点
			K k;
			if (p.hash == hash   //hash变量相同,且key相同
					&& ((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);
						if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
							treeifyBin(tab, hash); //链表长度为8时,转化为红黑树
						break;
					}
					if (e.hash == hash  // 处理key相同的情况
							&& ((k = e.key) == key || (key != null && key
									.equals(k))))
						break;
					p = e;  //p = p.next
				}
			}
			if (e != null) { // existing mapping for key
				V oldValue = e.value;
				if (!onlyIfAbsent || oldValue == null)
					e.value = value;
				afterNodeAccess(e);
				return oldValue; //key相同情况下,返回旧的value值
			}
		}
		++modCount; //监测内部数据结构的变化,但不包括key相同的情况
		if (++size > threshold) //集合中元素大于12
			resize();   //数组扩容
		afterNodeInsertion(evict);
		return null; //key不相同的情况,返回null
	}

代码中有具体解释,这里就不带大家一行行分析putVal方法的源码了。

初次调用put方法,会调用resize方法初始化table数组,执行hash&(tab.length-1)获取结点在数组的存储位置,并直接将Node存入数组。后面继续调用put方法,先处理数组中结点的Key与插入结点相同的情况,然后处理数组中结点是红黑树,链表的情况。若数组的结点是链表结构,遍历链表并插入新的结点,并处理新旧结点的key相同的情况。若链表的长度大于8,则转化为红黑树。新旧结点的key相同的情况,使用临时变量e存储旧结点,并返回e.value。当集合中键值对大于12时,调用resize方法扩容数组。


六,resize方法

	final Node<K, V>[] resize() {
		Node<K, V>[] oldTab = table;  //临时变量,指向数组table
		int oldCap = (oldTab == null) ? 0 : oldTab.length;
		int oldThr = threshold;
		int newCap, newThr = 0;
		if (oldCap > 0) { //数组有结点的情况
			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  //修改边界值,扩大集合中允许的结点个数
		} else if (oldThr > 0) // initial capacity was placed in threshold
			newCap = oldThr;
		else { // zero initial threshold signifies using defaults
			
			//第一次调用put方法,数组没初始化的情况
			newCap = DEFAULT_INITIAL_CAPACITY;  //16
			newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  //16 * 0.75 = 12
		}
		if (newThr == 0) {
			float ft = (float) newCap * loadFactor;
			newThr = (newCap < MAXIMUM_CAPACITY
					&& ft < (float) MAXIMUM_CAPACITY ? (int) ft
					: Integer.MAX_VALUE);
		}
		threshold = newThr; //修改threshold变量的值
		@SuppressWarnings({ "rawtypes", "unchecked" })
		Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; //初始化数组,或创建扩容后的数组
		table = newTab;  //修改table变量
		if (oldTab != null) { //处理数组扩容的情况
			for (int j = 0; j < oldCap; ++j) { //遍历旧数组的结点
				Node<K, V> e;
				if ((e = oldTab[j]) != null) { //临时遍历e, 指向旧数组中结点
					oldTab[j] = null;  //旧数组结点置空
					if (e.next == null)
						//在新数组中重新确定结点的位置,算法与数组大小为16时相同
						newTab[e.hash & (newCap - 1)] = e; 
					else if (e instanceof TreeNode) //处理结点是红黑树的情况
						((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) { //若hash变量的第5位二进制值为0
								if (loTail == null)
									loHead = e;
								else
									loTail.next = e;
								loTail = e;
							} else { //若hash变量的第5位二进制值为1
								if (hiTail == null)
									hiHead = e;
								else
									hiTail.next = e;
								hiTail = e;
							}
						} while ((e = next) != null);
						if (loTail != null) {
							loTail.next = null;
							newTab[j] = loHead; //hash变量的第5位二进制值为0的情况
						}
						if (hiTail != null) {
							hiTail.next = null;
							newTab[j + oldCap] = hiHead; //hash变量的第5位二进制值为1的情况
						}
					}
				}
			}
		}
		return newTab;
	}

代码中有具体解释,这里就不带大家一行行分析resize方法的源码了。

resize方法做了两件事,一个是初始化数组,一个是数组扩容。在数组扩容时,会重新创建新的数组,由于数组的长度tab.length发生变化,hash&(tab.length-1)得到的值发生变化。例如数组大小从16扩容到32时,tab.length-1是31,二进制表示是11111。hash变量在进行与运算时,第5位二进制会参与运算。若第5位二进制是0,则位置不变;若是1,则数组存放位置增加16,刚好是旧数组的大小。

因此,遍历链表重新确定结点的位置时,需要判断(e.hash & oldCap) == 0,就是判断hash的第5位二进制是0还是1,从而确定链表中的结点在新数组中的存储位置。HashMap在扩容时,可能会改变结点在数组中存储位置,蛋糕重分,由此可知HashMap存储元素的位置并不稳定。


七,get方法

    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) {  //table数组该位置有结点
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
            	//key相同的情况
                return first;
            if ((e = first.next) != null) {
                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); //遍历链表,直到找到相同的key
            }
        }
        return null;
    }

代码中有具体解释,这里就不带大家一行行分析get方法的源码了。

首先通过key获取其在数组中的存储位置index,分三种情况寻找相同的key:

  1. 数组中的结点的key是否相同;
  2. 数组中的结点的key不相同,处理是链表的情况,并遍历链接找到符合条件的Key;
  3. 数组中的结点的key不相同,处理是红黑树的情况;

 

HashMap是如何判断key是否相同呢?

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
  • e.hash == hash,hash变量的值取决于key的hashCode的值,因此需要key的hashCode相同,也就是hashCode方法返回值要相同;
  • (k = e.key) == key,判断两个对象是否相同,则判定key相同;
  • key != null && key.equals(k) ,调用equals方法返回true,则判定key相同;

小结:想正确的获取HashMap中集合的元素,判定key是否相同,要同时重写的hashCode方法和equals方法。


八,关于HashMap实现原理的问答题

1,HashMap的实现原理,内部数据结构?

底层使用哈希表,也就是数组+链表,当链表长度超过8个时会转化为红黑树,以实现查找的时间复杂度为log n。

2,HashMap中put方法的过程?

调用哈希函数获取Key对应的hash值,再计算其数组下标;

如果没有出现哈希冲突,则直接放入数组;如果出现哈希冲突,则以链表的方式放在链表后面;

如果链表的长度超过8,则会转化为红黑树;

如果结点的key已经存在,则替换其value即可;

如果集合中的键值对大于12,调用resize方法进行数组扩容;

3,哈希函数怎么实现的?

调用Key的hashCode方法获取hashCode值,并将该值的高16位和低16位进行异或运算。

4,哈希冲突怎么解决?

将新结点添加在链表后面

5,数组扩容的过程?

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。

6,除了链地址法,哈希冲突的其他解决方案?

开放定址法:发生哈希冲突,寻找另一个未被占用的数组地址

再哈希法:提供多个哈希函数,直到不再产生冲突;

建立公共溢出区:将哈希表分为基本表和溢出表,产生哈希冲突的结点放入溢出表

详细介绍见文章:哈希冲突的解决方案

 

                                                                                                                    O(∩_∩)O

展开阅读全文

没有更多推荐了,返回首页