HashMap大揭密

        今天一个朋友在群里问我hashmap的数据结构是什么。我大概给说了下,刚好总结下,我们在开发过程中经常会用到HashMap,相信大家对它的基本使用方法是很了解了,但是你了解hashmap的底层数据结构是什么,hash的具体算法是什么,hash碰撞又是什么,带着这些问题,我来给大家解剖下熟悉又陌生的hashmap吧。

1.      hashmap底层数据结构

        数据结构在Java中常用的有两种,数组和链表。这里我们简单的介绍下数组和链表,数组开辟的是栈空间,链表是堆空间,数组方便读取,不方便插入,当我们要读取数组中的某个元素的时候,我们只需要知道他的下标就可以直接拿到了,但是我们要插入的话比较麻烦,我们要把数组插入到指定位置,那么该位置后面的所有元素都要靠后移动,而且普通的数组是不支持动态扩展大小的。链表中的一个元素除了记录了他的数据外还记录了后面的一个元素位置,这样我们在插入和删除的时候就比较方便,我们只需要维护附近元素的指向就可以。但是链表在查询的时候就比较麻烦,我们要找到自己要的元素需要从第一个开始遍历,只到找到与我们需要的元素相等的才行。Java中的ArrayList和LinkList就是数组和链表的具体表现,这里需要说明的是LinkList除了记录了下一个元素的位置同时还记录了上一个元素的位置。这里只是大概的介绍了下这两种数据结构,如果大家想了解具体细节,g.cn。


2.      具体算法和碰撞

扯得有点远了,下面我们来看正题:我们知道hashmap是采用K-V的形式来存放数据的,那么hashmap到底用的他们两个中的哪个呢?答案是散列表,意思就是两个的合体,具体是如何使用的呢。

先来上一段hashmap的源码:

import java.io.Serializable;
import java.util.AbstractMap;
import java.util.Map;
import java.util.HashMap.Entry;

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

    /**
     * The default initial capacity - MUST be a power of two.
     * 数组默认大小
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     * 数组最大值
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     * 默认加载因子,当HashMap的数据大小>=容量*加载因子时,HashMap会将容量扩容
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

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

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

    /**
     * The next size value at which to resize (capacity * load factor).
     * @serial
     * 当实际数据大小超过threshold时,HashMap会将容量扩容,threshold=容量*加载因子
     */
    int threshold;

    /**
     * The load factor for the hash table.
     *加载因子
     * @serial
     */
    final float loadFactor;

    /**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     * 线程安全考虑,修改次数
     */
    transient volatile int modCount;

首先hashmap是通过数组来存放数据的(table[]),默认的数组大小是16,当我们存放数据的时候,hashmap首先会对key进行hashcode,然后再hash,通过hash算法得到这个数据的数组下标i.然后会把这个数据,插入到table[],假如出现两个元素hash算出来的值一样,那么就放入同一个数组位置,就会出现hash碰撞,这里hashmap采用了链表来存放这些数据。新来的元素会排在旧的前边。这就是hashmap的数据结构。当数组的范围很小的时候,假如就16个位置,我们放入了50个元素,那么肯定会出现很多碰撞,这样我们存取数据的复杂度会从原来的O(1),直接变为O(n),当我们放入N个元素,那么复杂度就是O(n^2)。

我们来看源代码:

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

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }
    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);
    }

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

    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key.hashCode());
        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;
    }
  private V putForNullKey(V value) {
        for (Entry<K,V> 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;
    }



通过代码我们可以看到,hashmap本身会动态的扩展大小,所以我们上面担心的那种情况就不会出现了,但是你以为这样就万事大吉了么,错了!为什么呢,因为即使数组大小够用,我们的元素不同的key,算出来的hashcode相同,那么hash后一样是相同的,那么用它来算出的数组位置应该也是相同的,这样又出现了碰撞,比如枣庄和无锡的hash算出来是一致的,还有Aa和BB。假如我是个黑客,恶意的把这些hash算出来相同的值排列组合,AaAa,BBBB,AaBB,BBAa,然后提交表单到你的系统,那你的系统没几分钟CPU就100%了,这就是传说中的hash dos攻击。

       作为jdk本身来说,他也是很大程度的去避免出现碰撞了,比如从hashmap数组的大小定义就可以看到。你知道数组的大小为什么要用2的次幂么,除了因为计算机对2的次方算起来相当效率外,这里面是有玄机的,我们来分析看看。

假如数组的大小是17,那么forindex的length-1二进制为10000,那么无论

h & (length-1)

中的h是多少&完0也是0,这样最后一位无论如何都是0,那么00001,00011,00101,00111,01001….这些位置就无法存放数据了,这样就造成了空间的浪费,同时数组的实际存放位置也少了很多,hash碰撞的概率也就更高了。当数组长度为16时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。

3.      hashmap的读取

直接上源码:

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        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.equals(k)))
                return e.value;
        }
        return null;
    }


通过源码很清楚的可以看到,通过indexFor(hash, table.length)找到数组位置,然后通过对比key是否相等去链表中找到对应的value.

4.      rehash

源码:

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);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

5.Fail-Fast

我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map:注意到modCount声明为volatile,保证线程之间修改的可见性。这一点和ArrayList相同,不会在未知的过程中发现被修改后在抛错,刚开始就抛,这样效率也高了很多。


看了源码我们发现,resize是非常耗费性能的,新建一个数组然后把原来的数组复制到新的数组里面,而且需要重新Hash,因为数组容量变大,所以必须重新hash这样能保证减少碰撞。

建议:如果数据大小是已知的,那么在初始化hashmap的时候带上大小,而如果有并发操作,不要使用hashmap, 用ConcurrentMap,按照sun官方的说法,会引起闭环,最终导致CPU100%。具体并发是如何引起闭环的,有篇文章说的很详细:<疫苗:Java HashMap的死循环>


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值