当前位置 : 主页 > 编程语言 > 其它开发 >

判等问题:如何确定程序的判断是正确的?

来源:互联网 收集:自由互联 发布时间:2022-06-11
文章内容摘自与极客时间——《Java 业务开发常见错误 100 例》   判断在我们的代码里随处可见,虽然常见,但是这一行代码处理不当,就可能会出现 Bug,甚至是引起内存泄漏等问题

文章内容摘自与极客时间——《Java 业务开发常见错误 100 例》

  判断在我们的代码里随处可见,虽然常见,但是这一行代码处理不当,就可能会出现 Bug,甚至是引起内存泄漏等问题。判等类 Bug 不太容易发现,可能会被隐藏很久。
  今天就来好好聊一聊判等的问题。

注意 equlas 和 == 的区别

  在业务代码中,我们通常使用 == 和 equals 来进行判等操作。equals 是方法,而 == 则是操作符,两者是有区别的:

  • 对于基础数据类型,比如 int、long、double 等基础数据类型,只能够使用 == 进行判等,他们进行时值判断
  • 对于对象类型, == 比较的是两个对象的直接指针,所以是判断两者在内存中的地址;而 equals 通常是用于比较两个对象的内容。

  上面的这段结论应该是我们都知道的一个结论:比较值的内容,除了基本类型只能使用 == 外,其他类型都需要使用 equals。

  接下来我们通过例子进行说明:

  1. 使用 == 对两个值为 127 的直接赋值的 Integer 对象判等;
  2. 使用 == 对两个值为 128 的直接赋值的 Integer 对象判等;
  3. 使用 == 对一个值为 127 的直接赋值的 Integer 和另一个通过 new Integer 声明的值为 127 的对象判等;
  4. 使用 == 对两个通过 new Integer 声明的值为 127 的对象判等;
  5. 使用 == 对一个值为 128 的直接赋值的 Integer 对象和另一个值为 128 的 int 基本类型判等。
Integer a = 127; //Integer.valueOf(127)
Integer b = 127; //Integer.valueOf(127)
log.info("\nInteger a = 127;\n" +
        "Integer b = 127;\n" +
        "a == b ? {}",a == b);    // true

Integer c = 128; //Integer.valueOf(128)
Integer d = 128; //Integer.valueOf(128)
log.info("\nInteger c = 128;\n" +
        "Integer d = 128;\n" +
        "c == d ? {}", c == d);   //false

Integer e = 127; //Integer.valueOf(127)
Integer f = new Integer(127); //new instance
log.info("\nInteger e = 127;\n" +
        "Integer f = new Integer(127);\n" +
        "e == f ? {}", e == f);   //false

Integer g = new Integer(127); //new instance
Integer h = new Integer(127); //new instance
log.info("\nInteger g = new Integer(127);\n" +
        "Integer h = new Integer(127);\n" +
        "g == h ? {}", g == h);  //false

Integer i = 128; //unbox
int j = 128;
log.info("\nInteger i = 128;\n" +
        "int j = 128;\n" +
        "i == j ? {}", i == j); //true

  在第一个案例中,编译器会把 Integer a = 127 转换为 Integer.valueOf(127)。查看源码可以发现,这个转换其实在内部做了缓存,使得两个 Integer 指向了同一个对象,所以结果是 true。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

  第二个案例是因为 Integer 的默认缓存大小是[-128,127] 数值之间,128 在这个区间之外,但这个其实是可以更改的,设置 JVM 参数加上 -XX:AutoBoxCacheMax=1000 再试试,是不是就返回 true 了呢?
  第三第四个案例是因为 New 出来的对象都是不走缓存的,比较两个新对象,结果肯定不是同一个对象。第五个案例则是因为拆箱的原因,比较的是数值而不是引用。
  看到这人,其实只要记住比较 Integer 去使用 equals 而不是 == 能避免大部分的问题。

String 也会有这种问题

  String 也会出现上述这种问题,我们可以用几个用例来测试一下:

  1. 对两个直接声明的值都为 1 的 String 使用 == 判等;
  2. 对两个 new 出来的值都为 2 的 String 使用 == 判等;
  3. 对两个 new 出来的值都为 3 的 String 先进行 intern 操作,再使用 == 判等;
  4. 对两个 new 出来的值都为 4 的 String 通过 equals 判等。
String a = "1";
String b = "1";
log.info("\nString a = \"1\";\n" +
        "String b = \"1\";\n" +
        "a == b ? {}", a == b); //true

String c = new String("2");
String d = new String("2");
log.info("\nString c = new String(\"2\");\n" +
        "String d = new String(\"2\");" +
        "c == d ? {}", c == d); //false

