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

作为一个程序猿,我们每天都在使用字符串,可是以前从来没想过去深究它,相信很多人只是会用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来查看一个类的字节码中的常量池长什么样子:
在这里插入图片描述
如图,Const Pool就是class文件常量池。

2、运行时常量池

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

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类的源码可知,字符串的值存放在字符数组中:
在这里插入图片描述
根据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进行存储。
图解:
在这里插入图片描述
那么问题来了,这句代码总共生成了几个对象?分别是什么?
1、TypeArrayOopDesc
char数组对象(char数组的对象信息在jvm中对应TypeArrayOopDesc)
2、InstanceOop
String对象,字符串常量池存放着它的引用

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

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

示例2:

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

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

JVM做了什么?

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

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

图解:
在这里插入图片描述
关于示例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[]对象

图解:
在这里插入图片描述

示例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[]对象

图解
在这里插入图片描述

示例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来验证一下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
发现结果跟我猜想的并不一致,其实从第二张图片就已经能得出结论了: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
在这里插入图片描述

示例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开始分析):
在这里插入图片描述

示例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字符串,你懂了吗?

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值