StringTable

50

基本特征

java中8种基本数据类型和字符串,为了让他们运行过程更快,更节省内存空间,提供了常量池的概念,常量池属于jvm提供的缓存,String的常量池比较特殊

  • 直接使用双引号声明或使用String的intern方法创建String对象会直接存储的常量池中

  • 常量池中不会存储相同的字符串

  • 当对字符串重新赋值时,会重写指定内存区域赋值,不会使用原有的value进行赋值

通过IDEA的debug可以看到执行到第二个System.out.println();,String和char[]的数量就不在增加了

public static void main(String[] args) {
    System.out.println(); // 1161
    System.out.println("1"); // 1177,此处堆内存中还加载了 Buffer.clear 所以一次性加载了16个
    System.out.println("2"); // 1178
    System.out.println("3"); // 1179
​
    System.out.println(); // 1180
    System.out.println("1"); // 1180
    System.out.println("2"); // 1180
    System.out.println("3"); // 1180
} // 1180

java中完全相同的字符串字面量应该包含相通的Unicode字符序列并且必须是指向同一个String类实例

数据结构转变

字符串在jdk8以前由char数组 用于存储字符串,jdk9改成byte 官方文档

此前String存储在char数组每个字符占2个字节,字符串是堆空间中使用的主要组成部分,并且大部分String对象仅包含Latin-1字符,char只需要一个字节的存储,因此这些对象内部数组中有一半空间未被使用.所以将使用UTF-16的char数组转换为byte数组加编码标志字段

这样的设计导致了内存占用量显著减少,进而导致GC的次数减少,不过在某些极端下的轻微性能下降

通过coder字段来确定value数组中存储的是何种编码

private final byte[] value;
private final byte coder;

内存分配

字符串常量池是一个固定大小的Hashtable,如果数组长度不够就会导致Hash冲突,导致性能下降

可以使用以下参数设置,StringTable的长度

  • 在jdk1.6长度为1009,固定无法修改

  • 在jdk1.7长度默认为60013

  • 在jdk1.8长度默认为60013,并且无法低于1009

-XX:StringTablesize

位置调整

在jdk1.6之前,字符串常量池在永久代

在jdk1.7之后将字符串常量池的位置移动到堆中.不在分配在永久代,而是和其他对象一样分配在年轻代和老年代中,这样可以让更多的对象存储在堆内存中,这样调优时只需要针对堆内存大小即可

字符串拼接

常量与常量的拼接通过编译期优化,结果直接放在常量池

通过反编译可知String s1 = "a" + "b" + "c"; 等同于 String s1 = "abc";

通过反编译可知String s5 = "1" + s4; 等同于 String s5 = "123";

String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2); // true
​
String s3 = "123";
final String s4 = "23";
String s5 = "1" + s4;
System.out.println(s4 == s5); // true

字符串中只要有一个是变量,返回的结果就存储在堆中

如果拼接字符串前后出现了变量,则会在堆内存中创建

String s1 = "abc";
String s2 = "def";
String s3 = "abcdef";
String s5 = s1 + "def";
String s6 = "abc" + s2;
String s7 = s1 + s2;
​
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

通过反编译可以得知,+是java提供的一个语法糖,只要拼接的字符串中存在变量就会是创建StringBuilder并调用append方法

此处的s3就相当于

String s3 = new StringBuilder().append(s1).append(s2).toString();

在jdk1.5之前使用的是StringBuffer

String s1 = "a";
String s2 = "b";
String s3 = s1 + s2;

以下代码会创建6个对象

  • new StringBuilder()

  • "a"

  • new String("a")

  • "b"

  • new String("b")

  • StringBuilder的toString()创建的对象

String s4 = new String("a") + new String("b");

通过StringBuilder拼接字符串效率更高

String src = "";
for (int i = 0; i < 100000; i++) {
    src += "a"; // 每次拼接都会创建一个新的StringBuilder和String对象
}
​
StringBuilder sb = new StringBuilder(100000); // 指定数组长度避免多次扩容
for (int i = 0; i < 100000; i++) {
    sb.append("a"); // 只会往sb中追加,不会创建多余的StringBuilder
}

intern

intern是一个native方法

如果不是通过双引号声明的String对象,可以使用该方法,它会将字符串常量池中返回该字符串,若不存在则将该字符串放入常量池中并返回

