深入理解 Java String
PS:本文章重点从Java的String类设计角度出发来思考(后期也会持续更新,不断完善),关于String的基本用法不再阐述。
部分内容借鉴 :
周志明老师的 《深入理解Java虚拟机》
张洪亮老师的 《深入理解Java核心技术》 - 第八章:字符串
一、字符串的本质
-
首先,我们要明确一个点,字符串是存储多个字符的"串",也就是由多个字符组成的数据结构,不论如何封装,字符串本质上即是一个
char[]
类型数组(JDK9之后优化存储改成了byte[]
),以及涉及到字符串相关的库函数,也都是基于操作char[]
类型数组来实现的 -
例如:
subString()
、charAt()
、equals()
、trim()
、strip()
、indexOf()
、replace()
等等,这里不再一一列举,关于这些库函数后期我会继续写针对函数实现的文章。(有兴趣的小伙伴可以去刷一刷字符串相关的常见算法题,可以对库函数的理解更深。) -
其次,字符串与八大基本类型从本质上来讲是完全不同的,除了引用类型与基本类型的区别外,在方方面面都存在着很大的差异。
二、字符串的不可变性
1、不可变性
字符串(String)在Java中特别常用,而且在代码中经常会有String的赋值、修改其值等操作。其实字符串有一个很重要的核心思想以及特性,就是不可变性。
为什么我们说字符串是不可变的呢?
首先,我们需要知道什么是不可变对象。不可变对象是指: 在完全创建完成后,其内部状态保持不变的对象 。这意味着,一旦对象赋值后我们既不能更新、也不能通过任何方式改变其内部状态。
这里可能会有人疑惑?字符串为什么不可变呢,我的代码里就“改变”了字符串的值,例如:
String s = "abcd";
s = s.concat("ef");//拼接字符串
上面的代码不就改变了字符串s
的值吗?从abcd
变成了abcdef
。
虽然表面上从abcd
变成了abcdef
, 但实际上,拼接之后,我们得到的已经是一个全新的字符串了。
如图所示:图中在堆中重新创建了一个abcdef
字符串,和abcd
并不是一个对象。
所以,一旦一个字符串在内存中被创建出来以后,它就无法被修改,而且String类涉及的所有函数都没有改变字符串本身,都是返回了一个新的对象。
如果我们想要一个可变的字符串,则可以选择StringBuilder
、StringBuffer
。
知道了 “String是不可变的”之后,我们是不是会有疑惑,为什么要把String设计成不可变的,有什么好处吗?
这个问题困扰着不少人,甚至有人直接问过Java的创始人James Gosling
在一次采访中,当James Gosling被问到什么时候应该使用不可变变量
他回答:i would use an immutable whenever i can (只要有可能,我就会使用不可变)
其实,String
的不可变性主要是从 缓存、安全性、线程安全和性能 等方面考虑的,下面且听一一道来。
2、缓存
字符串是使用最多的数据结构,相较于基本数据类型,字符串还是比较耗费资源的,频繁创建大量的字符串更是如此,秉承着 复用 的思想,Java提供了对字符串的缓存功能,这样可以大大 节省运行时内存空间 。
JVM为此专门开辟了一部分空间来存储字符串,它叫 字符串常量池 。
通过字符串常量池,两个内容相同的字符串会在池中指向同一个字符串对象,从而节省关键内存资源。例如:
String s = "abcd";
String s2 = s;
在上述代码中,s
和s2
都表示abcd
,所以它们会指向字符串常量池中的同一个字符串对象,如图所示:
之所以可以这么做,主要是因为字符串的不可变性。试想一下,如果字符串是可变的,我们一旦修改了s2
的内容,s
也会随之改变,这一定不是Java想要的,也不是我们想看到的。
3、安全性
字符串在Java程序中用于广泛 存储敏感信息 ,例如:用户名
、密码
、url
、网络连接
等等。 JVM类加载子系统 在加载类时也会广泛的使用字符串。
因此 保护String类不被修改 对于提升整个应用程序的安全至关重要。
当我们在程序中传递一个字符串时,如果这个字符串内容是不可变的,我们就可以 无条件信任、相信这个字符串中的内容 。
如果我现在告诉你,字符串是可变的,那么这个字符串的内容就可能随时被修改,这样整个系统就没有任何安全性可言了。
4、线程安全
不可变性 会自动使字符串成为 线程安全 的,因为当从多个线程中访问字符串时,字符串的内容不会被更改。
因此,一般来说,不可变对象可以在同时运行的多个线程之间共享。它们也是线程安全的,因为如果线程更改了值,那么将在字符串池中创建一个新的字符串,而
不是修改相同的值。因此,字符串对于多线程来说是安全的。
5、hashCode缓存
由于字符串对象被广泛地用作数据结构,所以它们也被广泛地用于Hash
实现,如HashMap
、HashTable
、HashSet
等。在对这些Hash
实现进行操作时,经常调用hashCode0
方法。
不可变性保证了字符串的值不会改变。因此,hashCode()
方法在String
类中被重写,以方便缓存,这样在第一次hashCode()
调用期间计算和缓存Hash
值,并从那时起返回相同的值。
在String
类中,有以下代码:
private int hash;// this is used to cache hash code.
6、性能
前面提到的 字符串池、hashCode缓存 等,都是提升性能的体现。
因为字符串不可变,所以可以使用字符串池缓存以大大节省堆内存。而且还可以提前对hashCode进行缓存,更加高效。
由于字符串是应用最广泛的数据结构,因此字符串的性能对整个应用程序的总体性能有相当大的影响。
我们可以得出这样的 结论 : 字符串是不可变的,因此它的引用可以被视为普通变量,可以在方法之间和线程之间传递它,而不必担心它所指向的实际字符串对象
是否会改变。
三、字符串常量池
String是一个常用的类, 《Java虚拟机规范》中规定:相同的字符串常量必须指向同一个String实例。为了保证这一机制,就需要有一个地方存储这些String实例来确保相同的字符串一定指向相同的实例,这个存储字符串常量的地方被称为字符串池(String pool),或者称为String Constant Pool和String table等。
1 、字符串池的实现方式
String pool
是一个固定大小的HashTable
,(哈希表),默认值大小长度是1009
,如果放入到String Pool
的字符串非常多,Hash冲突
的概率就会越来越大,从而导致链表会很长,而链表长了之后直接造成的影响就是当调用String.intern()
时性能会大幅度下降。
不同的虚拟机以及不同版本中,字符串池的实现都不太一样。针对不同版本的HotSpot虚拟机来讲,字符串池所处的位置都不一样,以及大小也有差异。
JDK1.6
及其之前的版本,字符串池位于永久代中JDK1.7
版本,字符串池转移到了堆内存中JDK1.8
版本,新增了非堆区:字符串池也转移到了这里, 也就是元空间(MetaDataSpace)
- 使用
-XX:StringTableSize
可设置StringTable
的长度 JDK1.7版本
,StringTable
的长度默认值是60013
,StringTableSize
设置没有要求JDK1.8版本
,设置StirngTable
长度的话,1009
即是可设置的最小值。
PS: 由于篇幅有限,这里不再详细说明String pool的其他细节,重点从还是从设计理念角度来理解, 其实它本质上就是在JVM运行时数据区中,开辟了一块线程共享的缓存空间,叫元空间,1.8之前在堆中,同样也是线程共享的 。用来存储常量。
在本人的JVM篇中会详细对String Pool原理机制进行学习、讲解。
2、池中常量的来源
运行时常量池中包含多种不同的变量,其主要来源主要有两个:
- 编译器可知的字面量以及符号引用
- 运行期间后解析获得的常量
四、intern
1、String::intern 方法概述
String::intern()
是一个本地native
方法,它的作用是:如果String Pool
中已经包含了一个等于此String
对象的字符串,则返回代表池中这个字符串的String
对象的引用。在JDK 6 或者更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:permSize
和 -XX:MaxPermSize
限制永久代的大小,即可简介限制其中常量池的容量。
我们在代码中创建字符串时,会涉及到String::intern()
方法,它的作用主要就是用来判断String Pool是否存在当前声明的字符串常量。
2、String::intern 经典案例
由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行,HotSpot从JDK7开始逐步“去永久代”的计划,并在JDK8完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是"元空间"来实现方法区,对程序有什么实际的影响。(这段话引用周志明老师的 《深入理解Java虚拟机》 )
下面拿书中的经典例子来剖析:
public class Test {
public static void main(String[] args) {
final String str1 = new StringBuilder("58").append("tongcheng").toString();
System.out.println(str1);
System.out.println(str1.intern());
System.out.println(str1 == str1.intern());
final String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2);
System.out.println(str2.intern());
System.out.println(str2 == str2.intern());
}
}
思考,输出结果是什么? 下面直接贴出运行结果,基于JDK1.8(周志明老师将此段代码放在不同JDK版本中运行,结果是不一样的,感兴趣的同学可以去书中了解下)
58tongcheng
58tongcheng
true
java
java
false
第一段代码由于jdk8中不存在永久代了,所以intern()不需要再将字符串拷贝到字符串常量池中,字符串常量池在堆中,所以只需要在常量池记录一下引用的地址即可,因此str1.intern()和new StringBuilder引用的实例是同一个,因此返回true。
第二段代码在new StringBuilder之前,"java"字符串已经加载过一次,( System类 -> initializeSystemClass()方法 -> Version类中加载) 所以在“java”.intern()时返回的是第一次“java”实例的引用地址。而new StringBuilder()是在堆中重新创建的一个“java”实例,这不符合intern()方法的“首次遇到”原则,是两个内容相同,但引用地址不同的字符串对象。 所以结果为false。
重点: intern()方法返回的是当前字符串对象首次出现的引用实例。
PS: 在java1.8版本是这样的,往后的版本都是true了,不再是false。
3、深入理解 new String() 与 String::intern
这里我会举出一些经典demo,我们可以去结合问题,帮助我们更深层次理解。个别例子对没有了解过的人来说可能会比较绕,还是建议可以看进去。
问题1: 这段代码创建了几个对象?
String s1 = new String("aa");
1、在类加载的过程中,会在String Pool
中创建出aa
对象,这是第一个对象。
2、类加载完成后,回到执行代码,new String
, 会new
出一个String
对象放在Java堆中,这是第二个对象。
3、执行完第二步后,s1
将指向第二个对象,也就是堆中的对象。
回答: 所以这段代码总共创建了两个对象
问题2:结果是true 还是false ?
String s1 = new String("aa");
String s2 = "aa";
System.out.println(s1 == s2);
1、执行完第一行后,会有两个aa
对象,一个在String Pool
中,一个在堆中。
2、s1
指向堆中的aa
对象的引用。
3、s2
则是指向常量池中的引用。一个指向堆中,一个指向常量池,当然是false
。
回答: 结果为false
问题3:结果是true还是false?
String s1 = new String("aa");
s1.intern();
String s2 = "aa";
System.out.println(s1 == s2);
1、我们回顾下上篇中intern
的作用,intern
先判断字符串是否存在于String POOl
中,如果存在直接返回该常量,如果没有找到,说明该字符串是存在于堆中,则处理是把该对象在堆中的引用加入到String Pool
中。以后再通过常量拿,拿到的就是该字符串常量的引用,实际在堆中。
2、也就是说现在String Pool
中的 aa
实际是指向堆上String
对象的?所以结果是true
?
回答: 并不是,结果仍然为false
。
我们看第一行代码:
String s1 = new String("aa");
4、这段代码毋庸置疑,创建了两个对象,而第一个就是在字符串常量池中的。
5、第二行代码s1.intern()
执行,判断到池中已经存在aa
对象了,所以它就不用在String Pool
中新建一个常量,并且指向堆上的String
对象地址了。
6、所以第二行代码只是返回了aa
在常量池中的实例,除此之外没有做任何操作以及修改。
所以,s1
还是指向堆,s2
还是指向常量池,结果仍然为false
。
问题4: 那怎样才可以是true ?
String s1 = new String("a") + new String("a");
s1.intren();
String s2 = "aa";
System.out.println(s1 == s2);
这样结果打印就是true
了,因为方式不再是new String("aa")
,而是通过new String("a") + new String ("a")
,如此一来,Class文件常量池中写入的就是a
,而不是aa
1、s1 指向堆中的对象 aa
, 常量池中存储了a
对象。
2、执行s1.intern
, 判断发现常量池中不存在aa
,则会将s1
在堆中的引用加入到字符串常量池中,所以此时,常量池中的aa
实际指向堆中。
3、s2直接从常量池中拿到aa
,而常量池中的aa
实际指向堆中的aa
,所以,s1
和 s2
都是指向堆中的同一个对象,故结果为true
问题5:这段代码创建了几个对象 ?
String s1 = new String("a") + new String("a");
回答: 5个对象
1、分别是Java对于+
操作处理的语法糖
,会在编译后改变为new StringBuilder.append().toString()
。
2、两个new String
加上常量池中的a
,至此是4个对象,语法糖最后还涉及到StringBuilder::toString()
操作,所以是5个
PS: 关于语法糖,感兴趣的同学可以去了解下Java 对 +
的处理。
问题6: 思考这段代码的输出结果
(结合上述的问题,如果上面的案例你都了解清楚了,那这题当然不在话下)
public static void main(String[] args) {
String s1 = new String("1");
s1.intern();
String s2 = "1";
System.out.println(s1 == s2);
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
回答: 这里直接贴出结果:
s1 == s2 //false
s3 == s4 //true
五、其他
问题1、String有没有长度限制?
1、String的编译期长度限制
对String的设计角度有一定了解后,我会有这样一个疑问,String的length是无限制的吗?
为了搞清楚这个问题,我们可以看下String的源码,它有形形色色的构造方法,下面贴出其中一个:
public String (byte[] bytes, int offset, int length) //通过byts[]数组构造String
可以看到,int length
,说明length
是一个int
类型的变量,而int的最大值范围是2的31次方减1,也就是2147483647
,从这里就可以看出String
的最大长度。
但是当我想要在编译期间定义一个这样的字符串却行不通,如下:
String s1 = "1111....111"; //其中有10w个字符
这段代码编译时会直接报错:
错误:常量字符串过长 //error
为什么呢?不是应该可以定义最大长度为2147483647
吗?为什么定义时却无法通过编译呢?
其实,当按照String s1 = "xxx"
这样的形式定义字符串时,"xxx"
被称为字面量,是要进入Class常量池的,因为要进入常量池,所以当然要遵守常量池的规范了。
2、常量池限制
这里引用 《深入理解Java核心核心技术》 中的原文:
根据**《Java虚拟机规范》中4.4节**对常量池的定义,CONSTANT_String_info
用于表示java.lang.String
类型的常量对象,格式如下:
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
其中,string_index
项的值必须是对常量池的有效索引,常量池在该索引处的项必须是CONSTANT_Utf8_info
结构,它表示一组Unicode
字符,这个字符最终会被初始化为String
对象。它用于表示字符创常量的值:
CONSTANR_Utf8_info {
u1 tag;
u2 length; //length的类型为 u2
u1 bytes[length];
}
通过翻阅《Java虚拟机规范》得知,u2
表示2字节的无符号数,1字节有8bi
t,2
字节16bit
,16位的无符号数最大值为 2的16次方 - 1,即 65535
。
如此一来再从新定义代码:
String s1 = "111...111"//其中有65534个字符
这时发现代码可以正常编译了。
3、运行期间限制
上面说的是字符串在编译期间对字面量常量的长度限制,也就是定义String s1 = "xxx"
这种形式时才会有的限制。
问题:那String
在程序运行期间有限制吗?
回答:是有限制的,就是前面说的2147483647
,int
的上限值,这个值如果用来存储字符串约等于4GB
。
是不是会有疑惑:编译期间的最大长度是65534
,运行期间为什么还会出现大于65534
的情况呢?这个情况其实很常见,例如以下demo:
String s1 = "";
for (int i = 0; i < 100000; ++i) {
s += i; //当然,实际开发中不要这些写,这样相当于每次循环都会new一个StringBuilder对象,巨慢无比
}
因此,在实际开发中,一定要注意这个问题,比如将一个高清图片转成base64
字符串存储,很可能超过最大限制,这样程序就抛异常了。
4、小结
关于String
的长度限制总结:
-
在编译期间不能超过
65535
,否则编译不通过。 -
在运行期间,不能超过
int
范围,否则会抛错误。
5、思考
所以我们可以大胆猜想:运行期间的长度限制远远大于编译期间长度限制,这种设计思路,是出于什么考虑的?或者说为什么要设计成这样?
以我个人目前对String的认知深度来讲:
-
因为编译期间需要将字面量存入常量池,而最大长度限制是
65535
,这取决于Class常量池的规范,Class常量池之所以这样设计肯定是为了节省不必要的空间浪费,毕竟谁会在实际开发中定义一个超长的字符串字面量呢,这样的方式在我看来没有任何意义,我觉得更大的方面是因为Class常量池设计如此。 -
从运行期间来看,程序又有很多无法预见的场景,或者说可能会有大字符串的场景,这是无法避免的,所以Java一定要允许一个合适的运行期间的最大长度,String的
length
定义为int
类型,没有定义成long
类型,这也是一种对长度的限制,因为一个最大长度(32bit
所表示的最大值)的字符串已经是4GB
了,如果说使用long类型,那可不止4GB
了,毕竟64bit
啊,所能表示的最大值跟32bit
可是天差地别。如果多来几个4GB
的大字符串,一定会拖垮JVM。