MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java内存中的常量池

2023-07-194.1k 阅读

Java内存中的常量池基础概念

在Java中,常量池是一个重要的概念,它与Java的内存管理、数据共享以及性能优化密切相关。常量池可以简单理解为是Java内存中专门用于存放编译期已确定的各种字面量(Literal)和符号引用(Symbolic References)的区域。

字面量

字面量是指在源代码中直接给出的值,比如字符串字面量 “Hello World”,整数字面量 10,浮点数字面量 3.14 等。这些字面量在编译时就已经确定,并且会被存放在常量池中。例如:

String str = "Hello";
int num = 10;

在上述代码中,“Hello” 这个字符串字面量和 10 这个整数字面量在编译后会进入常量池。

符号引用

符号引用主要用于在编译阶段表示类、接口、字段和方法等的引用。它是一组符号来描述所引用的目标,而不是直接指向目标的内存地址。例如,当我们在一个类中引用另一个类的方法时,在编译阶段,Java编译器会将这个方法的引用以符号引用的形式记录在常量池中。假设我们有如下两个类:

class A {
    public void methodA() {
        System.out.println("This is methodA in class A");
    }
}

class B {
    public void callAMethod() {
        A a = new A();
        a.methodA();
    }
}

在类 B 的 callAMethod 方法中对类 A 的 methodA 方法的引用,在编译阶段会以符号引用的形式存在于类 B 的常量池中。在运行时,这些符号引用会被解析为直接引用(即实际的内存地址)。

Java常量池的分类

Java中的常量池主要分为两类:静态常量池和运行时常量池。

静态常量池(Class文件常量池)

每个Java类被编译后,都会生成一个对应的 .class 文件。在这个 .class 文件中,有一部分区域就是静态常量池。它存放着该类用到的各种字面量和符号引用。静态常量池是在编译期生成的,它是类结构的一部分。例如,对于下面这个简单的Java类:

public class ConstantPoolExample {
    private static final String CONSTANT_STRING = "This is a constant string";
    public void printMessage() {
        System.out.println(CONSTANT_STRING);
    }
}

在编译后生成的 ConstantPoolExample.class 文件的静态常量池中,会包含 “This is a constant string” 这个字符串字面量以及对 System.out.println 方法的符号引用等。我们可以通过一些工具(如 javap -verbose)来查看 .class 文件的静态常量池内容。假设我们编译上述代码后,执行 javap -verbose ConstantPoolExample.class,会看到类似如下的输出(部分关键内容):

Constant pool:
   #1 = Methodref          #6.#20         // java/lang/System.out:Ljava/io/PrintStream;
   #2 = String             #21            // This is a constant string
   #3 = Fieldref           #5.#22         // ConstantPoolExample.CONSTANT_STRING:Ljava/lang/String;
   #4 = Methodref          #23.#24        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #25            // ConstantPoolExample
   #6 = Class              #26            // java/lang/System
   #7 = Utf8               CONSTANT_STRING
   #8 = Utf8               Ljava/lang/String;
   #9 = Utf8               printMessage
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               LConstantPoolExample;
  #16 = Utf8               main
  #17 = Utf8               ([Ljava/lang/String;)V
  #18 = Utf8               args
  #19 = Utf8               [Ljava/lang/String;
  #20 = NameAndType        #27:#28        // out:Ljava/io/PrintStream;
  #21 = Utf8               This is a constant string
  #22 = NameAndType        #7:#8          // CONSTANT_STRING:Ljava/lang/String;
  #23 = Class              #29            // java/io/PrintStream
  #24 = NameAndType        #30:#31        // println:(Ljava/lang/String;)V
  #25 = Utf8               ConstantPoolExample
  #26 = Utf8               java/lang/System
  #27 = Utf8               out
  #28 = Utf8               Ljava/io/PrintStream;
  #29 = Utf8               java/io/PrintStream
  #30 = Utf8               println
  #31 = Utf8               (Ljava/lang/String;)V

从上述输出中可以看到,静态常量池里包含了类、方法、字段的符号引用以及字符串字面量等信息。

运行时常量池

当Java虚拟机(JVM)加载一个类时,会将该类的静态常量池中的内容加载到内存中,形成运行时常量池。运行时常量池是每个类在运行时的常量池,它是方法区的一部分(在JDK 1.8之前,方法区是永久代的一部分;在JDK 1.8及之后,方法区由元空间实现)。运行时常量池在类加载的解析阶段,会将部分符号引用解析为直接引用。例如,对于前面提到的 ConstantPoolExample 类,当JVM加载该类时,会将 ConstantPoolExample.class 的静态常量池内容加载到运行时常量池中。在执行 printMessage 方法时,会将对 System.out.println 方法的符号引用解析为直接引用,从而能够正确调用该方法。运行时常量池具有动态性,不仅可以存放编译期就确定的常量,还可以在运行时将新的常量放入池中。比如,通过 intern() 方法就可以在运行时将一个字符串放入运行时常量池(后面会详细介绍 intern() 方法)。

字符串常量池

