Java 虚拟机系列文章目录导读:

深入理解 Java 虚拟机(一)~ class 字节码文件剖析
深入理解 Java 虚拟机(二)~ 类的加载过程剖析
深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析
深入理解 Java 虚拟机(四)~ 各种容易混淆的常量池
深入理解 Java 虚拟机(五)~ 对象的创建过程
深入理解 Java 虚拟机(六)~ Garbage Collection 剖析

前言

对于常量或者常量池,我们可能经常听到这两个概念。例如:

  • 介绍 class 字节码结构的时候提到了 常量池
  • 我们在介绍 class 的执行又提到了 运行时常量池
  • 我们在实际开发当中会通过 static final 定义各种各样的 常量
  • 我们在刚学 Java 的时候,可能还听过 字符串常量池

这么多概念都涉及到常量两个字,很容混淆。

而且很多开发者想当然的认为我们定义的“常量”都放在在常量池中的,注意我这里的常量是使用了双引号。

虽然我们日常开发中经常遇到这些概念,但是又说不出来个所以然来。今天我们就一起来聊一聊这个话题。

什么才是真正的常量

《深入理解 Java 虚拟机(二)~ 类的加载过程剖析》中,我们介绍到的对类的引用有主动引用和被动引用。

在介绍被动引用的常量引用时说道,如果类 A 用到了类 B 的常量,那么在类 A 在初始化的时候不会初始化类 B,因为在编译时已经将类 B 的常量放进了类 A 的常量池中

public class ConstTest {
    static {
        System.out.println("ConstTest init");
    }

    public static final String HELLO_WORLD = "HelloWorld";

}

public class Client{
	public static void main(String[] args) {
	    System.out.println(ConstTest.HELLO_WORLD);
	}
}

运行 Client,发现类 ConstTest 并不会 加载初始化

这是在之前的文章中介绍的被动引用的例子。

但是,如果我将上面的常量改成如下形式呢?

public static final Object HELLO_WORLD = new Object();

再运行 Client,输出:

ConstTest init
java.lang.Object@7ea987ac

发现类 ConstTest 被初始化了,不是说常量在编译的放进去了 Client 的常量池中吗。

对于这个问题,我们可以从 ConstTest 字节码着手分析,通过 javap 对 ConstTest 进行反编译:

{
  public static final java.lang.Object HELLO_WORLD;
    descriptor: Ljava/lang/Object;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String ConstTest init
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: new           #5                  // class java/lang/Object
        11: dup
        12: invokespecial #1                  // Method java/lang/Object."<init>":()V
        15: putstatic     #6                  // Field HELLO_WORLD:Ljava/lang/Object;
        18: return
      LineNumberTable:
        line 5: 0
        line 8: 8
}

从上面的字节码可以看出,常量 HELLO_WORLD准备阶段 的值为 null,在初始化阶段将其设置为 new Obejct()

我们在介绍类加载的 5 个阶段的时候提到:一个 静态变量,在准备阶段值为默认值,在初始化阶段才会设置成开发者给的值。

可见如果这个所谓的常量(static final 修饰)的类型是字面量(包括字符串),那么虚拟机会把它当做常量(Constants);如果类型为复杂类型,那么这个常量和静态变量在字节码层面本质上并没有什么太大的区别。

其实这个也容易理解,因为在编译期是不可能 new 一个 Object 对象的。所以虚拟机将其和静态变量一样处理,只不过这个静态变量不能被修改而已。

所以并不是所有 static final 修饰的变量的值都会放进运行时常量池中,如果值是字面量那么是放进常量池中的,如果是复杂类型,那么这个复杂类的对象是放进堆区的。

对于值为字面量的常量,编译期就会将用到常量的地方替换成字面量:

public class ConstTest {
    public static final String HELLO_WORLD = "HelloWorld";
}

public class Client {
    public static void main(String[] args) {
        System.out.println(ConstTest.HELLO_WORLD);
    }
}

javap 反编译,main 方法部分如下所示:

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String HelloWorld
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return

相当于直接输出了 “HelloWorld” 字符串:

System.out.println("HelloWorld");

需要注意的是,如果将常量的类型改成 Object,值为 String 字面量,依然会将其当做静态变量一样处理:

public class ConstTest {
    static {
        System.out.println("ConstTest init");
    }
    public static final Object HELLO_WORLD = "HelloWorld";
}

反编译后可以查看,HELLO_WORLD 依然会在静态代码块中被初始化:

  public static final java.lang.Object HELLO_WORLD;

  static {};
    Code:
       0: getstatic     #2          // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3          // String ConstTest init
       5: invokevirtual #4          // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: ldc           #5          // String HelloWorld
      10: putstatic     #6          // Field HELLO_WORLD:Ljava/lang/Object;
      13: return

常量池

字节码常量池 VS 运行时常量池

常量池 是占用 class 字节码占用最大的数据项之一。也就是说该常量池是编译期产生的。里面存放着两大类的常量类型:

  • 字面量(Literal)

    字面量类似于 Java 中的常量,如字符串,数字等

  • 符号引用(Symbolic)

    主要包括 3 类常量:

    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

而运行时常量池,字节码加载到内存中,其常量池的内容会放进运行时常量池中。运行时常量池里面除了字面量、符号引用,还有运行时对方法和字段解析出来的 直接引用

字节码的常量池,它是 class 字节码文件的组成部分,而运行时常量池它是所有线程共享的一块内存区域。

字符串常量池

字符串常量池顾名思义就是用来存储字符串的,它和运行时常量池一样也是在方法区内。

