Java 之 String 重点解析,成功定级腾讯 T3-2
基于重复使用 String 的情况比更改 String 的场景更多的前提下,Java 把 String 设计为不可变,保持数据一致性,使得同个字面量的字符串可以引用同个 String 对象,重复利用已存在的 String 对象。
在《Java 编程思想》一书中还提到另一个观点。我们先看下面的代码:
public String allCase(String s){
return string.toUpperCase();
}
allCase?方法把传入的 String 对象全部变成大写并返回修改后的字符串。而此时,调用者的期望是传入的 String 对象仅仅作为提供信息的作用,而不希望被修改,那么 String 不可变的特性则非常符合这一点。
使用 String 对象作为参数时,我们希望不要改变 String 对象本身,而 String 的不可变性符合了这一点。
存储原理
====
由于 String 对象的不可变特性,在存储上也与普通的对象不一样。我们都知道对象创建在?堆上,而 String 对象其实也一样,不一样的是,同时也存储在?常量池?中。处于堆区中的 String 对象,在 GC 时有极大可能被回收;而常量池中的 String 对象则不会轻易被回收,那么则可以重复利用常量池中的 String 对象。也就是说,?常量池是 String 对象得以重复利用的根本原因。
常量池不轻易垃圾回收的特性,使得常量池中的 String 对象可以一直存在,重复被利用。
往常量池中创建 String 对象的方式有两种: 显式使用双引号构造字符串对象、使用 String 对象的?intern()?方法 。这两个方法不一定会在常量池中创建对象,如果常量池中已存在相同的对象,则会直接返回该对象的引用,重复利用 String 对象。其他创建 String 对象的方法都是在堆区中创建 String 对象。举个栗子吧。
当我们通过?new String()?的方法或者调用 String 对象的实例方法,如?string.substring()?方法,会在堆区中创建一个 String 对象。而当我们使用双引号创建一个字符串对象,如?String s = "abc"?,或调用 String 对象的?intern()?方法时,会在常量池中创建一个对象,如下图所示:
还记得我们文章开头的问题吗?
String s = new String("abc")?,这句代码创建了几个对象??"abc"?在常量池中构造了一个对象,?new String()?方法在堆区中又创建了一个对象,所以一共是两个。
s=="abc"?的结果是 false。两个不同的对象,一个位于堆中,一个位于常量池中。
s.substring(0,2).intern()=="ab"?intern 方法在常量池中构建了一个值为“ab"的 String 对象,"ab"语句不会再去构建一个新的 String 对象,而是返回已经存在的 String 对象。所以结果是 true。
只有 显式使用双引号构造字符串对象、使用 String 对象的?intern()?方法 这两种方法会在常量池中创建 String 对象,其他方法都是在堆区创建对象。每次在常量池创建 String 对象前都会检查是否存在相同的 String 对象,如果是则会直接返回该对象的引用,而不会重新创建一个对象。
关于 intern 方法还有一个问题需要讲一下,在不同 jdk 版本所执行的具体逻辑是不同的。在 jdk6 以前,方法区是存放在永生代内存区域中,与堆区是分割开的,那么当往常量池中创建对象时,就需要进行深拷贝,也就是把一个对象完整地复制一遍并创建新的对象,如下图:
永生代有一个很严重的缺点:?容易发生 OOM?。永生代是有内存上限的,且很小,当程序大量调用 intern 方法时很容易就发生 OOM。在 JDK7 时将常量池迁移出了永生代,改在堆区中实现,jdk8 以后使用了本地空间实现。jdk7 以后常量池的实现使得在常量池中创建对象可以进行浅拷贝,也就是不需要把整个对象复制过去,而只需要复制对象的引用即可,避免重复创建对象,如下图:
观察这个代码:
String s = new String(new char[]{'a'});
s.intern();
System.out.println(s=="a");
在 jdk6 以前创建的是两个不同的对象,输出为 false;而 jdk7 以后常量池中并不会创建新的对象,引用的是同个对象,所以输出是 true。
jdk6 之前使用 intern 创建对象使用的深拷贝,而在 jdk7 之后使用的是浅拷贝,得以重复利用堆区中的 String 对象。
通过上面的分析,String 真正重复利用字符串是在使用双引号直接创建字符串时。使用 intern 方法虽然可以返回常量池中的字符串引用,但是本身已经需要堆区中的一个 String 对象。因而我们可以得出结论:
尽量使用双引号显式构建字符串;如果一个字符串需要频繁被重复利用,可以调用 intern 方法将他存放到常量池中。
字符串拼接
=====
字符串操作最多的莫过于字符串拼接了,由于 String 对象的不可变性,如果每次拼接都需要创建新的字符串对象就太影响性能了。因此,官方推出了两个类:?StringBuffer、StringBuilder?。这两个类可以在不创建新的 String 对象的前提下拼装字符串、修改字符串。如下代码:
StringBuilder stringBuilder = new StringBuilder("abc");
stringBuilder.append("p")
.append(new char[]{'q'})
.deleteCharAt(2)
.insert(2,"abc");
String s = stringBuilder.toString();
拼接、插入、删除都可以很快速地完成。因此,使用 StringBuilder 进行修改、拼接等操作来初始化字符串是更加高效率的做法。StringBuffer 和 StringBuilder 的接口一致,但 StringBuffer 对操作方法都加上了 synchronize 关键字,保证线程安全的同时,也付出了对应的性能代价。单线程环境下更加建议使用 StringBuilder。
拼接、修改等操作来初始化字符串时使用 StringBuilder 和 StringBuffer 可以提高性能;单线程环境下使用 StringBuilder 更加合适。
一般情况下,我们会使用?+?来连接字符串。?+?在 java 经过了运算符重载,可以用来拼接字符串。编译器也对?+?进行了一系列的优化。观察下面的代码:
String s1 = "ab"+"cd"+"fg";
String s2 = "hello"+s1;
Object object = new Object();
String s3 = s2 + object;
对于 s1 字符串而言,编译器会把?"ab"+
"cd"+"fg"?直接优化成?"abcdefg"?,与 String s1 = "abcdefg";?是等价的。这种优化也就减少了拼接时产生的消耗。甚至比使用 StringBuilder 更加高效。
s2 的拼接编译器会自动创建一个 StringBuilder 来构建字符串。也就相当于以下代码:StringBuilder sb =?new?StringBuilder(); sb.append("hello"); sb.append(s1); String s2 = sb.toString();那么这是不是意味着我们可以不需要显式使用 StringBuilder 了,反正编译器都会帮助我们优化?当然不是,观察下边的代码:String?s =?"a"; for(int?i=0;i<=100;i++){ s+=i; }这里有 100 次循环,则会创建 100 个 StringBuilder 对象,这显然是一个非常错误的做法。这时候就需要我们来显示创建 StringBuilder 对象了:StringBuilder sb = new StringBuilder("a");?for(inti=0;i<=100;i++){?sb.append(i);?} String s = sb.toString();只需要构建一个 StringBuilder 对象,性能就极大地提高了。
String s3 = s2 + object;?字符串拼接也是支持直接拼接一个普通的对象,这个时候会调用该对象的?toString?方法返回一个字符串来进行拼接。?toString?方法是 Object 类的方法,若子类没有重写,则会调用 Object 类的 toString 方法,该方法默认输出类名+引用地址。这看起来没有什么问题,但是有一个大坑:?切记不要在 toString 方法中直接使用?+?拼接自身?。如下代码 @Override public String toString() { return this+"abc"; }这里直接拼接 this 会调用 this 的 toString 方法,从而造成了无限递归。
Java 对+拼接字符串进行了优化:
可以直接拼接普通对象
字面量直接拼接会合成一个字面量
普通拼接会使用 StringBuilder 来进行优化
但同时也有注意这些优化是有限度的,我们需要在合适的场景选择合适的拼接方式来提高性能。
编码问题
====
在 Java 中,一般情况下,一个 char 对象可以存储一个字符,一个 char 的大小是 16 位。但随着计算机的发展,字符集也在不断地发展,16 位的存储大小已经不够用了,因此拓展了使用两个 char,也就是 32 位来存储一些特殊的字符,如 emoij。一个 16 位称为一个?代码单元?,一个字符称为?代码点?,一个代码点可能占用一个代码单元,也可能是两个。
评论