[总结篇]C#性能优化-垃圾回收-每个细节都有示例代码

在这里插入图片描述

前言

在C#开发中,性能优化是提升系统响应速度和资源利用率的关键环节。
当然,同样是所有程序的关键环节。
通过遵循下述建议,可以有效地减少不必要的对象创建,从而减轻GC的负担,提高应用程序的整体性能。记住,优化应该是有针对性的,只有在确定了性能瓶颈之后,才应该采取相应的措施。

垃圾回收

垃圾回收解放了手工管理对象的工作,提高了程序的健壮性,但副作用就是程序代码可能对于对象创建变得随意。

1.避免不必要的对象创建

由于垃圾回收的代价较高,所以C#程序开发要遵循的一个基本原则就是避免不必要的对象创建。

1.1 避免不必要的对象实例化

尽量重用对象而不是频繁地创建和销毁它们。
例如,对于小对象或短期使用的对象,考虑使用对象池(Object Pooling)技术。

// 不推荐:每次调用方法都会创建一个新的StringBuilder实例
public string Concatenate(string a, string b)
{
    return new StringBuilder().Append(a).Append(b).ToString();
}

// 推荐:重用同一个StringBuilder实例
private readonly StringBuilder _builder = new StringBuilder();

public string Concatenate(string a, string b)
{
    _builder.Clear();
    _builder.Append(a);
    _builder.Append(b);
    return _builder.ToString();
}

1.2. 使用值类型代替引用类型

当对象较小且生命周期较短时,考虑使用结构体(struct)而非类(class)。然而,这也需要权衡,因为过多的大结构体传递可能会导致更多的堆栈复制开销。

1.3. 尽量减少大型对象堆上的分配

大于85KB的对象会直接分配在大型对象堆(Large Object Heap, LOH)上,这会导致额外的GC压力。因此,尽量避免创建过大的对象或者将大对象拆分成多个小对象。

1.4. 实现IDisposable接口(下面文章会详细说)

如果类持有非托管资源(如文件句柄、数据库连接等),应实现IDisposable接口,并正确释放这些资源,以减少长时间占用内存的可能性。

1.5. 注意隐式装箱和拆箱

避免不必要的装箱(boxing)和拆箱(unboxing)操作,因为它们不仅增加了GC的压力,还可能降低程序的执行效率。

// 不推荐:对值类型进行隐式装箱
object obj = 10;

// 推荐:直接使用值类型的变量
int number = 10;

1.6.避免循环创建对象

如果对象并不会随每次循环而改变状态,那么在循环中反复创建对象将带来性能损耗。高效的做法是将对象提到循环外面创建。

  • 假设我们需要在一个循环中多次使用同一个Regex对象来进行字符串匹配操作。如果我们直接在循环内部创建这个Regex对象,每次迭代都会产生一个新的对象实例,这不仅浪费内存,还增加了垃圾回收的负担。

未优化的代码示例:

using System;
using System.Text.RegularExpressions;

public class Program
{
    public static void Main()
    {
        string[] inputs = { "abc123", "def456", "ghi789" };
        
        foreach (string input in inputs)
        {
            // 每次循环都创建新的Regex对象
            Regex regex = new Regex(@"\d+");
            Match match = regex.Match(input);
            if (match.Success)
            {
                Console.WriteLine($"Found digits: {match.Value}");
            }
        }
    }
}

优化后的代码示例:

为了避免在每次循环中都创建新的Regex对象,我们可以将Regex对象的创建移到循环外部,因为这里的正则表达式模式是固定的,并不随输入变化。

using System;
using System.Text.RegularExpressions;

public class Program
{
    public static void Main()
    {
        // 将Regex对象的创建移至循环外部
        Regex regex = new Regex(@"\d+");
        string[] inputs = { "abc123", "def456", "ghi789" };
        
        foreach (string input in inputs)
        {
            Match match = regex.Match(input);
            if (match.Success)
            {
                Console.WriteLine($"Found digits: {match.Value}");
            }
        }
    }
}

通过将不会改变的对象(在这个例子中是Regex对象)创建移到循环外部,我们可以显著地减少不必要的对象创建和内存分配,从而提升程序性能。这种方法尤其适用于那些需要频繁调用且构造成本较高的对象。

1.7 在需要逻辑分支中创建对象

  • 如果对象只在某些逻辑分支中才被用到,那么应只在该逻辑分支中创建对象。
  • 假设我们有一个应用程序,根据用户输入的选项执行不同的操作。对于某些选项,我们需要创建特定类型的对象来处理业务逻辑;而对于其他选项,则不需要这些对象。在这种情况下,我们应该只在必要的逻辑分支中创建这些对象。
using System;

public class Program
{
    public static void Main()
    {
        Console.WriteLine("请输入选项 (A/B/C):");
        string option = Console.ReadLine();

        // 根据用户输入决定是否创建对象
        if (option == "A")
        {
            // 只有当用户选择A时,才创建MyClassA实例
            MyClassA objA = new MyClassA();
            objA.DoSomething();
        }
        else if (option == "B")
        {
            // 只有当用户选择B时,才创建MyClassB实例
            MyClassB objB = new MyClassB();
            objB.DoSomethingElse();
        }
        else
        {
            Console.WriteLine("未识别的选项.");
        }
    }
}

public class MyClassA
{
    public void DoSomething()
    {
        Console.WriteLine("执行 MyClassA 的操作.");
    }
}

public class MyClassB
{
    public void DoSomethingElse()
    {
        Console.WriteLine("执行 MyClassB 的不同操作.");
    }
}
`` 

解释

  • 懒初始化:在这个例子中,MyClassAMyClassB的实例只有在满足特定条件(即用户选择了相应的选项)时才会被创建。这样做可以避免在不必要的情况下分配内存和调用构造函数。
  • 资源管理:通过这种方式,还可以帮助更好地管理资源,例如文件句柄、数据库连接等,确保它们仅在真正需要时被打开或创建。

这种方法不仅有助于提升性能,还能让代码更加清晰和易于维护。如果您有任何更具体的需求或想了解更多的编程技巧,请随时提问!

1.8 使用常量避免创建对象

  • 程序中不应出现如 new Decimal(0) 之类的代码,这会导致小对象频繁创建及回收,正确的做法是使用Decimal.Zero常量。我们有设计自己的类时,也可以学习这个设计手法,应用到类似的场景中。
  • 使用常量来代替频繁创建的对象是一种优化程序性能和资源管理的有效方法。在C#中,像Decimal.Zero这样的静态只读字段(或常量)提供了一种避免重复创建相同对象的方式,这不仅节省了内存,还减少了垃圾回收器的负担。

为何避免频繁创建小对象?

  1. 内存占用:每次创建新对象都会分配新的内存空间,即使这些对象是相同的值或状态。
  2. 垃圾回收压力:频繁创建和丢弃对象会增加垃圾回收的频率,从而影响应用程序的性能。
  3. 性能损耗:对象的创建和销毁都需要时间,过多的操作会影响程序运行效率。

使用常量的例子

Decimal.Zero为例,如果你需要表示数值0,直接使用Decimal.Zero而不是new Decimal(0)可以避免不必要的对象创建。这对于其他类型的类也是适用的,特别是那些具有固定不变状态的类。

自定义类示例

下面是一个如何在自定义类中应用这种设计手法的简单示例:

public class Temperature
{
    public double Value { get; private set; }

    // 定义一个零度的静态只读实例
    public static readonly Temperature Zero = new Temperature(0);

    // 构造函数私有化,防止外部随意构造对象
    private Temperature(double value)
    {
        Value = value;
    }

    // 其他方法...
    
    public override string ToString()
    {
        return $"Temperature: {Value}°C";
    }
}

class Program
{
    static void Main(string[] args)
    {
        // 使用预定义的Zero实例而不是每次都创建新实例
        var temp = Temperature.Zero;
        Console.WriteLine(temp);
    }
}

关键点

  • 静态只读字段:如上面的Zero字段,它提供了对特定状态的一个共享引用,避免了重复创建对象的需求。
  • 构造函数访问修饰符:在这个例子中,将构造函数设为私有,确保了不能从类的外部随意创建该类的新实例,强制使用预定义的状态实例。
  • 重写ToString方法:方便输出对象信息,用于验证是否正确使用了预定义实例。

通过这种方式,你可以在自己的类中实现类似Decimal.Zero的设计模式,有效地减少对象的创建,提高程序的性能和效率。

1.9 使用StringBuilder做字符串连接

