61
第一部分 Part 1 1 .NET 基础知识 2 C# 类型基础(上) 3 C# 类型基础(下) 4 C# 和面向对象 5 字符串 6 垃圾回收 7 异常与异常处理

第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

  • Upload
    others

  • View
    0

  • Download
    0

Embed Size (px)

Citation preview

Page 1: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第一部分 Part 1

基 础 知 识

■ 第 1 章 .NET 基础知识 ■ 第 2 章 C# 类型基础(上) ■ 第 3 章 C# 类型基础(下) ■ 第 4 章 C# 和面向对象 ■ 第 5 章 字符串 ■ 第 6 章 垃圾回收 ■ 第 7 章 异常与异常处理

Page 2: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章

.NET 基础知识

在探讨 C# 的各项功能和特性之前,我们有必要先了解它的生态环境—.NET 框架。

很多开发者对 C# 颇为熟悉,却“不探究其本”,对 .NET 框架的基础知识不太清楚。

本章首先会对 .NET 平台的发展做一简短回顾,然后,介绍 .NET 框架最重要的几个成

员:CLR、CTS、CLS、CLI、FCL 和 BCL。本章会重点介绍程序集的结构和部署,以及

CLR 是如何将代码转换为程序集的。

.NET 框架的基础知识是重要的背景知识,也是面试时比较高频的提问题材。了解

了这些知识,开发者可以站在一个更高的层面审视 C# 这门语言。虽然本章也会简

单提到 .NET Core,但它不是本书的重点,当提到 .NET 框架时,如非特别说明,均

指 .NET Framework。

1.1 .NET 框架介绍

.NET 框架是由微软开发的软件开发平台,其最主要的两个组成部分是公共语言运行时

(CLR)和框架类库(FCL),基础类库(BCL)是框架类库的一个子集。图 1-1 展示了 .NET框架的主要结构。

其中,最下层的无疑就是操作系统了。在 .NET 框架中,运行于底层的是公共语言运行

时(CLR),它是公共语言基础设施(CLI)的一个实现。在 CLR 之上,.NET 框架提供了全

面、丰富的框架类库(FCL),这些类库通过 CLR 调用操作系统的 API 实现各种功能。框架

类库致力于让开发者通过 .NET 平台编写各种各样的应用程序,并调用微软的其他产品。这

Chapter 1

提示

Page 3: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   3

些类库中,一些基础的类组成基础类库(BCL)。在类库之上,.NET 框架的各种语言共同实

现了公共语言规范(CLS)。

甲骨文公司的 Java EE 是 .NET 平台的竞争对手之一。.NET 框架的最新版本是 4.7.1(截

至 2017 年底)。

1.2 .NET 框架发展史

截至现在(2018)年,C# 和 .NET 平台

已经分别有了 18 和 16 年的历史。本节我们

简单回顾一下 .NET 平台的发展历史,它对于

我们更好地了解 .NET 平台有着重要的意义。

2002 年:.NET 1.0在 .NET 出现之前,编写 Windows 上可

以运行的程序就要直接和 COM(微软的一套

软件的接口标准)打交道。.NET 框架试图通

过 CLR 代替 COM,并提供更多功能,令用

户可以更轻松的使用 Windows 的资源进行软

件开发。

2002 年微软推出了 .NET Framework 1.0,相应地 Visual Studio 也升级到 7,称为 Visual Studio 2002(上一个版本叫做 Visual Studio 6)。该框架包括 C# 和 VB.NET(Visual Basic 的

继任者)。同时,第一个版本的 ASP.NET 也在 .NET Framework 1.0 亮相,它作为网站的解

决方案,一直是 .NET 框架最重要的产品线之一。

2005 年:.NET 2.02005 年 11 月,.NET Framework 2.0 发布。该框架包括 C# 2,相比第一代添加了新的语

法特性,其中最重要的就是泛型。与此同时,CLR 的版本也相应更新为 2.0,Visual Studio也升级到 Visual Studio 2005。

.NET Framework 2.0 中,Web 应用解决方案仍然是 ASP.NET WebForm,Windows Form则作为 Windows 下的应用解决方案。数据库连接为 ADO.NET(支持 SQL Server 和 Oracle),

Web Service 则多种多样,包括 .NET Remoting(TCP/HTTP/Pipeline communication)以及基

础的 Winsock 等。

2007 年:.NET 3.0,WCF+WPF+WF.NET 3.0 是 .NET 框架最重大的一个更新,包括三大产品线:

��WCF 统一了过去 Web 服务混乱的形式,形成了一个统一的格式。

��WPF 作为前端用户界面的解决方案,包括 Silverlight。��WF 提供工作流的管理。

C#

WCF

ADO.NET

WF

ASP.NET

WinForm

WPF

F#VB.NET

BCL

CLRCTS CLS

.NET Framework

FCL

图 1-1 .NET 框架的主要结构

Page 4: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

4   第一部分 基 础 知 识

.NET 3.0 标志着 Windows 平台开始全面转向 .NET 时代(以后所有版本的 Windows 都

预装 .NET)。而在这之前,只有服务器版本的 Windows 会预装 .NET。

当时的 .NET 只能在 Windows 上运行,通过 CLR 调用 Windows 的 API,从而控制电脑

硬件。.NET 3.0 的 CLR 和 .NET 2.0 相同。

2008 年:.NET 3.5,C# 3(LINQ)与 Entity Framework.NET 3.5 集成了 C# 3,包括 C# 3 最重要的一个功能,即 LINQ。相应地,为了支持

LINQ,此版本也加入了扩展方法、Lambda 表达式等新功能。从 C# 3 开始,C# 不再是 Java的一个跟班小弟,某种意义上实现了对 Java 的超越(Java 直到 2014 年才在 Java 8 加入了

Lambda 表达式)。

另外,.NET 3.5 SP1 新增了 ADO.NET Entity Framework 取代 ADO.NET,作为 ORM 的

解决方案。在 ADO.NET Entity Framework 发表之前,NHibernate 是在 .NET Framework 上

经常使用的 ORM 实现,顾名思义,它是 Hibernate 在 .NET Framework 上的实现。相应地,

Visual Studio 也升级到了 Visual Studio 2008。

2010 年:.NET 4.0 和 C# 4.NET 4.0 集成了 C# 4,主要增加了动态语言运行时(DLR)和任务并行库(TPL),它包

括 PLINQ、任务等,优化了多线程的编程方式。CLR 由 2 直接升级到 4。相应地,Visual Studio 也升级到了 Visual Studio 2010。

2012 年:.NET 4.5 和 C# 5.NET 4.5 和 C# 5 一 起 发 布,.NET

4.5 框 架 的 生 态 系 统 见 图 1-2。C# 5 基

本 上 全 部 都 是 围 绕 async/await 关 键 字

的。相应地,Visual Studio 也升级到了

Visual Studio 2012。在这之后,.NET 还

有 更 高 的 版 本 4.6 和 4.7, 对 应 Visual Studio 2015 和 Visual Studio 2017。当然

与此同时,各个 .NET 主要产品线也没有闲着,例如 ASP.NET MVC 作为 ASP.NET 的一组

类库,于 2009 年初次发布,最新的版本是 ASP.NET MVC 5.2。Web Service 也在不断进化,

从基于 XML 的 WCF 发展到 RESTful 的 WebAPI。从图中可以看到,最上面的应用层,ASP.NET 茁壮成长,继续和 PHP/JSP 等技术分庭

抗礼,用户有 WebForm 和 MVC 可供选择。在 JS 方面,选择了 jQuery 作为官方 JS。值得

注意的是,MVC 中的 M 由 ADO.NET Entity Framework 负责。Windows 下的解决方案仍然

是 WPF。中间的服务和数据层中,Web 服务拥抱 RESTful,数据库方面也是由 ADO.NET Entity Framework 唱主角。最下面当然就是基础类库(BCL)。在 C# 5 中,增加了对异步编

程的简化。

ASP.NET WebForm MVC 4HTML5 JS CSSWPF Silverlight

WCF/WebAPIWebSocket ADO.NET Entity Framework

BCL

C#5 with async/awaitDLR

图 1-2 .NET 4.5 框架的生态系统(部分)

Page 5: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   5

2016 年:.NET Core 的问世.NET Core 是 .NET Framework 的新一代版本,也是另外一种实现方式,是微软开发的

第一个跨平台的(Windows、Mac OSX、Linux)、开源的、模块化的应用程序开发框架。.NET Core 并不算是 .NET Framework 的继任者,而更像是它的兄弟,所以,现在它们两个各有自

己的版本编号。未来 .NET Framework 和 .NET Core 也将会是各自发展,但它们也会同时使

用彼此的功能。

.NET Core 和 .NET Framework 共用一部分底层功

能(例如一些 BCL),参见图 1-3。与 .NET Framework不同的是,.NET Core 采用组件化的管理方式,应用

程序只需要通过 nuget 获取需要的组件即可,与 .NET Framework 一上来就全部安装的做法不同。

.NET Core 的 应 用 层 包 括 UWP, 用 于 开 发

Windows 商 店 应 用(部 署 到 任 何 支 持 Win10 的 设 备

上, 例 如 XBox, 智 能 手 机 甚 至 眼 镜), 和 ASP.NET Core,用于开发网站应用(通常展现为微服务的形

式)。中间则是 CoreFX,它是 .NET Core 的基础类库,其作用类似于 .NET Framework 的基

础类库 BCL。底层则实现两种运行时,Core RT 和 Core CLR。Core RT 将 C# 或 VB.NET 代

码直接转换为机器码运行在宿主机器上,在不同的平台上,会使用不同的技术(Windows上使用的是 .NET Native,Mac OS 与 Linux 上使用的是 LLILC)。而 Core CLR 就是 .NET Framework CLR 的移植,它包括一个全新的 JIT 编译器—RyuJIT,依据微软的测试报告,

该 JIT 的性能比旧版本提升了约 25%。

由于 .NET Core 更佳的跨平台表现,越来越多的网络应用使用 ASP.NET Core 来编写。

1.3 .NET 框架的主要成员

1.3.1 两步编译与跨平台

任何编程语言要被计算机理解,都需要转化成机器码才行,而转化的过程分为两种主

要的形式,即编译和解释。编译(compile)是将一种语言转换为另一种语言的动作,而一般

来说,“另一种语言”通常是机器码(machine code/native code),即全部由 0 和 1 组成的语

言,或者某种公共语言,当然机器码可以看作所有语言的目标公共语言。使用编译器读入一

个文件,将会产出一个由目标语言写成的另一个文件,例如,C# 编译器会产出扩展名为 exe或 dll 的文件,目标语言是 IL。

而相对的,解释(interpret)虽然也会将代码翻译为机器码,但是,却不会产生目标文

件。因此,每次运行解释型语言时,都需要重新进行解释。不太准确地讲,编译可以类比为

做好一桌菜才开吃,而解释可以类比为吃火锅,边涮边吃。

ASP.NET WPF

.NET Framework

ASP.NET Core UWP

.NET Core

BCL CoreFX

CLR Core RT Core CLR

图 1-3 .NET Core 和已存在的 .NET

Framework 的关系

Page 6: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

6   第一部分 基 础 知 识

.NET 框架语言和 C、C++ 都是编译型语言,而它们之间还有一点不同。.NET 框架语言

是通过两步编译变成机器码的,参见图 1-4。第一步编译使用 .NET 自带的编译器(例如 C#会使用 csc.exe)并生成 IL 语言,它是一个基于栈的、面向对象的高级语言。显然,高级语

言是无法被机器直接执行的,在运行时(runtime),CLR 使用 JIT 来将 IL 转换为机器码,所

以,CLR 是 .NET Framework 最重要的组件,

没有之一。Java 和 C# 类似,它的中间层叫

做字节码(bytecode)。而 C 语言则是一步到

位的,没有中间 IL 这一层。

在第二步将 IL 或字节码转化为机器码

时,会使用对应机器的解释器来执行。在解

释器执行时,会采用即时编译的形式(一句

句编译源代码,但是会将翻译过的代码缓存

起来以降低性能损耗),因此,它的性能比普

通的解释型语言(没有缓存功能)要快。不

过,运行完毕,程序退出之后,缓存的内容

就消失了,每次运行时都需要重新编译。

跨平台最常见和最令人熟悉的定义是“一次编译,到处运行”。这其中,一次编译指

的就是第一步编译(转化为中间语言)。C 语言不能跨平台,是因为它的编译目标是机器

码(本地代码),而不同操作系统和处理器的机器码是不同的。C#/Java 采用了一个中间层来

解决这个问题。C# 的编译器 csc.exe 将 C# 代码编译为 IL,而不是机器码。拿到 IL 之后,

通过在特定平台的运行时程序(CLR 和 JRE),解释和编译 IL(即时编译 JIT)为机器码来 执行。

而这么做的好处则是,仅需要较少的编译器就可以完成跨平台功能。例如,图 1-4 中,

为了使三个语言支持三种机器,需要六个编译器,例如,C# 编译器和 x86JIT 编译器等。如

果撤掉 IL 层,则需要九种编译器才行,因为每个语言都需要三个编译器:3×3=9。

1.3.2 CLR

CLR 是 .NET 平台的核心,没有之一。组件对象模型(Component Object Mode,COM)

是 CLR 的前身,它将 Windows 平台上运行的所有成员标准化,并定义了它们之间沟通的

规范,使得不同编程语言的进程间通信和动态对象创建成为可能。所有 COM 组件都实现了

IUnknown 接口,采用引用计数管理对象的生命周期。

当微软开始 .NET 框架的开发时,他们最终决定改进现有的 COM,并将它命名为公

共语言运行时(Common Language Runtime,CLR)。CLR 中组件间的沟通规范由元数据

(metadata)负责,因此,每个可迁移执行文件(即 .exe 文件)都包含了元数据,以便 CLR知道文件内部的类型是什么。所以,有人称“CLR 是一个更好的 COM”。

CLR 是让 .NET 程序执行所需的外部服务的集合,.NET 平台的核心和最重要的组件,

VB.NET

IL

C#

x86 x64

F#

csc.exe fsc.exevbc.exe

JIT

图 1-4 .NET 平台的两步编译

Page 7: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   7

类似于 Java 的 JVM。它有如下的核心功能:

1)在程序的第一步编译时,将程序通过对应的编译器编译为 IL,并生成元数据。将 IL和元数据打包,加入 PE 和 CLR 文件头使之成为托管模块,将各个模块打包为程序集(不

过,语言编译器不被看作 CLR 的一部分)。

2)程序执行时,类型检查器负责保证类型安全(代码验证),即时编译器(JIT)负责将

IL 编译为机器码。

3)类型加载器(Class loader)负责通过清单和元数据探测类型的位置,并加载需要的

类型,生成类型对象,包括方法表等。

4)方法调用。

5)自动内存管理和垃圾回收(COM 使用引用计数,而 CLR 使用标记清除)。

6)线程池管理。

7)字符串驻留池管理。

8)异常处理。

9)动态加载程序集(晚期绑定),这是和类型加载器交互的必然结果。

10)与 COM 组件的交互。

可以看到,绝大部分功能都是在程序运行时保证程序的顺利执行的。因此,CLR 管

理 .NET 程序集的执行,运行于操作系统之上。

CLR 的一些功能仅仅是操作系统的一个再包装,例如线程,内存管理等,这些实际上

是进一步调用操作系统 API,访问内核对象。但 JIT 则是它独有的,如果没有它,就不能把

IL 变成机器码,计算机也就不认识 C#,你也就不能运行 C# 程序。对 CLR 了解越多,对整

个 .NET 的把握就越好。

托管代码是必须在 CLR 下执行的代码,而非托管代码则不需要 CLR 的支持就可以运

行。CLR 本身用于管理托管代码,因此它是由非托管代码编写的,而并不是一个包含了托

管代码的程序集,也不能使用基于 IL 的反编译工具进行查看。它位于 C:\%SystemRoot%\Microsoft.NET\Framework\【版本号】下,对应不同的机器有两个版本,一个是工作站版

本的 mscorwks.dll,一个是服务器版本的 mscorsvr.dll。wks 和 svr 分别代表 workstation 和

server。CLR 是 CLI 的一个实现。如果你想深入了解 CLR 的代码和实现方式,你可以读一读《The

Book of the Runtime》,它的链接是 https://github.com/dotnet/coreclr/blob/master/Documentation/ botr/README.md。在这个 Github 上还有 Core CLR 的全部源代码。

1.3.3 CLI

CLI(Common Language Infrastructure,公共语言基础设施)是一个规范,类似于产

品经理撰写的产品说明书。在微软开始 .NET 框架的搭建时,曾向 ECMA 标准化组织提交

