JDK hashMap源码解读,看完明白如何实现一个map

看这个贴的盆友我认为是有一定工作经验,对于map肯定是较为熟悉的,这里博主就不介绍map的api了;
首先对于map的一个基本概念就是map是通过数组+链表的形式存储数据的;
结构如下所示:
在这里插入图片描述
第一步:我们看一下hashMap的核心属性:

/**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;//默认的数组长度

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30; //数组最大长度 2的30次方
    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;  //负载因子,待会详细说明

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认的负载因子

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table; //真正存储数据的地方,可以看见是数组,而entry是数据链

如下是Entry的类结构:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;	//map操作中的key
        V value;	//map操作中的value
        Entry<K,V> next;	//链表结构  指向下一个数据
        final int hash;		//经hashMap的hash方法计算出的值

由上可见map就是一个通过数组+链表来存储数据的结构啦,数组中的每一个元素都是数据链,这样的设计可以让我们在get数据时的速度非常快,因为数组中元素的获取是不需要遍历,只要知道下标是可以直接获取的,性能是非常快的,而对于数组下标的生成和获取map都已经帮我们干了(下面对于数据数组我会称作哈希表);
那么究竟如何对数据进行put和get呢?先来看一下put方法

public V put(K key, V value) {
        if (key == null)	//判断key是否为空
            return putForNullKey(value); //key为空,将键值对存储在哈希表下标为0的位置
        int hash = hash(key.hashCode()); //通过hash方法计算key的hash值
        //通过indexFor方法计算该键值对应该存储在哈希表的什么位置
        int i = indexFor(hash, table.length); 
        //遍历哈希表下标为i处的链表,查看欲新增的键值对,键是否已存在,存在则覆盖,并将老的value返回
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //不存在则map中存储的元素个数+1
        modCount++;
        //调用增加数据的方法
        addEntry(hash, key, value, i);
        return null;
    }

如上通过注释可以大体明白put时的执行过程,关键的地方就是计算key的hash值,然后通过hash值获取它在哈希表中的下标,在这里面的操作有很精妙的地方,现在明白逻辑即可,具体干了什么我会在之后进行叙述;

然后我们看一下addEntry方法,看一下是如何添加数据的:

    void addEntry(int hash, K key, V value, int bucketIndex) {
    	//根据之前算好的哈希表下标获取数据,可能会是null
		Entry<K,V> e = table[bucketIndex];
		//new一个entry将键值存进去,并指向e,将此entry存入哈希表中之前算好下标的节点
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        //判断此时map中的元素个数是否大于threshold,大于则扩容,容量*2,即哈希表的长度*2
        if (size++ >= threshold)
            resize(2 * table.length);
    }

可以看到是先获取哈希表在该下标的元素,也就是老的数据,然后将新添加进来的数据放在该下标位置,并且新的数据指向老的数据,然后比较当前数组中的数据数量是否大于threshold,如果大于就扩容,对于这个threshold就是上面看到的map属性 初始长度DEFAULT_INITIAL_CAPACITY * 负载因子DEFAULT_LOAD_FACTOR了。
注意!!扩容是一个非常非常浪费性能的操作,他会将哈希表增大一倍,所有数据重新计算一次哈希值,全部重新分配位置,使用map尽量不要扩容!!如何控制留在下面讲构造的时候说明

看完put操作,其实map的操作逻辑大概已经清楚了,接下来的get操作我就不上代码了,简单说一下逻辑,通过key获取hash值 ——》 通过哈希值获取哈希表)的下标位置 ——》 从该下标位置去取数据,会得到entity对象,然后比较key的值,key匹配就return回来,不匹配则获取next属性,即下一个entity对象取比较。

如上就是HashMap的put和get操作,我这里上的代码时jdk1.6版本的,1.7版本的代码和1.6基本是一样的,现在大家一般都使用的是jdk1.8,而1.8的map新引入了红黑树来进行数据存储获取的性能优化,主要是当哈希表的长度大于等于64,且当前下标位置的链表长度大于等于8时,会使用红黑树的数据结构代替该节点的链表结构来存储数据,有兴趣的盆友可以去看看。

下面来说一下上面遗留的一些问题

  • 负载因子:DEFAULT_LOAD_FACTOR

map有着良好的性能,是因为它的数据结构是数组+链表,查询时,通过下标定位数据的位置,这一步操作是非常快的,不需要遍历,直接获取,然后再从哈希表下标的链表开始遍历获取数据,这一步是比较浪费性能的,那么当数据量过大时,链表就会越来越长,map的性能就会越来越差,所以才会出现扩容的概念,提升哈希表的长度以提升map的性能,但什么时候去扩容呢?
这就要说到如下3个属性了。
默认长度:DEFAULT_INITIAL_CAPACITY=16;
负载因子:loadFactory;
扩容界值:threshold;
一般情况下,我们使用new HashMap()的方式初始化一个map,那么它就会在map中数据量达到12的时候去进行扩容,此时扩容界值threshold就等于12,它此时等于默认长度*默认的负载因子(上面代码有写是0.75);
我们看一下hashMap的构造方法可以看到,它是重载的,有构造方法可以让我们传入哈希表的初始长度和负载因子。

	/**
	 * 无参构造,使用默认的哈希表长度16初始化哈希表,再根据负载因子0.75初始化扩容界值
	 */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;//值为0.75
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);//值为12
        table = new Entry[DEFAULT_INITIAL_CAPACITY];//初始化长度为16哈希表
        init();//空的方法,供子类重写用的
    }
    /**
    * 单参数构造,传入初始化数组长度,然后使用默认负载因子0.75去调用,双参构造
    */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
	/**
	* 双参数构造,传入初始化长度和负载因子
    */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)//校验数据
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)//校验数据
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))//校验数据
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
		
        // Find a power of 2 >= initialCapacity
        int capacity = 1;//此变量代表哈希表的初始化长度,初始化为1,默认2的0次方
        while (capacity < initialCapacity) //循环判断capatify是否小于我们传入的初始化长度
            capacity <<= 1; //小于初始化长度就让capacity翻倍,即乘2,这里可以看出一点,哈希表的初始化长度是2的幂次方

        this.loadFactor = loadFactor;//赋值负载因子
        threshold = (int)(capacity * loadFactor);//计算扩容界值
        table = new Entry[capacity];//初始化哈希表
        init();
    }

