1、 HashMap的基本属性及数据结构
HashMap的基本数据结构是数组,而数组元素是链表,其元素类型是Entry。HashMap是根据对key的hash运算决定将Entry放在数组的哪个位置上的,而对于hash值相同的元素,就会放在同一个链表中。
HashMap中有一个声明为“transient Entry[] table”的属性,Entry是HashMap存储的基本数据类,其基本属性如下:
final Kkey;
V value;
Entry<K,V> next;
final int hash;
key和value自然不用说,hash是key的hash值,next的类型是Entry,它存在的价值就是解决hash冲突的!如果put一个key-value对时,经过hash运算,该K-V对对应的EntryA应该放在Entry[] table中第5的位置,但是该位置已经有Entry B存在了,那么就将A.next = B,A放在第5的位置上。如下图所示:
HashMap中还有几个属性:
默认容量:static final int DEFAULT_INITIAL_CAPACITY = 16;
最大容量: static final int MAXIMUM_CAPACITY = 1 << 30;
默认加载因子: static final float DEFAULT_LOAD_FACTOR = 0.75f;
扩容因子:intthreshold;(当容量超过threshold时,扩容,threshold = loadFactor* capacity)
加载因子:final float loadFactor;
我们可以通过分析如下代码来了解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();
}
该构造函数的参数是我们期望的初始化容量initialCapacity和装载因子loadFactor。
我们通过
while (capacity <initialCapacity){
capacity <<= 1;}
这段代码可以了解到,capacity是大于initialCapacity的最小2次幂数值。也就是说,如果我们的参数initialCapacity = 10,loadFactor = 0.8,那么实际上capacity = 16,该HashMap的初始容量是16,当元素个数超过10 * 0.8 = 8的时候,map进行扩容。
|
从上面代码可以看出,i 的值就是元素处于table中的位置,i 是由hash和length计算出来的。
下面来看一下HashMap中的put/get/remove方法实现。
2、 put/get/remove操作如何实现
先看下put方法的源码:
//put 操作返回key对应的原来的value;(null:如果原来的不存在,oldvalue:原来的已经存在.)
public V put(K key, V value) {
//当key=null,调用putForNullKey方法,该方法默认将key=null的值放在table首位
if (key ==null)
return putForNullKey(value);
//计算hash值
int hash =hash(key.hashCode());
//计算存储的位置
int i =indexFor(hash,table.length);
//遍历table[i]处已经存在的元素
for (Entry<K,V> e =table[i]; e !=null; e = e.next) {
Object k;
//如果该元素的key已经存在,则替换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不存在,执行插入操作,返回null
modCount++;
addEntry(hash, key, value, i);
return null;
}
从代码中可以看出:当我们进行put操作的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。
addEntry(int hash, K key, V value,int bucketIndex)方法执行具体的插入操作,可以看下源码:
|
参数hash是key两次hash计算后的hash值,bucketIndex就是该元素在table的索引。
当执行put操作后,size>= threshold后,map会自动扩容为现在的2倍容量,稍后详细分析扩容的细节,先看get操作。
public V get(Object key) {
//如果key=null,则返回table[0]处的元素
if (key ==null)
returngetForNullKey();
//进行hash运算,获取索引位置,遍历该处list,根据key,获取返回值
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操作时,先进行hash运算,获取该元素在table中的位置,然后遍历该位置处得list,直到找到key与参数相同的元素,返回该元素的value,如果找不到,则返回null。
下面我们再来看下remove操作的源码:
|
归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。(此段引自网络)
3、 HashMap的扩容机制
当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小(而不是map的size噢,size是所有元素的个数,capacity是数组的大小)*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
来看resize操作源码:
|
resize方法实际上执行的操作是以newCapacity参数值新建一个Entry数组,将table中的元素转移到新Entry中去,并且将table指向新数组。
下面来看下transfer()方法的源码:
|
但是为什么要扩容为两倍呢?
我们知道,在初始化HashMap的时候,有下面的语句
|
该语句保证了table的初始大小是2的n次方,在resize的时候,也是将容量扩充为原来的两倍,这保证了table的大小一直都是2的n次方,而这,是很有玄机的。
我们知道indexFor操作执行的是hash&(length-1)的操作(该操作等价于hash%lengh,但是&操作比%要快),对与操作有了解的同学应该都明白,当length为2的n次幂时,length-1的二进制表示是0111…111,它能够保证与hash值进行&操作后,使元素分配的更均匀,更合理。
从上面可以看到,HashMap有一个不断扩容的过程,如果map中元素很多,将不断进行size的扩充和元素的拷贝,对于性能肯定会有很大的影响,所以我们在开发的过程中,可以根据预估的数据量对HashMap进行合理的初始化操作。
4、 Fail-Fast机制
我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException(如果是单线程遍历时,对map进行了修改,也会抛出ConcurrentModificationException,这个问题施嘉佳4月份邮件分享过),这就是所谓fail-fast策略。
这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。
|
modCount是修改的次数,在对map进行put/remove操作的时候,都会增加这个值。通过HashIterator源码我们可以看到,遍历时会判断当前的modCount和遍历开始时的modCount是否相等,如果不相等,则表示在遍历期间,map被修改了,直接抛出ConcurrentModificationException。