一: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源码解析
属性
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中在详细解说)构造方法
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的数据结构有个充分认识;
数据存储
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数组中分布较均匀,查询速度也较快。这里推荐一篇博文,对该长度定义有一个详细的解析:
数据读取
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。
数据删除
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。
数据扩容
//数据新增 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的扩容直接翻倍;在扩容中我们将创建一个新的容器在进行转移数据操作。
其他类别
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; } }
四:总结
- 单链
这是一个非常优雅的设计。系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。 - 存储原理
传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中;
然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束;
否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部 缺陷
hash冲突,在Java中,主要使用的是链地址法来解决hash冲突问题;主要还存在开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列) 再哈希法 建立一个公共溢出区
等等方式来处理,大家有兴趣的可以去了解下。
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等等。
在这里给大家推荐一篇博文,应该可以说是本篇的一个进阶吧