一文带你读懂String类源码

本文深入探讨 Java 中 String 类的特性,包括不可变性、内存分配、JVM 中的字符串优化及 intern() 方法的工作原理。

String 是日常开发非常频繁的类,此外我们常用的操作还有字符串连接操作符等等。String对象是不可变的,查看JDK文档,我们不难发现String类的每个修改值的方法,其实都是创建了一个新的String对象,以包含修改后的字符串内容。

我们分析String源码,除了要理解它提供的方法是如何被使用,如果结合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)方法区的一部分,存放:编译器生成的各种字面值和符号引用,这部分内容会在类加载后进入方法区的运行时常量池中存放

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类的存储优化由来已久了:

String类常用方法列表

String 类(JDK8)提供了很多实用方法,碍于篇幅,这里以列表形式概括总结:

方法作用备注
length()字符串的长度
charAt()截取一个字符
getChars()截取多个字符到目标数组
getBytes()返回字符串的字节数组以平台默认的编码字符集
toCharArray()完整拷贝到一个新字符数组
equals()和
equalsIgnoreCase() 

比较两个字符串

equals() 覆盖重写了Object类的方法
regionMatches()用于比较一个字符串中特定区域与另一特定区域,它有一个重载的形式允许在比较中忽略大小写。Sring提供了两个同名重载方法
startsWith()和
endsWith()
startsWith()方法决定是否以特定字符串开始,endWith()方法决定是否以特定字符串结束
equals()和==equals()方法比较字符串对象中的字符,==运算符比较两个对象是否引用同一实例。equals() 覆盖重写了Object类的方法
concat()连接两个字符串
replace()替换:第一种形式用一个字符在调用字符串中所有出现某个字符的地方进行替换;
第二种形式是用一个字符序列替换另一个字符序列;
Sring提供了两个同名重载方法
trim()去掉起始和结尾的空格
valueOf() 转换为字符串Sring提供了九个同名重载方法
toLowerCase()

转换为小写

toUpperCase()转换为大写
intern()返回字符串常量池的String对象(详见下文)String类中的一个native方法,底层是用c++来实现的

编译器优化字符串拼接

我们看个例子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对象。

字符串与JVM内存分配

不同版本的JVM的内存分配设计略有差异。当前主流jdk版本是jdk7和jdk8,结合JVM内存分配图,我们可以从底层上剖析字符串在JVM的内存分配流程。

不过首先,我们得捋顺3种常量池的关系和存在:

  • 全局字符串常量池(string pool,也做string literal pool)

  • class文件常量池(class constant pool)

  • 运行时常量池(runtime constant pool)

一、全局字符串常量池(String Pool)-- 位于方法区

全局字符串池里的内容是,string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。 

在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来,如"java")的引用,也就是说在堆中的某些字符串实例被这个StringTable引用之后,就等同被赋予了”驻留字符串”的身份。

这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

字符串常量池的作用:为了提高匹配速度,也就是为了更快地查找某个字符串是否在常量池中,Java在设计常量池的时候,还搞了张stringTable,这个有点像我们的hashTable,根据字符串的hashCode定位到对应的桶,然后遍历数组查找该字符串对应的引用。如果找得到字符串,则返回引用,找不到则会把字符串常量放到常量池中,并把引用保存到stringTable了里面。

在JDK7、8中,可以通过-XX:StringTableSize参数StringTable大小

二、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文件常量池有以下信息:

三、运行时常量池 -- 与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);

四、总结字符串的生命周期

总结一下字符串的生命周期(JVM version>= 1.7):

1、java文件中声明一个字符串常量:“java”;

2、经过编译,“java” 字符串进入到 类文件常量池里;

3、类文件加载到JVM后,“java”字符串会被加载到运行时常量池(保存的是内容);

4、在JVM启动之后,随着业务进行,对于后续动态生成的字符串,它们通过创建一个对象(new的对象存在于堆,运行时常量池保留的是new的对象的地址,保存的是对象地址);

5、字符串作为常量长期驻留在JVM内存模型的某个角落,或是永久代,或是元空间;(它们)或许会被GC所回收,或许永远不会被回收,这就取决于不同版本JVM的垃圾回收策略和内存管理算法了。

图解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)运行结果:

false
true

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

1)且先看 java文件 的编译结果:

结论:在类文件常量池中,存在字面量“def”,未存在数组 {'a','b','c'} 。也正是因为这个差异,在类加载过程中,前者会首先加载到字符串常量池中,而后者则是在对象创建后,才将拷贝对象的地址信息到字符串常量池。

2)两种初始化方式有何区别? 

  • 字符串 "def",编译期后放在类文件常量池,因此会被自动加载到JVM的方法区的常量池内。调用  x.intern() 方法返回的是编译器已经创建好的对象,跟x不是一个对象。所以结果是false。

  • 字符数组 new char[]{'a','b','c'},是动态创建的字符串类,此前并未提前加载到JVM的方法区的常量池内。 

    因此String对象a创建完成之后,再将该字符串对象的引用拷贝常量池内(a对象的引用),调用 a.intern()  返回的是JVM的方法区的常量池内(a对象的引用)。所以结果是true。

总结

上文我们介绍了String类常用方法列表,结合JVM内存结构和案例分析了3个底层原理,希望大家有所收益:

  • 编译器如何优化了字符串的拼接;

  • 图解分析字符串与JVM内存分配之间的关系;

  • 不同虚拟机版本下,String.intern() 的相同点与不同点。

    参考文章:

  1. JVM系列-(四)关于常量池中的String:

    https://zhuanlan.zhihu.com/p/51655449

  2. JVM系列之:String.intern和stringTable:

    https://zhuanlan.zhihu.com/p/163762693

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

    https://blog.youkuaiyun.com/u011552955/article/details/100079685

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

    https://www.cnblogs.com/cosmos-wong/p/12925299.html

  5. String类型在JVM中的内存分配:

    https://www.cnblogs.com/talkingcat/p/13341967.html

—END—

推荐

一文带你读懂Object类源码

一文带你读懂 BigDecimal 源码

Docker 核心设计理念

Kafka核心概念总结

读懂堆排序算法

读懂归并算法

一文带你读懂HTTP协议的前世今生

消除GC触及不到的过期对象引用

避免使用终结方法

用私有构造器或者枚举类型强化Singleton属性
谨慎的使用本地方法

扫描二维码

获取技术干货

后台技术汇

点个“在看”表示朕

已阅

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

后台技术汇

对你的帮助,是对我的最好鼓励。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值