参考资料

[1] @毛星云【《Effective C#》提炼总结】 https://zhuanlan.zhihu.com/p/24553860

[2] 《C# 捷径教程》

[3] @flashyiyi【C# NoGCString】 https://zhuanlan.zhihu.com/p/35525601

[4] 如何理解 String 类型值的不可变? @胖君和@程序媛小双的回答 https://www.zhihu.com/question/20618891

基础知识

  1. String类型在C#中用于保存字符,为引用类型,一旦创建,就不能再进行修改,其底层是根据字符数组(char[])实现的。
  2. StringBuilder表示可变字符字符串类型,其中的字符可以被改变、增加、删除,当向一个已满的StringBuilder添加字符时,其会自动申请内存进行扩容。
  3. Unity中Profiler窗口的GC Alloc那一列的信息表示的是当前帧产生了多少垃圾(指一块存储不再使用的数据的内存)。Unity官方文档对此标签是这样的解释的:

The GC Alloc column shows how much memory has been allocated in the current frame, which is later collected by the garbage collector.

大致意思是,GC Alloc这一列表示当前帧有多少内存被分配,这些内存将会在之后被垃圾回收器进行清理。

疑难解答

  1. 如何理解String类型值的不可变?
  2. 为什么String类型的连接(加法和Concat)性能低下?与之相比,为什么StringBuilder更快?
  3. String类型与GC(垃圾回收器)的关系?
  4. 如何正确的使用String与StringBuilder?

如何理解String类型值的不可变?

在C#中string类型的底层由char[],即字符数组进行实现,但我们并不能像修改字符数组的方式来对字符串进行修改。事实上,我们以为的修改(字符串的连接,字符串的赋值)对于字符串来说都不是真正的修改,每当我们对字符串进行赋值时,底层会进行两个操作。

  1. 首先会去查找字符串池,如果字符串池有这个字符串,那么直接将当前变量指向字符串池内的字符串。
  2. 如果字符串池内没有这个字符串,那么在堆上创建一块内存用于放置这个字符串,并将当前变量指向这个新建的字符串。

一个新建字符串的简单例子如下:

  1. public static void Main(string[] args) {
  2. string s = "abc";
  3. Console.WriteLine(s);
  4. s = "123";
  5. Console.WriteLine(s);
  6. }

其中第4行s的赋值语句并不是将原本"abc"的字符串修改成"123",而是另外在堆上创建了一个新的内存"123",并将s变量指向这个新字符串,而旧的字符串"abc"就被丢弃了,但它仍然在堆上占据着内存,等待GC将其回收。

对于字符串的连接(加法或Concat函数),其原理同上,事实上原来的字符串并没有真正在后面增加了字符,而是创建了一个新的字符串,其值是两个字符串连接后的结果。

字符串的这种特性,使得它的赋值和连接操作很容易造成内存浪费,因为每一次都将在堆上创建一个新的字符串对象。所以一个比较明确的思路是,不要频繁的调用字符串的连接操作(比如放在Unity的Update函数中)。

既然不可变特性使得我们不得不小心的使用字符串,那么字符串为什么还会被设计成不可变的形式呢?很显然,不可变的形式对于字符串可变的形式是利大于弊的,下面根据参考资料[4][3],尝试列举、阐述一下为什么字符串一定要是不可变的。

  1. 线程安全。在多线程环境下,只有对资源的修改是有风险的,而不可变对象只能对其进行读取而非修改,所以是线程安全。如果字符串是可修改的,那么在多线程环境下,需要对字符串进行频繁加锁,这是比较影响性能的。
  2. 为了安全(防止程序员意外修改了字符串)。想象下面这样一种情况,一个静态方法用于给字符串(或StringBuilder)后面增加一个字符串。
  1. public class StringTest{
  2. public static string AppendString(string s) {
  3. s += "abc";
  4. return s;
  5. }
  6. public static StringBuilder AppendString(StringBuilder s) {
  7. s = s.Append("abc");
  8. return s;
  9. }
  10. public static void Main(string[] args) {
  11. string s = "123";
  12. string s2 = AppendString(s);
  13. Console.WriteLine("原字符串:"+s+" 经过添加后的字符串:"+s2);
  14. StringBuilder sb = new StringBuilder("123");
  15. StringBuilder sb2 = AppendString(sb);
  16. Console.WriteLine("原字符串:" + sb.ToString() + " 经过添加后的字符串:" + sb2.ToString());
  17. }
  18. }

运行结果如下:

  1. 原字符串:123 经过添加后的字符串:123abc
  2. 原字符串:123abc 经过添加后的字符串:123abc

