如果有人问你,在 Java 中,== 和 equals() 的区别是什么?恐怕大多数人都会回答,== 用来比较地址,equals() 是用来比较内容的吧,要是直接这么回答,可就错了。
1 equals 方法
不信?请看下面这段代码:
1 | StringBuilder sb1 = new StringBuilder(); |
实际运行后发现输出的结果是 false。是不是一脸懵?sb1 和 sb2 的构造方法明明一模一样,内容也是一样的,怎么使用 equals() 比较的结果却是 false 呢?以前用 String 的时候比较过很多次,都是行得通的,这里怎么不一样呢?没错,String 用 equals() 比较内容确实是可以的:
1 | String s1 = new String("Hello"); |
为什么会这样呢?同样是 equals() 方法,同样都是比较相同的内容,怎么得到的结果却不一样呢?这个时候有必要来看一下 Object 的 equals() 方法的实现:
1 | public boolean equals(Object obj) { |
是的,Object 的 equals() 方法就是使用 == 来比较对象和传入的参数的。
那么,再看一下 String 类的 equals() 方法的实现:
1 | public boolean equals(Object anObject) { |
它这里就去比较内容了。
我们再去看看 StringBuilder 类的源码,就会发现它没有 equals() 方法,而是直接继承了 Object 的实现,使用的是 == 来作比较。Object 的 equals() 是一个实例方法(非 static),它可以被子类重写去实现自己想要的行为,因此,不能轻易的说 equals() 就是比较内容的,其行为是特定于实现的。但 == 确实是比较地址的,因为 Java 中不支持(至少现在不支持)运算符重载,我们不能改变 == 的含义,其行为是固定死的。
所以,记得下次不要说「== 比较地址,equals 比较内容」这样的话了,如果要说,也要在前面加上特定的条件。正确说法为:== 比较地址(固定的);但是 equals 要分情况讨论,对于 String 类型来说,equals 比较的是内容,但对于其他没有重写 equals 方法的类型来说,equals 比较的是地址 / 对象的引用。
如果重写了 equals() 方法,那么常常也需要考虑是否需要重写 hashCode() 方法,hashCode() 方法也由 Object 类提供,不过它是一个 native(本地)方法。通常情况下,hashCode 值是对象头部的一部分二进制位组成的数字,对对象具有一定的标识意义,但绝不等价于地址。它的作用是为了快速查找,帮助我们快速定位数据,与两个对象是否一致一点关系都没有。
只不过有的时候 hashCode() 和 equals() 方法会同时出现,在哈希存储结构中(比如 HashMap),添加元素前会进行 key 的重复性校验,方式就是先获取 key 的 hashCode 值,用来确定在散列结构中的存储位置,然后用 equals() 判断值是否存在。其实,当对象不用来创建像 HashMap、HashSet 这样的散列表时,实际上用不到 hashCode()。
2 分析 java.lang.String
首先大家知道,String 既可以作为一个对象来使用,又可以作为一个基本类型来使用。这里说的作为一个基本类型来使用只是指使用方法上的,比如 String s = “Hello”,它的使用方法和基本类型 int 一样,比如 int i = 1,而作为一个对象来使用,则是指通过 new 关键字来创建一个新对象,比如 String s = new String(“Hello”)。现在对 String 对象的创建做一些具体的分析:
2.1 代码段1
1 | String s1 = new String("Hello"); |
这两个输出结果应该好理解,两个 String 类型的变量 s1 和 s2 都通过 new 关键字分别创建了一个新的 String 对象,而 new 关键字为创建的每个对象都在堆中分配一块新的、独立的内存。因此通过 == 来比较的结果是 false,通过 equals() 来比较的结果是 true。
2.2 代码段2
1 | String s1 = new String("Hello"); |
这个结果应该更好理解,s1 还是通过 new 关键字创建了一个新的 String 对象,但 s2 不是,而是直接把 s1 赋值给了 s2,即把 s1 的引用赋值给了 s2,所以 s2 所引用的对象其实就是 s1 所引用的对象,自己跟自己作比较,怎么比都是 true。
2.3 代码段3
1 | String s1 = "Hello"; |
为什么是这个结果?首先这两个 String 对象都是作为一个基本类型来使用的,而不是通过 new 关键字来创建,因此虚拟机不会为这两个 String 对象分配新的堆内存,而是会到 String 常量池中寻找。
首先为 s1 寻找,String 常量池内是否有与 “Hello” 相同值的 String 对象存在,此时 String 常量池内是空的,没有相同值的 String 对象存在,所以虚拟机会在 String 常量池内创建此 String 对象,其动作就是 new String(“Hello”),然后把此 String 对象的引用赋值给 s1。
接着为 s2 寻找,String 常量池内是否有与 “Hello” 相同值的 String 对象存在,此时虚拟机找到了一个与其值相同的 String 对象,这个 String 对象其实就是前面为 s1 所创建的 String 对象。既然找到了一个相同值的对象,那么虚拟机就不再需要为此创建新的 String 对象,而是直接把存在的 String 对象的引用赋值给 s2。
所以 s1 和 s2 所引用的 String 对象其实是同一个,自己和自己作比较,结果自然都是 true。
2.4 代码段4
1 | String s1 = "Hello"; |
s1 把 String 作为一个基本类型来使用,它会去 String 常量池中找,此时 String 常量池内并没有与其值相同的 String 对象存在,因此虚拟机会为此创建一个新的 String 对象;s2 把 String 作为一个对象来使用,通过 new 关键字为它分配了一块新的堆内存,是独立于 String 常量池的。因此 == 的比较结果是 false,equals() 的比较结果是 true。
2.5 小结
如果把 String 作为一个基本类型来用,则视此 String 对象是在 String 常量池中,如果 String 常量池内不存在与其指定值相同的 String 对象,那么此时虚拟机将为此创建新的 String 对象,并存放在 String 常量池内;反之,如果存在,则直接返回已存在的 String 对象的引用。
如果把 String 作为一个对象来使用,那么虚拟机将为此创建一个新的 String 对象,即为此对象分配一块新的堆内存,并且它并不是 String 常量池所拥有的,它们是相互独立存在的。
2.6 小测试
String s = new String(“abc”); 创建了几个对象?
答案:2 个或者 1 个,过程是这样的:当代码中出现 “abc” 这个字符串字面量时,JVM 就会在字符串常量池中查找是否已经存在内容为 “abc” 的字符串对象,如果不存在,则创建;接着 new String() 这部分代码又会在堆内存中创建出一个新的 String 对象(两者创建对象的区域不同,在特定情况下可能会导致内存浪费,通常推荐使用字符串字面量的方式,即 String s = “abc”;)。
String s = “abc”; 创建了几个对象?
答案:1 个或者 0 个。
String a = “abc”; String b = “abc”; 创建了几个对象?
答案:1 个或者 0 个。
String s = “ab” + “cd”; 创建了几个对象?
答案:最多的时候创建 3 个,所以平时尽量少用字符串拼接。
System.out.println(3 + 22.06 + “” + 2.45 + 4 * 2); 输出的结果是什么?
答案:”25.062.458”,这与运算符的优先级和类型转换规则有关,先乘除后加减,从左至右进行,当数值与字符串进行拼接时,数值会被自动转换为字符串。
2.7 StringBuilder 和 StringBuffer
String:不可变,是线程安全的。也就是说,一旦创建了一个 String 对象,其内容就不能被改变,任何对 String 的修改实际上都会创建一个新的 String 对象,这可能会导致大量的内存分配和垃圾回收操作,从而影响性能。适用于不需要修改字符串内容的场景,如常量字符串、配置信息等。
StringBuffer:可变,是线程安全的,修改的是 StringBuffer 对象本身。
StringBuilder:可变,不是线程安全的,修改的是 StringBuilder 对象本身。
2.8 字符串常量池和运行时常量池
字符串常量池:Java7 后也挪到了堆中,但跟创建对象的区域是独立开的,专门用于存储字符串字面量和通过 intern() 方法加入的字符串对象。其目的是避免重复创建相同的字符串对象,节省内存并提高性能。每当遇到一个字符串字面量时,JVM 会首先检查该字符串是否已经存在于字符串常量池中,如果已存在,就返回该字符串的引用;如果不存在,就将该字符串加入常量池并返回引用。
运行时常量池:位于方法区 / 元空间中,包含类或接口的常量值表,如字面量、方法名、字段名、描述符等,它从 .class 文件中加载,不仅包括编译器生成的常量,还可能包括在运行时动态生成的常量。
3 String 对象的 intern 方法
intern() 方法将返回一个跟调用此方法的字符串对象内容相同的字符串对象,只是它来自于 String 常量池。这听起来有点拗口,换句话说,通过 intern() 方法可以将堆上的字符串对象添加到字符串常量池中,以便在需要时重用这些对象,从而减少内存占用。它的机制有如以下代码段:
1 | String s = new String("Hello"); |
其功能实现可以简单的看成为:
1 | String s = "Hello"; |
那么第一段代码的意思又是什么呢?我们知道通过 new 关键字所创建出的对象,虚拟机会为它分配一块新的堆内存,如果连续创建 10 个相同内容的 String 对象 new String(“Hello”),那么虚拟机将为此分配 10 块独立的堆内存,假设所创建的 String 对象的内容十分大,比如一个 String 对象封装了 1M 大小的字符串内容,那么我们将毫无意义的浪费掉 9M 的内存空间(String 是 final 类,它所封装的是字符串常量,对象创建后其内部值不可改变)。
如果可以只创建一个 String 对象,然后能共享给其它 String 变量就好了,最简单有效的实现方式就是使用 String 常量池,因为 String 常量池中的 String 对象都是唯一的,而 intern() 方法就是使用字符串常量池的途径。在一个已实例化的 String 对象上调用 intern() 方法后,虚拟机会在 String 常量池中寻找与此 String 对象内容相同的 String 对象,找不到的话就创建,然后把字符串常量池中的引用赋值给一个 String 变量。
这样就达到了共享同一个 String 对象的目的,而原先那个通过 new 关键字所创建出的 String 对象将被抛弃并被垃圾回收器回收掉,这样不但节省了内存,而且在 String 对象的比较上也更方便了,因为 String 对象被共享,所以要判断两个 String 对象的内容是否相同,只需使用 == 来比较即可,而无需再使用 equals() 比较,如此一来性能也提高了,因为 equals() 方法会拆解字符串的内容逐个进行比较,在字符串内容很大的情况下性能较低。
下面举一个简单的例子:
假设有一个类的方法,用来记录用户传来的消息(假设消息内容较大,并且重复率较高),这些消息按接收顺序存储在一个列表中。我想可能会这样设计:
1 | public class Message { |
这种设计方案好吗?假设不同的用户重复的发送同一个消息,并且消息内容较大,那么这个设计将会大大浪费内存空间,因为即使它们的内容都相同,记录的时候也会重新创建新的、独立的 String 对象。所以可以优化一下:
1 | public class Message { |
此例只为阐述概念,实际开发时可能会有差异!
至此,String 对象的迷雾都被消除了。