public native String intern();

主动调用intern方法,会主动将常量池中还没有的字符串对象放入字符串常量池中,并返回此对象

String s1 = "def";
String s2 = "abc" + s1;
String s3 = s2.intern(); // 放入常量池中
​
System.out.println(s2 == s3); // true

intern方法就是确保字符串在内存中只有一份拷贝,这样可以节省内存空间,加速字符串操作的执行速度

当s.equals(t) 为真时,s.intern() == t.intern() 一定为真

图例

intern

StringTable存储在堆中,此图只是方便理解才拆分开来

需要使用intern方法时,建议使用该方法返回的字符串

String s1 = new String("abc").intern(); // new String("abc")随时会被回收
String s2 = new String("abc"); // new String("abc")不会被回收,直到超出作用域
String s3 = "abc";
​
System.out.println(s1 == s3); // true
System.out.println(s1 == s2); // false

不同版本的区别

jdk1.6

  • 如果字符串常量池中已经存在,则直接返回常量池中对象的地址

  • 如果不存在,则直接把该对象复制一份,放入字符串常量池中并返回字符串常量池中的地址

intern_jdk1.6

jdk1.7

  • 如果字符串常量池中已经存在,则直接返回常量池中对象的地址

  • 如果不存在,则会把对象的引用地址复制一份,放入字符串常量池中并返回串池中的引用地址

intern_jdk1.7

String s = new String("a") + new String("b");
s.intern();
String s2 = "ab";
System.out.println(s == s2); // jdk1.6 false jdk1.7以及之后 true

修改调用顺序

new String("a") + new String("b") 返回的"ab"在任何情况下都是存储在堆中的

通过字面量的形式声明的字符串都是存储在字符串常量池中

这时候才调用intern方法就已经没有意义了,因为字符串常量池中已存在

一个存储在堆中一个存储在字符串常量池中,所以内存地址一定不相同

String s = new String("a") + new String("b");
String s2 = "ab";
s.intern();
System.out.println(s == s2);  // 所有版本都为false

垃圾回收

可以通过以下命令查看字符串常量池占用情况

-XX:+PrintStringTableStatistics

输出结果

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :     37101 =    890424 bytes, avg  24.000
Number of literals      :     37101 =   2085776 bytes, avg  56.219
Total footprint         :           =   3456304 bytes
Average bucket size     :     0.618
Variance of bucket size :     0.491
Std. dev. of bucket size:     0.700
Maximum bucket size     :         4

配合-XX:+PrintGCDetails可知字符串常量池是被JVM单独维护起来的一个HashTable

jdk1.7中内部存储的字符串和其他堆内存中存储的引用一样存储在堆内存中的Eden中,随着GC后移入老年代中

这也体现了jdk1.6之前存放在永久代的一些问题

去重

许多大规模java程序遭到了性能瓶颈,在这些程序中,大约25%堆数据被String消耗,而这些String中有一半是重复的.从本质上来讲这是一种浪费,现在G1垃圾收集器中实现自动持续的重复String数据删除,避免内存浪费、减少内存占用

此处的重复显然指的不是字符串常量池中的String,因为字符串常量池中也不会存储重复的String对象

默认关闭,使用以下参数开启

开启后只有底层的char数组会被替换掉,并不会消除重复字符串引用本身

-XX:+UseStringDeduplication

默认是3次GC后才会被列为去重的对象,可以使用此参数指定经历GC次数

-XX:StringDeduplicationAgeThreshold=6

去重是否要在生产环境使用还需要综合考虑,因为有以下缺点

  • 只针对G1垃圾收集器上

  • 要有大量长期字符串,因为不会对短期的对象去重,如果生命周期过短可能还没来得及去重就已经被释放了

  • 只有在java8u20以后的版本才有此特性

  • 每次去重是在GC的时候进行的,所以会增加GC停顿时间

实现方式

  • 在进行垃圾收集的时候,会访问对堆上存活的对象并且会检查是否是候选的要去重的String对象

  • 如果符合则把这个对象的引用插入到等待队列中,一个去重的线程会在后台运行处理这个队列,处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象

  • 使用一个hashtable来记录所有的被String对象使用的不重复的char数组.当去重的时候,会查看这个hashtable,来看堆上是否已经存在一个一模一样的char数组

  • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉

  • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了