本文的写作冲动来源于今晚看到的老赵的一则微博“大家知道System.Collections.Generic.List<T>是一种什么样的数据结构?内部的元素是怎么存放的?还有Dictionary<TKey,TValue>呢?…”。

查了一下书,如果参考数据结构和算法里介绍的线性表合哈希表的特点,非常官方的答案就类似:List<T>是一种线性的内存连续分配的存储结构,元素是顺序存放的;它的优点是内存连续分配,相对节省空间,在设定长度范围内增加元素开销很小;缺点是查找复杂度为O(n),不如哈希结构O(1)复杂度来的快,如插入节点超过指定长度需要重新开辟内存,开销很大云云。而Dictionary<TKey,TValue>则是哈希结构,优点blahblahblah缺点blahblahblah。回答结束。

然后再看老赵微博下面的回答,似乎很不统一,多数认为是基于数组实现的,但是…擦,看一圈都没有老赵满意的答案。以前看过文章听说过StringBuilder和HashTable内部是怎么实现的,以及一个笼统的列表内存扩容两倍说,但是一直不知道具体细节也不太肯定,所以我也很想知道答案。老赵说稍微有点儿好奇心的程序员都应该会去看看两个实现的源代码。世上无难事只怕有心人,要是真的有心人顺便还应该不论对错记录一下自己的学习心得,哈哈。

注:如果你是新手,建议直接到此为止,不要再往下看了。实在好奇想知道答案,最简单正确也是我的偶像老赵所推荐的做法当然是自己查MSDN和framework源码。为了不误导人,本文再加上一个标签:无责任乱写。

一、StringBuilder

StringBuilder有6种构造函数,直接通过无参构造函数创建对象比较常见。MSDN(中文)里说“此实现的默认容量是 16,默认的最大容量是 Int32.MaxValue”。默认容量是16,16什么呢,这话怎么说得这么模糊呢?反汇编跟一下源码,看到它的构造函数最终要调用一个方法:

StringBuilder

默认容量16,其实估计是指默认预分配的字符串容量为16个字符。

再分析其中带两个参数的:public StringBuilder(int capacity, int maxCapacity),它的主要实现如下:

StringBuilder

这里就有一个疑问:通常我们实现字符串拼接的时候,肯定不能保证字符串的容量不大于默认容量16,如果大于16,StringBuilder是如何实现这种动态扩容效果的呢,总不能一下子就留足内存吧?我们看一下常见的Append(string value)方法是如何实现的:

StringBuilder.Append

上面的代码中,对于超过拼接后默认最大容量的字符串的逻辑在AppendHelper中,AppendHelper最终是通过下面的方法实现的:

Append

接着来分析扩容函数ExpandByABlock:

ExpandByABlock

从上面的直白分析我们可明显看出,实例化一个StringBuilder必然要初始化并维护一个内部数组char[] m_ChunkChars。而学过C语言和数据结构的应该都知道,数组对于它的创建需要预先给出一定的连续分配的内存空间,它并不支持在原有的内存空间的基础上去扩展,所以数组对于动态内存分配是极为不利的,但是基本的数据结构如字符串和线性表就是基于数组实现的。

接着简单看了一下其他几个常用拼接方法和索引器,内部实现大致一样,几乎都是对字符数组的操作逻辑。有兴趣大家不妨也看看源码。

分析到这里,我们可以大胆假设:StringBuilder内部实现的字符串操作最终是通过字符数组char[] m_ChunkChars进行处理的。想一想也对啊,如果StringBuildr的实现是通过String加等于减等于地拼过来接过去那就逊掉了。

不能忽视的是它的扩展容量的算法,最关键的就是下面这行代码:

int num = Math.Max(minBlockCharCount, Math.Min(this.Length, 8000));

其实是非常简单的数学方法,StringBuilder内部的MaxChunkSize默认为8000,大家可以评估一下,如果进行多次拼接,字符串长度随机一点,内存分配情况会怎么样,8000这个数字有没有道理。据传说和GC内部算法一样,framework一些常用类的内部实现也遵循着平衡的设计,不知真假。

