08 | 判等问题:程序里如何确定你就是你?
下载APP
关闭
渠道合作
推荐作者
08 | 判等问题:程序里如何确定你就是你?
2020-03-26 朱晔 来自北京
《Java业务开发常见错误100例》
课程介绍
讲述:王少泽
时长20:31大小18.79M
你好,我是朱晔。今天,我来和你聊聊程序里的判等问题。
你可能会说,判等不就是一行代码的事情吗,有什么好说的。但,这一行代码如果处理不当,不仅会出现 Bug,还可能会引起内存泄露等问题。涉及判等的 Bug,即使是使用 == 这种错误的判等方式,也不是所有时候都会出问题。所以类似的判等问题不太容易发现,可能会被隐藏很久。
今天,我就 equals、compareTo 和 Java 的数值缓存、字符串驻留等问题展开讨论,希望你可以理解其原理,彻底消除业务代码中的相关 Bug。
注意 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 基本类型判等。
通过运行结果可以看到,虽然看起来永远是在对 127 和 127、128 和 128 判等,但 == 却没有永远给我们 true 的答复。原因是什么呢?
第一个案例中,编译器会把 Integer a = 127 转换为 Integer.valueOf(127)。查看源码可以发现,这个转换在内部其实做了缓存,使得两个 Integer 指向同一个对象,所以 == 返回 true。
第二个案例中,之所以同样的代码 128 就返回 false 的原因是,默认情况下会缓存[-128, 127]的数值,而 128 处于这个区间之外。设置 JVM 参数加上 -XX:AutoBoxCacheMax=1000 再试试,是不是就返回 true 了呢?
第三和第四个案例中,New 出来的 Integer 始终是不走缓存的新对象。比较两个新对象,或者比较一个新对象和一个来自缓存的对象,结果肯定不是相同的对象,因此返回 false。
第五个案例中,我们把装箱的 Integer 和基本类型 int 比较,前者会先拆箱再比较,比较的肯定是数值而不是引用,因此返回 true。
看到这里,对于 Integer 什么时候是相同对象什么时候是不同对象,就很清楚了吧。但知道这些其实意义不大,因为在大多数时候,我们并不关心 Integer 对象是否是同一个,只需要记得比较 Integer 的值请使用 equals,而不是 ==(对于基本类型 int 的比较当然只能使用 ==)。
其实,我们应该都知道这个原则,只是有的时候特别容易忽略。以我之前遇到过的一个生产事故为例,有这么一个枚举定义了订单状态和对于状态的描述:
在业务代码中,开发同学使用了 == 对枚举和入参 OrderQuery 中的 status 属性进行判等:
因为枚举和入参 OrderQuery 中的 status 都是包装类型,所以通过 == 判等肯定是有问题的。只是这个问题比较隐晦,究其原因在于:
只看枚举的定义 CREATED(1000, “已创建”),容易让人误解 status 值是基本类型;
因为有 Integer 缓存机制的存在,所以使用 == 判等并不是所有情况下都有问题。在这次事故中,订单状态的值从 100 开始增长,程序一开始不出问题,直到订单状态超过 127 后才出现 Bug。
在了解清楚为什么 Integer 使用 == 判等有时候也有效的原因之后,我们再来看看为什么 String 也有这个问题。我们使用几个用例来测试下:
对两个直接声明的值都为 1 的 String 使用 == 判等;
对两个 new 出来的值都为 2 的 String 使用 == 判等;
对两个 new 出来的值都为 3 的 String 先进行 intern 操作,再使用 == 判等;
对两个 new 出来的值都为 4 的 String 通过 equals 判等。
在分析这个结果之前,我先和你说说 Java 的字符串常量池机制。首先要明确的是其设计初衷是节省内存。当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。
再回到刚才的例子,再来分析一下运行结果:
第一个案例返回 true,因为 Java 的字符串驻留机制,直接使用双引号声明出来的两个 String 对象指向常量池中的相同字符串。
第二个案例,new 出来的两个 String 是不同对象,引用当然不同,所以得到 false 的结果。
第三个案例,使用 String 提供的 intern 方法也会走常量池机制,所以同样能得到 true。
第四个案例,通过 equals 对值内容判等,是正确的处理方式,当然会得到 true。
虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码中滥用 intern,可能会产生性能问题。
写代码测试一下,通过循环把 1 到 1000 万之间的数字以字符串形式 intern 后,存入一个 List:
在启动程序时设置 JVM 参数 -XX:+PrintStringTableStatistic,程序退出时可以打印出字符串常量表的统计信息。调用接口后关闭程序,输出如下:
可以看到,1000 万次 intern 操作耗时居然超过了 44 秒。
其实,原因在于字符串常量池是一个固定容量的 Map。如果容量太小(Number of buckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。输出结果中的 Average bucket size=167,代表了 Map 中桶的平均长度是 167。
解决方式是,设置 JVM 参数 -XX:StringTableSize,指定更多的桶。设置 -XX:StringTableSize=10000000 后,重启应用:
可以看到,1000 万次调用耗时只有 5.5 秒,Average bucket size 降到了 1,效果明显。
好了,是时候给出第二原则了:没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标。
实现一个 equals 没有这么简单
如果看过 Object 类源码,你可能就知道,equals 的实现其实是比较对象引用:
之所以 Integer 或 String 能通过 equals 实现内容判等,是因为它们都重写了这个方法。比如,String 的 equals 的实现:
对于自定义类型,如果不重写 equals 的话,默认就是使用 Object 基类的按引用的比较方式。我们写一个自定义类测试一下。
假设有这样一个描述点的类 Point,有 x、y 和描述三个属性:
定义三个点 p1、p2 和 p3,其中 p1 和 p2 的描述属性不同,p1 和 p3 的三个属性完全相同,并写一段代码测试一下默认行为:
通过 equals 方法比较 p1 和 p2、p1 和 p3 均得到 false,原因正如刚才所说,我们并没有为 Point 类实现自定义的 equals 方法,Object 超类中的 equals 默认使用 == 判等,比较的是对象的引用。
我们期望的逻辑是,只要 x 和 y 这 2 个属性一致就代表是同一个点,所以写出了如下的改进代码,重写 equals 方法,把参数中的 Object 转换为 Point 比较其 x 和 y 属性:
为测试改进后的 Point 是否可以满足需求,我们定义了三个用例:
比较一个 Point 对象和 null;
比较一个 Object 对象和一个 Point 对象;
比较两个 x 和 y 属性值相同的 Point 对象。
通过日志中的结果可以看到,第一次比较出现了空指针异常,第二次比较出现了类型转换异常,第三次比较符合预期输出了 true。
通过这些失效的用例,我们大概可以总结出实现一个更好的 equals 应该注意的点:
考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
修复和改进后的 equals 方法如下:
改进后的 equals 看起来完美了,但还没完。我们继续往下看。
hashCode 和 equals 要配对实现
我们来试试下面这个用例,定义两个 x 和 y 属性值完全一致的 Point 对象 p1 和 p2,把 p1 加入 HashSet,然后判断这个 Set 中是否存在 p2:
按照改进后的 equals 方法,这 2 个对象可以认为是同一个,Set 中已经存在了 p1 就应该包含 p2,但结果却是 false。
出现这个 Bug 的原因是,散列表需要使用 hashCode 来定位元素放到哪个桶。如果自定义对象没有实现自定义的 hashCode 方法,就会使用 Object 超类的默认实现,得到的两个 hashCode 是不同的,导致无法满足需求。
要自定义 hashCode,我们可以直接使用 Objects.hash 方法来实现,改进后的 Point 类如下:
改进 equals 和 hashCode 后,再测试下之前的四个用例,结果全部符合预期。
看到这里,你可能会觉得自己实现 equals 和 hashCode 很麻烦,实现 equals 有很多注意点而且代码量很大。不过,实现这两个方法也有简单的方式,一是后面要讲到的 Lombok 方法,二是使用 IDE 的代码生成功能。IDEA 的类代码快捷生成菜单支持的功能如下:
注意 compareTo 和 equals 的逻辑一致性
除了自定义类型需要确保 equals 和 hashCode 要逻辑一致外,还有一个更容易被忽略的问题,即 compareTo 同样需要和 equals 确保逻辑一致性。
我之前遇到过这么一个问题,代码里本来使用了 ArrayList 的 indexOf 方法进行元素搜索,但是一位好心的开发同学觉得逐一比较的时间复杂度是 O(n),效率太低了,于是改为了排序后通过 Collections.binarySearch 方法进行搜索,实现了 O(log n) 的时间复杂度。没想到,这么一改却出现了 Bug。
我们来重现下这个问题。首先,定义一个 Student 类,有 id 和 name 两个属性,并实现了一个 Comparable 接口来返回两个 id 的值:
然后,写一段测试代码分别通过 indexOf 方法和 Collections.binarySearch 方法进行搜索。列表中我们存放了两个学生,第一个学生 id 是 1 叫 zhang,第二个学生 id 是 2 叫 wang,搜索这个列表是否存在一个 id 是 2 叫 li 的学生:
代码输出的日志如下:
我们注意到如下几点:
binarySearch 方法内部调用了元素的 compareTo 方法进行比较;
indexOf 的结果没问题,列表中搜索不到 id 为 2、name 是 li 的学生;
binarySearch 返回了索引 1,代表搜索到的结果是 id 为 2,name 是 wang 的学生。
修复方式很简单,确保 compareTo 的比较逻辑和 equals 的实现一致即可。重新实现一下 Student 类,通过 Comparator.comparing 这个便捷的方法来实现两个字段的比较:
其实,这个问题容易被忽略的原因在于两方面:
一是,我们使用了 Lombok 的 @Data 标记了 Student,@Data 注解(详见这里)其实包含了 @EqualsAndHashCode 注解(详见这里)的作用,也就是默认情况下使用类型所有的字段(不包括 static 和 transient 字段)参与到 equals 和 hashCode 方法的实现中。因为这两个方法的实现不是我们自己实现的,所以容易忽略其逻辑。
二是,compareTo 方法需要返回数值,作为排序的依据,容易让人使用数值类型的字段随意实现。
我再强调下,对于自定义的类型,如果要实现 Comparable,请记得 equals、hashCode、compareTo 三者逻辑一致。
小心 Lombok 生成代码的“坑”
Lombok 的 @Data 注解会帮我们实现 equals 和 hashcode 方法,但是有继承关系时,Lombok 自动生成的方法可能就不是我们期望的了。
我们先来研究一下其实现:定义一个 Person 类型,包含姓名和身份证两个字段:
对于身份证相同、姓名不同的两个 Person 对象:
使用 equals 判等会得到 false。如果你希望只要身份证一致就认为是同一个人的话,可以使用 @EqualsAndHashCode.Exclude 注解来修饰 name 字段,从 equals 和 hashCode 的实现中排除 name 字段:
修改后得到 true。打开编译后的代码可以看到,Lombok 为 Person 生成的 equals 方法的实现,确实只包含了 identity 属性:
但到这里还没完,如果类型之间有继承,Lombok 会怎么处理子类的 equals 和 hashCode 呢?我们来测试一下,写一个 Employee 类继承 Person,并新定义一个公司属性:
在如下的测试代码中,声明两个 Employee 实例,它们具有相同的公司名称,但姓名和身份证均不同:
很遗憾,结果是 true,显然是没有考虑父类的属性,而认为这两个员工是同一人,说明 @EqualsAndHashCode 默认实现没有使用父类属性。
为解决这个问题,我们可以手动设置 callSuper 开关为 true,来覆盖这种默认行为:
修改后的代码,实现了同时以子类的属性 company 加上父类中的属性 identity,作为 equals 和 hashCode 方法的实现条件(实现上其实是调用了父类的 equals 和 hashCode)。
重点回顾
现在,我们来回顾下对象判等和比较的重点内容吧。
首先,我们要注意 equals 和 == 的区别。业务代码中进行内容的比较,针对基本类型只能使用 ==,针对 Integer、String 在内的引用类型,需要使用 equals。Integer 和 String 的坑在于,使用 == 判等有时也能获得正确结果。
其次,对于自定义类型,如果类型需要参与判等,那么务必同时实现 equals 和 hashCode 方法,并确保逻辑一致。如果希望快速实现 equals、hashCode 方法,我们可以借助 IDE 的代码生成功能,或使用 Lombok 来生成。如果类型也要参与比较,那么 compareTo 方法的逻辑同样需要和 equals、hashCode 方法一致。
最后,Lombok 的 @EqualsAndHashCode 注解实现 equals 和 hashCode 的时候,默认使用类型所有非 static、非 transient 的字段,且不考虑父类。如果希望改变这种默认行为,可以使用 @EqualsAndHashCode.Exclude 排除一些字段,并设置 callSuper = true 来让子类的 equals 和 hashCode 调用父类的相应方法。
在比较枚举值和 POJO 参数值的例子中,我们还可以注意到,使用 == 来判断两个包装类型的低级错误,确实容易被忽略。所以,我建议你在 IDE 中安装阿里巴巴的 Java 规约插件(详见这里),来及时提示我们这类低级错误:
思考与讨论
在实现 equals 时,我是先通过 getClass 方法判断两个对象的类型,你可能会想到还可以使用 instanceof 来判断。你能说说这两种实现方式的区别吗?
在第三节的例子中,我演示了可以通过 HashSet 的 contains 方法判断元素是否在 HashSet 中,同样是 Set 的 TreeSet 其 contains 方法和 HashSet 有什么区别吗?
有关对象判等、比较,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 35
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
07 | 数据库索引:索引并不是万能药
下一篇
09 | 数值计算:注意精度、舍入和溢出问题
精选留言(25)
- Darren2020-03-26稍微补充一点,可能因为篇幅的原因,老师没提到,关于equals其实还有一个大坑,equals比较的对象除了所谓的相等外,还有一个非常重要的因素,就是该对象的类加载器也必须是同一个,不然equals返回的肯定是false;之前遇到过一个坑:重启后,两个对象相等,结果是true,但是修改了某些东西后,热加载(不用重启即可生效)后,再次执行equals,返回就是false,因为热加载使用的类加载器和程序正常启动的类加载器不同。关于类加载器部分,JDK 9 之前的 Java 应用都是由「启动类加载器」、「扩展类加载器」、「应用程序类加载器」这三种类加载器互相配合来完成加载的,如果有需要还可以加入自定义的类加载器来进行拓展;JDK 9 为了模块化的支持,对双亲委派模式做了一些改动:扩展类加载器被平台类加载器(Platform ClassLoader)取代。平台类加载器和应用程序类加载器都不再继承自 java.net.URLClassLoader,而是继承于 jdk.internal.loader.BuiltinClassLoader。具体细节可以自行搜索。 现在回答下问题: 第一个问题: instanceof进行类型检查规则是:你是该类或者是该类的子类; getClass获得类型信息采用==来进行检查是否相等的操作是严格的判断。不会存在继承方面的考虑; 第二个问题: HashSet本质上就是HashMap的key组成的不重复的元素集合,contains方法其实就是根据hashcode和equals去判断相等的 TreeSet本质TreeMap的key组成的,数据结构是红黑树,是自带排序功能的,可以在放入元素的时候指定comparator(比较器),或者是放入的元素要实现Comparable接口后元素自己实现compareTo方法,contains方法是根据比较器或者compareTo去判断相等展开
作者回复: 👍🏻👍🏻👍🏻 这位同学作为本课课代表 😀
共 27 条评论276 - 👽2020-03-262 . HashSet 底册是HashMap。TreeSet底层是TreeMap HashSet就是使用HashMap调用equals,判断两对象的HashCode是否相等。 TreeSet因为是一个树形结构,则需要考虑数的左右。则需要通过compareTo计算正负值,看最后能否找到compareTo为0的值,找到则返回true。 简单来说,TreeSet底层使用compareTo方法比较,HashSet底层使用hash值比较。展开
作者回复: 👍🏻
22 - Sun2020-03-26老师的课程,真的是干货,每天凌晨更新完看一遍,早上上班前在看一遍,感受都不一样,期待出更多干货,共同进步
作者回复: 设计篇和安全篇还会有更丰富的内容,跟紧脚步,细细品味
11 - 👽2020-03-26问题1: Father father = new Father(); Son son = new Son(); System.out.println(son.getClass()==father.getClass()); System.out.println(son instanceof Father); 打印结果 false true 区别在此,getClass更加严格,而instanceof 子类instanceof 父类,也是true展开9
- yihang2020-03-28另外对于 intern 也有它的用武之处,据说 twitter 使用它减少重复地址(字符串)大量节约了内存7
- 东方奇骥2020-03-26看到这节,说起Lombok,老师觉得Lombok 适合用于生产环境吗?之前一直都是自己业余练习使用,但是工作中项目都还是没有使用。
作者回复: 只要你理解它各种注解会生成怎样的代码,就没问题
4 - pedro2020-03-261楼的回答已经趋于完美,我也翻了一下 JDK 源码,HashSet 的本质是 HashMap,会通过 hash 函数来比较值,TreeSet 的本质是 TreeMap 会通过 compareTo 比较。 至于类加载器的问题,我想这个不好复现,有没有楼下的小伙伴补充一下的。
作者回复: 👍🏻
共 4 条评论4 - 阿郑2020-03-26老师的每一篇文章都是满满的干货呀,手动点赞👍👍👍
作者回复: 觉得好可以多转发分享
3 - 学习使人快乐2020-08-05老师的课程特别好2
- James2020-07-11每一个案例都是独立的SpringBoot或Java命令行应用程序,可以单独启动,避免相互干扰 请问下,为啥我启动的是独立main类为啥还会启动其他无关这个包的类.比如连接redis
作者回复: 依赖是一起的
2 - 失火的夏天2020-03-28hashset和treeSet从根本上来说,没什么关系,只是有个N代以前的祖宗了,哈哈,一个玩hash,一个玩comparator。一个底层是散列表,一个底层是红黑树。2
- Geek_3b10962020-03-27上班前看一遍+12
- Huodefa_04262020-03-26老师,文中你说:在启动程序时设置 JVM 参数 -XX:+PrintStringTableStatistics,程序退出时可以打印出字符串常量表的统计信息。调用接口后关闭程序,输出如下。我设置了关闭程序怎么没看见输出的信息,是输出在控制台还是在日志文件中?如果是文件 是哪个文件?
作者回复: 控制台,确保参数生效了
共 2 条评论2 - 鲁鸣2021-01-15老师好,订单状态的那个没有特别理解,枚举值不是1000多吗?和127的关系是什么呢?共 1 条评论1
- yihang2020-03-28补充一点,浮点数的==比较也有坑,跟浮点数小数精度有关
作者回复: 是的,这个下篇提到了
1 - ✨2020-03-27Collections.sort(list); 也调用了compareTo吧,所以返回下标index2是不是应该等于0?1
- Ethan New2022-12-04 来自浙江生产环境中还是不要使用Lombok为好
- giteebravo2021-12-27如何查看 Lombok 编译后的代码呢?
- 身体健康万事如意2021-07-14Interge 的缓存机制 默认范围[-128,127),可以在jvm修改范围 Integer num = 100; 编译器的执行是 Integer num = Integer.valueof(100); //取缓存 字符串常量池 字符串不使用new时,会向字符串常量池添加新的字符串,如果常量池已存在,则直接引用已有的对象。 如果是new的String对象,可使用intern引用常量池已存在的对象,如果没有则添加。 1.如果重写equals方法,务必根据equals方法比较字段重写hashcode方法(根据hash结构存储的集合,会存储相同的对象) 2.实现comparable接口,务必保持compare,hashcode,equals方法比较字段一致展开
- flyailove2021-01-15基本类型 == 对象类型 equals 基本类型 有缓存池 int 为 [-128 - 127] 可以通过 -XX:AutoBoxCacheMax=1000 设置支持的最大值 字符串 常量池 字符串驻留或池化 字符串常量池 是MAP数据结构 //-XX:+PrintStringTableStatistics 打印池的信息 //-XX:StringTableSize=10000000 设置表的大小 intern 字段不能常用,会有性能问题。展开