Java 编程问题:二、对象、不变性和`switch`表达式2

简介: Java 编程问题:二、对象、不变性和`switch`表达式

46 equals()和hashCode()


equals()和hashCode()方法在java.lang.Object中定义。因为Object是所有 Java 对象的超类,所以这两种方法对所有对象都可用。他们的主要目标是为比较对象提供一个简单、高效、健壮的解决方案,并确定它们是否相等。如果没有这些方法和它们的契约,解决方案依赖于庞大而繁琐的if语句来比较对象的每个字段。


当这些方法没有被覆盖时,Java 将使用它们的默认实现。不幸的是,默认实现并不能真正实现确定两个对象是否具有相同值的目标。默认情况下,equals()检查相等性。换言之,当且仅当两个对象由相同的内存地址(相同的对象引用)表示时,它认为这两个对象相等,而hashCode()返回对象内存地址的整数表示。这是一个本机函数,称为标识**哈希码。


例如,假设以下类:

public class Player {
  private int id;
  private String name;
  public Player(int id, String name) {
    this.id = id;
    this.name = name;
  }
}


然后,让我们创建包含相同信息的此类的两个实例,并比较它们是否相等:

Player p1 = new Player(1, "Rafael Nadal");
Player p2 = new Player(1, "Rafael Nadal");
System.out.println(p1.equals(p2)); // false
System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094


