深入理解HashMap

Hash

哈希,译作散列,或哈希。就是把任意长度的输入,通过散列算法(hash算法),变换成固定长度的输出,这个输出的值就是哈希值。显然这是一个映射的过程。

hashCode()

再来看一看HashCode,这是一个方法,该方法返回一个特殊的值,在java中会返回一个整数,用来判断是否是两个相同的对象,和equals方法有紧密的联系:

  • HashCode主要用于提供快捷的查找,在HashTable和HashMap中都有使用,HashCode是用来在散列存储结构中确定对象的存储地址的(之所以这样说是因为index的计算与hashCode息息相关)
  • 如果使用equals(Objetc)方法,两个对象相等,那么这两个对象调用hashCode方法返回的值一定是相等的
  • 如果两个对象中的equals方法被重写了,那么一定也要按照同样的方法来重写hashCode方法(这是为了保持hashCode方法的常规协定,规定了相等对象必须有相同的hashCode值)
  • 借用网上看来的文章的一句话:两个对象的hashCode相同(其实更应该说成通过hashCode计算出的index相同),不代表就是同一个对象/两个对象相同,在hash存储结构中,这只说明了两个对象发生了冲突,被分配在了同一个桶里面。java判断两个对象是否相同还会判断对象引用中存储的地址是否相同(默认)

Hash函数

hash函数,用来计算出哈希值的函数,通常情况下,每一个对象都有自己单独的哈希值,通过hash函数计算出后,可以做到唯一识别。虽然有可能会有冲突的情况出现,出现了同一个hash值,但概率是微乎其微再来n个微乎其微…..
hash函数的用途有这么几个:可以这么说,hash就是找到一种数据内容和数据存放地址之间的映射关系。

  • 文件校验:通过对文件摘要,可以对文件进行校验,一定程度上能检测并纠正数据传输中的信道误码,但不能防止对数据的恶意破坏
  • 数字签名:在数字签名协议中,用的最多的单向散列函数可以产生一个机构的数字签名
  • 数据结构中提供快速查找的功能:常用的数据结构HashMap和HashTable会使用到Hash函数来产生hash值,是组成HashMap优越性能必不可少的一环

HashMap

在分析这个HashMap之前我们先来看一看数组和链表,我们都知道,数组提供了很好的查找性能,因为数组空间是连续的,查找起来很方便,但是在数据的插入和删除时,性能就不佳了;再看链表,它的存储空间是离散的,所以在数据的插入删除时,性能很高,但是当论到查找时,其性能就不行了。
综上所述,我们总是在面对问题时,根据自己的需求来使用不同的数据结构,这是权衡和妥协的结果。那么我们如果能使用到一种数据结构,它提供良好的查找性能,又可以很方便的插入删除。于是乎,把这两种数据结构组合起来就有了我们这个HashTable。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
深入理解HashMap_第1张图片
从图中可以看出,这是由数组和链表组成的数据结构,在数组中每个元素存储的是一个链表的头指针,把一个个数据存放到相应的位置,就需要由hash函数来计算了,一般是采用index = hash(value)%length计算出元素应该放到对应下标的数组中的位置。比如,如果value为5,数组长度为10,则计算出的下标位置就是5%10=5,这个值应该放到下标为5的元素中。当然了,如果俩个值计算出存放的位置相同了,就以后存入的值为头节点,以链表的形式存入,以此类推

现在回过头来看看HashMap,它其实也是一个线性的数组实现的,所以可以理解为其存储的数据结构就是一个线性数组。但是有一点我们需要注意的就是,HashMap是按照键值对来存取数据的,这一点怎么可能通过数组或是链表来实现呢?

深入到HashMap的源码中去看,对照着资料,发现在HashMap中存取数据的关键有一个叫做Map.Entry的内部接口很是关键,再去看Entry,发现它被定义为Entry,而Map.Entry

     /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table;

HashMap存取的实现

在“线性数组”的基础上如何做到随机存储呢:重点是确定键值对的存储位置,这里是希望HashMap里面的元素尽量离散分布,使每个位置上的元素只有一个。当使用hash算法求出这个位置时,马上就可以获取对应位置的值,而不用取遍历链表。也与hash方法的离散性能密切相关

