Ryuu 的个人博客

一个计算机初学者

总结来说,多线程安全主要要满足 三大条件:

原子性(Atomicity)

操作不可分割,要么全部成功,要么全部失败。

1
2
3
4
5
6
7
8
9
int x = 0;

// 原子性保证
Interlocked.Increment(ref x); // 原子 +1,不会丢失更新

// 非原子性
// 其实是 load → add → store,可能被打断,导致多个线程覆盖
// 例如线程a正在++,这个过程中线程b也++,本来x会+2结果会变成只+1
x++;

可见性 (Visibility)

一个线程对共享变量的修改,另一个线程能够立即看到。

CPU 会做缓存[1]和指令重排(跟有序性也有很大关系),导致线程 A 改了变量,线程 B 可能“看不到”。

需要内存屏障(memory barrier)[2]、volatile 或锁来保证。

1
2
3
4
5
6
7
8
9
10
11
12
volatile bool flag = false;

void ThreadA()
{
flag = true; // 修改对其他线程立即可见
}

void ThreadB()
{
while (!flag) { } // 否则可能无限循环
}

有序性 (Ordering)

程序执行顺序符合预期。

编译器,JIT和CPU,可能会重排指令,导致代码执行顺序和书写顺序不一致。

内存屏障、锁等可以限制重排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton {
private static Singleton instance;
private static readonly object lockObj = new object();

public static Singleton Instance {
get {
if (instance == null) {
lock (lockObj) {
if (instance == null) {
instance = new Singleton();
// 可能被重排为:
// 1. 分配内存
// 2. 把 instance 指向内存
// 3. 调用构造函数
// 线程 B 可能看到一个“未初始化完成”的对象
}
}
}
return instance;
}
}
}

这个时候可以给 instance 添加 volatile,或者是用 Lazy<T>(线程安全的初始化对象)

总结

在多线程编程里,原子性(Atomicity)、有序性(Ordering)、可见性(Visibility) 经常是相辅相成的——它们不是独立孤立的,而是互相依赖来保证线程安全。

脚注

[1] 现代的 cpu 大多有多级缓存,比如 L1,L2和L3,RAM,这么做是为了减少访存的时间。

缓存层级 特点
L1 Cache CPU 核心私有,最小、最快
L2 Cache CPU 核心私有,稍大,速度略慢
L3 Cache 共享缓存,多核心共享,速度比 L1/L2 慢,但比内存快
主存 (RAM) 所有核心共享,访问最慢

一般在cpu核心读操作时先从最近的缓存找,如果能命中缓存就直接读,不会再访问其他缓存。写操作时先写到缓存再延迟写回主存(根据cpu决定,通常也会写入 L2/L3,写主存很慢,所以这个操作是延迟的)。

[2] 内存屏障是编译器和CPU提供的用来限制指令的重排序和控制缓存可见性的指令/机制。

编译器为了性能可能会做指令重排,寄存器缓存。

为了避免这种优化导致问题,编译器会提供屏障语义:

比如编译器会在volatile变量前后插入内存屏障,保证可见和有序。

比如 lock,编译器会在周围插入 full fence(禁止编译器和cpu的重排序,同时保证读写可见性)。

CPU同样可能会为了性能去乱序执行,写缓冲区延迟写回等。

为了避免这种优化导致问题,cpu有专门的指令:

x86 有 mfencelfencesfence

ARM 有 dmbdsb

这些指令就是内存屏障。

背景

缓存 + 数据库 的经典架构里,缓存通常作为加速层来缓解数据库压力。
但在更新数据时,如果操作顺序处理不当,就可能导致缓存与数据库之间出现短暂的不一致。

常见的更新顺序有两种:

  1. 先写数据库,再写缓存

    • 优点:简单直观,能保证数据库最终一致性。
    • 缺点:在删除缓存和数据库更新之间的时间差,可能有请求先查到老数据,一致性弱。
  2. 先写缓存,再写数据库

    • 优点:能一定程度上减少脏读。
    • 缺点:如果数据库更新失败,就会丢掉缓存,反而更糟。

因此我们大多时候会选择先写数据库再写缓存。

删除与更新

在写缓存的时候我们可以选择删除或者更新,大多数时候我们会选择更新,因为[1]:

删除缓存 (Cache-Aside) 更新缓存
复杂程度 简单 复杂
幂等性 天然幂等(无论怎么删,最终结果都是缓存被删除了) 非幂等操作
并发写安全 安全 (删除顺序不影响最终状态) 不安全 (并发更新可能导致缓存数据错乱或覆盖,即使是redis也只保证单个命令是安全的)
效率 (直接删除,不关心数据) (可能频繁更新一个后续无人读取的值,消耗CPU和带宽,特别是当你在维护一个复杂的缓存时)
数据最终一致性 更易保证 (删除不依赖数据) 更难保证 (依赖数据,若顺序错误可能脏甚至是错误)

缓存失效策略(Cache-Aside / Lazy-Loading)

写数据库,删除缓存,下次请求重建缓存,简单可靠。

双删

双删也是经常提到的办法,属于一种增强版的写数据库后删除缓存,能够减少不一致窗口期。

在更新数据库之后,执行两次缓存删除操作

事务A 事务B
更新数据库
删除缓存(第一次)
访问数据
缓存未命中,读取数据库
返回数据库中的数据
延时一段时间后删除缓存(第二次)

第二次删除缓存通常会延迟,目的是解决以下问题:

事务A 事务B
写数据库 val = 37
写数据库 val = 42
写缓存 val = 42
写缓存 val = 37
  1. 延迟时间的选择
    延迟需要大于业务中可能的并发请求处理时间。
  2. 幂等性
    删除缓存操作需要是幂等的,即多删几次不会有副作用。

双删缺陷

弱一致性

  • 在第一次删除和第二次删除之间,如果有读请求访问数据,还是可能会从数据库加载旧数据回填缓存 → 读到脏数据
  • 因此双删保证的只是 最终一致性无法保证请求级别的强一致性

令人纠结的时间

  • 延迟太短:第二次删除可能还没覆盖到并发读回填的缓存,最终一致性可能都无法保证(长时间或永久脏缓存)。
  • 延迟太长:第二次删除的间隔内,缓存可能被大量请求读写回填,脏数据存在的时间过长(脏数据多)。

