java-hashmap源码分析:创建

·  阅读 355

本文关键知识点

阅读本文需要15分钟,可以先花1分钟快速浏览下面这些知识点是不是非常了解。如果有不了解的要点再往后看。

  • 初始大小和加载因子是最重要的参数,new一个hashMap只需要对这两个field的赋值;
  • initialCapacity K会被hashmap调整成为最小的大于K的2^N设置为真实的capacity,默认值为16最大值为2^30;对capacity的扩容不能破坏2^N这个基本设计;
  • loadfactor默认为0.75,threshold=capcity*loadfactor于是默认值是12,当元素个数超过threshold之后map会进行resize;
  • 内部的table采用基本数据结构array来代表buckets,每个bucket内采用单向链表或者红黑树来处理发生哈希冲突的数据;
  • hashmap采用了懒加载的模式,在new对象时仅设置容量和加载因子,table相关的初始化发生在第一次putValue时;
  • hashmap的capacity采用2^n是为了(1)快速index(2)rehash时最小化成本;也因此扩容的时候也是2倍的扩容,不能破坏这个基本的设计原则;
  • hashmap中的hash方程采用了key原生hashCode高低位xor的设计思路;
  • hashmap中重要的fields:modCount,table,size,threshold,loadfactor,entrySet;
  • hashmap中三类默认配置:容量、加载因子、红黑树。

重要参数:initialCapacity,loadFactor;懒加载;

创建一个hashmap最基本的构造器需要指定一个hashmap的初始大小和loadfactor。当然这个构造器大家其实不常用,hashmap在这个本质的构造思路的基础上包装了:隐藏loadFactor(使用默认值0.75),隐藏initCapacity(默认16)和loadFactor的构造器。当然也可以根据一个map直接创建对象并putValue。

从最本质的构造器可以发现new一个map其实只需要给两个定义hashmap的关键内部变量赋值,其他的什么都不用多做。这其实是一种lazy的思想,new对象时只记录定义这个数据结构的关键参数,而不进行初始化,在真正需要使用时再进行初始化。(这里根据其他map来构造除外,因为这个时候已经有了elements to put)。

// 最本质的构造器
public HashMap(int initialCapacity, float loadFactor) {
    // 核心参数initCapacity: 最小是0,有个最大限制1 << 30 (1,073,741,824大概是一百多万)
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 核心参数loadFactor: 必须设置大于0的数,毕竟这个数代表了
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity); 

// 包装或者变化之后更容易用的3个构造器
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
复制代码

关键的两个变量java doc解释非常清楚,这里简化翻译一下。

HashMap有两个影响其性能的变量,初始大小initial capacity和加载因子 load factor。
  • initial capacity和load factor。capacity代表了hash table中有多少个buckets,initial capacity指的是在hash table创建时的大小。
  • load factor是指当hash table多满的时候会进行自动扩容并重新哈希(rehash),当一个hash table中的元素的个数超过 当前的capcity*loadfactor时,hash table会扩容成原来的2倍并重新哈希。
  • 通用的load factor是0.75,这个取值比较好的平衡了时间和空间的成本。如果加载因子过高,可以节约内存空间,但是确加重了大部分hashmap提供操作的搜索的成本,比如get/put。(笔者补充,如果加载因子过小,会浪费空间并且会降低iterator的效率)。

初始容量调整为2^n

默认的hashmap的大小是16,如果用户指定了一个数n,在hashmap代码内部会把它修正成为不小于n的2的幂。在刚刚的构造器里可以看到这里的赋值并不是开发者传入的值,而是经过了转化:

this.threshold = tableSizeFor(initialCapacity);  // 构造器中的哈希表容量修正
复制代码

这里涉及3个问题: 1) hashmap源码是如何计算容量的; 2) 为什么要让size是2的幂; 3) 为了回答第2个问题需要了解一个key是如何映射到一个bucket的;

capacity 如何调整为2^n

