文章内容摘自与极客时间——《Java 业务开发常见错误 100 例》
判断在我们的代码里随处可见,虽然常见,但是这一行代码处理不当,就可能会出现 Bug,甚至是引起内存泄漏等问题。判等类 Bug 不太容易发现,可能会被隐藏很久。
今天就来好好聊一聊判等的问题。
在业务代码中,我们通常使用 == 和 equals 来进行判等操作。equals 是方法,而 == 则是操作符,两者是有区别的:
- 对于基础数据类型,比如 int、long、double 等基础数据类型,只能够使用 == 进行判等,他们进行时值判断
- 对于对象类型, == 比较的是两个对象的直接指针,所以是判断两者在内存中的地址;而 equals 通常是用于比较两个对象的内容。
上面的这段结论应该是我们都知道的一个结论:比较值的内容,除了基本类型只能使用 == 外,其他类型都需要使用 equals。
接下来我们通过例子进行说明:
- 使用 == 对两个值为 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
在第一个案例中,编译器会把 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 也会出现上述这种问题,我们可以用几个用例来测试一下:
- 对两个直接声明的值都为 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就会对这个字符串进行检测,如果常量池中已经存在相同内容字符串的引用时,就返回该字符串的引用;否则,创建新的字符串。这种机制叫字符串池化。
紧接着回到刚才的案例。
那么第一个案例是通过双引号声明的两个 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。
通过这些失效的案例,我们大概可以总结出
- 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
- 需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
- 需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
- 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
改进后的 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 方法来实现。
除了 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
注意到如下几点:
- binarySearch 方法内部调用了元素的 compareTo 方法进行比较;
- indexOf 的结果没问题,列表中搜索不到 id 为 2、name 是 li 的学生;
- 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 规约插件(详见这里),来及时提示我们这类低级错误: