委托设计与实现 从 .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)。依赖建立在被观察者和观察者之间,松耦合。
- M 通常会远小于 N
- 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
不仅如此,有的这些调用的 api 里还混了这个class自身的其他操作。
分析
可能是这个项目有很多人维护过,更新一些实现倒是有用 List
但之前提到的派生的耦合的问题还在,而且违反了 DRY 原则,我在维护这个项目的时候经常是要到处找这些 List
看看有没有什么额外操作,又或者是有没有地方忘记加 api 了,很浪费时间。
结论
现在项目里直接引用和维护 List
再这么浪费时间下去可不行,得像 .NET 的 multicast delegate 一样,提供一个统一多播实现。
设计与方案
设计目标
该库需要尽快完成,以满足项目需求,并确保高效开发。
成熟可靠,因为这是要用在项目的核心功能上,关系到百万千万个用户的体验,不能出错。
易用,我们的项目里有很多同事,水平参差不齐,得让大家都能用。我实际也问过同事,有的同事甚至都没听说过观察者模式。
方案
综合考虑,直接模仿 .NET 的 multicast delegate 是最合适的方案。又快又成熟可靠,还易用。
虽然 .NET 里的 delegate 一等公民而且还有方便的语法糖,我们在 java 里不能做到完全一样,但也能做到很接近。
详细设计
由于 java 已经有 function interface(函数式接口)去作为 lambda 表达式的目标了,我们是无法做到像 .NET
一样委托和多播委托无感的。但用函数式接口去做委托,再用已经写好了的委托去写多播委托是可以的。
委托实现
委托直接用函数式接口就行,主要是规范 api 和为多播委托做铺垫,同时不要忘记做好和 java 的适配。
在实现前我们可以先做好 java EventListener[1] 的适配同时用接口标记 delegate
1 | package org.ryuu.functional; |
保持和 .NET 一样,调用时的方法名称为 ... invoke(...)
减少理解成本[2]。用@FunctionalInterface
[3] 注解修饰实际的delegate
。
1 | package org.ryuu.functional; |
现在委托就做好了。这样的另一个好处是,我们不需要在需要传 lambda 表达式的时候满世界找一个适配的函数式接口了,因为我们会像
.NET 一样预定义好:
1 | void invoke(T arg); |
多播委托
我们需要先清楚 .NET MulticastDelegate 的实现
1 | protected sealed override Delegate CombineImpl(Delegate? follow) |
不可变性
任何“修改”都通过构造新对象来完成。虽然 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 | [ ] |
Low-Level c#
1 | [ ] |
c# 在编译的时候把 a += b
和 a -= b
变成了 Delegate.Remove
和Delegate::Combine
而且把新产生的对象赋回了 a。
在 java 里我们是不可能通过编写代码实现这样的语法糖的,在 java 里用方法去实现就行。
栈式操作(Stack Semantics)
- 语义一致
在委托(delegate)的操作语义上,+=
始终将新委托附加到 尾部(类似 push),而-=
则撤销 最近一次添加的委托(类似
pop)。这种设计确保了委托的修改符合 栈(LIFO)语义,简单直观。 - 实现高效
从尾部开始查找和移除,可以直接截断尾部,操作成本低;若从头部开始匹配,则需要移动后续所有元素,效率更差。
在 Java 中同样可以实现这种语义。做法是:
- 添加:始终往尾部追加。
- 删除:从尾部开始执行 子列表匹配(Sublist Matching),只移除最近一次匹配到的委托。
这样既能保证语义一致,又能在实现上保持高效。
event
c# 中 event,是 基于 delegate
的一种特殊成员
High-Level c#
1 | private event Action a = () => { }; |
Low-Level c#
1 | [ ] |
可见编译器为 event 生成了 add 和 remove 访问器,使用了 CAS[4] 做自旋锁,来保证对多播的修改是线程安全的。
在 Java 中,如果想实现类似的体验,可以通过 APT(Annotation Processing Tool) 来生成访问器代码,使使用体验更接近
.NET。然而,APT 会带来一些问题:
- 增加了项目复杂度。
- IDE 对生成代码的支持可能不完善。
- 用户自定义代码时,APT 生成的代码可能出现冲突或不易维护。
考虑到这个项目是一个通用库,而不是语法糖增强工具,我们不会采用 APT 的方式。
因此,我们选择通过 接口 实现类似 .NET 的 event
:
- 提供修改委托(添加/移除)的方法。
- 不暴露
invoke
方法给外部用户。
这样既能保证多播操作的安全与一致性,也简化了使用和维护。
多播委托实现
以多播委托移除为例,同时展示栈式操作,不可变性的具体实现:
1 | private void removeMulticastDelegate(MulticastDelegate<T> multicastDelegate) { |
内部的添加方法为例,展示多播委托的内部隐藏单播多播区别实现:
1 | private void addInternal(T delegate) { |
添加为例,展示 event 多线程安全实现:
1 | private final boolean isEvent; |
这里我们用的是 synchronized
保证多线程安全而不是基于 CAS
写一个自旋锁。我们需要考虑锁实现的性能,自旋锁是一种忙等(busy waiting
),如果竞争很激烈[5]会非常浪费 CPU。这种情况可以让线程阻塞,CPU 还可以去执行其他线程,这样效率更高。
代码段示例,展示 event 的调用全只留给声明事件的类内部实现:
1 | public interface Event<T extends Delegate> { |
1 | public abstract class MulticastDelegate<T extends Delegate> implements Iterable<T>, Delegate, Event<T> { |
1 | private static class ClassWithActionsEvent { |
微基准测试
.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