实际上,延迟双删更多是工程妥协:在读多写少、对短时间不一致可容忍的业务场景下适用(个人感觉有点骚操作,不过能保证最终一致性的同时还能减少脏数据时间总是好的)。

脚注

[1] 删除是 KISS原则YAGNI原则 的体现。此外,好的设计往往是对需求的精准把握,我们作为设计师要从需求好好考虑,找到最关键的问题,简单并不是简陋。

引子

Java 原生不支持 .NET 样式的 multicast delegate,但我们可以通过手动维护 List<Listener>
来实现类似功能。随着业务复杂化,这种做法会导致代码臃肿,维护成本上升。

这样的需求是很常见的,我看了下项目有类似需求的地方,问题还是挺大的。

项目的现状

项目里的实现有两种

直接获取其他业务的引用

A 直接 调用了 B,B 又 调用了 C,D。还有 A 里写了本该是 B 处理的逻辑的问题,有的时候想找 B 的功能甚至得去 A
里找。有的地方改起来特别头疼,动一个地方好几个地方要一起改。

分析
直接的耦合

直接持有其他对象的引用并调用耦合度过强了,逻辑会像网一样越织越复杂。
考虑到最坏的情况,有 N 个相互依赖的类:

直接引用:

O(N*(N-1)) 的复杂度,且依赖是直接的引用,非常强的耦合。

观察者模式(Observer Pattern):

O(N*M) 的复杂度,N 为观察者,M为被观察者(subject)。依赖建立在被观察者和观察者之间,松耦合。

  1. M 通常会远小于 N
  2. M 一般只是个方法的集合,而不是业务类,耦合度低,维护成本也很低

因此在实践中复杂度通常是 O(N)。

发布订阅模式(Publish-Subscribe Pattern):
O(M+N) 的复杂度,M 为发布者,N为订阅者。依赖建立在发布者和消息中介,订阅者和消息中介间,非常松耦合。

比起观察者模式,发布者订阅者只需要关心主题(subject),只维护一个消息中介,而不是维护多个被观察者。

对比表:

模式 依赖关系数量 复杂度 直观感受
全互相依赖 N*(N-1) O(N^2) 地狱耦合 O(N^2)
观察者模式 (Observer) N*M O(N*M) 中等耦合 近似 O(N)
发布订阅 (Pub/Sub) M+N O(N) 极低耦合 近似 O(1)
派生的耦合

当使用直接引用进行强耦合的同时,往往完全没必要的弱耦合也会出现,比如 A 直接引用 B,然后又在 A 里写了本该在 B
里处理的逻辑,这种现象在不同语境下有多种叫法:

叫法 语境 含义
职责泄漏 (Responsibility Leakage) 架构 A 越界实现了 B 的逻辑,职责不清晰
逻辑泄漏 (Logic Leakage) 一般工程实践 内部实现细节暴露到调用方
侵入式依赖 (Intrusive Dependency) 软件设计 依赖对象的内部细节侵入调用方
领域逻辑泄漏 (Domain Logic Leakage) DDD 领域逻辑没有收敛在正确的领域模型或聚合根
贫血模型 (Anemic Domain Model) DDD 反模式 领域对象只有数据,没有行为,逻辑散落在应用层或服务层
横向逻辑扩散 (Logic Scattering) 面向切面编程/架构 同一类逻辑被分散到多个调用点
总结

直接引用带来的强耦合,不仅仅体现在 依赖数量 的复杂度上,更隐蔽的危害在于它会 诱导错误的逻辑分布
这会导致开发的成本从开始到维护都很高,如果一开始就做好能省很多时间(当然后续能补救也是好的,至少后续不会再浪费时间了)。具体地,业务上的关系如果没有很强,那最好还是一开始就使用观察者模式或者发布订阅模式。

维护 List

有几个 class 维护了多个 List,每个 List 都有增删和调用的 api,class 里光是这些 api 就有十来个方法。
不仅如此,有的这些调用的 api 里还混了这个class自身的其他操作。

分析

可能是这个项目有很多人维护过,更新一些实现倒是有用 List 实现了多播,使用了观察者模式处理业务。
但之前提到的派生的耦合的问题还在,而且违反了 DRY 原则,我在维护这个项目的时候经常是要到处找这些 List 的 api,
看看有没有什么额外操作,又或者是有没有地方忘记加 api 了,很浪费时间。

结论

现在项目里直接引用和维护 List 的方式都已经出现维护的时候很费时间的问题了,后面任务加了那么多再这样下去问题就更大了。
再这么浪费时间下去可不行,得像 .NET 的 multicast delegate 一样,提供一个统一多播实现。

设计与方案

设计目标

该库需要尽快完成,以满足项目需求,并确保高效开发。

成熟可靠,因为这是要用在项目的核心功能上,关系到百万千万个用户的体验,不能出错。

易用,我们的项目里有很多同事,水平参差不齐,得让大家都能用。我实际也问过同事,有的同事甚至都没听说过观察者模式。

方案

综合考虑,直接模仿 .NET 的 multicast delegate 是最合适的方案。又快又成熟可靠,还易用。
虽然 .NET 里的 delegate 一等公民而且还有方便的语法糖,我们在 java 里不能做到完全一样,但也能做到很接近。

详细设计

由于 java 已经有 function interface(函数式接口)去作为 lambda 表达式的目标了,我们是无法做到像 .NET
一样委托和多播委托无感的。但用函数式接口去做委托,再用已经写好了的委托去写多播委托是可以的。

委托实现

委托直接用函数式接口就行,主要是规范 api 和为多播委托做铺垫,同时不要忘记做好和 java 的适配。

在实现前我们可以先做好 java EventListener[1] 的适配同时用接口标记 delegate

1
2
3
4
5
6
package org.ryuu.functional;

import java.util.EventListener;

interface Delegate extends EventListener {
}

保持和 .NET 一样,调用时的方法名称为 ... invoke(...) 减少理解成本[2]。用@FunctionalInterface[3] 注解修饰实际的
delegate

1
2
3
4
5
6
package org.ryuu.functional;

@FunctionalInterface
public interface Action extends Delegate {
void invoke();
}

