读Java 11 源码(3)HashMap

一图概览

在这里插入图片描述

0 基本属性

最基本的属性

// 存储数据的基本结构
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

transient int size;

transient int modCount;

int threshold;

final float loadFactor;

table:这个属性是就是所谓的HashMaps是“数组+链表”中的“数组”的由来。为什么要用transient来修饰,主要是因为本质上他它还是个动态扩容的数组,大部分的时间都不会满的,所以,如果每次直接序列化,浪费空间。
entrySet:这个参数,其实就是我们实现map遍历操作的时候的直接调用的就这个参数,entrySet()这个方法返回的就是这个参数(有代码有真相):

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

threshold :边界值,这个主要是和负载因子联系在一起的,一般来说就是当前的数组的大小*负载因子 = 边界值,主要是用来减少哈希碰撞。
loadFactor:负载因子,这个是可以用户自己手动赋值的,但是一般来说不建议这么做,除非你有非常好的哈希算法,不然一般不建议去修改这个负载因子。

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

size:不用说,但凡有是属于集合的,都有这个,表示现在集合中存储多少对象。

关键属性

/**
 * 默认的初始化大小16 - 必须是2的幂次方
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * MUST be a power of two <= 1<<30.
 * 可以存储最大的元素的个数 必须也是2的幂次方
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 负载因子 0.75
 */
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;

DEFAULT_INITIAL_CAPACITY:数组的默认默认的大小,HashMap底层也是一个可以动态扩容的数组,如果你没有指定初始化的大小的参数,那么这个在你第一次put操作的是就会进行初始化,table的第一次初始化大小就是DEFAULT_INITIAL_CAPACITY(16)。
MAXIMUM_CAPACITY :最大能放多少元素
DEFAULT_LOAD_FACTOR :默认的负载因子,负载因子(load_factor)这个主要是为了减少哈希碰撞而产生的。没有意外不建议修改。
TREEIFY_THRESHOLD :转化为红黑树的阀值,默认为8。
UNTREEIFY_THRESHOLD:当某个位置上的元素个数小于,等于6的时候,就会转化为链表。
MIN_TREEIFY_CAPACITY :容量最小64时才会转会成红黑树,就是如果整个map里的元素小于64,就算有某个位置上的超过8,也不能转换为红黑树

1 为什么是数组+链表

数组是怎么来的?
先来看个成员变量。

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

以上就是数组的由来,这就是hashmap的最基本的数据结构,它是由Node<K,V>组成的数组。
我们可以翻译他的注释:

该表在首次使用时初始化,如果必要的时候会调整大小。分配大小的时候,它的长度始终是2的幂次方。

链表是怎么来的 ?
来看看Node<K,V>,这个类定义的代码,其实熟悉链表的朋友肯定能看出来,这个就是链表的节点的基础数据结构。

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

看到 的时候,不知道你有没有感觉到这就是为了链表而生的感觉?
是的,这个就是单向链表的基本节点的结构。
所以目前看来就是这样的,数组的每个位置上放结点,就像这样:
在这里插入图片描述
前面那一排就是数组了,然后每个节点后面可以接着和他们发生 “哈西碰撞” 的节点。
这里要好好说说,因为哈希表本质上是个数组,那么我们怎么知道每次put的那些数据要放在数组的哪个位置上?我们是通过哈希函数根据对象值来算出一个值,然后利用值来和数组的长度取余数,是的,其实算出神奇的位置的步奏的最后一步就是取余数。
但是很遗憾,没有完美的 哈希函数可以完美的让每个值都分配到不同的位置的值,如果有两个不同的对象,但是却被算出相同的位置,那么我们就叫这种情况叫做 “哈希碰撞(哈希冲突)”
被分配到相同的位置要咋办,两个数组都要存储啊,市面上最佳实践就是 “链地址法”
分配到相同的位置的node,但是两个nodekey互相不equals,就这样一个接着一个,成了链表。

2 关键操作

插入、更新操作 :put