可以看到StringBuilder因为是可变的,所以原字符串直接在静态方法中被修改成了"123abc",而string类型因为其不可变的特性,所以它的原字符串和修改后的新字符串是不同的,这种不可变特性也就避免了程序员直接在方法里面直接对字符串进行连接操作,导致字符串在不知情的情况下被修改了(就像StringBuilder一样)。

  1. 因为字符串的不可变特性,所以其可以放心地作为Dictionary和Set的键(在Java中则是Map和Set)。在Dictionary和Set中使用可变类型作为键是极其危险的事,因为可修改键可能会导致Set和Dictionary中键值的唯一性被破坏。

为什么String类型的连接(加法和Concat)性能低下?与之相比,为什么StringBuilder更快?

先解决第一个问题,为什么String类型的连接(加法和Concat)性能低下?

前面提到了,因为字符串是不可变的,所以所有看似对其进行了修改的操作,都是在堆上另外创建了一个新的字符串,而这创建过程是耗费性能(申请内存,检查内存是否足够,不够的情况还要让GC对垃圾内存进行回收),所以可想而知字符串连接性能是比较低的。

当然,性能高低是需要有一个参照物的,与StringBuilder的连接操作相比,string类型就是相当慢了,除了慢以外,字符串的连接操作还会产生大量GC,因为每一次连接,都创建了新的字符串,而旧的字符串理所当然就被丢弃了,在没有任何变量引用这些旧字符串的情况下,GC要对这些旧字符串占据的内存进行回收,而GC的触发是十分耗费性能的(简单来说就是费时,因为GC是要遍历堆上所有无引用的对象),表现在Unity中,就是在某一帧相比其他帧额外消耗了几十ms来处理GC。

那么,StringBuilder的连接操作为什么快呢?

这要从StringBuilder的底层开始说起,StringBuilder的底层与string一样都是字符数组(即char[]),与string被设计为不可变不同的是,StringBuilder是可变的。

当StringBuilder进行连接操作时,它会经历以下步骤:

  1. 检查当前字符数量是否大于长度,如果大于,那么对StringBuilder进行扩容。
  2. 向char[]数组后面添加字符

很显然,只有在StringBuilder长度小于添加的字符时,才会额外申请内存对char[]数组进行扩容,其他情况下,就是对数组内的元素进行变换而已,与string类型每次连接都会废弃掉一个对象相比,StringBuilder就显得更快一些了。

当然,除了连接操作,StringBuilder还支持删除、修改字符串,这当然也是根据其中的char []数组进行操作的(而字符串因为其不可变性,是不支持这些操作的)。

考虑到StringBuilder扩容也是会产生GC的,所以一般比较好的做法是,在StringBuilder创建时就根据之后的使用情况为其指定一个容量。

String类型与GC(垃圾回收器)的关系?

这里主要研究在Unity3D引擎下,string类型和StringBuilder进行连接操作产生的GC Alloc情况。

之前一直说string类型的连接操作浪费内存,那么具体是什么情况呢?这里可以使用Unity3D引擎进行试验,下面尝试在每帧进行1000次字符串加法,然后使用Profiler查看GC Alloc。

  1. public class StringAppendGC : MonoBehaviour {
  2. string s = "";
  3. // Use this for initialization
  4. void Start () {
  5. }
  6. // 测试字符串加法在每一帧带来的GC
  7. void Update () {
  8. s = "";
  9. // 每一帧进行1000次字符串加法
  10. for (int i=0;i<=1000;i++) {
  11. s += i;
  12. }
  13. }
  14. }

GC产生情况如下:

可以看到上面的函数每一帧都产生了2.7M的垃圾,而Unity官方对于GC Alloc这一列的描述是这样的:

Keep this value at zero to prevent the garbage collector from causing hiccups in your framerate

大致意思是,保持该值为0以防止垃圾回收器使得某一帧(与其他帧相比)耗费的时间过长,造成“大帧”现象。

那么如果将上面的String改用StringBuilder会怎么样呢?将上面的代码改为如下所示:

  1. public class StringAppendGC : MonoBehaviour {
  2. // Use this for initialization
  3. void Start () {
  4. }
  5. // 测试字符串加法在每一帧带来的GC
  6. void Update () {
  7. StringBuilder stringBuilder = new StringBuilder(1000);
  8. // 每一帧进行1000次字符串加法
  9. for (int i=0;i<=1000;i++) {
  10. stringBuilder.Append(i);
  11. }
  12. }
  13. }

GC Alloc情况如下:

可以看到每帧分配内存的情况从2.7M下降到了44KB,相比于String类型有了明显的改善。

如何正确的使用String与StringBuilder?

既然知道了String类型在某些操作上会造成浪费,那么我们使用它的时候就要万分小心,根据参考资料[1]浅墨大佬所说,正确使用String与StringBuilder的姿势如下:

创建不可变类型的最终值。比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类

