死磕Java之JDK 1.6HashMap

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Oeljeklaus/article/details/88049406

死磕Java之JDK 1.6HashMap

    HashMap是常用的按照键值对存储的集合类,内部源码有很多值得思考和学习的地方。你知道为什么HashMap不安全吗?为什么HashMap的初始容量为16吗?HashMap的结构时怎么样的吗?本文将带你揭开HashMap的神秘面纱。

01

概述

    HashMap是基于哈希表实现的Map接口。这样的实现允许所有的Map操作,并且允许值为空和键为空。除了不同步和允许null值存在之外,HashMap大约和HashTable是相同的。下面将从构造函数、数据结构和源码方面分析HashMap。

02

HashMap中的字段和内部类

    首先,我们来看一下在HashMap中定义了那些字段,需要需要记住这些字段的含义,以及有些字段的初始值为何如此。

代码一:

//HashMap的默认初始容量,要求必须是2的指数倍

static final int DEFAULT_INITIAL_CAPACITY = 16;

//HashMap的最大容量

static final int MAXIMUM_CAPACITY = 1 << 30;

//HashMap的默认装载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

//Entry类型的数组

transient Entry[] table;

//HashMap的kv键值对数量

transient int size;

//阈值,默认为当前容量*装载因子

int threshold;

//哈希表装载因子

final float loadFactor;

//HashMap已经被修改的次数。

transient volatile int modCount;

      对于初始容量一般默认为16,在源码的注释中要求必须是2的指数倍,这是有一定原因的,将会在下面源码分析的章节中进行解释。装载因子默认为0.75,主要是为了考虑时间和空间的平衡,在重哈希时快速的重建以及一些方法的查询速度。在这里最需要注意的是modCount关键字,这里使用了volatile关键字,这个关键字保证了可见性,可见这里考虑到了多线程;modCount变量主要记录HashMap被结构性变化的次数,如果不小心使用,将会抛出ConcurrentModificationException,这里简略的总结,具体的细节将会在下面章节中给出。

接下来是内部类Entry的源码:

代码二:

static class Entry<K,V> implements Map.Entry<K,V> {

        final K key;

        V value;

        Entry<K,V> next;

        final int hash;

        /**

         * Creates new entry.

         */

        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;

        }

        public final boolean equals(Object o) {

            if (!(o instanceof Map.Entry))

                return false;

            Map.Entry e = (Map.Entry)o;

            Object k1 = getKey();

            Object k2 = e.getKey();

            if (k1 == k2 || (k1 != null && k1.equals(k2))) {

                Object v1 = getValue();

                Object v2 = e.getValue();

                if (v1 == v2 || (v1 != null && v1.equals(v2)))

                    return true;

            }

            return false;

        }

        public final int hashCode() {

            return (key==null   ? 0 : key.hashCode()) ^

                   (value==null ? 0 : value.hashCode());

        }

        public final String toString() {

            return getKey() + "=" + getValue();

        }

        /**

         * This method is invoked whenever the value in an entry is

         * overwritten by an invocation of put(k,v) for a key k that's already

         * in the HashMap.

         */

        void recordAccess(HashMap<K,V> m) {

        }

        /**

         * This method is invoked whenever the entry is

         * removed from the table.

         */

        void recordRemoval(HashMap<K,V> m) {

        }

    }

    Entry类是一个静态内部类,定义了四个变量,主要是K、V、next指针、hash;这其中K和hash定义为final,主要的原因是K和hash一旦确定就不在改变;在这个类中,我们可以看到重写了hashCode()方法和equals方法,主要用于以后其他方法中的判断。从equals方法的重写中,我们可以模仿它的写法,先判断是否同一类型,在判断需要比较的字段。

这里需要尤其注意,由于hashCode()方法主要与key和value的hash值有关,同时这个方法在HashMap定位上很重要,而且比较时地调用了key和value本身的equals方法,我们在将自定义类时需要重写自定义类中的equals方法和hashCode方法。

03