不要使用==运算符来测试对象的相等性(避免使用if(p1 == p2)。==操作符比较两个对象的引用是否指向同一个对象,而equals()比较对象值(作为人类,这是我们关心的)。


根据经验,如果两个变量拥有相同的引用,则它们相同,但是如果它们引用相同的值,则它们相等。

相同值的含义由equals()定义。


对我们来说,p1和p2是相等的,但是请注意equals()返回了false(p1和p2实例的字段值完全相同,但是它们存储在不同的内存地址)。这意味着依赖于equals()的默认实现是不可接受的。解决方法是覆盖此方法,为此,重要的是要了解equals()合同,该合同规定了以下声明:


   自反性:对象等于自身,即p1.equals(p1)必须返回true。

   对称性:p1.equals(p2)必须返回与p2.equals(p1)相同的结果(true/false)。

   传递性:如果是p1.equals(p2)和p2.equals(p3),那么也是p1.equals(p3)。

   一致性:两个相等的物体必须一直保持相等,除非其中一个改变。

   null返回false:所有对象必须不等于null。


因此,为了遵守此约定,Player类的equals()方法可以覆盖如下:

@Override
public boolean equals(Object obj) {
  if (this == obj) {
    return true;
  }
  if (obj == null) {
    return false;
  }
  if (getClass() != obj.getClass()) {
    return false;
  }
  final Player other = (Player) obj;
  if (this.id != other.id) {
    return false;
  }
  if (!Objects.equals(this.name, other.name)) {
    return false;
  }
  return true;
}


现在,让我们再次执行相等性测试(这次,p1等于p2

System.out.println(p1.equals(p2)); // true


好的,到目前为止还不错!现在,让我们将这两个Player实例添加到集合中。例如,让我们将它们添加到一个HashSet(一个不允许重复的 Java 集合):


Set<Player> players = new HashSet<>();
players.add(p1);
players.add(p2);


让我们检查一下这个HashSet的大小以及它是否包含p1

System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094
System.out.println("Set size: " + players.size());    // 2
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // false

与前面实现的equals()一致,p1和p2是相等的,因此HashSet的大小应该是 1,而不是 2。此外,它应该包含纳达尔。那么,发生了什么?


一般的答案在于 Java 是如何创建的。凭直觉很容易看出,equals()不是一种快速的方法;因此,当需要大量的相等比较时,查找将面临性能损失。例如,在通过集合中的特定值(例如,HashSet、HashMap和HashTable进行查找的情况下,这增加了一个严重的缺点,因为它可能需要大量的相等比较。


基于这个语句,Java 试图通过添加桶来减少相等比较。桶是一个基于散列的容器,它将相等的对象分组。这意味着相等的对象应该返回相同的哈希码,而不相等的对象应该返回不同的哈希码(如果两个不相等的对象具有相同的哈希码,则这是一个散列冲突,并且对象将进入同一个桶)。因此,Java 会比较散列代码,只有当两个不同的对象引用的散列代码相同(而不是相同的对象引用)时,它才会进一步调用equals()。基本上,这会加速集合中的查找。


但我们的案子发生了什么?让我们一步一步来看看:


   当创建p1时,Java 将根据p1内存地址为其分配一个哈希码。


   当p1被添加到Set时,Java 会将一个新的桶链接到p1哈希码。


   当创建p2时,Java 将根据p2内存地址为其分配一个哈希码。


   当p2被添加到Set时,Java 会将一个新的桶链接到p2哈希码(当这种情况发生时,看起来HashSet没有按预期工作,它允许重复)。


   当执行players.contains(new Player(1, "Rafael Nadal"))时,基于p3存储器地址用新的哈希码创建新的播放器p3。


   因此,在contains()的框架中,分别测试p1和p3 p2和p3的相等性涉及检查它们的哈希码,由于p1哈希码不同于p3哈希码,而p2哈希码不同于p3哈希码,比较停止,没有求值equals(),这意味着HashSet不包含对象(p3)


为了回到正轨,代码也必须覆盖hashCode()方法。hashCode()合同规定如下:


   符合equals()的两个相等对象必须返回相同的哈希码。

   具有相同哈希码的两个对象不是强制相等的。

   只要对象保持不变,hashCode()必须返回相同的值。

根据经验,为了尊重equals()和hashCode()合同,遵循两条黄金法则:

   当equals()被覆盖时,hashCode()也必须被覆盖,反之亦然。

   以相同的顺序对两个方法使用相同的标识属性。


对于Player类,hashCode()可以被覆盖如下:

@Override
public int hashCode() {
  int hash = 7;
  hash = 79 * hash + this.id;
  hash = 79 * hash + Objects.hashCode(this.name);
  return hash;
}


现在,让我们执行另一个测试(这次,它按预期工作):

System.out.println("p1 hash code: " + p1.hashCode()); // -322171805
System.out.println("p2 hash code: " + p2.hashCode()); // -322171805
System.out.println("Set size: " + players.size());    // 1
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // true


现在,让我们列举一下使用equals()和hashCode()时的一些常见错误:


   您覆盖了equals()并忘记覆盖hashCode(),反之亦然(覆盖两者或无)。


   您使用==运算符而不是equals()来比较对象值。


   在equals()中,省略以下一项或多项:


       从添加自检(if (this == obj)...开始。


       因为没有实例应该等于null,所以继续添加空校验(if(obj == null)...)。


       确保实例是我们期望的(使用getClass()或instanceof。


       最后,在这些角落案例之后,添加字段比较。


   你通过继承来破坏对称。假设一个类A和一个类B扩展了A并添加了一个新字段。B类覆盖从A继承的equals()实现,并将此实现添加到新字段中。依赖instanceof会发现b.equals(a)会返回false(如预期),而a.equals(b)会返回true(非预期),因此对称性被破坏。依赖切片比较是行不通的,因为这会破坏及物性和自反性。解决这个问题意味着依赖于getClass()而不是instanceof(通过getClass(),类型及其子类型的实例不能相等),或者更好地依赖于组合而不是继承,就像绑定到本书中的应用(P46_ViolateEqualsViaSymmetry一样)。


   返回一个来自hashCode()的常量,而不是每个对象的唯一哈希码。


自 JDK7 以来,Objects类提供了几个帮助程序来处理对象相等和哈希码,如下所示:


   Objects.equals(Object a, Object b):测试a对象是否等于b对象。


   Objects.deepEquals(Object a, Object b):用于测试两个对象是否相等(如果是数组,则通过Arrays.deepEquals()进行测试)。


   Objects.hash(Object ... values):为输入值序列生成哈希码。


通过EqualsVerifier库(确保equals()和hashCode()尊重 Java SE 合同)。


依赖Lombok库从对象的字段生成hashCode()和equals()。但请注意Lombok与 JPA 实体结合的特殊情况。



47 不可变对象简述


不可变对象是一个一旦创建就不能更改的对象(其状态是固定的)。


在 Java 中,以下内容适用:


   原始类型是不可变的。

   著名的 JavaString类是不可变的(其他类也是不可变的,比如Pattern、LocalDate)

   数组不是不变的。

   集合可以是可变的、不可修改的或不可变的。


不可修改的集合不是自动不变的。它取决于集合中存储的对象。如果存储的对象是可变的,那么集合是可变的和不可修改的。但是如果存储的对象是不可变的,那么集合实际上是不可变的。


不可变对象在并发(多线程)应用和流中很有用。由于不可变对象不能更改,因此它们无法处理并发问题,并且不会有损坏或不一致的风险。


使用不可变对象的一个主要问题与创建新对象的代价有关,而不是管理可变对象的状态。但是请记住,不可变对象在垃圾收集期间利用了特殊处理。此外,它们不容易出现并发问题,并且消除了管理可变对象状态所需的代码。管理可变对象状态所需的代码往往比创建新对象慢。


通过研究以下问题,我们可以更深入地了解 Java 中的对象不变性。




48 不可变字符串


每种编程语言都有一种表示字符串的方法。作为基本类型,字符串是预定义类型的一部分,几乎所有类型的 Java 应用都使用它们。


在 Java 中,字符串不是由一个像int、long和float这样的原始类型来表示的。它们由名为String的引用类型表示。几乎所有 Java 应用都使用字符串,例如,Java 应用的main()方法获取一个String类型的数组作为参数。


String的臭名昭著及其广泛的应用意味着我们应该详细了解它。除了知道如何声明和操作字符串(例如,反转和大写)之外,开发人员还应该理解为什么这个类是以特殊或不同的方式设计的。更确切地说,String为什么是不可变的?或者这个问题有一个更好的共鸣,比如说,String不变的利弊是什么?




字符串不变性的优点


在下一节中,我们来看看字符串不变性的一些优点。



字符串常量池或缓存池


支持字符串不变性的原因之一是由字符串常量池SCP)或缓存池表示的。为了理解这种说法,让我们深入了解一下String类是如何在内部工作的。


SCP 是内存中的一个特殊区域(不是普通的堆内存),用于存储字符串文本。假设以下三个String变量:

String x = "book";
String y = "book";
String z = "book";



创建了多少个String对象?说三个很有诱惑力,但实际上 Java 只创建一个具有"book"值的String对象。其思想是,引号之间的所有内容都被视为一个字符串文本,Java 通过遵循这样的算法(该算法称为字符串内化)将字符串文本存储在称为 SCP 的特殊内存区域中:


   当一个字符串文本被创建时(例如,String x = "book"),Java 检查 SCP 以查看这个字符串文本是否存在。


   如果在 SCP 中找不到字符串字面值,则在 SCP 中为字符串字面值创建一个新的字符串对象,并且相应的变量x将指向它。


   如果在 SCP 中找到字符串字面值(例如,String y = "book"、String z = "book"),那么新变量将指向String对象(基本上,所有具有相同值的变量都将指向相同的String对象):


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dNoey0Qn-1657077359610)(img/fb650718-b302-4bd2-8298-1b1b7813b4d9.png)]

但是x应该是"cook"而不是"book",所以我们用"c"-x = x.replace("b", "c");来代替"b"。


而x应该是"cook",y和z应该保持不变。这种行为是由不变性提供的。Java 将创建一个新对象,并对其执行如下更改:


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gnoRe675-1657077359611)(img/4c94c088-32d7-4df0-a37b-9ba4e589c142.png)]