String e = new String("3").intern();
String f = new String("3").intern();
log.info("\nString e = new String(\"3\").intern();\n" +
        "String f = new String(\"3\").intern();\n" +
        "e == f ? {}", e == f); //true

String g = new String("4");
String h = new String("4");
log.info("\nString g = new String(\"4\");\n" +
        "String h = new String(\"4\");\n" +
        "g == h ? {}", g.equals(h)); //true

  在分析结果之前,你需要知道 java 的常量池机制,设计常量池的初衷是为了节省内存。当代码中出现双引号形式创建的字符串对象时,JVM就会对这个字符串进行检测,如果常量池中已经存在相同内容字符串的引用时,就返回该字符串的引用;否则,创建新的字符串。这种机制叫字符串池化。

  紧接着回到刚才的案例。
  那么第一个案例是通过双引号声明的两个 String 类型的对象,那么因为 java 的字符串池化机制,结果是 true;第二个案例,new 出来的对象引用当然不同,结果是 false;第三个案例,intern方法走的也是常量池机制,所以结果也是 true;第四个案例,通过 equals 对值内容进行判等,是正确的形式,结果是true。

  这里提一下 intern 方法,我的建议是能不用就不要用。首先是该方法我在日常开发中真的很少看到有人去使用该方法,其次是滥用该方法,可能产生性能问题。
  写个代码测试一下:

List<String> list = new ArrayList<>();

@GetMapping("internperformance")
public int internperformance(@RequestParam(value = "size", defaultValue = "10000000")int size) {
    //-XX:+PrintStringTableStatistics
    //-XX:StringTableSize=10000000
    long begin = System.currentTimeMillis();
    list = IntStream.rangeClosed(1, size)
            .mapToObj(i-> String.valueOf(i).intern())
            .collect(Collectors.toList());
    log.info("size:{} took:{}", size, System.currentTimeMillis() - begin);
    return list.size();
}

  在启动程序时设置 JVM 参数 -XX:+PrintStringTableStatistic,程序退出时可以打印出字符串常量表的统计信息。调用接口后关闭程序,输出如下:

[11:01:57.770] [http-nio-45678-exec-2] [INFO ] [.t.c.e.d.IntAndStringEqualController:54  ] - size:10000000 took:44907
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :  10030230 = 240725520 bytes, avg  24.000
Number of literals      :  10030230 = 563005568 bytes, avg  56.131
Total footprint         :           = 804211192 bytes
Average bucket size     :   167.134
Variance of bucket size :    55.808
Std. dev. of bucket size:     7.471
Maximum bucket size     :       198

  intern 操作耗时达到 44 秒。其实,原因在于字符串常量是一个固定容量的 Map。如果容量太小,字符串太多,那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。解决方式是,设置 JVM 参数 -XX:StringTableSize,指定更多的桶。

实现一个 equals 没有那么简单

  如果你看到 Object 的源码,你就会知道 equals 方法实际上用的是 == 进行判断。之所以 Integer 或者 String 这类对象能够进行内容判断,是因为他们重写了 equals 方法。
  我们也是能经常碰到需要自己重写 equals 的场景,写个案例,假设有这样一个描述点的类 Point,有 x、y 和描述三个属性:

class Point {
    private int x;
    private int y;
    private final String desc;

    public Point(int x, int y, String desc) {
        this.x = x;
        this.y = y;
        this.desc = desc;
    }
}

  现在我们希望只要 x 和 y 这 2 个属性一致就代表这是同一个点,所以我们需要重写 equals 方法,而不是用 Object 的原始的 equals。

@Override
public boolean equals(Object o) {
	PointWrong that = (PointWrong) o;
	return x == that.x && y == that.y;
}

  但这样其实还是存在一些小问题,我们安排三个测试案例:

  • 比较一个 Point 对象和 null;
  • 比较一个 Object 对象和一个 Point 对象;
  • 比较两个 x 和 y 属性值相同的 Point 对象。
PointWrong p1 = new PointWrong(1, 2, "a");
try {
    log.info("p1.equals(null) ? {}", p1.equals(null));
} catch (Exception ex) {
    log.error(ex.getMessage());
}

Object o = new Object();
try {
    log.info("p1.equals(expression) ? {}", p1.equals(o));
} catch (Exception ex) {
    log.error(ex.getMessage());
}

