需求背景
出于一些原因,公司的项目缓存并不打算使用spring-data-redis提供的原生注解,而是基于自定义注解+AOP切面的形式完成缓存的操作。出于对开发友好的情况考虑,决定在注解中支持开发人员通过SpEL的形式,结合方法入参定制该方法的cache key。然而在压测时发现有大量对象阻塞,导致响应时间并不尽如人意。
问题代码
网上关于如何使用Spring提供的组件来解析SpEL的帖子已经很多了,这里不做赘述。直接来看核心代码
/**
* 通过AOP拦截方法、获取注解的key等方法就不写了
*/
public class CacheAspectDemo {
private static final LocalVariableTableParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();
/**
* 真正解析SpEL形式的key的方法
*/
private String doParseKey(String key, JoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
String[] parameterNames = DISCOVERER.getParameterNames(method);
// parse spel key
return "ok";
}
}
在这里有两点比较有意思的需要说明:
- 第一,在LocalVariableTableParameterNameDiscoverer这个组件中的注释写明了该类是个线程安全的类,并且是被官方推荐重复使用的,而网上绝大多数的帖子都是直接在doParseKey的方法中new了一个对象出来。虽然是仅做演示使用,但是该吹毛求疵的地方还是要吹一吹的。注释如下,在这里就不翻译了:
Implementation of ParameterNameDiscoverer that uses the LocalVariableTable information in the method attributes to discover parameter names. Returns null if the class file was compiled without debug information. Uses ObjectWeb's ASM library for analyzing class files. Each discoverer instance caches the ASM discovered information for each introspected Class, in a thread-safe manner. It is recommended to reuse ParameterNameDiscoverer instances as far as possible. - 第二,parameterNames究竟是干什么用的。暂时可以理解为支撑spel表达式用的。因为我们的java代码在编译过后的字节码文件中,所有的变量都变成了arg0、arg1这种,所以当开发人员传入一个SpEL表达式的变量时spring并不认识它。这个LocalVariableTableParameterNameDiscoverer就是通过ASM解析字节码,将arg0这种变量重新翻译回原来的比如id这种的变量名,再支撑后续SpEL中抽象语法树的解析。所以这一步是必要的。
原因分析
说了这么多,问题出在哪?就是这一步
DISCOVERER.getParameterNames(method);
在压测时发现,大量的线程在此处block了,仔细一看居然还是堵在ConcurrentHashMap的方法。我们进入这个方法看下:
/**
* 无关代码就不贴了 只贴用到的
*/
public class LocalVariableTableParameterNameDiscoverer implements ParameterNameDiscoverer {
private final Map<Class<?>, Map<Executable, String[]>> parameterNamesCache = new ConcurrentHashMap<>(32);
@Nullable
private String[] doGetParameterNames(Executable executable) {
Class<?> declaringClass = executable.getDeclaringClass();
Map<Executable, String[]> map = this.parameterNamesCache.computeIfAbsent(declaringClass, this::inspectClass);
return (map != NO_DEBUG_INFO_MAP ? map.get(executable) : null);
}
}
可以看到,这段代码本身是想用当前被执行的Method作为key,给代码加上缓存,使解析可以快一点。但是问题就出在了第二行,获取到方法后直接调用了ConcurrentHashMap#computeIfAbsent方法。
本人使用的JDK版本是JDK8。而众所周知,JDK8的ConcurrentHashMap#computeIfAbsent方法是有性能问题的...无论什么情况,都会进入synchronized方法从而锁住某个table中的某个节点。
bugs.openjdk.org/browse/JDK-…
所以查下来本质上还是ConcurrentHashMap的坑...而Spring官方提供的组件也没有对此做封装处理...
有没有别的替代方案呢?看了下core包的别的几个实现,好像没有什么特别好的替代品:
- PrioritizedParameterNameDiscoverer:用List实现的 效率还不如map
- StandardReflectionParameterNameDiscoverer:每次解析都要new String[],然后用反射进行属性填充...性能不言而喻了
解决方案
最直接的方案:升JDK版本 11或17都解决了 但是在项目组可不能自己升版本...
所以只能自己在最外层封装一下了。先get一次,如果get的值为空再进行computeIfAbsent。这里就不做过多的加锁处理了,只简化代码做一层get,同时这里也不用考虑get的原子性问题。虽然在这个场景中先get再computeIfAbsent的确不是原子操作,但是也无所谓覆盖了,无论覆盖多少次这个操作都是幂等的,无非是在最开始的几次get中由于并发可能还会出现block的情况,但是在最开始的几个线程操作完成后,之后所有的线程操作都可以不进入computeIfAbsent方法了,也就不存在对象阻塞的问题了。
第一次写文章有些紧张...如有理解不到位的地方欢迎大家批评指正