引子 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,每个 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 ){ return null ; return this ; return null ; return NewMulticastDelegate(list, invocationCount - 1 , true ); 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 += 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 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 { Action action1 = this .a; Action action2; do { action2 = action1; action1 = Interlocked.CompareExchange<Action>(ref this .a, (Action) Delegate.Combine((Delegate) action2, (Delegate) value ), action2); } 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 会带来一些问题:
增加了项目复杂度。
IDE 对生成代码的支持可能不完善。
用户自定义代码时,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); 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 { 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