不可变性
简单来说,一旦初始化后,在内存中没有被修改的内容就是不可变的。
使用C、Java和C#等命令式语言编写的程序可能会随意操作内存数据。一块物理内存一旦被设置,可以在程序执行期间任何时刻被执行线程整体或部分修改。事实上,命令式语言鼓励这种编程方式。
以这种方式编写程序对于单线程应用非常成功。然而,随着现代应用程序开发向单个进程内的多个并发线程移动,引入了潜在的问题和复杂性。
当只有一个执行线程时,您可以想象这个单个线程“拥有”内存中的所有数据,因此可以随意操作它。但是,当涉及多个执行线程时,不存在隐含的所有权概念。
相反,这个负担落在程序员身上,必须非常努力地确保内存结构对于所有读者都处于一致状态。锁定结构必须小心使用,以防止一个线程在另一个线程更新数据时看到数据。如果没有这种协调,线程将不可避免地消耗正在更新过程中的数据。这种情况的结果是不可预测的,通常是灾难性的。此外,在代码中正确使用锁定结构非常困难。如果做得不好,会瘫痪性能或在最坏的情况下导致死锁,无法恢复执行。
使用不可变数据结构可以减轻在代码中引入复杂锁定的需求。当保证程序生命周期内的某个内存段不会更改时,多个读者可以同时访问该内存。他们无法观察到该特定数据处于不一致状态。
许多函数式编程语言,如Lisp、Haskell、Erlang、F#和Clojure,通过其本质鼓励使用不可变数据结构。正是由于这个原因,随着我们向越来越复杂的多线程应用程序开发和多计算机架构移动,它们正在重新引起人们的兴趣。
状态
应用程序的状态可以简单地视为给定时间点上所有内存和CPU寄存器的内容。
从逻辑上讲,程序的状态可以分为两种:
- 堆的状态
- 每个执行线程的栈的状态
在像C#和Java这样的托管环境中,一个线程不能访问另一个线程的内存。因此,每个线程都“拥有”其堆栈的状态。堆栈可以被视为保存值类型(struct
)的局部变量和参数以及对象引用的位置。这些值与外部线程隔离。
然而,堆上的数据可以在所有线程之间共享,因此必须小心控制并发访问。所有引用类型(class
)对象实例都存储在堆上。
在面向对象编程中,类的实例状态由其字段确定。这些字段存储在堆上,因此可以从所有线程访问。如果类定义了允许在构造函数完成后修改字段的方法,则该类是可变的(不是不可变的)。如果字段无法以任何方式更改,则该类型是不可变的。重要的是要注意,具有所有C#readonly
/Java final
字段的类不一定是不可变的。这些结构确保引用不能更改,但是所引用的对象可能会随时更改。例如,一个字段可能具有指向对象列表的不可更改引用,但是列表的实际内容可以随时修改。
通过将类型定义为真正不可变,可以将其状态视为已冻结,因此该类型对于多个线程的访问是安全的。
实际上,定义所有类型为不可变可能会不方便。修改不可变类型的值可能涉及相当多的内存复制。有些语言使这个过程更容易,但无论如何,CPU最终都需要做一些额外的工作。许多因素有助于确定花费在复制内存的时间是否超过锁定争用所带来的影响。
很多研究都投入到不可变数据结构(例如列表和树)的开发中。当使用这样的结构(比如列表)时,“添加”操作会返回一个新的列表引用,其中包含了新添加的项。对先前列表的引用没有任何更改,仍然具有一致的数据视图。
List<SomeClassType>
将本身封装所包含对象的标识,但不会封装它们的状态。如果使用List<T>
来封装其中对象的可变状态,并且希望避免这些状态意外更改,必须限制将该List<T>
暴露给任何可能试图更改其中对象状态的东西。 - supercat