因此,字符串不变性允许缓存字符串文本,这允许应用使用大量字符串文本,对堆内存和垃圾收集器的影响最小。在可变上下文中,修改字符串字面值可能导致变量损坏。


不要创建一个字符串作为String x = new String("book")。这不是字符串文本;这是一个String实例(通过构造器构建),它将进入普通内存堆而不是 SCP。在普通堆内存中创建的字符串可以通过显式调用String.intern()方法作为x.intern()指向 SCP。



安全


字符串不变性的另一个好处是它的安全性。通常,许多敏感信息(用户名、密码、URL、端口、数据库、套接字连接、参数、属性等)都以字符串的形式表示和传递。通过使这些信息保持不变,代码对于各种安全威胁(例如,意外或故意修改引用)变得安全。



线程安全性


想象一个应用使用成千上万个可变的String对象并处理线程安全代码。幸运的是,在这种情况下,由于不变性,我们想象的场景不会变成现实。任何不可变对象本质上都是线程安全的。这意味着字符串可以由多个线程共享和操作,没有损坏和不一致的风险。



哈希码缓存


equals()和hashCode()部分讨论了equals()和hashCode()。每次对特定活动进行哈希运算(例如,搜索集合中的元素)时,都应该计算哈希码。因为String是不可变的,所以每个字符串都有一个不可变的哈希码,可以缓存和重用,因为它在创建字符串后不能更改。这意味着可以从缓存中使用字符串的哈希码,而不是每次使用时重新计算它们。例如,HashMap为不同的操作(例如,put()、get())散列其键,如果这些键属于String类型,则哈希码将从缓存中重用,而不是重新计算它们。



类加载


在内存中加载类的典型方法依赖于调用Class.forName(String className)方法。注意表示类名的参数String。由于字符串不变性,在加载过程中不能更改类名。然而,如果String是可变的,那么想象加载class A(例如,Class.forName("A")),在加载过程中,它的名称将被更改为BadA。现在,BadA物体可以做坏事!



