Java 中容易混淆的概念:Java 8 中的常量池、字符串池、包装类对象池

1 - 引言

摘录一些网上流传比较广泛的认识,但如果你认为只懂这些就够了,这篇文章就没有必要继续看下去了!!!

  1. 常量池分为静态常量池、运行时常量池。
  2. 静态常量池在 .class 中,运行时常量池在方法区中,JDK 1.8 元空间(metaspace)成为方法区的新实现,永久代被废除。
  3. 字符串池在JDK 1.7 之后被分离到堆区。
  4. String str = new String("Hello world") 创建了 2 个对象,一个驻留在字符串池,一个分配在 Java 堆,str 指向堆上的实例。
  5. String.intern() 能在运行时向字符串池添加常量。
  6. 部分包装类实现了池化技术,-128~127 以内的对象可以重用。

本文的实例讲解都是针对 HotSpot 虚拟机的,如下图,一般 Oracle 官网上安装的 JDK 都使用该款虚拟机,使用 java -version 就能查看相关信息了。
在这里插入图片描述

2 - 常量池

2.1 你真的懂 Java的“字面量”和“常量”吗?

在计算机科学中,字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示,诸如:整数、浮点数以及字符串等 1 。整数是程序中最常用的数字,整数在 Java 中就是一个整数字面量,例如十进制的1、2、16等,16进制的0x01、0x0A等。Java 中的字符串字面量和其他大多数语言相同,将一系列字符用双引号括起来,如 "Hello world"等。

那么常量又是什么呢?如果是从 C/C++ 转过来的程序员,一般认为常量是被 const 修饰的变量或者某些宏定义,而在 Java 中,final 修饰的变量也可以被称为是常量。

但 Java 程序员的圈子里,常量不单单指 final 变量,任何具有不变性的东西我们将它称为常量也不会带来什么歧义。

偶尔会在某些论坛中看到“字符串是常量,不可修改”。那么,这种说法是从哪里来的呢?这就要提到到 Java 中 String 类的设计了,打开 String 的源码,我们看到前面的几行定义如下:

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

我们看到不仅类定义使用 final 修饰,关键的字符数组同样声明为 private final。但这就能保证字符串的不可修改性吗?并不能,final修饰类定义只能使类不被继承,字符数组被 final 修饰只能保证 value 不能指向其他内存,但我们仍然可以通过 value[0] = 'V' 的方式直接修改 value 的内容。

String 是不可变,关键是因为 SUN 公司的工程师,在后面所有 String 的方法里很小心的没有去动数组里的元素,没有暴露内部成员字段。private final char value[] 这一句里,private的私有访问权限的作用都比 final 大。而且设计师还很小心地把整个 String 设成 final 禁止继承,避免被其他人继承后破坏。所以 String 是不可变的关键都在底层的实现,而不是一个 final。考验的是工程师构造数据类型,封装数据的功力 2

关于 String 的不可修改性更详细的内容请参考引用 2

2.2 常量和静态/运行时常量池有什么关系?什么是常量池?

在Java程序中,有很多的东西是永恒的,不会在运行过程中变化。比如一个类的名字,一个类字段的名字/所属类型,一个类方法的名字/返回类型/参数名与所属类型,一个常量,还有在程序中出现的大量的字面值。而这些在JVM解释执行程序的时候是非常重要的。那么编译器将源程序编译成class文件后,会用一部分字节分类存储这些不变的代码,而这些字节我们就称为常量池 3

Java 中静态/运行时常量池并非特指保存 final 常量,它还保存诸如字面量、类和接口全限定名、字段、方法名称、修饰符等永恒不变的东西。

2.3 字节码下的常量池以及常量池的加载机制

JDK 1.8 下常量池存储的常量类型主要是字面量和符号引用。

下面是静态/运行时常量池的常量表类型:

常量表类型标志值(占1 byte)描述
CONSTANT_Utf81UTF-8编码的Unicode字符串
CONSTANT_Integer3int类型的字面值
CONSTANT_Float4float类型的字面值
CONSTANT_Long5long类型的字面值
CONSTANT_Double6double类型的字面值
CONSTANT_Class7对一个类或接口的符号引用
CONSTANT_String8String类型字面值的符号引用
CONSTANT_Fieldref9对一个字段的符号引用
CONSTANT_Methodref10对一个类中方法的符号引用
CONSTANT_InterfaceMethodref11对一个接口中方法的符号引用
CONSTANT_NameAndType12对一个字段或方法的部分符号引用

下面讲一下符号引用:一个 Java 程序启动时加载了众多的类,有JDK的,也有我们自己定义的,那么我们怎么在程序运行的时候准确定位到类的位置呢?比如 String str = new String("xxx"),我们怎么在虚拟机内存中找到 String 这个类的定义(或者说类的字节码)呢?

