JDK1.8HashMap底层实现原理

Posted Code_BinBin

tags:

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

hashmap是我们开发中用的最常见的集合之一,但是我们是否有了解过他的底层呢?这篇文章我将带您了解一下hashmap的底层

位运算

在学习hashmap底层原理的时候,我们必须要掌握位运算,别问为什么,往下面看自然知晓

我们知道,计算机用的是二进制,也就是010101…这样的字符,当我们输入某一个数字的时候,存入计算机里面的也是二进制譬如说

  • 0-》00000000
  • 1-》00000001
  • 2-》00000010
  • 3-》00000011

&(按位与)

参加运算的两个二进制数,同时为1,才为1,否则为0。举例 3&5=1。

在这里插入图片描述

|(按位或)

参加运算的两个二进制数,一个为1就为1,否则为0。2 | 4=6

在这里插入图片描述

^(按位异或)

参加运算的两个二进制数,位不同则为1,位相同则为0。6^7=1

在这里插入图片描述

<< (左位移运算符)

将二进制码整体左移指定位数,左移后空出来的位用“0”填充,例如 -5 << 2 = -20

例如:2<<4
00000010->00100000=16

“>>”(右位移运算符)与 >>(无符号右位移运算符)

例如:16>>4
00100000->00000010=2

好了,现在了解了位运算,那么我们来了解一下hashmap

hashmap的概述

  • HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。
  • HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap中的映射不是有序的。
  • jdk1.8 之前 HashMap 由 数组 + 链表 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)。
  • jdk1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64时,此时此索引位置上的所有数据改为使用红黑树存储。

HashMap 特点:

  • 存储无序;
  • 键和值位置都可以是 null,但是键位置只能存在一个 null;
  • 键位置是唯一的,是由底层的数据结构控制的;
  • jdk1.8 前数据结构是链表+数组,jdk1.8 之后是链表+数组+红黑树;
  • 阈值(边界值)> 8 并且桶位数(数组长度)大于 64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询;

HashMap存储数据的过程

测试代码

 public static void main(String[] args) {
        HashMap map=new HashMap();
        map.put("student1","萧炎");
        map.put("student2","林动");
        map.put("student3","牧尘");
        map.put("student4","古尘沙");
        System.out.println(map);
    }

结果

在这里插入图片描述

执行流程分析:

  • 首先程序读到HashMap map=new HashMap();的时候,并不会马上创建一个数组,而是在我们第一次使用hashmap自己的put方法的时候,创建一个长度为16的数组,也叫作桶Node[]table ,这个用来存储数据

  • 当我们需要存入一个数据,比方说(key=a,value=3),首先会先调用重写的String的hashcode方法,计算出对应的hash数值,然后根据数组的长度和某种算法,找到这组数据应该对应的桶位数,就是数组的下标,例如0,1,2,3,4,5…然后查看这个桶里面是否有存其他的数据,如果没有,那么就直接把数据存入桶中,我们加入这一次存在‘3’这个位置
    在这里插入图片描述

  • 当我们又再一次的调用put方法,存入(key=b,value=4)的时候,假设这次算出来的又是存在三号位,这个时候,三号位已经有一个数据了,这个时候会判断两个数据的hash值是否相同,如果不相同,那我们这个时候就会在这个桶下生成一个链表,用来存储数据,这个叫做拉链法

  • 如果相同的话则会对两个数据进行一次判断
    数据相同:直接覆盖
    数据不同:从该桶位的链表开始,一直往下比,直到出现不同的时候,便存在不同的地方的下一个位置,如果这个时候链表长度超过了8,那么链表就会转化成红黑树

  • 在不断的添加新数据的时候,如果某一时刻超过了阈值,并且那个时候要存入数据的地方刚好不为空,那么,我们就要扩容了,每次扩容都是在原来的基础上,扩大2倍,原因后面会讲。

在这里插入图片描述

jdk1.8 中引入红黑树的进一步原因:

  1. jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。
  2. 针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

在这里插入图片描述

HashMap继承体系

