1.数据结构

1.7

数组+链表,键值对是以Entry内部类的数组存放的。键计算得到哈希值是该数组的下标。又称桶数组当存在哈希冲突时,会通过Entry类内部的成员变量 Entry<k,v> next; 形成一个链表,哈希值相同的元素会以头插法添加到链表中,即拉链法。

1.7有两个主要弊端

  • 头插法在多并发情况下,扩容使会导致两个线程中出现元素的互相指向而形成循环链表,在执行 get() 时会触发死循环而消耗CPU资源
  • 链表的搜索时间复杂度时O(n),不太好。

1.8

  • 数组+链表+红黑树,具体是当链表节点数的大于8(树化阈值),且数组的长度大于最小树化容量64时,链表就会树化成红黑树,将查询的时间复杂度维持在O(logN)级别;
  • 采用尾插法,避免了并发扩容后面get产生的死循环;
  • 使用继承了Entry的Node来作为键值对的存储内部类

2.HashMap(1.8)详解

2.1 继承/实现

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {}

2.2 静态变量

/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
  • 默认桶数组初始容量:16(1 << 4,必须是 \(2^{n}\) ) ,最大 \(2^{30}\)
static final float DEFAULT_LOAD_FACTOR = 0.75f; //扩容加载因子
static final int TREEIFY_THRESHOLD = 8; //树化阈值
static final int UNTREEIFY_THRESHOLD = 6; //链表化阈值
static final int MIN_TREEIFY_CAPACITY = 64; //树化容量阈值

2.2 数据存储结构

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
transient Node<K,V>[] table;

内部静态类 Node 继承了 Entry,保存了键值对,hash值,下一个节点的指针。然后组成了 table数组。

2.3 哈希值计算

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 位与运算
i = (n-1) & hash // 更快的取余

HashMap 可以存储Null,强制将其放在索引为0的桶内。当key不是null时,首先正常调用 hashCode 计算得到int类型(4个字节,32位),然后将 位右移运算 >> 高位32移到低位16,再和自己做 ^ 位异或运算,得到 hash码,再和 (n-1) 做 位与 运算得到最终的 数组下标位置。

& 与运算:两个位都为1时,结果才为1;

^ 或运算:两个位相同为0,相异为1;

上述处理被称为 扰动函数,目的是使得计算的哈希值更加均匀,增加随机性,降低哈希冲突的概率。具体解释:如果直接用32位的 hashcode来与 15=16-1 做位于运算,那么Hash值在高位就全部被舍弃了,全部为0,哈希冲突就会很严重。但是如上述 扰动函数处理后,通过 位右移,将32位高位半区和低位半区做异或,混合后低位掺杂了高位的差异特征,相当于保留了高位的差异信息,由此增加了低位的随机性。使得后面计算得到 i下标更加均匀。用另一句话来总结就是

因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址时忽略容量以上高位,扰动处理就可以有效避免类似情况下的哈希碰撞

这里还隐藏一个问题,那就是为什么n必须是 2的幂,因为 \(2^{n}-1\) 用二进制表示位数从第一个非空的位数往后都是1,没有0位,做与运算每一位就有两种可能,如果是其他的数据,那么就会有很多0位,与运算后只有一种可能,大大限制了索引i的范围。因此目的还是让桶数组中的桶分布的更加均匀一些。

2.4 初始化-构造函数

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);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

如果没有指定初始容量和加载因子,默认是加载因子 0.75,容量=16,此时并没有对 存储容器table的初始化,只有第一次 put 值的时候才会,因此是 Lazy_load,这里对table的初始化使用的是 resize() 方法初始化长16的数组。如果指定了 容量,那么最终实际的容量会调用 tableSizefor() 将容量设置位大于设置值最小的 \(2^{n}\)。

static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

2.5 Resize 扩容

Resize兼初始化和扩容两个功能(本质上是一个,初始化也是扩容,默认的话是从0扩容到16)。主要参数是加载因子,如果当前桶数组元素已经超过 capacity * loadFactor ,就会扩容两倍

扩容之后,将原数组的元素计算其新的 索引并放入,新的索引可能是原来的索引 oldIndex ,也可能是 oldIndex+ oldCapacity(原数组容量),这里可以参考 赵小发的解释

