zoukankan      html  css  js  c++  java
  • P7 HashMap数据结构

          如果想透彻理解什么是HashMap,首先需要知道HashMap的数据结构是什么;其次需要厘清它能做什么,即它的功能;最后,还需要知道HashMap怎么实现这些功能的。下面我们针对这三个方面展开剖析。

    什么是HashMap

          HashMap是基于哈希表的、Map接口的非同步(synchronized)实现。此实现提供所有可选的映射操作,并允许使用null值和null键。它存储的是键值对,速度很快。它不保证映射的顺序,特别是不保证该顺序恒久不变。

          HashMap实际上是一个“链表散列”的数据结构,关于其底层实现,在Java7中依靠数组+单链表实现,自Java8开始依靠数组+单链表+红黑树实现。它平衡了多种数据结构的优缺点,实现了寻址容易,插入删除也容易。

     

    Java 7中HashMap的数据结构

          在Java 7中,它的数据结构示意图如下:

            数组中的每个元素都是Entry<K,V>类型的一个对象。当出现哈希冲突的时候,把发生冲突的元素放入相同散列地址中,构造成一个单链表。Entry<K,V>定义如下所示:

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key; 
        V value; 
        Entry<K,V> next; //
        int hash; //
        /** * Creates new entry. */ 
       Entry(int h, K k, V v, Entry<K,V> n) {
          value = v; 
          next = n; 
          key = k; 
          hash = h; 
    }

          在代码①,next存储指向下一个Entry的引用。代码②是对key的hashCode值进行哈希运算后得到的数组索引,存储在Entry中以避免重复计算。

          简单来说,HashMap由数组+单链表组成的,左侧的数组是HashMap的主体,也称为哈希数组,数组的每个元素都是一个单链表的头节点;右侧的单链表则是主要为了解决哈希冲突而存在的,如果根据哈希函数定位到的哈希地址不含链表(当前entry的next指向null),那么对于查找和添加等操作速度很快,仅仅需要一次寻址即可。如果定位到的数组包含链表,则对于添加操作,其时间复杂度为O(n),需要首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对判断是否相等。所以,考虑到时间复杂度,在HashMap中链表出现的越少,性能就会越好。

     

    Java 8中HashMap的数据结构

          在Java 8中,HashMap的数据结构是数组+单链表+红黑树。之所以引入红黑树,是因为遍历单链表的时间复杂度是O(n),而红黑树的是O(logn)。它的数据结构示意图如下:

           从JDK8开始,HashMap将插入的键值对封装在Node对象中,每个Node对象包含四个属性——hash值,键对象key,值对象value和指向下一个元素的next。Node是HashMap的一个内部类,实现了Map.Entry接口,本质上就是一个映射(键值对)。Node对象定义如下:

    static class Node<K,V> implements Map.Entry<K,V> { 
        final int hash;
        final K key; 
        V value;
        Node<K,V> next; 
    }

          红黑树中,节点类型TreeNode定义如下:

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { 
          TreeNode<K,V> parent; // red-black tree links 
          TreeNode<K,V> left; 
          TreeNode<K,V> right; 
          TreeNode<K,V> prev; // needed to unlink next upon deletion 
          boolean red; 
    }

          红黑树比链表多了四个变量,parent父节点、left左节点、right右节点、prev上一个同级节点,红黑树内容较多,不在赘述。当遇到哈希碰撞时,新增的Node会被next变量指向,组成单链表。当该单链表的长度超过8时,将单链表转换为红黑树。而内部类TreeNode是Node的子类,关系图如下:

           下图同样很形象的展示了HashMap的数据结构(数组+链表+红黑树),桶(bucket)中的结构可能是链表,也可能是红黑树,红黑树的引入是为了提高效率。

            有了数组+链表+红黑树3个数据结构,我们可以大致联想到HashMap的实现了。首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。 

    扩容时如何确定数组大小

          在介绍如何确定数组索引之前,先介绍三个本文即将用到的位运算符:

          1. >>> : 无符号右移,忽略符号位,空位都以0补齐;

          2. ^ : 按位异或运算,第一个操作数的第n位于第二个操作数的第n位相反,那么结果的第n为也为1,否则为0;

          3. & : 按位与运算,针对二进制,只要有一个为0,就为0。

           下面分析求table大小的方法:

        /**
         *计算大于给定容量的最小的2的幂次,扩容后的容量必须是2的幂次
         */
        /**
         * Returns a power of two size for the given target capacity.
         */
        static final int tableSizeFor(int cap) {
            //从二进制cap的最左边的1开始,全部设置为 1,得到 n,这样 n + 1就是要求的值
            int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); // cap - 1 再计算避免cap假设刚好是8,但 n=16 这是不对的
            // cap 是 0 或 1 的时候 n 是 -1,此时返回 1
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        }

          这就保证了HashMap扩容后,容量总是2的幂次。

    确定元素数组索引位置

          不管增加、删除或者查找键值对,定位元素存储的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀,尽量使得每个位置上的元素数量只有一个,那么当我们用哈希函数求得这个位置的时候,马上就可以知道数组索引所对应的元素就是我们想要的,不用遍历链表,大大优化了查询的效率。先看看源码的实现:

        /**
         * Computes key.hashCode() and spreads (XORs) higher bits of hash
         * to lower.  Because the table uses power-of-two masking, sets of
         * hashes that vary only in bits above the current mask will
         * always collide. (Among known examples are sets of Float keys
         * holding consecutive whole numbers in small tables.)  So we
         * apply a transform that spreads the impact of higher bits
         * downward. There is a tradeoff between speed, utility, and
         * quality of bit-spreading. Because many common sets of hashes
         * are already reasonably distributed (so don't benefit from
         * spreading), and because we use trees to handle large sets of
         * collisions in bins, we just XOR some shifted bits in the
         * cheapest possible way to reduce systematic lossage, as well as
         * to incorporate impact of the highest bits that would otherwise
         * never be used in index calculations because of table bounds.
         */
        static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//
    }
    //Java7的源码,Java8没有这个方法,但是实现原理一样
    static int indexFor(int h, int length) {
    return h & (length-1); //取模运算
    }

          函数hash(Object key)中,代码①的实现可以拆分为如下两步:

          第一步计算hashCode:h = key.hashCode();

          第二步应用位运算: h ^ (h >>> 16)。

          从Java 8开始,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么设计可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与散列地址的计算中,同时不会有太大的开销。

          函数hash(Object key)在Java 7和Java 8之后的版本中都有。至于indexFor函数,则是计算数组索引的最后一步取模运算:h & (length-1)。

    Java 7中封装了indexFor函数,但在Java 8中不再单独抽象为一个方法,但是采用了同样的计算原理。所以最终存储位置的确定流程是这样的:

           上述两个函数共同构成了HashMap中计算元素散列地址的哈希函数。这个函数被定义的非常巧妙,它通过h & (table.length -1)来得到给定元素的哈希地址,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。 

    Reference

    https://www.jianshu.com/p/aa715ff9a572

    http://www.importnew.com/20386.html

    https://www.cnblogs.com/xiaoxi/p/7233201.html

     

  • 相关阅读:
    在peoplecode中直接调用SQR
    想起了李雷和韩梅梅
    结婚两周年纪念
    Unix Command Summary
    在PeopleSoft中如何隐藏菜单,导航栏,以及其他定制化链接
    那些朋友们
    整天工作的人为何当不了富翁
    ActiveX简单介绍
    SQL UNION
    Java程序设计问答大全(一)
  • 原文地址:https://www.cnblogs.com/east7/p/12797457.html
Copyright © 2011-2022 走看看