// hash jdk1.8
static final int hash(Object key) {  
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

简单说起来,这里的Hash算法本质上就是三步:取key的hashCode的值、高位运算、取模运算
对于任意对象,只要hashCode返回值相同,那么程序调用方法所计算的Hash码时一样的,把hash值对数组的长度取模运算,这样元素的分布相对来说是比较均匀的。在上面的方法中,通过把hashCode返回值高16位和低16位与计算,达到了hashCode返回值取模数组长度的效果。因为在HashMap底层数组中,length总是2的n次方(不够的用null填充),此时使用hashCode返回值与数组长度进行与运算依然达到了上述的效果,这是jdk1.7中的实现方法,在1.8中高16位与低16位进行与运算是优化的算法,能保证在hashCode返回值很大时,高低Bit都会参与到hash运算中,并且不会产生较大的开销

put

我们知道HashMap中键 Key一定是唯一的,那么当再次往HashMap中存入键相同的键值对时,上一次存入的键值对就会被覆盖。但是如果两个键值对的index值一样时,HashMap会把先存入的值放入链表的尾部,最新加入的值则是该线性数组中每个下标对应的链表的首元素,以此类推。
需要注意到的是,jdk1.8新增了HashMap链表中节点的个数对于8个时,转为红黑树的存储方式
查看HashMap中的put方法源码:

 public V put(K key, V value) {
        // 进行hash运算
        return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node p; int n, i;
        // 判断键值对数组table是否为空或null,否则进行resize扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 根据键值key计算hash得到插入位置的索引
        if ((p = tab[i = (n - 1) & hash]) == null)// p被赋值
            tab[i] = newNode(hash, key, value, null);
        else {
            Node e; K k;
            // 判断键值对中key是否存在(相同),存在直接覆盖,相同指hashCode和equals
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 判断是否为树,是的话直接插入新结点
            else if (p instanceof TreeNode)
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            // 链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 如果链表的长度大于8就 转化为红黑树处理
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // key已经存在,直接覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // 存在key的映射
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 插入成功判断是否超出了最大容量,是就进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

reszie的源码是将原来数组的容量扩大一倍,这个过程是一个十分消耗性能的过程,所以在使用中最好定一个预定的最大值,避免HashMap进行频繁的扩容。默认的负载因子是0.75

注意

还一个小细节就是,每次put入键值对时,都是先比较key的hashCode,再去使用equals比较key,这样可以节省查重的效率

get

首结点都是Entry类型的键值对

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

final Node getNode(int hash, Object key) {
        Node[] tab; Node first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // 先检查链表中的首结点
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first; // 判断出了与key相同(hashCode和equals)
            if ((e = first.next) != null) {
                // 继续根据hash查找
                if (first instanceof TreeNode)
                    return ((TreeNode)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;
    }

null key

null key总是放在Entry[]数组的第一个元素

private V putForNullKey(V value) {
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

    private V getForNullKey() {
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

获得索引值index

HashMap存取时需要计算索引index来确认到Entry[]数组取元素的位置,也就是获取数组下标的过程

     /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

按位取并,作用上相当于取模:index = hashcode % table.length

hashtable初始大小

在调用HashMap的无参构造方法时,初始大小是16。当后续大小改变时,table初始大小总是2的n次方(没有填充满就空着)

Hash冲突

我们总是希望整个HashMap是一个尽量离散的优秀结构,用尽量少的空间存储尽量多的数据,且其查找增删的性能依据很高效。这个是一个复杂的平衡过程,和负载因子相关,和解决hash冲突的办法相关:hash冲突是指两个key被分配到了同一个桶中

  • 开放定址法(线性探查再散列、二次探查再散列、为随机探查再散列)
  • 再哈希法
  • 链地址法(拉链法)
  • 建立一个公共的溢出桶
    java中的HashMap使用的就是拉链法,如前面图所示

再散列过程 rehash

当哈希表的容量超过默认的大小时,就需要将所有的元素换一个新的“桶”来存储,这个新的桶中的键值对存放的位置会发生改变,需要重新根据新桶的大小来重新计算各个键值对的索引位置,这个过程就叫做rehash

谈一谈血与泪

之所以新加上这个片段就是因为真是彻底的被自己的记性教育了,这真是血淋林的教训啊,已经不记得有几次面试时答错了,这里总结记录一下:

  • HashMap是非线程安全的,HashTable才是线程安全的
  • HashMap中允许有null键值对,HashTable不允许

总结

此次深入探究java中的HashMap查阅了不少资料和源码,感谢先行者的指引,这里仅是个人愚见,如有异议,欢迎联系
HashMap实现原理分析
java8重新认识HahsMap

深入理解HashMap_第2张图片

你可能感兴趣的:(Java)