Java 源码解读系列 —— HashMap

新博客地址,愿大家多多点击

Hash 算法

简单介绍一下 Hash 算法以及解决 Hash 冲突的方案。

优秀 Hash 算法的特点:

  • 从 Hash 值不可以反向推导出原始的数据
  • 输入数据的微小变化会得到完全不同的 Hash 值,相同的数据会得到相同的值
  • 哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
  • Hash 算法的冲突概率要小

Hash 冲突解决方案

链地址法

链表地址法是使用一个链表数组,来存储相应数据,当 Hash 遇到冲突的时候依次添加到链表的后面进行处理;这也是 HashMap 在 1.8 之后做的优化之一。

Hash-链地址法

开放地址法

线性探测法,就是比较常用的一种开放地址哈希表的一种实现方式。

线性探测法的核心思想是当冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。简单来说就是:一旦发生冲突,就去寻找下 一个空的散列表地址,只要散列表足够大,空的散列地址总能找到。

HashMap 数据结构

HashMap-数据结构

  • 数组:通过 Hash 后得到的值可以直接找到对应的数组下标,时间复杂度为 O(1),就算是遍历素组,复杂度也是 O(n)
  • 链表:1.7 版本采用首插法,在 resize 时,会重新计算每个元素的 Hash 值,从新放到不同的位置,首插可能会出现链表变环出现死循环;1.8 版本后采用尾插法,避免了该问题的出现。
  • 红黑树:1.8 版本后添加,使查询性能由链表的 O(n),提升到 O(logn),效率更高

关于 1.7 到 1.8 的升级,后面说明。

HashMap Java 源码

先确定几个预定义常量

默认初始容量
/**
 * 默认初始化容量,必须是 2 的整数幂
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认 16
扩容阈值
/**
 * 如果当前容量到达指定阈值 (capacity * loadFactor)
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
树化与链化阈值
/**
 * 当链表长度到达 8,满足树化的条件之一
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 当链表长度小于 6,满足链化的条件
 */
static final int UNTREEIFY_THRESHOLD = 6;
最小树化容量
/**
 * 满足链表树化的条件之一,数组长度到达 64
 */
static final int MIN_TREEIFY_CAPACITY = 64;

HashMap 中的变量

// 该表在首次使用时初始化,并根据需要调整大小。 分配时,长度始终是2的幂
// table 是一个以 Node 为单位的数组
// 数组中的每个元素为桶,桶中的元素称作 bin,在 putVal() 方法中有一个 binCount 就是用来计算 bin 的数量的
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

// map 中的元素个数
transient int size;

// 这个变量设计涉及到 Java 集合中的 “快速失败” 机制,会另起博客描述
transient int modCount;

// 扩容阈值 = capacity * loadFactor
int threshold;

// 装载因子,用来衡量 HashMap 满的程度,影响扩容时机,默认值为0.75
// 计算实时装载因子的方法:size / capacity
final float loadFactor;

存储数据的最小单位 Node

Node 是整个 HashMap 的最小单位,实际的值都存储在这个 Node 中;

static class Node<K,V> implements Map.Entry<K,V> {
    // key 的 Hash 值计算结果
    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;
        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; }
    
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

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

构造方法

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    // 不用多说,容量小于 0 只能抛异常
    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;
    // tableSizeFor 用于初始化容量,这个方法很重要
    this.threshold = tableSizeFor(initialCapacity);
}

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

这里的算法主要目的是计算出离给定数值向上最近的 2 的整数幂的值,简单点来说就是:给 1 就返回 2;给 5 返回 8;给 15 返回 16;给 16 也返回16。

可能会有疑问,给了16 按道理来说不应该是返回 32 吗?这就是为什么一开始会有一个 int n = cap - 1 的操作。

从使用者的视角来看,我需要创建的是一个容量为 16 的 HashMap,那就应该只创建一个容量为 16 的 HashMap,而不是一个 32 的 HashMap,虽然在使用上感受不到,但是在 JVM 中,每块内存都是寸土寸金,能不浪费就不浪费,减 1 操作就是为了得到一个与设置值更加接近的 2 的整数幂的值作为 HashMap 实际的容量,但如果传入的值为 -1,那初始容量就会为 1,会在后面的 resize 中立即扩容;

putVal() 方法

在除了构造方法歪,HashMap 仍然还有几个比较重要的方法,先来介绍一个 putVal() 方法,然后在引入其他的方法。先上源码:

// 实际调用的方法
public V put(K key, V value) {
    // 这里说明一下,hash 方法中将 null 值进行了特殊处理,null 的 hash 值为 0
    return putVal(hash(key), key, value, false, true);
}

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

/**
 * 为了方便阅读源码,我把源码格式调整了一下,里面的变量名也进行了修改,提高一定的可读性
 * (初步认为之所以源码的格式和变量名不符合开发规范,主要是为了减少一定的空间占用,以免源码包过大)
 */
