HashMap实现原理与源码分析(JDK1.7与JDK1.8对比分析)

前言
HashMap是Java集合框架中很重要的一个数据结构,掌握了它对于其它与之相关的数据结构就会迎刃而解。因为在JDK1.8对HashMap做了数据结构和扩容的优化,本文会对JDK1.7和JDK1.8的HashMap的实现原理和源码进行对比分析。

Map的集合框架

首先上一张图来直观感受下java.util.Map集合框架,Map接口有几个常用的实现类,有HashMap,HashTable,LinkedHashMap,还有TreeMap,我们对每一个做一些说明:
Map集合框架

  • HashMap: 它通过Key的hashCode()做相关的位运算得到数组中的位置索引,如果hash散列算法以及数组长度合适的话,效率很高。它可以存入键为null的键值对,JDK1.7是将其存在数组的第一个索引位置。因为它不是线程安全的,并且在高并发的情况下HashMap容易出现死循环,所以多线程环境下可以使用Collections.synchronizedMap()或者HashTable,它们两个是线程安全的,但是因为HashTable是对方法整体加锁,而Collections.synchronizedMap()是对代码块加锁,所以效率不够高,在高并发的环境下可以使用ConcurrentHashMap,它采用的是分段加锁机制。
  • LinkedHashMap: 它是HashMap的子类,它和HashMap的区别是在HashMap的基础上,增加了一个双向链表来记录存储数据的先后顺序,能够保证遍历数据的时候,首先得到的是先插入的数据。
  • HashTable: 它是一个面试中经常被问到的类,它不允许存入键值对为null(Key和Value都不能为null,否则会报NullPointerException)。它是线程安全的,但是因为是对方法整体加锁,所以并发性不太好。一般不使用这个类,在不要求线程安全的时候可以使用HashMap, 对线程安全有要求的时候可以使用ConcurrentHashMap。
  • TreeMap 它实现了SortedMap接口,查看put源码可以看到,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。它能够把保存的记录根据键排序,默认是按键值的升序排序,可以在构造方法中指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。

HashMap的数据结构

  • HashMap采用的是数组加链表的数据结构(JDK1.8中增加了红黑树),首先我们要知道HashMap中存储的是什么,我们通过查看JDK1.7中和JDK1.8中的源码得知,在1.7中存储的是HashMapEntry,在1.8中存储的是Node(当链表长度大于8时候转换为红黑树), 源码如下:

    // JDK 1.7 中存储的元素
    static class HashMapEntry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        HashMapEntry<K,V> next;
        int hash;
        
        HashMapEntry(int h, K k, V v, HashMapEntry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
    }
    // JDK 1.8 中存储的元素
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
    
  • 由上可知,基本上就是换了个名字而已,他们是HashMap中的一个静态类,实现Entry接口。

  • HashMap是一个数组加链表的数据结构(在JDK1.8中当链表长度大于8时候会转换为红黑树),它的结构图如下:
    HashMap结构图

HashMap简介

  • HashMap内部采用哈希表来存储,因为有hash冲突,采用的是链地址法,就是数组加链表的结构。每一个数组位置上都可能是一个链表,当有冲突的时候,就将新的值要么覆盖原来的位置,要么放在对应索引的链表的末尾。所以想要有高的存取效率就得设计好的hash散列算法,散列的越均匀,碰撞的概率就越小,存取效率就会越高。

  • HashMap内部有几个重要的成员变量:

    table: HashMap内部维护的一个一维数组,它的长度必须2的幂,后面会详细讲解原因。

    size:数组中真实存储的键值对(Entry)的数量。

    capacity: 数组的容量,等于table.length()。

    loadFactor:加载因子,默认是0.75,此数值用来衡量Hashmap存储的疏散程度。这个值尽量不要修改,它肯定是官方经过很多研究才确定下来的。这个值是对时间和空间的一个权衡,如果内存多并且对效率要求比较好,可以降低这个因子的值,这样数组中的元素较少的时候就会扩容,减少了hash碰撞的几率,效率就会提升。反之如果内存少并且对效率不是很高的时候,可以增加这个值,使得数组长度一定,链表长度变长,就增加了hash碰撞的可能性,效率就会降低。

    threshold: 扩容阀值,它等于capacity * loadFactor ,当Hashmap的size大于或者等于 threshold 时,Hashmap将进行扩容(resize)。

  • 无论多么完美的设计都避免不了hash碰撞的可能性,在JDK1.8中,如果链表长度太长的话(默认是超过8)就会转换为红黑树结构,可以点击教你初步了解红黑树了解。

