JPA中的hashCode() / equals()困境

395
在这里已经有关于JPA实体的一些讨论,包括应该为JPA实体类使用哪个hashCode()/equals()实现。大多数(如果不是全部)依赖于Hibernate,但我想讨论JPA实现中性的问题(顺便说一下,我正在使用EclipseLink)。
所有可能的实现都有自己的优点缺点,涉及到以下方面:
  • hashCode()/equals()合同符合性(不可变性)适用于List/Set操作
  • 是否可以检测到相同对象(例如来自不同会话、从惰性加载的数据结构中的动态代理)
  • 实体在分离(或未持久化)状态下是否表现正确
就我所看到的,有三种选择
  1. 不要覆盖它们;依赖于Object.equals()Object.hashCode()
    • hashCode()/equals()有效
    • 无法识别相同的对象,动态代理存在问题
    • 与分离实体没有问题
  2. 根据主键覆盖它们
    • hashCode()/equals()被破坏了
    • 正确的标识(对于所有受控实体)
    • 与分离实体存在问题
  3. 根据业务ID(非主键字段;外键怎么办?)覆盖它们
    • hashCode()/equals()被破坏了
    • 正确的标识(对于所有受控实体)
    • 与分离实体没有问题

我的问题是:

  1. 我是否错过了任何选项和/或优缺点?
  2. 您选择了哪个选项以及为什么?



更新1:

我所谓的“hashCode()/ equals()已损坏”,是指连续的hashCode()调用可能返回不同的值,这在正确实现时并非Object API文档中所述的“损坏”,但当尝试从Map、Set或其他基于哈希的Collection检索更改的实体时会导致问题。因此,JPA实现(至少是EclipseLink)在某些情况下将无法正常工作。
更新2:
感谢您的回答 - 其中大部分质量很高。不幸的是,我仍然不确定哪种方法对于实际应用程序最好,或者如何确定我的应用程序的最佳方法。因此,我将保持问题开放,并希望进行更多讨论和/或意见。

4
“hashCode()/equals() broken” 是什么意思,我不明白。 - nanda
5
那么,如果你在选项2和3中使用相同的策略来实现equals()和hashCode(),它们就不会以那种意义上被“破坏”。 - matt b
14
选项3并非如此。hashCode()和equals()应该使用相同的标准,因此,如果您的某个字段更改了,hashcode()方法将为相同实例返回一个不同的值,而equals()方法也将是如此。您已经省略了hashcode() javadoc中第二部分的内容:在Java应用程序的执行过程中多次调用同一对象的hashCode方法时,只要没有修改用于比较对象的equals信息,hashCode方法必须始终返回相同的整数。 - matt b
2
实际上,该句子的这一部分意思是相反的 - 在同一对象实例上调用hashcode()应该返回相同的值,除非在equals()实现中使用的任何字段发生更改。换句话说,如果您的类中有三个字段,并且您的equals()方法仅使用其中两个来确定实例的相等性,则可以期望hashcode()返回值在更改其中一个字段的值时发生更改 - 当您考虑到此对象实例不再“等于”旧实例所代表的值时,这是有意义的。 - matt b
2
尝试从Map、Set或其他基于哈希的集合中检索更改的实体时可能会出现问题,应该是"尝试从HashMap、HashSet或其他基于哈希的集合中检索更改的实体时可能会出现问题"。 - nanda
显示剩余16条评论
21个回答

2

这里已经有非常详细的答案了,但我会告诉你我们是如何做的。

我们什么都不做(即不覆盖equals/hashcode方法)。

如果我们需要在集合中使用equals/hashcode方法,我们会使用UUID。只需在构造函数中创建UUID即可。我们使用http://wiki.fasterxml.com/JugHome来生成UUID。相比于序列化和数据库访问,UUID的CPU开销略高,但成本很低。


