HashMap重点详解

    Map即映射表通常称为散列表。开发中经常使用到这种数据结构,Java中HashMap和ConcurrentHashMap被用到的频率较高,本文重点说下HashMap的实现原理以及设计思路。html

    HashMap的本质是一个数组,数组的每一个索引被称为桶,每一个桶里放着一个单链表,一个节点连着一个节点。很明显经过下标来检索数组元素时间复杂度为O(1),并且遍历链表的时间复杂度是常数级别,因此总体的查询复杂度仍为O(1)。咱们先来看下HashMap的成员属性:java

    

    // 默认的初始容量是16,必须是2的幂(这点很重要,后面讲述缘由)
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 存储数据的Entry数组
    transient Entry[] table;

    // HashMap的大小,它是HashMap实际保存的键值对的数量
    transient int size;

    // HashMap的阈值(threshold = 容量*加载因子),就是经过它和    
    //size进行比较来判断是否须要扩容
    int threshold;

    // 加载因子实际大小
    final float loadFactor;

    // HashMap被改变的次数(用于快速失败,后面详细讲)
    transient volatile int modCount;

    成员属性的意义如上所述,咱们再来看下它们修饰符的设计含义:table和size以及modCount都被transient所修饰,transient为短暂的意思,java中只能用来修饰类成员变量,做用是对象序列化时被修饰的字段不会被序列化到目的地。很容易想到:map只要执行put或remove操做后三者的值都会产生变化,对于这种状态常变(短暂)的属性咱们不必在对象序列化时将其值带入。此外,modCount还被volitile修饰,这个关键字主要做用是使被修饰的变量在内存中的变化可被多线程所见,由于modCount用于快速失败机制,因此写线程执行时带来的变化需及时被读线程知道。数组

    咱们再看下Entry类:数据结构

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        // 指向下一个节点
        Entry<K,V> next;
        final int hash;

        // 构造函数。
        // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
    }

每一个桶的Entry对象其实就是指的单链表,Entry做为hashMap的静态内部类,实现了Map.Entry<K,V>接口。设计的很硬气,全部的get&set都是final,不容许再被使用者重写重定义了。多线程

    研究一种数据结构,知道了它的基本组成,就可进一步了解它的存取机制:map的get,put,remove。map不管是增删查,经历的第一步就是定位桶的位置,即经过对象的hashCode(其实map中又再次hash了一遍)来取模定位,而后遍历桶中的链表元素进行equals比较。因此,我在这里重点说下hashCode()和equals(Object o)两个方法的关联。并发

    常说hashCode是equals的必要不充分条件,这个说法主要就是根据散列表来的。不重写的状况下,hashCode默认返回对象在堆内存中的首地址,equals默认比较两个对象在堆内存中的首地址。就equals而言,这种比较方式在实际业务中基本无心义,咱们判断两个对象是否相等,一般根据他们的某些属性值是否相等来判断,就像根据ID和name咱们就能够判定一个员工的惟一性。eclipse或者idea如今均可默认为你的model生成equals方法,以下所示:app

class Animal{
    private int id;

    private String name;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || o.getClass()!=this.getClass()) return false;

        Animal animal = (Animal) o;

        if (id != animal.id) return false;
        return name != null ? name.equals(animal.name) : animal.name == null;
    }

}

流程:若是首地址都相等那确定就是一个对象,直接返回true,不等就继续判断是否同属一个类,不是一个类那根本就不用继续判断直接false。这里仍是有争议的,由于有的写法是  !(o  instanceof  Animal),二者的区别会在继承中体现出来,好比我再建立一个子类Dogeclipse

class Dog extends Animal{
    private double weight;

    public double getWeight() {
        return weight;
    }

    public void setWeight(double weight) {
        this.weight = weight;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;

        Dog dog = (Dog) o;

        return Double.compare(dog.weight, weight) == 0;
    }

}

Dog中添加了一个weight属性,并在基类Animal的基础上再次重写了equals方法。看下面一段代码:ide

        Animal animal=new Animal();
        animal.setId(1);
        animal.setName("dog");
        Dog dog = new Dog();
        dog.setId(1);
        dog.setName("dog");
     dog.setWeight(1); System.out.print(animal.equals(dog));

若是按照  getClass() != o.getClass()  这个逻辑,二者equals就直接false了,而按照!(o  instanceof  Animal)这个逻辑最终会返回true。理论讲应该返回false的,不然weight这个字段的意义呢?被dog吃了?因此当该类下有子类时,equals中最好采用getClass()这种判断方式。再看hashCode():函数

    @Override
    public int hashCode() {
        int result = id;
        result = 31 * result + (name != null ? name.hashCode() : 0);
        return result;
    }

这时候就要思考为何hashCode值取决于ID和Name字段,咱们知道在map里寻找元素经过equals比较只是第二步骤,首要步骤是先定位到桶的位置(hash&length-1),若是两个本equals的对象连hashCode都不相等,那就很容易形成下述3种状况:

1:get(key)的时候取不出来

2:put(k,v)的时候存了重复值

3:remove(key)的时候删不掉