使用StringBuilder做字符串连接
在C#中,当你需要进行大量的字符串连接操作时,使用StringBuilder类通常比直接使用字符串连接(如使用+运算符)更加高效。这是因为字符串在C#中是不可变的,每次修改字符串实际上都是创建了一个新的字符串对象,这会导致性能下降和额外的内存消耗,特别是当连接操作在一个循环内执行时。

StringBuilder的优势

  • 可变性StringBuilder允许你在同一个实例上进行多次修改而不创建新的对象。
  • 性能:对于大量字符串拼接操作,使用StringBuilder能显著提高性能,因为它减少了不必要的对象创建和垃圾回收。
  • 灵活性:提供了丰富的方法来插入、追加、移除或替换子字符串。

使用StringBuilder的例子

下面是一个简单的示例,演示了如何使用StringBuilder来进行字符串连接:

using System;
using System.Text;

class Program
{
    static void Main()
    {
        StringBuilder sb = new StringBuilder();

        // Append some strings
        sb.Append("Hello, ");
        sb.Append("world");
        sb.Append("!");

        // Insert a string at the beginning
        sb.Insert(0, "Message: ");

        // Replace part of the string
        sb.Replace("world", "C# user");

        Console.WriteLine(sb.ToString());
    }
}

这段代码首先创建了一个StringBuilder实例,然后通过调用其Append方法添加了一些字符串。接着,它在字符串的开头插入了一段文本,并且替换了其中的一部分内容。最后,通过调用ToString()方法将StringBuilder的内容转换为一个普通的字符串并打印出来。

何时使用StringBuilder

  • 当你需要对同一个字符串进行多次修改时。
  • 尤其是在循环内部进行字符串连接时,使用StringBuilder可以避免由于频繁创建临时字符串对象而导致的性能问题。

总之,虽然对于少量的字符串连接操作来说,直接使用+运算符既简单又足够高效,但对于涉及到大量字符串操作的情况,使用StringBuilder能够带来明显的性能提升。

2.不要使用空析构函数

如果类包含析构函数,由创建对象时会在 Finalize 队列中添加对象的引用,以保证当对象无法可达时,仍然可以调用到 Finalize 方法。垃圾回收器在运行期间,会启动一个低优先级的线程处理该队列。相比之下,没有析构函数的对象就没有这些消耗。如果析构函数为空,这个消耗就毫无意 义,只会导致性能降低!因此,不要使用空的析构函数。

在实际情况中,许多曾在析构函数中包含处理代码,但后来因为种种原因被注释掉或者删除掉了,只留下一个空壳,此时应注意把析构函数本身注释掉或删除掉。

错误示范:含有空析构函数

public class ResourceHolderWrong
{
    // 构造函数
    public ResourceHolderWrong()
    {
        // 初始化资源
    }

    // 空析构函数,不推荐
    ~ResourceHolderWrong()
    {
        // 本来是用来释放资源的,但现在没有实际操作
    }
}

正确示范:移除空析构函数

public class ResourceHolderCorrect
{
    // 构造函数
    public ResourceHolderCorrect()
    {
        // 初始化资源
    }

    // 直接移除了析构函数,因为我们不需要它来进行资源清理
}

// 如果确实需要进行资源清理,应该实现 IDisposable 接口
public class ResourceHolderWithCleanup : IDisposable
{
    // 标记是否已经释放资源
    private bool disposed = false;

    // Dispose 方法
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // 告诉垃圾回收器不用再调用终结器
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // 释放托管资源
            }

            // 释放非托管资源

            disposed = true;
        }
    }

    // 如果需要,可以保留终结器以防止用户忘记调用 Dispose 方法
    ~ResourceHolderWithCleanup()
    {
        Dispose(false);
    }
}

在这个正确的示范中,我们通过实现IDisposable接口来手动管理资源的释放,而不是依赖于析构函数。这样做不仅可以避免因为空析构函数带来的性能问题,还可以让用户更加明确何时以及如何释放资源。如果确实需要确保资源能够被释放,即使用户忘记了调用Dispose方法,也可以保留一个非空的析构函数作为备用。但是,最好的实践是始终鼓励使用Dispose方法来显式地释放资源。

3. 实现 IDisposable 接口

垃圾回收事实上只支持托管内在的回收,对于其他的非托管资源,例如 Window GDI 句柄或数据库连接,在析构函数中释放这些资源有很大问题。原因是垃圾回收依赖于内在紧张的情况,虽然数据库连接可能已濒临耗尽,但如果内存还很充足的话, 垃圾回收是不会运行的。

C#的 IDisposable 接口是一种显式释放资源的机制。通过提供 using 语句,还简化了使用方式(编译器自动生成 try … finally 块,并在 finally 块中调用 Dispose 方法)。对于申请非托管资源对象,应为其实现 IDisposable 接口,以保证资源一旦超出 using 语句范围,即得到及时释放。这对于构造健壮且性能优良的程序非常有意义!

为防止对象的 Dispose 方法不被调用的情况发生,一般还要提供析构函数,两者调用一个处理资源释放的公共方法。同时,Dispose 方法应调用 System.GC.SuppressFinalize(this),告诉垃圾回收器无需再处理 Finalize 方法了。

在C#中,实现IDisposable接口是管理非托管资源的一种推荐做法。这样做可以确保你的对象能够正确地释放它们持有的资源,而不是依赖于垃圾回收器的不确定性行为来完成这项工作。以下是如何正确实现IDisposable接口的基本指南和一个示例。

实现IDisposable接口的基本步骤

  1. 声明实现IDisposable接口:让你的类实现IDisposable接口。
  2. 提供Dispose方法:实现Dispose()方法,在其中执行所有必要的清理操作,并调用GC.SuppressFinalize(this)以避免最终化器被调用。
  3. 提供一个受保护的虚方法用于实际的清理逻辑:这样子类可以重写此方法以清理它们自己的资源。
  4. 提供析构函数(可选):如果需要的话,你可以提供一个析构函数作为安全网,以防Dispose()没有被调用。

示例代码

下面是一个简单的例子,演示了如何在一个包含非托管资源的类中实现IDisposable接口:

using System;

public class ResourceHolder : IDisposable
{
    // 非托管资源
    private IntPtr nativeResource;
    
    // 托管资源
    private Component someComponent = new Component();

    public ResourceHolder()
    {
        // 初始化非托管资源
        nativeResource = /* 假设这里初始化了一些非托管资源 */;
    }

    // 实现Dispose方法
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            // 释放托管资源
            someComponent.Dispose();
        }
        
        // 释放非托管资源
        if (nativeResource != IntPtr.Zero)
        {
            // 假设这里有一些代码来释放非托管资源
            nativeResource = IntPtr.Zero;
        }
    }

    // 析构函数作为安全网
    ~ResourceHolder()
    {
        Dispose(false);
    }
}

class Program
{
    static void Main(string[] args)
    {
        using (var holder = new ResourceHolder())
        {
            // 使用holder进行一些操作
        } // 在此处自动调用Dispose
    }
}

在这个例子中,ResourceHolder类实现了IDisposable接口,并且通过Dispose()方法提供了明确的资源释放机制。Dispose(bool disposing)是一个受保护的虚方法,它包含了实际的清理逻辑。当用户使用using语句时,会在超出作用域时自动调用Dispose()方法,从而保证资源得到及时释放。同时,为了防止Dispose()未被调用的情况,还提供了一个析构函数作为最后的安全网。

4. String 操作

4.1.使用 StringBuilder 做字符串连接

String 是不变类,使用 + 操作连接字符串将会导致创建一个新的字符串。如果字符串连接次数不是固定的,例如在一个循环中,则应该使用 StringBuilder 类来做字符串连接工作。因为 StringBuilder 内部有一个 StringBuffer ,连接操作不会每次分配新的字符串空间。只有当连接后的字符串超出 Buffer 大小时,才会申请新的 Buffer 空间。典型代码如下:

StringBuilder sb = new StringBuilder( 256 );
for ( int i = 0 ; i < Results.Count; i ++ )
{
    sb.Append (Results[i]);
}

如果连接次数是固定的并且只有几次,此时应该直接用 + 号连接,保持程序简洁易读。实际上,编译器已经做了优化,会依据加号次数调用不同参数个数的 String.Concat 方法。例如:

string str = str1 + str2 + str3 + str4;

会被编译为 String.Concat(str1, str2, str3, str4)。该方法内部会计算总的 string 长度,仅分配一次,并不会如通常想象的那样分配三次。作为一个经验值,当字符串连接操作达到 10 次以上时,则应该使用 StringBuilder。

