1. 说明

在看关于内存泄露的相关文章时,很有意思的给出了一个很有说服力的案例:
在HashSet中存在的对象,如果修改了其属性,则会导致获取不到对象了,及时使用了contains()也无法匹配;

2. 案例

我们来准备这个案例场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class HashInaccessibleTest {
public static void main(String[] args) {
HashSet<HashChange> hashChanges = new HashSet<>();
HashChange change = new HashChange(1, 1, "name");
hashChanges.add(change);

//1
System.out.println(hashChanges.contains(new HashChange(1, 1, "name2"))); //true
//2
System.out.println(hashChanges.contains(change)); //true

//修改ID
change.setId2(2);

//3
System.out.println(hashChanges.contains(new HashChange(1, 2, "name2"))); //false
//4
System.out.println(hashChanges.contains(new HashChange(1, 1, "name2"))); //false
//5
System.out.println(hashChanges.contains(change)); //false
}
@Data
static class HashChange {
private Integer id1;
private Integer id2;
private String name;

public HashChange(Integer id1, Integer id2, String name) {
this.id1 = id1;
this.id2 = id2;
this.name = name;
}


@Override
public boolean equals(Object o) {
if (!(o instanceof HashChange)) return false;
HashChange that = (HashChange) o;
return Objects.equals(id1, that.id1) && Objects.equals(id2, that.id2);
}

@Override
public int hashCode() {
return Objects.hash(id1, id2);
}
}
}

我们做的就是重写了hash,以id1和id2作为hash计算,这也是常用的对象重写。

3. 解析

1、2处是正常的匹配,这也很好理解,
然后我们修改了set中对象的id2,此时计算hash的时候肯定会发生变化,原来的就匹配不上了
那么HashSet中的也会发生变化吗,还能匹配上吗?

3、4、5处我们得知,不管是new一个新的Hash可以匹配的对象,或者是new一个和原来参数一样的,还是用原来的相同的地址去匹配,都无法再匹配成功。
这其实有点超出意料了。按我们理解不管怎样,相同的地址,怎么都会匹配不上呢。

这里我们就需要分析以下HashMap的源码了

我们知道,HashSet的底层是HashMap,当contains()的时候实际上是调用了HashMap的containsKey,我们从containsKey开始解析源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class HashMap<K,V>{
public boolean containsKey(Object key) {
//获取节点,看是否为空
//hash(key)就是把key取hashCode()然后求模,具体分析见下
return getNode(hash(key), key) != null;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//数组赋值给tab && 数组的长度大于0 && 通过hash&(n-1)获取数组的位置 (这里两个不同的Hash一般找到的都是不同的数组位置了)
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
}

hash(key)就是把key取hashCode()然后求模,由于我们的对象已经修改了id2了,因此hashCode和原来存入的hashCode肯定不同,
由于HashMap获取到模之后getNode(int hash, Object key)是根据模来存储在数组中的位置的,因此两次不同的hash,第一次的时候已经根据hash存入了数组对应的位置,第二次变了hash,得到了另一个key,查找的时候会去数组的另一个位置,因此这样去找肯定就找不到了。

从这里说明,通过再去找Hash是无法匹配了

因此,HashSet本来就有数据,但是contains为空,则从字面上表示就内存泄露了。同样,HashMap中对应的Key和Value本来在Map中,但是无法再通过key获取到value,因此就发生了内存泄露。

但是,这里有一个问题,其实数据本来是存在的,我们通过迭代器应该是可以获取到数据的,因为迭代器本来不依赖hash。

我们来验证一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class HashIteratorAccessibleTest {
public static void main(String[] args) {
HashSet<HashChange> hashChanges = new HashSet<>();
HashChange change = new HashChange(1, 1, "name");
hashChanges.add(change);
//修改ID
change.setId2(2);
System.out.println(hashChanges.contains(change)); //false

Iterator<HashChange> iterator = hashChanges.iterator();
while (iterator.hasNext()) {
HashChange next = iterator.next();
System.out.println(next==change); //true
}
}
@Data
static class HashChange {
private Integer id1;
private Integer id2;
private String name;

public HashChange(Integer id1, Integer id2, String name) {
this.id1 = id1;
this.id2 = id2;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof HashChange)) return false;
HashChange that = (HashChange) o;
return Objects.equals(id1, that.id1) && Objects.equals(id2, that.id2);
}
@Override
public int hashCode() {
return Objects.hash(id1, id2);
}
}
}

执行以下代码,结果和预期一样,同一个对象是拿得到的。

4. 结论

如果修改了HashCode中对应key的Hash计算的值,则hash发生了变化,通过原来相同的对象key则无法再通过get方式获取到数据了,但是数据仍然存在,通过迭代器仍然可以获取到。

我们使用HashMap和HashSet,甚至于其余基于hash的键值存储的时候都要注意修改数据带来的后果。一般而言我们覆写hashCode()equals()的目的都是根据hashCodeequals来定义对象的唯一性,如果修改了其值就如同修改了数据库的id一样,数据肯定就会有问题。

因此修改hash时应谨慎,否则就会导致我们使用上的“内存泄露”。