接下来,再了解下HashMap的构造方法:

    // 指定“容量大小”和“加载因子”的构造函数
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // HashMap的最大容量只能是MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // 找出“大于initialCapacity”的最小的2的幂
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        // 设置“加载因子”
        this.loadFactor = loadFactor;
        // 设置“HashMap阈值”,当HashMap中存储数据的数量达到threshold时,就须要将HashMap的容量加倍。
        threshold = (int)(capacity * loadFactor);
        // 建立Entry数组,用来保存数据
        table = new Entry[capacity];
        init();
    }


    // 指定“容量大小”的构造函数
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

你不指定容量和加载因子时hashMap就按默认的给你,指定的话就按你的来,有意思的是hashmap怕你不够懂它特地又对你赋的容量值进行了一次计算,转化为小于该值的最大偶数。容量值为二次幂的设计魅力后面会讲。

    最后再简单看下两个方法咱们奔主题了:

    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    // 返回索引值
    // h & (length-1)保证返回值的小于length
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

 

 hashmap会对全部的key再重hash一次,至于为何这么写不须要理解,只须要知道一切都是最好的安排。indexFor则是用来定位key对应哪一个桶。

    准备完毕,开始看下get(key)的实现:

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        // 获取key的hash值
        int hash = hash(key.hashCode());
        // 在“该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.equals(k)))
                return e.value;
        }
        return null;
    }

hashMap与hashTable其中不一样的一点是前者容许key为null,这点设计的很取巧,把key为null的对象存在数组首位(table[0]),代码以下:

    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

接下来的步骤就是:重hash->定位桶->遍历桶中的链表一一比较。在判断过程当中会先判断e.hash==hash,更印证了以前说的hashCode相等是equals成立的必要不充分条件。

再来看put方法的实现:

public V put(K key, V value) {
        // 若“key为null”,则将该键值对添加到table[0]中。
        if (key == null)
            return putForNullKey(value);
        // 若“key不为null”,则计算该key的哈希值,而后将其添加到该哈希值对应的链表中。
        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;
            // 若“该key”对应的键值对已经存在,则用新的value取代旧的value。而后退出!
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        // 若“该key”对应的键值对不存在,则将“key-value”添加到table中
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

首先仍是会先判断key值是否为null,若是为null,则将该元素放置在数组0位置,以下图所示:

    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,新key对应的value值会替换掉old值。因此put操做会先判断一下是否已经存在该key,存在的话就替换成新值返回老值。不存在执行addEntry返回null。这里须要注意的是若是key以前存在过,替换旧值不会修改modCount,不存在该key则modCount+1。咱们能够这么认为,只有map中的元素数量增多或减小的状况下才认为map的结构的发生了变化。

接下来说一下重点方法:addEntry(xxx);扩容操做就是在这里进行的

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 保存“bucketIndex”位置的值到“e”中
        Entry<K,V> e = table[bucketIndex];
        // 设置“bucketIndex”位置的元素为“新Entry”,
        // 设置“e”为“新Entry的下一个节点”
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        // 若HashMap的实际大小 不小于 “阈值”,则调整HashMap的大小
        if (size++ >= threshold)
            resize(2 * table.length);
    }

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        // 新建一个HashMap,将“旧HashMap”的所有元素添加到“新HashMap”中,
        // 而后,将“新HashMap”赋值给“旧HashMap”。
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

    // 将HashMap中的所有元素都添加到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);
            }
        }
    }

流程以下:

1:将新元素做为桶中链表的头节点,若是达到阈值则第二步

2:扩容为原来2倍,若是以前容量已是最大值了,则直接将阈值设为Int型的最大值返回(有点弃疗的意思)

3:从新散列-->外循环遍历每一个桶,内循环遍历每一个桶中链表的每一个节点,将每一个节点定位到新的位置。

 

在多线程中,通过resize过程后,再涉及到迭代或者扩容操做时,会有必定概率形成死循环或者数据丢失。

先看图一:首先向length=2的map中插入三个元素(为方便画图这里直接采用hash&length),最终桶1中造成链表3-7-5。

 这时候A线程再添加一个元素,而后进行扩容操做,并将元素3房屋新的桶,此时元素3的next是7:

 

 

此时线程B添加了元素后也进行了扩容操做,且直接扩容完成,以下图:

此时7的next指向了3而再也不指向5

而后A线程继续向下走的时候就出现了死循环问题,由于在线程A中3的next是指向7的,因此当再把7进行重定位时就出现了以下图所示:

因此以后的遍历或者扩容过程只要到了桶3,便会一直在7和3之间死循环。数据缺失的发生场景也是如此,能够本身分析。

下面来说下:为何map内部数组的长度要为2次幂。

咱们知道数组的长度主要被用来作了这么一件事,就是经过indexFor方法去定位key位于哪一个桶,即 h & (length-1);

分析一下:&运算是同一位都为1时才为1,假如一个key的hash为43,即二进制为101011,map的长度为16

则indexFor:

  101011

 &  001111

——————

   001011

为11,

当进行一次resize操做时,length=16<<1=32,再次进行indexFor操做:

        101011

 &  011111