final V putVal(int hash, 
               K key, 
               V value, 
               boolean onlyIfAbsent, 
               boolean evict) {
    
    Node<K,V>[] tab; 
    Node<K,V> node; 
    int newLen, index;
    
    // 从刚才的构造方法中可以看出,调用构造方法是不会真正实例化的
    // 真正实例化是第一次调用 putVal 的时候,进行到这一步的时候会实例化,给 Node[] 实际分配内存
    if ((tab = table) == null || (newLen = tab.length) == 0) {
        newLen = (tab = resize()).length;
    }
    
    // 根据 key 的 hash 值找到对应的位置插入第一 node
    if ((node = tab[index = (newLen - 1) & hash]) == null) {
        tab[index] = newNode(hash, key, value, null);
    }
    // 如果当前位置有一个 node 了,那就在这个 node 后面接着插入(链表尾插)
    else {
        Node<K,V> nodeBuffer; 
        K k;
        // 这个 if 是用于判断当前 key 是否已经存在,如果存在就不进行下面的添加操作,直接跳到下面进行更新操作
        if (node.hash == hash && ((k = node.key) == key || (key != null && key.equals(k)))) {
            nodeBuffer = node;
        } else if (node instanceof TreeNode) {
            // 若判断为数节点,则根据红黑树的原理进行添加操作
            nodeBuffer = ((TreeNode<K,V>)node).putTreeVal(this, tab, hash, key, value);
        } else {
            // 从这里开始计算 bin 的数量,也就是某个链表或者红黑树节点的数量
            for (int binCount = 0; ; ++binCount) {
                // 先实现链表尾插
                if ((nodeBuffer = node.next) == null) {
                    node.next = newNode(hash, key, value, null);
                    // 树化第一步,判断链表长度是否为 >= 8
                    if (binCount >= TREEIFY_THRESHOLD - 1) {
                        treeifyBin(tab, hash);
                    }
                    break;
                }
                // 和上面一样,如果找到一个已经存在的 key,直接执行更新操作
                if (nodeBuffer.hash == hash && ((k = nodeBuffer.key) == key || (key != null && key.equals(k)))) {
                    break;
                }
                node = nodeBuffer;
            }
        }
        
        // 更新已有的值
        if (nodeBuffer != null) {
            V oldValue = nodeBuffer.value;
            if (!onlyIfAbsent || oldValue == null) {
                nodeBuffer.value = value;
            }
            afterNodeAccess(nodeBuffer);
            return oldValue;
        }
    }
    // 用于实现 “快速失败” 机制
    ++modCount;
    // 超过阈值扩容
    if (++size > threshold) {
        resize();
    }
    // 这是个空方法,可以由继承的类自行实现,比如 LinkedHashMap(key 有序)
    afterNodeInsertion(evict);
    return null;
}

resize() 方法

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 如果 capacity 已经扩容到最大 (2^31-1),则不进行扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {
            // capacity > 16  且  capacity * 2 < MAXIMUM_CAPACITY, 则进行扩容 2 倍
            newThr = oldThr << 1;
        }
    } else if (oldThr > 0) {
        // 如果 capacity < 0  且  threshold > 0, 则 capacity = threshold
        newCap = oldThr;
    } else {
        // 如果 capacity < 0  且  threshold < 0, 初始化 table (都使用默认值)
        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;
    
    // 将原来 table 中的数据迁移到新的 table 中
    @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) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null) {
                    // 重新计算 hash 值,放到新的桶中
                    newTab[e.hash & (newCap - 1)] = e;
                } else if (e instanceof TreeNode) {
                    // 如果是红黑树,使用红黑树复制方式
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                } else {
                    // 如果是线性链表, 使用链表复制方式
                    // 这里会将原来的链表拆分为两个链表,分别放在 lo 和 hi 两个不同的桶中
                    // 拆分完成后,hi 在新的数组中的位置 = lo 的位置 + oldCap
                   	// 注:lo 表示 高位为 0,hi 表示高位为 1,关键计算方法:(e.hash & oldCap) == 0,下文有解释
                    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);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

链表拆分算法

主要算法就是上面源码中出现的:(e.hash & oldCap) == 0,大白话就不说了,直接上图:

(PS:还是多一句嘴,2 的整数幂都是只有一个 1,比如 8 => 01000,图中的最上一行是 hash 结果,01000 作为 oldCap,最下一行是运算结果)

HashMap-链表拆分算法

由上图可知,至于是分到 lo 链表还是 hi 链表,就看最高位是 0 还是 1。

treeifyBin() 方法

进入这个方法的前提是在该桶中的链表长度 >= 8,但不代表进入就一定会树化,源码如下:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
        // 这里说明了,就算你链表长度 >= 8,但是桶的长度没到 64,那就不树化,只扩容
        resize();
    } else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 到了这里才会开始树化
        // 这里也说明了树化的可能性是非常小的
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null) {
                hd = p;
            } else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null) {
            hd.treeify(tab);
        }
    }
}

这里解释一下为什么 8 是树化的阈值(摘抄自网络):

根据泊松分布,在负载因子默认为 0.75 的时候,单个 hash 桶内元素个数为 8 的概率小于百万分之一,所以将 7 作为一个分水岭,等于 7 的时候不转换,大于等于 8 的时候才进行转换,小于等于 6 的时候就化为链表。

所以不要面试的时候就说到 8 就树化,这是错误的!

1.7 到 1.8 的升级

主要升级点有两个:

  1. 从 1.7 的链表头插法改成了 1.8 的尾插;
  2. 添加了红黑树弥补当链表长度过大,查询效率变低的问题

对于头插改尾插的升级主要是考虑到当多个线程操作 HashMap 时,一旦出现 resize,就会将一个链表拆分成两个链表,但是 HashMap 是线程不安全的,就会导致出现链表变成环,当要查找该桶中的元素时就有可能会出现死循环的情况。

而红黑树可以对查询性能提升数倍,从 O(n) 提升到 O(logn),但是红黑树也会有问题,就是它占用的空间会比链表大很多,所以就出现了负载因子,而且要同时满足两个要求才会树化操作。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值