欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

java中的字符串,你真的懂吗?

程序员文章站 2022-05-23 14:17:24
...

作为一个程序猿,我们每天都在使用字符串,可是以前从来没想过去深究它,相信很多人只是会用String,可并不真正了解它,今天我们就来深扒一下字符串在JVM中的秘密吧。

在介绍字符串之前,需要首先简单了解一下JVM的Oop-Klass模型。

一、Oop-Klass模型

1、Klass模型

描述的是java类在jvm的存储形式,从Klass模型的角度将java中的类分类数组类型和非数组类型,非数组类型的原信息在jvm中用instanceKlass类表示,数组类型用ArrayKlass类表示

InstanceKlass有3个子类:
1、InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象实际上就是这个C++中InstanceMirrorKlass的实例,存储在堆区,学名镜像类;
2、InstanceRefKlass:用于表示java/lang/ref/Reference类的子类;
3、InstanceClassLoaderKlass:用于遍历某个加载器加载的类

ArrayKlass有2个子类:
1、TypeArrayKlass:表示基本类型数组
2、ObjArrayKlass:表示引用类型数组

2、Oop模型

Oop全称是普通对象指针,表示java对象在jvm中的存储形式,非数组对象用instanceOopDesc表示,数组对象用typeArrayOopDesc表示。

二、常量池

JVM中有3种常量池,分别是class文件常量池、运行时常量池和字符串常量池。

1、class文件常量池

在java的编译期间,虚拟机将java文件编译成class文件,其中class文件包含Constant pool的数据结构,这个就是常量池,存储在磁盘上,class文件常量池在编译阶段就已经确定,是静态的。查看class文件常量池的方式有很多,比如jdk自带的javap命令、idea的插件Jclasslib等,笔者为了方便,使用Jclasslib来查看一个类的字节码中的常量池长什么样子:
java中的字符串,你真的懂吗?
如图,Const Pool就是class文件常量池。

2、运行时常量池

运行时常量池是InstanceKlass的一个属性,可以通过openJdk源码来验证这一点,在 openjdk\hotspot\src\share\vm\oops\instanceKlass.hpp代码中
java中的字符串,你真的懂吗?
我们常说的常量池一般指的就是这个常量池,存在于方法区(元空间)上,主要用来保存一些 class 文件中描述的符号引用,同时在类加载的“解析阶段”还会将这些符号引用所翻译出来的直接引用(直接指向实例对象的指针)。
可以使用HSDB工具来验证运行时常量池是否是InstanceKlass的一个属性,如下图,可以看到ConstantPool确实是InstanceKlass属性,而InstanceKlass存在于方法区,因此运行时常量池在方法区中。
java中的字符串,你真的懂吗?

3、字符串常量池

字符串常量池即String Pool,对于String类型的字面量在编译器就已经存放在class文件常量池中,那么为什么还要有字符串常量池呢?主要是因为String类型的变量在运行期有着不同于普通常量的行为,那么是什么行为?在最后第四节中会介绍。字符串常量池存放的是字符串对象的引用,它的底层是StringTable,而StringTable是由HashTable实现的,它存在于堆区。我们知道HashTable其实就是一种Map,由key-value存储数据,它的key生成算法跟HashMap类似,这里不详细赘述,主要说一下value是如何存储的。

HashtableEntry<oop, mtSymbol>* entry = new_entry(hashValue, string());

JVM会将name计算出的hash值和String类的实例instanceOopDesc封装成HashtableEntry,它的结构如下(见 openjdk\jdk\src\windows\native\sun\windows\Hashtable.h):

struct HashtableEntry {
 INT_PTR hash;
 void* key;     //根据name计算的hash值,即hashValue
 void* value;   //instanceOopDesc,即上述代码的string()
 HashtableEntry* next;
};

因此当定义一个String变量时,jvm会将String对象instanceOopDesc 封装成 HashtableEntry,而HashtableEntry里存储了字符串的值。

