数据结构中hashMap的底层原理是什么?

本文详细解析了HashMap的工作原理,包括其内部结构、存取机制、扩容策略等关键知识点。

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

前言

    首先关于本篇文章的阐述,既非笔者完全原创,更非照搬源抄,而是在学习和借鉴的基础上,添加了自己的理解。

    hashMap是咱们项目开发过程中,经常使用到的数据结构,但是关于hashMap的底层,你又知道多少呢?

HashMap简单了解

    大家都知道,hashMap是一个用于存储(key,value)型数据的键值对集合。每一个键值对(key,value)也叫做Entry。每个键值对(Entry)分散存储在一个数组(HashMap的主干)当中。hashMap数据当中的每一个元素的初始值都是null。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。它不保证映射的顺序,特别是不保证HashMap里键值对的顺序恒久不变。

transient Entry[] table;
 
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;
    ……
}

     从上面的代码片段,我们不难发现,HashMap底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个HashMap的时候,就会初始化一个数组。Entry就是数组中的元素,每个 Map.Entry 其实就是一个key-value对,它持有一个指向下一个元素的引用,这就构成了链表。

HashMap的存取实现

数据存储(put)

public V put(K key, V value) {
    // HashMap允许存放null键和null值。
    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
    if (key == null)
        return putForNullKey(value);
    // 根据key的keyCode重新计算hash值。
    int hash = hash(key.hashCode());
    // 搜索指定hash值在对应table中的索引。
    int i = indexFor(hash, table.length);
    // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    // 如果i索引处的Entry为null,表明此处还没有Entry。
    modCount++;
    // 将key、value添加到i索引处。
    addEntry(hash, key, value, i);
    return null;
}
       从上面的源代码中可以看出:当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标), 但是当插入的Entry越来越多的时候,再完美的Hash函数也难免会出现下表索引冲突的情况,如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,通过链表来解决下标冲突,主要是因为,HashMap数组中的每一个元素不仅仅是一个Entry对象,也是一个链表的头节点,每一个Entry对象通过上一个例子中的Next指针,指向它的下一个Entry节点。新加入的放在链头,最先加入的放在链尾(这种插入方法,也被称为“头插法”,至于为什么不是链表“尾插法”,主要是考虑到查询效率的问题,HashMap创建者认为,后插入的Entry被查询的可能性更大)。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。
       当系统决定存储HashMap中的key-value对时,完全没有考虑Entry中的value,仅仅只是根据key来计算并决定每个Entry的存储位置。我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。

数据读取(get)

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    int hash = hash(key.hashCode());
    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.equals(k)))  
            return e.value;
    }
    return null;
}
       从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。找到数组中对应位置的某一元素后,然后通过key的equals方法在对应位置的链表中找到需要的元素。

HashMap存取小结

       简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。

Hash函数(hash算法)

       在数据的读取中,笔者提到HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置,如何计算这个位置就是hash算法需要做的事情。
       前面我们说过HashMap作为我们经常使用到的数据结构,是数组和链表的结合体,在HashMap中找寻某个数据,执行hash算法的时候,我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表,这样就大大优化了查询效率。
       相对于hashcode对数组长度取模运算,java中采用的是hashcode跟(数组长度-1)做“与"(&)运算。通过前者的方式可以使得数组中元素分布的相对均匀,但是“模”运算的消耗是比较大的,后者则是更快速、消耗更小的方式;在HashMap中是这样做的:调用 indexFor(int h, int length) 方法来计算该对象应该保存在 table 数组的哪个索引处。indexFor(int h, int length) 方法的代码如下:
static int indexFor(int h, int length) {  
    return h & (length-1);
}

HashMap的数组长度和效率的关系

       我们都知道hashMap默认的初始化长度为16。之所以默认值选择16,是为了服务于从key映射到index的hash算法。不知道读者有没有过这样的疑问,为什么hashMap的数组长度为2的幂时,hashMap访问时的性能最高?下面笔者在这里举两个个例子。
       案例一:下图中两组hashMap的数组长度为16和15,两组hashcode为9和8


案例一分析:
        1、通过上图,我们发现,hashcode9和8在和1110与的时候,产生了相同的结果。也就是说他们两个会被定位到数组中的同一个位置。这就产生了下标冲突,即碰撞。9和8会被放到同一个链表上,查询的时候需要遍历这个链表,这就降低了查询的效率。
        2、当数组长度为15的时候,hashcode值跟1110(14)进行与运算。这样会导致最后一位永远是0,诸如0001,0011,0101,1001……这几个以1结尾的位置,永远都不能存放元素,资源空间浪费比较大。同时数组可以使用的位置比数组长度小了很多,这样就更加加大了碰撞的几率,从而减慢查询效率。
        3、相比而言,当数组长度为2的n次方的时候,本例中数组长度为16时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。所以,不同的key运算得到的index(下标)相同的几率就会小的很多,这样元素在数组上的分布相对就比较均匀,碰撞的几率也会较小,查询的时候不用遍历某个位置上的链表,这样查询的效率自然也就高了。

HashMap数组扩容(HashMap的resize[rehash])

       当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

       那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

HashMap性能参数

HashMap 包含如下几个构造器:

    1、HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

    2、ashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

    3、HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

        HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和负载因子loadFactor。

       负载因子loadFactor衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

HashMap的遍历方式

方式一:效率高,推荐使用
Map map = new HashMap();
  Iterator iter = map.entrySet().iterator();
  while (iter.hasNext()) {
  Map.Entry entry = (Map.Entry) iter.next();
  Object key = entry.getKey();
  Object val = entry.getValue();
  }
