java中的数据结构之HashMap学习

本文仅仅是对hashmap中的一些机制进行讲解,不做具体的使用分析,中间会牵扯一些源码。先看一个hashmap的整体结构图:

在这里插入图片描述

equal与hashcode

equals与hashcode的源码

equals()和hashCode()方法都是Object类中的公有方法,先看一下hashCode方法源码

/ * @return  a hash code value for this object.
 * @see     java.lang.Object#equals(java.lang.Object)
 * @see     java.lang.System#identityHashCode
 */
public int hashCode() {
    return identityHashCode(this);
}

// Android-changed: add a local helper for identityHashCode.
// Package-private to be used by j.l.System. We do the implementation here
// to avoid Object.hashCode doing a clinit check on j.l.System, and also
// to avoid leaking shadow$_monitor_ outside of this class.
/* package-private */ static int identityHashCode(Object obj) {
    int lockWord = obj.shadow$_monitor_;
    final int lockWordStateMask = 0xC0000000;  // Top 2 bits.
    final int lockWordStateHash = 0x80000000;  // Top 2 bits are value 2 (kStateHash).
    final int lockWordHashMask = 0x0FFFFFFF;  // Low 28 bits.
    if ((lockWord & lockWordStateMask) == lockWordStateHash) {
        return lockWord & lockWordHashMask;
    }
    return identityHashCodeNative(obj);
}

@FastNative
private static native int identityHashCodeNative(Object obj);

最终调用的是一个native方法,而这个native方法的意思,实际上调用的是对应键值的存放地址。下面看一下equals方法

 */
public boolean equals(Object obj) {
    return (this == obj);
}

方法直接调用 == ,说明equals实际上和恒等于==是一样的。

