Java集合底层源码剖析-HashMap

文章目录

  • 什么是Map
  • 什么是哈希表
  • HashMap 源码中提到的几个重要概念
  • 红黑树
  • 数组+链表+红黑树的数据结构
  • 核心成员变量的作用
  • HashMap的Node实体
  • HashMap的构造方法
  • 优化后的降低冲突概率的hash算法
  • put操作原理以及hash寻址算法
  • get方法
  • contains
  • remove方法
  • clear()方法
  • 处理hash冲突时的链表
  • 通过红黑树来解决hash冲突
  • 基于数组的扩容原理
  • JDK1.8的高性能rehash算法
  • 有顺序的map数据结构

什么是Map

  • Map是一个接口,他是key-value的键值对,一个map不能包含重复的key,并且每一个key只能映射一个value;
  • Map接口提供了三个集合视图:key的集合,value的集合,key-value的集合;
  • Map内元素的顺序取决于Iterator的具体实现逻辑,获取集合内的元素实际上是获取一个迭代器,实现对其中元素的遍历;
  • Map接口的具体实现中存在三种Map结构,其中HashMap和TreeMap都允许存在null值,而HashTable的key不允许为空,但是HashMap不能保证遍历元素的顺序,TreeMap能够保证遍历元素的顺序。
    HashMap是基于哈希表的Map接口的实现,提供所有可选的映射操作,允许使用null值和null键,存储的对象时一个键值对对象Entry
    是基于数组+链表的结构实现,在内部维护这一个数组table,数组的每个位置保存着每个链表的表头结点,查找元素时,先通过hash函数得到key值对应的hash值,再根据hash值得到在数组中的索引位置,拿到对应的链表的表头,最后去遍历这个链表,得到对应的value值。

什么是哈希表

哈希表(HashTable,散列表)是根据key-value进行访问的数据结构,他是通过把key映射到表中的一个位置来访问value,加快查找的速度,其中映射的函数叫做散列函数,存放value的数组叫做散列表,哈希表的主干是数组。
存在的问题就是不同的值在经过hash函数之后可能会映射到相同的位置上,当插入一个元素时,发现该位置已经被占用,这时候就会产生冲突,也就是所谓的哈希冲突,因此哈希函数的设计就至关重要,一个好的哈希函数希望尽可能的保证计算方法简单,但是元素能够均匀的分布在数组中,但是数组是一块连续的且是固定长度的内存空间,不管一个哈希函数设计的多好,都无法避免得到的地址不会发生冲突,因此就需要对哈希冲突进行解决。
(1)开放定址法:当插入一个元素时,发生冲突,继续检查散列表的其他项,直到找到一个位置来放置这个元素,至于检查的顺序可以自定义;
(2)再散列法:使用多个hash函数,如果一个发生冲突,使用下一个hash函数,直到找到一个位置,这种方法增加了计算的时间;
(3)链地址法:在数组的位置使用链表,将同一个hashCode的元素放在链表中,HashMap就是使用的这种方法,数组+链表的结构。

HashMap 源码中提到的几个重要概念

  • 哈希桶(buckets):在 HashMap 的注释里使用哈希桶来形象的表示数组中每个地址位置。注意这里并不是数组本身,数组是装哈希桶的,他可以被称为哈希表。

  • 初始容量(initial capacity) : 这个很容易理解,就是哈希表中哈希桶初始的数量。如果我们没有通过构造方法修改这个容量值默认为DEFAULT_INITIAL_CAPACITY = 1<<4 即16。值得注意的是为了保证 HashMap 添加和查找的高效性,HashMap 的容量总是 2^n 的形式。

  • 加载因子(load factor):加载因子是哈希表(散列表)在其容量自动增加之前被允许获得的最大数量的度量。当哈希表中的条目数量超过负载因子和当前容量的乘积时,散列表就会被重新映射(即重建内部数据结构),重新创建的散列表容量大约是之前散列表哈系统桶数量的两倍。默认加载因子(0.75)在时间和空间成本之间提供了良好的折衷。加载因子过大会导致很容易链表过长,加载因子很小又容易导致频繁的扩容。所以不要轻易试着去改变这个默认值。

  • 扩容阈值(threshold):其实在说加载因子的时候已经提到了扩容阈值了,扩容阈值 = 哈希表容量 * 加载因子。哈希表的键值对总数 = 所有哈希桶中所有链表节点数的加和,扩容阈值比较的是是键值对的个数而不是哈希表的数组中有多少个位置被占了。

  • 树化阀值(TREEIFY_THRESHOLD) :这个参数概念是在 JDK1.8后加入的,它的含义代表一个哈希桶中的节点个数大于该值(默认为8)的时候将会被转为红黑树行存储结构。

  • 非树化阀值(UNTREEIFY_THRESHOLD): 与树化阈值相对应,表示当一个已经转化为数形存储结构的哈希桶中节点数量小于该值(默认为 6)的时候将再次改为单链表的格式存储。导致这种操作的原因可能有删除节点或者扩容。

  • 最小树化容量(MIN_TREEIFY_CAPACITY): 经过上边的介绍我们只知道,当链表的节点数超过8的时候就会转化为树化存储,其实对于转化还有一个要求就是哈希表的数量超过最小树化容量的要求(默认要求是 64),且为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD);在达到该有求之前优先选择扩容。扩容因为因为容量的变化可能会使单链表的长度改变。

