HashMap底层原理浅析

HashMap底层实现原理

此文关于HashMap的底层浅析


前言

关于HashMap是我们在Java后端开发中使用较多的结构,也是我们在面试的过程中会遇到的频率较高的面试题之一。


以下是Collection的体系结构图(简略版)
在这里插入图片描述

一、Hash表

在我们了解HashMap之前我们首先应该知道Hash的概念,因为HashMap的实现方法与Hash表有着密切联系。散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表
Hash表具体的实现不在这里详细解释,如有兴趣可以数据结构的书籍或是其他博客搜索了解。

二、HashMap底层实现

Hash在JDK1.7及之前底层是依靠哈希表(数组+链表)实现的,为什么要使用此结构呢?我们都知道数组结构占用内存中连续的一片空间,根据索引进行访问,查询速度快,随机访问性强,但是在实现添加和删除的功能时,因为要移动数组中的大量节点来实现,导致操作数据效率很低。而链表采用链式结构,在内存中不需要占用连续的空间地址,只需要用一个指针来存储下一个结点的地址即可,因此在执行增加和删除操作时只需要修改指针即可,执行效率高,非常灵活。但是链表不能够采用随机访问,每次进行访问某一个节点都需要从头开始进行遍历,查询效率低
此时HashMap采用哈希表(Hash表)的结构,哈希表结合了数组和链表的优点,从而实现了查询和修改的效率提高,插入和删除效率也高。所以HashMap底层就是使用了Hash表来提高性能。而在JDK1.7中又出现了一个问题就是,如果HashMap的容量不够大,那么添加键值时出现hash冲突的几率就会增加,而每当出现哈希冲突就会去每个节点的链表上比较键值对是存在于链中(此过程会在下面的hashCode和equals的讲解),如果比较不相同则会添加到链表中,久而久之,链表会越来越大的,此时查询键值时所消耗的时间也越来越多。
而在JDK1.8中哈希表添加了叫红黑树的概念,此时HashMap采用数组+链表+红黑树的结构,每当链表的节点超过8时,就会转换为红黑树便于查询和遍历节点,红黑树基于平衡二叉树,因此在查询和遍历方面要比链表消耗的时间少。
结构如下图所示
在这里插入图片描述

三、HashMap源码中的一些重要属性

1.初始容量

在这里插入图片描述
此处定义了HashMap的默认容量,可以看到1<<4为Java当中的位运算,位运算的结果为16.所以默认容量为16,aka16。可以通过上面的英语得知默认初始容量必须为2的幂。而此处有一个问题就是为什么一定要是2的次幂呢?
这主要是从性能和分布均匀方面来考虑的,2的n次方,可以通过位移操作来实现,可以加快hash计算速度,结合按位与计算加快数组下标的计算。在hash表的底层有一种索引计算方法为i = (n - 1) & hash
改方法可以实现一个均匀分布。(具体实现可以了解hash表的数据结构)

2.最大容量

在这里插入图片描述
此处为HashMap的最大容量,可以看到为1<<30也就是2的30次幂,而为什么要设置为2的30次幂呢?
由于hash值时int类型的,而int类型限制了变量的长度为4个字节功32个二进制位,按理说可以往左移动31位即2的31次幂,为什么此处是2的30次幂而不是31次幂呢?
由于二进制位中最左边的以为代表符号位,用来表示正负之分(0为正,1为负),所以只能往左移动30位,不能移动到最高位的符号位,所以容量只能是2的30次幂。
至于在设置容量的是后超过2的30次幂会发生什么情况我也没有了解过。(可以自行探索)

3.默认负载因子

在这里插入图片描述
从上面的英语我们可以得知在没有在构造器特殊指定的情况下默认的加载因子为0.75
加载因子和扩容机制相关,当HashMap中存储的数量 > HashMap容量 * 负载因子时,就会把HashMap的容量扩大为原来的二倍相当于位运算往左移动一位。JDK1.7实在添加数据之前进行扩容判断,到了JDK1.8则是在添加数据之后或者判断树化时进行罗荣判断。

4.树化阙值

在这里插入图片描述
从上面的英语通过百度翻译我们可以得知(百度翻译的有点离谱,没办法英语水平一般),简单理解就是当链表中的节点超过8个时把链表转换为红黑树。二者的转换也与下面的链表阙值相关。

5.链表阙值

在这里插入图片描述
大概的意思就是当红黑树的节点个数小于这个值的时候,需要把红黑树转换为链表。

6.扩容临界值

在这里插入图片描述
说实话,目前的我还不太了解(努力了解的过程中)

四、HashMap的构造方法

