揭穿迷思:代理影响性能

工程 | Alef Arendsen | 2007 年 7 月 19 日 | ...

在最近的一篇博客文章中,Marc Logemann 提到了代理性能的话题。在他的文章中,他请求 Spring 方面的 '伙计们' 提供一份白皮书。我不想花很多篇幅讨论代理和字节码织入机制之间精确到纳秒的差异,但我确实认为重申一下这些差异以及这场讨论是否重要是很有价值的。

什么是代理,我们为何使用它们?

让我们首先简要回顾一下代理的用途(通常意义上,以及在 Spring 中)。根据 Gang of Four (GoF) 关于设计模式书籍的说法,代理是另一个对象的替身或占位符,用于控制对该对象的访问。因为代理位于对象的调用者和实际对象之间,所以它可以决定阻止调用实际(或目标)对象,或者在调用目标对象之前做一些事情。prox.jpg

换句话说,代理可以作为真实对象的替身,为这些对象应用额外的行为——无论是安全相关的行为、缓存还是性能度量。

许多现代框架使用代理来实现原本不可能实现的功能。许多对象关系映射器使用代理来实现一种行为,即在实际需要数据之前阻止数据加载(这有时称为延迟加载)。Spring 也使用代理来实现其部分功能,例如其远程处理能力、事务管理能力和 AOP 框架。

代理的替代方案是字节码织入。使用字节码织入机制时,永远不会有第二个对象(即代理)。相反,如果需要应用行为(例如事务管理或安全性),它会被“织入”现有代码中,而不是“围绕”代码。进行织入过程的一种方法是使用 Java5 的 -javaagent 标志。也有其他方法可用。

换句话说:使用代理时,你最终会得到一个位于目标对象前面的代理对象,而使用字节码织入方法时,不会有需要委托调用的代理。

残酷的事实

好的,让我们直面这个问题:代理会给普通方法调用增加开销……而且是显著的开销。在我看来,这完全不足为奇。在中间放置一个代理是完全自然的。一般来说,可以说中间层总是会增加开销。现在问题是:**我们为代理增加的开销换来了什么?**

请注意,我在这里不打算提供具体数字。正如 Stefan Hansel 在他在 Marc 博客上的评论中正确指出的那样,测量普通目标调用与中间有代理之间的差异的微基准测试(或任何微基准测试)并没有太大意义,因为还需要考虑其他许多因素。

好的,但你**确实**想要数字?

好的,那我们来看吧。让我们考虑以下这段代码,我们有两个对象,一个被代理,一个没有。假设目标对象本身(`doIt()` 方法)没有做任何特别的事情。我们也假设代理本身没有做任何特别的事情(它只是委托给目标对象)。

如果我在我的笔记本电脑 (MacBook) 上使用普通的 JDK 动态代理(稍后会详细介绍)运行此代码,那么对 *myRealObject* 的一次方法调用需要 9 纳秒 (10-9)。对被代理对象的一次调用需要 500 纳秒(大约慢了 50 倍)。


// real object
MyInterface myRealObject;
myRealObject.doIt();

// proxied object
MyInterface myProxiedObject;
myProxiedObject.doIt();

相比之下,如果我使用字节码织入方法(在这种情况下,我使用 AspectJ 来模拟相同的设置),我的调用只增加了大约 2 纳秒。

所以总结来说,我无法美化事实:代理会给普通方法调用带来显著的开销。

在我们继续之前,先认识到这里增加的开销是**固定的**。如果 `doIt()` 方法本身需要 5 秒,被代理的调用**绝对不会**花费 50 倍的时间。不,相反,调用会花费 5 秒 + 约 500 纳秒。

结合实际情况来看(或者:你需要在乎吗?)

好的,现在我们知道代理并非那种能神奇地工作而不会产生副作用的超快速对象,问题是:“我们需要担心开销吗?”答案很简单:“不,你不需要” ;-)。我会解释原因。

我们使用代理来透明地为对象添加行为。也许是为了用安全规则装饰一个对象(管理员可以访问它,但普通用户不能),或者也许是因为我们想启用延迟加载,只在第一次访问时从数据库加载数据。另一个原因可能是为我们的对象启用透明的事务管理。

事务管理

让我们看看事务管理的例子。以下序列图大致描绘了(简化视图)在调用服务时发生的情况,其中事先启动一个事务,并在成功完成后提交事务。seq.jpg

现在调用服务本身确实会涉及一定的开销(我们之前已经讨论过的开销)。然而问题是,我们为这些开销换来了什么?

实现的益处

如果我们继续看上面的例子,我们已经实现了几个益处。

**代码简化** 通过在中间放置一个代理,我们极大地简化了代码。如果我们使用 Spring 提供的 `@Transactional` 注解,我们只需要做以下事情


public class Service {

  @Transactional 
  public void executeService() { }

}

以及


<tx:annotation-driven/>

<bean class="com.mycompany.Service"/>

另一种(编程方式的)方法将涉及显著修改客户端(调用者)或服务类本身。

**集中式事务管理** 事务管理现在由一个中心设施负责,这使得优化和事务管理方法更加一致。如果我们在服务或调用者本身实现了事务管理代码,这是不可能实现的。

那这一切又有什么关系呢?

如果这还不够,我们随时可以开始研究代理机制带来的实际性能下降,并将其与启动和/或提交事务所需的实际时间进行比较。我没有任何具体的数字,但我可以向你保证,在 JDBC 事务上提交一个事务所需的时间肯定比 491 纳秒要长。

但是如果代理执行的是非常细粒度的操作呢?

啊哈!那是完全不同的情况。当然,你可以透明地添加不同类别的行为(无论使用代理还是字节码织入方法)。我通常区分细粒度行为和粗粒度行为。在我看来,粗粒度行为应用于服务级别或仅应用于我们应用程序中的某个有限的操作集。更细粒度的行为集例如包括记录系统中每个方法的日志。我绝对不会选择基于代理的方法来处理这种细粒度的场景。

经验法则

综上所述,我们可以得出以下结论
  • 首先,代理会增加开销,但如果应用于被代理对象的行为与耗时较长的操作(例如数据库或文件访问或事务管理)有关,那么这种开销可以忽略不计。
  • 我们还可以说,如果你需要非常细粒度的行为,并希望将其应用于大量对象,那么选择字节码织入方法(例如 AspectJ)可能更安全。
  • 如果这些还不够,那么仍然可以说,代理(除非应用于系统中数千个或更多对象)绝不会是你在系统性能下降时首先需要查看的地方。
  • 另一个可能的经验法则是,系统中的任何请求可能不应涉及(调用)超过 10 个(左右)被代理方法。**10 次代理操作 * 每次代理操作 500 纳秒 = 5 微秒**(我认为这仍然可以忽略不计),但**100,000 次代理操作 * 每次代理操作 500 纳秒 = 50 毫秒**(在我看来这就不再可以忽略不计了)。

不同类型的代理

除了关于代理是否会增加开销的讨论之外,简要讨论不同类型的代理也很重要。存在几种不同类型的代理。在我做的小型基准测试中,我使用了 JDK 动态代理基础设施(来自 `java.lang.reflect` 包),它只能为接口创建代理。另一种代理机制是 CGLIB,它使用了略微不同的代理方法。上次我在两者之间进行小型性能基准测试时,并没有发现显著差异,坦率地说,我对此不太在意。重要的是已创建代理的内部工作原理。如果你自己开始实现代理,有很多事情可能会出错。例如,如果你比较以下两段代码,你可能不会预料到两者之间在性能上会存在**巨大**差异。当我说巨大时,我是指大约 10 倍的差距……

public Object invoke(Object proxy, Method proxyMethod, Object[] args)
throws Throwable {
	Method targetMethod = null;
	if (!cachedMethodMap.containsKey(proxyMethod)) {
		targetMethod = target.getClass().getMethod(proxyMethod.getName(), 
			proxyMethod.getParameterTypes());
		cachedMethodMap.put(proxyMethod, targetMethod);
	} else {
		targetMethod = cachedMethodMap.get(proxyMethod);
	}
	Ojbect retVal = targetMethod.invoke(target, args);
	return retVal;
}

public Object invoke(Object proxy, Method proxyMethod, Object[] args)
throws Throwable {
	Method targetMethod = target.getClass().getMethod(proxyMethod.getName(), 
			proxyMethod.getParameterTypes());
	Ojbect retVal = targetMethod.invoke(target, args);
	return retVal;
}

换句话说,将代理的生成或创建留给知道自己在做什么的人或框架来做。幸运的是,我没有参与代理的设计,而且 Rob、Juergen、Rod 等人在这方面比我做得好得多,所以不用担心 ;-)。

那字节码织入呢?

总的来说,可以说字节码织入方法需要根据你的环境花费更多时间来设置。在某些场景中,你需要设置 Java Agent,在其他情况下,你可能需要修改编译过程,其他框架可能需要使用不同的类加载器。换句话说,字节码织入更难设置一些。根据我的经验,(和往常一样)80/20 法则也适用于这里。80% 的需求可能可以使用基于代理的系统来解决。对于最后的部分,或者剩下的 20%,选择字节码织入方法可能是一个不错的选择。

与 AOP 的关系

你可能想知道我为什么还没有触及 AOP 这个话题。代理和字节码织入与 AOP 有很强的关系。或者也许反过来。无论如何,Spring 的 AOP 框架*使用*代理来实现其功能。在我看来,代理只是一个实现细节(虽然是一个相当重要的细节),与 AOP 和整个 Spring 框架紧密相连。

结论

总而言之,我们可以说代理确实会给对其代理的对象调用带来一点开销,但在大多数情况下,关于这个开销的讨论并不重要。原因部分在于代理带来的巨大益处(例如由于代码简化和集中控制带来的代码维护性大大提高),也在于我们使用代理执行的操作(例如事务管理或缓存)通常对性能的影响远大于代理机制本身。

订阅 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您快速进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部