这个put的操作其实用的还是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;
    // 判断当天的table数组是否为空,或者长度是否为0,
    // 就是还没初始化的意思,如果没有初始化,就初始化,而且返回初始化后的数组的数量。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根据hashcode来判断是否存在数组老的数组是否存在职,如果不存在就直接赋值即可。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 说明tab[i = (n - 1) & hash])已经存在值了
    else {
        Node<K,V> e; K k;
        // 如果在tab[i = (n - 1) & hash]) 的p 已经在存在的p
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
            e = p;
        }
        // 判断p是否已经是红黑树的节点了,执行红黑树的插入值的操作
        else if (p instanceof TreeNode) {
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        }
        // 说明说出现hash碰撞
        else {
            // 这个过程就是在找位置的过程,以下几种情况:
            // 1 如果说一直到链表的最后都没找既和hashCode相等,又equals的那个值,
            // 那么直接插入到链表的最后即可
            // 2 如果存在hashCode而且equals的值,那么就赋值给e,然后退出
            for (int binCount = 0; ; ++binCount) {
                // p的next,也就是链表的下一个是null,说明到了链表最后都没找到。
                // 那么直接把吧新的值接到链表的最后就行了
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 接了链表之后,再判断是否需要转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 这就是那个hashCode相等,还equals的e的位置
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 找到了e,如果这个不是null,那么就开始执行替换旧的value的操作
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent == false,或者原来的值为null, 就执行改变值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // modCount 加上1 
    ++modCount;
    // 如果是新的值,说明需要扩容了
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3 关键字参数详解

为什么要在链表长度8的时候变成红黑树?

这个解释在解释中找到。设计8的这值的时候,作者们结合泊松分布的函数。HashMap中的每个位置上的各种长度的命中概率。

* 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

可以看到达到链表达到8这个长度的,出现的概率不到千万只一,如果说达到了,那么很遗憾,就是你的对象的哈希算法(就是那个hashCode()方法)出了问题。
为了让这种极端情况,这个依然有效,就把它给转换为了红黑树,保证查询效率为O(LogN)O(LogN),就转为红黑树了

为什么复杂因子是0.75?

load factor(影响因子) 默认值是 0.75, 是均衡了时间和空间损耗算出来的值,较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(hash 冲突增加,链表长度变长)。但是具体是怎么计算的,这个,我就跟你掰扯掰扯吧。。。
原来的文档的解释这样的。
https://docs.oracle.com/javase/6/docs/api/java/util/HashMap.html

An instance of HashMap has two parameters that affect its performance: initial capacity and load factor. The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

翻译过来就是:

HashMap的实例具有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中存储桶的数量,初始容量只是创建哈希表时的容量。负载因子是在自动增加其哈希表容量之前允许哈希表获得的满度的度量。当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表将被重新哈希(即,内部数据结构将被重建),因此哈希表的存储桶数大约为两倍。
通常,默认负载因子(.75)在时间和空间成本之间提供了一个很好的折衷方案。较高的值会减少空间开销,但会增加查找成本(在HashMap类的大多数操作中都得到体现,包括get和put)。设置映射表的初始容量时,应考虑映射中的预期条目数及其负载因子,以最大程度地减少重新哈希操作的数量。如果初始容量大于最大条目数除以负载因子,则将不会进行任何哈希操作。

桶就表示hashmap数组每个位置上的链表的称呼。的那是以上的这不足以解决为什么是0.75的问题。我们只知道结果有啥用,但是不知道结果是怎么来的。
直到找到https://stackoverflow.com/questions/10901752/what-is-the-significance-of-load-factor-in-hashmap
在这里插入图片描述
大概就是非空桶的概率是大概是0.693,负载因子在0.693之上,和.075之间都有不错的效果。这个0.75是个意想不到的,天选之子的答案吧。。

为什么得到索引的算法是(size-1)&hash?

我们知道,哈希表的最后就是把元素存在数组上,那么就要知道存放的位置。所以应该是个取余的操作。但是%对计算机来说计算量比较大!所以,我们需要一个更好的算法来取代取余。结合下面的的为什么都要是2的幂次方?因为只有2的幂次方,才能够实现用&运算来代替取余数。

System.out.println(10 % 8);
System.out.println(7 & 10);
System.out.println("--------------------");
System.out.println(11 % 8);
System.out.println(11 & 7);
System.out.println("--------------------");
System.out.println(13 % 8);
System.out.println(13 & 7);
System.out.println("--------------------");
System.out.println(15 % 8);
System.out.println(15 & 7);

System.out.println("+++++++++++++++++++++++");
System.out.println(10 % 32);
System.out.println(10 & 31);
System.out.println("--------------------");
System.out.println(11 % 32);
System.out.println(11 & 31);
System.out.println("--------------------");
System.out.println(13 % 32);
System.out.println(13 & 31);
System.out.println("--------------------");
System.out.println(15 % 32);
System.out.println(15 & 31);

会有以下的结果:

2
2
--------------------
3
3
--------------------
5
5
--------------------
7
7
+++++++++++++++++++++++
10
10
--------------------
11
11
--------------------
13
13
--------------------
15
15

所以只有在2的幂次方,然后用这种方法,就能用与运算得到和取余数相同的结果了。

为什么map的大小都要是2的幂次方?

以上就说明,为什么都要是2的幂次方了,只有这样的情况,才能够完成快速运算出索引位置。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章