笔记整理——HashMap底层原理详解
Posted 茀园日记
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了笔记整理——HashMap底层原理详解相关的知识,希望对你有一定的参考价值。
1. HashMap的数据结构
数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。
哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为“链表的数组” ,如图:
或者:
(方块表示Entry对象,横排表示数组table[],纵排表示哈希桶bucket【实际上是一个由Entry组成的链表,新加入的Entry放在链头,最先加入的放在链尾】,)
二、实现原理
成员变量
源码分析:
/** 初始容量,默认16 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 最大初始容量,2^30 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 负载因子,默认0.75,负载因子越小,hash冲突机率越低 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 初始化一个Entry的空数组 */
static final Entry<?,?>[] EMPTY_TABLE = {};
/** 将初始化好的空数组赋值给table,table数组是HashMap实际存储数据的地方,并不在EMPTY_TABLE数组中 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/** HashMap实际存储的元素个数 */
transient int size;
/** 临界值(HashMap 实际能存储的大小),公式为(threshold = capacity * loadFactor) */
int threshold;
/** 负载因子 */
final float loadFactor;
/** HashMap的结构被修改的次数,用于迭代器 */
transient int modCount;
构造方法
源码分析:
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);
// 设置负载因子,临界值此时为容量大小,后面第一次put时由inflateTable(int toSize)方法计算设置
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
关于负载因子不理解:
1先从hashCode()与equals()方法理解
1.1Java语言对hashCode()与equals()的要求:
a. 在java应用程序运行时,无论何时多次调用同一个对象时的hsahCode()方法,这个对象的hashCode()方法的返回值必须是相同的一个int值
b. 如果两个对象equals()返回值为true,则他们的hashCode()也必须返回相同的int值
c. 如果两个对象equals()返回值为false,则他们的hashCode()返回值也必须不同
1.2为什么要重写euqals()?
(1)在很多类中已经重写了equals()方法,例如String,Integer,Character…等类型。已经不是比较两个引用是否为一个对象,而是比较逻辑上的值是否相等,此时”==”与equals()是两个概念。按照原则,我们重写了equals()方法,也要重写hashCode()方法,要保证上面所述的b,c原则;所以java中的很多类都重写了这两个方法,例如String类,包装类
1.3为什么重写了equals()方法,也要重写hashCode()方法?
总的来说是要保证上诉的b,c原则!
本质来说,先看代码:
public class HashCodeClass
{
private String str0;
private double dou0;
private int int0;
public boolean equals(Object obj)
{
if (obj instanceof HashCodeClass)
{
HashCodeClass hcc = (HashCodeClass)obj;
if (hcc.str0.equals(this.str0) &&
hcc.dou0 == this.dou0 &&
hcc.int0 == this.int0)
{
return true;
}
return false;
}
return false;
}
}
public class TestMain
{
public static void main(String[] args)
{
System.out.println(new HashCodeClass().hashCode());
System.out.println(new HashCodeClass().hashCode());
System.out.println(new HashCodeClass().hashCode());
System.out.println(new HashCodeClass().hashCode());
System.out.println(new HashCodeClass().hashCode());
System.out.println(new HashCodeClass().hashCode());
}
}
打印出来的值是:
1901116749
1807500377
355165777
1414159026
1569228633
778966024
我们希望两个HashCodeClass类equals的前提是两个HashCodeClass的str0、dou0、int0分别相等。OK,那么这个类不重写hashCode()方法是有问题的。
现在我的HashCodeClass都没有赋初值,那么这6个HashCodeClass应该是全部equals的。如果以HashSet为 例,HashSet内部的HashMap的table本身的大小是16,那么6个HashCode对16取模分别为13、9、1、2、9、8。第一个放入 table[13]的位置、第二个放入table[9]的位置、第三个放入table[1]的位置。。。但是明明是全部equals的6个 HashCodeClass,怎么能这么做呢?HashSet本身要求的就是equals的对象不重复,现在6个equals的对象在集合中却有5份(因 为有两个计算出来的模都是9)。
那么我们该怎么做呢?重写hashCode方法,根据str0、dou0、int0搞一个算法生成一个尽量唯一的hashCode,这样就保证了 str0、dou0、int0都相等的两个HashCodeClass它们的HashCode是相等的,这就是重写equals方法必须尽量要重写 hashCode方法的原因。
2.再来看put方法
HashMap中我们最长用的就是put(K, V)和get(K)。我们都知道,HashMap的K值是唯一的,那如何保证唯一性呢?我们首先想到的是用equals比较,没错,这样可以实现,但随着内部元素的增多,put和get的效率将越来越低,这里的时间复杂度是O(n),假如有1000个元素,put时需要比较1000次。
实际上,HashMap很少会用到equals方法,因为其内通过一个哈希表管理所有元素,哈希是通过hash单词音译过来的,也可以称为散列表,哈希算法可以快速的存取元素,当我们调用put存值时,HashMap首先会调用K的hashCode方法,获取哈希码,通过哈希码快速找到某个存放位置,这个位置可以被称之为bucketIndex,通过上面所述hashCode的协定可以知道,如果hashCode不同,equals一定为false,如果hashCode相同,equals不一定为true。(因为有冲突现象)所以理论上,hashCode可能存在冲突的情况,有个专业名词叫碰撞,当碰撞发生时,计算出的bucketIndex也是相同的,这时会取到bucketIndex位置已存储的元素,最终通过equals来比较,equals方法就是哈希码碰撞时才会执行的方法,所以前面说HashMap很少会用到equals。HashMap通过hashCode和equals最终判断出K是否已存在,如果已存在,则使用新V值替换旧V值,并返回旧V值,如果不存在 ,则存放新的键值对<K, V>到bucketIndex位置。文字描述有些乱,通过下面的流程图来梳理一下整个put过程。
现在我们知道,执行put方法后,最终HashMap的存储结构会有这三种情况,情形3是最少发生的,哈希码发生碰撞属于小概率事件。到目前为止,我们了解了两件事:
· HashMap通过键的hashCode来快速的存取元素。
· 当不同的对象hashCode发生碰撞时,HashMap通过单链表来解决,
新元素加入链表表头,通过next指向原有的元素。单链表在Java中的实现就是对象的引用(复合)。
put()源码分析:
public V put(K key, V value) {
// 如果table引用指向成员变量EMPTY_TABLE,那么初始化HashMap(设置容量、临界值,新的Entry数组引用)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 若“key为null”,则将该键值对添加到table[0]处,遍历该链表,如果有key为null,则将value替换。没有就创建新Entry对象放在链表表头
// 所以table[0]的位置上,永远最多存储1个Entry对象,形成不了链表。key为null的Entry存在这里
if (key == null)
return putForNullKey(value);
// 若“key不为null”,则计算该key的哈希值
int hash = hash(key);
// 搜索指定hash值在对应table中的索引
int i = indexFor(hash, table.length);
// 循环遍历table数组上的Entry对象,判断该位置上key是否已存在
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 哈希值相同并且对象相同
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 如果这个key对应的键值对已经存在,就用新的value代替老的value,然后退出!
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 修改次数+1
modCount++;
// table数组中没有key对应的键值对,就将key-value添加到table[i]处
addEntry(hash, key, value, i);
return null;
}
3. 终于到了负载因子了!
3.1前面我们讲到比较hashMap里的key值的时候说HashMap很少会用到equals方法
因为用equals比较,随着内部元素的增多,put和get的效率将越来越低,这里的时间复杂度是O(n),假如有1000个元素,put时需要比较1000次。但是hashCode也是有可能相同,我们称之为哈希冲突,这个时候再用equals来比较,如果为false,新元素占据这个位置,next指向原有的Entry对象,通过一个单链表来维护这种关系(hash碰撞解决方案)。所以我们要尽可能的减少哈希冲突。
3.2如何尽可能的减少哈希冲突?
HashMap负载因子为什么是0.75?
HashMap有一个初始容量大小,默认是16
static final int DEAFULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为了减少冲突概率,当HashMap的数组长度达到一个临界值就会触发扩容,把所有元素rehash再放回容器中,这是一个非常耗时的操作。而这个临界值由负载因子和当前的容量大小来决定:
DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
即默认情况下数组长度是16*0.75=12时,触发扩容操作。所以使用hash容器时尽量预估自己的数据量来设置初始值。
那么,为什么负载因子要默认为0.75,在HashMap注释中有这么一段:
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*
在理想情况下,使用随机哈希吗,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素的个数和概率的对照表。
从上表可以看出当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为负载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
hash容器指定初始容量尽量为2的幂次方。HashMap负载因子为0.75是空间和时间成本的一种折中。
负载因子为什么会影响HashMap性能
首先回忆HashMap的数据结构,
我们都知道有序数组存储数据,对数据的索引效率都很高,但是插入和删除就会有性能瓶颈(回忆ArrayList),
链表存储数据,要一次比较元素来检索出数据,所以索引效率低,但是插入和删除效率高(回忆LinkedList),
两者取长补短就产生了哈希散列这种存储方式,也就是HashMap的存储逻辑.
而负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。
所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。
反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高。
如何科学设置 initailCapacity,loadFactor的值
HashMap有三个构造函数,可以选用无参构造函数,不进行设置。默认值分别是16和0.75.
官方的建议是initailCapacity设置成2的n次幂,laodFactor根据业务需求,如果迭代性能不是很重要,可以设置大一下。
为什么initailCapacity要设置成2的n次幂,网友解释了,我觉得很对,以下摘自网友博客:深入理解HashMap
左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定
位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以
发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能
存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用
遍历某个位置上的链表,这样查询效率也就较高了。
resize()方法
initailCapacity,loadFactor会影响到HashMap扩容。
HashMap每次put操作是都会检查一遍 size(当前容量)>initailCapacity*loadFactor 是否成立。如果不成立则HashMap扩容为以前的两倍(数组扩成两倍),
然后重新计算每个元素在数组中的位置,然后再进行存储。这是一个十分消耗性能的操作。
以上是关于笔记整理——HashMap底层原理详解的主要内容,如果未能解决你的问题,请参考以下文章