三、示例讲解

关于字符串,网上最经典也是出现最多的一个问题就是,给出一段代码,问执行这句代码时总共创建了几个对象?这个问题需要从以下两个角度去分析:
1、String变量的值存在哪?
2、JVM做了什么?

示例1:

String s = "test";

String变量的值存在哪?

查看String类的源码可知,字符串的值存放在字符数组中:
java中的字符串,你真的懂吗?
根据Oop-Klass模型可知,字符数组的原信息存放在TypeArrayKlass中,而字符数组对象则存放在TypeArrayOopDesc中,因此字符数组在jvm中对应一个TypeArrayKlass和一个TypeArrayOopDesc,由于TypeArrayKlass表示的是数组类的原信息,存在于方法区,不属于对象,因此不在这个问题的考虑范围之内,因此一个String对象首先就包含了一个TypeArrayOopDesc对象。

注:不管使用哪种方式创建一个字符串,对于此问题都是一样,都会生成一个TypeArrayOopDesc对象,所以后面的实例中就不再分析这个问题了

JVM做了什么?

(1)jvm会将s变量压入虚拟机栈,然后会去字符串常量池中找是否存在值为“test”的字符串对象的引用,如果有,那么就直接返回对应的String对象,即将这个String对象的引用赋值给虚拟机栈中的s变量;
(2)这个例子中显然没有,因此会创建一个String对象和char数组对象(也就是TypeArrayOopDesc),并将虚拟机栈中s1指向堆区的这个对象;
(3)将这个String对象对应的InstanceOopDesc封装成HashtableEntry(HashTableEntry不是oop,它是C++里面的一个对象,并不是java对象,只是jvm中用于存放HashTable的值),作为StringTable的value进行存储。
图解:
java中的字符串,你真的懂吗?
那么问题来了,这句代码总共生成了几个对象?分别是什么?
1、TypeArrayOopDesc
char数组对象(char数组的对象信息在jvm中对应TypeArrayOopDesc)
2、InstanceOop
String对象,字符串常量池存放着它的引用

因此这句代码一共创建了两个对象。嗯?为什么跟网上说的不一样,我看到的网上的所有帖子说的都是这句代码创建了一个对象,这是为何呢?是因为网上所说的一个对象指的都是String对象,并没有把TypeArrayOopDesc这个oop对象算进去,其实是个不严谨的答案。所以,下次再有人问你这个问题,你就可以说这句代码创建了一个String对象,2个OOP对象(String对象对应的InstanceOop和char[]对应的TypeArrayOopDesc)

