记忆总是会随着时间的流逝而逐渐的遗忘,不管多么深刻的记忆终究会有遗忘的一天,唯有文字的记忆才是永恒!
在我们平时的开发中,经常会用到HashMap,而且在面试的过程中更是面试过程中的常客,比如说:
1.为什么HashMap是线程不安全的?
2.元素存放在数组的下标的链表是怎么计算位置的?
3.多线程的条件下会发生什么问题,即“死锁”问题?
4.为什么会出现hash冲突问题以及是怎么解决的?
5.为什么不直接采用hash值作为数组的下标而是用数组的长度减1以后进行& 运算?
6.为什么每次扩容都是2倍(2的N次方)以及为什么初始容量大小需要强制设置成2N次方?
7.什么条件下会进行重新rehash()操作?
8.为什么hasmMap的key,value均可以为null?
因此有必要重新认识一下熟悉而又陌生的它。
注:以下介绍均以 java7版本
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable{
....
}
//增加,修改操作
public V put(K key, V value) {
....
}
//删除操作
public V remove(Object key) {
....
}
//查找操作
public V get(Object key) {
....
}
//1.参数名称:容量
//1.1默认的初始容量大小=16,即我们常说的hashMap中数组(hash桶)的初始容量大小
//1.2的幂次方<=容量为范围<=2的30次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//1.3数组的最大长度(并不是hashMap可以存放的数据的大小)
static final int MAXIMUM_CAPACITY = 1 << 30;
//2.加载因子:用于计算是否扩容是的一个重要条件
//2.1加载因子越大,达到扩容的条件越困难,即需要的数据量越多,空间的利用率越高,但是hash冲突的概率越大,查询的效率越慢
//2.1加载因子越小,达到扩容的条件越简单,即需要的数据量越少,空间的利用率越低,但是hash冲突的概率越小,查询的效率变块
//默认的加载因子为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//3.扩容阀值:扩容所需要的达到的必备条件,默认是扩容为原先的两倍大小
//3.1扩容阀值 = 容量 * 加载因子
//3.2 默认的初始扩容阀值 = 1 << 4 * 0.75 = 16*0.75 = 12
int threshold;
基本上大家都知道HashMap是由数组加链表的上数据结构,类似于下图:
横项就是数组,列项就是链表,其中的每一个框子我们可以当做是一个存放数据的节点,其节点的实现是Entry对象,其对象属性源码如下:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
从源码我们可以看出,该对象有四个属性,分别对应 HashMap中的 key,value,通过该key的hash算法计算出来的hash值以及一个Entry对象,但属性中的Entry对象指的是链表中下一个元素的信息。
下面会分别对HashMap的几个主要方法的源码进行分析。
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
其实HashMap的get()方法很简单,这里不对其进行详细的介绍,因为get()方法其实就是put()方法的逆向操作,看懂了put方法以后,这个方法就会觉得很简单。
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
虽然说HashMap自身提供了remove()的方法,但是不建议使用自带的remove方法删除元素,因为有时候在使用这个方法的时候会报错。如下
HashMap<String,Object> hashMap = new HashMap<String,Object>();
hashMap.put("张三",1);
hashMap.put("李四",2);
hashMap.put("王五",3);
hashMap.put("赵六",4);
hashMap.put("王八",5);
hashMap.put("李九",6);
hashMap.put("王二麻子",7);
for(String key : hashMap.keySet()) {
if(key.contains("李")) {
hashMap.remove(key);
}
}
报错信息如下
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1442)
at java.util.HashMap$KeyIterator.next(HashMap.java:1466)
at HashMap7.main(HashMap7.java:13)
由此我们要想,为什么会报错呢,查看源码得知问题在这里
为什么会出现这个问题呢,因为我们在put元素进去的时候,modCount++,那么当我们将来能两个元素put完成以后modCount = 2,expectedModCount会初始循环时会赋值,赋的值为2,源码如下:
现在我们发现了问题,怎么解决这个问题,其实最简单的方法就是利用迭代器去删除,写法如下
Set<Entry<String, Object>> set=hashMap.entrySet();
Iterator<Entry<String, Object>> iterator=set.iterator();
while(iterator.hasNext()){
Entry<String, Object> entry=iterator.next();
String name=entry.getKey();
if(name.contains("李")){
iterator.remove();//这里不能写成hashMap的remove方法
}
}
public V put(K key, V value) {
//1.判断当前的HashMap是否需要初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//2.如果key==null 将value放入到数组中
if (key == null)
return putForNullKey(value);
//3.计算key对应的hash值
int hash = hash(key);
//4.计算key在数组中对应的位置,即对应数组的下标
int i = indexFor(hash, table.length);
//5.判断元素是否在链表中是否存在,存在则更新原先的key对应的值
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//该步骤就是前面为什么不建议使用HashMap的remove方法中有提及到
//6,添加元素
addEntry(hash, key, value, i);
return null;
}
从上述的源码中我们可以看出,put方法大概分成了6个小步骤,现在我们就一起分析这六个步骤的行情况。
//1.判断当前的HashMap是否为空
if (table == EMPTY_TABLE) {
//1.1如果hashMap为空,则初始化hashMap
inflateTable(threshold);
}
===================1.1 分析=====================
//1.1如果hashMap为空,则初始化hashMap
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//a.将hashMap中初始数组的大小设置成2的N次方
int capacity = roundUpToPowerOf2(toSize);
//b.计算扩容阀值,并数组容量初始化大小 --这段代码简单,就不用分析
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
//c.该方法在这初始化的时候不做源码分析,在后面的会有说明
initHashSeedAsNeeded(capacity);
}
===================a 分析=====================
private static int roundUpToPowerOf2(int number) {
//这段的意思就是判断传入的数组大小是否大于等于最大数组容量
//1.如果大于最大的数组容量,则取最大容量返回最大的数组容量
//2.否则,(number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1
//2.1.判断传入的值是否大于1,如果是Integer.highestOneBit((number - 1) << 1)
//2.1.1 Integer.highestOneBit((number - 1) << 1);
// Integer.highestOneBit()这个方法也就是说取最高位为1对应的十进制,(number - 1) << 1 表示 number - 1的结果左移两位
//如:number = 5 那么返回的就是 8
// (number - 1) = 4, 4对应的二进制为 0000 0100,然后左移两位就是0001 0000,结果对应的8;
//这里只要最高位的二进制,如果低位有1,当成0,即如果是0001 0100,也是8
//2.2 否则返回 1
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
//这段代码就是前面提及的强制变成2的N次方倍大小的数组容量
private V putForNullKey(V value) {
//2.1 替换原有key对应的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++;//上面已经有说过
//2.2 添加key==null的数据,并且放到数组下标是0的位置,具体方法分析会在分析6中有更加详细的介绍
//这里也就相当于解释了为什么hashMap的key,value可以为null;
addEntry(0, null, value, 0);
return null;
}
===================2.1 分析=====================
//a. Entry e = table[0];表示去数组下标是0的第一个元素,为什么要取第一个呢?
//因为在2.2中的添加方法就是说会把key==null的元素放在数组下标为0的链表里面
//b. e!= null; 如果获取到的第一个元素为空,则说明该位置目前没有元素,则跳出循环
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//c. 这里为什么还需要判断key是否为null呢?再添加的时候,不是添加的key==null的元素吗?
//因为数组下标为0所代表的链表中不是只有key==null的数据,因为它还会存经过计算后,
//刚好存放在该位置的元素,因此该位置存放的数据不仅仅只有一个key==null的元素
if (e.key == null) {
//d. 这几步就是满足条件是,替换原有key==null所对应的value,并返回旧的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
int hash = hash(key);
//计算hashCode
final int hash(Object k) {
int h = hashSeed;//默认参数等于0,一般不会被改变,但是也有可能会被改变,在分析6中会有介绍
//这里就是如果key满足条件,直接返回对应的hashCode,一般不会被执行 h=0;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//k.hashCode() 计算出key所对应的hashCode
//既然这里计算出来hashCode,为什么不直接返回,还需要进行5次异或,4次位运算?
//这里是为了让其计算出来的hash值更加均匀分布,防止链表太长,从而影响效率
//如:假如现在有10000个key,但是k.hashCode()出来的值在某几个中大量重复,那么就会造成某几个链表存放了大量元素,
//而其他链表只存放少数的几个,甚至是空的(这里是1.7的写法,1.8不一样,1.8只用了1次异或,1次位运算)
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
//这里代码看起来简单,就一个与运算,但是需要知道为什么要是用&运算?
//前面说到了数组大最大长度都是2的N次方,是不是意味着数组下标是不是2的N次方减1,
//是有一个范围的,但是hashCode的值是不确定的,有可能会超出数组的长度;
return h & (length-1);
}
//如:现在有一个key对应的hashCode转成2进制是:1001 0110 1111 1100,数组长度为16,减1以后其对应的二进制是:0000 0000 0000 1111,
//那么直接将这个值当成数组下标,肯定会数组下标越界,但是如果去采用位运算那么会出现什么情况呢?
例1. 1001 0110 1111 1100
& 0000 0000 0000 1111
0000 0000 0000 1100 十进制等于:12
1. 得到的结果等于12,这个12刚好在0-15之间,因为第四位全为1,其余高位全为0,不管计算出来的hashCode是多少,都不会超过length-1;
2. 从这也可以看出,元素所放的位置与数组的大小无关,至于其自身的hashCode有关,由于key值是随机的,我们也可以看成key所计算出来的hashcode也是随机,
也就是说是均匀分布的,最终存放在数组中的位置也是均匀的;
如果数组大小不是2的N次方呢?数组的长度为7,length-1 = 6 如下
例2. 1001 0110 1111 1100
& 0000 0000 0000 0110
0000 0000 0000 0100 十进制:4
问1:结果看起来是不是也满足条件,数组下标没有越界,但是是否有均匀分布呢?
答案是否应的,因为结果只会有三个值: 0 ,2或者4,&运算同1才为1,因此当有大量数据时,就会造成大量数据存放在某几个位置,从而造成链表长度太长,影响效率
问2:既然数组下标是2的N次方,为什么不用取余的方式而采用&?
答案是因为对于计算机而言,位运算比取余要快。
//这段代码在前面的分析2中已经分析过了,原理都是一样的,只是判断条件不一样
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//判断是否需要扩容,这里的size 会在分析2中的方法每次做+1,并且本次添加元素锁对应的位置不为空
if ((size >= threshold) && (null != table[bucketIndex])) {
//分析1.扩容
resize(2 * table.length);
//计算hash值
hash = (null != key) ? hash(key) : 0;
//计算对应的数组下标
bucketIndex = indexFor(hash, table.length);
}
//分析2. 存放添加的元素
createEntry(hash, key, value, bucketIndex);
}
====================分析1 扩容===========================
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];
//分析1.1 initHashSeedAsNeeded(newCapacity) 这个方法就是前面没有讲解,说这里会讲解的,这个方法主要是判断是否需要重新hash
//分析1.2 transfer()方法将旧数组的元素转移到新数组中******这个方法很重要*********
transfer(newTable, initHashSeedAsNeeded(newCapacity));//重点方法
table = newTable;
//重新计算扩容后的阀值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
******************分析1.1 重新hash********************************
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
* 在1.7的版本中是有可能重新hash的,但是正常情况下不会:
1. 正常情况下,我们不会修改这个参数--sun.misc.VM.isBooted(),这个参数可以配置启动的时候(主要);
2. Holder.ALTERNATIVE_HASHING_THRESHOLD 最大值等于Integer.MAX_VALUE;
3. 上述两个原因都是只是个人猜测,可能是错误的,因为是从结果推的原因。
******************分析1.2 将旧数组的元素转移到新数组中********************************
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; ***①***
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;***②***
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; ***③***
newTable[i] = e; ***④***
e = next; ***⑤***
}
}
}
从分析1.2的代码简单看来,主要就是把旧数组的数据挪到新数组中,采用的头插法(就是将就数组的第一个元素先挪到新数组,依次类推的话,最先挪的数组会成为新数组的最后一个元素),在多线程情况下,头插法可能会存在“死锁”的问题,即循环链表问题。那就让我们以单线程或者多线程的情况下的挪数据的过程,以及多线程情况下为什么会发生循环链表的问题。
不考虑重新hash的情况,因此代码可以简化成如下:
======================单线程情况下==========================
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; ***①***
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;***②***
。。。//此处省略一些无关紧要的代码
//之所以重新计算数组下标是因为扩容后,数组下标可能会改变,其位置有两种可能,
//要么在原本对应的数组下标位置或者在原先数据下标+原数组长度的位置
//如 原先元素a在原数组下标为3的位置,原数组长度是16,那么扩容后要么在3要么在19,
//这个原因同样是因为数组的长度为2的N次方以及扩容后的长度是原数组的两倍(2的1次方)
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; ***③***
newTable[i] = e; ***④***
e = next; ***⑤***
}
}
}
注:以下分析时,重新计算数组下标后,数组下标的值不变,即 i=3(不管数组下标是否更改,在多线程环境下均可能会出现问题),并且简化数组默认的长度
执行完③处代码的状态
然后重复②,③,④,⑤的步骤,最后效果如图
上述过程可以看出,扩容后新数组的元素的顺序会和原数组的顺序相反,这就是头插法的结果,同时也就造成了多线程情况下。可能会产生循环链表问题。
=====================多线程情况下==========================
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; ***①***
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;***②***
。。。//此处省略一些无关紧要的代码
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; ***③***
newTable[i] = e; ***④***
e = next; ***⑤***
}
}
}
假设现在有线程e1,e2同时put一个元素,并且两者同时走到了方法①;
2.此时线程e1继续往下走,直到线程e1执行完本段代码,此时效果如下
注:
1.当线程e1执行完以后,e1,next1 均会指向null
2.这里需要注意的是两个线程会共用拥有一个数组,即原数据,但扩容后都会有的线程内的新数组
3.由于线程e1执行完后,原数组数据会转移到e1扩容后的数组中,因此e2,next2的当前的指向也会就会变成如图显示
3.此时线程e2继续往下执行,当执行完③以后
3.此时线程e2继续往下执行,当执行完④以后
4.此时线程e2继续往下执行,当执行完⑤以后
5.当前循环没有结束,因此继续执行②
Entry
前面忘记强调一点就是扩容后的数组1,2中,两者的(k1,k2)其实是一样的,都是存的一个指向的地址,其实将nest2的箭头指向标记黄色的位置也是一样的效果,地址是一样的;这样知道下面是为了演示方便。
6.执行③结果
7.执行完④以后
在单线程情况下,这里没有详细说明的是newTable[i] = e;执行完以后,其实就是将e放到扩容后的数组位置,由于上一步e.next = newTable[i]; 即e的中的next属性的值已经指向了下一个节点元素,e.next是e的一个属性,这里可能有点难懂,可以去仔细看一下e所代表对象的Entry
在JDK1.8中,采用的尾插法,即正向遍历数组链表中的元素,往新数组的尾部添加元素,就可以避免循环链表问题,但是1.8的HashMap依旧是线程不安全的,因为没有加锁,如果想用线程安全的HashMap可以用ConcurrentHashMap。
到这里1.7的HashMap源码就介绍完成了,仅仅是个人的理解,但不一定是对的,有错误的地方请指出!