从JDK源码分析HashMap

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

前言:面试

对于如何快速学习一门语言,除了一遇到问题就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,如图数据结构

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

只截图了一部分,app

稍做总结以下:工具

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和其余相关的常常在面试中见到的问题进行汇总:

1、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由大到小

开发者使用状况                               很是频繁                                                     再也不使用

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

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

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

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

4、HashMap工做原理

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

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

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

6、对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线程可使用原来老的数据。而写线程也能够并发的完成改变

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

相关文章
相关标签/搜索