美文网首页
HashMap源码初探

HashMap源码初探

作者: 先生_吕 | 来源:发表于2017-09-24 18:19 被阅读15次

    附图:

    集合架构:


    2608446-e941e68f81f5448c.jpg

    map架构:

    20161027225853859.png

    一:前言

    HashMap想必是所有java开发人员最常用的集合操作类之一,在日常开发中我们最常用的双列(key-value)集合便是hashmap,它是一个采用哈希表实现的键值对集合,是一种<key,value>的存储结构,继承自 [AbstractMap],实现了 [Map 接口]。它特殊的数据存储结构致使他能在众多的集合操作类中脱颖而出,线性链表结构让它在保证了查询效率的同时也兼顾了增删的效率,简单方便的API操作可以完成各种复杂的数据业务,成为最受欢迎的集合操作类。

    二:为什么会出现HashMap:

    我们知道,在数据结构中,数组结构可以保证元素的检索效率,但是在添加和删除的功能上就显得略微迟钝了,例如java中的ArrayList。在检索元素的时候,只需要根据索引直接检索,无需去遍历整个集合,但是如果要增加或删除元素,就必须去重新维护索引。另一种数据结构链表就恰恰相反,增加或删除元素只需要修改引用,而由于没有索引,它在检索元素的时候就必须从头到尾的去遍历整个集合,最具有代表性的就是java中的linkedList。当然,在实际开发中完全可以根据业务需求去选择更适合的工具类去操作数据(多检索动作的就选用底层是数组结构的集合类,多增删动作的就选用底层是链表结构的集合类)。但是如果检索和增删操作都很频繁呢?这时候,HashMap就应运而生了,毫无疑问它是数组和链表的组合体(线性链表结构),集数组与链表的优点为一身。我们先来解释一下“线性链表”这个名词吧,其本质就是一个元素为链表的数组。

    三:数据结构(线性链表):

    HashMap的存储结构:哈希表有多种不同的实现方法,HashMap采用的是一种常用的实现方法——拉链法,我们可以理解为“链表的数组”。

    210003116887371.png

    从上图可以看出HashMap是Y轴方向是数组,X轴方向就是链表的存储方式。大家都知道数组的存储方式在内存的地址是连续的,大小固定,一旦分配不能被其他引用占用。它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n),链表的存储方式是非连续的,大小不固定,特点与数组相反,插入和删除快,查询速度慢。HashMap可以说是一种折中的方案吧。

    数据的存储结构主要包括:顺序存储,链式存储,索引存储,散列存储。
    这里注意:hashmap的链表中的每一个元素是一个Entry对象,包括key和value,当然还有其他的东西,而非只有键和值。

    四:架构:

    本图引自武皇帝博客


    202221148131465.png

    五:特点:

    (1):底层实现是 链表数组,JDK 8 后又加了 红黑树
    (2):实现了 Map 全部的方法
    (3):key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法
    (4):允许空键和空值(但空键只有一个,且放在第一位)
    (5):元素是无序的,而且顺序会不定时改变
    (6):插入、获取的时间复杂度基本是 O(1)(前提是有适当的哈希函数,让元素分布在均匀的位置)(7):遍历整个 Map 需要的时间与 桶(数组) 的长度成正比(因此初始化时 HashMap 的容量不宜太大)
    (8):两个关键因子:初始容量、加载因子
    (9):除了不允许 null 并且同步,Hashtable 几乎和他一样。

    六:原理:

    1、首先判断Key是否为Null,如果为null,直接查找Enrty[0],如果不是Null,先计算KeyHashCode,然后经过二次Hash。得到Hash值,这里的Hash特征值是一个int值。
    2、根据Hash值,要找到对应的数组啊,所以对Entry[]的长度length求余,得到的就是Entry数组的index。
    3、找到对应的数组,就是找到了所在的链表,然后按照链表的操作对Value进行插入、删除和查询操作。

    七:源码解析:

    一下源码出自JDK1.7

    7.1:重要属性
        /**
         * The default initial capacity - MUST be a power of two.
         * 默认的初始化容量,必须是2的指数。
         */
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
        /**
         * 最大容量 1<<30.必须是2的指数。
         */
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        /**
         * The load factor used when none specified in constructor.
         * 默认的负载因子,值是0.75f(跟数据结构中的hash有关)。
         */
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        /**
         * An empty table instance to share when the table is not inflated.
         * 一个空的entry数组
         */
        static final Entry<?, ?>[] EMPTY_TABLE = {};
    
        /**
         * The table, resized as necessary. Length MUST Always be a power of two.
         * Entry数组,哈希表,长度必须为2的幂  
         */
        transient Entry<K, V>[] table = (Entry<K, V>[]) EMPTY_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).
         * 下次扩容的临界值,size>=threshold就会扩容 
         */
        int threshold;
    
        /**
         * The load factor for the hash table.
         * 加载因子  
         */
        final float loadFactor;
    
        /**
         * HashMap被改变的次数
         */
        transient int modCount;
    
    7.2构造方法
        /**
         * 无参构造(默认容量16,默认加载因子0.75)
         */
        public HashMap() {
            this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
        }
    
        /**
         * 带参构造(给出初识容量,加载因子使用默认0.75)
         */
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
       /**
         * 真正的初始化
         * @param initialCapacity
         * @param loadFactor
         */
        public HashMap(int initialCapacity, float loadFactor) {
            //如果初始化容量小于0,抛出异常
            if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
            //如果初始化容量大于最大容量1<<30,则令其等于1<<30
            if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY;
            //如果加载因子小于等于0或者不是数字,则抛出异常
            if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
            //赋值成员变量
            this.loadFactor = loadFactor;
            threshold = initialCapacity;
            //初始化
            init();
        }
    
    7.3:静态内部类Entry
        /**
         * ClassName: Entry 静态内部类
         */
        static class Entry<K, V> implements Map.Entry<K, V> {
            final K key;//key
            V value;//value
            Entry<K, V> next;//下一个元素
            int hash;//hash值
    
            /**
             * 创建一个Entry
             */
            Entry(int h, K k, V v, Entry<K, V> n) {
                value = v;
                next = n;
                key = k;
                hash = h;
            }
            //获取key
            public final K getKey() {
                return key;
            }
            //获取value
            public final V getValue() {
                return value;
            }
            //设置value
            public final V setValue(V newValue) {
                V oldValue = value;
                value = newValue;
                return oldValue;
            }
            //与另一个Entry对象比较
            public final boolean equals(Object o) {
                //先判断是否是entry对象类型
                if (!(o instanceof Map.Entry)) return false;
                Map.Entry e = (Map.Entry) o;//获取到需要比较的entry对象
                Object k1 = getKey();//获取当前Ehtry的key值
                Object k2 = e.getKey();//获取要比较Entry的key值
                //比较两者的key值
                if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                    //同样的方法比较value值
                    Object v1 = getValue();
                    Object v2 = e.getValue();
                    //如果key和value都相等,那两个Entry就相等
                    if (v1 == v2 || (v1 != null && v1.equals(v2)))
                        return true;
                }
                //否则,不等
                return false;
            }
            //获取hash值
            public final int hashCode() {
                return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
            }
            //tostring
            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) {
            }
        }
    

    HashMap底层维护的Entry类型的数组,每个元素都是Entry类型,Entry类型会维护两个信息,一个是key的信息,一个是value的信息,而key跟value正好是我们往HashMap里面put的那个key跟value。还有一个Entry类型的引用,用于指向下一个Entry类型的引用,还有一个hash哈希值,就是位置。

    7.4:添加元素

    在看添加元素之前,我们先要了解hashmap到底是怎样存储元素的。首先我们知道HashMap里面实现一个静态内部类Entry,这个上面的代码已经说明了,其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。理解了这些,我们再来看它put的源码就稍微容易点了,直接上代码

        /**
         * 添加元素
         */
        public V put(K key, V value) {
            //如果这个table是一个空的entry数组,(table指成员变量transient Entry<K, V>[] table)
            if (table == EMPTY_TABLE) {
                inflateTable(threshold);//就以默认加载因子创建一个entry
            }
            //如果key是null的(hashmap是允许一个null键的)
            if (key == null)
                return putForNullKey(value);
            
            //当key不为null
            int hash = hash(key);//计算key的hash值
            int i = indexFor(hash, table.length);//根据哈希值和table数组的长度定位数组项(取模)
            
            /**
             * 对数组项的链表进行遍历
             */
            for (Entry<K, V> e = table[i]; e != null; e = e.next) {
                Object k;
                //如果key的哈希值与链表中的某一项的哈希值相等且key本身引用值相等或者引用值所指向的对象相等
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    //则替换相应项的value值为新的value,并返回老的value
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            //如果没有找到相同的key,则加入该项到链表中
            modCount++;
            addEntry(hash, key, value, i);//此方法直接将新的项加入到链表的头部,新项的next引用指向原来的链表项
            return null;
        }
    
    public V put(K key, V value)

    添加方法是hashMap的精髓所在,我们来看看他具体都做了哪些事情,
    (第一步):先判断成员变量table(即entry数组)是否为EMPTY_TABLE空数组,如果是,就调用 inflateTable(threshold) 方法以默认加载因子为参数创建一个新的Entry数组。反之则代码继续运行。

    (第二步):判断这个key是否为null(hashMap是运行null键的),如果put的键是null,则执行putForNullKey(value) 方法。我们来看看这个方法:

    private V putForNullKey(V value) {
            //如果事先已经存在key为null的映射,则替换后返回old 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(int hash, K key, V value, int bucketIndex)我们发现当key为null时,此Entry始终放在数组的第一个索引0处
            addEntry(0, null, value, 0);
            return null;
        }
    

    这个方法是对key为null的情况作了一个特殊处理,当事先已经有key为null的映射时,用新的value代替旧的value,并将旧的value返回;当事先没有key为null的映射时,则是直接调用 addEntry(0, null, value, 0)方法,注意这个方法的参数,我们发现当key为null时,此Entry始终放在数组的第一个索引0处。

    (第三步):如果key不为null,则先算出key的hash值,再根据hash值和table数组的长度进行取莫运算,找到Entry要存放的索引位置(这时也就找到对应的链表了)

    (第四步):遍历这个index索引处的链表,寻找链表中所有的key,如果key已存在,则用新的value替换旧的value,并将旧的value返回。我们来看看代码:

        /**
             * 对数组项的链表进行遍历
             */
            for (Entry<K, V> e = table[i]; e != null; e = e.next) {
                Object k;
                //如果key的哈希值与链表中的某一项的哈希值相等且key本身引用值相等或者引用值所指向的对象相等
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    //则替换相应项的value值为新的value,并返回老的value
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    

    (第五步):如果第四步没有找到相同的key,则证明这个链表中此key的映射还不存在,这时候,我们便可以直接去执行添加Entry,并将最新的Entry放在链表头部,将其引用指向旧的Entry,返回null。

    20160825173336382.png

    上面我们大致已经了解了hashmap元素的实现,但是这里遗漏一个问题,我们都知道hashmap的key是唯一的,并且大部分同学也都知道是通过hashCode和equals方法来实现的,那么它到底是如何实现key唯一的呢?我们大可先了解了解hashcode和equal。

    我们首先想到的是用equals比较,没错,这样可以实现,但随着内部元素的增多,put和get的效率将越来越低,这里的时间复杂度是O(n),假如有1000个元素,put时需要比较1000次。实际上,HashMap很少会用到equals方法,因为其内通过一个哈希表管理所有元素,当我们调用put存值时,HashMap首先会调用K的hashCode方法,获取哈希码,通过哈希码快速找到某个存放位置,这个位置可以被称之为bucketIndex,通过上面所述hashCode的协定可以知道。

    如果hashCode不同,equals一定为false,如果hashCode相同,equals不一定为true。

    所以理论上,hashCode可能存在冲突的情况,有个专业名词叫碰撞,当碰撞发生时,计算出的bucketIndex也是相同的,这时会取到bucketIndex位置已存储的元素,最终通过equals来比较,equals方法就是哈希码碰撞时才会执行的方法,所以前面说HashMap很少会用到equals。

    20160825165407067.jpg

    现在我们知道,执行put方法后,最终HashMap的存储结构会有这三种情况,情形3是最少发生的,哈希码发生碰撞属于小概率事件。到目前为止,我们了解了两件事:

    (1):HashMap通过键的hashCode来快速的存取元素。
    (2):当不同的对象hashCode发生碰撞时,HashMap通过单链表来解决,将新元素加入链表表头,通过next指向原有的元素。单链表在Java中的实现就是对象的引用(复合)。

    这里添加一个小的知识点(时间复杂度与空间复杂度)
    时间复杂度:时间频度 一个算法执行所耗费的时间,其评判标准以时间长短为主
    空间复杂度:空间频度 一个算法执行所消耗的内存空间,其评判标准以内存空间为主

    相关博文:http://blog.csdn.net/booirror/article/details/7707551/

    7.5元素获取
        /**
         * 元素获取
         */
        public V get(Object key) {
            //先判断key是否为null
            if (key == null)
                return getForNullKey();
            //根据key获取对应的entry
            Entry<K, V> entry = getEntry(key);
            //返回value
            return null == entry ? null : entry.getValue();
        }
    

    元素的获取看起来很简单,大致分为三步
    (第一步):先判断此key是否为null,如果不为null则继续向下执行,反之调用 getForNullKey() 方法,这也是一个经过处理的方法,来看看方法实现

        private V getForNullKey() {
            //判断map大小
            if (size == 0) {
                return null;
            }
            //直接获取table[0]
            for (Entry<K, V> e = table[0]; e != null; e = e.next) {
                if (e.key == null)
                    return e.value;
            }
            return null;
        }
    

    上边说过了,当put是key为null则直接存入table数据的0索引处,所以取的时候当然也从0索引处取

    (第二步):根据key获取对应的Entry对象

        final Entry<K, V> getEntry(Object key) {
            //先判断map的大小
            if (size == 0) {
                return null;
            }
            //计算key的hash值
            int hash = (key == null) ? 0 : hash(key);
            //遍历当前hash列所对应的链表,并根据key查找Entry对象并返回
            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;
        }
    

    (第三步):当找到对应的Entry时,则返回Entry中的value值,反正返回null即没有找到

    线程安全:

    测试代码

    /**
     * ClassName: TestHashMap
     * @author lvfang
     * @Desc: TODO
     * @date 2017-9-23
     */
    public class TestHashMap implements Runnable {
        
        private HashMap<Integer, Integer> map = null;
        
        public TestHashMap(HashMap<Integer, Integer> map){
            this.map = map;
        }
        
        @Override
        public void run() {
            for(int i=0 ; i<50 ; i++) map.put(i, i);
            System.out.println(map.size());
        }
    
        public static void main(String[] args) throws InterruptedException {
            HashMap<Integer, Integer> map = new HashMap<>();
            map.put(1, 1);
            map.get(1);
            
            for(int i=0 ; i<5 ; i++){
                new Thread(new TestHashMap(map)).start();
            }
        }   
    }
    
    

    上边的例子很明显,开启5个线程,每个线程对map中都添加key为0-50的键和值,由于hashmap的key是唯一的,所以最终map的大小还是50,而运行结果则不定。

    为什么会产生线程安全:
    HashMap在并发时可能出现的问题主要是两方面,首先如果多个线程同时使用put方法添加元素,而且假设正好存在两个put的key发生了碰撞(hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。第二就是如果多个线程同时检测到元素个数超过数组大小*loadFactor,这样就会发生多个线程同时对Node数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失。

    解决方案一:方法同步必然是不可少的或者Hashtable;Map<String, String> hashtable = new Hashtable<>();

    解决方案二:用collections创建一个线程安全的map;Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());

    解决方案三:java并发包下的ConcurrentHashMap;Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

    面试题:

    http://www.cnblogs.com/zywu/p/5753736.html
    http://blog.csdn.net/song19890528/article/details/16891015
    http://www.cnblogs.com/lchzls/p/6714474.html

    相关博文:

    http://www.importnew.com/21396.html
    http://blog.csdn.net/happy_horse/article/details/52316865
    http://www.cnblogs.com/wuhuangdi/p/4175991.html
    http://blog.csdn.net/jerryburning/article/details/47619491
    http://blog.csdn.net/ghsau/article/details/16890151

    相关文章

      网友评论

          本文标题:HashMap源码初探

          本文链接:https://www.haomeiwen.com/subject/rvhkextx.html