HashMap实现原理通俗详解

本文深入浅出地解析了HashMap的工作原理,包括其数组+链表的数据结构、哈希冲突处理、扩容机制、查找方法等核心内容,并探讨了线程安全性问题。

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

HashMap实现原理通俗详解

前言

JDK版本:1.8
IDE:IntelliJ IDEA

概述

  • HashMap的实现方式是数组+链表,主体是数组,链表只是用来做辅助的
  • HashMap的主干是一个Node数组,每一个Node包含一个Key-Value键值对
  • HashMap通过计算Key的哈希值来确实Key-Value在HashMap中的插入、查询位置
  • HashMap最重要和复杂的方法put方法,扩容方法,get相对来说比较简单
  • HashMap是基于哈希表的Map接口的非同步实现(简单说多线程下使用它会出事儿的)

原理

先说下数组,数组的特性是通过给定数组下标进行操作(查找,插入,删除)速度非常快,怎样把这一特性应用到Map呢?
于是有聪明的coder想到了这样一种方法:通过一种方式由Map的Key生成一个随机数(哈希),
这个随机数再通过一种方式生成数组的下标(对数组长度取余),然后我们就可以非常迅速地找到它的位置了
ok基本思路确实了,下面自然就是解决这种方式存在的问题了

问题1:生成的随机数重复(哈希冲突, 碰撞)

通过Key很难生成生成一个全球唯一的随机数,而且就算生成全球唯一的随机数,需要花费的性能代价也非常高,不可取,怎么办呢?
答案就是我们只需要让生成的这个随机数足够均匀地分散到数组的每一个位置就可以了,然后如果有重复的,只需要让这个元素挂到前一个元素的后面就可以了
于是就有了数组加链表的形式,但是链表只是做辅助,最终还是以数组为主的。通过这种实现方式,HashMap查找容易,插入删除也容易

问题2:数组的长度是固定的,而Map的大小是不确定的,怎么办呢?

还能怎么办呢?Map的大小超出数组的长度总不能不插吧?只能扩容了!创建一个新的更大的数据,然后把原来数组里的所有元素重新计算放到这个新的数组里,
当然Map扩容是非常耗性能的,这个没有办法,只能你在初始化Map的时候指定数组的大小了

问题3:什么时候扩容呢?

JDK1.8的解决方案是HashMap的大小超过了数组长度的75%(loadFactor=0.75), 就会对数组进行扩容(创建一个新数组容量是原来的2倍)

问题4:怎么查找呢?

查找的时候通过Key以同样的方式生成一个随机数,然后就可以定位到目标在数组中的标下了,如果该下标下有一串好几个元素(单向链表),那就没有办法了,只能一个一个遍历,
如果这个链表超过了8个元素就把它转为一个平衡红黑树,这样查找起来更快,不过最好是一个数组下标一个元素

然后就有了HashMap如下的结构图
HashMap实现原理

要点:

1. HashMap的基本组成单元:Node

HashMap的基本组成单元是Node,它主要的实现属性如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    ...
}

取自HashMap源码,后面的构造方法啊,get,set,toString,equals,hashCode方法啊什么的这里就不列出来了,可以支JDK源码自己看
Node类在JDK1.7的时候是叫Entry,Oracle把它定义成静态内部类了,
每个Node包含一个Key-Value对,
key和value就是HashMap的key, value
next:指向下一个Node,就通过这么一个属性就实现了意向链表啦
hash:通过key的哈希值,存起来后面进行扩容的时候就不用再计算了
HashMap的主干就是一个Node数组:

