JDK1.8版本HashMap源码原理分析

Posted hymKing

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK1.8版本HashMap源码原理分析相关的知识,希望对你有一定的参考价值。

HashMap是一个KV的容器对象,也是日常在开发当中非常常用的对象,比如我们可能经常做一些内存缓存的时候,很多时候就选用HashMap这种KV的数据结构。在实现原理上,是基于哈希表的Map接口实现,是常用的java集合之一,非线程安全的。

HashMap可以存储null的key和value,但是null作为键值只能有一个,做为值的话,可以是多个。这和Map的键要保持唯一性并不冲突。

一、HashMap的类图结构

二、概念、原理概述

Jdk1.8之前的HashMap是由数组+链表作为底层数据结构实现的,数组是hashMap的主体,链表则是为了解决哈希冲突而存在内部解决方案(拉链法)。

jdk1.8以后的HashMap在解决哈希冲突时有了较大的变化,引入了红黑树,以减少搜索时间。

先明确几个概念:

哈希表:指的就是hashMap;

哈希桶:HashMap的底层数据结构,即数组;

链表:Hash桶的下标装的是链表(或树型结构体);

节点:链表上的节点就是哈希表上的元素

哈希表元素容量:元素的总个数

哈希桶的容量:数组数组个数。

哈希桶的默认容量是16

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

对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。

上图初步呈现了hashMap的实现结构,在进行增删改查操作的时候,首先要定位到元素所在桶的位置,桶的位置指的就是table数组元素的位置,之后再从桶元素所对应的链表中定位该元素。

比如我们要查找35的元素,先定位到数组中3元素的位置,然后通过链表定位到第三个元素,确定了35元素的位置。

HashMap的底层结构原理概述就如上所示,HashMap的基本操作就是对拉链式散列算法的一层包装,无论1.8版本后引入的红黑树,虽底层数据结构由【数组+链表】变成【数组+链表+红黑树】,核心的原理设计没变。

在jdk1.8中引入的红黑树,在链表的长度大于8并且哈希桶的长度大于等于64的时候【TODO】,会将链表进行树化。红黑树是一个自平衡的二叉查找树,查找效率会从链表O(N)降低为o(logn),大大提升查找效率。

详细分析,看接下来的源码分析

三、HashMap源码原理分析

3.1构造函数分析

/**
 * 根据初始化容量、加载因子初始化一个空元素的Map
 * @param  initialCapacity 初始化容量
 * @param  loadFactor  负载因子
 * @throws IllegalArgumentException 负数抛异常
 */
public HashMap(int initialCapacity, float loadFactor) 
    ...
    //初始化容量超过了最大容量1 << 30(2的30次幂),则使用最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    ...
    //负载因子初始化:负载因子或者叫做扩容因子
    this.loadFactor = loadFactor;
    //HashMap进行扩容的阈值,实际上就是数组的长度
    this.threshold = tableSizeFor(initialCapacity);


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


/**
 * 以16的数组容量和0.75的负载因子,进行默认初始化
 */
public HashMap() 
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted


public HashMap(Map<? extends K, ? extends V> m) 
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);


上述4个构造函数,最常用的是第三个,就是以默认的方式进行初始化一个HashMap,最终会调用都构造函数1,构造函数构造的过程,就是对几个核心的成员变量做了初始化。

3.2 hashMap中桶的长度设计(数组的长度是如何计算的)?

this.threshold = tableSizeFor(initialCapacity);看一下这个方法:

/**
 * 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;

5步或等和移位运算,最终会找到大于或等于 cap 的最小2的正数幂的值,举例说明一下:

比如构造过程中传入的是15,最终tableSizeFor后的值是16,如果是28,最终tableSizeFor的值是32,如果是64,则是64.

tableSizeFor()这个函数就是计算hashMap的长度,那么从设计层面为什么HashMap的长度设计成2的整数幂次方呢?

1、为了加快哈希计算

查找一个KEY在哈希表的那个桶中,需要计算hash(key)%桶的长度,%属于算术运算,算术运算的效率要低于&位运算符的效率,恰好,当被取余的数是2的n次幂的时候,可以用位&替代取余来提升效率。

如下,当b为2的n次方时,有如下替换公式(公式可自行验证):
a % b = a & (b-1) (b=2^n)
即:a % 2^n = a & (2^n-1)

2、2次幂必然是偶数,这种偶数设计能使得散列结果均匀,从而减少Hash冲突的可能性。

假设数组的长度length是奇数,length-1为偶数,最后一位是0,通过hash函数hash&(length-1)的结果最后一位肯定是0,即只能为偶数,这样任何hash值经过hash函数计算后的结果都是偶数,元素就只能被散列在偶数的下标位置上,这样既浪费了空间,同时可能带来2倍的hash冲突的可能性。

关于tableSizeFor()方法运算中大量的使用了位运算和逻辑运算的详细说明可以参考https://segmentfault.com/a/1190000039392972。

3.2 HashMap源码中的关键常量变量的声明部分

/**
 * 最大的容量值
 * MUST be a power of two(必须2的幂) <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的负载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 树化的链表数量阈值
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 树拆分的阈值
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 最小的树化桶容量
 */