这里有一个细节应注意:StringBuilder 内部 Buffer 的缺省值为 16 ,这个值实在太小。按 StringBuilder 的使用场景,Buffer 肯定得重新分配。经验值一般用 256 作为 Buffer 的初值。当然,如果能计算出最终生成字符串长度的话,则应该按这个值来设定 Buffer 的初值。使用 new StringBuilder(256) 就将 Buffer 的初始长度设为了256。

4.2.避免不必要的调用 ToUpper 或 ToLower 方法

string是不变类,调用ToUpperToLower方法都会导致创建一个新的字符串。如果被频繁调用,将导致频繁创建字符串对象。这违背了前面讲到的“避免频繁创建对象”这一基本原则。

例如,bool.Parse方法本身已经是忽略大小写的,调用时不要调用ToLower方法。

另一个非常普遍的场景是字符串比较。高效的做法是使用 Compare 方法,这个方法可以做大小写忽略的比较,并且不会创建新字符串。

还有一种情况是使用 HashTable 的时候,有时候无法保证传递 key 的大小写是否符合预期,往往会把 key 强制转换到大写或小写方法。实际上 HashTable 有不同的构造形式,完全支持采用忽略大小写的 key: new HashTable(StringComparer.OrdinalIgnoreCase)

4.3.最快的空串比较方法

将String对象的Length属性与0比较是最快的方法:

if (str.Length == 0)

其次是与String.Empty常量或空串比较:

if (str == String.Empty)if (str == "")

注:C#在编译时会将程序集中声明的所有字符串常量放到保留池中(intern pool),相同常量不会重复分配。

5. 多线程

5. 1. 线程同步

线程同步是编写多线程程序需要首先考虑问题。C#为同步提供了 Monitor、Mutex、AutoResetEvent 和 ManualResetEvent 对象来分别包装 Win32 的临界区、互斥对象和事件对象这几种基础的同步机制。C#还提供了一个lock语句,方便使用,编译器会自动生成适当的Monitor.Enter 和 Monitor.Exit 调用。
线程同步是多线程编程中非常关键的一部分,它确保了当多个线程同时访问共享资源时数据的一致性和完整性。C#提供了多种机制来实现线程同步,每种都有其特定的应用场景和优缺点。

同步机制

  1. Monitor:提供了一种简单的机制来锁定对象,以确保只有一个线程可以执行指定的代码块。Monitor.Enter()用于获取锁,而Monitor.Exit()则用于释放锁。C#中的lock语句实际上是Monitor的一个简化使用形式。
  2. Mutex:与Monitor类似,但它可以在进程之间使用,允许不同进程中的线程进行同步。MutexMonitor更重量级,因为它涉及到了内核对象。
  3. AutoResetEvent 和 ManualResetEvent:这两种机制基于信号量概念,允许一个或多个线程等待其他线程完成某个任务。AutoResetEvent在被一个线程释放后自动重置,而ManualResetEvent需要手动重置。
  4. lock 语句:这是C#中提供的一个便捷的同步方法,用于简单地锁定对象,防止其他线程进入临界区。编译器会自动生成Monitor.EnterMonitor.Exit调用,并将其放在try...finally块中,以确保即使发生异常也会正确释放锁。

示例代码

下面是一个使用lock语句的基本示例,演示如何保护共享资源免受并发访问的影响:

Csharp深色版本

using System;
using System.Threading;

class Program
{
    private static readonly object _lock = new object();
    private static int _sharedResource = 0;

    static void Main()
    {
        // 创建两个线程尝试修改共享资源
        Thread t1 = new Thread(IncrementResource);
        Thread t2 = new Thread(IncrementResource);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        Console.WriteLine($"Final value of shared resource: {_sharedResource}");
    }

    static void IncrementResource()
    {
        for (int i = 0; i < 1000; i++)
        {
            // 使用lock语句确保对共享资源的访问是线程安全的
            lock (_lock)
            {
                _sharedResource++;
            }
        }
    }
}

在这个例子中,我们创建了一个共享资源_sharedResource,并从两个不同的线程中对其进行递增操作。为了保证递增操作的原子性,避免竞态条件,我们使用了lock语句来同步对共享资源的访问。这确保了在同一时刻只有一个线程能够执行临界区内的代码,从而维护了数据的一致性。

通过合理选择和应用这些同步机制,你可以编写出高效且线程安全的多线程程序。每种同步机制都有其适用场景,理解它们的工作原理和差异对于解决具体的并发问题至关重要。

5.1.1 同步粒度

同步粒度可以是整个方法,也可以是方法中某一段代码。为方法指定MethodImplOptions.Synchronized 属性将标记对整个方法同步。
例如:

[MethodImpl(MethodImplOptions.Synchronized)]
public static SerialManager GetInstance()
{
    if (instance == null )
    {
        instance = new SerialManager();
    }
    return instance;
}

通常情况下,应减小同步的范围,使系统获得更好的性能。简单将整个方法标记为同步不是一个好主意,除非能确定方法中的每个代码都需要受同步保护。

5.1.2 同步策略

使用 lock 进行同步,同步对象可以选择 Type、this 或为同步目的专门构造的成员变量。

避免锁定Type

锁定Type对象会影响同一进程中所有AppDomain该类型的所有实例,这不仅可能导致严重的性能问题,还可能导致一些无法预期的行为。这是一个很不 好的习惯。即便对于一个只包含static方法的类型,也应额外构造一个static的成员变量,让此成员变量作为锁定对象。

锁定Type对象是一个常见的反模式,因为它可能会导致整个应用程序域中所有该类型的实例都被影响,从而引起不必要的性能瓶颈和潜在的死锁问题。这是因为Type对象是全局唯一的,所以锁定它会影响到整个进程中的所有线程对该类型的操作。

  • 更好的做法
    一个更推荐的做法是为每个需要同步访问的资源或代码段提供一个专门的私有静态对象作为锁对象。这样可以最小化锁的作用范围,减少对其他不相关的代码路径的影响,并且避免了锁定公共对象可能带来的风险。

  • 示例改进
    这里提供了一个改进后的示例,展示了如何正确地使用一个私有的静态对象来代替锁定Type对象:

using System;
using System.Threading;

class SafeSynchronizationExample
{
    // 专用的锁对象,用于保护共享资源
    private static readonly object _lockObject = new object();
    private static int _sharedResource = 0;

    public static void Main()
    {
        // 创建多个线程尝试修改共享资源
        Thread[] threads = new Thread[5];
        for (int i = 0; i < threads.Length; i++)
        {
            threads[i] = new Thread(IncrementResource);
            threads[i].Start();
        }

        foreach (Thread thread in threads)
        {
            thread.Join();
        }

        Console.WriteLine($"Final value of shared resource: {_sharedResource}");
    }

    private static void IncrementResource()
    {
        for (int i = 0; i < 1000; i++)
        {
            // 使用专用的锁对象进行同步
            lock (_lockObject)
            {
                _sharedResource++;
            }
        }
    }
}

在这个改进的例子中,我们定义了一个私有的静态成员变量_lockObject作为锁对象,而不是直接使用当前类型的Type对象(例如:typeof(SafeSynchronizationExample))。这样做不仅减少了锁的影响范围,也避免了因锁定公共对象而可能引发的问题。

此外,将锁对象声明为readonly是一种良好的实践,这确保了在初始化之后锁对象不会被改变,从而维护了同步机制的完整性。通过这种方式,你可以更加安全、有效地管理多线程环境下的资源共享问题。

避免锁定 this

锁定 this 会影响该实例的所有方法。假设对象 obj 有 A 和 B 两个方法,其中 A 方法使用 lock(this) 对方法中的某段代码设置同步保护。现在,因为某种原因,B 方法也开始使用 lock(this) 来设置同步保护了,并且可能为了完全不同的目的。这样,A 方法就被干扰了,其行为可能无法预知。所以,作为一种良好的习惯,建议避免使用 lock(this) 这种方式。

使用为同步目的专门构造的成员变量;

这是推荐的做法。方式就是 new 一个 object 对象, 该对象仅仅用于同步目的。

如果有多个方法都需要同步,并且有不同的目的,那么就可以为些分别建立几个同步成员变量。