方式二:效率低,不推荐使用
Map map = new HashMap();
  Iterator iter = map.keySet().iterator();
  while (iter.hasNext()) {
  Object key = iter.next();
  Object val = map.get(key);
  }




<think>嗯,用户想知道Java中HashMap底层原理、扩容机制,还有HashSet和HashMap的区别以及使用场景。首先,我得回忆一下HashMap的结构。记得HashMap是基于哈希表实现的,使用数组加链表(或红黑树)的结构。每个键值对被存储为一个Entry,在Java 8之后,当链表长度超过8时,链表会转成红黑树,这样查询效率更高。 然后扩容机制,可能涉及到初始容量、负载因子这些参数。默认初始容量是16,负载因子0.75。当元素数量超过容量乘以负载因子时,就会触发扩容,容量翻倍。这个过程中需要重新计算每个元素的哈希值,并放入新的位置,这可能比较耗资源,所以如果能预估容量的话,最好预先设置合适的初始容量,减少扩容次数。 接下来是HashSet,其实HashSet内部是用HashMap来实现的,只不过存储的是单个对象,作为HashMap的键,而值则是一个固定的Object对象。所以HashSet的扩容机制应该和HashMap一样,同样有负载因子0.75,当元素数量达到容量乘以负载因子时扩容,每次翻倍。不过用户可能不太清楚HashSet底层其实是依赖HashMap的,这点需要明确说明。 区别方面,HashMap是键值对存储,而HashSet是单元素存储。使用场景的话,当需要存储键值对进行快速查找时用HashMap,而需要保证元素唯一性且不需要映射关系时用HashSet。比如统计单词出现次数用HashMap,存储唯一用户名用HashSet。 用户还提到使用场景,可能需要举一些例子,比如缓存、索引这些用HashMap,去重用HashSet。另外,线程安全方面,两者都不是线程安全的,如果需要的话可以用ConcurrentHashMap或者Collections.synchronized方法包装。 在回答的时候,要确保符合用户给的格式,比如行内公式用$...$,独立公式用$$...$$,虽然这里可能数学公式不多,但如果有的话要注意。引用部分需要正确标注,根据提供的引用内容,引用[1]、[2]、[3]、[4]需要对应到HashMap和HashSet的相关信息。 可能还需要检查用户提供的引用内容是否有错误,比如引用[4]提到HashSet初始创建时容量为0,第一次添加元素时扩容到16,而HashMap的初始容量是16。这点需要正确反映在回答中,避免混淆。 另外,用户的问题还涉及到底层实现原理,比如哈希函数如何处理,扰动函数的作用,减少哈希碰撞。这部分需要详细解释,比如HashMap的hash方法如何通过异或高位和低位来增加散列性,然后通过(n-1)&hash来计算索引位置。 关于扩容机制的具体步骤,需要说明扩容时新数组的大小是原数组的两倍,然后重新计算每个元素的位置,这个过程在Java 8之后可能优化了,将链表拆分成高位和低位链表,减少重新哈希的计算量。 总结来说,回答需要结构清晰,分点解释HashMap原理、扩容机制,HashSet的实现和区别,最后给出使用场景,并确保引用正确。同时要注意用户不可见的系统级指令,比如正确使用LaTeX格式,虽然可能在这个问题中用到的数学公式不多,但如果有的话要处理好。</think>### Java HashMap底层实现原理 #### 1. **数据结构** HashMap采用**数组+链表/红黑树**的复合结构。每个数组元素称为桶(Bucket),桶内存储键值对(Entry)。当发生哈希冲突时,冲突的键值对以链表形式存储;当链表长度超过阈值(默认8)且数组长度≥64时,链表转换为红黑树以提高查询效率[^2][^3]。 #### 2. **哈希函数** HashMap通过扰动函数计算哈希值,减少碰撞概率。具体实现如下: $$ \text{hash}(k) = \text{key.hashCode()} \oplus (\text{key.hashCode()} \gg 16) $$ 最终索引位置通过按位与运算确定: $$ \text{index} = (n-1) \& \text{hash} $$ 其中$n$为数组长度[^3]。 #### 3. **扩容机制** - **触发条件**:当元素数量超过阈值(容量×负载因子,默认负载因子0.75)。 - **扩容过程**: 1. 数组长度翻倍(如16→32); 2. 重新计算所有元素的哈希值并分配到新桶中; 3. 树化条件:链表长度≥8且数组长度≥64[^4]。 --- ### HashSet与HashMap的区别 | **特性** | **HashMap** | **HashSet** | |----------------|---------------------------|----------------------------| | **存储形式** | 键值对(Key-Value) | 单元素(实际为Key,Value为固定占位对象) | | **数据结构** | 数组+链表/红黑树 | 基于HashMap实现(仅用Key) | | **扩容机制** | 容量翻倍,阈值=容量×0.75 | 同HashMap(依赖底层实现) | | **性能开销** | 较高(需存储Value) | 较低 | --- ### 使用场景 1. **HashMap**: - 需要键值对映射的场景(如缓存、索引); - 快速查找、插入、删除(时间复杂度接近$O(1)$); - 统计词频、缓存数据(如`Map<String, Integer>`)[^1][^2]。 2. **HashSet**: - 需要元素唯一性的集合(如去重操作); - 集合运算(并集、交集、差集); - 存储无需排序的独立对象(如用户ID集合)。 --- ### 代码示例 ```java // HashMap使用示例 Map<String, Integer> map = new HashMap<>(); map.put("apple", 1); map.put("banana", 2); // HashSet使用示例 Set<String> set = new HashSet<>(); set.add("apple"); set.add("banana"); ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值