妙啊😏

认识 String,从 0 开始

无论是 初学 java 还是 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[];

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

从String 的源码可以看到:

  1. String 是基于 char数组实现的
  2. String 以及 成员变量 value、hash 都是 final 修饰的。

为什么要使用 final 去修饰呢?先看看 final 关键字的作用:

  1. 修饰的类,无法被继承。
  2. 修饰的方法,无法被重写。
  3. 修饰的变量,必须初始化,且初始化之后,值不能更改。
  4. 隐藏属性:如果 final 修饰的 是一个 普通对象类型, 那么只能限定其引用不能修改,其非 final 修饰的成员变量 的值 ,是可以修改的,就好比我改了名字,但在改名前后,我任然是代表我自己,指代没有发生变化。

那么,综合来看,可以得出以下结论:

  1. Stirng 对象无法被继承。
  2. 成员 value 和 hash 必须被初始化。
  3. 成员 value 的引用不能更改、成员hash 的值不能被修改。

注意第三条,value 只是引用不能修改,,因为它是 final char[] 对象,在不修改引用的前提下,修改对象内部值是不违反 final 关键字的约束的(见 final 隐藏属性)。

但是,在 String 中,成员 value 除了 有 final 修饰以外,还有一个 private 修饰符,它表示 value 只能被 String 对象自身 以及 String 的 子类(上面总结第一点:String 无法被继承)访问,而 String 类中 涉及 对 value 写入 的方法,只有 构造器了,所以 String 的成员 value 既不能被修改引用,又不能修改 value 对象内的值。

再次归纳一下:
String 对象自创建后,它的对象引用无法被修改、内部成员 的引用 和值 也都无法被修改,它从初始化后,就没有办法被改变了,我们就称这个特性为”不可变性“ 好了。

但是,这也太奇怪了,它明明是一个对象不是吗?为什么要给他加上这种 ”不可变性“的约束呢?
要我说,这样的对象,它除了定义本身是对象、有构造器 和 诸多成员方法外,更像是一个基础数据类型呢!事实上,我们在使用 String 类型变量的 便捷、频率上,它几乎是和基础类型没什么区别,甚至我在刚刚接触编程的时候,都有一种 String 是基础数据类型的错觉!

要解释这个疑问,不妨逆向思维一下:假如 String 没有 ”不可变性“,现有的程序会出现哪些问题?

  1. 静态 String 变量将失去意义,比如如下常量,可以通过实现 String 的子类来修改其内部属性。
  2. String 对象,将变得不安全,和第一种情况相似,任何一个线程都有可能会修改 String 对象的内容。
  3. String 的 hash 将可改变,这会导致 HashMap 失去作用(每次hash 改变都要去 计算hash,改变所属桶),因为 HashMap 的键值设计就是基于 String 的 hashCode。
  4. 我们常使用的 字符串常量池, 也将失去作用,当 String 可变时, 就已经不能满足常量这一称呼了。

所以,String 类型“不可变性”的作用是如此的重要, 正是有了这一个特性,我们才能:

  • 像使用常量一样随意去使用它,不用考虑它的安全问题
  • 能像常量一样把常用的 String 对象 存储到 常量池中, 节省内存
  • 利用 hashCode 快速在 HashMap 中 存储、查找元素

String 创建机制

如果把 String 比作子弹,不可变性 就好比附魔,jdk 提供的 String 创建机制 就是一把能打出附魔子弹的手枪。

下面一段代码使用了常见的几种 方式来 创建 String 对象:

public class StringDemo {
	public void init() {
		String s1 = "foo bar"; // 1
		String s2 = new String("foo bar");// 2
		String s3 = "foo " + "bar";// 3
		String s4 = "foo " + new String("bar");// 4
		String s5 = String.valueOf("foo bar");// 5
	}
}

直接双引号创建、支持 + 操作符,这些便捷的语法,或许和编译器有关,但,他们之间有没有区别呢?大致的原理又是什么?是时候让字节码发言了!

// class version 52.0 (52)
// access flags 0x21
public class StringDemo {

