HashMap详解

 基本原理

        HashMap根据键的hashCode值存储数据,在JDK7中采用的是数组+链表的方式,而在JDK8中采用的是位桶+链表/红黑树的方式,如果发生哈希冲突时,HashMap通过链表将产生碰撞冲突的元素组织起来,通过拉链法解决冲突;当链表的长度超过8时,就将链表转换成红黑树, Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对),Node[] table,即哈希桶数组。

解决 hash 冲突的常见方法

a. 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

b. 开放定址法:即发生冲突时,去寻找下一个空的哈希地址。只要哈希表足够大,总能找到空的哈希地址。

c. 再哈希法:即发生冲突时,由其他的函数再计算一次哈希值。

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

HashMap 就是使用链地址法来解决冲突的(jdk8中采用平衡树来替代链表存储冲突的元素,但hash() 方法原理相同)。数组中的每一个单元都会指向一个链表,如果发生冲突,就将 put 进来的 K- V 插入到链表的尾部。

所谓 “拉链法”就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

 确定哈希桶数组索引位置

        不管增加、删除、查找键值对,都要先定位到哈希桶数组的位置,HashMap的Hash算法本质上是三步:取key的hashCode值、高位运算(高16位异或低16位)、取模运算。

put方法  

        首先判断数组table是否为空或长度为0,若是就执行resize()进行扩容,否则根据key计算hash值得到插入的数组索引i,若i桶为空,就新建节点添加,否则判断i桶的首个元素和key是否一样,若相同(hashcode与equals),就覆盖value,否则去判断是否为红黑树,若是就直接插入,否则遍历i桶,若链表长度大于8,就将链表转换为红黑树后插入,否则就在链表中插入,遍历i桶的过程中若发现key已经存在直接覆盖value即可。最后判断size是否超过threshold,如果超过,进行扩容。

扩容机制

        扩容(resize)就是重新计算容量,用一个新的数组代替已有的容量小的数组,若容量大于16(默认),就进行扩容,将size扩大为原来的2倍,然后重新确定索引值(rehash),在JDK1.7中需要重新计算hash,但在JDK8中只需要看原来的hash值新增的那个bit是1还是0,若是0索引不变,是1的话索引变成“原索引+oldCap”,若新表的数组索引位置相同,在JDK7中链表元素会倒置,但JDK8不会倒置。

get方法

       先通过计算key的hash值,找到哈希桶的位置,否则返回null,然后与桶的第一个key比较,若相同就返回,否则遍历红黑树查找,若没找到就在链表中查找key。HashMap使用链地址法来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。在JDK7中,若两个不同的键对象的hashcode相同, 即存储在同一个bucket位置的链表中。利用keys.equals()方法用来找到键值对。

线程安全性

        当HashMap在多线程的情况下同时put,若同时触发了rehash操作,会导致HashMap中的链表中出现循环节点(Node的next不为空),导致CPU利用率接近100%,这个问题在JDK1.8已经解决,但多线程下使用HashMap还会有一些其他问题比如数据丢失,所以多线程下不应该使用HashMap。

          解决方法:使用Collections.synchronizedMap(在put和get方法加了synchronized)或ConcurrentHashMap(采用了分段锁以及CAS支持更高的并发)来保证线程安全。

利用负载因子,可以减缓哈希冲突,hashmap的默认负载因子是0.75,阈值是16 * 0.75 = 12;初始长度为16;

 

HashMap和HashTable的区别

  1. 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

HashMap的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483648,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。”并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

 

HashMap存储,两个键值对中key如果哈希值相同是怎么存储的? 
哈希值相同,但内容不相同,采用桶存储; 
哈希值相同,equals()比较内容也相同的话,就不存储,因为这个情况下,key相等,不允许这种情况发生; 
扩充: 
HashMap和和Hashtable都是基于哈希表存储数据的,具体就是:内部维护了一个存储数据的Entry数组,HashMap采用链表解决冲突,每一个Entry本质上是一个单向链表。当准备添加一个key-value对时,首先通过hash(key)方法计算hash值,然后通过indexFor(hash,length)求该key-value对的存储位置,计算方法是先用hash&0x7FFFFFFF后,再对length取模,这就保证每一个key-value对都能存入HashMap中,当计算出的位置相同时,由于存入位置是一个链表,则把这个key-value对插入链表头。

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值