了 CLI 的全部内容(https://www.ecma-international.or g/publications/f iles/ECMA-ST/ECMA-335.pdf)。该标准化文件包括以下四部分:

Page 8: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

8   第一部分 基 础 知 识

�� CTS�� IL��底层文件

��元数据格式

而 CLR 是 CLI 的一个实现(implementation),类似于开发者遵循产品说明书所开发出

来的产品。CLI 有很多种实现,除了 .NET 框架之外,还有 Rotor、Mono 和上面提到的 Core CLR 等。

1.3.4 CTS 和 CLS

.NET 框架最主要的两个组成部分是 CLR 和框架类库,而 CLR 最主要的两个组成部

分是 CTS(公共类型系统)和 CLS(公共语言规范)。简单地说,CTS 就是计算机语言的

一种语法和规范。例如,它允许接口、类、结构、枚举这些类型的存在,并且,规定了每

种类型可以或不可以包含什么成员。如果 C# 没了语法,那就没有类,没有接口,变成了 伪码。

1)CTS 是微软制定的一套标准化语法。它规定了一套约束,就像英语规定所有的字

词都是由 26 个字母组成的一样,服从这套语法的语言都可以看成是英语(或英语的某种方

言),例如中古英语、现代英语都是英语,而德语字母并非英语字母,所以它不是英语。同

理,所有服从 CTS 的语言,都可以看成 .NET 框架的语言。

2)CTS 定义了类型,类型指的是集合 { 类、结构、接口、枚举、委托 } 中的任意一个成员。

3).NET 框架的众多语言各自实现了 CTS 的一部分功能。做一个不太恰当的类比,C#可以被认为是“美国英语”,F# 是“英国英语”,而 VB 是“印度英语”。他们是英语的各种

方言。他们共享一部分相同的词汇,但也各有各的特点。例如颜色在英国英语中的拼写是

colour,美国英语则是 color。4).NET 框架的众多语言都只实现 CTS 的一小部分功能,这部分功能称为 CLS

(Common Language Specif ication,公共语言规范)。这是这些语言(的程序集)可以相互使

用的前提。如果你创建一个新语言,但不满足 CLS 的要求,那你的语言就不能被其他 .NET框架的语言(的程序集)使用。

图 1-5 演示了 CTS 和 CLS 的关系。如果你想实现一个 .NET 框架语言,你最少必须实

现 CLS 中的功能。

1.3.5 框架类库(FCL)

.NET Framework 提供了丰富的框架类库,使

得我们不需要再编写代码处理大量的日常操作。

BCL 是 FCL 的一个子集。简单来说,FCL 就是

我们用到的所有外部参考,而 BCL 是其中不需要

手动引用的那部分。

CLSC#

CTS

VB.NET

图 1-5 CTS 和 CLS 的关系

Page 9: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   9

1.3.6 基础类库(BCL)

基础类库是微软所提出的一组标准库,可提供给 .NET Framework 所有语言使用。随着

Windows 以及 .NET Framework 的成长,BCL 已近乎成为在 .NET 上的 Windows API。当安装 .NET Framework 时,所有的基础类库都部署到全局程序集缓存(GAC)中,所

以不需要在你的工程中手动引用任何的基础类库,它们会被自动引用。

部分 mscorlib.dll 包括的命名空间如下:

�� System :.NET Framework 类 库 中 最 基 底 的 服 务, 提 供 应 用 程 序 域(Application Domain),数据类型,I/O 以及其他类库的基础。

�� System.Collections : 提 供 非 泛 型 数 据 结 构 以 及 集 合 对 象 的 支 持, 其 中 System.Collections.Generic 中包括所有的泛型数据结构。

�� System.Data:ADO.NET 的组成类库,为数据访问功能的核心功能。

�� System.IO:提供数据流与文件读写的支持。

�� System.Net:.NET 中的网络功能。

�� System.Ref lection:反射。

�� System.Diagnostics:.NET 中提供系统诊断、除错、追踪与运行外部进程的能力。

�� System.Text:对文字、编码以及正则表达式的支持。

�� System.Threading:线程控制。

�� System.Web :ASP.NET 的 组 成 类 库, 令 工 程 可 以 和 IIS 服 务 器 交 互,XML Web Service 开发的基本支持也由本类别提供。ASP.NET Core 中消失(如果你不打算用

IIS 做服务器的容器,则你不需要这个类库)。

�� System.Xml:XML 解析器。

�� System.Linq,System.Xml.Linq:LINQ 的核心类库,System.Linq 是 LINQ to Object,而 System.Xml.Linq 则是 LINQ to XML。

然而在 C:\Program F iles(x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\ v4.0\,我们还有一个 System.dll,这个参考是每次新建工程时 VS 自动引用的若干参考之一。这

个程序集中也有一个 System 命名空间,它的内容和 mscorlib.dll 中的不同。可以看到,System这个命名空间存在于不止一个程序集中。这意味着不同的程序集可以共享一个命名空间。

在 System.dll 中,System 类型拥有 Uri 这个成员,mscorlib.dll 中的 System 类型则拥有

int 这个成员(基元类型)。所以我们可以做个试验,如果我们将工程中对 System 的引用去

掉,那么我们就不能定义一个 Uri 类型的对象。但我们仍然可以使用 int 类型,因为它虽然

也在 System 这个类型里面,但位于 mscorlib.dll 中。当你去掉对 System 的引用时,你仅仅

去掉了 System.dll 和里面的功能,但你没有去掉 mscorlib.dll 中 System 类型的功能。

BCL 是属于整个 .NET 框架的,并非某种语言的一个基础类库。例如,C# 中 string 类型

的所有功能和定义来源于 mscorlib.dll 中的 System.String,而 VB 中 string 类型的功能和定义

也来源于相同的地方,所以 String 和 string 两个关键字没有任何区别,类似的还有 Int32 和

Page 10: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

10   第一部分 基 础 知 识

int。基础类库中定义的类型称为基元(primitive)类型,由 .NET 框架所有的语言共享。

1.4 程序集

在写完代码之后进行生成(build)时,CLR 将 .NET 应用程序打包为由模块(module)组成的程序集(assembly)。一个程序集由一或多个托管模块组成,程序代码被编译为 IL 代

码,存在于托管模块之中。程序集是一个可以寄宿于 CLR 中的、拥有版本号的、自解释、

可配置的二进制文件,程序集的扩展名为 exe 或 dll。程序集中的代码可以被其他程序集中的 C# 代码调用,例如几乎所有的 C# 代码都会用

到 mscorlib.dll 这个程序集中的对象。程序集是自解释的,因为它记录了它需要访问的其他

程序集(在清单中)。另外,元数据描述了程序集内部使用的类型的所有信息,包括类型的

成员和构造函数等。程序集可以私有或共享的方式配置,如果以共享方式进行配置,则同一

台机器的所有应用程序都可以使用它。

程序集也可以手动进行生成,这需要选择对应语言的编译器。C# 的编译器是 csc.exe。

可以通过 /t 参数指定编译目标,最常见的几个为:

�� /t:library:目标为一个 dll 文件(需要指定托管模块)。

�� /t:exe:目标为一个可执行 exe 文件,必须指定入口点。

�� /t:module:目标为一个托管模块。

其中,前两个目标的结果文件都是程序集,而最后一个目标的结果文件是托管模块。

1.4.1 反向工程—使用 ILSpy 观察 IL

ILSpy 是一个免费的、开源的反编译工具,可以将程序集反编译为 C# 代码。下载地址

为:http://ilspy.net(也可以在附带资源中的工具文件夹中找到它),和 .NET 自带的 ildasm 相

比,它有一个很大的优点,就是在打开文件之后,其他的程序仍然可以修改它。使用 ildasm打开一个工程的 .dll 之后,你在 Visual Studio 就无法再生成它了(Visual Studio 会提示有其

他程序正在使用),但 ILSpy 还不支持 C# 的较新版本的反编译(不过,它正在与时俱进)。

在本书随后的所有章节中,我们会交替使用 ILSpy 或 ildasm 来探测 C# 代码内部的细节。

我们平常将 C# 代码通过编译器转换为 IL 的动作叫做正向工程;而反过来,将程序集文件内部

的 IL 和元数据重新组合并转换为 C# 代码的动作就叫反向工程(reverse engineering)。ILSpy 还

允许在反向工程的同时,对某些语法关键

字不进行反编译,这将会生成一个较长的

C# 代码,但可以让你更加清楚地了解编译

器幕后所做的工作(本书默认全部选择),

如图 1-6 所示。

图 1-6 的选项中,第一个选项为是否

反编译匿名方法。如果不选择该项,就不 图 1-6 ILSpy 的选项

Page 11: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   11

会对匿名方法进行反编译(它会保持原来的样子),这样生成的代码和本来的代码不同(更低

级一些)。另外,我们在得到反编译的 C# 代码之后,还可以将这个代码再次编译为 IL,这

相当于打了一个转,这种可以在 C# 和 IL 中来回切换的行为,称为正反向工程(round-trip engineering)。图 1-7 简单显示了各种代码和编译器之间的关系。

VB.NETC#IL F#

IL

csc.exe

ilasm.exe

fsc.exevbc.exe

Ildasm,ILSpy

x86 x64

JIT

图 1-7 反向工程

对于非开源软件公司(例如,银行系统)的开发者来说,他们的代码是不能外泄的。但

别有用心的人一旦获得了 .dll 或 .exe 文件,可以轻松地通过反编译工具还原出代码原本的面

貌。这种担心一般通过两种方式解决,一是将程序集放在服务器上,从而使得无人可以获得

原始的程序集,另外就是使用混淆器工具打乱程序集的元数据。对这方面有兴趣的读者,可

以阅读《微软 .NET 程序的加密与解密》这本书。

1.4.2 程序集与托管模块

假 设 我 们 现 在 建 立 一 个 新 的 控 制 台 应 用(.NET Framework, 除 非 特 别 注 明, 本 书

不会选择 .NET Core),然后在 Program.cs 中键入如下的代码(也可以在本章对应代码的

ConsoleApp1 中找到代码):

class Program

{

static void Main(string[] args)

{

var a = new A();

var b = new B();

Console.ReadKey();

}

}

新建一个文件,命名为 A.cs,并键入如下代码:

public class A

{

public A()

Page 12: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

12   第一部分 基 础 知 识

{

Console.WriteLine("A 被创建 ");

}

}

之后我们再新建一个文件,命名为 B.cs,并键入如下代码:

public class B

{

public B()

{

Console.WriteLine("B 被创建 ");

}

}

此时的程序应该可以通过编译了,但我们不进行编译,并使用 VS 2017 的开发人员命

令提示符(不能是普通的命令提示符)定位到工程文件夹。如果你的工程文件夹中已经存在

bin 和 obj 文件夹,可以把它们删除。

1.4.2.1 编译托管模块现在我们将 A 和 B 两个类编译为托管模块(managed module):

csc /t:module A.cs

csc /t:module B.cs

编译成功之后,工程文件夹会多出 A.netmodule 和 B.netmodule 两个文件。我们使用

ILSpy 工具打开 A.netmodule 瞧瞧(需要在打开文件时选择所有文件),如图 1-8 所示。

图 1-8 托管模块的结构

我们会发现,托管模块麻雀虽小但五脏俱全,包括 IL(右边的代码)和元数据。

1.4.2.2 托管模块的结构托管模块的结构比较复杂,主要的几部分为 Windows PE 文件头、CLR 文件头、IL 代

码和元数据。

1. Windows PE 文件头拥有 Windows PE 文件头的文件可以被 Windows 操作系统加载和操作。显然,如果打

算在 Windows 下运行该程序集,则必须要有 Windows PE 文件头。Windows PE 文件头有

PE32 和 PE32+ 两种格式,拥有后者作为文件头的文件只能在 64 位 Windows 上运行,拥有

Page 13: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   13

前者作为文件头的文件则能在 32 或 64 位 Windows 上运行。通过使用下面的命令来查看托

管模块的 Windows PE 文件头:

dumpbin/headers a.netmodule

获得的文件头如下(以下只显示了一部分):

Dump of file a.netmodule

PE signature found

File Type: DLL

FILE HEADER VALUES

14C machine (x86)

2 number of sections

5A123E1D time date stamp Mon Nov 20 10:29:49 2017

0 file pointer to symbol table

0 number of symbols

E0 size of optional header

2102 characteristics

Executable

32 bit word machine

DLL

OPTIONAL HEADER VALUES

10B magic # (PE32)

11.00 linker version

400 size of code

200 size of initialized data

0 size of uninitialized data

22CE entry point (100022CE)

2000 base of code

4000 base of data

10000000 image base (10000000 to 10005FFF)

2000 section alignment

200 file alignment

4.00 operating system version

0.00 image version

4.00 subsystem version

0 Win32 version

6000 size of image

200 size of headers

0 checksum

3 subsystem (Windows CUI)

...

一般来说,无需深究这些内容的含义。

2. CLR 文件头程序集中的 CLR 文件头使得文件可以寄宿于 CLR。CLR 文件头告诉操作系统这个文件

是一个 .NET 程序集,区别于其他类型的可执行程序。通过使用下面的命令来查看托管模块

的 CLR 文件头:

dumpbin/clrheader a.netmodule

Page 14: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

14   第一部分 基 础 知 识

获得的文件头如下:

Dump of file a.netmodule

File Type: DLL

clr Header:

48 cb

2.05 runtime version

2068 [ 218] RVA [size] of MetaData Directory

1 flags

IL Only

0 entry point token

0 [ 0] RVA [size] of Resources Directory

0 [ 0] RVA [size] of StrongNameSignature Directory

0 [ 0] RVA [size] of CodeManagerTable Directory

0 [ 0] RVA [size] of VTableFixups Directory

0 [ 0] RVA [size] of ExportAddressTableJumps Directory

0 [ 0] RVA [size] of ManagedNativeHeader Directory

Summary

2000 .reloc

2000 .text

通常 .NET 开发者不需要去关心上面内容的细节,下面的部分才是托管模块以及程序集

的重点所在。

3. IL 代码这是将 C# 通过 csc 编译器编译成的中间语言代码。IL 代码也称为托管代码,因为它是

由 CLR 生成的,也会由 CLR 进行管理。中间语言代码在运行时还会被 JIT 再次转换为高级

码(面向特定 CPU 架构,例如 x86、x64 等)。

正如前面所说的,IL 提供了 CLR 所有的功能,可以将 IL 视为一个面向对象的机器

语言。

4. 元数据托管模块的元数据记录了模块中的类型及其成员。它的用途非常广泛,例如,它是反

射的基石、IDE 的智能感知、序列化和反序列化也靠它来实现。通过反射可以访问元数据,

而通过特性,你可以自定义自己想要的元数据。

1.4.2.3 程序集的结构

虽然程序存在于模块中,但仅仅有模块还不够。CLR 是和程序集一起工作的,而不是

和模块一起工作。程序集是一个或多个托管模块的集合,并且作为版本控制、代码重用以及

部署的最小单元。

既然程序集本质上是一组托管模块,它当然也具有托管模块的所有成分,例如文件头、

IL 和元数据。值得注意的是,程序集还包括一个清单(manifest),令 CLR 知道该程序集所

Page 15: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   15

有托管模块的信息,以及它所需要的外部程序集的信息。它还描述了程序集本身的信息,例

如程序集标识等(名称、版本、文化),这些都是托管模块所不具备的。这个清单也位于程

序集的某个模块之中,而这个模块就称为主模块。在程序运行时,主模块先加载,然后,根

据清单就可以找到其他模块以及其他外部程序集的位置(这个过程称为探测)。

除了清单之外,程序集还包括若干资源文件,例如图片文件等。在加载程序集中,清

单的作用是至关重要的,它能帮助 CLR 定位所有需要的资源。

一个程序集包括一或多个托管模块。两个程序集共同引用一个托管模块的可能性也是

存在的,此时,CLR 将这个公共模块视为两个不同的模块。不过这种情况非常少见。

可以用下面的例子来理解程序集和托管模块:如果程序集是我们平时吃的菜,例如宫

保鸡丁,那么托管模块就是里面所有的成分,例如鸡肉、花生、大葱,甚至盐和酱油。我们

(CLR)是不会直接吃鸡肉和大葱(托管模块)的,而是将宫保鸡丁(程序集)整个看成是一

道菜,也就是基本单元。宫保鸡丁的清单描述了它的做法以及在该菜中,各个部件都是什

么,去哪里找,而部件本身就不需要清单了。但部件仍然可以储存一些元数据,例如,酱油

可以储存它的牌子、出厂日期和过期时间(托管模块的元数据)。不同地方的宫保鸡丁做法

也不同,有时我们还会改进它的做法,这正是程序集的版本和文化所体现的。我们(CLR)

是吃宫保鸡丁这道菜,而不是吃它的配料。多个程序集便组成了一个应用程序,例如我们的

一顿饭通常包括几道菜。当然,一个应用程序也可以只有一个程序集。

程序集是可配置的:可以将其配置为私有或共享(全局程序集缓存,GAC)。当你在

一个类库中引用其他程序集(通过 Add References)时,系统将该程序集的 .dll 文件拷贝到

类库的子目录 bin\Debug 或 Release 下(这就是私有配置)。注意 Add References 不会显示

GAC 中的程序集。全局的程序集不需要 Add References,IDE 自动添加。

1. 托管模块和程序集的关系一个程序集可以包含一或多个托管模块。因此,单模块程序集的结构就是它的托管模

块加上清单和资源文件,这使得程序集变成自解释的。而多模块程序集,需要指定一个主模

块,程序集的清单将会放入主模块中,而资源文件将视其调用模块放在对应的模块中。

Visual Studio 只能生成单模块程序集,因此,无论是使用 Debug 还是 Release 模式,都

不会得到 .module 文件。如果要生成多模块程序集,只能按照本书所描述的命令行模式,调

用 csc.exe 加上合适的参数。不过,很少有人用这种方式。

2. 合并为程序集现在我们将刚刚得到的两个托管模块合并为一个程序集 Classes.dll。注意,在 out 和

addmodule 之后的冒号和文件名之间不能有空格:

csc /out:Classes.dll /t:library /addmodule:A.netmodule;B.netmodule

现在,工程文件夹将会多一个 Classes.dll 文件。通过 ILSpy 工具,我们可以打开程序

集 Classes.dll,看到其中包括了我们之前编译的两个托管模块,还有外部参考 mscorlib.dll,因为我们的代码中使用了 Console 静态类,它的定义在 mscorlib.dll 之中,参见图 1-9。所

Page 16: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

16   第一部分 基 础 知 识

以,我们也验证了程序集可以包含多个托管模块这一事实。

图 1-9 程序集的结构

因此,我们的程序集结构为:

1)Classes.dll(主模块),包括 PE 文件头、CLR 文件头和清单(显示在图 1-9 的右边)。

