Java集合------HashMap底层原理(1.7)

解析HashMap的底层实现及核心方法,包括基于哈希表的存储结构、数组和链表的结合使用、put和get方法的工作流程。

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

Java集合------HashMap底层原理(1.7)

前言

在java集合中,Map是一种特殊的集合,原因在于这种集合容器并不是保存单个元素,而是保存一个一个的Key-Vaue键值对.HashMap是基于哈希表的Map接口的实现,在项目开发中使用广泛,下面就对HashMap的源码进行解析.

正文

HashMap的特点
  1. HashMap是基于哈希表的Map实现.
  2. HashMap底层采用的是Entry数组(1.7)和链表实现.
  3. HashMap是采用key-value形式存储,其中key是可以允许为null,但是只能有一个,并且key不能重复.
  4. HashMap是线程不安全的.
  5. HashMap存入数据的顺序和遍历的顺序有可能是不一样的.*

在HashMap中存在很多的方法,在此我们只对添加、删除、遍历等方法进行解析.以便了解其原理.

HashMap的数据结构

在数据结构中,有数组和链表来实现对数据的存储,但这两者基本上是两个极端.

  • 数组: 数组储存区间是连续的,占用内存严重,故空间复杂度很大.但数组的二分查找时间复杂度小,为O(1);数组的特点是寻址容易,插入和删除困难
  • 链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N).链表的特点是寻址困难,插入和删除容易

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是哈希表,哈希表即满足了数据查找的方便,同时不占用太多的内存空间,使用也十分方便.

HashMap底层使用的就是哈希表.

HashMap实际上是一个"链表"的数组,每个数组中的元素存放链表的头结点,在每一个头结点的中,包含着下一个节点的地址,即数组和链表的结合体.

在这里插入图片描述

更为详细的结构如下:

在这里插入图片描述

从上图可以看到,HashMap底层就是一个数组结构,数组中的每一项又是一个链表.

HashMap源码