在这里插入图片描述
从继承体系可以看出:

  • HashMap 实现了Cloneable接口,可以被克隆。
  • HashMap 实现了Serializable接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
  • HashMap 继承了AbstractMap,父类提供了 Map 实现接口,具有Map的所有功能,以最大限度地减少实现此接口所需的工作。

存储结构

在这里插入图片描述
在Java中,HashMap的实现采用了(数组 + 链表 + 红黑树)的复杂结构,数组的一个元素又称作
在添加元素时,会根据hash值算出元素在数组中的位置,如果该位置没有元素,则直接把元素放置在此处,如果该位置有元素了,则把元素以链表的形式放置在链表的尾部。
当一个链表的元素个数达到一定的数量(且数组的长度达到一定的长度)后,则把链表转化为红黑树,从而提高效率。
数组的查询效率为O(1),链表的查询效率是O(k),红黑树的查询效率是O(log k),k为桶中的元素个数,所以当元素数量非常多的时候,转化为红黑树能极大地提高效率。

HashMap基本属性与常量

基本属性,常量一览

/*
 * 序列化版本号
 */
private static final long serialVersionUID = 362498820763181265L;

/**
 * HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
 * 最大的容量为2的30次方
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的装载因子
 */
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;

/**
 * Node数组,又叫作桶(bucket)
 */
transient Node<K,V>[] table;

/**
 * 作为entrySet()的缓存
 */
transient Set<Map.Entry<K,V>> entrySet;

/**
 * 元素的数量
 */
transient int size;

/**
 * 修改次数,用于在迭代的时候执行快速失败策略
 */
transient int modCount;

/**
 * 当桶的使用数量达到多少时进行扩容,threshold = capacity * loadFactor
 */
int threshold;

/**
 * 装载因子
 */
final float loadFactor;

  • 容量:容量为数组的长度,亦即桶的个数,默认为16 ,最大为2的30次方,当容量达到64时才可以树化。
  • 装载因子:装载因子用来计算容量达到多少时才进行扩容,默认装载因子为0.75。
  • 树化:树化,当容量达到64且链表的长度达到8时进行树化,当链表的长度小于6时反树化。

Hashmap属性解释

DEFAULT_INITIAL_CAPACITY

集合的初始化容量(必须是 2 的 n 次幂):

// 默认的初始容量是16	1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

提问:为什么是2的次幂呢?如果输入值不是 2 的幂比如 10 会怎么样?

 /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

  • HashMap 构造方法可以指定集合的初始化容量大小,根据上述讲解我们已经知道,当向 HashMap 中添加一个元素的时候,需要根据 keyhash 值,去确定其在数组中的具体位置。HashMap 为了存取高效,减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现的关键就在把数据存到哪个链表中的算法。
  • 这个算法实际就是取模,hash % length,而计算机中直接求余效率不如位移运算。所以源码中做了优化,使用 hash & (length - 1),而实际上 hash % length 等于 hash & ( length - 1) 的前提是 length 是2 的 n 次幂。(这段话是摘抄传智播客锁哥的,这个解释确实很完美!)

例如,数组长度为 8 的时候,3 & (8 - 1) = 3,2 & (8 - 1) = 2,桶的位置是(数组索引)3和2,不同位置上,不碰撞。
再来看一个数组长度(桶位数)不是2的n次幂的情况:

数组长度为9 hash为3

00000011 3
00001000 8
————————
00000000 0

数组长度为9 hash为5

00000101 5
00001000 8
————————
00000000 0

数组长度为9,hash为6

00000101 5
00001000 8
————————
00000000 0

由此可见,如果不是2的次幂,hash值很容易一模一样,这样会经常产生哈希碰撞,导致性能下降,所以,这里采用的是2的次幂

为什么要用2的次幂小结:

  • 由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
  • 另一方面,一般我们可能会想通过%求余来确定位置,这样也可以,只不过性能不如&运算。而且当n是2的幂次方时: hash & (length1) == hash % length
  • 因此,HashMap容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能

如果创建HashMap对象时,输入的数组长度length是10,而不是2的n次幂会怎么样呢?

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

这里HashMap会采用一个tableSizeFor()方法,通过这个方法,把数组长度设置成最接近自己输入数组长度数量的2的次幂的数量例如如果我输入10,最后返回的就是16,我输入5,那么返回的便是8,我们复制一下这个方法,自己测试 一下

