Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用实现类,HashMap,Hashtable,LinkedHashMap,TreeMap.如图:
HashMap简介:
它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因为具有很快的访问速度,但遍历顺序是不固定的。HashMap最多只允许一个键为null,允许多个值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
内部实现
搞清楚HashNMap,首先需要知道它是什么,即它的存储结构-字段,还要弄明白它能干什么,即它的功能实现-方法。
存储结构-字段
结构实现讲,HashMap是数组加链表+红黑树(jdk1.8新加的)实现的,如图
每一个黑圈圈就是一个Node<K,V> (1.8之前是Entry<K,V>)。
HashMap有一个非常重要的字段就是Node[] table 就是哈希桶数组,是一个Node数组。
这是个什么东西呢,我们查看HashMap源码可知 有一个内部类也就是Node<K,V>
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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
该类实现了Entry<K,V>接口,本质上也就是一个映射,也就是我们所说的键值对。
HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法,HashMap采用的是链地址法。简单来说就是数组加链表,在每个数组元素上都加一个链表结构,当我们存储元素时,先调用key的hashCode()方法,然后通过Hash算法的后两步运算(后面会说到)来定位该键值对的存储位置,如果该位置已经有了某键值对,那么就发生了hash碰撞。如果哈希桶数组很大,即使交叉的Hash算法也会比较分散,如果哈希桶数组很小,即使好的Hash算法也会出现较多的碰撞,所以就需要在空间成本和时间成本之间做权衡。下面看一下HashMap是通过什么来控制这些的。
先看一下HashMap的几个字段
int threshold; //所能容纳的键值对,也叫做阀值 通过负载因子 * 容量得来
final float loadFactor; //负载因子 默认0.75 可以超过1
int modCount;
int size;
首先table的默认初始化长度是16,负载因子是0.75,默认允许存储的元素数量就是0.75 * 16 。也就是说,在数组定义好长度之后,负载因子越大 ,能存储元素的数量就越多。如果超过了这个容量,那么就要进行resize(扩容),扩容后的容量是之前的两倍。默认的负载因子0.75 是一个权衡的选择 ,尽量不要修改,当然特殊情况下除外。如果内存很充足而对效率的要求很高,可以降低负载因子的值。如果内存很紧张,而对效率要求不是很高的情况下 ,可以提高负载因子的值,前面说过 这个值是可以大于1的。
size 就很好理解了,就是HashMap中实际存储的元素数量。注意table的长度length和容纳最大键值对数量threshold是有区别的。
modCount字段主要记录了HashMap内部结构发生变化的次数,比如put新的键值对,但是如果有key相同value被覆盖的情况 则不计数。
这里就有一个问题 即使负载因子和Hash算法在合理,也会出现Hash碰撞多次的情况 也就是链表过长,这里在jdk1.8进行了优化,当链表长度大于8的时候 就转换为红黑树,利用红黑树crud比较快的特点提高了HashMap的性能。想了解红黑树的同学们可以另查一下哈。
功能实现-方法:
不管是增删改查,快速定位到元素在哪个哈希桶是很重要的。我们看一下源码可知
static final int hash(Object key) { int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
//第一步取hashCode值 第二步 高位参与运算>>>16
static int indexFor(int h, int length) { //偷来的 jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的 return h & (length-1); //第三步 取模运算 }
对于给定的对象,如果它的hashCode值是相同的,那么程序调用第一个方法得到的Hash码就是相同的,HashMap通过第二个方法来计算到底将元素放在table数组的那个索引处。
这个方法通过h & (table.length -1) 来得到该对象的保存位置,而HashMap底层数组的长度总是2的n次方,这时HashMap在速度上的优化。当length总是2的n次方时, h & (length - 1) 运算等价于对length取模,h % length 但是& 比 % 有更高的效率。
在jdk1.8的实现中,又花了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h=k.hashCode())^(h>>>16) ,主要是从速度,功效,质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
HashMap的put方法
1 public V put(K key, V value) { 2 // 对key的hashCode()做hash 3 return putVal(hash(key), key, value, false, true); 4 } 5 6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 7 boolean evict) { 8 Node<K,V>[] tab; Node<K,V> p; int n, i; 9 //tab为空则创建新的 10 if ((tab = table) == null || (n = tab.length) == 0) 11 n = (tab = resize()).length; 12 // 计算index,并对null做处理 13 if ((p = tab[i = (n - 1) & hash]) == null) 14 tab[i] = newNode(hash, key, value, null); 15 else { 16 Node<K,V> e; K k; 17 // 如果key已经存在那么将value值覆盖 18 if (p.hash == hash && 19 ((k = p.key) == key || (key != null && key.equals(k)))) 20 e = p; 21 // 判断该链是否为红黑树 22 else if (p instanceof TreeNode) 23 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 24 // 该链为链表 25 else { 26 for (int binCount = 0; ; ++binCount) { 27 if ((e = p.next) == null) { 28 p.next = newNode(hash, key,value,null); //链表长度大于8转换为红黑树进行处理 29 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 30 treeifyBin(tab, hash); 31 break; 32 } // key已经存在直接覆盖value 33 if (e.hash == hash && 34 ((k = e.key) == key || (key != null && key.equals(k)))) break; 36 p = e; 37 } 38 } 39 40 if (e != null) { // existing mapping for key 41 V oldValue = e.value; 42 if (!onlyIfAbsent || oldValue == null) 43 e.value = value; 44 afterNodeAccess(e); 45 return oldValue; 46 } 47 } 48 ++modCount; 49 //超过最大容量 就扩容 50 if (++size > threshold) 51 resize(); 52 afterNodeInsertion(evict); 53 return null; 54 }
扩容机制:
扩容(resize) 就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组。
看一下resize的源码,jdk1.8加入了红黑树的比较复杂,我们先看一下1.7的。
1 void resize(int newCapacity) { //传入新的容量 2 Entry[] oldTable = table; //引用扩容前的老的Entry数组 3 int oldCapacity = oldTable.length; 4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了 5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 6 return; 7 } 8 9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组 10 transfer(newTable); //将数据转移到新的Entry数组里 11 table = newTable; //HashMap的table属性引用新的Entry数组 12 threshold = (int)(newCapacity * loadFactor);//计算新的阀值 13 }
大概就是用一个新的容量大的数组代替了老的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的数组中
1 void transfer(Entry[] newTable) { 2 Entry[] src = table; //src引用了旧的Entry数组 3 int newCapacity = newTable.length; 4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组 5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素 6 if (e != null) { 7 src[j] = null;//将就的数组对象制空(for循环后,旧的Entry数组不再引用任何对象) 8 do { 9 Entry<K,V> next = e.next; 10 int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置 11 e.next = newTable[i]; //标记 12 newTable[i] = e; //将元素放在数组上 13 e = next; //访问下一个Entry链上的元素 14 } while (e != null); 15 } 16 } 17 }
这里我个人理解的也不是特别完善,jdk1.8这里也有一些改动 我会继续研究并加上来的
小结:
1、扩容是一个特别耗性能的操作,所以在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容
2、负载因子是可以修改的,也可以大于1,但是建议不要轻易修改
3、HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap
4、jdk1.8引入红黑树大程度优化了HashMap的性能