3

超过1W字深度剖析JVM常量池(全网最详细最有深度)

 2 years ago
source link: https://segmentfault.com/a/1190000040922573
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

面试题:String a = "ab"; String b = "a" + "b"; a == b 是否相等

面试考察点

考察目的: 考察对JVM基础知识的理解,涉及到常量池、JVM运行时数据区等。

考察范围: 工作2到5年。

要回答这个问题,需要搞明白两个最基本的问题

  1. String a=“ab”,在JVM中发生了什么?
  2. String b=“a”+“b”,底层是如何实现?

JVM的运行时数据

首先,我们一起来复习一下JVM的运行时数据区。

为了让大家有一个全局的视角,我从类加载,到JVM运行时数据区的整体结构画出来,如下图所示。

对于每一个区域的作用,在我之前的面试系列文章中有详细说明,这里就不做复述了。

在上图中,我们需要重点关注几个类容:

  1. 字符串常量池
  2. 封装类常量池
  3. 运行时常量池
  4. JIT编译器

这些内容都和本次面试题有非常大的关联关系,这里对于常量池部分的内容,先保留一个疑问,先跟随我来学习一下JVM中的常量池。

JVM中都有哪些常量池

大家经常会听到各种常量池,但是又不知道这些常量池到底存储在哪里,因此会有很多的疑问:JVM中到底有哪些常量池?

JVM中的常量池可以分成以下几类:

  1. Class文件常量池
  2. 全局字符串常量池
  3. 运行时常量池

Class文件常量池

每个Class文件的字节码中都有一个常量池,里面主要存放编译器生成的各种字面量和符号引用。为了更直观的理解,我们编写下面这个程序。

public class StringExample {
    private int value = 1;
    public final static int fs=101;

    public static void main(String[] args) {
        String a="ab";
        String b="a"+"b";
        String c=a+b;
    }
}

上述程序编译后,通过javap -v StringExample.class查看该类的字节码文件,截取部分内容如下。

Constant pool:
   #1 = Methodref          #9.#32         // java/lang/Object."<init>":()V
   #2 = Fieldref           #8.#33         // org/example/cl07/StringExample.value:I
   #3 = String             #34            // ab
   #4 = Class              #35            // java/lang/StringBuilder
   #5 = Methodref          #4.#32         // java/lang/StringBuilder."<init>":()V
   #6 = Methodref          #4.#36         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StrvalueingBuilder;
   #7 = Methodref          #4.#37         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #8 = Class              #38            // org/example/cl07/StringExample
   #9 = Class              #39            // java/lang/Object
  #10 = Utf8               value
  #11 = Utf8               I
  #12 = Utf8               fs
  #13 = Utf8               ConstantValue
  #14 = Integer            101
  #15 = Utf8               <init>
  #16 = Utf8               ()V
  #17 = Utf8               Code
  #18 = Utf8               LineNumberTable
  #19 = Utf8               LocalVariableTable
  #20 = Utf8               this
  #21 = Utf8               Lorg/example/cl07/StringExample;
  #22 = Utf8               main
  #23 = Utf8               ([Ljava/lang/String;)V
  #24 = Utf8               args
  #25 = Utf8               [Ljava/lang/String;
  #26 = Utf8               a
  #27 = Utf8               Ljava/lang/String;
  #28 = Utf8               b
  #29 = Utf8               c
  #30 = Utf8               SourceFile
  #31 = Utf8               StringExample.java
  #32 = NameAndType        #15:#16        // "<init>":()V
  #33 = NameAndType        #10:#11        // value:I
  #34 = Utf8               ab
  #35 = Utf8               java/lang/StringBuilder
  #36 = NameAndType        #40:#41        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #37 = NameAndType        #42:#43        // toString:()Ljava/lang/String;
  #38 = Utf8               org/example/cl07/StringExample
  #39 = Utf8               java/lang/Object
  #40 = Utf8               append
  #41 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #42 = Utf8               toString
  #43 = Utf8               ()Ljava/lang/String;

我们关注一下Constant pool描述的部分,表示Class文件的常量池。在该常量池中主要存放两类常量。

  1. 符号引用。

字面量

  • 字面量,给基本类型变量赋值的方式就叫做字面量或者字面值。 比如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

    在上述代码中,字面量常量的字节码为:

    #3 = String             #34            // ab
    #26 = Utf8               a
    #34 = Utf8               ab
  • final修饰的成员变量、静态变量、实例变量、局部变量,比如:

      #11 = Utf8               I
      #12 = Utf8               fs
      #13 = Utf8               ConstantValue
      #14 = Integer            101