红黑树

红黑树是一种自平衡二叉查找树,可以在 O (log (n)) 时间内完成查找、插入和删除。红黑树是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees),它具有以下性质:
1.每个节点要么是红色,要么是黑色。
2.根节点是黑色。
3.每个叶节点(NIL节点,空节点)是黑色。
4.如果一个节点是红色,那么它的两个子节点都是黑色。
5.对于每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数量的黑色节点。

当红黑树的某个节点的左子树高度大于右子树高度的两倍时,需要进行旋转操作。旋转操作分为左旋和右旋两种情况。左旋操作是将左子树的根节点移到当前节点的右子树,然后对当前节点进行右旋操作;右旋操作是将右子树的根节点移到当前节点的左子树,然后对当前节点进行左旋操作。

插入操作的过程如下:
将新节点插入到红黑树的正确位置上,即左子树或右子树。
如果新节点是红色,它的父节点也是红色,这违反了红黑树的性质4,需要进行颜色调整,即将父节点和兄弟节点都设为黑色,将父节点的父节点设为红色,然后进行左旋或右旋操作。
如果新节点的兄弟节点是红色,这违反了红黑树的性质4,需要进行颜色调整,即将兄弟节点设为黑色,将新节点的父节点设为红色,然后进行左旋或右旋操作。
如果新节点的兄弟节点是黑色,但新节点的父节点是红色,这违反了红黑树的性质5,需要进行旋转操作。如果新节点的兄弟节点的另一个子节点也是黑色,需要进行右旋操作;否则需要进行左旋操作。

删除操作的过程如下:
找到要删除的节点及其前驱节点(pre)和后继节点(succ)。
如果要删除的节点有两个子节点,则将前驱节点的键值替换到要删除的节点,然后删除前驱节点;否则将后继节点的键值替换到要删除的节点,然后删除后继节点。
如果删除的节点是红色,它的兄弟节点也是红色,这违反了红黑树的性质4,需要进行颜色调整,即将要删除的节点和兄弟节点都设为黑色,将兄弟节点的另一个子节点设为红色,然后进行右旋或左旋操作。
如果删除的节点的兄弟节点是黑色,但兄弟节点的另一个子节点是红色,这违反了红黑树的性质5,需要进行旋转操作。如果兄弟节点的另一个子节点是左子节点,则进行右旋操作;否则进行左旋操作。
通过以上插入和删除操作的过程,红黑树可以在保持平衡的同时进行高效的插入和删除操作。

数组+链表+红黑树的数据结构

数组+链表+红黑树(JDK1.8之前)
在JDK1.8之前,HashMap的内部实现主要基于数组+链表+红黑树的数据结构。具体来说,HashMap内部有一个Entry类,用于存储键值对。每个Entry对象包含一个指向Node对象的引用,Node对象又包含一个指向下一个节点的引用和一个表示节点颜色的字段(红色或黑色)。当链表的长度超过一定阈值时,链表会转换为红黑树以提高查询效率。

数组+链表+红黑树+跳表(JDK1.8之后)
从JDK1.8开始,HashMap的内部实现采用了数组+链表+红黑树+跳表的数据结构。与之前的版本相比,这个版本的HashMap在处理哈希冲突时使用了链表和红黑树的组合,而不是仅使用链表。此外,为了进一步提高查询效率,还在红黑树的基础上引入了跳表。跳表是一种可以在O(logN)时间内完成查找、插入和删除操作的数据结构,它可以在红黑树的基础上进行优化,提高HashMap的性能。

