HashMap-源码核心方法分析(基于Jdk8)

一.宏观结构

HashMap体量比较巨大,必须先站在远处宏观的认识它,然后逐步深入。
本文基于jdk1.8。
在这里插入图片描述
jdk1.8版本的hashmap如上图所示,是数组+链表+红黑树(之前版本没有红黑树)。
其中,

  • table数组:一段连续的地址,这里面存放是后边链表的头地址;
  • bucket: 桶,当key的hash值相同时,就只能往后排了,形成链表(链表长度大于等于默认值8,转为红黑树,小于等于6,转为链表)。
    链表中的每个节点的结构分如下4个部分(参考源码Node静态内部类):
    在这里插入图片描述
    a.hash值:就是key的hash值;
    b.key:就是键值;
    c.value:就是value;
    d.next :指向下一个节点的指针

还要注意几个概念:

  • 扩容:当hashmap中的元素数量,大于一定阈值(容量*负载因子)时,hashmap就需要进行扩容;
  • 扩容消耗性能:为啥??因为原来已经放入的对象要挪地方重新插入;为啥要挪地方,留在原地不行吗,新来的对象放在新开辟的地址中呗?不行的,因为table的地址必须是连续的(数组结构特点),假如初始大小是16,扩容之后是32,这32个地址必须也是连续的,也就是得重新找一块地方,之前那16个,也得跟着换地方了。也就是大家一起搬家住新房子,搬家肯定很麻烦的。

二.微观方法

HashMap直接看源码,发现有2000多行,其实很大部分是让人看不太懂的注释,这里直接拿掉,替换为我简单粗暴的解释,剩下的代码,一下子少了好多。
主要内容有:

  • 一些常量定义:比如默认容量等,通过这些参数,可以了解hashmap的一些特性;
  • 构造方法:简单了解下;
  • put()方法 :比较核心重要
  • 扩容方法resize():比较核心重要
  • get()方法

下面详细扒一扒,建议还是自己直接分析源码,有不懂的地方,在参考本文或者其他博客。

1.一些常量

先列举常量作用(看代码中的注释),如果对这些已经熟悉的,直接跳过。

//默认的初始化容量,16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量,2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子,默认0.75;不建议修改
//作用:比如初始容量是16,负载因子是0.75,map的容量并不是达到16才进行扩容,而是达到16*0.75=12,就开始扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表变为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树变回链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树机制开启的最小数组大小,不能小于DEFAULT_INITIAL_CAPACITY*4
//作用就是当数组的长度小于64,但是tab[i]上的节点数已经大于等于8(TREEIFY_THRESHOLD)了,它也不会先转化为红黑树,而是先扩容
//只有数组长度大于64,并且节点数量大于等于8才转化为红黑树结构
static final int MIN_TREEIFY_CAPACITY = 64;

2.四个构造方法,很简单

//指定初始容量和负载因子
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);
}

//指定初始容量,使用默认负载因子0.75
HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}

//使用默认初始容量16,默认负载因子0.75
HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; }

//从一个已有的hashmap初始化
public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

注意:当你指定一个初始容量时,比如12,但是实际上真正的初始化并不是12,而是16;因为hashmap的容量规定必须是2的次幂(考虑到性能原因,下面分析put方法时,会详细说明),当你给定的初始容量cap不是2的次幂时,计算大于cap最近的一个2次幂数值;如何计算呢,就是下面这个方法(看不懂具体算法没关系,直到方法功能就行了):

//计算>=cap的,最近的一个2的倍数
//输入7,得到8;输入12,得到16
static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

3.put(K key, V value)方法

