从JDK源码分析HashMap

笔者只是一个大三期末慌着找实习、工作一枚渣渣,第一次正式开始总结JAVA基础知识(面试中很需要的啊),如果讲得有错的地方还请读者们多多包涵并狠狠地在评论区怼出来哈~【由于很多内容借鉴于互联网的精彩博文,故而欢迎大家转载】

前言:

对于如何快速学习一门语言,除了一遇到问题就GOOGLE或者问度娘之外,还要注意培养自己动手,独立解决问题的能力。(笔者这个ZZ话在嘴上讲,ACDEF数心中留)那么官方API文档以及JDK本身自带的源码包(就是安装的JDK目录下的src.zip)都是非常好的自学工具。

下面先说说如何使用IDE查看JDK源码,以笔者现在使用的IDE(IntelliJ IDEA)为例,可以新建一个project(专门存放JDK的src.zip源码包工程,方便以后学习查看),在图中<10>(笔者现在是查看JDK 10的包,虽然项目中还是用JDK 1.8【尴尬】)目录下,最后结果是中的src.zip,之后右键选择设置为library root,就可以查看内容了

正文:

在Java.base中找HashMap的class,如图

点开后,首先查看到上方的绿色注释【概述了该类或者接口的主要情况】:

只截图了一部分,

稍作总结如下:

1.允许key value为null

2.大致跟HashTable相同,除了不保证同步和允许null;想要同步建议使用

Map m = Collections.synchronizedMap(new HashMap(...));

个人认为concurrentHashMap也可以啊,不过前者可以接受任何种类Map实例,但后者只能是HashMap实例,具体细节(参考大佬)如下:

Collections.synchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步,而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。这样,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,所以,即使在遍历map时,其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。不论Collections.synchronizedMap()还是ConcurrentHashMap对map同步的原子操作都是作用的map的方法上,map在读取与清空之间,线程间是不同步的

3.初始容量过高或装载因子过低会造成遍历效率低下【重要】

4.若 初始容量*装载因子<哈希表的容量 ,则哈希表进行散列-->buckets*2

故考虑好初始容量和装载因子设定,尽量使结果接近并稍大于哈希表,即避免散列,提高效率

5.装载因子=0.75(默认)

6.迭代器:两种迭代方式Map map = new HashMap();  [1] Set set = map.keySet();

String key = (String)iter.next();  //键            String value = (String)map.get(key);//值

             [2]Set set = map.entrySet();  Map.Entry entry =     (Map.Entry)iter.next();//【键+值】

引用大佬知识

1.HashMap 最底层依然是数组来实现的,我们想HashMap中所放置的对象实际上是存储在该数组当中的
2.当向HashMap中put一对键值时,它会根据key的hashcode值计算出一个位置,该位置就是此对象准备往数组中存放的位置
3.如果该位置没有对象存在,就将对象直接放进数组当中;如果该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(Entry  类有一个Entry类型的next成员变量,指向了该对象的下一个对象),如果此链上有对象的话,再去使用equal方法进行比较,如果对此链上的某个对象的equals方法比较为false,则将该对象放到数组当中,该位置以前存在的那个对象链接到此对象的后面

之后会在JDK源码中找到实现,以作解释

现先继续从头分析源码:

初始容量=16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

最大容量=2*1<<30(左移,相当于2*1的30次方)

static final int MAXIMUM_CAPACITY = 1 << 30;

装载因子=0.75(默认)

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,实际是一个链表

还有put()方法:

其中putVal()说明存进去的数据就是Node的链表解构,放在了一个数组中,故而证实HashMap底层就是链表+数组-->散列表

看下HashMap的构造方法(有四个):

每个都看一下

1.判断初始大小是否合理、赋值初始化初始大小、判断装载因子、赋值初始化装载因子

其中在1.中有

this.threshold = tableSizeFor(initialCapacity);

tableSizeFor()是为初始化阈值,threshold 决定了是否要将散列表再散列。

选出最小的2的N次方数值做阈值(跟红黑树有关)

下面是查看怎么计算Hash的:

>>>无符号右移,忽略符号位,空位都以0补齐

之前由于设定初始大小为16,那么在给put进的数找位置就是根据hash值来找的,故而必须保证hash不冲突,key的hashCode也要与h的右移16位进行异或运算,降低hash碰撞冲突的概率

另外,上图中分析:1.若要插入的key和hash都相等,记录->e到桶中

2.如果是红黑树的话就调用红黑树的插入法

3.那么是链表结构了,在循环查找之前先判断链表容量大小是否>=TREEIFY_THRESHOLD,是则变为红黑树,然后循环查找Key映射的节点,找到说明已有存在,break;否则在尾部插入节点。

4.若此Key已经存在Value映射了,那就更新值

下面看resize():(初始化tab的时候就有用到,当散列表中元素总量 > 初始大小*装载因子时,也必须进行resize())相较于JDK1.7,在1.8中resize()方法不再调用transfer()方法,而是直接将原来transfer()方法中的代码写在自己方法体内。

【1】若原来Tab的容量比设定的最大容量都大时,更新threshold为Integer.MAX_VALUE,不用进行散列

