为什么要关心 equals 和 hashcode

位置:首页>文章>详情   分类: Java教程 > 编程技术   阅读(313)   2023-09-05 14:19:57

等于和哈希码是每个 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);
 }
}

您可能已经注意到 equalshashCode 是由我们的 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 用例

不说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 方法的信息,您可以查看两篇关于此的精彩帖子:

  • https://www.percona.com/blog/2014/12/19/store-uuid-optimized-way/
  • https://vladmihalcea.com/2014/07/01/hibernate-and-uuid-identifiers/

偏向锁定

十多年来,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

相关阅读

Java HashSet 教程展示了如何使用 Java HashSet 集合。 Java哈希集 HashSet 是一个不包含重复元素的集合。此类为基本操作(添加、删除、包含和大小)提供恒定时间性...
SpringApplicationBuilder 教程展示了如何使用 SpringApplicationBuilder 创建一个简单的 Spring Boot 应用程序。 春天 是用于创建企业应...
通道是继 buffers 之后 java.nio 的第二个主要新增内容,我们在之前的教程中已经详细了解了这一点。通道提供与 I/O 服务的直接连接。 通道是一种在字节缓冲区和通道另一端的实体(通...
课程大纲 Elasticsearch 是一个基于 Lucene 的搜索引擎。它提供了一个分布式的、支持多租户的全文搜索引擎,带有 HTTP Web 界面和无模式的 JSON 文档。 Elasti...
解析器是强大的工具,使用 ANTLR 可以编写可用于多种不同语言的各种解析器。 在这个完整的教程中,我们将: 解释基础:什么是解析器,它可以用来做什么 查看如何设置 ANTLR 以便在 Java...
Java 是用于开发各种桌面应用程序、Web 应用程序和移动应用程序的最流行的编程语言之一。以下文章将帮助您快速熟悉 Java 语言,并迈向 API 和云开发等更复杂的概念。 1. Java语言...
Java中的继承是指子类继承或获取父类的所有非私有属性和行为的能力。继承是面向对象编程的四大支柱之一,用于提高层次结构中类之间的代码可重用性。 在本教程中,我们将了解 Java 支持的继承类型,...
Java Message Service 是一种支持正式通信的 API,称为 网络上计算机之间的消息传递。 JMS 为支持 Java 程序的标准消息协议和消息服务提供了一个通用接口。 JMS 提...
Java 项目中的一项常见任务是将日期格式化或解析为字符串,反之亦然。解析日期意味着你有一个代表日期的字符串,例如“2017-08-3”,你想把它转换成一个代表 Java 中日期的对象,例如Ja...
之前,我介绍了spring 3 + hibernate 集成 示例和struts 2 hello world 示例。在本教程中,我将讨论在将 spring 框架与 struts 与 hibern...