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 

posted on 2019-02-19 21:32  AoTuDeMan  阅读(326)  评论(0编辑  收藏  举报

导航