> 文章列表 > JVM学习笔记十一:StringTable

JVM学习笔记十一:StringTable

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

  1. permSize 默认比较小。
  2. 永久代中垃圾回收的评率低。
    结合上面两个原因,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)
JVM学习笔记十一:StringTable

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 JVM学习笔记十一:StringTable
不使用intern JVM学习笔记十一:StringTable

6. StringTable 的垃圾回收

待完成

7. G1 的String 去重操作

待完成