锁定当前实例(即使用 lock(this))确实可能导致意想不到的行为,特别是当有多个方法或外部代码尝试对同一个实例进行锁定时。为了避免这种情况,推荐的做法是创建专门用于同步的私有成员变量。

  • 为什么避免锁定 this

    • 潜在的竞争条件:如果不同的方法出于不同的目的锁定相同的对象(this),那么它们可能会相互阻塞,即使这些方法实际上并不需要互相等待。
    • 外部锁定风险:由于 this 引用是公共的,外部代码也可以对其进行锁定,这增加了不可预测行为的风险。
    • 降低锁的粒度:通过为每个需要保护的资源或代码段使用独立的锁对象,可以更精确地控制并发访问,从而提高性能并减少死锁的可能性。
  • 推荐做法
    对于每一个需要同步的方法或资源,应该定义一个专用的私有对象作为锁。这样不仅减少了锁的影响范围,也避免了不必要的竞争条件。

  • 示例代码
    以下是一个改进的例子,展示了如何使用专用的锁对象来代替锁定 this

using System;
using System.Threading;

class SafeSynchronizationExample
{
    // 为第一个共享资源准备的专用锁对象
    private readonly object _lockObjectA = new object();
    // 为第二个共享资源准备的专用锁对象
    private readonly object _lockObjectB = new object();

    private int _resourceA = 0;
    private int _resourceB = 0;

    public void IncrementResourceA()
    {
        lock (_lockObjectA)
        {
            // 操作_resourceA...
            _resourceA++;
            Console.WriteLine($"Resource A incremented to {_resourceA}");
        }
    }

    public void IncrementResourceB()
    {
        lock (_lockObjectB)
        {
            // 操作_resourceB...
            _resourceB++;
            Console.WriteLine($"Resource B incremented to {_resourceB}");
        }
    }

    public static void Main()
    {
        var example = new SafeSynchronizationExample();

        // 创建线程操作ResourceA
        Thread threadA1 = new Thread(example.IncrementResourceA);
        Thread threadA2 = new Thread(example.IncrementResourceA);

        // 创建线程操作ResourceB
        Thread threadB1 = new Thread(example.IncrementResourceB);
        Thread threadB2 = new Thread(example.IncrementResourceB);

        threadA1.Start();
        threadA2.Start();
        threadB1.Start();
        threadB2.Start();

        threadA1.Join();
        threadA2.Join();
        threadB1.Join();
        threadB2.Join();
    }
}

在这个示例中,我们分别为两个可能被并发访问的资源 _resourceA_resourceB 创建了各自的锁对象 _lockObjectA_lockObjectB。这样做确保了不同资源之间的操作不会互相干扰,并且降低了因为锁定 this 而带来的潜在问题。此外,将锁对象声明为 readonly 是一种良好的实践,它保证了锁对象在初始化之后不会被改变,从而维护了同步机制的安全性和有效性。

5.1.3 集合同步

C#为各种集合类型提供了两种方便的同步机制:Synchronized 包装器和 SyncRoot 属性。

// Creates and initializes a new ArrayList
ArrayList myAL = new ArrayList();
myAL.Add( " The " );
myAL.Add( " quick " );
myAL.Add( " brown " );
myAL.Add( " fox " );
// Creates a synchronized wrapper around the ArrayList
ArrayList mySyncdAL = ArrayList.Synchronized(myAL);

调用 Synchronized 方法会返回一个可保证所有操作都是线程安全的相同集合对象。考虑 mySyncdAL[0] = mySyncdAL[0] + "test" 这一语句,读和写一共要用到两个锁。一般讲,效率不高。推荐使用 SyncRoot 属性,可以做比较精细的控制。

在C#中处理集合类型的线程安全问题时,提供了两种机制:Synchronized包装器和SyncRoot属性。然而,值得注意的是,对于新的开发工作,直接使用这些方法并不总是推荐的做法。下面我将详细解释这两种机制,并提供现代的替代方案。

Synchronized包装器

正如你提到的,通过调用ArrayList.Synchronized方法可以获取一个线程安全的包装器。这种方法的优点是简单易用,但正如你指出的,它可能不是最高效的解决方案,尤其是在需要进行读写操作时。这是因为每个单独的操作(如读取或写入)都需要获取锁,这可能导致性能瓶颈,特别是在高并发环境下。

SyncRoot属性

SyncRoot属性提供了一种更精细的控制方式,允许开发者手动管理对集合的访问。这意味着你可以决定何时锁定和解锁资源,从而可能实现更高的效率和更好的并发控制。然而,这也增加了代码的复杂性,因为开发者必须确保正确地使用锁来避免竞态条件和其他多线程问题。

现代替代方案

随着.NET的发展,现在更推荐使用以下几种方式来实现线程安全的集合操作:

  1. 使用System.Collections.Concurrent命名空间下的集合类:例如ConcurrentBag<T>ConcurrentQueue<T>ConcurrentStack<T>等。这些集合类被设计为支持高度并发的场景,通常比手动同步提供更好的性能和易用性。
  2. 使用lock语句或其他高级同步原语:对于自定义的数据结构或更复杂的同步需求,可以考虑使用lock关键字、Monitor类、Mutex类或者ReaderWriterLockSlim类等。这些工具提供了更加灵活的同步控制能力。
  3. Immutable Collections:在某些情况下,使用不可变(immutable)集合也是一种有效的策略。不可变集合一旦创建后就不能修改,任何“修改”操作都会返回一个新的集合实例。这种方式天然地避免了并发修改的问题。

示例代码使用ConcurrentQueue<T>

using System;
using System.Collections.Concurrent;

class Program
{
    static void Main()
    {
        ConcurrentQueue<string> queue = new ConcurrentQueue<string>();
        queue.Enqueue("The");
        queue.Enqueue("quick");
        queue.Enqueue("brown");
        queue.Enqueue("fox");

        while (queue.TryDequeue(out string result))
        {
            Console.WriteLine(result);
        }
    }
}

总之,虽然C#提供了Synchronized包装器和SyncRoot属性作为传统的线程安全集合解决方案,但在现代.NET应用中,根据具体的需求选择合适的集合类型或同步机制通常是更优的选择。

5.2.使用 ThreadStatic 替代 NameDataSlot

存取 NameDataSlot 的 Thread.GetData 和 Thread.SetData 方法需要线程同步,涉及两个锁:一个是 LocalDataStore.SetData 方法需要在 AppDomain 一级加锁,另一个是 ThreadNative.GetDomainLocalStore 方法需要在 Process 一级加锁。如果一些底层的基础服务使用了 NameDataSlot,将导致系统出现严重的伸缩性问题。

规避这个问题的方法是使用 ThreadStatic 变量。示例如下:

public sealed class InvokeContext
{
    [ThreadStatic]
    private static InvokeContext current;
    private Hashtable maps = new Hashtable();
}

使用ThreadStatic特性替代NameDataSlot(通过Thread.GetDataThread.SetData访问)是一个优化线程本地存储(TLS, Thread Local Storage)访问性能的好方法。这样做可以避免由于线程同步带来的开销,特别是在高并发场景下。下面是对如何使用ThreadStatic以及其优势的详细解释。

使用 ThreadStatic

ThreadStatic是C#中的一种属性,它标记一个静态字段为每个线程提供单独的存储空间。这意味着即使在不同的线程上访问相同的静态字段,它们实际上将指向不同的内存位置。这非常适合于需要在线程间保持独立状态的场景。

示例代码:

public sealed class InvokeContext
{
    [ThreadStatic]
    private static InvokeContext current;

    private Hashtable maps = new Hashtable();

    // 提供一个方法来初始化当前线程的InvokeContext实例
    public static void Initialize()
    {
        if (current == null)
        {
            current = new InvokeContext();
        }
    }

    // 提供一个属性来获取当前线程的InvokeContext实例
    public static InvokeContext Current
    {
        get
        {
            return current;
        }
    }

    // 示例方法,用于演示如何使用maps
    public object GetValue(object key)
    {
        return maps[key];
    }

    public void SetValue(object key, object value)
    {
        maps[key] = value;
    }
}

优点

  1. 性能提升:通过消除对锁的需求,提高了程序的可伸缩性和性能,特别是在多线程环境中。
  2. 简化同步逻辑:由于每个线程都有自己的数据副本,因此不需要额外的同步机制来保护这些数据。
  3. 降低复杂性:与使用NameDataSlot相比,使用ThreadStatic使得代码更加直观和易于理解。

注意事项

  • 初始化问题:如上述示例所示,当使用[ThreadStatic]时,不能依赖构造函数进行字段初始化,因为静态构造函数仅执行一次。需要手动确保每个线程都正确地初始化了该字段。
  • 不支持继承:如果基类和派生类都声明了带有[ThreadStatic]的字段,则这些字段将是独立的,不会共享值。
  • 跨AppDomain问题ThreadStatic变量在同一个进程的不同AppDomain之间是不共享的。

总之,对于那些需要高效管理线程本地数据的应用程序,ThreadStatic提供了比传统的NameDataSlot更好的解决方案。然而,在使用时需要注意正确的初始化方式,并考虑到它在某些特定情况下的局限性。

5.3.多线程编程技巧

5.3.1. 使用 Double Check 技术创建对象

internal IDictionary KeyTable
{
    get
    {
        if ( this ._keyTable == null )
        {
            lock ( base ._lock)
            {
                if ( this ._keyTable == null )
                {
                    this ._keyTable = new Hashtable();
                }
            }
        }
        return this ._keyTable;
    }
}

创建单例对象是很常见的一种编程情况。一般在 lock 语句后就会直接创建对象了,但这不够安全。因为在 lock 锁定对象之前,可能已经有多个线程进入到了第一个 if 语句中。如果不加第二个 if 语句,则单例对象会被重复创建,新的实例替代掉旧的实例。如果单例对象中已有数据不允许被破坏或者别的什么原因,则应考虑使用 Double Check 技术。

  • 使用双重检查锁定(Double-Check Locking)模式来实现线程安全的延迟初始化。这种技术旨在减少在获取同步锁上的开销,同时确保对象仅被初始化一次,即使在多线程环境下也是如此。下面是对该模式的详细解释以及一些额外的最佳实践建议。
双重检查锁定解释

双重检查锁定是一种设计模式,用于减少同步的性能开销,特别是在创建单例对象或执行其他形式的延迟初始化时。它通过首先不加锁地检查实例是否已被初始化,如果未初始化,则进入同步块进行第二次检查并创建实例。这样做可以避免每次访问该属性时都进行昂贵的锁获取操作。

你的示例代码:

internal IDictionary KeyTable
{
    get
    {
        if (this._keyTable == null) // 第一次检查
        {
            lock (base._lock)
            {
                if (this._keyTable == null) // 第二次检查
                {
                    this._keyTable = new Hashtable();
                }
            }
        }
        return this._keyTable;
    }
}
优点
  1. 性能:减少了不必要的锁竞争,提高了并发性能。
  2. 安全性:确保了单例对象的唯一性,防止了多个线程同时初始化同一个对象的问题。
注意事项

尽管双重检查锁定是一个强大的工具,但在C#中实现它时还需要注意以下几点:

  • Volatile 关键字:为了防止指令重排序带来的问题,应该将_keyTable字段声明为volatile。这保证了字段的读写不会被编译器或处理器重新排序,从而确保了一个线程对字段的修改能够立即对其他线程可见。
private volatile IDictionary _keyTable;
  • 现代替代方案:在.NET中,对于简单的单例模式,可以考虑使用更简单且无锁的方式,比如利用静态构造函数或者Lazy<T>类型来实现延迟初始化。这些方法不仅更简洁,而且由于运行时的优化,它们通常也足够高效。

例如,使用Lazy<T>:

private static readonly Lazy<IDictionary> _keyTable = new Lazy<IDictionary>(() => new Hashtable());
internal IDictionary KeyTable => _keyTable.Value;

这种方式不仅实现了延迟初始化,还自动处理了线程安全性问题,使得代码更加简洁和易读。

总的来说,双重检查锁定是一种有效的实现线程安全延迟初始化的方法,尤其是在需要高度控制初始化过程的情况下。然而,在实际应用中,考虑到代码的可维护性和.NET提供的更高级的语言特性,探索其他可能更简洁、更高效的解决方案也是值得的。

6.类型系统

6.1.避免无意义的变量初始化动作

CLR保证所有对象在访问前已初始化,其做法是将分配的内存清零。因此,不需要将变量重新初始化为0、false或null。

需要注意的是:方法中的局部变量不是从堆而是从栈上分配,所以C#不会做清零工作。如果使用了未赋值的局部变量,编译期间即会报警。不要因为有这个印象而对所有类的成员变量也做赋值动作,两者的机理完全不同!

理解CLR(Common Language Runtime)如何处理变量的初始化对于编写高效且无误的C#代码至关重要。下面是对这个问题更详细的解释:

对象字段的自动初始化

在C#中,当一个对象被实例化时,CLR会为该对象分配内存,并将这些新分配的内存区域清零。这意味着所有的字段(包括值类型和引用类型)都会有一个默认值:

  • 对于数值类型的字段,默认值是0或者其对应的零值。
  • 对于引用类型的字段,默认值是null
  • 对于布尔类型的字段,默认值是false

因此,在类定义中显式地将字段初始化为其默认值(如int x = 0;bool isActive = false;)是不必要的,因为CLR已经保证了这一点。这样做不仅没有带来任何好处,还可能增加代码的冗余度,降低代码的可读性。

public class ExampleClass
{
    private int number; // 不需要初始化为0,CLR已经保证了
    private bool flag; // 默认就是false
    private string text; // 默认是null
}

局部变量的初始化

与上述情况不同的是,局部变量(即方法内部声明的变量)并不享受这种自动初始化的待遇。这是因为局部变量是从栈上分配而非堆上,CLR不会对它们进行类似的零初始化操作。如果尝试使用未赋值的局部变量,编译器会在编译期间发出警告或错误。

public void ExampleMethod()
{
    int localVar; // 错误:使用未赋值的局部变量
    Console.WriteLine(localVar); // 编译器报错
}

因此,在使用局部变量之前,必须明确地给它们赋值。

结论

  • 成员变量:不需要手动将其设置为其默认值(0、false、null等),因为CLR已经确保了这一点。
  • 局部变量:必须在使用前手动赋值,否则会导致编译错误。

正确理解和应用这些原则可以帮助你避免不必要的变量初始化动作,从而编写更加简洁、高效的代码。同时,这也反映了深入理解语言运行机制的重要性,有助于提高代码质量和开发效率。

6.2. ValueType 和 ReferenceType

6.2.1. 以引用方式传递值类型参数

值类型从调用栈分配,引用类型从托管堆分配。当值类型用作方法参数时,默认会进行参数值复制,这抵消了值类型分配效率上的优势。作为一项基本技巧,以引用方式传递值类型参数可以提高性能。

在C#中,值类型(如int, struct等)和引用类型(如class)有着不同的内存分配方式和传递机制。默认情况下,当你将一个值类型作为参数传递给方法时,实际上是传递了这个值类型的一个副本,这意味着方法内部对参数的任何修改都不会影响到原始变量。这种行为确保了数据的安全性,但可能会带来性能上的开销,特别是对于较大的结构体。

以引用方式传递值类型

为了克服值类型传递带来的性能问题,C#提供了两种关键字来以引用的方式传递参数:refout

  • ref:用于传递之前已经初始化过的变量。它允许方法内部对该变量进行读写操作。
  • out:类似于ref,但是被标记为out的参数在方法内必须被赋值,且在调用方法前无需初始化。

下面通过示例说明如何使用ref关键字:

public struct LargeStruct
{
    public int[] Data;
    // 假设这里有很多其他字段
    public LargeStruct(int size)
    {
        Data = new int[size];
        for (int i = 0; i < size; ++i)
        {
            Data[i] = i;
        }
    }
}

public class Program
{
    public static void ModifyStruct(ref LargeStruct ls)
    {
        // 修改结构体内的数据
        if (ls.Data.Length > 0)
        {
            ls.Data[0] = -1;
        }
    }

    public static void Main()
    {
        var ls = new LargeStruct(1000);
        Console.WriteLine(ls.Data[0]); // 输出: 0
        ModifyStruct(ref ls); // 以引用方式传递
        Console.WriteLine(ls.Data[0]); // 输出: -1
    }
}

在这个例子中,通过使用ref关键字,我们避免了复制LargeStruct实例所带来的性能损耗,并且能够直接修改原始结构体的内容。

使用refout的注意事项
  • 在方法签名和方法调用点都必须使用refout关键字。
  • 使用ref传递参数时,调用方提供的实参必须是已明确赋值的。
  • 使用out时,方法内部必须为out参数赋值,但在调用前不需要初始化该参数。

通过合理使用refout关键字,可以有效地提升涉及大型值类型的数据处理效率,同时保持代码清晰和意图明确。然而,过度使用这些特性也可能导致代码复杂度增加,因此应当谨慎应用。