2)A 和 B 两个托管模块,包括 PE 文件头、CLR 文件头、IL 代码和元数据。

在任何时候,IL 代码和元数据都是存在于托管模块中的。

3. 编译可执行文件如果我们的工程是类库的话,那么我们的编译到这里就结束了。但是,我们的工程

是控制台工程,所以可以通过指明程序的入口点和程序集,来编译可执行文件。这里的

Classes.dll 文件代表的是程序集,如果这个程序集由多个托管模块组成,必须保证托管模块

文件都存在,否则编译会失败。Program.cs 则代表程序的入口点:

csc /out:Program.exe /R:Classes.dll Program.cs

我们执行 .exe 文件,会得到预期的结果:

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 \ConsoleApp1>program

A 被创建B 被创建

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 \ConsoleApp1>

通常,我们把扩展名为 .dll 或 .exe 的文件称为主模块,扩展名为 netmodule 的文件称为

普通模块,程序集则包括主模块和普通模块。一般情况下,我们看不到扩展名为 netmodule的文件,这是因为在 Visual Studio 默认生成的 .dll 中,已经将各个普通模块包含进主模

块中,所以除了 .dll 并没有其他的模块文件。我们已经习惯了直接将主模块看成程序集 本身。

在使用 Visual Studio 进行编译时,将执行上述所有过程,包括:

Page 17: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   17

��将所有 C# 源代码编译为托管模块(编译结果保存在 obj 目录中)。

��将所有托管模块合并为程序集(Visual Studio 只能生成单模块程序集),输出 .dll或 .exe 文件(结果保存在 bin 目录中),然后,obj 目录中的托管模块就被删除。

4. 程序集清单我们使用 ildasm 打开生成的 program.exe 文件,

并双击清单(M A N I F E S T),如图 1-10 所示。

可以看到,清单的头部包括当前程序集运行

所 必 需 的 所 有 外 部 程 序 集(本 例 子 中 就 只 有 一 个

mscorlib.dll):

.assembly extern mscorlib

{

.publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) // .z\V.4.. .ver 4:0:0:0

}

每一个被 extern 修饰的 .assembly 都是外部程序集。.publickeytoken 意味着该程序集是

强名称的程序集,而 .ver 则标注了程序集的版本。

清单中的 .assembly[ 当前程序集名称 ] 部分包括一些修饰当前程序集的特性(以下显示

了一部分):

可以通过 Properties 中的 AssemblyInfo.cs,修改程序集的版本、文化等(图 1-11 显示了

一部分),该文件由 Visual Studio 维护。

图 1-11 AssemblyInfo.cs 的一部分内容

图 1-10  使用 ildasm 打开生成的

program.exe 文件

Page 18: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

18   第一部分 基 础 知 识

也可以通过使用项目的属性编辑器进行可视化的修改。单击图中工具按钮之后,选择

“应用程序”中的“程序集信息”即可,如图 1-12 所示。

图 1-12 属性编辑器

1.4.2.4 为什么很少使用多模块程序集多模块程序集在生成为 .dll 或 .exe 时,所有需要的 .netmodule 文件都必须存在,但在

运行时,如果某个托管模块中的代码没有被使用过,则该文件可以不存在。例如,在上面的

例子中,如果我们的主程序没有初始化 B 类型的对象:

class Program

{

static void Main(string[] args)

{

var a = new A();

var b = new B();

Console.ReadKey();

}

}

那么即使在生成程序集之后,删除 B.module,程序也可以运行。因此,如果将不常用

的代码存入一个单独的托管模块,那么就可以减少程序集的加载时间。

但是,由于 CLR 本来就是在程序需要资源时才会加载对应的程序集,因此,单模块程

序集完全可以达到上面所说的效果。

1.4.3 程序集的部署

正如上面所说的,程序集是部署的最小单元,不能部署托管模块。

1.4.3.1 部署为私有程序集通常我们使用的都是这种方式。将程序集部署为私有的意味着必须将程序集放置在应

用程序所在目录下(这里的目录指的是编译之后程序集所在的位置,例如 \bin\debug 下面)。

比如,我们刚才编译的 Classes.dll 可以被其他工程引用,引用之后,Visual Studio 就会将

Classes.dll 复制到该工程的应用程序目录下。

CLR 使用探测(probing)的技术来解析所有需要的外部程序集的位置。首先,它判断

程序集是否是强名称的,如果是的话就去 GAC 查找;否则不去。然后,如果它没能在应用

程序所在目录下以及额外的地方(下面的内容会讨论)找到程序集,就会尝试去查找具有相

同友好名称的可执行程序集(例如,a.dll 的具有相同友好名称的可执行程序集为 a.exe),如

果还是没有找到,就会引发运行时异常。如果(不管是哪一步)找到了,就加载这个程序

Page 19: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   19

集。加载可以是隐式的(早期绑定,手动增加引用之后生成项目,外部程序集记录到清单

中),也可以是显式的(晚期绑定,运行时动态加载)。

如果你的项目拥有很多私有程序集,你可以在程序目录下建立子目录,然后指示 C# 到

一些额外的地方去寻找程序集。这可通过编辑 App.conf ig 文件来实现:

<configuration>

<runtime>

<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">

<probing privatePath="SubFolder1;SubFolder2;SubFolder3"/>

</assemblyBinding>

</runtime>

</configuration>

上 面 的 XML 中,probing 元 素 的 privatePath 可 以 含 有 多 个 文 件 夹, 名 称 用 分 号 分

隔。注意,这些文件夹必须位于应用程序所在目录内部,而不能位于其他地方。也不能在

privatePath 中指定某个绝对路径或相对路径。另外,XML 文件区分大小写。

我们也不能为某个(或某类)程序集指定其所在的位置。探测过程是针对所有外部程序

集的,不能分类。如果你指定了若干个文件夹,探测过程将会一一进行搜索,直到找到第一

个匹配的程序集为止。

1.4.3.2 共享程序集几乎所有的 C# 程序都需要 mscorlib.dll 的支持,但是我们似乎对这个 .dll 文件非常陌

生。你在应用程序的目录下找不到这个文件,这是因为它是一个共享程序集,被机器上所有

的程序共享。共享程序集位于机器的全局程序集缓存(Global Assembly Cache,GAC)中,

它是一个机器级别的程序集,其中包括 mscorlib.dll 等至关重要的程序集。在添加引用时,

它不会被自动包括进来,必须手动浏览才可以找到部署到 GAC 中的程序集。如果你打算将

类库部署到 GAC,一般来说,这个库应当被大量其他工程引用。

GAC 存在于两个地方,第一个地方是 Windows 目录下的 assembly 子目录。第二个

地方则存储了 .NET 4 和更高版本的库,位于 Windows 目录下的 Microsoft.NET\assembly\GAC_MSIL。

不能把可执行的程序集部署到 GAC。在部署到 GAC 之前,程序集必须是强名称的。

Visual Studio 不会将强名称程序集拷贝到应用程序的目录下,因为它假定强名称程

序集已经被部署到 GAC。

1.4.3.3 将程序集部署到 GAC将程序集部署到 GAC 之前必须将其变成强名称的。强名称给予程序集一个独一无二的

标识,它包括程序集的名称、版本号、文化和数字签名等。

可以使用 Visual Studio 为程序集添加强名称。定位到工程的属性页面,选择签名,如

图 1-13 所示。

注意

Page 20: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

20   第一部分 基 础 知 识

图 1-13 选择签名

然后,可以为强名称密钥文件提供一个名称(扩展名必须是 snk),还可以选择是否用密

码来保护文件,如图 1-14 所示。

在创建完毕之后,就可以在解决方案资源管理器中发现 snk 文件。每次生成时,该文件

会给程序集分配一个强名称。接下来,就可以

将该程序集部署到 GAC 了。部署的方法十分

简单,就是使用 gacutil 工具。首先,定位到程

序集所在的目录。然后,使用 -i 参数安装程

序集:

gatutil –i Program.exe

注 意, 这 需 要 管 理 员 权 限。gacutil 工 具

的 -l 参数会列出程序集的列表(很多!),-u 则

允许你从 GAC 中删除一个程序集。当配置好

程序集之后,在其他工程中便可以引用该程序

集的功能。但是,该程序集不会被自动拷贝到

应用程序的目录下。

本部分对程序集部署到 GAC 的讲解比较粗浅,因为一般来说较少会遇到将程序集部

署到 GAC 的情景。读者想了解更多的话,可以参考《 CLR via C#》第三章,以及

《 .NET 之美 .NET 关键技术深入解析》第七章。

1.5 .NET 程序的编译:IL 与 JIT

所有 .NET 支持的语言编写出来的程序,在对应的编译器编译之后,会先产出程序集,其

图 1-14 密钥文件

提示

Page 21: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   21

主要内容是中间语言 IL 和元数据。之后,JIT 再将 IL 翻译为机器码(不同机器实现方式不同)。

IL 使得跨平台成为可能,并且统一了各个框架语言编译之后的形式,使得框架实现

的代价大大降低了。比如,.NET 框架有 N 种语言,那么每种语言都必须有自己的编译器。

而 .NET 框架又决定跨 M 种平台,那么,就需要有 M 种 JIT。

如果不存在 IL,则 .NET 框架为了支持 N 种语言跨 M 种平台,需要 M×N 个编译器。

但如果所有 .NET 框架的 N 种语言经过编译之后,都变成相同的形式,那么只需要 M+N 个

编译器就可以了。因此,IL 大大降低了跨平台的代价。

1.5.1 什么是 IL(CIL)

在 .NET 的开发过程中,IL 的官方术语是 MSIL 或 CIL(Common Intermediate Language,

公共中间语言)。因此,IL、MSIL 和 CIL 指的是同一种东西,本书统一使用 IL 进行指代。

使用不同语言(例如 C# 和 VB)经过不同编译器(例如 C# 编译器和 VB 编译器),编译一段

功能相似的代码(区别仅仅在于语法),其 IL 也基本相似。

可以通过反编译工具加载任意的 .NET 程序集并分析它的内容,包括它所包含的 IL 代

码和元数据,以及反编译之后的 C# 代码。在 C# 没有开源之前,这项技能是开发者进阶的

必备技能,这是因为有些性质是必须通过查看 IL 或反编译才能得知的,例如装箱和拆箱发

生了多少次(box 是 IL 指令),using 的本质实际上是一个 try-f inally 块,闭包和委托涉及密

封类、迭代器和状态机等等。另外,也可以自己书写 IL 代码,然后使用 .NET 自带的 ilasm.exe 编译为程序集。

1.5.2 初识 IL

IL 虽然比 C# 低级一些,但它实际上也拥有很多助记符和指令,这些指令使得 IL 的可

读性没有想象中那么差。IL 中的关键字可以分为三类:指令、特性和操作码。

IL 指令在语法上使用一个点前缀来表示。在 ILSpy 中,IL 指令是绿色的。这些指令用

来描述代码文件的结构,包括 .namespace、.class、.method、.f ield、.property 等等。例如,

如果你的代码文件包括了三个 .class,这意味着 C# 源代码包含三个类型。下面的 IL 代码中

包括四个字段和一个方法:

IL 特性和 IL 指令一起修饰成员。例如,一个 .class 可以被 public 修饰,指定它的可见

性,也可以被 extend 修饰,指定它的父类,也可以被 static 或 instance 修饰指定它是静态还

Page 22: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

22   第一部分 基 础 知 识

是实例成员等等。

IL 操作码提供了可以在 IL 上实现的各种操作。真正的操作码是无法一眼理解的二进制

数据(例如相加的操作码是 0x58),但是,每个操作码都对应一个助记符(例如相加的助记

符是 add),助记符的长度设计得较短,这使它们有时会让人难以理解,例如创建一个新的字

符串,需要使用 ldstr 助记符。

1.5.2.1 IL 以栈为基础IL 实际上是完全以栈为基础的。IL 提供了将变量压入虚拟执行栈中(称为加载,这会

使栈的成员增加 1)的操作码,然后,也提供了将栈顶的值拿出来移动到内存中(称为存

储,这会使栈的成员减少 1)的操作码。

初始化局部变量时,必须将它加载入栈,然后再弹出来赋给本地变量。因此,初始化

完局部变量之后,栈应当是空的。当使用局部变量时,必须将其从栈顶弹出,不能直接访

问。加载的助记符中,最常见的是 ldloc/ldc/ldstr,存储的助记符中,最常见的一个是 stloc。

IL 助记符的列表可以参考 http://www.cnblogs.com/zery/p/3368460.html。我们先看一个非常简单的例子:

class Program

{

static void Main(string[] args)

{

int i = 999;

int j = 888;

Console.WriteLine(i+j);

}

}

该段代码对应的未被优化的 IL 代码(存在很多 nop 指令,它是空指令):

.class private auto ansi beforefieldinit AssemblyLab.Program extends [mscorlib]System.Object{

// Methods .method private hidebysig static void Main ( string[] args ) cil managed {

// Method begins at RVA 0x207c // Code size 15 (0xf) .maxstack 2 .entrypoint .locals init ( [0] int32 i, [1] int32 j )

IL_0000: nop

IL_0001: ldc.i4 999

IL_0006: stloc.0

Page 23: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   23

IL_0007: ldc.i4 888

IL_000c: stloc.1

IL_000d: ldloc.0

IL_000e: ldloc.1

IL_000f: add

IL_0010: call void [mscorlib]System.Console::WriteLine(int32) IL_0015: nop

IL_0016: ret

} // end of method Program::Main

.method public hidebysig specialname rtspecialname instance void .ctor () cil managed {

// Method begins at RVA 0x2097 // Code size 7 (0x7) .maxstack 8

IL_0000: ldarg.0

IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: ret

} // end of method Program::.ctor

} // end of class AssemblyLab.Program

Main 方法的大部分代码都含有一个助记符。其中,nop 是编译器在 Debug 模式下插入

的方便我们调试设置断点的空操作,所以,这里我们就忽略 nop。首先看看方法的定义:

.method private hidebysig static void Main ( string[] args ) cil managed

IL 指 令 .method 指 出 后 面 的 代 码 为 一 个 方 法。IL 特 性 private 指 出 该 方 法 是 私 有 的

