HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。Hashmap主要采用数组加链表的方式进行实现。
1. HashMap的几个成员变量:
- int DEFAULT_INITIAL_CAPACITY = 1 << 4 //初始容量,默认为16
- int MAXIMUM_CAPACITY = 1 << 30 //最大容量,2的30次幂
- float DEFAULT_LOAD_FACTOR = 0.75f //默认的负载因子(0.75)
- transient Node<K,V>[] table; //存放数据的数组
- transient int size; //map存放的大小
- final float loadFactor; //负载因子
- int threshold; //桶的大小
2. HashMap中的Hash()是怎么设计的?
这里首先要考虑一个问题:如何实现一个均匀的hash函数?
其次,我们知道,hashmap中主要是通过对index进行hash运算得到他存放的位置的,因此一个设计良好的hash函数必须囊括index的信息,并且满足均匀分布
index = HashCode(Key) & (Length - 1)
这里我们将HashCode方法得到的值进行高16位和低16位异或
key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为 -2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。
源码中模运算就是把散列值和数组长度-1做一个 "与"操作,位运算比%运算要快。
bucketIndex = indexFor(hash, table.length);
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值
3. Resize
3.1 什么时候进行扩容?
当hashmap中的元素个数超过 容量大小*loadFactor 时,就会进行数组扩容,这是一个非常消耗性能的操作,当我们已经预知hashmap中元素的个数,那么预设元素的个数能够提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 理论上来讲new HashMap(1024)更合适,因为即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为 0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们应该把容量设置成 new HashMap(2048) 才最合适,避免了resize的性能消耗
3.2 resize的流程
先来看看jdk1.7的代码
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),避免之后继续扩容
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null; //释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
扩容之后要把原来数组上的元素移动到新的数组上,因为index是与数组长度相关的。这时就需要进行rehash,一般来说rehash只要把key.hashCode和newCapacity进行indexFor()运算就行,就像上面代码中所写。但是jdk1.8中进行了优化。
- 我们知道key.hashCode不管扩容前还是扩容后,都是不变的,变得只有数组长度,而且是以2的幂次在变,所以假设原有长度为2^6, 那么扩容后就会成为2^7,扩容后进行运算的是length-1,那么就会在原来的111111前面再加1,变成1111111。这时进行运算,后面的都不会变,变的只有前面的一位,由于与运算,结果要么为0,要么为1,取决hashcode。因此,最终的index,要么在原位置,要么在原位置加原数组长度的位置。
// preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { //与运算看oldCap那一位值
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
3.3 resize中存在的问题
在JDK1.7之前,HashMap存在线程不安全问题,这个问题是怎么引起的呢?
不妨把刚刚的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; //释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
在JDK1.7之前,插入元素采用的是头插法,也就是说,产生hash碰撞时,后来的元素是插在头部的。但是在resize中假设元素重新Hash后均在同一位置(这里是假设,真是情况会在原有位置和原有位置加原有长度的位置)那么此时元素在链表中会位置颠倒。
因为这几行代码
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
遍历的时候是正向遍历,但插入的时候永远把next指向变化后的位置,并把该位置赋给新来的元素,就会导致颠倒
那死循环,环形链表是怎么产生的呢?
假设只有两个元素a和b,在一条链表上,a->b
简单的说: 在并发的时候,假设线程A和线程B都要进行扩容,线程A先扩容,停在了这一步,在这一步挂起
newTable[i] = e; //将元素放在数组上
线程B把工作都做完了,并写回了工作内存。那么轮到线程A,在自己的工作内存中,执行完这一循环后,e指向了next,这里还是工作内存
e = next; //访问下一个Entry链上的元素
在下一个循环时,需要从主内存中取数据,取到的
Entry<K, V> next = e.next;
因为主内存已经处理完一切问题了,那么这个e.next不是别人,正是上一步的e
,因为B线程已经重写主内存,而且处理的时候顺序会颠倒,那么next的指向就会由原来的 a->b ,变成 b->a,而A线程并不知道主内存已经改变了。A线程只会照着继续执行。原来是 a->b->null 。 B线程之后,变成
b->a->null
而A线程应该想要的是
b->null
a->null
b.next应该是null,而不应该是a
如果是a会有什么后果呢?
不妨看看循环的停止条件,while(e!=null),e的每一步都会变成 e = next。
本来b的next是null,执行完b后就结束循环,现在b的next是a,那么a会在执行一遍循环,把a又放回前面,并且a的next指向原来在前面的那个元素,也就是b,就形成a->b->a的环形,然后结束循环。
这就形成了死链。
4.关于重写hashcode()和equals()方法
首先要问:为什么要重写这两个方法?
不妨假设有这么一个对象
class User{
private int id;
private String name;
public User(int id, String name){
this.id = id;
this.name = name;
}
}
当我们往这么一个HashMap中添加元素的时候,如果key是重复的会覆盖掉value值,那么这里就涉及到一个比较key值的问题,就需要用到equals和hashcode方法。
如果你运行下面这段代码
public static void main(String[] args) {
//声明HashMap对象
Map<User,Integer> map= new HashMap<>();
map.put(new User(1,"张三"),100);
map.put(new User(1,"张三"),99);
这时你惊奇的发现,这两个数据都存进去了。按道理第二个数据会覆盖第一个数据,但并没有。原因在于你没重写hashcode()函数。对于new出来的对象即使属性相同,但仍是两个不同的对象,它们的hashcode是不会相同的。
那就重写hashcode()
class User{
private int id;
private String name;
public User(int id, String name){
this.id = id;
this.name = name;
}
@override
public int hashcode(){
return id * name.hashcode();
}
}
再运行一次,发现还是能存进去。哦,你忘了重写equals()方法,那就再加上去
public boolean equals(Object o){
User u = (User) o;
if (name.equals(o.name) && id == o.id){
return true;
} else return false;
}
一试,终于成功了。