/**
* 第一次初始化的时候使用,扩容的时候使用,扩容大小总是2的n次方
* 以Node<K,V>为元素的数组,也就是HashMap的纵向的长链数组,起长度必须为2的n次方
* (We also tolerate(默许) length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;

上面的中文注释是我加上去的,它是懒加载的。

2. HashMap的put方法

put方法主要实现以下步骤:
第一步:如果数组(table)为空,则调用resize函数扩容创建一个数组
第二步:计算元素所要存储的数组下标,如果此下标没有元素则直接插入
第三步:否则说明要添加的位置已经有元素了,也就是发生了碰撞,这个时候分以下几种情况
第一种情况:key值相同,直接覆盖
第二种情况:判断链表是否为红黑树
第三种情况:链表是正常的链表(直接插到最后面就可以了)
做完以上三步后判断是否需要扩容啊什么的
下面是部分代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //tab[]为数组,p是每个桶
    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;
    //第二步:计算元素所要储存的位置index,如果此位置没有元素则直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)  
        tab[i] = newNode(hash, key, value, null);
    //否则说明要添加的位置上面已经有元素了,也就是发生了碰撞,这个时候就要具体情况具体讨论了
    else {  
        Node<K,V> e; K k;
        //第一种情况:key值相同,直接覆盖
        if (p.hash == hash &&   
            ((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);
                    //链表大于8转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        treeifyBin(tab, hash);
                    break;
                }
                //如果节点key存在,则覆盖原来位置上的key,同时将原来位置的元素沿着链表身后移一位。
                if (e.hash == hash &&   
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        ...
}

3. 数组索引位置

通过对元素的key生成哈希值的函数:

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

意思就是取Key的哈希值,然后对HashCode()的高16位异或低16位,
为什么要高16位异或低16位呢?
这样做是为了让最终的哈希值更加离散分布得更加均匀,更加详细的可以去网上查,讲的的话会很长
然后利用这个值对数组的长度取余就是Key在数组中的下标啦
你可能会问如果数组扩容了,它的下标不就变了吗?
对啊,确实变了,需要重新计算它的下标了,然后把它插入到新的更大的数组里
这就是为什么Node类中要存储hash值
这就是为什么HashMap是没有顺序的
这就是为什么说扩容是非常消耗性能的

4. 扩容机制

插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍
当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组
然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。
HashMap有两个成员变量:
DEFAULT_INITIAL_CAPACITY: HashMap默认的初始化数组的大小,默认为16
DEFAULT_LOAD_FACTOR: 加载因子,默认为0.75,,当HashMap的大小达到数组的0.75的时候就会扩容

final Node<K,V>[] resize() {
    //创建一个oldTab数组用于保存之前的数组
    Node<K,V>[] oldTab = table;     
    //获取原来数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;  
    //原来数组扩容的临界值
    int oldThr = threshold;     
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果原来的数组长度大于最大值(2^30)
        if (oldCap >= MAXIMUM_CAPACITY) {   
            //扩容临界值提高到正无穷
            threshold = Integer.MAX_VALUE;  
            //返回原来的数组,也就是系统已经管不了了
            return oldTab;      
        }
        //新数组(newCap)长度乘2 < 最大值(2^30) && (原来的数组长度) >= 初始长度(2^4)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)    
            //这个else if中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,
            //同时交待了扩容是以2^1为单位扩容的。
            newThr = oldThr << 1; 
    }// newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1
    else if (oldThr > 0) // initial capacity was placed in threshold
        //新数组的初始容量设置 为老数组扩容的临界值
        newCap = oldThr;    
    // 否则 oldThr == 0,零初始阈值表示使用默认值
    else {               
        //新数组初始容量设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;  
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果newThr ==0,说明为上面 else if(oldThr > 0)的情况(其他两种情况都对newThr的值做了改变),
    //此时newCap = oldThr;
    if (newThr == 0) {  
        //ft为临时变量,用于判断阈值的合法性,
        float ft = (float)newCap * loadFactor; 
        //计算新的阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE); 
    }
    //改变threshold值为新的阈值
    threshold = newThr; 
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //改变table全局变量为扩容后的newTable
    table = newTab; 
    if (oldTab != null) {
        //遍历数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //新建一个Node<K,V>类对象,用它来遍历整个数组。
            if ((e = oldTab[j]) != null) {  
                oldTab[j] = null;
                //将e也就是oldTab[j]放入newTab中e.hash & (newCap -1)的位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果e已经是一个红黑树的元素
                else if (e instanceof TreeNode) 
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 链表重排
                else { 
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    ...
                    ...
                    ...
}

5. 线程安全性

HashMap是线程不安全的,在多线程的情况下,尽量不要使用HashMap(虽然它的性能很好),而使用线程安全的ConcurrentHashMap

小结

  1. 扩容是一个特别耗性能的操作,所以在使用HashMap的时候,最好估算一下Map的大小,初始化的时候给一个大致的数值,避免Map频繁扩容。
  2. JDK1.8引入红黑树大大优化了HashMap的性能。
  3. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap
  4. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊
<think>嗯,用户想了解Java泛型的详解。首先,我需要回忆一下Java泛型的基本概念和应用场景。泛型是Java 5引入的特性,主要用于增强类型安全,减少类型转换的麻烦,以及提高代码的可重用性。用户可能是在学习过程中遇到了泛型相关的困惑,或者在实际开发中需要更深入的理解。 接下来,用户的需求可能包括了解泛型的基本语法、类型擦除、通配符、泛型方法、泛型类与接口,以及使用限制等。需要分步骤讲解,确保结构清晰。可能需要从为什么需要泛型开始,然后逐步展开各个部分。 要注意的是,用户可能对类型擦除这一Java泛型的实现机制不太清楚,需要详细解释。同时,通配符部分,尤其是上下界通配符的区别和使用场景,可能是容易混淆的地方,需要举例说明。此外,泛型在集合框架中的应用是一个常见的例子,可以用来帮助用户理解。 还需要考虑用户可能的误区,比如泛型在运行时类型信息被擦除的问题,导致无法进行instanceof判断或者创建泛型数组。这些限制需要明确说明,避免用户在实际使用中犯错。 另外,用户可能对泛型方法与泛型类的区别不太清楚,需要分别举例说明。比如,泛型方法可以在普通类中定义,而泛型类则是整个类作用于类型参数。 最后,要确保语言通俗易懂,避免过于技术性的术语,同时结合代码示例,让用户能够通过实例理解抽象的概念。检查是否有遗漏的重要知识点,比如泛型继承、通配符捕获等,但可能根据用户的提问深度决定是否展开。</think>以下是对Java泛型的系统化详解,分步骤解析核心概念和用法: --- ### 一、泛型解决的问题 1. **类型安全** 在集合等场景中,编译时检测类型错误,避免运行时`ClassCastException` ```java // 非泛型写法(存在隐患) List list = new ArrayList(); list.add("abc"); Integer num = (Integer) list.get(0); // 运行时异常 // 泛型写法(编译时报错) List<String> list = new ArrayList<>(); list.add(123); // 编译错误 ``` 2. **消除强制类型转换** 明确容器存储类型,减少冗余代码 --- ### 二、基础语法 #### 1. 泛型类 ```java public class Box<T> { private T content; public void setContent(T content) { this.content = content; } public T getContent() { return content; } } ``` - `T`为类型参数(可任意命名,常用`T`, `E`, `K`, `V`等) - 实例化时指定具体类型: ```java Box<Integer> intBox = new Box<>(); ``` #### 2. 泛型接口 ```java public interface Comparator<T> { int compare(T o1, T o2); } ``` #### 3. 泛型方法 ```java public <T> void printArray(T[] array) { for (T element : array) { System.out.println(element); } } ``` - 类型参数声明在方法返回值前 --- ### 三、类型擦除(关键机制) 1. **编译时类型检查** 编译器检查泛型类型合法性 2. **运行时类型擦除** 生成的字节码中不包含泛型类型信息,替换为原始类型 ```java List<String> list = new ArrayList<>(); // 编译后等效于 List list = new ArrayList(); ``` 3. **桥方法生成** 用于保持多态性(如继承泛型类时) --- ### 四、通配符 #### 1. 上界通配符 `<? extends T>` ```java void process(List<? extends Number> list) { // 允许读取为Number类型 Number num = list.get(0); // 不允许添加元素(编译器无法确定具体子类型) } ``` #### 2. 下界通配符 `<? super T>` ```java void addNumbers(List<? super Integer> list) { list.add(42); // 允许添加Integer及其父类元素 Object obj = list.get(0); // 读取只能作为Object } ``` #### 3. 无限定通配符 `<?>` ```java void printList(List<?> list) { for (Object elem : list) { System.out.println(elem); } } ``` --- ### 五、重要限制 1. **无法实例化类型参数** ```java T obj = new T(); // 编译错误 ``` 2. **不能创建泛型数组** ```java T[] array = new T[10]; // 编译错误 ``` 3. **静态上下文中不可用** ```java public class Test<T> { private static T instance; // 错误 } ``` 4. **instanceof检测无效** ```java if (list instanceof ArrayList<String>) { ... } // 编译错误 ``` --- ### 六、高级技巧 1. **类型参数多重限定** ```java <T extends Comparable & Serializable> ``` 2. **泛型与反射** ```java Type type = new TypeToken<Map<String, Integer>>(){}.getType(); ``` 3. **PECS原则** Producer-Extends, Consumer-Super (适合选择通配符边界的场景) --- ### 七、典型应用场景 1. 集合框架(`ArrayList<E>`, `HashMap<K,V>`) 2. 工具类方法(`Collections.sort()`) 3. 回调接口设计(`Comparator<T>`) 4. 自定义通用数据结构 --- 通过掌握这些核心概念,可以写出类型更安全、复用性更强的Java代码。建议结合具体项目实践加深理解。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值