学习笔记-深入理解Java中的HashMap数据结构
一:定义
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
public class HashMap<K,V> extends AbstractMap<K,V> 2 implements Map<K,V>, Cloneable, Serializable
二:构造函数和数据结构
HashMap提供了三个构造函数:
1 HashMap()
2
3 HashMap(int initialCapacity)
4
5 HashMap(int initialCapacity, float loadFactor)
第一个:构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
第二个:构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
第三个:构造一个带指定初始容量和加载因子的空 HashMap。
那么这两个参数有什么含义呢?在HashMap中有什么作用?我们先来看一下HashMap的数据结构深入理解之后在回过头来看。我们看每次调用map.put方法是,其实我们是往Entry<K,V>[] tab 这个数组里面添加元素的。那么Entry这个又是一个什么呢?
1 static class Entry<K,V> implements Map.Entry<K,V> {
2 final K key;
3 V value;
4 Entry<K,V> next;
5 final int hash;
6
7 /**
8 * Creates new entry.
9 */
10 Entry(int h, K k, V v, Entry<K,V> n) {
11 value = v;
12 next = n;
13 key = k;
14 hash = h;
15 }
16 }
很显然,这其实是一个链表的数据结构。所以我们分析一下,那么HashMap的数据结构是不是就是张的这个样子的呀?
其实呢,我们在开始的构造函数里面的initialCapacity这个参数,就是指的这个数组的长度了,我们在看一下具有两个参数的那个构造方法。
1 public HashMap(int initialCapacity, float loadFactor) {
2 //初始容量不能<0
3 if (initialCapacity < 0)
4 throw new IllegalArgumentException("Illegal initial capacity: "
5 + initialCapacity);
6 //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
7 if (initialCapacity > MAXIMUM_CAPACITY)
8 initialCapacity = MAXIMUM_CAPACITY;
9 //负载因子不能 < 0
10 if (loadFactor <= 0 || Float.isNaN(loadFactor))
11 throw new IllegalArgumentException("Illegal load factor: "
12 + loadFactor);
13
14 // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
15 int capacity = 1;
16 while (capacity < initialCapacity)
17 capacity <<= 1;
18
19 this.loadFactor = loadFactor;
20 //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
21 threshold = (int) (capacity * loadFactor);
22 //初始化table数组
23 table = new Entry[capacity];
24 init();
25 }
每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。
三,添加数据
1 public V put(K key, V value) {
2 //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
3 if (key == null)
4 return putForNullKey(value);
5 //计算key的hash值
6 int hash = hash(key.hashCode()); ------(1)
7 //计算key hash 值在 table 数组中的位置
8 int i = indexFor(hash, table.length); ------(2)
9 //从i出开始迭代 e,找到 key 保存的位置
10 for (Entry<K, V> e = table[i]; e != null; e = e.next) {
11 Object k;
12 //判断该条链上是否有hash值相同的(key相同)
13 //若存在相同,则直接覆盖value,返回旧value
14 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
15 V oldValue = e.value; //旧值 = 新值
16 e.value = value;
17 e.recordAccess(this);
18 return oldValue; //返回旧值
19 }
20 }
21 //修改次数增加1
22 modCount++;
23 //将key、value添加至i位置处
24 addEntry(hash, key, value, i);
25 return null;
26 }
重点来了,我们看一下上面代码第6,8行,一个是通过key计算hash值,一个是通过hash值定位到数组中的位置。
1 static int hash(int h) {
2 h ^= (h >>> 20) ^ (h >>> 12);
3 return h ^ (h >>> 7) ^ (h >>> 4);
4 }
5
6 static int indexFor(int h, int length) {
7 return h & (length-1);
8 }
对于HashMap的数组而言,我们需要他里面的数据分布的尽量均匀,最好是每一项都有元素。因为分布的太紧张查询蛮,分布的太分散浪费空间。因为我们在构造函数中capacity <<= 1;这样做总是能够保证HashMap的底层数组长度为2的n次方。所以index的方法就相当于是对lenght取模处理。那么我们为什么要取模处理呢?因为length是2的N次方,当length-1的时候,你会发现得到的结果值 进行地位&运算时候,值与原来的hash值相同,而进行高位运算时,其值等于其低位值。所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。
那么所以整体上来讲,HashMap的put方法就是:首先会计算key的hash值,然后根据hash值确认在table中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等 ,则将该节点插入该链表的链头。
这种解决hash冲突的方法叫做【链地址法】,还有其他的比如:再哈希法,开放定址法,建立一个公共溢出区。
具体的实现过程见addEntry方法。
1 void addEntry(int hash, K key, V value, int bucketIndex) {
2 //获取bucketIndex处的Entry
3 Entry<K, V> e = table[bucketIndex];
4 //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
5 table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
6 //若HashMap中元素的个数超过极限了,则容量扩大两倍
7 if (size++ >= threshold)
8 resize(2 * table.length);
9 }
首先,系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。
然后是扩容, 随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度,为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。该临界点在当HashMap中元素的数量等于table数组长度*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。具体扩容的代码很简单,
1 void resize(int newCapacity) { 2 Entry[] oldTable = table; 3 int oldCapacity = oldTable.length; 4 if (oldCapacity == MAXIMUM_CAPACITY) { 5 threshold = Integer.MAX_VALUE; 6 return; 7 } 8 Entry[] newTable = new Entry[newCapacity]; 9 boolean oldAltHashing = useAltHashing; 10 useAltHashing |= sun.misc.VM.isBooted() && 11 (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); 12 boolean rehash = oldAltHashing ^ useAltHashing; 13 //transfer函数的调用 14 transfer(newTable, rehash); 15 table = newTable; 16 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); 17 } 18 19 void transfer(Entry[] newTable, boolean rehash) { 20 int newCapacity = newTable.length; 21 //这里才是问题出现的关键 22 for (Entry<K,V> e : table) { 23 //遍历旧的Entry数组的每个元素, 24 while(null != e) { 25 //寻找到下一个节点.. 26 Entry<K,V> next = e.next; 27 if (rehash) { 28 e.hash = null == e.key ? 0 : hash(e.key); 29 } 30 //重新计算每个元素在数组中的位置 31 int i = indexFor(e.hash, newCapacity); 32 e.next = newTable[i]; 33 newTable[i] = e; 34 e = next; 35 } 36 }
四,读取数据
通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。
1 public V get(Object key) {
2 // 若为null,调用getForNullKey方法返回相对应的value
3 if (key == null)
4 return getForNullKey();
5 // 根据该 key 的 hashCode 值计算它的 hash 码
6 int hash = hash(key.hashCode());
7 // 取出 table 数组中指定索引处的值
8 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
9 Object k;
10 //若搜索的key与查找的key相同,则返回相对应的value
11 if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
12 return e.value;
13 }
14 return null;
15 }
2018年5月2日更新:
发现当时jdk1.7的版本和现在的版本中HashMap的实现变化还挺大的。所以这里在重新更新一下,把认识到的变化在这里再做一些补充下,避免后面大家看到了这个对大家产生误导。
第一点:
JDK1.8 之后的 HashMap 底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。我们知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。
第二点:在hash方面,首先,在高位扰动方面,只是简单的h = h ^ (h >>> 16),没有再做那么多的扰动,就得到了hash值。其次,去掉了indexFor这个专门定位的函数,而是在put,get等操作中直接定位,可以看到这些函数中都有这两行。
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 }
第三点:就是扩容机制,jdk1.7扩容是直接采用头插将老的数据遍历插入到新的table中。在jdk1.8新版本中,我们来看一下扩容机制。
1 final Node<K,V>[] resize() { 2 Node<K,V>[] oldTab = table;//首次初始化后table为Null 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold;//默认构造器的情况下为0 5 int newCap, newThr = 0; 6 if (oldCap > 0) {//table扩容过 7 //当前table容量大于最大值得时候返回当前table 8 if (oldCap >= MAXIMUM_CAPACITY) { 9 threshold = Integer.MAX_VALUE; 10 return oldTab; 11 } 12 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 13 oldCap >= DEFAULT_INITIAL_CAPACITY) 14 //table的容量乘以2,threshold的值也乘以2 15 newThr = oldThr << 1; // double threshold 16 } 17 else if (oldThr > 0) // initial capacity was placed in threshold 18 //使用带有初始容量的构造器时,table容量为初始化得到的threshold 19 newCap = oldThr; 20 else { //默认构造器下进行扩容 21 // zero initial threshold signifies using defaults 22 newCap = DEFAULT_INITIAL_CAPACITY; 23 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 24 } 25 if (newThr == 0) { 26 //使用带有初始容量的构造器在此处进行扩容 27 float ft = (float)newCap * loadFactor; 28 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 29 (int)ft : Integer.MAX_VALUE); 30 } 31 threshold = newThr; 32 @SuppressWarnings({"rawtypes","unchecked"}) 33 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 34 table = newTab; 35 if (oldTab != null) { 36 for (int j = 0; j < oldCap; ++j) { 37 HashMap.Node<K,V> e; 38 if ((e = oldTab[j]) != null) { 39 // help gc 40 oldTab[j] = null; 41 if (e.next == null) 42 // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1) 43 // 扩容都是按照2的幂次方扩容,因此newCap = 2^n 44 newTab[e.hash & (newCap - 1)] = e; 45 else if (e instanceof HashMap.TreeNode) 46 // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表 47 ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap); 48 else { // preserve order 49 // 把当前index对应的链表分成两个链表,减少扩容的迁移量 50 HashMap.Node<K,V> loHead = null, loTail = null; 51 HashMap.Node<K,V> hiHead = null, hiTail = null; 52 HashMap.Node<K,V> next; 53 do { 54 next = e.next; 55 if ((e.hash & oldCap) == 0) { 56 // 扩容后不需要移动的链表 57 if (loTail == null) 58 loHead = e; 59 else 60 loTail.next = e; 61 loTail = e; 62 } 63 else { 64 // 扩容后需要移动的链表 65 if (hiTail == null) 66 hiHead = e; 67 else 68 hiTail.next = e; 69 hiTail = e; 70 } 71 } while ((e = next) != null); 72 if (loTail != null) { 73 // help gc 74 loTail.next = null; 75 newTab[j] = loHead; 76 } 77 if (hiTail != null) { 78 // help gc 79 hiTail.next = null; 80 // 扩容长度为当前index位置+旧的容量 81 newTab[j + oldCap] = hiHead; 82 } 83 } 84 } 85 } 86 } 87 return newTab; 88 }
2018.12.3更新
hashMap扩展知识点:
1、为什么用HashMap?
HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射
HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
HashMap是非synchronized,所以HashMap很快
HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以)
2、HashMap的工作原理是什么?
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node 。
数据结构:
Node[] table=new Node[16] 散列桶初始化,table class Node { hash;//hash值 key;//键 value;//值 node next;//用于指向链表的下一层(产生冲突,用拉链法) }
以下是具体的put过程(JDK1.8版)
1、对Key求Hash值,然后再计算下标
2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)
3、如果碰撞了,以链表的方式链接到后面
4、如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表
5、如果节点已经存在就替换旧值
6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)
以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)
当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。
3、有什么方法可以减少碰撞?
扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。)
使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。
4、HashMap中hash函数怎么是是实现的?
我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式,我们来看看JDK1.8的源码是怎么做的 -- 经过简化处理
static final int hash(Object key) { if (key == null){ return 0; } int h; h=key.hashCode();返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 //其中n是数组的长度,即Map的数组部分初始化长度 return (n-1)&(h ^ (h >>> 16)); }
简单来说就是
1、高16bt不变,低16bit和高16bit做了一个异或(得到的HASHCODE转化为32位的二进制,前16位和后16位低16bit和高16bit做了一个异或)
2、(n·1)&hash=->得到下标
5、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
6、重新调整HashMap大小存在什么问题吗?
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。(多线程的环境下不使用HashMap)
为什么多线程会导致死循环,它是怎么发生的?
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。1.扩容:创建一个新的Entry空数组,长度是原数组的2倍。2.ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
7、HashMap ,HashTable 区别
默认容量不同。扩容不同
线程安全性,HashTable 安全
效率不同 HashTable 要慢因为加锁
8、ConcurrentHashMap 原理
1、最大特点是引入了 CAS(借助 Unsafe 来实现【native code】)
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
Unsafe 借助 CPU 指令 cmpxchg 来实现
使用实例:
1、对 sizeCtl 的控制都是用 CAS 来实现的
1、sizeCtl :默认为0,用来控制 table 的初始化和扩容操作。
-1 代表table正在初始化
N 表示有 -N-1 个线程正在进行扩容操作
如果table未初始化,表示table需要初始化的大小。
如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
4、CAS 会出现的问题:ABA
对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。
9、我们可以使用CocurrentHashMap来代替Hashtable吗?
我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。(CocurrentHashMap在JAVA8中存在一个bug,会进入死循环,原因是递归创建ConcurrentHashMap 对象,但是在1.9已经修复了)