HashMap底层原理与面试题

1 类图

HashMap是非常常用的工具类,实现Map接口,存储key-value键值对,功能与HashTable类似,但是线程不安全,允许空键和空值。
在这里插入图片描述

2 属性

2.1 常见属性介绍

// 存放数据的数组
transient Node<K,V>[] table;

// 缓存entrySet()值,用于keySet()和values()
transient Set<Map.Entry<K,V>> entrySet;

// map中键值对个数
transient int size;

// HashMap结构修改的次数,用于快速失败
transient int modCount;

//下次resize操作的阈值,值等于capacity * load factor
//此外,数组还未分配时,本字段存放初始数组容量,0表示DEFAULT_INITIAL_CAPACITY
int threshold;

// 负载因子--由final修饰,必须在构造器中初始化。
final float loadFactor;

初始容量和载入因子影响HashMap性能。容量即桶的数量,初始容量就是Node数组初始大小。载入因子是哈希表有多满的度量,当map中Entry个数大于当前容量与载入因子乘积时,进行rehash操作。

loadFactor值默认为0.75,是均衡时间和空间成本的折中值。loadFactor高会较少空间开销(扩容次数减少),但是增加了查询成本(因为hash冲突变长,导致桶中Node较多)。

2.2 底层数据结构

HashMap底层采用的数据结构是数组、链表和红黑树。数组元素(称为桶)可能是链表,也可能是红黑树,对于null和单个node可以视为是链表。链表长度大于等于8(TREEIFY_THRESHOLD)并且数组容量大于64时,转化为红黑树;红黑树元素数小于等于6(UNTREEIFY_THRESHOLD)时,转化为链表。

在这里插入图片描述

HashMap在Java8之前底层结构是数组+链表结构,我们知道数组查询快、增删慢,而链表增删快、查询慢,实现整合了数组和链表的优点;同时是非线程安全的,查询速率快。

当hash位运算后总是得到同一个值,会使得某个桶链表很长。链表查找是从头遍历,因此HashMap最坏时间复杂度是O(n)。为了解决掉这个问题,Java8设置TREEIFY_THRESHOLD(树化)值,超过阈值便转为红黑树,最坏时间复杂度降低为O(logn)。

2.3 重要常量

// 默认的初始化容量--必须是2的次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 链表长度树化的最小长度
static final int TREEIFY_THRESHOLD = 8;

// 树桶元素个数小于等于6,转化为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 数组容量大于等于64时,才允许链表转化为红黑树,否则对table数组进行resize操作。
static final int MIN_TREEIFY_CAPACITY = 64;

2.4 链表和树的问题

1 为什么不直接采用红黑树?

因为红黑树需要进行左旋,右旋操作, 而单链表不需要,以下都是单链表与红黑树结构对比。
如果元素小于8个,查询成本高,新增成本低
如果元素大于8个,查询成本低,新增成本高

因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

2 为什么采用红黑树,而不是AVL平衡树?

主要数据结构的差别。

(1)AVL树是更加严格的平衡,因此可以提供更快的查找速度,一般读取查找密集型任务,适用AVL树。
(2)红黑树更适合于插入修改密集型任务。
(3)通常,AVL树的旋转比红黑树的旋转更加难以平衡和调试。

3 构造器

3.1 常用构造器

我们主要介绍默认构造器和map参数构造器,实际使用很少修改loadFactor值。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

无参构造器中只初始化了loadFactor,也就是说,使用了懒加载,在添加元素时初始化数组。

public HashMap(Map<? extends K, ? extends V> m) {
	// 设置负载因子
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 批量添加元素
    putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
    	// 若是在构造器中调用
        if (table == null) { 
        	// 计算满足负载因子的最小容量
            float ft = ((float)s / loadFactor) + 1.0F; 
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
            	// 取大于阈值的2的n次幂作为阈值
                threshold = tableSizeFor(t); 
        }
        // 若是在map.putAll()中调用
        else if (s > threshold)  
            resize(); 
        // 逐个放入元素
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            // put操作
            putVal(hash(key), key, value, false, evict);
        }
    }
}

