最新仔细看了一遍JDK1.6 HashMap的源码,收获颇深,写一篇博客来记录下自己的学习心得。HashMap也是Java中一个非常重要的集合,确实值得研究和学习。
本文主要分几个步骤来讲HashMap:
一、HashMap底层实现
二、HashMap源码分析
1.成员变量
2.构造方法
3.put()方法和get()方法
三、HashMap需要注意的关键点
一、HashMap底层实现
HashMap底层是通过数组和链表来实现的。
HashMap是Java中存储键值对的容器,当我们调用HashMap的put()方法往Map存储数据时,HashMap先通过“hash算法”得出该key的hashCode值,再通过hash值和数组长度计算出该key存储在数组中的索引位置,如果该索引位置上存在相同的hashCode和相同的key,则新值覆盖旧值,并返回旧值;如果存在相同的hashCode,但是key不相同,这时就产生hash冲突,hash冲突后数组里单个元素存放的不是一个Entry,而是一个Entry链,Entyr存放的是key-value,HashMap结构如下图:
二、HashMap源码分析
1、成员变量
static final int DEFAULT_INITIAL_CAPACITY = 16;// 默认容量 static final int MAXIMUM_CAPACITY = 1 << 30;// 最大容量,其实就是2的30次幂 static final float DEFAULT_LOAD_FACTOR = 0.75f;// 默认加载因子 transient Entry[] table;// 存放key-value的数组 transient int size;// Map容器里存放的元素个数 int threshold;// 临界值 final float loadFactor;// 加载因子 transient volatile int modCount;// Map被操作的次数分析:
treashold 表示Map容量的临界值,当存放的元素超过这个临界值时,Map就会扩容,threashold=capacity * loadFactor,loadFactor 是加载因子,默认为0.75f这是时间和空间的权衡,当loadFactor配置得过大时,内存的利用率比较高,也就是table数组可以存放更多的元素,同时也降低了查询效率,因为hash冲突机会大了,当loadFactor配置得过小时,减少了hash冲突,提高了查询效率,但是内存的利用率低了,table数组很多位置还没存放元素就开始扩容了。
2、构造方法
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); // 找到2的n次幂 >= 指定容量值 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; this.loadFactor = loadFactor; threshold = (int)(capacity * loadFactor);// 计算临界值 table = new Entry[capacity];// 创建容量为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(); }
分析:
2.1)public HashMap(int initialCapacity, float loadFactor) 从代码中可以看出,HashMap并不是直接使用传入的initialCapactiy来创建数组,而是通过一个算法来取得需要创建的数组大小,这个算法就是找到2的n次幂,使得这个数大于等于initialCapcity,使用这个数来创建存放键值对的数组。 2.2)public HashMap(int initialCapacity) 使用指定的容量和默认的加载因子来创建Map 2.3)public HashMap() 使用默认的容量和默认的加载因子来创建Map3、put()方法
public V put(K key, V value) { // 如果key为null,为把key-value存放在table[0]的位置上 if (key == null) return putForNullKey(value); // 如果key不为null,则通过“hash算法”计算出key的hash值,并通过hash值计算出该值存储在table数组中的索引位置 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); // 如果该索引位置上存放的不是单独的Entry元素,而是一个Entry链,则遍历该链表 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; // 如果hash值和key都相同,则新值覆盖旧值,并返回旧值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++;// 操作数+1 addEntry(hash, key, value, i);// 将键值对存入数组 return null; }
put()方法工作流程:
1)如果key为null,则把键值对存到talbe[0]位置上; 2)如果key不为null,则通过“hash算法”计算出key的hash值,并通过hash值计算出该值存储在table数组中的索引位置; 3)如果该索引位置上的元素是空,则直接将键值对存入数组中; 3)如果该索引位置上不为空,并且是一个Entry链,则遍历该链表,如果找到hash值和key都相同,则新值覆盖旧值,并返回旧值; 4)如果存在相同的hash值,但是key不同,这时就形成hash冲突,hash冲突时,会形成一个Entry链,新的元素会存在链表的首位置,并用一个next对象指向下一个元素。 接着我们一步一步分析put里面调用的每一个方法,首先看看putNullForKey()方法的源码:private V putForNullKey(V value) { // 获取table[0]位置上的元素,如果该元素是一个Entry链表,则遍历该链表 // 找到key==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++;// 操作数+1 // 如果数组不存在key为null的元素,则存储在table[0]的位置上 addEntry(0, null, value, 0); return null; }接着我们看看hash()源码:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }这个方法我们可以不用理解细节,只要认为它可以算出一个合理的hash值就好,接着我们看看indexFor()的源码:
static int indexFor(int h, int length) { return h & (length-1); }该方法是通过key的hash值和数组的长度,计算出元素所要存储的索引位置,它能比较均匀的散列在数组的各个索引中,相当于取模,但是取模用到除法,效率比较低,这个通过二进制的位运算,效率高很多。 接着我们看看addEntry()方法源码:
void addEntry(int hash, K key, V value, int bucketIndex) { // 取得计算出来的索引位置上的元素 Entry<K,V> e = table[bucketIndex]; // 创建新的Entry对象,存入table数组中,并用一个next变量指向原来的元素 // 如果原来为空,则next=null,否则形成一个Entry链表 table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 添加元素后,如果存放的元素个数超过临界值,则扩容 if (size++ >= threshold) // 数组扩容以2的倍数扩展 resize(2 * table.length); }addEntry() 方法是形成链表的核心代码,需要重点分析下,首先取得table[bucketIndex]上的元素,这个bucketIndex就是上面通过hash算法计算出来的索引值,然后通过传入的hash,key,value构建一个新的Entry对象,存入table[bucketIndex]中,并用一个Entry对象的next变量指向原来的元素,形成链表。 接着我们看看扩容方法resize()的源码:
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; // 如果原来数组的容量已经等于最大值,则临界值直接等于Integer的最大值,并且不再扩容 if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } // 根据指定容量创建一个新的数组 Entry[] newTable = new Entry[newCapacity]; // 数组拷贝,把原来的数组内容拷贝到新的数组中(该方法最耗时) transfer(newTable); // 新的数组引用指向给table table = newTable; // 重新计算临界值 threshold = (int)(newCapacity * loadFactor); }扩容时,最耗时的就是数组拷贝,我们看看数组拷贝的 transfer()源码:
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;// 旧的元素引用已经不再使用,置空操作,方便GC回收 do { Entry<K,V> next = e.next;// 如果数组存放的是Entry链,则取得当前元素的下一个元素 int i = indexFor(e.hash, newCapacity);// 重新计算当前元素在新数组的索引位置 e.next = newTable[i];// 新数组当前索引下如果有值,则赋值给需要添加进来的元素的下一个元素 newTable[i] = e;// 把当前元素存入新的数组中 e = next;// 遍历下一个元素 } while (e != null); } } }transfer()方法也需要重点分析下,因为它不是一个普通的数组拷贝,元素里存放的不是一个简单的对象,而一个是链表。首先用一个src指向旧的数组引用,遍历旧的数组,遍历过程中取得每个元素,如果旧的数组元素是一个Entry链,则用一个next变量取得当前元素的下一个元素,重新计算当前元素需要存放在新数组中的索引位置,如果新数组该索引位置上有值,则把该索引位置上的元素赋值给需要存放在新数组的元素的next变量上,并把旧数组中的元素存入新的数组中,把旧的数组的元素引用置空,然后遍历下一个元素。从中可以看出数组拷贝之后,原来的链表顺序已经发生了变化,所以说HashMap是不保证顺序的。说得有点绕,需要读者仔细体会这其中的思想,最好亲自上手写一遍。 我们再来看看put的方法声明: public V put(K key, V value) 从上面的源码分析中,我们知道如果需要存储的key-value在HashMap中不存在,则直接将其存入数组相应的位置中,并返回null; 如果存在相同的hash和相同的key,则会用新值替换旧值,并返回旧值; 如果存在相同的hash,但是key不相同,则会形成链表,新加入的元素会存在链表的表头位置,并返回null; 如果key为null,则会检查数组中第一个元素table[0]是否存在key为null的值,如果有,则新值替换旧值,并返回旧值;如果没有找到key为null,则直接把key存入talbe[0]位置上,并返回null; 这说明put()返回值,要么返回与key关联的旧的值,如果没有关联过,则返回null。 4、get()方法 理解完put()方法之后,再来理解get()方法就容易多了,原来怎么存,现在就怎么取,我们看看get()源码:
public V get(Object key) { // 如果key为null,则从数组的第一个元素中查找 if (key == null) return getForNullKey(); // 如果key不为null,则计算key的hash值,并通过hash和数组长度计算出该key在数组中的索引 int hash = hash(key.hashCode()); // 如果该索引下的元素是一个链表,则遍历该链表 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; // 如果hash和key都相同,则返回该key对应的value值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } // 查找不到,则返回null return null; }分析get()方法: 如果key为null,则从table[0]的位置上去查找; 如果key不为null,则通过hash算法计算得出该key在数组中的索引,取得该索引下的元素,如果该索引下存储的是一个链表,则遍历链表,找到hash和key都相同的元素,如果找到,则返回该key对应的value值,如果找不到,则返回null。 接着我们看看 getForNullKey()方法的源码:
private V getForNullKey() { // 取得数组中的第一个元素,如果是链表,则遍历链表, // 找到key为null的元素,如果有则返回该key对应的value值,否则返回null for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }分析getFroNullKey()方法: 取得数组中的第一个元素,如果元素是一个链表,则遍历链表,找到key等于null的元素,如果找到则返回该key对应的value值,否则返回null。 我们后看看get()方法声明: public V get(Object key) 从上面的源码中,我们知道如果该HashMap存在映射关键,则返回该key下对应的value值,否则返回null 返回null,还表示该key下对应的映射值就是null。 三、HashMap需要注意的关键点 1.HashMap怎么实现的? HashMap底层是通过数组和链表来实现。 2.什么是hash冲突,HashMap怎么解决冲突? 当需要存入的key和原来数组中的某个元素的key,存在hash值相同,但是key不相同时,这时就产生了hash冲突;HashMap通过链表来解决冲突,当存在hash冲突时,就把需要存入的元素存在冲突元素的表头上,并用一个next变量指向原来的元素,形成链表。 3.HashMap什么时候扩容,以什么方式扩容? 当HashMap调用put()方法存入元素之后,会检查当前存放的元素个数是否超过临界值,如果超过临界值,则进行扩容,扩容时以原来长度的2倍进行扩容。这个临界值threashold是通过这种方式计算的:threashold = size * loadFactor,其中size是HashMap元素个数,loadFactor是加载因子。从这个公式可以看出,当size不变时,loadFactor越大,临界值threashold就越大,也就是数组可以存放更多 的元素 ,这样提高了内存利用率,但是增加了冲突概率,也就是降低了查询效率;当loadFactor越小,临界值threashold就越小,数组还没使用完就开始扩容,这样提高了查询效率,却浪费了很多内存空间,因为很多元素还没使用数组就开始扩容了。