1. 前言

判断语句“==”可能是java初学者因为理解不够深入而最容易犯的最常见的错误之一。不久前笔者就在代码中就犯了这个错误。之前已看过==与equals()方法的比较,但是在自己写代码时却已然忘记了,而在对此问题的debug和研究过程中发现竟还附带关联了不少其他问题,一个小小的==判断竟然也有深挖的价值,笔者在感叹之余也决定把这次探险之旅记录下来。

2. 问题发生

问题出现的情形非常常见,笔者试图使用json对象作为http传输的内容,使用了alibaba的jsonObject类,并将一个自定义对象的部分属性赋值至jsonObject对象中,再通过判断传输的json对象的属性值来展开不同的操作,于是写了如下的代码。

JSONObject jsonObject = new JSONObject();
jsonObject.put("command", messageReceive.getCommmand());
if(jsonObject.get("command") == "TERM"){
    doSomething();
}

测试时则发现判断语句jsonObject.get("command")=="TERM"一行的判断结果一直为false,而我为此打印出来jsonObject.get("command")的值又确实是“TERM”字符串。那么,问题究竟出现在哪里呢?

3. 问题排查

3.1 hashCode和identityHashCode

由于当时完全忘记了==和equals()方法的区别,我首先的排查方法是在test文件夹下新建测试类,简单地复现了上面的过程,代码如下:

JSONObject jsonObject = new JSONObject();
jsonObject.put("command", "TERM");
console.log(josnObject.put("command") == "TERM"); //打印结果为true!
if(jsonObject.get("command") == "TERM"){
    doSomething();
}

然而测试的结果竟然为true,这说明在上述代码中jsonObject.put("command")的结果确实是TERM,这让我一度以为我找到了问题的根源,一定是我自定义的类出了问题,可是为什么我使用println输出的结果又确实是目标字符串呢?就在我一愁莫展之际,冥冥之中的天意让我想起来了hashcode这一属性,想起来"=="号,对于非基本类型,实际好像是比较的二者的存储地址?而这个地址好像是基于hashcode属性来判断的?我赶紧将我的test和Source文件夹下生成的这堆东西全部hashcode一下,得到的结果是惊人的,所有的hashcode竟全部相同!一定是哪里出了问题!果然,在度娘的帮助下,我认识了一个更加高级的方法——System.identityHashCode(String s)。对于String类,它重写了String的hashcode()方法,返回值只根据字符串的内容决定。而System.identityHashCode()方法则返回重写前的根据对象存储地址所得的hashcode值!同时也学习到了new关键字声明会产生不同的引用地址,我决定在test文件夹下仔细研究一下新学习到的知识,于是有了如下运行结果。

TERM
============================================
s: String s = new String("TERM");
s2: String s2 = "TERM";
s3: String s3 = "TERM";
s4: String s4 = new String("TERM");
s5: String s5 = jsonObject.get("command");
============================================
s的identity-hashcode:1878246837
s2的identity-hashcode:929338653
s3的identity-hashcode:929338653
s4的identity-hashcode:1259475182
s5的identity-hashcode:929338653
"TERM"的identity-hashcode:929338653
============================================
s的hashcode:2571372
s2的hashcode:2571372
s3的hashcode:2571372
s4的hashcode:2571372
s5的hashcode:2571372
TERM的hashcode:2571372
=============================================
s==s4: false
s2==s3: true
s==s5: false
s2==s5: true
s==s2: false
=============================================

如上面代码结果显示所示,s~s5通过不同的定义方式定义,然后我列出了它们的identity-hashcode和hashcode,并拿不同的字符串进行了比较,结果和我查阅的解释十分地吻合!

  • 通过各种方式得到的内容为“TERM”字符串对象的hashcode值均相同!
  • 通过new关键字声明的s和s4即使内容相同,它们的identity-hashcode也完全不同,说明每次new关键字声明都会生成一个新的引用地址;
  • 直接定义的String对象s2,s3则共享了相同的identity-hashcode,
  • 通过jsonObject.get("command")方法得到的identity-hashcode也和直接定义的s2, s3一致;