先看hashMap内部哈希表的大小是如何计算的。这里DougLea大哥直接使用了位运算,果然大佬们都是用位运算的(写redis的antirez也通篇用位运算)。 下面的无符号右移运算的结果是取得了一个11...111(二进制表示法)的数,二进制位数与cap的位数相同。大家可以自己写一个数严格按这个操作试试。

这里解释一下为什么右移的位数是每次*2的,因为第一次右移一定能保证n的最高位和次高位都是1(n的最高位一定是1,第一次按位或使得次高位无论是什么也一定是1),这样就保证了原来数的最前面两个bit是1,下次用这两个1去按位或就好,这样下一轮可以把2个bit确定为1,在下一轮4个。每次确认bit的个数是原来的2倍,这是一种增速算法。

在此基础上+1,就有了一个一定比initial cap大的2^k的容量。

    /**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }
复制代码

capacity 为什么要调整为2^n

这里有两个原因(1)为了key能快速index到bucket;(2)为了减少resize之后rehash的成本;

1-bitmask代替求余快速index

这里为什么要让cap是2^n,是因为hashmap这里采用了bitmask的方法来代替除法求余哈希。求余运算是一种CPU低效率的运算,相反位运算就快速的多了,所以bitmask就是一种常见的替代思路。bitmask正如其名,去mask掉(掩盖掉)bit值,留下需要的值。

举个栗子来说,一个hashtable的长度是4(2^2),那么buckets用二进制表示就是00,01,10,11。那么对key一个很简单的分配bucket的方法就是0000...00011&key,也就是看key的最后两位属于哪个范畴。这里hashmap源码也是这么实现的,

这里hash是key在经过hash function计算的值,tab是hashmap中的Node[](array)与n-1按位与计算得到index;

 tab[i = (n - 1) & hash]
复制代码

2-resize之后低成本的rehash,类似一致性hash的思路

这里首先要知道resize是编程原来capacity的两倍,遵循了capacity是2整数次幂的设定。这里的设计和arrayList是不同的,arraylist看一下源码可以粗糙的认为扩容是1.5倍,采用了oldCap+oldCap>>1的方案。至于我看还有面试官喜欢问为什么两者不一样,原因主要是hashmap 2^n的设计不能因为扩容而破坏(当然可以进一步回答为什么要这么设计),此外hashmap扩容可以减少hash碰撞对查询效率是有帮助的,而arraylist扩容如果没有真的填写更多数据就造成了浪费,所以保守选了1.5倍。

一致性hash希望达到的目的是hash nodes发生变化时,rehash最小化需要迁移的数据。而通过使用上述2^n的table容量,和(n - 1) & hash的index定位方法,hashmap在resize到两倍时能保证:1)一部分数据不需要迁移;2)需要迁移和不需要迁移的数据能快速确认;(注意者不代表resize是低成本的,只是在一个比较高成本的操作上尽可能减少其成本)。

resize的源码分析放在后面,这里先讲一下原理:index=(n-1)&hash,当n-1的二进制表示从0011变成两倍的0111时,一个key的hash只有在新增的一个最高位1有值时才会被hash到新的位置,否则就保持不动。那么如何这个1呢?0100&hash就可以了,这个简单的位运算非常快,而0100其实就是原来的容量值old Capacity。

哈希函数xor of higher bits

刚刚在讲bitmask的方法确认index时,参与 index = a&(n-1) 的并不是key本身,而是其hash值。hashmap相关的数据结构最重要但是却最容易被忽略的就是hashfunction,如果hashfunction选择的不好往往这个数据结构设计的再好也没法用,然而hash往往是数据家研究的领域工程师们往往了解的比较粗浅。不过略懂也是好的。

看一下源码:

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

这里第一个信息点事可以看到hash支持key是null,会hash到0。 第二个信息点是key的自定义的hash值进行位移后的异或操作,这里code的作者做了解释,这里翻译一下:

在前面bitmasking方案中,由于hashtable的长度关系,一个key的hashCode往往只有最低的一些bit位参与了运算,如果两个hashCode只有比mask高位bit有变化,那么在这种index方案下就一定会发生哈希碰撞。小的hashtale如果key是连续的浮点数的话,这种哈希碰撞的方案非常常见。所以我们提出了一个让高位bits参与到hash值的方案,这个方案权衡了计算速度、可用性和bit分散的程度。由于大部分hashCode可能已经足够分散了,并且我们也用了tree来处理哈希碰撞,所以我们就用了右移高位bits进行XOR运算这个廉价的方案来避免系统性的失败。

这里再总结一下从一个key到哈希表有以下过程: 1. key.hashCode() 2. 再次hash (>>>16, xor) 3. hash & (cap-1) 得到index 4. index中node的创建

内部字段

  1. hashmap的instance fields中首先是有前面讲过的loadfactor和threshold,loadfactor一般在初始化之后就固定了但是threshold在每次hash resize之后会进行重新计算,这两者的关系是threshold=cap*loadfactor。

  2. size字段来记录key-value的个数,每次putVal发生时size++,维护这个字段方便程序判断是否size超过了threshold的限制需要进行扩容。

3)modCount就是一个常见且非常巧妙的设计了,这里有必要看一下源码大佬们的原话: modCount用来表示hashmap结构性的修改,结构性的修改指的是影响hashmap中mapping个数的修改,或者直接改变hashmap内部结构的修改(比如rehash)。这个字段的维护是为了帮助iterator和views能快速失败,避免读到不正确的数据(ConcurrentModificationException)。

这里看一下putVal过程就会改变刚刚讲过的size和modCount的数值。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    ...//中间省略了
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
复制代码

4) hashmap中哈希表的具体实现table内部使用了两种基本数据结构:array和基于node的单向链表。hashmap其实就两大类解决哈希冲突的方式,或者说两大类实现方式:碰撞采用链表或者tree的数据结构,或者碰撞采用开放寻址。而java使用了第一种的方案。

/* ---------------- Fields -------------- */
/** 这里表明table的初始化发生在第一次使用的时候,resize也发生在需要的时候,并不会提前操作。同时table size是2^n。
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;
复制代码

5) node的定义就比较复杂了,直接间接涉及到hashmap的有三个:单向链表,双向链表,TreeNode。这里就不贴全部代码了,看一下核心字段: /** * 最基本的单向链表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;
        this.key = key;
        this.value = value;
        this.next = next;
    }

// 在linked hashmap中的双向链表,所以与Node唯一的区别只是指针的区别
    static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}
// treeNode是最复杂的结构,因为红黑树的算法比链表要复杂多了,本篇就不大篇幅展开分析了,需要在讲treefy和untreefy中尝试讲解。
复制代码

静态字段

static field主要有我们之前讲过的:initial_capacity默认值是16,最大的容量默认2^30大概是100多万,默认加载因子是0.75。上面对于这两个值已经充分讨论过了,就不多赘述了。

// 容量相关
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;

// 加载因子相关
static final float DEFAULT_LOAD_FACTOR = 0.75f;
复制代码

剩下3个默认值都与红黑树相关。链表虽然搜索复杂度是O(n)比红黑树的O(logN)满,但是链表的操作简单,所以hashmap只有当一个bucket内部不断putValue直到nodes超过一定数量时才会考虑把链表转换为红黑树,这个默认数值TREEIFY_THRESHOLD是8;当然哈希碰撞变多的另一个原因是table太小;所以hashmap的另一个限制是table中buckets的个数也就是table的长度,如果table比较小那么优先选择对整个hashtable进行扩容而不是改造成树,这个默认的table大小是64。

如果一个bucket已经转换成红黑树,但是一个bucket内部数量不断减少,少于一个下下限时hashmap会认为链表更经济把树再转换为链表,这个下限默认是UNTREEIFY_THRESHOLD=6。

6个核心static字段

// 红黑树相关
static final int TREEIFY_THRESHOLD = 8; 
static final int UNTREEIFY_THRESHOLD = 6; 
static final int MIN_TREEIFY_CAPACITY = 64;(进行红黑树转换的最小table长度)

// treefy的判断前提
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
复制代码
分类:
阅读
标签:
分类:
阅读
标签:
收藏成功!
已添加到「」, 点击更改