static final int MIN_TREEIFY_CAPACITY = 64;

以上都是简单的描述,在后续的章节分析中会得到这些常量的使用和设计。

3.3 hashMap的桶的的数据结构和源码实现

/**
 * table数组, 第一次使用的时候初始化,必要的时候会进行扩容,扩容一般都是原来的2倍
 */
transient Node<K,V>[] table;
/**
 * 基本的hash存储节点, 用于存储大量的entries. 
 */
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;
    

    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) 
       ...
    

    public final boolean equals(Object o) 
        ...
    

Node是HashMap的的一个静态内部类,实现了Map.Entry接口,本质上就是一个映射。final K key;V value; 类的成员变量中定义了key和value,是泛型化的,但是注意key被关键字final修饰了,虽然无论创建出来什么样的节点的key数据,程序不会出现问题,但是如果Key对应的对象本身应该也是不可变对象。

3.4.1HashMap的元素插入逻辑和源码实现分析

元素的插入流程是首先要定位需要插入的键值元素属于哪个通,定位到桶过后,判断当前的桶中是否已经有元素,如果桶为空,则直接将键值对存入即可。如果不为空,则需要将键值对接在链表的最后一个位置,或者更新键值对。

以上就是元素插入的核心流程,但由于hashMap是一个变长的集合,实际在插入的时候还有扩容机制,在1.8版本jdk中,还有树化过程和树拆过程。看一下源码,源码分析后,会画一个流程帮助理解:

/**
 * 将键值对元素插入的桶中。
 * 如果同种存在了相同key的键值对,则替换
 *
 * @param key 
 * @param value 
 */
public V put(K key, V value) 
  //实际会调用到以下5个参数的重载方法
    return putVal(hash(key), key, value, false, true);


/**
 * 实现了Map的put和其它相关方法
 *
 * @param key的hash
 * @param key
 * @param value
 * @param onlyIfAbsent true的话不改变当前key的值
 * @param evict 驱逐  这个参数是给定长的LinkedHashMap使用的,可以实现达到最大长度后移除元素
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) 
    Node<K,V>[] tab; Node<K,V> p; int n, i;//n 桶的长度,待插入元素定位到的桶的位置索引
    //如果table是空或者长度是0,初始化table数组(桶),
    if ((tab = table) == null || (n = tab.length) == 0)
      //首次插入:此条件第一次调用put的时候,会调用,实际tab初始化的过程在resize()函数中实现
        n = (tab = resize()).length;//tab的长度,前面分析过tab的长度是2^n次方
    if ((p = tab[i = (n - 1) & hash]) == null)
      //首次插入or非首次插入头结点为空:
      //首次插入的符合此条件,i = (n - 1) & hash i其实就是计算定位出来的当前待插入元素的索引
        tab[i] = newNode(hash, key, value, null);//在桶中存入此节点作为头节点
    else 
      //非首次插入头节点非空:hash算法(i = (n - 1) & hash)定位到桶位置中存在元素
        Node<K,V> e; K k;
      //判定是否等于第一个节点P
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
          //如果节点的hash和键的值等于链表中第一个节点的值,则将e指向该节点
            e = p;//存储元素
        else if (p instanceof TreeNode)//p节点是红黑树节点,调用红黑树节点插入
            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);
                  //插入后判断是否要将链表转化成红黑树,节点元素个数大于等于8的时候
                  //新追加了一个元素,所以实际元素个数的判断TREEIFY_THRESHOLD - 1=7
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //转换成红黑树
                        treeifyBin(tab, hash);
                    break;
                
              //在链表的非第一个节点中,找到重复key的元素
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;//p的应用指针后移1个,实际就是在遍历
            
        
      //在链表中的第一个位置或是后续位置找到相同的key
        if (e != null)  // existing mapping for key
          //要插入的键值的键已经存在,更新value
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        
    
    ++modCount;//用于并发修改的一个异常判断,基本非线程安全集合类都有类似操作
    if (++size > threshold)//大于阈值需要进行扩容
        resize();
    afterNodeInsertion(evict);//主要用于LinkedHashMap使用,这里不做说明了
    return null;

上述源码中的注释已经添加的非常详细,然后我们在看如下流程,更清晰的理解元素插入的过程。

从流程图,能够清晰的看到,插入或是更新元素,以及链表转换红黑树的时机,桶的初始化和扩容都是调用resize方法,对照上面的代码注释,应该能很清晰的理解HashMap的元素插入过程。

3.4.2扩容机制

java中普通数组是定长的结构体,对于动态数组(非定长,有扩容机制)有ArrayList和HashMap。下面就看一下HashMap扩容机制的实现流程:

HashMap中,桶数组的长度均是2的n次幂,阈值的大小为桶的数组长度和负载因子的乘积。当hashMap中的键值对数超过阈值的时候,进行扩容。HashMap会按照当前桶数组的长度的2倍进行扩容,扩容后,阈值自然也变为了原来的2倍。扩容之后,要重新计算键值对元素的位置,并把他们移动到合适的位置上去。jdk1.8版本中扩容的核心方法是resize()方法,在插入元素的源代码中,已经做过部分源码的分析,resize()同时也承担着HashMap的桶的初始化工作。

/**
 * 初始化or2倍table的大小
 * @return the table
 */
