HashMap就是将key做hash算法,然后将hash值映射到内存地址,直接取得key所对应的数据。

关于hash算法的原理知识在之前的博客中有讲到:哈希表之一初步原理了解

在Java中的HashMap底层用的也是数组。这里的说法有问题,以前的API中HashMap底层是数组,但是JDK8之后如果元素超过了8个就开始使用红黑树了。

Java8对HashMap进行了一些修改,最大的不同就是利用了红黑树,所以其由"数组+链表+红黑树"组成。

根据Java7 HashMap的介绍,我们知道,查找的时候,根据hash值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。

为了降低这部分的开销,在Java8中,当链表中的元素超过了8个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

这里只针对JDK8中的HashMap进行分析。

hash(Object key)

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

这里的key就是HashMap中存储的key。这个方法的描述如下:

/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/

key.hashCode()就是平常说的那个hashCode方法,如果重写了hashCode方法,那么就用重写了的,如果没有的话就用父类的hashCode方法。hashCode的实现要求高效和分散。

Java 中一般规定 Integer 类型占 32 位,Long 类型占 64位。

现在复习一下几个基本的位运算:

>>> :不带符号的右移,无论正数还是负数,高位都用 0 补齐。
>> :带符号的右移,正数高位用 0 补齐,负数高位用 1 补齐。
| :对应进行或运算,0 | 1 = 1,0 | 0 = 0
& :对应位进行与运算 ,0 & 0 = 0,1 & 0 = 0,1 & 1 = 1
^ :对应位进行异或运算(XOR),0 ^ 1 = 1,1 ^ 1 = 0,0 ^ 0 = 0

如果 key 为 null,那么 hashCode 就是 0 了。

如果 key 不为 null,则先计算 key 的 hashCode 为 h,让后将 h 异或 (h 不带符号的右移 16 的结果) 。

public static void main(String[] args) {

   String hello = "hello";

   // 求 hello 的 hashCode
int helloHashCode = hello.hashCode();
// hello hashCode 值得二进制字符串
String helloHashCodeBinaryString = Integer.toBinaryString(helloHashCode); // 将 helloHashCode 不带符号右移 16 位
int rightShift16OfHelloHashCode = helloHashCode >>> 16; // 获取右移之后的二进制字符串
String rightShift16String = Integer.toBinaryString(rightShift16OfHelloHashCode); // 获取异或的值
int xorValue = helloHashCode ^ rightShift16OfHelloHashCode;
// 异或后的字符串
String xorBinaryString = Integer.toBinaryString(xorValue); System.out.println("helloHashCode = " + helloHashCode);
System.out.println("xorValue = " + xorValue);
System.out.println("helloHashCodeBinaryString = " + helloHashCodeBinaryString);
System.out.println("rightShift16String = " + rightShift16String);
System.out.println("xorBinaryString = " + xorBinaryString);
}

执行后的结果为:

helloHashCode             =
xorValue =
helloHashCodeBinaryString =
rightShift16String =
xorBinaryString =

分别补齐到 32 位,正数首位为 0,负数首位为 1。补齐之后的结果为:

helloHashCodeBinaryString = 00000
rightShift16String = 000000000000000000000
xorBinaryString = 00000

由此发现,算法的执行逻辑是没有问题的,你把 helloHashCodeBinaryString 和 rightShift16String 逐位异或一下就知道了。

再看看下 HashMap 计算 "hello" 的 hashCode 是否是 xorValue  的值呢。

从图中可以发现,HashMap 算出来的 hash 确实和我们手工计算的结果一样。(用的同一个算法,能不一样么)

计算流程大概是这样的,为什么要这么搞,写 JDK 的那帮人觉得你是个二货,你的对象 hashCode 可能分布太不均匀了,导致性能问题,别个觉得要帮你兜底一下。

put方法分析

直接来看一下HashMap的put(key,value)方法的实现:

 public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

给我的启发式,实现接口中的方法时,不要把实现都放在接口方法中,而是在接口方法中进行委托。在put方法中又调用了一个有5个参数的方法,顿时没有心情看了,因为不熟。

这里注意第4个参数用的是false,也就是如果插入相同的key,但是value不一样,则会替换掉原来的key对应的value值。

但是这个putVal方法才是精髓,还是一个final修饰的方法,它并不想被重写,说明是一个通用的方法。

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