(如果一个方法在 C# 中,没有显式给出可见性关键字,则默认的关键字是 private)。而

hidebysig 的意思是,这个方法会被隐藏,当且仅当其父类存在同名且同签名(相同的输入

和输出参数个数和类型)的方法(hide by name and signature)。后面的 static、void 和 C# 的

意思是一样的。Main 方法接受一个字符串数组作为参数,cil managed 顾名思义是表示该方

法为托管的。

下面的这一段代码中,我们看到了栈的身影:

.maxstack 2

.entrypoint

.locals init ( [0] int32 i, [1] int32 j)

由于代码仅仅有两个变量,因此栈的最大空间为 2。之后,.entrypoint 指令指示编译器,

代码的入口点在此。.local init 指令定义两个 int 类型的变量 i 和 j。使用类之前,如果没有声明构造函数,C# 自动提供一个构造函数,用来调用它的所有

父类的构造函数。Program 类没有显式声明父类那么它的父类就是 System.Object。Program

Page 24: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

24   第一部分 基 础 知 识

的构造函数以 .ctor 作为名称(这是实例构造函数,静态构造函数以 .cctor 作为名称):

.method public hidebysig specialname rtspecialname instance void .ctor () cil managed {

// Method begins at RVA 0x2097 // Code size 7 (0x7) .maxstack 8

IL_0000: ldarg.0

IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: ret

} // end of method Program::.ctor

Program 类 Main 方法的 IL 代码主体如下:

IL_0000: nop

IL_0001: ldc.i4 999

IL_0006: stloc.0

IL_0007: ldc.i4 888

IL_000c: stloc.1

IL_000d: ldloc.0

IL_000e: ldloc.1

IL_000f: add

IL_0010: call void [mscorlib]System.Console::WriteLine(int32)IL_0015: nop

IL_0016: ret

0001 行加载了第一个变量(通过 ldc.i4),

其 中,i4 代 表 int32 类 型, 而 后 面 的 999 则 是

变量的值。0006 行则把刚刚加载的变量从栈中

第 0 个位置弹出,并赋值给第 0 个局部变量 i。0007 和 000c 行与前面的逻辑类似,如图 1-15所示。

之 后,000d 行 使 用 ldloc 将 第 0 个 变 量 加

载进栈,000e 将第 1 个变量加载进栈,000f 将

它们加起来,在加完之后,原本的 i 和 j 将消

失,值为 888+999=1887,将它推入栈。最后,

0010 行使用 call 调用指定的方法。0016 行使用

ret 返回,如图 1-16 所示。

1.5.2.2 编写 IL 代码并运行我们可以通过记事本等文本处理工具编写

IL 代码。之后,可通过微软提供的 ilasm 工具

将其生成为一个具有 PE 文件头的程序集。我们

建立一个新的文件 helloworld.il,使用 IL 编写

HelloWorld 程 序。 该 程 序 需 要 一 个 Main 方 法

999 888

图 1-15  加载了第一个变量之后,它位于栈

顶,在 stloc 时,会弹出栈顶的元素

给局部变量,所以运行完 000c 之后

栈是空的

999 999

888

1 887

图 1-16 后四句 IL 执行时栈中的活动

Page 25: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   25

以及一个空的 .assembly 即可。幸运的是,ilasm 会自动帮我们将 mscorlib.dll 引用加入进来

(或者你也可以打开之前生成的 program.exe,将外部程序集 mscorlib 的引用那一段抄过来也

行)。所以,其实下面的代码就足够了:

// 没有这段也行// 这是对 mscorlib 的引用,可以从其他地方直接抄过来// .assembly extern mscorlib// {// .ver 4:0:0:0// .publickeytoken = (B7 7A 5C 56 19 34 E0 89)// }

.assembly helloWorld

{

}

// 程序集的 IL 代码,所有的助记码都应该已经见过了// 其中,IL0001 这些行号不是必须的,不影响程序运行.method static void Main()

{

.entrypoint

.maxstack 1

ldstr "Hello, world!"

call void [mscorlib]System.Console::WriteLine(string)

ret

}

注意,我们的 Main 方法不属于任何类,是一个光杆方法,这在 Visual Studio 中是不可

能的。使用 ilasm helloworld.il 命令进行生成,会收到一个警告(没有添加对 mscorlib 的引

用),不过,编译器好心地为我们加上了 mscorlib。因此,编译可以通过:

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >ilas

m helloworld.il

Microsoft (R) .NET Framework IL Assembler. Version 4.7.2558.0

Copyright (c) Microsoft Corporation. All rights reserved.

Assembling 'helloworld.il' to EXE --> 'helloworld.exe'

Source file is ANSI

helloworld.il(21) : warning : Reference to undeclared extern assembly 'mscorlib'

. Attempting autodetect

Assembled global method Main

Creating PE file

Emitting classes:

Emitting fields and methods:

Global Methods: 1;

Emitting events and properties:

Global

Writing PE file

Page 26: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

26   第一部分 基 础 知 识

Operation completed successfully

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >

编译结束之后,会生成 helloworld.exe 文件。执行之:

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >hell

oworld

Hello, world!

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >

我们看看编译之后的 helloworld.exe 文件,会发现它非常有趣:

是的,仅仅三行就结束了。而且,最重要的是,我们的 IL 代码中,Main 方法不属于一

个类,也就是说我们的程序没有类,只有一个方法。所以,编译器自己给我们生成了一个类

<Module>,而且,它不继承自 System.Object,也没有构造函数。实际上,如果直接写 IL代码,你的程序可以变得十分无法无天:

��将上面的 IL 代码中的方法名由 Main 改为其他名称,程序仍然可以运行,实际上,

IL 只会关心是否有且仅有一个方法带有 .entrypoint 指令,根本不在乎方法的名称。

��定义一个不属于任何类型的全局方法。

��阻止类型自动从 Object 类继承。

��使用非 0 开始的数组。

��允许两个相同名字、相同输入类型及个数的参数,但输出类型不同的方法存在。

类似毁三观的事情还有很多,这些都是 IL 允许但 C# 不允许的,因为正如上所说,C#只实现了 CTS 的一部分功能。而且,还通过 csc.exe 做了手脚,强制为我们加上了一些性

质,例如类型(除了接口)必须继承自 System.Object。有兴趣的读者可以尝试使用 IL 编写

一些在 C# 中不合规的代码,看看是否可以顺利运行。

1.5.2.3 加载与存储IL 中所有的变量都需要加载到栈上才可以使用,这里的使用包括赋值和运算(赋值也

Page 27: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   27

是一种运算)。通过自己编写 IL 可以更好地理解 IL 中的加载与存储。IL 中常用的加载命

令有:

�� ldloc.x:将索引 x 处的局部变量加载到栈上。

�� ldstr:推送一个字符串。

�� ldc.xx.y:将类型为 xx 的值加载到栈上。

常用的存储命令有:

�� stloc.x:弹出栈顶的值,并存储于索引 x 处的局部变量列表中。

下面举例说明,C# 目标代码如下:

static void Main(string[] args)

{

int i = 999;

int j = 888;

Console.WriteLine(i+j);

}

因此,我们总共有 2 个变量,最大栈容量为 2:

// 方法不一定要使用 Main 命名.method static void Add()

{

.entrypoint

.maxstack 2

// 做加法 // 调用 WriteLine 的 int32 重载 call void [mscorlib]System.Console::WriteLine(int32)

ret

}

在使用变量之前,要先声明局部变量(名字可以随便起):

// 局部变量.locals init (int32 num1,int32 num2)

下面开始做加法。这需要加载 2 个 int 变量,然后将它们存储在局部变量中:

// ldc.i4 加载一个 int32 变量进栈,值为 888

ldc.i4 888

// stloc 将栈顶的变量弹出,并赋给 num1(写成 stloc.0 也可,0 对应 num1)stloc num1 // stloc.0

ldc.i4 999

stloc num2 // stloc.1

注意,stloc.x 不意味着使用栈上第 x 个元素,而是将栈顶的元素拿出来,赋给第 x 个变

量。当加载、存储都完成之后,现在要使用变量了。使用变量时,必须也将变量压入栈中,

然后才可以使用:

// 将两个变量压入栈

Page 28: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

28   第一部分 基 础 知 识

ldloc num1 // ldloc.0ldloc num2 // ldloc.1

// add 会将栈上最顶的两个数相加,然后弹出结果add

完整的 IL 代码如下:

.assembly helloWorld

{

}

// 方法不一定要使用 Main 命名.method static void Add()

{

.entrypoint

.maxstack 2

// 局部变量 .locals init (int32 num1,

int32 num2)

// ldc.i4 加载一个 int32 变量进栈,值为 888

ldc.i4 888

// stloc 将栈顶的变量弹出,并赋给 num1(写成 stloc.0 也可,0 对应 num1) stloc num1 // stloc.0 ldc.i4 999

stloc num2 // stloc.1

// 将两个变量压入栈 ldloc num1 // ldloc.0 ldloc num2 // ldloc.1

// add 会将栈上最顶的两个数相加,然后弹出结果 add

// 调用 WriteLine 的 int32 重载 call void [mscorlib]System.Console::WriteLine(int32)

ret

}

将上面的代码保存在 add.il 文件中,编译并运行,可以得到 1887 的正确结果:

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >ilas

m add.il

Microsoft (R) .NET Framework IL Assembler. Version 4.7.2558.0

Copyright (c) Microsoft Corporation. All rights reserved.

Assembling 'add.il' to EXE --> 'add.exe'

Source file is ANSI

add.il(30) : warning : Reference to undeclared extern assembly 'mscorlib'. Attem

pting autodetect

Page 29: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   29

Assembled global method Add

Creating PE file

Emitting classes:

Emitting fields and methods:

Global Methods: 1;

Emitting events and properties:

Global

Writing PE file

Operation completed successfully

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >add

1887

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第 1 章 .NET 基础知识 >

1.5.2.4 Call 与 Callvirt 的第一次讨论调用函数的 IL 助记符有 Call 与 Callvirt 两种形式。顾名思义,Callvirt 可以调用虚方法,

不过它也可以调用实例方法,但不能调用静态方法;而 Call 可以调用所有方法。

那么,Call 与 Callvirt 有什么区别呢?我们先写出下面的代码(可以在 Callvirt 文件夹

下找到代码):

namespace Callvirt

{

class Program

{

static void Main(string[] args)

{

A nullObject = null;

nullObject.Print();

Console.ReadKey();

}

}

class A

{

public void Print()

{

Console.WriteLine("A");

}

}

class B : A

{

public void Print()

{

Console.WriteLine("B");

}

}

}

Page 30: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

30   第一部分 基 础 知 识

代码很简单,显然它可以编译通过,但却会在运行时报错,因为我们试图在一个 null对象上调用方法。我们看看对应的 IL 代码(这里只显示了 Main 方法):

IL_0000: nop

IL_0001: ldnull

IL_0002: stloc.0

IL_0003: ldloc.0

IL_0004: callvirt instance void AssemblyLab.A::Print()IL_0009: nop

IL_000a: call valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System. Console::ReadKey()

IL_000f: pop

IL_0010: ret

其中,IL_0004 使用了 callvirt。我们将整个 IL 代码保存在 callvirt.il 文件中,然后使用

ilasm 编译并运行,程序出错,这符合我们的预期:

C:\Users\Administrator\Desktop\C#书 \第一次交稿 \代码 \第三章 \第 1章 .NET基础知识 >ilas

m callvirt.il

Microsoft (R) .NET Framework IL Assembler. Version 4.7.2558.0

Copyright (c) Microsoft Corporation. All rights reserved.

Assembling 'callvirt.il' to EXE --> 'callvirt.exe'

Source file is ANSI

callvirt.il(7) : warning : Reference to undeclared extern assembly 'mscorlib'. A

ttempting autodetect

Assembled method Callvirt.A::Print

Assembled method Callvirt.A::.ctor

Assembled method Callvirt.B::Print

Assembled method Callvirt.B::.ctor

Assembled method Callvirt.Program::Main

Assembled method Callvirt.Program::.ctor

Creating PE file

Emitting classes:

Class 1: Callvirt.A

Class 2: Callvirt.B

Class 3: Callvirt.Program

Emitting fields and methods:

Global

Class 1 Methods: 2;

Class 2 Methods: 2;

Class 3 Methods: 2;

Resolving local member refs: 2 -> 2 defs, 0 refs, 0 unresolved

Emitting events and properties:

Global

Class 1

Class 2

Class 3

Resolving local member refs: 0 -> 0 defs, 0 refs, 0 unresolved

Writing PE file

Page 31: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   31

Operation completed successfully

C:\Users\Administrator\Desktop\C#书 \第一次交稿 \代码 \第三章 \第 1章 .NET基础知识 >call

virt

未经处理的异常 : System.NullReferenceException: 未将对象引用设置到对象的实例。 在 Callvirt.Program.Main(String[] args)

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第三章 \ 第 1 章 .NET 基础知识 >

现在我们将代码中的这一句(位于 Main 函数中):

callvirt instance void A::Print()

中的 callvirt 替换为 call,然后再次使用 ilasm 编译并运行:

C:\Users\Administrator\Desktop\C#书 \第一次交稿 \代码 \第三章 \第 1章 .NET基础知识 >call

virt

A

C:\Users\Administrator\Desktop\C# 书 \ 第一次交稿 \ 代码 \ 第三章 \ 第 1 章 .NET 基础知识 >

程序不会报错,并确实执行了 Print 方法,打印出 A。也就是说,当使用 Callvirt 时,

会检查实例对象是否为 null,而 Call 就可以直接进行调用。所以为了避免在 null 对象上调

用方法,虽然有时候某个方法并不是虚方法,IL 也选择使用 Callvirt 进行调用,这看上去

似乎不符合微软一贯的高标准,不过这个问题的历史已经十分久远了,微软 C# 工作组的一

位成员在自己的博客中,回想了他们似乎是在 1999 年就决定这么干了:https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/。

在后面的章节中,我们还会遇到 Call 与 Callvirt 的比较。

1.5.3 System.Reflection.Emit

除了可以使用记事本写 IL 代码之外,微软还提供了 System.Ref lection.Emit,使得我们

可以用 C# 写 IL。有了 IL 的基本知识,我们现在就看看动态程序集的作用。

顾 名 思 义, 动 态 程 序 集 是 在 运 行 时 才 加 载 的 程 序 集, 它 们 可 以 绑 定 通 过 System.Ref lection.Emit 在运行时创建的新程序集。创建新程序集也必须遵循程序集的构成:创建程

序集,托管模块、类型,最后才是方法,因为程序集必须至少要有一个托管模块。

System.Ref lection.Emit 提 供 了 ILGenerator 为 方 法 注 入 IL 代 码。ILGenerator 提 供 了

Emit 方法,方法可以传入 IL 助记码,这使得建立 IL 代码片断变得十分简单:只需要先写

C# 代码,再编译,使用 ILSpy 获得 IL 代码,然后再对照 IL 代码即可。

下面演示了一个动态程序集的例子(代码在 EmitExample 目录下):

static void Main(string[] args)

{

// 创建程序集 EmitLab();

// 加载动态程序集

Page 32: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

32   第一部分 基 础 知 识

var asm = Assembly.Load("HelloWorldAssembly");

// 获得 HelloWorld 类 var type = asm.GetType("HelloWorld");

// 获得 Print 方法 var m = type.GetMethod("Print");

// 创建一个 HelloWorld 类的实例,并调用方法 var instance = Activator.CreateInstance(type);

m.Invoke(instance, null);

Console.ReadKey();

}

static void EmitLab()

{

// 得到当前线程所在的应用程序域 var appdomain = Thread.GetDomain();

// 必须在应用程序域中才能创建程序集 var assemblyName = new AssemblyName

{

Name = "HelloWorldAssembly"

};

var assembly = appdomain.DefineDynamicAssembly(assemblyName,

AssemblyBuilderAccess.Save);

// 在程序集中定义模块,如果程序集只有一个模块,可以将程序集名设置为主模块的名字 var module = assembly.DefineDynamicModule("MainModule",

"HelloWorldAssembly.dll");

// 定义一个公共类 var classHelloWorld = module.DefineType("HelloWorld",

TypeAttributes.Public);

// 定义一个名为 Print 的方法,没有参数,什么也不返回 var method = classHelloWorld.DefineMethod("Print",

MethodAttributes.Public, null, null);

var methodIL = method.GetILGenerator();

// 为方法加入输出 methodIL.EmitWriteLine("Hello world!");

// 这里调用了助记符 ret 返回 methodIL.Emit(OpCodes.Ret);

// 在做好所有工作之后,创建 HelloWorld 类 classHelloWorld.CreateType();

// 保存程序集 assembly.Save("HelloWorldAssembly.dll");

}

在这个例子中,我们沿着应用程序域→程序集→托管模块→类型→方法的顺序建立程

Page 33: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   33

序集。使用这种方法建立程序集和直接书写 IL 代码相比,可以说各有优缺点。书写 IL 代码

建立程序集,托管模块和类型非常简单,也可以一目了然地得知类型有多少方法。但使用

Emit,不需要计算栈的最大值,也不需要手动使用 ilasm 编译。

在反射效率较差的时代,Emit 曾用来改善程序的性能。当出现表达式树(内部使用了

Emit)之后,很少会有直接使用 Emit 编程的情景了,除非你需要在运行时动态创建程序集,

或者对性能有非常苛刻的需求,通常来说,对需要大量调用的方法进行优化是比较有意义的

选择。例如,可以参考 Dapper 的源代码,其中用到了很多 Emit。

1.5.4 即时编译(JIT)

为了使程序真正运行起来,IL 代码需要变成机器码,从而使得机器能够理解。CLR 把

这一步交给 JIT 编译器去做。JIT 编译器会使用即时编译,对不同架构的机器生成不同的代