final Node<K,V>[] resize() 
    //旧的桶数组
    Node<K,V>[] oldTab = table;
    //旧的桶的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧的扩容阈值
    int oldThr = threshold;
    //定义新容量、新阈值
    int newCap, newThr = 0;
    //旧的容量的大于0
    if (oldCap > 0) 
        //超过容量的最大值
        if (oldCap >= MAXIMUM_CAPACITY) 
            threshold = Integer.MAX_VALUE;//整形的最大值
            return oldTab;//旧的节点数组直接返回
        
        //左移位运算,将newCap变为oldCap的2倍,即扩容两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //阈值同样扩容2倍
            newThr = oldThr << 1; // double threshold
    
    else if (oldThr > 0) 
      // 初始化容量存储在threshold中,这块不是很好理解,后面会再分析。【单独分析点1】
        newCap = oldThr;
    else                // 空桶,进行初始化
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    
    if (newThr == 0) //新阈值==0时,按照阈值公式进行计算
        float ft = (float)newCap * loadFactor;//计算新阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);//赋值新阈值,并做好边界控制
    
    threshold = newThr;//将新阈值赋值给成员变量
    @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)//只有一个元素
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)//如果是红黑树节点
                  //对红黑树进行拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else  // preserve order:保存顺序
                    //低位头                  低位尾
                    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;

上面的源码扩容桶部分的注释已经很详细,后面一部分是扩容后的数据迁移操作的算法,整体扩容过程实际上做了三件事情:

1、计算新桶数组的容量newCap和新阈值newThr。

2、根据计算出来的newCap创建新的桶数组,桶数组table也是在这里进行初始化的。

3、将键值对节点重新映射到新的桶数组里,如果节点是treeNode类型,则需要拆分红黑树,如果是普通节点,则节点按原顺序进行分组。

在计算newCap和新阈值newThr的代码注释中,有一行【单独分析点1】:

else if (oldThr > 0) 
            newCap = oldThr;

这种情况是怎么产生的呢,回顾一下构造函数:

public HashMap(int initialCapacity, float loadFactor) 
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);


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

通过传入初始化容量的构造函数,会产生上述的分支条件:

newCap=oldThr;等价于newCap=threshold=tableSizeFor(initialCapacity)。threshold=newThr=(float)newCap * loadFactor;这里面忽略了溢出的逻辑。

举个例子,如果调用构造函数hashMap(15),最终newCap=tableSizeFor(15)=16,thresHold=newThr=15*0.75=12;

得出的hashMap的首次容量和扩容阈值。这是一种初始状态,扩容的触发条件,是在前面分析的插入元素代码的尾部如下:

if (++size > threshold)
    resize();

正常情况的扩容结果是容量扩大为原来的2倍,阈值也扩大为原来的2倍,通过左移运算完成。

接下来,

以上是关于JDK1.8版本HashMap源码原理分析的主要内容,如果未能解决你的问题,请参考以下文章

Java中HashMap底层实现原理(JDK1.8)源码分析

Java中HashMap底层实现原理(JDK1.8)源码分析

JDK1.8版本HashMap源码原理分析

JDK1.8版本HashMap源码原理分析

底层原理之旅—HashMap深入浅出的源码分析(JDK1.8版本)

HashMap工作原理回顾 (基于JDK1.8源码分析)