对最后一条发现,其实也不难解释,因为jsonObject中定义command属性时的put方法也就是直接使用了“TERM”这个字符串,和直接的String声明实质是一样的,即使将其作为一个属性加入了对象中,也并没有改变该对象的identity-hashcode值 。说明该String对象放入jsonObject时仅仅是一个建立了引用关系。

那么,我的Source源码中判断时报错就应该是...

//MessageRecive.java
...
    this.command = new String(subBytes(startPos, endPos));

果然,我的自定义类中,command属性的值就是通过new String()声明的,因为需要人byte[]中转换过来。到此,一切似乎真相大白,new String()式声明会产生不同的identity-hashcode值,它代表的是对象的存储地址,而==的比较就是通过判断identity-hashcode来比较,而不是比较的String的具体内容。

而这当然不能就此结束,因为此时摆在我们面前的仍有一个关键问题:String str = "value"和String str = new String("value")究竟差异在哪里??

3.2 java内存的粗浅理解

为什么String str = "value"和String str = new String("value")得到的结果会有如此的区别呢,网上学习了一番,发现造成这一结果的差异与java的内存及java编绎运行的原理相关,对于一个java初学者,太高深的底层知识目前还无法理解,直接从源码分析也暂时超出了我的能力,甚至连官方文档的相关说明也看不懂,只能从网上学习大神的解读并用自己能理解的方法稍作简化。

3.2.1 堆,栈和方法区

java的内存分主堆,栈及方法区。其各部分的内容及功能包含如下:

  • 栈:栈用于存放基本类型的数据及变量,线程私有,因此不同线程的变量一般不能共用。栈由一个个栈帧构成,一个方法的一次调用即产生一个栈帧,栈帧中的内容则包括一个局部变量区(存放局部变量和参数)和一个操作数栈(方法的操作台,方法运行时相关变量在操作数栈中压入压出)。注:基本类型的成员变量存储在堆中对应的对象中,而不是在栈中。
  • 堆:用于存放对象和数组的实例,全局共享。
  • 方法区:静态存储区域,包括类的相关信息,常量,静态变量,方法区中还有比较重要的一部分内容就是常量池。常量池可分为全局字符串常量池,class静态常量池,以及运行时常量池。
  • 常量池:

    • class静态常量池。顾名思义,是类加载时生成的常量池,每个类都有私有的常量池,该常量池在编译时产生,包含编译期生成的字面量和符号引用,其中字面量主要就是通过双引号定义的字符串(也包括声明为final的常量)。在编译时,jvm对字符串字面量有一个编译优化的过程,即对于String str = "stu" + "de" + "nt"; 的命令,jvm在编译时会自动将其拼接为一个完整的”student“对象存储在常量池中,而不会存储3个字符串片段。但对于包含变量的拼接,jvm则无法进行这样的优化。
    • 全局字符串常量池。由于字符串是程序中使用较多的对象,而对象实例的创建是一个非常耗费时间和空间的过程。为了提高程序效率,同时借助于字符串的不可改变性,java专门开辟了全局字符串常量池,在类加载时就可确认的字符串对象(一般都来自class静态常量池)加入全局字符串常量池。自jdk1.8及以后,全局字符串常量池是以一个hash表的形式存在,常量池中存储的不是具体的string对象实例,而是对应字符串对象的引用,这个实例被存储在堆中。一般来讲,全局字符串常量池只在类加载时会更新,特别地还可以通过String的intern()方法在类加载完成以后再手动添加字符串至字符串常量池(如果该字符串已经存在则不会添加而是返回当前常量池中的引用地址)。字符串常量池显然是全局共享的。全局字符串有说并不在方法区内,这块目前还未深入理解,但是其位置并不影响整个功能流程的理解。
    • 运行时常量池。指程序运行时的常量池,程序运行时,class静态常量池在类加载时也会将其中的大部分内容会导入到运行时常量池中,因为运行时常量池也是类私有的。对于字符串常量,其在导入运行时常量池时会先查询全局字符串常量池,若字符串常量池中已有,则直接将常量池中的引用替换给运行时常量池,若没有,则在堆中创建该字符串对象,并将其添加至全局字符串常量池,同时返回该引用给自身。需要注意的是,类加载的过程是懒加载(lazy)的,即每个字符串是否会进入运行时or全局字符串常量值只在对应语句需要被执行时才会进行检查和加入到对应常量值中(通过执行ldc命令)。后续程序在运行过程中,如果产生了新的字符串(如通过拼接或打断),将只会存储于运行时常量池,而不会再存储于全局字符串常量池中。