// jdk8
// 获取大于cap的最小2的n次幂
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;
}

使用现有map创建新HashMap,主要分为两步:(1)计算阈值;(2)迭代元素处理。

  • threshold的处理:
    • 此处采用tableSizeFor()方法来计算阈值,得到大于并最接近初始容量的2的n次幂值;
    • 后续的阈值则由capacity * loadFactor计算得到。
  • 构造器只是初始化loadFactor和计算阈值,延时初始化。

4 put方法

4.1 put操作的整体思路

步骤总结:

  1. 计算hash,进行put操作
  2. 如果数组为null或者空数组,直接resize,进行初始扩容,得到一个容量为16,阈值为12的Node数组。
  3. 计算索引位置,存入值到数组
    1. 如果索引位置处Node为null,直接初始化新Node;
    2. 如果不为空,则可能存在hash冲突
      1. 若索引位置节点key与新key的hash和值相等,则直接替换。
      2. 若为红黑树,以红黑树形式新增。
      3. 若为链表,自旋判断替换旧节点,还是添加新节点到尾部。
      4. 若找到老节点,进行只替换,返回老值。
  4. 进行到这一步,说明存在新增节点,调整modCount。
  5. 判断阈值,是否需要扩容操作(resize)。
public V put(K key, V value) {
    
    
    return putVal(hash(key), key, value, false, true);
}
// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
// 入参 evict:false表示table是创造器初始化模式
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    
    
    Node<K, V>[] tab; // 数组
    Node<K, V> p; // i位置节点
    int n, i; // n:数组长度;i:索引位置
    //如果数组为空,使用 resize 方法初始化
    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 {
    
     // 索引位置节点不为空,可能存在hash冲突
        Node<K, V> e; // 存放老节点
        K k;
        // 如果 key 的 hash 和值都相等,直接把当前下标位置的 Node 值赋值给临时变量
        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) // -1 for 1st
                        treeifyBin(tab, hash); // 树化操作
                    break;
                }
                // 链表遍历过程中,发现有元素和新增的元素相等,结束循环
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; //更新指针
            }
        }
        // 如果找到匹配的老节点,则更新值,返回老值
        if (e != null) {
    
     // existing mapping for key
            V oldValue = e.value;
            // 当 onlyIfAbsent 为 false 时,才会覆盖值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount; // 更新结构修改次数
    //如果 HashMap 的实际大小大于扩容的门槛,开始扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

4.2 树操作(略)

5 get方法

理解了put方法后,get方法浅显易懂。

public V get(Object key) {
    
    
    Node<K,V> e;
    // hash(key) 哈希值
    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;
    // 查找 hash 对应 table 位置的 p 节点
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
    
    
        // 如果找到的 first 节点,就是要找的,则则直接使用即可
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
    
    
            // 如果找到的 first 节点,是红黑树 Node 节点,则直接在红黑树中查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 如果找到的 e 是 Node 节点,则说明是链表,需要遍历查找
            do {
    
    
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

注意:

  • get方法得到的值是null,除了不包含key,也可能是原本值就是null的情况。可以通过containsKey判断。

6 哈希处理

6.1 如何有效较少碰撞

HashMap通过树化(treeify)被动提升性能,hash元素也是提升性能的关键。方法主要有两个:

(1)使用扰动函数:不同的对象生成不同的hashcode,促使位置分布均匀,减少碰撞。

(2)使用final对象,并采用合适的hashcode()和equals()方法。final对象hashcode不会改变,并且通常会缓存hashcode值,例如String、Integer。

6.2 hash的实现

static final int hash(Object key) {
    
    
    int h;
    // h = key.hashCode() 计算哈希值
    // ^ (h >>> 16) 高 16 位与自身进行异或计算,保证计算出来的 hash 更加离散
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash过程,取hashcode值,高位右移16位,然后异或。

在这里插入图片描述

右移16位与异或的目的是,混合原始hashcode值的高位与低位,以此加大低位的随机性,同时也掺杂了部分高位特征,使散列结果更加均匀。

6.2 HashMap底层数组为什么总是2的n次方?

HashMap基于桶思想,为了存取高效,要尽量较少碰撞,就是要尽量把每个桶的数据分配均匀;

将数据分配到哪个桶的算法,就是取模,即hash%length。由于在计算机内部取余效率不如位运算,HashMap源码中将其优化为hash&(length-1),hash%length==hash&(length-1)的前提是length等于2的n次方。

为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;
例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;

7 扩容resize

7.1 代码分析

final Node<K, V>[] resize() {
    
    
    Node<K, V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 1. 计算扩容后的新容量
    if (oldCap > 0) {
    
     // oldCap 大于 0 ,说明 table 非空
        // 超过最大容量,则直接设置 threshold 阀值为 Integer.MAX_VALUE ,不再允许扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
    
    
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                   oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 扩容一倍
    } else if (oldThr > 0) // 初始容量被设置在threshold字段(即非默认构造器)
        newCap = oldThr;
    else {
    
                   // 初始threashold为0,表示默认构造器,采用默认值初始化数组
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 2. 上述逻辑中,未计算新阈值的情况,采用newCap * loadFactor 作为新的阀值
    if (newThr == 0) {
    
     // 看上去,也就是非默认构造
        float ft = (float) newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                  (int) ft : Integer.MAX_VALUE);
    }
    threshold = newThr; // 将 newThr 赋值给 threshold 属性
    // 3. 数据迁移
    @SuppressWarnings({
    
    "rawtypes", "unchecked"})
    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; // 创建新数组
    table = newTab; // table指向新数组
    if (oldTab != null) {
    
    
        for (int j = 0; j < oldCap; ++j) {
    
    
            Node<K, V> e; // 当前节点
            if ((e = oldTab[j]) != null) {
    
    
                oldTab[j] = null; // 置空,便于GC
                // 桶内只有一个节点,直接放到新table
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果是红黑树节点,通过红黑树分裂处理
                else if (e instanceof TreeNode)
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                // 链表
                else {
    
     // preserve order
                    // HashMap 是成倍扩容,这样原来位置的链表的节点们,会被分散到新的 table 的两个位置中去
                    // 通过 e.hash & oldCap 计算,根据结果分到高位、和低位的位置中。
                    // 1. 如果结果为 0 时,则放置到低位
                    // 2. 如果结果非 1 时,则放置到高位
                    // 举个例子,数组大小是 8 ,在数组索引位置是 1 的地方挂着两个值,两个值的 hashcode 是9和33。
                    // 当数组发生扩容时,新数组的大小是 16,此时 hashcode 是 33 的值计算出来的数组索引位置仍然是 1
                    Node<K, V> loHead = null, loTail = null; // 低位头尾索引
                    Node<K, V> hiHead = null, hiTail = null; // 高位头尾索引
                    Node<K, V> next;
                    do {
    
    
                        next = e.next;
                        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);
                    // 设置低位到新的 newTab 的 j 位置上
                    if (loTail != null) {
    
    
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 设置高位到新的 newTab 的 j + oldCap 位置上
                    if (hiTail != null) {
    
    
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
  1. 如果是初始化,使用threshold值分配初始容量
  2. 否则,扩展2的次幂扩展。每个桶或者留在原位置,或者移动到高位倍位置上

7.2 扩容的问题

(1)当多个线程同时发现hashMap需要调整大小,容易导致条件竞争,进而死锁。

(2)rehash操作是耗时操作。

源码分析地址

Git源码地址

参考资料

  1. https://github.com/luanqiu/java8/blob/master/src/main/java/java/util/HashMap.java
  2. http://svip.iocoder.cn/JDK/Collection-HashMap/

猜你喜欢

转载自blog.csdn.net/LIZHONGPING00/article/details/108504929