从上面的字节码来看,字面量和final修饰的属性是保存在常量池中,这些存在于常量池的字面量,指得是数据的值,比如ab101

对于基本数据类型,比如private int value=1,在常量池中只保留了他的字段描述符(I)字段名称(value),它的字面量不会存在与常量池。

  #10 = Utf8               value
  #11 = Utf8               I

另外,对于String c=a+b;c这个属性的值也没有保存到常量池,因为在编译期间,ab的值时不确定的。

#29 = Utf8               c
#35 = Utf8               java/lang/StringBuilder
#36 = NameAndType        #40:#41        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#37 = NameAndType        #42:#43        // toString:()Ljava/lang/String;
#39 = Utf8               java/lang/Object
#40 = Utf8               append
#41 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;

如果,我们把代码修改成下面这种形式

public static void main(String[] args) {
  final String a="ab";
  final String b="a"+"b";
  String c=a+b;
}

重新生成字节码之后,可以看到字节码发生了变化,c这个属性的值abab也保存到了常量池中。

#26 = Utf8               c
#27 = Utf8               SourceFile
#28 = Utf8               StringExample.java
#29 = NameAndType        #12:#13        // "<init>":()V
#30 = NameAndType        #7:#8          // value:I
#31 = Utf8               ab
#32 = Utf8               abab

符号引用

符号引用主要设涉及编译原理方面的概念,包括下面三类常量:

  1. 类和接口的全限定名(Full Qualified Name),也就是Ljava/lang/String;,主要用于在运行时解析得到类的直接引用。

      #23 = Utf8               ([Ljava/lang/String;)V
      #25 = Utf8               [Ljava/lang/String;
      #27 = Utf8               Ljava/lang/String;
  2. 字段的名称和描述符(Descriptor),字段也就是类或者接口中声明的变量,包括类级别变量(static)实例级的变量

    #1 = Methodref          #9.#32         // java/lang/Object."<init>":()V
    #2 = Fieldref           #8.#33         // org/example/cl07/StringExample.value:I
    #3 = String             #34            // ab
    #4 = Class              #35            // java/lang/StringBuilder
    #5 = Methodref          #4.#32         // java/lang/StringBuilder."<init>":()V
    #6 = Methodref          #4.#36         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StrvalueingBuilder;
    #7 = Methodref          #4.#37         // java/lang/StringBuilder.toString:()Ljava/lang/String;
    #8 = Class              #38            // org/example/cl07/StringExample
      
    #24 = Utf8               args
    #26 = Utf8               a
    #28 = Utf8               b
    #29 = Utf8               c
  3. 方法的名称和描述符,方法的描述类似于JNI动态注册时的“方法签名”,也就是参数类型+返回值类型,比如下面的这种字节码,表示main方法和String返回类型。

      #19 = Utf8               main
      #20 = Utf8               ([Ljava/lang/String;)V

小结:在Class文件中,存在着一些不会发生变化的东西,比如一个类的名字、类的字段名字/所属数据类型、方法名称/返回类型/参数名、常量、字面量等。这些在JVM解释执行程序的时候非常重要,所以编译器将源代码编译成class文件之后,会用一部分字节分类存储这些不变的代码,而这些字节我们就称为常量池。

运行时常量池

运行时常量池是每一个类或者接口的常量池(Constant Pool)的运行时的表现形式。

我们知道,一个类的加载过程,会经过:加载连接(验证、准备、解析)初始化的过程,而在类加载这个阶段,需要做以下几件事情:

  1. 通过一个类的全类限定名获取此类的二进制字节流。
  2. 在堆内存生成一个java.lang.Class对象,代表加载这个类,做为这个类的入口。
  3. class字节流的静态存储结构转化成方法区(元空间)的运行时数据结构。

而其中第三点,将class字节流代表的静态储存结构转化为方法区的运行时数据结构这个过程,就包含了class文件常量池进入运行时常量池的过程。

所以,运行时常量池的作用是存储class文件常量池中的符号信息,在类的解析阶段会把这些符号引用转换成直接引用(实例对象的内存地址),翻译出来的直接引用也是存储在运行时常量池中。class文件常量池的大部分数据会被加载到运行时常量池。