进入到第 39 行代码,也是这篇文章最重要的链表的 rehash 方法。在分析之前,我们来思考一下,为什么原先在 index 为 2 位置上的元素要么停留在原位,要么跑到 index 为 18 的位置上呢?我们来举个例子:

假设张三的 hash 的后 5 位是 00010(在这种情况下,只有后 5 位比较重要,如果不知道为什么的同学先看看我之前的 hash 优化算法的那篇文章)。

0 0010(张三的hash)

0 1111(n - 1 = 16 -1 = 15)

0 0010

这是在扩容前(初始容量16),张三算出来的 index 是 2 。如果此时扩容,新容量为 32 ,那么 rehash 的结果为

0 0010(张三的hash)

1 1111(n - 1 = 32 -1 = 31)

0 0010

rehash 的结果,index 还是 2,位置不变。

那如果张三的 hash 的后 5 位是 10010 呢?

1 0010(张三的hash)

0 1111(n - 1 = 16 -1 = 15)

0 0010

即 10010 & 15 = 2 ,那此时扩容 rehash 后呢?

1 0010 (张三的hash)

1 1111(n - 1 = 16 -1 = 15)

1 0010

1 0010 的十进制是 18 。我们思考一个问题,18 有什么特殊的含义呢?

18 = 2 + 16 = 2 + oldCap 。我们找到了一个规律,在 rehash 之后,要么停留在原位置,要么移到原 index + 原数组长度的位置上。其实很容易理解,初始化和 (16 - 1)做与运算时,只有低 4 位的 hash 是有意义的,但是扩容的时候,和 (32 -1 )做与运算时,低 5 位也参与了运算,所以低 5 位的值决定了 rehash 后新的 index 的值。如果低 5 位为 0 ,index 值不变,如果低 5 位为1,则 index 改变,并且在原先基础上加了 2 的 4 次方,即 16 。

依次类推,如果从 32 扩容到 64 ,则低 6 位决定了 index 是否改变,如果改变,在原先基础上加 2 的 5 次方,即 32 。

综上所述:在 resize 方法中,数组 rehash 时,要么停留在原位,要么移到 oldIndex + oldCap 的位置上。

2.7 Put方法

  1. 检查是否初始化,若未初始化,则先调用 resize() 初始化;
  2. 对key求 Hashcode,再计算得到索引;
  3. 如果无哈希冲突,则加入桶中,如果冲突,则尾插入链表中;
  4. 如果链表长度超过树化阈值8,且容量超过64,就将链表转换为红黑树,如果链表长度低于6,则将红黑树转回链表,;
  5. 如果节点的key已经存在,则更新value;
  6. 如果容量没有超过64,容量超过总容量的0.75,就要扩容。

2.8 Get方法

参考jackMan-HashMap之get方法详解

