字符串相关的操作对我们平时的开发是很重要的,这次就从底层出发讲讲字符串相关内容。
String基本特性
创建字符串的方式
大致分为两种,一种是直接加双引号,一种是使用new关键字,代码如下
String s1="hello bb";
String s2="hi baobao";
类相关
请看如下代码
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
//.......
}
final
String类是不可继承的,因为加了final关键字,且人家已经写的很完美了,无需我们扩展。
Serializable
实现了序列化接口,所以在一些远程传输方面的序列化与反序列化方面也是支持的。
Comparable
实现了比较接口,让其可以根据一定的规则进行排序,或者比较字符串序列。
总结三点
- 不可继承
- 序列化
- 可比较
不可变性
从三个角度来证实字符串的不可变性
1.字符串的重新赋值
重新赋值,不会修改原先内存区域的value,而是新创建一个value
public class Demo1 {
public static void main(String[] args) {
String s1 = "bb";
String s2 = "bb";
System.out.println(s1 == s2);//true
s1 = "hello";
System.out.println(s1 == s2);//false
}
}
其实这个例子并不明显,首先我们要知道,s1被赋值了bb,那么就会被放入常量池中,由于常量池中不会有相同的常量,所以s1,s2指向的是同一个常量,而s1被赋值为hello,对原有的bb是不会进行修改的,只不过多了一个常量,如下图所示。
2.字符串连接操作
public class Demo2 {
public static void main(String[] args) {
String s1 = "bb111";
String s2 = "bb";
String s3 = s2 + "111";
System.out.println(s1 == s3);//false
}
}
对已有的字符串的拼接操作,也需要重新指定内存区域赋值,不能对原value进行修改,简单来讲,拼接后相当于创建了新的对象,如图所示。
3.字符串替换操作
public class Demo3 {
public static void main(String[] args) {
String s1 = "bb111";
String s2 = "bb222";
String s3 = s2.replace("2", "1");
System.out.println(s3);//bb111
System.out.println(s1 == s3);//false
}
}
对字符串进行替换操作,内容上来讲是一样的,但是得到的结果是false,原因还在于s3也是新创建的对象,与常量池对象做比较,地址不同,自然是不一样的,如图所示。
字符串在jdk9的改变
private final char value[];//jdk8
private final byte[] value;//jdk9
private final byte coder; //jdk9
我截取了部分代码,可以看到8我们用的是char数组,而到了9用byte数组加上coder编码,为什么要这样呢?直接贴官方解释。
总结下,原因就在于官方语言是英语,一般都是占1个字节的空间,使用char会造成空间浪费,而如果我们要使用中文,需要修改coder变量的标志位为UTF-16。
不存储相同字符串
重点内容!!!!!
注意,是字符串常量池,而不是字符串本身。
字符串常量池的底层是用HashTable实现的,类似于HashMap,是由数组+链表的形式,所以说不允许重复,并且内部维护了一个默认大小的StringTable,如果字符串常量过多,就很容易造成hash冲突,使得链表过长,效率变低。
jdk7和7dk8的时候,默认长度为60013,但jdk8有一点不同,长度最小为1009,但jdk7是没有限制的。
字符串内存分配
字符串内部维护了一个常量池,只有两种方法能够使得字符串进入常量池。
- 直接使用字面量赋值操作
- 使用intern()方法
在jdk6及以前,字符串常量池是放在永久代的,但是jdk7及以后就去掉了永久代,统一将字符串常量池放入堆空间中了,我们从原因分析,因为永久代的调优比起堆来说要困难,且gc的频率也很低,容易造成字符串的堆积,这么一想确实应该放在堆中。
案例分析
下面是对象创建的过程,试试能不能动手画一下内存结构图呢
public class Memory {
public static void main(String[] args) {
int i = 1;
Object obj = new Object();
Memory mem = new Memory();
mem.foo(obj);
}
private void foo(Object param) {
String str = param.toString();
System.out.println(str);
}
}
图示如下,最终str指向堆空间的对象,因为没有使用字面量或者intern,所以不会放入常量池中,这点需要注意。
字符串拼接的情况
对于字符串拼接的,我总结了以下四种情况
案例1
public class Demo1 {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "a" + "b" + "c";
System.out.println(s1==s2);//true
}
}
结果是true,因为字符串在编译期间进行了优化,这样的常量拼接就相当于直接定义"abc"。
案例2
public class Demo2 {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "a";
String s3 = s2 + "bc";
System.out.println(s1==s2);//false
}
}
之前其实也提到过,现在拿出来在说一下,内部细节是这样的。
- 先new StringBuilder()对象
- StringBuilder对象.append(”a“) 追加a字符串
- StringBuilder对象.append(”bc“) 追加bc字符串
- StringBuilder对象.toString() 转化为字符串
最终的结果约等于创建了新的对象,所以返回false。
案例3
public class Demo3 {
public static void main(String[] args) {
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1 == s2);//false
s2 = s2.intern();
System.out.println(s1 == s2);//true
}
}
可以看到,第一次比较,两个对象的地址是不相同的,但是后面执行了intern方法,会先判断下abc是否存放在常量池,没有就放入,有的话就重新指向。
案例4
public class Demo4 {
public static void main(String[] args) {
String s1 = "abc";
final String s2 = "a";
final String s3 = "bc";
String s4 = s2 + s3;
System.out.println(s1 == s4);//true
//其实就是等价于
//s4="a"+"bc";
}
}
我们可以看到,s4变量有一个拼接的操作,但是最终得到的结果竟是和s1相等的,也就是指向了同一常量,因为final变量会在编译期就进行优化,就相当于常量的拼接。
这里还需要给出一点小建议,我们在平时开发的时候,对于那些不会改变的变量和类,建议加上final,这使得一些代码在编译期就确定下来,也算是一个小优化吧。
测试拼接与追加操作
出于测试方便,我引入了hutool的包,依赖如下
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.6.3</version>
</dependency>
定义两个方法,各执行拼接操作十万次
public class Demo8 {
public static void main(String[] args) {
f1();
f2();
f3();
}
public static void f1() {
TimeInterval timer = DateUtil.timer();
String s = "";
for (int i = 0; i < 100000; i++) {
s += "b";
}
long interval = timer.interval();//花费毫秒数
System.out.println("字符串拼接花费时间:"+interval);
}
public static void f2() {
TimeInterval timer = DateUtil.timer();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("b");
}
long interval = timer.interval();//花费毫秒数
System.out.println("sb append花费时间:"+interval);
}
}
测试结果如下
字符串拼接花费时间:2897
sb append花费时间:4
可以看到,使用append操作能够大大提高效率,原因在于字符串的拼接在循环体中会频繁创建StringBuilder
对象的,大部分时间其实是创建对象导致的。
小优化
当然我们还可以优化,如果我们在确定了拼接对象大概需要多少容量,就可以提前指定,代码如下,这次拼接一百万次。
public static void f2() {
TimeInterval timer = DateUtil.timer();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000000; i++) {
sb.append("b");
}
long interval = timer.interval();//花费毫秒数
System.out.println("sb append花费时间:"+interval);
}
public static void f3() {
TimeInterval timer = DateUtil.timer();
StringBuilder sb = new StringBuilder(1000000);
for (int i = 0; i < 1000000; i++) {
sb.append("b");
}
long interval = timer.interval();//花费毫秒数
System.out.println("sb优化 append花费时间:"+interval);
}
结果如下,效果还是有一些的。
sb append花费时间:26
sb优化 append花费时间:12