以上只是理论分析,如何通过直观地方式来验证呢?IDEA就可以做到!来看看吧:
1、如图,在要验证的代码上打个断点
java中的字符串,你真的懂吗?
2、debug运行,然后在右下角找到这个图标,点击并勾选Memory,然后点击Load classes
java中的字符串,你真的懂吗?
java中的字符串,你真的懂吗?
3、可以看到执行代码之前,堆中char[]对象个数和String对象个数,记住这两个数字,然后单步执行这句代码,再点击Load classes
java中的字符串,你真的懂吗?
4、可以看到,这两个对象个数分别加了1个,这也证明了上面的推断:总共产生了两个对象(char[]和String
java中的字符串,你真的懂吗?

示例2:

String s1 = "test";
String s2 = "test";

这段代码创建了几个对象?或者说创建了几个oop,分别是什么?

JVM做了什么?

1、第一句代码跟示例1一样;
2、第二句代码执行的时候,jvm去字符串常量池去查找值为“test”的字符串对象的引用,发现已经存在了,就直接返回了这个对象的引用。

因此这里一共创建了2个对象(oop对象),分别是char[]对应的TypeArrayOopDesc和String对象对应的InstanceOop。

图解:
java中的字符串,你真的懂吗?
关于示例2,如果修改一下其中一个变量的值,比如:

String s1 = "test";
String s2 = "test1";

这时的情况就会有所不同,为什么呢?示例2中s1和s2的值都是test,所以执行s2的时候,字符串常量池中已经有了“test”这个对象,就直接返回了;而这里两个值并不同,执行s1的时候会产生两个oop对象,执行s2也同样会产生两个oop对象,因此一共是4个oop对象,分别是2个String对象对应的InstanceOop和char[]数组对应的TypeArrayOopDesc。如果不相信的话,同样可以用idea来验证,方法跟示例1中相同,笔者就不再赘述。

示例3:

String s = new String("test2");

JVM做了什么?

1、去字符串常量池中去查,如果存在值为“test2”的String对象的引用,直接将该引用复制给虚拟机栈中的s变量;
2、如果没有,就会创建String对象(InstanceOopDesc)、char数组对象(TypeArrayOopDesc);
3、将这个String对象对应的InstanceOopDesc封装成HashtableEntry,作为StringTable的value进行存储
4、由于有new String显示创建对象,所以又在堆区创建了一个String对象,前面已经有了char[]对象了,这是就会直接把new出来的String对象里的char数组直接指向前面已经创建的char[]对象。

因此这句代码一共创建了3个oop对象,分别是两个String对象,一个char[]对象

图解:
java中的字符串,你真的懂吗?

示例4:

String s1 = new String("test");
String s2 = new String("test");

JVM做了什么?

1、第一句代码跟示例3相同;
2、去字符串常量池中去查,已经存在了值为“test”的String对象的引用,直接将该引用复制给虚拟机栈中的s2变量;
3、由于有两个new String显示创建对象,所以又在堆区创建了两个String对象,并将这两个String对象中的char[]对象指向第一步所创建的TypeArrayOopDesc对象。

因此这段代码一共创建了4个oop对象,分别是3个String对象,1个char[]对象

图解
java中的字符串,你真的懂吗?

示例5:

String s1 = "ha";
String s2 = "ha";
String s3 = s1 + s2;

JVM做了什么?

这个例子与前面4个都不同,它涉及到了字符串的拼接,那么字符串拼接的底层是如何实现的呢?
需要借助这段代码的字节码来分析,我这里使用idea的Jclasslib插件来查看字节码:

 0 ldc #2 <ha>
 2 astore_1
 3 ldc #2 <ha>
 5 astore_2
 6 new #3 <java/lang/StringBuilder>
 9 dup
10 invokespecial #4 <java/lang/StringBuilder.<init>>
13 aload_1
14 invokevirtual #5 <java/lang/StringBuilder.append>
17 aload_2
18 invokevirtual #5 <java/lang/StringBuilder.append>
21 invokevirtual #6 <java/lang/StringBuilder.toString>
24 astore_3
25 return

通过字节码,可以一目了然的发现,字符串拼接符号“+”底层就是通过StringBuilder.append(“”)方法实现的,最终通过StringBuilder.toString()方法得到拼接后的字符串的值,所以上面那段代码等价于下面代码:

String s1 = "ha";
String s2 = "ha";
String s3 = new StringBuilder().append(s1).append(s2).toString();

通过示例2分析可知,前两句代码一共创建了4个oop对象(2个String对象,2个char[]对象),那么第3句代码创建了几个对象?分别是什么?其实通过分析可知,关键代码在于toString(),看一下toString()源码:

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

是不是发现了什么?没错,toString()本质上也会new一个String对象,只是调用的是String的3个参数的构造方法。根据示例3的分析结果,我先猜想一下,这个构造方法也是创建了3个oop对象(2个String对象,一个char[]对象,那2个String对象中有一个String对象在字符串常量池中,另一个是new显示创建出来的)。由于这个3个参数的构造方法也是通过new显示创建的,所以至少会有一个String对象和一个char[]对象,这是跑不掉的,那么唯一的不确定点就是会不会在字符串常量池中也产生一个String对象?通过代码来验证这一点:

String s1 = new String(new char[]{'1', '1'}, 0, 2);
String s2 = "11";
System.out.println(s1 == s2);

如果第一句代码执行之后会在字符串常量池中创建一个String对象,那么这句代码就会产生3个oop对象(见示例3),执行完之后s1=“11”,那么执行到第二句代码的时候,由于字符串常量池中已经存在,那么就会直接返回,不会再创建对象了,并且输出结果会是true。下面通过idea来验证一下:
java中的字符串,你真的懂吗?
java中的字符串,你真的懂吗?
java中的字符串,你真的懂吗?
java中的字符串,你真的懂吗?
发现结果跟我猜想的并不一致,其实从第二张图片就已经能得出结论了:String的构造方法:new String(value, 0, count);不会在字符串常量池中创建对象,所以它总共产生2个oop对象(一个String对象,一个char[]对象),回到示例5,我把代码再贴一下:

String s1 = "ha";
String s2 = "ha";
String s3 = s1 + s2;

这段代码总共会产生4个oop对象,分别是2个String对象和2个char[]对象,结果输出为false
java中的字符串,你真的懂吗?

示例6:

在示例5的代码中,加一句s3.intern():

String s1 = "ha";
String s2 = "ha";
String s3 = s1 + s2;
s3.intern();
String s = "haha";
System.out.println(s3 == s);

这时输出结果就变成了true……很神奇,是不是?那么这个intern()到底做了什么?其实仔细想一下也能大概猜出一二:由示例5可知,s1 + s2 这句代码并不会在字符串常量池中创建对象,所以才导致s3 != s,加了intern()之后s3 == s了,所以应该就是s3.intern()会将s3对象的引用存储在字符串常量池中,而事实上也确实如此。

JVM做了什么?

在执行String s = “haha”;的时候,发现字符串常量池中已经有了值为“haha”的String对象的引用了,就直接将该引用返回了,所以输出结果才为true。这段代码一共创建了4个oop对象,分别是2个String对象和2个char[]对象。需要注意的是,s3.intern()只是将s3对象的引用写入了常量池,并不会另外产生一个String对象。

图解(这里忽略了s1和s2的过程,直接从s3和s开始分析):
java中的字符串,你真的懂吗?

示例7:

final String s1 = "ha";
final String s2 = "ha";
String s3 = s1 + s2;
String s = "haha";
System.out.println(s3 == s);

JVM做了什么?

上面那段代码输出结果为true,因为s1和s2是final常量,所以在编译阶段就已经把s3编译成了“haha”,如何来验证?还是字节码,来看一下它的字节码:

0 ldc #2 <ha>
 2 astore_1
 3 ldc #2 <ha>
 5 astore_2
 6 ldc #3 <haha>
 8 astore_3
 9 ldc #3 <haha>
11 astore 4
13 getstatic #4 <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 #5 <java/io/PrintStream.println>
30 return

看到第5行:ldc #3 ,ldc表示将常数“haha”压入操作数栈(关于字节码指令详情请看字节码手册),所以s3直接被编译成“haha”,所以上面代码等价于:

final String s1 = "ha";
final String s2 = "ha";
String s3 = "haha";
String s = "haha";
System.out.println(s3 == s);

四、JVM为什么要引入字符串常量池

最后来说一下在文中最前面那个flag:jvm为什么要引入字符串常量池?我们知道,java String类使用了final修饰,String对象是一个不可变对象,在运行时,经常会对字符串进行各种频繁的操作,为了避免多次创建相同值的字符串对象,产生不必要的内存浪费,jvm使用字符串常量池来存储String对象,这样每次创建String对象之前,就会先到字符串常量池中进行查找,查到了就直接返回了,找不到才会创建对象,因此字符串常量池是为了节省内存空间,防止大量创建相同值的String对象。

至此,关于java字符串就全部写完了,本文通过几个经典例子分别进行了讲解,能够帮助我们更好地结合实际来理解字符串。最后一个问题:java字符串,你懂了吗?