HashMap源码学习——初探

写这篇文章前,首先感谢MrBird的(https://mrbird.cc/Java-HashMap底层实现原理.html)这篇文章,里面讲解的HashMap源码内容非常棒,本文仅作为自身学习的记录,如果还能帮助其他同样在看HashMap源码的朋友,那也是极好的。

本文提到的HashMap源码内容,均来自于Jdk1.8版本。虽然HashMap可以通过直接进入HashMap class的方式进行阅读,但为了更好的写注释和提交自己的修改记录,还是建议下载源码阅读更为方便,源码下载地址:https://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d

好,话不多说,下面开始! 

HashMap采用哈希表结构,也就是数组table+链表Node实现,因此结合了二者的优点:数组的高访问效率和链表方便的插入、删除(不需要移动元素)。实际上HashMap不仅使用了链表,为提升查询效率,当链表达到一定长度,会转换为红黑树。

图片来自: Java HashMap底层实现原理 | MrBird

说到这个数据结构,第一个冒出来的问题大概率是:HashMap是如何分配什么时候使用数组,什么时候使用链表?

答案是通过hash计算的方式,由 (n-1) & hash 来决定元素在数组中的位置(计算中的n为数组长度),当该位置存在元素时,即发生哈希碰撞,则将新放入的元素挂在已存在的元素下形成链表。前面提到,数组由于在内存中的位置是连续的,查询效率高,当HashMap中的链表越来越长,查询的效率就会越来越低,链表查询复杂度为O(N),此时会进行判定(如何判定之后会提到)是否需要转为红黑树,花费时间进行转换后将复杂度降为O(logN)。

数组大家都再熟悉不过,而链表也是大家多接触过的单向链表结构,包含了hash、key、value以及next:

    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) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

红黑树的结构如下:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        ...
}

我不知道大家在实例化一个HashMap时会不会对它的参数进行设置,我个人很少会进行设置(这个习惯大概率是不太好的),那么当我们不进行设置时,HashMap的默认配置是如何的呢?请看以下几个常量:

//数组的默认长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//数组的最大容量,2^30,即1073741824
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;

//链表转换为红黑树,数组容量必须超过的阈值,和TREEIFY_THRESHOLD配套使用    
static final int MIN_TREEIFY_CAPACITY = 64;

我当时看到这几个参数,一下子被两个地方迷惑住,第一个是你16就16,为啥弄个1<<4,还在后面注释一个aka 16,有一种想“羞辱”我“没文化”又怕我真看不懂的感觉;第二个问题就是什么是加载因子

第一个问题一看就更有吸引力,所以这里先回答第二个问题。

加载因子也叫扩容因子,用于决定HashMap数组何时进行扩容,公式为扩容阈值=数组容量*加载因子,默认为DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 16*0.75 = 12,即HashMap数组的数据数量大于等于12时,数组进行扩容。

我们都知道创建数组时就要确认其长度,扩容的操作实际上是创建一个新的更大的数组,然后把数据复制过去(像极了人类改善住房)。搬过家的都懂,这个过程非常耗时。那么就会发现:

加载因子越大,越不容易发生扩容,容量小了哈希碰撞的概率就增加,链表也就变长,查询效率降低;

加载因子越小,越容易发生扩容,扩容的耗时增加了,但哈希碰撞概率降低,查询效率提高。

世间安得两全法,不负如来不负卿。容量占用、扩容耗时、查询耗时等因素互相影响,无法都达到最好,因此需要一个平衡点,那么这个平衡点就由加载因子决定,而这个0.75就是JDK开发人员设置的经验之值(当然里面的门道可能不仅仅是“经验”二字这般简单)。

解释完加载因子,让我们回到第一个问题,1<<4 why?其实对这个问题的答案我反复查找了几次资料,有许多人的回答是“位运算直接操作内存,不需要进行‘进制’的转换,方便后面进行二进制运算”,于是我用最简单粗暴的方式试了试,看看是不是会输出一个二进制的结果:

public class main {
    public static void main(String[] arg) {
        System.out.println(1<<4);
    }
}

不知道是不是我的方式不对,确实也只是输出一个“冰冷”的16. 

那这直接=16,连位运算都省了不是更好吗?后来由于好奇心作祟,我又去查了查,有一位网友的答案让我觉得较为有说服力:

如果哪位大佬有更棒的答案,也希望能告诉我。那既然提到了碰撞几率低这个原因,就聊一聊为什么2^N会降低碰撞几率。

 前文提到由HashMap通过 (n-1) & hash 计算来决定元素在数组中的位置,这个计算在源码中会频频出现,如下:

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 ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {...}

&符号表示与运算,也就是两个操作数第n位都为1则为1,否则为0。那么=2^N的数都有什么特点呢,我们找几个转为二进制来看一看。

2          10
4         100
8        1000
16      10000

找一下规律:都是最高位1,其余位都为0。

那么n-1呢?

1           1
3          11
7         111
15       1111

全为1,这样的数有什么特点呢。比如字符“学习” ,对应的hashCode为745402,对应的二进制10110101111110111010。n为16,(n-1) & hash=1111&10110101111110111010=1010,也就是说,hash计算的结果完全取决于key的hashCode最后几位。

那么不使用2^N的数组长度呢,比如长度10,10-1= 9的二进制为1001,那么不管key是多少,&运算的四位结果中,最多只能出现两个1,而其余两位永远为0,共4种可能性(0000、1000、1001、0001)对应数组里的4个下标,而n为16的四位结果在0000-1111共16种,对应数组的16个下标,分布均匀,冲突几率最低。

不得不感慨,哪怕一个默认长度,里面也蕴藏了JDK开发人员的智慧。

到这里,算是初步探索了一下HashMap,记录了一些我一开始阅读好奇的地方,下一篇文章让我们继续探索内部具体的方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值