HashMap重点详解
Map即映射表一般称为散列表。开发中常用到这种数据结构,Java中HashMap和ConcurrentHashMap被用到的频率较高,本文重点说下HashMap的实现原理以及设计思路。
HashMap的本质是一个数组,数组的每个索引被称为桶,每个桶里放着一个单链表,一个节点连着一个节点。很明显通过下标来检索数组元素时间复杂度为O(1),而且遍历链表的时间复杂度是常数级别,所以整体的查询复杂度仍为O(1)。我们先来看下HashMap的成员属性:
// 默认的初始容量是16,必须是2的幂(这点很重要,后面讲述原因) static final int DEFAULT_INITIAL_CAPACITY = 16; // 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换) static final int MAXIMUM_CAPACITY = 1 << 30; // 默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 存储数据的Entry数组 transient Entry[] table; // HashMap的大小,它是HashMap实际保存的键值对的数量 transient int size; // HashMap的阈值(threshold = 容量*加载因子),就是通过它和 //size进行比较来判断是否需要扩容 int threshold; // 加载因子实际大小 final float loadFactor; // HashMap被改变的次数(用于快速失败,后面详细讲) transient volatile int modCount;
成员属性的意义如上所述,我们再来看下它们修饰符的设计含义:table和size以及modCount都被transient所修饰,transient为短暂的意思,java中只能用来修饰类成员变量,作用是对象序列化时被修饰的字段不会被序列化到目的地。很容易想到:map只要执行put或remove操作后三者的值都会产生变化,对于这种状态常变(短暂)的属性我们没必要在对象序列化时将其值带入。此外,modCount还被volitile修饰,这个关键字主要作用是使被修饰的变量在内存中的变化可被多线程所见,因为modCount用于快速失败机制,所以写线程执行时带来的变化需及时被读线程知道。
我们再看下Entry类:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; // 指向下一个节点 Entry<K,V> next; final int hash; // 构造函数。 // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)" 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; } }
每个桶的Entry对象其实就是指的单链表,Entry作为hashMap的静态内部类,实现了Map.Entry<K,V>接口。设计的很硬气,所有的get&set都是final,不允许再被使用者重写重定义了。
研究一种数据结构,知道了它的基本组成,就可进一步了解它的存取机制:map的get,put,remove。map无论是增删查,经历的第一步就是定位桶的位置,即通过对象的hashCode(其实map中又再次hash了一遍)来取模定位,然后遍历桶中的链表元素进行equals比较。所以,我在这里重点说下hashCode()和equals(Object o)两个方法的关联。
常说hashCode是equals的必要不充分条件,这个说法主要就是根据散列表来的。不重写的情况下,hashCode默认返回对象在堆内存中的首地址,equals默认比较两个对象在堆内存中的首地址。就equals而言,这种比较方式在实际业务中基本无意义,我们判断两个对象是否相等,通常根据他们的某些属性值是否相等来判断,就像根据ID和name我们就可以断定一个员工的唯一性。eclipse或者idea现在都可默认为你的model生成equals方法,如下所示:
class Animal{ private int id; private String name; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || o.getClass()!=this.getClass()) return false; Animal animal = (Animal) o; if (id != animal.id) return false; return name != null ? name.equals(animal.name) : animal.name == null; } }
流程:如果首地址都相等那肯定就是一个对象,直接返回true,不等就继续判断是否同属一个类,不是一个类那根本就不用继续判断直接false。这里还是有争议的,因为有的写法是 !(o instanceof Animal),两者的区别会在继承中体现出来,比如我再创建一个子类Dog
class Dog extends Animal{ private double weight; public double getWeight() { return weight; } public void setWeight(double weight) { this.weight = weight; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; Dog dog = (Dog) o; return Double.compare(dog.weight, weight) == 0; } }
Dog中添加了一个weight属性,并在基类Animal的基础上再次重写了equals方法。看下面一段代码:
Animal animal=new Animal(); animal.setId(1); animal.setName("dog"); Dog dog = new Dog(); dog.setId(1); dog.setName("dog");
dog.setWeight(1); System.out.print(animal.equals(dog));
如果按照 getClass() != o.getClass() 这个逻辑,两者equals就直接false了,而按照!(o instanceof Animal)这个逻辑最终会返回true。理论讲应该返回false的,否则weight这个字段的意义呢?被dog吃了?所以当该类下有子类时,equals中最好采用getClass()这种判断方式。再看hashCode():
@Override public int hashCode() { int result = id; result = 31 * result + (name != null ? name.hashCode() : 0); return result; }
这时候就要思考为什么hashCode值取决于ID和Name字段,我们知道在map里寻找元素通过equals比较只是第二步骤,首要步骤是先定位到桶的位置(hash&length-1),如果两个本equals的对象连hashCode都不相等,那就很容易造成下述3种情况:
1:get(key)的时候取不出来
2:put(k,v)的时候存了重复值
3:remove(key)的时候删不掉
接下来,再了解下HashMap的构造方法:
// 指定“容量大小”和“加载因子”的构造函数 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // HashMap的最大容量只能是MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 找出“大于initialCapacity”的最小的2的幂 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 设置“加载因子” this.loadFactor = loadFactor; // 设置“HashMap阈值”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。 threshold = (int)(capacity * loadFactor); // 创建Entry数组,用来保存数据 table = new Entry[capacity]; init(); } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
你不指定容量和加载因子时hashMap就按默认的给你,指定的话就按你的来,有意思的是hashmap怕你不够懂它特意又对你赋的容量值进行了一次计算,转化为小于该值的最大偶数。容量值为二次幂的设计魅力后面会讲。
最后再简单看下两个方法我们奔主题了:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // 返回索引值 // h & (length-1)保证返回值的小于length static int indexFor(int h, int length) { return h & (length-1); }
hashmap会对所有的key再重hash一次,至于为什么这么写不需要理解,只需要知道一切都是最好的安排。indexFor则是用来定位key对应哪个桶。
准备完毕,开始看下get(key)的实现:
public V get(Object key) { if (key == null) return getForNullKey(); // 获取key的hash值 int hash = hash(key.hashCode()); // 在“该hash值对应的链表”上查找“键值等于key”的元素 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; }
hashMap与hashTable其中不同的一点是前者允许key为null,这点设计的很取巧,把key为null的对象存在数组首位(table[0]),代码如下:
private V getForNullKey() { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }
接下来的步骤就是:重hash->定位桶->遍历桶中的链表一一比较。在判断过程中会先判断e.hash==hash,更印证了之前说的hashCode相等是equals成立的必要不充分条件。
再来看put方法的实现:
public V put(K key, V value) { // 若“key为null”,则将该键值对添加到table[0]中。 if (key == null) return putForNullKey(value); // 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。 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; // 若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出! if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 若“该key”对应的键值对不存在,则将“key-value”添加到table中 modCount++; addEntry(hash, key, value, i); return null; }
首先还是会先判断key值是否为null,如果为null,则将该元素放置在数组0位置,如下图所示:
private V putForNullKey(V 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(0, null, value, 0); return null; }
我们知道在hashMap中存储一个已有的key,新key对应的value值会替换掉old值。所以put操作会先判断一下是否已经存在该key,存在的话就替换成新值返回老值。不存在执行addEntry返回null。这里需要注意的是如果key之前存在过,替换旧值不会修改modCount,不存在该key则modCount+1。我们可以这么认为,只有map中的元素数量增多或减少的情况下才认为map的结构的发生了变化。
接下来讲一下重点方法:addEntry(xxx);扩容操作就是在这里进行的
void addEntry(int hash, K key, V value, int bucketIndex) { // 保存“bucketIndex”位置的值到“e”中 Entry<K,V> e = table[bucketIndex]; // 设置“bucketIndex”位置的元素为“新Entry”, // 设置“e”为“新Entry的下一个节点” table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 若HashMap的实际大小 不小于 “阈值”,则调整HashMap的大小 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; } // 新建一个HashMap,将“旧HashMap”的全部元素添加到“新HashMap”中, // 然后,将“新HashMap”赋值给“旧HashMap”。 Entry[] newTable = new Entry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } // 将HashMap中的全部元素都添加到newTable中 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; 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); } } }
流程如下:
1:将新元素作为桶中链表的头节点,如果达到阈值则第二步
2:扩容为原来2倍,如果之前容量已经是最大值了,则直接将阈值设为Int型的最大值返回(有点弃疗的意思)
3:重新散列-->外循环遍历每个桶,内循环遍历每个桶中链表的每个节点,将每个节点定位到新的位置。
在多线程中,经过resize过程后,再涉及到迭代或者扩容操作时,会有一定几率造成死循环或者数据丢失。
先看图一:首先向length=2的map中插入三个元素(为方便画图这里直接采用hash&length),最终桶1中形成链表3-7-5。
这时候A线程再添加一个元素,然后进行扩容操作,并将元素3房屋新的桶,此时元素3的next是7:
此时线程B添加了元素后也进行了扩容操作,且直接扩容完成,如下图:
此时7的next指向了3而不再指向5
然后A线程继续向下走的时候就出现了死循环问题,因为在线程A中3的next是指向7的,所以当再把7进行重定位时就出现了如下图所示:
所以之后的遍历或者扩容过程只要到了桶3,便会一直在7和3之间死循环。数据缺失的发生场景也是如此,可以自己分析。
下面来讲下:为什么map内部数组的长度要为2次幂。
我们知道数组的长度主要被用来做了这么一件事,就是通过indexFor方法去定位key位于哪个桶,即 h & (length-1);
分析一下:&运算是同一位都为1时才为1,假如一个key的hash为43,即二进制为101011,map的长度为16
则indexFor:
101011
& 001111
——————
001011
为11,
当进行一次resize操作时,length=16<<1=32,再次进行indexFor操作:
101011
& 011111
——————
001011
依然为11。
我们可以很容易发现,如果length是2的次幂,length-1的二进制每位均是1,而扩容后-1二进制依然每位均是1,所以&的结果取决于hash的二进制,即有一半几率该节点依然位于原来的桶(但节点依然是会移动的),一半几率被分到了其他的桶,从而保证了扩容后节点分配的均衡性。这是其一。
其二:我们假如桶的长度不是2次幂,拿length=15举例,length-1=14=1110。那么这时候任何key与其&操作,最后一位都是0,这就意味着桶的第1个位置永远都不会被放入元素!同理假如length-1=12=1100,那么第1,2,3的位置也永远不可能被放入元素。这会造成空间的浪费以及数据的分配不均。
以上,就是map的数组长度要为2次幂的奥秘所在。
顺便在提一下除map外的其他容器的初始长度设定:拿StringBuilder来讲,字符串相加时我们考虑到内存回收一般采用StringBuilder或StringBuffer的append来代替,那么假如可以提前估算出一个字符串的大概长度,那么请以这个大概长度直接在集合类的构造器中赋值进去,因为StringBuilder每次进行数组扩容的时候都会伴随着元素的copy,频繁的copy会一定程度上影响效率。ArrayList也是同理。
研究数据结构,我们还有一个重要的关注点就是元素的遍历。java的集合类一般都会在内部实现一个迭代器即Iterator,它的意义是什么呢?从客户端角度来讲,我可能并不关心目前操作的数据结构的内部实现,像ArrayList内部是个数组,LinkedList内部是个链表,HashMap内部又是个数组链表,I dont care。我只想拿它做个遍历,而不是针对数组时使用索引遍历,针对链表时使用xxx.next,map时又两者并用,Iterator就解决了这个问题,它作为接口定义了hasNext(),next(),remove()三个核心方法。任何一个集合类,都可以通过自己的一个内部类去实现该接口然后对外提供遍历方法和移除元素方法。现在通过hashmap的源码来看下原理:
hashmap实现了三种Iterator,分别针对key,value,还有entry。源码如下:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> { public final int size() { return size; } public final void clear() { HashMap.this.clear(); } public final Iterator<Map.Entry<K,V>> iterator() { return new EntryIterator(); } } abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry
int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } } final class KeyIterator extends HashIterator implements Iterator<K> { public final K next() { return nextNode().key; } } final class ValueIterator extends HashIterator implements Iterator<V> { public final V next() { return nextNode().value; } } final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }
内部类的一大特征就是可以访问主类的成员变量和成员方法,EntrySet和EntryIterator作为HashMap的内部类三者相辅相成,可以看到无论是next还是remove,实际上都是操作的主类hashMap的table。但是这种操作对外部是透明的,可以看到封装的魅力。在HashIterator构造器中,modCount会被赋值到expectedModcount,顾名思义expectedModcount是期望的变化值,如果当前是多线程环境,进行next遍历时,当前节点可能已被其他线程remove了,或者其他线程的put操作已经改变了当前节点的位置。这种情况下expectedModcount不再等于modCount,HashMap会认为该遍历得到的数据是无效的,便执行快速失败机制。这就是modCount被validate修饰的原因。当然这种快速失败机制只是为了防止一定程度上的脏读,而不是彻底解决并发问题。
说完Iterator我们再来谈HashMap的遍历方式,无需多说,数据量大的时候第一种远高于第二种
/* * 通过entry set遍历HashMap * 效率高! */ private static void iteratorHashMapByEntryset(HashMap map) { if (map == null) return ; System.out.println("\niterator HashMap By entryset"); String key = null; Integer integ = null; Iterator iter = map.entrySet().iterator(); while(iter.hasNext()) { Map.Entry entry = (Map.Entry)iter.next(); key = (String)entry.getKey(); integ = (Integer)entry.getValue(); System.out.println(key+" -- "+integ.intValue()); } } /* * 通过keyset来遍历HashMap * 效率低! */ private static void iteratorHashMapByKeyset(HashMap map) { if (map == null) return ; System.out.println("\niterator HashMap By keyset"); String key = null; Integer integ = null; Iterator iter = map.keySet().iterator(); while (iter.hasNext()) { key = (String)iter.next(); integ = (Integer)map.get(key); System.out.println(key+" -- "+integ.intValue()); } }
第二种方法每当取得了key值后又进行了一次get(key)操作,不但无意义且影响效率。
以上是个人对HashMap的理解和分析,没有什么布局且作为第一版吧,本文粘贴的代码小部分直接来源于jdk,大部分采用了下面的博客(https://www.cnblogs.com/skywang12345/p/3310835.html#a3),因为这边博客已经将每行代码做了什么用中文讲的很清楚了,我又在一些关键点上加了一些个人理解。不足之处还望大家指正。