字符串不变性的缺点

在下一节中,我们来看看字符串不变性的一些缺点。



字符串不能扩展

应该声明一个不可变的类final,以避免扩展性。然而,开发人员需要扩展String类以添加更多的特性,这一限制可以被认为是不变性的一个缺点。


然而,开发人员可以编写工具类(例如,Apache Commons Lang、StringUtils、Spring 框架、StringUtils、Guava 和字符串)来提供额外的特性,并将字符串作为参数传递给这些类的方法。



敏感数据长时间存储在内存中


字符串中的敏感数据(例如密码)可能长时间驻留在内存(SCP)中。作为缓存,SCP 利用了来自垃圾收集器的特殊处理。更准确地说,垃圾收集器不会以与其他内存区域相同的频率(周期)访问 SCP。


作为这种特殊处理的结果,敏感数据在 SCP 中保存了很长一段时间,并且很容易被不必要的使用。


为了避免这一潜在缺陷,建议将敏感数据(例如密码)存储在char[]而不是String中。



OutOfMemoryError错误

SCP 是一个很小的内存区,可以很快被填满。在 SCP 中存储过多的字符串字面值将导致OutOfMemoryError



字符串是完全不变的吗?

在幕后,String使用private final char[]来存储字符串的每个字符。通过使用 Java 反射 API,在 JDK8 中,以下代码将修改此char[](JDK11 中的相同代码将抛出java.lang.ClassCastException):

String user = "guest";
System.out.println("User is of type: " + user);
Class<String> type = String.class;
Field field = type.getDeclaredField("value");
field.setAccessible(true);
char[] chars = (char[]) field.get(user);
chars[0] = 'a';
chars[1] = 'd';
chars[2] = 'm';
chars[3] = 'i';
chars[4] = 'n';
System.out.println("User is of type: " + user);

因此,在 JDK8 中,String有效不可变的,但不是完全



49 编写不可变类


一个不可变的类必须满足几个要求,例如:


   该类应标记为final以抑制可扩展性(其他类不能扩展该类;因此,它们不能覆盖方法)

   所有字段都应该声明为private和final(在其他类中不可见,在这个类的构造器中只初始化一次)

   类应该包含一个参数化的public构造器(或者一个private构造器和用于创建实例的工厂方法),用于初始化字段

   类应该为字段提供获取器

   类不应公开设置器


例如,以下Point类是不可变的,因为它成功地通过了前面的检查表:

public final class Point {
  private final double x;
  private final double y;
  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }
  public double getX() {
    return x;
  }
  public double getY() {
    return y;
  }
}

如果不可变类应该操作可变对象,请考虑以下问题。




50 向不可变类传递/从不可变类返回可变对象


将可变对象传递给不可变类可能会破坏不可变性。让我们考虑以下可变类:

public class Radius {
  private int start;
  private int end;
  public int getStart() {
    return start;
  }
  public void setStart(int start) {
    this.start = start;
  }
  public int getEnd() {
    return end;
  }
  public void setEnd(int end) {
    this.end = end;
  }
}

然后,让我们将这个类的一个实例传递给一个名为Point的不可变类。乍一看,Point类可以写为:

public final class Point {
  private final double x;
  private final double y;
  private final Radius radius;
  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }
  public double getX() {
    return x;
  }
  public double getY() {
    return y;
  }
  public Radius getRadius() {
    return radius;
  }
}

这个类仍然是不变的吗?答案是否定的,Point类不再是不变的,因为它的状态可以改变,如下例所示:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);
Point p = new Point(1.23, 4.12, r);
System.out.println("Radius start: " + p.getRadius().getStart()); // 0
r.setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

注意,调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变,所以Point不再是不可变的。该问题的解决方案是克隆Radius对象并将克隆存储为Point的字段:

public final class Point {
  private final double x;
  private final double y;
  private final Radius radius;
  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    Radius clone = new Radius();
    clone.setStart(radius.getStart());
    clone.setEnd(radius.getEnd());
    this.radius = clone;
  }
  public double getX() {
    return x;
  }
  public double getY() {
    return y;
  }
  public Radius getRadius() {
    return radius;
  }
}

这一次,Point类的不变性级别增加了(调用r.setStart(5)不会影响radius字段,因为该字段是r的克隆)。但是Point类并不是完全不可变的,因为还有一个问题需要解决,从不可变类返回可变对象会破坏不可变性。检查下面的代码,它分解了Point的不变性:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);
Point p = new Point(1.23, 4.12, r);
System.out.println("Radius start: " + p.getRadius().getStart()); // 0
p.getRadius().setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

