14 Jun 2015
Dispose 模式
上一篇文章中我们说过,Finalizer 是“最后一道防线”,但我们不能依赖它去释放非托管资源,因为 Finalizer 的执行时机是不确定的。如果我们分配了非托管资源,要及时手工释放。现在轮到 Dispose 模式上场了,这个模式很重要,因为它和语言是紧密结合的,例如在 C# 中 using 块结束时会自动调用相关对象的Dispose
方法。
下面是 Dispose 模式的模板:
public class MyDisposable : IDisposable
{
// 注意: Finalizer 通常是不需要的
~MyDisposable()
{
Dispose(false);
}
public void Dispose()
{
// 调用 Dispose 重载,传入 true
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
// 清理操作
}
}
- 开发人员手工调用
Dispose
时,调用的是第一个公开的Dispose
方法,它转而调用protected
的Dispose
重载,并传入disposing = true
,表示是手工调用Dispose
。如果是 Finalizer 在调用Dispose
重载,则传入disposing = false
。所有的清理逻辑都应当写在protected
的Dispose
重载中,并通过disposing
参数来判断Dispose
是什么时候被调用的 (这很重要,后面再说);
- 如果第一个
Dispose
被调用,那一定是开发人员手工调用,此时我们要告诉 GC,开发人员已经手工清理过了,不要再调用Finalizer
,这是通过GC.SuppressFinalize(this)
实现的。GC.SuppressFinalize(this)
要放在Dispose(true)
后面,因为要保证 Dispose 成功调用后才能不执行Finalizer
;
- 这里添加 Finalizer 是为了说明
disposing
的意义,事实上,Finalizer 不属于 Dispose 模式的内容,如上一篇博文所说,99.9% 的情况下我们都不要 Finalizer;
Dispose
方法中不要抛出异常,除非我们觉得系统状态已经严重破坏,必须马上中止执行;
Dispose
要允许被多次调用,调用多次和调用一次的效果要一样。我们可以在内部维护一个bool
字段,用于标识Dispose
是否已调用过,如果已调用过,再次调用时直接返回即可;
什么时候需要 Finalizer
.NET 中我们是没有办法直接分配并使用非托管资源的,我们一定是要通过 P/Invoke 调用非托管 API 才可能导致非托管资源的分配,该 P/Invoke 调用通常还会返回IntPtr
,用来作为相应非托管资源的“句柄”,以便后面释放。简单的说,只有在类中使用了IntPtr
时,才需要考虑 Finalizer。所以不能直接套用前面的 Dispose 模板,不需要 Finalizer 时要将模板中的 Finalizer 删除,但其余部分都应保留 (不删除的性能代价在上篇博文中已经说过)。
如果我们只是引用了FileStream
,则无需添加 Finalizer。FileStream
是对非托管资源的一个托管包装 (Managed Wrapper),它内部有 P/Invoke 调用,所以它会去实现 Finalizer,但实现 Finalizer 是FileStream
要做的事,不是引用了FileStream
的类要做的事。
常见的一个误解是以为只要我们引用了FileStream
就要实现 Finalizer,并在里面调用FileStream.Dispose()
。假如我们真实现了这样的 Finalizer,那当它执行的时候,调用FileStream.Dispose()
其实已经没有意义了,就算不调用,FileStream
中分配的非托管资源也会很快因为它的 Finalizer 的执行而被释放。但注意我们说的是不需要再重复实现Finalizer,而不是不实现IDisposable
,引用了FileStream
的类还是应该实现IDisposable
,其中调用FileStream.Dispose()
。
事实上,Finalizer 的调用是无序的,即使是在MyDisposable
中引用FileStream
,FileStream
的 Finalizer 也可能先于MyDisposable
的 Finalizer 执行。
.NET Framework 2.0 引入了SafeHandle
类,它是对IntPtr
的一个包装,并带有Finalizer,所以,现在的我们真的已经没有什么机会再直接使用IntPtr
了,这也是为什么 99.9% 的情况下我们都不再需要 Finalizer。
继承实现了 Dispose 模式的基类
Dispose 模式中第二个Dispose
方法被标记为protected virtual
,所以它注定是要给子类用的,子类需要这样来继承实现了 Dispose 模式的基类:
public class MyDrivedDisposable : MyDisposable
{
protected override void Dispose(bool disposing)
{
// 清理操作写在这里
// 再调用 base.Dispose(disposing)
base.Dispose(disposing);
}
}
注意重写Dispose
方法时,要在末尾调用base.Dispose(disposing)
,以保证父类的清理代码可以执行,并且这个调用要放在方法的末尾,因为子类的清理代码可能还会用到父类中的相关资源,要保证子类使用完相关资源后才能对父类进行清理。
如果子类满足前面说的添加 Finalizer 的条件,且父类未实现 Finalizer,那可在子类加上Finalizer,其中调用Dispose(false)
。如果父类已实现 Finalizer,那子类就不要再实现Finalizer 了,因为这样会导致 Dispose 方法的重复执行:
public class MyFinalizable : BaseFinalizable
{
~MyFinalizable()
{
Dispose(false);
}
}
上面的代码实际上会被编译器编译成:
public class MyFinalizable : BaseFinalizable
{
protected override void Finalize()
{
try
{
// 子类调用一次 Dispose(false)
Dispose(false);
}
finally
{
// 基类的 Finalize 中还会再调用一次 Dispose(false)
base.Finalize();
}
}
}
disposing
参数
一般说来,Dispose(bool disposing)
会这么实现:
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// 这里可以调用其它托管对象的方法
// 或按需调用其它对象的 Dispose()
}
// 这里不能调用其它托管对象的方法,
// 如果要调用,要放到上面的 if 中去。
// 这里可以释放非托管资源,例如 XXX.CloseHandle(...) 之类的调用
// 如果基类实现了 Dispose 模式,
// 这里就还要加上 base.Dispose(disposing)
}
- 如果要调用其它托管对象的方法,一定要放到
if
中去,也就是说,只有在开发人员手工调用Dispose
时,才可以调用其它对象的方法,这是因为 Finalizer 是无序执行的,我们内部引用了FileStream
,并不意味着我们的 Finalizer 一定会先于FileStream
的 Finalizer 执行,当我们的 Finalizer 执行时,FileStream
的 Finalizer 可能已经先执行了,显然,此时调用FileStream
上的方法是很危险的。也许被引用的对象上确实有些方法总是可以安全调用,但我们很难确定具体哪些方法可以,也许这些方法第一版本时还可以安全调用,但第二版时就不行了,所以最保险的办法就是永远别调用;
- 可以考虑添加一个内部字段,用来标识
Dispose
是否已调用过,如果已调用过就直接返回,这可以避免Dispose
的重复调用带来的性能影响;
Finalizer 执行的无序性
Finalizer 难写的一个主要原因在于它执行的无序性,我们很容易误以为 Finalizer 是按顺序执行的,这是很多问题的根源。考虑下面的代码:
var stream = new FileStream("C:\\Work\\temp.txt",
FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096);
var bytes = Encoding.ASCII.GetBytes("Hello World");
stream.Write(bytes, 0, bytes.Length);
GC.Collect();
GC.WaitForPendingFinalizers();
这里我特意不调用Dispose
,因为我要模拟忘记调用Dispose
的场景。FileStream
指定了 4KB 的缓冲,而我们要写入的”Hello World”显然远不足 4KB,代码的最后我们强制执行 GC,并等待 Finalizer 执行完毕,然后退出程序。
你觉得上面的代码能不能将”Hello World”写入磁盘呢?答案是确定的,也是合理的,“缓冲”其实是实现细节,对 API 使用者来说,最理想的情况就是调用FileStream.Write()
方法后,就认为内容已写入磁盘,不用过多关心缓冲之类的东西。
现在我们把代码改成这样:
var stream = new FileStream("C:\\Work\\temp.txt",
FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096);
var writer = new StreamWriter(stream, Encoding.ASCII);
writer.Write("Hello World");
GC.Collect();
GC.WaitForPendingFinalizers();
如果执行上面的代码,我们会惊奇地发现”Hello World”并没有写入到磁盘,而如果我们显式添加一行writer.Dispose()
的调用,它竟然又会如期写入磁盘,这可真是…
StreamWriter
自己有一个缓冲,要想在忘记调用Dispose
时也能刷新缓冲,就只能考虑在StreamWriter
上添加 Finalizer,然后在 Finalizer 中刷新缓冲。但问题也在这,Finalizer 是无序执行的,执行StreamWriter
的 Finalizer 时,其引用的FileStream
可能已经被Finalize
,此时刷新StreamWriter
的缓冲必然报错。如果运气好一点,FileStream
可能会在StreamWriter
之后才被Finalize
,但我们总不能靠运气写代码。
所以StreamWriter
并没有实现 Finalizer,这意味着我们自己要注意调用Flush
或Dispose
来刷新缓冲,也因为这种不一致,我们写代码时不管使用的是哪个Stream
实现,都最好显式地调用Flush
。
但有些时候,还真的就需要有个先后顺序,于是,.NET Framework 2.0 引入了 Critical Finalizer。
Critical Finalizer
CriticalFinalizerObject
是一个超级简单的抽象类,它的源码如下:
public abstract class CriticalFinalizerObject
{
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
protected CriticalFinalizerObject()
{
}
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
~CriticalFinalizerObject()
{
}
}
虽然简单,但 CLR 却对它有特殊照顾,这实际上涉及到了 CER (Constrained Execution Region),这也是个我们平常几乎用不到的东西,只有在编写可靠性要求极高的代码时才可能用到,本文不对其进行探讨。
这里我们只要知道,如果一个类从CritialFinalizerObject
继承,CLR 就会保证它的 Finalizer 会在普通的 Finalizer (即非 Critical Finalizer) 执行完后才执行,这就保证了一定的有序性。例如,FileStream
使用了SafeHandle
,因为SafeHandle
是CriticalFinalizerObject
,而FileStream
不是,所以 CLR 会保证在执行FileStream
的 Finalizer 时,SafeHandle
的 Finalizer 一定还没有执行。
需要使用 Finalizer 的时候本来就已经极少,使用 Critical Finalizer 的时候就更少了,所以我们了解了解即可。
除了保证在普通 Finalizer 之后执行,CLR 还为 Critical Finalizer 提供了另外两个保证:
1. 其Finalize
方法会在对象创建时立即被 JIT 编译,我们知道 CLR 采用的是即时编译,一个方法只有在被用到时才会被 JIT 编译,而编译方法需要内存,在内存受限系统中,如果等到要执行时才编译Finalize
,可能会导致OutOfMemoryException
。当然,如果硬要在Finalizer 中做分配内存之类的操作 (比如装箱,字符串上的方法调用等,都可能分配内存),那 CLR 也搞不定,所以除了 CLR 的努力,我们也要做相应配合;
2. 即使宿主要强制中止 (rude abort) 一个 AppDomain,那 CLR 也尽可能保证该 AppDomain 中的 Critical Finalizer 能得以执行。但 CLR 只是在尽“最大努力”保证 Finalizer 能执行,并不意味着它就能保证 Finalizer 一定会被执行 (也没办法保证),所以永远都不要假设 Finalizer 一定会执行;
总结
Dispose 是一个非常重要的模式,但千万别把它和 Finalizer 混淆,如果不是直接使用 P/Invoke 分配了非托管资源,我们永远都不需要使用 Finalizer (从 .NET Framework 2.0 开始,P/Invoke 返回的IntPtr
可以全部用SafeHandle
或其子类替代,意味着几乎没有使用 Finalizer 的时候了)。要真有需要 Finalizer 的时候,就记着,永远不要假设它是按顺序执行的,也永远不要假设它一定会执行。
参考资料
13 Jun 2015
Finalization
在System.Object
类中,有一个Finalize
虚方法,它定义为:
protected virtual void Finalize() { }
Finalize
方法的目的是,在一个对象被回收前,留下个最后的机会来做一些清理操作 (比如释放非托管资源)。.NET 中所有的类都继承自Object
,因此每个类都可以通过重写Finalize
方法来实现自己的“清理操作”。
Finalize
是一个被 CLR 特殊照顾的方法,当 GC 准备回收一个对象时,会先检查其是否重写了Finalize
,如果是,则暂不回收其内存,而把它放入一个叫做ToBeFinalized
队列中,一个专门的线程会逐一执行ToBeFinalized
队列中对象的Finalize
方法。当一个对象的Finalize
方法执行完后,它就变成了普通的垃圾对象,GC 下一次工作时会将它回收。所以,重写Finalize
方法是有代价的,它会延长一个对象的存活时间,增加内存的压力。
其实一个对象可以在执行Finalize
方法时让自己“复活”,比如把自己赋值给一个静态属性,这样它就不再是一个垃圾对象,也就不会被 GC 回收,但这是一种非常不建议的做法。
C# 设计者觉得应该在语言层面来支持这个特殊的Finalize
方法,因此 C# 提供了一个称为析构函数的语法:
public class MyObject
{
~MyObject()
{
// 清理操作
}
}
上面的代码相当于 (C# 中无法显式重写Finalize
):
public class MyObject
{
protected override void Finalize()
{
// 清理操作
}
}
如果你来自 C++ 背景,千万不要被这里的“析构函数”迷惑,虽然是一样的语法,但效果是完全不一样的。因此,在 C# 中我们也尽量不用“析构函数”这样的称呼,而称它为 Finalizer。
为什么要使用 Finalizer
这个问题的答案可能会让你失望,如果你使用的是 .NET Framework 2.0+,99.9% 的情况下,你都不需要使用Finalizer
,如果你用了,反而要考虑这可能是个错误 (除非你穿越到 10 年以前,在 .NET Framework 2.0 还没有发布的时候)。
.NET 中有 GC 来做自动内存管理,但它管理的仅仅只是内存,而程序中可能会用到许多内存之外的资源,比如文件句柄,网络连接等。当你使用这些资源的时候,要时刻警惕着,一定要在使用完后将其释放,否则会导致资源泄漏。但是人都会犯错,开发人员有可能某天心情不太好,导致写下的代码中忘记释放某个文件句柄。
CLR 想把这种失误带来的影响控制到最低,所以它提出了Finalizer
来作为最后一道防线,它通常由类库开发人员来编写。比如System.IO.FileStream
,我们可以用它来读写文件流,使用完毕后,调用FileStream.Close()
来释放相关资源。因为FileStream
封装了非托管资源的使用,所以作为FileStream
使用者来说,需要关心的事情不多,惟一需要注意的就是使用完记得调用FileStream.Close()
。
但要是忘记调用FileStream.Close()
怎么办?这时FileStream
的Finalizer
就隆重登场了。FileStream
实现人员为其添加了Finalizer
,里面有释放相关非托管资源的代码。如果FileStream
的使用者记得调用FileStream.Close()
,那就打5星,然后加薪,如果忘记调用FileStream.Close()
,那也还有Finalizer
来做最后一道防护。GC 在准备回收FileStream
对象时,看到它有定义Finalizer
,会把它放入ToBeFinalized
队列,这样,即使忘记调用FileStream.Close()
,也还有最后一次机会来释放非托管资源。
如果开发人员有及时调用FileStream.Close()
,那就不需要再执行Finalizer
,所以 .NET 基础库中提供了GC.SuppressFinalize(object)
方法来告诉 CLR 不要再执行某个对象的 Finalizer。例如下面是FileStream.Close()
方法 (实际上是定义在FileStream
的基类Stream
中):
public virtual void Close()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
Finalizer
是最后一道防线,但我们不能依赖它,因为它执行的时机是不确定的,你可能现在独占使用完某个资源,但忘记释放它,而Finalizer
可能在一小时后才执行,那这一小时内简直就是占着茅坑不拉屎,自己不用还导致别人不能用。Finalizer
甚至还可能永远都没机会执行 (具体原因后续博文再介绍),所以我们一定要记得用完就及时释放。
Finalizer 的代价
其中一个代价在文章开始时就已经提到了,Finalizer
会导致对象被放入一个ToBeFinalized
队列中,导致对象的存活时间变长。GC 把对象分成了三代,新创建的对象是第 0 代 (Generation 0),GC 对这一代对象的回收频率较高,如果一个 0 代对象在一次垃圾回收中侥幸活下来了,那它就变成 1 代对象,GC 对 1 代对象的回收频率就明显降低了,如果 GC 回收 1 代对象时它还能侥幸活下来,它就变成 2 代对象,而 GC 对 2 代对象的回收频率就更低了。所以我们平常写程序要尽量避免对象“逃”到下一代,而Finalizer
则恰好会导致一个对象在一次垃圾回收中侥幸逃脱,导致它进入下一代,进而延长了对象的存活时间。
另外,带有Finalizer
的对象的创建开销比普通对象要大,CLR 在创建这些对象后还要把它们放到一个特殊的列表中,这样垃圾回收时才知道哪些对象有Finalizer
。这个开销是不受GC.SuppressFinalize()
影响的,GC.SupressFinalize()
只是让Finalizer
不执行而已,额外的对象创建开销还是需要的。
当前 CLR 只分配了一个线程来执行Finalizer
,如果程序中创建了大量需要执行Finalizer
的对象,这个线程的压力就很大,尤其当多个 CPU 都在不停创建这类对象的时候,一对多怎么扛得住啊 (未来 CLR 有可能会使用多个线程并发执行Finalizer
)。
除此之外,Finalizer
还很难写对。本想在这篇博文里把Finalizer
写完,但现在看来这篇博文已经太长了,所以,下篇博文里我们再继续探讨为什么Finalizer
很难写对,同时还会研究下为什么在 .NET Framework 2.0 以后不需要再写Finalizer
。
总结
只要写过 .NET 代码的,应该都知道IDisposable
,但Finalizer
则相应显得陌生一些,就我自己而言,我得承认我在很长的一段时间内对Finalizer
都存在着错误的理解,所幸用到的不多,也没有太大影响。更多内容,我们下回分解。
29 May 2015
static void Main(string[] args)
{
// 创建一个 Timer,每 2 秒调用一次 OnTimerTick
var timer = new Timer(OnTimerTick, null, 0, period: 2000);
Console.ReadKey();
}
static void OnTimerTick(object state)
{
Console.WriteLine("Tick tick");
// Timer 的 Callback 每次调用完都回收一次垃圾
GC.Collect();
}
在 Release 模式下编译上面的代码,然后执行,你会期望看到什么结果?
期待每两秒就会得到一个 Tick tick 的输出? 可惜事实并非如此,我们只会看到输出一次 Tick tick,然后,然后就再也没有然后了 (可以在 Visual Studio 中试一下)。
但如果不用 Release ,而是用 Debug 模式编译,那我们就会如期看到每两秒输出一次 Tick tick。说实话,这种事情最可怕,试想你在开发机上一切正常,部署到服务器后各种错误,这会是怎样一种感受!不过本文会解释这个“奇怪”现象发生的原因和解决办法。
Mark and Sweep 垃圾回收算法
所谓垃圾回收,就是当我们创建的对象“不再被程序使用”时,把它占用的内存回收回来,那就需要明确一个问题,一个对象怎样才是“不再被程序使用”? Mark and Sweep 算法认为,如果一个对象是不可达的 (unreachable),那它就“不再被程序使用”,也就成了可以回收的垃圾,反之则不是垃圾,CLR GC 用得便是这个算法。
每个程序都有一些根对象 (roots),它们不被其它对象引用,比如静态变量、局部变量,或方法参数,但只有引用类型的对象才被认为是根对象,值类型的变量不关 GC 什么事,不去管它。
在 C# 中,如果一个局部变量在匿名方法中被用到了,那因为闭包的原因,这个局部变量会变成编译器生成的一个匿名类的属性,这时就有一个我们“看不见”的根对象产生了。
在 CLR GC 进行垃圾回收时,遍历所有的根对象,给它们做上标记,表示它们是可达的 (reachable),同时,也遍历它们所引用的其它对象,也给它们做上同样的标记,完了之后,GC 就知道当前哪些对象是可达的,哪些是不可达的了,这称为标记阶段 (Mark Phase)。
于是,托管堆上未被标记为可达的对象就都被当作垃圾了,所以接下来 GC 的工作就是回收这些不可达对象占用的内存,然后压缩托管堆,完了 CLR 还得纠正所有对象引用的地址 (因为压缩后托管堆上对象的内存地址发生了变化)。这称为压缩阶段 (Compact Phase)。
“回收对象的内存”只是一种形象的表达,事实上,GC 只要把托管堆上分散开的可达对象移到一起 (压缩),然后再移动NextObjPtr
指针到相应位置就可以了。
另外,Mark and Sweep 算法中第二个阶段称为 Sweep 阶段,但为了突出“压缩”动作,CLR GC 的第二个阶段称为 Compact 阶段更合适;
引用计数
曾经还有一个称为“引用计数 (Reference Counting)”的垃圾回收算法,这个算法认为,如果一个对象没有被其它对象引用 (引用计数为 0),那它就是可被回收的“垃圾”。
但引用计数有个比较严重的问题: 循环引用。假设 A 对象引用了 B,B 也引用了 A,那它们的引用计数永远都大等于1,也就意味着它们永远都不会被回收,这就可能导致内存泄漏。而如果是 Mark and Sweep 算法,因为这时 A 和 B 都是不可达的 (假设它们没被其可达对象引用),所以它们都会被回收,也就不存在内存泄漏的问题。所以,引用计数后来就逐渐被 Mark and Sweep 取代了。
CLR 的优化
显然,一个对象占用内存的时间越短,内存的压力就越小,当然就越好,所以 CLR 对对象生命周期的管理相当苛刻,这也就直接导致了本文开头处的现象。
1. static void Main(string[] args)
2. {
3. // 创建一个 Timer,每 2 秒调用一次 OnTimerTick
4. var timer = new Timer(OnTimerTick, null, 0, period: 2000);
5.
6. Console.ReadKey();
7. }
8.
9. static void OnTimerTick(object state)
10. {
11. Console.WriteLine("Tick tick");
12. // Timer 的 Callback 每次调用完都回收一次垃圾
13. GC.Collect();
14. }
直观上,我们可能会觉得Main
方法中创建的Timer
对象在方法返回前都不应该被回收,这和局部变量在线程栈上的生命周期一致,也好理解。但 CLR 认为这不够优化,如果一个局部变量从方法的某一行代码开始就再也没用过,那它所指向的托管对象就可以被当作垃圾了。Main
方法中的timer
自从声明并赋值后就再也没被用过,所以从第 5 行开始,这个才刚刚创建的Timer
对象就已经是垃圾了 (但尚未回收)。
假如执行到第 5 行时,GC 刚好开始工作,那连一次 Tick tick 的输出都看不到 (试下在第 5 行加上GC.Collect()
)。我们之所以能看到一次输出,是因为示例程序中没分配什么对象,所以第 5 行时 GC 还没有被触发。而我们在第 13 行处强制进行了垃圾回收,所以此时Timer
对象就被回收了,也就看不到第二次输出了。
实际上,JIT 在编译一个方法时,会在内部创建一个表结构来存储各个局部变量的使用情况,它会记录一个局部变量在哪一行开始被使用,及哪一行结束使用。比如上面的timer
,JIT 会记录它最后一次被使用的位置是第 3 行 (实际上,JIT 记录的是编译后指令的偏移地址)。
假设执行到第 5 行时 GC 开始工作,GC 检查 JIT 创建的内部表结构,发现timer
已不再被使用,于是就知道timer
所指向的Timer
对象可以当成垃圾了,于是在 Compact 阶段中就将它给回收了。
Debug 模式下的不同行为
有意思的是,上面描述的现象只在 Release 模式下发生,在 Debug 模式时就不会,这是因为 CLR 团队考虑到,如果在 Visual Studio 中调试代码时,也那么早就把Timer
对象当成垃圾,那开发人员调试代码时可能就没办法在 Watch 窗口中方便地检查timer
,所以如果是在 Debug 模式下编译的,JIT 在编译方法时会自动将局部变量的生命周期延长到方法的末尾。
CLR 的这些优化是好事,但同时也可能给开发人员带来一些难解的困惑,比如一段代码,在开发期明明一切正常,部署后就不正常了。所幸这种情况只有在类似Timer
这样的对象上才会发生,普通的对象,比如String
,不论它在方法未执行完时就被回收,还是到方法返回时才被回收,对程序的正确性都没有影响。
GC.KeepAlive(obj)
显然Timer
的例子中我们不希望Timer
被过早回收,这有很多种解决办法,一种是在方法的后面“使用”一下timer
,比如调用其Dispose
方法:
static void Main(string[] args)
{
var timer = new Timer(OnTimerTick, null, 0, period: 2000);
Console.ReadKey();
// 通过“使用” timer 来防止被回收
timer.Dispose();
}
这样就可以顺利防止Timer
被过早回收,但要注意下面的这种“使用”法是无效的:
static void Main(string[] args)
{
var timer = new Timer(OnTimerTick, null, 0, period: 2000);
Console.ReadKey();
// 无法防止 Timer 被回收
timer = null;
}
另一种方法是使用GC.KeepAlive()
方法:
static void Main(string[] args)
{
var timer = new Timer(OnTimerTick, null, 0, period: 2000);
Console.ReadKey();
// 防止 Timer 被过早回收
GC.KeepAlive(timer);
}
如果去看GC.KeepAlive()
方法的源代码,会发现它什么事都没做,是一个空方法,它存在的意义就是让 JIT 认为timer
还在被使用。所以,我们也可以自己加一个空方法来防止Timer
被回收:
static void Main(string[] args)
{
var timer = new Timer(OnTimerTick, null, 0, period: 2000);
Console.ReadKey();
// 防止 Timer 被过早回收
DoNotCollectMe(timer);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void DoNotCollectMe(object obj)
{
// 什么都不做
}
这样也能防止Timer
过早被回收。但在开启优化选项时 (Release 模式下编译默认会开启优化选项),编译器可能会内联DoNotCollectMe
方法,导致DoNotCollectMe(timer)
这行调用在优化编译后丢掉了,所以我们要使用MethodImplOptions.NoInlining
来告诉编译器不要内联这个方法。
另一个例子
下面的这段代码,你能看出它有什么潜在问题吗?
public class SomeClass
{
// 非托管资源
private IntPtr _unmanagedResource;
public SomeClass()
{
// 这里分配非托管资源
// _unmanagedResource = ...;
}
~SomeClass()
{
// 这里释放 _unmanagedResource
}
public void DoSomeWork()
{
// 接下来准备使用 _unmanagedResource
// (假设后面没再用到 this)
}
}
static void Main()
{
// 调用 DoSomeWork 方法
new SomeClass().DoSomeWork();
Console.ReadKey();
}
GC 回收垃圾时,如果发现一个对象定义了Finalizer
(C# 中用 C++ 中析构函数的语法来定义Finalizer
),就不会回收它的内存,而是把它放到一个To be finalized
队列中,而另一个专门的线程会去执行To be finalized
队列中对象的Finalizer
方法,完了之后下一次垃圾回收时,该对象才会被真正被回收。
这个类在构造函数中分配了非托管资源 (IntPtr
),而在Finalizer
中释放了相应的非托管资源,这一切看起来都很正常。但实际上,它有一个潜在的问题: 假如执行DoSomeWork
方法时,恰好 GC 开始工作,那在DoSomeWork
方法后面使用_unmanagedResource
时,_unmanagedResource
可能已被释放!
也就是说,一个实例方法还没执行完时,它所在的类实例就可能已经被垃圾回收。什么?! 哥已经凌乱了,但事实就是如此。
不知道大家如何感觉,反正我第一次知道这个的时候感觉很惊讶,因为我下意识里觉得方法是“属于”类实例的,要是实例都没了,方法怎么还能执行? 但这种归属关系其实只是面向对象语言高度的抽象带给我们的错觉。
如前面所述,Main
函数中DoSomeWork
方法调用后就没再用过这个SomeClass
对象,并且,在DoSomeWork
方法内部没有用到this
,因此,如果 GC 在执行DoSomeWork
方法时开始工作,它就会认为this
(即当前的SomeClass
实例)是垃圾,可以被回收,因为SomeClass
定义了Finalizer
,所以此时this
会被放入To be finalized
队列,如果恰好此时负责执行Finalizer
的线程没什么事干,马上把SomeClass
的Finalizer
给执行了,那等到DoSomeWork
继续往下执行时,_unmanagedResource
就已经失效了(在Finalizer
执行时被释放),后面的事情有多危险我就不说了。
下面的代码可以模拟这个问题的发生:
public class SomeClass
{
public SomeClass()
{
Console.WriteLine("分配非托管资源");
}
~SomeClass()
{
Console.WriteLine("释放非托管资源");
}
public void DoSomeWork()
{
// 强制垃圾回收,并等待 Finalizer 执行完
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("使用非托管资源");
}
}
static void Main(string[] args)
{
new SomeClass().DoSomeWork();
Console.ReadKey();
}
在 Release 模式下编译并执行,会得到输出:
知道问题的原因后,解决办法也很简单,就是在DoSomeWork
方法的后面加上一行GC.KeepAlive(this)
:
public void DoSomeWork()
{
// 这里好好享用 _unmanagedResource
GC.KeepAlive(this);
}
但对于日常开发来说,这种情况其实很少会碰到,.NET 中有一个SafeHandle
类,它包装了IntPtr
,并且实现了标准的 Dispose 模式,所以我们只要记得,把使用IntPtr
的地方改成使用SafeHandle
,生活就会变得更加轻松 (又安全又少写代码)。
总结
本文主要探讨了 CLR GC 中使用的垃圾回收算法,以及 CLR 对对象生命周期近乎苛刻的管理,从中也可以感觉到 CLR 团队在性能优化上花的心思,向他们致敬。文中也稍稍提到了Finalizer
和 Dispose 模式,下一篇文章我们将进一步研究一下它们。
参考资料
27 May 2015
最近比较高产,主要是因为在复习一些 .NET 基础。和以前不同的是,现在我决定书每看完一部分就要动手实践,并在博客里记录下来 (在这两个过程中通常会发现很多问题)。这其实是谁都懂得道理,我也在很多年前就“懂”了,但实际上,各种原因 (主要是懒) 会让我把这个简单有效的道理忘得一干二净,这导致我看的很多书约等于白看 (看一遍吸收 1% 的意思)。幸运的是,我对这个道理重新认识得还不算太晚,所以…废话不多说,进入正题吧。
在 .NET 中创建一个对象的开销到底有多大?如果你还在纠结多创建几个对象会不会影响性能这样的问题,就需要来一起好好地探讨一下 (当然,实际应用中我们需要 Profiling,不能仅凭经验来下结论)。
托管内存的分配
众所周知,CLR 的世界里有一个叫“托管堆 (Managed Heap)”的东西,引用类型的对象都会被分配在托管堆上 (值类型在装箱后也会分配在托管堆上)。创建一个对象需要做许多事,包括计算存储对象所需的空间,添加 header,分配内存等。接下来我们主要讨论内存的分配。
假设托管堆空间充足,那为一个对象分配内存的开销其实非常小。首先托管堆上的对象是连续分配的 (注意在进行垃圾回收之前,是只分配而不回收内存的哦),而托管堆内部维护了一个NextObjPtr
的指针,指在下一个“空位”上,所以为一个对象分配内存,不过是从NextObjPtr
开始预留一段内存空间用来存放新对象,然后再把NextObjPtr
往后移而已。
假设为对象 C 分配内存前,托管堆如下所示:
+---+---+------------------------+
| A | B | |
+---+---+------------------------+
↑
NextObjPtr
现在为 C 分配内存,分配之后,就变成:
+---+---+---+--------------------+
| A | B | C | |
+---+---+---+--------------------+
↑
NextObjPtr
但我们不可能这样无止境地分配而不回收,所以,当托管堆内存被分配到一定程度后,GC (Garbage Collector) 会开始工作,除了回收不再被引用的对象的内存外,它还有一个重要的任务是压缩托管堆。
接上图的例子,假设现在 B 对象不再被引用 (变成垃圾),并且 GC 开始工作,于是 B 对象占用的内存被回收,同时托管堆被压缩,然后变成这样:
+---+---+------------------------+
| A | C | |
+---+---+------------------------+
↑
NextObjPtr
需要注意,因为这个“压缩”的存在,托管堆中不会有“空洞”存在 (碎片, Fragmentation)。
这样的机制有两大优势:
(1) 在没有进行垃圾回收的时候,内存的分配非常快 (基本上就是移动一下指针),快到什么程度呢?可能比 C 语言的malloc
还快;
(2) 因为对象是连续分配的,所以还有局部性 (Locality) 的优势,因为局部性原理,代码中要使用的对象可能都已经乖乖躺在 CPU Cache 中了,这就减少了对内存的访问。
C 语言内存的分配
这里指的是 C 语言标准库中的 malloc
函数。在 C 里面,我们可以调用malloc
在进程堆上分配内存,然后再调用free
释放内存。和 CLR 托管堆相比,它主要有两个问题:
内存碎片 (Fragmentation)
一开始时可能很好,像 CLR 的托管堆一样,在进程堆上连续分配了一片内存,但当你释放掉一些内存的时候,就可能会出现很多“空洞“(内存碎片),这时就有点纠结了,下次分配内存时分配在哪里呢?如果“洞”够大,那直接分配在“洞”里就可以了,但要是“洞”太小,这个“洞”里的空闲内存就显得有点鸡肋了。更要命的是,如果“小洞”非常多,即使合计起来总剩余空间非常充足,但因为每个“洞”都太小,导致没有任何一个“洞”可以满足内存分配的请求。
+---+---+---+---+---+---+---+---+---+
| A | | B | C | | D | E | | F |
+---+---+---+---+---+---+---+---+---+
G:我要放哪里?
+---+---+
| G |
+---+---+
如上图所示,堆中还有三个空位,加起来本足以放下 G 了,但是却不行,因为每个“洞”都太小。
内存分配效率
如前面所述,C 语言中内存的分配和回收可以由程序员控制,所以空闲内存并不是连续的,因此需要一种机制来把这些不连续的空闲内存给管理起来,它就是 Free List,如下图所示。

(图源: 深入理解计算机系统)
上图的箭头把空闲内存块都串起来了,没错,这是一个链表,所以在 C 中分配内存时,需要遍历链表直到找到一块合适的空闲内存 (不同的分配策略有所不同,比如采用 First fit 时只要找到第一块合适的内存块就停止查找,而 Best fit 则要遍历整个链表,然后在合适的内存块里选最小的那个),这显然比 CLR 托管堆中的内存分配要慢得多,尤其当内存碎片较多的时候。
但即使碎片再多,C 运行时也不能随意压缩进程堆,因为这会导致已创建对象的内存地址发生变化,这显然是不能接受的 (C中可直接使用指针)。.NET 则不同,在 .NET 中我们拿到手的都是对象引用,引用不是指针,在 GC 压缩托管堆之后,所有对象引用所指向的内存地址都会被 CLR 纠正。
当然,C# 也允许在非安全代码中直接使用指针,既然 GC 会压缩托管堆,那我们还能好好地使用指针吗?当然可以,要不然干嘛允许我们用。这是因为 CLR 提供了一些机制,允许指定对象被临时 Pin 住,这可以保证在 GC 压缩托管堆的时候,这些对象不会被移动,这些机制包括GCHandle
及 C# 的fixed
关键字等。
总结
所以,如果没有进行垃圾回收,CLR 内存分配的效率是非常高的,但这也不是说垃圾回收是多么可怕的一件事,事实上 M$ 对 CLR 做了很多优化,只要我们稍加注意,就不会有太大影响,具体细节我们下回分解。
参考资料
- 《CLR via C#》第3版
- 《深入理解计算机系统》第2版
21 May 2015
上一篇文章我们介绍了Unicode,它定义了一个供全人类使用的字符集合以及各自对应的码位 (Code position / code point),而 UTF-32 和 UTF-16 是两种编码形式 (Encoding forms),它们负责把码位以不同的方式映射到各自的编码单元 (Code unit),比如 UTF-32 将码位一一映射到其 4 字节的编码单元,而 UTF-16 将 0x0000 - 0xFFFF 之间的码位映射到其 2 字节的编码单元,但对 0xFFFF 之上的码位则映射到两个编码单元。
但事实上,除了 UTF-32 和 UTF-16,我们更耳熟能详的应该是 UTF-8。
UTF-8
UTF-8 应用非常广泛,即使是个刚入行的小白,也应该会经常听到前辈说,“把文件保存成 UTF-8”,“这个讨厌的网站居然用的是 GB2312 编码”,等等。
之所以这么流行,是因为 UTF-8 完全兼容 ASCII,对于 ASCII 字符,UTF-8 使用和 ASCII 完全一样的编码方式,同样只使用一个字节,这就意味着,如果被编码的字符仅含 ASCII 字符,那即使是用 UTF-8 进行编码,只支持 ASCII 的旧系统仍然能够准确地解码。同时,如果被编码的字符大部分是 ASCII 字符,因为只占用一个字节,UTF-8 也最节省空间。
但有得必有失,对于其它字符,UTF-8 则需要采用二到四字节进行编码。
// 输出 UTF-8: 3 bytes
Console.WriteLine(
"UTF-8: " + Encoding.UTF8.GetBytes("ABC").Length + " bytes");
// 输出 UTF-16: 6 bytes
Console.WriteLine(
"UTF-16: " + Encoding.Unicode.GetBytes("ABC").Length + " bytes");
// 输出 UTF-32: 12 bytes
Console.WriteLine(
"UTF-32: " + Encoding.UTF32.GetBytes("ABC").Length + " bytes");
上面的代码对比了三种编码对”ABC”进行编码的结果,UTF-8 只需要三字节。
// 输出 UTF-8: 6 bytes
Console.WriteLine(
"UTF-8: " + Encoding.UTF8.GetBytes("我们").Length + " bytes");
// 输出 UTF-16: 4 bytes
Console.WriteLine(
"UTF-16: " + Encoding.Unicode.GetBytes("我们").Length + " bytes");
// 输出 UTF-32: 8 bytes
Console.WriteLine(
"UTF-32: " + Encoding.UTF32.GetBytes("我们").Length + " bytes");
上面的代码对比了三种编码分别对”我们”进行编码的结果,UTF-8 需要六字节,而 UTF-16 只需要 四字节。所以,如果大部分是中文字符,UTF-16 相对会更节省空间。而 UTF-32,无论哪种情况它基本上都是最差的,所以它应用不是很广泛,但它有一个好处是每个字符统一占用 4 字节,处理起来简单,所以在内存中使用时也可以看情况考虑 UTF-32。
UTF-8 的编码单元是 8 位,是面向字节的 (Byte-oriented),但具体的编码算法这里不做过多介绍,网上搜索一下会有很多相关内容。
字节序 (Byte-order / Endianness)
现在我们知道了,Unicode 定义了字符集及它们各自对应的码位,Encoding Forms (UTF-32, UTF-16 和 UTF-8) 将码位映射到编码单元 (Code unit),但这里实际上还没涉及具体的存储,涉及具体的存储时,我们需要把 UTF-32 细分为 UTF-32BE 和 UTF-32LE,相对应的 UTF-16 需要细分为 UTF-16BE 和 UTF-16LE,这些称为编码方案 (Encoding schemes),其中的 BE 是 Big-endian 的意思,LE 是 Little-endian 的意思。
Big-endian 和 Little-endian
吃一个鸡蛋的时候,要先从大头吃起呢,还是从小头吃起?这实际上是一个很纠结的问题,故事里的人们曾因为这个问题爆发过战争。
故事来源于英国作家斯威夫特的《格列佛游记》,在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-End)敲开还是从小头(Little-End)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。
当一个数据类型需要占用两个或两个以上的字节时,需要怎么在内存里存储呢?比如 C# 里声明为 Int16 类型的 31,占用两个字节,二进制表示是 0000 0000 0001 1111,这看起来有点头疼,我们把它转成 16 进制,就变成 00 1F,所以,Int16 类型的 31 的第一个字节是 00,第二个字节是 1F,这个顺序我们把它称作逻辑顺序。那我们也很自然的想到,在内存里存储时,先存 00,然后在下一个内存地址上存储 1F,如下所示:
内存地址增长方向
------------>
0x100 0x101
------+--------+--------+------
.. | 00 | 1F | ..
------+--------+--------+------
(0x100 和 0x101 表示内存地址,仅作示例用,其值没有特殊含义)
但是有些人却不这么认为,他们觉得应该要先存 1F,然后再存 00,于是,同样是 31,在他们看来应该要这样存储:
0x100 0x101
------+--------+--------+------
.. | 1F | 00 | ..
------+--------+--------+------
那么,就存在了两种选择:
(1) 先存高位字节 (Most significant byte),然后再存低位字节,那 00 1F 就会被存储成 00 1F,这种叫做 Big-endian (“大头”先来);
(2) 先存低位字节 (Least significant byte),然后再存高位字节,那 00 1F 就会被存储成 1F 00,这种叫做 Little-endian (“小头”先来);
不同的处理器可能会采用不同的字节序,Intel 的处理器大部分是 Little-endian,在 C# 中,可以通过BitConverter.IsLittleEndian
获得这个信息。
x86,MOS Technology 6502,Z80,VAX,PDP-11 等处理器为 Little endian; Motorola 6800,Motorola 68000,PowerPC 970,System/370,SPARC (除V9外) 等处理器为 Big endian; ARM,PowerPC (除PowerPC 970外),DEC Alpha,SPARC V9,MIPS,PA-RISC and IA64 的字节序是可配置的;
– Wikipedia
字节序的影响
首先,对于只占用一个字节的对象,是不影响的 (只有一个字节,何来谁先谁后)。其次,即使是占用两个字节或以上的对象,大部分情况下也不会影响,因为 C# 已经把这个事情做的相当透明了,但当我们要在代码中直接处理内存数据时,就不能不考虑字节序了;或者网络传输时,也要保证通信的双方都能理解传输的数据所采用的字节序。
现在我们通过 C# 来看一下,Int16 是不是真的像前面说的这样存储:
Int16 value = 31;
byte[] bytes = BitConverter.GetBytes(value);
Console.WriteLine("Little-endian: " + BitConverter.IsLittleEndian);
Console.WriteLine(BitConverter.ToString(bytes));
上面的代码在我的电脑上运行时会输出:
Little-endian: True
1F-00
这是因为我的机器用的是 Little-endian。如果你用的是 Big-endian 的机器,上面的字节数组就不是[0x1F, 0x00]
,而是[0x00, 0x1F]
,这时如果你把你机器上拿到的这个字节数组丢给我 (Little-endian), 而我却不经处理地直接将它转成 Int16,得到的 Int16 值就不正确:
// 假设这是来自于 Bit-endian 的机器的字节数组,期望值是 31
byte[] bytes = new byte[] { 0x00, 0x1F };
// 在我 Little-endian 的机器上直接将它转成 Int16
Int16 value = BitConverter.ToInt16(bytes, 0);
// 输出结果
Console.WriteLine(value);
上面的代码在我的电脑上运行时会输出 7936,就不再是我们所期望的 31 了。
Int32 和字节序
好了,再来一个例子。Int16 占用两个字节,如果是占用四个字节的 Int32,比如 0x01234567,在 Big-endian 的机器和 Little-endian 的机器上进行存储时,分别是怎样的呢?思考一下
.
.
.
好了,也比较简单,如果是 Big-endian 的机器,那它和我们的直觉是一样的,高位字节先来:
0x100 0x101 0x102 0x103
------+--------+--------+--------+-------+------
.. | 01 | 23 | 45 | 67 | ..
------+--------+--------+--------+-------+------
如果是 Little-endian,就“反转”一下:
0x100 0x101 0x102 0x103
------+--------+--------+--------+-------+------
.. | 67 | 45 | 23 | 01 | ..
------+--------+--------+--------+-------+------
UTF-16BE 和 UTF-16LE
UTF-16 的编码单元是两字节 (16位),已经超过了一个字节,这时就需要考虑字节序。
比如字符”A”,它的 Unicode 码位是 0x0041,对应的 UTF-16 编码也是 0x0041,如果对它进行编码时,把 00 放在 41 前面(高位字节先来),就是 UTF-16 Big-Endian (UTF-16BE),如果把 41 放在 00 前面(低位字节先来),就是 UTF-16 Little-Endian (UTF-16LE)。
UTF-16 属于 Encoding Form,而 UTF-16BE 或 UTF-16LE 属于 Encoding Scheme,区别是,前者将 Unicode 码位映射成编码单元 (还没有涉及实际存储),而后者将 Code unit 映射成 serialized byte sequence (翻译不出来,但是大家应该明白我的意思)。
我们同样可以通过 C# 来验证一下:
// UTF-16 Little-endian
var utf16_LE = Encoding.Unicode;
var littleEndianBytes = utf16_LE.GetBytes(str);
Console.WriteLine(BitConverter.ToString(littleEndianBytes));
// UTF-16 Big-endian
var utf16_BE = Encoding.BigEndianUnicode;
var bigEndianBytes = utf16_BE.GetBytes(str);
Console.WriteLine(BitConverter.ToString(bigEndianBytes));
上面的代码会输出:
注意,这个输出不会因为硬件字节序的不同而产生差异,因为每种编码方式都已经指定了确切的字节序。
UTF-32BE 和 UTF-32LE
UTF-32 的编码单元是四字节 (32位),同样超过了一个字节,所以也有 Big-endian 和 Little-endian 的区分,这和 UTF-16 类似,不再赘述。在 .NET 中,静态属性 System.Text.Encoding.UTF32
是 Little-endian 的,如果需要 Big-endian,则需要手工创建System.Text.UTF32Encoding
实例,其构造函数的第一个参数可以指定字节序。
Byte Order Mark (BOM)
如果拿到一个 UTF-16 编码的文本文件,但却不知道到底是哪种字节序,UTF-16LE 还是 UTF-16LE? 怎么办?说实话这个问题真比较难办。
不过有一个办法,就是在文件保存时,在文件起始处添加几个特殊的字节来标识用的是哪种字节序,这几个特殊的字节就称为 Byte Order Mark (BOM)。在 UTF-16 中,BOM 是两个字节 (一个编码单元),Big-endian 对应的 BOM 是 0xFEFF,Little-endian 对应的 BOM 是 0xFFFE。但注意 BOM 并不是强制要写入到文件里的。
所谓的“自动检测文件编码”,也就是检测文件开头的几个字节,比如你发现文件的前两字节是 0xFEFF,那就可以用 UTF-16BE 对它进行解码,如果前两字节是 0xFFFE,就用 UTF-16LE。但需要注意的是,这个是没有办法保证完全正确的,有可能有的文件用的其它编码,但前两字节还真就刚好是 0xFEFF,出于什么目的我不懂,反正就是这样,所以你也没办法。
UTF-32 的 BOM 也类似,只不过 UTF-32 的编码单元是 4 字节,所以它用 0x0000FEFF 表示 Big-endian,而用 0xFFFE0000 表示 Little-endian。
UTF-8 的 BOM 永远都是 0xEFBBBF,这是因为 UTF-8 是 Byte-oriented 的,因为其特殊的编码规则,所以不需要 BOM。UTF-8 的 BOM 据说 M$ 的一个发明,目的就是标识这个文件用的是 UTF-8 编码,在其它平台不用,所以在保存 UTF-8 文件时,推荐不添加 BOM。
在 Windows 下,可以通过记事本来试验一下 BOM 是如何影响记事本程序对文件编码的理解的。
var filePath = "C:\\Work\\tmp.txt";
var bytes = Encoding.Unicode.GetBytes("你好");
// 将字节数组写入文件
using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
}
运行上面的 C# 代码,然后在 Windows Explorer 中打开 tmp.txt,会发现我们看到的是乱码,这是因为我们用了 UTF-16LE,但没有将 BOM 写入到文件,所以记事本不知道文件是采用什么编码,所以它只好使用默认的编码打开,就变成乱码了。
如果我们把 BOM 写入到文件中:
var filePath = "C:\\Work\\tmp.txt";
var bytes = Encoding.Unicode.GetBytes("你好");
// 将字节数组写入文件
using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
// 写入 BOM
byte[] bom = new byte[] { 0xFF, 0xFE };
fs.Write(bom, 0, bom.Length);
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
}
这次再打开 tmp.txt 就会正常地看到“你好”了。这说明记事本在打开文本文件时,是会先检查 BOM 的。
总结
大部分情况下我们都可以忽略字节序的存在,但需要在字节这个级别上处理数据时,就不得不考虑字节序了。采用什么样的字节序,更多的是当时硬件设计人员的一个选择而已,虽然不同人的不同选择给我们造成了困扰,但现在事实就是如此,也无法逃避,只能去认识它。因为字节序的原因,我们就有了 UTF-16LE 和 UTF16BE,以及 UTF-32LE 和 UTF32BE,为了方便读取文件内容的程序,我们可以在文件头部添加 BOM,这样它们就知道文件用的是什么编码了。
参考资料