总结:Java中的HashMap是一个基于数组+链表+红黑树(JDK1.8之前)或数组+链表+红黑树+跳表(JDK1.8之后)的数据结构实现的哈希表。它通过哈希函数将键映射到数组的一个位置,然后在该位置上创建一个链表或红黑树来存储具有相同哈希值的键值对。当链表的长度超过一定阈值时,链表会转换为红黑树以提高查询效率。从JDK1.8开始,HashMap还引入了跳表,以进一步提高查询效率。

核心成员变量的作用

table:这是一个Entry数组,存储了所有的key-value对,其中每个Entry对象都包含key、value、链表下一个Entry的引用以及两个整型变量,分别代表hash值和链表长度。用于存储键值对的数组,其长度为2的幂次方。当元素数量超过容量时,会进行扩容操作,将数组长度扩大至原来的两倍,并将原来的元素重新哈希到新的数组中。
链表头节点head:指向数组的第一个元素,即链表的头节点。当链表长度超过一定阈值时,链表会转换为红黑树以提高查询效率。
红黑树root:当链表长度超过一定阈值时,链表会转换为红黑树,该红黑树的根节点即为root。红黑树是一种自平衡二叉查找树,可以在O(log(n))时间内完成查找、插入和删除操作。
size:当前存储在HashMap中的元素个数。记录HashMap中键值对的数量,初始值为0。
threshold:当前HashMap的容量极限,当元素个数达到这个值的时候,HashMap会自动扩容。
loadFactor:负载因子,它表示了底层数组的利用率。负载因子越小,扩容时增加的元素越多,查询效率越高,但会增加内存开销;负载因子越大,扩容时增加的元素越少,查询效率越低,但可以减少内存开销。表示在扩容之前,HashMap允许的最大填充程度。默认值为0.75,即当元素数量达到容量的0.75倍时,会触发扩容操作。
modCount:用来记录对HashMap结构进行了多少次的修改操作(包括put、remove、clear等),每次修改操作都会增加modCount的值。
keySet、entrySet:这两个变量分别存储了HashMap中所有的键和所有的键值对。它们是两个不可变的集合视图,可以用来对外提供Map接口的方法。
哈希函数hashCode()和equals():用于将键映射到数组的索引位置。

 // 默认的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30; 
    // 默认的填充因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当桶(bucket)上的结点数大于这个值时会转成红黑树
    static final int TREEIFY_THRESHOLD = 8; 
    // 当桶(bucket)上的结点数小于这个值时树转链表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中结构转化为红黑树对应的table的最小大小
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存储元素的数组,总是2的幂次倍
    transient Node<k,v>[] table; 
    // 存放具体元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;   
    // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
    int threshold;
    // 加载因子
    final float loadFactor;

HashMap的Node实体

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 指向下一个节点
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash; // 哈希值,存放元素到hashmap中时用来与其他元素hash值比较
        this.key = key;
        this.value = value;
        this.next = next;
    }
    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }
    // 重写hashCode()方法
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    // 重写 equals() 方法
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&Objects.equals(value, e.getValue()))
                return true;
            }
        return false;
    }
}

其中key值定义的为final,因此在定义之后就无法进行修改,key和value就是在调用map时对应的键值对,next存储的是链表中的下一个节点,他是一个单链表,hash是对key的hashcode再次进行哈希运算之后得到的值,存储起来是为了避免重复计算。

HashMap的构造方法

/**
*使用默认的容量及装载因子构造一个空的HashMap
*/
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

/**
* 根据给定的初始容量和装载因子创建一个空的HashMap
* 初始容量小于0或装载因子小于等于0将报异常 
*/
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;
    //这个方法就是把容量控制在2的倍数
        this.threshold = tableSizeFor(initialCapacity);
        
}

