JVM(十四)—— StringTable
- String的基本特性
- String的内存分配
- 字符串拼接
- intern方法
- 常见面试题:到底创建了几个String对象
String的基本特性
作为一名Java程序员肯定少不了和 String打交道,使用方法就是将字符串用一对""引起来表示。查看String的源码,使用final修饰,说明是不可被继承的。String实现了java.io.Serializable接口,表示祖父穿是支持序列化的,实现了Comparable接口,表示String可以比较大小。
String 在JDK8及之前内部定义了final char value[]用于存储字符串数据,在JDK9时改成了byte[].
我们知道一个char数组中的char占用两个字节,大部分的String包含的都是拉丁字符,用一个字节就可以存储了,导致了使用char数组空间的浪费,在JDK9进行了修改,变成了byte[]。但是对于中文存储需要两个字符,所以还加了一个encoding-flag字符编码进行标识。
当然基于String的类比如StringBuffer,StringBuilder也做了修改,包括HotSpot虚拟机内部固有的String结构。
String代表不可变的字符序列,简称不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用String的replace方法修改指定字符或者字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池中是不会存储相同内容的字符串的。
- String的String Pool是一个固定大小的Hashtable,JDK6的大小长度固定为1009,JDK7中长度默认是60013,JDK8开始1009是可设置的最小值。
- 如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接回造成的影响就是当调用String.intern时的性能会大幅下降。
- 使用-XX:StringTableSize可设置StringTable的长度。
String的内存分配
Java中的8种基本数据类型和String为了使它们在运行过程中速度更快,更节省内存,提供了一种常量池的概念。
常量池就类似于一个Java系统界别提供的缓存。8种基本数据类型的常量池都是系统协调的。String类型的常量池比较特殊,主要的使用方法有两种:直接使用双引号生命出来的String对象会直接存储在常量池中,或者使用String提供的intern方法
- JDK6及以前,字符串常量池存放在永久代。
* - JDK7中将字符串常量池的位置调整到Java堆中。
* 所有的字符串都保存在堆中。和其他普通对象一样,这样可以在进行调优应用是仅需要调整堆大小就可以了。
- JDK8元空间,字符串常量在堆。
在之前的方法区篇章说过StringTable调整的原因:永久代空间太小,永久代的垃圾回收频率较低,会经常出现OOM异常。
字符串拼接
在我们的的开发中经常用到字符串的拼接,那字符串执行拼接的原理又是什么呢?
- 常量与常量的拼接结果在常量池,原理是编译期优化。
public static void main(String[] args) {
String s1 = "a" + "b" + "c"; // 在编译期优化为abc
String s2 = "abc";
System.out.println(s1 == s2); // true
}
上边的代码会返回true, String s1 = “a” + “b” + “c”;在编译期优化为abc,查看class文件也可以看出来。
- 只要其中有一个是变量,结果就在堆中,变量拼接的原理是StringBulider.
- 如果拼接的结果调用intern方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
public static void main(String[] args) {
String s1 = "java";
String s2 = "hadoop";
String s3 = "javahadoop";
// 如果拼接符号的前后出现了变量。则相当于在堆空间中new String(), 具体的额内容为拼接的结果:javahadoop
String s4 = s1 + "hadoop";
String s5 = "java" + s2;
String s6 = s1 + s2;
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // false
System.out.println(s3 == s5); // false
System.out.println(s3 == s6); // false
System.out.println(s4 == s5); // false
System.out.println(s4 == s6); // false
System.out.println(s5 == s6); // false
// intern() 判断字符串常量池中是否存在javahadoop值,
// 如果存在,则返回常量池中javahadoop的地址,如果不存在,则在常量池中加载一份javahadoop,并返回此对象的地址。
String s7 = s5.intern();
System.out.println(s3 == s7); // true
}
通过查看字节码:
我们发现在进行拼接符号的前后出现了变量的操作时,是用的是StringBuilder的append方法,当拼接完之后,调用toString方法转成String对象。
但是有变量的拼接一定是使用StringBuilder吗?我们来看下边的例子:
public static void main(String[] args) {
final String s1 = "java";
final String s2 = "hadoop";
// 如果拼接符号左右两边都是字符串常量或常量引用,则仍然时候编译期优化。
String s3 = "javahadoop";
String s6 = s1 + s2;
System.out.println(s3 == s6); // true
}
查看class文件:
从class文件可以看出:如果拼接符号左右两边都是字符串常量或常量引用,则仍然时候编译期优化。
根据上边的分析:使用字符串+字符串拼接的方式,会大量的创建StringBuilder和String对象,不仅仅时间成本高,还会占用很多内存,如果进行GC,还会花费额外的时间。如果我们使用StringBuilder的append方法自始至终只创建一个对象,时间更短,占用内存更小。所以在实际的开发中对于字符串的拼接建议使用StringBuilder的append方法操作。
如果基本确定要添加的字符创的长度的最大值,我们可以使用new StringBuilder(int capacity)创建,这样也会减少扩容的时间。
intern方法
如果不是用双引号声明的String对象,可以使用String提供的intern方法:intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
也就是说如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。
通俗点讲,interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部常量池。
常见面试题:到底创建了几个String对象
new String("ab");
上边的代码创建了几个String对象?
查看字节码:
从上边的字节码可以看到一共创建了两个对象:
第一个:new关键字在堆空间创建的。
第二个:字符串常量池中的对象。
String str = new String("a") + new String("b");
上边的代码创建了几个String对象
查看字节码:
从字节可以看到创建了以下对象:
① new StringBuilder
② new String(“a”)
③ 常量池中的"a"
④ new String(“b”)
⑤ 常量池中的"b"
深入剖析:StringBuilder.toString()会创建new String对象.
⑥ new String(“ab”) ,toString方法的调用,在字符创常量池中没有生成"ab"。
接下来我们从内存的角度分析一下代码:
public static void main(String[] args) {
String str = new String("1");
str.intern(); // 执行之前字符串常量池中已经有1了
String s2 = "1";
System.out.println(str == s2); // false
}
从上边的介绍可以知道new String(“1”)创建了两个对象,str指向的是堆空间创建的对象地址,而str.intern()执行之前字符串常量池中已经有1了,所以s2指向的是字符串常量池中的对象.所以str == s2为false。
我们在看一个例子:
public static void main(String[] args) {
String str = new String("1") + new String("1"); // str记录的地址为new String("11")
// 执行完上一行代码以后,字符串场景莲池中, 不存在"11"
str.intern(); // jdk6中常量池存放的是"11"的对象地址。jdk7此时常量池中没有创建"11",为了节省空间,在常量池存储的是上边创建的new String("11")对象的地址
String s2 = "11";//在jdk6中存的是"11"常量池的地址,jdk7存的是引用地址
System.out.println(str == s2); // true
}
总结String的intern的使用:
- JDK6中,将字符串对象尝试放入常量池
- 如果串池中有,则不会放入,返回已有的串池中的对象的地址。
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址。
- JDK7开始,将字符串对象尝试放入常量池
- 如果串池中有,则不会放入,返回已有的串池中的对象的地址。
- 如果没有,会把此对象引用地址复制一份,放入串池,并返回串池中的引用地址。