1. String 的特性
1.1 基本特性
- String 类型的数据表现形式是使用” “表示的字符串
- String声明为final,即字符串是不可改变的,不可继承的
String实现了Serializable接口和Comparable接口,从而支持序列化和大小比较
public final class Stringimplements java.io.Serializable, Comparable<String>, CharSequence {private static final long serialVersionUID = -6849794470754667710L;...}
String底层采用
final char[] value保存数据(JDK8及以前),或是final byte[] value保存(JDK9及之后)// JDK8public final class Stringimplements java.io.Serializable, Comparable<String>, CharSequence {/** The value is used for character storage. */private final char value[];...}
之所以将char型数组变成byte型数组,原因是:字符串中通常只存在拉丁文。因此,使用byte就够了,再去使用char就显得空间有些浪费。所以,JDK9及之后修改为byte[]存储,并且加入了字符码标识。
- String类型数据在字符串常量池中存在且唯一
1.2 字符串的修改
首先需要清楚地是字符串是不可改变的,因此,针对于字符串所做的修改操作并不会影响原先的字符串。具体来说,常见的修改操作有:
当字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值,value指的是保存数据的底层数组
例如在下面的程序中,s1在重新赋值前和s2指向的都是字符串常量池中的”Forlogen”,因此,它们两者是相等的。当s1重新赋值后,实际是在字符串常量池中新建了一个字符串”Kobe”,s1此时是指向的它。因此,此时s1和s2不再相等。@Testpublic void test2(){String s1 = "Forlogen";String s2 = "Forlogen";System.out.println(s1 + " " + s2); // Forlogen ForlogenSystem.out.println(s1 == s2); // trues1 = "Kobe";System.out.println(s1 + " " + s2); // Kobe ForlogenSystem.out.println(s1 == s2); // false}
当对现有的字符串进行拼接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
例如在下面的程序中,s1在执行拼接操作前和s2指向的都是字符串常量池中的”Forlogen”,因此,它们两者是相等的。和上面同理,拼接后会在字符串常量池中新建一个字符串”Forlogen kobe”,s1指向的是它,自然s1和s2此时就不再相等了。@Testpublic void test3(){String s1 = "Forlogen";String s2 = "Forlogen";System.out.println(s1 + " " + s2); // Forlogen ForlogenSystem.out.println(s1 == s2); // trues1 += " kobe";System.out.println(s1 + " " + s2); // Forlogen kobe ForlogenSystem.out.println(s1 == s2); // false}
当调用String的
replace()修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
例如在下面的程序中,s1初始为”Forlogen”,它会在字符串常量池中存在该字符串。当使用replace()进行字符替换后,s2指向的是字符串常量池中新建的字符串,内容就是替换后的结果。因此,此时s1和s2不再相等。@Testpublic void test4(){String s1 = "Forlogen";System.out.println(s1); // ForlogenString s2 = s1.replace("F", "R");System.out.println(s2); // RorlogenSystem.out.println(s1 == s2); // false}
最后再看一个特性:通过字面量方式(区别于new)给一个字符串赋值,此时的字符串声明在字符串常量池中。同样通过程序辅助理解,程序如下所示:
@Testpublic void test1(){String s2 = new String("Forlogen");String s1 = "Forlogen"; // Forlogen保存在字符串常量池中System.out.println(s1.hashCode()); // 538205156System.out.println(s2.hashCode()); // 538205156System.out.println(s1 == s2); // false}
虽然s1和s2的字符串内容相同,因为它们的哈希值相同。但是s1位于字符创常量池中,s2是在堆中,因此两者是不等的。至于为什么会出现这种情况,后续接着详细解释。
2. 字符串常量池
字符串常量池中不会存储相同内容的字符串,也就是说同样的字符串只会在常量池中存在一份,拥有相同字符串的变量指向的是相同的地址。这是因为字符创常量池的数据结构是HashTable,它是一个键值结构,而键值结构中键存储的内容是不能有重复的。如果放入常量池中的字符串非常多,大概率会出现哈希碰撞,从而使得保存相同哈希值的字符串的链表很长,而链表很长后直接会造成的影响就是当调用String.intern()时,性能大幅度下降。通常可以根据对性能的要求,使用-XX:StringTableSize参数设置大小。
JDK6:常量池大小是确定的1009,如果常量池中的字符串过多就会导致效率下降很快 JDK7:常量池大小默认是60013,也可以通过参数自行调整 JDK8:1009是可设置的最小值
3. String的内存分配
Java中的八大数据类型和String类型都提供了常量池的概念,目的是为了使它们在运行过程中速度更快、更节省内存。常量池就类似一个Java系统级别提供的缓存,八种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的String对象会直接存储在常量池中
- 如果不是使用双引号声明的String对象,可以使用String提供的
intern()方法
Java 6及以前,字符串常量池存放在永久代。Java 7将字符串常量池的位置调整到Java堆内。Java8对于方法去的实现从永久代变成了元空间,但是字符串常量池仍然在堆中。之所以将其从永久代转移到堆中,主要原因有两个:
- PermSize默认比较小,而字符串会在程序的运行中大量的使用,可能就会出现OOM
- 永久代垃圾回收效率低,如果将字符串常量池放在永久代中,大量的字符串会占有内存空间,不仅会出现OOM,而且会造成性能下降
4. String基本操作
4.1 实例化
Java语言规范里要求完全相同的字符串字面量, 应该包含同样的Unicode字符序列(包含同一份码点序列的常量) , 并且必须是指向同一个String类实例。
例如下面的程序
public class Memory {public static void main(String[] args) {//line 1int i = 1;//line 2Object obj = new Object();//line 3Memory mem = new Memory();//line 4mem.foo(obj);//line 5}//line 9private void foo(Object param) {//line 6String str = param.toString();//line 7System.out.println(str);}//line 8}
编译后使用jclasslib查看反编译后的局部变量表,如下所示:
可以看到,param和str存放于局部变量表的不同位置。而且从内存空间出发也可以验证,如下所示:
4.2 拼接
字符串的拼接遵循如下规范:
常量和常量的拼接结果在常量池,原理是编译期优化操作。例如在下面的程序中,s1的来源是字符常量的拼接,s2是定义的字符串字面量,它们指向的是字符串常量池中的同一个字符串”abc”
@Testpublic void test1(){String s1 = "a" + "b" + "c";String s2 = "abc";System.out.println(s1 == s2); // trueSystem.out.println(s1.equals(s2)); // true}}
只要有一个是变量,结果就在堆中,原理是StringBuilder的使用
@Testpublic void test2(){String s1 = "Hello";String s2 = "World";String s3 = "HelloWorld";String s4 = "Hello" + "World";System.out.println(s3 == s4); // trueSystem.out.println("-----------------");String s5 = s1 + "World";System.out.println(s3 == s5); // falseSystem.out.println("-----------------");String s6 = "Hello" + s2;System.out.println(s3 == s6); // falseSystem.out.println("-----------------");String s7 = s1 + s2;System.out.println(s3 == s7); // falseSystem.out.println("-----------------");System.out.println(s5 == s6); // falseSystem.out.println(s5 == s7); // falseSystem.out.println(s6 == s7); // falseSystem.out.println("-----------------");}}
例如在上面的程序中:
- s4进行的常量的拼接,那么它和s3指向的都是字符串常量池中的”HelloWorld”
- s5、s6、s7都涉及变量和常量、变量和变量直接的拼接,则相当于在堆空间中new String(),那么得到的结果就在堆中,自然和s3是不等的
s5、s6、s7是经过不同的操作得到的字符串,那么它们在堆中保存在不同的位置,因此,它们彼此之间也是不同的
如果调用的是
intern(),则主动的将常量池中还没有的字符串对象放入池中,并返回此对象的地址。例如在下面的程序中调用intern(),它判断字符串常量池中是否存在”HelloWorld”值,如果存在,则返回常量池中”HelloWorld”的地址;如果字符串常量池中不存在”HelloWorld”,则在常量池中加载一份”HelloWorld”,并返回此对象的地址@Testpublic void test2(){String s1 = "Hello";String s2 = "World";String s3 = "Hello" + s2;String s4 = s3.intern();System.out.println(s3 == s4); // false}}
那么变量之间的拼接原理是什么呢?假设程序如下所示:
@Testpublic void test3(){String s1 = "Hello";String s2 = "World";String s3 = "HelloWorld";String s4 = s1 + s2;System.out.println(s3 == s4); //false}
它对应的反编译后的指令为:
0 ldc #6 <Hello>2 astore_13 ldc #7 <World>5 astore_26 ldc #8 <HelloWorld>8 astore_39 new #11 <java/lang/StringBuilder>12 dup13 invokespecial #12 <java/lang/StringBuilder.<init>>16 aload_117 invokevirtual #13 <java/lang/StringBuilder.append>20 aload_221 invokevirtual #13 <java/lang/StringBuilder.append>24 invokevirtual #14 <java/lang/StringBuilder.toString>27 astore 429 getstatic #3 <java/lang/System.out>32 aload_333 aload 435 if_acmpne 42 (+7)38 iconst_139 goto 43 (+4)42 iconst_043 invokevirtual #4 <java/io/PrintStream.println>46 return
从指令可以看到,首先s1、s2、s3在字符串常量池中创建对应的字符串。而s4 = s1 + s2;对应的执行细节是:
- 首先创建一个StringBuilder对象,
StringBuilder s = new StringBuilder(); - 然后调用
append(),s.append("Hello") - 然后调用
append(),s.append("World") - 调用
toString()返回字符串形式的结果在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
但字符串拼接操作不一定使用的是StringBuilder!
- 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式
- 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上
@Testpublic void test4(){String s1 = "Hello";String s2 = "World";String s3 = "HelloWorld";String s4 = s1 + s2;System.out.println(s3 == s4); // false}@Testpublic void test5(){final String s1 = "Hello";String s2 = "HelloWorld";String s3 = s1 + "World";System.out.println(s2 == s3); // true}
而且通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式,这是因为:
- StringBuilder的
append()的方式:自始至终中只创建过一个StringBuilder的对象 - 使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
因此,使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。所以在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化。
StringBuilder s = new StringBuilder(highLevel);
5. intern方法
5.1 概述
intern的方法描述:public native String intern();
Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.
- Returns:
a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.
如果不是用双引号声明的String对象, 可以使用String提供的intern()。intern()会从字符串常量池中查询当前字符串是否存在,如果存在,则返回常量池中该字符串的地址;如果不存在就会将当前字符串放入常量池中,并返回字符串的地址。
如果在任意字符串上调用string.intern方法, 那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。即下面程序中==两端是相等的:
("a" + "b" + "c").intern() == "abc"
通过调用intern()确保字符串在内存中只有一份拷贝,这样可以节约内存空间,加快字符串操作的执行速度。
public class StringTest4 {public static void main(String[] args) { {String s = new String("1");//调用此方法之前,字符串常量池中已经存在了"1"s.intern();String s2 = "1";System.out.println(s == s2); // false//s3变量记录的地址为:new String("11")String s3 = new String("1") + new String("1");//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!//jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址s3.intern();//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址String s4 = "11";System.out.println(s3 == s4); //true}}}
它所对应的的内存图如下所示:
5.2. 面试题
5.2.1 new String
new String创建了几个对象呢?假设程序如下所示:
@Testpublic void test2(){String s = new String("Forlogen");}
它所对应的的字节码指令为:
0 new #2 <java/lang/String>3 dup4 ldc #13 <Forlogen>6 invokespecial #4 <java/lang/String.<init>>9 astore_110 return
因此,new String操作创建了两个对象:
- new 关键字在堆中创建
- 字符串常量池中的对象”Forlogen”,对应指令ldc
5.2.2 new String(“a”) + new String(“b”)
new String(“a”) + new String(“b”)创建了几个对象呢?假设程序如下所示:
@Testpublic void test3(){String str = new String("a") + new String("b");}
对应的字节码指令为:
0 new #8 <java/lang/StringBuilder>3 dup4 invokespecial #9 <java/lang/StringBuilder.<init>>7 new #2 <java/lang/String>10 dup11 ldc #14 <a>13 invokespecial #4 <java/lang/String.<init>>16 invokevirtual #10 <java/lang/StringBuilder.append>19 new #2 <java/lang/String>22 dup23 ldc #15 <b>25 invokespecial #4 <java/lang/String.<init>>28 invokevirtual #10 <java/lang/StringBuilder.append>31 invokevirtual #11 <java/lang/StringBuilder.toString>34 astore_135 return
一般来说,它创建了5个对象:
- new StringBuilder
- new String(“a”)
- 常量池中的”a”
- new String(“b”)
- 常量池中的”b”
如果再细说,在调用toString()返回字符串的时候还创建了另一个对象,new String(“ab”),但此时常量池中是没有”ab”存在的。
5.3 总结
总结String的intern()的使用:
- Jdk 1.6 中, 将这个字符串对象尝试放入常量池。如果常量池中有,则并不会放入,返回已有的常量池中的对象的地址;如果没有,会把此对象复制一份,放入常量池,并返回常量池中的对象地址
- Jdk 1.7 起, 将这个字符串对象尝试放入常量池。如果常量池中有,则并不会放入,返回已有的常量池中的对象的地址;如果没有,则会把对象的引用地址复制一份,放入常量池,并返回常量池中的引用地址
对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
6. String去重操作
对许多Java应用(有大的也有小的) 做的测试得出以下结果:
- 堆存活数据集合里面String对象占了25%
- 堆存活数据集合里面重复的string对象有13.5%
- String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存, 测试表明, 在这些类型的应用里面, Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的。因此,如果对重复的String对象进行去重, 这样就能避免浪费内存。
G1垃圾收集器实现了String的去重操作,详细步骤如下:
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素, 然后尝试去重它引用的String对象。
- 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候, 会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在, String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉
- 如果查找失败, char数组会被插入到hash table,这样以后的时候就可以共享这个数组
命令行选项
Use String De duplication (bool):开启String去重, 默认是不开启的, 需要手动开启PrintString De duplication Statistics (bool):打印详细的去重统计信息String De duplication Age Threshold (uint x):达到这个年龄的String对象被认为是去重的候选对象