——————

   001011

依然为11。

咱们能够很容易发现,若是length是2的次幂,length-1的二进制每位均是1,而扩容后-1二进制依然每位均是1,因此&的结果取决于hash的二进制,即有一半概率该节点依然位于原来的桶(但节点依然是会移动的),一半概率被分到了其余的桶,从而保证了扩容后节点分配的均衡性。这是其一。

其二:咱们假如桶的长度不是2次幂,拿length=15举例,length-1=14=1110。那么这时候任何key与其&操做,最后一位都是0,这就意味着桶的第1个位置永远都不会被放入元素!同理假如length-1=12=1100,那么第1,2,3的位置也永远不可能被放入元素。这会形成空间的浪费以及数据的分配不均。

以上,就是map的数组长度要为2次幂的奥秘所在。

顺便在提一下除map外的其余容器的初始长度设定:拿StringBuilder来说,字符串相加时咱们考虑到内存回收通常采用StringBuilder或StringBuffer的append来代替,那么假如能够提早估算出一个字符串的大概长度,那么请以这个大概长度直接在集合类的构造器中赋值进去,由于StringBuilder每次进行数组扩容的时候都会伴随着元素的copy,频繁的copy会必定程度上影响效率。ArrayList也是同理。

研究数据结构,咱们还有一个重要的关注点就是元素的遍历。java的集合类通常都会在内部实现一个迭代器即Iterator,它的意义是什么呢?从客户端角度来说,我可能并不关心目前操做的数据结构的内部实现,像ArrayList内部是个数组,LinkedList内部是个链表,HashMap内部又是个数组链表,I dont care。我只想拿它作个遍历,而不是针对数组时使用索引遍历,针对链表时使用xxx.next,map时又二者并用,Iterator就解决了这个问题,它做为接口定义了hasNext(),next(),remove()三个核心方法。任何一个集合类,均可以经过本身的一个内部类去实现该接口而后对外提供遍历方法和移除元素方法。如今经过hashmap的源码来看下原理:

hashmap实现了三种Iterator,分别针对key,value,还有entry。源码以下:

     final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        public final int size()                 { return size; }
        public final void clear()               { HashMap.this.clear(); }
        public final Iterator<Map.Entry<K,V>> iterator() {
            return new EntryIterator();
        }
     }

     abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
      int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } } final class KeyIterator extends HashIterator implements Iterator<K> { public final K next() { return nextNode().key; } } final class ValueIterator extends HashIterator implements Iterator<V> { public final V next() { return nextNode().value; } } final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }

 

内部类的一大特征就是能够访问主类的成员变量和成员方法,EntrySet和EntryIterator做为HashMap的内部类三者相辅相成,能够看到不管是next仍是remove,实际上都是操做的主类hashMap的table。可是这种操做对外部是透明的,能够看到封装的魅力。在HashIterator构造器中,modCount会被赋值到expectedModcount,顾名思义expectedModcount是指望的变化值,若是当前是多线程环境,进行next遍历时,当前节点可能已被其余线程remove了,或者其余线程的put操做已经改变了当前节点的位置。这种状况下expectedModcount再也不等于modCount,HashMap会认为该遍历获得的数据是无效的,便执行快速失败机制。这就是modCount被validate修饰的缘由。固然这种快速失败机制只是为了防止必定程度上的脏读,而不是完全解决并发问题。

说完Iterator咱们再来谈HashMap的遍历方式,无需多说,数据量大的时候第一种远高于第二种

    /*
     * 经过entry set遍历HashMap
     * 效率高!
     */
    private static void iteratorHashMapByEntryset(HashMap map) {
        if (map == null)
            return ;

        System.out.println("\niterator HashMap By entryset");
        String key = null;
        Integer integ = null;
        Iterator iter = map.entrySet().iterator();
        while(iter.hasNext()) {
            Map.Entry entry = (Map.Entry)iter.next();
            
            key = (String)entry.getKey();
            integ = (Integer)entry.getValue();
            System.out.println(key+" -- "+integ.intValue());
        }
    }

    /*
     * 经过keyset来遍历HashMap
     * 效率低!
     */
    private static void iteratorHashMapByKeyset(HashMap map) {
        if (map == null)
            return ;

        System.out.println("\niterator HashMap By keyset");
        String key = null;
        Integer integ = null;
        Iterator iter = map.keySet().iterator();
        while (iter.hasNext()) {
            key = (String)iter.next();
            integ = (Integer)map.get(key);
            System.out.println(key+" -- "+integ.intValue());
        }
    }

第二种方法每当取得了key值后又进行了一次get(key)操做,不但无心义且影响效率。

以上是我的对HashMap的理解和分析,没有什么布局且做为初版吧,本文粘贴的代码小部分直接来源于jdk,大部分采用了下面的博客(https://www.cnblogs.com/skywang12345/p/3310835.html#a3),由于这边博客已经将每行代码作了什么用中文讲的很清楚了,我又在一些关键点上加了一些我的理解。不足之处还望你们指正。

相关文章
相关标签/搜索