HashMap底层原理解析

HashMap底层原理解析

1.基本、常用性质
HashMap储存的是键值对
HashMap 允许 null 键和 null 值,在计算哈希值时,null 键哈希值为 0,而HashTable则不能
HashMap 是非线程安全类,在多线程环境下可能会存在问题,HashTable、ConcurrentHashMap线程安全

2.前提
数据结构中有数组和链表来实现对数据的存储,但这两者各有利弊。
数组:
数组存储区间是连续的,占用内存严重,故空间复杂度大。但数组的二分查找时间复杂度小,为O(logn);
二分法的关键思想是 假设该数组的长度是N那么二分后是N/2,再二分后是N/4……直到二分到1结束(最坏情况)
于是我们可以设次数为x,N*(1/2)^x=1;则x=logn,底数是2,
数组特点:寻址容易,插入和删除困难;扩展性差

       线性查找  O(N)
       二分查找  O(logN)
       无序数组的插入 O(1)
       有序数组的插入O(N)
       无序数组的删除O(N)
       有序数组的删除O(N)

链表:
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。
链表特点:寻址困难,插入和删除容易。动态扩展存储个数大小
链表(是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。
   链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,
同时链表由于增加了结点的指针域,空间开销比较大。
哈希表:
那么我们综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构,这就是哈希表。
哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内存空间,使用也十分方便。
哈希表有多种不同的实现方法,HashMap则使用的是拉链法,也叫作【链地址法】;

3.原理
HashMap 底层是基于散列算法实现,散列算法分为散列再探测和拉链式(链地址法)。
一个长度为16的数组中,每个元素存储的是一个链表的头结点
JDK 1.7之前,数组+链表;
JDK 1.8之后,数组+链表+红黑树(查找时间复杂度为 O(logn))(链表长度大于8时转化为红黑树)

具体画图展示:
容量(capacity):Entry[] table 的默认长度为16
负载因子(load factor):其默认值为 0.75
size :记录的是map中包含的Entry的数量
桶(bucket) : Entry[] table中的某一个元素及其对应的Entry<Key,Value>又被称为桶; capacity 其实就是桶的长度
阈值:threshold = loadFactor * capacity,需要resize的阈值;threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

注:这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,
而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。

举例说明:1-100如何存进去;如何取;
在进行增删查等操作时,首先要定位到元素的所在桶的位置,之后再从链表中定位该元素。
比如我们要查询上图结构中是否包含元素35,步骤如下:
定位元素35所处桶的位置,index = 35 % 16 = 3 ,在3号桶所指向的链表中继续查找,发现35在链表中。

  1. hash冲突
    冲突:哈希表基于数组,类似于key-value的存储形式,关键字值通过哈希函数映射为数组的下标,如果一个关键字哈希化到已占用的数组单元,这种情况称为冲突。
    解决冲突方法:
    a.开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列):把冲突的数据项放在数组的其它位置;
    b.再哈希法
    c.链地址法 (Java中HashMap使用的算法):每个单元都包含一个链表,把所有映射到同一数组下标的数据项都插入到这个链表中。
    d.建立一个公共溢出区

举例:
12%16=12,28%16=12,108%16=12,140%16=12
所以12、28、108以及140都存储在数组下标为12的位置

第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。
一会后又进来一个键值对B,通过计算其index也等于0,HashMap会这样做: B.next = A,Entry[0] = B;
如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;
这样我们发现index=0的地方其实是利用单链表的头插法存取了A、B、C三个键值对,他们通过next这个属性链接在一起。
所以会发生覆盖的情况,数组中存储的总是最后插入的元素。

5.扩容:为什么扩容?什么时候扩容?扩容多少?怎么扩容?
(1)为什么
由于哈希是一种压缩映射,每一个Entry节点无法对应到一个只属于自己的桶,必然会存在多个Entry共用一个桶,拉成一条链表的情况,这种情况叫做哈希冲突。
当哈希冲突产生严重的情况,某一个桶后面挂着的链表就会特别长,顺序查找效率低。