public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
} /**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

根据key使用前面 的扰动函数 hash 计算哈希值,然后调用 getNode获取node,如果node为null,返回null,否则返回node.val。

在getNode()中,(n-1)*hash获取索引,即桶位置,然后判断首节点是否为空以及key是否等。如果首节点不是,则使用 遍历链表的用 equals 判断 key是否相等。如果是红黑树,则调用 getTreeNode去搜索。

如果使用对象作为key,最好覆写keyhashcodeequals方法

补充:

虽然HashMap 1.8使用尾插法避免了死循环,但是HashMap线程不安全,并发下应该使用ConcurrentHashMap 或者 HashTable。

如有错误,欢迎讨论指正。

参考

  1. 赵小发的解释
  2. jackMan-HashMap之get方法详解
  3. 阿进写字台-HashMap是如何工作的
  4. Sam哥哥-HashMap的put和get方法原理

HashMap底层实现-基础的更多相关文章

  1. HashMap底层结构、原理、扩容机制

    https://www.jianshu.com/p/c1b616ff1130 http://youzhixueyuan.com/the-underlying-structure-and-princip ...

  2. 最简单的HashMap底层原理介绍

    HashMap 底层原理  1.HashMap底层概述 2.JDK1.7实现方式 3.JDK1.8实现方式 4.关键名词 5.相关问题 1.HashMap底层概述 在JDK1.7中HashMap采用的 ...

  3. HashMap底层原理

    原文出自:http://zhangshixi.iteye.com/blog/672697 1.    HashMap概述: HashMap是基于哈希表的Map接口的非同步实现.此实现提供所有可选的映射 ...

  4. HashMap底层实现原理/HashMap与HashTable区别/HashMap与HashSet区别(转)

    HashMap底层实现原理/HashMap与HashTable区别/HashMap与HashSet区别 文章来源:http://www.cnblogs.com/beatIteWeNerverGiveU ...

  5. hashMap 底层原理+LinkedHashMap 底层原理+常见面试题

    1.源码 java1.7    hashMap 底层实现是数组+链表 java1.8 对上面进行优化  数组+链表+红黑树 2.hashmap  是怎么保存数据的. 在hashmap 中有这样一个结构 ...

  6. HashMap底层实现原理/HashMap与HashTable区别/HashMap与HashSet区别

    ①HashMap的工作原理 HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象.当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算h ...

  7. hashMap底层put和get方法逻辑

    1.hashmap put方法的实现: public V put(K key, V value) { if (key == null) return putForNullKey(value); int ...

  8. ArrayList、LinkedList、HashMap底层实现

    ArrayList 底层的实现就是一个数组(固定大小),当数组长度不够用的时候就会重新开辟一个新的数组,然后将原来的数据拷贝到新的数组内. LinkedList 底层是一个链表,是由java实现的一个 ...

  9. 操作系统内核Hack:(二)底层编程基础

    操作系统内核Hack:(二)底层编程基础 在<操作系统内核Hack:(一)实验环境搭建>中,我们看到了一个迷你操作系统引导程序.尽管只有不到二十行,然而要完全看懂还是需要不少底层软硬件知识 ...

随机推荐

  1. nginx反向代理tcp协议的80端口

    需求:内网有一台mqtt协议服务器,需要将外网的mqtt请求通过一台服务器代理到内网的mqtt服务器上.而这台代理服务器不会开放出了80之外的端口,所以只能使用80端口来转发mqtt请求. 步骤:1. ...

  2. springboot集成swagger添加消息头(header请求头信息)

    springboot集成swagger上篇文章介绍: https://blog.csdn.net/qiaorui_/article/details/80435488 添加头信息: package co ...

  3. Spirng boot maven多模块

    https://blog.csdn.net/Ser_Bad/article/details/78433340

  4. ESP32构建系统 (传统 GNU Make)

    概述: 一个 ESP-IDF 项目可以看作是多个不同组件的集合,ESP-IDF 可以显式地指定和配置每个组件.在构建项目的时候,构建系统会前往 ESP-IDF 目录.项目目录和用户自定义目录(可选)中 ...

  5. 认识Java中String与StringBuffer以及StringBuilder

    String(引用数据类型) String对象一经创建就不会发生变化(在常量池里),即便是赋予新的值,也不是在原来的基础上改变,而是创建一个新的字符串对象,将引用指向这个对象,会造成空间的浪费: St ...

  6. DEV C++5.11编译没有结果提示

    点击"视图"菜单--选择"浮动报告 窗口"

  7. 【redis前传】redis整数集为什么不能降级

    前言 整数集合相信有的同学没有听说过,因为redis对外提供的只有封装的五大对象!而我们本系列主旨是学习redis内部结构.内部结构是redis五大结构重要支撑! 前面我们分别从redis内部结构分析 ...

  8. 从代码生成说起,带你深入理解 mybatis generator 源码

    枯燥的任务 这一切都要从多年前说起. 那时候刚入职一家新公司,项目经理给我分配了一个比较简单的工作,为所有的数据库字段整理一张元数据表. 因为很多接手的项目文档都不全,所以需要统一整理一份基本的字典表 ...

  9. 技术期刊 · 天光台高未百尺 | Uber 工程师的 JS 算法课;大数据时代的个人隐私;设计师的 Github;告别 PPT 工程师;从零开始实现的像素画

    蒲公英 · JELLY技术期刊 Vol.42 这是一个最好的时代,多样化的平台给了所有人成长发展的机会,各种需求和解决需求的人让人大开眼界:但这也并不是完美的时代,"前端还需要懂什么算法?& ...

  10. 搭建kerberos和NTP服务器以及安全的NFS服务

    说明:这里是Linux服务综合搭建文章的一部分,本文可以作为单独搭建Kerberos和NTP时钟服务的参考. 注意:这里所有的标题都是根据主要的文章(Linux基础服务搭建综合)的顺序来做的. 如果需 ...