String 是日常开发非常频繁的类,此外我们常用的操作还有字符串连接操作符等等。String对象是不可变的,查看JDK文档,我们不难发现String类的每个修改值的方法,其实都是创建了一个新的String对象,以包含修改后的字符串内容。
我们分析String源码,除了要理解它提供的方法是如何被使用,如果结合JVM内存结构的设计思路来一起分析,可以举一反三。
开讲前,我们先回顾下JVM的基本结构。
根据《Java虚拟机规范(Java SE 7版)》。(这章重点是堆、方法区、运行时常量池)
Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
且看JDK8下,String的类源码,我们能对其全貌了解一二了:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence{
/** The value is used for character storage. */
private final char value[];
}
另外,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;
}
}
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来实现新的字符串拼接。
实际上:
不同版本的JVM的内存分配设计略有差异。当前主流jdk版本是jdk7和jdk8,结合JVM内存分配图,我们可以从底层上剖析字符串在JVM的内存分配流程。
不过首先,我们得捋顺3种常量池的关系和存在:
一、全局字符串常量池(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都没有这个东西。 字符串常量池和运行时常量池逻辑上属于方法区,但是实际存放在堆内存中,因此既可以说两者存放在堆中,也可以说两则存在于方法区中,这就是造成误解的地方。
四、总结字符串的生命周期
总结一下字符串的生命周期(JVM version>= 1.7): 1、java文件中声明一个字符串常量:“java”; 2、经过编译,“java” 字符串进入到 类文件常量池里; 3、类文件加载到JVM后,“java”字符串会被加载到运行时常量池(保存的是内容); 4、在JVM启动之后,随着业务进行,对于后续动态生成的字符串,它们通过创建一个对象(new的对象存在于堆,运行时常量池保留的是new的对象的地址,保存的是对象地址); 5、字符串作为常量长期驻留在JVM内存模型的某个角落,或是永久代,或是元空间;(它们)或许会被GC所回收,或许永远不会被回收,这就取决于不同版本JVM的垃圾回收策略和内存管理算法了。
String 类的 intern() 方法跟JVM内存模型设计息息相关:
JDK6:intern()方法,会把首次遇到的字符串实例复制到字符串常量池(永久代)中,返回的也是字符串常量池(永久代)中这个字符串实例的引用。
JDK6,常量池和堆是物理隔离的,常量池在永久代分配内存,永久代和Java堆的内存是物理隔离的。 此处的 intern() ,是将在堆上对象存的内容"abc"拷贝到常量池中。
JDK7及之后:intern()方法,如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则将此String对象包含的字符添加到常量池中,并返回此String对象的引用。
JDK7,常量池和堆已经不是物理分割了,字符串常量池已经被转移到了java Heap中了。 此处的 intern() 则是将在堆上的地址引用拷贝到常量池里。
我们得出结论,比较上面两者的差异是:String 的 intern() 方法分别拷贝了堆对象的内容和地址。
我们通过例子2,可以更好理解 intern() 方法的底层原理:
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)两种初始化方式有何区别?
上文我们介绍了String类常用方法列表,结合JVM内存结构和案例分析了3个底层原理,希望大家有所收益:
—END—