等于和哈希码是每个 Java 对象的基本元素。它们的正确性和性能对您的应用程序至关重要。然而,我们经常看到即使是有经验的程序员也如何忽略类开发的这一部分。在这篇文章中,我将介绍与这两种非常基本的方法相关的一些常见错误和问题。
上述方法的关键是所谓的“契约”。有三个关于 hashCode 的规则和五个关于 equals 的规则(您可以在 Object 类的 Java 文档中找到它们),但我们将讨论三个基本的规则。让我们从 hashCode() 开始:
“只要在 Java 应用程序的执行期间对同一对象多次调用,hashCode 方法必须一致地返回相同的整数,不提供任何信息在对象的等于比较中使用被修改。”这意味着对象的哈希码不必是不可变的。那么让我们来看看真正简单的 Java 对象的代码:
public class Customer { private UUID id; private String email; public UUID getId() { return id; } public void setId(final UUID id) { this.id = id; } public String getEmail() { return email; } public void setEmail(final String email) { this.email = email; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Customer customer = (Customer) o; return Objects.equals(id, customer.id) && Objects.equals(email, customer.email); } @Override public int hashCode() { return Objects.hash(id, email); } }
您可能已经注意到 equals 和 hashCode 是由我们的 IDE 自动生成的。我们确信这些方法不是不可变的,而且这些类肯定被广泛使用。也许如果这样的类如此普遍,那么这样的实现就没有错吗?那么让我们看一下简单的用法示例:
def "should find cart for given customer after correcting email address"() { given: Cart sampleCart = new Cart() Customer sampleCustomer = new Customer() sampleCustomer.setId(UUID.randomUUID()) sampleCustomer.setEmail("emaill@customer.com") HashMap customerToCart = new HashMap<>() when: customerToCart.put(sampleCustomer, sampleCart) then: customerToCart.get(sampleCustomer) == sampleCart and: sampleCustomer.setEmail("email@customer.com") customerToCart.get(sampleCustomer) == sampleCart }
在上面的测试中,我们要确保在更改示例客户的电子邮件后,我们仍然能够找到其购物车。不幸的是,这个测试失败了。为什么?因为 HashMap 将键存储在“桶”中。每个桶都包含特定范围的哈希值。由于这个想法,哈希映射是如此之快。但是,如果我们将密钥存储在第一个桶中(负责 1 到 10 之间的哈希值),然后 hashCode 方法的值返回 11 而不是 5(因为它是可变的),会发生什么?哈希映射试图找到密钥,但它检查第二个桶(持有哈希 11 到 20)。它是空的。因此,对于给定的客户来说,根本就没有购物车。这就是为什么拥有不可变的哈希码如此重要!
实现它的最简单方法是使用不可变对象。如果由于某些原因在您的实现中是不可能的,那么请记住将 hashCode 方法限制为仅使用对象的不可变元素。第二个 hashCode 规则告诉我们,如果两个对象相等(根据 equals 方法),则哈希值必须相同。这意味着这两种方法必须相关,这可以通过基于相同的信息(基本上是字段)来实现。
最后但同样重要的是告诉我们关于等于的传递性。它看起来微不足道,但事实并非如此——至少当你想到继承时是这样。想象一下,我们有一个扩展日期时间对象的日期对象。为日期实现 equals 方法很容易——当两个日期相同时,我们返回 true。日期时间也一样。但是当我想将日期与日期时间进行比较时会发生什么?他们有同一天、同一月和同一年就足够了吗?由于日期中不存在此信息,因此可以湿比较小时和分钟吗?如果我们决定使用这种方法,我们就完蛋了。请分析下面的例子:
2016-11-28 == 2016-11-28 12:20 2016-11-28 == 2016-11-28 15:52
由于 equals 的传递性,我们可以说 2016-11-28 12:20 等于 2016-11-28 15:52 这当然是愚蠢的。但是当你考虑平等合同时,这是正确的。
不说JPA了。看起来在这里实现 equals 和 hashCode 方法真的很简单。我们对每个实体都有唯一的主键,因此基于此信息的实现是正确的。但是这个唯一ID是什么时候分配的呢?在对象创建期间还是在将更改刷新到数据库之后?如果你手动分配 ID 没问题,但如果你依赖底层引擎,你可能会落入陷阱。想象一下这样的情况:
public class Customer { @OneToMany(cascade = CascadeType.PERSIST) private Set addresses = new HashSet<>(); public void addAddress(Address newAddress) { addresses.add(newAddress); } public boolean containsAddress(Address address) { return addresses.contains(address); } }
如果地址的 hashCode 基于 ID,在保存 Customer 实体之前,我们可以假设所有哈希码都等于零(因为还没有 ID)。刷新更改后,将分配 ID,这也会产生新的哈希码值。现在您可以调用 containsAddress 方法,不幸的是,它总是返回 false,原因与第一节 HashMap 中解释的相同。我们怎样才能避免这样的问题呢?据我所知,只有一种有效的解决方案——UUID。
class Address { @Id @GeneratedValue private Long id; private UUID uuid = UUID.randomUUID(); // all other fields with getters and setters if you need @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Address address = (Address) o; return Objects.equals(uuid, address.uuid); } @Override public int hashCode() { return Objects.hash(uuid); } }
uuid 字段(可以是 UUID 或简单的 String)在对象创建期间分配,并在整个实体生命周期中保持不变。它存储在数据库中,并在查询此对象后立即加载到字段中。它或当然会增加一些开销和占用空间,但没有免费的东西。如果您想了解更多关于 UUID 方法的信息,您可以查看两篇关于此的精彩帖子:
十多年来,Java 中的默认锁定实现使用一种称为“偏向锁定”的东西。有关此技术的简要信息可以在标记评论中找到(来源:Java 调优白皮书):
-XX:+UseBiasedLocking 启用一种提高无竞争同步性能的技术。一个对象“偏向于”首先通过 monitorenter 字节码或同步方法调用获取其监视器的线程;该线程执行的后续监视器相关操作在多处理器机器上相对要快得多。启用此标志后,一些具有大量无竞争同步的应用程序可能会获得显着的加速;某些具有特定锁定模式的应用程序可能会变慢,但已尝试将负面影响降至最低。
关于这篇文章,我们感兴趣的是偏向锁定是如何在内部实现的。 Java 使用对象头来存储持有锁的线程的 ID。问题是对象头布局定义良好(如果您有兴趣,请参考 OpenJDK 源代码 hotspot/src/share/vm/oops/markOop.hpp)并且它不能“扩展” ” 就这样。在 64 位 JVM 线程 ID 是 54 位长所以我们必须决定是否要保留这个 ID 或其他东西。不幸的是,“其他”指的是对象哈希码(实际上是身份哈希码,存储在对象标头中)。
每当您在自 Object 类以来未覆盖它的任何对象上调用 hashCode() 方法时,或当您直接调用 System.identityHashCode() 方法时,都会使用此值。这意味着当您检索任何对象的默认哈希码时;您禁用对此对象的偏向锁定支持。这很容易证明。看看这样的代码:
class BiasedHashCode { public static void main(String[] args) { Locker locker = new Locker(); locker.lockMe(); locker.hashCode(); } static class Locker { synchronized void lockMe() { // do nothing } @Override public int hashCode() { return 1; } } }
当您使用以下 VM 标志运行 main 方法时:-XX:BiasedLockingStartupDelay=0 -XX:+TraceBiasedLocking
您可以看到……没有什么有趣的:)
然而,在从 Locker 类中删除 hashCode 实现后,情况发生了变化。现在我们可以在日志中找到这样一行:Revoking bias of object 0x000000076d2ca7e0 , mark 0x00007ff83800a805 , type BiasedHashCode$Locker , prototype header 0x0000000000000005 , allow rebias 0 , requesting thread 0x00007ff83800a800
为什么会这样?因为我们要求提供身份哈希码。总结这部分:你的类中没有 hashCode 意味着没有偏向锁定。
非常感谢 https://www.sitepoint.com/java/ 的 Nicolai Parlog 审阅这篇文章并指出我的一些错误。
标签2: Java教程地址:https://www.cundage.com/article/jcg-care-equals-hashcode.html