前面的文章已经包含了JVM内存区划的主要内容,然而String相关的内容也是面试中的常客,有一部分小伙伴还没有很好的掌握,所以这篇文章整理String的相关内容。
概述
关于String的以下几点,你是否全部知道:
-
常见的两种定义String 的方式,
String s1 = "come" ; String s2 = new String("go");
这两种方式有什么区别?不会没有关系,下文会揭晓。
-
String被声明为final的,不可被继承。
-
String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小
-
String在jdk8及以前内部定义了
final char value[]用于存储字符串数据。JDK9时改为byte[]。
为什么要改为 byte[] 存储?简单来说,用
char[]存储,每个字符使用两个字节(16位),然而大多数情况下,字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用,产生了大量浪费。因此,改成了byte [] 加上编码标记,节约了一些空间。
String 的内存分配(字符串常量池)
-
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
-
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。如何能将String对象直接存储在常量池中,方法有两种。
- 直接使用双引号声明出来的String对象会直接存储在常量池中。比如:
String info="hello"。 - 使用String提供的intern()方法。
- 直接使用双引号声明出来的String对象会直接存储在常量池中。比如:
-
Java 6及以前,字符串常量池存放在永久代
-
Java 7中 Oracle的工程师对字符串池的逻辑做了很大的改变,将字符串常量池的位置调整到Java堆内。所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
-
Java8字符串常量池在堆中。
为什么要将字符串常量池要调整到堆中? 永久代的默认空间大小比较小永久代垃圾回收频率低,大量的字符串无法及时回收,容易进行Full GC产生STW或者容易产生OOM。而堆中空间足够大,字符串可被及时回收。
如下图,StringTable 也就是字符串常量池:
String 的底层结构
- String的String Pool(字符串常量池)是一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern()方法时性能会大幅下降。
- 使用-XX:StringTablesize可设置StringTable的长度
- 在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTablesize设置没有要求
- 在JDK7中,StringTable的长度默认值是60013,StringTablesize设置没有要求
- 在JDK8中,StringTable的长度默认值是60013,StringTable可以设置的最小值为1009
正是因为String 使用 Hashtable 存储,字符串常量池中不会存储相同内容的字符串。
字符串的拼接操作
-
常量与常量的拼接结果在常量池,原理是编译期优化。
-
常量池中不会存在相同内容的变量。(不会重复)
-
拼接前后,只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
-
如果拼接的结果调用intern()方法,根据该字符串是否在常量池中存在,分为:
-
如果存在,则返回字符串在常量池中的地址
-
如果字符串常量池中不存在该字符串,则在常量池中创建一份,并返回此对象的地址
-
结合下面几个例子,理解一下:
//常量与常量的拼接结果在常量池,原理是编译期优化。
public void test1(){
String s1 = "a" + "b" + "c"; //编译期优化:等同于 String s1 = "abc"
String s2 = "abc"; //此时"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 经过编译期优化,上面两行代码相当于:
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}
// 拼接前后,只要其中有一个是变量,结果就在堆中
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop"; //编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
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
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
字符串拼接的细节
经过上面的描述,我们知道, 类似于这样的拼接String s4 = "javaEE" + "hadoop"是利用编译器优化,不需要经过堆,直接在字符串常量池中生成。而对于 String s4 = s1 + s2 这种形式的字符拼接,则会在堆中生成,其实底层是利用 StringBuilder 实现。
请注意,并不是所有情况都会使用 StringBuilder 在堆中生成,看下面例子:
/*
字符串拼接操作不一定使用的是StringBuilder!
1. 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
*/
@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
}
-
通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
-
原因:
-
StringBuilder的append()的方式:
- 自始至终中只创建过一个StringBuilder的对象
-
使用String的字符串拼接方式:
- 创建过多个StringBuilder和String(调的toString方法)的对象,内存占用更大;
- 如果进行GC,需要花费额外的时间(在拼接的过程中产生的一些中间字符串可能永远也用不到,会产生大量垃圾字符串)。
-
-
改进的空间:
-
在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
-
StringBuilder s = new StringBuilder(highLevel); //new char[highLevel] -
这样可以避免频繁扩容
-
new String(“ab”)会创建几个对象?
如果常量池中存在,则只需创建一个对象,否则需要创建两个对象。