HashMap的难点,源码分析

HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。Hashmap主要采用数组加链表的方式进行实现。

1. HashMap的几个成员变量:

  1. int DEFAULT_INITIAL_CAPACITY = 1 << 4 //初始容量,默认为16
  2. int MAXIMUM_CAPACITY = 1 << 30 //最大容量,2的30次幂
  3. float DEFAULT_LOAD_FACTOR = 0.75f //默认的负载因子(0.75)
  4. transient Node<K,V>[] table; //存放数据的数组
  5. transient int size; //map存放的大小
  6. final float loadFactor; //负载因子
  7. 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->nullB线程之后,变成

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;
}

一试,终于成功了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值