前言
String是java语言最重要的类,也是最重要的数据类型。
但是你真的了解String吗?试从jvm角度分析String
以及深度剖析String类的常见面试题
一、String特性
声明方式:
(1)字面量
即String a = “a”;
(2)对象式
String a = new String(“a”);
经过堆的学习,new出来的肯定都是去堆中创建对象。
而字面量方式会去堆中的字符串常量池引用对象,若没有,再在其中创建对象。
String类特点:
- String是不可变类
具有以下特点:
- 类中所有的变量都不能进行修改。【因此也可以说是常量】
- 类中不提供setXXX()方法;
- 类不可被继承,使用final修饰【继承之后有可能子类实例有修改父类变量的能力】
- 类的成员全部使用final修饰
- 只提供带参构造方法指定对象值,一旦创建,直到对象被销毁,值都是永久的
- 提供getter方法时,必须返回对象的clone(),确保不会因为浅拷贝而导致对象值被修改
- Serializable:为了java对象能够跨阶层的传输,提供Serializable这个接口标记对象能够从和到二进制流的相互转化。
String的底层实现:
jdk1.8使用char[]
jdk9使用的是byte[]加上@Stable注解
动机:早期的String使用char[]来存储,但是实际使用的大部分String的数据都是基于Latin-1(拉丁)字符,而拉丁字符数量是小于256个的,因此每个拉丁字符完全可以使用一个字节就能存的下,因此使用两个字节的char来存储导致总有一半空间被浪费了。
因此,使用byte[]来代替char[].
并且,为了照顾其他编码的(UTF-8等)语言,允许他们在自己的编码首部加上一个自己编码的标识,这样的标识让jvm可以去读取几个连续的byte的,因此也可以解决一个byte不够用的场景。
同时,基于String的类,如StringBuilder、StringBuffer、JVM内部String操作都做了修改。
String的重新赋值都会导致一个新的String变量被制造出来。
一道关于不可变性的面试题:
str的值不会改变,ch的内部会改变。
主要因为String的不可变性,形参str的引用值被改变,指向了新的String对象。
而java是值传递方式,每次只会把当前对象的引用复制一份传到方法中,因此原来引用的值不会修改,在方法内部修改形参的引用不会影响到调用者的原版引用。
有几种方式的String使用会使用常量池:
①字面量;
②intern //当前字符串引用从常量池中查找地址并返回引用;若未查找到会在常量池中创建并返回
③常量: 常量型变量【这里的变量不是变的意思】会在类加载时的prepare阶段就赋值,且之后不会修改。
字符串常量池的底层实现:HashTable【这可能是为什么字符串常量池叫做StringTable的原因】
HashTable是什么?
HashTable可以理解为并发版的HashMap,与后者不同,他的键和值都不允许null【hashMap都允许】。
HashTable由于实现了并发,因此性能较低;且性能低于ConcurrentHashMap,因此现在不推荐使用。
HashTable等Map的底层实现都是数组。
正因为HashTable的键的唯一性,字符串常量池中的字符串不会有重复的.
既然是数组,其最大大小就可以设置【当其充斥度达到0.75时,就会扩容。减少Hash碰撞的发生,降低性能】
jkd6,默认1009
jdk7,默认60013
jdk8时,StringTable默认为60013,若设置最低位1009
命令:
- 查看StringTable大小:
jinfo -flag StringTableSize
- 修改StringTable大小:
-XX:StringTableSize
测试不同大小字符串常量池对操作字符串时程序数据的影响:
(1)生成十万行随机字符串
(2)设置不同大小常量池读取,测试时间差距
static class MakeText{
public static void main(String[] args) {
FileWriter fw = null;
try {
fw = new FileWriter("a.txt");
for (int i = 0; i < 100000; i++) {
fw.write(getString());
}
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
fw.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static String getString() {
Random random = new Random();
int len = random.nextInt(10);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) {
sb.append((char)(random.nextInt(60) + 'A'));
}
sb.append("\n");
return sb.toString();
}
}
static class ReadText{
public static void main(String[] args) {
long start = System.currentTimeMillis();
try(
BufferedReader br = new BufferedReader(new FileReader("a.txt"));
) {
String line = null;
while ((line = br.readLine()) != null) {
line.intern(); //若字符串常量池中没有这个值,就在常量池创建它
}
System.out.println(System.currentTimeMillis() - start);
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用常量池大小为1009:
使用常量池大小为10000009
性能提示了七倍。
jdk7中,由于StringTable从永久代移入到堆中,StringTable才能放心大胆的扩容。
为什么移入了堆中效果拔群?
- 移入堆中,GC的频率大大提高【永久代几乎不会发生GC,而字符串又是常常创建一次性的,需要大量GC】
- 堆的空间大小远远大于永久代,更不容易发生OOM
二、String的基本操作
2.1 添加到常量池
下图的代码中,打开debug的内存监控,发现第一轮一到十的输出导致每次操作String类对象数目加一【原来就有2000多个,恐怖如斯】
而第二轮不会增加一个String常量池。
可见使用双引号的字面量会直接在字符串常量池查找或者创建对象。
相同的String字面量具有相同的码点序列【即其编码之后的值是一样的】
官方提供的·方法中的字符串创建case:
当方法中字符串创建时,首先通过toString()在字符串常量池生成一个引用,该对象中的该部位拥有一个指向该StringTable位置的引用,同时给栈帧也返回一个引用。
2.2 拼接
几个结论:【VeryImportant】
case1:
证明拼接的字符串会被编译器优化为拼接后结果存储在常量池中
IDEA有自动反编译的效果:打开.class文件,可以直接看到编译后的代码
public static void main(String[] args) {
String a = "a" + "b" + "c";
String b = "abc";
System.out.println(a == b); //true
System.out.println(a.equals(b));//true
}
a经过编译期优化,变成字节码中就会变成 String a = "abc"
;
因此会在常量池创建常量对象,b就会直接去常量池寻找
从字节码也可以看到,第一个变量就是:abc"
case2 :证明,只要有一个是变量,拼接的结果就会存储到堆中
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a" + "b";
String s5 = s1 + "b";
String s6 = "a" + s2;
String s7 = s1 + s2;
String s9 = s6.intern();
String s10 = s4.intern();
String s11 = s5.intern();
System.out.println(s3 == s4); //true
System.out.println(s3 == s5); //false
System.out.println(s3 == s6); //false
System.out.println(s4 == s5); //false
System.out.println(s6 == s5); //false
System.out.println(s9 == s10); //true
System.out.println(s7 == s10); //false
System.out.println(s11 == s4); //true
}
当拼接操作中出现了一个变量,拼接的结果就相当于进行了 new String ()
,因此存储到了堆中。
变量拼接的底层实现:
- 创建StringBuilder类【注,1.5之前使员工的StringBuffer】
- 调用append()方法;
- 调用toString()方法;【toString()的底层是一个String类的构造方法,约等于new String(“xxx”)】
case:使用字节码查看拼接操作的过程
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = a + b;
}
0 ldc #7 <a>
2 astore_1
3 ldc #9 <b>
5 astore_2
6 aload_1
7 aload_2
8 invokedynamic #11 <makeConcatWithConstants, BootstrapMethods #0>
13 astore_3
14 return
走来两次加载字符串常量池,并存储到局部变量表。
注意看第三个:
【其实我也被震惊到了,这和老师说的不一样啊】
invokedynamic?
查了一下,这是jdk9的变动,将字符串拼接的行为交给了一个工厂类:StringConcatFactory.makeConcatWithConstants
invoke-dynamic 调用一个方法名为invoke,返回值为callsite的方法。这方法的前四个参数为methodhandler,methodhandler.lookup,strong,methodtype. 它可用于支持编译lambda 函数什么的。
jdk8左右的版本:
这个才是老师说的那样:按照创建对象的三部曲:
(1)new (2)dup复制引用到操作数栈 (3)调用构造方法
《结论三》;常量的String成员拼接时,会在编译器进行优化,被等价为一个融合后的String。字符串常量的赋值在prepare阶段。
public static void main(String[] args) {
final String a = "a";
final String b = "b";
String ab = a + b;
String ab2 = "a" + "b";
System.out.println(ab == ab2);//true
}
0 ldc #7 <a>
2 astore_1
3 ldc #9 <b>
5 astore_2
6 ldc #11 <ab>
8 astore_3
9 ldc #11 <ab>
11 astore 4
13 getstatic #13 <java/lang/System.out>
16 aload_3
17 aload 4
19 if_acmpne 26 (+7)
22 iconst_1
23 goto 27 (+4)
26 iconst_0
27 invokevirtual #19 <java/io/PrintStream.println>
30 return
从命令0到11都基本上是一毛一样的指令,ldc是指从字符串常量池取字符。
这与字面量的字节码操作如出一辙【即编译器优化】,可以得出结论:
final成员 = 字面量成员
另外一个建议:
开发时多使用final,可以早点赋值【prepare】,而且会直接创建常量池变量。
总结一下:
“变量”的拼接使用对象拼接【jdk9使用StringComcatFactory,jdk使用StrignBuilder】
常量的拼接使用字面量方式。
为什么StringBuilder的拼接速度远远高于+的拼接
两个对象创建
内存占用大,gC
还可以改进:
需要扩容,每次扩容都复制一次,前面的丢弃
StringBuilder的有参构造【int capacity】
三、intern()方法
3.1 api文档描述:
当常量池已经存在这个String值时,返回该常量引用;
当不存在时,会在常量池创建一个,并返回引用。
3.2 引出的几个面试题
- String ab = new String(“ab”); 创建了几个对象?
两个对象:
- new创建的正常对象;
- 字符串常量池创建的常量
0 new #7 <java/lang/String>
3 dup
4 ldc #9 <ab>
6 invokespecial #11 <java/lang/String.<init>>
9 astore_1
10 return
看前四句:
平时堆中创建对象的流程应当是三步走策略:
new, dup, invokespecial
这次好像多了一个不速之客。
前面说过,ldc是指去字符串常量池加载内容。
这就很奇怪了,我已经new了对象了,为什么还去字符串常量池得到引用?
因为创建对象时,会检查常量池有没有这个常量,没有就会创建并返回引用。
- String ab = new String(“a”) + new String(“b”);中创建了几个对象?
0 new #7 <java/lang/String>
3 dup
4 ldc #9 <a>
6 invokespecial #11 <java/lang/String.<init>>
9 new #7 <java/lang/String>
12 dup
13 ldc #14 <b>
15 invokespecial #11 <java/lang/String.<init>>
18 invokedynamic #16 <makeConcatWithConstants, BootstrapMethods #0>
23 astore_1
24 return
- 出现+号,必定创建了StringBuilder对象;《我是9以上版本,创建StringComcatFactory对象》
- new String()创建堆中a对象;
- ldc说明了字符串常量池出现了“a”
- new String() b
- 字符串常量池出现b
- +导致StringBuilder进行append(), 最后toString()中创建new String(“ab”).
正常的new String()与StringBuilder类的toString()中的new String()有什么区别?
正常的new String()会创建两个String对象,一个堆中正常对象,一个String常量池对象;
toString()中的new String()只会创建堆中变量,不会在字符串常量池添加对象。
重头戏:
3. 看图吧:
此时s == s2 为false; s3 == s4 为false【jdk7之前】, s3 == s4 为true【jdk7及以后】的原因?
第一个为false的原因:
String a = new String(“1”);会创建两个对象,第一个是堆中的,第二个是常量池中的。
第二句,a.intern(),因为常量池中已有对象,所以没有作用。
因此,引用a会指向堆中的正常变量地址。
引用b指向常量池中地址。
两者不是一个对象,自然是false。
s3 == s4辨析;
两个new String()并且+,经过上题的分析,会首先创建五个对象,最后toString()生成一个new String(),且这个new String()不会放到常量池中。
因此s3指向堆中new的对象;
下句s3.intern()会生效,因为他此时并没有在常量池生成对象。
s4会指向常量池的对象。
照上面的分析,s3与s4应该指向的是不同的对象,那肯定是fasle了,那为什么只有jdk7之前的版本才是这样的?
jdk7时,字符串常量池移入堆区,此时有一种新的特性出现了:
当堆中已经有该String变量时,intern()不会在创建新的对象,而是在StringTable中创建一个指针,指向之前创建的String对象,因此s4经过两次指针应用的中转,最终指向之前s3创建的String
因此s3与s4指向同一个变量。
而jdk6时,两者都不在一个区,只能选择创建新对象,因此之前确实是false。
注意,是intern()方法会跳过生成new String()的操作,不要误以为都是这样的,不然上面那个jdk7也是true了。【使用字面量还是会毫不犹豫的真的去创建实打实的对象,而不会创建指针,只是intern()的个人行为】
变形
字面值不会生成引用,因此只会真的去常量池创建对象。
故s3、s4指向不同的变量。
注意点总结:
(1)使用+拼接变量会引起StringBuilder或者StringConcatFactory对象的创建。
(2)StringBuilder的toString()不会在常量池创建对象,普通显式书写的new String()会同时在常量池创建对象。
(3)intern()在1.7之后,再堆中已经有该String变量的情况下不会创建对象,而是生成引用。
指针指向示意:
case:
证明擅用intern()对时间和空间效率上的提升。
public static void main(String[] args) {
final int CAPACITY = 100_000_0;
final int[] pool = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
String[] result = new String[CAPACITY];
long start = System.currentTimeMillis();
for (int i = 0; i < CAPACITY; i++) {
String s = new String(String.valueOf(pool[i % pool.length]));
// String s = new String(String.valueOf(pool[i % pool.length])).intern();
result[i] = s;
}
System.out.println(System.currentTimeMillis() - start);
try {
Thread.sleep(1000_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
可见时间和空间消耗都降低了许多。
前者的数组存储使得时刻有引用指向这个堆区的对象,而newString()又是买一送一的,导致StringTable中又造了这个对象,而String Tbale的字符串得不到数组的引用,就是恰白饭的,因此白白损耗了空间。
而堆区中实际被数组引用的对象由于得不到回收,一直占着堆区不放,后来就会越来越难分配空间,到了后来甚至需要不断的GC勉强维持生活,等到GC也GC不动了就会OOM。
后者那句因为intern()前面的引用接收的是字符串常量池的地址,因此堆中的地址会被GC掉,自然空间有所下降。相对的堆区较为空旷,分配效率高,又不会进行GC,执行效率也就提高了。
四、C1的String去重
堆内存中25%的内容都是String对象,而其中有13%都是重复的。
而大规模的java应用的性能瓶颈都在于内存空间不足,需要频繁地GC。
因此减少这String的堆内存一半浪费很有必要。
C1编译器设计者的想法是,若堆中有等值String的对象,将不再创建对象,而是直接指向这个对象的引用。
既然涉及到去重,我们最熟悉的HashMap与HashTable又登场了。
垃圾回收器在GC时会去查询这个记录所有不重复byte[]的HashTable,然后帮助重复的String引用指向Hashtable中已经存在的引用,而将没有引用指向的那个重复串释放。