HashMap实现原理与源码分析

  • 按照我们使用首先会使用new关键字实例化一个HashMap对象,那首先我们看它的构造方法(JDK1.7):
     static final HashMapEntry<?,?>[] EMPTY_TABLE = {};
    
     transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE;
    
     public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY) {
            initialCapacity = MAXIMUM_CAPACITY;
        } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
            initialCapacity = DEFAULT_INITIAL_CAPACITY;
        }
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        threshold = initialCapacity;
        init();
    }
    
  • 构造方法中并没有对table数组进行初始化的操作,实际上是在进行put操作的时候进行的,构造中有一个init方法,在它的子类LinkedHashMap中有具体的实现。接下来我们看put方法:
    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 (HashMapEntry<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;
    }
    

  • put方法内容就比较多了,我们一步一步来分析,首先判断table == EMPTY_TABLE为true,就会执行inflateTable(threshold)函数,源码如下:
    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
        
        float thresholdFloat = capacity * loadFactor;
        if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
            thresholdFloat = MAXIMUM_CAPACITY + 1;
        }
    
        threshold = (int) thresholdFloat;
        table = new HashMapEntry[capacity];
    }
    
  • inflateTable(threshold)函数首先执行roundUpToPowerOf2方法,roundUpToPowerOf2的源码和图解如下:
    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        int rounded = number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (rounded = Integer.highestOneBit(number)) != 0
                    ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
                    : 1;
    
        return rounded;
    }
    
  • roundUpToPowerOf2图解
    在这里插入图片描述
  • roundUpToPowerOf2返回一个大于等于toSize的2次幂的容量值(capacity),然后将获取的新的容量capacity * loadFactor赋值给threshold,再然后就初始化了table这个数组了,所以table的初始化是在put方法中,而不是构造方法中。这里roundUpToPowerOf2为什么一定要返回一个2次幂的值呢?我们在后面真正put的时候会详细解释。
  • 接着上面put中的源码分析,inflateTable之后检查key==null,如果为true的话就直接执行putForNullKey(value)方法,说明HashMap是可以放入<Key,Value>为null的值的,但是HashTable是不可以的,会报空指针异常的错误。查看源码知道,这里把key为null的值存储在了数组的第一个位置。
  • 再然后根据key值计算在数组中的索引位置,先执行hashCode,再执行hash算法,再执行indexFor(hash, table.length),我们来比较一下JDK1.7和1.8中取索引位置的操作::
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    //JDK1.8中没有这个单独的函数,它是在put方法内部实现的这个逻辑,原理一样
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }
    
  • hash函数在JDK1.7和JDK1.8的源码中都是有的,这里先求取key的hashCode()值等于h,再对h取无符号向右移动16位,再对这两个值取异或运算,这样高位和地位都参与了运算,使得散列更加的均匀分布。
  • indexFor这一步是用来获取实际在数组table中存储的位置,返回的是数组的下标。这里获取数组下标的方式采用的是位运算,而不是index = HashCode(Key) % Length的取模运算是因为位运算更加的高效。这里就可以解释为什么要使用2次幂作为数组的长度了,我们举例说明,如果这里以cat为key举例说明h & (length-1)的值。
    • cat的hash值为98263,二进制为10111111111010111,假定数组长度为16,那么这里indexFor函数就是执行10111111111010111 & 1111 = 0111,等于7。
    • 我们看到其实不管我们put的是什么值,其实只和它的hash值的末尾几位相关。这样不但结果上完全和取模一样,而且采用位运算很高效。
    • 如果这里数组的长度不是16,而是9的话,执行10111111111010111 & 1000 = 0000,等于0。如果末尾0010 & 1000 = 0000或者0100 & 1000 = 0000,我们看到结果都是0,这样就在一定的程度上,有的数组位置可能一直处于空置状态,就大大增加了其它位置的hash碰撞的可能性,那就不是一个好的数据结构了,所以这里要将数组长度设置为2次幂。
  • 获取完数组的下标索引之后就可以执行put操作了,再继续之前,我们先看一下HashMapEntry的数据结构,源码如下:
    static class HashMapEntry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        HashMapEntry<K,V> next;
        int hash;
    
        /**
         * Creates new entry.
         */
        HashMapEntry(int h, K k, V v, HashMapEntry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
    
        public final K getKey() {
            return key;
        }
    
        public final V getValue() {
            return value;
        }
    }
    
  • HashMapEntry属于HashMap的一个静态内部类,next存储指向下一个Entry的引用,是一个单链表结构,hash是对key的hashcode值运算后得到的值,存储在Entry,避免重复计算。

