前几天在某乎上面看到了一些关于 String 的讨论:String 能否能够被继承?底层的 char array 会不会被共享?以及字符串常量池的一些问题。仔细一想,对于平时频繁的用到 String,还真没有深入的去了解过。于是就开始查询资料,进行深入的学习。
不查不知道,一查吓一跳!原来 String 里面还大有学问!不得不承认,是我太孤陋寡闻了。
以下就是对查阅资料的一个整理,希望能够加深记忆。
在 Java 6 及以前, String 主要有四个成员变量: char[] value 、 int offset 、 int count 、 int hash 。
value offset count hash
通过 offset 和 count 定位 value 数组,得到字符串;这种方式可以高效、快速的共享 value 数组对象,同时节省内存空间。但是这种方式存在一个潜在的风险:在调用 substring 的时候很有可能发生内存泄漏。
我们来看一下 Java 6 的 substring 的 实现 :
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > count) {
throw new StringIndexOutOfBoundsException(endIndex);
}
if (beginIndex > endIndex) {
throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
}
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
//新创建的 String 共享原有对象的 value 引用
}
// Package private constructor which shares value array for speed.
String(int offset, int count, char value[]) {
this.value = value;// value 直接拿过来用
this.offset = offset;
this.count = count;
}
复制代码
我们可以看到,由 substring 新生成的 String 对象共享了原有对象的 value 引用。如果 substring 的对象一直被引用,且原有 String 对象非常大,就会导致原有 String 对象的字符串一直无法被 GC 释放,从而导致 内存泄漏 。
到了 Java 7/8, String 的成员变量变成了两个: char[] value 、 int hash ;没错, int offset 、 int count 被去掉了, substring 的实现也做了一定的调整:
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// copy 了一份 value,而不是直接使用 value
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
复制代码
在调用 substring 的时候,不是共享原有的 value 数组,而是 copy 了一份。这样就解决了可能发生的内存泄漏问题。
在 Java 9 发布后, String 的成员变量又做了一次调整: char[] value 、 byte coder 、 int hash ;
为什么要这样子改呢?因为 oracle 公司觉得,用两个字节长度的 char 来存一个字节长度的 byte 有点过于浪费,为了节省空间,采用 byte[] 来存储字符串。除此之外,Java 9 还维护了一个新的属性 coder ,作为编码格式的标志,在计算字符串长度和比较字符串的时候会用到它。
既然节省了空间,那我们就来看一下 "Hello World" 在 Java 8 和 9 下的内存大小分别是多少,看看能节省多少空间。(均在 64 位系统、开启指针压缩前提下计算。对象内存大小计算可参考: 深入理解Java虚拟机之----对象的内存布局 )
(1)String 对象本身:24 bytes
(2)value[] 字符串:40 bytes
char length(2) * array length(11) (1)String 对象本身:24 bytes
coder(1) (2)value[] 字符串:32 bytes
byte length(1) * array length(11) 我们可以看到,Java 9 存储 String 对象本身和 Java 8 是一样的,虽然多了一个 byte coder ,实际上占用的是对齐填充的一个字节,没有额外的存储开销;不过对于存储字符串的长度是大大减少了。 byte 只需要一个字节存储,而 char 需要两个字节来存储,这样一来, value[] 数组的这一部分实例数据长度减半,大大减小了内存开销,并且字符串长度越长,节省的就越多。
没想到吧?JDK 在升级,String 也一直在改变,这些变化你都知道吗?
还没有升级的小伙伴们是不是也可以考虑一下要不要升级 JDK 的版本(坏笑:smirk:)。
好了,我们缓一缓,歇口气。接下来我们进入下一个环节: String 真的是 immutable 的吗?
刚开始看到这个问题的时候,我就在思考:到底怎么才算 immutable 呢?
String 文档上写有这么一句话( JDK 8#String ):
Strings are constant; their values cannot be changed after they are created. 复制代码
String 对象一旦被创建,它们的值就无法改变。
Why?为什么是这样子的呢?我带着疑问继续看了下去。紧接着我就看到 String 是一个 final 类----这代表了它不可被继承;另外,String 有两个成员变量:
/** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0 复制代码
一个被 final 修饰的 value[] 数组,用于存储字符串;和一个 int 型的 hash ,字符串的哈希值。看完 String 源码之后发现,这两个值在 String 被创建的时候初始化,并且没有对外提供任何修改它们的方法。所以我们可以看出 String 的不可变性体现在:
即:对象一旦被创建,即是不可变对象。
因为没办法通过常规的手段对 String 做修改。那么,是否真的就无法修改 String 对象了呢?
结果很显然:既然常规的手段不行,那就用非常规的手段嘛(手动滑稽)。
可能大家已经想到非常规的手段是什么了:反射。没错,就是反射!反射就是这么的强大!这里贴一段来自 stackoverflow 上的代码:
String s1 = "Hello World";
String s2 = "Hello World";
String s3 = s1.substring(6);
System.out.println(s1); // Hello World
System.out.println(s2); // Hello World
System.out.println(s3); // World
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[])field.get(s1);
value[6] = 'J';
value[7] = 'a';
value[8] = 'v';
value[9] = 'a';
value[10] = '!';
System.out.println(s1); // Hello Java!
System.out.println(s2); // Hello Java!
// 注意:Java 7 及之后输出为 World,Java 6 及之前版本为 Java! 具体原因请读者自己思考,参考 substring 的具体实现。
System.out.println(s3); // World
复制代码
从上面的代码可以看出,String 还是可以被修改的。
由此可见,从其提供的公用接口来看,String 是 immutable 的。但是如果使用一些非常规手段,也是可以修改 String 对象的。
immutable ? 上一节我们知道了在不使用非常规的前提下: String 是 immutable 的 。那么,为什么要这样设计呢?
可以参考这篇文章: Why String is Immutable in Java?
主要有以下几个原因:
String 是使用最广泛的数据结构。常量池的存在可以节省很多内存,因为值一样的不同 String 变量在常量池中只保存了一份,它们指向的是同一个对象。如果 String 是 mutable 的,那么如果其中一个 String 变量发生了改变,势必会影响到所有其他指向这个对象的 String 变量,很显然很不合理。
举个栗子:
String s1 = "Hello World"; String s2 = "Hello World" 复制代码
因为常量池的存在,没办法做到只修改 s1 变量而不影响 s2 变量。
所以,如果想把值一样的不同 String 变量在常量池中只保存一份,String 就必须是 immutable 的。
String 被广泛用于存储敏感信息,例如: usernames , passwords , connection URLs , network connections 等等,以及 JVM 类加载器也广泛使用了 String。
如果 String 是 mutable 的,很可能造成不可控的安全问题。比如看下面的代码:
void criticalMethod(String username) {
// perform security checks
if (!isAlphaNumeric(username)) {
throw new SecurityException();
}
// do some secondary tasks
initializeDatabase();
// critical task
connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
" WHERE Username = '" + username + "'");
}
复制代码
因为在方法的外部持有 username 的引用,即使在验证了 username 以后,我们也没办法保证后面执行 executeUpdate 就一定是安全的,因为没法保证在执行安全检查之后 username 没有发生改变。
不可变,所以先天线程安全;
String 的使用实在是是太广泛了,各种各样的数据结构都会用到 String,对于依赖于 hash 值的 HashMap , HashTable , HashSet 这种数据结构,会频繁的调用 hashCode() 方法,由于 String 类不可变,所以 String 类重写了 hashCode() 方法,在第一次调用 hashCode() 计算 hash 值之后就把 hash 值缓存了起来,下次调用时不需要再进行计算,极大的提高了效率。
总体来说, String 不可变的原因包括 常量池的设计 、 性能 以及 安全性 这三大方面。
下面这些是在搜索了众多资料之后整理的面试题。
在没有深入研究 String 之前,有好多都答不上来(= =)。
往下看之前需要了解的知识点: == 在比较引用类型时,比较的是引用地址。
String s1 = new String("hello");
复制代码
问:创建了几个 String 对象?
答:参考 R大的回答: 请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧
String s1 = "hello"; String s2 = "hello"; System.out.println(s1 == s2); 复制代码
答:输出 true , s1 、 s2 均指向常量池中 "hello" 的地址。
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2);
复制代码
答:输出 false , s1 为常量池中的地址,而 s2 为堆上 new 出来的对象。
String s1 = "hello"; String s2 = "he"; String s3 = "llo"; String s4 = s2 + s3; System.out.println(s1 == s4); 复制代码
答:输出 false ,上述代码等价于:
String s1 = "hello"; String s2 = "he"; String s3 = "llo"; String s4 = (new StringBuilder()).append(s2).append(s3).toString(); System.out.println(s1 == s4); 复制代码
s4 是 StringBuilder#toString() 方法 new 出来的对象。
String s1 = "hello"; final String s2 = "he"; final String s3 = "llo"; String s4 = s2 + s3; System.out.println(s1 == s4); 复制代码
答:输出 true ,由于 s2 、 s3 是被 final 修饰的 String 变量,编译器在编译的时候就能推断出 s4 = 'hello' ,所以上述代码等价于:
String s1 = "hello"; String s4 = "hello"; System.out.println(s1 == s4); 复制代码
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2);
System.out.println(s1 == s2.intern());
复制代码
答:输出 false true , s1 为常量池中地址, s2 为堆上 new 出来的对象, s2.intern() 为常量池中地址。
String s1 = new String("hello");//(1)
s1.intern();//(2)
String s2 = "hello";//(3)
System.out.println(s1 == s2);//(4)
String s3 = new String("wo") + new String("rld");//(5)
s3.intern();//(6)
String s4 = "world";//(7)
System.out.println(s3 == s4);//(8)
复制代码
答:输出:
false false (JDK 1.6 及以下)
false true (JDK 1.7 及以上)
可以先思考以下为什么会是这种结果,然后我们再来看一看到底发生了什么:
(1) 执行时会在常量池创建一个值为 "hello" 的字符串对象,同时在堆上也创建一个值为 "hello" String 对象;
(2) 执行时会首先去常量池中查看是否存在一个值为 "hello" 的常量,发现 "hello" 存在于常量池,所以直接返回常量池中 "hello" 的引用;
(3) 执行时发现 "hello" 已经存在于常量池,因此直接返回常量池中的引用;
(4) 由于 s1 指向的是堆上 new 出来的 String 对象引用,而 s2 为常量池中的引用,所以输出为 false 。
(5) 执行时会在常量池创建两个字符串对象,一个是 "wo" ,另一个是 "rld" ,同时在堆上创建了三个 String 对象,分别为两个 new 关键字创建的 "wo" 、 "rld" ,和 StringBuilder 将两个 new 出来的 String 对象 append 之后调用 toString() 方法创建的 "world" 对象,注意,此时 "world" 并未在常量池中;
(6) 执行时会首先去常量池中查看是否存在值为 "world" 的常量,发现不存在,则把 "world" 放入常量池,并返回其引用;
在 JDK 1.6 及之前的版本,常量池是放在 PermGen 区的,所以放入常量池的操作为:在 PermGen 区创建一个值为 "world" 的对象,将其引用放入常量池并返回。
而 在 JDK 1.7 及之后,常量池被移至 Heap 区,放入常量池的操作就变成了:直接将堆中 s3 对象的引用放入常量池并返回。
这也是为什么 case 7 在不同的 JDK 版本下输出结果不一样的原因。
(7) 执行时发现 "world" 已经存在于常量池,因此直接返回常量池中的引用;
(8) 对比 s3 与 s4 的值,并将结果打印出来。由于在 JDK 1.6 中, s3 与 s4 为两个不同的对象,因此输出 false ;而在 JDK 1.7 里,二者是同一个对象,所以输出为 true 。
String s1 = new String("hello");
String s2 = "hello";
s1.intern();
System.out.println(s1 == s2);
String s3 = new String("wo") + new String("rld");
String s4 = "world";
s3.intern();
System.out.println(s3 == s4);
复制代码
答:输出:
false false (JDK 1.6 及以下)
false false (JDK 1.7 及以上)
Case 8留给大家分析,可以参考 Case 7
最后,还有一个问题困扰了我很久:不在常量池的 String 变量在调用 intern() 方法时,是如何放入常量池的?对于 "" 这种方式创建的变量会自动放入常量池,那对于
String s3 = new String("wo") + new String("rld");
s3.intern();
复制代码
这种方式又是怎么放入常量池的呢?(假设调用 intern() 时 "world" 没有存在于常量池)
比如 new String("hello world"); 这一行代码我通过反编译得到字节码可以看到每一步都在做什么:
stack=3, locals=1, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #23 // String hello world
6: invokespecial #24 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: pop
10: return
复制代码
而对于 intern() 方法来说,只有一行
11: invokevirtual #25 // Method java/lang/String.intern:()Ljava/lang/String; 复制代码
这个时候我在想,既然 ldc 是从常量池中变量推送至栈顶,那么为什么没有相应的将变量放入常量池的指令呢?
其实这个时候我已经跑偏了,这应该属于 JVM 是如何实现常量池的范畴了。
其实通过 "" 这种方式创建的 String 对象会放入常量池,也没有相应的指令,在 Java 字节码层次我们只能看到 ldc 指令,即如何将常量池中的变量推送至栈顶。而对于 native 的 intern() 方法,是 C++ 写的,也不清楚当中到底做了什么操作,这个时候就恨不得自己能快速看懂 C++ 源码。虽然从大学毕业后几乎就没接触过 C++,想吃透 C++ 中 intern() 的实现,可不是一件简单的事情;不过看了一下其中的实现: jvm.cpp#l3639 和 symbolTable.cpp#l543 ,还是能了解一些大概:
oop StringTable::intern(Handle string_or_null, jchar* name,
int len, TRAPS) {
unsigned int hashValue = java_lang_String::hash_string(name, len);
int index = the_table()->hash_to_index(hashValue);
oop string = the_table()->lookup(index, name, len, hashValue);
// Found
if (string != NULL) return string;
// Otherwise, add to symbol to table
return the_table()->basic_add(index, string_or_null, name, len,
hashValue, CHECK_NULL);
}
复制代码
调用 intern() 方法时,会先去 the_table() 中找,如果找到就直接返回;否则将其加至 the_table() 中并返回。
最后,送一句话给自己,也送给大家: 每天再忙也应该给自己留点成长的时间!
参考链接:
(1) java String的intern方法
(2) Java8内存模型—永久代(PermGen)和元空间(Metaspace) (标题其实有误,应该是 Java8 运行时数据区,这里参考链接展示原标题)
(3) 深入解析String#intern
(4) Save Memory by Using String Intern in Java
(5) Is a Java string really immutable?
(6) Why is String immutable in Java?