在这里贴上HashMap的部分源码:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable{

	//初始化桶大小,也就是数组的大小,默认大小为16
	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;

	//存放数据的数组
	transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

	//存储key-value键值对的个数
	transient int size;

	//桶大小,在初始的时候可以显式指定(一定是2的次幂)
	int threshold;

	//负载因子,初始化时可以显式指定
	final float loadFactor;

	//修改次数,每次map集合变动一次,就加1
	transient int modCount;

	//真正存放数据的entry内部类
    static class Entry<K,V> implements Map.Entry<K,V> {
	    final K key;
	    V value;
	    Entry<K,V> next;
	    int hash;
	    ...省略其他
	}

相关的字段释义已经写在注释.

在这里,我们看一下这个内部类Map.entry:

    static class Entry<K,V> implements Map.Entry<K,V> {
	    final K key;
	    V value;
	    Entry<K,V> next;
	    int hash;
	    ...省略其他
	}
  • key其实就是写入数据的键
  • value是指
  • next变量正是实现链表结构的关键
  • hash存放的是当前key的hashcode

到这里,我们应该有个概念:当我们创建了一个HashMap的时候,其实是创建了一个数组,此数组的默认大小是16,数组中的每一项都叫一个桶,数组中保存的数据正是Map.entry对象,它内部包含一个key-value键值对,还持有一个指向下一个元素的引用,还有他的key运算后的hashcode值.

再换句话说:

当系统开始初始化HashMap的时候,系统会创建一个长度为capacity 的Entry数组,这个数组里可以存储元素的位置被称为"桶(bucket)",每个桶都有自己的索引,系统可以根据这个索引快速访问该bucket里的元素.

无论何时,HashMap的每个桶只存储一个元素(一个Entry),由于Entry对象可以包含一个引用,用于指向下一个Entry,所以会出现这种情况:HashMap的桶中只有一个Entry,但是这个Entry指向另一个Entry,这样就形成了一个Entry链.

HashMap的构造函数

HashMap提供了四个构造函数:

HashMap():构造一个具有默认初始容量(16)和默认加载因子(0.75)的空HashMap

HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子的空HashMap.

HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和指定负载因子的空HashMap.

HashMap(Map<? extends K, ? extends V> m):根据指定的map集合创建一个HashMap.

这里我们来看一下它两个参数的构造函数:

public HashMap(int initialCapacity, float loadFactor) {
    	//如果初始容量小于0,则抛出一个异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //如果指定的初始化大小大于最大值,则将容量置为最大值.
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //如果负载因子不是数字或者小于等于0,抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        //让当前map的容器大小和加载因子等于指定的值	
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        //初始化方法,在HashMap中没有实现,其子类有具体实现.
        init();
    }

我们看到,在初始化的时候,没有为table数组分配内存空间,而是在put操作的时候才真正构建table数组.

初始容量和负载因子

在HashMap的属性中,有两个参数:初始容量,负载因子.

这两个参数是影响HashMap性能的重要参数.

其中容量表示哈希表中桶的数量,也就是数组的长度.初始容量是创建哈希表时的容量,如果不指定,默认是16.

加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它可以衡量一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之越小.

查看put方法的源码可知:当哈希表中数据的数量超出了当前容量*加载因子时,对该HashMap进行扩容,将容量扩充至两倍的桶数

HashMap的put方法
    public V put(K key, V value) {
    	//如果table数组为空{},则为table初始化分配内存空间,入参为threshold,默认是16
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //如果key为null,保存null于table的第一个位置,也就是table[0]
        if (key == null)
            return putForNullKey(value);
        //根据ket计算出hash值
        int hash = hash(key);
        //计算出该key所应该保存的桶位置,也就是在数组table中的位置
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //遍历该索引位置的桶链表,如果存在相同的key,用老值替换新值,返回老值
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //修改次数增加1
        modCount++;
        //根据key-value新增一个Entry对象写入当前位置
        addEntry(hash, key, value, i);
        return null;
    }

通过源码我们可以很清晰的看到put方法的执行逻辑:

  • 首先判断HashMap中的table表是不是为空,如果为空,调用inflateTable(threshold)方法为table分配内存空间
  • 然后判断key是否为空,如果key为空,则调用putForNullKey(value)方法,将value放在数组的第一个位置上.
  • 若key不为空,则根据hash(key)方法计算出hash值,然后根据hash值,得到这个元素在table数组中的位置(下标),如果table在该位置已经存放了其他元素,则通过比较是否存在相同key,若存在则覆盖原来key的value,否则将该元素保存在链头.
  • 若table所在该处没有元素,那就直接将该元素放到此数组中的该位置上.

至此完成来了put方法的全过程.这个过程看似只有这么几个步骤,其实其中还有很多的东西,我们一个一个来分析.

首先我们看看inflateTable方法:

    private void inflateTable(int toSize) {
        //这里的capacity是桶大小,threshold是容量阈值,如果桶
        //大小是16,那么容量超过16*0.75=16的时候,就要扩容了
        //roundUpToPowerOf2方法确保桶大小是2的次幂,
        //比如toSize=13,则capacity=16;
        //to_size=16,capacity=16;to_size=17,capacity=32
        int capacity = roundUpToPowerOf2(toSize);
        //为容量赋值,在capacity * loadFactor和MAXIMUM_CAPACITY + 1中取一个小值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //初始化table数组
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

其次,因为在迭代map的逻辑中的判断,如果存在相同的key,则覆盖value,这就使得HashMap的不重复,要注意的是,这里判断key是否相同的逻辑:先比hashCode是否相同,hashCode相同再判断equals是否为true,这样就大大增加了效率.

我们再来看看一个比较重要的方法:hash(key)

    /**
    *这个方法中运用了很多的位运算、异或等方法,其目的是为了
    *对于不同的key,获得一个唯一的数字值
    */
    final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

关于详细的为什么要这样计算可以参考hash方法原理

计算出hash值以后,会调用indexFor()方法来获得该数据在数组中的位置下标.我们知道对于HashMap的table而言,数据分布需要均匀(最好每一项都有元素),不能太紧也不能太松,太紧会导致查询慢,且hash碰撞的几率变大(不知道hash碰撞的同学自行百度),太松则浪费空间,所以怎么保证table元素分布均匀呢?那就是取模,在有些版本的源码中,indexFox方法是直接取模的,但是后来优化以后就变成了下面这样:

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

我们知道,HashMap的数组长度在roundUpToPowerOf2()方法的调用下,确保是2的次幂(在后面会详细讲为什么是2的n次方),当length为2的n次方时,h&(length-1)就相当于对length取模,也就是h%length,但是&比%运算要快的多,这是HashMap在效率上的优化.

最后,调用addEntry()方法在指定位置保存数据:

    void addEntry(int hash, K key, V value, int bucketIndex) {
    	//如果map中的key-value键值对的数量大于等于阈值,
    	//并且当前位置不为空时,数组需要进行2倍扩容.
    	//扩容以后重新计算hash以及在数组中的位置
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        //创建一个entry对象,并将原来的entry引用赋给新entry
        createEntry(hash, key, value, bucketIndex);
    }

在这里有两点:

  1. 正是因为在createEntry方法中,包含了旧的entry对象引用,才会产生entry链,这也是链形结构产生的原因.
  2. 随着HashMap中元素的越来越多,发生碰撞的概率就越来越大,所以产生的链表就越来越长,所以在该方法总,如果容量超过了阈值,就会调用resize方法进行扩容,具体的扩容包含新数组的创建,数据的复制,是非常耗性能的,因此在使用的时候能预估HashMap的大小是最好的.

以上就是对HashMap中put方法的源码分析,在这里,我们再来总结一下:

当程序试图将一对key-value放入HashMap中时,首先会根据key的hash值计算出该Entry对象的存储位置,如果两个Entry的key的hash值计算后的返回值相同,那么他们存储的位置也是相同的.如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部。

为什么HashMap的数组长度一定是2的n次方?

关于这个问题感兴趣的小伙伴可以查看以下博文进行扩展:

为什么HashMap的数组长度一定是2的n次方?

为什么HashMap的数组长度一定是2的n次方?

为什么HashMap的负载因子默认是0.75?

关于这个问题感兴趣的小伙伴也可以看看下面这个文章…

好像是0.75满足泊松分布??

HashMap的get方法
    public V get(Object key) {
    	//如果key为null,直接去table[0]处检索.
        if (key == null)
            return getForNullKey();
        //查找出map中对应key的entry对象
        Entry<K,V> entry = getEntry(key);
        //如果value不存在就返回null,否则返回对应的value
        return null == entry ? null : entry.getValue();
    }

在具体的getEntry()方法中:

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        //计算出key对应的hash值
        int hash = (key == null) ? 0 : hash(key);
        //获取最终数组中的索引,遍历链表,通过equals方法找出对应的entry对象返回.
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

get方法相对比较简单:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

总结

本文重点对HashMap的put方法和get方法进行了分析,尤其是put方法的理解,能帮助我们理解HashMap容器的底层原理.希望这篇文章能帮助各位同行,同时也欢迎讨论指正~~.

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值