答案就在常量池的符号引用中。在未加载到JVM的时候,在 .class 文件的静态常量池中我们可以找到这么一项 CONSTANT_Class,当然这一项仅仅只是符号引用,我们只知道有 java.lang.String 这么一个类。只有等 JVM 启动,并判断程序用到 java.lang.String 的时候才会加载 String 的 .class 文件到内存中(准确地说是方法区),之后,我们就可以在运行时常量池中将原本的符号引用替换为直接引用了。也就是说实际上我们的定位是依靠运行时常量池的,这也就是为什么运行时常量池对于动态加载非常重要的原因。

详细的内容可以了解一下 JVM 的类加载过程(加载、连接和初始化),如下图,将 .class 文件中的静态常量池转换为方法区的运行时常量池发生在“Loading”阶段,而符号引用替换为直接引用发生在 “Resolution”阶段。

在这里插入图片描述
我们特别关注 CONSTANT_Utf8CONSTANT_String 这两种常量类型。

CONSTANT_Utf8:用 UTF-8 编码方式来表示程序中所有的重要常量字符串。这些字符串包括: ①类或接口的全限定名, ②超类的全限定名,③父接口的全限定名, ④类字段名和所属类型名,⑤类方法名和返回类型名、以及参数名和所属类型名,⑥字符串字面值。

每一个 CONSTANT_Utf8 常量项包括三项信息:length of byte array、length of string、string,以 System.out.println("Hello world") 为例,我们可以找到下面这两个 utf8 常量项(out、println 相关常量项省略了)。
在这里插入图片描述
在这里插入图片描述

CONSTANT_String:字符串字面量都以 utf8 的形式存储,但是使用CONSTANT_Utf8 存储的各种类型字符串这么多,哪些是字符串字面量?哪些是全限定名字符串?所以需要一些指向该 utf8 项的符号引用常量来区分。CONSTANT_Class 的作用也是类似的,指向的是类全限定名的 utf8 项。

更加详细的内容参考《Java虚拟机规范 Java SE 8版》4

2.4 是不是所有的数字字面量都会被存到常量池中?

看看下面的代码:

void main(){
	int i = 1;
}

是不是能在常量池中找到CONSTANT_Integer 为 1 的项呢?很遗憾,我们并没有找到这么一项 ,直到 int i = 32768 我们才在表中找到 CONSTANT_Integer 为 32768 的项。

为什么会出现这种情况呢?对于整数字面量来说,如果值在 -32768~32767 都会直接嵌入指令中,而不会保存在常量区。

对于 long、double 都有一些类似的情况,比如long l = 1Ldouble d = 1.0,都找不到对应的常量项。

但是如果使用 final 修饰变量,将其定义成类常量(注意不是在方法体内定义的局部常量),结果又有所不同,如下:

class main{
	final int i = 1;
}

此时,我们可以在常量池中找到 CONSTANT_Integer 为 1 的项。

3 - 包装类对象池 ≠ \ne =JVM 常量池

包装类的对象池(也有称常量池)和JVM的静态/运行时常量池没有任何关系。静态/运行时常量池有点类似于符号表的概念,与对象池相差甚远。

包装类的对象池是池化技术的应用,并非是虚拟机层面的东西,而是 Java 在类封装里实现的。打开 Integer 的源代码,找到 cache 相关的内容:

 	/**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * sun.misc.VM class.
     */

    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() {}
    }

	/**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

IntegerCache 是 Integer 在内部维护的一个静态内部类,用于对象缓存。通过源码我们知道,Integer 对象池在底层实际上就是一个变量名为 cache 的数组,里面包含了 -128 ~ 127 的 Integer 对象实例。

使用对象池的方法就是通过 Integer.valueOf() 返回 cache 中的对象,像 Integer i = 10 这种自动装箱实际上也是调用 Integer.valueOf() 完成的。

如果使用的是 new 构造器,则会跳过 valueOf(),所以不会使用对象池中的实例。

Integer i1 = 10;
Integer i2 = 10;
Integer i3 = new Integer(10);
Integer i4 = new Integer(10);
Integer i5 = Integer.valueOf(10);

System.out.println(i1 == i2);  // true
System.out.println(i2 == i3);  // false
System.out.println(i3 == i4);  // false
System.out.println(i1 == i5);  // true

注意到注释中的一句话 “The cache is initialized on first usage”,缓存池的初始化在第一次使用的时候已经全部完成,这涉及到设计模式的一些应用。这和常量池中字面量的保存有很大区别,Integer 不需要显示地出现在代码中才添加到池中,初始化时它已经包含了所有需要缓存的对象。

4 - 字符串池

字符串池也是类似于对象池的这么一种概念,但它是 JVM 层面的技术。

在 JDK 1.6 以及以前的版本中,字符串池是放在 Perm 区(Permanent Generation,永久代)。Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,容量是固定的,默认在 32 M 到 96 M 间,我们可以通过 -XX:MaxPermSize = N 来配置永久代的大小,但是在运行过程中它仍然还是固定大小的。也有说 Perm 区实际上就是 HotSpot 下的方法区,HotSpot 的开发人员更愿意将方法区称为 Permanent Generation,这里我们不做过多的探讨。

在 JDK 1.7 的版本中,字符串池移到Java Heap。在 JDK 1.8 中永久代的说法被废弃,元空间成为方法区的替代品。(本文 5.1 章节补充关于为什么永久代被废弃)

4.1 字符串池的实现——StringTable

由于字符串池是虚拟机层面的技术,所以在 String 的类定义中并没有类似 IntegerCache 这样的对象池,String 类中提及缓存/池的概念只有intern() 这个方法,我将部分注释做了一些翻译和删减:

	/**
     * 返回一个标准的字符串对象。
     * 
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * 
     * 当 intern 方法被调用,若池中包含一个被{@link #equals(Object)}方法认定为和该
     * String对象相等的String,那么返回池中的String,否则,将该String对象添加到池中
     * 并返回它的引用。
     * 
     * All literal strings and string-valued constant expressions are
     * interned. 
     */
    public native String intern();