一般对于equals会被重写,比如String类中重写equals如下:

 /*
 * @param  anObject
 *         The object to compare this {@code String} against
 *
 * @return  {@code true} if the given object represents a {@code String}
 *          equivalent to this string, {@code false} otherwise
 *
 * @see  #compareTo(String)
 * @see  #equalsIgnoreCase(String)
 */
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String) anObject;
        int n = length();
        if (n == anotherString.length()) {
            int i = 0;
            while (n-- != 0) {
                if (charAt(i) != anotherString.charAt(i))
                        return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

自反性:对于任何非空引用值 x,x.equals(x) 都应返回 true。
对称性:对于任何非空引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 才应返回 true。
传递性:对于任何非空引用值 x、y 和 z,如果 x.equals(y) 返回 true,并且 y.equals(z) 返回 true,那么 x.equals(z) 应返回 true。
一致性:对于任何非空引用值 x 和 y,多次调用 x.equals(y) 始终返回 true 或始终返回 false,前提是对象上 equals 比较中所用的信息没有被修改。
对于任何非空引用值 x,x.equals(null) 都应返回 false。

为什么hashmap中作为键值的类要重写hashcode和equals方法

  1. 通过Hash算法来了解HashMap对象的高效性
    我们先复习数据结构里的一个知识点:在一个长度为n(假设是10000)的线性表(假设是ArrayList)里,存放着无序的数字;如果我们要找一个指定的数字,就不得不通过从头到尾依次遍历来查找,这样的平均查找次数是n除以2(这里是5000)。

我们再来观察Hash表(这里的Hash表纯粹是数据结构上的概念,和Java无关)。它的平均查找次数接近于1,代价相当小,关键是在Hash表里,存放在其中的数据和它的存储位置是用Hash函数关联的。

我们假设一个Hash函数是x*x%5。当然实际情况里不可能用这么简单的Hash函数,我们这里纯粹为了说明方便,而Hash表是一个长度是11的线性表。如果我们要把6放入其中,那么我们首先会对6用Hash函数计算一下,结果是1,所以我们就把6放入到索引号是1这个位置。同样如果我们要放数字7,经过Hash函数计算,7的结果是4,那么它将被放入索引是4的这个位置。这个效果如下图所示。
在这里插入图片描述

这样做的好处非常明显。比如我们要从中找6这个元素,我们可以先通过Hash函数计算6的索引位置,然后直接从1号索引里找到它了。

不过我们会遇到“Hash值冲突”这个问题。比如经过Hash函数计算后,7和8会有相同的Hash值,对此Java的HashMap对象采用的是”链地址法“的解决方案。效果如下图所示。

在这里插入图片描述

具体的做法是,为所有Hash值是i的对象建立一个同义词链表。假设我们在放入8的时候,发现4号位置已经被占,那么就会新建一个链表结点放入8。同样,如果我们要找8,那么发现4号索引里不是8,那会沿着链表依次查找。

虽然我们还是无法彻底避免Hash值冲突的问题,但是Hash函数设计合理,仍能保证同义词链表的长度被控制在一个合理的范围里。这里讲的理论知识并非无的放矢,大家能在后文里清晰地了解到重写hashCode方法的重要性。

  1. 为什么要重写equals和hashCode方法
    当我们用HashMap存入自定义的类时,如果不重写这个自定义类的equals和hashCode方法,得到的结果会和我们预期的不一样。我们来看WithoutHashCode.java这个例子。

在其中的第2到第18行,我们定义了一个Key类;在其中的第3行定义了唯一的一个属性id。当前我们先注释掉第9行的equals方法和第16行的hashCode方法。

1   import java.util.HashMap;
2   class Key {
3       private Integer id;
4       public Integer getId()
5   {return id; }
6       public Key(Integer id)
7   {this.id = id;  }
8   //故意先注释掉equals和hashCode方法
9   //  public boolean equals(Object o) {
10  //      if (o == null || !(o instanceof Key))
11  //      { return false; }
12  //      else
13  //      { return this.getId().equals(((Key) o).getId());}
14  //  }
15     
16  //  public int hashCode()
17  //  { return id.hashCode(); }
18  }
19 
20  public class WithoutHashCode {
21      public static void main(String[] args) {
22          Key k1 = new Key(1);
23          Key k2 = new Key(1);
24          HashMap<Key,String> hm = new HashMap<Key,String>();
25          hm.put(k1, "Key with id is 1");    
26          System.out.println(hm.get(k2));    
27      }
28  }

在main函数里的第22和23行,我们定义了两个Key对象,它们的id都是1,就好比它们是两把相同的都能打开同一扇门的钥匙。

在第24行里,我们通过泛型创建了一个HashMap对象。它的键部分可以存放Key类型的对象,值部分可以存储String类型的对象。

在第25行里,我们通过put方法把k1和一串字符放入到hm里; 而在第26行,我们想用k2去从HashMap里得到值;这就好比我们想用k1这把钥匙来锁门,用k2来开门。这是符合逻辑的,但从当前结果看,26行的返回结果不是我们想象中的那个字符串,而是null。

原因有两个—没有重写。第一是没有重写hashCode方法,第二是没有重写equals方法。

当我们往HashMap里放k1时,首先会调用Key这个类的hashCode方法计算它的hash值,随后把k1放入hash值所指引的内存位置。

关键是我们没有在Key里定义hashCode方法。这里调用的仍是Object类的hashCode方法(所有的类都是Object的子类),而Object类的hashCode方法返回的hash值其实是k1对象的内存地址(假设是1000)。

在这里插入图片描述

如果我们随后是调用hm.get(k1),那么我们会再次调用hashCode方法(还是返回k1的地址1000),随后根据得到的hash值,能很快地找到k1。

但我们这里的代码是hm.get(k2),当我们调用Object类的hashCode方法(因为Key里没定义)计算k2的hash值时,其实得到的是k2的内存地址(假设是2000)。由于k1和k2是两个不同的对象,所以它们的内存地址一定不会相同,也就是说它们的hash值一定不同,这就是我们无法用k2的hash值去拿k1的原因。

当我们把第16和17行的hashCode方法的注释去掉后,会发现它是返回id属性的hashCode值,这里k1和k2的id都是1,所以它们的hash值是相等的。

我们再来更正一下存k1和取k2的动作。存k1时,是根据它id的hash值,假设这里是100,把k1对象放入到对应的位置。而取k2时,是先计算它的hash值(由于k2的id也是1,这个值也是100),随后到这个位置去找。

但结果会出乎我们意料:明明100号位置已经有k1,但第26行的输出结果依然是null。其原因就是没有重写Key对象的equals方法。

HashMap是用链地址法来处理冲突,也就是说,在100号位置上,有可能存在着多个用链表形式存储的对象。它们通过hashCode方法返回的hash值都是100。
在这里插入图片描述

当我们通过k2的hashCode到100号位置查找时,确实会得到k1。但k1有可能仅仅是和k2具有相同的hash值,但未必和k2相等(k1和k2两把钥匙未必能开同一扇门),这个时候,就需要调用Key对象的equals方法来判断两者是否相等了。

由于我们在Key对象里没有定义equals方法,系统就不得不调用Object类的equals方法。由于Object的固有方法是根据两个对象的内存地址来判断,所以k1和k2一定不会相等,这就是为什么依然在26行通过hm.get(k2)依然得到null的原因。

为了解决这个问题,我们需要打开第9到14行equals方法的注释。在这个方法里,只要两个对象都是Key类型,而且它们的id相等,它们就相等。

  1. 对面试问题的说明
    由于在项目里经常会用到HashMap,所以我在面试的时候一定会问这个问题∶你有没有重写过hashCode方法?你在使用HashMap时有没有重写hashCode和equals方法?你是怎么写的?

根据问下来的结果,我发现初级程序员对这个知识点普遍没掌握好。重申一下,如果大家要在HashMap的“键”部分存放自定义的对象,一定要在这个对象里用自己的equals和hashCode方法来覆盖Object里的同名方法。

Integer 与String等不可变得类

什么是哈希冲突

哈希法又称为散列法,实际上是对关键字通过哈希算法进行计算得到键值与哈希值得一个映射,但是有可能存在不同的键值映射到同一个哈希值得情况,这就就是哈希冲突,或者哈希碰撞。

哈希算法

哈希算法的原则是,1、本身便于计算。2、计算出来的地址分布均匀,即对任何一个关键字,得到的哈希值的地址概率相同。

常用的哈希算法有以下几种:

1.除留余数法:
假设哈希表长为m,p为小于等于m的最大素数,则哈希函数为h(k)=k % p 。
2.平方取中法:
先求出关键字的平方值,然后按需取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。

哈希冲突的解决办法

1、开放地址法

基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
这种方法有一个通用的再散列函数形式:Hi=(H(key)+di)% m i=1,2,…,n

其中di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:

线性探测再散列 di=1,2,3,…,m-1
这种方法的特点是:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。二次探测再散列 di=12,-12,22,-22,…,k2,-k2 ( k<=m/2 )
这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。伪随机探测再散列 di=伪随机数序列
具体实现时,应建立一个伪随机数发生器,(如i=(i+p) % m),并给定一个随机数做起点。

2.链地址法

基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。

3.再哈希法:

这种方法是同时构造多个不同的哈希函数:Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

开放地址法:计算简单快捷,处理起来方便,但线性探测法容易形成“堆聚”。另外,该方法的删除操作显得十分复杂,我们不能直接删除关键字所在的记录,否则在查找删除位置后面的元素时,可能会出现找不到的情况,因为删除位置上已经成了空地址,查找到这里时会终止查找。所以,就需要重建哈希表,特别浪费性能。链地址法:该方法将所有哈希地址相同的结点构成一个单链表,单链表的头结点存在哈希数组里,链地址法常出现在经常插入和删除的情况下,此时,哈希表的插入/删除/查找都是O(1)的时间复杂度。该法不会出现“堆聚”现象,哈希地址不同的关键字不会发生冲突;不需要重建哈希表。另外,如果开放地址法中,哈希表里存满关键字了就需要扩充哈希表然后重建哈希表,而链地址法不需要

hashmap原理

使用的hash算法

首先谈一下hashmap的数据结构,首先生命hashmap选择的哈希算法是链地址法。

在这里插入图片描述

hashmap底层使用数组加链表的数据结构,每一个数组空间都会存储一个链表结构,每个链表节点都是一个node对象,里面包含存储的hash,key,value,next(下一个节点node的值),那么问题来了,如果执行一个map.put操作的时候,整个流程是什么样子的呢?

  1. 首选对key值进行hash算法,key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。(h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位是不变的。)

    [java]  view plain  copy
    
    public V put(K key, V value) {  
       return putVal(hash(key), key, value, false, true);  
    }  
    [java]  view plain  copy
    
    static final int hash(Object key) {  
       int h;  
       return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
    }  
    
  2. 然后执行putval方法,首先判断table是不是为空,如果为空,说明是第一次执行put操作,需要确认数组的大小,从源码中可以看到,数组的初始大小是16,最大值为Integer.max_value。数组的大小必须是2的n次方。然后需要确认数据落在数组的哪一个位置上,也就是确认数组下标,确认数组下标是通过hash&(数组大小-1)相当于取余数的方式,如果数组下标位置没有存在其他节点,那么直接放入数组桶中(如图中的node0、node2),如果发生了碰撞(数组下标位置存在其他节点),如果key已经存在,说明节点已经存在,将对应节点的旧value换成新value,如果不存在将node链接到链表的后面(如图中的node1和node3)。

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 数组初始大小

  3. hashmap如果数据变大,数组是可以扩充的,常量定义了一个负载因子,默认是0.75,也就是说当数组开始有大于16*0.75=12个桶有数据的时候,数组就开始扩充,扩充的大小是原来的两倍,因为要保证数组大小是2的n次方。如果链表过长会影响查询速度,jdk1.8对此做了改进,有一个常量TREEIFY_THRESHOLD=8和UNTREEIFY_THRESHOLD=6,如果链表的长度大于TREEIFY_THRESHOLD=8时,链表会转换成红黑树。如果执行remove操作的时候,红黑树节点又会变少,如果节点小于UNTREEIFY_THRESHOLD=6时,又会从红黑树转成链表。

static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子

以上就是hashmap执行一次put操作的历程,此时肯定会有一些疑问:

(1)数组大小为什么是2的n次方?

数组的下标是用hash值&(数组大小-1)计算的,2的n次方的转换为二进制最高位是1,后面都是0,例如2的4次方是16,二进制表示为10000,16-1=15(二进制表示1111),如果不是2的n次方,比如数组大小为20。20-1=19(10011),这样于hashcode与运算,第2.3位为0,这样得到的数组下标只由第1.4.5位决定,大大减小了数组下标的利用率。

如下源码,是hashmap的get源码,源码中的getNode中获取对应元素的时候,用的是table[(n-1)&hash],只有当hashmap的容量是2的幂次方,这样才能保证减去一的时候,二进制中1的数量最多,因此可以最大概率的使用数组下标。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
 * Implements Map.get and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @return the node, or null if none
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

(2)hash算法为什么要高16位不变,低16位与高16位异或作为key的最终hash值?
这个地方的高十六位和第十六位是值得hash值的高十六位和第十六位,不是指数组长度的高低位。

因为数组的大小是2的n次方并且初始大小为16,假设目前数组大小为16,如果不进行这样运算的话,直接进行hash值与16-1=15的二进制与运算的话,其实只有hash值的后四位参与了运算,这样发生碰撞的概率会提高,而且高16位只有在数组很大的时候才能参与运算,所以用高16位和低16位进行运算,让高位也参与运算,在数组很小的时候增加运算可能性,减少碰撞。
此处甚是难以理解,但是举例如下。

得到 hash 值后 便是计算索引下标的步骤

p = tab[i = (n - 1) & hash])

如果map的长度是 length 那index的值就从 0 ~ length-1。所以index需要尽可能的平衡,也就是分布均匀,不能某些位置上存储特别多的数据,某些位置上又特别少。 解决办法:

取模计算
hash值为int,index需要映射到0 ~ length -1,最直观的使用取模运算, index = hash值 % length
位运算
(n - 1) & hash

这就是二进制算法的神奇之处,与上一个你想要映射的(偶数-1),那么就相当于求余
肯定是位运算的效率比较高!!!注意此时 n -1 是一个奇数

(3)为什么负载因子是0.75?

如果负载因子是0.5的话:有一半的数组空间会被浪费,随着数组的增大,浪费的越多。

如果负载因子是1的话:数组扩充是为了减少hash碰撞的次数,如果是1,需要每个数组下标都有值才会扩充,如果有一个数组下标迟迟没有值,很有可能其他下标的链表已经非常长了,已经经历过很多次的碰撞,也经历过很多次链表查找并比较的操作,大大影响性能

(4)为什么使用红黑树不使用平衡二叉树?

平衡二叉树追求绝对平衡,条件比较苛刻,左右两个子树的高度差的绝对值不超过1,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。
红黑树放弃了追求完全平衡,追求大致平衡,保证每次只需要三次旋转就能达到平衡,实现起来简单。
(5)数组扩充后,原来的节点放在新数组的哪里?
上面提到,数组扩充后大小是原来的两倍。扩充后会对每个节点进行重hash,从下图源码可以看到这个值只有可能存在两个地方,一个是原下标位置,另一个是原下标+原数组大小的位置

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { //节点hash值与原数组大小与运算,等于0后面操作把节点放在原下标位置
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
else {       //节点hash值与原数组大小与运算,不等于0后面操作把节点放在原下标+数组大小位置
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;          //将节点放入原下标位置
}
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; //将节点放入原下标+原数组大小位置

(6)HashMap进行put时放入头部还是尾部?
在jdk1.8之前是插入头部的,在jdk1.8中是插入尾部的。另外,
jdk1.8中Entry不见了
在jdk1.6中,HashMap中有个内置Entry类,它实现了Map.Entry接口;而在jdk1.8中,这个Entry类不见了,变成了Node类,也实现了Map.Entry接口,与jdk1.6中的Entry是等价的。

(参考:https://blog.csdn.net/ghsau/article/details/16843543
https://zhuanlan.zhihu.com/p/78249480
https://blog.csdn.net/qq_41291945/article/details/108308730)

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值