现在委托就做好了。这样的另一个好处是,我们不需要在需要传 lambda 表达式的时候满世界找一个适配的函数式接口了,因为我们会像
.NET 一样预定义好:

1
2
3
4
5
void invoke(T arg);
void invoke(T1 arg1, T2 arg2);
TResult invoke();
TResult invoke(T arg);
...

多播委托

我们需要先清楚 .NET MulticastDelegate 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected sealed override Delegate CombineImpl(Delegate? follow)
{
return this;
throw new ArgumentException(SR.Arg_DlgtTypeMis);
return NewMulticastDelegate(resultList, resultCount);
return NewMulticastDelegate(resultList, resultCount, true);
}

protected sealed override Delegate? RemoveImpl(Delegate value)
{
// Special case - no values left
return null;
return this;
// they are both not real Multicast
return null;
return NewMulticastDelegate(list, invocationCount - 1, true);
// Special case - only one value left, either at the beginning or the end
return (Delegate)invocationList[i != 0 ? 0 : invocationCount - 1];
return NewMulticastDelegate(list, invocationCount - vInvocationCount, true);
return this;
}
不可变性

任何“修改”都通过构造新对象来完成。虽然 MulticastDelegate 内部维护了一个 _invocationList,但每次 CombineImpl
RemoveImpl 实际上都会创建一个新的委托对象,而不会修改已有对象。这种方式使得委托本质上是不可变的(immutable)(虽然里面的 _
invocationList 在实现细节上并非只读)。

在 Java 中,我们可以利用深拷贝和 Collections.unmodifiableList 来实现不可变性。

多播委托

在 .NET 中,委托(delegate)统一由 Delegate / MulticastDelegate 管理。用户代码层面并没有“单播 / 多播”的区分
,这个细节完全隐藏在实现内部:

  • **单播 (single delegate)**:invocationList 为空,只存一个方法引用。
  • **多播 (multicast delegate)**:invocationList 是数组,存多个方法引用。

多播委托的合并与移除逻辑会自动处理这两种情况。因此,.NET 用户只会看到“委托可以 += / -= 方法”,而不会关心它当前是单播还是多播。

在 Java 中,因为 lambda 表达式必然作用在单个函数的接口上,所以单播对用户是显式的,实现多播时可以与 .NET 一样过内部封装隐藏掉单播多播的区别。

语法糖

在 c# 中可以直接用 += -= 修改委托

High-Level c#

1
2
3
4
5
6
7
8
[Fact]
public void Test1()
{
Action a = () => { };
Action b = () => { };
a += b;
a -= b;
}

Low-Level c#

1
2
3
4
5
6
7
[Fact]
public void Test1()
{
Action a1 = UnitTest2.<>c.<>9__0_0 ?? (UnitTest2.<>c.<>9__0_0 = new Action((object) UnitTest2.<>c.<>9, __methodptr(<Test1>b__0_0)));
Action b = UnitTest2.<>c.<>9__0_1 ?? (UnitTest2.<>c.<>9__0_1 = new Action((object) UnitTest2.<>c.<>9, __methodptr(<Test1>b__0_1)));
Action a2 = (Action) Delegate.Remove(Delegate.Combine((Delegate) a1, (Delegate) b), (Delegate) b);
}

c# 在编译的时候把 a += ba -= b 变成了 Delegate.Remove Delegate::Combine 而且把新产生的对象赋回了 a。

在 java 里我们是不可能通过编写代码实现这样的语法糖的,在 java 里用方法去实现就行。

栈式操作(Stack Semantics)

  1. 语义一致
    在委托(delegate)的操作语义上,+= 始终将新委托附加到 尾部(类似 push),而 -= 则撤销 最近一次添加的委托(类似
    pop)。这种设计确保了委托的修改符合 栈(LIFO)语义,简单直观。
  2. 实现高效
    从尾部开始查找和移除,可以直接截断尾部,操作成本低;若从头部开始匹配,则需要移动后续所有元素,效率更差。

Java 中同样可以实现这种语义。做法是:

  • 添加:始终往尾部追加。
  • 删除:从尾部开始执行 子列表匹配(Sublist Matching),只移除最近一次匹配到的委托。

这样既能保证语义一致,又能在实现上保持高效。

event

c# 中 event,是 基于 delegate
的一种特殊成员

High-Level c#

1
private event Action a = () => { };

Low-Level c#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private Action a;

private event Action a
{
[CompilerGenerated] add
{
// 先读取当前事件委托链,存入 action1,这里是为了在循环中对比 CAS 是否成功
// 虽然这里的 action1 和 this.a 引用类型的,但 delegate 是不可变的
// this.a 改变时会引用一个新的实例,而 action1 还是会引用 this.a 改变前的旧实例
// 如果是可变的:
// this.a 改变时引用的实例没有改变,action1 引用的还是 this.a 的实例,此时无法进行 != 判断
Action action1 = this.a;
// 保存当前尝试的旧值
Action action2;
do
{
// 存放 尝试用 CAS 更新的“旧值”。
action2 = action1;
// public static T CompareExchange<T>(ref T location, T value, T comparand);
// location:你要修改的变量(这里是 this.a)。
// value:如果 location == comparand 条件满足,要写入的新值(这里是 Delegate.Combine(action2, value))。
// comparand:预期旧值(这里是 action2)。
// return:location 在此操作之前的值(无论是否成功)
// 这保证了无论怎样循环 action1 都是最新的 this.a 的值
action1 = Interlocked.CompareExchange<Action>(ref this.a, (Action) Delegate.Combine((Delegate) action2, (Delegate) value), action2);
}
// action1 不等于 action2(说明 CAS 失败,this.a 被其他线程修改过)。
// 循环重新读取 this.a 并尝试合并。
while (action1 != action2);
}
[CompilerGenerated] remove
{
Action action1 = this.a;
Action action2;
do
{
action2 = action1;
action1 = Interlocked.CompareExchange<Action>(ref this.a, (Action) Delegate.Remove((Delegate) action2, (Delegate) value), action2);
}
while (action1 != action2);
}
}

可见编译器为 event 生成了 add 和 remove 访问器,使用了 CAS[4] 做自旋锁,来保证对多播的修改是线程安全的。

