C#中Hashtable、Dictionary详解以及写入和读取对比
在本文中将从基础角度讲解HashTable、Dictionary的构造和通过程序进行插入读取对比。
一:HashTable
1.HashTable是一种散列表,他内部维护很多对Key-Value键值对,其还有一个类似索引的值叫做散列值(HashCode),它是根据GetHashCode方法对Key通过一定算法获取得到的,所有的查找操作定位操作都是基于散列值来实现找到对应的Key和Value值的。
2.我们需要使用一个算法让散列值对应HashTable的空间地址尽量不重复,这就是散列函数(GetHashCode)需要做的事。
3.当一个HashTable被占用一大半的时候我们通过计算散列值取得的地址值可能会重复指向同一地址,这就是哈希冲突。
在.Net中键值对在HashTable中的位置Position= (HashCode& 0x7FFFFFFF) % HashTable.Length,.net中是通过探测法解决哈希冲突的,当通过散列值取得的位置Postion以及被占用的时候,就会增加一个位移x值判断下一个位置Postion+x是否被占用,如果仍然被占用就继续往下位移x判断Position+2*x位置是否被占用,如果没有被占用则将值放入其中。当HashTable中的可用空间越来越小时,则获取得到可用空间的难度越来越大,消耗的时间就越多。
4.当前HashTable中的被占用空间达到一个百分比的时候就将该空间自动扩容,在.net中这个百分比是72%,也叫.net中HashTable的填充因子为0.72。例如有一个HashTable的空间大小是100,当它需要添加第73个值的时候将会扩容此HashTable.
5.这个自动扩容的大小是多少呢?答案是当前空间大小的两倍最接近的素数,例如当前HashTable所占空间为素数71,如果扩容,则扩容大小为素数131.
二:Dictionary
1.Dictionary是一种变种的HashTable,它采用一种分离链接散列表的数据结构来解决哈希冲突的问题。
2.分离链接散列表是当散列到同一个地址的值存为一个链表中。
3.这个变种HashTable的填充因子是1
三:本文将以代码的形式探索HashTable和Dictionary的插入和三种读取方式的效率(for/foreach/GetEnumerator)
public class HashTableTest { static Hashtable _Hashtable; static Dictionary<string, object> _Dictionary; static void Main() { Compare(10); Compare(10000); Compare(5000000); Console.ReadLine(); } public static void Compare(int dataCount) { Console.WriteLine("-------------------------------------------------\n"); _Hashtable = new Hashtable(); _Dictionary = new Dictionary<string, object>(); Stopwatch stopWatch = new Stopwatch(); //HashTable插入dataCount条数据需要时间 stopWatch.Start(); for (int i = 0; i < dataCount; i++) { _Hashtable.Add("Str" + i.ToString(), "Value"); } stopWatch.Stop(); Console.WriteLine(" HashTable插入" + dataCount + "条数据需要时间:" + stopWatch.Elapsed); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); stopWatch.Start(); for (int i = 0; i < dataCount; i++) { _Dictionary.Add("Str" + i.ToString(), "Value"); } stopWatch.Stop(); Console.WriteLine(" Dictionary插入" + dataCount + "条数据需要时间:" + stopWatch.Elapsed); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); int si = 0; stopWatch.Start(); for(int i=0;i<_Hashtable.Count;i++) { si++; } stopWatch.Stop(); Console.WriteLine(" HashTable遍历时间:" + stopWatch.Elapsed + " ,遍历采用for方式"); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); si = 0; stopWatch.Start(); foreach (var s in _Hashtable) { si++; } stopWatch.Stop(); Console.WriteLine(" HashTable遍历时间:" + stopWatch.Elapsed + " ,遍历采用foreach方式"); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); si = 0; stopWatch.Start(); IDictionaryEnumerator _hashEnum = _Hashtable.GetEnumerator(); while (_hashEnum.MoveNext()) { si++; } stopWatch.Stop(); Console.WriteLine(" HashTable遍历时间:" + stopWatch.Elapsed + " ,遍历采用HashTable.GetEnumerator()方式"); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); si = 0; stopWatch.Start(); for(int i=0;i<_Dictionary.Count;i++) { si++; } stopWatch.Stop(); Console.WriteLine(" Dictionary遍历时间:" + stopWatch.Elapsed + " ,遍历采用for方式"); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); si = 0; stopWatch.Start(); foreach (var s in _Dictionary) { si++; } stopWatch.Stop(); Console.WriteLine(" Dictionary遍历时间:" + stopWatch.Elapsed + " ,遍历采用foreach方式"); //Dictionary插入dataCount条数据需要时间 stopWatch.Reset(); si = 0; stopWatch.Start(); _hashEnum = _Dictionary.GetEnumerator(); while (_hashEnum.MoveNext()) { si++; } stopWatch.Stop(); Console.WriteLine(" Dictionary遍历时间:" + stopWatch.Elapsed + " ,遍历采用Dictionary.GetEnumerator()方式"); Console.WriteLine("\n-------------------------------------------------"); } }
四:从上面的结果可以看出
1.HashTable大数据量插入数据时需要花费比Dictionary大的多的时间。
2.for方式遍历HashTable和Dictionary速度最快。
3.在foreach方式遍历时Dictionary遍历速度更快。
五:在单线程的时候使用Dictionary更好一些,多线程的时候使用HashTable更好。
因为HashTable可以通过Hashtable tab = Hashtable.Synchronized(new Hashtable());获得线程安全的对象。
当然因为各自电脑的情况不一样,可能会有部分误差。如有问题,敬请斧正。
C# 多线程总结
1 创建线程
1.1 异步委托方式
使用异步委托创建的线程,都是由.Net线程池维护的。
线程池中的线程总是后台线程。
为了方便起见,接下来使用的共通委托方法如下1:
static int TakesAWhile(int data, int ms) { Console.WriteLine("TakesAWhile started"); Thread.Sleep(ms); Console.WriteLine("TakesAWhile completed"); return ++data; }
1.1.1 IAsyncResult.IsCompleted
根据IAsyncResult.IsCompleted判断异步委托是否执行完成。
EndInvoke获取返回值。
TakesAWhileDelegate dl = TakesAWhile; IAsyncResult ar = dl.BeginInvoke(1, 3000, null, null); while (!ar.IsCompleted) { Console.Write("."); Thread.Sleep(50); } int result = dl.EndInvoke(ar); Console.WriteLine("result: {0}", result);
1.1.2 IAsyncResult.AsyncWaitHandle
使用WaitHandle,可指定异步调用的超时时间进行后续处理。
TakesAWhileDelegate dl = TakesAWhile; IAsyncResult ar = dl.BeginInvoke(1, 3000, null, null); if (!ar.AsyncWaitHandle.WaitOne(200, false)) { Console.WriteLine("Thread not invoked."); } if (ar.AsyncWaitHandle.WaitOne(3000, false)) { int result = dl.EndInvoke(ar); Console.WriteLine("result: {0}", result); }
1.1.3 AsyncCallBack
通过传入回调函数,进行后续处理
- 分支一:单独定义回调方法
static void Main(string[] args) { TakesAWhileDelegate dl = TakesAWhile; dl.BeginInvoke(1, 3000, TakesAWhileCompleted, dl); //必须程序主线程一直存在才会执行回调方法,所以使用了如下for循环(说明了异步委托所创建的线程是一个后台线程) for (int i = 0; i < 100; i++ ) { Console.Write("."); Thread.Sleep(50); } } //定义回调方法 static void TakesAWhileCompleted(IAsyncResult ar) { if (ar == null) { throw new ArgumentNullException("ar"); } TakesAWhileDelegate dl = ar.AsyncState as TakesAWhileDelegate; Trace.Assert(dl != null, "Invalid object type"); int result = dl.EndInvoke(ar); Console.WriteLine("result: {0}", result); }
- 分支二:使用lambada表达式
TakesAWhileDelegate dl = TakesAWhile; dl.BeginInvoke(1, 3000, //这是个回调函数,使用lambada表达式的话,代码不够清晰。 ar => { int result = dl.EndInvoke(ar);//lambda表达式可使用该作用域外部的变量dl Console.WriteLine("result: {0}", result); }, null); //必须程序主线程一直存在才会执行回调方法 for (int i = 0; i < 100; i++) { Console.Write("."); Thread.Sleep(50); }
1.2 Thread类
1.2.1 无参数线程方法
var t1 = new Thread(() => Console.WriteLine("running in a thread, id {0}", Thread.CurrentThread.ManagedThreadId)); t1.Start(); Console.WriteLine("This is a main thread, id {0}", Thread.CurrentThread.ManagedThreadId);
1.2.2 有参数线程方法
public struct Data { public string Message; } static int TakesAWhile(int data, int ms) { var d = new Data { Message = "Info" }; var t2 = new Thread((object obj) => { Data data = (Data)obj; Console.WriteLine("running in a thread, id {0}, Data {1}", Thread.CurrentThread.ManagedThreadId, data.Message); }); t2.Start(d); Console.WriteLine("This is a main thread, id {0}", Thread.CurrentThread.ManagedThreadId); }
1.2.3 后台线程
Thread类默认创建的是前台线程,设定IsBackground属性可转为后台线程
var t1 = new Thread( () => { Console.WriteLine("branch thread Start, id {0}", Thread.CurrentThread.ManagedThreadId); Thread.Sleep(3000); Console.WriteLine("branch thread End"); }) { Name = "NewBKThread", IsBackground = true }; t1.Start(); Thread.Sleep(50);//为了使后台线程的情况下,能打出branch thread Start, id Console.WriteLine("This is a main thread, id {0}", Thread.CurrentThread.ManagedThreadId);
- IsBackground = true 结果:
branch thread Start, id 3
This is a main thread, id 1
- IsBackground = false 结果:
branch thread Start, id 3
This is a main thread, id 1
branch thread End
1.2.4 关于线程优先级
可以通过Thread.Priority属性调整线程的 基本 优先级。实际线程调度器会动态调整优先级
频繁使用CPU的线程的优先级会动态调低,等待资源(等待磁盘IO完成等)的线程会动态调高优先级。
以便在下次等待结束时获得CPU资源。2
1.2.5 线程状态
通过属性Thread.ThreadState获取当前线程状态
运行Thread.Start()后,状态为Unstarted。
系统线程调度器选择了运行该线程后,状态为Running。
调用Thread.Sleep(),状态为WaitSleepJoin。
停止另一个线程,调用Thread.Abort()。接到中止命令的线程中会抛出ThreadAbortException。3
涉及的状态有AbortRequested、Aborted。
继续停止的线程,调用Thread.ResetAbort()。线程将会在抛出ThreadAbortException后的语句后继续进行。
等待线程的结束,调用ThreadInstance.Join()。
该调用会停止 当前 线程,当前线程状态设为WaitSleepJoin。
等待加入的线程处理完成,再继续当前线程的处理。
1.3 线程池
超出最大线程数时,QueueUserWorkItem会等待获取线程资源时再调用。
static void Main(string[] args) { ThreadPool.SetMinThreads(3, 3);//创建线程池时启动的最小线程数 ThreadPool.SetMaxThreads(10, 10);//最大线程数 for (int i = 0; i < 5; i++ ) { ThreadPool.QueueUserWorkItem(JobForAThread); } Thread.Sleep(3000);//由于是后台线程,需要使主线程等一会,否则程序直接退出 } static void JobForAThread(object state) { for (int i = 0; i < 3; i++) { Console.WriteLine("loop {0}, running inside pooled thread {1}", i, Thread.CurrentThread.ManagedThreadId); } }
使用线程池的限制:
- 其中的所有线程只能是后台线程。
- 无法设置线程的优先级或名称。
- 关键点 适用于耗时较短的任务。长期运行的线程,应使用Thread类创建。
2 同步问题
2.1 lock关键字
只能锁定引用类型,锁定值类型等于锁定了一个副本,没有意义,编译器也不允许你这么做。
使用锁定需要时间,并不总是必须。可以创建类的两个版本,一个同步版本,一个异步版本。
2.1.1 将实例成员设为线程安全的
lock(this) { //一次只有一个线程能访问相同实例的该语句块 }
因为该实例对象也可用于外部访问,这样做会导致外部访问时也得等待该同步语句块执行完成。正确的做法:
private object syncRoot = new object(); public void DoSomething() { lock (object) { //Do something } } private static object syncRoot = new object();//可用于锁定类静态成员
2.1.2 lock关键字由编译器解析为Monitor类
lock (obj) { };
等价于:
Monitor.Enter(obj); try { } finally { Monitor.Exit(obj); }
与lock关键字的区别:
- 可添加一个等待解锁的超时时间,使用TryEnter传递超时值。
bool lockTaken = Monitor.TryEnter(obj, 500); if (lockTaken) { try { } finally { Monitor.Exit(obj); } } else { //didn't get the lock, do something else }
2.1.3 更快速的Interlocked类
仅用于简单的针对变量赋值的同步问题
lock(this) { if (someState == null) { someState = newState; } }
等价于(可用于单件模式的GetInstance):
Interlocked.CompareExchange<SomeState>(ref someState, newState, null);//第一个参数和第三个参数比较,如果相等,替换为第二个参数的值
public int State { get { lock (this) { return ++state; } } }
等价于:
public int State { get { return Interlocked.Increment(ref state); } }
2.2 WaitHandle
WaitHandle是一个抽象基类。用于等待某个信号量。
Mutex、EventWaitHandle、Semaphore类都从WaitHandle派生。
2.3 Mutex类
提供进程之间的同步访问。创建一个进程之间能共享的以字符串命名的互斥锁。
构造函数的一种形式如下:
bool created; Mutex mutex = new Mutex(false, "IFFileMutex", out created);
其中,第一个参数定义了该互斥体的所有权是否应属于调用线程。
第二个参数是互斥体名字,操作系统能识别该字符串,以此实现各进程之间的同步。
第三个参数,如果系统中已存在该命名的互斥体返回false,否则返回true。
Mutex mutex = Mutex.OpenExisting("IFFileMutex");//打开系统中已存在的互斥体 if(mutex.WaitOne(500))//500为等待超时时间 { try { //synchronized region } finally { mutex.ReleaseMutex(); } }
2.4 Semaphore类
信号量可以同时由多个线程使用,是计数的互斥体。一般用于受数量限制的访问资源(如DB连接资源)。
2.5 Event类
系统级的资源同步方式,比之Mutex,多了个Reset方法,
重置nonsignaled的状态(等同于互斥体的锁定状态),释放所有等待的线程。
Set方法:将事件设为signaled状态,使其他等待的线程得以继续,类似锁的Release方法。
Waitone方法:等待事件被设为signaled状态。
Reset方法:将事件设为nonsignaled状态,并且阻塞所有等待的线程。
2.5.1 AutoResetEvent
Reset方法会在某一线程Waitone成功后,自动重置为nonsignaled。
达到的效果:一次只能一个线程继续处理。
2.5.2 ManualResetEvent
需手动调用Reset方法重置为nonsignaled。
达到的效果:多个线程都能继续进行处理。
2.6 ReaderWriterLockSlim类(.Net 3.5引入)
如果没有Writer锁定资源,就允许多个Reader访问资源,但只能有一个
Writer锁定该资源(所有访问中的Reader都必须先释放锁)。
比之.Net 1.0版本 ReaderWriterLock类,重新设计为防止死锁,提供更好的性能。
- EnterReadLock 进入锁定,另一个方法TryEnterReadLock允许指定一个超时时间。ExitReadLock释放锁定
- EnterUpgradableReadLock 用于读取锁定需要改为写入锁定的情况。
- EnterWriteLock 获得多资源的写入锁定。仅一个线程能获取锁定,在这之前还必须释放所有的读取锁定。
3 Timer类
.Net提供了几个Timer类,比较如下:
命名空间 | 说明 |
---|---|
System.Threading | 提供了Timer的核心功能,在构造函数中传入回调的委托。 |
System.Timer | 继承Component,可在设计界面拖入,提供了基于事件的机制(非委托)。 |
System.Windows.Forms | 为单线程环境设计的(创建和回调在同一个线程中执行),执行回调方法时UI会假死,不宜执行耗时较长的代码。该Timer时间精度55ms。 |
System.Web.UI | 是一个AJAX扩展,可以用于Web页面 |
4 总结
类 | 目的 | 参考开销4 | 是否跨进程? |
---|---|---|---|
lock(Monitor) | 保证单个进程内只有一个线程能够获取同步资源 | 20ns | No |
Mutex | 保证只有一个线程能够获取同步资源 | 1000ns | Yes |
Semaphore | 可指定可获取同步资源的线程数 | 1000ns | Yes |
ReaderWriterLock | 允许多个Reader访问同步资源 | 100ns | No |
AutoResetEvent | 当信号被设为signaled状态时,允许单个线程进入同步资源块 | 1000ns | Yes |
ManualResetEvent | 当信号被设为signaled状态时,允许所有等待线程进入同步资源块 | 1000ns | Yes |
ReaderWriterLockSlim | 可锁定多个Reader访问资源以及单个Writer修改资源 | 40ns | No |
- 注:一些Slim类(如ManualResetEventSlim),比之旧版本,通常拥有更好的性能。参考 MSDN。
几条规则:
- 尽量使同步要求最低,尝试避免共享状态。
- 类的静态成员应是线程安全的。
- 实例成员不需要是线程安全的。为了最佳性能,最好在类的外部处理同步问题。
示例代码:MultiThreadDemo.rar
5 推荐阅读
Footnotes:
1 例子参照《C# 高级编程(第7版)》
2 给线程指定较高的基本优先级时,需注意。这有可能会降低其他线程的运行概率。
3 可以捕捉该异常完成线程的资源清理任务。
4 该时间测自CPU Intel Core i7 860的环境,参考Threading in C#
Last Updated 2013-04-15 11:20:32 中国标准时间