/**
*根据指定容量创建一个空的HashMap
*/
public HashMap(int initialCapacity) {
    //调用上面的构造方法,容量为指定的容量,装载因子是默认值
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//通过传入的map创建一个HashMap,容量为默认容量(16)和(map.zise()/DEFAULT_LOAD_FACTORY)+1的较大者,装载因子为默认值
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

HashMap提供了四种构造方法:

(1)使用默认的容量及装载因子构造一个空的HashMap;

(2)根据给定的初始容量和装载因子创建一个空的HashMap;

(3)根据指定容量创建一个空的HashMap;

(4)通过传入的map创建一个HashMap。

第三种构造方法会调用第二种构造方法,而第四种构造方法将会调用putMapEntries方法将元素添加到HashMap中去。

putMapEntries方法是一个final方法,不可以被修改,该方法实现了将另一个Map的所有元素加入表中,参数evict初始化时为false,其他情况为true,我们来看看这个方法吧

final void putMapEntries(Map m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        if (table == null) { 
        //根据m的元素数量和当前表的加载因子,计算出阈值
        float ft = ((float)s / loadFactor) + 1.0F;
        //修正阈值的边界 不能超过MAXIMUM_CAPACITY
        int t = ((ft < (float)MAXIMUM_CAPACITY) ?(int)ft : MAXIMUM_CAPACITY);
        //如果新的阈值大于当前阈值
        if (t > threshold)
            //返回一个>=新的阈值的 满足2的n次方的阈值
            threshold = tableSizeFor(t);
        }
        //如果当前元素表不是空的,但是 m的元素数量大于阈值,说明一定要扩容。
        else if (s > threshold)
            resize();
        //遍历 m 依次将元素加入当前表中。
        for (Map.Entry e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

从中可以看出,它这个涉及了2个操作,一个是计算新的阈值,另一个是扩容方法

如果新的阈值大于当前阈值,需要返回一个>=新的阈值的 满足2的n次方的阈值,这涉及到了tableSizeFor:

  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;
    }

如果当前元素表不是空的,但是 m的元素数量大于阈值,说明一定要扩容。这涉及到了扩容方法resize。最复杂的方法之一

final Node<K,V>[] resize() {
    //oldTab 为当前表的哈希桶
    Node<K,V>[] oldTab = table;
    //当前哈希桶的容量 length
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //当前的阈值
    int oldThr = threshold;
    //初始化新的容量和阈值为0
    int newCap, newThr = 0;
    //如果当前容量大于0
    if (oldCap > 0) {
        //如果当前容量已经到达上限
        if (oldCap >= MAXIMUM_CAPACITY) {
            //则设置阈值是2的31次方-1
            threshold = Integer.MAX_VALUE;
            //同时返回当前的哈希桶,不再扩容
            return oldTab;
        }//否则新的容量为旧的容量的两倍。 
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
            //如果旧的容量大于等于默认初始容量16
            //那么新的阈值也等于旧的阈值的两倍
            newThr = oldThr << 1; // double threshold
    }
    //如果当前表是空的,但是有阈值。代表是初始化时指定了容量、阈值的情况
    else if (oldThr > 0) 
        newCap = oldThr;//那么新表的容量就等于旧的阈值
    else {    
    //如果当前表是空的,而且也没有阈值。代表是初始化时没有任何容量/阈值参数的情况               
        newCap = DEFAULT_INITIAL_CAPACITY;//此时新表的容量为默认的容量 16
    //新的阈值为默认容量16 * 默认加载因子0.75f = 12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        //如果新的阈值是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) {
        //遍历老的哈希桶
        for (int j = 0; j < oldCap; ++j) {
        //取出当前的节点 e
        Node<K,V> e;
        //如果当前桶中有元素,则将链表赋值给e
        if ((e = oldTab[j]) != null) {
            //将原哈希桶置空以便GC
            oldTab[j] = null;
            //如果当前链表中就一个元素,(没有发生哈希碰撞)
            if (e.next == null)
            //直接将这个元素放置在新的哈希桶里。
            //注意这里取下标 是用 哈希值 与 桶的长度-1 。 由于桶的长度是2的n次方,这么做其实是等于 一个模运算。但是效率更高
            newTab[e.hash & (newCap - 1)] = e;
            //如果发生过哈希碰撞 ,而且是节点数超过8个,转化成了红黑树
            else if (e instanceof TreeNode)
                 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            //如果发生过哈希碰撞,节点数小于8个。则要根据链表上每个节点的哈希值,依次放入新哈希桶对应下标位置。
            else {
                //因为扩容是容量翻倍,所以原链表上的每个节点,现在可能存放在原来的下标,即low位,或者扩容后的下标,即high位。high位=low位+原哈希桶容量
                //低位链表的头结点、尾节点
                Node<K,V> loHead = null, loTail = null;
                //高位链表的头节点、尾节点
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;//临时节点 存放e的下一个节点
                do {
                    next = e.next;
                  //利用位运算代替常规运算:利用哈希值与旧的容量,可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于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);
                    //将低位链表存放在原index处
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //将高位链表存放在新index处
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize的操作主要涉及以下几步操作:

  • 如果到达最大容量,那么返回当前的桶,并不再进行扩容操作,否则的话扩容为原来的两倍,返回扩容后的桶;
  • 根据扩容后的桶,修改其他的成员变量的属性值;
  • 根据新的容量创建新的扩建后的桶,并更新桶的引用;
  • 如果原来的桶里面有元素就需要进行元素的转移;
  • 在进行元素转移的时候需要考虑到元素碰撞和转红黑树操作;
  • 在扩容的过程中,按次从原来的桶中取出链表头节点,并对该链表上的所有元素重新计算hash值进行分配;
  • 在发生碰撞的时候,将新加入的元素添加到末尾;
  • 在元素复制的时候需要同时对低位和高位进行操作。

优化后的降低冲突概率的hash算法

Java中的HashMap使用了一种优化后的hash算法,以降低冲突的概率。该算法的核心思想是使用一个扰动函数和一个非线性组合方案,使得不同的key可以产生出不同的hash值,从而降低冲突的可能性。

具体来说,HashMap的hash算法步骤如下:
1、对key的hashCode进行高16位与低16位异或运算,得到一个扰动值。
2、将扰动值与HashMap的capacity-1进行按位异或运算,得到一个桶的索引。
3、将桶中的链表长度设置为桶内最大长度(即HashMap的loadFactor倍),如果链表长度已经达到最大长度,则进行扩容操作。
4、通过这种优化后的hash算法,HashMap可以更好地分布键值对,降低冲突的概率,从而提高查询效率。

TimSort排序数组的哈希算法首先将键值对转换为字符串,然后将字符串转换为字符数组。接着,对于每个字符数组,计算其哈希值,并将其映射到哈希表的相应位置上。如果发生哈希冲突,则使用链表或红黑树来存储具有相同哈希值的键值对。

put操作原理以及hash寻址算法

//向哈希表中添加元素
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
  • 向用户开放的put方法调用的是putVal方法:
  • putVal方法需要判断是否出现哈希冲突问题:
  • 其中如果哈希值相等,key也相等,则是覆盖value操作;如果不是覆盖操作,则插入一个普通链表节点;
  • 遍历到尾部,追加新节点到尾部;
  • 在元素添加的过程中需要随时检查是否需要进行转换成红黑树的操作;
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;
    //如果当前哈希表是空的,代表是初始化
    if ((tab = table) == null || (n = tab.length) == 0)
    //那么直接去扩容哈希表,并且将扩容后的哈希桶长度赋值给n
    n = (tab = resize()).length;
    //如果当前index的节点是空的,表示没有发生哈希碰撞。直接构建一个新节点Node,挂载在index处即可。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {//否则 发生了哈希冲突。
        Node<K,V> e; K k;
        //如果哈希值相等,key也相等,则是覆盖value操作
        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
            e = p;//将当前节点引用赋值给e
        else if (p instance of 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) // -1 for 1st
                    treeifyBin(tab, hash);
                    break;
                }
                //如果找到了要覆盖的节点
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //如果e不是null,说明有需要覆盖的节点,
        if (e != null) { // existing mapping for key
            //则覆盖节点值,并返回原oldValue
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //这是一个空实现的函数,用作LinkedHashMap重写使用。
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //如果执行到了这里,说明插入了一个新的节点,所以会修改modCount,以及返回null。
    ++modCount;
    //更新size,并判断是否需要扩容。
    if (++size > threshold)
    resize();
    //这是一个空实现的函数,用作LinkedHashMap重写使用。
    afterNodeInsertion(evict);
    return null;
}

HashMap的put操作原理如下:
1、根据key的hashCode计算出hash值。
2、使用hash寻址算法计算出存储位置的索引。
3、将key-value对存储在存储位置的桶中。
4、如果桶中已经存在元素,则覆盖原来的元素。

详细步骤

  • 第一步当然是先计算key的hash值(有过处理的 (h = key.hashCode()) ^ (h >>> 16))
  • 第二步调用putval方法,然后判断是否容器中全部为空,如果是的话,就把容器的容量扩容。
  • 第三步,把最大容量和hash值求&值(i = (n - 1) & hash),判断这个数组下标是否有数据,如果没有就把它放进去。还要判断key的equals方法,看是否需要覆盖。
  • 第四步,如果有,说明发生了碰撞,那么继续遍历判断链表的长度是否大于8,如果大于8,就继续把当前链表变成红黑树结构。
  • 第五步,如果没有到8,那么就直接把数据存在链表的尾部
  • 第六步,最后将容器的容量+1。

key.hashCode()是Key自带的hashCode()方法,返回一个int类型的散列值。我们大家知道,32位带符号的int表值范围从-2147483648到2147483648。这样只要hash函数松散的话,一般是很难发生碰撞的,因为HashMap的初始容量只有16。但是这样的散列值我们是不能直接拿来用的。用之前需要对数组的长度取模运算。得到余数才是索引值。

hash寻址算法的核心思想是通过key的hashCode对HashMap的capacity-1进行按位异或运算,得到一个桶的索引。这样可以使得不同的key能够均匀地分布在桶中,降低冲突的概率。如果两个key的hashCode相同,即hash碰撞,HashMap会使用链表或红黑树来处理冲突。当桶中的元素数量大于等于一定的阈值(即loadFactor倍)时,会将链表转换为红黑树,这样可以进一步提高查询效率。

get方法

public V get(Object key) {
    Node<K,V> e;
    //传入扰动后的哈希值 和 key 找到目标节点Node
    return (e = getNode(hash(key), key)) == null ? null : e.value; 
}

HashMap向用户分开放的get方法是调用的getNode方法来实现的

//传入扰动后的哈希值 和 key 找到目标节点Node
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //查找过程,找到返回节点,否则返回null
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

简单讲讲查询过程,还是比较简单的

  • 第一步,看下整个容器是否为空。
  • 第二步,如果不为空,再比较hash值的同时需要比较key的值是否相同e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))
  • 然后返回

contains

HashMap没有提供判断元素是否存在的方法,只提供了判断Key是否存在及Value是否存在的方法,分别是containsKey(Object key)、containsValue(Object value)。
containsKey(Object key)方法很简单,只是判断getNode (key)的结果是否为null,是则返回false,否返回true。

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null; 
}
public boolean containsValue(Object value) {
    Node<K,V>[] tab; V v;
    //遍历哈希桶上的每一个链表
    if ((tab = table) != null && size > 0) {
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next) {
            //如果找到value一致的返回true
            if ((v = e.value) == value || (value != null && value.equals(v)))
                return true;
            }
        }
    }
    return false; 
}

判断一个value是否存在比判断key是否存在还要简单,就是遍历所有元素判断是否有相等的值。这里分为两种情况处理,value为null何不为null的情况,但内容差不多,只是判断相等的方式不同。这个判断是否存在必须遍历所有元素,是一个双重循环的过程,因此是比较耗时的操作。

remove方法

HashMap中“删除”相关的操作,有remove(Object key)和clear()两个方法。
其中向用户开放的remove方法调用的是removeNode方法,,removeNode (key)的返回结果应该是被移除的元素,如果不存在这个元素则返回为null。remove方法根据removeEntryKey返回的结果e是否为null返回null或e.value。

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; 
}

final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
    // p 是待删除节点的前置节点
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //如果哈希表不为空,则根据hash值算出的index下 有节点的话。
    if ((tab = table) != null && (n = tab.length) > 0&&(p = tab[index = (n - 1) & hash]) != null) {
        //node是待删除节点
        Node<K,V> node = null, e; K k; V v;
        //如果链表头的就是需要删除的节点
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;//将待删除节点引用赋给node
        else if ((e = p.next) != null) {//否则循环遍历 找到待删除节点,赋值给node
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash && ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //如果有待删除节点node,  且 matchValue为false,或者值也相等
        if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)//如果node == p,说明是链表头是待删除节点
                tab[index] = node.next;
            else//否则待删除节点在表中间
                p.next = node.next;
            ++modCount;//修改modCount
            --size;//修改size
            afterNodeRemoval(node);//LinkedHashMap回调函数
            return node;
        }
    }
    return null;
}