再次调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变。解决方案包括修改getRadius()方法以返回radius字段的克隆,如下所示:

...
public Radius getRadius() {
    Radius clone = new Radius();
    clone.setStart(this.radius.getStart());
    clone.setEnd(this.radius.getEnd());
    return clone;
  }
...

现在,Point类又是不可变的。问题解决了!


在选择克隆技术/工具之前,在某些情况下,建议您花点时间分析/学习 Java 和第三方库中可用的各种可能性(例如,检查本章中的”克隆对象“部分)。对于浅拷贝,前面的技术可能是正确的选择,但是对于深拷贝,代码可能需要依赖不同的方法,例如复制构造器、Cloneable接口或外部库(例如,Apache Commons LangObjectUtils、JSON 序列化与Gson或 Jackson,或任何其他方法)。



相关文章
|
16天前
|
存储 Java
Java中判断一个对象是否是空内容
在 Java 中,不同类型的对象其“空内容”的定义和判断方式各异。对于基本数据类型的包装类,空指对象引用为 null;字符串的空包括 null、长度为 0 或仅含空白字符,可通过 length() 和 trim() 判断;集合类通过 isEmpty() 方法检查是否无元素;数组的空则指引用为 null 或长度为 0。
|
1月前
|
Java
Java快速入门之类、对象、方法
本文简要介绍了Java快速入门中的类、对象和方法。首先,解释了类和对象的概念,类是对象的抽象,对象是类的具体实例。接着,阐述了类的定义和组成,包括属性和行为,并展示了如何创建和使用对象。然后,讨论了成员变量与局部变量的区别,强调了封装的重要性,通过`private`关键字隐藏数据并提供`get/set`方法访问。最后,介绍了构造方法的定义和重载,以及标准类的制作规范,帮助初学者理解如何构建完整的Java类。
|
1月前
|
安全 Java
Object取值转java对象
通过本文的介绍,我们了解了几种将 `Object`类型转换为Java对象的方法,包括强制类型转换、使用 `instanceof`检查类型和泛型方法等。此外,还探讨了在集合、反射和序列化等常见场景中的应用。掌握这些方法和技巧,有助于编写更健壮和类型安全的Java代码。
50 17
|
1月前
|
Java
Java中的控制流语句:if、switch、for、foreach、while、do-while
Java中的控制流语句包括条件判断语句 `if`和 `switch`,以及循环语句 `for`、增强型 `for`(`foreach`)、`while`和 `do-while`。这些语句提供了灵活的方式来控制程序的执行流程,确保代码逻辑清晰且易于维护。掌握这些基本语法,对于编写高效和可读的Java程序至关重要。
76 15
|
1月前
|
Java
java代码优化:判断内聚到实体对象中和构造上下文对象传递参数
通过两个常见的java后端实例场景探讨代码优化,代码不是优化出来的,而是设计出来的,我们永远不可能有专门的时间去做代码优化,优化和设计在平时
36 15
|
2月前
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
Java 并发编程——volatile 关键字解析
|
2月前
|
算法 Java 调度
java并发编程中Monitor里的waitSet和EntryList都是做什么的
在Java并发编程中,Monitor内部包含两个重要队列:等待集(Wait Set)和入口列表(Entry List)。Wait Set用于线程的条件等待和协作,线程调用`wait()`后进入此集合,通过`notify()`或`notifyAll()`唤醒。Entry List则管理锁的竞争,未能获取锁的线程在此排队,等待锁释放后重新竞争。理解两者区别有助于设计高效的多线程程序。 - **Wait Set**:线程调用`wait()`后进入,等待条件满足被唤醒,需重新竞争锁。 - **Entry List**:多个线程竞争锁时,未获锁的线程在此排队,等待锁释放后获取锁继续执行。
90 12
|
2月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
239 2
|
5月前
|
存储 Java
Java编程中的对象和类
【8月更文挑战第55天】在Java的世界中,“对象”与“类”是构建一切的基础。就像乐高积木一样,类定义了形状和结构,而对象则是根据这些设计拼装出来的具体作品。本篇文章将通过一个简单的例子,展示如何从零开始创建一个类,并利用它来制作我们的第一个Java对象。准备好让你的编程之旅起飞了吗?让我们一起来探索这个神奇的过程!
46 10
|
5月前
|
存储 Java
Java的对象和类的相同之处和不同之处
在 Java 中,对象和类是面向对象编程的核心。
82 18

热门文章

最新文章