有关HashMap的几个小彩蛋,你想知道的全在这里了

2024-02-21 17:30
文章标签 几个 hashmap 知道 彩蛋

本文主要是介绍有关HashMap的几个小彩蛋,你想知道的全在这里了,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

今天闲来无事翻HashMap的源码,结合几篇之前看过的帖子,发现之前看源码时一笔带过,其实蛮有意思的小问题点,今天就整个梳理一下,算是个总结。

hashMap的capacity和size

大家都知道hashMap是一个数组加链表(或红黑树)的结构,在初始化时数组的长度就是capacity,而容器里面放置的<k,v>键值对的个数就是size,这里还是有一点区别的。

初始容量和扩容问题

看过源码的都知道,hashMap的初始容量是16,很多人将这解释为一个经验值,我也如此理解,你如果看的是JDK1.8以后的源码,会发现这里的源码多了一行注释

    /*** The default initial capacity - MUST be a power of two.*/static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

为什么好端端的不写个16,非要写个1<<4呢?其实这里就是作者再告诉我们,容量的默认定义就是2次幂,不管你外界传进来的initcapacity是多少,最终在初始化时都会转化为2的整数次幂来定义。
为什么是2次幂呢?其实是和HashMap的底层涉及有关,我们知道HashMap是一个数组➕链表(之后是红黑树)的结构,根据当前key的Hash值来确定数组中的位置,如果这个位置选择的不好,就会导致数组有一些地方始终放不进去值,有一些地方一直在链式加元素,造成资源的浪费。
这个HashMap中的位置是怎么计算的,我们再看看代码

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;//重点就是这一句,p代表当前存放的数组位置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 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}...

核心就是这一句

p = tab[i = (n - 1) & hash]
这里关注下(n - 1) & hash,n是目前的数组容量,hash是当前key的hash值,看到n-1你可能已经有感觉了,我们不妨把n的各种情况模拟一下,假设n=16,hash值从0递增

hash值(n - 1) & hash结果
01111&00000
11111&00011
21111&00102
31111&00113
41111&01004
141111&111014
151111&111115
1601111&100000(链式存储在0后面)

能发现算出来的位置基本是散列开的,很均匀。

那么我们把n换成15试试,n-1 = 14 (1110)

hash值(n - 1) & hash结果
01110&00000
11110&00010
21110&00102
31110&00112
41110&01004
141110&111014
151110&111114

会发现0,2,4等位置都链式存储了两个数据,而1,3等位置始终没有数据存储,这就造成了浪费。
这里面还有一个小彩蛋,其实这种计算数组位置的问题,我的第一想法就是取余运算,那么为什么这里不用取余呢?取余就没这个问题了,其实p&(q-1)就等于p%q,不信的同学可以自己验证下,这里这么写主要还是因为&的性能要优于取余运算,实际上位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。所以这种最底层的计算逻辑,优先考虑位运算,这样就不难理解为什么如此设计了。

hash问题

每一次看源码的时候都是理解其过程,遇到hash函数就跳过了,这一次我们把hash函数单独拎出来品一品,其实也蛮有意思的。

final int hash(Object k) {int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);
}static int indexFor(int h, int length) {return h & (length-1);
}

indexFor就是寻找数组下标,这里 h & (length-1)的问题之前已经讲过了,下面我们着重看一些哈希的过程,就是hash这个方法。
首先明确一些概念,HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到他应该保存在数组的哪个下标中。而这个通过键值求取下标的操作就叫做哈希。
HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。
求哈希简单的做法是先求取出键值的hashcode,然后在将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作。
因为按位与的计算方式,也带出来了一些问题,那就是如果hashMap的容量不大时,很容易造成hash值的“相似低位碰撞”。

什么叫“相似低位碰撞”?

举个例子,假设一个hashMap的初始容量是16,length-1 = 15,二进制码就是1111,那么两个高位不同而低位相同的hash值,计算之后的数组加标是相同的,比如
hash1 :0110 0011 & 0000 1111 = 0000 0011
hash2 :0101 0011 & 0000 1111 = 0000 0011
为了避免因为低位相同而造成的hash碰撞,进而降低底层hashMap的存储效率,需要对hash值进行处理,也就是这一段代码

   h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);

这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
举个例子,假设目前有一个对象A的hash值是1011000110101110011111010011011,对应的数组长度是16,根据上面的描述相信你已经知道,不管这个数字的前28位是什么,最终产生的结果都是1011,相应的也就发生了碰撞。
假设目前还有一个对象B的hash值是0000000000000000011111010011011,我们通过上面的扰动计算说明下最后的数值
在这里插入图片描述
从上面图中可以看到,之前会产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样了,这就很好的避免了冲突。
其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。