哈希冲突无法完全避免,因此为了提高HashMap的性能,HashMap不得尽量缓解哈希冲突以缩短每个桶的外挂链表长度。

频繁产生哈希冲突最重要的原因就像是要存储的Entry太多,而桶不够。
因此,当HashMap中的存储的Entry较多的时候,要考虑增加桶的数量,这样对于后续要存储的Entry来讲,就会大大缓解哈希冲突。

(2)什么时候扩容: put()
当size大于等于某一个阈值thresholdde时候且该桶并不是一个空桶
当map中包含的Entry的数量大于等于threshold = loadFactor * capacity的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍。
理解 :因为size 已经大于等于阈值了,说明Entry数量较多,哈希冲突严重,那么若该Entry对应的桶不是一个空桶,
这个Entry的加入必然会把原来的链表拉得更长,因此需要扩容;若对应的桶是一个空桶,那么此时没有必要扩容。
源码中:put(K, V)操作

(3)扩容多少
resize()容量需要是2倍这样扩张。HashMap的容量必须是2的幂,这么设计是为了性能。
在HashMap通过键的哈希值进行定位桶位置的时候,调用了一个indexFor(hash, table.length)方法
static int indexFor(int h, int length) {
return h & (length-1);
}
这里将哈希值h与桶数组的length-1(实际上也是map的容量-1)进行了一个与操作得出了对应的桶的位置,h & (length-1)。

不采用h % length,因为Java的%、/操作比&慢10倍左右,因此采用&运算会提高性能。

通过限制length是一个2的幂数,使得h & (length-1)和h % length结果是一致的。

举例:
hashcode是311,对应的二进制是(1 0011 0111)

length为16,对应的二进制位(1 0000)

%操作:311 = 16*19 + 7;所以结果为7,二进制位(0111);

&操作:(1 0011 0111) & (0111) = 0111 = 7, 二进制位(0111)

(4)怎么扩容?
resize() : 重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作
rehash() : 经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置
transfer() : 使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
对于resize的过程,相对来讲是比较简单清晰易于理解的。
旧桶数组中的某个桶的外挂单链表是通过头插法插入新桶数组中的,并且原链表中的Entry结点并不一定仍然在新桶数组的同一链表。

(5)结合图8.9分析1.8以后的优势
(6)举例说明扩容的给的大小
比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,但即使是1000,hashmap也自动会将其设置为1024。
但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,
既考虑了&的问题,也避免了resize的问题。

  1. 再散列rehash过程
    将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。
    这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置

当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,
这时,需要创建一张新表,将原表的映射到新表中。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,
是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图

  1. 负载因子:
    当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键的键之间产生的碰撞会下降,链表长度变短。
    此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。
    相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,
    效率也随之降低,这种情况是拿时间换空间。

  2. 看源码
    get(): first = tab[(n - 1) & hash]
    这里通过(n - 1)& hash即可算出桶的在桶数组中的位置,(n - 1) & hash 等价于对 length 取余。举个例子说明一下吧,假设 hash = 185,n = 16。

计算键的 hash 值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高4位数据与低4位数据进行异或运算,即 hash ^ (hash >>> 4)。通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中。此时的计算过程如下:
重新计算 hash 的另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。
通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。

HashMap的遍历:
HashMap<Integer, String> map = new HashMap(16);
map.put(7, "");
map.put(11, "");
map.put(43, "");
map.put(59, "");
map.put(19, "");
map.put(3, "");
map.put(35, "");

    for (Integer key : map.keySet()) {
        int h;
        int s=(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        Log.i("hahhaha", "setupView: "+key+"->"+key.hashCode()+"->"+s);
    }

插入操作:
插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:

1.当桶数组 table 为空时,通过扩容的方式初始化 table
2.查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
3.如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
4.判断键值对数量是否大于阈值,大于的话则进行扩容操作
以上就是 HashMap 插入的逻辑,并不是很复杂,这里就不多说了。接下来来分析一下扩容机制。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容