字符串常量池是运行时常量池的一个特殊部分,它专门用于存储字符串字面量。在Java中,字符串是一种非常常用的数据类型,字符串常量池的存在对于字符串的共享和内存优化起到了重要作用。

字符串常量池的工作原理

当代码中出现字符串字面量时,JVM首先会在字符串常量池中查找是否已经存在相同内容的字符串。如果存在,则直接返回该字符串在常量池中的引用;如果不存在,则在常量池中创建一个新的字符串对象,并返回其引用。例如:

String str1 = "Hello";
String str2 = "Hello";

在上述代码中,当执行 String str1 = "Hello"; 时,JVM会在字符串常量池中查找是否有 “Hello” 这个字符串。由于此时常量池中没有,所以会在常量池中创建一个 “Hello” 字符串对象,并将其引用赋给 str1。当执行 String str2 = "Hello"; 时,JVM再次在常量池中查找,发现已经有 “Hello” 字符串,所以直接将常量池中 “Hello” 字符串的引用赋给 str2。因此,str1 == str2 的结果为 true,因为它们指向的是字符串常量池中的同一个对象。

new String() 与字符串常量池

当使用 new String() 方式创建字符串时,情况会有所不同。例如:

String str3 = new String("World");
String str4 = "World";

在执行 String str3 = new String("World"); 时,首先会在字符串常量池中查找是否有 “World” 字符串。如果没有,则在常量池中创建一个 “World” 字符串对象。然后,在堆内存中创建一个新的 String 对象,其内容为 “World”,并将堆内存中这个 String 对象的引用赋给 str3。而执行 String str4 = "World"; 时,由于常量池中已经有 “World” 字符串,所以直接将常量池中 “World” 字符串的引用赋给 str4。因此,str3 == str4 的结果为 false,因为 str3 指向的是堆内存中的 String 对象,而 str4 指向的是字符串常量池中的对象。但是,str3.equals(str4) 的结果为 true,因为 equals 方法比较的是字符串的内容。

intern() 方法

intern() 方法是 String 类的一个 native 方法,它的作用是将字符串对象尝试放入字符串常量池中。如果常量池中已经存在相同内容的字符串,则返回常量池中该字符串的引用;如果不存在,则将该字符串对象添加到常量池中,并返回其引用。例如:

String str5 = new String("Java");
String internedStr = str5.intern();
String str6 = "Java";

在上述代码中,执行 String str5 = new String("Java"); 时,会在堆内存中创建一个 String 对象,同时在字符串常量池中创建一个 “Java” 字符串对象(如果常量池中原本没有的话)。然后执行 str5.intern();,由于常量池中已经有 “Java” 字符串,所以返回常量池中 “Java” 字符串的引用,并赋给 internedStr。执行 String str6 = "Java"; 时,同样是获取常量池中 “Java” 字符串的引用并赋给 str6。所以,internedStr == str6 的结果为 true

基本数据类型包装类的常量池

除了字符串常量池,Java中基本数据类型的包装类也有常量池的概念,不过它们的常量池实现和字符串常量池有所不同。

Integer常量池

Integer 类有一个常量池,它用于缓存 -128127 之间的 Integer 对象。当使用 valueOf() 方法或者自动装箱机制创建 Integer 对象时,如果值在 -128127 之间,会直接从常量池中获取对象,而不是创建新的对象。例如:

Integer num1 = 100; // 自动装箱
Integer num2 = Integer.valueOf(100);
System.out.println(num1 == num2); // 输出 true

在上述代码中,num1num2 都指向 Integer 常量池中的同一个对象,所以 num1 == num2 的结果为 true。但是,如果值超出了 -128127 的范围,每次都会创建新的 Integer 对象。例如:

Integer num3 = 200;
Integer num4 = Integer.valueOf(200);
System.out.println(num3 == num4); // 输出 false

这里 num3num4 是不同的对象,所以 num3 == num4 的结果为 falseInteger 常量池的存在主要是为了减少频繁创建和销毁小范围的 Integer 对象,提高性能和节省内存。

其他基本数据类型包装类的常量池情况

  • Byte常量池Byte 类的常量池缓存了所有 byte 类型的取值范围(-128 到 127)的对象。因为 byte 类型本身取值范围固定,所以无论通过 valueOf() 方法还是自动装箱,获取的都是常量池中的对象。
Byte b1 = 10;
Byte b2 = Byte.valueOf(10);
System.out.println(b1 == b2); // 输出 true
  • Short常量池Short 类的常量池缓存了 -128127 之间的 Short 对象,与 Integer 类类似。当值在这个范围内时,通过 valueOf() 方法或自动装箱获取的是常量池中的对象;超出范围则创建新对象。
Short s1 = 100;
Short s2 = Short.valueOf(100);
System.out.println(s1 == s2); // 输出 true

Short s3 = 200;
Short s4 = Short.valueOf(200);
System.out.println(s3 == s4); // 输出 false
  • Long常量池Long 类的常量池缓存了 -128127 之间的 Long 对象。同样,在这个范围内通过 valueOf() 方法或自动装箱获取的是常量池中的对象,超出范围创建新对象。
