深入理解剖析Java String
什么是 String?
String是java.lang包下的一个类,它不是一个基本数据类型,String类是java语言最常用的数据类型之一,所有类似"ABC"的字面量都是String类的实例对象,以下为String类的部分源码:
// An highlighted block
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
...
}
通过源码可以得知以下几点:
- String类被final修饰,被final修饰的类无法被被继承。
- String字符串底层通过char数组value[]实现,故String类型其实就是对字符数组的封装,但该成员属性同时被private和final所修饰,所以它是不能被修改的。
String字符串真的不能被修改吗?
value比较特殊,因为他是一个引用变量,而不是真正的对象。value是final修饰的,也就是说final不能再指向其他数组对象,那么我们能改变数组中的内容吗? 比如将数组中的某个位置上的字符变为下划线“_”。 至少在我们自己写的普通代码中不能够做到,因为我们根本不能够访问到这个value引用,更不能通过这个引用去修改数组。
那么用什么方式可以访问私有成员呢? 没错,用反射, 可以反射出String对象中的value属性, 进而改变通过获得的value引用改变数组的结构。以下是通过反射修改String的代码
public static void testReflection() throws Exception {
//创建字符串"Hello World", 并赋给引用s
String s = "Hello World";
System.out.println("s = " + s); //Hello World
//获取String类中的value字段
Field valueFieldOfString = String.class.getDeclaredField("value");
//改变value属性的访问权限
valueFieldOfString.setAccessible(true);
//获取s对象上的value属性的值
char[] value = (char[]) valueFieldOfString.get(s);
//改变value所引用的数组中的第5个字符
value[5] = '_';
System.out.println("s = " + s); //Hello_World
}
String用 +号拼接的原理
由于字符串拼接太常用了,java支持可以直接用+号对两个字符串进行拼接。其真正实现的原理是底层通过建立临时的StringBuilder对象,然后调用append方法实现,最后通过调用 toString 方法返回字符串。
我们可以通过反编译验证一下
public class Test {
public static void main(String[] args) {
int i = 10;
String s = "abc";
System.out.println(s + i);
}
}
/**
* 反编译后
*/
public class Test {
public static void main(String args[]) { //删除了默认构造函数和字节码
byte byte0 = 10;
String s = "abc";
System.out.println((new StringBuilder()).append(s).append(byte0).toString());
}
}
“+”连接符的效率
使用“+”连接符时,JVM会隐式创建StringBuilder对象,这种方式在大部分情况下并不会造成效率的损失,不过在进行大量循环拼接字符串时则需要注意。
String s = "abc";
for (int i=0; i<10000; i++) {
s += "abc";
}
/**
* 反编译后
*/
String s = "abc";
for(int i = 0; i < 1000; i++) {
s = (new StringBuilder()).append(s).append("abc").toString();
}
这样由于大量StringBuilder创建在堆内存中,肯定会造成效率的损失,所以在这种情况下建议在循环体外创建一个StringBuilder对象调用append()方法手动拼接(如上面例子如果使用手动拼接运行时间将缩小到1/200左右)。
/**
* 循环中使用StringBuilder代替“+”连接符
*/
StringBuilder sb = new StringBuilder("abc");
for (int i = 0; i < 1000; i++) {
sb.append("abc");
}
sb.toString();
与此之外还有一种特殊情况,也就是当"+"两端均为编译期确定的字符串常量时,编译器会进行相应的优化,直接将两个字符串常量拼接好,例如:
System.out.println("Hello" + "World");
/**
* 反编译后
*/
System.out.println("HelloWorld");
/**
* 编译期确定
* 对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。
* 所以此时的"a" + s1和"a" + "b"效果是一样的。故结果为true。
*/
String s0 = "ab";
final String s1 = "b";
String s2 = "a" + s1;
System.out.println((s0 == s2)); //result = true
/**
* 编译期无法确定
* 这里面虽然将s1用final修饰了,但是由于其赋值是通过方法调用返回的,那么它的值只能在运行期间确定
* 因此s0和s2指向的不是同一个对象,故上面程序的结果为false。
*/
String s0 = "ab";
final String s1 = getS1();
String s2 = "a" + s1;
System.out.println((s0 == s2)); //result = false
public String getS1() {
return "b";
}
综上,“+”连接符对于直接相加的字符串常量效率很高,因为在编译期间便确定了它的值,也就是说形如"I"+“love”+“java”; 的字符串相加,在编译期间便被优化成了"Ilovejava"。对于间接相加(即包含字符串引用,且编译期无法确定值的),形如s1+s2+s3; 效率要比直接相加低,因为在编译器不会对引用变量进行优化。
字符串常量池
在Java的内存分配中,总共3种常量池,分别是Class常量池、运行时常量池、字符串常量池。
字符串常量池使用C++代码编写,数据结构上就是一个hash表,字符串对象就充当hash表的key,key的不重复是hash表的基本特征。
字符串的分配和其他对象分配一样,是需要消耗高昂的时间和空间的,而且字符串使用的非常多。JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池。每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于String字符串的不可变性,常量池中一定不存在两个相同的字符串。
/**
* 字符串常量池中的字符串只存在一份!
* 运行结果为true
*/
String s1 = "hello world!";
String s2 = "hello world!";
System.out.println(s1 == s2);
字符串常量池内存区域
在HotSpot VM中字符串常量池是通过一个StringTable类实现的,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例中只有一份,被所有的类共享;字符串常量由一个一个字符组成,放在了StringTable上。要注意的是,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降(因为要一个一个找)。
在JDK6及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中的,StringTable的长度是固定的1009;在JDK7版本中,字符串常量池被移到了堆中,StringTable的长度可以通过**-XX:StringTableSize=66666**参数指定。至于JDK7为什么把常量池移动到堆上实现,原因有如下两点:
- 方法区需要触发Full GC才会进行清理,一般系统不会经常触发Full GC,故字符串常量池如果在方法区,则池中未被使用的字符串不会及时被回收掉。
- 堆中的GC一般为Minor GC,会比Full GC频繁很多,且清理效率也要高,可以及时的清理字符串常量池中已经未被使用的字符串对象。
存放的内容
/**
* 运行结果为true false
*/
String s1 = "AB";
String s2 = "AB";
String s3 = new String("AB");
System.out.println(s1 == s2);
System.out.println(s1 == s3);
由于常量池中不存在两个相同的对象,所以s1和s2都是指向JVM字符串常量池中的"AB"对象。new关键字一定会产生一个对象,并且这个对象存储在堆中。所以String s3 = new String(“AB”);产生了两个对象:保存在栈中的s3和保存堆中的String对象。
当执行String s1 = "AB"时,JVM首先会去字符串常量池中检查是否存在"AB"对象,如果不存在,则在字符串常量池中创建"AB"对象,并将"AB"对象的地址返回给s1;如果存在,则不创建任何对象,直接将字符串常量池中"AB"对象的地址返回给s1。
intern方法
intern最主要的作用是为了减少内存的占用,复用字符串对象。
直接使用双引号声明出来的String对象会直接存储在字符串常量池中,如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法是一个native方法,intern方法会从字符串常量池中查询当前字符串是否存在,如果存在,就直接返回当前字符串,如果不存在,则需要根据JDK版本来分别讨论。
JDK1.6版本:
如果字符串常量池不存在当前String对象,则会复制一个当前字符串对象,并将复制出来的对象放入字符串常量池中并返回,代码示例 :
/**
* jdk1.6 结果为false
*/
String x = new String(new char[]{'a','b','c'});
String z = x.intern();
System.out.println(z == x);
JDK1.7的改动:
如果字符串常量池不存在当前String对象, 则会将当前字符串对象加入到字符串常量池,并返回该对象
/**
* jdk1.7 结果为true
*/
String x = new String(new char[]{'a','b','c'});
String z = x.intern();
System.out.println(z == x);
JDK8的字符串自动去重复用功能
如果你已经厌倦了无休止的使用intern方法去重复用字符串对象,如果你的项目恰好是使用的JDK8u20以上的版本,你就可以通过G1垃圾回收器自动对字符串进行去重复用,使用以下jvm参数:
-XX:+UseG1GC -XX:+UseStringDeduplication
底层原理是让多个字符串对象引用同一个char[]数组来达到节省内存的目的。G1垃圾回收器会在minor gc阶段自动分析优化重复字符串,不需要程序员人为干预,且只会对多次GC后还未被回收的字符串对象进行优化。
与调用intern方法去重相比,G1去重的好处在于全自动,缺点在于即使复用了char数组,但字符串对象本身还要占用一定的内存(对象头、value引用、hash),intern去重是字符串对象只保存一份,更省内存。
String、StringBuilder和StringBuffer
继承结构
主要区别
1)String是不可变字符序列,StringBuilder和StringBuffer是可变字符序列。
2)执行速度StringBuilder > StringBuffer > String。
3)StringBuilder是非线程安全的,StringBuffer是线程安全的。
总结
String类是我们使用频率最高的类之一,也是面试官经常考察的题目,下面是一个小测验。
public static void main(String[] args) {
String s1 = "AB";
String s2 = new String("AB");
String s3 = "A";
String s4 = "B";
String s5 = "A" + "B";
String s6 = s3 + s4;
System.out.println(s1 == s2);
System.out.println(s1 == s5);
System.out.println(s1 == s6);
System.out.println(s1 == s6.intern());
System.out.println(s2 == s2.intern());
}
运行结果:
解析:真正理解此题目需要清楚以下三点
1)直接使用双引号声明出来的String对象会直接存储在常量池中;
2)String对象的intern方法会得到字符串对象在常量池中对应的引用,如果常量池中没有对应的字符串,则该字符串将被添加到常量池中,然后返回常量池中字符串的引用;
3) 字符串的+操作其本质是创建了StringBuilder对象进行append操作,然后将拼接后的StringBuilder对象用toString方法处理成String对象,这一点可以用javap -c命令获得class文件对应的JVM字节码指令就可以看出来。