Equals implementation of Hibernate Entity

Equals implementation of Hibernate Entity

十二月 02, 2019

结论先行

  1. 假如不存在把Entity作为Key放进HashSet/HashMap等集合的情况下,可以不实现equals()hashCode()
  2. 假如只会将处于persistent state的Entity作为Key放进HashSet/HashMap等集合,且也只使用persistent state的Entity作为Key对这些集合进行操作的情况下,也可以不实现equals()hashCode()。(Hibernate一级缓存会保证代表同一数据行的实体的引用唯一)
  3. 如果需要实现equals(),需要使用instanceof而非getClass()进行类比较。IntelliJ IDEA的code-generate-equalsAndHashCode菜单可以接受参数Accept subclasses as parameter to equals() method,勾选后生成的equals()方法也会使用instanceof。lombok实现的equals方法使用的即为instanceof,因此可以放心使用。

实体类代码

1
2
3
4
5
6
7
8
9
10
@Entity
@lombok.EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Child {
// ...
@lombok.EqualsAndHashCode.Include
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Parent parent;
// ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Parent {
@Id
private Long id;

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Parent that = (Parent) o;
return getId().equals(that.getId());
}
}

Parent实体实际上是存储在数据库内的静态数据,在代码中只有读取操作,因此以id field作为比较参数是合理的。

同时Parent作为Child的业务主键的一部分,也通过lombok注解@EqualsAndHashCode加入到了Child类的equals判断逻辑中。@EqualsAndHashCode默认将被注解的类中所有field添加进equals方法比较逻辑中。而默认值为false的onlyExplicitlyIncluded参数只会将被`@EqualsAndHashCode.Include注解的field添加进equals`方法中。

Child作为ChildParentManyToOneowning side具有外键parent_id指向Parent的主键id,因此无论FetchTypeEAGER还是LAZYchild.getParent().getId()都可以正常执行。

问题场景

  1. Child实体创建或更新的业务逻辑中,先从数据库查询现在已经存在的Child,并存放在Map中:

    1
    2
    Map<Child, Child> childsMap = childRepository.findAll()
    .stream().collect(Collectors.toMap(c -> c, c -> c));
  2. 创建新的Child对象,通过判断从Map中取得的value是否为null判断是否存在,如果不存在则作为新增的数据保存,否则执行更新

    1
    2
    3
    4
    5
    6
    7
    8
    Child newChild = childService.produceNewChild();
    Child existingChild = childsMap.get(newChild);
    if (existingChild == null) {
    entityManager.persist(newChild);
    } else {
    existingChild.updateFrom(newChild);
    entityManager.merge(existingChild);
    }
  3. 实际执行时出现了问题,Hibernate抛出了MySQLIntegrityConstraintViolationException,提示执行entityManager.persist(newChild)时违反了数据库唯一索引约束。

排查

  1. 第一反应是类中注解加错了,`@EqualsAndHashCode.Include注解的field比唯一索引中字段更多,导致新旧实体的业务主键虽然相同,但hashCode()不相同,equals()也不为true`。仔细检查了一下排除该问题
  2. 查看了一下Collectors.toMap(Function keyMapper, Function valueMapper)的源码,确定其使用的是我们熟悉的HashMap

    1
    2
    3
    4
    5
    public static <T, K, U>
    Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
    Function<? super T, ? extends U> valueMapper) {
    return toMap(keyMapper, valueMapper, throwingMerger(), HashMap::new);
    }
  3. Map实现没问题,问题肯定还在equals()hashCode()上。首先找到有问题的数据,删掉数据库里其它干扰数据。加了几行代码debug看看实际的输出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Child newChild = childService.produceNewChild();
    Child existingChild = childsMap.get(newChild);
    if (existingChild == null) {
    existingChild = childsMap.keySet().iterator().next();

    boolean isHashCodesEqual = existingChild.hashCode() == newChild.hashCode(); // true
    boolean isEntitiesEqual = existingChild.equals(newChild); // true
    Child child1 = childsMap.get(existingChild); // existingChild
    Child child2 = childsMap.get(newChild); // null
    }

奇怪的事情发生了,hashCode相等,equals()返回的也是true,但是两次get()返回的结果却不一样。首先可以确认的是hashCode()方法的实现是没问题的,这也可以判断目前的问题不是由于【在把child放进map之后它的hashCode发生变化】引起的,否则childsMap.get(existingChild)也应返回null

  1. hashCode没问题,问题只能在equals()上了。下一步将断点打到了HashMap中,观察childsMap.get(newChild)在执行中究竟发生了什么才会返回null。终于有了线索,在执行到Parent#equals的这行代码时方法直接返回了false

    1
    2
    3
    if (o == null || getClass() != o.getClass()) {
    return false;
    }
  2. debug模式查看当前栈帧,this指向的是newChild,而传入equals()的参数则是一个Hibernate提供的代理类实现包裹着的Child

由于parentFetchType.LAZY的一个需要Hibernate进行懒加载的field,Hibernate在加载Child时会使用一个代理对象放在对应的field中,以便真正需要读取parent中数据时再进行加载。

问题到这里已经可以解决了,Object#getClass返回的是对象的运行时类型,通过这个方法比较的两个对象必须是完全相同的类;使用instanceof替代则可以将条件放宽,如果是目标类的子类也可以通过判断。参考此链接

  1. 但还有些疑惑没有解开:
    1. 为什么childsMap.get(existingChild)可以正常获取出value呢?
    2. 为什么existingChild.equals(newChild)返回的是true呢?
  2. 第一个问题的解答在HashMap#getNode中:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    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 && // 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;
    }

在调用equals()之前会直接通过==比较hashTable中的key与传入的key的引用。debug时使用的是map.keySet()中的key,自然是同一引用。

  1. 第二个问题则是代理的特性。很不巧的是debug过程中一时疏漏只浅显地测试了existingChild.equals(newChild),而没有及时想起equals()的实现规则:
    1. Reflexive: x.equals(x) == true, an object must equal to itself.
    2. Symmetry: if(x.equals(y)==true) then y.equals(x) == true.
    3. Transitive: if x.equals(y) and y.equals(z); then x.equals(z)
    4. Consistent: if x.equals(y)==true and no value is modified, then it’s always true for every call
    5. For any non-null object x, x.equals(null)==false

通过新加两行代码:

1
2
boolean isSymmetic = newChild.equals(existingChild); // false
boolean isReflexive = existingChild.equals(existingChild); // false

立马可知该equals实现在当前场景下是不适用的。

当调用代理对象的equals()方法时,代理对象拦截了调用,并将调用委派至被代理的原始对象。因此在进行existingChild.equals调用时,最后真正执行equals方法的是被代理的Child类的原始对象,而被比较的newChild也是Child类的对象。

可在newChild.equals(existingChild)existingChild.equals(existingChild),以及HashMap#getNode中调用的key.equals(k)(key为传入的newChild,k为存在hashTable中的existingChild)这三处调用中,代理对象existingChild作为参数被传入,直接被调用了Object#getClass进行了比较,最终导致了结果与预期的差异。

参考阅读

https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-with-jpa-and-hibernate/