C# /JAVA: 字符串构建利器StringBuilder区别

  • 前言
    • 名词解释
    • 1.1 示例
      • 案例一: 不同变量赋值( = )
      • 案例二:相同变量赋值( = )
      • 案例三:变量追加赋值( += )
    • 1.2 常量池扩展(IsInterned、ReferenceEquals)
      • Object.ReferenceEquals() 确定实例是否为同一实例
      • String.IsInterned() 判断字符串是否存在内部池中
    • 2.1 stringBuilder是什么?
      • 全文例子(下方讲解全都按照这个走,不再重复写)
    • 2.2 StringBuilder — C#
      • 2.2.1 单项链表
      • 2.2.2 扩容机制
        • 如图所示,从扩容方面讲解:
        • 如图所示,从底层实现原理方面讲解:
    • 2.3 StringBuilder — JAVA
      • 2.3.1 char数组(与C#扩展区别)
      • 2.3.2 扩容机制
  • 推荐内容

前言

本章笔记直接记录的string、StringBuilder内存存储原理,并没有大幅度、重点的去介绍堆、栈、常量池的相关底层实现原理。
所以,为了帮助大家更好的去理解,可以在阅读本文章前去了解堆、栈、常量池之间的基础关系,对以下的阅读有很大的帮助。

名词解释

栈:存放变量(值类型)

堆:存放对象(引用类型)

常量池:它是一个Hash表。
为了提升性能和减少内存开销,避免字符串的重复创建,所以开辟出来一个单独的内存空间,就是字符串池。
字符串常量池是由String类私有的维护。

八大基本类型: byte、short、int、long、boolean、float、double、char
四大引用类型:数组、class、interface、字符串(string


一、String


1.1 示例

案例一: 不同变量赋值( = )

String  str1 = "Hello" ;
String  str2 = "Hello" ;
System.out.println(str1 );  //结果: Hello
System.out.println(str1 );  //结果: Hello
  • 情况1:不存在

    1. 变量 str1 会存放中,
    2. 首先在常量池中进行查找“Hello”是否存在。
    3. 不存在时,会在常量池中以键值对格式创建<key,value>,value则指向堆中的“Hello”对象指针。
    4. 从而,str1的引用地址就是堆中0x0001对应的“Hello”指针
  • 情况2:存在
    1. 变量 str2 会存放中,
    2. 首先在常量池中进行查找“Hello”是否存在。
    3. 存在时,会继续使用常量池中已存在的key,不会再新建。也就是 str2使用常量池中hello指向堆中的0x0001对应的“Hello”指针
    4. 与str1的引用地址是一个。

案例二:相同变量赋值( = )

我们都知道String属于类,它是不可变的。即一旦一个String对象被创建以后便不能被更改、变长、修改;直至这个对象被销毁。

不可变 : 文章下方会有专门的讲解

下面写了一个小例子,如下方所示:

String  str1 = "Hello" ;
str1 = "Word" ;
//打印出来的str1为: Word
System.out.println(str1);

看到这里,可能就会有疑问:不是不能被修改吗?怎么会对他进行了修改?
针对这个问题,我画了一张底层实现原理图,希望能够帮助到大家。如图所示:

前面我们说到了第一次str1赋值“Hello”,在常量池中创建后,其value指向对象指针

从图中可以看出,再次给str1赋值“Word”时,并不是对原来堆中的实例对象进行重新赋值,而是生成一个新的实例对象,并且str1的引用地址变指向了这个新的“Word”这个字符串。

之前的实例对象“Hello”依然存在,只是不再被引用了而已;如果没有被再次引用,则会被垃圾回收。
但是吧,在这里暂时不回被回收(垃圾回收不能释放被Hash表中引用的字符串,因为Hash表中正在容纳对他们的引用。除非进程终止)

案例三:变量追加赋值( += )

前面提到了String属于类,它是不可变的。即一旦一个String对象被创建以后便不能被更改、变长、修改;直至这个对象被销毁。

小案例:

String  str1 = "Hello" ;
str1 + = " Word" ;
//打印出来的str1为:Hello  Word
System.out.println(str1)

看到这里,可能就会有疑问:不是长度不可以变吗?为什么变量str1的长度会增加?会被修改?
针对这个问题,我又画了一张底层实现原理图,希望能够帮助到大家。如图所示:

公式变化:
str1在追加赋值+=“Word”时,(图中为了好看,堆地址0x0003对应的字符串对象我添加了空格,不要被误导了哈~)实际上是str1+“Word”(str1指向Hello) Hello+Word " HelloWord "。

底层变化:

  1. str1 中的“Hello”、“Word” 在常量池中没有,所以需要在常量池中创建对应key,其value(0x0001、0x0002)指向堆中的对象指针(Hello、Word)。
  2. 前面也提到了,实际上str1 = str1+“Word”,而这一步操作是隐式操作,不走字符串常量池的。
  3. 也就是说:Hello+Word 是在堆中相加的,生成了新的对象
  4. 其新生成堆地址0x0003(对象在堆中地址)会赋给str1。
  5. str1 根据这个地址去找到对象 “ HelloWord ”。(之前的实例对象“Hello”“Word”依然存在,只是不再被引用了而已)。

1.2 常量池扩展(IsInterned、ReferenceEquals)

有关常量池动态机制在此处查看: string常量池/驻留池——动态机制
这两个都是在C#中提供的方法,java的我还没有使用过。之后的java练习中找到了平替将会及时补充本文哈。

Object.ReferenceEquals() 确定实例是否为同一实例

ReferenceEquals 方法是 Object类的静态方法 ,不能被改写。该方法可以比较两个引用类型的引用是否指向用一个实例。

案例一:
> string aa = "Hello";
> string bb = "Hello";
> aa == bb
//判断变量是否相等。返回结果为:truetrue案例二:
> object.ReferenceEquals(aa,bb)//判断两个变量引用地址是否相同true

在这里我们可以使用object.ReferenceEquals(aa,bb) 来判断aa与bb是否是使用的一个常量池中的字符串,是否指向同一个对象指针。

由于ReferenceEquals()是判断两个对象的引用是否相等

  • 对于值类型,因为每次判断前都必须进行装箱操作,也就是每次都生成了一个临时的object,因而永远返回false。
  • 对于2个引⽤类型,ReferenceEquals则会⽐较它们是否指向同⼀地址。(特殊情况是两个都是null的话,会返回true)

String.IsInterned() 判断字符串是否存在内部池中

String.IsInterned(验证是否在常量池中),用来判断一个字符串是都已在常量池中。如果存在,返回该字符串;反之则返回null。

案例一:
> string aa = "Hello";
> string.IsInterned(aa)"Hello"  //正确输出“Hello”,说明常量池中存在“Hello”案例二:
> string.IsInterned(aa.ToUpper())null  //因为常量池中不存在“HELLO”,故返回:null//因为常量池中不存在“HELLO”,所以其对象、引用地址都为false
> aa == aa.ToUpper()false
> object.ReferenceEquals(aa,aa.ToUpper())false

二、StringBuilder 底层实现(C#/JAVA)


StringBuilder来源:
前面提到了String是不可变的对象。这就相当于每次对字符串进行 ++= 操作的时候会产生一个新的String实例。
对于大量进行拼接的场景非常不友好。因此,StringBuilder诞生~~~~ 撒花撒花~~~

2.1 stringBuilder是什么?

StringBuilder是一个可变的字符序列。此类提供一个与StringBuilder兼容的API,但不保证同步。
该类被设计用作StringBuilder的一个简易替换,用在字符串缓冲区被单个线程使用的时候。

StringBuffer就是为了解决大量拼接字符串时产生很多中间对象问题而提供的一个类,提供 appendadd 方法,可以将字符串添加到已有序列的末尾或指定位置。
它的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上synchronized。但是保证了线程安全是需要性能的代价的。

StringBuilder,它和StringBuffer本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。

其中StringBuffer是线程安全的。有个小地方需要慎重,就是toString()方法。

全文例子(下方讲解全都按照这个走,不再重复写)

//方式1 通过构造函数初始化数据StringBuilder stringBuilder = new StringBuilder("我爱你中国心爱的母亲");System.out.println(stringBuilder);
//方式2 使用Append追加StringBuilder builder = new StringBuilder(16);builder.append("我爱你中国");builder.append("心爱的母亲");builder.append(",");builder.append("我为你流泪");builder.append("也为你自豪。");builder.append("我爱你中国");builder.append("心爱的母亲");System.out.println(builder);
//方式3 将StringBuilder转换成字符串String str = builder.toString();System.out.println(str);
//结果输出
> 我爱你中国心爱的母亲
> 我爱你中国心爱的母亲,我为你流泪也为你自豪。我爱你中国心爱的母亲
> 我爱你中国心爱的母亲,我为你流泪也为你自豪。我爱你中国心爱的母亲

上面例子中简单操作了一个StringBuilder 的简单使用方式,主要操作是使用 Append() 方法和 ToString() 方法。这也是最常用的拼接方式。

2.2 StringBuilder — C#

2.2.1 单项链表

单链表是一种特殊的数据结构,能够动态的存储一种结构类型数据。
链表是通过指针将一组零散的内存块儿串联在一起使用。为了将所有的结点(内存块儿)串联起来。

每个链表除了存储数据data之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下一个结点(后继指针)我们把这个记录下个结点地址的指针叫作后继指针next。

如图所示,由四个扩容产生的对象 组合在一起,形成了链表

其中是第一个结点和最后一个结点是比较特殊的。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是: 指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上的最后一个结点。


下面进入到了对象扩容之间的了(存储数据data + 后继指针next)

该结构可以看成由两个部分组成。分别包含两个部分数据:

  • 第一部分data :结点本身的数据
  • 第二部分next :指向下一个结点的指针(整个stringBuilder对象的地址)

下图是画了一张StringBuilder的大体数据流转情况,希望能够帮助理解。


如图所示:

  1. stringBuilderB 是stringBuilderA扩容出来的char[ ](后面会讲到扩容相关)
  2. 声明变量 builder 通过堆地址0x0002(也就是整个stringBuilder对象的地址)找到stringBuilderB。
  3. stringBuilderB 后继指针next存放了stringBuilderA 的引用地址0x0001,也就是结点的指针。(这里就用到了单链表)
  4. 底层实现是循环遍历查找:
    • 通过循环,使得stringBuilderB 找到 stringBuilderA 。(会开辟一个新的空间,stringBuilderB放在新数组后方,接着通过stringBuilderB 后继指针next存放引用地址0x0001找到stringBuilderA放到新数组前面,一次类推)
    • ToString()方法将char[ ] 拼接在一起(会开辟一个总的长的,然后把数组挨个放进去),最后输出得到结果。

2.2.2 扩容机制

想了解StringBuilder的扩容机制,还需要从它的Append方法入手。
只有Append的时候才有机会去判断原有的 char[ ] 长度是否满足存储Append进来的字符串。

如图所示,从扩容方面讲解:

  1. 我刚开始初始化了一个char[16],
  2. 首次Append《我爱你中国 》存放在stringBuilderA 。并未达到16,未达到扩容条件。
  3. 二次Append《心爱的母亲》继续存放在stringBuilderA(因为tringBuilderA 还有11位空闲着,遂继续追加)占用下标5~9 。
  4. …一直这样一个个的追加,直到stringBuilderA 住满了,到达扩容条件。则会重新生成一个新char[ ] stringBuilderB,将剩余元素接着存放在其中。后继指针next指向下一个结点的指针,也就是stringBuilderA对象的地址。
  5. 其本质就是将Append进来的字符串复制到stringBuilderA数组中去。其字符串字符长度也代表了stringBuilderA已经使用的长度; 那么下一次的Append进来的元素,将会接着在上一个Append的后面继续追加

如果当前存储块满足存储,则直接使用。

如果当前存储位置不满足存储,那么存储空间也不会浪费,按照当前存储块的可用存储长度去截取需要Append的字符串的长度,放入到这个存储块的剩余位置,剩下的存储不下的字符则存储到扩容的新的存储块stringBuilderB中去,这个做法就是为了不浪费存储空间。

即时要扩容,那么我当前结点的存储块也一定要填充满,不浪费空间,保证了存储空间最大的利用。

如图所示,从底层实现原理方面讲解:

  1. 声明变量 builder 存放在栈中
  2. 首次Append(“我爱你中国”) ,将会在字符串常量池中搜索是否存在?
  3. 不存在:将在常量池新建<key,value>,并在堆中创建string对象。string对象中存放着“我爱你中国”,之后同时会进行两步操作:
    • 指针,也就是引用地址0x123返回给字符串常量池进行value绑定。
    • 同时返回给Append(也就是stringBuilderA)。
  4. stringBuilderA会根据引用地址0x123去找到str1,将里面的char[ ]拷贝一份至stringBuilderA中的char[ ]
  5. 以此类推,等到占满了就会进行扩容机制,扩容出stringBuilderB。(详情看上方,不再重复作业)
  6. 输出结果:实例对象的 . ToString()干了什么事
    • 会开辟一个新的空间 char[ ] ,目的是将stringBuilderA+stringBuilderB拼接起来
    • 栈中的builder会找到指针(引用地址)0x0002
    • 变量builder引用地址指向0x0002;接着继续循环查找出0x0001(通过stringBuilderB 后继指针next存放引用地址0x0001),根据线索找到stringBuilderA。
    • 会开辟一个新的空间,循环倒序遍历出来一个char[ ],就挨个放进去,以此类推,直到循环结束。

2.3 StringBuilder — JAVA

Java的实现方式与C#那些常量池什么的几乎一样,只是存储格式(数据结构)+扩容机制有些区别,其他都一样。
区别咱们继续往下看:

2.3.1 char数组(与C#扩展区别)

数据结构
C# 的 StringBuilder 整体结构来说是一个单向链表
JAVA的StringBuilder整体结构来说是一个char[ ]字符数组。

扩展长度
C# 的 StringBuilder 容量和上一个stringBuilder长度有关,每次扩容不固定:max(当前追加字符的剩余长度,min(当前StringBuilder长度,8000))

JAVA的StringBuilder容量则是生成一个新的数组,在原来char[ ]数组长度*2+2。(可理解为克隆)

底层实现
C# 扩容是单向链表,该结构有结点组成:后继指针next存放 指向下一个结点的指针 ;尾结点存放本身的数据。最后ToString()时,根据后继指针next的指针找到之前的元素,倒序遍历单项列表,拼接起来。

JAVA 扩容整体都是char[ ]数组:当达到扩容条件,会生成新的数组(一般是原来的char[ ]长度*2+2),新数组会把旧数组的元素克隆一份给自己。原来旧的数组没人引用了,就等待着垃圾回收。

2.3.2 扩容机制

刚才大体说了一下JAVA的底层实现机制是在旧数组的基础上,新生成一个新数组。并且克隆旧数组元素给自己。
旧数组没人引用就会等待着垃圾回收。

扩容机制也并不全都是旧char[]数组长度*2+2。他也会根据追加的长度进行判断,从而减少空间浪费。
对于这个问题,我们可以先进入java内部方法实现中去看(我用的是jdk1.8):

情景回顾:

声明char[16],数组中的16个字符已经被占满了,我现在二次.Append(“…50个字符…”),展开以下扩展路径

> 方法一,Append方法:
public AbstractStringBuilder append (String str)
{if (str == null) return appendNull();   int len = str.length();                   /.****** Append的50个字符长度 ******/  ensureCapacityInternal(count + len);      /.****** 16+50=66  原数组长度+现在新Append长度******/str.getChars(0, len, value, count);count += len;return this;
}> 方法二,扩容条件:
private void ensureCapacityInternal(int minimumCapacity)
{if (minimumCapacity - value.length > 0)     /.****** 66-16>0,也就是66>16,条件成立,进入下一个方法******/  {value = Arrays.copyOf(value,newCapacity(minimumCapacity));}}> 方法三,获取扩容之后的数组长度:
private int newCapacity(int minCapacity)
{int newCapacity = (value.length << 1) + 2;     /.****** 16*2+2=34 新扩容长度 ******/  //新扩容的长度 小于 .Append()的长度if (newCapacity - minCapacity < 0)               /.****** 34-66<0,也就是34<66 条件成立 ******/ {//申请的扩容空间 就等于 你.Append()的长度newCapacity = minCapacity;                    /.****** 新扩容长度=66, 取两个最大值 ******/ }//下方代码判断是否溢出//      66<=0   ||  int最大值-8<66 return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)   ? hugeCapacity(minCapacity)  //rue 进入溢出代码: newCapacity;                 //false 返回刚申请的长度
} > 方法四,溢出相关:
private int hugeCapacity(int minCapacity)
{if (Integer.MAX_VALUE - minCapacity < 0)   /.******int最大值<大于最大数组长度或者小于等于0******/ {throw new OutOfMemoryError();// overflow}/.*******如果大于int最大值-8,正好小于int最大值,返回它自己*反之, 返回int最大值-8,作为该扩展容器长度。(可能也有负数 我也是服了这个老6了)******/return (minCapacity > MAX_ARRAY_SIZE) ? minCapacity : MAX_ARRAY_SIZE;
}

三、ToSting()方法


具体的ToString底层实现代码讲解,请移到下方链接进行查看:
链接: stringBuilder.ToString()方法浅谈


结语


要说两种哪一个好,emmmmm不太好说,各有各的好。

C# 的StringBuilder 是单向链表,扩容的时候挺好,充分利用空间,保证了存储空间的最大利用。但是最后ToString()需要循环倒序遍历,最终把结果组装成一个字符串返回。

JAVA 的StringBuilder 是 char[ ] 类型的,扩容的时候,会生成一个新的数组并且克隆旧数组中的元素到自己里面。之前的旧数组没人引用就会等待垃圾回收。
所以,类似数组扩容再copy的逻辑没有链表的方式高效。
最后输出结果的时候因为本身存储在char[ ]中,所以随后输出 Java的StringBuilder优势是非常明显的。

推荐内容


  • ToString底层代码解析(C#/JAVA)
    分别浅谈 C# / JAVA 中 stringBuilder.ToString()方法底层原理以及区别
    附有C# /JAVA 底层源码分析。

  • string/stringBuilder常量池(驻留池) java/C#学习
    JAVA / C# 详解之:运行时常量池

String/StringBuilder/ToString()底层代码解析( JAVA / C# )相关推荐

  1. 【Java系列】从JVM角度解析Java核心类String的不可变特性

    凯伦说,公众号ID: KailunTalk,努力写出最优质的技术文章,欢迎关注探讨. 1. 前言 最近看到几个有趣的关于Java核心类String的问题. String类是如何实现其不可变的特性的,设 ...

  2. java图形用户界面设计代码,Java面试题及解析

    第一个模块:数据库 1.1 腾讯数据库面试问题 解释ACID四大特性 原子性的底层实现 数据库宕机后恢复的过程 如何保证事务的ACID特性 MySQL日志类型 这5个题目相对来说是比较普遍的,这里我就 ...

  3. java获取object属性值_java反射获取一个object属性值代码解析

    有些时候你明明知道这个object里面是什么,但是因为种种原因,你不能将它转化成一个对象,只是想单纯地提取出这个object里的一些东西,这个时候就需要用反射了. 假如你这个类是这样的: privat ...

  4. Java中String字符串toString()、String.valueOf()、String强转、+ 的区别

    Object#toString(): Object object = getObject(); System.out.println(object.toString()); 在这种使用方法中,因为ja ...

  5. 面试官系统精讲Java源码及大厂真题 - 02 String、Long 源码解析和面试题

    02 String.Long 源码解析和面试题 劳动是一切知识的源泉. --陶铸 引导语 String 和 Long 大家都很熟悉,本小节主要结合实际的工作场景,来一起看下 String 和 Long ...

  6. Java回顾-String/StringBuilder/StringBuffer

    一.String的特点 1.String类代表字符串.Java 程序中的所有字符串字面值(如 "abc" )都作为此类的实例实现. 2.String是一个final类,代表不可变字 ...

  7. java实现冒泡排序+图解冒泡排序+代码实现+代码解析(java)

    基本介绍 冒泡排序(Bubble Sorting)的基本思想是:通过对待 排序序列从前向后(从下标较小的元素开始),依次比较 相邻元素的值,若发现逆序则交换,使值较大 的元素逐渐从前移向后部,就象水底 ...

  8. java的时间变化_通过java记录数据持续变化时间代码解析

    这篇文章主要介绍了通过java记录数据持续变化时间代码解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 1.需求:获取count为null和不为n ...

  9. java 树状数据算法_使用递归算法结合数据库解析成Java树形结构的代码解析

    这篇文章主要介绍了使用递归算法结合数据库解析成Java树形结构的代码解析的相关资料,需要的朋友可以参考下 1.准备表结构及对应的表数据 a.表结构:create table TB_TREE ( CID ...

最新文章

  1. 【iOS官方文档翻译】UICollectionView与UICollectionViewFlowLayout
  2. linux命令more
  3. 滴滴行程单用的什么字体_打车就送冰淇淋!滴滴出行放大招,限时19天
  4. 2021 Spring 自定义注解 +AOP +方法入参
  5. 批量生成变量及引用_R语言:data.table语句批量生成变量
  6. 51单片机汇编_1_内外存储器转移数据
  7. ftp 服务器的主动模式和被动模式
  8. 又一灵异事件 Delphi 2007 在 Win7
  9. 无源蜂鸣器c语言编程,无源蜂鸣器题目
  10. 《自己动手写网络爬虫》笔记4-带偏好的网络爬虫
  11. 全自动高清录播服务器,常态化高清录播服务器 高清全自动录播系统
  12. 从0到1,搭建经营分析体系
  13. 妖魔复苏:天师下山!开局传承天师度(二)
  14. 有python画螺旋线
  15. Data truncation: Incorrect datetime value: ‘XXXX‘
  16. 负数在计算机中的存储方式
  17. Netstat常用参数及常用CMD命令总结
  18. python中tile的用法_Python numpy.tile函数方法的使用
  19. 手把手搭建一个完整的ssm登录注册项目(适合新手)
  20. 友善之臂中的mini2440 GPIO相关函数操作

热门文章

  1. 虚拟机迁移提示设备 “HD audio“ 的备用类型不受支持
  2. mysql 修改自动递增值_MySql数据库自动递增值问题
  3. 网站性能监测成杀手应用 基调网络脱颖而出
  4. 黑客盯上了Google相册漏洞
  5. 数据在堆栈中存储方式
  6. 海中月是天上月,眼前人是心上人。
  7. 艾美捷ProSci丨ProSci 凋亡抑制蛋白检测套装解决方案
  8. 我最近一个项目的架构与演变过程
  9. 05组-选题与需求分析报告
  10. 重新认识键盘与鼠标——键盘事件与鼠标事件