Day631.判等问题 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day631.判等问题 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

判等问题

Hi,阿昌来也,

今天学习记录的是equalscompareToJava 的数值缓存、字符串驻留等问题展开讨论.

一、equals 和 == 的区别

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

  • 对基本类型,比如 int、long,进行判等,只能使用 ==,比较的是直接值。因为基本类型的值就是其数值。
  • 对引用类型,比如 Integer、Long 和 String,进行判等,需要使用 equals 进行内容判等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。

这就引出了我们必须必须要知道的第一个结论:

比较值的内容,除了基本类型只能使用 == 外,其他类型都需要使用 equals

在开篇我提到了,即使使用 == 对 Integer 或 String 进行判等,有些时候也能得到正确结果。

这又是为什么呢?我们用下面的测试用例深入研究下:

  • 使用 == 对两个值为 127 的直接赋值的 Integer 对象判等;
  • 使用 == 对两个值为 128 的直接赋值的 Integer 对象判等;
  • 使用 == 对一个值为 127 的直接赋值的 Integer 和另一个通过 new Integer 声明的值为 127 的对象判等;
  • 使用 == 对两个通过 new Integer 声明的值为 127 的对象判等;
  • 使用 == 对一个值为 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

通过运行结果可以看到,虽然看起来永远是在对 127 和 127、128 和 128 判等,但 == 却没有永远给我们 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);

第二个案例中,之所以同样的代码 128 就返回 false 的原因是,默认情况下会缓存[-128, 127]的数值,而 128 处于这个区间之外。

设置 JVM 参数加上 -XX:AutoBoxCacheMax=1000 再试试,是不是就返回 true 了呢?

private static class IntegerCache 
    static final int low = -128;
    static final int high;


    static 
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) 
            try 
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
             catch( NumberFormatException nfe) 
                // If the property cannot be parsed into an int, ignore it.
            
        
        high = h;


        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);


        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    

第三和第四个案例中,New 出来的 Integer 始终是不走缓存的新对象。比较两个新对象,或者比较一个新对象和一个来自缓存的对象,结果肯定不是相同的对象,因此返回 false。

第五个案例中,我们把装箱的 Integer 和基本类型 int 比较,前者会先拆箱再比较,比较的肯定是数值而不是引用,因此返回 true。

看到这里,对于 Integer 什么时候是相同对象什么时候是不同对象,就很清楚了吧。但知道这些其实意义不大,因为在大多数时候,我们并不关心 Integer 对象是否是同一个,只需要记得比较 Integer 的值请使用 equals,而不是 ==(对于基本类型 int 的比较当然只能使用 ==)。

其实,我们应该都知道这个原则,只是有的时候特别容易忽略。以我之前遇到过的一个生产事故为例,有这么一个枚举定义了订单状态和对于状态的描述:

enum StatusEnum 
    CREATED(1000, "已创建"),
    PAID(1001, "已支付"),
    DELIVERED(1002, "已送到"),
    FINISHED(1003, "已完成");

    private final Integer status; //注意这里的Integer
    private final String desc;

    StatusEnum(Integer status, String desc) 
        this.status = status;
        this.desc = desc;
    

在业务代码中,开发同学使用了 == 对枚举和入参 OrderQuery 中的 status 属性进行判等:

@Data
public class OrderQuery 
    private Integer status;
    private String name;


@PostMapping("enumcompare")
public void enumcompare(@RequestBody OrderQuery orderQuery)
    StatusEnum statusEnum = StatusEnum.DELIVERED;
    log.info("orderQuery: statusEnum: result:", orderQuery, statusEnum, statusEnum.status == orderQuery.getStatus());

因为枚举和入参 OrderQuery 中的 status 都是包装类型,所以通过 == 判等肯定是有问题的。只是这个问题比较隐晦,究其原因在于:

  • 只看枚举的定义 CREATED(1000, “已创建”),容易让人误解 status 值是基本类型;
  • 因为有 Integer 缓存机制的存在,所以使用 == 判等并不是所有情况下都有问题。在这次事故中,订单状态的值从 100 开始增长,程序一开始不出问题,直到订单状态超过 127 后才出现 Bug。

在了解清楚为什么 Integer 使用 == 判等有时候也有效的原因之后,我们再来看看为什么 String 也有这个问题。我们使用几个用例来测试下:

  • 对两个直接声明的值都为 1 的 String 使用 == 判等;
  • 对两个 new 出来的值都为 2 的 String 使用 == 判等;
  • 对两个 new 出来的值都为 3 的 String 先进行 intern 操作,再使用 == 判等;
  • 对两个 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 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;

否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。

再回到刚才的例子,再来分析一下运行结果:

  • 第一个案例返回 true,因为 Java 的字符串驻留机制,直接使用双引号声明出来的两个 String 对象指向常量池中的相同字符串。
  • 第二个案例,new 出来的两个 String 是不同对象,引用当然不同,所以得到 false 的结果。
  • 第三个案例,使用 String 提供的 intern 方法也会走常量池机制,所以同样能得到 true。
  • 第四个案例,通过 equals 对值内容判等,是正确的处理方式,当然会得到 true。

虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码中滥用 intern,可能会产生性能问题。

写代码测试一下,通过循环把 1 到 1000 万之间的数字以字符串形式 intern 后,存入一个 List:

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

可以看到,1000 万次 intern 操作耗时居然超过了 44 秒。其实,原因在于字符串常量池是一个固定容量的 Map。

如果容量太小(Number of buckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。输出结果中的 Average bucket size=167,代表了 Map 中桶的平均长度是 167。

解决方式是,设置 JVM 参数 -XX:StringTableSize,指定更多的桶。设置 -XX:StringTableSize=10000000 后,重启应用:

[11:09:04.475] [http-nio-45678-exec-1] [INFO ] [.t.c.e.d.IntAndStringEqualController:54  ] - size:10000000 took:5557
StringTable statistics:
Number of buckets       :  10000000 =  80000000 bytes, avg   8.000
Number of entries       :  10030156 = 240723744 bytes, avg  24.000
Number of literals      :  10030156 = 562999472 bytes, avg  56.131
Total footprint         :           = 883723216 bytes
Average bucket size     :     1.003
Variance of bucket size :     1.587
Std. dev. of bucket size:     1.260
Maximum bucket size     :        10

可以看到,1000 万次调用耗时只有 5.5 秒,Average bucket size 降到了 1,效果明显。

好了,是时候给出第二原则了:

没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标。

二、实现一个 equals 没有这么简单

如果看过 Object 类源码,你可能就知道,equals 的实现其实是比较对象引用:

public boolean equals(Object obj) 
    return (this == obj);

之所以 Integer 或 String 能通过 equals 实现内容判等,是因为它们都重写了这个方法。比如,String 的 equals 的实现:

public boolean equals(Object anObject) 
    if (this == anObject) 
        return true;
    
    if (anObject instanceof String) 
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) 
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) 
                if (v1[i] != v2[i])
                    return false;
                i++;
            
            return true;
        
    
    return false;

对于自定义类型,如果不重写 equals 的话,默认就是使用 Object 基类的按引用的比较方式。

我们写一个自定义类测试一下。假设有这样一个描述点的类 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;
    

定义三个点 p1、p2 和 p3,其中 p1 和 p2 的描述属性不同,p1 和 p3 的三个属性完全相同,并写一段代码测试一下默认行为:

Point p1 = new Point(1, 2, "a");
Point p2 = new Point(1, 2, "b");
Point p3 = new Point(1, 2, "a");
log.info("p1.equals(p2) ? ", p1.equals(p2));
log.info("p1.equals(p3) ? ", p1.equals(p3));

通过 equals 方法比较 p1 和 p2、p1 和 p3 均得到 false,原因正如刚才所说,我们并没有为 Point 类实现自定义的 equals 方法,Object 超类中的 equals 默认使用 == 判等,比较的是对象的引用。

我们期望的逻辑是,只要 x 和 y 这 2 个属性一致就代表是同一个点,所以写出了如下的改进代码,重写 equals 方法,把参数中的 Object 转换为 Point 比较其 x 和 y 属性:

class PointWrong 
    private int x;
    private int y;
    private final String desc;

    public PointWrong(int x, int y, String desc) 
        this.x = x;
        this.y = y;
        this.desc = desc;
    

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

为测试改进后的 Point 是否可以满足需求,我们定义了三个用例:

  • 比较一个 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。

[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:32  ] - java.lang.NullPointerException
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:39  ] - java.lang.ClassCastException: java.lang.Object cannot be cast to org.geekbang.time.commonmistakes.equals.demo1.EqualityMethodController$PointWrong
[17:54:39.120] [http-nio-45678-exec-1] [INFO ] [t.c.e.demo1.EqualityMethodController:43  ] - p1.equals(p2) ? true

通过这些失效的用例,我们大概可以总结出实现一个更好的 equals 应该注意的点:

考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
需要对

以上是关于Day631.判等问题 -Java业务开发常见错误的主要内容,如果未能解决你的问题,请参考以下文章

AC日记——字符串判等 openjudge 1.7 17

百练-16年9月推免-B题-字符串判等

☀️光天化日学C语言☀️(25)- 浮点数的精度问题 | 浮点数判等千万不要写成 a == b

object.Equals与object.ReferenceEquals方法

CodeForces - 631C

CF Round #631 题解