1
Business keys方法不适用于我们。我们使用DB生成的ID、临时的瞬态tempId和override equal()/hashcode()来解决这个困境。所有实体都是Entity的子孙。优点:
  1. 没有额外的DB字段
  2. 没有子类实体中的额外编码,一个方法适用于所有实体
  3. 没有性能问题(例如UUID),DB Id生成
  4. 没有Hashmaps问题(不需要记住equal &等的使用)
  5. 新实体的Hashcode即使在持久化后也不会随时间改变
缺点:
  1. 可能存在序列化和反序列化未持久化实体的问题
  2. 从DB重新加载后,保存实体的Hashcode可能会发生变化
  3. 未持久化的对象被认为始终不同(也许这是正确的?)
  4. 还有什么?
看看我们的代码:
@MappedSuperclass
abstract public class Entity implements Serializable {

    @Id
    @GeneratedValue
    @Column(nullable = false, updatable = false)
    protected Long id;

    @Transient
    private Long tempId;

    public void setId(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }

    private void setTempId(Long tempId) {
        this.tempId = tempId;
    }

    // Fix Id on first call from equal() or hashCode()
    private Long getTempId() {
        if (tempId == null)
            // if we have id already, use it, else use 0
            setTempId(getId() == null ? 0 : getId());
        return tempId;
    }

    @Override
    public boolean equals(Object obj) {
        if (super.equals(obj))
            return true;
        // take proxied object into account
        if (obj == null || !Hibernate.getClass(obj).equals(this.getClass()))
            return false;
        Entity o = (Entity) obj;
        return getTempId() != 0 && o.getTempId() != 0 && getTempId().equals(o.getTempId());
    }

    // hash doesn't change in time
    @Override
    public int hashCode() {
        return getTempId() == 0 ? super.hashCode() : getTempId().hashCode();
    }
}

1

在实现equals/hashCode方面,您有三种选择:

  • 使用应用程序生成的标识符,例如UUID
  • 基于业务键实现它
  • 基于主键实现它

使用应用程序生成的标识符是最简单的方法,但也有一些缺点

  • 如果将其用作PK,则使用128位比32位或64位更慢
  • "调试更难",因为检查某些数据是否正确非常困难

如果您可以使用这些缺点,则只需使用此方法。

为了克服连接问题,可以将UUID用作自然键,将序列值用作主键,但是在具有嵌入式ID的组合子实体中仍可能遇到equals/hashCode实现问题,因为您将希望基于主键进行连接。在子实体ID中使用自然键,使用主键来引用父级是一个很好的折衷方案。

@Entity class Parent {
  @Id @GeneratedValue Long id;
  @NaturalId UUID uuid;
  @OneToMany(mappedBy = "parent") Set<Child> children;
  // equals/hashCode based on uuid
}

@Entity class Child {
  @EmbeddedId ChildId id;
  @ManyToOne Parent parent;

  @Embeddable class ChildId {
    UUID parentUuid;
    UUID childUuid;
    // equals/hashCode based on parentUuid and childUuid
  }
  // equals/hashCode based on id
}

我认为这是最干净的方法,因为它将避免所有缺点,并同时为您提供一个值(UUID),您可以与外部系统共享,而不会暴露系统内部。

如果您期望用户提供业务密钥,则基于业务密钥实现是一个不错的想法,但也有一些缺点

大多数情况下,此业务密钥将是用户提供的某种代码,较少情况下是多个属性的组合。

  • 连接速度较慢,因为基于可变长度文本的连接速度很慢。如果关键字超过一定长度,某些DBMS甚至可能无法创建索引。
  • 根据我的经验,业务密钥往往会发生变化,这将需要对引用它的对象进行级联更新。如果外部系统引用它,则这是不可能的。

我认为您不应该仅基于业务密钥实现或操作。这是一个不错的附加功能,即用户可以快速通过业务密钥搜索,但系统不应依赖它来运行。

基于主键实现也有问题,但可能并不是那么重要