数据结构

    首先来看一下,如何从HashMap中取出一个元素呢?分析这个方法,我们可以使用逆向的思维得到HashMap的数据结构。

代码三:

public V get(Object key) {

    //如果key为null通过该方法取出

        if (key == null)

            return getForNullKey();

    //计算hash值

        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;

    }

private V getForNullKey() {

        for (Entry<K,V> e = table[0]; e != null; e = e.next) {

            if (e.key == null)

                return e.value;

        }

        return null;

    }

    从上面的代码看出,如果key为null,那么我们取出的是table[0],并且在table[0]的链表上进行遍历(上一节说明了Entry类内部有个指向next的指针);可以为其他值时,仅仅只是indexFor函数返回值改变而已,依然是在链表上遍历。由此我们可以推断出HashMap的结构如下:

HashMap数据结构.png

    为了方便,这里采用不同的颜色表示不同数组下表的链表。HashMap的数据结构,是一个数组,与其他数组不同的是,数组元素是元素为Entry的链表而已。数组下表为0的链表,仅仅只有一个元素。毕竟Key为null,如果再次放入Key为null,value为其他值,就是更新元素。  

04

源码分析之构造函数

    HashMap的构造函数如下:

代码四:

//接受初始容量和转载因子的构造函数

public HashMap(int initialCapacity, float loadFactor) {

    //如果初始容量小于0那么直接抛出参数异常

        if (initialCapacity < 0)

            throw new IllegalArgumentException("Illegal initial capacity: " +

                                               initialCapacity);

    //如果初始容量大于最大容量即2^30则初始容量为最大容量

        if (initialCapacity > MAXIMUM_CAPACITY)

            initialCapacity = MAXIMUM_CAPACITY;

    //装载因子小于等于0或者非数值那么抛出参数违法异常

        if (loadFactor <= 0 || Float.isNaN(loadFactor))

            throw new IllegalArgumentException("Illegal load factor: " +

                                               loadFactor);

        //这里找到大于传入容量的最小2的指数

        int capacity = 1;

        while (capacity < initialCapacity)

            capacity <<= 1;

        this.loadFactor = loadFactor;

        threshold = (int)(capacity * loadFactor);

        table = new Entry[capacity];

        init();

    }

    //参数为容量的构造函数

public HashMap(int initialCapacity) {

        this(initialCapacity, DEFAULT_LOAD_FACTOR);

    }

   //默认无参构造函数

   public HashMap() {

        this.loadFactor = DEFAULT_LOAD_FACTOR;

        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);

        table = new Entry[DEFAULT_INITIAL_CAPACITY];

        init();

    }

   //参数为Map的构造函数

   public HashMap(Map<? extends K, ? extends V> m) {

        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,

                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);

        putAllForCreate(m);

    }

    对于无参的构造函数,HashMap使用的是默认的16和0.75的装载因子,这里主要分析第一个构造函数。从上面代码的注释分析可以得出,如果我们这里传入的参数是20,HashMap将会初始化容量大小为32,因为32是大于20的最小的2的指数。

那么为啥HashMap的初始容量为啥是16呢?我们这里来做一个测试,分别使用不同大小的初始容量来进行测试:

代码五:

 int num=1000000;

        HashMap<Integer,Integer> map1=new HashMap<Integer, Integer>();

        Long s1=System.currentTimeMillis();

        for(int i=0;i<num;i++)

            map1.put(i,i);

        Long s2=System.currentTimeMillis();

        //程序猿技术

        HashMap<Integer,Integer> map2=new HashMap<Integer, Integer>(num/2);

        Long s3=System.currentTimeMillis();

        for(int i=0;i<num;i++)

            map2.put(i,i);

        Long s4=System.currentTimeMillis();

        HashMap<Integer,Integer> map3=new HashMap<Integer, Integer>(num);

        Long s5=System.currentTimeMillis();

        for(int i=0;i<num;i++)

            map3.put(i,i);

        Long s6=System.currentTimeMillis();

        System.out.println("初始容量为16,插入的耗时为:"+(s2-s1)+"ms");

        System.out.println("初始容量为500000,插入的耗时为:"+(s4-s3)+"ms");

        System.out.println("初始容量为1000000,插入的耗时为:"+(s6-s5)+"ms");

    这里分别使用初始容量为16、500000和1000000,然后插入1000000的键值对,得到结果如下:

HashMap测试结果.png

    可以看出初始容量为16,耗时更少;可见初始容量为16是有合理性的。

   

05

源码分析之hash值计算和下标定位

    结合上一节中getEntry方法中的源代码,hash方法传入的值时key的hash值。下面来看一下下面的hash方法:

代码六:

static int hash(int h) {

        h ^= (h >>> 20) ^ (h >>> 12);

        return h ^ (h >>> 7) ^ (h >>> 4);

    }

    这里有个很有趣的代码,那就是hash方法的第一行,表示使用传入hash值的无符号右移20位异或hash值的无符号右移12位,那么为何需要这样做呢?这里我们来看一组计算:

异或计算.png

    上图计算了第一行代码的值。结合return返回的代码,这个函数确保哈希码在每个位的位置上只有常数倍的差异,碰撞的次数是有限的。因而主要的目的是为了减少碰撞。

下标定位方法代码如下:

代码七:

static int indexFor(int h, int length) {

        return h & (length-1);

    }

    按照一般的思路,如何确定key在那个table中呢?我们会想到使用模运算来进行确定下标,例如如果table为20,传入的值为99 那么下标为多少呢?99%20=19.但是在计算机中,模运算的效率是非常低的。有什么好方法取代模运算呢?这就体现计算机组成原理的思维了,在计算机中位运算的效率是非常高。下标定位的方法计算如下:

异或计算:

12: 0000 0000 0000 0000 0000 0000 0000 1100

16: 0000 0000 0000 0000 0000 0000 0001 0000

12&16:0000 0000 0000 0000 0000 0000 0000 1100

    可以看出12&16==12%16。可见,计算机是多么有趣啊!

06

源码分析之Put方法

    Put方法是HashMap方法中尤其核心的方法,这个方法中包含重定位、扩容等特性。首先来一份源码:

代码八:

public V put(K key, V value) {

    //如果key是null,直接使用putForNullKey方法

        if (key == null)

            return putForNullKey(value);

    //使用内部hash方法计算hash值

        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;

    }

    使用putForNullKey方法,下面给出这个方法的源码:

代码九:

private V putForNullKey(V value) {

      //遍历下标为0的链表,如果已经有元素了,更新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++;

      //添加到下标为0的链表

        addEntry(0, null, value, 0);

        return null;

    }

    上面两个方法中有相同的方法recordAccess(),这个方法具体是来做什么呢,我们来看一下:

代码十:

void recordAccess(HashMap<K,V> m) {

            }

    该方法位于内部类Entry中,表示每当entry中的值被HashMap中键k的put(k,v)调用覆盖时,就会调用此方法。

现在,让我们把重点放在addEntry方法中,代码如下:

代码十一:

void addEntry(int hash, K key, V value, int bucketIndex) {

    //取出链表的第一个元素

        Entry<K,V> e = table[bucketIndex];

    //将需要插入的元素放在table的链表第一个位置

        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);

    //看是否需要扩容,如果需要扩容,那么就扩容两倍

        if (size++ >= threshold)

            resize(2 * table.length);

    }

再来看看如何扩容。

代码十二:

void resize(int newCapacity) {

    //用一个引用指向旧table

        Entry[] oldTable = table;

    //旧容量

        int oldCapacity = oldTable.length;

    //判断旧容量是否等于最大容量

        if (oldCapacity == MAXIMUM_CAPACITY) {

            threshold = Integer.MAX_VALUE;

            return;

        }

//分配新的空间

        Entry[] newTable = new Entry[newCapacity];

    //这里涉及元素的重新分配

        transfer(newTable);

    //新分配的空间被table指向

        table = newTable;

    //重新计算阈值

        threshold = (int)(newCapacity * loadFactor);

    }

    上面代码的核心内容是transfer方法,我们来看核心内容:

代码十三:

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;

                //根据hash值重新计算下标,知道将旧table中的元素移动到新table中

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

            }

        }

    }

让我们来整理一下Put方法的思路:

1.首先判断key是否为null,如果为null,进入下标为0的table上,如果下标为0的table非空,更新value,否则插入新Entry;如果key不为null,进入第二步。

2.根据key的hash值找到下标,取出链表第一位,然后将新的键值对封装为新Entry,然后将新Entry添加到链表头,旧链表添加到新Entry后。接下来判断是否需要扩容,如果不需要,就此终止。否则,进入第三步。

3.根据旧容量扩容,如何扩容呢?将table变为原来的两倍,然后根据容量重新计算每一个Entry的位置,然后插入到新的位置。

07

源码分析之Get方法

在分析结构时,已经分析了get方法,这里省略。

08

源码分析之迭代方法

在使用HashMap迭代方法时,主要的使用代码如下:

代码十四:

HashIterator() {

    //初始时等于该值

            expectedModCount = modCount;

            if (size > 0) { // advance to first entry

                Entry[] t = table;

                while (index < t.length && (next = t[index++]) == null)

                    ;

            }

        }

final Entry<K,V> nextEntry() {

    //判断修改次数和期盼次数是否相等,如果不等,抛出异常

            if (modCount != expectedModCount)

                throw new ConcurrentModificationException();

            Entry<K,V> e = next;

            if (e == null)

                throw new NoSuchElementException();

            if ((next = e.next) == null) {

                Entry[] t = table;

                while (index < t.length && (next = t[index++]) == null)

                    ;

            }

            current = e;

            return e;

        }

    在迭代时,需要特别注意的是ConcurrentModificationException。首先要判断modCount和expectedModCount,那么在那些方法中,这两个值是不相同的呢?

迭代器中的remove方法:

代码十五:

public void remove() {

            if (current == null)

                throw new IllegalStateException();

            if (modCount != expectedModCount)

                throw new ConcurrentModificationException();

            Object k = current.key;

            current = null;

            HashMap.this.removeEntryForKey(k);

            expectedModCount = modCount;

        }

这里方法中始终保持两个值相同,那么这个方法是安全的。那么HashMap的remove方法呢?

代码十六:

public V remove(Object key) {

        Entry<K,V> e = removeEntryForKey(key);

        return (e == null ? null : e.value);

    }

    final Entry<K,V> removeEntryForKey(Object key) {

        int hash = (key == null) ? 0 : hash(key.hashCode());

        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;

    }

    在remove方法中,两个值并没有保持一致,所以会抛出异常。这种异常叫fail-fast,是在未同步的集合中,在集合视图上修改集合,导致期望修改值和修改值不同抛出的异常。

09

线程安全问题

    HashMap线程不安全,为什么不安全呢?下面看一个图:

QHashMap3.png

    我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。 假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。

如果在取链表的时候从头开始取(现在是从尾部开始取)的话,则可以保证节点之间的顺序,那样就不存在这样的问题了。

还有一个问题是:put的时候导致的多线程数据不一致。

这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

那么如何让HashMap线程安全呢?使用Collections.synchronizedMap(new HashMap(...))进行包装。

010

序列名称

     HashMap是一个线程不全的,允许key/value为null的集合类,这个类在Hash值计算、下标定位方法上结合了计算机原理的相关知识;同时,HashMap的初始值容量和装载因子也是很值得我们学习的地方,它考虑到了时间空间的负载均衡;关于它的数据结构,尽管在设计上结合初始值大小好像很合理,但是每一个数组元素都是链表的情况下,仍然是非常费劲的,在以后的JDK版本中,这一点做了很好的优化,主要是在一定的阈值后将链表改写称为红黑树。当然,尽管美中不足,它的设计思想,仍然值得我们学习。

2019_02_22_1933083452.png

点击上方蓝色字体,关注我们

15

猜你喜欢

转载自blog.csdn.net/Oeljeklaus/article/details/88049406
今日推荐