综合上述描述,可以总结java的堆栈及字符串定义的方式如下图。

image style="zoom: 200%;"

上述过程可简单描述如下:

  1. 程序编译时生成的字面量包括”abc“,"abcd"(编译时优化),"ef","gh";(s6的拼接包含new的对象,无法编译优化);
  2. 程序运行时,加载此段程序所在的类,String s1 = "abc",在栈中创建变量s1,此时全局字符串常量池中无内容,在堆中创建”abc“实例,并将此实例地址加入全局字符串常量池”abc“,运行时常量池中"abc"直接指向全局字符串常量池中"abc"对应的地址;
  3. String s2 = "abc",在栈中创建变量s2,此时全局字符串常量池中已有定义,直接复用,将字符串常量池中”abc“地址传递给变量s2;
  4. String s3 = new String("abc"),在栈中创建变量s3,new关键字创建的是一个对象,在堆中开辟空间存储该对象,空间地址指向s3;字面量”abc“在全局变量字符串中已存在,直接拷贝一份至堆中s3指向的存储空间(疑问:此处拷贝是在堆中将值深拷贝一份还是在堆中创建一个新的引用仍然指向原先的字符串实例?);
  5. String s4 = new String("abc"),过程与4相同,new关键字必须开辟一个新的存储空间,创建一个新的对象实例;
  6. String s5 = "ab" + "cd",由于编译优化,相当于执行String s5 = "abcd",过程同1一致;
  7. String s6 = "ef" + new String("gh"),栈中创建变量s6,无法编译优化,加载过程中在常量池中增加“ef”和“gh”,然后运行时通过StringBuilder.append()方法实现连接,所得s6结果为"efgh",在堆中开辟新的空间存储此字符串,注意此时“efgh”并未添加至运行时常量池和全局字符串常量池中;
  8. s6.intern(),执行intern()方法,检查s6的字面量“efgh”发现不在全局字符串常量池中,则在全局字符串常量池中创建该常量,并指向当前对象对应的地址(疑问:此处添加时是否会一并加入到运行时常量池?
3.2.2 String类型的字面量定义和对象定义

字符串的两种定义,直接使用双引号“value”定义的方式叫字面量定义,使用new关键字的中对象定义,两种定义方法由上一节的分析已经比较清晰,此处再重点强调下其过程。

  • 字面量定义,String s = "value",编译时”value“已经存在于class常量池中;在运行阶段,通过类加载,运行到这句话时,发现字符串”value“不在当前全局字符串常量池中,则首先在堆中创建”value"实例,并将实例地址加入全局字符串常量池和运行时常量池,生成一个String实例;
  • 对象定义, String s = new String("value"),编译时”value“存在于class常量池中;在运行阶段,通过类加载,运行到这句话时,首先通过new关键字会在堆中创建一个对象,存放字符串对象s,然后检查”value“字面量,发现字符串”value“不在当前全局字符串常量池中,则在堆中再创建”value"字面量实例,并将实例地址加入全局字符串常量池和运行时常量池,随后拷贝常量池中对应的”value“对象的内容至通过new关键字创建的字符串对象s中,因此共生成了2个String实例,此处存在的疑问是字符串s实例对应的内容是直接拷贝了常量池中的值还是也是通过引用的方式关联至了字面量”value“对应的String实例中。

3.3 ==和equals()方法

根据3.2节的分析可以看出,通过new String()实例化的字符串对象在堆中均指向不同的地址。通过==进行的判断正是比较地址,即比较的结果是2者是否指向同一个对象;而equals()方法则有所不同,对于Object父类,equals()方法与==号完全一致,但是在几种基本类型的包装类和String类中,均重写了该方法,如下:

// Object类中的equals()方法
public boolean equls(Object obj){
    return (this == obj);
}

//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;
}

