HashMap原理浅析(关于红黑树是什么?)

HashMap

1. HashMap的数据结构

数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。

数组
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

哈希表
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

2. HashMap的版本简单对比

以下主要对JDK1.7和1.8进行介绍对比

  1. JDK1.7:数组+链表
    原理: JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低

  2. JDK1.8:数组+链表+红黑树
    原理: JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

差异:
1. 首先头插法改成尾插法,在之前作者认为先插入的有更大几率会被get,但是这样在多线程并情况会导致死锁,于是改成了尾插发避免该情况(后面详谈)
2. 在1.8添加了红黑树,因为1.7单链长度越长,查询效率越慢,而引入红黑树后,将单链变成树,查询效率是根据树的深浅,链表的长度而定,所以增加效率。

HashMap的实现步骤:

  1. 首先有一个每个元素都是链表的数组
  2. 当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置
  3. 但是可能存在同一hash值的元素已经被放在数组同一位置了(hash冲突)
  4. 这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一各链表上的Hash值是相同的,所以说数组存放的是链表。
  5. 而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。(查询效率是根据树的深浅,链表的长度而定)

接下来主要对HashMap的重要属性名词解释:

3.HashMap的重要属性:

 public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {
    private static final long serialVersionUID = 362498820763181265L;
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16----  默认初始化大小 16 
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比------负载因子0.75
    //当add一个元素到某个位桶,其链表长度达到8时将链表转换为红黑树
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    static final int MIN_TREEIFY_CAPACITY = 64;
    transient Node<k,v>[] table;//存储元素的数组
    transient Set<map.entry<k,v>> entrySet;
    transient int size;//存放元素的个数
    transient int modCount;//被修改的次数fast-fail机制
    int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容 
    final float loadFactor;//填充比 

重点解析:

1.为什么需要使用加载因子,为什么需要扩容呢?

加载因子(默认0.75):

  • 因为如果填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,这样查找的效率很低,因为链表的长度很大(当然最新版本使用了红黑树后会改进很多),扩容之后,将原来链表数组的每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置,这样就减少了每个链表的长度,增加查找效率
  • HashMap本来是以空间换时间,所以填充比没必要太大。但是填充比太小又会导致空间浪费。如果关注内存,填充比可以稍大,如果主要关注查找性能,填充比可以稍小。

2.负载因子值的大小,对HashMap有什么影响

  • 负载因子的大小决定了HashMap的数据密度。
    负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
    负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。

    按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。

位桶数组

每个元素都是链表的数组(Entry数组)

transient Node<k,v>[] table;//存储(位桶)的数组

HashMap的主干是一个Entry数组,里面存放Entry对象,每一个Entry对象包含(key,value)键值対和next指针以及对应的hash值。[jdk1.8之前用的是Entry,jdk1.8之后用的是Node,两者基本等价]

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;-------------每一个Key都会计算出一个hash值
        final K key;----------------put中的key值
        V value;
        Node<K,V> next;-------------下一个结点的指针,就是下一个结点的对象
      .........
 }

解释:首先获取Key的hash值,查找到指定的数组坐标后,若是该坐标已经存在数据,则形成链表,采用尾插法,插入到上一个对象下面,而上一个对象中的next中存着当前插入的key,也就是指针,而若是同一个坐标中的链表长度超过8,jdk1.8会将此链表转化为红黑树。

以上大概对hashMap的一些重要的属性进行解释 ,接下来对hashmap的一些基本方法原理进行解释

hashMap的方法

put方法

  1. 首先对key使用hashcode算出哈希值,通过哈希值来确定具体的数组下标:
    例如: hashMap.put(“Java”,0)
    此时要插入一个Key值为“Java”的元素,这时首先需要一个Hash函数来确定这个Entry的插入位置,设为index
    即 index = hash(“Java”),假设求出的index值为2,那么这个Entry就会插入到数组索引为2的位置

  2. 如果索引处的Entry为null的话,则直接在此处插入元素,
    如果索引出的Entry不为null的话,通过循环不断遍历链表查找是否有相同哈希值的key,
    如果有,再比较两个key的是否相同,当哈希值与key都相同时,则认为是同一个Entry对象并覆盖原对象的value值。
    在这里插入图片描述

  3. 但是HaspMap的长度肯定是有限的,当插入的Entry越来越多时,不同的Key值通过哈希函数算出来的index值肯定会有冲突,此时就可以利用链表来解决。(hash冲突)
    HashMap原理浅析(关于红黑树是什么?)_第1张图片
    HaspMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点,每一个Entry对象通过Next指针指向下一个Entry对象,这样,当新的Entry的hash值与之前的存在冲突时,只需要插入到对应点链表即可。

注意点:
添加到方法的具体操作,在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进性扩容操作,空充的容量为table长度的2倍。重新计算hash值,和数组存储的位置