Unity/C#基础复习(3) 之 String与StringBuilder的关系的更多相关文章

  1. Unity/C#基础复习(5) 之 浅析观察者、中介者模式在游戏中的应用与delegate原理

    参考资料 [1] <Unity 3D脚本编程 使用C#语言开发跨平台游戏>陈嘉栋著 [2] @张子阳[C#中的委托和事件 - Part.1] http://www.tracefact.ne ...

  2. java基础复习之对于String对象,能够使用“=”赋值,也能够使用newkeyword赋值,两种方式有什么差别?

    String类型是实际工作中经经常使用到的类型,从数据类型上划分,String是一个引用类型,是API中定义的一个类.所以String类型的对象能够用new创建,比如String name=new S ...

  3. Java基础(40)String、StringBuilder和StringBuffer的区别(TODO)

    一.String String实现了Serializable接口.Comparable<String>接口和CharSequence接口,并且使用final char value[]不可变 ...

  4. JAVA基础部分复习(一、8中基础类型,以及String相关内容)

    以下是关于java中8种基本类型的介绍说明: package cn.review.day01; /** * java基础复习,8种数据类型 * (byte,short,long,int,double, ...

  5. [java基础]复习 java三大特性,异常,接口,String

    继承 关键字extends 继承是为了不同的实现(龙生九子,各不相同) 单继承,一个类最多只能有一个父类 除了私有的外,子类可以访问父类的方法.属性. new过程中,父类先进行初始化,可通过super ...

  6. JAVA基础复习与总结<五> String类_File类_Date类

    String类 .Java字符串就是Unicode字符序列,例如串“Java”就是4个Unicoe字符组成. .Java没有内置的字符串类型,而是在标准java类库中提供了一个预定义的类String, ...

  7. C#基础复习(4) 之 浅析List、Dictionary

    参考资料 [1] .netCore 源码 https://github.com/dotnet/corefx [2] <Unity 3D脚本编程 使用C#语言开发跨平台游戏>陈嘉栋著 [3] ...

  8. Java基础复习笔记系列 九 网络编程

    Java基础复习笔记系列之 网络编程 学习资料参考: 1.http://www.icoolxue.com/ 2. 1.网络编程的基础概念. TCP/IP协议:Socket编程:IP地址. 中国和美国之 ...

  9. Java基础复习笔记系列 八 多线程编程

    Java基础复习笔记系列之 多线程编程 参考地址: http://blog.csdn.net/xuweilinjijis/article/details/8878649 今天的故事,让我们从上面这个图 ...

随机推荐

  1. Segments(叉积)

    Segments http://poj.org/problem?id=3304 Time Limit: 1000MS   Memory Limit: 65536K Total Submissions: ...

  2. 通过阿里OSS文件服务返回的URL获取文件流下载

    我们都知道将文件上传到阿里的OSS文件服务上后,可以通过generatePresignedUrl(bucketName, key, expiration)方法获取该文件的防问路径,但是当我们知道该文件 ...

  3. VMware虚拟机安装Centos预安装环境图文教程1

    前言: 习惯了微软的各种可视化开发软件环境,突然接触Linux命令式的操作环境,总是会让人有些反感跟抵触的. 经过了几天的研究,发现Linux也并不是那么的深不可测.在配置网站部署环境的时候,系统集成 ...

  4. 自动化预备知识上&&下--Android自动化测试学习历程

    章节:自动化基础篇——自动化预备知识上&&下 主要讲解内容及笔记: 一.需要具备的能力: 测试一年,编程一年,熟悉并掌握业界自动化测试工具(monkey--压力测试.monkeyrun ...

  5. 修改UITextView光标高度

    自定义UITextView文字字体时,经常出现光标与字体的高度不匹配,可以通过下面代码修改默认的光标高度, //创建子类重写UITextView方法 - (CGRect)caretRectForPos ...

  6. Linux dkpg命令

    一.简介 dpkg 是Debian Package 的简写,是Debian系列系统下的一个软件安装.更新及移除工具. 二.常用指令 1.查询功能 查看软件包信息: dpkg -info xxx.deb ...

  7. python使用input()来接受字符串时一直报错“xxx is not defined”

    报错信息: “Please input your guess: gussTraceback (most recent call last):  File "coinGuessGame.py& ...

  8. 前端之css笔记3

    一 display属性 <!DOCTYPE html> <html lang="en"> <head> <meta charset=&qu ...

  9. hdu-1253(bfs+剪枝)

    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=1253 思路:简单的bfs,就是要注意剪枝. #include<iostream> #inc ...

  10. Vim配置(转)

    1.按F5可以直接编译并执行C.C++.java代码以及执行shell脚本,按“F8”可进行C.C++代码的调试 2.自动插入文件头 ,新建C.C++源文件时自动插入表头:包括文件名.作者.联系方式. ...