如果您需要向外部系统公开ID,请使用我建议的UUID方法。如果不需要,您仍然可以使用UUID方法,但不必这样做。 使用DBMS生成的ID在equals/hashCode中的问题源于对象可能已经被添加到基于哈希的集合中,而此时尚未分配ID。
解决这个问题的明显方法是在分配ID之前不要将对象添加到基于哈希的集合中。我知道这并非总是可能的,因为您可能希望在分配ID之前进行去重。为了仍然能够使用基于哈希的集合,您只需在分配ID后重新构建集合即可。
您可以像这样做:
@Entity class Parent {
  @Id @GeneratedValue Long id;
  @OneToMany(mappedBy = "parent") Set<Child> children;
  // equals/hashCode based on id
}

@Entity class Child {
  @EmbeddedId ChildId id;
  @ManyToOne Parent parent;

  @PrePersist void postPersist() {
    parent.children.remove(this);
  }
  @PostPersist void postPersist() {
    parent.children.add(this);
  }

  @Embeddable class ChildId {
    Long parentId;
    @GeneratedValue Long childId;
    // equals/hashCode based on parentId and childId
  }
  // equals/hashCode based on id
}

我自己没有测试过确切的方法,所以我不确定在 pre- 和 post-persist 事件中更改集合的方式是如何工作的,但思路是:

  • 暂时从基于哈希的集合中删除对象
  • 持久化它
  • 将对象重新添加到基于哈希的集合中

另一种解决方法是在更新/持久化后简单地重建所有基于哈希的模型。

最终决定权在你手中。我个人大多数情况下使用序列号为基础的方法,只有在需要向外部系统公开标识符时才使用 UUID 方法。


1
我过去一直使用选项1,因为我知道这些讨论并认为在我知道正确操作前最好不做任何事情。这些系统目前仍在成功运行。
然而,下次我可能会尝试选项2 - 使用数据库生成的ID。
如果ID未设置,则哈希码和equals将抛出IllegalStateException。
这将防止涉及未保存实体的微妙错误意外出现。
人们对这种方法有什么看法?

0
回答OP的两个问题:
我认为你已经涵盖了所有的选项和背景。
我选择了OP的第二个选项-根据主键覆盖它们。
以下是我在Java 16及其之后使用的模式,其中JPA(即数据库)在首次保存(插入)到其存储库时生成marketId。
关键区别在于:保持equals和hashCode在marketId的两个状态(null和非null)之间的一致性。
换句话说,它们根据其规定的合同相互一致。我看到的其他解决方案允许equals在marketId从null变为非null时发生变化,并且hashCode在同一时间和方式上也发生变化。这违反了合同。
这个关键部分在于hashCode本身,它明确地避免对null值进行哈希处理,因为这总是会得到一个哈希值为0的结果。
如果许多实体尚未被插入,Objects.hash(null)可能会导致Set和/或Map键的性能下降。
@Override
public boolean equals(Object o) {
  return (o == this) ||
      ((o instanceof Market that)
          //&& (this.getClass() == that.getClass())
          && ((this.marketId != null) && (that.marketId != null))
          && (this.marketId.intValue() == that.marketId.intValue()));
}

@Override
public int hashCode() {
  return
      this.marketId == null
          ? super.hashCode()
          : Objects.hash(marketId);
}

关于上面的“equals”模式的更多细节,请参阅此StackOverflow回答。
注意:由于JPA/Hibernate如何处理代理,行“&& (this.getClass() == that.getClass())”的工作方式与预期不符,并已注释掉。
注意:这假设在JPA实体在第一次持久化(插入)之前,“marketId”为null。并且在持久化操作期间,实例被修改(即变异)。
在现有的Set或作为Map键中使用具有null marketId字段的Market实体实例之前,必须“重新初始化”它,然后才能继续使用。
由于JPA在初始保存(插入)期间对实体进行了变异,Set(或Map)的行为变得不确定。equals和hashCode方法返回的值都已经改变。
因此,要求停止使用任何依赖于一致的equalshashCode(即SetMap)的当前集合实例,这些集合实例包含了新持久化的实体。
并且要明确重新生成任何集合,以正确地包含由JPA框架注入的新的非空marketId值。

