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. Activity的onPause()、onStop()和onDestroy()里要做的事情

    onPause(): 当系统调用你的activity中的onPause(),从技术上讲,那意味着你的activity仍然处于部分可见的状态,当时大多数时候,那意味着用户正在离开这个activity并马 ...

  2. Android lrucache 实现与使用(Android内存优化)

    什么是LruCache? LruCache实现原理是什么? 这两个问题其实可以作为一个问题来回答,知道了什么是 LruCache,就只然而然的知道 LruCache 的实现原理:Lru的全称是Leas ...

  3. 《TCP/IP详解卷1:协议》读书笔记

    <TCP/IP详解卷1:协议>读书笔记 - QingLiXueShi - 博客园https://www.cnblogs.com/mengwang024/p/4425834.html < ...

  4. 手把手教你ranorex_android源码instrument

    话说ranorex能把android程序看的透彻,关键是在潜伏,他使用instrumentation,在每个界面(activity)里面,准确的说是onresume,也就是页面显示的时候,都给安装了个 ...

  5. 目标检测 的标注数据 .xml 转为 tfrecord 的格式用于 TensorFlow 训练

    将目标检测 的标注数据 .xml 转为 tfrecord 的格式用于 TensorFlow 训练. import xml.etree.ElementTree as ET import numpy as ...

  6. QT creator 编辑器快捷键

    QT creator 编辑器快捷键 一.快捷键配置方法:   进入“工具->选项->环境->键盘”即可配置快捷键.     二.常用默认快捷键:       编号 快捷键 功能 1 ...

  7. 【Unity】2.11 了解游戏有哪些分类对你开阔思路有好处

    分类:Unity.C#.VS2015 创建日期:2016-03-31 一.简介 对游戏类型的划分有助于游戏的市场定位,以便吸引具有同一爱好的玩家群体.此外,制作游戏策划方案时,也通常会依据不同的游戏类 ...

  8. Mysql数据库If语句的使用

    MySQL的if既可以作为表达式用,也可在存储过程中作为流程控制语句使用,如下是做为表达式使用: IF表达式 [sql] view plain copy 如果 expr1 是TRUE (expr1 & ...

  9. 行为类模式(五):中介者(Mediator)

    定义 定义一个中介对象来封装系列对象之间的交互.中介者使各个对象不需要显示地相互引用,从而使其耦合性松散,而且可以独立地改变他们之间的交互. 试想一下,如果多个类之间相互都有引用,那么当其中一个类修改 ...

  10. Java 编程下简介 Class 与类加载

    即使有一个类并对它一无所知,但其实它本身就包含了许多信息,Java 在需要使用到某个类时才会将类加载,并在 JVM 中以一个 java.lang.Class 的实例存在.从 Calss 实例开始,可以 ...