在 Java 中,如果想实现类似的体验,可以通过 APT(Annotation Processing Tool) 来生成访问器代码,使使用体验更接近
.NET。然而,APT 会带来一些问题:

  1. 增加了项目复杂度。
  2. IDE 对生成代码的支持可能不完善。
  3. 用户自定义代码时,APT 生成的代码可能出现冲突或不易维护。

考虑到这个项目是一个通用库,而不是语法糖增强工具,我们不会采用 APT 的方式。

因此,我们选择通过 接口 实现类似 .NET 的 event

  • 提供修改委托(添加/移除)的方法。
  • 不暴露 invoke 方法给外部用户。

这样既能保证多播操作的安全与一致性,也简化了使用和维护。

多播委托实现

以多播委托移除为例,同时展示栈式操作不可变性的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void removeMulticastDelegate(MulticastDelegate<T> multicastDelegate) {
int sourceCount = count();
int targetCount = multicastDelegate.count();

// 栈式操作
for (int i = sourceCount - targetCount; i >= 0; i--) { // 从尾部开始
if (subListEquals(multicastDelegate.delegates, i, targetCount)) { // 子表匹配
List<T> newList = new ArrayList<>(delegates);
newList.subList(i, i + targetCount).clear();
delegates = Collections.unmodifiableList(newList); // 内部实现 delegates 的不可变
return;
}
}
}

内部的添加方法为例,展示多播委托的内部隐藏单播多播区别实现:

1
2
3
4
5
6
7
8
9
10
11
private void addInternal(T delegate) {
if (delegate == null) {
return;
}

if (delegate instanceof MulticastDelegate) {
addMulticastDelegate((MulticastDelegate<T>) delegate);
} else /* if (delegate instanceof Delegate) */ {
addDelegate(delegate);
}
}

添加为例,展示 event 多线程安全实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private final boolean isEvent;
private final Object delegatesWriteLock = new Object();

public MulticastDelegate(boolean isEvent) {
this.isEvent = isEvent;
}

@Override
public final void add(T delegate) {
if (isEvent) {
addSync(delegate);
} else {
addAsync(delegate);
}
}

private void addSync(T delegate) {
synchronized (delegatesWriteLock) {
addInternal(delegate);
}
}

private void addAsync(T delegate) {
addInternal(delegate);
}

这里我们用的是 synchronized 保证多线程安全而不是基于 CAS
写一个自旋锁。我们需要考虑锁实现的性能,自旋锁是一种忙等(busy waiting
),如果竞争很激烈[5]会非常浪费 CPU。这种情况可以让线程阻塞,CPU 还可以去执行其他线程,这样效率更高。

代码段示例,展示 event 的调用全只留给声明事件的类内部实现:

1
2
3
4
public interface Event<T extends Delegate> {
void add(T delegate);
void remove(T delegate);
}
1
2
public abstract class MulticastDelegate<T extends Delegate> implements Iterable<T>, Delegate, Event<T> {
}
1
2
3
4
5
6
7
8
9
10
11
private static class ClassWithActionsEvent {
private final Actions actions = Actions.event();

public Event<Action> getActions() {
return actions;
}

private void privateMethod() {
actions.invoke();
}
}

微基准测试

.NET 的 delegate 的性能是不错的,我们可以使用 JMH(Java Microbenchmark Harness)[6]进行基准测试来查看是否符合性能模型。

Benchmark Param (baseActions) Sub-Benchmark Mode Cnt Score Error Units
MultithreadInvokeBenchmark.invoke 1 total thrpt 64 270.771 ±5.094 ops/us
gc.alloc.rate thrpt 64 8080.918 ±190.085 MB/sec
gc.norm thrpt 64 32.001 ±0.001 B/op
gc.count thrpt 64 6187.000 counts
gc.time thrpt 64 4410.000 ms
MultithreadMixedOpsBenchmark.mix 1 total thrpt 64 36.785 ±3.842 ops/us
add thrpt 64 0.507 ±0.054 ops/us
invoke thrpt 64 35.550 ±3.920 ops/us
remove thrpt 64 0.729 ±0.042 ops/us
gc.alloc.rate thrpt 64 2004.565 ±83.029 MB/sec
gc.norm thrpt 64 61.101 ±5.008 B/op
gc.count thrpt 64 5122.000 counts
gc.time thrpt 64 3669.000 ms
SingleThreadInvokeBenchmark.invoke 1 total avgt 64 7.601 ±0.429 ns/op
gc.alloc.rate avgt 64 3989.645 ±227.297 MB/sec
gc.norm avgt 64 32.002 ±0.002 B/op
gc.count avgt 64 4292.000 counts
gc.time avgt 64 4203.000 ms
32 total avgt 64 27.727 ±0.925 ns/op
gc.alloc.rate avgt 64 1081.411 ±34.195 MB/sec
gc.norm avgt 64 32.007 ±0.008 B/op
gc.count avgt 64 4471.000 counts
gc.time avgt 64 3918.000 ms

单线程 invoke

我们先从单线程的 invoke 性能分析开始,测试机器的 CPU 频率为 4.5GHz。执行速度测试结果如下:

单个委托执行消耗约 32 个 CPU 时钟周期,具体分布为:

  • invokeinterface 调用 + JIT 内联 lambda:约 3–10 cycles
  • 循环控制、列表访问、JVM 辅助调整及 CPU 流水线开销:约 20 cycles

而执行 32 个委托的多播调用时,总开销仅约 126 个时钟周期:

  • 平均每个委托仅需 3–4 cycles,远低于单个委托的开销。
  • 这是因为多委托调用形成连续指令流,充分利用了 CPU 流水线,大幅减少了方法调用开销。
  • 另外,委托对象在内存中连续布局,提高了缓存命中率,有效摊平了列表访问和循环控制开销。

性能模型分析

JVM 处理单个委托调用的开销主要来自方法调用和循环管理

多线程 invoke

MultithreadInvokeBenchmark.invoke 中,我们测试了多线程环境下的 invoke 性能,基准测试结果显示:

  • 16个线程的总吞吐量约为 270.77 ops/us,虽然高于单线程(3.694ns < 7.6 ns),但性能瓶颈明显(GC)。
  • GC 分配率高达 8080 MB/sec,表明多线程并发执行 invoke 时创建的快照式迭代器严重增加了垃圾回收负担。
  • 每操作平均内存开销约 32 B,与单线程一致,这主要来自快照式迭代器的创建成本。
  • GC 时间与次数数据显示垃圾回收被频繁触发,特别是在短周期高频调用场景下,内存分配压力显著增大。

