jdk-HashMap

来源:互联网 发布:数组下标-1 编辑:程序博客网 时间:2024/06/08 09:23

HashMap是我有意识去研究jdk源码时看的第一个。印象很深刻,当我看过源码之后,突然发现自己对之前很多东西理解突然透彻了,有此感叹。

笔者有幸也参与过公司的几次面试(跟着打酱油而已),有时候也有自己提问的时候,很多时候都是问关于HashMap的问题,并发现,其实很多面试者对这方面了解的并不好,基本80%都是在用,但是对于它的机制和处理过程确知之甚少。所以笔者也很纳闷,至少很大一部分人都没有关注过HashMap的源码,也是令人不解,这个集合应该是Java中用的最多的了。


废话不多说了。

首先要知道HashMap它的底层是个什么东西,其实看过源码之后,一句话就能概括。 16位的数组(这边的16位是初始化时候这个数组的大小)+ 链表(链表其实是去解决hash冲突),所谓的链表法就是这个。 HashMap采用的是链表法解决hash冲突。其实很多面试题都会问到,我也问过很多次,很少有人回答的出。再次记录一下。(这边更新一下,偶然看见一篇文章说java8的hashmap底层结构改变了,笔者写的时候还没用上java8,所以暂时不去管这个了。)


网络盗图一张,很多人博客里面有这张图,这张图其实很明显就能看出它的结构........

上图就是HashMap中的散列表,也叫做哈希表。为什么是数组+链表的形式呢?数组的优点就是 查询快,增删慢。 而链表的特点就是查询慢,增删快,两者一结合,就是哈希表了。-----------------------------------------

我们知道hashmap是通过<key,value>的形式来存储键值对。那么左边的数组记录的就是key的存放地址,暂时可以这么理解,其实hashmap中需要对key先做hash,然后对hash值与数组大小进行取模运算,才能得到最终的存放地址,也就是 hash(key)%len,得到的这个值就是数组中的存放地址。

那么可以预见的是,在基数很大时,会出现,某两个key值的进行了hash之后的存放地址一样了,例如 两个key,key1 和key2, 对它们进行 hash之后发现,它们的值都是上图中的12,那么此时就出现了所谓的hash冲突了,这块地方其实也是面试常见之处,解释什么是hash冲突。其实很简单。看过就知道。那么此时hashmap就会用链表法去存储相应的记录,此处在下面会分析是如何存储的。


-------------------------分割线---------------------------------

以上大致说下hashmap的一些概念,基本上该有的概念都涉及到,下面就是源码了,根据源码去体会,印象更深刻。。

初始化:

一般看源码都会去关注一下初始化~~~~(这个是不是废话啊),不过这是我的习惯,当前版本是jdk1.7.17

/**
     * The default initial capacity - MUST be a power of two.  //默认的初始容量 为 16,也就是 数组长度为16
     */
    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 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<K,V>[] table;

     /**
       * 需要调整大小的极限值(容量*装载因子)
       */
      int threshold;
     /**
     *装载因子,当HashMap的数据大小>=容量*加载因子时,HashMap会将容量扩容
      */
      final float loadFactor;


上面有个Entry数组的概念需要关注一下,hashmap中存放key,value就是放在这个entry里面,当然,它还记录了一个next,可以理解为单链表中的pre和next,就是指向想一个元素的地址,hash的话就是记录的当前key的hash值,其他的变量就是需要用到的一些值。暂时我也没去关注过。

 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

以下就是初始化方法,传参为默认的大小 16 和 默认的加载因子 0.75f。

这边注意标红的这一段,因为默认值为16,假设穿参进来为(7,0.75f),那么 第一次 1 <<=1 为 1*2^1 =2 ,第二次为 2*2^1 = 4 ,第三次为 4*2^1=8 ,因此最终值为8,并不是你传进来的7,因为hashmap中的大小都是2的幂等性,据我了解,可能是为之后的扩容方便。所以当你传入的是(7,0.75f)时,其实最终数组的大小才8,并不是默认的16,hh。

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);
        int capacity = 1;
        //设置capacity为大于initialCapacity且是2的幂的最小值
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }

接下来就是put方法,还是费点心截图上传吧,直接截取代码省事,但是看起来太累。 