6.2.2. 为 ValueType 提供 Equals 方法

.net 默认实现的 ValueType.Equals 方法使用了反射技术,依靠反射来获得所有成员变量值做比较,这个效率极低。如果我们编写的值对象其 Equals 方法要被用到(例如将值对象放到 HashTable 中),那么就应该重载 Equals 方法。

public struct Rectangle
{
    public double Length;
    public double Breadth;

    public override bool Equals(object obj)
    {
        // 判断是否为相同类型,并使用强类型的Equals方法进行比较
        if (obj is Rectangle other)
        {
            return Equals(other);
        }
        return false;
    }

    private bool Equals(Rectangle other)
    {
        // 比较两个Rectangle实例的Length和Breadth字段
        return this.Length == other.Length && this.Breadth == other.Breadth;
    }

    public override int GetHashCode()
    {
        // 使用组合函数生成基于Length和Breadth的哈希码
        return HashCode.Combine(Length, Breadth);
    }
}
关键点解释
  • Equals 方法:在重写的 Equals(object obj) 方法中,我们首先检查传入的对象是否可以转换为 Rectangle 类型。如果是,则调用私有的 Equals(Rectangle other) 方法来执行实际的字段比较。

  • GetHashCode 方法:为了支持哈希集合(例如 HashSet<Rectangle> 或作为字典的键),我们需要重写 GetHashCode 方法。这里使用了 HashCode.Combine 方法来根据 LengthBreadth 字段计算哈希码,这样可以保证具有相同尺寸的矩形拥有相同的哈希码。

  • 在C#中,所有值类型都隐式继承自System.ValueTypeValueType自身重写了Object类的Equals方法,提供了基于值比较的实现。默认情况下,这个实现使用反射来比较两个值类型实例的所有字段是否相等。虽然这种方法非常通用,可以适用于任何结构体,但由于反射带来的性能开销,在高性能要求的场景下可能不是最佳选择。

  • 因此,如果您的值类型需要频繁进行相等性比较(例如作为哈希表的键),推荐您重写Equals方法和GetHashCode方法,以提高性能。这样做不仅能加快相等性检查的速度,还能确保哈希表操作(如查找、插入)更加高效。

如何重写 Equals 和 GetHashCode 方法

以下是一个示例,展示了如何为一个简单的结构体重写EqualsGetHashCode方法:

public struct Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override bool Equals(object obj)
    {
        if (obj is Point p)
        {
            return this.X == p.X && this.Y == p.Y;
        }
        return false;
    }

    public override int GetHashCode()
    {
        // 为了简化,这里直接组合X和Y的哈希码。
        // 在实际应用中,可能需要考虑更好的混合策略以避免哈希冲突。
        return HashCode.Combine(X, Y);
    }

    // 还可以实现IEquatable<T>接口,提供更强类型的Equals方法
    public bool Equals(Point other)
    {
        return this.X == other.X && this.Y == other.Y;
    }
}

在这个例子中,我们首先通过判断传入的对象是否是Point类型来优化Equals方法。然后直接比较了XY字段的值,而不是使用反射。对于GetHashCode方法,我们使用了HashCode.Combine方法来生成哈希码,这是一个更有效的方法来计算多个字段的哈希值。

  • 默认的ValueType.Equals实现由于使用反射而导致性能低下,特别是当值类型包含大量字段时。
  • 通过重写EqualsGetHashCode方法,可以根据具体情况优化性能。
  • 实现IEquatable<T>接口也是一个不错的选择,它允许你定义强类型的相等性比较方法,从而避免装箱操作并进一步提升性能。

这种优化对于那些将值对象用于字典键或其他需要频繁进行相等性检查的数据结构中特别重要。

6.3. 避免装箱和拆箱

C#可以在值类型和引用类型之间自动转换,方法是装箱和拆箱。装箱需要从堆上分配对象并拷贝值,有一定性能消耗。如果这一过程发生在循环中或是作为底层方法被频繁调用,则应该警惕累计的效应。

一种经常的情形出现在使用集合类型时。例如:
下面的代码示例展示了由于使用了非泛型集合ArrayList而导致的装箱和拆箱操作:

ArrayList al = new ArrayList();
for (int i = 0; i < 1000; i++)
{
    al.Add(i); // Implicitly boxed because Add() takes an object
}

int f = (int)al[0]; // The element is unboxed

在这个例子中,每次调用Add()方法时都会对整数i进行装箱操作,因为ArrayListAdd()方法接受一个object类型的参数。同样地,在获取元素时需要进行拆箱操作以将其转换回int类型。

解决方案

为了避免不必要的装箱和拆箱操作,可以使用泛型集合类,比如List<T>,它允许指定列表中存储的数据类型。这样做的好处是可以避免装箱操作,因为不需要将值类型转换为object类型。下面是使用List<int>的改进版本:

List<int> list = new List<int>();
for (int i = 0; i < 1000; i++)
{
    list.Add(i); // No boxing occurs, since List<int> can directly store integers
}

int f = list[0]; // Direct access without unboxing

在这个改进后的版本中,没有发生装箱和拆箱操作,因为List<int>直接支持存储整数类型的数据。这样做不仅提高了性能,还增强了类型安全性和代码清晰度。

总结

  • 尽量使用泛型集合(如List<T>Dictionary<TKey,TValue>等)代替非泛型集合(如ArrayListHashtable),以减少装箱和拆箱。
  • 在设计API或编写公共方法时,考虑使用泛型来提高灵活性和效率。
  • 对于性能关键的应用部分,特别注意避免装箱和拆箱带来的额外开销。通过优化数据结构的选择,可以有效提升程序运行效率。

7. 异常处理

异常也是现代语言的典型特征。与传统检查错误码的方式相比,异常是强制性的(不依赖于是否忘记了编写检查错误码的代码)、强类型的、并带有丰富的异常信息(例如调用栈)。

7.1. 不要吃掉异常

关于异常处理的最重要原则就是:不要吃掉异常。这个问题与性能无关,但对于编写健壮和易于排错的程序非常重要。这个原则换一种说法,就是不要捕获那些你不能处理的异常

吃掉异常是极不好的习惯,因为你消除了解决问题的线索。一旦出现错误,定位问题将非常困难。除了这种完全吃掉异常的方式外,只将异常信息写入日志文件但并不做更多处理的做法也同样不妥。

7. 2. 不要吃掉异常信息

有些代码虽然抛出了异常,但却把异常信息吃掉了。

为异常披露详尽的信息是程序员的职责所在。如果不能在保留原始异常信息含义的前提下附加更丰富和更人性化的内容,那么让原始的异常信息直接展示也要强得多。千万不要吃掉异常。

7. 3. 避免不必要的抛出异常

抛出异常和捕获异常属于消耗比较大的操作,在可能的情况下,应通过完善程序逻辑避免抛出不必要不必要的异常。与此相关的一个倾向是利用异常来控制处理逻辑。尽管对于极少数的情况,这可能获得更为优雅的解决方案,但通常而言应该避免。

7. 4. 避免不必要的重新抛出异常

如果是为了包装异常的目的(即加入更多信息后包装成新异常),那么是合理的。但是有不少代码,捕获异常没有做任何处理就再次抛出,这将无谓地增加一次捕获异常和抛出异常的消耗,对性能有伤害。

8. 反射

反射是一项很基础的技术,它将编译期间的静态绑定转换为延迟到运行期间的动态绑定。在很多场景下(特别是类框架的设计),可以获得灵活易于扩展的架构。但带来的问题是与静态绑定相比,动态绑定会对性能造成较大的伤害。

8.1. 反射分类

  • type comparison :类型判断,主要包括 is 和 typeof 两个操作符及对象实例上的 GetType 调用。这是最轻型的消耗,可以无需考虑优化问题。注意 typeof 运算符比对象实例上的 GetType 方法要快,只要可能则优先使用 typeof 运算符。

  • member enumeration : 成员枚举,用于访问反射相关的元数据信息,例如Assembly.GetModule、Module.GetType、Type对象上的 IsInterface、IsPublic、GetMethod、GetMethods、GetProperty、GetProperties、 GetConstructor调用等。尽管元数据都会被CLR缓存,但部分方法的调用消耗仍非常大,不过这类方法调用频度不会很高,所以总体看性能损失程 度中等。

  • member invocation:成员调用,包括动态创建对象及动态调用对象方法,主要有Activator.CreateInstance、Type.InvokeMember等。

8.2. 动态创建对象