性能模型分析

这是快照式迭代器的性能特征:读取速度快、线程安全,但在高频多线程调用下 GC 压力明显。

多线程混合 add/remove/invoke

当我们将16线程 invoke 改为12线程 invoke、2线程 add、2线程 remove时,invoke 性能从270.771降至35.550,显著变慢。这是由于 invoke
的CPU缓存失效,线程间需要频繁同步内存数据所导致。而 add 和 remove 操作比 invoke 更慢,主要因为它们需要保证线程安全。

性能模型分析

写时复制表(Copy-On-Write List)的性能模型:读取操作高效,不受线程竞争影响,可并行执行多个读操作;写操作则需要复制并修改表,时间和空间成本较高,频繁写入会造成性能瓶颈。

总结

至此微基准测试与分析完毕,基准测试结果表明,该实现符合预期的性能模型。

另请参阅

详细的实现请查看 项目源码仓库

dotnet runtime 仓库 委托的实现在 System.Private.CoreLib 程序集中

脚注

[1] 详情请参考文档 https://docs.oracle.com/javase/8/docs/api/java/util/EventListener.html

[2] 在自然语言中,invoke是比call更正式的一个单词,例如,“invoke a law” (启用一条法律)或“invoke a blessing”
(祈求祝福)。一般用来表示某种东西被激活或应用。在编程中invoke一般是对事件,委托和回调的操作,而call是对函数和方法的直接调用。

[3] @FunctionalInterface会将接口标记为函数式接口,这种类型的接口是lambda表达式和方法引用的目标。如果不小心添加了多个方法,编辑器会显示错误,如果需要一个函数式接口,请坚持使用
@FunctionalInterface。同样的情况还有 Effective-Java-40坚持使用Override注解

[4] 从线程安全性上来说,CAS 同时拥有原子性(硬件CPU指令级别的原子性),可见性(CAS操作自带内存屏障,操作结果立刻对其他线程可见)和有序性(CAS前后的代码会限制重排序),所以是线程安全的。

从设计上来说 CAS 有 ABA 问题和忙等待(busy waiting)问题,但 delegate 是不可变对象,所以根本不会产生 ABA 的情况,而 +=,-=
操作频率远低于 invoke,一般来说不会忙等待或较少见,而如果选择加重锁,会额外引入用户态和内核态之间的上下文开销(唤醒线程又得有一次上下文切换开销)。

[5] delegates 的业务是典型的读多写少,竞争激烈的情况应该不会很多,但无论如何我们还是要为使用者考虑,尽量覆盖更多情况。

[6] 微基准测试是用来理解底层代价的,不能当作现时的使用场景,不能用于推测 QPS。

基准测试的数值本身并不重要,重要的是我们可以从这些数值推导的性能模型(比如在我们的 delegate 里是快照不可变列表和*
*接口调用/方法引用**的性能模型)。

如果你对此很好奇请阅读 nanotrusting-nanotime 博客内容。

如果你对代码的性能很好奇可以观看此视频 code::dive conference 2015 - Andrei Alexandrescu - Writing Fast Code II

引子

起因是有群友在尝试解决 double 不够存数据的问题,看到了 decimal,但对 decimal 的理解还是不对。

这段话说的很含糊,可能是从 ai 的文本中截取的一段。而问为什么要用 decimal 时,这也是一般人的回答。

清晰的解释

浮点数在计算机中以二进制形式存储,而很多十进制小数(例如 0.1)在二进制下表示为无限循环小数。由于浮点数的存储空间有限,这些无限二进制小数必须被截断,从而引入精度误差。相比之下,decimal 类型使用十进制存储,可以精确表示十进制小数,因此不会产生类似的精度问题,适合对精度要求高的场景(如财务计算)。

进制与截断

decimal 如其名一样是十进制的,而一般的浮点(float/double)是基于二进制(binary floating-point)遵循 IEEE 754 标准的。

最常见的情况是 0.1 + 0.2 = 0.3 的例子,请参考以下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
checkFiniteInBinary("0.1");
checkFiniteInBinary("0.2");
checkFiniteInBinary("0.3");
checkFiniteInBinary("0.5");

float floatResult = .1f + .2f;
System.out.printf("floatResult = %.64f\n", floatResult);

double doubleResult = .1 + .2;
System.out.printf("doubleResult = %.64f\n", doubleResult);

BigDecimal decimalResult = new BigDecimal("0.1").add(new BigDecimal("0.2"));
System.out.printf("decimalResult = %.64f\n", decimalResult);

doubleResult = .2 + .3;
System.out.printf("doubleResult = %.64f\n", doubleResult);
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
19:53:38: Executing ':org.ryuu.Main.main()'…

Starting Gradle Daemon...
Gradle Daemon started in 1 s 634 ms
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE

> Task :org.ryuu.Main.main()
十进制数0.1在二进制数下不能有限表示。
0.0001100110011001100110011001100110011001100110011001100110011001... (仅显示64位)

十进制数0.2在二进制数下不能有限表示。
0.0011001100110011001100110011001100110011001100110011001100110011... (仅显示64位)

十进制数0.3在二进制数下不能有限表示。
0.0100110011001100110011001100110011001100110011001100110011001100... (仅显示64位)

十进制数0.5在二进制数下能有限表示。
0.1

floatResult = 0.3000000119209289600000000000000000000000000000000000000000000000
doubleResult = 0.3000000000000000400000000000000000000000000000000000000000000000
decimalResult = 0.3000000000000000000000000000000000000000000000000000000000000000
doubleResult = 0.5000000000000000000000000000000000000000000000000000000000000000

BUILD SUCCESSFUL in 6s
2 actionable tasks: 1 executed, 1 up-to-date
19:53:46: Execution finished ':org.ryuu.Main.main()'.

0.1,0.2这样的数在二进制里类似十进制的1/3(0.3333333…),是无限循环的。浮点数会将数据截断,因此会丢失精度。