这个方法比较重要,核心部位衣服比较难扒,不要猴急,注意代码中的注释A B C …

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; Node<K,V> p; int n, i;
    //如果数组是空的,则直接进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //A.:::    注意(n - 1) & hash ,效果等同于同于hash%n
    //n是数组长度,hash是key的hash值
    //如果tab[i]的位置,没有存过节点,就直接把这个新节点加入进去
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    //B. :::  如果tab[i]的位置已经有节点了,就形成链表(或者红黑树),往链表里放
        Node<K,V> e; K k;
        //B.a ::: 当tab[i]位置的节点key与要插入的节点key相同,将这个已存在的节点先赋值给变量e,后边好覆盖老节点中的value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //B.b :::  如果tab[i]的位置节点是红黑树类型的,就往红黑树里面插入节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//B.c ::: tab[i]位置key与要插入的节点key不相等,并且也不是红黑树,那说明是链表了
        	  //这里循环遍历链表,找到最后一个,将新节点插入尾部(注意jdk1.7是插入链表头部的)
            for (int binCount = 0; ; ++binCount) {
                //==null,说明是尾部了
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //B.d    如果链表长度达到8,变为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //B.d ::: 如果要要插入的key已存在,则用新值覆盖旧值,并返回旧值  
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //C. ::: 如果 map中元素个数大于扩容阈值,进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

put步骤说明
A. 先判断tab[i]位置有没有元素,如果没有直接将新节点放入即可;hash值与数组下标的映射关系是(n - 1) & hash;
其实, (n - 1) & hash的效果,等同于hash%n(前提是n为2的次幂,比如hash=17,n=16,那么下标i就是1)
为啥不直接取余计算???因为这样&计算,效率比取余高。这就是为啥hashmap要求容量必须是2的次幂(8,16,32这样),就是为了计算数组下标时,能使用这种高效的计算方式。

B. 如果tab[i]的位置已经有节点了,就形成链表(或者红黑树),往链表里放;如果是红黑树,调用红黑树的方法,平衡插入,左旋右旋等方法,本文先不深入分析了。
B.a 当tab[i]位置的节点key与要插入的节点key相同,将这个已存在的节点先赋值给变量e,后边好覆盖老节点中的value,通过步骤B.d。
B.b 如果tab[i]的位置节点是红黑树类型的,就往红黑树里面插入节点;
B.c 如果tab[i]位置key与要插入的节点key不相等,并且也不是红黑树,那说明是链表了;那就循环遍历链表,找到最后一个节点,将新节点插入链表尾部(注意jdk1.7是插入链表头部的)
B.d 如果要插入的key已存在,则用新值覆盖旧值,并返回旧值 。
C. 如果 map中元素个数大于扩容阈值,进行扩容;这里扩容阈值在初始化的时候,=数组长度,扩容一次之后,重新赋值为数组长度*负载因子。

4.resize(),扩容

当map中的元素个数大于扩容阈值,就需要进行扩容了。
这个方法同样比较重要,核心部位衣服比较难扒,不要猴急,注意代码中的注释A B C D …

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //A.:::扩容之前容量是否大于0
    if (oldCap > 0) {
        //A.a:::如果扩容之前就是最大容量了,就算了,直接返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //A.b:::新容量等于oldCap*2,新阈值等于oldThr*2,但都不能大于最大默认值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    /B.:::hashmap初始化的时候,容量为0,直接让容量等于阈值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
    //C.::: 也就是说初始化hashmap的时候,如果不指定容量,使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //使用新的容量申请数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
   
    if (oldTab != null) {
        //D.:::  老的数组不是null,那么就遍历老数组,挨个处理吧
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //D.a:::  如果数组中内容是null,就不需要处理;直接下一个循环
            if ((e = oldTab[j]) != null) {
            	//D.b::: 如果不是空,将老位置置空,相当于释放内存
                oldTab[j] = null;
                if (e.next == null)
                //如果tab[i]位置只有一个节点,后边都是空,那么将此节点重新计算哈希存入新table中即可
                    newTab[e.hash & (newCap - 1)] = e;
                //D.c:::  如果是红黑树结构,遍历分解红黑树
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                //D.d:::  链表,则遍历每个元素,重新插入newTab
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 这个判断比较重要, e.hash & oldCap的效果等同于hash除以oldCap,商是偶数的
                        //符合这个条件的元素,下标不变,不符合条件的,下标+oldCap
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize步骤说明
A. 扩容之前容量是否大于0
A.a 如果扩容之前就是最大容量了,就算了,直接返回
A.b 新容量等于oldCap2,新阈值等于oldThr2,但都不能大于最大默认值
B. hashmap初始化的时候,容量为0,直接让容量等于阈值
C. 说初始化hashmap的时候,如果不指定容量,使用默认值
以上是容量阈值初始化过程;
以下是真正扩容方法
D. 如果老的数组不是null,那么就遍历老数组,挨个处理吧
D.a 如果数组中内容是null,就不需要处理;直接下一个循环
D.b 如果不是空,将老位置置空,相当于释放内存
D.c 如果是红黑树结构,进入红黑树中遍历,具体同D.d,把红黑树中的元素拆分两部分,拆完后如果元素小于6,调整为链表。
D.d 如果是链表,则遍历每个元素,重新插入newTab;那具体下标如何重新分配呢?规则很简单:
e.hash & oldCap==0这个判断比较重要, 效果等同于hash除以oldCap,商是偶数的;符合这个条件的元素,下标不变,不符合条件的,下标+oldCap。

比如,原来容量是16,扩容之后肯定是32;我们把这32分为两部分,下标0-15和16-31;hash值除以16,商是偶数的元素,其在新tab中的下标不变,比如hash=13,之前在tab[12]的位置,那么扩容之后,他还在这个位置;
再比如hash=17,之前在tab[1]的位置,扩容之后,他在tab[1+16]的位置。

注意:
在jdk1.7中的扩容,移动老数据到新的table中,如果发生hash冲突,后面链表是头插法,在多线程并发场景下,会出现循环列表进而导致死循环;同时也会出现插入覆盖的问题;
在jdk1.8中,采用尾插法,避免了死循环的问题,但是在多线程并发场景,依然会出现插入覆盖的问题;

5.get(Object key),获取元素

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) {
        //A.:::根据hash,得到tab[i]位置第一个元素,如果是要找的,直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
        	//B.:::如果tab[i]是红黑树,遍历红黑树
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
            //C.::否则就是链表,那么就遍历链表
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

步骤说明:
A.:::根据hash,得到tab[i]位置第一个元素,如果是要找的,直接返回
B.:::如果tab[i]是红黑树,遍历红黑树
C.::否则就是链表,那么就遍历链表

本文就蹂躏到这里吧,已经比较持久了,还有一私密的地方,后续在慢慢探索噢~

参考文章
https://www.cnblogs.com/leesf456/p/5242233.html

<think>嗯,用户让我介绍一下JDK8HashMap源码。首先,我得先回想一下HashMap的基本结构。在JDK8之前,HashMap是数组加链表的结构,当哈希冲突较多时,链表会变得很长,查询效率会下降。而JDK8之后,当链表长度超过8时,会转为红黑树,这样查询的时间复杂度就从O(n)变成O(log n)了。这个优化应该是个重点。 接下来,我需要考虑HashMap的几个核心部分:数据结构、哈希计算、put方法、扩容机制、红黑树转换条件等。用户可能对这些部分感兴趣,特别是和之前版本不同的地方,比如红黑树的引入。 然后,数据结构方面,HashMap内部有一个Node数组,每个Node可能是一个链表节点或者树节点。需要解释一下Node类的结构,包括hash、key、value、next这些属性。当链表转成树时,会用TreeNode,这可能继承自LinkedHashMap的Entry,但具体的继承关系可能需要查证一下。 哈希计算方面,JDK8的hash方法做了优化,将高位的信息也参与到索引的计算中,减少哈希冲突。具体来说,hashCode()的高16位和低16位进行异或,这样在数组长度较小时,高位的变化也能影响最终的索引位置,减少碰撞。 put方法的流程是关键。当插入一个键值对时,首先计算哈希值,找到数组下标。如果该位置为空,直接插入;如果是链表或树节点,则遍历查找是否存在相同的key,存在则替换,否则添加到链表末尾或树中。当链表长度达到8且数组长度大于等于64时,链表转红黑树,否则进行扩容。这个阈值需要注意,可能用户会问到为什么是8或者64。 扩容机制,也就是resize方法。当元素数量超过容量乘以负载因子时,数组会扩容为原来的两倍。扩容后,原来的节点会重新计算位置,要么在原位置,要么原位置+旧容量。这里可能涉及到高位判断,通过(e.hash & oldCap)是否为0来决定位置。同时,在JDK8中,链表在扩容时会被拆分成两个链表,保持顺序,避免死循环的问题,而JDK7之前可能因为头插法导致死循环。 线程安全方面,HashMap不是线程安全的,多线程环境下可能出现问题,比如数据覆盖、死循环(在JDK7中)等。需要提到ConcurrentHashMap或者使用Collections.synchronizedMap来保证线程安全。 另外,初始容量和负载因子的默认值,初始容量是16,负载因子0.75,这些参数的作用和调整的影响也需要解释一下。比如负载因子高可以减少空间开销,但增加查找成本;负载因子低则相反。 红黑树转换的条件需要明确,链表长度超过8且数组长度至少64才会转换,否则只是扩容。这是为了避免在哈希表还很小的时候,不必要的树化,因为扩容可能更有效。 可能用户还想知道源码中的一些具体实现细节,比如TreeNode的结构,红黑树的插入、删除操作是如何实现的,但可能这部分太深入,需要根据用户的背景决定是否展开。 在解释的时候,需要注意避免过于技术化的术语,用比较易懂的语言,同时结合代码结构,比如putVal方法、resize方法的关键步骤。可能还需要提到modCount用于快速失败机制,迭代器在检测到结构变化时抛出异常。 用户可能的疑问点包括:为什么选择红黑树而不是其他数据结构?为什么阈值是8?扩容为什么是两倍?这些可能需要准备答案,比如红黑树在查询和插入之间取得平衡,阈值8是基于泊松分布统计,两倍扩容是为了保持容量为2的幂,方便位运算计算索引。 总结下来,应该按照数据结构、哈希计算、put流程、扩容机制、树化条件、线程安全等方面来组织回答,重点突出JDK8的改进,比如红黑树的引入和扩容优化。</think>在 JDK8 中,`HashMap` 是一个基于哈希表实现的键值对容器,其源码设计在性能和数据结构上进行了优化(如引入红黑树)。以下是对其核心实现的介绍: --- ### **1. 数据结构** JDK8 的 `HashMap` 使用 **数组 + 链表 + 红黑树** 的复合结构: - **数组(`Node<K,V>[] table`)**:存储链表的头节点或红黑树的根节点。 - **链表(Node)**:解决哈希冲突的基础结构,每个节点存储键值对和下一个节点的引用。 - **红黑树(TreeNode)**:当链表长度超过阈值(默认 8)且数组长度 ≥ 64 时,链表转换为红黑树,提升查询效率。 ```java static class Node<K,V> implements Map.Entry<K,V> { final int hash; // 哈希值 final K key; V value; Node<K,V> next; // 链表的下一个节点 } ``` -- ### **2. 哈希计算** 通过 `hash()` 方法将键的哈希码分散到数组中,减少碰撞: ```java static final int hash(Object key) { int h; // 高16位与低16位异或,增加低位的随机性 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` -- ### **3. `put()` 方法流程** 1. **计算哈希值**:根据 `key` 计算哈希值,确定数组下标 `(n-1) & hash`。 2. **处理空桶**:若目标位置为空,直接插入新节点。 3. **处理冲突**: - **链表**:遍历链表,若 `key` 已存在则更新值,否则添加到链表尾部。 - **红黑树**:调用树的插入方法。 4. **树化检查**:链表长度 ≥ 8 时尝试树化(需数组长度 ≥ 64,否则优先扩容)。 5. **扩容检查**:元素总数超过 `容量 * 负载因子`(默认 0.75)时触发扩容。 --- ### **4. 扩容机制(`resize()`)** - **新容量**:原容量的 2 倍(保持为 2 的幂)。 - **数据迁移**: - 链表节点根据 `(e.hash & oldCap)` 是否为 0 分配到原位置或新位置。 - 红黑树节点会拆分为两个链表,若长度 ≤ 6 则退化为链表。 -- ### **5. 红黑树转换条件** - **链表 → 红黑树**:链表长度 ≥ 8 **且** 数组长度 ≥ 64。 - **红黑树 → 链表**:树节点数 ≤ 6 时退化为链表。 --- ### **6. 关键参数** - **默认初始容量**:16。 - **负载因子(`loadFactor`)**:默认 0.75(平衡空间与时间效率)。 - **扩容阈值**:`容量 * 负载因子`。 - **树化阈值**:链表长度 ≥ 8,数组长度 ≥ 64。 --- ### **7. 线程安全性** `HashMap` **非线程安全**,多线程操作可能导致数据不一致。替代方案: - 使用 `ConcurrentHashMap`。 - 通过 `Collections.synchronizedMap()` 包装。 -- ### **8. 性能优化点** - **红黑树**:避免长链表的低效查询(时间复杂度从 O(n) 优化到 O(log n))。 - **高位异或哈希**:减少哈希碰撞。 - **扩容优化**:避免 JDK7 及之前版本的头插法导致的死循环问题。 -- ### **源码分析示例(简化的 `put()` 流程)** ```java 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; Node<K,V> p; int n, i; // 初始化或扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算桶位置 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 直接插入 else { // 处理哈希冲突... } // 检查扩容... } ``` --- 通过以上设计,JDK8 的 `HashMap` 在高并发和高负载场景下显著提升了性能,同时保持了合理的空间利用率。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值