0

这是每个使用Java和JPA的IT系统中的常见问题。痛点不仅限于实现equals()和hashCode(),它还影响组织如何引用实体以及其客户如何引用相同的实体。我已经看到了足够多的痛苦,因为没有业务键而写了我的博客来表达我的观点。

简而言之:使用短的、可读性强的、有意义的前缀的顺序ID作为业务键,该键在生成时不依赖于除RAM之外的任何存储。Twitter的Snowflake是一个非常好的例子。


0

我使用一个名为EntityBase的类,并将其继承到我所有的JPA实体中,这对我非常有效。

/**
 * @author marcos.oliveira
 */
@MappedSuperclass
public abstract class EntityBase<TId extends Serializable> implements Serializable{
    /**
     *
     */
    private static final long serialVersionUID = 1L;

    @Id
    @Column(name = "id", unique = true, nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected TId id;



    public TId getId() {
        return this.id;
    }

    public void setId(TId id) {
        this.id = id;
    }

    @Override
    public int hashCode() {
        return (super.hashCode() * 907) + Objects.hashCode(getId());//this.getId().hashCode();
    }

    @Override
    public String toString() {
        return super.toString() + " [Id=" + id + "]";
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        EntityBase entity = (EntityBase) obj;
        if (entity.id == null || id == null) {
            return false;
        }
        return Objects.equals(id, entity.id);
    }
}

参考资料:https://thorben-janssen.com/ultimate-guide-to-implementing-equals-and-hashcode-with-hibernate/


-1
实际上,选项2(主键)似乎是最常用的。自然且不可变的业务键很少见,创建和支持合成键太过繁重,以解决可能永远不会发生的情况。请查看{{link1:spring-data-jpa AbstractPersistable}}实现(唯一的事情:{{link2:对于Hibernate实现,请使用Hibernate.getClass}})。
public boolean equals(Object obj) {
    if (null == obj) {
        return false;
    }
    if (this == obj) {
        return true;
    }
    if (!getClass().equals(ClassUtils.getUserClass(obj))) {
        return false;
    }
    AbstractPersistable<?> that = (AbstractPersistable<?>) obj;
    return null == this.getId() ? false : this.getId().equals(that.getId());
}

@Override
public int hashCode() {
    int hashCode = 17;
    hashCode += null == getId() ? 0 : getId().hashCode() * 31;
    return hashCode;
}

刚刚了解如何在HashSet/HashMap中操作新对象。

相反,选项1(保留Object实现)在merge之后就会出现问题,这是非常常见的情况。

如果您没有业务键,并且确实需要在哈希结构中操作新实体,请重写hashCode为常量,如Vlad Mihalcea所建议的那样。


-1

我曾试图自己回答这个问题,但一直没有找到令我完全满意的解决方案,直到我读到了这篇帖子,特别是DREW的回答。我喜欢他懒惰地创建UUID并将其最优地存储的方式。

但我想要更多的灵活性,即仅在访问hashCode()/equals()之前首次持久化实体时才懒惰地创建UUID,并利用每个解决方案的优势:

  • equals()表示“对象引用相同的逻辑实体”
  • 尽可能使用数据库ID,因为为什么要做两次工作(性能问题)
  • 防止在尚未持久化实体时访问hashCode()/equals()时出现问题,并在确实持久化后保持相同的行为

我真的很希望能够得到有关我的混合解决方案的反馈。

public class MyEntity { 

    @Id()
    @Column(name = "ID", length = 20, nullable = false, unique = true)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;

    @Transient private UUID uuid = null;

