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);
System.out.println(hashChanges.contains(new HashChange(1, 1, "name2"))); System.out.println(hashChanges.contains(change));
change.setId2(2); System.out.println(hashChanges.contains(new HashChange(1, 2, "name2"))); System.out.println(hashChanges.contains(new HashChange(1, 1, "name2"))); System.out.println(hashChanges.contains(change)); } @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) { 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; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && ((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); change.setId2(2); System.out.println(hashChanges.contains(change));
Iterator<HashChange> iterator = hashChanges.iterator(); while (iterator.hasNext()) { HashChange next = iterator.next(); System.out.println(next==change); } } @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()
的目的都是根据hashCode
和equals
来定义对象的唯一性,如果修改了其值就如同修改了数据库的id一样,数据肯定就会有问题。
因此修改hash时应谨慎,否则就会导致我们使用上的“内存泄露”。