Loading

深入源码-HashMap

😉 本文共3175字,阅读时间约10min

数据结构

before 1.8

在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的。

使用拉链法来解决哈希冲突(两个对象的hashcode在调用hash()后一致导致数组索引相同)。

Node<K,V>[] table; // 底层是Node数组,Node<K,V>是泛型类
Set<Map.Entry<K,V>> entrySet; 
int threshold;     // 扩容阈值
float loadFactor;  // 负载因子
int DEFAULT_INITIAL_CAPACITY = 1 << 4;   // 初始化容量

since 1.8

在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。

  • 解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

    将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。

    数组较小时,优先扩容,避免红黑树结构。红黑树要进行左旋、右旋、变色等操作保持平衡,不如扩容效率高。

why 红黑树?

哈希函数取得再好,也很难达到元素百分百均匀分布。

当大量元素到同一个桶时,桶下链表很长,查询效率是O(n)。

链表长度变长时,查询效率太低,红黑树查询效率是O(logn)。

why 大于8才转为红黑树?

  1. 希望尽量不用红黑树:这个数字要足够大。树节点占用空间是链表节点2倍,只有链表节点太多时,才认为转成红黑树带来的查询优化抵消掉空间劣势。
  2. 防止不好的hashcode情况
    1. 当hashcode离散性很好时,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。
    2. 这个数字要足够小。如果用户实现了不好的hashcode时,要能优化单链表的查询时间。
  3. why 8 ?泊松分布
    1. 随机hashcode方法的节点分布频率贴近泊松分布,源码注释列出了链表长度为0到8的概率,达到8的概率非常小,几乎是不可能事件。(泊松分布适合于描述单位时间内随机事件发生的次数)
    2. 这样,正常的hashcode函数几乎不会树化,而非正常的也得到了树化的防止机会。

当长度小于6时,则从红黑树转为链表

HashMap的hash

对象的hashcode无符号右移16位 与 hashcode做异或,相当于高16位保留,低16位是高16位和低16位异或后的结果。

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

why 这样hash?

  • 如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。
  • 如果当n即数组长度很小,假设是16的话,那么n-1即为 ---》1111 ,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。

其他的hash函数

构造散列函数的两个基本原则:计算简单+均匀分布。

平方取中法:将关键字平方之后取中间若干位数字作为散列地址。

除留余数法:f(key) = key mod p ,结合开放定址法。如果冲突发生,就选择另外一个可用/空的位置。只要散列表足够大,空的/可用的散列地址总能找到,并将记录存入。

这两个都不如位运算效率高。

why loadFactor 0.75?

负载因子,衡量hashmap满的程度。

threshold计算公式:capacity(数组长度默认16) * loadFactor(负载因子默认0.75)。size达到该值,hashmap需要扩容。负载因子 = size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。

loadFactor太大容易哈希碰撞,太小数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。

put流程图

说明:

1.size表示 HashMap中K-V的实时数量 , 注意这个不等于数组的长度 。

2.threshold( 临界值) =capacity(容量) * loadFactor( 加载因子 )。这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍 。

重要方法

构造方法

//创建HashMap集合的对象,指定数组长度是10,不是2的幂
HashMap hashMap = new HashMap(10);
public HashMap(int initialCapacity) {//initialCapacity=10
   this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity=10
    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);//initialCapacity=10
}

static final int tableSizeFor(int cap) {//int cap = 10
    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;
}
  1. 长度必须是2的幂次:如果指定数组长度是10,不是2的幂,会通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
  2. Node数组初始化延迟:构造函数并不分配那么大的Node数组空间,延迟到第一次put进行Node的初始化。
  3. 初始化指定容量好处:在已知多少数据的情况下,避免频繁扩容

why 容量必须是2的幂次?

计算数据在数组上的索引位置是一次取模操作,如果容量是2的幂次可以利用位运算实现高效的取模。

hash & (length - 1) 等效于 hash % length

put方法

// 内部调用putVal方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