Long l1 = 100L;
Long l2 = Long.valueOf(100L);
System.out.println(l1 == l2); // 输出 true

Long l3 = 200L;
Long l4 = Long.valueOf(200L);
System.out.println(l3 == l4); // 输出 false
  • Character常量池Character 类的常量池缓存了 0127 之间的 Character 对象。对于这个范围内的字符,通过 valueOf() 方法或自动装箱获取的是常量池中的对象。
Character c1 = 'a';
Character c2 = Character.valueOf('a');
System.out.println(c1 == c2); // 输出 true

Character c3 = (char)128;
Character c4 = Character.valueOf((char)128);
System.out.println(c3 == c4); // 输出 false

FloatDouble 类没有常量池,每次通过 valueOf() 方法或自动装箱都会创建新的对象。因为浮点数的精度问题以及取值范围的广泛性,不适合采用常量池的方式进行缓存。

常量池与性能优化

常量池在Java程序的性能优化方面起着重要的作用。

减少对象创建

通过常量池,相同的字面量或符号引用可以被共享,避免了重复创建对象。例如字符串常量池,大量相同内容的字符串可以共享常量池中的对象,减少了堆内存中字符串对象的数量,从而节省了内存空间。对于基本数据类型包装类的常量池,在小范围数值的情况下,也避免了频繁创建新的包装对象,提高了性能。假设在一个循环中频繁创建相同的字符串对象,如果没有字符串常量池,每次都需要在堆内存中分配空间创建新对象,而有了字符串常量池,只需要从常量池中获取已有的对象引用,大大减少了内存分配和垃圾回收的压力。

提高程序执行效率

在方法调用时,常量池中的符号引用在解析为直接引用后,可以快速定位到目标方法的实际内存地址,提高了方法调用的效率。而且,由于常量池中的数据在编译期就已经确定,JVM可以在运行时对这些数据进行优化,例如进行一些常量折叠(Constant Folding)操作。常量折叠是指在编译期对常量表达式进行计算,并将结果替换为常量值。例如:

final int a = 10 + 20;

在编译时,10 + 20 会被计算为 30a 的值就直接被确定为 30,而不是在运行时再进行加法运算,从而提高了程序的执行效率。

常量池相关的常见问题与陷阱

在使用常量池的过程中,有一些常见的问题和陷阱需要开发人员注意。

字符串常量池的内存泄漏风险

如果在程序中不断创建大量的字符串对象,并调用 intern() 方法将其放入字符串常量池,而这些字符串对象又长时间不被释放,可能会导致字符串常量池占用大量内存,甚至引发内存泄漏。例如,在一个循环中:

for (int i = 0; i < 1000000; i++) {
    String str = new String("largeString" + i).intern();
}

在上述代码中,不断创建新的字符串并调用 intern() 方法将其放入常量池,如果这些字符串长时间不被释放,会使常量池不断膨胀,消耗大量内存。为了避免这种情况,应该尽量避免在不必要的情况下频繁调用 intern() 方法,并且及时释放不再使用的字符串对象。

基本数据类型包装类常量池的范围问题

开发人员需要清楚基本数据类型包装类常量池的范围,避免因为对常量池范围的不了解而导致逻辑错误。比如在比较 Integer 对象时,如果值超出了 -128127 的范围,使用 == 比较可能会得到不符合预期的结果。在实际开发中,对于数值比较,应该尽量使用 equals() 方法,以确保比较的是数值本身而不是对象引用。例如:

Integer num1 = 200;
Integer num2 = 200;
if (num1.equals(num2)) {
    System.out.println("The values are equal");
} else {
    System.out.println("The values are not equal");
}

这样可以避免因为 == 比较对象引用而导致的错误判断。

常量池与类加载的关系

常量池的内容是在类加载过程中加载到运行时常量池的。如果在类加载过程中出现问题,例如类文件损坏导致静态常量池数据错误,可能会影响到运行时常量池的正常使用,进而导致程序运行错误。此外,不同的类加载器加载同一个类时,会产生不同的运行时常量池,这可能会导致一些混淆。比如,两个不同的类加载器加载了同一个含有字符串常量的类,由于它们的运行时常量池相互独立,即使是相同内容的字符串常量,在这两个不同的运行时常量池中也会被视为不同的对象。开发人员在处理涉及类加载和常量池的问题时,需要充分考虑这些因素,确保程序的正确性和稳定性。

总结

Java内存中的常量池是一个复杂而重要的概念,它涵盖了静态常量池和运行时常量池,其中字符串常量池和基本数据类型包装类常量池又各有其特点和工作方式。常量池在减少对象创建、提高程序执行效率方面发挥着关键作用,但同时也存在一些容易引发问题的地方,如字符串常量池的内存泄漏风险、基本数据类型包装类常量池的范围问题以及与类加载的关系等。开发人员深入理解常量池的原理和机制,能够更好地编写高效、稳定的Java程序,避免因为对常量池的不了解而产生的各种错误和性能问题。在实际开发中,应根据具体需求合理利用常量池的特性,同时注意避免相关的陷阱,以充分发挥Java语言的优势。