Java集合五之HashMap

一:HashMap简介

HashMap可以说是Java中最常用的集合类框架之一,是Java语言中非常典型的数据结构。HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。

HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。
HashMap中的映射不是有序的。

HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null ,允许多条记录的值为 null 。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用ConcurrentHashMap 。  

二:HashMap源码解析

  1. 属性
    HashMap主要有八个属性:
    这里有两个很重要的参数:initialCapacity(初始容量)、loadFactor(加载因子),看看JDK中的解释:
      HashMap 的实例有两个参数影响其性能:初始容量 和加载因子。
      容量 :是哈希表中桶的数量,初始容量只是哈希表在创建时的容量,实际上就是Entry< K,V>[] table的容量
      加载因子 :是哈希表在其容量自动增加之前可以达到多满的一种尺度。它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
      当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
      假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。

    //默认初始容量
        static final int DEFAULT_INITIAL_CAPACITY = 16;
    
        //最大容量数
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        //默认加载因子,用于自动扩容
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        //存放容器
        transient Entry[] table;
    
        //容器大小
        transient int size;
    
        //扩容临界点 = 加载因子*容量
        int threshold;
    
        //加载因子
        final float loadFactor;
    
        //计数器,Fail-Fast 机制
        transient volatile int modCount;

    这里我们来谈谈最后的那个modCount属性。

    是不是看的很眼熟,没错在之前几篇源码讲解中ArrayList,LinkedList等等都存在该属性,其内部实现的增,删,改方法中我们总能看到modCount的身影。从其本身字面理解modCount为修改次数,但为什么要记录modCount的修改次数呢?

    大家发现一个公共特点没有,所有使用modCount属性的全是线程不安全的,其实如果我们深入研究了Java的并发编程的时候就可以发现,这个属性在其中经常性的出现,这是为什么呢?

    这里我们首先谈谈一个机制:Fail-Fast 机制
    我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对HashMap 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:注意到 modCount 声明为 volatile,保证线程之间修改的可见性。(volatile这个关键字将在之后的JVM中在详细解说)

  2. 构造方法
    HashMap 提供了四种构造方法:

    //给定初始容量和加载因子
        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();
        }
        //给定初始容量
        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();
        }
        //存放初始值
        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);
        }

    从上面可以看出,在不设定初始容器和加载因子时,其默认给定16和0.75,计算出的初始扩容临界点为 16*0.75,也就是当我们存放的数据大于扩容临界点,容器就会进行自动扩容;当然我们本身也可以自定义初始容器和加载因子,这个主要根据自身需要,具体情况具体分析了。
    这里我们在对容器 Entry[] table进行介绍:

    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();
            }
    
            void recordAccess(HashMap<K,V> m) {
            }
    
            void recordRemoval(HashMap<K,V> m) {
            }
        }

    通过这个源码我们可以看出,容器数组所存放的数据对象Entry为一个静态内部类对象,数据结构为单链结构;即整个HashMap实际上是由一个数组组成,数组中的成员为一个单链。

三:HashMap的方法解析