二、基于Array的可动态扩容的线性结构

大家应该都非常熟悉,就像StringBuilder一样,framework中很多集合都有动态扩容效果。比如我们熟悉的线性集合ArrayList,泛型List,Queue,Stack等等,这些都是直接基于Array而实现的。

那么基于Array的集合它们内部是如何实现动态扩容的,扩容的量是怎么控制的?也许我们早有耳闻,就是动态扩展的容量是上一次已有容量的两倍,到底是不是这样的呢?带着疑问我们挑一个最常见的集合泛型List<T>分析一下。

和StringBuilder分析法类似,我们也从构造函数和Add方法着手分析。

无参构造函数如下:

List

_items是个泛型数组(private T[] _items;泛型数组好像不能这么说?),通过构造函数它肯定有默认初始容量了,_emptyArray肯定初始化分配了一定空间,猜对了吗?_emptyArray定义如下:

  private static readonly T[] _emptyArray = new T[0];

看看有一个参数的:

List

这回直接给_items new了一个数组对象。

好,再来一个传入初始集合的:

List

到这里估计大家都看到关键的地方了:每个构造函数都要对_items 变量进行初始化,而这个_items 正是一个数组(如我们所知,泛型List确实是基于数组实现的)。

再来分析下增删改查中的增加也就是Add方法:

List.Add

我们目标明确,终于找到了扩容函数EnsureCapacity:

List.EnsureCapacity

到这里,我们看到,添加一个元素的时候,如果容量没超过预分配的数组空间大小,直接通过下面的索引器赋值:

List.Indexer

如果新增的项超过了现有数组的最大容量,通过扩容函数进行容量再分配(再new一个数组对象),在函数EnsureCapacity中,我们看到:

int num = (this._items.Length == 0) ? 4 : (this._items.Length * 2); //容量=当前的长度的两倍

这里进行了数组容量的重新计算,方法很简单。最重要的是给属性Capacity赋值:

this.Capacity = num; //当前数组容量赋值

这个属性的set里面包含了数组new的操作:

List.EnsureCapacity

分析到这里我们完全可以通过伪代码总结出集合的扩容算法:

Collection.EnsureCapacity

也许有人会问,重新分配内存空间时直接简单的乘以2会不会太草率了,这样不是容易造成内存空间的浪费吗?MS的实现也不都是非常严谨的嘛?

确实,缓冲区扩容的时候,如数据量较大,很明显会造成内存浪费,泛型List提供了一个方法叫TrimExcess用来解决内存浪费的问题:

TrimExcess

原理也就是一个简单的数学方法,你在外部调用一次或多次,它就会自动帮你平衡空间节省内存了。

有一个非常常用的功能就是判断元素是否在列表里,方法名就是熟悉的Contains,看代码果然是O(n)复杂度:

Contains

大功告成,哈哈,我惊诧极了目瞪口呆,泛型List果然也是基于数组的,而且我们现在可以理直气壮地说我很清楚它的内部扩容实现算法。此时我真想拍着肩膀对自己说,哥们你辛苦了,太有成就感了,抓紧时间多想想你喜欢的姑娘吧……我果然又红光满面了。

然后,再查看源码,发现很多核心方法的实现都直接对_items数组进行操作,并多次调用了Array的静态方法,所以我们一定不可小视数据结构数组(Array)。

反汇编查看Array的源码,发现增删改查方法相当之丰富,哪位有心人可以挖掘挖掘里面使用了多少种经典算法(比如BinarySearch听说用了二分查找),反正感觉它是被我大大低估了。至少现在我们知道,经常使用的常用数据结构如StringBuilder、ArrayList,泛型List,Queue,Stack等等都是基于Array实现的。不要小看了它,随着framework的发展,也许未来还会不断出现基于Array的新的数据结构的出现。

三、基于Array的可动态扩容的哈希结构

平时开发中经常使用的其他的一些数据结构如HashTable,泛型Dictionary,HashSet以及线程安全容器ConcurrentDictionary等等也可以动态扩容。下面从HashTable源码入手简单分析下。

