zoukankan      html  css  js  c++  java
  • HashMap

    哈希表

    在介绍HashMap之前,先介绍一下哈希表的概念。

    哈希表(Hash table,也叫做散列表),是根据关键码值(Key Value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表

        记录的存储位置 = f(关键字)

    这里的对应关系 f 称为散列函数,又称为哈希(Hash函数)函数,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash Table)。

    哈希表就是把key通过一个固定的算法函数即将所谓的哈希函数转换成一个整型数字,然后就将改数字对数组的长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。

    数组的特点是:寻址容易、插入和删除困难

    链表的特点是:寻址困难、插入和删除容易

    那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们提到的哈希表,哈希表有多种不同的实现方法,接下来解释的是最常用的一种方法----拉链法,可以理解为“链表的数组”:

     
    左边很明显是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。

    初始HashMap

    之前讲了ArrayList、LinkedList,就这两者而言,反映的是两种思想:

    (1)ArrayList以数组形式实现,顺序插入、查找快,插入、删除慢

    (2)LinkedList以链表形式实现,顺序插入、查找较慢,插入、删除较快

    那么是否有一种数据结构能够结合上面两种的优点呢?答案就是HashMap

    HashMap是一种非常常见、方便和有用的集合,是一种键值对(K-V)形式的存储结构。

    四个关注点在HashMap上的答案

    关  注  点 结      论
    HashMap是否允许空 Key和Value都允许为空
    HashMap是否允许重复数据 Key重复会覆盖、Value允许重复
    HashMap是否有序 无序,特别说明这个无序指的是遍历HashMap的时候,得到的元素的顺序基本不可能是put的顺序
    HashMap是否线程安全 非线程安全

    源码解析

    HashMap的存储结构如上图所示(JDK1.8以后,当链表长度大于8的时候,链表会变为红黑树)。

    看一下HashMap的存储单元Entry的源码结构:

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

    HashMap是采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它里面包含next指针、key的hash值、value、key,其中next指针可以连接下一个Entry实体,HashMap是以此来解决hash冲突的问题的,因为HashMap是按照key的hash值来计算Entry在HashMap中存储的位置的,如果hash值相同,而key不相等,那么就用链表来解决这种hash冲突。

    构造函数

    HashMap()    //无参构造方法
    HashMap(int initialCapacity)  //指定初始容量的构造方法 
    HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子
    HashMap(Map<? extends K,? extends V> m)  //指定集合,转化为HashMap

    HashMap的KPI

    void                 clear()
    Object               clone()
    boolean              containsKey(Object key)
    boolean              containsValue(Object value)
    Set<Entry<K, V>>     entrySet()
    V                    get(Object key)
    boolean              isEmpty()
    Set<K>               keySet()
    V                    put(K key, V value)
    void                 putAll(Map<? extends K, ? extends V> map)
    V                    remove(Object key)
    int                  size()
    Collection<V>        values()

    HashMap的签名

    public class HashMap<K,V> extends AbstractMap<K,V>
        implements Map<K,V>, Cloneable, Serializable {

    从签名可以看出:

    (1)HashMap继承于AbstractMap类,实现了Map接口。Map是“key-value键值对”接口,AbstractMap实现了“键值对”的通用函数接口。

    (2)HashMap是通过“拉链法”实现的哈希表,它里面包含有几个重要的成员变量:table、size、threshold、loadFactor、modCount。

      table:是Entry[]类型的数组,Entry实际上是一个单向链表。哈希表的“key-value键值对”都是存储在Entry数组中的

      size:HashMap的大小,也是HashMap保存的键值对的数量

      threshold:HashMap的阀值,用于判断是否需要调整HashMap的容量。threshold的值 = 容量 * 加载因子,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍

      loadFactor:加载因子

      modCount:用来实现fail-fast机制的

    添加方法(JDK1.7)

    public V put(K key, V value) {
            if (table == EMPTY_TABLE) { //是否初始化
                inflateTable(threshold);
            }
            if (key == null) //放置在0号位置
                return putForNullKey(value);
            int hash = hash(key); //计算hash值
            int i = indexFor(hash, table.length);  //计算在Entry[]中的存储位置
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    
            modCount++;
            addEntry(hash, key, value, i); //添加到Map中
            return null;
    }

    在该方法中,添加键值对时,首先进行的是table是否初始化的判断,如果没有进行初始化(分配空间也就是Entry[]数组的长度为0)。然后进行key是否为null的判断,如果key == null,放置在Entry[]的0号位置。然后计算Entry[]数组的存储位置,判断该位置上是否已经有元素,如果已经有元素存在,则遍历该Entry[]数组上的单链表,判断key是否存在,如果key已经存在,则用新的value值,替换旧的value值,并将旧的value值返回;如果key不存在于HashMap中,程序将对应的key-value值,生成Entry[]实体,添加到HashMap中的Entry[]数组中。

    注意:JDK1.6和以前的版本,初始化时的容量是16,在JDK1.7以后,采用懒加载的方式,到真正放入元素的时候再初始化长度,长度也是16(使用无参构造器时)。

    addEntry()

    /*
     * hash hash值
     * key 键值
     * value value值
     * bucketIndex Entry[]数组中的存储索引
     * / 
    void addEntry(int hash, K key, V value, int bucketIndex) {
         if ((size >= threshold) && (null != table[bucketIndex])) {
             resize(2 * table.length); //扩容操作,将数据元素重新计算位置后放入newTable中,链表的顺序与之前的顺序相反
             hash = (null != key) ? hash(key) : 0;
             bucketIndex = indexFor(hash, table.length);
         }
    
        createEntry(hash, key, value, bucketIndex);
    }
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

    添加Entry[]的操作,在添加之前先进行容量的判断,如果当前容量达到了阀值,并且需要存储到Entry[]数组中,先进行扩容操作,扩充的容量为table长度的两倍,重新计算hash值和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反,然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。

    注意:在JDK1.8之前,新插入的元素都是放在了链表的头部位置,但是这种操作在高并发的环境下容易导致死锁,所以在JDK1.8及以后,新插入的元素都放在了链表的尾部。

    获取方法

    public V get(Object key) {
         if (key == null)
             //返回table[0] 的value值
             return getForNullKey();
         Entry<K,V> entry = getEntry(key);
    
         return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
         if (size == 0) {
             return null;
         }
    
         int hash = (key == null) ? 0 : hash(key);
         for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
             Object k;
             if (e.hash == hash &&
                 ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
          }
         return null;
    }

    在get方法中,首先计算hash值,然后调用indexFor方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回Entry.value值。

    删除方法

    public V remove(Object key) {
         Entry<K,V> e = removeEntryForKey(key);
         return (e == null ? null : e.value);
    }
    final Entry<K,V> removeEntryForKey(Object key) {
         if (size == 0) {
             return null;
         }
         int hash = (key == null) ? 0 : hash(key);
         int i = indexFor(hash, table.length);
         Entry<K,V> prev = table[i];
         Entry<K,V> e = prev;
    
         while (e != null) {
             Entry<K,V> next = e.next;
             Object k;
             if (e.hash == hash &&
                 ((k = e.key) == key || (key != null && key.equals(k)))) {
                 modCount++;
                 size--;
                 if (prev == e)
                     table[i] = next;
                 else
                     prev.next = next;
                 e.recordRemoval(this);
                 return e;
             }
             prev = e;
             e = next;
        }
    
        return e;
    }

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

    上述源码皆是JDK1.7的源码。

    JDK1.8的改变

    在Jdk1.8中HashMap的实现方式做了一些改变,但是基本思想还是没有变得,只是在一些地方做了优化,下面来看一下这些改变的地方,数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式,在性能上进一步得到提升。

    数据存储方式

    当链表长度大于8的时候,链表将会转化为红黑树存储,对极端情况下的HashMap性能大大提升。

    put方法简单解析

    public V put(K key, V value) {
        //调用putVal()方法完成
        return putVal(hash(key), key, value, false, true);
    }
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判断table是否初始化,否则初始化操作
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //计算存储的索引位置,如果没有元素,直接赋值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //节点若已经存在,执行赋值操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //判断链表是否是红黑树
            else if (p instanceof TreeNode)
                //红黑树对象操作
                e = ((TreeNode<K,V>)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) { // existing mapping for 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;
    }

     HashMap的table为什么是 transient的

     一个很小的细节:

    transient Node<K,V>[] table;

    看到table用了transient修饰,也就是说table里面的内容全都不会被序列化,不知道大家有没有想过这么写的原因?

    在我看来,这么写是非常必要的。因为HashMap是基于HashCode的,HashCode作为Object的方法,是native的:

     public native int hashCode();

    这意味着的是:HashCode和底层实现相关,不同的虚拟机可能有不同的HashCode算法。再进一步说得明白些就是,可能同一个Key在虚拟机A上的HashCode=1,在虚拟机B上的HashCode=2,在虚拟机C上的HashCode=3。

    这就有问题了,Java自诞生以来,就以跨平台性作为最大卖点,好了,如果table不被transient修饰,在虚拟机A上可以用的程序到虚拟机B上可以用的程序就不能用了,失去了跨平台性,因为:

    1、Key在虚拟机A上的HashCode=100,连在table[4]上

    2、Key在虚拟机B上的HashCode=101,这样,就去table[5]上找Key,明显找不到

    整个代码就出问题了。因此,为了避免这一点,Java采取了重写自己序列化table的方法,在writeObject选择将key和value追加到序列化的文件最后面:

    private void writeObject(java.io.ObjectOutputStream s)
            throws IOException
    {
    Iterator<Map.Entry<K,V>> i =
        (size > 0) ? entrySet0().iterator() : null;
    
    // Write out the threshold, loadfactor, and any hidden stuff
    s.defaultWriteObject();
    
    // Write out number of buckets
    s.writeInt(table.length);
    
    // Write out size (number of Mappings)
    s.writeInt(size);
    
        // Write out keys and values (alternating)
    if (i != null) {
     while (i.hasNext()) {
        Map.Entry<K,V> e = i.next();
        s.writeObject(e.getKey());
        s.writeObject(e.getValue());
        }
        }
    }

     而在readObject的时候重构HashMap数据结构:

    private void readObject(java.io.ObjectInputStream s)
             throws IOException, ClassNotFoundException
    {
    // Read in the threshold, loadfactor, and any hidden stuff
    s.defaultReadObject();
    
    // Read in number of buckets and allocate the bucket array;
    int numBuckets = s.readInt();
    table = new Entry[numBuckets];
    
        init();  // Give subclass a chance to do its thing.
    
    // Read in size (number of Mappings)
    int size = s.readInt();
    
    // Read the keys and values, and put the mappings in the HashMap
    for (int i=0; i<size; i++) {
        K key = (K) s.readObject();
        V value = (V) s.readObject();
        putForCreate(key, value);
    }
    }

    一种麻烦的方式,但却保证了跨平台性。

    这个例子也告诉了我们:尽管使用的虚拟机大多数情况下都是HotSpot,但是也不能对其它虚拟机不管不顾,有跨平台的思想是一件好事。

    HashMap和Hashtable的区别

    HashMap和Hashtable是一组相似的键值对集合,它们的区别也是面试常被问的问题之一,我这里简单总结一下HashMap和Hashtable的区别:

    1、Hashtable是线程安全的,Hashtable所有对外提供的方法都使用了synchronized,也就是同步,而HashMap则是线程非安全的

    2、Hashtable不允许空的value,空的value将导致空指针异常,而HashMap则无所谓,没有这方面的限制

    3、上面两个缺点是最主要的区别,另外一个区别无关紧要,我只是提一下,就是两个的rehash算法不同,Hashtable的是:

    private int hash(Object k) {
        // hashSeed will be zero if alternative hashing is disabled.
        return hashSeed ^ k.hashCode();
    }

     这个hashSeed是使用sun.misc.Hashing类的randomHashSeed方法产生的。HashMap的rehash算法上面看过了,也就是:

    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

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

    HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法,这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方;为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1  实际就是n个1:

    例如长度为9时候,3&(9-1)=0  2&(9-1)=0 ,都在0上,碰撞了;

    例如长度为8时候,3&(8-1)=3  2&(8-1)=2 ,不同位置上,不碰撞;

    其实就是按位“与”的时候,每一位都能  &1  ,也就是和1111……1111111进行与运算

     0000 0011     3

    & 0000 1000      8

    = 0000 0000      0

     0000 0010     2

    & 0000 1000    8

    = 0000 0000      0

     ----------------------------------------------------------------------------

     0000 0011     3

    & 0000 0111     7

    = 0000 0011       3

     0000 0010      2

    & 0000 0111      7

    = 0000 0010      2

    当然如果不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了);
    总结:

      (1)为了更高的效率(位移运算比取余速度快,效率高)

      (2)为了更好的利用空间,减少重复率,比如说:当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

    capacity 永远都是 2 次幂,那么如果我们指定 initialCapacity 不为 2次幂时呢,是不是就破坏了这个规则?

    答案是不会的,HashMap的tableSizeFor方法做了处理,能保证n永远都是2次幂。

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        //cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000)
        int n = cap - 1;
        //n = (00010000 | 00001000) = 00011000
        n |= n >>> 1;
        //n = (00011000 | 00000110) = 00011110
        n |= n >>> 2;
        //n = (00011110 | 00000001) = 00011111
        n |= n >>> 4;
        //n = (00011111 | 00000000) = 00011111
        n |= n >>> 8;
        //n = (00011111 | 00000000) = 00011111
        n |= n >>> 16;
        //n = 00011111 = 31
        //n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

    总结:可以确保capacity为大于或等于cap的最接近cap的二次幂,比如cap=13,则capacity=16;cap=16,capacity=16;cap=17,capacity=32。

    参考:https://www.cnblogs.com/xrq730/p/5030920.html 

  • 相关阅读:
    Myeclipse下使用Maven搭建spring boot项目
    Dubbo+Zookeeper视频教程
    dubbo项目实战代码展示
    流程开发Activiti 与SpringMVC整合实例
    交换两个变量的值,不使用第三个变量的四种法方
    数据库主从一致性架构优化4种方法
    数据库读写分离(aop方式完整实现)
    在本地模拟搭建zookeeper集群环境实例
    box-sizing布局
    盒子模型
  • 原文地址:https://www.cnblogs.com/Joe-Go/p/10401784.html
Copyright © 2011-2022 走看看