我们看到,intern() 是一个native 的方法,那么说明它本身并不是由 Java 语言实现的,而是通过 jni (Java Native Interface)调用了其他语言(如C/C++)实现的一些外部方法。

在 JDK 1.7后,Oracle 接管了 Java 的源码后就不对外开放了,根据 JDK 的主要开发人员声明 openJdk7 和 JDK 1.7 使用的是同一分主代码,只是分支代码会有些许的变动。所以可以直接跟踪 openJdk7 的源码来探究 intern() 的实现。

 \openjdk7\hotspot\src\share\vm\prims\jvm.cpp

// String support 
JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
  JVMWrapper("JVM_InternString");
  JvmtiVMObjectAllocEventCollector oam;
  if (str == NULL) return NULL;
  oop string = JNIHandles::resolve_non_null(str);
  oop result = StringTable::intern(string, CHECK_NULL);
  return (jstring) JNIHandles::make_local(env, result);
JVM_END

大体实现:Java 调用 c++ 实现的 StringTable 的 intern() 方法。StringTable 的 intern() 方法跟 Java 中的 HashMap 的实现是差不多的,只是不能自动扩容,默认大小是1009。(不懂HashMap/HashSet/HashTable 实现的赶紧恶补~)

字符串池(String pool)实际上是一个 HashTable。Java 中 HashMap 和 HashTable 的原理大同小异,将字符串池看作哈希表更便于我们套用学习数据结构时的一些知识。比如解决数据冲突时,HashMap 和 HashTable 使用的是开散列(或者说拉链法)。

参考引用 5

The string pool is implemented as a fixed capacity hash map with each bucket containing a list of strings with the same hash code. Some implementation details could be obtained from the following Java bug report: http://bugs.sun.com/view_bug.do?bug_id=6962930.

常量池是一个固定容量的 hash map,每一个 bucket 包含一系列相同 hash 码的字符串。

The default pool size is 1009 (it is present in the source code of the above mentioned bug report, increased in Java7u40). It was a constant in the early versions of Java 6 and became configurable between Java6u30 and Java6u41. It is configurable in Java 7 from the beginning (at least it is configurable in Java7u02). You need to specify -XX:StringTableSize = N, where N is the string pool map size. Ensure it is a prime number for the better performance.

池的默认大小为 1009,在早期的 1.6 版本中是固定的,但是在 Java6u30 后,我们已经可以通过 -XX:StringTableSize = N 参数来配置这个 hash map 的大小。

This parameter will not help you a lot in Java 6, because you are still limited by a fixed size PermGen size. The further discussion will exclude Java 6.

但是配置 hash map 的大小在 1.6 版本中意义不大,因为,此时 String pool 还在永久代中,正如我们前面所说,永久代的大小是固定的,hash map 的大小受限于此,我们仍需要小心使用 intern(),否则就有溢出的风险。

4.2 字符串池存的是实例还是引用?

这个问法其实本身就不太妥当,根据《Java 虚拟机规范》,堆是供对象实例化分配的区域,Java 程序中的对象实例都应该分配在堆上,我们通过引用对这些实例进行访问。在 HotSpot 下的 reference 类型使用的都是直接指针的访问形式,也就是直接指向堆上的实例对象。(相信大家也听过 reference 使用句柄而非直接指针的另一种访问形式,不过这里讨论的是 HotSpot VM)

