委托设计与实现 从 .NET 到 Java

引子

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