    @Column(name = "UUID_MOST", nullable = true, unique = false, updatable = false)
    private Long uuidMostSignificantBits = null;
    @Column(name = "UUID_LEAST", nullable = true, unique = false, updatable = false)
    private Long uuidLeastSignificantBits = null;

    @Override
    public final int hashCode() {
        return this.getUuid().hashCode();
    }

    @Override
    public final boolean equals(Object toBeCompared) {
        if(this == toBeCompared) {
            return true;
        }
        if(toBeCompared == null) {
            return false;
        }
        if(!this.getClass().isInstance(toBeCompared)) {
            return false;
        }
        return this.getUuid().equals(((MyEntity)toBeCompared).getUuid());
    }

    public final UUID getUuid() {
        // UUID already accessed on this physical object
        if(this.uuid != null) {
            return this.uuid;
        }
        // UUID one day generated on this entity before it was persisted
        if(this.uuidMostSignificantBits != null) {
            this.uuid = new UUID(this.uuidMostSignificantBits, this.uuidLeastSignificantBits);
        // UUID never generated on this entity before it was persisted
        } else if(this.getId() != null) {
            this.uuid = new UUID(this.getId(), this.getId());
        // UUID never accessed on this not yet persisted entity
        } else {
            this.setUuid(UUID.randomUUID());
        }
        return this.uuid; 
    }

    private void setUuid(UUID uuid) {
        if(uuid == null) {
            return;
        }
        // For the one hypothetical case where generated UUID could colude with UUID build from IDs
        if(uuid.getMostSignificantBits() == uuid.getLeastSignificantBits()) {
            throw new Exception("UUID: " + this.getUuid() + " format is only for internal use");
        }
        this.uuidMostSignificantBits = uuid.getMostSignificantBits();
        this.uuidLeastSignificantBits = uuid.getLeastSignificantBits();
        this.uuid = uuid;
    }

“UUID在实体持久化之前生成”是什么意思?你能举个例子吗? - jhegedus
你能使用分配的GenerationType吗?为什么需要Identity GenerationType?它有什么优势比Assigned更好吗? - jhegedus
如果你做以下操作:1)创建一个新的MyEntity,2)将其放入列表中,3)然后将其保存到数据库中,4)从数据库中加载该实体,5)尝试查看加载的实例是否在列表中。我的猜测是,即使它应该在列表中,但它不会出现在列表中。 - jhegedus
感谢您的第一条评论,它让我意识到我的表述不够清晰。首先,“UUID one day generated on this entity before i was persisted”是一个打字错误...应该读作“before IT was persisted”。对于其他的评论,我会尽快编辑我的帖子,以更好地解释我的解决方案。 - user2083808
这个解决方案在并发/多线程的情况下似乎是不安全的。换句话说,至少存在一个潜在的竞争条件,如果不是多个的话。由于JPA实体不能被标记为final,也不能被“有效地不可变”,因此需要改进这个解决方案以确保并发/多线程的一致性。 - undefined

-1

如果UUID对许多人来说是答案,那么为什么我们不只是使用业务层的工厂方法来创建实体并在创建时分配主键呢?

例如:

@ManagedBean
public class MyCarFacade {
  public Car createCar(){
    Car car = new Car();
    em.persist(car);
    return car;
  }
}

这样我们可以从持久化提供程序中获取实体的默认主键,使得我们的hashCode()和equals()函数可以依赖于它。

我们还可以将Car的构造函数声明为protected,然后在业务方法中使用反射来访问它们。这样开发人员就不会使用new来实例化Car,而是通过工厂方法。

这个方案怎么样?


如果你愿意承受生成 GUID 时进行数据库查找所带来的性能损失,这种方法非常有效。 - Michael Wiles
2
对于汽车进行单元测试怎么样?这种情况下,您需要数据库连接进行测试吗?此外,您的域对象不应依赖于持久性。 - jhegedus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接