怎么才能摸透 String 类的底层原理?看完这篇你就懂了
1、写在开头
String 是日常开发非常频繁的类,此外我们常用的操作还有字符串连接操作符等等。String 对象是不可变的,查看 JDK 文档,我们不难发现 String 类的每个修改值的方法,其实都是创建了一个新的 String 对象,以包含修改后的字符串内容。
我们分析 String 源码,除了要理解它提供的方法是如何被使用,如果结合 JVM 内存结构的设计思路来一起分析,可以举一反三。
2、温习 JVM 内存基础
开讲前,我们先回顾下 JVM 的基本结构。
根据《Java 虚拟机规范(Java SE 7 版)》。(这章重点是堆、方法区、运行时常量池)
Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:
程序计数器(Program Counter Register):当前线程执行的字节码指示器
Java 虚拟机栈(Java Virtual Machine Stacks):Java 方法执行的内存模型,每个方法会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息
本地方法栈(Native Method Stack):(虚拟机使用到的)本地方法执行的内存模型
Java 堆(Java Heap):虚拟机启动时创建的内存区域,唯一目的是存放对象实例,处于逻辑连续但物理不连续内存空间中
方法区(Method Area):堆的一个逻辑部分。存储被虚拟机加载的 Class 信息:类名、访问修饰符、常量池(静态变量/常量)、字段描述、方法描述等数据
运行时常量池(Runtime Constant Pool):方法区的一部分,存放:编译器生成的各种字面值和符号引用,这部分内容会在类加载后进入方法区的运行时常量池中存放
3、String 类
且看 JDK8 下,String 的类源码,我们能对其全貌了解一二了:
final 修饰类名:String 作为不可重写类它保证了线程安全
Serializable 实现接口:String 默认支持序列化。
Comparable<String> 实现接口:String 支持与同类型对象的比较与排序。
CharSequence 实现接口:String 支持字符标准接口,具备以下行为:length/charAt/subSequence/toString,在 jdk8 之后,CharSequence 接口默认实现了 chars()/codePoints() 方法:返回 String 对象的输入流。
另外,JDK9 与 JDK8 的类声明比较也有差异,下面是 JDK9 的类描述源码部分:
在 JDK8 中:String 底层最终使用字符数组 char[] 来存储字符值;但在 JDK9 之后,JDK 维护者将其改为了 byte[] 数组作为底层存储(究其原因是 JDK 开发人员调研了成千上万的应用程序的 heap dump 信息,然后得出结论:大部分的 String 都是以 Latin-1 字符编码来表示的,只需要一个字节存储就够了,两个字节完全是浪费)。
在 JDK9 之后,String 类多了一个成员变量 coder,它代表编码的格式,目前 String 支持两种编码格式 LATIN1 和 UTF16。LATIN1 需要用一个字节来存储。而 UTF16 需要使用 2 个字节或者 4 个字节来存储。
而实际上,JDK 对 String 类的存储优化由来已久了:
4、String 类常用方法列表
String 类(JDK8)提供了很多实用方法,碍于篇幅,这里以列表形式概括总结:
5、编译器底层对字符串拼接的优化
我们看个例子 1:
我们使用 javac 编译结果:
得出结论:在 java 文件中,进行字符串拼接时,编译器会帮我们进行一次优化:new 一个 StringBuilder,再调用 append 方法对之后拼接的字符串进行连接。低版本的 java 编译器,是通过不断创建 StringBuilder 来实现新的字符串拼接。
实际上:
字符串拼接从 jdk5 开始就已经完成了优化,并且没有进行新的优化。
我们 java 循环内的 String 拼接,在编译器解析之后,都会每次循环中 new 一个 StringBuilder,再调用 append 方法;这样的弊端是多次循环之后,产生大量的失效对象(即使 GC 会回收)。
我们编写 java 代码时,如果有循环体的话,好的做法是在循环外声明 StringBuilder 对象,在循环内进行手动 append。这样不论外面循环多少层,编译器优化之后都只有一个 StringBuilder 对象。
6、字符串与 JVM 内存分配
不同版本的 JVM 的内存分配设计略有差异。当前主流 jdk 版本是 jdk7 和 jdk8,结合 JVM 内存分配图,我们可以从底层上剖析字符串在 JVM 的内存分配流程。
不过首先,我们得捋顺 3 种常量池的关系和存在:
全局字符串常量池(string pool,也做 string literal pool)
class 文件常量池(class constant pool)
运行时常量池(runtime constant pool)
6.1 全局字符串常量池(String Pool)-- 位于方法区
全局字符串池里的内容是,string pool 中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。
在 HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来,如"java")的引用,也就是说在堆中的某些字符串实例被这个 StringTable 引用之后,就等同被赋予了”驻留字符串”的身份。
这个 StringTable 在每个 HotSpot VM 的实例只有一份,被所有的类共享。
字符串常量池的作用:为了提高匹配速度,也就是为了更快地查找某个字符串是否在常量池中,Java 在设计常量池的时候,还搞了张 stringTable,这个有点像我们的 hashTable,根据字符串的 hashCode 定位到对应的桶,然后遍历数组查找该字符串对应的引用。如果找得到字符串,则返回引用,找不到则会把字符串常量放到常量池中,并把引用保存到 stringTable 了里面。
在 JDK7、8 中,可以通过-XX:StringTableSize 参数 StringTable 大小
6.2 class 文件常量池(Constant Pool Table)--位于本地
class 文件常量池(constant pool table):用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。
1、字面量就是我们所说的常量概念,如文本字符串、被声明为 final 的常量值等。
2、符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。
符号引用一般包括下面三类常量:
2.1、 类和接口的全限定名
2.2、 字段的名称和描述符
2.3、 方法的名称和描述符
常量池是最繁琐的数据,因为下面的 14 种常量类型各自均有自己的结构,下面仅列出类型列表,每种类型的常量结构可以参考《深入理解 Java 虚拟机》(P169)。
结合我们以上面例 1 的类文件为例,看下 class 文件常量池有以下信息:
6.3 运行时常量池 -- 与 JVM 版本相关
运行时常量池,在 JVM1.6 内存模型中位于方法区,JVM1.7 内存模型中位于堆,在 JVM1.8 内存模型中位于元空间(堆的另一种实现方式)。
而永久代是 Hotspot 虚拟机特有的概念,是方法区的一种实现,别的 JVM 都没有这个东西。
字符串常量池和运行时常量池逻辑上属于方法区,但是实际存放在堆内存中,因此既可以说两者存放在堆中,也可以说两则存在于方法区中,这就是造成误解的地方。
在 Java 6 中,方法区中包含的数据,除了 JIT 编译生成的代码存放在 native memory 的 CodeCache 区域,其他都存放在永久代;
在 Java 7 中,Symbol 的存储从 PermGen 移动到了 native memory,并且把静态变量从 instanceKlass 末尾(位于 PermGen 内)移动到了 java.lang.Class 对象的末尾(位于普通 Java heap 内);
在 Java 8 中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间(Metaspace);
6.4 总结字符串的生命周期
总结一下字符串的生命周期(JVM version>= 1.7):
1、java 文件中声明一个字符串常量:“java”;
2、经过编译,“java” 字符串进入到 类文件常量池里;
3、类文件加载到 JVM 后,“java”字符串会被加载到运行时常量池(保存的是内容);
4、在 JVM 启动之后,随着业务进行,对于后续动态生成的字符串,它们通过创建一个对象(new 的对象存在于堆,运行时常量池保留的是 new 的对象的地址,保存的是对象地址);
5、字符串作为常量长期驻留在 JVM 内存模型的某个角落,或是永久代,或是元空间;(它们)或许会被 GC 所回收,或许永远不会被回收,这就取决于不同版本 JVM 的垃圾回收策略和内存管理算法了。
7、图解 String.intern() 底层原理
String 类的 intern() 方法跟 JVM 内存模型设计息息相关:
JDK6:intern()方法,会把首次遇到的字符串实例复制到字符串常量池(永久代)中,返回的也是字符串常量池(永久代)中这个字符串实例的引用。
JDK6,常量池和堆是物理隔离的,常量池在永久代分配内存,永久代和 Java 堆的内存是物理隔离的。
此处的 intern() ,是将在堆上对象存的内容"abc"拷贝到常量池中。
JDK7 及之后:intern()方法,如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象,否则将此 String 对象包含的字符添加到常量池中,并返回此 String 对象的引用。
JDK7,常量池和堆已经不是物理分割了,字符串常量池已经被转移到了 java Heap 中了。
此处的 intern() 则是将在堆上的地址引用拷贝到常量池里。
我们得出结论,比较上面两者的差异是:String 的 intern() 方法分别拷贝了堆对象的内容和地址。
我们通过例子 2,可以更好理解 intern() 方法的底层原理:
我们创建了一个 String 对象,并调用构造器,用字符串字面量初始化它
我们创建了一个 String 对象,并调用构造器,用字符数组初始化它
(JDK7/8)运行结果:
如何解析这个运行结果呢?
1)且先看 java 文件 的编译结果:
结论:在类文件常量池中,存在字面量“def”,未存在数组 {'a','b','c'} 。也正是因为这个差异,在类加载过程中,前者会首先加载到字符串常量池中,而后者则是在对象创建后,才将拷贝对象的地址信息到字符串常量池。
2)两种初始化方式有何区别?
字符串 "def",编译期后放在类文件常量池,因此会被自动加载到 JVM 的方法区的常量池内。调用 x.intern() 方法返回的是编译器已经创建好的对象,跟 x 不是一个对象。所以结果是 false。
字符数组 new char[]{'a','b','c'},是动态创建的字符串类,此前并未提前加载到 JVM 的方法区的常量池内。
8、总结
上文我们介绍了 String 类常用方法列表,结合 JVM 内存结构和案例分析了 3 个底层原理,希望大家有所收益:
编译器如何优化了字符串的拼接;
图解分析字符串与 JVM 内存分配之间的关系;
不同虚拟机版本下,String.intern() 的相同点与不同点。
参考文章:
JVM系列之:String.intern和stringTable
9、延伸阅读
《源码系列》
《经典书籍》
《Java并发编程实战:第2章 影响线程安全性的原子性和加锁机制》
《Java并发编程实战:第3章 助于线程安全的三剑客:final & volatile & 线程封闭》
《服务端技术栈》
《算法系列》
《设计模式》
版权声明: 本文为 InfoQ 作者【后台技术汇】的原创文章。
原文链接:【http://xie.infoq.cn/article/9ff230e91d0ec22f705c4c8f4】。文章转载请联系作者。
评论 (8 条评论)