1
2
0.1(decimal) = 0.0001100110011001100110011001100110011001100110011001100110011001... (binary)
0.2(decimal) = 0.0011001100110011001100110011001100110011001100110011001100110011... (binary)

另请参阅

github 有完整的示例工程

如果你想在 Project 视图中定位到当前正在编辑的文件

自定义快捷键

  1. 打开 Settings/PreferencesCtrl + Alt + S⌘ Command + ,)。
  2. 选择 Keymap
  3. 搜索 **”Select File in Project View”**。
  4. 给这个动作分配一个你喜欢的快捷键。(我自己用的 alt + shift 1

枚举类型 (enum type) 是指由一组固定的常量组成合法值的类型,例如一年中的季节、太阳系中的星星或者一副牌中的花色。

在 Java 变成语言引入枚举类型之前,通常是用一组 int 常量 来表示枚举类型,其中每一个 int 表示枚举类型的一个成员:

1
2
3
4
5
6
7
8
// The int enum pattern - severely deficient!
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;

这种方法称作 int 枚举模式 (int enum pattern),他存在许多不足。int 枚举模式无类型安全性,也无描述性可言。

例入将 apple 传到需要 orange 的方法中,编译器也不会产生任何警告,还会用 == 操作符对 apple 与 orange 进行比较,甚至更糟:

1
2
// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

简述

单例 (Singleton) 模式提供一个可以全局访问的实例,并保证该类仅有一个实例。

设计模式类型:创建型

实现

1.懒汉 多线程不安全

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

2.懒汉 多线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance;

private Singleton() {
}

public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

3.双检锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton singleton;

private Singleton() {
}

public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

4.饿汉

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static final Singleton instance = new Singleton();

private Singleton() {
}

public static Singleton getInstance() {
return instance;
}
}

5.静态内部类

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}

private Singleton() {
}

public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

6.枚举

1
2
3
public enum Singleton {
INSTANCE;
}

选择

不建议使用 1 和 2

不考虑继承问题使用 6 枚举

单例是派生类,确定会使用,建议使用 4 饿汉

单例是派生类,且不确定单例是否会使用,考虑 3 双检锁 或 5 静态内部类

委托是类

委托是类,C# 提供了 delegate 关键字,使得用户能简单的声明委托。编译器及 CLR 做了大量的工作来隐藏委托的复杂性。[1]

1
internal delegate void FeedBack(int value);

编译器为如上的委托声明定义一个完整的类:

1
2
3
4
5
6
7
8
9
10
11
12
internal class FeedBack : System.MulticastDelegate
{
// 构造器(Constructor)
public FeedBack(object @object, IntPtr method);

// 委托调用 [2]
public virtual void Invoke(int value);

// 委托异步调用
public virtual IAsyncResult BeginInvoke(int value, AsyncCallback asyncCallback, object @object);
public virtual void EndInvoke(IAsyncResult result);
}

可使用 ILDasm.exe 打开生成的程序集,查看这个自动生成的类。

编译器定义了 FeedBack 类,其派生自 FCL (Framework Class Library) 中的 System.MulticastDelegate 类 (所有的委托都派生自 MulticastDelegate,MulticastDelegate 派生自 Delegate)。

MulticastDelegate

所有的委托都派生自 MulticastDelegate,所以它们继承了 MulticastDelegate 的字段、属性与方法。在这些成员中,有三个非公共字段是最重要的:

字段 类型 说明
_target (Delegate 类的字段) System.Object 若委托对象包装静态方法时,此字段为 null
若委托对象包装实例方法时,此字段引用回调方法需要操作的对象 (实例方法所在的对象)
_methodPtr (Delegate 类的字段) System.IntPtr 根据平台而定的整数类型 (所以上文 ILDasm 中的显示是 native int),CLR 使用它标记需要回调的方法
_invocationList System.Object 此字段通常为 null。构造委托链时,引用一个委托数组

委托的构造器有两个参数,一个是对象引用 (System.Object),另一个则是根据平台而定的整型 (System.IntPtr)。C# 编译器构造委托时,会分析源码以确定引用的对象及方法。对象引用被传给构造器的 object 参数,标识方法的特殊 IntPtr 值 (从 MethodDef 或 MemberRef 元数据 token 获得) 被传给构造器的 method 参数。对于静态方法,为 object 参数传递 null。构造器将这两个实参分别保存于 _target 及 _methodPtr。此外,构造器将 _invocationList 设为 null。

委托链/多播

委托链是委托对象的集合

合并

调用 Delegate.Combine(Delegate a, Delegate b) 方法对两个委托进行合并。

1
fbChain = (Feedback)Delegate.Combine(fbChain, fb1);

移除

调用 Delegate.Remove(Delegate source, Delegate value) 方法对两个委托进行合并。

1
fbChain = (Feedback)Delegate.Remove(fbChain, fb1);

原理

详情请参阅 Reference Source

Combine

  • 若一个委托为 null

    返回非 null 委托

  • 若两个委托为 null

    返回 Combine 的第二个参数 (当然返回的也是 null)

  • 若都不为 null

    进行委托合并

    1. 判断两委托类型是否相同,不同则抛出 ArgumentException
    2. 合并两个委托对象中的委托 (包括委托链中的委托) (Object[] : resultList) (使用 for 遍历实现)
    3. 统计两个委托对象中的委托数 (int: invocationCount) (委托链不为空则统计委托链中的委托)
    4. 根据 resultList 及 invocationCount 构建新的委托对象并返回

Remove

注意,若 Remove 的目标为委托链,则该委托链需为当前操作委托对象委托链的连续子列表。[3]

  • 若指定需要去除的委托为空,直接返回当前委托
  • 若指定需要去除的委托不为空,在当前委托及其委托链中寻找目标委托或目标委托链,剔除并返回
    • 结果是委托链则构建新委托并返回
    • 结果是单一委托则直接返回该委托

不要定义过多的委托

Microsoft 在刚开始开发 .NET Framework 的时候引入了委托的概念。开发人员在 FCL 中添加类时,凡是有回调方法的地方都定义了新的委托类型。随时间的推移,他们定义的委托越来越多。仅在 MSCorLib.dll 中,就有接近 50 个委托类型,例如:

1
2
3
4
5
6
7
public delegate void TryCode(Object userData);
public delegate void WaitCallback(Object state);
public delegate void TimerCallback(Object state);
public delegate void ContextCallback(Object state);
public delegate void SendOrPostCallback(Object state);
public delegate void ParameterizedThreadStart(Object obj);
...

以上的示例委托,实际上都是一样的:这些委托引用的方法都是获取一个 Object 返回 void。没有必要定义这么多委托,定义一个就够了。

现在的 .NET Framework 支持泛型 (C# 2.0 版本引入),只需要几个泛型委托,就能表示多达16个参数的方法:

  • 从无参,到至多16个参数,返回值为 void 的 Action 委托:

    1
    2
    3
    4
    5
    public delegate void Action(); // (这个不是泛型委托)
    public delegate void Action<in T>(T obj);
    public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
    ...
    public delegate void Action<in T1, ... , in T16>(T1 arg1, ... , T16 arg16);
  • 从无参,到至多16个参数,返回值为 TResult 的 Func 委托:

    1
    2
    3
    4
    public delegate TResult Func<out TResult>();
    public delegate TResult Func<in T, out TResult>(T arg);
    ...
    public delegate TResult Func<in T1, ... , in T16, out TResult>(T1 arg1, ... , T16 arg16);

建议尽量使用以上的委托类型,而不是定义更多的委托类型。这样能减少系统中的类型数量,简化代码。

但若需使用 ref 或 out 关键字以传递引用的方式传递参数,可能不得自定义委托:

1
delegate void Foo(ref int bar);

event 关键字

event 关键字用于在发布类 (publisher class) 中声明事件。这是一种特殊的多播委托,仅能从声明事件的类或结构(发布类)中对其进行调用,否则产生编译器:event 的委托仅能作为 += 或 -= 的左值 (除非在其声明的类或结构中)。 如果其他类或结构订阅该事件,则在发布类引发该事件时,将调用其事件处理程序方法。 有关详细信息和代码示例,请参阅事件委托

1
public event Action action;

EventHandler

EventHandler 委托是一个预定义的委托,专门表示不生成数据的事件的事件处理程序方法。

1
public delegate void EventHandler(object? sender, EventArgs e);

如果事件生成数据,则必须使用泛型 EventHandler 委托类。

1
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

委托的简化语法

Combine 与 Remove 的简化

C# 为委托重载 += 调用 Combine ,重载 -= 调用 Remove ,简化了委托链的构造。[4]

1
2
3
4
action1 = (Action) Delegate.Combine(action1, action2);
action1 += action2;
Delegate.Remove(action1, action2);
action1 -= action2;

不需要显式构造委托对象

仅仅是为了指定委托地址就构建一个对象显得有些奇怪,实际上构建委托对象是 CLR 的要求,该对象是包装器,可保证被包装的方法只能以类型安全的方式调用。C# 简化了委托的构建过程,不需要用户显示的使用 new 关键字进行委托的构造。

  • 未显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static void Main(string[] args)
    {
    Action action = PrintAction;
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }
  • 显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // DON'T DO THIS
    public static void Main(string[] args)
    {
    Action action = new Action(PrintAction);
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }

对比显式构造与非显式构造的 IL code,他们都会构造一个 Action 委托实例。

不需要定义回调方法 (使用 lamdba 表达式)

不需要因构造委托而定义一个方法:

1
2
3
4
5
6
7
8
9
10
11
public static void Main(string[] args)
{
Action action = PrintAction;
action.Invoke();
}

// NOT NEED
private static void PrintAction()
{
Console.WriteLine("Action");
}

可以使用 lamdba 表达式简化回调:[5]

1
2
3
4
5
public static void Main(string[] args)
{
Action action = () => Console.WriteLine("Action");
action.Invoke();
}

局部变量不需要手动包装到类中即可传递给回调方法

有时可能希望回调代码引用类中定义的其他成员或方法中的局部参数:

1
2
3
4
5
6
7
8
9
10
11
internal class Program
{
private static int bar = 21;

public static void Main(string[] args)
{
int foo = 21;
Action action = () => Console.WriteLine(foo + bar); // Closure allocation: 'foo' variable
action.Invoke();
}
}

实际上 lamdba 表达式主体的代码在一个单独的方法中 (CLR 的要求)。C# 通过自动辅助类实现闭包 (closure) [6]。在辅助类中,为需要传递给回调的每个值都定义一个字段。将回调方法定义为其实例方法。

构建回调方法实际上也构造了辅助类实例,使用方法中的局部变量的值初始化该实例中的字段,最后构造委托对象并绑定到该辅助对象的实例方法。

委托与反射

开发者可以在不知道回调方法的原型时使用回调。使用 MethodInfo.CreateDelegate,可在编译期不知道委托的所有必要信息的情况下创建委托:

1
2
3
4
// 构造包含静态方法的委托
public virtual Delegate CreateDelegate (Type delegateType);
// 构造包含实例方法的委托 (target 引用 this 实参)
public virtual Delegate CreateDelegate (Type delegateType, object? target);

创建完成后可用 Delegate.DynamicInvoke(Object[]) 调用它们:

1
2
// 调用委托并传递参数
public object? DynamicInvoke (params object?[]? args);

使用反射 API 获取引用了回调方法的 MethodInfo 对象,调用 CreateDelegate 构造委托 (如果是实例方法则需要传递 target 参数,指定其 this 参数)。

使用 DynamicInvoke 方法对委托对象的回调方法进行调用。DynamicInvoke 可传递一组参数,其在内部保证传递的参数与回调方法期望的参数兼容,兼容则调用回调方法,否则抛出 ArgumentException。若参数数不匹配,则抛出 TargetParameterCountException

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
internal class Foo
{
public static void StaticMethod() => Console.WriteLine(42);

public void NonStaticMethod() => Console.WriteLine(42);

public static void MethodWithPara(int num) => Console.WriteLine(num);

}

internal class Program
{
public static void Main(string[] args)
{
Foo delegateReflectionTest = new Foo();
MethodInfo nonStaticMethodInfo = delegateReflectionTest.GetType().GetMethod("NonStaticMethod");
Delegate delegate1 = nonStaticMethodInfo?.CreateDelegate(typeof(Action), delegateReflectionTest);
delegate1?.DynamicInvoke();
MethodInfo staticMethodInfo = delegateReflectionTest.GetType().GetMethod("StaticMethod");
Delegate delegate2 = staticMethodInfo?.CreateDelegate(typeof(Action));
delegate2?.DynamicInvoke();
MethodInfo methodInfoWithPara = delegateReflectionTest.GetType().GetMethod("MethodWithPara");
Delegate delegate3 = methodInfoWithPara?.CreateDelegate(typeof(Action<int>));
delegate3?.DynamicInvoke(42);
}
}
}