public class Test01 {
    public static void main(String[] args) {
        System.out.println(tableSizeFor(12));
    }

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

int n = cap - 1;为什么要减去1呢?

防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂,又没有这个减 1 操作,则执行完后面的几条无符号操作之后,返回的 capacity 将是这个 cap 的 2 倍(后面还会再举个例子讲这个)。

最后为什么有个 n + 1 的操作呢?

如果 n 这时为 0 了(经过了cap - 1后),则经过后面的几次无符号右移依然是 0,返回0是肯定不行的,所以最后返回n+1最终得到的 capacity 是1。
注意:容量最大也就是 32bit 的正数,因此最后 n |= n >>> 16;最多也就 32 个 1(但是这已经是负数了,在执行 tableSizeFor 之前,对 initialCapacity 做了判断,如果大于MAXIMUM_CAPACITY(2 ^ 30),则取 MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY,会执行位移操作。所以这里面的位移操作之后,最大 30 个 1,不会大于等于 MAXIMUM_CAPACITY。30 个 1,加 1 后得 2 ^ 30)。

完整例子

在这里插入图片描述

DEFAULT_LOAD_FACTOR

负载因子:默认为0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

MAXIMUM_CAPACITY

static final int MAXIMUM_CAPACITY = 1 << 30; // 2的30次幂

集合最大容量:为2的30次方

TREEIFY_THRESHOLD

当桶(bucket)上的结点数大于这个值时会转为红黑树

// 当桶(bucket)上的结点数大于这个值时会转为红黑树
static final int TREEIFY_THRESHOLD = 8;

为什么 Map 桶中结点个数超过 8 才转为红黑树?

8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了 8 是 bin( bucket 桶)从链表转成树的阈值,但是并没有说明为什么是 8。

在 HashMap 中有一段注释说明:

Because TreeNodes are about twice the size of regular nodes, we use them only when bins
contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too
small (due to removal or resizing) they are converted back to plain bins.  In usages with
well-distributed user hashCodes, tree bins are rarely used.  Ideally, under random hashCodes, 
the frequency of nodes in bins follows a Poisson distribution 
(http://en.wikipedia.org/wiki/Poisson_distribution) 
with a parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, 
the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:

翻译:因为树结点的大小大约是普通结点的两倍,所以我们只在箱子包含足够的结点时才使用树结点(参见TREEIFY_THRESHOLD)。
当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户 hashCode 时,很少使用树箱。
理想情况下,在随机哈希码下,箱子中结点的频率服从泊松分布。
默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预朗出现次数是(exp(-0.5)*pow(0.5, k) / factorial(k)
第一个值是:

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

一句话概括:
hashCode 算法下所有 桶 中结点的分布频率会遵循泊松分布,这时一个桶中链表长度超过 8 个元素的槪率非常小,权衡空间和时间复杂度,所以选择 8 这个数宇。

UNTREEIFY_THRESHOLD

当链表长度低于6会从红黑树转化成链表

// 当桶(bucket)上的结点数小于这个值,树转为链表 
static final int UNTREEIFY_THRESHOLD = 6;

MIN_TREEIFY_CAPACITY

当 Map 里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)

// 桶中结构转化为红黑树对应的数组长度最小的值 
static final int MIN_TREEIFY_CAPACITY = 64;

table(重点)

table 用来初始化(必须是二的n次幂)

// 存储元素的数组 
transient Node<K,V>[] table;

在 jdk1.8 中我们了解到 HashMap 是由数组加链表加红黑树来组成的结构,其中 table 就是 HashMap 中的数组,jdk8 之前数组类型是 Entry<K,V> 类型。从 jdk1.8 之后是 Node<K,V> 类型。只是换了个名字,都实现了一样的接口:Map.Entry<K,V>。负责存储键值对数据的。

entrySet

用来放缓存

// 存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;

size(重点)

HashMap 中存放元素的个数

// 存放元素的个数,注意这个不等于数组的长度
 transient int size;

size 为 HashMap 中 K-V 的实时数量,不是数组 table 的长度。

modCount

用来记录 HashMap 的修改次数

// 每次扩容和更改 map 结构的计数器
 transient int modCount;  

threshold(重点)

用来调整大小下一个容量的值计算方式为(容量*负载因子)

// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;

loadFactor(重点)

哈希表的负载因子

// 负载因子
final float loadFactor;// 0.75f

说明:

  • oadFactor 是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响 hash 操作到同一个数组位置的概率,计算HashMap 的实时负载因子的方法为:size/capacity,而不是占用桶的数量去除以 capacity。capacity是桶的数量,也就是 table 的长度 length。
  • loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f是官方给出的一个比较好的临界值。
  • 当 HashMap 里面容纳的元素已经达到 HashMap 数组长度的 75% 时,表示 HashMap
    太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建HashMap 集合对象时指定初始容量来尽量避免。
  • 在 HashMap 的构造器中可以定制 loadFactor。
// 构造方法,构造一个带指定初始容量和负载因子的空HashMap
HashMap(int initialCapacity, float loadFactor);

为什么负载因子loadFactor 设置为0.75,初始化临界值threshold是12?

loadFactor 越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。
在这里插入图片描述
如果希望链表尽可能少些,要提前扩容。有的数组空间有可能一直没有存储数据,负载因子尽可能小一些。

例举:

例如:负载因子是0.4。 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了。
	 负载因子是0.9。 那么16*0.9--->14 那么这样就会导致链表有点多了,导致查找元素效率低。

所以既兼顾数组利用率又考虑链表不要太多,经过大量测试 0.75 是最佳方案。

threshold 计算公式:capacity(数组长度默认16) * loadFactor(负载因子默认0.75)==12。

这个值是当前已占用数组长度的最大值。当 Size >= threshold(12) 的时候,那么就要考虑对数组的 resize(扩容),也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。 扩容后的 HashMap 容量是之前容量的两倍。

HashMap扩容机制

 /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
        //把旧的table 赋值个一个变量
        Node<K,V>[] oldTab = table;
        //获取旧的tabel的长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 旧的阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
    
        if (oldCap > 0) {
            //判断数组的长度是否大约等于最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果数组的长度达到了最大值,那么就不在进行扩容,直接返回,不管了任由hash冲突
                threshold = Integer.MAX_VALUE;
                return oldTab;
            //把旧的数组长度左移一位(也就是乘以2),然后判断是否小于最大值,并且判断旧的数组长度是否大于等于默认的长度16
            }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //如果条件成立就把旧的阈值左移一位复制给新的阈值
                newThr = oldThr << 1; // double threshold
        }//如果就的数组长度小于0并且旧的阈值大于0
        else if (oldThr > 0) // initial capacity was placed in threshold
            //就把旧的阈值赋值给新的数组长度(初始化新的数组长度)
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            //使用默认值 
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //如果新的阈值等于0
        if (newThr == 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;
                    //如果没有子元素那么说明是下面不是一个链表,直接通过 hash&(新的数组长度-1)计算出新的位置,把就的数据放入新的位置
                    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
                        //有多个数据并且不是树那么该节点上放的是链表
                        //这里是java1.8很精妙的地方,如果 e.hash& 旧的数组长度 如果等于0
                        那么该数据的位置没有发生变化,还在原来的索引位置上,如果不等于0 那么就在该值就在 (原来的索引位置+旧的数组长度)的位置上,
                        这里重新创建了两个节点,在原来位置上的放入loHead中,在新的位置上的放入
hiHead 中,最后把这两组数据放入新的数组中即可。(这里的精妙之处是不用重新计算每一个数据的hash,就可以把旧的数据放入新的数组中去)
                        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以上是关于JDK1.8HashMap底层实现原理的主要内容,如果未能解决你的问题,请参考以下文章

JDK1.8HashMap底层实现原理

jdk1.8hashmap常见的面试问题

hashMap tableSizeFor 实现原理

HashMap源码分析--jdk1.8

jdk1.8 HashMap底层数据结构:深入解析为什么jdk1.8 HashMap的容量一定要是2的n次幂

记一次线上故障--HashMap在多线程条件下运行造成CPU 100%