Put操作

  • 接着进行put的操作,这里因为举例前面太远,就再贴下源码:
    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 (HashMapEntry<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;
    }
    
  • 我们看到根据索引值在数组中找到了对应的位置的对象,执行for循环:
    • 如果这个位置的HashMapEntry不为null的话,并且key的hash值和equal值都相等的话,说明是要覆盖此处之前的值,并且返回之前的旧Value值。
    • 如果这个地方的HashMapEntry不为null,并且key的hash值不相等,说明产生了冲突,就需要处理,接着执行addEntry方法,源码如下:
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
    
        createEntry(hash, key, value, bucketIndex);
    }
    
  • addEntry方法中,首先会判断容量是否超过阈值,如果是的话就要使用resize()方法扩容,扩容后的数组还是2次幂大小,resize源码如下:
    void resize(int newCapacity) {
        HashMapEntry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
    
        HashMapEntry[] newTable = new HashMapEntry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    
  • 我们看到了将数组长度扩大了一倍,并且为tablethreshold重新赋值。接着看transfer(newTable)方法:
    void transfer(HashMapEntry[] newTable) {
        int newCapacity = newTable.length;
        for (HashMapEntry<K,V> e : table) {
            while(null != e) {
                HashMapEntry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    
  • 其实resize扩容的真正逻辑都在transfer这里了,这里会打破原来的位置信息,对每一个元素重新根据indexFor函数求位置索引,然后将其放入新的数组中(PS:众所周知,扩容在高并发的时候会出现链表环形的情况,当获取值的时候会陷入死循环,详细信息可以查看高并发下的HashMap)。
  • 扩容完之后执行addEntry方法,此方法源码如下,由源码可知,将旧的HashMapEntry值放在了链表的尾端,将新的值放在了链表的头部,之所以这样做是基于新值被使用到的可能性更大,在链表的头部,会便于更早点获得
    void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMapEntry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
        size++;
    }
    

Get操作

  • 接下来我们看下get方法的源码:
    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
    
        return null == entry ? null : entry.getValue();
    }
    
  • 如果key为null的话,就直接调用getForNullKey方法,根据前面分析知道,这里其实是在第一个索引位置查找。如果key不为null的话就调用getEntry方法获取Entry对象,源码如下:
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
    
        int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
        for (HashMapEntry<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;
    }
    
  • 如果知道了put的原理的话,这里get的原理就很简单了,根据indexFor函数求取在数组中的索引位置,然后调用table[indexFor(hash, table.length)]得到此处的值,使用for循环,查找此位置的链表中和要查询的key值匹配的元素,返回即可。

总结

本文基于JDK1.7源码讲述了HashMap的实现原理,并对JDK1.8源码做了进了对比分析,希望对大家有多帮助。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值