image-20211106202558917

运行时常量池保存在方法区(JDK1.8元空间)中,它是全局共享的,不同的类共用一个运行时常量池。

另外,运行时常量池具有动态性的特征,它的内容并不是全部来源与编译后的class文件,在运行时也可以通过代码生成常量并放入运行时常量池。比如String.intern()方法。

字符串常量池

字符串常量池,简单来说就是专门针对String类型设计的常量池。

字符串常量池的常用创建方式有两种。

String a="Hello";
String b=new String("Mic");
  1. a这个变量,是在编译期间就已经确定的,会进入到字符串常量池。
  2. b这个变量,是通过new关键字实例化,new是创建一个对象实例并初始化该实例,因此这个字符串对象是在运行时才能确定的,创建的实例在堆空间上。

字符串常量池存储在堆内存空间中,创建形式如下图所示。

image-20211106235703069

当使用String a=“Hello”这种方式创建字符串对象时,JVM首先会先检查该字符串对象是否存在与字符串常量池中,如果存在,则直接返回常量池中该字符串的引用。否则,会在常量池中创建一个新的字符串,并返回常量池中该字符串的引用。(这种方式可以减少同一个字符串被重复创建,节约内存,这也是享元模式的体现)。

如下图所示,如果再通过String c=“Hello”创建一个字符串,发现常量池已经存在了Hello这个字符串,则直接把该字符串的引用返回即可。(String里面的享元模式设计)

image-20211107001801733

当使用String b=new String(“Mic”)这种方式创建字符串对象时,由于String本身的不可变性(后续分析),因此在JVM编译过程中,会把Mic放入到Class文件的常量池中,在类加载时,会在字符串常量池中创建Mic这个字符串。接着使用new关键字,在堆内存中创建一个String对象并指向常量池中Mic字符串的引用。

如下图所示,如果再通过new String(“Mic”)创建一个字符串对象,此时由于字符串常量池已经存在Mic,所以只需要在堆内存中创建一个String对象即可。

image-20211107002344014

简单总结一下:JVM之所以单独设计字符串常量池,是JVM为了提高性能以及减少内存开销的一些优化:

  1. String对象作为Java语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。
  2. 创建字符串常量时,首先检查字符串常量池是否存在该字符串,如果有,则直接返回该引用实例,不存在,则实例化该字符串放入常量池中。

字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”!

封装类常量池

除了字符串常量池,Java的基本类型的封装类大部分也都实现了常量池。包括Byte,Short,Integer,Long,Character,Boolean

注意,浮点数据类型Float,Double是没有常量池的。

封装类的常量池是在各自内部类中实现的,比如IntegerCache(Integer的内部类)。要注意的是,这些常量池是有范围的:

  • Byte,Short,Integer,Long : [-128~127]
  • Character : [0~127]
  • Boolean : [True, False]

测试代码如下:

public static void main(String[] args) {
  Character a=129;
  Character b=129;
  Character c=120;
  Character d=120;
  System.out.println(a==b);
  System.out.println(c==d);
  System.out.println("...integer...");
  Integer i=100;
  Integer n=100;
  Integer t=290;
  Integer e=290;
  System.out.println(i==n);
  System.out.println(t==e);
}

运行结果:

false
true
...integer...
true
false

封装类的常量池,其实就是在各个封装类里面自己实现的缓存实例(并不是JVM虚拟机层面的实现),如在Integer中,存在IntegerCache,提前缓存了-128~127之间的数据实例。意味着这个区间内的数据,都采用同样的数据对象。这也是为什么上面的程序中,通过==判断得到的结果为true

这种设计其实就是享元模式的应用。

private static class IntegerCache {
  static final int low = -128;
  static final int high;
  static final Integer cache[];

  static {
    // high value may be configured by property
    int h = 127;
    String integerCacheHighPropValue =
      sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
    if (integerCacheHighPropValue != null) {
      try {
        int i = parseInt(integerCacheHighPropValue);
        i = Math.max(i, 127);
        // Maximum array size is Integer.MAX_VALUE
        h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
      } catch( NumberFormatException nfe) {
        // If the property cannot be parsed into an int, ignore it.
      }
    }
    high = h;

    cache = new Integer[(high - low) + 1];
    int j = low;
    for(int k = 0; k < cache.length; k++)
      cache[k] = new Integer(j++);

    // range [-128, 127] must be interned (JLS7 5.1.7)
    assert IntegerCache.high >= 127;
  }