C#主要支持 5 种动态创建对象的方式:

  1. Type.InvokeMember

  2. ContructorInfo.Invoke

  3. Activator.CreateInstance(Type)

  4. Activator.CreateInstance(assemblyName, typeName)

  5. Assembly.CreateInstance(typeName)

最快的是方式 3 ,与 Direct Create 的差异在一个数量级之内,约慢 7 倍的水平。其他方式,至少在 40 倍以上,最慢的是方式 4 ,要慢三个数量级。

8.3. 动态方法调用

方法调用分为编译期的早期绑定和运行期的动态绑定两种,称为Early-Bound InvocationLate-Bound Invocation

  • Early-Bound Invocation可细分为Direct-call、Interface-call和Delegate-call。
    • Late-Bound Invocation主要有Type.InvokeMember和MethodBase.Invoke,
  • 还可以通过使用LCG(Lightweight Code Generation)技术生成IL代码来实现动态调用。

从测试结果看,相比Direct Call,Type.InvokeMember要接近慢三个数量级;MethodBase.Invoke虽然比Type.InvokeMember要快三 倍,但比Direct Call仍慢270倍左右。可见动态方法调用的性能是非常低下的。我们的建议是:除非要满足特定的需求,否则不要使用!

8.4. 推荐的使用原则

模式

1. 如果可能,则避免使用反射和动态绑定

2. 使用接口调用方式将动态绑定改造为早期绑定

3. 使用Activator.CreateInstance(Type)方式动态创建对象

4. 使用typeof操作符代替GetType调用

反模式

1. 在已获得Type的情况下,却使用Assembly.CreateInstance(type.FullName)

解释3. 使用Activator.CreateInstance(Type)方式动态创建对象

在C#中,Activator.CreateInstance方法允许您以反射的方式动态创建对象实例。这在您需要在运行时决定要实例化的类型的情况下特别有用。下面我将向您展示如何使用Activator.CreateInstance(Type)方法来动态创建对象。

使用步骤
  1. 确定目标类型:首先,您需要知道想要实例化的类型的Type对象。可以通过typeof(YourClass)或者使用其他反射技术来获取。
  2. 调用CreateInstance方法:使用Activator.CreateInstance(Type)方法来创建该类型的实例。
示例代码

假设我们有一个简单的类Person,我们将演示如何使用Activator.CreateInstance来动态创建它的实例。

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person()
    {
        // 默认构造函数
    }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public void Introduce()
    {
        Console.WriteLine($"Hello, my name is {Name} and I'm {Age} years old.");
    }
}

// 动态创建Person实例的示例
public static void Main()
{
    Type type = typeof(Person);
    // 使用默认构造函数创建实例
    Person person = (Person)Activator.CreateInstance(type);
    person.Name = "John";
    person.Age = 30;
    person.Introduce();

    // 如果需要使用带有参数的构造函数,则需要传递参数的数组
    object[] args = new object[] { "Jane", 25 };
    Person personWithParams = (Person)Activator.CreateInstance(type, args);
    personWithParams.Introduce();
}
注意事项
  • 性能考虑:反射操作(包括Activator.CreateInstance)通常比直接的新实例化慢得多,因为它们绕过了编译时的优化。如果性能是一个关键因素,并且您反复执行此类操作,考虑使用其他模式,如工厂模式结合依赖注入框架。
  • 异常处理:确保适当的异常处理,例如当尝试实例化一个没有公共无参数构造函数的类型时,可能会抛出异常。
  • 安全性:考虑到安全性和访问权限的问题,只有当调用代码具有足够的权限时,才能成功实例化类型。

9.基本代码技巧

这里描述一些应用场景下,可以提高性能的基本代码技巧。对处于关键路径的代码,进行这类的优化还是很有意义的。普通代码可以不做要求,但养成一种好的习惯也是有意义的。

9.1. 循环写法

可以把循环的判断条件用局部变量记录下来。局部变量往往被编译器优化为直接使用寄存器,相对于普通从堆或栈中分配的变量速度快。如果访问的是复杂计算属性 的话,提升效果将更明显。
for (int i = 0, j = collection.GetIndexOf(item); i < j; i++)

需要说明的是:这种写法对于CLR集合类的Count属性没有意义,原因是编译器已经按这种方式做了特别的优化。

这是一种常见的性能优化技巧,尤其是在循环中涉及到复杂计算或者访问相对耗时的操作(如属性访问、方法调用等)时。通过将这些操作的结果存储在一个局部变量中,可以避免在每次循环迭代时重复执行这些计算或访问,从而提高程序的执行效率。

示例解释

for (int i = 0, j = collection.GetIndexOf(item); i < j; i++)

这里,collection.GetIndexOf(item)可能是一个相对较重的操作,它需要遍历集合以找到特定项的位置。如果直接在循环条件中使用collection.GetIndexOf(item),那么这个方法将在每次循环迭代时被调用,导致性能下降。通过将其结果赋值给一个局部变量j,我们确保该方法仅被调用一次,之后的循环迭代将使用这个局部变量进行判断,这通常会更快。

关于CLR集合类的Count属性

对于.NET中的集合类(例如List<T>),它们的Count属性是特别优化过的。这意味着获取Count属性的值通常是非常快速的操作,因为它只是返回内部维护的一个字段值,而不是每次都要重新计算。因此,在这种情况下,预先将Count存储在一个局部变量中并不会带来显著的性能提升,而且可能会使代码变得稍微复杂一些。

实际应用建议

尽管对Count属性做这样的优化可能不会带来明显的性能改善,但在其他场景下,比如当循环条件依赖于一个复杂的表达式或方法调用时,这样做仍然是有益的。总的来说,优化策略应该基于实际性能测试和分析,而不应仅仅依靠直觉或假设。在某些情况下,过早的优化可能会导致代码可读性下降而没有带来足够的性能增益。

最后,请记住“不要过早优化”这一原则。首先保证代码的清晰和正确,然后只在证明有性能瓶颈的情况下才进行针对性的优化。这样可以在保证代码质量的同时,也保持开发效率。

9.2. 拼装字符串

拼装好之后再删除是很低效的写法。有些方法其循环长度在大部分情况下为1,这种写法的低效就更为明显了:

public static string ToString(MetadataKey entityKey)
{
    string str = "" ;
    object [] vals = entityKey.values;
    for ( int i = 0 ; i < vals.Length; i ++ )
    {
        str += " , " + vals[i].ToString();
    }
    return str == "" ? "" : str.Remove( 0, 1 );
}

推荐下面的写法:

if (str.Length == 0 )
    str = vals[i].ToString();
else
    str += " , " + vals[i].ToString();

其实这种写法非常自然,而且效率很高,完全不需要用个Remove方法绕来绕去。

推荐的改进方法

推荐的写法通过条件判断来避免不必要的字符串拼接,但这种方法仍然不是最优解,特别是在处理多个字符串拼接时。更高效的解决方案是使用StringBuilder类,它专门用于解决频繁修改字符串的情况。

使用StringBuilder的示例

这里提供一个基于StringBuilder的优化版本:

public static string ToString(MetadataKey entityKey)
{
    StringBuilder strBuilder = new StringBuilder();
    object[] vals = entityKey.values;
    for (int i = 0; i < vals.Length; i++)
    {
        if (i > 0) // 在非首次添加前加上", "
            strBuilder.Append(", ");
        strBuilder.Append(vals[i].ToString());
    }
    return strBuilder.ToString();
}

StringBuilder的优势

  • 高效性StringBuilder减少了因字符串拼接造成的临时对象创建,降低了内存使用和垃圾回收的压力。
  • 灵活性:可以方便地追加、插入、移除字符或子串,以及执行其他字符串操作。

这种方法不仅提高了效率,还使得代码更加清晰易读。因此,在需要频繁修改字符串的场景下,推荐使用StringBuilder而非直接使用+运算符拼接字符串。

9.3. 避免两次检索集合元素

获取集合元素时,有时需要检查元素是否存在。通常的做法是先调用ContainsKey(或Contains)方法,然后再获取集合元素。这种写法非常符合逻辑。

但如果考虑效率,可以先直接获取对象,然后判断对象是否为null来确定元素是否存在。对于Hashtable,这可以节省一次GetHashCode调用和n次Equals比较。

如下面的示例:

public IData GetItemByID(Guid id)
{
    IData data1 = null ;
    if ( this .idTable.ContainsKey(id.ToString())
    {
    data1 = this .idTable[id.ToString()] as IData;
    }
    return data1;
}

其实完全可用一行代码完成:

return this.idTable[id] as IData

9.4. 避免两次类型转换

考虑如下示例,其中包含了两处类型转换:

if (obj is SomeType)
{
    SomeType st = (SomeType)obj;
    st.SomeTypeMethod();
}

效率更高的做法如下:

SomeType st = obj as SomeType;
if (st != null )
{
    st.SomeTypeMethod();
}

as运算符的优势

  • 单次检查:避免了重复的类型检查,提升了性能。
  • 安全性:如果转换不可能成功(例如,当对象为null或其实际类型不匹配时),as运算符不会抛出异常,而是简单地返回null
  • 清晰性:代码更加直观,易于理解。

注意事项

尽管as运算符提供了更高的效率和更好的代码清晰度,但它仅适用于引用类型和可以为null的类型(如支持null的值类型)。对于不可为null的值类型(例如,基本数据类型int, float等),必须使用显式的类型转换或is关键字结合转换,因为这些类型不能被赋值为null。在这种情况下,使用is关键字加上类型转换是合适的。然而,在您的示例中,由于涉及到的是引用类型SomeType,因此as结合null检查的方法是最优选择。

10.Hashtable

  • Hashtable是一种使用非常频繁的基础集合类型。需要理解影响Hashtable的效率有两个因素:一是散列码(GetHashCode方法),二 是等值比较(Equals方法)。Hashtable首先使用键的散列码将对象分布到不同的存储桶中,随后在该特定的存储桶中使用键的Equals方法进 行查找。

  • 良好的散列码是第一位的因素,最理想的情况是每个不同的键都有不同的散列码。Equals方法也很重要,因为散列只需要做一次,而存储桶中查找键可能需要做多次。从实际经验看,使用Hashtable时,Equals方法的消耗一般会占到一半以上。

  • System.Object类提供了默认的GetHashCode实现,使用对象在内存中的地址作为散列码。我们遇到过一个用Hashtable来缓存对 象的例子,每次根据传递的OQL表达式构造出一个ExpressionList对象,再调用QueryCompiler的方法编译得到 CompiledQuery对象。

  • 以ExpressionList对象和CompiledQuery对象作为键值对存储到Hashtable中。 ExpressionList对象没有重载GetHashCode实现,其超类ArrayList也没有,这样最后用的就是System.Object类 的GetHashCode实现。由于ExpressionList对象会每次构造,因此它的HashCode每次都不同,所以这个 CompiledQueryCache根本就没有起到预想的作用。这个小小的疏漏带来了重大的性能问题,由于解析OQL表达式频繁发生,导致 CompiledQueryCache不断增长,造成服务器内存泄漏!解决这个问题的最简单方法就是提供一个常量实现,例如让散列码为常量0。虽然这会导 致所有对象汇聚到同一个存储桶中,效率不高,但至少可以解决掉内存泄漏问题。当然,最终还是会实现一个高效的GetHashCode方法的。

  • Hashtable在C#中是一个非常基础且重要的集合类型,它通过键值对的形式存储数据,并允许根据键快速访问值。Hashtable的性能高度依赖于其内部实现的散列机制以及如何正确地重写GetHashCodeEquals方法。

Hashtable的工作原理

  1. 散列码(GetHashCode):当一个对象被添加到Hashtable时,首先会调用其GetHashCode方法来计算出一个散列码,然后根据这个散列码确定该对象应该存放在哪个“桶”里。理想情况下,不同的对象应该有不同的散列码,这样可以减少冲突(即不同对象落在同一个桶中的情况)。
  2. 等值比较(Equals):当需要查找或删除Hashtable中的某个对象时,首先使用GetHashCode找到对应的桶,然后在这个桶内使用Equals方法逐个比较对象以找到匹配项。

问题案例分析

提到的例子涉及将ExpressionList对象作为键存储在Hashtable中,但是由于ExpressionList没有重写GetHashCode,导致每次都使用了默认的基于内存地址的散列码实现。这意味着即使两个ExpressionList对象内容相同,但由于它们是在不同时间创建的,所以它们的散列码也不同,无法正确利用Hashtable进行缓存。

解决方案:

  • 临时解决方案:让GetHashCode返回一个常量值(如0),这虽然会导致所有对象都进入同一个桶中,影响查找效率,但至少解决了内存泄漏的问题。
  • 最终解决方案:提供一个高效的GetHashCode实现,确保不同的ExpressionList对象如果包含相同的内容,则生成相同的散列码。同时,也需要重写Equals方法,使得具有相同内容的对象被认为是相等的。

实现示例

假设ExpressionList包含一些基本类型的列表,这里提供一个简单的示例说明如何重写这两个方法:

public class ExpressionList : List<string>
{
    public override int GetHashCode()
    {
        // 使用一种方式组合元素的哈希码,例如异或操作
        unchecked // 允许溢出
        {
            int hash = 17;
            foreach (var item in this)
            {
                hash = hash * 31 + (item?.GetHashCode() ?? 0);
            }
            return hash;
        }
    }

    public override bool Equals(object obj)
    {
        if (obj is ExpressionList otherList)
        {
            return this.SequenceEqual(otherList);
        }
        return false;
    }
}

此代码片段展示了如何根据ExpressionList的内容计算散列码,并实现了Equals方法用于判断两个ExpressionList是否相等。这样做不仅提高了Hashtable的性能,还确保了其正常工作。

11.大批量数据操作

当需要对数据库进行大批量数据操作的时候,推荐使用分批操作的功能,比如一百万条数据将其分为每一万条数据进行数据库操作,而不是每条数据循环去进行操作。

查询

分批查询对数据库的压力较小,如果那一张表在这个时候可能其他地方也更新或新增 可能需要考虑增加with(NOLOCK) ,当然如果是EF 就套上读未提交的事务(会变卡) 也可以让查询不加锁。

当执行大规模数据查询时,分批查询可以显著降低对数据库的压力,并提高响应速度。具体方法包括但不限于:

  • 使用分页查询:通过限制每次查询返回的数据量(如每批次10,000条记录),并使用偏移量或标识符来追踪已经检索的数据。
  • 增加WITH (NOLOCK)提示:在SQL Server中,WITH (NOLOCK)提示允许查询在不请求共享锁的情况下读取数据,这可以避免由于其他事务持有排他锁而造成的阻塞问题。但需要注意的是,使用NOLOCK可能会导致脏读(读取到未提交的数据)。
  • Entity Framework中的读未提交隔离级别:在EF中可以通过设置事务的隔离级别为ReadUncommitted来达到类似WITH (NOLOCK)的效果。虽然这种方法能减少锁定,但也可能导致脏读。
using (var context = new YourDbContext())
{
    using (var transaction = context.Database.BeginTransaction(System.Data.IsolationLevel.ReadUncommitted))
    {
        // 执行你的查询逻辑
        var queryResult = context.YourEntities.ToList();
        
        transaction.Commit();
    }
}

删除

首推根据主健进行删除,因为数据库根据主键的索引查找和删除数据非常快,当然分批更好。

对于大批量数据的删除操作,以下策略有助于提升效率:

  • 基于主键删除:由于数据库通常会为主键创建索引,因此根据主键进行删除是最快的方法之一。
  • 分批删除:将要删除的数据分为多个小批次进行操作,以减轻数据库压力。例如,每次只删除一定数量的记录,直到所有需要删除的数据都被处理完毕。

下面是一个简单的示例,展示了如何使用C#和ADO.NET实现基于主键的分批删除:

public void BatchDelete(string connectionString, string tableName, List<int> ids)
{
    const int batchSize = 1000;
    using (SqlConnection connection = new SqlConnection(connectionString))
    {
        connection.Open();
        for (int i = 0; i < ids.Count; i += batchSize)
        {
            var batchIds = ids.Skip(i).Take(batchSize);
            string inClause = string.Join(",", batchIds);
            string sql = $"DELETE FROM {tableName} WHERE Id IN ({inClause})";
            
            using (SqlCommand command = new SqlCommand(sql, connection))
            {
                command.ExecuteNonQuery();
            }
        }
    }
}

这个例子中,我们定义了一个批量大小batchSize,然后循环遍历所有的ID列表,每次构建一个仅包含当前批次内ID的IN子句,从而实现分批删除。

记住,在进行任何大批量数据操作之前,最好先评估这些操作对数据库性能的影响,并考虑在非高峰时段执行这些任务。此外,确保有适当的备份和恢复计划以防万一。

参考文献

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

橙-极纪元JJYCheng

客官,1分钱也是爱,给个赏钱吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值