clear()方法

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

删除HashMap中所有的元素,这里就不用一个个删除节点了,而是直接将table数组内容都置空,这样所有的链表都已经无法访问,Java的垃圾回收机制会去处理这些链表。table数组置空后修改size为0。

处理hash冲突时的链表

Java中的HashMap在处理hash冲突时会使用链表。当两个或多个key的hashCode相同,即hash碰撞时,它们会被存储在一个链表中。这个链表是存储在对应的桶中的,每个链表节点都包含一个key-value对。

当进行get操作时,根据key的hashCode计算出存储位置的索引,然后在对应的链表中查找该key。如果找到了,就返回对应的value;如果没找到,就返回null。

当进行put操作时,如果对应的桶中没有元素,就直接将key-value对存储在对应的链表头部;如果对应的桶中已经有元素,就先遍历链表,找到要插入的位置并插入。

如果链表长度大于一定的阈值(即HashMap的loadFactor倍),HashMap会将链表转换为红黑树。这样可以进一步提高查询效率。

通过红黑树来解决hash冲突

Java中的HashMap通过红黑树来解决hash冲突。当HashMap的负载因子大于等于阈值(loadFactor)时,会将链表转换为红黑树。

红黑树是一种自平衡的二叉查找树,它满足以下五个性质:
1、每个节点要么是红色,要么是黑色。
2、根节点是黑色。
3、每个叶节点(NIL节点,空节点)是黑色。
4、如果一个节点是红色,那么它的两个子节点都是黑色。
5、对于每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数量的黑色节点。
当发生hash冲突时,HashMap会将冲突的元素存储在链表中。当链表长度超过一定阈值时,会将链表转换为红黑树。这个过程涉及到一些细节,包括节点的颜色调整和树的旋转操作。