1.传入初始容量initialCapacity和负载因子loadFactor的构造方法

 /**
     * Constructs an empty <tt>HashMap</tt> 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);
    }

我们可以看到构造方法的两个参数,分别为初始容量initialCapacity和负载因子loadFactor,在上面我讲了负载因子的作用,而在这里最大的问题时初始容量initialCapacity,例如此时传入的initialCapacity的值为10,那么构造方法初始化出来的HashMap的大小就是10吗?并非如此,我在上面也讲过了,初始容量必须为2的次幂,而此时初始容量的值为10,那么就会把大于10的一个最小的2的次幂当作初始容量(此时传递值为10,那么大于10的最小的2的次幂为16。如果传递值为20,那么初始容量为32)。

2.传入初始容量initialCapacity的情况

	/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

这种情况和上面的情况一直,只不过时少了一个负载因子。

3.空参的情况(默认构造函数)

/**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

这里的英文就很简单了,不难看出这种默认情况使用的initialCapacity为上面定义的默认初始容量(在上面讲到的属性里面),默认值为16。而默认的负载因子为0.75 。

4.还有一种构造方法目前了解的不够详细不做过多解释

/**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

五、HashMap部分重要方法的源码浅析

1.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) {
        //通过定义新的节点数组对象来进行操作,tab哈希数组,
        //p为该hash桶(逻辑上时桶,实际时个链表)首节点,n时hashmap的长度,i为计算得出的数组下标
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //获取长度并进行扩容,使用懒加载,table一开始时没有加载的,使用put才开始加载
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //首先把桶的首节点地址赋给p,在通过(n - 1) & hash计算出下标,如果tab[i]为空。则直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //发生hash冲突
        else {
        	//e 临时节点  k存放当前节点的key
            Node<K,V> e; K k;
            //第一种,插入的key-value的hash值,key都与当前节点的相等,e = p,则表示为首节点
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //第二种,hash值不等于首节点,判断该p是否属于红黑树的节点
            else if (p instanceof TreeNode)
            	//是红黑树的节点,则在红黑树中进行添加,已存在则返回该节点
            	//不存在则添加,成功返回NULL
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //第三种,hash值不等于首节点,不为红黑树的节点,则为链表的节点
            else {
            	//遍历链表
                for (int binCount = 0; ; ++binCount) {
                	//如果遍历到尾部节点,表示添加的key-value没有重复,使用尾插法添加
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //此时判断该链表的节点是否大于树化阙值,来选择是否转换为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果链表中有重复的key,e为当前重复的节点,结束循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //用来移动节点
                    p = e;
                }
            }
            //针对以上判断的操作,如果e部位空,则表示以上操作检查出有重复的key存在
            //将其覆盖,返回旧值
            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;
    }

2.get方法

public V get(Object key) {
		//临时节点
        Node<K,V> e;
        //调用下面的getNode方法
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
    	//前面的代码大多跟上面解析的put源码一样,所以说一法通则万法通。
    	//tab相当于一个中介,不直接操作table操作tab和操作table是一样的效果
    	//而table还是指向原来的地址,一直不变(啰嗦了一点)
    	//first 首节点   e临时节点  n长度   k-key
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //判断table和根据key计算的下标的元素是否为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //如果需要获取的key-value和首节点的相同,则直接返回首节点
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //和首节点不同的情况
            if ((e = first.next) != null) {
            	//判断是否为红黑树节点,如果是则调用getTreeNode方法
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //循环链表,直至查找到相同的key-value
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        //查找不到返回NULL
        return null;
    }

具体的remove方法和修改方法这边不过多解析,HashMap中并不存在修改的方法,而还是通过put的方法来实现修改,根据key获取相应的节点进行直接覆盖就达到了修改的效果。而remove方法,只要上面的两种方法看懂了,我相信remove不难理解。还有其他的重要方法,例如resize等,我还没有理解到位,目前的能力只能写到这里。

六、hashcode()及equals()方法与HashMap的联系

hashcode()和equals()时Object类中的方法

 * @return  a hash code value for this object.
     * @see     java.lang.Object#equals(java.lang.Object)
     * @see     java.lang.System#identityHashCode
     */
    public native int hashCode();


    * @param   obj   the reference object with which to compare.
     * @return  {@code true} if this object is the same as the obj
     *          argument; {@code false} otherwise.
     * @see     #hashCode()
     * @see     java.util.HashMap
     */
    public boolean equals(Object obj) {
        return (this == obj);
    }

关于这两个方法又分为两种情况

1.不重写hashcode()和equals()方法

如果不重写两个方法那么。原生的equals()方法定义在Object类中,是用来比较两个引用所指向的对象的内存地址是否一致,并不是比较两个对象的属性值是否一致。原生的hashcode()方法是一个本地方法,返回值类型为整型,如果没有被重写的话,实际上是将对象在内存中的地址作为哈希码返回,每一个对象的地址值都不相同,因此哈希值也各不相同。

2.重写hashcode()和equals()方法

如果重写了两个方法那么hashcoed()可以根据对象的属性来生成hash值,而equals()方法则比较的是对象的属性值。
关于两个方法的重写有以下原则:

  • 一旦重写了equals()方法,就必须重写hashCode()方法。
  • 如果使用equals表示相同的对象,那么对象的hash值一定相同

关于具体的重写过程将在下一篇文章中具体讲解。

3.二者与HashMap的联系

上面我们讲到了当往HashMap中添加键值的时候,首先通过hashcode来生成hash值,再通过相应的计算(散列的过程),得到对应的索引,如果该索引处为NULL,那么直接添加到该节点中。如果已经存在键值,则表示发生哈希冲突,此时就需要equals()方法来进行元素判断是否为相同的对象,如果链表上没有使用equals返回为true的对象,那么就添加到链表中,否则则覆盖已经存在的键的节点。

总结

此文是我在学习过程中目前了解到的知识点,还有许多东西没有了解清楚,如有错误,欢迎指正。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值