  // compiled from: StringDemo.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcj/StringDemo; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public init()V
   L0
    LINENUMBER 14 L0
    LDC "foo bar" // 常量池 对象 的引用,入栈
    ASTORE 1 // 栈顶出栈,赋值给 变量表 index=1 的变量,s1
   L1
    LINENUMBER 15 L1
    NEW java/lang/String // 创建 String 对象,入栈
    DUP // 赋值栈顶对象并入栈
    LDC "foo bar" // 常量池 对象 的引用,入栈
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V // 调用 String 构造器
    ASTORE 2 // 栈顶出栈,赋值给 变量表 index=2 的变量,s2
   L2
    LINENUMBER 16 L2
    LDC "foo bar" // 常量池 对象 的引用,入栈
    ASTORE 3 // 栈顶出栈,赋值给 变量表 index=3 的变量,s3
   L3
    LINENUMBER 17 L3
    NEW java/lang/StringBuilder // 创建 StringBuilder 对象,入栈
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V // 调用 StringBuilder 构造器
    LDC "foo " // 常量池 对象 的引用,入栈
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; // 调用 StringBuilder#append ,返回值入栈
    NEW java/lang/String // 创建 String 对象,入栈
    DUP
    LDC "bar" // 常量池 对象 的引用,入栈
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V // 调用 String 构造器,利用 常量池中“bar”的引用创建新的对象
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; // 调用 StringBuilder#append ,返回值入栈
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    ASTORE 4 // 栈顶出栈,赋值给 变量表 index=4 的变量,s4
   L4
    LINENUMBER 18 L4
    LDC "foo bar" // 常量池 对象 的引用,入栈
    INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String;
    ASTORE 5 // 栈顶出栈,赋值给 变量表 index=5 的变量,s5
   L5
    LINENUMBER 19 L5
    RETURN
   L6
    LOCALVARIABLE this Lcj/StringDemo; L0 L6 0
    LOCALVARIABLE s1 Ljava/lang/String; L1 L6 1
    LOCALVARIABLE s2 Ljava/lang/String; L2 L6 2
    LOCALVARIABLE s3 Ljava/lang/String; L3 L6 3
    LOCALVARIABLE s4 Ljava/lang/String; L4 L6 4
    LOCALVARIABLE s5 Ljava/lang/String; L5 L6 5
    MAXSTACK = 4
    MAXLOCALS = 6
}

先解释几个指令:

  • NEW:创建对象,参数为对象类型
  • DUP: 复制栈顶的一个或两个数值并将其重新压入栈顶
  • LDC: 接收一个 8 位的参数,指向常量池中的索引
  • ASTORE n: 从操作数中弹出一个引用数据类型,并把它赋值给局部变量表中索引为 n 的变量
  • INVOKESPECIAL: 调用一些需要特殊处理的方法,包括构造方法、私有方法和父类方法
  • INVOKEVIRTUAL: 调用对象的成员方法,根据对象的实际类型进行分派,支持多态
  • INVOKESTATIC: 调用静态方法

逐个对象对照分析:

  • s1 直接使用双引号创建, 其实际是常量池对象的引用,也就是说,直接双引号创建的字符串都是直接创建在了常量池中。
  • s2 通过构造器创建,它是 基于 常量池对象引用 ,创建(new)的 新的对象,即:先在常量池中创建了对象,再基于该对象引用,在堆中创建新的对象,不考虑s1的话,这里一共创建了2个对象!
  • s3 看上去和 s1 指令相同,说明编译器将其优化了
  • s4 通过 + 拼接 两段 字符串,它的本质居然是 创建了一个空的 StringBuilder,经过两次apend后,调用toString 返回的对象。

至此 String 三种创建机制的原理和区别,已经清晰明了:

  1. “”创建: 创建了1个对象, 对象在常量池中
  2. new String(""):创建了 2 个对象, 第一个对象在常量池中,后一个对象value 是 前一个对象value的引用
  3. “+”拼接:如果拼接的是两段 双引号字符串,那么将直接被编译器优化一个双引号字符串(方式1),否则,将使用 StringBuilder 进行 拼接,其中,每一段构造器创建子串 都将执行 方式2 。

字符串常量池

上一步的字节码中,有一个指令出现得很频繁:LDC(创建常量池对象,返回其引用)
这里常量池准确来说是指代 字符串常量池,那么,字符串常量池是什么?

探究常量池,可以从 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&trade; 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 类私有地维护的] [初始容量为空的] 字符串池。

intern 方法的作用就是:如果池中 已经有了一个 (与当前对象内容) 相同value 的对象,返回池中对象;否则, 将当前对象添加到池中,并返回 当前对象的引用。

注释中还提到:所有明面上的字符串和 字符串值常量表达式,都是已经执行了 intern 方法的。所谓 明面上的字符串 就是 上面提到的 双引号创建的字符串,如:String s1 = "abc",字符串常量表达式则是形如 「String s2 =“a”+ "b"」 这种 表达式,这也应证了前面分析 String 创建机制 的字节码内容。

intern 是一个 native 方法,它的实现部分是在 jdk 的 C++ 类中,在线查看 openjdk 源码(我选择的是 open jdk 8

jdk: String.c

#include "jvm.h"
#include "java_lang_String.h"

JNIEXPORT jobject JNICALL
Java_java_lang_String_intern(JNIEnv *env, jobject this)
{
    return JVM_InternString(env, this);
}

\jdk-687fd7c7986d\src\share\javavm\export\jvm.h:
hostspot: jvm.h

...
/*
 * java.lang.String
 */
JNIEXPORT jstring JNICALL
JVM_InternString(JNIEnv *env, jstring str);

hostspot: 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.lang.String#intern 是 通过 JNI(Java Native Interface) 调用 C++ 代码中的 StringTable 的 intern 方法实现的。 StringTable 是类似与 java 中 HashMap的结构,区别是,它的长度是固定的: 1009,另外,我们可以通过设置 jvm 启动参数修改默认大小:

  -XX:StringTableSize=9999

jdk1.7 以前的版本中,字符串常量池 维护在永久代中,但是在 1.7 版本开始,jdk 移除了永久代,字符串常量池移到了 元数据区,该区又是维护在了堆内存中,这样可以将 永久代 OOM 的风险转到 堆中。字符串常量池的移动对 版本的兼容是有一定影响的,典型的就是:

创建了几个 String 对象 的问题,我们实际应用中可能不会去触发这个问题,但是这个问题作为一个基础知识,能绕晕一大片老司机😵

是的,我必然是要分析这个经典八股题的,这篇八股能讲清楚了,那是真正对 『字符串常量池』、『永久代和 元数据区的关系』 了解的透彻了。

public static void main(String[] args) {
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2); 

    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
}

输出结果:
jdk1.6: false false
jdk1.7: false true

为什么是这个结果,可以结合以下内存变量内存分布图来分析:

     ┌────────┐      ┌───────Heap───────────┐  ┌────PermGen───────────┐
     │        │      │                      │  │                      │
     │        │      │     ┌─────────┐      │  │ ┌────String Pool───┐ │
     │   s ───┼──────┼────►│  String │      │  │ │                  │ │
     │        │      │     │   Obj   │--------------►┌──────────┐   │ │
     │        │      │     └─────────┘      │  │ │   │          │   │ │
     │        │      │                      │  │ │   │   "1"    │   │ │
     │  s2 ───┼──────┼──────────────────────┼──┼─┼──►│          │   │ │
     │        │      │                      │  │ │   └──────────┘   │ │
     │        │      │     ┌─────────┐      │  │ │                  │ │
     │        │      │     │ String  │      │  │ │   ┌──────────┐   │ │
     │  s3 ───┼──────┼────►│   Obj   │      │  │ │   │          │   │ │
     │        │      │     └─────────┘      │  │ │   │   "11"   │   │ │
     │        │      │                      │  │ │   │          │   │ │
     │  s4 ───┼──────┼──────────────────────┼──┼─┼──►└──────────┘   │ │
     │        │      │                      │  │ │                  │ │
     │        │      │                      │  │ └──────────────────┘ │    ─────►:对象引用
     │        │      │                      │  │                      │    -----►:对象值引用
     └────────┘      └──────────────────────┘  └──────────────────────┘
                             jdk 1.6

先来看 jdk1.6 ,结合前面的 String 实例化方式以及字节码知识逐条分析:

  1. String s = new String("1")
    创建 s 对象的时候, 常量池中以及有常量 "1" 了, 然后基于该常量创建了新的对象 s,此时, s 对象 不等于常量池中常量,但其 「char[] value」是指向 常量池中 常量 的 value (见构造器)。
  2. s.intern()
    由于常量池中已经有了对象,s.intern() 直接返回常量池中的对象,注意该表达式并没有使用变量来接收返回值,故不产生任何影响。
  3. String s2 = "1"
    使用书面表达式创建字符串,所以 s2 是直接引用的 常量池对象。

因此,s 指向堆中对象,s2 指向常量池对象,二者不相同,但它们指向的对象 的 值(value)相同。

  1. String s3 = new String("1") + new String("1");
    根据 前面字节码分析,可知 s3 创建流程为:

    1. 创建 StringBuilder 对象
    2. 创建常量池对象 “1”
    3. 用常量池对象的值(value)在堆中创建一个新的对象“1”
    4. StringBuilder 对象 append 新创建的对象
    5. 重复第 3 步,第4 步
    6. s3 = StringBuilder#toString

    s3 创建完成后,常量池中创建了对象 “1”,但没有创建常量 “11”

  2. s3.intern();
    由于常量池中没有 “11”,因此将 s3 放到常量池中,由于 s3 在堆中,常量池在 永久区,因此此时的 『将 s3 放入 常量池』实际是 『用 s3的值在常量池中创建一个对象,二者对象、对象值不存在任何引用关系』。这里 的 intern 放回的是 常量池中创建的新对象,其返回值没有变量接收,不产生任何影响。

  3. String s4 = "11";
    s4 使用书面表达式创建字符串,所以 s4 = 常量池中对象

    因此,s3 指向堆中对象, s4 指向 常量池中对象, 其对象 和 对象值 没有任何关系

     ┌────────┐      ┌────────────────────Heap────────────────────────┐
     │        │      │                                                │
     │        │      │     ┌─────────┐           ┌─────tring Pool───┐ │
     │   s ───┼──────┼────►│  String │           │                  │ │
     │        │      │     │    Obj  │--------------►┌──────────┐   │ │
     │        │      │     └─────────┘           │   │ String   │   │ │
     │        │      │                           │   │    1     │   │ │
     │  s2 ───┼──────┼───────────────────────────┼──►│          │   │ │
     │        │      │                           │   └──────────┘   │ │
     │        │      │     ┌─────────┐           │                  │ │
     │        │      │     │ String  │           │   ┌──────────┐   │ │
     │  s3 ───┼──────┼────►│   11    │◄──────────┼───┤  String  │   │ │
     │        │      │     └─────────┘           │   │    Obj   │   │ │
     │        │      │                           │   │          │   │ │
     │  s4 ───┼──────┼───────────────────────────┼──►└──────────┘   │ │
     │        │      │                           │                  │ │
     │        │      │                           └──────────────────┘ │    ─────►:对象引用
     │        │      │                                                │    -----►:对象值引用
     └────────┘      └────────────────────────────────────────────────┘

                            jdk1.7

再来看 jdk1.7

  1. String s = new String("1")
  2. s.intern()
  3. String s2 = "1"

s 和 s2 创建 过程和结果 与 jdk 1.6 一样,唯一区别是常量池位置。

  1. String s3 = new String("1") + new String("1");
    s3 过程和结果 与 jdk 1.6 一样
  2. s3.intern();
    由于常量池中没有 “11”,因此将 s3 放到常量池中,由于 s3 在堆中,常量池也在堆中,因此,intern 会直接将 s3 “放入”到常量池中,其结果就是 常量池对象 是 s3 对象(堆中对象)的引用
  3. String s4 = "11";
    s4 使用书面表达式创建字符串,所以 s4 = 常量池中对象 = s3

合理适用 字符串常量池/intern

字符串常量池的作用就一个:节省空间
通常来讲,空间和时间不可兼得,节省了空间,就会耗费更多时间,intern 操作 是有额外时间开销的。
如果常量池使用不当, 这个时间开销可能会被放大,甚至有 OOM的风险。

举个栗子,假如一个应用 持续产生 不重复的字符串, 并每次获取字符串时都尝试使用 intern 将其存入常量池 或从 常量池返回已有对象,由于 字符串不重复,intern 实际 是持续往 StringTable put 数据的,前面我们提到过,StringTable 的结构和 HashMap 类似,我们可以类比 HashMap 来分析:

首先,HashMap 是有 若干个 桶 组成, 每个桶又由一个链表或红黑树组成,当元素特别多的时候,Hash 碰撞比较频繁, 桶内 链表长度 或 树的深度会非常大,此时若是 查询,时间开销将很大。

intern 在将数据 放入到 StringTable 前,是需要去查字符串值是否已经在 StringTable 中的,此时刚好就产生了大量的时间开销。

那么正确的姿势是?
大量字符串对象且重复率高的应用,可以在字符串获取的时候使用 intern,这样虽然增加了些许时间开销,但能节省大量 新对象的空间开销。 值得注意的是,实际使用中也要根据 字符串对象的量 配置合适的 StringTableSize,太小,Hash碰撞 导致 查询耗时增加, 太大, 浪费内存。

参考:
美团-深入解析 String#intern
openjdk 源码