【JDK1.8源码阅读】 HashMap源码实现分析整理(一)

HashMap数据结构概括

在Java8后,HashMap的数据结构如下所示:
image
在表中,最顶层是一个数组,每个数组元素下可以抽象为一个桶,桶中元素可能为链表,也可能为树节点,在源码中分别用Node和TreeNode表示。

元素属性

分析源码,HashMap的具体属性元素包括:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
   
    // 序列号
    private static final long serialVersionUID = 362498820763181265L;    
    // 默认的初始容量是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;
}

每一个节点元素都以Node或TreeNode的形式存在,从源码中看到,顶层数组的数据结构是Node<k,v>[] table这是一个Node数组,数组大小根据threshold而定,在插入新元素超过threshold大小后,会触发扩容,每次扩容总是扩容到2的幂次方倍,如2,4,8……

其中Node和TreeNode的基础定义如下:

/**
 * Basic hash bin node, used for most entries.  (See below for
 * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
 */
static class Node<K,V> implements Map.Entry<K,V> {
   
    // key的哈希值
    final int hash;
    // 节点key
    final K key;
    // 节点值
    V value;
    // 链表的下一个节点
    Node<K,V> next;
}

/**
 * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
 * extends Node) so can be used as extension of either regular or
 * linked node.
 */
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
   
    // 父节点
    TreeNode<K,V> parent;  // red-black tree links
    // 左子树节点
    TreeNode<K,V> left;
    // 右子树节点
    TreeNode<K,V> right;
    // 上节点
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    // 节点颜色是否为红色
    boolean red;
}

值得一提,其中TreeNode继承自LinkedHashMap.Entry<K,V>而LinkedHashMap.Entry<K,V>又继承自Node。

构造函数、容量算法和哈希碰撞优化

HashMap存在多个重载构造函数,大多最后会调用:

/**
 * 根据初始容量和负载因子构造一个空的HashMap
 * @param  initialCapacity 初始容量,最后容量会是比初始容量大的最小2幂次方值
 * @param  loadFactor      填充因子,当Map内元素量 >=  threshold*loadFactor,会触发扩容操作
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
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;
    // 更新阈值,这里阈值暂且为容量,后续通过resize函数获取规整的table时,会乘以loadFactor,得到实际的扩容阈值
    this.threshold = tableSizeFor(initialCapacity);
}

实际的容量计算是基于tableSizeFor(int cap)方法,具体实现为:

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
   
    // 防止如果cap已经是2的幂次方,最终计算会是cap的两倍
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // n在[1,MAXIMUM_CAPACITY]之间,如果在区间内,需要加1来凑足2的幂次方。
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

>>>表示无符号右移,高位全部用0填充,和>>的区别是如果操作对象是负数,>>高位用1填充,>>>高位用0填充。|是位或,任何二进制位和1位或都等于1。

注意n是一个32位整型,这个函数的具体实现原理是将最高位1之前的所有位都变成1,以1xxxxxxx为例,其中x表示0或1,来看每步操作结果:

// n = 1xxxxxxxx
n |= n >>> 1;
// n = 11xxxxxxx
n |= n >>> 2;
// n = 1111xxxxx
n |= n >>> 4;
// n = 11111111x
n |= n >>> 8;
// n = 111111111
n |= n >>> 16;
// // n = 111111111
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
// n = 1000000000

从上面看到,n |= n >>> a,表示从n的最高位1往后的前a/2~a位都置为1。最后得到的是一个2的幂次方,最开始int n = cap - 1,考虑cap=0b100,如果不-1,将得到0b1000,但实际0b100即为我们需要的结果

看到这里可能有疑问,为什么要将HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置,源码中抽象的算法如下::

index = (table.length - 1& hash(key);

index表示key应在table的位置,table.length - 1相当于是一个低位掩码,假设table.length=10000,-1后变成01111,和哈希值取与,即最终取的是哈希值的低4位,假设哈希的低4位是分布均匀的,则可以保证key在数组容量内是分布均匀的,同时经过位运算得到,整体算法效率极高。

这里还有问题,散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。而如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就会出现部分不均匀的情况,这里的关注点主要在key的hash碰撞优化,先看key的hash算法的实现:

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

从上面的代码可以看到key的hash值的计算方法。key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。

这里考虑是假设我们直接用key的hashCode作为哈希值,经过(table.length - 1)取与,最多利用的仅仅hashCode的低16位,而hash()函数对hashCode高半区和低半区做异或,可以混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,同时还降低了冲突的可能性。

最后看Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

HashMap有几个基础操作:put插入、get获取、remove移除、entrySet遍历。下面对这4个操作进行分析

put插入或更新Map元素

先看put的源码实现:

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 *
 * @param key key with which the specified value is to be associated
 * @param value value to be associated with the specified key
 * @return 上一个key对应的value,可能为null,表示放的是null或不存在对应key-value
 */
public V put(K key, V value) {
   
    return putVal(hash(key), key, value, false, true);
}

putVal实现

put方法底层调用了核心基础方法putVal,大致实现过程如下:

  1. 先获取table长度,必要时进行扩容
  2. 根据key的hash定位key应在table数组的索引位i和首节点p
  3. 如果首节点位空,直接初始化一个新节点
  4. 判断key如果应为首节点,则记录为e
  5. 如果p instanceof TreeNode,则调用putTreeVal转换为树节点插入
  6. 否则p instanceof Node,遍历链表尝试插入key
    1. 遍历如果到了尾节点,则在尾部插入节点,并判断节点数是否达到TREEIFY_THRESHOLD阈值,如果达到,调用treeifyBin将链表转换为红黑树
    2. 如果在遍历过程找到替换节点,记录为e,并退出循环
  7. 如果e存在,则进行替换操作,并返回旧值
  8. 如果e不存在,说明进行了结构性变更(新增节点或红黑树化),则记录modCount,同时判定如果实际大小大于阈值则扩容。

具体实现源码如下:

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
   
    // tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
    // n是table长度,i是key在table的索引位置
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table未初始化或者长度为0,通过resize进行初始化
    if ((tab = table) == null || (n =
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值