java8的一些改变

关于Java 8中的hash函数,原理和Java 7中基本类似。Java 8中这一步做了优化,只做一次16位右位移异或混合,而不是四次,但原理是不变的。

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

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。以上方法得到的int的hash值,然后再通过h & (table.length -1)来得到该对象在数据中保存的位置。

负载因子的问题

在HashMap中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,也就是说默认情况下,当HashMap中元素个数达到了容量的3/4的时候就会进行自动扩容。
那么这个值为什么是0.75呢?我们看一下官方给出的解释

As a general rule, the default load factor (.75) offers a good tradeoff 
between time and space costs. 
Higher values decrease the space overhead but increase the lookup cost(reflected in most of the operations of the HashMap class, including get and put).

大意是什么呢?
就是说,默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。
试想一下,如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容。
那么在HashMap中,最好的情况是这16个元素通过hash算法之后分别落到了16个不同的桶中,否则就必然发生哈希碰撞。而且随着元素越多,哈希碰撞的概率越大,查找速度也会越低。
私人以为0.75还有一个考虑,就是threshold = loadFactor * capacity,而capacity通过前文我们已经分析出来一定是2的倍数,所以这里设置0.75可以保证threshold一定是整数。

HashMap的初始容量

通过前文可以知道初始容量在不赋值的情况下是16,如果赋值,将会初始化为大于赋值容量的第一个2次幂,那么假如我们在代码上下文中清晰的知道一个hashMap会放入多少个值,这个时候能不能按照我们的想法来初始化呢?
比如我们需要往hashMap中放入7个值,于是我们设置initcapacity = 7,这时初始化的hashMap容量是大于7的第一个2次幂,也就是8,可是因为负载因子是0.75,导致我们放入6个元素后hashMap就需要扩容,这显然不是我们所期望的。
有没有什么好一点的办法?其实可以参照guava的设计,

return (int) ((float) expectedSize / 0.75F + 1.0F);

比如我们计划向HashMap中放入7个元素的时候,我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过JDK处理之后,会被设置成16,这就大大的减少了扩容的几率。当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize/0.75 + 1的话,可以有效的减少冲突也可以减小误差。
所以,我们可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
这个算法在guava中有实现,开发的时候,可以直接通过Maps类创建一个HashMap:

Map<String, String> map = Maps.newHashMapWithExpectedSize(7);其代码实现如下:public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {return new HashMap(capacity(expectedSize));}static int capacity(int expectedSize) {if (expectedSize < 3) {CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");return expectedSize + 1;} else {return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : 2147483647;}}

链表与红黑树的转换

在JDK1.8以后,为了提升哈希碰撞后元素的查找效率,系统会在单链表长度大于8的时候将链表转化为一颗红黑树,转化的过程大家可以看看代码,不在这里赘述,我想要说明的一个问题是,为什么这个数字是8?
在JDK源码的注释中是这么描述的:

     *Because TreeNodes are about twice the size of regular nodes, we* use them only when bins contain enough nodes to warrant use* (see TREEIFY_THRESHOLD). And when they become too small (due to* removal or resizing) they are converted back to plain bins.  In* usages with well-distributed user hashCodes, tree bins are* rarely used.  Ideally, under random hashCodes, the frequency of* nodes in bins follows a Poisson distribution* (http://en.wikipedia.org/wiki/Poisson_distribution) with a* parameter of about 0.5 on average for the default resizing* threshold of 0.75, although with a large variance because of* resizing granularity. Ignoring variance, the expected* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /* factorial(k)). The first values are:** 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

大意是说,理想情况下,在随机哈希码下,存储箱中节点的频率遵循泊松分布,默认大小调整阈值为0.75时,平均参数约为0.5,但由于大小调整的粒度,变化较大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5,k)/*factorial(k))
而在一个理想的hash函数下,红黑树模型是极少会用到的,8以内可以覆盖绝大多数的情况。
(英语有限,大致就是这个意思吧)

参考资料:Hollis(ID:hollischuang)微信公众号的几篇文章

这篇关于有关HashMap的几个小彩蛋,你想知道的全在这里了的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/732536

相关文章

每天认识几个maven依赖(ActiveMQ+activemq-jaxb+activesoap+activespace+adarwin)

八、ActiveMQ 1、是什么? ActiveMQ 是一个开源的消息中间件(Message Broker),由 Apache 软件基金会开发和维护。它实现了 Java 消息服务(Java Message Service, JMS)规范,并支持多种消息传递协议,包括 AMQP、MQTT 和 OpenWire 等。 2、有什么用? 可靠性:ActiveMQ 提供了消息持久性和事务支持,确保消

uva 10061 How many zero's and how many digits ?(不同进制阶乘末尾几个0)+poj 1401

题意是求在base进制下的 n!的结果有几位数,末尾有几个0。 想起刚开始的时候做的一道10进制下的n阶乘末尾有几个零,以及之前有做过的一道n阶乘的位数。 当时都是在10进制下的。 10进制下的做法是: 1. n阶位数:直接 lg(n!)就是得数的位数。 2. n阶末尾0的个数:由于2 * 5 将会在得数中以0的形式存在,所以计算2或者计算5,由于因子中出现5必然出现2,所以直接一

使用JS/Jquery获得父窗口的几个方法(笔记)

<pre name="code" class="javascript">取父窗口的元素方法:$(selector, window.parent.document);那么你取父窗口的父窗口的元素就可以用:$(selector, window.parent.parent.document);如题: $(selector, window.top.document);//获得顶级窗口里面的元素 $(

Verybot的几个视频

1、Verybot的运动控制                 http://v.youku.com/v_show/id_XNjYxNjg4MTM2.html           2、Verybot比较初步的网络视频监控           http://v.youku.com/v_show/id_XNjYxNjkyMjg0.html           3、V

PHP原理之内存管理中难懂的几个点

PHP的内存管理, 分为俩大部分, 第一部分是PHP自身的内存管理, 这部分主要的内容就是引用计数, 写时复制, 等等面向应用的层面的管理. 而第二部分就是今天我要介绍的, zend_alloc中描写的关于PHP自身的内存管理, 包括它是如何管理可用内存, 如何分配内存等. 另外, 为什么要写这个呢, 因为之前并没有任何资料来介绍PHP内存管理中使用的策略, 数据结构, 或者算法. 而在我们

Java虚拟机垃圾回收的几个关键问题

20151008 GC的几个关键问题,触发条件,触发的机制 主线是数据的移动,从什么位置到什么位置,移动的条件是什么? 1 垃圾收集在什么时候触发? GC都是在带满了的时候触发的,每次触发都是把不会用的不可达的对象空间回收了,留下还在用的对象。 1) MinorGC的触发是伊甸园空间满的时候 2) FullGC的触发是在老年代满的时候 2 垃圾回收的时候做哪些工作? 1) 一个新的对象new出

[情商-13]:语言的艺术:何为真实和真相,所谓真相,就是别人想让你知道的真相!洞察谎言与真相!

目录 前言: 一、说话的真实程度分级 二、说谎动机分级:善意谎言、中性谎言、恶意谎言 三、小心:所谓真相:只说对自己有利的真相 四、小心:所谓真相:就是别人想让你知道的真相 五、小心:所谓善解人意:就是别人只说你想要听到的话 前言: 何为真实和真相,所谓真相,就是别人想让你知道的真相!洞察谎言与真相! 人与人交流话语中,处处充满了不真实,完全真实的只是其中一小部分,这

HashMap中常用的函数

假设如下HashMap<String, Integer> map = new HashMap<>();获取value值1、返回key为a的valueget(a)2、返回key为a的value,若没有该key返回0getOrDefault(a,0)新增键值对1、新增键值对(a,1)put(a,1)2、如果key为a的键不存在,则存入键值对(a,1)putIfAbsent(a,1)3

hashmap的存值,各种遍历方法

package com.jefflee;import java.util.HashMap;import java.util.Iterator;import java.util.Map;public class HashmapTest {// 遍历Hashmap的四种方法public static void main(String[] args) {//hashmap可以存一个null,把

看病要排队这个是地球人都知道的常识

归纳编程学习的感悟, 记录奋斗路上的点滴, 希望能帮到一样刻苦的你! 如有不足欢迎指正! 共同学习交流! 🌎欢迎各位→点赞 👍+ 收藏⭐ + 留言​📝唯有付出,才有丰富的果实收获! 看病要排队这个是地球人都知道的常识。 不过经过细心的0068的观察,他发现了医院里排队还是有讲究的。0068所去的医院有三个医生(汗,这么少)同时看病。而看病的人病情有轻重,所以不能根据简单的先来