PointWrong p2 = new PointWrong(1, 2, "b");
log.info("p1.equals(p2) ? {}", p1.equals(p2));

  通过日志中的结果可以看到,第一次比较出现了空指针异常,第二次比较出现了类型转换异常,第三次比较符合预期输出了 true。
  通过这些失效的案例,我们大概可以总结出

  1. 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
  2. 需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
  3. 需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
  4. 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
      改进后的 equals 就是这样:
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PointRight that = (PointRight) o;
    return x == that.x && y == that.y;
}
hashCode 和 equals 要配对实现

  定义后 equlas 方法后,我们还需要注意一点,比如现在定义两个 x 和 y 属性完全一致的 Point 对象 p1 和 p2,按照改进之后的 equlas 方法,它们一定是一致的,现在把 p1 放进 HashSet,然后判断这个 Set 中是否存在 p2:

PointWrong p1 = new PointWrong(1, 2, "a");
PointWrong p2 = new PointWrong(1, 2, "b");

HashSet<PointWrong> points = new HashSet<>();
points.add(p1);
log.info("points.contains(p2) ? {}", points.contains(p2));

  按理来说,是肯定为 true 的,但结果却是 false。原因很简单,散列表需要使用 hashCode 来定位元素放那个桶。如果自定义对象没有实现自定义 hashCode 方法,就会使用 Object 超类的默认实现,那么得到的 hashcode 就是不同的值(这也是我们面试题里超级常见的一个问题了)。
  要自定义 hashCode,我们可以直接使用 Objects.hash 方法来实现。

注意 compareTo 和 equals 的逻辑一致性

  除了 hashcode 和 equals 方法之外,还有个更容易被我们忽略的问题,即 ompareTo 同样需要和 equals 确保逻辑一致性。
  我之前遇到过这么一个问题,代码里本来使用了 ArrayList 的 indexOf 方法进行元素搜索,但是一位好心的开发同学觉得逐一比较的时间复杂度是 O(n),效率太低了,于是改为了排序后通过 Collections.binarySearch 方法进行搜索,实现了 O(log n) 的时间复杂度。没想到,这么一改却出现了 Bug。
  那么现在来重现一下问题:

@Data
@AllArgsConstructor
class Student implements Comparable<Student>{
    private int id;
    private String name;

    @Override
    public int compareTo(Student other) {
        int result = Integer.compare(other.id, id);
        if (result==0)
            log.info("this {} == other {}", this, other);
        return result;
    }
}

  然后,写一段测试代码分别通过 indexOf 方法和 Collections.binarySearch 方法进行搜索。列表中我们存放了两个学生,第一个学生 id 是 1 叫 zhang,第二个学生 id 是 2 叫 wang,搜索这个列表是否存在一个 id 是 2 叫 li 的学生:

@GetMapping("wrong")
public void wrong(){

    List<Student> list = new ArrayList<>();
    list.add(new Student(1, "zhang"));
    list.add(new Student(2, "wang"));
    Student student = new Student(2, "li");

    log.info("ArrayList.indexOf");
    int index1 = list.indexOf(student);
    Collections.sort(list);
    log.info("Collections.binarySearch");
    int index2 = Collections.binarySearch(list, student);

    log.info("index1 = " + index1);
    log.info("index2 = " + index2);
}

  代码输出的日志如下:

[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:28  ] - ArrayList.indexOf
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:31  ] - Collections.binarySearch
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:67  ] - this CompareToController.Student(id=2, name=wang) == other CompareToController.Student(id=2, name=li)
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:34  ] - index1 = -1
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:35  ] - index2 = 1

  注意到如下几点:

  1. binarySearch 方法内部调用了元素的 compareTo 方法进行比较;
  2. indexOf 的结果没问题,列表中搜索不到 id 为 2、name 是 li 的学生;
  3. binarySearch 返回了索引 1,代表搜索到的结果是 id 为 2,name 是 wang 的学生。

  修复方式很简单,确保 compareTo 的比较逻辑和 equals 的实现一致即可:

@Data
@AllArgsConstructor
class StudentRight implements Comparable<StudentRight>{
    private int id;
    private String name;

    @Override
    public int compareTo(StudentRight other) {
        return Comparator.comparing(StudentRight::getName)
                .thenComparingInt(StudentRight::getId)
                .compare(this, other);
    }
}

  其实,这个问题容易被忽略的原因在于两方面:一方面是我们习惯于用 @Data 来标记对象,也就是默认情况下使用类型所有的字段(不包括 static 和 transient 字段)参与到 equals 和 hashCode 方法的实现中。因为这两个方法的实现不是我们自己实现的,所以容易忽略其逻辑;另一方面,compareTo 方法需要返回数值,作为排序的依据,容易让人使用数值类型的字段随意实现。
  强调一下,,对于自定义的类型,如果要实现 Comparable,请记得 equals、hashCode、compareTo 三者逻辑一致

  最后,如果你想编码时能够及时回避掉这些问题,我建议你在 IDE 中安装阿里巴巴的 Java 规约插件(详见这里),来及时提示我们这类低级错误:
image

网友评论