深入理解 Java String

深入理解 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类涉及的所有函数都没有改变字符串本身,都是返回了一个新的对象。

如果我们想要一个可变的字符串,则可以选择StringBuilderStringBuffer

知道了 “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;

在上述代码中,ss2都表示abcd,所以它们会指向字符串常量池中的同一个字符串对象,如图所示:

在这里插入图片描述

之所以可以这么做,主要是因为字符串的不可变性。试想一下,如果字符串是可变的,我们一旦修改了s2的内容,s也会随之改变,这一定不是Java想要的,也不是我们想看到的。


3、安全性

字符串在Java程序中用于广泛 存储敏感信息 ,例如:用户名密码url网络连接等等。 JVM类加载子系统 在加载类时也会广泛的使用字符串。

因此 保护String类不被修改 对于提升整个应用程序的安全至关重要。

当我们在程序中传递一个字符串时,如果这个字符串内容是不可变的,我们就可以 无条件信任、相信这个字符串中的内容

如果我现在告诉你,字符串是可变的,那么这个字符串的内容就可能随时被修改,这样整个系统就没有任何安全性可言了。


4、线程安全

不可变性 会自动使字符串成为 线程安全 的,因为当从多个线程中访问字符串时,字符串的内容不会被更改。

因此,一般来说,不可变对象可以在同时运行的多个线程之间共享。它们也是线程安全的,因为如果线程更改了值,那么将在字符串池中创建一个新的字符串,而

不是修改相同的值。因此,字符串对于多线程来说是安全的。


5、hashCode缓存

由于字符串对象被广泛地用作数据结构,所以它们也被广泛地用于Hash实现,如HashMapHashTableHashSet等。在对这些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的长度默认值是60013StringTableSize设置没有要求
  • 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,所以,s1s2都是指向堆中的同一个对象,故结果为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字节有8bit,2字节16bit,16位的无符号数最大值为 2的16次方 - 1,即 65535

如此一来再从新定义代码:

String s1 = "111...111"//其中有65534个字符

这时发现代码可以正常编译了。


3、运行期间限制

上面说的是字符串在编译期间对字面量常量的长度限制,也就是定义String s1 = "xxx"这种形式时才会有的限制。

问题:那String在程序运行期间有限制吗?

回答:是有限制的,就是前面说的2147483647int的上限值,这个值如果用来存储字符串约等于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。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值