在下面的解析中,我将重点通过讲解数据存储中的HashMap底层实现来让大家对整个HashMap的数据结构有个充分认识;

  1. 数据存储

    public V put(K key, V value) {   
        if (key == null)  
            return putForNullKey(value);  
        // 得到key的哈希码  
        int hash = hash(key);  
        // 通过哈希码计算出bucketIndex  
        int i = indexFor(hash, table.length);  
        // 取出bucketIndex位置上的元素,并循环单链表,判断key是否已存在  
        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;  
            }  
        }  
    
        // 修改次数+1,加入新元素  
        modCount++;  
        addEntry(hash, key, value, i);  
        return null;  
    }  

    从第一个判断中,如果为null,则调用putForNullKey:字面来理解我们也可以看出该方法处理了HashMap中key用null的原因,来看看HashMap是如何处理null键的:

    private V putForNullKey(V value) {
            //遍历,查找链表中是否有null键
            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++;
            //如果链中查找不到,则把该null键插入
            addEntry(0, null, value, 0);
            return null;
        }

    如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回:这就是为什么HashMap不能有两个相同的key的原因;我们从这里可以发现,所有的存储过程中,HashMap都会先进行一次判定,有值就覆盖,无值则新增一个。

    接下来我们来看看HashMap中的hash()方法,如何计算一个key的哈希码?

    final int hash(Object k) {
            int h = 0;
            if (useAltHashing) {
                if (k instanceof String) {
                    return sun.misc.Hashing.stringHash32((String) k);
                }
                h = hashSeed;
            }
    
            h ^= k.hashCode();
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

    这里主要通过一个数学算法来进行位运算操作,最终得出一个hash码;接下来我们看看indexFor(hash, table.length),如何得到一个hash值;

    static int indexFor(int h, int length) {
            return h & (length-1);
        }

    这里进行了一次位运算符 &与 操作得到容器数组的下标值;接下来根据这个下标值进行数组遍历,得到了单链再继续判断是否存在值,存在则覆盖,返回原值;否则操作+1,添加数据;那么这里为什么用&呢?

    对于HashMap的table而言,数据分布需要均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。计算hash值后,怎么才能保证table元素分布均与呢?我们会想到取模,但是由于取模的消耗较大,而HashMap是通过&运算符(按位与操作)来实现的:h & (length-1)

    在构造函数中存在:capacity <<= 1,这样做总是能够保证HashMap的底层数组长度为2的n次方。当length为2的n次方时,h&(length - 1)就相当于对length取模,而且速度比直接取模快得多,这是HashMap在速度上的一个优化。至于为什么是2的n次方下面解释。
      
    我们回到indexFor方法,该方法仅有一条语句:h&(length - 1),这句话除了上面的取模运算外还有一个非常重要的责任:均匀分布table数据和充分利用空间。
      分析一下:当length-1 = 14时,二进制的最后一位是0,在&操作时,一个为0,无论另一个为1还是0,最终&操作结果都是0,这就造成了结果的二进制的最后一位都是0,这就导致了所有数据都存储在2的倍数位上,碰撞几率太高;当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

    这里推荐一篇博文,对该长度定义有一个详细的解析:
      

    http://www.cnblogs.com/chengxiao/p/6059914.html

  2. 数据读取

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

    这里get方法和put的原理基本一样,只是当遍历到该数据时,直接返回value;没有则为null。

  3. 数据删除

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

    删除方法和查询和存储有点不同,这里当找到key对应数组索引时,对该处存放的单链进行遍历,进行删除操作;这里用到了recordRemoval方法,我们来看看源码:

    void recordRemoval(HashMap<K,V> m) {
                remove();
            }
            private void remove() {
                before.after = after;
                after.before = before;
            }

    从这里我们可以发现,modCount只进行了一次操作,当该单链下有多条数据时,删除第一条之后直接调用recordRemoval清空该数组索引下的Entry。

  4. 数据扩容

    //数据新增
        void addEntry(int hash, K key, V value, int bucketIndex) {
            Entry<K,V> e = table[bucketIndex];
                table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
                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;
                }
                //不超出最大值,new一个新容器,长度翻倍
                Entry[] newTable = new Entry[newCapacity];
                //赋值
                transfer(newTable);
                table = newTable;
                //计算新的扩容临界点
                threshold = (int)(newCapacity * loadFactor);
            }

    从上面add方法我们可以看出,当新增数据之后的容器长度达到临界点时,将进行自动扩容操作;这里resize(2 * table.length),很明显HashMap的扩容直接翻倍;在扩容中我们将创建一个新的容器在进行转移数据操作。

  5. 其他类别
    HashMap是通过拉链法实现的散列表。表现在HashMap包括许多的Entry,而每一个Entry本质上又是一个单向链表。那么HashMap遍历key-value键值对的时候,是如何逐个去遍历的呢?
    下面我们就看看HashMap是如何通过entrySet()遍历的。
    entrySet()实际上是通过newEntryIterator()实现的。 下面我们看看它的代码:

    // 返回一个“entry迭代器”
    Iterator<Map.Entry<K,V>> newEntryIterator()   {
        return new EntryIterator();
    }
    // Entry的迭代器
    private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
        public Map.Entry<K,V> next() {
            return nextEntry();
        }
    }
    // HashIterator是HashMap迭代器的抽象出来的父类,实现了公共了函数。
    // 它包含“key迭代器(KeyIterator)”、“Value迭代器(ValueIterator)”和“Entry迭代器(EntryIterator)”3个子类。
    private abstract class HashIterator<E> implements Iterator<E> {
        // 下一个元素
        Entry<K,V> next;
        // expectedModCount用于实现fast-fail机制。
        int expectedModCount;
        // 当前索引
        int index;
        // 当前元素
        Entry<K,V> current;
        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                // 将next指向table中第一个不为null的元素。
                // 这里利用了index的初始值为0,从0开始依次向后遍历,直到找到不为null的元素就退出循环。
                while (index < t.length && (next = t[index++]) == null)
    
            }
        }
        public final boolean hasNext() {
            return next != null;
        }
        // 获取下一个元素
        final Entry<K,V> nextEntry() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();
            // 注意!!!
            // 一个Entry就是一个单向链表
            // 若该Entry的下一个节点不为空,就将next指向下一个节点;
            // 否则,将next指向下一个链表(也是下一个Entry)的不为null的节点。
            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
    
            }
            current = e;
            return e;
        }
        // 删除当前元素
        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;
        }
    }

四:总结

  1. 单链
    这是一个非常优雅的设计。系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。
  2. 存储原理
    传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中;
    然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束;
    否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部
  3. 缺陷
    hash冲突,在Java中,主要使用的是链地址法来解决hash冲突问题;主要还存在

    开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
    再哈希法
    建立一个公共溢出区
    

    等等方式来处理,大家有兴趣的可以去了解下。

  4. JDK1.8 新特性(红黑树)

    在jdk7中,HashMap处理“碰撞”的时候,都是采用链表来存储,当碰撞的结点很多时,查询时间是O(n)。

    在jdk8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点较少时,采用链表存储,当较大时(>8个),采用红黑树(特点是查询时间是O(logn))存储(有一个阀值控制,大于阀值(8个),将链表存储转换成红黑树存储)

    问题分析:

    我们知道哈希碰撞会对hashMap的性能带来灾难性的影响。如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。

    随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。

    JDK1.8HashMap的红黑树是这样解决的:

    在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值 8 时,将链表转换为红黑树,这样大大减少了查找时间。
    如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。

    它是如何工作的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。

    总之就是一句话:HashMap的底层通过位桶实现,位桶里面存的是链表(1.7以前)或者红黑树(有序,1.8开始) ,其实就是数组加链表(或者红黑树)的格式,通过判断hashCode定位位桶中的下标,通过equals定位目标值在链表中的位置,所以如果你使用的key使用可变类(非final修饰的类),那么你在自定义hashCode和equals的时候一定要注意要满足:如果两个对象equals那么一定要hashCode相同,如果是hashCode相同的话不一定要求equals!所以一般来说不要自定义hashCode和equls,推荐使用不可变类对象做key,比如Integer、String等等。

在这里给大家推荐一篇博文,应该可以说是本篇的一个进阶吧

http://www.jianshu.com/p/e54047b2b563

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值