文章目录
首先,在Java中创建大量对象是非常耗费时间的。
其次,在程序中又经常使用相同的字符串对象,如果每次都去重新创建相同的字符串对象将会非常浪费空间。
最后,字符串对象具有不可变性,即字符串对象一旦创建,内容和长度是固定的,既然这样,那么字符串对象完全可以共享。所以就有了StringTable这一特殊的存在,StringTable叫作字符串常量池,用于存放字符串常量,这样当我们使用相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。
String的基本特性
String类概述
String是字符串的意思,可以使用一对双引号引起来表示,而String又是一个类,所以可以用new关键字创建对象。因此字符串对象的创建有两种方式,分别是使用字面量定义
和new的方式创建
。
字面量的定义方式
:String s1 = “atguigu”;
以new的方式创建
:String s2 = new String(“hello”)。
String类声明是加final修饰符的,表示String类不可被继承
;
String类实现了Serializable接口,表示字符串对象支持序列化
;
String类实现了Comparable接口,表示字符串对象可以比较大小
。
String在JDK 8及以前版本内部定义了final char[] value
用于存储字符串数据。JDK 9时改为final byte[] value
。
String类的当前实现将字符串存储在char数组中,每个char类型的字符使用2字节(16位)。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而大多数字符串对象只包含Latin-1字符。这些字符只需要1字节的存储空间,也就是说这些字符串对象的内部字符数组中有一半的空间并没有使用。
我们建议将String类的内部表示形式从UTF-16字符数组更改为字节数组加上字符编码级的标志字段。新的String类将根据字符串的内容存储编码为ISO-8859-1/Latin-1(每个字符1字节)或UTF-16(每个字符2字节)编码的字符。编码标志将指示所使用的编码。
基于上述官方给出的理由,String不再使用char[]来存储
,改成了byte[]加上编码标记
,以此达到节约空间的目的。JDK9关于String类的部分源码如代码清单所示,可以看出来已经将 char[] 改成了byte[]。
String的不可变性
String是不可变的字符序列,即字符串对象具有不可变性。
例如,对字符串变量重新赋值
、对现有的字符串进行连接操作
、调用String的replac
e等方法修改字符串等操作时,都是指向另一个字符串对象而已,对于原来的字符串的值不做任何改变。下面通过代码验证String的不可变性,如代码清单所示。
import org.junit.Test;
public class StringTest1 {
@Test
public void test01() {
String sl = "java";
String s2 = "java";
sl = "atguigu";
System.out.println(sl);//atguigu
System.out.println(s2);//java
}
@Test
public void test2() {
String sl = "java";
String s2 = sl + "atguigu";
System.out.println(sl);//java
System.out.println(s2);//javaatquigu
}
@Test
public void test3() {
String sl = "java";
String s2 = sl.concat("atguigu");
System.out.println(sl);//java
System.out.println(s2);//javaatguigu
}
@Test
public void test4() {
String sl = "java";
String s2 = sl.replace("a","A");
System.out.println(sl);//java
System.out.println(s2);//jAvA
}
}
String的“值传递”表象
public class StringExer {
String str = new String("good");
char[] ch = {'t', 'e', 's', 't'};
public void change(String str, char ch[]) {
System.out.println(str);//good
System.out.println(ch);//test
str = str + " test ok";
ch[0] = 'b';
System.out.println(str);//good test ok
System.out.println(ch);//best
}
public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str);//good
System.out.println(ex.ch);//best
}
}
在上面的代码中,因为change(String str,char ch[])方法的两个形参都是引用数据类型,接收的都是实参对象的首地址,即str和ex.str指向同一个对象,ch和ex.ch指向同一个对象,所以在change方法中打印str和ch的结果和实参ex.str和ex.ch一样。虽然str在change方法中进行了拼接操作,str的值变了,但是由于String对象具有不可变性,str指向了新的字符串对象,就和实参对象ex.str无关了,所以ex.str的值不会改变。而ch在change方法中并没有指向新对象,对ch[0]的修改,相当于对ex.ch[0]的修改。
字符串常量池
因为String对象的不可变性,所以String的对象可以共享。但是Java中并不是所有字符串对象都共享
,只会共享字符串常量对象
。Java把需要共享的字符串常量对象存储在字符串常量池(StringTable)中,即字符串常量池中是不会存储相同内容的字符串的。
字符串常量池的大小
在JDK6中StringTable长度默认是1009,所以如果常量池中的字符串过多就会导致效率下降很快。在JDK 7和JDK 8中,StringTable长度默认是60013。StringTable长度默认是60013
。使用-XX:StringTableSize
可自由设置StringTable的长度。但是在JDK 8中,StringTable长度设置最小值是1009
。
当字符串常量池的长度较短时,代码执行性能降低。
字符串常量池的位置
当直接使用字面量的方式(也就是直接使用双引号)创建字符串对象时,会直接将字符串存储至常量池。当使用其他方式(如以new的方式)创建字符串对象时,字符串不会直接存储至常量池,但是可以通过调用String的intern()方法将字符串存储至常量池。
HotSpot虚拟机中在Java 6及以前版本中字符串常量池放到了永久代
,在Java 7及之后版本中字符串常量池被放到了堆空间
。字符串常量池位置之所以调整到堆空间,是因为永久代空间默认比较小,而且永久代垃圾回收频率低。将字符串保存在堆中,就是希望字符串对象可以和其他普通对象一样,垃圾对象可以及时被回收,同时可以通过调整堆空间大小来优化应用程序的运行。
不同JDK版本中字符串常量池的变化
import java.util.ArrayList;
/**
* JDK6中:
* -XX:PermSize=20m -XX:MaxPermSize=20m -Xms128m -Xmx256m
* JDK7中:
* -XX:PermSize=20m -XX:MaxPermSize=20m -Xms128m -Xmx256m
* JDK8中:
* -XX:MetaspaceSize=20m -XX:MaxMetaspaceSize=20m -Xms128m -Xmx256m
*/
public class StringTest3 {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
当使用JDK 6时,设置永久代(PermSize)内存为20MB,堆内存大小最小值为128MB,最大值为256MB,运行代码后,报出永久代内存溢出异常,如图所示。
在JDK 7中设置永久代(PermSize)内存为20MB,堆内存大小最小值为128MB,最大值为256MB,运行代码后,报出如图所示堆内存溢出异常。由此可以看出,字符串常量池被放在了堆中,最终导致堆内存溢出。
在JDK 8中因为永久代被取消,所以PermSize参数换成了MetaspaceSize参数,设置元空间(MetaspaceSize)的大小为20MB,堆内存大小最小值为128MB,最大值为256MB时,运行代码报错和JDK版本一样,也是堆内存溢出错误。
字符串常量对象的共享
import org.junit.Test;
public class StringTest4 {
@Test
public void test1() {
String s1 = "hello";//code (1)
String s2 = "hello";//code(2)
String s3 = "atguigu";//code (3)
System.out.println();
}
}
Debug运行并查看Memory内存结果如图所示,code(1)代码运行之前,字符串的数量为3494 个,code(1)语句执行之后,字符串的数量为3495 个,说明code(1)语句产生了1个新的字符串对象。当code(2)语句执行之后,字符串的数量仍然为 3495个,说明code(2)语句没有产生新的字符串对象,和code(1)语句共享同一个字符串对象“hello”。当code(3)语句执行之后,字符串的数量为3496 个,说明code(3)语句又产生了1个新的字符串对象,因为code(3)语句的字符串“atguigu”和之前的字符串常量对象不一样。
演示new出来的字符串不在字符串常量池
Debug运行并查看Memory内存结果如图所示。code(4)代码运行之前,字符串的数量为3513个,code(4)语句执行之后,字符串的数量为3515个,说明code(4)语句产生了两个新的字符串对象,一个是new出来的,一个是字符串常量对象“hello”。当code(5)语句执行之后,字符串的数量为3516个,说明code(5)语句只新增了1个字符串对象,它是新new出来的,而字符串常量对象“hello”和code(4)语句共享同一个。
字符串拼接操作
不同方式的字符串拼接
import org.junit.Test;
public class StringTest5 {
@Test
public void test1() {
String s1 = "a" + "b" + "c"; // 编译期优化:等同于 "abc"
String s2 = "abc"; // "abc"一定是放在StringTable中
System.out.println(s1 == s2);//true
//字面常量与字面常量的"+"拼接结果在常量池,原理是编译器优化
}
@Test
public void test2() {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + new String("hadoop");
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
//"+"拼接中出现字符串变量等非字面常量
//结果都不在StringTable中
System.out.println(s3 == s4);//false
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
//字符串"+"拼接中只要其中有一个是变量或非字面常量,结果不会直接放在StringTable中
}
@Test
public void test3() {
String sl = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = sl.concat(s2);
//concat拼接结果不在StringTable中
System.out.println(s3 == s4);//false
//凡是使用concat()方法拼接的结果不会放在StringTable中
}
@Test
public void test4() {
String s1 = "hello";
String s2 = "java";
String s3 = "hellojava";
String s4 = (s1 + s2).intern();
String s5 = s1.concat(s2).intern();
//拼接后调用intern()方法,结果都在StringTable中
System.out.println(s3 == s4); //true
System.out.println(s3 == s5); //true
//如果拼接的结果调用intern(方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
}
@Test
public void test5() {
final String s1 = "hello";
final String s2 = "java";
String s3 = "hellojava";
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
//s1和s2前面加了final修饰,那么s1和s2仍然是字符串常量,即s1和s2是"hello"和"java"的代名词而已
}
}
通过上面的代码我们可以得出以下结论:
(1)字符串常量池中不会存在相同内容的字符串常量。
(2)字面常量字符串与字面常量字符串的“+”拼接结果仍然在字符串常量池。
(3)字符串“+”拼接中只要其中有一个是变量或非字面常量,结果不会放在字符串常量池中。
(4)凡是使用concat()方法拼接的结果也不会放在字符串常量池中。
(5)如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
字符串拼接的细节
字节码命令视图可以看出代码清单中StringTest类的test1方法中两个字符串加载的内容是相等的,也就是编译器对"a"+“b”+“c"做了优化,直接等同于"abc”。
从字节码命令视图可以看出代码清单中StringTest类的test2方法中,两个字符串拼接过程使用StringBuilder类的append()方法来完成,之后又通过toString()方法转为String字符串对象。而StringBuilder类的toString()方法源码如代码清单所示,它会重新new一个字符串对象返回,而直接new的String对象一定是在堆中,而不是在常量池中。
下面查看String类的concat()方法源码,如代码清单所示,只要拼接的不是一个空字符串,那么最终结果都是new一个新的String对象返回,所以拼接结果也是在堆中,而非常量池。
“+”拼接和StringBuilder拼接效率
我们提到了“+”拼接过程中,如果“+”两边有非字符串常量出现,编译器会将拼接转换为StringBuilder的append拼接。那么使用“+”拼接和直接使用StringBuilder的append()拼接,效率有差异吗?代码清单演示了字符串“+”拼接和StringBuilder的append()的效率对比。
import org.junit.Test;
public class StringConcatTimeTest {
@Test
public void test1() {
long start = System.currentTimeMillis();
String src = "";
for (int i = 0; i < 100000; i++) {
src = src + "a";
}
//每次循环都会创建一个StringBuilder、String
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
//花费的时间为:4092ms
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
System.out.println("占用的内存:" + (totalMemory - freeMemory));
// 占用的内存:18042600B
}
@Test
public void test2() {
long start = System.currentTimeMillis();
StringBuilder src = new StringBuilder();
for (int i = 0; i < 100000; i++) {
src.append("a");
}
//每次循环都会创建一个StringBuilder、String
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
//花费的时间为:8ms
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
System.out.println("占用的内存:" + (totalMemory - freeMemory));
// 占用的内存:11038776B
}
}
明显test2()方法的效率更高。这是因为test2()方法中StringBuilder的append()自始至终只创建过一个StringBuilder对象。test1()方法中使用String的字符串拼接方式会创建多个StringBuilder和String对象。
intern()的使用
StringTest5类的test4()方法中可以看出,无论是哪一种字符串拼接,拼接后调用intern()结果都在字符串常量池。这是为什么呢
当调用intern()方法时,如果池中已经包含一个等于此String对象的字符串,则返回池中的字符串。否则,将此String对象添加到池中,并返回此String对象的引用。也就是说,如果在任意字符串上调用intern()方法,那么其返回地址引用和直接双引号表示的字面常量值的地址引用是一样的
。例如:new String(“I love atguigu”).intern()== “I love atguigu”和“I love atguigu” == new String(“I love atguigu”).intern()的结果都是true。