水言木 · 做一个真正的blogger

水言木 做一个真正的blogger

.NET内存管理(4) - Dispose 和 Finalizer

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)
    {
        // 清理操作
    }
}
  1. 开发人员手工调用Dispose时,调用的是第一个公开的Dispose方法,它转而调用protectedDispose重载,并传入disposing = true,表示是手工调用Dispose。如果是 Finalizer 在调用Dispose重载,则传入disposing = false。所有的清理逻辑都应当写在protectedDispose重载中,并通过disposing参数来判断Dispose是什么时候被调用的 (这很重要,后面再说);
  2. 如果第一个Dispose被调用,那一定是开发人员手工调用,此时我们要告诉 GC,开发人员已经手工清理过了,不要再调用Finalizer,这是通过GC.SuppressFinalize(this)实现的。GC.SuppressFinalize(this)要放在Dispose(true)后面,因为要保证 Dispose 成功调用后才能不执行Finalizer;
  3. 这里添加 Finalizer 是为了说明disposing的意义,事实上,Finalizer 不属于 Dispose 模式的内容,如上一篇博文所说,99.9% 的情况下我们都不要 Finalizer;
  4. Dispose方法中不要抛出异常,除非我们觉得系统状态已经严重破坏,必须马上中止执行;
  5. 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中引用FileStreamFileStream的 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)
}
  1. 如果要调用其它托管对象的方法,一定要放到if中去,也就是说,只有在开发人员手工调用Dispose时,才可以调用其它对象的方法,这是因为 Finalizer 是无序执行的,我们内部引用了FileStream,并不意味着我们的 Finalizer 一定会先于FileStream的 Finalizer 执行,当我们的 Finalizer 执行时,FileStream的 Finalizer 可能已经先执行了,显然,此时调用FileStream上的方法是很危险的。也许被引用的对象上确实有些方法总是可以安全调用,但我们很难确定具体哪些方法可以,也许这些方法第一版本时还可以安全调用,但第二版时就不行了,所以最保险的办法就是永远别调用;
  2. 可以考虑添加一个内部字段,用来标识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,这意味着我们自己要注意调用FlushDispose来刷新缓冲,也因为这种不一致,我们写代码时不管使用的是哪个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,因为SafeHandleCriticalFinalizerObject,而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 的时候,就记着,永远不要假设它是按顺序执行的,也永远不要假设它一定会执行。

参考资料

.NET内存管理(3) - Finalizer

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()怎么办?这时FileStreamFinalizer就隆重登场了。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都存在着错误的理解,所幸用到的不多,也没有太大影响。更多内容,我们下回分解。

.NET内存管理(2) - 垃圾回收算法

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的线程没什么事干,马上把SomeClassFinalizer给执行了,那等到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 模式,下一篇文章我们将进一步研究一下它们。

参考资料

.NET内存管理(1) - 内存分配

最近比较高产,主要是因为在复习一些 .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,如下图所示。

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版

C#字符串与编码 (下)

上一篇文章我们介绍了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));

上面的代码会输出:

41-00
00-41

注意,这个输出不会因为硬件字节序的不同而产生差异,因为每种编码方式都已经指定了确切的字节序。

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,这样它们就知道文件用的是什么编码了。

参考资料