JVM学习笔记十一:StringTable
0. 前言
声明:
感谢尚硅谷宋红康老师的讲授。
感谢广大网友共享的笔记内容。
B站:尚硅谷JVM全套教程,百万播放(宋红康详解java虚拟机)
本文的内容基本来源于宋老师的课件,其中有一些其他同学共享的内容,也有一些自己的理解内容。
1. String的基本特性
- String:字符串,使用一对""引起来表示
- String声明为final的,不可被继承
- String实现了Serializable接口:表示字符串是支持序列化的。
- String实现了Comparable接口:表示string可以比较大小
- String在jdk8及以前内部定义了final char[] value用于存储字符串数据。JDK9时改为byte[]
String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用string的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(也就是直接引号创建的String。区别于new的方式)给一个字符串赋值,此时的字符串值声明在字符串常量池中。字符串常量池在JDK6时在永久代当中,在JDK7时为了方便垃圾回收移动到 Heap 中。
字符串常量池是不会存储相同内容的字符串的。
String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。
使用 -XX:StringTablesize
可设置 StringTable
的长度。
- 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize 设置没有要求。
- 在jdk7中,StringTable 的长度默认值是60013,StringTablesize 设置没有要求。
- 在JDK8中,设置 StringTable 长度的话,1009是可以设置的最小值。
- 在后续JDK8+的版本中,StringTable 的默认长度可能还有有变化。
可以通过
jps 和 jinfo -flag StringTableSize 进程号
的方式查看常量池的大小。
可以通过下面的例子感受 StringTableSize 的大小对字符串操作的性能影响。
/*
从 words.txt(10万个字符串) 文件当中读取字符串,创建字符串并放入常量池中。
分别设置 -XX:StringTableSize 1009 、 100009 和 默认大小 时,比较三者的效率。*/
public static void testStringTableEfficiency(){BufferedReader br = null;try {br = new BufferedReader(new FileReader("words.txt"));long start = System.currentTimeMillis();String data;while((data = br.readLine()) != null){data.intern(); //如果字符串常量池中没有对应data的字符串的话,则在常量池中生成}long end = System.currentTimeMillis();System.out.println("花费的时间为:" + (end - start)+ "ms");//1009:143ms 100009:47ms} catch (IOException e) {e.printStackTrace();} finally {if(br != null){try {br.close();} catch (IOException e) {e.printStackTrace();}}}
}
对比结果:可以发现,StringTableSize 的设置并不是越大越好。
StringTableSize | 耗时 |
---|---|
60013(默认&JDK8) | 32ms |
1009 | 72ms |
100009 | 35ms |
70009 | 29ms |
65535 | 25ms |
1.1 String在JDK9中存储结构的变更
官网地址:JEP 254: Compact Strings (java.net)
动机
目前String类的实现将字符存储在一个char数组中,每个字符使用两个字节(16位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含Latin-1字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。
说明
我们建议将String类的内部表示方法从UTF-16字符数组改为字节数组加编码标志域。新的String类将根据字符串的内容,以ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。
与字符串相关的类,如AbstractStringBuilder、StringBuilder和StringBuffer将被更新以使用相同的表示方法,HotSpot VM的内在字符串操作也是如此。
这纯粹是一个实现上的变化,对现有的公共接口没有变化。目前没有计划增加任何新的公共API或其他接口。
迄今为止所做的原型设计工作证实了内存占用的预期减少,GC活动的大幅减少,以及在某些角落情况下的轻微性能倒退。
结论:String再也不用char[] 来存储了,改成了byte [] 加上编码标记,节约了一些空间
2. String的内存分配
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。
- 直接使用双引号声明出来的String对象会直接存储在常量池中。
- 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。这个后面重点谈
StringTable 在 JDK6 与之后的变化
Java 6及以前,字符串常量池存放在永久代,Java 7中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
- 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用 String.intern()。
StringTable为什么要调整?
https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html#jdk7changes
- permSize 默认比较小。
- 永久代中垃圾回收的评率低。
结合上面两个原因,StringTable在永久代中容易导致内存溢出:OutOfMemoryError:PermGen space
。通过下面的程序可以验证在不同JDK版本中 StringTable 存放位置的不同(主要体现在报错的信息不同)。
/* jdk6中:* -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m jdk8中:* -XX:MetaspaceSize=6m -XX:MaxMetaspaceSize=6m -Xms6m -Xmx6m* @author shkstart shkstart@126.com* @create 2020 0:36*/
public class StringTable3 {public static void main(String[] args) {//使用Set保持着常量池引用,避免full gc回收常量池行为Set<String> set = new HashSet<String>();//在short可以取值的范围内足以让6MB的PermSize或heap产生OOM了。short i = 0;while(true){set.add(String.valueOf(i++).intern());}}
}
3. String的基本操作
11111111111
4. String的拼接操作
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
例子1
public static void testString3(){// 都是常量,前端编译期会进行代码优化// 通过idea直接看对应的反编译的class文件,会显示 String s1 = "abc"; 说明做了代码优化String s1 = "a" + "b" + "c";String s2 = "abc";// true,有上述可知,s1和s2实际上指向字符串常量池中的同一个值System.out.println(s1 == s2);
}
例子二:
public static void testString4() {String s1 = "javaEE";String s2 = "hadoop";String s3 = "javaEEhadoop";String s4 = "javaEE" + "hadoop";String s5 = s1 + "hadoop";String s6 = "javaEE" + s2;String s7 = s1 + s2;System.out.println(s3 == s4); // true 编译期优化System.out.println(s3 == s5); // false s1是变量,不能编译期优化System.out.println(s3 == s6); // false s2是变量,不能编译期优化System.out.println(s3 == s7); // false s1、s2都是变量System.out.println(s5 == s6); // false s5、s6 不同的对象实例System.out.println(s5 == s7); // false s5、s7 不同的对象实例System.out.println(s6 == s7); // false s6、s7 不同的对象实例String s8 = s6.intern();System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"System.out.println(s4 == s8); // true intern之后,s8和s4一样,指向字符串常量池中的"javaEEhadoop"
}
例子三:final 修饰后的 String 对象
public void test6(){String s0 = "beijing";String s1 = "bei";String s2 = "jing";String s3 = s1 + s2;System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"String s7 = "shanxi";final String s4 = "shan";final String s5 = "xi";String s6 = s4 + s5;System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}
- 不使用 final 修饰,即为变量。如s3行的s1和s2,会通过new StringBuilder进行拼接
- 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用。
例子四:
public static void testString6(){String s1 = "a";String s2 = "b";String s3 = "ab";String s4 = s1 + s2;System.out.println(s3==s4); // false
}
上面案例对应的 Bytecode:
0 ldc #2 <a>2 astore_13 ldc #3 <b>5 astore_26 ldc #4 <ab>8 astore_39 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
24 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
27 astore 4
29 getstatic #9 <java/lang/System.out : Ljava/io/PrintStream;>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println : (Z)V>
46 return
4.1 字符串拼接操作性能对比
public static void main(String[] args) {int times = 50000;// Stringlong start = System.currentTimeMillis();testString(times);long end = System.currentTimeMillis();System.out.println("String: " + (end-start) + "ms");// StringBuilderstart = System.currentTimeMillis();testStringBuilder(times);end = System.currentTimeMillis();System.out.println("StringBuilder: " + (end-start) + "ms");// StringBufferstart = System.currentTimeMillis();testStringBuffer(times);end = System.currentTimeMillis();System.out.println("StringBuffer: " + (end-start) + "ms");}public static void testString(int times) {String str = "";for (int i = 0; i < times; i++) {str += "test";}}public static void testStringBuilder(int times) {StringBuilder sb = new StringBuilder();for (int i = 0; i < times; i++) {sb.append("test");}}public static void testStringBuffer(int times) {StringBuffer sb = new StringBuffer();for (int i = 0; i < times; i++) {sb.append("test");}}
结果:
拼接方式 | 耗时 |
---|---|
String | 3465ms |
StringBuilder | 2ms |
StringBuffer | 2ms |
我们还可以通过设置 StringBuilder 的 capacity 来进一步提高我们的速度。
5. intern 的使用
当调用intern方法时,如果池子里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。
由此可见,对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。
所有字面字符串和以字符串为值的常量表达式都是interned。
返回一个与此字符串内容相同的字符串,但保证是来自一个唯一的字符串池。
intern是一个native方法,调用的是底层C的方法。
public native String intern();
如果不是用双引号声明的String对象,可以使用String提供的intern方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
String myInfo = new string("I love atguigu").intern();
也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true
("a"+"b"+"c").intern() == "abc"
通俗点讲,Interned string 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)
5.1 intern 的使用 JDK6 vs JDK7/8
/* ① String s = new String("1")* 创建了两个对象* 堆空间中一个new对象* 字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")* ② s.intern()由于字符串常量池中已存在"1"* * s 指向的是堆空间中的对象地址* s2 指向的是堆空间中常量池中"1"的地址* 所以不相等*/
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s==s2); // jdk1.6 false jdk7/8 false/*
String s3 = new String("1") + new String("2");
上面这一行代码创建的对象:1. 创建一个 StringBuilder 对象 sb2. 创建一个 String 对象 s13. 创建一个 “123” 放入常量池中4. 调用 sb.append(s1)5. 创建一个 String 对象 s26. 创建一个 “456” 放入常量池中7. 调用 sb.append(s2)8. sb.toString() 返回一个新的 String 对象共创建了6个对象。
*/
String s3 = new String("1") + new String("2");
// 在 JDK6时,String Pool 中没有 "12", 下面这句会在 String Pool(位于永久代) 创建一个新的对象。
// 在 JDK7和JDK8+时, String Pool 中没有 "12",下面这句会在 String Pool(位于堆) 创建一个引用,指向 s3,作为 String Pool 中的 "12" 对象。
s3.intern();
String s4 = "12";
System.out.println(s3==s4); //jdk1.6 false , jdk7/8 true
总结String的intern()的使用:
JDK1.6中,将这个字符串对象尝试放入串池。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
JDK1.7起,将这个字符串对象尝试放入串池。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
5.2 intern 的效率测试:空间角度
我们通过测试一下,使用了intern和不使用的时候,其实相差还挺多的。
public class StringIntern2 {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]));arr[i] = new String(String.valueOf(data[i%data.length])).intern();}long end = System.currentTimeMillis();System.out.println("花费的时间为:" + (end - start));try {Thread.sleep(1000000);} catch (Exception e) {e.getStackTrace();}}
}// 运行结果
不使用intern:7256ms
使用intern:1395ms
同时我们也可以从空间的角度分析一下。通过 Idea 的 Debug 工具查看 String 对象的个数。通过下图可以得出结论:当我们的字符串对象有过多的重复时,可以通过 intern 的方式减小内存的占用。
方式 | 空间占用 or 对象个数 |
---|---|
使用intern | ![]() |
不使用intern | ![]() |
6. StringTable 的垃圾回收
待完成
7. G1 的String 去重操作
待完成