码,所以大部分的代码优化都在这里完成。JIT 只有在运行时才会工作,当生成(Build)项

目时,JIT 不会工作。

即时编译(Just-in-time compilation,JIT)是动态编译的一种形式,是一种提高程序运

行效率的方法。通常,程序有两种运行方式:预先编译(AOT)与动态编译。预先编译的程

序在执行前全部被翻译为机器码,而动态编译执行的则是一句句,边运行边翻译。即时编译

则混合了这二者,一句句编译源代码,但是会将翻译过的代码缓存起来以降低性能损耗。相

对于预先编译代码,即时编译的代码可以处理延迟绑定并增强安全性。

当运行程序时,CLR 先会调用类加载器加载需要的类型,加载完成之后,就创建了类

型对象,包括方法表。假设类型已经加载完成了,则此时的情况如图 1-17 所示。

现在,试图首次调用该类型的 X 方法。由于其没有对应的机器码(除非它预先编译好

了,例如,Console 类中的方法是预先编译好的),所以 CLR 会在调用时遭遇 jmp 指令(目

标为 JIT 编译器)。它负责将方法的 IL 代码转换为机器码。编译过程中,会做运行时的类型

验证。编译完成之后,将机器码存储在缓存中,并将缓存地址放在 jmp 指令的后面,代替

之前的 JIT 编译器地址。以后对该方法的所有调用都不需要再次 JIT 编译,如图 1-18 所示。

Xjmp

JIT

   X

jmp

图 1-17 JIT 之前               图 1-18 JIT 之后

Page 34: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

34   第一部分 基 础 知 识

JIT 会将机器码储存在内存中,当程序结束后,这些机器码就会消失。所以每次程序运

行都伴随着即时编译。不过,这个现象带来的性能损耗仅仅会在方法第一次调用时体现,大

部分程序都会调用方法不止一次。如果你十分在乎机器码的重建带来的性能损失,你可以使

用 NGen.exe 工具将 IL 预先编译为本地代码,并将本地代码存入磁盘持久化,不过,本地

代码的大小可比 IL 大多了,而且,对完全没有调用过的方法,预先编译也会将其转换为本

地代码,但 JIT 则是按需编译。因此,预先编译可能未必有听上去那么好。有兴趣的读者可

以参考《CLR via C#》第一章 1.5 节。

1.5.5 运行时的验证

在 JIT 的编译过程中会执行验证,通过将代码和元数据中的定义进行比对,确定代码的

类型安全性。JIT 会扫描 IL 代码,并对其中引用的方法探测对应的程序集,例如:

这里第一次调用了方法 GetType,它属于类型 System.Type,而 System.Type 的元数据

又指明它位于 mscorlib。因此,CLR 会定位并加载这个程序集。解析一个类型时,如果类型

本身便位于该 IL 代码文件中,或者虽然不位于该文件中,但位于同一个程序集,则不需要

加载其他程序集。如果类型位于不同的程序集,则需要先通过元数据找到程序集的名称(例

如它的名称为 X),再通过当前程序集的清单,确定 X 确实在清单中。最后,根据 X 为强名

称程序集(去 GAC 寻找)或非强名称程序集(使用探测,配合 App.conf ig 的设置)。如果还

是找不到,就会引发异常。

普通情况下,每个程序都有自己的进程和独立的内存空间,这保证了程序的稳定,使

得程序不能读取自己的内存空间之外的内存地址(很可能是无效的地址)。不过,通过 CLR的验证,可以确保代码不会访问一个无效的内存地址,这使得将多个托管的程序放到一个

Windows 进程中运行成为了可能。CLR 提供了一个“轻型”的进程机制—应用程序域,

为程序和进程之间增加了一层。程序实际运行在应用程序域中,而多个应用程序域可以运行

于一个进程之中。应用程序域减少了系统的总进程数,也减少了上下文切换的速度,从而提

升了系统的性能。

上面的验证全部发生在运行时,在程序刚开始运行的时候,并不是所有的程序集都会

被加载,程序集是根据需要被加载的。

1.5.6 Visual Studio 的编译模式与本地代码的优化

Visual Studio 提供了两种编译模式:调试(debug)和发布(release)。其中,调试模式

指定的编译参数为 /optimize- 和 /debug:full,它会带来以下的结果:

1)禁止 CLR 对 IL 和 JIT 的编译中,对本地代码进行优化的行为。

2)生成一个 PDB 文件。

这两个结果联合起来,使得单步调试成为可能。首先,未优化的 IL 代码将包含许多

Page 35: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   35

nop 指令,它是一个空的操作,方便你设置端点时使得代码可以停在那里。例如,我们生成

的 A.netmodule 托管模块中的 IL:

IL_0000: ldarg.0

IL_0001: call instance void [mscorlib]System.Object::.ctor()IL_0006: nop

IL_0007: nop

IL_0008: ldstr "A 被创建 "

IL_000d: call void [mscorlib]System.Console::WriteLine(string)IL_0012: nop

IL_0013: ret

整个 IL 只有 8 句话,竟包含了 3 个 nop。如果使用发布模式进行编译,这些 nop 都将

消失不见,使得程序集变得更小。另外,PDB 文件和调试器一起工作,使得开发者可以在

调试时查找局部变量,甚至运行一些短小的方法。

发布模式指定的编译参数为 /optimize+ 和 /debug:pdbonly,它会带来以下的后果:

��允许 CLR 对 IL 和 JIT 的编译中,对本地代码进行优化的行为。

��禁止生成 PDB 文件。

CLR 的 JIT 编译器会对本地代码进行优化,例如字符串驻留中对常量字符串相加的优

化,或者一个永远不会进入的 for 循环(比如,i=100,i<10,i++)。和没有优化相比,优化

之后的代码将获得更出色的性能,程序集也更小。而 IL 编译器(C# 的 csc.exe)也会优化你

的 C# 代码,例如,会把一个没有赋值也没有使用过的局部变量直接忽略掉。

1.5.7 托管代码与非托管代码的互操作性

上面说的所有内容都是 CLR 编译的 IL 或机器码,它们也叫做托管代码。但实际上,很

多托管代码都要和非托管代码打交道,例如 Winform 程序背后是由 Windows 消息机制在支

撑。因此,即使一个简单的按钮点击,都会牵扯到给 Windows 系统发送消息,而这需要非

托管代码来实现。CLR 支持三种互操作的方式:

��使 用 平 台 调 用(Platform Invoke,PInvoke): 通 过

PInvoke 可以调用一个外部 dll 中的函数。C# 源码中,

很多 FCL 的库都要继续调用 Windows API(Kernel32.dll、User32.dll 等 dll)实现需要的功能。

��使用 COM 组件:如果你希望在 C# 中调用其他微软系

列的产品(例如 Excel、Word 等),就需要使用 COM 组

件。Excel、Word 等软件直接和 COM 库进行交互,如

图 1-19 所示,因此,在 C# 中要想使用 Excel、Word 等

软件产生表格或文档,只能暂时离开 CLR,去到更底层

的 COM 中。

��非托管代码使用托管类型:例如可以使用 C# 创建一个 ActiveX 控件或者一个 shell 扩展。

虽然可以调用非托管代码,但非托管代码运行是和 CLR 没关系的,也不会经过 JIT 编

ExcelWordPowerPoint...

C#

COM

CLR

图 1-19 使用 COM 组件交互

Page 36: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

36   第一部分 基 础 知 识

译和验证,所以,访问无效内存是完全可能的。

编写非托管代码时,你的对象没有 CLR 精心呵护,可能会让你觉得面目狰狞,所以,

一定要管理好对象。图 1-20 这个喜闻乐见的图片

就很可能是因为访问了一个已经死掉的对象而产

生的。

1.6 CLR 启动与 Hello World 的运行

C# 的 Hello World 代码非常简单,如下所示:

class Program

{

static void Main(string[] args)

{

Console.Write("hello, world");

}

}

在 CLR 执行代码前,会创建三个应用程序域,其中两个(系统域和共享域)对于托管

代码是不可见的,它们只能由 CLR 启动进程创建,而提供 CLR 启动进程的是 mscoree.dll和 mscorwks.dll(在多处理器系统下是 mscorsvr.dll)。第三个则是默认的应用程序域。

系统域负责创建和初始化共享域和默认应用程序域,它将系统库 mscorlib.dll 载入共享

域。该域还包括了字符串驻留池。系统域在进程中保持跟踪所有域,并实现加载和卸载应用

程序域的功能。

系统命名空间的基本类型,如 Object、ValueType、Array、Enum、String、Delegate 等,

在 CLR 启动程序过程中会被预先加载到共享域中。

大部分的程序在运行期间只使用一个域,就是默认的应用程序域。也可以在程序中加

载和卸载额外的域。

现在,我们已经基本上将程序的编译和运行的全过程粗糙地浏览了一遍。那么,运行

C# 的 Hello World 程序,需要的基本流程是:

1)建立控制台项目,编写代码并通过编译。此时,会生成可执行文件。这是一个含有

IL 代码和元数据的程序集。

2)运行这个程序集。因为 Windows 发现它是一个有 PE 文件头和 CLR 文件头的文件,

于是知道它是 CLR 程序集。通过 CLR 创建系统域和共享域,并加载一个默认的应用程序域。

3)在该应用程序域中,找到程序的入口点,通过 IL 中的 .entrypoint 即可。找到入口

点之后,使用类型加载器,加载该入口点所在的类型(Program 类型),生成 Program 类型

对象(该类型就只有一个成员 Main 和一个 C# 生成的构造函数)。同时,也生成了 Object 类

型对象和 System.Type 类型对象。生成 Object 类型对象时,用到了 mscorlib,因为 System.Object 是属于它的类型,但 mscorlib 已经被加载,所以不需要做额外的事情。

图 1-20 内存出错

Page 37: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 1 章 .NET 基础知识   37

4)Helloworld 主程序的 IL 代码只有三行,nop 不算。进入 Main 函数,执行代码。

5)通过 ldstr 加载字符串 Hello World,字符串是属于 mscorlib 的,且已被加载,故没

问题,将字符串压入栈。

6)使用 call 调用 WriteLine 方法,该方法不在本程序集中,而在 mscorlib 中。我们已

经加载了 mscorlib,故没问题,继续。

7)由于我们使用 call 调用方法,所以可以在 mscorlib 中找到 WriteLine 方法的 IL 代

码,这段代码其实是 PreJIT 的,所以它的机器码是现成的。

8)执行这段机器码,字符串出栈,打印到控制台上。

9)执行 IL 代码 ret,返回,程序结束。

从运行角度看,Helloworld 程序和真正自己编写的程序相比简单很多,没有私有程序集

的探测。不过,通过本章的学习,相信你可以举一反三,对自己编写的更加复杂的程序运行

时发生的关键步骤心中有数。

1.7 本章小结

本章主要介绍了 .NET 框架发展史、框架各主要部分以及程序集这个重要概念。

程序集是 .NET 最重要的概念之一,它的各部分,尤其是 IL 和元数据,支撑着 .NET 的

运作。IL 是微软创造的语言,它为各种框架语言建立了一个共同的编译目标,也为接下来

的 JIT 编译做好准备。了解 IL 在一定程度上可以令你对 C# 编译器幕后所做的小动作更为清

晰。而 JIT 是最终翻译为机器码的编译器,它会做更多的优化,并通过使用不同的编译方式

来利用这些优化。.NET 和 C# 的类型安全都靠元数据在后面支持。对于一个合格的 C# 开发

者,应该对自己的代码是如何在 CLR 上跑起来的大致过程比较熟悉。关于 IL 的更多资料可

以参考这个系列:http://blog.zhaojie.me/2009/06/my-view-of-il-1-il-and-asm.html。《 CLR via C#》的第一部分(前三章)含有很丰富的关于程序集的结构,以及部署的内

容,本书参考了这本大名鼎鼎的书籍,并从中吸收了很多知识。另外,《微软 .NET 程序的

加密与解密》这本书的前两章也讲了很多 IL 方面的知识,如果你对 IL 和 JIT 十分有兴趣,

推荐你读完全书。金旭亮的《 NET 4.0 面向对象编程漫谈 基础篇》也是一本好书,但它

在 .NET 圈的知名度谜之低下,我在此强烈推荐它。

1.8 思考题

1. 使用 Emit 实现一个函数,它接受两个输入字符串 A 和 B,返回一个字符串 A+B。

2. 将这个网站上所说的 CodeTimer 类(http://blog.zhaojie.me/2009/03/codetimer.html)部署到

GAC。

Page 38: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章

C# 类型基础(上)

本章主要讨论类型(包括值类型和引用类型)创建时的内存分配,这涉及堆和栈的概

念。另外也会重点讨论值类型和引用类型的区别与联系。

现在越来越多的面试官喜欢对本章所包含的内容提问。通过对类型基础知识的考察,

可以得知面试者是否对 C# 语言中各种类型有一个基本的认识,以及对对象初始化的流程是

否大概了解。

2.1 公共类型系统

公共类型系统(Common Type System,CTS)定义了什么是类型,以及每个类型可以

拥有的成员和行为。之所以称它是通用的,是因为它对所有 .NET 语言都适用。CTS 是保证

C# 语言类型安全的前提。

如果你对 CTS 的具体内容有兴趣,可以访问 https://www.ecma-international.or g/publications/files/ECMA-ST/ECMA-335.pdf,这篇 500 多页的英文文档实际上是 CLI

(根据前一章所学,它实际上是 CTS 的一个超集)的产品规范,它绝对可以满足你的

一切好奇心。另外,你也可以找来《 Shared Source CLI Essentials》这本书读一读,

它的难度很大,目前还没有中文翻译。

前边已经说过,CTS 只是一个规范,并没有限制它的实现方式。CLR 可以看作是 CTS的一种实现,它符合上面给出的链接上的所有要求。其他的实现包括 Mono、Rotor 等。

表 2-1 列出了 .NET 中一些基础名词及缩写,以及这些名词之间的相互关系。

Chapter 2

提示

Page 39: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   39

表 2-1 .NET 中一些基础名词的解释

名  词 说  明

VES(虚拟执行系统)

负责为某种语言在执行时提供一切必须的上下文和内存、线程管理、字符串驻

留、垃圾回收、即时编译等服务。CLR 是它的一个实现。可以用运行时(Runtime)代称。一个 VES 的实现可以提供上面所说的所有服务,也可以只提供部分服务

IL(中间语言) .NET 框架语言第一步编译的目标语言。它会再通过 JIT 编译器编译为机器码

CTS(公共类型系统) CLI 的一部分,描述了各种类型应该具有怎样的行为以及可以拥有什么样的成员

CLS(公共语言规范) CTS 的一部分,描述了一个框架语言需要拥有的最小功能集

CLI(公共语言基础设施)

上面所有的汇总。它包括了 VES、IL、CTS、CLS 的定义,还规定了元数据应

该包括什么等等。目前,主流的 CLI 实现有 .NET 编译器、微软 XNA、Rotor(即

Shared Source CLI)等等。可以在 ECMA334 和 ECMA335 这两份文件中找到 CLI的全部内容

类型指的是集合 { 类,结构,接口,枚举,委托 } 中的任意一个成员。类型(type)和

类(class)不同,后者是前者的一个特殊情况。任何拥有某类型的值(value)称为某类型的

一个实例(instance)。在 .NET 中,对任何值的使用(作为参数传递或操作)都需要首先明

确这个值的类型是什么。接口和委托都是一种特殊的类,而结构可以看作一个轻量级的类。

根据 ECMA-335 I.8.2.1 小节,类型可以被分为值类型(结构和枚举)以及引用类型(类、接

口、指针、字符串、委托、数组),没有第三种情况。特别的,指针属于引用类型。

类型可以拥有 0 或多个成员,这些成员可以简单地分为字段、方法和嵌套类型。字段

成员包括:

��常量(const):一个隐式为静态和只读的成员。由于它一定是静态的,所以它属于类

型本身。

��字段(f ield)和属性(property):刻画类型的基本性质。静态的字段和属性属于类型

本身。属性还分为无参属性和有参属性(索引器),并包括两个方法。

��事件(event):事件在对象以及其他相关对象之间实现了一个通知机制。例如,利用

按钮提供的一个事件,可以在按钮被单击之后通知其他对象。实际上也可以将其看

作一种方法,只不过不能自定义。

其中,属性和事件自带的方法位于附加的元数据中,无法自定义和修改。

方法成员包括:

��构造函数(constructor)和析构函数(destructor):用于类型初始化和类型的垃圾回收。

构造函数又可以分为静态的和实例的。

��方法(method):类型的方法可以视为它提供的功能,分为静态的和实例的。

��操作符重载:一种特殊的方法,它用于重写操作符的定义。

可以在类型中定义其他类型,这称为嵌套类型。例如,类中可以含有委托和另一个类。

不过,嵌套类型的使用有很多限制。

CTS 也规定了某些类型只能拥有上述成员的一小部分而不是全部。例如,枚举类型不

可以拥有自己的方法,属性和事件。

Page 40: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

40   第一部分 基 础 知 识

类型的访问修饰符

C# 支持 6 种类型访问修饰符。访问范围由大到小排序为:

1)public:没有限制。

2)protected internal:只能在所在程序集、定义的类型或派生类型进行访问。

3)internal:只能在所在程序集访问。

4)private protected:C# 7.2 新增的访问修饰符,等同于 5 和 6 中满足任意一个。

5)protected:只能由定义的类型或派生类型进行访问。

6)private:只能由定义的类型进行访问。

所有修饰符都可以修饰类型成员,而对应于直接定义在命名空间下面的类型,则只有

public 和 internal 是可以使用的(除非类型嵌套于另一个更大的类型中)。显然,为一个非嵌

套类加上 private 和 protected 是毫无意义的(你写了一个没人能实例化的类)。所以,除了

public 和 internal,其他四种修饰符不能作用于非嵌套类。

由 internal 修饰的类中的方法只能被同程序集的其他对象访问。如果一个程序集 A 中有

一个 internal 的类型 B,它含有方法 C,那么程序集 D 中的代码不能调用方法 C,即使 C 是

public 的。因为类型 B 对于程序集 D 不可见。程序集 D 也可以定义自己的类型 B,它被视

为和程序集 A 中的 B 不同。

类型的访问修饰符体现了封装性,即开发者可以控制类型中哪些方法和成员对外界是

否可见,从而令外界不需要知道它们永远不会访问或调用的成员。如果没有指定访问修饰

符,类型的访问修饰符默认为 internal,类型成员则默认为 private(都是限制最大的那个)。

2.2 堆与栈

堆与栈都是内存空间的一部分,其中,堆又可以分为托管堆和非托管堆。托管堆和栈

由 CLR 管理。对托管堆中的一部分—GC 堆中不用的对象进行释放就是垃圾回收的主要

工作,而托管堆的其他部分,和开发者关系相对没有那么大。对非托管堆的管理则需要由开

发者完成。

在 CLR 开始执行第一行代码之前,它会先建立三个程序域:系统域、共享域以及默认

的一个应用程序域(AppDomain 类型的一个实例)。其中,开发者无法直接操作系统域和共

享域,但 AppDomain 类型的实例可以有多个。这点我们在 1.5 节中已经有所提及。对于简

单的程序,例如控制台程序,第一个默认域的名称就是可执行文件的全名,例如 abc.exe。

可以使用 CreateDomain 方法创建更多的应用程序域。

每一个 AppDomain 的实例都有自己的加载堆,下面就会介绍加载堆到底是什么。

2.2.1 堆

这里的堆(heap)是托管堆(managed heap)的简称。顾名思义,它由 CLR 进行管理。

它是在运行程序时,CLR 申请的一块内存空间。它基于进程,属于进程内存空间的一部分。

Page 41: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   41

这块空间可以划分为下面几个主要部分:

��(至少)三个程序域,以及它们自带的加载堆和其他零部件。

�� GC 堆(GC heap):垃圾收集器的处理对象。它分为 0,1,2 代三块区域,越高代的

堆大小越大。大对象堆(large object heap)是 2 代堆的一部分,它存放超过 85KB 大

小的对象。因为大对象堆里面的对象太大,移动代价过高,所以微软设计的意图是直

接将它提升到 2 代,避免升代移动引起的性能损失。在第 6 章会详细介绍垃圾回收。

其中,加载堆(loader heap)存在于每一个程序域中,存放 CLR 自己的类型系统以及用

户定义的类型对象。不同域的加载堆存放的对象不同。另外,AppDomain 的加载堆也存放

静态对象,由于静态对象是全局的,不会成为垃圾,所以加载堆不受垃圾收集器管辖。加载

堆又可以分为高频堆(大小为 32KB),低频堆(大小为 8KB)等。顾名思义,高频堆存放的

是 CLR 认为访问次数可能较多的对象,例如类型方法表等。

图 2-1 简单展示了托管堆的结构(其中有三个加载堆位于三个域中,并没有画出来)。还

有其他的堆,例如 JIT 代码堆,用来存放 JIT 之

后的本地代码,但一些较不常见的堆并不重要。

这里记住垃圾收集器只会光顾 GC 堆就可以了。

具体的细节可以参考下面的文章:http://www.cnblogs.com/awpatp/archive/2009/11/ 11/1601397.html,这是目前我见过的对 CLR 内存管理叙述

最深刻的文章,不过,有些结构可能已经过

时。 文 章 的 英 文 原 文:https://web.archive.org/web/20080919091745/http://msdn.micro soft.com:80/en-us/magazine/cc163791.aspx

当创建新对象时,若该对象是引用类型或者包括引用类型的值类型,就会在 GC 堆上

申请空闲的内存空间,CLR 会先计算需要的空间大小。如果堆上已经没有剩余空间了,就

触发一次垃圾回收。如果回收之后仍然无法获得足够的剩余空间,则掷出 OutOfMemory 异

常。GC 堆维护一个叫做 NextObjPtr 的指针,指向 GC 堆的下一个可用地址。

为了尽量合理利用空间,GC 堆的内存分配是连续的。当垃圾收集结束之后,由于有

些空间被释放,内存可能出现碎片,此时,会进行压缩,将内存重新变回连续状态。当然,

GC 堆只是全部内存资源的一小部分。对于非托管资源来说,它们会占用另一部分的内存。

这块空间叫做本地堆(Native Heap),或者非托管堆(Unmanaged Heap),CLR 不负责这块

空间的垃圾回收。非托管资源有很多,比如文件流、数据库连接、打印机资源等。如果没有

妥善地处理非托管资源,就会发生各种稀奇古怪的错误。

2.2.2 栈

栈(stack),又称“线程栈”(thread stack),顾名思义,它是基于线程的。它的空间比较

小,在每开启一个新的线程时,从内存中开辟大约 1M 空间,作为该线程的自留地。线程栈

ID

mscorlib.dll,System

GC 0,1,2 JIT

图 2-1 托管堆结构简图

Page 42: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

42   第一部分 基 础 知 识

是一个先进后出的栈数据结构,所以它一直都是连续的。CLR 维护一个指针,指向栈的下

一个自由空间的地址,当成员出入栈时,指针地址跟着发生变化。因为栈中的对象离开了定

义域就会被自动销毁,通常栈的空间是够用的。不过,如果程序写的有问题,还是可能会爆

栈,此时就会掷出大名鼎鼎的 StackOverf low 异常。写出一个爆栈的程序很容易,例如一个

没有出口的递归即可,此时,所有的变量都还在它们的定义域中。

对于非静态的、纯粹的值类型(例如,不包含任何引用类型成员的结构体),初始化时,

CLR 会计算它需要的空间大小,然后将其值存储在栈上。例如,一个 int 的大小是 4 个字

节。而对于引用类型,它也会使用栈,但栈上仅仅存储一个地址(即引用),就是它在托管

堆上的内存地址。通过访问栈上的地址,就可以间接访问到堆上的引用类型对象,以及对象

真正的成员和它们的值。

由于栈有着得天独厚的优势(只能从顶部放入和拿走数据),栈中的内存总是连续的,

不需要进行 GC。

2.3 引用类型的内存分配

引用类型(reference type)内存分配的复杂程度远高于值类型。引用类型的内存分配永

远是两部分:一个引用它的对象,加上堆上的一个对象,参见图 2-2。引用类型对象包括方

法表指针和同步块索引(值类型则没有这两样东

西),方法表指针指向该引用类型自己的类型对象。

引用类型的默认值为 null。通过令某个引用类型为

null,我们实际上是将它与某个堆上的对象之间的

关联切断。此时,该引用类型变量将不指向任何堆

上的对象。

如果 GC 堆上的某个对象不被任何其他对象关

联(即没有任何栈上的对象地址是它的所在地址),

则它成为垃圾,等待垃圾回收器进行回收。概括地

说,GC 堆上的一个对象可以被如下的对象引用:

1)栈上的一个变量(最常见的情况)。

2)P/Invoke 情形下的句柄表。

3)F inalizer queue,即终结队列。

4)寄存器。

我们创建一个新的工程 TypeFundamentalLab,采用下面的类型作为示例类型,在主程

序中写如下的代码:

class Program

{

static void Main(string[] args)

图 2-2 引用类型的内存布局简图

Page 43: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   43

{

var a = new ExampleRef();

Console.WriteLine(" 调用前 ");

Console.ReadKey();

Console.WriteLine(" 调用后 ");

a.NormalMethod();

Console.ReadKey();

}

}

class testRef

{

public byte e = 1;

public string e2 = "test";

public byte e3;

public int e4;

public byte e5;

public int e6;

public byte e7;

public int e8;

}

class ExampleRef :testRef

{

private int a = 1;

public string b = "test";

public static string c = "static";

}

引用类型在申请内存时,需要计算它本身所需要的内存以及它的父类成员需要的内存,

一直算到 System.Object(不过它没有成员,所以一般没指定父类的引用类型计算内存就只

需要算它自己就够了,因为对于没指定父类的引用类型来说,其父类为 System.Object)。在

进行计算时,不考虑方法,只考虑字段和嵌套类型。而且,需要加上方法表指针和同步块索

引这两项,在 32 位机器上它们各占 4 个字节,64 位机器上它们各占 8 个字节。

那么,如果请求 CLR 建立一个 ExampleRef 实例,该实例在堆上的部分需要多少字

节?在栈上的部分需要多少字节?我们用 32 位机为例进行讨论。

首先,对内存分配从同步块索引开始,它占据 4 个字节。有趣的是,栈上的引用将指

向同步块索引后边的部分(称为偏移量),也就是说,同步块索引的地盘是从 -4 字节到 0。

然后,方法表指针(又名类型对象指针)上场,占据 4 个字节。这 8 个字节是每个引用类型

都一定会有的,没有办法直接操作它们(这会破坏类型安全性),无论是 C# 还是 IL。

下面,就轮到类型的实例字段(静态字段在类型对象中)。32 位机上,任何对象占据的

字节数都必须是 4 的倍数。所以,即使一个引用类型仅有一个 byte 类型的字段,它也占据

12 字节(实际占据 9 字节,3 字节被浪费),而下一个引用类型不能从第 10 字节,而必须从

第 13 字节开始分配内存,这称为内存的对齐(alignment)。而在 64 位机上,任何对象占据

的字节数都必须是 8 的倍数,所以,仅有一个 byte 类型的字段的引用类型占据 24 字节(实

际占据 17 字节,7 字节被浪费)。不过默认情况下,CLR 会智能地将可以合并到 4/8 字节的

对象尽量合并到一起,以免内存空间浪费,除非你显式地阻止它。例如,64 位机器上两个

int,四个 short,8 个 byte 可以合并到一起。

Page 44: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

44   第一部分 基 础 知 识

2.3.1 字段的对齐

类型字段最终被创建的顺序不一定就是它在代码中的顺序,这是因为 CLR 会选择一个

较好的方式排列这些字段,尽量消除对齐带来的负面影响。不过,这方面并没有正式的文

档,因此我们对字段重排的算法一无所知。

例如,示例中我们的类型含有父类型 testRef,在定义中,我们故意隔着声明 byte 类型

成员,如果 CLR 按照我们的定义顺序来建立对象,那么考虑到对齐,需要 32 个字节(其

中,每个 byte 字节后面的 3 字节都被浪费)。但实际上,CLR 会将四个 byte 放在最后:

MT Field Offset Type VT Attr Value Name

6fc53234 4000001 14 System.Byte 1 instance e

6fc51d64 4000002 4 System.String 0 instance e2

6fc53234 4000003 15 System.Byte 1 instance e3

6fc53c04 4000004 8 System.Int32 1 instance e4

6fc53234 4000005 16 System.Byte 1 instance e5

6fc53c04 4000006 c System.Int32 1 instance e6

6fc53234 4000007 17 System.Byte 1 instance e7

6fc53c04 4000008 10 System.Int32 1 instance e8

这是通过 WinDbg 获得的资料,对 WinDbg 的使用在后面会介绍,这里仅仅是用作对齐

的证明。通过表中的 Offset(偏移量)可以看到,第一个字段实际上是 e2,它的偏移量为 4 因

为它前面是方法表指针,然后是 e4、e6 和 e8(这里的偏移量是 16 进制所以 c=12,10=16)。

然后才出现 e、e3、e5 和 e7。八个字段仅仅会占据 20 字节,这也是最省空间的布局方式。

那么,示例类型的父类型就讨论完了,它占据 20 字节。示例类型本身含有两个实例字段

a 和 b。对于 int 类型,它占据 4 个字节,而对于字符串,它是一个引用类型,所以,这里只会

有一个引用,引用是一个地址,地址的大小和计算机的位有关,例如 32 位机的地址长度为 4个字节。静态字段 c 不在实例对象中,而在类型对象中。因此,整个堆上的部分需要 36 个字

节。在栈上的部分则只是一个引用地址,32 位机的地址长度为 4 个字节。整个内存布局如下:

1)同步块索引。

2)方法表指针(指向方法表,它位于类型对象中,而类型对象一般位于同一个应用程

序域的加载堆中)。

3)类型所有父对象的实例成员(静态成员存储在类型的类型对象中),其中,所有引用

类型成员都分配 4 字节,因为只需要分配地址。分配顺序不定,CLR 会尽量消除对齐带来

的负面影响。

4)类型自己的实例成员(静态成员存储在类型的类型对象中),引用类型成员分配同上。

当然,我们的 ExampleRef 类型含有一个字符串,因此,初始化字符串时,还需要在堆

上建立字符串这个 class 的一个新的实例,值为 test。它占据的空间大小为 14(64 位机器则

为 26)+ 字符串本身的长度 *2。因此,ExampleRef 类型的字符串 b 拥有初始值 test,它的

大小应为 14+4*2=22。不过,通常我们不把这部分空间(字符串字面量,string literals)算

成 ExampleRef 实例的一部分。关于这一点,不同的资料有不同的理解,不过主流的看法都

是不把这部分空间算进去(《C# 本质论》,《CLR via C#》等)。

Page 45: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   45

当完成内存大小计算之后,0 代 GC 堆的 NextObjPtr 指针后移 36 个字节。然后,调用

类型的构造函数,这会造成字符串的初始化,又需要堆上 22(实际为 24)字节的空间。最

后,返回方法表指针的地址给栈上的 ExampleRef 类型的对象。如果为 64 位机器,上面所

有的 4 字节都要改为 8 字节,因此 64 位机器的 ExampleRef 类型的一个实例会占据 48 字节

(36 字节 + 同步块索引,方法表指针额外的 8 字节 +string 地址额外的 4 字节)。

2.3.2 同步块索引

同步块索引(synchronization block index)是类的标准配置,它位于类在堆上定义的开

头 -4(或 -8)至 0 字节。在程序运行时,CLR 管理一个同步块数组。它是一个总共 32/64位的多功能结构,其中,前 6 位的值提示访问者目前同步块索引的功能是什么,高 6 位就像

6 个开关,有的打开(1),有的关闭(0),不同位的打开和关闭有着不同的意义。它的用处

非常广泛,例如线程同步和 GC 都会用到它,它还会储存对象的哈希码。

同步块索引在线程同步中用来判断对象是被使用还是闲置。默认的情况是,同步块索

引被赋予一个特殊的值,此时对象没有被线程独占。当一个线程拿到对象,并打算对其操作

时,它会检查对象的同步块索引。如果索引的值为特殊值,说明没有任何线程正在操作它,

此时这个线程获得它的操作权。同时在 CLR 的同步块数组中分配一个新的同步块,并将该

块的索引值写入实例的同步块索引值中。

这时,如果有其他线程来访问该实例,它就不能操作这个实例了,因为它的同步块索

引的值不为特殊值。当独占的线程操作完之后,同步块索引的值被重设回特殊值。

2.3.3 方法表指针和类型对象

方法表指针(method table pointer)又叫类型对象指针(TypeHandle)。类型对象由 CLR在加载堆中创建,创建时机为加载该程序集时。类型对象最重要的成员为类型的静态字段和

方法表,创建完之后就不会改变,通过这个事实,可以验证静态字段的全局性。

因为类型对象存储了静态字段和方法表,它们被所有的该类型实例共享。因此为了做

到这点,需要满足如下条件:

1)一个类型无论有多少个实例,这些实例在堆中的内存的类型对象指针都指向同一个

类型对象。

2)类型对象的位置在不受 GC 控制的加载堆中。即使没有任何实例类型指向它,它也不

会被回收。如果它被回收,下次实例类型的创建会伴随类型对象的创建,而这是没有必要的。

静态字段很好理解,方法表就是类型所有的方法,包括静态方法和实例方法。方法会