【2】若 最大容量比原来Tab容量的两倍还大 而且 原来Tab满足大于默认初始化容量大小 那么新的阈值扩大为原来的2倍(注意,源码扩大两倍或进行2的指数级操作时是使用移位操作符而不是乘号,一位这样计算效率高)

【3】若旧容量不是>0, 且原来的阈值就够大,那直接允许新容量大小跟阈值一样大

【4】Tab初始化的阶段执行过程

【5】其中用到TreeNode的split()方法,看一哈(目的是按照之前顺序重新连入lo(w)和hi(gh)列表中)

参考博文

若就散列表存在,根据容量循环整个列表,对于其中非空的数据,复制放在新的table中,判断:如果只有一个数据就直接赋值,并确定存放的位置,当是一个红黑树节点时,就按照上面的split()按照之前顺序重新连入lo(w)和hi(gh)列表中。(原理与下面进行的链表移植原理相同,操作有些许差异)接下来进行链表复制,采用  原始位置加原数组长度的方法  计算得到位置,而非重新计算:【重要!!】

(e.hash & oldCap)

因为【table是2倍扩容,即左移一位】这个与运算,来判断元素的在数组中的位置是否需要移动,若 =0 则说明其在数组中的位置未发生改变,而新位置 = 原下标位置+原数组长度,即  新的index  =  原来index  +  oldCap(原来的数组容量capacity);若 = 1 则说明发生了改变;

(e.hash & (oldCap-1)) 用来得到其下标位置;【两者截然不同!】

接上面的分析:

如果原元素位置没有发生变化,且low部分没有元素,将e确定为low部分的Head元素,否则,将e加入到low部分的Tail;对于high部分同理。最后完善-->将链表的尾节点指向null

小结一下:参考博文

【1】扩容后,新数组中的链表顺序依然与旧数组中的链表顺序保持一致!

HashMap底层数组的一些优化: 
【2】数组长度总是2的倍数,扩容则是直接在原有数组长度基础上乘以2。

有两个优点: 
1. 通过与元素的hash值进行与操作,能够快速定位到数组下标 
相对于取模运算,直接进行与操作能提高计算效率。在CPU中,所有的加减乘除都是通过加法实现的,而与操作时CPU直接支持的。 
2. 扩容时简化计算数组下标的计算量 
因为数组每次扩容都是原来的两倍,所以每一个元素在新数组中的位置要么是原来的index,要么index = index + oldCap

接着get():

getNode(hash,key)遍历寻找并返回节点。

对于remove()方法:

也调用removeNode(xxx),同样也是遍历查找,如果找到(key和value都对应),根据其所在位置【链表、桶的首位、红黑树】进行删除。

最后关于hashmap和其他相关的经常在面试中见到的问题进行汇总:

一、HashMap和Hashtable比较:

贴出原文

                                                        HashMap                                        Hashtable

对外接口:                             继承AbstractMap                               继承Dictionary

null的键值                                        支持                                 不支持,hashCode(0)=0

数据结构                                 链表+数组、红黑树                              链表+数组

默认初始容量                                   16【即桶的数量】                                11

扩容方式                         oldCap<<1【<<一位表示*2】                       oldCap<<1+1

底层数组容量                              2<<n(次数)                                       不要求

确认key数组中的索引                       (n-1)&hash                        (hash & 0x 7FFF-FFFF)%(tab.length)

线程安全                         否【resize中链表出现环路->get()】                    是【使用synchronized】

遍历方式                                        iterator                                     iterator + enumerarion

遍历数组顺序                           index由小到大                                       index由大到小

开发者使用情况                               非常频繁                                                     不再使用

二、其中的hashCode()和equals()方法

       hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

三、如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

四、HashMap工作原理

HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。

五、当两个不同的键对象的hashcode相同时会发生什么?

它们会储存在同一个bucket位置【底层数组的位置】的链表中。键对象的keys.equals()方法用来找到键值对。

六、对HashMap中ConcurrentModificationException认识

JDK中源码注释:迭代器返回的是这个类的“收集【视图】方法”是会快速失效的,如果这个键值映射在迭代器生成后的任何时间被更改,迭代器就会抛出该异常,除非该修改是通过该迭代器本身的remove()方法。因此在未来的未知时间点及非确定性的操作,面对多并发修改时,该迭代器就会干净利落地失效,而不是随意冒险。【手动翻译(太渣了,但意思明白了就行)】

引用博文

原来得到的keySet和迭代器都是Map中元素的一个“视图”,而不是“副本” 。问题也就出现在这里,当一个线程正在迭代Map中的元素时,另一个线程可能正在修改其中的元素。此时,在迭代元素时就可能会抛出 ConcurrentModificationException异常。

 ConcurrentHashMap提供了和Hashtable以及SynchronizedMap中所不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。     

    ConcurrentHashMap默认将hash表分为16个桶,诸如get,put,remove等常用操作,只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。 
    在迭代方面,ConcurrentHashMap使用了一种不同的迭代方式,即当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException, 取而代之的是  在改变时new新的数据从而不影响原有的数据。 iterator完成后再将头指针替换为新的数据。这样iterator线程可以使用原来老的数据。而写线程也可以并发的完成改变

七、【今天左8个小时,还没吃饭,在此先挖个坑,有空就来补充问题】

猜你喜欢

转载自my.oschina.net/u/3805464/blog/1825492