那什么时候会将字符串放进字符串常量池中呢?例如下面的代码:

String str1 = "Chiclaim";

如果字符串常量池中没有 "Chiclaim",会将其放入池中,如果字符串常量池中存在该字符串,那么 str1 指向该字符串的引用。

public static void main(String[] args) {
    String str1 = "Chiclaim";
    String str2 = "Chiclaim";
    
    System.out.println(str1 == str2);
}

有点 Java 基础的都知道,上面的例子输出 true。因为 str1、str2 指向同一个字符串引用

我们可能会经常遇到下面的面试题,问一共创建了几个对象:

public static void main(String[] args) {
    String str1 = new String("Chiclaim");
    String str2 = new String("Chiclaim");
}

如果字符串常量池中不存在 "Chiclaim",那么总共创建了 3 个对象,手动 new 了 2 个对象,加上 1 个字符串对象。

如果字符串常量池中存在 "Chiclaim",那么总共创建了 2 个对象。

可能还会类下面的变种:

public static void main(String[] args) {
    String str = new String("Chiclaim" + "Hello");
}

网上很多文章都会说会创建 4 个对象:

3 个常量池对象 "Chiclaim" ,"Hello","ChiclaimHello",

1 个堆对象(new String())

其实上面只会创建 2 个对象,这 2 个对象是哪 2 个呢?我们先来上面代码的反编译的情况:

public static void main(java.lang.String[]);
    Code:
       0: new           #2          // class java/lang/String
       3: dup
       4: ldc           #3          // String ChiclaimHello
       6: invokespecial #4          // Method java/lang/String."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: return

可见上面的代码相当于:

public static void main(String[] args) {
    String str = new String("ChiclaimHello");
}

所以,上面代码最终只会创建 2 个对象,它们分别是:

1 个常量池对象:"ChiclaimHello"

1 个堆对象(new String())

关于字符串拼接这里多扯一下。

如果是变量和其他字符串字面量拼接,编译器会使用 StringBuilder 对象来拼接,如:

public static void main(String[] args) {
    String str = "before" + System.currentTimeMillis() + "after";
}

反编译后,可以发现编译会帮我们创建 StringBuilder 来拼接字符串:

public static void main(java.lang.String[]);
    Code:
       0: new           #2          // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3          // Method java/lang/StringBuilder."<init>":()V
       7: ldc           #4          // String before
       9: invokevirtual #5          // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      12: invokestatic  #6          // Method java/lang/System.currentTimeMillis:()J
      15: invokevirtual #7          // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
      18: ldc           #8          // String after
      20: invokevirtual #5          // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: invokevirtual #9          // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      26: astore_1
      27: return
}

上面的字节码程序就相当于:

public static void main2(String[] args) {
    StringBuilder builder = new StringBuilder();
    builder.append("before");
    builder.append(System.currentTimeMillis());
    builder.append("after");
    String str = builder.toString();
}

这样的话,总会创建 4 个对象:

2 个字符串常量:"before"、"after"

1 个 StringBuilder 对象

1 个 String 对象, StringBuilder.toString() 方法会 new String 返回

如果是字符串字面量之间的拼接,或字符串字面量和数字字面量拼接,编译器都不会使用 StringBuidler,如:

public class Client {

public static final int AGE = 17;

public static void main(String[] args) {
    String str = new String("Chiclaim" + "Hello");
    String str2 = "Chiclaim" + AGE;
    }
}

javap 反编译可以看到编译器并不会使用 StringBuilder,上面的代码相当于:

String str = new String("ChiclaimHello");
String str2 = "Chiclaim17";

更多关于字符串拼接的内容,可以查看之前的文章:深入理解 Java 虚拟机(一)~ class 字节码文件剖析

String.intern()

我们可以使用 String.intern 方法来操作字符串常量池。

该方法用来返回常量池中的某个字符串。简单来说该功能是利用 Hotspot 虚拟机里的一个 StringTable 来存储字符串,StringTable 底层使用 HashTable 来实现的。如果 StringTable 中已经存在该字符串(通过 String.equals 方法来判断是否存在),则直接返回常量池中该对象的引用。否则,在 StringTable 中加入该对象,然后返回对象的引用。

我们来看看下面的程序会输出什么:

public static void main(String[] args) {

    String str = new String("Chiclaim");

    String str2 = str.intern();

    System.out.println(str == str2);
}

上面的程序会输出 false。执行 new String("Chiclaim") 的时候,会将字符串 "Chiclaim" 放入常量池中。执行 str.intern() 的时候会通过 String.equals 方法来判断常量池中是否存在该字符串,当然是存在的,所以 str.intern() 返回的是常量池中的 "Chiclaim" 的引用。如下所示:

string.intern

可以将上面的例子,改成如下的形式:

public static void main(String[] args) {

    String str = "Chiclaim";

    String str2 = new String(str);

    String str3 = str2.intern();
    
    System.out.println(str == str3);
}

上面的程序输出 true,可见 str2.intern() 返回的就是 str 的引用。

至此,我们就将 Java 中的常量与字面量,字节码常量池和运行时常量池,以及字符串常量池的区别介绍完了。

Reference


如果你觉得本文帮助到你,给我个关注和赞呗!

另外本文涉及到的代码都在我的 AndroidAll GitHub 仓库中。该仓库除了 Java虚拟机 技术,还有 Android 程序员需要掌握的技术栈,如:程序架构、设计模式、性能优化、数据结构算法、Kotlin、Flutter、NDK,以及常用开源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持续更新,欢迎 star。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