在初次执行时,经由 JIT 编译为机器码,并将机器码存在内存之中,获得一个入口地址。此

时,方法表中的该方法指向一个 jmp 指令,使得其可以跳跃到该入口地址。在下次调用该

方法时,直接跳到入口地址,无需再次编译。在下一章会更详细的讨论方法表。

类型对象是反射的重要操作对象。System.Type 类会返回类型对象(包括静态成员和方

法表)。获得类型对象之后,就可以得到该对象所有的信息。

Page 46: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

46   第一部分 基 础 知 识

注意,类型对象也有类型对象指针,这是因为类型对象本质上也是对象。所有类型对

象的“类型对象指针”都指向 System.Type 类型对象。值得提出的是,System.Type 类型对

象本身也是一个对象,内部的“类型对象指针”指向它自己。

例如,验证类型所有的实例都指向同一个类型对象:

var a = new AStruct();

var b = new AStruct();

Console.WriteLine(ReferenceEquals(a, b)); // FalseConsole.WriteLine(ReferenceEquals(a.GetType(), b.GetType())); // True

类型对象指针在 32 位机器上占用 4 字节,64 位机器则为 8 字节。类型对象在所处的应

用程序域加载时创建,并在应用程序域被卸载时才会被跟着销毁。如果希望在应用程序域被

卸载时执行一些代码,可以向 System.AppDomain.DomainUnload 事件登记一个回调方法。

2.3.4 静态字段和属性

类型的静态字段和静态属性的支持字段(例如 int)存储在类型对象(加载堆)中。JIT会在进行编译时找到这些静态成员的地址,并在之后的编译时硬编码它们,然后写在机器码

中。这样,再次访问静态成员时就不需要通过类型对象了。如果你还不知道属性是什么,这

里可以简单地理解为属性等于一个支持字段加两个方法,用来获得和写入属性的值。

程序中所有类型的静态成员组成一个全局的数组,它包括每一个类型中的基元类型静

态成员的内存地址。数组的地址会被钉死(pinned),使得它不会被 GC 回收掉(除非卸载应

用程序域),这样一来,机器码中的硬编码将一直有意义,直到程序终止。

2.4 使用 WinDbg 探查内存

现在,我们请出一个更加底层的工具—WinDbg,帮助我们看清内存分配的真实情况。

WinDbg 是一个 Windows 操作系统上非常强大的调试工具,在本书中我们仅仅会使用它的

很小一部分功能。

通过 WinDbg 可以探查任意时刻内存的分布状态。可以在微软的官网上 https://www.microsoft.com/en-us/store/p/windbg/9pgjgd53tn86 获得 WinDbg Preview,这是它最新的版本

(需要安装 Windows 10)。下载并安装好之后,在应用中搜索 WinDbg Preview,并打开它。

WinDbg Preview 会自动下载符号文件,而老版本的 WinDbg 需要手动设置一下符号文件的

路径。本书中我们使用 Windows 7 进行开发,因

此我们要使用老版本的 WinDbg,可以从这里获

得 : h t t p : / / d o w n l o a d . c s d n . n e t / d o w n l o a d /zhang957411207/4750492。

当安装了 WinDbg 之后,我们可以从程序的

标题上看到它的版本,如图 2-3 所示。 图 2-3 WinDbg 版本

Page 47: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   47

我安装的是 64 位的版本。如果你的分析环境是 32 位的你就需要使用 X86 的版本,同

理,如果是用 64 位的环境就需要使用 X64 的版本。

启动 WinDbg 成功之后,我们需要载入 .NET 调试扩展包 SOS,它是 .NET 框架的一部

分,无需另行下载。根据环境可以找到两个 SOS.DLL:

�� C:\Windows\Microsoft.NET\Framework\v4.0.30319�� C:\Windows\Microsoft.NET\Framework64\v4.0.30319

现在,我们生成 TypeFundamentalLab 工程(根据 32 或 64 位系统,请将生成目标调整

为 x86 或 x64,否则,将会在使用 WinDbg 中出错),然后定位到工程的 \bin\debug 文件夹,

找到可执行程序,并执行它。因为有 Console.ReadKey 的存在,程序会一直停在那里等待我

们的输入。此时,我们就可以使用 WinDbg,好奇的对内存一探究竟了。

2.4.1 WinDbg 简易命令速查

表 2-2 列出了本章用到的一些 WinDbg 命令,这只是全部命令的冰山一角。

表 2-2 WinDbg 部分命令

命  令 说  明

.loadby sos clr 加载 sos.dll(支持更多有用的指令)

.chain 查看目前加载的所有功能集

!clrstack 打印当前线程调用 Stacktrace

!clrstack -a 打印当前线程调用栈的参数

lm 查看所有加载的托管模块

~0s 将线程切换到主线程(主线程的 ID 为 0,也可以切换到任意 ID 的线程)

!name2ee 通过类型名称定位到它的方法表

!eeheap 打印当前各个堆的状态

!dumpheap 打印堆中所有对象的地址,大小和方法表

通过这些命令,我们就可以真正深入神秘的内存世界,慢慢探开整个地图的迷雾。

2.4.2 使用 WinDbg 探查引用类型

本次探查主要做下面几个事情:

1)拿到栈上的变量地址。

2)通过地址导向堆中的实例对象。

3)探查实例对象的结构。

我 们 打 开 WinDbg, 点 击 左 上 角 的 F ile 菜 单, 并 选 择 Attach to process, 将 它 关 联

到刚才运行的进程 TypeFundamentalLab 上。之后,我们就进入到调试的界面,如图 2-4 所示。

Page 48: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

48   第一部分 基 础 知 识

图 2-4 WinDbg 主界面

我们需要在下面的方框内输入各种命令来取得需要的信息。首先,我们需要加载 SOS工具,它提供了很多有用的拓展命令。我们在窗口下面的命令行中输入:

.loadby sos clr

这样即可完成 SOS 工具的加载。可以输入 .chain 命令确定 SOS 确实被加载。然后,

我们设定符号文件。单击 F ile 菜单,选择 Symbol F ile Path,然后在输入框中输入 srv*d:\SymbolsLocal*http://msdl.microsoft.com/download/symbols 即可。本动作只需要做一次,而

加载 SOS 则每次都需要,如图 2-5 所示。

图 2-5 符号文件路径设置

为了获得主线程栈上的引用,我们输入 ~0s 来到主线程(ID 为 0):

0:005> ~0s

ntdll!NtRequestWaitReplyPort+0xa:

00000000`77aabd5a c3 ret

之后,输入 !clrstack -l 打印主线程中线程栈的信息(如果你在这里遇到问题,请确保你

Page 49: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   49

目前附加的线程和 WinDbg 的位数相同,例如都是 64 位的):

0:000> !clrstack -l

OS Thread Id: 0x8c4 (0)

Child SP IP Call Site

000000000052e9e8 0000000077aabd5a [InlinedCallFrame: 000000000052e9e8] Microsoft.

Win32.Win32Native.ReadConsoleInput(IntPtr, InputRecord ByRef, Int32, Int32 ByRef)

000000000052e9e8 000007fee9269593 [InlinedCallFrame: 000000000052e9e8] Microsoft.

Win32.Win32Native.ReadConsoleInput(IntPtr, InputRecord ByRef, Int32, Int32 ByRef)

000000000052e9b0 000007fee9269593 *** WARNING: Unable to verify checksum for

C:\windows\assembly\NativeImages_v4.0.30319_64\mscorlib\

5d0c037297cc1a64b52ce43b45c2ac2e\mscorlib.ni.dll

DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr, InputRecord ByRef, Int32,

Int32 ByRef)

000000000052eac0 000007fee9365ae1 System.Console.ReadKey(Boolean) [f:\dd\ndp\

clr\src\BCL\system\console.cs @ 1497]

LOCALS:

<no data>

<no data>

<no data>

<no data>

<no data>

<no data>

<no data>

<no data>

0x000000000052eaf8 = 0x00000000028d3058

<no data>

<no data>

<no data>

<no data>

000000000052eba0 000007fe8aa404d4 TypeFundamentalLab.Program.Main(System.

String[]) [C:\Users\Administrator\Desktop\C#书 \第三次交稿 \第 2章 C#类型基础(上)\ TypeFundamentalLab\TypeFundamentalLab\Program.cs @ 16]

LOCALS:

0x000000000052ebc8 = 0x00000000028d2f18

000000000052ee20 000007feea065a03 [GCFrame: 000000000052ee20]

在输出结果中能看到,现在栈上有一个局部变量,它的位置在 0x00000000028d2f18。

现在,我们获得了一个对象的地址,那么就可以使用 !dumpobj/d 0x00000000028d2f18 进一

步打印出对应信息:

0:000> !dumpobj /d 0x00000000028d2f18

Name: TypeFundamentalLab.ExampleRef

MethodTable: 000007fe8a935c88

EEClass: 000007fe8aa81098

Size: 48(0x30) bytes

File: C:\Users\Administrator\Desktop\C# 书 \ 第三次交稿 \ 第 2 章 C# 类型基础(上) \TypeFundamentalLab\TypeFundamentalLab\bin\x64\Debug\TypeFundamentalLab.exe

Fields:

MT Field Offset Type VT Attr Value Name

000007fee8ca8a68 4000001 18 System.Byte 1 instance 1 e

000007fee8ca9428 4000002 8 System.Int32 1 instance 2 e2

Page 50: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

50   第一部分 基 础 知 识

000007fee8ca8a68 4000003 19 System.Byte 1 instance 3 e3

000007fee8ca9428 4000004 c System.Int32 1 instance 4 e4

000007fee8ca8a68 4000005 1a System.Byte 1 instance 5 e5

000007fee8ca9428 4000006 10 System.Int32 1 instance 6 e6

000007fee8ca8a68 4000007 1b System.Byte 1 instance 7 e7

000007fee8ca9428 4000008 14 System.Int32 1 instance 8 e8

000007fee8ca69f8 4000009 8 System.String 0 static 0000000000000000 a

000007fee8ca9428 400000a 1c System.Int32 1 instance 9 a

000007fee8ca69f8 400000b 20 System.String 0 instance 00000000028d2f70 b

000007fee8ca69f8 400000c 10 System.String 0 static 00000000028d2f48 c

打出来的信息里面包含对象的名称、方法表地址(000007fe8a935c88)、EEClass 地址、

对象大小、程序集所在文件路径、一个字段的详细信息表格:

��MT 表示该字段的方法表地址。例如,我们可以看到,System.Byte 的方法表地址是

000007fee8ca8a68。

�� F ield 是该字段的元数据 Token。

�� Offset 表示该字段的相对偏移位置,通过这些偏移量可以一步到位找到任意一个字

段,而不是按顺序查找。例如字段 e7 位于对象开头指针 +1b 的位置。

�� Type 表示该字段的类型。

�� VT 列的值为 1 表示该字段是值类型,为 0 表示其为引用类型。

�� Attr 列的值表示该字段为静态的还是实例的。

�� Value 表示该字段的值,如果该字段是引用类型,那么这个值就是该字段真正位置的

地址,如果是值类型,有可能是这个值类型的值,也有可能是地址。

�� Name 表示该字段的名字。

通过上面的输出,我们证明了该引用类型的类型对象(64 位机器)确实占据 48 个字

节,并且验证了对齐的存在。最后,我们再挖深一层,来看看内存到底长什么样。我们打开

菜单 View 的 Memory,并在地址栏输入 0x00000000028d2f18(Memory 窗口默认按照字节

显示),如图 2-6 所示。

图 2-6 探查内存

其中,我们关心的前三行是:

Page 51: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   51

00000000`028d2f18 88 5c 93 8a fe 07 00 00 02 00 00 00 04 00 00 00 .\..............

00000000`028d2f28 06 00 00 00 08 00 00 00 01 03 05 07 09 00 00 00 ................

00000000`028d2f38 70 2f 8d 02 00 00 00 00 00 00 00 00 00 00 00 80 p/..............

对象指向的方法表指针位于开头,但对象并不是从这里开始的。在 0x00000000028d2f18这一行的上面一行,最后的 8 个 00 表示了一个闲置的同步块索引。如果我们将该对象作为

lock 的锁定对象,该同步块索引的值就会被改变。

0x00000000028d2f18 的头 8 个字节表示了方法表的地址(000007fe8a935c88),而后面

的信息,相信大家已经看出来了,就是四个 int 的值(2,4,6,8),之后,跟着四个 byte的值(1,3,5,7),这也证明 CLR 做了对齐。最后跟着的是 a 的值 9 和 b 的引用。

到这里,我们的内存之旅就到此结束了(如果你在上面的工具栏中选择按照 Bit 显示,

字节将会被转换为位,你将会看到一个只由 0 和 1 组成的内存布局图,这也就是真正的终点

了)。我们得到的信息可以简略地表示为图 2-7。

0x00000000028d2f12

0x00000000028d2f18

8

12

16

20

24

25

26

27

28

32

8

8

e2

e4

e6

e1

e3

e5

4 inte8

2 bytee7

a(4 int

b8

000007fe8a935c88

00000000028d2f70 test0 0s

图 2-7 引用类型内存分配详图

Page 52: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

52   第一部分 基 础 知 识

其中,8 字节为一个对齐单位,两个 int(e2 和 e4,e6 和 e8)共用一个单位,4 个 byte和一个 int(e1,e3,e5,e7 和 a)共用一个单位。栈上的引用指向堆上的地址。堆上的实例

对象占据 48 字节,其中 b 的引用指向 b 实例对象。方法表指针指向 ExampleRef 类型对象

的方法表。我们先不去关心方法表的结构,在下一章我们再具体展开方法表的探讨。

此次内存之旅的大致路线如下:

1)通过 ~0s 切换到主线程,然后通过 clrstack -l 拿到引用类型栈上的变量。

2)通过获得的变量地址使用 !dumpobj 探查该地址上实例对象的结构。

也许读到这里你也跃跃欲试,想自己使用 WinDbg 进行内存之旅了吧,WinDbg 以及它

的扩展 SOS 命令繁多,玩法多样。本次旅行之后,我们对内存的认识清晰了许多。如果紧

接着再建立一个新的 ExampleRef 类型

对象,那么内存将会变为图 2-8。

方法表指针指向相同的地方,两

个字符串也指向相同的地方,可以使用

WinDbg 或 C# 代码来证明,这里就不再

赘述了。不过,如果更改了其中一个对象

的 b 的值,另一个对象的 b 的值是不会受

到影响的。这是因为,更改字符串的值实

际上是新建一个字符串,并将原字符串的

引用指向新的字符串的地址。因此,更改

其中一个对象的字符串只会令它的引用地

址更改,而不会影响另一个。

由于 CLR 最核心的技术内幕并未

公开,因此对于使用 WinDbg 得出的结

果,有时也不要太过钻牛角尖。

2.4.3 引用类型的复制

引用类型的复制分为深复制和浅复制。默认的情况为浅复制,浅复制只会复制地址本

身,然后将这个地址赋值给新的变量,所以,新的对象和旧的对象同时指向堆上的旧对象。

更改任何一个对象成员的值都会影响另一个。

var a = new AClass();

a.a = 1;

a.b = "hey";

var b = a;

b.a = 2;

// 输出 2

Console.WriteLine(a.a);

在刚刚执行完 var b=a 这句之后,内存的情况如图 2-9 所示。

000007fe8a935c88

00000000028d2f70 test

图 2-8 两个相同类型的对象的连续创建

Page 53: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   53

由于它们指向相同的对象,所以更改任意一个对象其实就是更改了两个。为了实现深

复制,我们需要实现 ICloneable 接口并实现 Clone 方法:

public class AClass : ICloneable

{

public int a;

public string b;

public AClass(int aa, string bb)

{

a = aa;

b = bb;

}

public object Clone()

{

return new AClass(a, b);

}

}

现在 AClass 类支持深复制,内存的简图如图 2-10 所示。

AClass b

AClass a

a 1b “hey”

GC 0

  

AClass b

AClass a

a 1b “hey”

a 1b “hey”

线程栈 GC 0

 图 2-9 引用类型的浅复制            图 2-10 引用类型的深复制

我们可以验证它和浅复制的区别:

var a = new AClass(1, "1");

var b = a.Clone();

var c = a;

Console.WriteLine(ReferenceEquals(a, b)); // FalseConsole.WriteLine(ReferenceEquals(a, c)); // True

2.5 值类型

值类型(Value Type)包括两个成员:结构体和枚举类型。通常来说,值类型就是字面

Page 54: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

54   第一部分 基 础 知 识

意义上的那种值,例如整数 int,小数 f loat/double,布尔值等。而实际上,整数、小数,布

尔值等全部都是结构体。值类型的默认值一般为 0,例如整数和小数的默认值都是 0,枚举

类型的默认值也为 0,char 的默认值为 '\0'。和引用类型相比,值类型的内存分配简单得多。