这几个构造方法主要是双参构造,而无参构造使用默认的长度和负载因子计算扩容界值和哈希表,单参构造使用默认的负载因子也调用了双参构造。而双参数构造主要是生成扩容界值和哈希表,而这里关键的一点就是哈希表的长度,会变成2的幂次方,这是为什么呢?
通过上面我们可以知道,map就是数组+链表的结构,而通过map获取数据数据的时候,他会计算key的哈希值获取哈希表下标位置,然后遍历链表元素,比较key,然后找到key相同的元素返回,这个过程中,通过哈希表下标元素是非常快的,而遍历链表是很慢的,那么java出于性能的考虑,肯定是想尽可能的通过遍历哈希表直接过去到元素,而去尽可能少的去遍历链表,这也是他给了负载因子,和扩容界值这两个属性的原因,但如何保证尽可能少的出现同一个哈希表下标下面的链表元素少,甚至只有一个链表元素呢?
在这里我们先把不同的key获取到相同的哈希表下标的过程称作碰撞,从上面一段话可以看出碰撞越少,map的效率就越好,而正是处于这一点的考虑,map中哈希表的长度才会是2的幂次方,我们回到上面的一个遗留问题,看下map的put操作中是如何获取哈希表的下标位置的:

  /**
     * Returns index for hash code h.
     * 返回哈希码的哈希表下标
     * 	@h 根据key获取的哈希码
     * 	@length 数据哈希表的长度
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

可以看到是通过将key的哈希值h与哈希表长度-1进行与运算获取的,那么为什么要-1;
首先可以肯定一点,哈希表长度为2的幂次方和此处要使用使用哈希表长度-1都是为了提升map的性能;
然后说以下 h & (length-1) 这句代码是什么意思,因为可能有些人不明白,它是将哈希值h和(length-1)进行与运算,会先将两个值转换为2进制,然后在每位数上进行比较,但两个数的指定位数都为1则得出一个1,否则为0,看一个demo:
在这里插入图片描述
这里可以看到变量a和变量b进行与运算的结果,a和b转换为2进制,然后再相同位数的比较,同为1得到1,否则得到0;
根据这个规则,我们把b看作哈希表的长度-1,因为哈希表的长度是2的幂次方,那么它-1的值一定会是111或11111等等,总之会是n个1的组合,这样的数字会与key的哈希值进行与运算会使得生成的下标更均匀,生成的下标也完全由key来决定,如果b变量不是2的幂次方-1,换成图上的1010,我们可以看到不管a为什么值,得到的结果在倒数第一位和倒数第三位的值都是固定的0,即结果总是X0Y0,那么如果用这个当哈希表下标,哈希表有些位置会一直没值,那么碰撞发生的概率就更大了。
关于上述这一点看不明白的可以看一下这个博客:
Hashmap中为什么hash的长度是2的幂次方
对于通过key生成hash的方法在上面的put中可以看到,首先通过key的getHash()方法获取哈希值,然后再调用map的hash(int i)方法去生成,而hash(int)方法具体做了什么呢?看一下代码:

    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

可以看到 他就是通过将h不断无符号右移然后进行位异或运算,这样做的目的通过注释也可以看到就是为了确保不同的key尽可能的不相同,这样就能分配到一个不一样的哈希表下标,从而使碰撞更少。

以上便是hashMap的核心功能了,怎么样,看到这里你是不是也能实现一个map了?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值