[jdk1.8之前插入链表头部,jdk1.8之后插入链表尾部]

get方法

  1. get()方法用来根据Key值来查找对应点Value

  2. 当调用get()方法时
    例如:
    hashMap.get(“apple”),这时同样要对Key值做一次Hash映射,算出其对应的index值,
    即index = hash(“apple”)。
    HashMap原理浅析(关于红黑树是什么?)_第2张图片

  3. 获取到‘apple’的哈希值,例如上图apple的哈希为2,于是寻找到数组2的坐标下

  4. 然后判断key值是否相等,不相等就往下一个结点做判断,直到key=apple为止。

remove方法

  1. 删除操作,先计算指定key的hash值,然后计算出table中的存储位置,
  2. 判断当前位置是否Entry实体存在,如果没有直接返回,
  3. 若当前位置有Entry实体存在,则开始遍历列表。
  4. 例如:
    定义了三个Entry引用,分别为pre, e ,next。 在循环遍历的过程中,首先判断pre 和 e 是否相等,若相等表明,table的当前位置只有一个元素,直接将table[i] = next = null 。若形成了pre -> e -> next 的连接关系,判断e的key是否和指定的key 相等,若相等则让pre -> next ,e 失去引用。

HasMap的扩容机制resize()

1. 构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16)
2. 如果Node[]数组中的元素达到(填充比*Node.length)重新调整HashMap大小 变为原来2倍大小,扩容很耗时
3. 新建的hashmap是一种懒加载的机制,当第一次put的时候才进行扩容

4. 散列rehash过程
当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。

JDK1.8使用红黑树的改进

  • 在java jdk8中对HashMap的源码进行了优化,在jdk7中,HashMap处理“碰撞”的时候,都是采用链表来存储
    当碰撞的结点很多时,查询时间是O(n)。

  • 在jdk8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点较少时,采用链表存储,当较大时(>8个),采用红黑树(特点是查询时间是O(logn))存储(有一个阀值控制,大于阀值(8个),将链表存储转换成红黑树存储)
    HashMap原理浅析(关于红黑树是什么?)_第3张图片
    解释: 当存储的链表中长度大于8会自动转化为红黑树,在1.7是单链表,在查询是根据链表的长度来决定效率,而红黑树通过左大右小的方式形成一棵树,树的长度深度下降 ,效率自然就高了许多。

以下摘取了一些大佬对hashmap的原理解析

问题分析:
你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。

随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。

JDK1.8HashMap的红黑树是这样解决的:

  1. 如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。
  2. 前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里

HashMap的死锁

  1. 我们知道HashMap是非线程安全的,那么原因是什么呢?
    由于HashMap的容量是有限的,如果HashMap中的数组的容量很小,假如只有2个,那么如果要放进10个keys的话,碰撞就会非常频繁,此时一个O(1)的查找算法,就变成了链表遍历,性能变成了O(n),这是Hash表的缺陷。
    为了解决这个问题,HashMap设计了一个阈值,其值为容量的0.75,当HashMap所用容量超过了阈值后,就会自动扩充其容量。
  2. 在多线程的情况下,当重新调整HashMap大小的时候,就会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就会产生死循环了。

HashMap的长度

  1. HaspMap的默认初始长度是16,并且每次扩展长度或者手动初始化时,长度必须是2的次幂。之所以是16,是为了服务于从Key值映射到index的hash算法。

  2. 前面说到了,从Key值映射到数组中所对应的位置需要用到一个hash函数:index = hash(“Java”);
    那么为了实现一个尽量分布均匀的hash函数,利用的是Key值的HashCode来做某种运算。
    因此问题来了,如何进行计算,才能让这个hash函数尽量分布均匀呢?
    一种简单的方法是将Key值的HashCode值与HashMap的长度进行取模运算,即 index = HashCode(Key) % hashMap.length,但是,但是!这种取模方式运算固然简单,然而它的效率是很低的, 而且,如果使用了取模%, 那么HashMap在容量变为2倍时, 需要再次rehash确定每个链表元素的位置,浪费了性能。
    因此为了实现高效的hash函数算法,HashMap的发明者采用了位运算的方式。那么如何进行位运算呢?可以按照下面的公式:
    index = HashCode(Key) & (hashMap.length - 1);

  3. 接下来我们以Key值为“apple”的例子来演示这个过程:
    计算“apple”的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
    HashMap默认初始长度是16,计算hashMap.Length-1的结果为十进制的15,二进制的1111。
    把以上两个结果做 与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
    可以看出来,hash算法得到的index值完全取决与Key的HashCode的最后几位。这样做不但效果上等同于取模运算,而且大大提高了效率。

参考优秀文章
文章1
文章2
文章3

你可能感兴趣的:(JAVA基础知识篇,hashmap,java,算法,集合,map)