HashMap学习剖析

1.入门

package com.lf.hashMap.day1;
import java.util.HashMap;
public class Test01 {
    public static void main(String[] args) {
        HashMap<String,String > map = new HashMap<>();
        map.put("01","zhangsan");
        map.put("02","lisi");
        System.out.println(map.get("01"));
    }
}

2.技术本质(程序=数据结构+算法)

(1)在JDK1.7中HashMap的实现是使用的数组+链表
(2)在JDK1.8中HashMap的实现是使用了数组+链表+红黑树
(3)算法采用的是哈希算法

数据结构

数组: 数组就是相同数据类型的元素按一定顺序排列的集合;数组的存储区间是连续的,占用内存比较大,故空间复杂的很大。但数组的二分查找时间复杂度小,都是O(1);数组的特点是:查询简单,增加和删除困难

链表:链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。 相比于线性表顺序结构,操作复杂。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而线性表和顺序表相应的时间复杂度分别是O(logn)和O(1)。

哈希算法的本质

(1)首先要了解散列表(哈希表)

散列技术是指在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使每一个关键字都对应一个存储位置。即:存储位置=f(关键字)。这样,在查找的过程中,只需要通过这个对应关系f 找到给定值key的映射f(key)。只要集合中存在关键字和key相等的记录,则必在存储位置f(key)处。我们把这种对应关系f 称为散列函数或哈希函数。
    按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为哈希表。所得的存储地址称为哈希地址或散列地址。
    在这里插入图片描述把任意长度的key变换为固定长度的key(地址)[例如我们使用的MD5,ASCll],通过这个key访问数据结构
 例如采用ASCLL来计算
 在这里插入图片描述我们先把lies转换为ASCLL,然后再转换后的数字进行hash fuction()进行取余操作得到的是9
 这里进行取余操作的原因是因为数组是要采用一段连续的存储单元,这样会造成很多的空间浪费,

问题因为使用这样的方法,会存在两个不同的key通过hash算法会计算出相同的key(地址),如果使用数组,我们都知道数组在相同的位置存两个不同的元素,新只会把旧值给覆盖掉,所以为了解决这个问题我们就引入了链表,因为链表会存在一个指针指向下一个元素.

我们看一下HashMap存储元素的过程,下面是我们算出的几个对象的hash值
在这里插入图片描述如下是存储过程
在这里插入图片描述细心的同学会发现我们的存储中主要有四部分内容组成
(1)key
(2)value
(3)hash
(4)next

这里hash的存在和next的存在主要是为了我们get()取出元素服务的,我们调用get方法是需要传进去一个key,然后通过我们传入的key会返回hash,然后通过我们计算然后去和map存储元素的hash进行比较,发现不相等,然后和他的next进行比较的这么一个过程

HashMap源码

//HashMap继承自AbstractMap类
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

一些重要的常量

//默认数组长度,必须为2的幂次方,此处为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
 
//所能容纳的结点最大叔,2^30,超过这个数便不再扩容
static final int MAXIMUM_CAPACITY = 1 << 30;
 
//默认负载因子,计算threshold = length * Loadfactor 时用
static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
//链表长度达到8便转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
 
//结点总数达到64才转化为红黑树,否则即便链表长度达到8也不转化为红黑树,仅仅扩容
static final int MIN_TREEIFY_CAPACITY = 64;

一些重要的变量

//table即为hash数组
transient Node<K,V>[] table;
 
//size存储当前结点数
transient int size;
 
//threshold = length * loadFactor 
int threshold;
 
//负载因子:默认为0.75,可以认为是哈希桶的满载程度 
final float loadFactor;

问题:同学们思考一下使用数组加链表这样的结构实现HashMap会存在怎么样的问题呢
答案是,使用数组加链表的结构实现会存在链表的长度过长的问题,我们都知道链表的一个特点是查询慢,链表的查询的时间复杂度是O(n),所以为了解决查询效率低的问题在JDK1.8中我们引入了红黑树来解决这个问题
但是在HashMap中并不是一上来就直接使用红黑树

static final int TREEIFY_THRESHOLD = 8;

在HashMap中有一个阈值就是我们上面看到的这行代码,当链表的长度超过8是才会转换为红黑树存储
那为什么当链表的长度打到8之后转换为红黑树存储呢?
在JDK8及以后的版本中,Java HashMap引入了红黑树结构,其底层的数据结构变成了数组+链表或数组+红黑树。HashMap桶中添加元素时,若链表个数超过8,链表会转换成红黑树。 那么,为什么HasMap红黑树的阈值为8呢?

为什么是8呢?

首先和hashcode碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡。在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为8的概率小于百万分之一,将7作为一个分水岭,等于7时不做转换,大于等于8才转红黑树,小于等于6才转链表。链表中元素个数为8时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了8,是根据概率统计而选择的。

红黑树中的TreeNode是链表中的Node所占空间的2倍,虽然红黑树的查找效率为o(logN),要优于链表的o(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。所以,要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。
源码中给出的注释

源码中的注释:

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(-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

之所以是8,是因为Java的源码贡献者在进行大量实验发现,hash碰撞发生8次的概率已经降低到了0.00000006,几乎为不可能事件,如果真的碰撞发生了8次,那么这个时候说明由于元素本身和hash函数的原因,此次操作的hash碰撞的可能性非常大了,后序可能还会继续发生hash碰撞。所以,这个时候,就应该将链表转换为红黑树了,也就是为什么链表转红黑树的阈值是8。 最后,红黑树转链表的阈值为6,主要是因为,如果也将该阈值设置于8,那么当hash碰撞在8时,会反生链表和红黑树的不停相互激荡转换,白白浪费资源。

HashMap中初始化大小为什么是16?

这里涉及到了数组扩容的具体过程,不懂的同学可以自行学习一下,这个很重的过程

HashMap中初始化大小为什么是16?

首先我们看hashMap的源码可知当新put一个数据时会进行计算位于table数组(也称为桶)中的下标:

int index =key.hashCode()&(length-1);

hahmap每次扩容都是以 2的整数次幂进行扩容

比如:

十进制: 201314

二进制: 11 0001 0010 0110 0010

假设初始化大小为16

15转化为二进制: 1111

index : 11 0001 0010 0110 0010 & 1111 =0010 为 3

假设初始化大小为10

10转化为二进制: 1010

index: 11 0001 0010 0110 0010 & 1010=0010 为 3

因为是将二进制进行按位于,(16-1) 是 1111,末位是1,这样也能保证计算后的index既可以是奇数也可以是偶数,并且只要传进来的key足够分散,均匀那么按位于的时候获得的index就会减少重复,这样也就减少了hash的碰撞以及hashMap的查询效率。

那么到了这里你也许会问? 那么就然16可以,是不是只要是2的整数次幂就可以呢?

答案是肯定的。那为什么不是8,4呢? 因为是8或者4的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小

总结: 1 减少hash碰撞

         2 提高map查询效率

        3 分配过小防止频繁扩容

        4 分配过大浪费资源

HashMap1.7中采用头插法导致cpu%100的问题解密

在JDK1.8中将头插法改变为尾插法
为什么这这样做会解决CPU占用率100%的问题
HashMap中的原始数据,但是在多线程环境下访问会存在数组的扩容,发生拷贝,因为在1.7中采用的是头插法,所以扩容后会产生下面的情况

在这里插入图片描述
扩容后的变化情况
在这里插入图片描述
线程1和线程2 拿到的分别是情况1和情况2 ,就会造成下面的情况,形成一个死循环问题所以,在JDK1.8中就讲头插法改变为尾插发解决掉了这样的问题.

在这里插入图片描述

哈希冲突解决的方法

一般比较常用的方法有开放地址法:
1.开放寻址法:Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),其中H(key)为散列函数,m为散列表长,di为增量序列,可有下列三种取法:

1.1. di=1,2,3,…,m-1,称线性探测再散列;顺序查看表的下一单元,直至找到某个空单元,或查遍全表。

1.2. di=12,-12,22,-22,⑶2,…,±(k)2,(k<=m/2)称二次探测再散列;在表的左右进行跳跃式探测。

1.3. di=伪随机数序列,称伪随机探测再散列。根据产生的随机数进行探测。

2 再散列法:建立多个hash函数,若是当发生hash冲突的时候,使用下一个hash函数,直到找到可以存放元素的位置。

3 拉链法(链地址法):就是在冲突的位置上简历一个链表,然后将冲突的元素插入到链表尾端,(HashMap中使用的解决Hash冲突使用的)

4 建立公共溢出区:将哈希表分为基本表和溢出表,将与基本表发生冲突的元素放入溢出表中。

底层的hashMap是由数组和链表来实现的,就是上面说的拉链法。首先当插入的时候,会根据key的hash值然后计算出相应的数组下标,计算方法是index = hashcode%table.length,(这个下标就是上面提到的bucket),当这个下标上面已经存在元素的时候那么就会形成链表,将后插入的元素放到尾端,若是下标上面没有存在元素的话,那么将直接将元素放到这个位置上。

当进行查询的时候,同样会根据key的hash值先计算相应的下标,然后到相应的位置上进行查找,若是这个下标上面有很多元素的话,那么将在这个链表上一直查找直到找到对应的元素。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值