参数分析:

 /**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/

在putVal方法中的参数和返回值:

  1. hash:key的hash值
  2. key:key
  3. value:value
  4. onlyIfAbsent:如果为真的话,不会改变已经存在的值
  5. evict:如果evict为假,那么table是creation mode
  6. 返回值:这个key之前对应的值,如果没有previous value则是null

基本操作就是:

  1. 判断当前的hash表是否为空(null或者size为0)
  2. 如果是的话要resize,不是则前往下一步
  3. 根据key的hash值计算出在数组中应该是那个坑的位置
  4. 如果坑上没有元素,则直接把当前的元素插入进来
  5. 如果在坑上已经有元素了,就要解决冲突
  6. modCount++,用于快速失效
  7. 判断对size++,并且判断是否超过了阈值,如果超过了则要resize
  8. 调用给LinkedHashMap准备的回调函数

接下来看一下冲突部分是如何处理的(上圈红部分是如何处理的)

其实也挺简单的,就是判断一下p的属性,然后做不同的处理。可以分一下四种情况:

分析到这里还是先看一下源码中的实现笔记(Implement Note)

实现笔记

以下内容来自JDK8中源码注释的翻译。

这个map实际上是作为装箱的(分时段)的哈希表,但是当箱变得的非常大的时候,它们就会变成TreeNodes,在结构上类似于java.util.TreeMap。大多数的方法都会使用正常的bins,但是在合适的时候会依赖于TreeNode的方法。当过于聚集的时候使用TreeNode有利于提高查找性能。但是在正常使用的情况下大多数的bin都不会过于聚集,检测是否是tree的方法会延迟在table methods中进行。Tree bins(也就是它的元素都是TreeNodes)都是主要通过hashCode进行排序,如果两个元素都实现了Comparable接口,那么就是用它们自己的compareTo方法来排序。添加树带来的复杂性因为提供了最坏情况下O(log n)的性能而被认为是值的的。当hash的分不行很差的时候或者key都共用一个hashCode的时候,性能的降级还是挺优雅的。因为TreeNode的大小要比一般的节点大2倍,当bins中有足够多的元素的时候才会考虑使用它们。在hashCode的分布足够好的时候,tree bins几乎很少用的上。在随机hashCode的理想情况下,bins中节点的频率符合泊松分布。在threshold为0.75的时候,平均是0.5个,虽然因为resize的粒度会导致方差很大。在不考虑方差的情况下,list的size k符合: (exp(-0.5) * pow(0.5, k) / factorial(k))

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
tree bin的根节点自然是它的第一个节点了。然而,有时候(在Iterator remove操作),根节点可能是其他的元素,但还是可以通过TreeNode.root()找到。所有的内部方法都需要hash code作为参数,允许不需要重复计算hashcode来调用。大多数的内部方法同样也会接受tab参数,也就是当前的table,随着resizing或者converting,table会变新或者变旧。当bin lists被树化,分裂或者树退化,我们会保持它们相对的访问和遍历顺序Node.next,以更好的保存局部性。在plain和tree模式之间的转换通过现有的LinkedHashMap来实现的,比较很复杂。

HashMap中的参数

// 默认的初始容量是16, 必须是2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大的容量是2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 构造方法中没有指定负载因子时默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 超过多少个节点就会转为tree
static final int TREEIFY_THRESHOLD = 8;
// 在resize的时候少于多少个就会退化为list
static final int UNTREEIFY_THRESHOLD = 6;
// table中最少的容量是的bins转为tree, 为了避免resizing和树化阈的冲突,最少是4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

resize的实现分析

初始化和加倍table的size。如果为null的时候,分配的容量为初始容量。如果不是的话,通常会以2的幂来扩张,每个bin中的元素要么在同一索引中,要么以2的幂之差移到新的table中。

 final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

看上去好像有点复杂,现在来逐行分析一下这个resize是怎么完成的。

代码的思路还是很清楚的:

  1. 计算出新的容量cap和与之thr
  2. 以新容量创建数组并把原来哈希表中的元素转移大新的数组中

在元素转移的时候又分为三种情况:

  • 数组坑中只有一个元素,元素的next为null,只需要重新计算新的坑位置
  • 坑下挂了一棵树,这里调用的是TreeNode.split方法
  • 坑下挂了一个链表

个人觉的这里还是要注意一下resize调用的时机,因为它是一个默认访问权限的方法,只有包访问权限,包外是不能调用的。源码中大部分的情况就是:

  • tab为null
  • size>threshold

这两种情况下会resize。

回头再来分析。。。

HashMap源码学习的更多相关文章

  1. hashMap源码学习记录

    hashMap作为java开发面试最常考的一个题目之一,有必要花时间去阅读源码,了解底层实现原理. 首先,让我们看看hashMap这个类有哪些属性 // hashMap初始数组容量 static fi ...

  2. 基于jdk1.8的HashMap源码学习笔记

    作为一种最为常用的容器,同时也是效率比较高的容器,HashMap当之无愧.所以自己这次jdk源码学习,就从HashMap开始吧,当然水平有限,有不正确的地方,欢迎指正,促进共同学习进步,就是喜欢程序员 ...

  3. 【jdk源码3】HashMap源码学习

    可以毫不夸张的说,HashMap是容器类中用的最频繁的一个,而Java也对它进行优化,在jdk1.7及以前,当将相同Hash值的对象以key的身份放到HashMap中,HashMap的性能将由O(1) ...

  4. Java集合专题总结(1):HashMap 和 HashTable 源码学习和面试总结

    2017年的秋招彻底结束了,感觉Java上面的最常见的集合相关的问题就是hash--系列和一些常用并发集合和队列,堆等结合算法一起考察,不完全统计,本人经历:先后百度.唯品会.58同城.新浪微博.趣分 ...

  5. 基于JDK1.8版本的hashmap源码笔记(二)

    这一篇是接着上一篇写的, 上一篇的地址是:基于JDK1.8版本的hashmap源码分析(一)     /**     * 返回boolean类型的值,当集合中包含key的键值,就返回true,否则就返 ...

  6. 由JDK源码学习HashMap

    HashMap基于hash表的Map接口实现,它实现了Map接口中的所有操作.HashMap允许存储null键和null值.这是它与Hashtable的区别之一(另外一个区别是Hashtable是线程 ...

  7. 集合框架源码学习之HashMap(JDK1.8)

    目录: 0-1. 简介 0-2. 内部结构分析 0-2-1. JDK18之前 0-2-2. JDK18之后 0-3. LinkedList源码分析 0-3-1. 构造方法 0-3-2. put方法 0 ...

  8. JDK源码学习笔记——HashMap

    Java集合的学习先理清数据结构: 一.属性 //哈希桶,存放链表. 长度是2的N次方,或者初始化时为0. transient Node<K,V>[] table; //最大容量 2的30 ...

  9. HashSet源码学习,基于HashMap实现

    HashSet源码学习 一).Set集合的主要使用类 1). HashSet 基于对HashMap的封装 2). LinkedHashSet 基于对LinkedHashSet的封装 3). TreeS ...

随机推荐

  1. Python 文件 isatty() 方法

    概述 Python 文件 isatty() 方法检测文件是否连接到一个终端设备,如果是返回 True,否则返回 False. 语法 isatty() 方法语法如下: fileObject.isatty ...

  2. Android studio的一些常用快捷键

    Alt+回车 导入包,自动修正 Ctrl+N 查找类 Ctrl+Shift+N 查找文件 Ctrl+Alt+L 格式化代码 Ctrl+Alt+O 优化导入的类和包 Alt+Insert 生成代码(如g ...

  3. win7重命名文件时 提示 “指定的设备名无效”的解决办法

    同事从mac上传一个文件夹到win7上,但是少了一张图片con.jpg.查了半天发现将备份文件改名为con.jpg时提示 “指定的设备名无效”. 谷歌了下,发现了问题所在.坑爹的win7. 从不同的系 ...

  4. MySQL 示例数据库 employees 详解

    [引子] IT这一行在我看来是比较要求动手能力的,但是人非生而知之:人们身上的技能除了一些本能之外,大多都是通过学习而得到的. 前一段时间一直在整理素材,写一个关于explain 的系列文章:在一开始 ...

  5. [转]cubemap soft shadow

    https://community.arm.com/graphics/b/blog/posts/dynamic-soft-shadows-based-on-local-cubemap

  6. php分享十五:php的数据库操作

    一:术语解释: What is an Extension? API和扩展不能理解为一个东西,因为扩展不一定暴露一个api给用户 The PDO MySQL driver extension, for ...

  7. RSA算法 JS加密 JAVA解密

    有这样一个需求,前端登录的usernamepassword,password必需加密.但不可使用MD5,由于后台要检測password的复杂度,那么在保证安全的前提下将password传到后台呢,答案 ...

  8. mysql SQLyog导入csv数据失败怎么办?

    分享下mysql使用SQLyog导入csv数据失败的解决方法 给mysql导入数据,选中某个表选择导入--导入使用本地csv数据即可,单有的时候不知道什么问题导入不成功!!! 给mysql导入数据,使 ...

  9. 使用JS生成二维码QRCode

    这其实很简单,项目中使用插件即可生成,主要有两种方式: 一种是在项目中使用java生成,把图片生成到某个目录,然后在用tomcat或者nginx虚拟一下路径即可访问,这种方式我们不用,因为会在目录中生 ...

  10. 译: 3. Axis2快速入门指南

    本指南的目的是让您尽快使用Axis2开始创建服务和客户端.我们将采用一个简单的StockQuote服务,向您展示可以创建和部署它的一些不同方式,以及快速查看Axis2附带的一个或两个实用程序.然后,我 ...