  private IntegerCache() {}
}

封装类常量池的设计初衷其实String相同,也是针对频繁使用的数据区间进行缓存,避免频繁创建对象的内存开销。

关于字符串常量池的问题探索

在上述常量池中,关于String字符串常量池的设计,还有很多问题需要探索:

  1. 如果常量池中已经存在某个字符串常量,后续定义相同字符串的字面量时,是如何指向同一个字符串常量的引用?也就是下面这段代码的断言结果是true

    String a="Mic";
    String b="Mic";
    assert(a==b); //true
  2. 字符串常量池的容量到底有多大?
  3. 为什么要设计针对字符串单独设计一个常量池?

为什么要设计针对字符串单独设计一个常量池?

首先,我们来看一下String的定义。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
}

从上述源码中可以发现。

  1. String这个类是被final修饰的,代表该类无法被继承。
  2. String这个类的成员属性value[]也是被final修饰,代表该成员属性不可被修改。

因此String具有不可变的特性,也就是说String一旦被创建,就无法更改。这么设计的好处有几个。

  1. 方便实现字符串常量池: 在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,String pool将不能够实现!
  2. 线程安全性,在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。
  3. 保证 hash 属性值不会频繁变更。确保了唯一性,使得类似HashMap容器才能实现相应的key-value缓存功能,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。

注意,由于String的不可变性可以方便实现字符串常量池这一点很重要,这时实现字符串常量池的前提。

字符串常量池,其实就是享元模式的设计,它和在JDK中提供的IntegerCache、以及Character等封装对象的缓存设计类似,只是String是JVM层面的实现。

字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为 了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串池,每当代码创建字符串常量时,JVM会首先检查字符串常量池。如果字符串已经存在池中, 就返回池中的实例引用。如果字符串不在池中,就会实例化一个字符串并放到池中。Java能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突 进行共享。

我们把字符串常量池当成是一个缓存,通过双引号定义一个字符串常量时,首先从字符串常量池中去查找,找到了就直接返回该字符串常量池的引用,否则就创建一个新的字符串常量放在常量池中。

常量池有多大呢?

我想大家一定和我一样好奇,常量池到底能存储多少个常量?

前面我们说过,常量池本质上是一个hash表,这个hash表示不可动态扩容的。也就意味着极有可能出现单个 bucket 中的链表很长,导致性能降低。

在JDK1.8中,这个hash表的固定Bucket数量是60013个,我们可以通过下面这个参数配置指定数量

-XX:StringTableSize=N

可以增加下面这个虚拟机参数,来打印常量池的数据。

-XX:+PrintStringTableStatistics

增加参数后,运行下面这段代码。

public class StringExample {
    private int value = 1;
    public final static int fs=101;

    public static void main(String[] args) {
        final String a="ab";
        final String b="a"+"b";
        String c=a+b;
    }
}

在JVM退出时,会打印常量池的使用情况如下:

SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     12192 =    292608 bytes, avg  24.000
Number of literals      :     12192 =    470416 bytes, avg  38.584
Total footprint         :           =    923112 bytes
Average bucket size     :     0.609
Variance of bucket size :     0.613
Std. dev. of bucket size:     0.783
Maximum bucket size     :         6
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :       889 =     21336 bytes, avg  24.000
Number of literals      :       889 =     59984 bytes, avg  67.474
Total footprint         :           =    561424 bytes
Average bucket size     :     0.015
Variance of bucket size :     0.015
Std. dev. of bucket size:     0.122
Maximum bucket size     :         2

可以看到字符串常量池的总大小是60013,其中字面量是889

字面量是什么时候进入到字符串常量池的

字符串字面量,和其他基本类型的字面量或常量不同,并不会在类加载中的解析(resolve) 阶段填充并驻留在字符串常量池中,而是以特殊的形式存储在 运行时常量池(Run-Time Constant Pool) 中。而是只有当此字符串字面量被调用时(如对其执行ldc字节码指令,将其添加到栈顶),HotSpot VM才会对其进行resolve,为其在字符串常量池中创建对应的String实例。

具体来说,应该是在执行ldc指令时(该指令表示int、float或String型常量从常量池推送至栈顶)

在JDK1.8的HotSpot VM中,这种未真正解析(resolve)的String字面量,被称为pseudo-string,以JVM_CONSTANT_String的形式存放在运行时常量池中,此时并未为其创建String实例。