由String类重写的方法可知,String的equals()方法比较的是两个字符串的值完全相同。

问题到此似乎圆满解决,但是细心的我突然想起来我在测试时曾试图用如下代码接收通过jsonObject.get()方法返回的结果 ,但是代码提示错误:

String command = jsonObject.get("command"); //Reqired is String but Provided is Object

//jsonObject.get();
public Object get(Object key){
    Object val = this.map.get(ket);
    if(val == null && key instanceof Number){
        val = this.map.get(key.toString());
    }
}

//下面语句执行的equals方法不应该是Object的equals方法吗?
jsonObject.get("command").equals("TERM"); //结果为true,说明执行的是String的equals方法!

查看对应代码确实如此,jsonObject.get()方法返回的是一个Object类型,因为其本身就支持你放入的类型可以是各种类型!对么当我对get()方法的结果再使用euqals()方法时,不是应该调用Object类的equals方法吗?毕竟获得的结果可是一个Object对象呀!

3.4 Java的继承与多态

继续研究,这个问题其实很简单,涉及到的正是面向对象编程的核心特质,继承与多态。由于我们在jsonObject中通过put方法放入的"command"属性对应的值实际为一个String对象,其返回给jsonObject的实际只是一个隐式转换后的Object对象。

String value = new String("TERM");
JSONObject jsonObject = new JSONObject();
jsonObject.put("command", value);
/*此处实际为:
Object this.command = vaue = new String("TERM");
*/
Object command = jsonObject.get("command");

这里实际隐含了一个Object command = new String("TERM")的一个多态写法,而且String类重写了Object类的equals()方法,所以Object对象command调用equals()方法时根据多态的特性必定会调用String类的equals()方法。网上查阅的更深入的有关多态方法调用存在一个优先级,这个可以日后再深入挖掘一下。

所以多态机制遵循的原则概括为:当超类对象引用变量引用子类对象时,被引用对象的类型而不是引用变量的类型决定了调用谁的成员方法,但是这个被调用的方法必须是在超类中定义过的,也就是说被子类覆盖的方法,但是它仍然要根据继承链中方法调用的优先级来确认方法,该优先级为:this.show(O)、super.show(O)、this.show((super)O)、super.show((super)O)。

4. 总结

简单总结下本次研究涉及到的若干问题:

  1. hashcode()和System.identityHashCode(),前者在部分类中会被重写,后者则无视重写方法返回对象的存储地址对应计算的hashcode值。
  2. java的堆、栈及方法区,3个常量池。对象实例存储在堆中,字面量字符串在编译时存在在class常量池,程序运行过程中类加载时会将class常量池导入运行时常量池和全局字符串常量池。
  3. ==和equals()方法,String类重写了equals()方法,比较值,而非比较地址;
  4. java的继承和多态,父类引用变量引用子类对象时,若子类重写了父类的方法,则调用引用变量的方法时会调用子类重写后的方法。

5. 后记

至此有关”if(xxx == "TERM")“这一行语句牵扯出的诸多问题总算告一段落,文中仍存留有部分问题留待后续学习过程中进一步弄清楚。一番总结下来不禁让人心生感慨,如此简单的一个问题一旦深入探究下去竟也能牵扯出如此众多的内容,果然万事无外乎钻研二字!


做个好少年
6 声望0 粉丝