首先就是 key 为null时,hashmap是允许null值的,所以先看看它是怎么处理null值的。 调用了 putForNullKey ,发现并没有传key值进去,只是传了value,难不成是放在一个特定的位置上了?不猜测,接下去看,事实果然是如此,注意到table[0] 了?对于null key,其实就是放在table[0]处了,对于map来说的话,链表链接起来的本质上就是这个Entry对象,上面说过,它记录了一些关键信息。此处,就是遍历table[0]处的链表,找出哪一个位置上的key是null,然后直接替换,并返回旧值。put方法是有返回值的。第一看的时候发现了它竟然有返回值,平时用的时候都是直接put,put的在塞值,,,


当然,如果for循环执行不了的时候,其实就是第一次塞null 进去的时候,会执行 addEntry方法。if条件中就是 扩容的问题,扩容问题准备在下一篇继续记录,当前一篇不做解释。看createEntry方法,传入的参数值为 hash值 0,key为null,value就是value,bucketindex,桶中的位置,形象点解释,桶的概念就是那个数组。也是传入0.

首先将table[0]赋值给一个Entry,这边的作用就是将原有的table[0]处的值接到下面table[bucketindex] 的后面,这也就是链表的产生,以及它的值是如何连接起来的关键,其实也能发现,最新的值都是放在最开始的地方,也就是一个简单的单链表的头链接。最后size++,没什么好说的。


以上是null的处理,对于null的话其实本质上就是在table[0]的那一个链表中去找寻key为null的值,并替换新值。并不会到table的其他位置上去。

下面关注非null的时候,这也是绝大多数情况

首先得到一个hash值,hash方法内部实现很复杂,现阶段我也理解不了到底是怎么回事,也不知道自己什么时候能理解到。


indexFor 的作用是拿hash值和length 做按位与操作。 其实这里面有很多东西可以思考,笔者一开始的时候在想为什么需要拿hash值做indexFor,带着这个疑问我去百度了一下,本人习惯了百度(莫要鄙视啊,哈哈)。

首先我们来看 length -1 是多少。 一般来说 数组的大小为 16 那么此处就是 15,换算成二进制就是1111。在按位与操作中,1和0是0,1和1才是1。而且我们也应该注意到hashmap中有个2的幂等性的概念,它 的初始化,它的扩容,都是基于这个幂等性来的,所以就会出现一个有意思的情况。
假设hashmap本身并没有拿hash值去做indexFor,那么对于有一种数据就很特殊,就是2^n-1,也就是key值是31,63,95这个数据,换算成二进制来看,低位处都是1

31 --  11111      63 -- 111111  95 -- 1011111  ,末尾都是1,在和 1111 做按位与 操作时,结果 01111,01111,01111,那么结果都是1111,位置还是15,这就是问题所在了,会导致大量的数据存储在15的位置上。这种实现明显不好,那么就增加了hash函数在indexFor的前面。对key值先做一次hash,再做indexFor时就明显减少了hash冲突的可能性。

因此,如果两个对象的hashcode相同了,那么他们肯定就存放在一个链表上了,这边就是hash冲突的体现。

接下来再看for循环,如果当前位置上 table[i]已经存在hashcode是i的key ,value键值对,那么此时就会进入覆盖逻辑,会判断此时需要put的key,value键值对是否已经存在。

   if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 

判断条件很关键,先判断 hash值是否相等,再判断key值是否相等,相等则覆盖,不相等则进入下面的新增流程。新增流程调用的方法和null时的新增是一样的。



接下来就是get方法。

key为null时,进入 getFroNullKey ,其实就是在table[0]中找key为null的值,不去看了。

看getEntry源码


put的时候是用hash值去做插入,那么get的时候也就用hash去找放在哪个位置上了。

get的时候其实也做 了比较,hash值和key做equals,保证当前key是同一个对象。

这边其实就可以去理解java中的一个概念,如果两个对象相等,那么它们的hashcode一定相等。如果两个hashcode值相等,那么hashcode对应的对象不一定相等。这两句话从hashmap的get和put中的判断条件就可以非常清晰的看到。针对String,Long等java这种常见的作为key的对象来说,是满足的,因为它们都实现了个字的equals和hashcode方法,但是对于自定义对象作为key的话,如果你不重写equals和hashcode方法,那么就会出错。

0 0