在编译期,字符串字面量以"CONSTANT_String_info"+"CONSTANT_Utf8_info"的形式存放在class文件的 常量池(Constant Pool) 中;

在类加载之后,字符串字面量以"JVM_CONSTANT_UnresolvedString(JDK1.7)"或者"JVM_CONSTANT_String(JDK1.8)"的形式存放在 运行时常量池(Run-time Constant Pool) 中;

在首次使用某个字符串字面量时,字符串字面量以真正的String对象的方式存放在 字符串常量池(String Pool) 中。

通过下面这段代码可以证明。

public static void main(String[] args) {
  String a =new String(new char[]{'a','b','c'});
  String b = a.intern();
  System.out.println(a == b);

  String x =new String("def");
  String y = x.intern();
  System.out.println(x == y);
}

使用new char[]{‘a’,’b’,’c’}构建的字符串,并没有在编译的时候使用常量池,而是在调用a.intern()时,将abc保存到常量池并返回该常量池的引用。

intern()方法

在Integer中的valueOf方法中,我们可以看到,如果传递的值i是在IntegerCache.lowIntegerCache.high范围以内,则直接从IntegerCache.cache中返回缓存的实例对象。

public static Integer valueOf(int i) {
  if (i >= IntegerCache.low && i <= IntegerCache.high)
    return IntegerCache.cache[i + (-IntegerCache.low)];
  return new Integer(i);
}

那么,在String类型中,既然存在字符串常量池,那么有没有方法能够实现类似于IntegerCache的功能呢?

答案是:intern()方法。由于字符串池是虚拟机层面的技术,所以在String的类定义中并没有类似IntegerCache这样的对象池,String类中提及缓存/池的概念只有intern() 这个方法。

