揭穿神话:代理对性能的影响

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

在最近的一篇博客文章中,Marc Logemann 讨论了代理性能问题。在他的文章中,他请求“Spring 团队”提供一份白皮书。我不想花费大量篇幅来讨论代理和字节码编织机制之间精确到纳秒的差异,但我认为再次重申它们的区别以及这个讨论是否重要是有价值的。

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

让我们首先简要回顾一下代理的用途(一般情况下,以及在 Spring 中)。根据四人帮(GoF)关于设计模式的书籍,代理是一个替代对象或另一个对象的占位符,用于控制对它的访问。因为代理位于对象的调用者和实际对象之间,所以它可以决定阻止调用实际(或目标)对象,或者在调用目标对象之前执行某些操作。prox.jpg

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

许多现代框架使用代理来实现否则不可能实现的功能。许多对象关系映射器使用代理来实现一种行为,这种行为可以防止数据在实际需要之前加载(这有时被称为延迟加载)。Spring 还使用代理来实现其某些功能,例如其远程处理功能、其事务管理功能和 AOP 框架。

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

换句话说:使用代理,最终会得到一个位于目标对象之前的代理对象,而使用字节码编织方法,则不会有必须委托调用的代理。

残酷的真相

好吧,让我们直接说:代理会增加普通方法调用的开销……相当大的开销。在我看来,这绝对不足为奇。在两者之间放置一个代理是完全自然的。通常可以说,中间件总是会增加开销。现在问题是:**我们从代理增加的开销中获得了什么?**

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

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

好吧,让我们开始吧。让我们考虑以下代码片段,其中我们有两个对象,一个是被代理的,一个不是。让我们假设目标对象本身(dotIt() 方法)并没有执行任何特定操作。让我们还假设代理也没有执行任何特定操作(它只是委托给目标对象)。

如果我在我的笔记本电脑(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 纳秒花费更多时间。

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

啊!这是一个完全不同的故事。当然,您可以透明地添加不同类别的行为(使用代理或使用字节码编织方法)。我通常区分细粒度和粗粒度行为。在我看来,粗粒度行为是在服务级别应用的,或者仅应用于应用程序中特定且有限的操作集。更细粒度的行为集例如包括记录系统中每个方法的日志。我绝对不会选择对这种细粒度的方法使用基于代理的方法。

经验法则

总而言之,我们可以说:
  • 首先,代理会增加开销,并且如果应用于被代理对象的 behavior 与较长时间运行的操作(例如数据库或文件访问或事务管理)有关,则此开销可以忽略不计。
  • 我们还可以说,如果您需要非常细粒度的行为并希望将其应用于大量对象,那么选择字节码编织方法(例如 AspectJ)可能更安全。
  • 如果这还不够,可以肯定地说,代理(除非应用于系统中的数千个或更多对象)永远不会是您在性能下降的系统中首先应该寻找的地方。
  • 另一个经验法则是,系统中的任何请求可能都不应该包含(调用)超过 10 个(左右)代理方法。**10 个代理操作 * 500 ns/代理操作 = 5 微秒**(我认为这仍然可以忽略不计),但是 **100,000 个代理操作 * 500 ns/代理操作 = 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代理;在其他情况下,你可能需要修改编译过程;其他框架可能需要使用不同的类加载器。换句话说,字节码织入的设置稍微困难一些。根据我的经验(一如既往),二八法则在这里也适用。80%的需求可能都可以通过基于代理的系统来解决。对于剩下的20%(最后一公里),选择字节码织入方法可能是一个不错的选择。

与AOP的关系

你可能想知道为什么我还没有提到AOP这个主题。代理和字节码织入与AOP有着密切的关系。或者也许是反过来的。无论如何,Spring的AOP框架*使用*代理来实现其功能。在我看来,代理只是一个实现细节(虽然是一个非常重要的细节),它与AOP和Spring总体而言紧密相关。

结论

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

获取Spring简报

关注Spring简报

订阅

领先一步

VMware提供培训和认证,以加速您的进步。

了解更多

获取支持

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

了解更多

即将举行的活动

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

查看全部