String相等之谜

Java中的“相等”

等号大比拼

  • ==

    众所周知,在 Java 中如果用 == 比较两个对象,那就是比较两个对象是否在内存的同一个位置(地址是否相同)。

  • equals

    在 Java 中,所有类的父类 Object 存在一个 equals 方法,String类复写了这个方法,它实现了真正的字符串比较,代码如下:

    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;
        }
    
  • hashCode

    通常情况下,当我们重写 equals 方法时,我们需要重写 hashCode方法 以保持一致性。默认的 hashCode 方法与地址有关。

热身--JVM内存结构

概览

字符串比较--小问题大智慧-LMLPHP

看到上图,我们简单介绍一下 Java StackMethod AreaHeap 。这几个我们经常提到的内存区。

  • Java堆(Heap)

    对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。(我们 new 出来的东西都放在这里)

  • 方法区(Method Area)

    方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

  • JVM栈(JVM Stacks)

    与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

    局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。

    其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

    在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

    public static void main(String[] args) {
            String t1 = "abc";
            String t2 = new String("abc");
            System.out.println(t1==t2);
    
        }
    

    我们看到 t1t2 的值都是 "abc",直觉上来看结果 应该返回 true,但是运行以后 返回的是 false ,这是为什么呢?(因为他们的地址不同)。那么他们分别在哪呢?这里我们就要引入常量池的概念了。

常量池

Java中的常量池,通常指的是运行时常量池,它是方法区的一部分,一个JVM实例只有一个运行常量池,各线程间共享该运行常量池。

Java常量池简介:Java常量池中保存了一份在编译期间就已确定的数据。它里面包括final常量的值(包括成员常量、局部常量和引用常量)、以及对象字面量的值。

在编译期间,每当给常量赋值它就会去检测常量池中是否存在该值,若存在直接返回该值的地址给常量,若不存在则先在常量池中创建该值,再返回该值的地址给常量。因此常量池中不可能出现相等的数据。

  • final常量

一切经final关键字修饰的变量均为常量,final常量必须在定义时就赋初值,否则编译不通过。

  • 对象字面量

对象字面量是指直接以一常量给对象赋值,而不是在堆空间new出一个对象实例。

常见的两种对象字面量:基本类型的包装类对象字面量、String对象字面量。

String Pool

字符串常量池(String Pool)保存着所有字符串字面量(literal strings),这些字面量在编译时期就确定。不仅如此,还可以使用 String 的 intern() 方法在运行过程中将字符串添加到 String Pool 中。

当一个字符串调用 intern() 方法时,如果 String Pool 中已经存在一个字符串和该字符串值相等(使用 equals() 方法进行确定),那么就会返回 String Pool 中字符串的引用;否则,就会在 String Pool 中添加一个新的字符串,并返回这个新字符串的引用。

String s1 = new String("aaa");
String s2 = new String("aaa");
System.out.println(s1 == s2);     // false,s1和s2的地址不同所以返回了false
String s3 = s1.intern();
String s4 = s1.intern();
System.out.println(s3 == s4);     // true, s3和s4都指向了StringPool中的"aaa"是同一个对象所以返回了 true

PS: 在 Java 7 之前,String Pool 被放在运行时常量池中,它属于永久代。而在 Java 7,String Pool 被移到堆中。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误。

常量折叠

接下来让我们再看一个问题:

public static void main(String[] args) {
        String a = "hello2";
        final String b = "hello";
        String d = "hello";
        String c = b + 2;
        String e = d + 2;
        System.out.println((a == c));
        System.out.println((a == e));

    }

结果返回了:

true
false

为什么呢?这就要引入常量折叠的概念。

常量折叠的概念

  • 常量折叠是一种编译器优化技术。
  • 常量折叠主要指的是编译期常量加减乘除的运算过程会被折叠

对于 String s1 = "1" + "2";
编译器会给你优化成 String s1 = "12";
在生成的字节码中,根本看不到 "1" "2" 这两个东西。

我们通过idea进行验证下

1、源码文件

    public static void main(String[] args) {
        String s1 = "1"+"2";
    }

2、运行后,idea有个out文件夹,找到上面文件的class文件

    public static void main(String[] args) {
        String s1 = "12";
    }

确实如上面所说,编译器会给你进行优化

常量折叠发生的条件

  • 必须是编译期常量之间进行运算才会进行常量折叠。

  • 编译期常量就是

    “编译的时候就可以确定其值的常量”,

    • 首先:字面量是编译期常量。(数字字面量,字符串字面量等)
    • 其次:编译期常量进行简单运算的结果也是编译期常量,如1+2,"a"+"b"。
    • 最后:被编译器常量赋值的 final 的基本类型和字符串变量也是编译期常量。

举个栗子

1.第一个栗子

    public static void main(String[] args) {
        String s1="a"+"bc";
        String s2="ab"+"c";
        System.out.println(s1 == s2);
    }

相信大家都知道了,输出为true
并且只创建了一个 "abc" 字符串对象,且位于字符串常量池中。

2、第二个栗子

    public static void main(String[] args) {
        String a = "a";
        String bc = "bc";
        String s1 = "a" + "bc";
        String s2 = a + bc;
        System.out.println(s1 == s2);
    }

这个结果呢?false

s1 是字符串字面量相加,但是 s2 却是两个非 final 的变量相加,所以不会进行常量折叠。

而是根据 String 类特有的 + 运算符重载,变成类似这样的代码 (jdk1.8)

String s2 = new StringBuilder(a).append(b).toString();

这里 toString() 会生成新的String变量,显然用 == 运算符比较是会返回 false。

总结,只要牢记常量折叠主要指的是编译期常量加减乘除的运算过程会被折叠

总结

综上所述,在使用 String的比较时,我们最好使用 equals()方法,而==是内存上的比较。不过正是它的引入,才致使我们分析学习了,jvm的内存模型,常量池,常量折叠等编译器层面的知识。因祸得福,希望本节课能巩固我们的底层知识,下期再会了!

04-10 22:16