HashMap自定义对象作key时内存泄露的问题

 HashMap可用自定义对象作key,但是要重写hashcode和equals方法。使用时,如果key已插入HashMap中,就千万不要修改hashcode和equals方法用到的属性值,否则该key对应的value值就几乎不可能被找到了。

首先要明确一点,key的hashcode与map中用于计算数组下标、判断相同key的hash是不同的。hashcode是根据key的hashcode方法生成的,而map中用的hash是hash方法利用hashcoe位移后异或运算得到的。

JDK 7中,hash存放在Entry中,JDK8是存放在Node中的。之后直接使用,不再重新计算。

JDK 7

 JDK 8 Node节点

 

一、示例

第一步:自定义一个User类,用name和age属性重写hashcode和equals方法。

第二步:测试。先创建对象,插入map中。查找时,name、age与key的属性值相同,就可以正常获取value。

HashMap<User, String> h = new HashMap<>();
//name age作为重写hashcode和equals方法的属性
User u = new User("小明",10);
h.put(u,"北京");
System.out.println(h.get(u));
u = new User("小明",10);
System.out.println(h.get(u));

执行结果:
小明	北京
小明	北京

第三步:插入key后,修改key的name或age值

先插入name="小红",再改为name="小刚"

u = new User("小红",11);
h.put(u,"上海");
//1、原值查找正常
System.out.println("原值查找:"+u.getName()+"\t"+h.get(u));
//2、修改key的属性值
u.setName("小刚");
//直接用刚插入的key对象查找
System.out.println("key修改后:"+u.getName()+"\t"+h.get(u));

执行结果:
原值查找:小红	上海
key修改后:小刚	null

虽然是直接使用hashmap中的key来查找,但仍然无法找到。

第四步:创建新对象,分别用修改前和修改后的名字查找

//创建新对象,用修改前的属性值,查不到
user1 = new User("小红",11);
System.out.println("新key旧值查找:"+user1.getName()+"\t"+h.get(user1));
//创建新对象,用修改后的属性值,查不到
user1 = new User("小刚",11);
System.out.println("新key新值查找:"+user1.getName()+"\t"+h.get(user1));

执行结果:
新对象旧值查找:小红	null
新对象新值查找:小刚	null

不管是用修改前,还是修改后的值,都无法获取value。

第五步:遍历map

   用entrySet遍历map,仍然可以找到全部属性

结论:

插入key后,再修改与hashcode和equals方法有关的属性,就无法通过key获取原来的value值了。

二、分析原因

1、get方法

get方法中调用了getNode方法

      getNode方法中包含了查找key主要逻辑

查找node时,先通过hash计算数组下标位置,获取第一个Node节点first。如果first为null,表示该位置无数据,返回null;如果first不为null,判断是否是要找的key。如果不是,再遍历first后key,可能是红黑树或链表。

这里很重要的一个逻辑就是比较两个key是否相同:

(1)hash值必须相同;

(2)判断key相同或equals方法为true,满足其一。

进一步分析可知:

1、key的属性修改后,hashcode改变,但hashmap没有重新计算并更新hash值

2、查询属性与插入时相同,虽然查询用的hash值与hashmap中维护的hash值相同,但key的属性被修改过,equals判断为false,无法找到;

3、查询属性与key修改后的属性相同,hash值与插入时不同。即使计算数组下标与插入时恰好相同,但hash值不同,无法找到;

4、value无法通过key查找,也无法删除,发生内存泄露。

2、put方法

put方法判断相同key也用了与get方法相同的逻辑

put方法调用了putVal方法。putVal方法中:

1、628-629行是判断数组为空,初始化;

2、630-631行是数组下标位置无数据,创建新节点放入;

3、从632行之后,是数组下标位置有数据时,从该位置的链表或红黑树中查找元素。找到则更新value,找不到再插入新节点。

4、判断相同key的逻辑与get方法是一样的。

在JDK 7和JDK 8中判断相同key的逻辑是完全一样的,如下:

if (e.hash == hash &&
    ((k = e.key) == key || (key != null && key.equals(k))))
    return e;

3、扩容能否解决问题?

jdk7中,扩容时用hash值重新计算key在新数组的下标位置。

static int indexFor(int h, int length) {
     // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
     return h & (length-1);
}

但是hash值是在初始插入hashmap时就计算好的,存放在Entry对象中。扩容时,会判断是否重新计算hash。默认是不会重新计算hash的。而旧hash值是错误的,所以扩容仍然不能解决问题;如果扩容时hash值允许重新计算,那么扩容后,hash就被修正了。重新计算数组下标,位置也正确,就能正确获取value了 

