HashMap源码解析

283 阅读8分钟

1, 存储结构

HashMap中的数据结构是数组+单链表+红黑树的组合, 它存储的内容是键值对(key-value)映射;

jdk1.8的更新里,HashMap底层数据结构里引入了红黑树,来解决在链表过长时查询效率的问题,后面会有详细说明。

我们先分析下HashMap的数组和链表:

数组: 存储区间是连续的,占用内存严重,空间复杂度大。但数组的二分查找时间复杂度小,为O(1);
链表:链表存储区间离散,占用内存比较宽松,空间复杂度很小,但时间复杂度很大,为O(N);

由此得出结论
数组特点:查询快,增删慢;
链表特点:查询慢,增删快;

那么有没有既寻址快又增删快的数据结构呢?

HashMap此时作为解决方案出现了。它基于数组和链表来实现的,采用 Hash 算法来决定集合中元素的存储位置。

当系统开始初始化 HashMap 时,会创建一个长度为 capacity 的 Entry 数组,数组内可存储元素的位置被称为“桶(bucket)”,每个桶(bucket)都有其指定索引,系统可以根据其索引快速访问该桶里存储的元素。

2, 构造函数

HashMap有四个构造函数:

    public HashMap()

    public HashMap(int initialCapacity)

    public HashMap(int initialCapacity, float loadFactor)

    public HashMap(Map<? extends K, ? extends V> m) 

初始化过程中,有两个重要的参数:容量(Capacity)和负载因子(Load factor)

容量(Capacity)指的是桶(bucket)数量, 默认初始值为16。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

负载因子(Load factor)默认值为0.75,如果哈希表内的元素数到达了容量的75%, 就会执行再哈希(rehashing)

再哈希(rehashing)会创建新的哈希表,容量翻倍并将将哈希表内的元素导入, 在删除原有哈希表

static final float DEFAULT_LOAD_FACTOR = 0.75f;

无参构造函数只指定了负载因子 loadFactor,常量DEFAULT_LOAD_FACTOR默认为 0.75,初始化容量为默认容量16:

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

指定初始容量的构造函数,内部调用了两个参数的构造函数,loadFactor默认为0.75 ,初始化容量为传入的数值:

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

两参的构造函数,负载因子是直接传入的参数,初始容量经过了tableSizeFor函数的处理

    public HashMap(int initialCapacity, float loadFactor) {
        //..省略条件约束代码
        this.loadFactor = loadFactor;    
        this.threshold = tableSizeFor(initialCapacity);
    }

tableSizeFor函数的功能是传入任意数, 都能找到距离它最近的2的次幂。也就是说,通过tableSizeFor函数,HashMap的容量始终都是2的次幂。

至于为什么要2的次幂,后面会详细说明。

3, tableSizeFor函数

先看下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;
    }

初看会有些懵,我们忽略第一行代码,模拟运行一下。

假设参数cap为 010010:

010010 右移1位: 001001 再位或:011011011011 右移2位: 000110 再位或:011111
...

对比输入输出:

010010
011111

高位数1后全变为1,这时如果再+1,就会变为2的次幂。

为了便于理解, 我们举一个小例子

1 + 1 = 2           //21次幂
11 + 1 = 4          //22次幂
111 + 1 = 8         //23次幂
1111 + 1 = 16       //24次幂

此时已经达到了目的

传入 tableSizeFor 函数任意数, 都能找到距离它最近的2的次幂

回过头来, 看下第一行代码

    int n = cap - 1;

如果cap已经是2的次幂,不做-1操作得到的值则是cap的2倍。

例如二进制数 10000,十进制为 8,如果不减1,将得到11111 + 1,即16。减1得到1111+1 = 10000 即 8;

4, 为什么是2的次幂?

由于HashMap的结构原因(数组 + 链表,每个桶都指向一个链表 ), 我们希望的是元素存放的更均匀, 最理想的状态是每个桶内只存放一个元素,这样既不用遍历链表,也不用equals key。

那如何计算才会分布最均匀呢?

我们看下源码(jdk 1.7源码, 在jdk1.8中已经省略, 但作用依旧):

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

2的n次幂转换成二进制后,会呈现第1位数为1其余n位为0的形式,如:

    2 -> 10
    4 -> 100
    8 -> 1000
    16 -> 10000

2的n次幂 - 1转换成二进制后,会呈现第1位数为0其余n位为1的形式, 如:

    1 -> 01
    3 -> 011
    7 -> 0111
    15 ->01111

如果length不为2的幂,比如15。那么length-1的2进制就会变成1110。在h为随机数的情况下,和1110做&操作尾数永远为0。那么0001、1001、1101等尾数为1的位置就永远不可能被entry占用。这会造成浪费、不随机等问题。

所以,length参数二进制下为1的位数越多,分布就会约平均。在参数length是2的次幂时,是最优解。

5, put(K key, V value)操作

put(K key, V value) 源码:

    public V put(K key, V value) {    
        return putVal(hash(key), key, value, false, true);
    }

先看下 hash(key) 函数对key值的处理:

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

这里做了几件事:

  • key == null时, 返回 0
  • key 的hashCode赋值该变量h
  • h 于 h高16位 进行 异或 运算

此时的 h 就是在indexFor函数中于(length-1)做&运算的 h

为了让hash更加的散列随机, 这里将hashCode的前16位参与运算, 保留了hashCode的前16位的特征。

至于为什么使用^而不是&或|,是因为&或|的运算结果都有偏向0或1,所以最适合的是^运算。