总之,Java中的HashMap通过红黑树来解决hash冲突,以提高查询效率。

基于数组的扩容原理

Java中的HashMap是一种常用的数据结构,它基于数组实现。当HashMap的元素数量达到一定的阈值时,需要进行扩容操作。

HashMap的扩容原理如下:
创建一个新的Entry数组,其容量是原数组容量的两倍加1。
遍历原数组的每个元素,重新计算每个元素的存储位置,并将元素插入到新数组中。
将原数组引用新的Entry数组,完成扩容操作。
在扩容过程中,HashMap仍然支持插入和查询操作。为了保证操作的正确性,需要使用一些额外的技巧,例如使用一个“dummy”元素作为新数组的头部,以及使用一个“shadow”指针来记录原数组的头部元素在新数组中的位置。

总之,Java中的HashMap通过基于数组的扩容原理来实现容量的扩展,以适应更多的元素存储需求。

JDK1.8的高性能rehash算法

Java中的HashMap在JDK1.8中引入了一种高性能的rehash算法,以提高其性能和扩容效率。

该算法的核心思想是利用原数组的容量,重新计算每个元素的存储位置,并将元素插入到新的Entry数组中。与传统的扩容方式不同,该算法在扩容时不再需要遍历整个原数组,而是通过一些优化技巧,减少了扩容所需的时间和空间开销。

具体来说,该算法的优化技巧包括:
使用“dummy”元素作为新数组的头部,避免了在新数组头部插入元素时进行复杂的扩容操作。
使用一个“shadow”指针来记录原数组的头部元素在新数组中的位置,避免了遍历整个原数组寻找头部元素。
将扩容操作与查询操作合并,即在重新计算元素的存储位置时,同时检查是否需要将元素移动到新的Entry数组中。
使用二次哈希函数来计算元素的存储位置,以减少哈希冲突的概率。

有顺序的map数据结构

Java中的HashMap是一种基于哈希表的数据结构,它实现了Map接口。HashMap中的元素是通过键值对的形式存储的,其中键是唯一的,而值可以重复。

与有序的Map数据结构(如TreeMap)不同,HashMap不保证元素的顺序。它根据键的hashCode值存储元素,因此元素在HashMap中的位置与键的顺序无关。

如果需要使用有序的Map数据结构,可以考虑使用TreeMap或者LinkedHashMap。TreeMap根据键的自然顺序或者自定义的比较器来排序元素;而LinkedHashMap则根据插入顺序或访问顺序来维护元素的顺序。

你可能感兴趣的:(Java技术栈源码分析,java,hashmap,经典面试题,源码分析,详细介绍)