字符串池这个 HashTable 保存的本质上是 reference,我们实际上想要知道的是字符串池是怎么保存引用,引用的指向,有多少个实例的引用而已?

看一道比较常见的面试题,下面的代码创建了多少个 String 对象?

String s1 = new String("he") + new String("llo");
String s2 = s1.intern();

System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false,创建了 6 个对象
// 在 JDK 1.7 之后的版本输出是 true,创建了 5 个对象
// 当然我们这里没有考虑GC,但这些对象确实存在或存在过

为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern() 方法也相应发生了变化:

  1. 在 JDK 1.6 中,调用 intern() 首先会在字符串池中寻找 equal() 相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。
    在这里插入图片描述
  2. 在 JDK 1.7 中,由于字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的对象。字符串存在时和 JDK 1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例。
    在这里插入图片描述

由上面两个图,也不难理解为什么 JDK 1.6 字符串池溢出会抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 抛出 OutOfMemoryError: Java heap space

(题外话:写的过程中还是会下意识地说“字符串池中的字符串”而不是“字符串池中的引用指向的字符串”,也确实可以体谅为什么许多人被这些文字游戏绕得头昏脑胀)

第 4 节更加详细的内容请参考引用 6

5 - 补充

5.1 永久代为何被 HotSpot VM 废弃?

引用 7 提到原因有两个:

  1. 由于 Permanent Generation 内存经常不够用或发生内存泄露,引发恼人的java.lang.OutOfMemoryError: PermGen (在Java Web开发中非常常见)。

  2. 移除 Permanent Generation 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代 。

    This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
    —— JEP 122: Remove the Permanent Generation

早在 JDK 1.7 的版本中 Orcale 已经宣布要废弃永久代了,事实上,永久代拥有了实例对象,这本身就已经不符合虚拟机规范。大多数虚拟机都没有设置永久代的概念,字符串池的移出,更是使得永久代名存实亡,HotSpot 让永久代回归为方法区, 恐怕也是顺应潮流了。

甚至我们可以理解为 JDK 1.6 下的 永久代 = 字符串池 + 方法区 或者 永久代 = (包含字符串池的)方法区。了解它的真实存在形式,怎么称呼也无伤大雅。

5.2 为什么 Java 要分常量、简单类型、引用类型等

这是偶然看到的一段话,特别摘录下来:

为什么 Java 要分常量、简单类型、引用类型等?
显然 Java 并非是为了考试和刁难它的使用者而徒增这些概念的。唯一的动机就是增加复杂性换取性能。那么如果不换取性能,最简单的方式是什么呢?显然就是一切变量都是引用类型,这是最简单的,一个引用类型可以概括 Java 里所有的东西。

那么简单类型和常量是什么?它是特例,用特例换取性能。
对于整数来说,它频繁参与到计算中,如果用定义一个类,并且使用一个指针的方式来使用它,就会浪费很多性能,所以才有了简单类型。而常量是怎么回事?它是对大量重复使用的引用类型的一种性能优化,用共享对象的方式,来将大量相同的对象合并存储唯一的一份 8

6 - 初学者容易混淆的地方

  1. 提到常量池,一般指的是方法区中的静态/运行时常量池。
  2. 字符串池/字符串常量池/字符串对象池/String Pool/String Table 都可以看作一个东西。
  3. 包装类对象池技术和 JVM 的常量池没有任何关系。

等等…


正文结束,欢迎留言讨论。码字不易,请尊重原创,转载注明出处:https://blog.csdn.net/Xu_JL1997/article/details/89150026

说点题外话,笔者真的希望这篇文章可以被更多人看到,特别是我们这代新生的 Java 使用者们。这篇文章的东西没有什么新鲜的,拆开每一部分你都能在某些文章、文档或者书里找到,它的组织思路来源于我在学习中产生的一个又一个问题,我要做的不仅是找到答案抄下来背一背,这意义不大,这并不是考试。

很庆幸当我将这篇文章的知识串联起来并写下来时,我的收获已经远远超出预期了。我是愿意将这份收获分享给读者的,我已然尽了最大的努力,接下来只能顺其自然了。


  1. 百度百科词条“字面量” ↩︎

  2. 在java中String类为什么要设计成final ↩︎ ↩︎

  3. 百度百科词条“常量池” ↩︎

  4. 《Java虚拟机规范 Java SE 8版》 ↩︎

  5. String.intern in Java 6, 7 and 8 – string pooling ↩︎

  6. String的Intern方法详解 ↩︎

  7. 深入探究JVM(2) - 探秘Metaspace ↩︎

  8. CSDN问答:java 局部变量 int a =1 是存放在哪里 ↩︎

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值