总结一下hash(Object key)函数的作用:

  • 使hashCode更加随机
  • 使hashCode所有位都参与运算

继续看putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)函数

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {    
    Node<K,V>[] tab; Node<K,V> p; int n, i;    
    if ((tab = table) == null || (n = tab.length) == 0)
        // 如果table为空或table的长度为0 则创建一个默认16长度的哈希表
        n = (tab = resize()).length;    
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果存储的哈希表位置里没有数据, 就创建新数据放在该位置        
        tab[i] = newNode(hash, key, value, null);    
    else {
        // 此处代表该储存位置有内容        
        Node<K,V> e; K k;        
        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
            // 如果当前节点哈希值相同并且key相同 替换掉key下的value
            e = p;        
        else if (p instanceof TreeNode)
            // 如果key不相同,是红黑树类型的节点, 创建红黑树类型的节点            
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);        
        else {
            // 不是红黑树节点, 此时就是链表节点
            // 遍历该节点            
            for (int binCount = 0; ; ++binCount) {                
                if ((e = p.next) == null) {
                    // p节点的下一个节点为空,说明到达节点尾端
                    // 创建节点并插入                    
                    p.next = newNode(hash, key, value, null);                    
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        // 当前链表是否超过长度限制, 超过需要树化                        
                        treeifyBin(tab, hash);                    
                    break;                
                }                
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))           
                    break;
                // 将p更新为下一个节点                
                p = e;            
                }        
            }      
            if (e != null) {
                // 如果e不为空, 说明链表某个节点和我们要添加的节点的key和hash都相同
                // 需要进行替换操作
                // 取出的value值
                V oldValue = e.value;            
                if (!onlyIfAbsent || oldValue == null)
                    // 要替换的值
                    e.value = value;            
                    afterNodeAccess(e);            
                    return oldValue;        
            }    
          }   
          // 增加数量
          ++modCount;    
          if (++size > threshold)
            // 如果当前元素的数量大于总容量, 则扩容        
            resize();    
            afterNodeInsertion(evict);    
            return null;
}

putVal函数比较复杂, 关键点已经添加了注释, 简单归纳:

由于掘金不支持格式,暂使用图片

这里涉及了两个重要的概念,扩容resize()和树化treeifyBin(tab, hash)

6,扩容 resize()

1, 扩容什么时候触发?

回顾一下putVal() 函数:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {    
    Node<K,V>[] tab; Node<K,V> p; int n, i;    
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;    
    // 扩容无关代码省略..
    if (++size > threshold)  
        resize();    
}

resize()函数在这里出现了两次:

  1. tab数组为空时,使用resize()构建数组
  2. size大于threshold时,使用resize()扩容。其中threshold等于 table.length(数组长度) * loadFactor(负载因子);

当元素数量超过阈值时,便会触发扩容操作,每次扩容是扩容前的两倍大小。

2, 扩容的实现过程?

源码过长,暂无法粘贴,这里只做简单归纳:

由于掘金不支持格式,暂使用图片

7,树化 treeifyBin(tab, hash)

先看具体源码:

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)        
        resize();    // hash表不为空或小于 64长度
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //hd 首节点 tl尾节点        
        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); // 转换树的方法    
        }
}

该函数在进行条件判断后,将链表转换成了双向链表,而具体的把双向链表树化的实现在TreeNode类的treeify函数中。

阅读到这里,已经进行问题的回答了,再深入就是具体的链表转红黑树的操作。

1,什么是树化?

将链表结构转换为红黑树结构,这个过程称之为树化。

2,为什么要树化?

HashMap里key的冲突越多,链表的长度就会越长,这会严重影响到map的查询性能。

链表的查询时间复杂度是O(n),转换为树后,时间复杂度降低到了log(n)。这样就解决了链表过程带来的查询性能问题。

而付出的代价,是相较于链表两倍的空间占用,属于典型的用空间换时间。

3,树化有什么条件?

具体到源码细节

执行树化函数treeifyBin()的前置条件,在 putVal()函数里:binCount >= TREEIFY_THRESHOLD - 1要求链表的实际长度大于8。

treeifyBin()函数内部,同样做了条件判断:                                                                          if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) 条件下会执行resize()操作, 而不会进行树化,相反且tab不等于null的情况则进行树化,也就是数组长度大于等于 MIN_TREEIFY_CAPACITY 即64的情况。

树化条件总结:

  1. 链表节点大于8
  2. 数组长度大于64

4,为什么会有条件限制?

找到条件不是目的,我们来分析下原因。

假设链表节点数为8,O(n)平均查询长度为4,log(n)的平均查询长度为3,这里得出结论,当数量大于8时,树的优势会更加明显。

对于过小的数组,链表节点数已经到达需要树化的标准,说明hash碰撞已经很严重了,此时树化并不能直接解决问题。扩容重新计算hash分布,使链表长度变短才是解决问题的关键。

8,get(Object key)操作

先看具体源码:

public V get(Object key) {    
    Node<K,V> e;    
    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;    
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node            
                   ((k = first.key) == key || (key != null && key.equals(k))))            
            return first;        
        if ((e = first.next) != null) {            
            if (first instanceof TreeNode)                
            return ((TreeNode<K,V>)first).getTreeNode(hash, key);            
            do {                
                if (e.hash == hash &&                    
                    ((k = e.key) == key || (key != null && key.equals(k))))                    
                return e;            
            } while ((e = e.next) != null);        
        }
    }
    return null;
}

代码比较简单,归纳一下:

由于掘金不支持格式,暂使用图片