img

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table长度为空或为null,初始化扩容
    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 {
         // 执行else说明tab[i]不等于null,表示这个位置已经有值了。
        Node<K,V> e; K k;
       	// 第一个元素是否equal,相等则替换
        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);
                    // 添加完成,若达到树化阈值,则树化,break
                    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) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    
    // 判断实际大小是否大于threshold阈值,如果超过则扩容
    if (++size > threshold)
        resize();
    
    afterNodeInsertion(evict);
    return null;
} 
  • 树化方法treeifyBin很有意思,实际变成红黑树的方法是调用的另一个内部方法。这个方法做了三件事:
    • 数组长度小于64,选择扩容而非树化
    • 遍历ListNode,创建相同个数的TreeNode,复制内容,建立起联系prev、next
    • 让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容

get 方法

  1. 步骤:
    1. 通过hash值找索引
    2. 索引上的Node就是要查找的key,直接返回
    3. 如果不是,后续节点是链表,就到链表搜。后续节点是红黑树,就到红黑树搜索。
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 通过hash值找索引
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 索引上的Node就是要查找的key,直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        
        if ((e = first.next) != null) {
            // 判断是否是红黑树,是的话调用红黑树中的getTreeNode方法获取节点
            // 红黑树查找跟二叉搜索树一样,递归左右子树的一边
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

resize扩容方法

何时扩容?

当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容。

注:第一次put,也会resize,因为此时table为空。

怎么扩容?

  1. 创建新的Node数组,容量是原来的两倍(如果超出最大值,就按最大值)
  2. 遍历旧数组的所有Node,将其rehash到新数组。
    1. 如果是链表,按链表遍历处理
    2. 如果是红黑树,按红黑树处理

巧妙的rehash方式

rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。

正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。

使用hashmap

如何遍历

  1. 遍历keySet(),获取key然后get到value

    说明:根据阿里开发手册,不建议使用这种方式,因为迭代两次。keySet获取Iterator一次,还有通过get又迭代一次。降低性能。

    image-20191117160733756

  2. 使用Iterator迭代器迭代

    image-20191117160627369

  3. 遍历entrySet()

  4. jdk8以后使用Map接口中的默认方法:

public class Demo02 {
    public static void main(String[] args) {
        HashMap<String,String> m1 = new HashMap();
        m1.put("001", "zhangsan");
        m1.put("002", "lisi");
        m1.forEach((key,value)->{
            System.out.println(key+"---"+value);
        });
    }
}

EntrySet 之所以比 KeySet 的性能高是因为,KeySet 在循环时使用了 map.get(key),而 map.get(key) 相当于又遍历了一遍 Map 集合去查询 key 所对应的值。为什么要用“又”这个词?那是因为在使用迭代器或者 for 循环时,其实已经遍历了一遍 Map 集合了,因此再使用 map.get(key) 查询时,相当于遍历了两遍

EntrySet 只遍历了一遍 Map 集合,之后通过代码“Entry<Integer, String> entry = iterator.next()”把对象的 keyvalue 值都放入到了 Entry 对象中,因此再获取 keyvalue 值时就无需再遍历 Map 集合,只需要从 Entry 对象中取值就可以了。

所以,EntrySet 的性能比 KeySet 的性能高出了一倍,因为 KeySet 相当于循环了两遍 Map 集合,而 EntrySet 只循环了一遍

hashmap的初始化

默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16) 。

《阿里巴巴Java开发手册》中建议我们设置HashMap的初始化容量。

image-20191117164748836

如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以防止多次扩容

  • 初始大小建议为size/ 0.75F + 1.0F
    • 注意,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。
    • 为什么加1?比如size = 6,不加1初始大小为8,又会触发一次扩容。加1初始大小为16,不会触发扩容。

为什么1.7头插法不安全?

多线程扩容rehash操作时,容易形成循环链表。jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。

  1. 线程一和线程二都进行扩容,假设线程一暂时被调度挂起

img

  1. 线程一被调度回来执行,继续扩容

img

img

  1. 这下就死循环了。原来 3 - > 7 ,线程2 7 -> 3,线程1 3 -> 7,但此时7的下一个不是null,而是3,这是由线程2引起的。
posted @ 2023-01-22 23:09  iterationjia  阅读(47)  评论(0编辑  收藏  举报