jdk 8中,扩容时不会重新计算hash值,而是直接用Node对象中记录的hash。hash无法修正,一切操作都是枉然。

//扩容
for (int i=0;i<15;i++){
    u = new User("小伟"+i,11);
    h.put(u,"上海");
}
user1 = new User("小刚",11);
System.out.println("扩容后,用修改后的属性值查找:"+user1.getName()+"\t"+h.get(user1));

执行结果:
扩容后,修改后的属性值查找:小刚	null

三、结论

前提条件:自定义对象作key,重写hashcoe和equals方法。插入key后,再修改与hashcode和equals方法有关的属性

1、get方法获取value时,会根据hash值计算key的位置,遍历数组该位置的数据,可能遇到红黑树或链表。

     get、put、remove等方法中比较key是否相同的条件:

  (1)hash值相同(2)key是同一个对象或equals方法返回true

2、已插入hashmap的key,如果修改了与hashcode和equals方法有关的属性,那么value值几乎不可能通过key找到了,会造成内存泄露。除非修改前后hashcode仍然相同

3、hashcode和equals方法用到的属性被修改后,hashcode值也会改变(属性修改后,hashcode仍相同的概率极低)。但key放入map时,创建的对象(JDK 7中是Entry对象,JDK8中是Node对象)会维护计算后的hash值,这个值不会改变。

4、key的hash值是用key的hashcode经过位移、按位异或等操作计算后得到。hash值会存到Entry或Node对象中。

5、hashcode虽然改变,但hashmap不会重新计算更新hash值,调整key在数组中的位置。此时key存储位置错误;

6、属性值修改后,如果查询用的属性与插入时相同,那么查询的hash值也与插入时相同,能找到key插入时的位置。但hashmap中key的属性值被修改过,equals方法判断不通过,所以无法找到value;

7、属性值修改后,如果查询用的属性与修改后相同,也会因为查询的hash值与插入时不同,而无法找到数据;即便在极小概率下,hash值不变,但equals方法判断为false,仍然找不到

8、jdk7中,数组扩容时,如果允许重新计算并更新hash值,那么hash值就会被修正;新数组下标位置用按位与运算计算,也会被修正。之后就可以通过key找到value了。但一般情况下不会允许重新计算hash的,hash仍然错误,无法被找到;

9、jdk8中,数组扩容无法修复错误的hash值。

10、entrySet遍历,可取出所有值。

 

 

自定义对象为哈希表的键,需要注意以下几点: 1. 重写 `__hash__` 方法:哈希表以哈希值为键值对的索引,因此需要重写 `__hash__` 方法来计算哈希值。如果不重写 `__hash__` 方法,则默认使用对象的内存地址为哈希值,这样无法保证不同对象的哈希值不同,会导致哈希冲突。 2. 重写 `__eq__` 方法:哈希表通过哈希值来判断键值对是否相等,如果哈希值相等,还需要通过 `__eq__` 方法来判断键值对是否相等。如果不重写 `__eq__` 方法,则默认使用 `is` 运算符来判断是否相等,这样会导致相同内容的不同对象被认为是不同的键。 3. 不可变性:为哈希表的键,对象必须是不可变的,否则在修改对象后哈希值会发生变化,导致键值对无法被正确找到。因此,自定义对象需要保证其属性值不可变,或者只使用不可变类型的属性为键。 4. 散列均匀性:哈希表的效率与散列均匀性有关,因此需要保证哈希函数能够均匀地分布键值对。如果哈希函数不均匀,会导致哈希冲突增多,降低哈希表的效率。 下面是一个示例,演示如何使用自定义对象为哈希表的键: ```python class Person: def __init__(self, name, age): self.name = name self.age = age def __hash__(self): return hash((self.name, self.age)) def __eq__(self, other): return self.name == other.name and self.age == other.age person1 = Person('Alice', 25) person2 = Person('Bob', 30) map = {} map[person1] = 'Alice' map[person2] = 'Bob' print(map[person1]) # 输出 'Alice' print(map[person2]) # 输出 'Bob' ``` 在这个示例中,我们重写了 `__hash__` 和 `__eq__` 方法,以确保自定义对象为哈希表的键能够正常工。同,由于 `name` 和 `age` 属性都是不可变的,因此可以保证对象不可变。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值