关闭

HashMap的那些事

标签: hashmapjava
314人阅读 评论(0) 收藏 举报
分类:

关于HashMap你们肯定不会陌生,它是常用的一种数据类型,在java开发中占据了重要的地位。我觉得这样一个比喻非常的好:HashMap就像一个陶瓷碗,我们经常用到它,却又经常不小心将它摔碎。

在一些书中我们经常看到研究问题的一些基本思路:你是谁?你从哪里来?你去干啥类?这篇浅析HashMap的文章同样也要解决这几个问题:HashMap是谁(源码解读);HashMap是干啥的?(特点,使用场景);

当然,我发现自己有在废话了,还是开始吧!!

(1)HashMap的数据结构

HashMap底层是一个数组结构,数组中的每一项又是一个链表。当新建一个 HashMap 的时候,就会初始化一个数组,所以HashMap是数组和链表的完美的结合体。如下图所示:

这里写图片描述

再来看一下源码:

transient Entry[] table;

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

HashMap内部定义了一个静态的内部类Entry,Entry有三个重要的属性 Key、Value、next,而其中next为指向下一个Entry的引用,这样就构成了一个链表。

(2)HashMap的put实现
如下说是为HashMap的Put方法的源码,先简单梳理一下put方法的过程:

  • 向HashMap中put元素的时候,先根据key得到其hashCode,在对得到的hashcode进行二次hash;
  • 根据hash值找到这个元素在数组中的位置(即下标)
  • 如果对应数组位置没有元素。直接将该元素放在数组的该位置上
  • 如果对应数组位置已经存放了其他的元素,那么开始遍历这个链表,如果在链表找到对应的元素则将新的元素的value替换就旧的元素的value;如果没有找到的话,将该元素放在链表的头部,最先加入的放在链表的尾部。
 public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        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);
        return null;
    }

举个例子:现在有一个空的map,假设”小明”的hash得到的index=0,将小明存入map,调用put方法之后:

Entry[0]="小明"

不一会,来一个“小猫”,“小猫”的hash得到index也为0,那么,现在结果是这样的:

Entry[0]="小猫"
"小猫".next="小明"

又过一会,来一个“小狗”,“小狗”的hash得到index也为0,那么,现在结果是这样的:

Entry[0]="小狗"
"小狗".next="小猫"
"小猫".next="小明"

也即是说数组存储的都是最新的插入数据

在这里,就出现了几个非常严肃的问题

  • 为啥要二次hash

这是干啥子,直接调用Object的hashcode方法不就完事了吗?大家仔细看一下源码:

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

二次hash的根源还是JVM生成的hashcode的低字节冲突概率较大,为了提高性能,使hashcode尽可能的散列,hashmap在hash()方法中加入高位运算。

为啥根据hash值获取数组下标时不采用取模的方法?

先来看看HashMap中indexFor方法

 static int indexFor(int h, int length) {
        return h & (length-1);
    }

无非就是根据hash获取元素在数组的位置,通常来说,我们首先想到是把hash值对数组长度取模运算,

return hash % length;

这样一来,元素的分布总的来说还是比较均匀的。但是,我们要知道”模”运算的消耗是比较大的,进行位操作消耗比较低。但是,我们又不得不怀疑这个真的管用吗?真的与取模运算的效果是等价的吗?

在继续刨根问底的时候又引入下一个问题!

为啥hashmap的size为2的n次方?

回答了这个问题,同样回答了上面取模的问题。站在上一个问题的角度来讲,其实,追根溯源原因很简答就是:hashcode获取数组的index尽量服从均匀分布!

先来做一组实验看看!hashcode从0-100,length大小为14

        int  arry[]=new int[100];
        for(int i=0;i<100;i++){
            arry[i]=i;
        }
        for(int tmp:arry){
            System.out.println(tmp&13);
        }
        for(int tmp:arry){
            System.out.println(tmp%14);
        }       

统计之后的结果为:

这里写图片描述

这里写图片描述

很明显的发现该情况下取模方式的结果分布更加均匀!
假设length为16呢?两种方式是等价的!
这里写图片描述

为什么会这样呢?我们来仔细的分析一下!

2^n转换成二进制就是1+n个0,减1之后就是0+n个1,如16 -> 10000,15 -> 01111,而&位运算的规则,都为1(真)时,才为1,因此,length-1的二进制1越多,越是平均分布。到此,我明白了原因在当HashMap的大小为 2^n时,&与%的效果是等价的,但是&的处理性能高于%,我想这可能是HashMap作者考虑的,但是一味的限定hashmap的大小为2^n,有点浪费空间,但是我还是对这种设计由衷的赞叹,太精妙了。(hashtable就是粗暴的采用取模运算)

(3)HashMap的get实现

分析完put方法之后,我们来看看get方法

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
    }

其中,getEntry(key)为

    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;
    }

从上面的源代码中可以看出:

  • 向HashMap中get元素的时候,先根据key得到其hashCode,在对得到的hashcode进行二次hash;
  • 根据hash值找到这个元素在数组中的位置(即下标);
  • 如果对应数组位置没有元素,返回null;
  • 如果对应数组位置已经存放了其他的元素,那么开始遍历这个链表,如果在链表找到对应的元素返回其value,

(4)HashMap的初始化

HashMap初始化必将常用的有三个构造函数,在介绍之前首先介绍一下capacity和load factor。capacity表示HashMap的最大容量,即为底层数组的长度;load factor为负载因子,它是散列表的实际元素数目(n)/ 散列表的容量(m), 负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

HashMap()默认的capacity为16,load factor为0.75。

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

HashMap(int initialCapacity)构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

面试总经常会问:如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

HashMap()默认的capacity为16,load factor为0.75,在HashMap内有一个字段threshold,它表征了HashMap的最大容量

threshold = (int)(capacity * loadFactor); 

当hashmap的大小超过12的时候,将会创建一个长度为32的bucket数组(原来hashmap的两倍),来重新调整map的大小,并将原来的对象放入新的数组。这就引入了下一个问题rehash!

(5)HashMap的rehash过程
上面已经将了,当HashMap大小超过了最大容量threshold,将会发生resize(rehash)的 过程。

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }

这是put方法中的addEntry()方法,如果size >= threshold,将会执行resize(2 * table.length),再来看看resize()方法:

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

我么可以看出:

  • 首先将会创建一个原来数组长度2倍的新的数组new Entry[newCapacity]
  • 然后调用transfer()方法,将原来数组中数据重新装入新的数组
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

resize(rehash)是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数,减小resize(rehash)的过程,这样能够有效的提高HashMap的性能。

(6)HashMap的线程安全问题

那么,问题又来了,如果两个线程都发现HashMap需要重新调整大小,它们会同时试着调整大小,这样就会产生条件竞争。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历。如果条件竞争发生了,那么就死循环了。

这时候HashTable 与ConcurrentHashMap到了出场的时候了!

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:149100次
    • 积分:2208
    • 等级:
    • 排名:第17238名
    • 原创:62篇
    • 转载:1篇
    • 译文:2篇
    • 评论:15条
    个人说明
    实时计算,专注于storm/spark、 hbase、redis、zookeeper、kafka、presto与java开发
    文章分类
    最新评论