写点什么

怎么才能摸透 String 类的底层原理?看完这篇你就懂了

发布于: 2021 年 01 月 31 日
怎么才能摸透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 的类源码,我们能对其全貌了解一二了:


public final class String implements java.io.Serializable, Comparable<String>, CharSequence{ /** The value is used for character storage. */ private final char value[]; }
复制代码
  • final 修饰类名:String 作为不可重写类它保证了线程安全

  • Serializable 实现接口:String 默认支持序列化。

  • Comparable<String> 实现接口:String 支持与同类型对象的比较与排序。

  • CharSequence 实现接口:String 支持字符标准接口,具备以下行为:length/charAt/subSequence/toString,在 jdk8 之后,CharSequence 接口默认实现了 chars()/codePoints() 方法:返回 String 对象的输入流。


另外,JDK9 与 JDK8 的类声明比较也有差异,下面是 JDK9 的类描述源码部分:


public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ @Stable private final byte[] value; private final byte coder; @Native static final byte LATIN1 = 0; @Native static final byte UTF16 = 1; static final boolean COMPACT_STRINGS; static { COMPACT_STRINGS = true; } }
复制代码


  • 在 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:



/** * <p>"+" 和 "+=" 是Java重载过的操作符,编译器会自动优化引用StringBuilder,更高效</p > */public class Concatenation { public static void main(String[] args) { String mango = "mango"; String s = "abc" + mango + "def" + 47; System.out.print(s); }}
复制代码


我们使用 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 对象,并调用构造器,用字符数组初始化它


public class TestIntern {  public static void main(String[] args){    testIntern();  }  private static void testIntern() {    String x =new String("def");    String y = x.intern();    System.out.println(x == y);    String a =new String(new char[]{'a','b','c'});    String b = a.intern();    System.out.println(a == b);  }}
复制代码


(JDK7/8)运行结果:


falsetrue
复制代码


如何解析这个运行结果呢?

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

JVM系列之:String.intern和stringTable

字符串常量池、class常量池和运行时常量池

字符串常量池和运行时常量池是在堆还是在方法区

String类型在JVM中的内存分配

9、延伸阅读

《源码系列》

JDK之Object 类

JDK之BigDecimal 类

JDK之String 类

JDK之Lambda表达式


《经典书籍》

Java并发编程实战:第1章 多线程安全性与风险

Java并发编程实战:第2章 影响线程安全性的原子性和加锁机制

Java并发编程实战:第3章 助于线程安全的三剑客:final & volatile & 线程封闭


《服务端技术栈》

《Docker 核心设计理念

《Kafka史上最强原理总结》

《HTTP的前世今生》


《算法系列》

读懂排序算法(一):冒泡&直接插入&选择比较

《读懂排序算法(二):希尔排序算法》

《读懂排序算法(三):堆排序算法》

《读懂排序算法(四):归并算法》

《读懂排序算法(五):快速排序算法》

《读懂排序算法(六):二分查找算法》


《设计模式》

设计模式之六大设计原则

设计模式之创建型(1):单例模式

设计模式之创建型(2):工厂方法模式

设计模式之创建型(3):原型模式

设计模式之创建型(4):建造者模式



发布于: 2021 年 01 月 31 日阅读数: 842
用户头像

Diligence is the mother of success. 2018.03.28 加入

公众号:后台技术汇 笔者主要从事Java后台开发,喜欢技术交流与分享,保持饥渴,一起进步!

评论 (8 条评论)

发布
用户头像
final修饰的属性是可以保证线程安全的,我没有理解到final修饰的类是如何保证线程安全的,求解惑

final 修饰类名:String 作为不可重写类它保证了线程安全

2021 年 02 月 26 日 11:00
回复
final类可以这么理解:它修饰的对象,被new出来之后,就不能被更改了(堆内存有它原来的一席之地,除非被GC掉)。
1、final修饰的String,就是不可变类。它在多线程下,被读取到的永远都是同一个值(因为不可变,所以该类的对象被修改的操作永远关闭了,既然不能改,JVM的一份只读孤本,肯定线程安全)。
2、你疑惑的点可能是,万一String被重新赋值了呢?
非也,String被赋值时,仅仅是修改了创建出来的(指向常量池的内容的)对象,它所保管的地址索引而已。这一点,我们也知道,java编译器底层,对于修改的String对象,本质是新分配一块内存空间。
你看看,能不能解决你的疑惑?
展开
2021 年 03 月 05 日 09:51
回复
用户头像
这段话下面的图说的是JDK7吧!看图上写着JDK6

JDK7,常量池和堆已经不是物理分割了,字符串常量池已经被转移到了java Heap中了。 此处的 intern() 则是将在堆上的地址引用拷贝到常量池里。

2021 年 02 月 09 日 16:50
回复
1、首先感谢您的指出哦,然后我已经改正啦。
2、原理是这样的,在 JDK7 之后,SCP(String Constant Pool 字符串常量池) 移至了堆(Heap),而字符串对象的创建也在堆(Heap),为了节省内存开支,intern方法,不再是把该字符串直接加入scp里面,而是将其地址引用放到scp。
3、祝你新年快乐哈!!
2021 年 02 月 11 日 00:08
回复
感谢!也祝你新年快乐!
2021 年 02 月 18 日 09:51
回复
用户头像
位于本地是指本地方法栈吗?

6.2 class文件常量池(Constant Pool Table)--位于本地

2021 年 02 月 04 日 09:38
回复
“本地”准确意思是:位于我们本地磁盘class文件的常量池常量字段声明部分,因为jvm运行前是要先加载 .java编译好的.class文件。
1、我们知道,class文件的作用就是为了加载到JVM内存的。
2、class常量池项目,会告诉JVM--“这些内容要加载到jvm的方法区中”
3、所以我这里就简写为“本地”了。
2021 年 02 月 04 日 15:51
回复
好的,了解,谢谢
2021 年 02 月 06 日 15:12
回复
没有更多了
怎么才能摸透String类的底层原理?看完这篇你就懂了