先从无参构造函数开始:

HashTable

无参构造函数最终需要调用的构造方法如下:

HashTable

正如我们所知,哈希函数的计算结果是一个存储单位地址,每个存储单位称为桶,而buckets不正是我们要找的存储散列函数计算结果的哈希桶吗?原来它也是个数组。这里大家看到了吗?this.buckets原来就是一个struct结构数组,心里好像一下子就有底了。

好,接着找动态扩容函数,从Add方法入手吧:

      public virtual void Add(object key, object value){this.Insert(key, value, true);}

跟踪到Insert方法,代码一下子变得内容丰富,但是我们直接看到了一个判断语句:

          if (this.count >= this.loadsize){this.expand();}

上面这个if判断一目了然,expand不就是扩展的意思吗?跟进去:

      private void expand(){int prime = HashHelpers.GetPrime(this.buckets.Length * 2); //直接乘以2 好像还有个辅助调用获取素数 HashHelpers.GetPrimethis.rehash(prime); //重新hash}

有个rehash函数,再跟进去:

rehash

果然又看到了数组的重新定义和再赋值:this.buckets = newBuckets;

这不就又回到老路上来了吗?再查查Array出现的频率,哈希表很多方法的实现也还是数组操作来操作去的。到这里,忍不住想起周星驰电影里的话:打完收功。

但是还没完,有一个动态扩容容量的计算问题,是和泛型列表一样的直接乘以2吗?在expand函数里我们看到了下面这一行:

int prime = HashHelpers.GetPrime(this.buckets.Length * 2);

很显然,乘以2以后还需要一个辅助函数HashHelpers.GetPrime(看命名就知道是获取素数)的运算,HashHelpers.GetPrime代码如下:

GetPrime

获取素数的实现好像还蛮熟悉的。继续跟踪HashHelpers,发现它的primes数组列举了最小为3,最大为7199369的素数。如果你的哈希表容量不是特别大,3到7199369的素数足够使用了(符合二八原则),否则哈希表扩容的时候需要在2147483647这个数字范围内通过HashHelpers.IsPrime来判断并动态生成素数。所以简单地说哈希表扩容后的容量是原来的两倍并不准确,这个就和哈希算法对素数的要求有直接关系,在不太精确的情况下我们可以认为约等于两倍。

出于好奇顺便查看了一下泛型字典的源码,它的内部实现包含了buckets和entries两个结构数组,还发现哈希结构的容器通常内部算法果然都比较绕。我猜它们的实现也不同程度地利用了数组的特点,说到底应该也是基于Array实现的吧(看到没,不知道就算无责任乱写)?其他的几种常见哈希结构的容器还没有认真看,有兴趣大家可以直接查看源码详细分析一下,挑典型的一两个对比着看应该非常有效果。

欢迎大家畅所欲言说说你所知道的基于Array的其他数据结构,这里我就不再班门弄斧贻笑大方了。

思考:抛一个弱弱的疑问向大家求证:实现容器的动态特性是不是必须要基于数组呢,基于数组的实现是唯一的方式吗?






本文转自JeffWong博客园博客,原文链接:http://www.cnblogs.com/jeffwongishandsome/archive/2012/11/06/2753054.html,如需转载请自行联系原作者

从源码分析常见的基于Array的数据结构动态扩容机制相关推荐

  1. TreeMap源码分析——深入分析(基于JDK1.6)

    TreeMap有Values.EntrySet.KeySet.PrivateEntryIterator.EntryIterator.ValueIterator.KeyIterator.Descendi ...

  2. OkHttpClient 源码分析 1(基于3.9.0的源码)

    OkHttpClient是目前开发 android 应用使用最广泛的网络框架,最近看了阿里的 httpdns 里面对于 dns 的处理,我们团队就想调研一下在项目中有什么利弊,并且框架中是否对 soc ...

  3. 【转】ABP源码分析三十五:ABP中动态WebAPI原理解析

    动态WebAPI应该算是ABP中最Magic的功能之一了吧.开发人员无须定义继承自ApiController的类,只须重用Application Service中的类就可以对外提供WebAPI的功能, ...

  4. jQuery-1.9.1源码分析系列(七) 钩子(hooks)机制及浏览器兼容

    处理浏览器兼容问题实际上不是jQuery的精髓,毕竟让技术员想方设法取弥补浏览器的过错从而使得代码乱七八糟不是个好事.一些特殊情况的处理,完全实在浪费浏览器的性能:突兀的兼容解决使得的代码看起来既不美 ...

  5. docker 源码分析 三(基于1.8.2版本),NewDaemon启动

    本文来分析一下New Daemon的启动过程:在daemon/daemon.go文件中: func NewDaemon(config *Config, registryService *registr ...

  6. kafka源码分析(二)Metadata的数据结构与读取、更新策略

    一.基本思路 异步发送的基本思路就是:send的时候,KafkaProducer把消息放到本地的消息队列RecordAccumulator,然后一个后台线程Sender不断循环,把消息发给Kafka集 ...

  7. Django框架深入了解_01(Django请求生命周期、开发模式、cbv源码分析、restful规范、跨域、drf的安装及源码初识)

    阅读目录 一.Django请求生命周期: 二.WEB开发模式: 三.cbv源码分析: 四.认识RESTful 补充知识:跨域 五.基于原生django开发restful的接口 六.drf安装.使用.A ...

  8. 「源码分析」CopyOnWriteArrayList 中的隐藏知识,你Get了吗?

    前言 本觉 CopyOnWriteArrayList 过于简单,寻思看名字就能知道内部的实现逻辑,所以没有写这篇文章的想法,最近又仔细看了下 CopyOnWriteArrayList 的源码实现,大体 ...

  9. 集合的get方法中参数从多少开始_源码分析CopyOnWriteArrayList 中的隐藏知识,你Get了吗?...

    欢迎点击 "未读代码" ,关注公众号,文章每周更新 杭州-阿里园区墙 前言 本觉 CopyOnWriteArrayList 过于简单,寻思看名字就能知道内部的实现逻辑,所以没有写这 ...

最新文章

  1. Java虚拟机5:常用JVM命令参数
  2. 平均分辨准确率对网络隐藏层节点数的非线性变化关系03
  3. windows 下redis在后台运行(亲测)
  4. 6_2 铁轨(UVa514)栈
  5. Samba将Linux集成到Windows网络
  6. unity, sceneview 中拾取球体gizmos
  7. STM32F103:一.(3)IO方向
  8. 薅羊毛新思路!腾讯游戏:成年人借未成年人名义申诉退款频发
  9. bfs-poj-Bloxorz I
  10. php中的数值型字符串相加 相比较( ==)
  11. wps xml转换表格_wps手机版下载-WPS Office 安卓版v12.9.2
  12. AD如何修改PCB文件的黑色编辑区
  13. CuPy is not correctly installed
  14. AUTOMATE THE BORING STUFF WITH PYTHON读书笔记 - 第18章:SENDING EMAIL AND TEXT MESSAGES
  15. window7系统下如何使用虚拟机安装苹果系统
  16. 《WEB开发-阿里云建站》第1章 建站前的准备
  17. Linux终端分屏软件tmux工具基本快捷键
  18. 基于HTML个人博客网站项目的设计与实现——个人博客作品展示6页 HTML+CSS
  19. cent os 7 与cent os 6区别
  20. python爬虫--破解js加密:kankan登录破解

热门文章

  1. 位运算(、|、^、~、>>、<<)
  2. VB谨慎使用IsMissing函数
  3. ubuntu安装rpm的方法
  4. 利用Nginx对不同的域名进行解析
  5. Apple Watch,其实是个老司“机”
  6. 北大主场夺金ACM-ICPC全球总决赛,总教练罗国杰分享背后“秘笈”
  7. AI即开即用,这是悄然推出的“腾讯最新AI技术”小程序
  8. 下个乳业蓝海风口 竟很可能是低温鲜奶?
  9. 简单识别 RESTful 接口
  10. 【命令小结】“|”的用法