JVM(十)StringTable
1 String的基本特性
-
String即字符串,通过一对引号""表示,String创建的方式主要有
- String s = "abc"; // 字面量的方式
- String s = new String("abc"); // 类创建new方式
-
String声明为final,不可以被继承
-
String实现了Serializable接口,表示字符串是支持序列化、可以跨进程传输的;实现了Comparable接口,表示字符串可以比较大小
-
String在JDK8及以前在内部定义了final char[] value用于存储字符串数据,jdk9的时候改成了byte数组
-
String是不可变的字符序列,即具有不可变性,主要表现在:
- 当对字符串重新赋值的时候,需要重写指定内存区域赋值,而不能使用原有的value进行赋值
- 当对字符串进行连接操作的时候,也需要重写指定内存区域赋值,而不能使用原有的value进行赋值
- 当调用String的replace()方法修改指定字符或者字符串的时候,也需要重写指定内存区域赋值,而不能使用原有的value进行赋值
-
通过字面量的方式给一个字符串赋值,此时字符串值声明在字符串常量池中
jdk6之前字符串常量池和静态变量位于方法区中,6及之后位于堆中
-
字符串常量池中是不会存放相同内容的字符串的(原理下面的String底层HashTable的说明)
为什么jdk9修改String的底层结构为字节数组?
一些统计表名字符串是堆中使用的主要部分,而且大部分字符串对象只包含拉丁字符,这些字符只需要一个字节的存储空间,但是char型数据类型占两个字节,这也就意味着字符串对象的内部char数组中有一半的空间将不会使用,造成空间浪费,因此改变String的结构为字节数组存储能够节约空间。并且基于String的数据结构的StringBuilder和StringBuffer都做出了改变。
String底层HashTable的说明
- String的字符串常量池是一个固定大小的HashTable,默认大小为1009
- 如果放入String Table的字符串特别多,就会造成Hash冲突,进而使用拉链法解决冲突会导致链表越来越长,当调用String.intern时性能会大幅度下降
- 使用-XX:StringTableSize可以设置StringTable的大小
- jdk6中字符串常量池大小是固定1009,jdk7开始默认大小变为60013,并且1009是可以设置的最小值
2 String的内存分配
-
在Java中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行时速度更快、更节省内存,都提供了一种常量池的概念
-
常量池类似于一个Java系统级别的缓存,8种基本数据类型的缓存是系统协调的,String类型的常量池比较特殊,使用的方法有两种:
-
直接使用字面量赋值,声明出来的String对象会直接存储在常量池中
-
使用String提供的intern()方法,可以将String对象放入常量池
String.valueOf(1).intern();
Java语言规范要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且指向同一个String实例
System.out.println("1"); // 2162 System.out.println("2"); // 2164 System.out.println("3"); // 2165 System.out.println("4"); // 2166 System.out.println("5"); // 2167 // 下面的字符串存在于字符串常量池中,因此不会被再次加载 System.out.println("1"); // 2168 System.out.println("2"); // 2168 System.out.println("3"); System.out.println("4"); System.out.println("5"); // 2168
-
-
在jdk6及之前,字符串常量池存放在永久代
-
jdk7开始,将
字符串常量池
和静态变量
放入堆中StringTable为什么要调整?
- 永久代大小permSize默认比较小,大量字符串容易OOM
- 永久代的垃圾回收频率太低,容易造成空间浪费
3 String的分配:new String()到底创建了几个对象?
-
new String("a")创建了几个对象?
两个,一个是在字符串常量池中根据字面量“a”创建的对象,另一个是在堆中创建的对象
@Test public void test7() { String s = new String("a"); }
上面的代码的字节码文件可以证明这一点,ldc表示从字符串常量池中得到该字符串对象
-
new String("a") + new String("b")呢?
七个,包括:
- 在字符串常量池中字面量"a"对应的对象
- 堆中new String("a")创建的对象
- 在字符串常量池中字面量"b"对应的对象
- 堆中new String("b")创建的对象
- 拼接操作创建的StringBuilder("ab")对象
- 字符串常量池中拼接而成的字面量“ab”对应的变量
- StringBuilder("ab").toString()产生的String对象
字节码文件通用可以证明:
@Test public void test8() { String s = new String("a") + new String("b"); }
最后一个方法调用应看toString方法的字节码:
4 字符串拼接操作
-
常量与常量引用之间的拼接结果在常量池,原理是编译期优化
public static void main(String[] args) { var s1 = "abc"; var s2 = "a" + "b" + "c"; System.out.println(s1 == s2); // true System.out.println(s1.equals(s2)); // true }
上面代码编译后变为下面结果,说明对于常量与常量的拼接进行了编译器优化,并将拼接结果放在了字符串常量池中,因此两个变量的地址相同
public static void main(String[] args) { String s1 = "abc"; String s2 = "abc"; System.out.println(s1 == s2); System.out.println(s1.equals(s2)); }
常量引用的拼接结果也是在常量池中,仍然使用编译期优化:
@Test public void test4() { final String s1 = "a"; final String s2 = "b"; String s3 = "ab"; String s4 = s1 + s2; System.out.println(s3 == s4); // true } @Test public void test4() { final String s1 = "a"; String s3 = "ab"; String s4 = s1 + "b"; System.out.println(s3 == s4); // true }
final修饰的变量变为了常量,修饰的类不能被继承,修饰方法不能被重写
并且被final修饰的变量的显示赋值在编译期就完成了,而不是像其他变量在前端编译后的字节码文件还是符号引用,需要在运行的时候转化为运行时常量池的直接引用(即使用的时候直接访问而不需要在运行时确定),因此能够使用final的地方尽量都使用final
-
常量池中不会存在相同内容的常量
-
只要其中有一个是变量,结果就在堆中,变量的拼接原理是StringBuilder,JDK5.0之前使用的是StringBuffer
-
如果拼接的结果调用intern()方法,则主动将变量池中还没有出现过的字符串对象加载一份到字符串常量池中,并返回常量池中此对象的地址
public void test2() { var s1 = "javaEE"; var s2 = "hadoop"; var s3 = "javaEEhadoop"; var s4 = "javaEE" + "hadoop"; var s5 = "javaEE" + s2; var s6 = s1 + "hadoop"; var s7 = s1 + s2; System.out.println(s3 == s4); // true:常量拼接编译器优化,并且放入常量池 System.out.println(s3 == s5); // false:拼接出现变量,结果相当于在堆空间中new String 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 var s8 = s6.intern(); System.out.println(s3 == s8); // true:intern检查字符串常量池中是否存在内容相同的字符串,没有则将字符串值加载一份到字符串常量池,并返回字符串常量池中该字符串的地址 }
4.1 字符串拼接的底层原理
public void test3() {
var s1 = "a";
var s2 = "b";
var s3 = "ab";
var s4 = s1 + s2;
System.out.println(s3 == s4); // false
}
编译后的字节码文件如下,结合对象的实例创建过程,包含变量的字符串拼接的底层实际上进行了:
-
① StringBuilder s = new StringBuilder(),包括
- 首先调用new操作符指令创建运行时常量池中指定索引下标的类的对象并对其进行零值初始化,即:1.将该类进行加载 2.并在堆中开辟创建对象的空间并进行零值初始化
- (不用说这条)dup指令在栈帧的操作数栈中将指向创建对象的引用复制一份,这样就有两个引用指向堆空间的对象实体,栈底部的引用负责对对象进行赋值操作;栈顶的引用则是作为一个句柄调用相关方法;也可以看到操作数栈的深度为2
- 执行<init>方法进行对象的显示初始化,<init>方法包括无参或者有参构造器以及代码块中的显示赋值
-
② 执行append操作拼接字符串
-
③ 转换为String字符串,s.toString()
toString()方法底层与new String()类似
练习:
@Test
public void test5() {
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3); // false
final String s4 = "javaEE";
String s5 = s4 + "hadoop";
System.out.println(s1 == s5); // true
}
4.2 字符串拼接与append操作的效率对比
- StringBuilder拼接的方式自始至终只创建一个StringBuilder对象,而字符串拼接的过程每次拼接都会会创建一个StringBuilder和String对象(执行toString()产生的),因此会耗时更多
- 由于字符串拼接产生了大量对象,因此内存不足的情况下会进行GC,所以耗时更多
@Test
public void test6() {
long l1 = System.currentTimeMillis();
//method1(100000); // 4031
method2(100000); // 5
long l2 = System.currentTimeMillis();
System.out.println(l2 - l1);
}
public void method1(int times) {
String s = "";
while(times-- > 0) {
s += "a";
}
}
public void method2(int times) {
StringBuilder s = new StringBuilder();
while(times-- > 0) {
s.append("a");
}
}
在实际开发中,使用StringBuilder的时候如果底层的char数组容量不足的时候会进行扩容,因此可以使用构造方法设定容量大小防止不断的扩容操作,减少耗时
即如果能够确定拼接字符串的长度,建议进行一次性扩容
private int newCapacity(int minCapacity) { // overflow-conscious code int newCapacity = (value.length << 1) + 2; if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; } return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0) ? hugeCapacity(minCapacity) : newCapacity; }
5 intern()的使用
-
String对象调用intern方法的时候,首先会判断字符串常量池中是否含有equals相同的对象,如果存在直接返回字符串常量池中该对象的地址,否则加载该字符串对象到常量池并返回其地址
-
在任何字符串上调用String.intern方法的时候,其返回结果指向的类实例必定和以常量形式存在的字符串变量完全相同,也即是:intern方法就是确保字符串在内存里只有一份拷贝,即存放在字符串内部池中,以节省内存空间,加快字符串操作任务的执行速度
-
确保引用指向字符串常量池的方式:
- 以字面量的方式定义字符串
- 字符串调用intern方法
6 SringTable的垃圾回收
对虚拟机添加启动参数执行下面的代码,出现YGC
-Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
public class StringGCTest {
public static void main(String[] args) {
for(int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}
[GC (Allocation Failure) [PSYoungGen: 4096K->504K(4608K)] 4096K->664K(15872K), 0.0025364 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 4608K, used 3718K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
eden space 4096K, 78% used [0x00000000ffb00000,0x00000000ffe23b10,0x00000000fff00000)
from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e030,0x00000000fff80000)
to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen total 11264K, used 160K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000)
object space 11264K, 1% used [0x00000000ff000000,0x00000000ff028000,0x00000000ffb00000)
Metaspace used 3308K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
...
G1垃圾回收器的String去重操作
-
这里的String去重操作,指的是对堆中创建的重复字符串对象的value的去重操作,而不是对字符串常量池中进行去重,字符串常量池中的字符串始终是唯一的
String s1 = new String("abc"); String s2 = new String("abc");
首先在方法栈帧的局部变量表中有两个引用变量s1和s2,分别指向在堆中创建的两个String对象,同时在字符串常量池中也存在一个“abc”字符串,而这里的去重,就是指的在堆中创建的两个String对象
-
Java堆中存活的数据集合25%是String对象,而差不多有一半是重复的,因此造成了内存的浪费
-
G1垃圾回收器的String去重操作的实现:
- 垃圾收集器在工作的时候,首先会访问堆上存活的对象,检查其是否是候选的需要去重的String对象
- 使用一个HashTable来记录所有被String对象使用的不重复的数组,检查去重的时候查找这个HashTable即可
- 如果是则将对象的引用插入到队列,等待后续的处理。这个队列由一个后台线程进行处理,处理即是从队列删除这个元素,并且去重它所指向的对象
- 否则char数组就会被插入到HashTable
-
命令行选项:
UseStringDeduplication(bool)
:开启String去重,默认是不开启的PrintStringDeduplicationStatistics(bool)
:打印详细的去重统计信息StringDeduplicationAgeThreshold(unitx)
:设置年龄阈值,到达这个年龄的对象就会被认为是去重的对象