目录
1. 基本特性
String:字符串,使用一对""引起来表示
String声明为final的,不可被继承
String实现了Serializable接口:表示字符串是支持序列化的。
String实现了Comparable接口:表示string可以比较大小
String在jdk8及以前内部定义了final char[] value用于存储字符串数据。JDK9时改为byte[]
为什么在JDK9的时候更改String的内部结构?
Java8及以前,String内部使用一个char[]保存UTF-16格式字符串,但是这样做有几个问题:
- 如果字符串中主要是ASCII字符,那么用16位的char来保存指利用了8位的ASCII字符相当于浪费了一半的空间
- 在Java起源时,1个16位的char可以完美表示1个16位的UTF-16字符,但是,2^16 = 65536个字符显然无法表示世界上所有奇奇怪怪的字符,包括很多汉字,因此UTF-16的标准被修改了,使得有的字符必须用2个16位长的码元来表示,这下就坑爹了,无奈之下,Java只好有些时候用2个char表示一个字符,这2个char被成为代理项对(surrogate pair)。这种情况的出现使得1个字符对应1个char的方便性大大丧失,很多假定1字符1char的API也失效了。
- 2中的情况会导致一些令人迷惑的行为,例如当字符串含有emoji字符时,String.length()不等于String.toCharArray().length。这是因为emoji字符必须使用两个char存储,而String.toCharArray().length会返回char的数量(即UTF-16码元数),String.length()则会返回“真实”的、眼睛可以看到的字符数量。曾经,在UTF-16编码一个码元对应一个字符的时代,这两个是相等的。
因此在Java9中,引入了一个叫做紧凑字符串(Compact String)的新特性,在String内部使用byte[]代替char[]。如果字符串的内容都是ISO-8859-1/Latin-1字符(1个字符1byte),则使用ISO-8859-1/Latin-1编码存储字符串,否则使用UTF-16编码存储数组(一个字符2byte或者4byte)。但是为了兼容,String类的方法向外表现的行为是与之前的版本一致的,比如toCharArray()和charAt()方法的行为依然和之前一致。
String:代表不可变的字符序列。简称:不可变性。 重点!!!
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用string的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池是不会存储相同内容的字符串的。
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是可以设置的最小值
2. String的内存分配
2.1 字符串常量池
常量池就是JAVA系统级别提供的缓存。八种数据类型的常量池都是系统协调的,只有String类型的常量池比较特殊。
两种使用方法
- 直接使用双引号声明出来的String对象会直接存储在常量池中。
- 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。这个后面重点谈
2.1 字符串常量池位置的改变
在JDK1.6之前字符串常量池存放在永久代
在JDK7字符串常量池的位置调整到Java堆内
调整的理由:所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
3. String的基本操作
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
在下面的代码中,字面量相同的字符串应该是同一内存地址。
@Test
public void test1() {
System.out.print1n("1"); //2321
System.out.println("2"); //2322
System.out.println("3"); //2323
System.out.println("1"); //2321
System.out.println("2"); //2322
System.out.println("3"); //2323
}
3.1 字符串的拼接操作
常量和常量的拼接发生在常量池,并且常量池不会存在相同内容的变量
拼接的两者只要有一个是变量,拼接的结果就在堆中。拼接的底层会创建StringBuilder,再调用它的append方法。比如
s1 + s2
实际上是new了一个StringBuilder对象,并使用了append方法将s1和s2添加进来,最后调用了toString方法赋给s4。
例子1:
public static void test1() {
// 都是常量,前端编译期会进行代码优化
// 通过idea直接看对应的反编译的class文件,会显示 String s1 = "abc"; 说明做了代码优化
String s1 = "a" + "b" + "c";
String s2 = "abc";
// true,有上述可知,s1和s2实际上指向字符串常量池中的同一个值
System.out.println(s1 == s2);
}
例子2:
public static void test5() {
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"
}
例子3:
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修饰,即为常量。会在编译器进行代码优化(比如 "a"+"b"就会被当成"ab")。在实际开发中,能够使用final的,尽量使用。
例子4:
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3==s4);
}
3.2 拼接操作性能对比
通过StringBuilder的append方式的速度,要比直接对String使用“+”拼接的方式快的不是一点半点
那么,在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用StringBuilder进行append操作
StringBuilder空参构造器的初始化大小为16。那么,如果提前知道需要拼接String的个数,就应该直接使用带参构造器指定capacity,以减少扩容的次数(扩容的逻辑可以自行查看源代码)
4. intern()的使用
这个方法是一个 native 的方法,
当调用intern方法时,如果池子里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。
对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。
如果不是用双引号声明的String对象,可以使用String提供的intern方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
String myInfo = new string("I love atguigu").intern();
如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。
//肯定成立
("a"+"b"+"c").intern() == "abc"
4.1 intern的使用:JDK6 vs JDK7/8
JDK1.6中,将这个字符串对象尝试放入串池。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
JDK1.7起,将这个字符串对象尝试放入串池。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
我们来看一行代码:
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
打印结果是:
- jdk6 下
false false
- jdk7 下
false true
具体为什么稍后再解释,然后将s3.intern();
语句下调一行,放到String s4 = "11";
后面。将s.intern();
放到String s2 = "1";
后面。是什么结果呢
public static void main(String[] args) {
String s = new String("1");
String s2 = "1";
s.intern();
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);
}
打印结果为:
- jdk6 下
false false
- jdk7 下
false false
jdk6中的解释
注:图中绿色线条代表 string 对象的内容指向。 黑色线条代表地址指向。
如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用
String.intern
方法也是没有任何关系的。
JDK7中的解释
再说说 jdk7 中的情况。这里要明确一点的是,在 Jdk6 以及以前的版本中,字符串的常量池是放在堆的 Perm 区的,Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用 intern 是会直接产生
java.lang.OutOfMemoryError: PermGen space
错误的。 所以在 jdk7 的版本中,字符串常量池已经从 Perm 区移到正常的 Java Heap 区域了。为什么要移动,Perm 区域太小是一个主要原因。正是因为字符串常量池移动到 JAVA Heap 区域后,再来解释为什么会有上述的打印结果。
- 在第一段代码中,先看 s3和s4字符串。
String s3 = new String("1") + new String("1");
,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")
我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。- 接下来
s3.intern();
这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 “11” 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。最后
String s4 = "11";
这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较s3 == s4
是 true。再看 s 和 s2 对象。
String s = new String("1");
第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern();
这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。接下来
String s2 = "1";
这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。
- 来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是
s3.intern();
的顺序是放在String s4 = "11";
后了。这样,首先执行String s4 = "11";
声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();
时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。- 第二段代码中的 s 和 s2 代码中,
s.intern();
,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");
的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。
接下来在看几个例子:
例一:
例二:
4.2 空间效率测试
对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。
G1垃圾收集器的String去重操作
再堆区中其实是存在着大量的重复的String,堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的string对象进行去重,这样就能避免浪费内存。
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。
- 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
5. 常见面试题
参考:参考
String a="123" 创建了几个对象?
创建了1个对象
jvm在编译阶段会判断常量池中是否有 "123" 这个常量对象如果有,a直接指向这个常量的引用,如果没有会在常量池里创建这个常量对象。
String a=new String("123")会创建几个对象?
创建了2个对象
同情况1,jvm编译阶段判断常量池中 "123"存在与否,进而来判断是否创建常量对象,然后运行阶段通过new关键字在java heap创建String对象。
String a="123"+"456" 会创建几个对象?
创建了1个对象
jvm编译阶段过编译器优化后会把字符串常量直接合并成"123456",所有创建对象时最多会在常量池中创建1个对象。
String a="123"+new String("456") 创建几个对象?
创建了5个对象
常量池对象"123" ,"456",new String("456")创建堆对象,还有一个堆对象"123456"和stringbuilder()对象
String a=new String("123")+new String("456")会创建几个对象?
创建了6个对象
常量池中的:123,456 以及堆空间:new String("123")和new String("456") 和123456 最后还有 stringbuilder()
stringbuilder的toString会创建123456,但是不会将其放在字符串常量池中