从JVM的角度解析String
1. 字符串生成过程
我们都知道String s = "hello java";会将“hello java”放入字符串常量池,但是从jvm的角度来看字符串和三个常量池有关,class常量池,运行时常量池,全局字符串常量池(也就是常说的字符串常量池)
第一个是class的常量池,看一下下面这个代码
public class StringTest { public void test1() { String s = "hello java"; } } |
如果用javap -v StringTest.class 来查看他的字节码文件,代码如下
Constant pool: #1 = Methodref #4.#17 // java/lang/Object."<init>":()V #2 = String #18 // hello java ... #18 = Utf8 hello java |
#2表示有一个字符串的索引指向#18,一个utf8编码的字符串字面量,这个#18只代表由utf编码的数据,不是java对象,#2是java对象,但是他现在还没有初始化,#18是在文件编译后就生成的
utf8字面量字符串,他在项目启动加载类时就进入了运行时常量池。
那么就有一个问题,#2什么时候初始化,以及什么时候进入全局字符串常量池(也就是平常说的字符串常量池)呢?我们继续看字节码
源码: public void test1() { String s = "hello java"; } 字节码: public void test1(); 0: ldc #2 // String hello java 2: astore_1 3: return 为了方便看,去掉部分代码 |
我们看字节码第一行,很明显的看到他调用了#2,ldc的作用是将常量池中的数据加载到操作数栈中(简单来说就是进行数据操作的地方),这个时候#2肯定要初始化生成java对象了。
如果是java7及以后的版本这时候jvm会在堆中创建一个“hello java”的对象,。然后将这个对象的引用放入全局字符串变量池(也是在堆中)中,当以后出现“hello java”,能在全局字符串变量池找到,就不会再生成对象。
如果是java6版本jvm会在permGen中创建一个“hello java”的对象,。然后将这个对象的引用放入全局字符串变量池(在permGen)中,当以后出现“hello java”,能在全局字符串变量池找到,就不会再生成对象。
所以全局字符串常量池中存放的只是索引,他类似于java中的HashMap,key是字面量字符串,value是是指向真正字符串对象的引用。
2.String.intern
JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中然后把这个字符串的引用放入全局字符串常量池,返回的也是永久代中这个字符串的实例的引用。
JDK1.7中,intern()方法首次遇到字符串实例时不会在复制实例,直接把这个实例的引用存入全局字符串常量池,返回的就是这个字符串实例的引用。
那就有一个疑问,为什么jdk1.6要重新复制一份呢? 因为1.6时字符串常量池在永久代,而通过new 产生的字符串在堆中,两个区域内存隔离,永久代无法存堆中的引用,
1.7时代,jvm把字符串常量池移到了堆中,所以在1.7中就不用创造实例了。
/** * jdk 1.8 */ public static void main(String[] args) { //调用了new String String s1 = new StringBuilder("lh").append("cy").toString(); String s2 = s1.intern(); //true System.out.println(s1 == s2); } |
3.字符串相加
3.1编译时确定的字符串相加
//源代码 public static void main(String[] args) { String s = "lh" + "cy"; } //字节码类的常量池 Constant pool: #1 = Methodref #4.#20 // java/lang/Object."<init>":()V #2 = String #21 // lhcy #21 = Utf8 lhcy //字节码操作指令 0: ldc #2 // String lhcy 2: astore_1 3: return |
可以看见 “lh”和“cy”被拼成了“lhcy”,这个结论大家应该早就知道,这里从字节码角度来看一下。
3.2运行时确定的字符串相加
源代码: public String test(String s1, String s2) { return s1 + s2; } 字节码: //新建一个StringBuilder对象 0: new #2 // class java/lang/StringBuilder //复制新建的StringBuilder对象 3: dup //消耗刚才复制的StringBuilder对象用于初始化 4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V //加载s1 7: aload_1 //拼接到StringBuilder 8: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 加载s2 11: aload_2 //拼接到StringBuilder 12: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; //调用StringBuilder的toString方法 15: invokevirtual #5 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 18: areturn |
根据注释应该知道了上述代码创建了一个StringBuilder然后append,最后toString。那么又有一个小疑问,既然两个字符串相加生成了StringBuilder,那么我们还
手动创建StringBuilder干嘛,为什么不让我们之间使用“+”来拼接字符串。那么请看下面的代码
//源代码 public static void main(String[] args) { String s = ""; for (int i = 0; i < 100; i++) { s += i; } } //jvm实际执行的代码 public static void main(String[] args) { String s = ""; for (int i = 0; i < 100; i++) { StringBuilder stringBuilder = new StringBuilder(); s = stringBuilder.append(s).append(i).toString(); } } |
从代码块中,我们发现当每次要给字符串赋值时,StringBuilder就会调用toString来新建字符串,jvm并不知道你只需要循环后的结果,在其中创建了大量无用的String对象,不仅耗时
创建了对象,并且占用了大量内存,从而加快了gc的频率,对系统运行非常不利。
总结
String是一个常用的类,基本使用非常简单,但是他的底层实现非常复杂,c++基础不错的同学可以去看一下String.intern()的源码和ldc的源码。