/**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java™ Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
public native String intern();

这个方法的作用是:去拿String的内容去Stringtable里查表,如果存在,则返回引用,不存在,就把该对象的"引用"保存在Stringtable表里

比如下面这段程序:

public static void main(String[] args) {
  String str = new String("Hello World");
  String str1=str.intern();
  String str2 = "Hello World";
  System.out.print(str1 == str2);
}

运行的结果为:true。

实现逻辑如下图所示,str1通过调用str.intern()去常量池表中获取Hello World字符串的引用,接着str2通过字面量的形式声明一个字符串常量,由于此时Hello World已经存在于字符串常量池中,所以同样返回该字符串常量Hello World的引用,使得str1str2具有相同的引用地址,从而运行结果为true

image-20211107151916196

总结:intern方法会从字符串常量池中查询当前字符串是否存在:

  • 若不存在就会将当前字符串放入常量池中,并返回当地字符串地址引用。
  • 如果存在就返回字符串常量池那个字符串地址。

注意,所有字符串字面量在初始化时,会默认调用intern()方法。

这段程序,之所以a==b,是因为声明a时,会通过intern()方法去字符串常量池中查找是否存在字符串Hello,由于不存在,则会创建一个。同理,变量b也同样如此,所以b在声明时,发现字符常量池中已经存在Hello的字符串常量,所以直接返回该字符串常量的引用。

public static void main(String[] args) {
  String a="Hello";
  String b="Hello";
}

OK,学习到这里,是不是感觉自己懂了?我出一道题目来考考大家,下面这段程序的运行结果是什么?

public static void main(String[] args) {
  String a =new String(new char[]{'a','b','c'});
  String b = a.intern();
  System.out.println(a == b);

  String x =new String("def");
  String y = x.intern();
  System.out.println(x == y);
}

正确答案是:

true
false

第二个输出为false还可以理解,因为new String(“def”)会做两件事:

  1. 在字符串常量池中创建一个字符串def
  2. new关键字创建一个实例对象string,并指向字符串常量池def的引用。

x.intern(),是从字符串常量池获取def的引用,他们的指向地址不同,我后面的内容还会详细解释。

第一个输出结果为true是为啥捏?

JDK文档中关于intern()方法的说明:当调用intern方法时,如果常量池(内置在 JVM 中的)中已经包含相同的字符串,则返回池中的字符串。否则,将此String对象添加到池中,并返回对该String对象的引用。

在构建String a的时候,使用new char[]{‘a’,’b’,’c’}初始化字符串时(不会自动调用intern(),字符串采用懒加载方式进入到常量池),并没有在字符串常量池中构建abc这个字符串实例。所以当调用a.intern()方法时,会把该String对象添加到字符常量池中,并返回对该String对象的引用,所以ab指向的引用地址是同一个。

面试题:String a = "ab"; String b = "a" + "b"; a == b 是否相等

回答a==b是相等的,原因如下:

  1. 变量ab都是常量字符串,其中b这个变量,在编译时,由于不存在可变化的因素,所以编译器会直接把变量b赋值为ab(这个是属于编译器优化范畴,也就是编译之后,b会保存到Class常量池中的字面量)。
  2. 对于字符串常量,初始化a时, 会在字符串常量池中创建一个字符串ab并返回该字符串常量池的引用。
  3. 对于变量b,赋值ab时,首先从字符串常量池中查找是否存在相同的字符串,如果存在,则返回该字符串引用。
  4. 因此,a和b所指向的引用是同一个,所以a==b成立。

关于常量池部分的内容,要比较深入和全面的理解,还是需要花一些时间的。

比如大家通过阅读上面的内容,认为对字符串常量池有一个非常深入的理解,可以,我们再来看一个问题:

public static void main(String[] args) {
  String str = new String("Hello World");
  String str1=str.intern();
  System.out.print(str == str1);
}

上面这段代码,很显然返回false,原因如下图所示。很明显strstr1所指向的引用地址不是同一个。

image-20211107155237442

但是我们把上述代码改造一下:

public static void main(String[] args) {
  String str = new String("Hello World")+new String("!");
  String str1=str.intern();
  System.out.print(str == str1);
}

上述程序输出的结果变成了:true。 为什么呢?

这里也是JVM编译器层面做的优化,因为String是不可变类型,所以理论上来说,上述程序的执行逻辑是:通过+进行字符串拼接时,相当于把原有的String变量指向的字符串常量HelloWorld取出来,加上另外一个String变量指向的字符串常量!,再生成一个新的对象。

假设我们是通过for循环来对String变量进行拼接,那将会生成大量的对象,如果这些对象没有被及时回收,会造成非常大的内存浪费。

所以JVM优化之后,其实是通过StringBuilder来进行拼接,也就是只会产生一个对象实例StringBuilder,然后再通过append方法来拼接。

为了证明我说的情况,来看一下上述代码的字节码。

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=3, args_size=1
         0: new           #3                  // class java/lang/StringBuilder
         3: dup
         4: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
         7: new           #5                  // class java/lang/String
        10: dup
        11: ldc           #6                  // String Hello World
        13: invokespecial #7                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        16: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: new           #5                  // class java/lang/String
        22: dup
        23: ldc           #9                  // String !
        25: invokespecial #7                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        28: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: astore_1
        35: aload_1
        36: invokevirtual #11                 // Method java/lang/String.intern:()Ljava/lang/String;
        39: astore_2
        40: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
        43: aload_1
        44: aload_2
        45: if_acmpne     52
        48: iconst_1
        49: goto          53
        52: iconst_0
        53: invokevirtual #13                 // Method java/io/PrintStream.print:(Z)V
        56: return

从字节码中可以看到,构建了一个StringBuilder,

 0: new           #3                  // class java/lang/StringBuilder

然后把字符串常量通过append方法进行拼接,最后调用toString()方法得到一个字符串常量。

16: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

因此,上述代码,等价于下面这种形式。

public static void main(String[] args) {
  StringBuilder sb=new StringBuilder().append(new String("Hello World")).append(new String("!"));
  String str=sb.toString();
  String str1=str.intern();
  System.out.print(str == str1);
}

所以,得到的结果是true

基于这个问题的变体还有很多,比如再来变一次,下面这段程序的运行结果是多少?

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

答案是false

因为上述程序等价于, s3s4指向不同的地址引用,自然不相等。

public static void main(String[] args) {
  String s1 = "a";
  String s2 = "b";
  String s3 = "ab";
  StringBuilder sb=new StringBuilder().append(s1).append(s2);
  String s4 = sb.toString();
  System.out.println(s3 == s4);
}

总结: 只有足够清晰的理解了字符串常量池相关的所有知识点,不管面试过程中如何变化,你都能准确回答,这就是知识的力量!
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Mic带你学架构
如果本篇文章对您有帮助,还请帮忙点个关注和赞,您的坚持是我不断创作的动力。欢迎关注同名微信公众号获取更多技术干货!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK