public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
// jdk1.8及之前
private final char value[];
public String() {
this.value = "".value;
}
}
Serializable
接口:表示字符串是支持序列化的。Comparable
接口:表示 String 可以比较大小。final
的,不可被继承,一旦被创建就不会改变,对字符串的操作只是产生了一个新的字符串。jdk8及以前
定义了 final char[] value
用于存储字符串数据,jdk9时
改为 final byte[] value
String 声明为 final
的,一旦被创建就不会改变。String的每次操作都是生成一个新的对象,不改变原来的对象
@Test
public void test1() {
String s1 = "abc";
String s2 = s1;
System.out.println(s1.hashCode()); // 96354
System.out.println(s2.hashCode()); // 96354
// 不会改变原来的对象("abc"),只是新生成一个对象("hello"),并指向新对象
s2 = "hello";
System.out.println(s1.hashCode()); // 96354
System.out.println(s2.hashCode()); // 99162322
System.out.println(s1); // abc
System.out.println(s2); // hello
}
@Test
public void test2() {
String s1 = "abc";
String s2 = s1 + "def";
System.out.println(s1); // abc
System.out.println(s2); // abcdef
}
@Test
public void test3() {
String s1 = "abc";
String s2 = s1.replace('a', 'm');
System.out.println(s1); // abc
System.out.println(s2); // mbc
}
public class test4() {
public static void main(String[] args) {
String str = "old";
char[] ch = {'t', 'e', 's', 't'};
change(str, ch);
System.out.println(str); // old
System.out.println(ch); // best
}
public static void change(String str, char ch[]) {
// 拼接和replace同理
str = "new";
ch[0] = 'b';
}
}
Java 中有 8种基本数据类型
和 1种特殊的引用数据类型String
。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了常量池
的概念。(常量池就类似一个 Java 系统级别提供的缓存)
8 种基本数据类型的常量池都是系统协调的,而String类型的常量池比较特殊。
字符串常量池 StringTable 为什么要调整位置?
String 的 String Pool 是一个固定大小的 Hashtable(数组+链表)。如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。
使用-XX:StringTablesize
可设置 StringTable 的长度
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
下面我们通过debug以下案例来分析一下:
public class StringTest {
public static void main(String[] args) {
System.out.println(); // 1544
System.out.println("1"); // 1545
System.out.println("2"); // 1545
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9"); // 1553
// 以下的字符串不会再次加载
System.out.println("1"); // 1554
System.out.println("2"); // 1554
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9"); // 1554
}
}
下面我们通过Debug查看一下idea的Memory中String的个数
class Memory {
public static void main(String[] args) {
int i = 1;
Object obj = new Object();
Memory mem = new Memory();
mem.foo(obj);
}
private void foo(Object param) {
String str = param.toString().intern();
System.out.println(str); // java.lang.Object@42a57993
}
}
直接由双引号
""
给出声明的字符串,存储在字符串常量池中(并且相同的"xxx"只会有一份)
public static void test() {
String str = "ab";
}
0 ldc #2
2 astore_0
3 return
""
创建字符串时,JVM首先会去常量池中查找是否存在这个字符串对象。
new
关键字声明的字符串,先在堆内存中创建一个字符串对象(new
),然后在字符串常量池中创建一个字符串常量(ldc
)。
public static void test() {
String s1 = new String("ab");
String s2 = "ab";
}
0 new #3
3 dup
4 ldc #2
6 invokespecial #4 : (Ljava/lang/String;)V>
9 astore_0
10 ldc #2
12 astore_1
13 return
注意:最后返回的是堆内存中字符串对象的地址,不是常量池中的字符串对象的地址。
public static void test() {
String s1 = new String("ab");
String s2 = "ab";
System.out.println(s1 == s2); // false
}
可以看到,StringBuilder
的toString()
其实会new
一个String
对象
public final class StringBuilder extends AbstractStringBuilder implements Serializable, CharSequence {
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
}
需要注意的是,StringBuilder.toString()
不会在常量池中创建对象,下面写个例子分析一下。
public static void test() {
StringBuilder stringBuilder = new StringBuilder("a");
stringBuilder.append("b");
String str = stringBuilder.toString();
}
0 new #5
3 dup
4 ldc #6
6 invokespecial #7 : (Ljava/lang/String;)V>
9 astore_0
10 aload_0
11 ldc #8
13 invokevirtual #9
16 pop
17 aload_0
18 invokevirtual #10
21 astore_1
22 return
可以看到没有出现 ldc #x
,可见StringBuilder.toString()
只在堆内存创建了一个字符串,并没有放到字符串常量池
intern()
判断字符串常量池中是否存在该字符串,存在,则返回常量池中的地址;不存在,则在常量池中加载一份并返回地址
@Test
public void test() {
String s1 = "nb";
String s2 = "n";
String s3 = s2 + "b";
String s4 = s3.intern(); // 常量池中存在,返回常量池中的地址
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // true
}
关于intern()
方法下面会详细展开。
什么情况下,字符串会被放入字符串常量池呢?
""
给出声明的字符串,会直接放在字符串常量池中。new
创建的字符串,也会有一份放在字符串常量池中。intern()
方法的字符串,也会被放到字符串常量池中。注意:StringBuilder.toString()
生成的字符串,是不会放到字符串常量池中的,只会在堆中创建一份。
场景1:
常量
与常量
拼接,拼接结果在字符串常量池,原理是编译期优化
public class AppendTest {
public void test() {
String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2); // true
}
}
从class文件的反编译结果可以看出:编译器做了优化,将 “a” + “b” + “c” 优化成了 “abc”
0 ldc #2
2 astore_0
3 ldc #2
5 astore_1
6 getstatic #3
9 aload_0
10 aload_1
11 if_acmpne 18 (+7)
14 iconst_1
15 goto 19 (+4)
18 iconst_0
19 invokevirtual #4
22 return
从IDEA中的AppendTest.class也可以直接看出来
public class AppendTest {
public AppendTest() {}
public void test() {
String s1 = "abc"; // 显示 String s1 = "abc"; 说明做了代码优化
String s2 = "abc";
System.out.println(s1 == s2);
}
}
场景2:拼接中只要有一个是
变量
,拼接结果就在堆中,原理是StringBuilder
的append
操作。
public void test2() {
String s1 = "n";
String s2 = "b";
String s3 = "nb";
String s4 = "n" + "b"; // 编译期优化
String s5 = s1 + "b";
String s6 = "n" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4); // true
System.out.println(s3 == s5); // false
System.out.println(s3 == s6); // false
System.out.println(s3 == s7); // false
System.out.println(s5 == s6); // false
System.out.println(s5 == s7); // false
System.out.println(s6 == s7); // false
// 这里使用intern(),会返回常量池中"nb"的地址并赋给s8(这里先了解,具体用法后续会详细展开)
String s8 = s7.intern();
System.out.println(s3 == s8); // true
}
下面我们从class文件的反编译结果进行分析
0 ldc #5 <n>
2 astore_1
3 ldc #6 <b>
5 astore_2
6 ldc #7 <nb>
8 astore_3
9 ldc #7 <nb>
11 astore 4
s4之前都是【例1】的内容,这里就不赘述了,主要看一下 s5、s6、s7这三行
13 new #8
16 dup
17 invokespecial #9 : ()V>
20 aload_1
21 invokevirtual #10
24 ldc #6
26 invokevirtual #10
29 invokevirtual #11
32 astore 5
34 new #8
37 dup
38 invokespecial #9 : ()V>
41 ldc #5
43 invokevirtual #10
46 aload_2
47 invokevirtual #10
50 invokevirtual #11
53 astore 6
55 new #8
58 dup
59 invokespecial #9 : ()V>
62 aload_1
63 invokevirtual #10
66 aload_2
67 invokevirtual #10
70 invokevirtual #11
73 astore 7
场景3:
final
修饰的String变量
,视作String常量
。
public static void test3() {
final String s1 = "n";
final String s2 = "b";
String s3 = "nb";
String s4 = "n" + "b"; // 编译期优化
String s5 = s1 + "b";
String s6 = "n" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4); // true
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
System.out.println(s3 == s7); // true
System.out.println(s5 == s6); // true
System.out.println(s5 == s7); // true
System.out.println(s6 == s7); // true
}
可以看到,我们只是在String变量前加上final
,结果就完全不同了。
下面我们看一下class文件的反编译结果
0 ldc #6
2 astore_0
3 ldc #7
5 astore_1
6 ldc #8
8 astore_2
9 ldc #8
11 astore_3
12 ldc #8
14 astore 4
16 ldc #8
18 astore 5
20 ldc #8
22 astore 6
可以看出,String变量被final修饰之后,所有的拼接操作都在编译期优化了,而没有使用StringBuilder
常量
与常量
拼接:
变量
:
StringBuilder
,然后用append()
拼接,最后调用 toString()
返回结果final
修饰的String变量
拼接:
StringBuilder
。因此,在开发中能使用上final
的时候还是建议使用
public class StringAppendTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
// String s1 = append1(100000); // 1670ms
String s2 = append2(100000); // 4ms
long end = System.currentTimeMillis();
System.out.println("拼接花费的时间为:" + (end - start));
}
public static String append1(int highLevel) {
String str = "";
for (int i = 0; i < highLevel; i++) {
str = str + "a"; // 每次循环都会创建一个StringBuilder、String
}
return str;
}
public static String append2(int highLevel) {
StringBuilder strBuilder = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
strBuilder.append("a"); // 只需要创建一个StringBuilder
}
return strBuilder.toString();
}
}
结论:通过StringBuilder的append()的方式拼接字符串的效率,远远高于String 使用 +
拼接
原因:
+
拼接方式:
因此使用字符串变量+
拼接会占用更大的内存,产生大量垃圾字符串,如果发生了GC,也会花费额外的时间。
StringBuilder 空参构造器的初始化大小为16,超过该大小会进行扩容,涉及数组的copy操作
public StringBuilder() {
super(16);
}
如果提前知道需要拼接 String 的个数,就应该直接使用带参构造器指定capacity,以减少扩容的次数
public StringBuilder(int capacity) {
super(capacity);
}
final
。这样拼接操作会在编译期优化,而不会创建StringBuilder对象去appendStringBuilder
的append()
效率要高于使用String的+
拼接。StringBuilder
,避免频繁扩容。场景1:
new String("ab")
会创建几个对象?(答案是2个)
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String ab
6: invokespecial #4 // Method java/lang/String."":(Ljava/lang/String;)V
9: astore_1
10: return
对象1 new String("ab")
对象2 常量池中的"ab"
场景2:
"a" + new String("b")
会创建几个对象?(答案是5个)
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."":()V
7: ldc #4 // String a
9: invokevirtual #5 // Method java/lang/StringBuilder.append:
12: new #6 // class java/lang/String
15: dup
16: ldc #7 // String b
18: invokespecial #8 // Method java/lang/String."":(Ljava/lang/String;)V
21: invokevirtual #5 // Method java/lang/StringBuilder.append:
24: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore_1
28: return
对象1 new StringBuilder()
对象2 常量池中的"a"
对象3 new String("b")
对象4 常量池中的"b"
对象5 StringBuilder.toString() 会 new String("ab")
注意:StringBuilder.toString
,在字符串常量池中,没有生成 "ab"
,toString()
的字节码指令中没有ldc
场景3:
new String("a") + new String("b")
会创建几个对象?(答案是6个)
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."":()V
7: new #4 // class java/lang/String
10: dup
11: ldc #5 // String a
13: invokespecial #6 // Method java/lang/String."":(Ljava/lang/String;)V
16: invokevirtual #7 // Method java/lang/StringBuilder.append:
19: new #4 // class java/lang/String
22: dup
23: ldc #8 // String b
25: invokespecial #6 // Method java/lang/String."":(Ljava/lang/String;)V
28: invokevirtual #7 // Method java/lang/StringBuilder.append:
31: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: astore_1
35: return
对象1 new StringBuilder()
对象2 new String("a")
对象3 常量池中的"a"
对象4 new String("b")
对象5 常量池中的"b"
对象6 StringBuilder.toString() 会 new String("ab")
注意:常量池中没有"ab"
intern是一个native方法,调用的是底层C的方法
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
public native String intern();
}
调用intern()
时,会判断 字符串常量池 中 是否已存在当前字符串(通过equals()
方法判断)
JDK1.6中
:会把此对象复制一份(新的引用地址),放入常量池
,并返回新的引用地址。JDK1.7起
:会把此对象的引用地址复制一份(相同的引用地址),放入常量池
,并返回此对象的引用地址。也就是说,任意字符串调用intern()
,其返回结果所指向的那个类实例,必定和直接以常量形式出现的字符串实例完全相同。
("a"+"b"+"c").intern() == "abc" // true;
intern()
可以确保字符串在内存里只有一份(即字符串常量池中的),可以节约内存空间,加快字符串操作任务的执行速度。
public void test() {
String s1 = new String("a");
s1.intern();
String s2 = "a";
System.out.println(s1 == s2); // jdk6 false ; jdk7/8 false
}
s1
记录的是堆中new String("a")
的地址s2
记录的是字符串常量池中"a"
的地址public void test() {
String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // jdk6 false ; jdk7/8 true
}
s1
记录的是堆中"ab"
的地址(注意,这个"ab"
是StringBuilder.toString()
生成的,没有往常量池里放)s1.intern()
调用这个方法之前,字符串常量池中并不存在"ab"
,所以要把"ab"
放入字符串常量池
"ab"
指向新的地址。"ab"
指向的是调用intern()
的s1
的地址s2
记录的是字符串常量池中的"ab"
指向的地址public void test() {
String s1 = new String("a") + new String("b");
String s2 = s1.intern(); // 常量池没有"ab",会放入
System.out.println(s1 == "ab"); // jdk6 false ; jdk7/8 true
System.out.println(s2 == "ab"); // jdk6 true ; jdk7/8 true
}
public void test() {
String s1 = "ab"; // 常量池中创建一个新的对象"ab"
String s2 = new String("a") + new String("b");
String s3 = s2.intern(); // 常量池已有"ab",不会再放入
System.out.println(s1 == s2); // jdk6/7/8 false
System.out.println(s1 == s3); // jdk6/7/8 true
}
public class StringInternTest {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
public static void main(String[] args) {
Integer [] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
// arr[i] = new String(String.valueOf(data[i%data.length])); // 不用intern 7256ms
arr[i] = new String(String.valueOf(data[i%data.length])).intern(); // 使用intern 1395ms
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (Exception e) {
e.getStackTrace();
}
System.gc();
}
}
new
:堆中 和 字符串常量池中可能会存在相同的字符串。intern()
:保证内存中相同的字符串只会有一个,就在字符串常量池中。结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern()
方法能够节省内存空间。
VM options:-Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
先空跑一下,看一下StringTable statistics
的信息
public class StringGCTest {
public static void main(String[] args) {
}
}
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1277 = 30648 bytes, avg 24.000
Number of literals : 1277 = 100808 bytes, avg 78.941
然后循环添加String对象,再看一下StringTable statistics
的信息
public class StringGCTest {
public static void main(String[] args) {
for (int j = 0; j < 1000; j++) {
String.valueOf(j).intern();
}
}
}
// 这里可以看到Number大概增长了1000
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 2258 = 54192 bytes, avg 24.000
Number of literals : 2258 = 147896 bytes, avg 65.499
Total footprint : = 682192 bytes
最后我们把循环次数调大一点,使其发生GC
public class StringGCTest {
public static void main(String[] args) {
for (int j = 0; j < 100000; j++) {
String.valueOf(j).intern();
}
}
}
这里我们可以看到 PSYoungGen 区发生了垃圾回收
// 这里也可以看到Number没有达到循环次数100000
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 63891 = 1533384 bytes, avg 24.000
Number of literals : 63891 = 3607296 bytes, avg 56.460
Total footprint : = 5620784 bytes
以上两点都说明了 StringTable 发生了垃圾回收
注意:不是字符串常量池的去重操作,字符串常量池本身就没有重复的字符串
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
堆上存在大量重复的String对象必然是一种内存的浪费。对重复的String对象进行去重,就能避免浪费内存。
当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。
处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
使用一个Hashtable来记录所有的被String对象使用的不重复的char数组。
当去重的时候,会查这个Hashtable,来看堆上是否已经存在一个一模一样的char数组。
如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
如果查找失败,char数组会被插入到Hashtable,这样以后的时候就可以共享这个数组了。
UseStringDeduplication(bool)
:开启String去重,默认是不开启的,需要手动开启。PrintStringDeduplicationStatistics(bool)
:打印详细的去重统计信息StringDeduplicationAgeThreshold(uintx)
:达到这个年龄的String对象被认为是去重的候选对象