浅析 String
妙啊😏
认识 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 的源码可以看到:
- String 是基于 char数组实现的
- String 以及 成员变量 value、hash 都是 final 修饰的。
为什么要使用 final 去修饰呢?先看看 final 关键字的作用:
- 修饰的类,无法被继承。
- 修饰的方法,无法被重写。
- 修饰的变量,必须初始化,且初始化之后,值不能更改。
- 隐藏属性:如果 final 修饰的 是一个 普通对象类型, 那么只能限定其引用不能修改,其非 final 修饰的成员变量 的值 ,是可以修改的,就好比我改了名字,但在改名前后,我任然是代表我自己,指代没有发生变化。
那么,综合来看,可以得出以下结论:
- Stirng 对象无法被继承。
- 成员 value 和 hash 必须被初始化。
- 成员 value 的引用不能更改、成员hash 的值不能被修改。
注意第三条,value 只是引用不能修改,,因为它是 final char[] 对象,在不修改引用的前提下,修改对象内部值是不违反 final 关键字的约束的(见 final 隐藏属性)。
但是,在 String 中,成员 value 除了 有 final 修饰以外,还有一个 private 修饰符,它表示 value 只能被 String 对象自身 以及 String 的 子类(上面总结第一点:String 无法被继承)访问,而 String 类中 涉及 对 value 写入 的方法,只有 构造器了,所以 String 的成员 value 既不能被修改引用,又不能修改 value 对象内的值。
再次归纳一下:
String 对象自创建后,它的对象引用无法被修改、内部成员 的引用 和值 也都无法被修改,它从初始化后,就没有办法被改变了,我们就称这个特性为”不可变性“ 好了。
但是,这也太奇怪了,它明明是一个对象不是吗?为什么要给他加上这种 ”不可变性“的约束呢?
要我说,这样的对象,它除了定义本身是对象、有构造器 和 诸多成员方法外,更像是一个基础数据类型呢!事实上,我们在使用 String 类型变量的 便捷、频率上,它几乎是和基础类型没什么区别,甚至我在刚刚接触编程的时候,都有一种 String 是基础数据类型的错觉!
要解释这个疑问,不妨逆向思维一下:假如 String 没有 ”不可变性“,现有的程序会出现哪些问题?
- 静态 String 变量将失去意义,比如如下常量,可以通过实现 String 的子类来修改其内部属性。
- String 对象,将变得不安全,和第一种情况相似,任何一个线程都有可能会修改 String 对象的内容。
- String 的 hash 将可改变,这会导致 HashMap 失去作用(每次hash 改变都要去 计算hash,改变所属桶),因为 HashMap 的键值设计就是基于 String 的 hashCode。
- 我们常使用的 字符串常量池, 也将失去作用,当 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个对象, 对象在常量池中
- new String(""):创建了 2 个对象, 第一个对象在常量池中,后一个对象value 是 前一个对象value的引用
- “+”拼接:如果拼接的是两段 双引号字符串,那么将直接被编译器优化一个双引号字符串(方式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™ 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)
#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);
// 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 实例化方式以及字节码知识逐条分析:
- String s = new String("1")
创建 s 对象的时候, 常量池中以及有常量 "1" 了, 然后基于该常量创建了新的对象 s,此时, s 对象 不等于常量池中常量,但其 「char[] value」是指向 常量池中 常量 的 value (见构造器)。 - s.intern()
由于常量池中已经有了对象,s.intern() 直接返回常量池中的对象,注意该表达式并没有使用变量来接收返回值,故不产生任何影响。 - String s2 = "1"
使用书面表达式创建字符串,所以 s2 是直接引用的 常量池对象。
因此,s 指向堆中对象,s2 指向常量池对象,二者不相同,但它们指向的对象 的 值(value)相同。
-
String s3 = new String("1") + new String("1");
根据 前面字节码分析,可知 s3 创建流程为:- 创建 StringBuilder 对象
- 创建常量池对象 “1”
- 用常量池对象的值(value)在堆中创建一个新的对象“1”
- StringBuilder 对象 append 新创建的对象
- 重复第 3 步,第4 步
- s3 = StringBuilder#toString
s3 创建完成后,常量池中创建了对象 “1”,但没有创建常量 “11”
-
s3.intern();
由于常量池中没有 “11”,因此将 s3 放到常量池中,由于 s3 在堆中,常量池在 永久区,因此此时的 『将 s3 放入 常量池』实际是 『用 s3的值在常量池中创建一个对象,二者对象、对象值不存在任何引用关系』。这里 的 intern 放回的是 常量池中创建的新对象,其返回值没有变量接收,不产生任何影响。 -
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
- String s = new String("1")
- s.intern()
- String s2 = "1"
s 和 s2 创建 过程和结果 与 jdk 1.6 一样,唯一区别是常量池位置。
- String s3 = new String("1") + new String("1");
s3 过程和结果 与 jdk 1.6 一样 - s3.intern();
由于常量池中没有 “11”,因此将 s3 放到常量池中,由于 s3 在堆中,常量池也在堆中,因此,intern 会直接将 s3 “放入”到常量池中,其结果就是 常量池对象 是 s3 对象(堆中对象)的引用 - String s4 = "11";
s4 使用书面表达式创建字符串,所以 s4 = 常量池中对象 = s3
合理适用 字符串常量池/intern
字符串常量池的作用就一个:节省空间
通常来讲,空间和时间不可兼得,节省了空间,就会耗费更多时间,intern 操作 是有额外时间开销的。
如果常量池使用不当, 这个时间开销可能会被放大,甚至有 OOM的风险。
举个栗子,假如一个应用 持续产生 不重复的字符串, 并每次获取字符串时都尝试使用 intern 将其存入常量池 或从 常量池返回已有对象,由于 字符串不重复,intern 实际 是持续往 StringTable put 数据的,前面我们提到过,StringTable 的结构和 HashMap 类似,我们可以类比 HashMap 来分析:
首先,HashMap 是有 若干个 桶 组成, 每个桶又由一个链表或红黑树组成,当元素特别多的时候,Hash 碰撞比较频繁, 桶内 链表长度 或 树的深度会非常大,此时若是 查询,时间开销将很大。
intern 在将数据 放入到 StringTable 前,是需要去查字符串值是否已经在 StringTable 中的,此时刚好就产生了大量的时间开销。
那么正确的姿势是?
大量字符串对象且重复率高的应用,可以在字符串获取的时候使用 intern,这样虽然增加了些许时间开销,但能节省大量 新对象的空间开销。 值得注意的是,实际使用中也要根据 字符串对象的量 配置合适的 StringTableSize,太小,Hash碰撞 导致 查询耗时增加, 太大, 浪费内存。