MSDN 提供了默认值表,显示了值类型的默认值:https://msdn.microsoft.com/zh-cn/library/ 83fhsxwc(VS.80).aspx。

2.5.1 基元类型

之前讲过,C# 和其他 .NET 语言都是运行在通用类型系统(CTS)上的,而 CTS 提供

一些“基本的”类型—基元类型(Primitive Type):各个 .NET 语言分别使用不同的关键字,

但最终它们都会被映射到同一个 IL 类型。这样的类型就叫做基元类型,它们由 CTS 定义,

由编译器与 BCL 直接支持,属于 BCL 而非任何某个语言。基元类型包括了几乎所有的值类

型(除了用户定义的结构体和枚举)以及字符串,object 和 dynamic。

Primitive 有原始的意思,可以将基元类型理解为基本的、原始的类型,少了它们就什

么都做不了。有了基元类型,各个 .NET 语言的互操作性就可以实现了,例如,通过 ildasm工具,我们可以查看到 int i=1 对应的 IL 代码为(这里省略了赋值的那一句 IL 代码):

.locals init ([0] int32 i)

这说明了在 IL 中 int 对应的基元类型为 Int32。当然,在 C# 中也可以直接写 Int32 i=1,

不过,这样并不会给你带来任何好处。而对于 VB.NET,int 的关键字为 Integer。如果你在

VB.NET 中声明了一个 Integer,你也可以通过 ildasm 发现,它对应的类型仍然为 Int32。

2.5.2 值类型的内存分配

这个问题分为以下几种情况:

1)值类型作为局部变量。

2)值类型作为引用类型的成员。

3)值类型中包含引用类型。

2.5.2.1 值类型作为局部变量普通的值类型总是分配在栈上。例如以最简单的 int 为例,int i=1 意味着我们在栈上开

辟了一块空间存储这个值类型。注意,int 实际上是一个结构体,它有 2 个值类型成员(最

大值,最小值),它们是常量,所以是静态的(const=static readonly)。静态的成员和 int 的方

法均存储在加载堆中。值类型也没有同步块索引和类型对象指针。所以,新建一个 int,不

会重新复制它的最大值和最小值,int 的开销永远是 4 个字节(就是它自己)。即使机器是 64位机,int 的大小永远是 32 位,因为 int 实质上是 Int32。Int64 这个基元类型在 C# 中对应

long。

对于局部变量的复制来说,情况非常简单。我们知道,值类型复制时,将只复制值的

副本。所以更改原值对复制的新值不会有影响。

Page 55: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   55

var i = 1;

var j = i;

i = 2;

// 输出 1

Console.WriteLine(j);

当执行代码 var j=i 时,将会在栈上新建一个名为 j 的变量,然后将 i 的值复制给 j,它

和 i 没有任何关系。值类型也不可能有浅复制。

2.5.2.2 值类型作为引用类型的成员如果值类型为引用类型的成员,则遵从引用类型的内存分配和复制方式。例如:

public class AClass

{

public int a;

public string b;

}

在 创 建 一 个 该 类 的 实 例 时, 遵 从 引 用 类

型的内存分配方式。下面的代码会实例化一个

AClass 对象:

var a = new AClass();

a.a = 1;

a.b = "hey";

执行完上面的代码之后,内存的分配如图 2-11所示。

其中,托管堆的方法表指针还指向 AClass 的类型对象,其位于加载堆中,图上没有画

出来。

2.5.2.3 值类型中包含引用类型如果一个结构体中包含了引用类型(例如结构体),例如包含了一个字符串或者类,则

它引用类型的那部分会遵从引用类型创建的内存分配,值类型的那部分则遵从值类型创建的

内存分配。

如果我们有这样的结构体:

public class AClass

{

public int a;

public string b;

}

public struct AStruct

{

public AClass ac;

public double c;

}

AClass a

a 1b “hey”

GC 0

图 2-11 值类型作为引用类型的成员

Page 56: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

56   第一部分 基 础 知 识

则新建这个值类型的实例时,它的引用类型成员遵循引用类型成员分配的机制,值类

型成员则储存在栈上。

var a = new AStruct();

a.ac = new AClass();

a.c = 1;

a.ac.a = 2;

a.ac.b = "hey";

在运行完上面的代码之后,内存中的情况如图 2-12 所示。

为了看得更清楚,我们复制它,然后试图更改其成员的值:

var b = a;

b.c = 999;

b.ac.a = 888;

b.ac.b = "bye";

Console.WriteLine(a.c); // 1Console.WriteLine(a.ac.a); // 888Console.WriteLine(a.ac.b); // bye

此时内存的情况,如图 2-13 所示。

GC 0

AStruct ac 1

AClass a.ac

a 2b “hey”

 

GC 0

AStruct b

AStruct a

c 999

c 1

AClass b.ac

AClass a.ac

a=888b=“bye”

图 2-12 值类型中包含引用类型(1)       图 2-13 值类型中包含引用类型(2)

通过下面的代码,我们可以证明 a.ac 和 b.ac 指向同一个对象,而 a.c 和 b.c 没有关系:

Console.WriteLine(ReferenceEquals(a.ac, b.ac)); // TrueConsole.WriteLine(ReferenceEquals(a.c, b.c)); // False

我们可以发现,即使值类型复制可以保证值类型中的值类型成员相互无关,它内部的

引用类型仍然是浅复制。

2.5.3 值类型的构造函数

你可以为值类型和引用类型定义构造函数。但对于值类型,在构造函数中必须为所有

Page 57: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   57

的成员赋值,不支持无参构造函数。例如 DateTime 类型,其为一个结构体,所以是值类

型。它的成员有年、月、日、时、分、秒、DayOfWeek(代表星期几)等。它有很多个构造

函数,每个都拥有一至多个输入。例如

public DateTime(int year, int month, int day);

虽然在这个构造函数中,我们没有传入 DayOfWeek,但在构造函数中,C# 会算出

DayOfWeek。因此,我们可以访问到 2017 年 9 月 8 日的星期信息:

var a = new DateTime(2017,9,8);

Console.WriteLine(a.DayOfWeek); // Friday

如果没有自定义构造函数,C# 默认会生成一个无参构造函数,它遍历类型中所有的

成员,并将它们设置为默认值,如默认值表所列。默认值表可以参看 https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/default-values-table。这个行为适用于所

有类型。

2.5.4 何时考虑使用值类型

人们通常将值类型认为是轻量级的引用类型,设计它的目的是提高程序的性能。如果

所有类型都是引用类型,则性能将大大下降,这是因为:

��每次新声明变量都要建立类型对象指针和同步块索引。

��内存分配必定牵扯到堆,增加 GC 的压力。

当结构体的全部属性都是值类型时,结构体不会和堆扯上关系(例如,int 就是这样的

结构体),这样做可以减轻垃圾收集器的压力。另外,选择结构体而不是类,会在初始化时

提高性能,因为不需要初始化类的两个“标准配置”,即类型对象指针和同步块索引。

因此,对于一些常见的轻量级的类型,可以考虑选择结构体。我们先看看 C# 是如何

做的。最常见的结构体莫非 int,DateTime 莫属。这里我们就拿 DateTime 做个例子。根据

MSDN(https://msdn.microsoft.com/zh-cn/library/system.datetime(v=vs.110).aspx),DateTime的属性都是值类型,其中包括 int、DateTime、TimeSpan、long 和几个枚举。我们可以看到,

这里没有一个属性不重要。而且在创建 DateTime 时要给所有属性赋予一个有意义的值,比

如 DayOfWeek、DayOfYear 等。

当你初始化一个 DateTime 时,通常都是传入年月日,系统负责将其他属性初始化,例

如计算出 DayOfWeek 等。所以参考微软的思路,我们可以认为,以下情况适合使用结构体:

��当对象的所有属性都需要在创建之初即赋值时。

��当对象的全部属性都是值类型时(如果存在引用类型,就会牵扯到内存分配到堆上的

问题,就无法减轻垃圾收集的压力了)。

��当对象不需要被继承时。

例如,二维坐标系(包括两个 double),长方形(包括长、宽、面积等一些 double)这样

的对象,适合使用结构体。

Page 58: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

58   第一部分 基 础 知 识

2.5.5 值类型是密封的

值类型一定是密封的,不支持继承。之所以这样设计,是因为如果值类型可以被其他

类型继承(尤其是引用类型),那么它的创建就会牵扯到堆上的内存分配,这违反了设计值

类型的初衷。

2.5.6 值类型和引用类型的区别与联系

它们的区别主要有:

1)所有值类型隐式派生自 System.ValueType。该类确保值类型所有的成员全部分配在

栈上。有三个例外:

a)结构体如果含有引用类型成员,则该成员也会牵扯到堆的分配。

b)静态类型,如果一个变量是静态的,则无论它是什么类型,都会分配在加载堆上。

c)局部变量被捕获升级为密封类,这个现象在闭包中会讲到。

所以,“值类型都分配在栈上,引用类型都分配在堆上”这样的说法并不准确。

2)引用类型的初值为 null。值类型则是 0,因为字符串的初值为 null,故字符串为引

用类型。

3)对于引用类型,栈中会有一个变量名和变量类型,指向堆中对象实例的地址。值类

型仅有栈中的变量名和类型,不包括指向实例的指针。

4)值类型不能被继承,引用类型则可以。典型的例子是结构体,它是值类型,所以结

构体不能被继承。但结构体里面可以包括引用类型。值类型也可以有自己的方法,例如 Int.TryParse 方法。

5)值类型的生命周期是其定义域。当值类型离开其定义域后将被立刻销毁。引用类型

则会进入垃圾回收分代算法。我们不知道何时才会销毁。

6)值类型的构造函数必须为所有成员赋值。

7)你可以重写引用类型的析构函数。值类型不需要析构函数,因为析构函数只会被垃

圾收集器调用。

8)值类型没有同步块索引,不能作为线程同步工具。

它们的联系主要有:

��值类型和引用类型可以通过装箱和拆箱互相转化。

��所有值类型都派生自 System.ValueType,它是 System.Object 的子类。

��类 和 结 构 体 都 可 以 实 现 接 口。 类 实 现 接 口 的 例 子 就 不 说 了, 结 构 体 例 如 int,DateTime 等都实现了 IComparable 接口,使得它们可以比较大小。

2.5.7 嵌套:值类型与引用类型

我们都知道链表是这样定义的:

public class LinkedList<T>

Page 59: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   59

{

public T data { get; set; }

public LinkedList<T> next { get; set; }

}

现在问题来了,假设 T 是 int,那么在 32 位机器上 new 这个对象时,需要在堆上开辟

多少字节内存?既然 LinkedList<T> 是一个类,那么,next 属性是一个引用类型。当 new这个对象时,我们需要 4 个字节的 int 以及一个地址,而 32 位操作系统的地址是 4 个字节。

所以,对于 32 位操作系统,创建一个 LinkedList<T> 需要堆上的 8 个字节,还有类的两个

标配—类型对象指针和同步块索引,共 16 字节。

假设我们将上面代码中的 class 改为 struct,其他完全不变,会发生什么?结论是编译

不通过。因为对于一个结构,CLR 无法计算出新建这个结构时,需要多少字节分配给栈。

这是因为,此时 next 属性不再是一个引用类型,而是一个值类型,所以它的大小取决于它

所有的成员大小之和(data 和 next),而它的成员又包括它自己,所以构成无限循环。而对

于引用类型,栈上只需要一个 4 字节的地址,所以不会循环下去。

2.6 装箱和拆箱

装箱与拆箱(又叫取消装箱)就是值类型与引用类型的转换,是值类型和引用类型之间

的桥梁。之所以可以这样转换是因为 CTS 允许这样做。只有值类型才存在装箱和拆箱。装

箱是隐式的,拆箱是显式的,因为你需要告诉 CLR 你要给拆出来的值赋予什么类型。

通过深入了解装箱与拆箱的过程,我们可以知道其中包含了对堆上内存的操作,故会

消耗性能,这是完全不必要的。另外值得注意的是,装箱需要比原数据更多的空间,因为它

需要两个引用类型的标准配置:类型对象指针和同步块索引。

2.6.1 装箱的过程

装箱就是把值类型转换为 object 类型或由此值类型实

现的任何接口类型(参见图 2-14):

int i = 1;

object o = i;

具体过程:

1)在堆中申请内存,内存大小为值类型的大小,再

加上额外固定空间(类型对象指针和同步块索引)。

2)将值类型的字段值拷贝到新分配的内存中。

3)返回新引用对象的内存地址(给栈上的引用)。

我们可以从图中看到,装箱就是生成图中除了一开始 i=1 的变量之外另外两块变量的过

程。实际上,仅仅通过观察 C# 代码,是无法意识到装箱的,只有访问对应的 IL 代码才能

真正观察到装箱。IL 代码的装箱指令为 box。上面两行代码对应的 IL 代码为:

Object o

int i=1

int i 1

图 2-14 装箱

Page 60: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

60   第一部分 基 础 知 识

IL_0040: ldc.i4.1

IL_0041: stloc.2

IL_0042: ldloc.2

IL_0043: box [mscorlib]System.Int32

IL_0048: stloc.3

其中前两行对应 int i=1 这句代码,后三行对应 object o=i 这句代码。

2.6.2 拆箱的过程

简单地说,就是把装箱后的引用类型转换为值类型。由于并非一定成功,所以存在抛

出异常的可能。IL 代码的拆箱指令为 unbox。具体过程:

int b = (int) o;

1)检查是否为 Null,否则抛出 NullReferenceException 异常。检查实例是否为给定值

类型的装箱值。否则抛出 InvalidCastException 异常,最后获得对象各个成员的地址

2)创建一个新的对象 b,并将第一步获得的值复制到 b 中

在 CLR via C# 中,拆箱被定义为第一步。下面的代码就是拆箱:

(int) o;

但这句代码是无法通过编译的。通常来说,我们拆箱的目的都是为了将值拷贝到一个

值类型中,所以拆箱之后,往往伴随着一次值的复制动作。上面的例子中,就将值复制到了

变量 b。

拆箱对应的 IL 代码为:

IL_000a: ldloc.1

IL_000b: unbox.any [mscorlib]System.Int32IL_0010: stloc.2

与拆箱比较,装箱的性能消耗更大,因为引用对象的分配更加复杂,成本也更高,值

类型分配在栈上,分配和释放的效率都很高。装箱过程需要创建一个新的引用类型对象

实例。

2.6.3 如何避免拆箱和装箱

在 C# 1 的时代,没有泛型,我们要定义一组自定义类型的数组只能使用 ArrayList。由

于 ArrayList 支持任何类型,所以其方法的参数全都是 object,这意味着即使我们的类型是

结构体,也会被隐式地装箱,然后在使用时再拆箱。

C# 2 的泛型解决了这个问题。我们可以通过使用泛型集合避免不必要的装箱和拆箱。

很多地方会出现隐蔽的装箱,例如,对结构体的判等。当我们要实现自定义结构体的

判等时:

struct Rectangle

{

Page 61: 第一部分 Part 1images.china-pub.com/ebook8050001-8055000/8050831/ch01.pdf · 2018-08-14 · 第1 章 .NET 基础知识 5 2016 年:.NET Core 的问世.NET Core是.NET Framework的新一代版本,也是另外一种实现方式,是微软开发的

第 2 章 C# 类型基础(上)   61

public override bool Equals(object obj)

{

}

}

我们发现,默认的签名为将两个比较对象转换为 object,这当然会引起装箱了。解决的

办法是令结构体实现 IEquatable<T> 接口:

struct Rectangle : IEquatable<Rectangle>

{

public bool Equals(Rectangle r)

{

// ... }

}

这样一来,即使类型存在两个 Equals,CLR 也会优先选择类型较小的那个,即参数为

具体类型的 Equals,而不是参数为 Object 的 Equals。

2.7 本章小结

本章主要讨论的内容是 C# 的类型基础,C# 的类型可以分为值类型和引用类型。这两

种类型既有联系也有区别,并且具有不同的初始化方式和内存分布。通过了解了它们的区

别,就可以在实际开发时有的放矢地选择使用值类型还是引用类型。

本 章 着 重 强 调 了 内 存 分 配, 也 使 用 了 WinDbg 工 具 配 合 介 绍。 这 里 有 一 个 超 猛 的

WinDbg 使用说明:http://www.voidcn.com/article/p-pkncf igp-bka.html。引用类型的内存分配

相比值类型复杂得多。

《 Pro .NET Performance 》的第三章对类型的内部讲述十分清晰,我不知道为什么这

本书现在还没有中文版,如果有哪位读者自告奋勇,请联系我,我会十分乐意和你合译这

本书。

2.8 思考题

1. 在讨论对象成员的对齐时,我们的例子是一个 class。请改用 struct 来试验并使用 WinDbg观察。另外,特性 StructLayout 可以手动控制对齐方式,试试使用它看看会有什么效果?

2. 值类型可能会分配在堆上吗?有哪几种可能?