参阅

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe

如何合并委托(多播委托)- C# 编程指南 | Microsoft Docs

- - 和 -= 运算符 - C# 参考 | Microsoft Docs

+ 和 += 运算符 - C# 参考 | Microsoft Docs

注释

[1] 因此,可以定义类的地方,就可以定义委托。

[2] 这里把 Invoke 翻译为调用。但是要清楚 Invoke 和 Call 的区别,执行委托方法不是直接执行目标方法,而是从委托处援引 (Invoke) 目标方法执行。

[3] 实现细节请参阅 Reference Source Multicastdelegate ,算法为移除目标数组中的连续子序列

- - 和 -= 运算符 - C# 参考 | Microsoft Docs (委托删除) 中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Action a = () => Console.Write("a");
Action b = () => Console.Write("b");

var abbaab = a + b + b + a + a + b;
var aba = a + b + a;

var first = abbaab - aba;
first(); // output: abbaab
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(abbaab, first)); // output: True

Action a2 = () => Console.Write("a");
var changed = aba - a;
changed(); // output: ab
Console.WriteLine();
var unchanged = aba - a2;
unchanged(); // output: aba
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(aba, unchanged)); // output: True

[4] 可通过查看 IL code 验证这点:

[5] 请参阅 => 运算符 - C# 参考 | Microsoft Docs (表达式主体定义)

[6] 捕获上下文的外部变量以在回调方法中使用。闭包有对外部变量的引用,所以可能导致外部变量所在的对象声明周期延长。

可能有人认为相比于 ForTest1,ForTest2 存储了数组的 Length,少了对于数组属性的频繁调用,会有更好的性能表现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;

namespace JITPropertyAccessInFor
{
internal static class Program
{
public static void Main(string[] args)
{
}

private static void Test1()
{
var a = new int[5];
}

private static void ForTest1()
{
var a = new int[5];
for (var i = 0; i < a.Length; i++)
{
Console.WriteLine(a[i]);
}
}

private static void ForTest2()
{
var a = new int[5];
int len = a.Length;
for (var i = 0; i < len; i++)
{
Console.WriteLine(a[i]);
}
}
}
}

以下 是 上段代码编译出的 IL code:(以下所述栈均为操作数栈 (Operand stack))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.method private hidebysig static void  ForTest1() cil managed
{
// Code size 30 (0x1e)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldc.i4.0 // int32 0 入栈 (i = 0)
IL_0008: stloc.1 // 出栈 置于局部变量表1位置 (i = 0)
IL_0009: br.s IL_0017 // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000b: ldloc.0 // 局部变量表0位置变量入栈 (数组元素入栈)
IL_000c: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈)
IL_000d: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_000e: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0013: ldloc.1 // 局部变量表1位置变量入计算栈 (i 入栈) (i++ 开始)
IL_0014: ldc.i4.1 // int32 1 入栈
IL_0015: add // 出栈两次,出栈值相加,结果入栈
IL_0016: stloc.1 // 出栈 置于局部变量表1位置 (i++ 结束)
IL_0017: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_0018: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈,准备获取数组长)
IL_0019: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_001a: conv.i4 // 将栈顶元素转换为 int32 类型
IL_001b: blt.s IL_000b // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001d: ret // 从当前方法返回
} // end of method Program::ForTest1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.method private hidebysig static void  ForTest2() cil managed
{
// Code size 32 (0x20)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 len,
[2] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldloc.0 // 局部变量表1位置变量入栈 (a 入栈,准备获取数组长)
IL_0008: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_0009: conv.i4 // 将栈顶元素转换为 int32 类型
IL_000a: stloc.1 // 出栈 置于局部变量表1位置 (len = a.Length)
IL_000b: ldc.i4.0 // int32 0 入栈 (声明的数组大小)
IL_000c: stloc.2 // 出栈 置于局部变量表2位置 (i = 0)
IL_000d: br.s IL_001b // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000f: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈)
IL_0010: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈)
IL_0011: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_0012: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0017: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (i++ 开始)
IL_0018: ldc.i4.1 // int32 1 入栈
IL_0019: add // 出栈两次,出栈值相加,结果入栈
IL_001a: stloc.2 // 出栈 置于局部变量表2位置 (i++ 结束)
IL_001b: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_001c: ldloc.1 // 局部变量表1位置变量入栈 (len 入栈)
IL_001d: blt.s IL_000f // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001f: ret // 从当前方法返回
} // end of method Program::ForTest2

对比上述的 IL code,确实临时存储数组长,能够少在 for 的比较进行中少进行一定的操作,无需将数组从局部变量表(Local Variable Table)入操作数栈 (Operand stack),并执行 ldlen 获取数组长。 但要注意, JIT 编译器知道 Length 是 Array 类的属性,生成的代码中只会调用该属性一次,结果会存储到临时变量中,此后的检查中调用的都是此临时变量。不需要自己用局部变量做缓存,这样既没有性能提升,还可能造成可读性下降

参阅

CLR via C# (第四版) 16.7 数组的内部工作原理

注释

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe

文章名称不需要后缀名

1
{% post_link 文章名称 %}

1
{% post_link Hello-World %}

文章链接配置位于 _config.yml 中 permalink

1
2
3
4
5
6
7
8
9
# URL
## If your site is put in a subdirectory, set url as 'http://yoursite.com/child' and root as '/child/'
url: http://yoursite.com
root: /
permalink: :title/ # :year/:month/:day/:title/
permalink_defaults:
pretty_urls:
trailing_index: true # Set to false to remove trailing 'index.html' from permalinks
trailing_html: true # Set to false to remove trailing '.html' from permalinks
0%