我们永远不知道面试官为什么能找出这么多奇奇怪怪的场景去比较两个变量的值,但这也的确是基础中的基础。只是如果不好好的了解清楚这块内容,就很有可能在阴沟里翻车,被啪啪打脸。
本文就详细的讲述一下,equals、==和hashCode之间的情感纠纷,基本上大部分变量间的比较都绕不开它们三个。
在阅读本文之前,最好你得彻底的弄懂java中基本类型,尤其是自动拆装箱的场景和常量池之类的。尽管我在本篇文章已经尽量用大白话描述了,但是如果你对基本类型了如指掌,会理解的更容易。
想更全面的了解基本数据类型以及包装类,可以先阅读:Java中的基本数据类型
先说【==】,它的功能就是比较它两边的值是否相同。
【==】其实没那么复杂,它的功能就只是比较两边的值是否相等。只是如果变量是引用类型(Integer、String、Object)的话,比较的就是内存地址,因为引用类型变量存的值就是对象的地址而已。而基本类型(int、double)的变量存的就是它们的值,和内存地址什么的无关。
所以我们在用【==】比较引用类型的变量时,就麻烦了。如果引用类型是Integer、String、Long这种,我们比较它的时候肯定是打算比较它们代表的值,而不是比较什么内存地址。
显然,我们无法改变【==】的功能,无法让它读取引用类型所代表的值去进行比较,所以equals()就出现了。
equals()是Object类的方法,而Object类又是所有类的祖宗(父类),所以所有的引用类型都有equals()方法。
首先我们看Object中的equals()方法:
public boolean equals(Object obj) {
return (this == obj);
}
贼xx简单,就只是用了【==】,这时候可能大家就无语了,这不是脱裤子放屁吗,那我特么为啥不直接用==呢?
因为java中是有重写这一说的,如果是我们自己定义的类,通常不会重写equals()方法。这样的话如果使用了equalst()方法的话,实际上就会调用Object里的这个equals(),和==无异。
但如果是Integer、String这种包装类,它们的源码已经重写过equals()方法了,重写后的equals()就不是简单的调用【==】了,我们可以看看Integer的源码:
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
简单分析下逻辑,首先是判断了要比较的对象是不是Integer的实例,毕竟只有同类才能比较内容嘛,如果是不同类型比较个锤子,先转成同类型再说吧。然后内部获取了该对象的int值。众所周知int是基本类型,所以这个equals的实现原理就是取出两个变量的int值然后进行【==】比较,以达到比较的是值内容而不是内存地址的效果。
其他的包装类String、Long也是有经过重写的,所以它们的equals方法都是比较值的内容而不是内存地址。
如果是基本类型,只能用【==】,它们没有equals方法。
如果是引用类型,直接【==】的话是比较内存地址。如果这个引用类型重写过equals()方法,可以用equals()方法比较内容,如Integer、String……等常用的引用类型都是有重写过euqals()的。
说完【==】和equals,我们还需要了解不同赋值方式也会影响【==】的结果。
int无需多说,基本类型,无论是声明变量还是==比较我们都很清楚了,都只是比较值而已。
Integer的初始化就不太一样了,仔细想想,我们是不是通常Integer a1=3
这样声明的比较多呢?但是大家应该都知道初始化一个对象,应当是Integer a1=new Integer(3)
,因此就会扯出一些问题。
举例:
Integer a1=3;
Integer a2=3;
Integer a3=new Integer(3);
Integer a4=new Integer(3);
System.out.println(a1==a2);
System.out.println(a3==a4);
System.out.println(a1==a3);
结果是true、false,离谱吧,创建了4个3,结果却不一样。相信还有人会好奇:如果是a1==a3呢,这个结果我也先放出来,是false。
由此可以看出,直接将int值赋值给Integer和new初始化的方式是不同的,所以我们必须先了解到int值直接赋给Integer变量这种方式特殊在哪里。
相信学过包装类的会知道,直接将int值赋给Integer就是自动装箱,但是不知道什么是自动装箱也没关系,在这里我先简单的说明一下,后面可以自己去加深这一块内容的学习。
系统会自动将赋给Integer变量的int值封装成一个Integer对象,例如Integer a1=3
,实际上的操作是
Integer a = Integer.valueof(3)
这里有个需要注意的地方,在Integer.valueof()方法源码里,当int的范围在-128——127之间时,会通过一个IntegerCache缓存来创建Integer对象;当int不在该范围时,就直接是new Integer()创建对象,所以会有如下情况:
(1)Integer a = 120;Integer b = 120;a == b
为true
(2)Integer a = 130; Integer b = 130;a == b
为false
只不过加了10,结果就完全不一样了,这就是因为两个Integer变量赋值超过了127,本质上是用了new Integer(),比较内存当然就不一样了。而缓存范围内的数据就只是从Integer缓存里取的相同的值,自然指向的也是相同的地址。
同样的值,不同的赋值方式,总共分三种情况,只需要记住下面这三种,以后就不会有疑问了:
(1)只要比较的任意一方有int
只要是和int值比较,不管另一边是怎样赋值,都会自动拆箱成int类型进行比较,所以只要有一方是int,【==】比较的结果就是true。
(2)两个直接赋值Integer之间比较
因为IntegerCache缓存的缘故,产生了这种情况,当直接赋的值是-128-127之间时,返回为true,因为内存地址实质是相同的。超出这个范围就相当于两个new Integer在比较内存地址,返回false。
(3)剩下的其他情况
剩下的情况就只有两个new Integer()之间、或一个new与一个直接赋值Integer比较了,这种情况都是比较内存地址,并且由于至少有一边是new,所以结果都是false。
其他包装类型直接赋值的异同
还可以举一反三,如果想知道Long、Double类型的直接赋值内部是什么,也可以查看其**valueOf()**方法的源码。
例如Long和Integer是一样的,有一个-128~127的缓存,Double和Float则是没有缓存直接返回new。
下面给出几个样例源码:
Integer的valueOf:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
Double的valueOf:
public static Double valueOf(String s) throws NumberFormatException {
return new Double(parseDouble(s));
}
Boolean的valueOf:
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
然后String也需要特别的说明一下,因为它并不属于基本类型,所以没有int、long那种类型,这种情况我们只需要比较两种情况,直接赋值和new,也就是比较:
String a=new String("haha");
String b="haha";
System.out.println(a==b);
结果是false,因为只要有一边是new的方式初始化的变量,那地址肯定是不一样的,并且这里也是用【==】进行比较地址,自然是false。
字符串常量池
关于String的直接赋值,则需要先说明一下字符串常量池。
String类是我们平时项目中使用的很多的对象类型,jvm为了提升性能和减少内存开销,以及避免字符的重复创建,专门维护了一块字符串常量池的内存空间。当我们需要使用字符串时,就会先去这个常量池中找,如果存在该字符串,就直接将该字符串的地址返回去直接用。如果不存在,则用new进行初始化并将这个字符串投入常量池中以供后面使用。
这个字符串常量池就是通过直接赋值时使用的,所以如果是直接赋值的方式初始化相同内容的String,那么其实都是从同一个常量池里取到字符串,地址指向的是同一个对象,自然结果都是相同的。
说个题外话,通常也是建议使用直接赋值的方式的,因为这样可以节省内存开销。
字符串的拼接比较
还有种特殊的情况,是拼接字符串进行比较。
举个简单的例子:
String a = "hello";
String d = "helloworld";
System.out.println(d == a + "world"); //false
System.out.println(d == "hello" + "world"); //true
如果只看内容,d都是和helloworld进行了比较,但是带有变量的就是false,纯字符串的就是true,这是为什么呢?
其实这跟jvm的编译期有关,在进行编译时,它可以识别出"hello" + “world"这样的字面量和"helloworld"是相同的,认为这是直接的字面量赋值。通过反编译其实可以看出来,编译后,它直接将"hello” + “world"编译成了"helloworld”。所以自然都是在同一个常量池里找,比较起来也是相同的。
而一旦涉及了变量,编译时无法判断这点,也就做不了处理。在启动运行后,就会通过new的方式创建。一旦通过new创建变量,那么地址肯定是不同的。
另外,还有一种情况,就是加了关键字final的字符串变量,它会被视为常量,因为是final不可变的:
final String a = "hello";
String d = "helloworld";
System.out.println(d == a + "world"); //true
这里由于a被视为了常量,所以同样认为是字面量赋值,最终还是在常量池中获取的值,结果就是true了。
总结字符串比较
如果有任一边是通过new赋值的,那么结果肯定是false。
如果两边都是直接赋值的,或是通过final变量进行拼接赋值的,结果是true。只要有一边有涉及非final变量,结果就是false。
hashCode()方法的作用是获取哈希码,也称为散列码,它实际上只是返回一个int整数。
但是它主要是应用在散列表中,如:HashMap,Hashtable,HashSet,在其他情况下一般没啥用,原因后面会说明。
和equal()方法作用类似,hashCode()在java中的也是用于比较两个对象是否相等。我们应该都大概听过一句话:重写equals()方法的话一定要重写hashCode()。但是从来也不知道是为啥,这里就说明一下这点。
分两种情况:
①首先一种情况是确定了不会创建散列表的类,我们不会创建这个类的HashSet、HashMap集合之类的。这种情况下,这个类的hashCode()和equals() 完全没有任何关系,当我们比较这个类的两个对象时,直接用的就是equals(),完全用不上hashCode()。自然,也没啥必要重写。
②另一种情况自然就是可能会需要使用到散列表的类了,这里hashCode()和equals()就比较有关系了:
在散列表的使用中,经常需要大量且快速的对比,例如你每次插入新的键值对,它都必须和前面所有的键值对进行比较,确保你没有插入重复的键。这样如果每个都用equals,可想而知,性能是十分可怕的。如果你的equals再复杂一些,那就凉凉了。
这时候就需要hashCode()了,如果利用hashCode()进行对比,只要生成一个hash值比较数字是否相同就可以了,能显著的提高效率。但是虽然如此,原本的equals()方法还是需要的,hashCode()虽然效率高,可靠性却不足,有时候不同的键值对生成的hashcode也是一样的,这就是哈希冲突。
在使用时,hashCode()将与equals结合使用。虽然hashcode可能会让不同的键产生相同的hashcode,但是它能确保相同的对象返回的hashcode一定是相同的(除非你重写没写好),我们只需要利用后面这点,一样可以提高效率。
在散列表中进行对比时,先比较hashCode(),如果它不相等,那说明两个对象肯定不可能相同,就可以直接进行下一个比较了。但如果hashCode()相同,因为哈希冲突的缘故我们无法直接判断两个对象是相同的,就必须继续比较equals()来获取可靠的结果。
注(怕大家看不懂因果逻辑,简单说明下):
因为相同对象一定是相同hashcode,所以相同对象一定不会有不同的hashcode,所以两个对象如果是不同的hashcode,那么这两个对象一定是不同的。
所以如果这个类可能会创建散列表的情况下,重写了equals方法,就必须重写配套的hashcode方法,他们两个在散列表中是搭配使用的。
核心是保证相同的对象能返回相同的hash值,尽量做到不同的对象返回不同的hash值。
这点可难可易,主要能保证核心的规则即可。例如Integer的hashcode就很简单粗暴,直接返回它所代表的的value值。也就是1的hashcode还是1,100的hashcode还是100。
但是这样也是符合核心规则的:相同的对象,绝壁是相同的hashcode。
所以实现起来按照这个规则做,就没问题了。
再夸张点举个例子,哪怕你hashcode固定返回1,不管是谁都返回1,那它也是符合这个规则的。
只是它没有尽量做到第二个规则:不同的对象返回不同的hash值。
但是它还是可以正常的用,不影响,因为我们在散列表中不会取到有问题的数据。它因为全部都是相同的hashcode,所以每次比较都会比较到equals而已。只是性能慢了,但是不会有错误数据,所以可以这样用。
这里提供一些例子,希望能让大家产生一种解题思路一样的东西
Integer a = 1;
Integer b = 2;
Integer c = 3;
1.c==(a+b)
结果是true,因为a+b必然要自动拆箱,变成int值3,然后Integer和int比较又会拆箱一次,所以本质上最终是两个int数据3比较。
Integer d = 2;
Integer e = 2;
Integer f = 200;
Integer g = 200;
2.d==e和f==g
d==e是true,f==g却是false,首先==的两边都是对象类型,所以不会拆箱,而是比较内存地址。而因为-128到127有缓存的对象,所以在赋值给d和e自动装箱时调用的不是new构造方法,而是直接读取了缓存里的值为2的对象,并赋给d和e,所以它们是相等的。而200已经超出了缓存范围,所以本质上是调用了new Integer()新建了对象,自然内存地址不同。
Long h = 3L;
Long i = 2L;
3.h==(a+b)
结果是true,虽然h是Long类型,但经过a+b的拆箱运算后,本质是Long类型数据3和int基本数据3比较,然后Long拆箱变成long类型比较int类型,再根据不同基本类型的自动转换,int转换成long,最后就是两个long的基本数据3L进行比较了。
4.h.equals(a+b)
结果是false,这里是调用了equals方法,先经过a+b拆箱运算,等价于h.equals(3),通过看equals源码,基本上如果是不同包装类间比较的话,都是直接返回false,并没有想象中会那么智能的进行转换哦。如果是同类型包装类型,就拆箱后比内容。
5.h.equals(a+i)
结果是true,这里和上面一问不同的是,它里面是Integer+Long,通过拆箱就是long+int,会自动转换为高级的long类型,所以里面实际上是h.equals(3L),是同种包装类型,会拆箱比较值。
Integer j = new Integer(5);
Integer k = new Integer(4);
Integer m = new Integer(1);
6.j==k+m
结果是true,虽然3个都是new的对象,==按理说是比较地址,肯定是不一样的,但是由于k+m涉及运算,会自动拆箱计算结果,这样实质上就是j==5,左边是对象右边是基本类型5,这样结果就明了了。
一个小小的比较是否相等,也能搞出这么多门道。这也说明了基础的重要性,平时我们在工作中可能就算不懂它们的区别,随便使用,也可以用的好好的,至少表面不会有什么问题。但这都是潜在的隐患,只有认真学过的人,才能发现这些隐患。
参考资料:
1.Java中Int、Integer、new Integer()之间的区别
https://blog.csdn.net/yuncaidaishu/article/details/100079364
2.Java:String类型为什么可以直接赋值?使用new String赋值不可以吗?
https://blog.csdn.net/qq_43437122/article/details/105806
3.深入理解Java中的String
https://www.cnblogs.com/xiaoxi/p/6036701.html