背景

反射获取方法参数

当在AspectJ的切面中,需要获取切面方法的参数名信息时,可以怎么做?

自 JDK 1.8 之后,开始引入 java.lang.reflect.Parameter 类,用于对参数名称的支持,通过使用“-parameters”编译器标志,开发者在编译时可以保留方法参数的名称信息。

StandardReflectionParameterNameDiscoverer 则是Spring封装的,使用 JDK 8 的反射工具,内省方法参数名的工具类,在 Spring MVC 中有大量使用该类获取参数信息。

Arthas

什么是 Arthas 可以参考 官网介绍

简单来说,作为一个 Java 开发,遇到线上问题时才发现由于 日志不齐全、无法debug 等原因无法定位问题时,怎么办?有Arthas就可以处理了。我们能用Arthas来做什么官网也有介绍:

Arthas 能给我们带来什么能力?

问题

表现:消失的参数名列表

为了排查线上某个接口的性能问题,使用 Arthas - trace 命令观察指定接口的调用路径以及相应的耗时时,发现当服务刚启动且未发起请求时,使用trace命令观察接口后,所有该接口的调用都会触发一个异常错误:

java.lang.NullPointerException: Cannot read the array length because "params" is null

详细查看报错行代码后发现,接口出入参打印是由一个公共组件封装的能力,这个通用组件内会获取接口方法的参数名信息做一些处理。大概代码参考:

1
2
3
4
5
6
ParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer();
Method method = ((MethodSignature) point.getSignature()).getMethod();
String[] params = paramDiscover.getParameterNames(method);
for (int len = 0; len < params.length; len++) {
    // do something
}

此刻心里缓缓冒出一个❓

为什么这个 params 会为 null 呢?

分析

反射相关

首先来看下反射获取参数名列表的代码: paramDiscover.getParameterNames(method)

 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
public class StandardReflectionParameterNameDiscoverer implements ParameterNameDiscoverer {

	@Override
	@Nullable
	public String[] getParameterNames(Method method) {
		return getParameterNames(method.getParameters());
	}

	@Override
	@Nullable
	public String[] getParameterNames(Constructor<?> ctor) {
		return getParameterNames(ctor.getParameters());
	}

	@Nullable
	private String[] getParameterNames(Parameter[] parameters) {
		String[] parameterNames = new String[parameters.length];
		for (int i = 0; i < parameters.length; i++) {
			Parameter param = parameters[i];
			if (!param.isNamePresent()) {
				return null;
			}
			parameterNames[i] = param.getName();
		}
		return parameterNames;
	}
}

从实现代码可以看到,当 Parameter#isNamePresent 返回false时,参数名列表才会是个null,那这个方法是干什么用的呢?

1
2
3
4
public boolean isNamePresent() {
    // 当没有真实参数数据,或name为null,则会返回false
    return executable.hasRealParameterData() && name != null;
}

追踪代码可以看到一个关键类:java.lang.reflect.Executable#privateGetParameters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Parameter[] privateGetParameters() {
    // Use tmp to avoid multiple writes to a volatile.
    Parameter[] tmp = parameters;
    if (tmp == null) {
        // Otherwise, go to the JVM to get them
        try {
            tmp = getParameters0();
        } catch(IllegalArgumentException e) {
            // Rethrow ClassFormatErrors
            throw new MalformedParametersException("Invalid constant pool index");
        }
        // If we get back nothing, then synthesize parameters
        if (tmp == null) {
            hasRealParameterData = false;
            tmp = synthesizeAllParams();
        } else {
            hasRealParameterData = true;
            verifyParameters(tmp);
        }
        parameters = tmp;
    }
    return tmp;
}

这个方法的作用简单来说就是,先尝试从JVM中获取参数列表:

  • 参数列表依旧为null,则说明无参数数据,标记 hasRealParameterData 为false,并尝试合成参数信息(获取参数数量,并以 arg0,1,2…表示)
  • 参数列表不为null,标记 hasRealParameterData 为true,并校验参数列表

到目前为止基本可以确定,因为从JVM中获取的参数列表是个null,才导致出现的 NullPointException,而获取参数信息时调用的java.lang.reflect.Executable#getParameters0 是一个native方法。

Arthas 相关

由于反射相关的Java代码无法定位为什么从JVM中获取的参数列表是个null,再深入就得去看native实现的C++代码了。这时我突然意识到,可以去看看Arthas的issues是不是有人踩过这个坑,于是我就找到了这个:arthas增强类以后参数名会抹除 #2800

按照issue的描述来看,当使用arthas对类进行增强的时候,增强类的方法参数名会丢失,变成arg0这种占位符。

本地用arthas来手动尝试一把:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[arthas@40916]$ ognl '@com.xxx.XxxController@class.getMethods()[0].getParameters()'
@Parameter[][
    @Parameter[com.xxx.ParamVO request],
]
[arthas@40916]$ stack com.xxx.XxxController index -n 5
Press Q or Ctrl+C to abort.
Affect(class count: 2 , method count: 2) cost in 167 ms, listenerId: 1
[arthas@40916]$ ognl '@com.xxx.XxxController@class.getMethods()[0].getParameters()'
@Parameter[][
    @Parameter[com.xxx.ParamVO arg0],
]

尝试结果如下:

  • 第一次执行 getParameters 方法,能正常获取参数名信息:request。
  • 使用stack命令增强
  • 第二次执行 getParameters 方法,获取到的参数名信息就变成了 arg0

到目前为止基本可以确定,params 为 null 的原因,就是因为Arthas。

深入

排查一番之后,虽然找到了直接原因,但是随之而来的是更多的问题:

  • Arthas 增强后为什么反射获取参数列表会失败?
  • 为什么增强后再获取参数列表,参数名就变成了arg0?
  • 为什么只有在服务启动后首次访问接口前增强会导致后续接口调用失败?

Arthas 增强导致参数名丢失

首先关注这个issue: arthas增强类以后参数名会抹除 #2800 ,其中有提到JDK有一个bug,其会导致JVM在重新加载class时丢失方法参数信息。如下:JDK-8240908 RetransformClass does not know about MethodParameters attribute

手动尝试一下,是否符合预期,使用JDK17: retransformClass测试

可以看到,在触发 Instrumentation#retransformClasses 重新加载类字节码后,参数名信息丢失了。而 Arthas 的实现就是借助 Java Agent 在字节码层面对类和方法进行修改,达到观测目标的。而最终实现就是通过 java.lang.instrument 相关工具,通过 addTransformer/retransformClasses 来修改并重新加载类字节码。

看看源码:https://github.com/alibaba/arthas

类增强命令都会继承 EnhancerCommand,而 EnhancerCommand 都会调用 Enhancer#enhance 方法。Enhancer是Arthas中实现的 ClassFileTransformer,用于对类进行增强,参考代码: Enhancer#enhance

从图片中可以观察到,Enhancer 确实会触发上述 Instrumentation#retransformClasses 方法,因此在增强类后会导致参数名丢失。

为什么是 arg0?

知道了参数信息丢失的原因后,下一个问题是,为什么参数名会变成arg0?这个就需要回到 Executable#getParameters 方法来看,当JVM丢失参数信息后,Java的反射代码会怎么处理这种情况,直接看代码吧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * {@return an array of {@code Parameter} objects representing
 * all the parameters to the underlying executable represented by
 * this object} An array of length 0 is returned if the executable
 * has no parameters.
 *
 * <p>The parameters of the underlying executable do not necessarily
 * have unique names, or names that are legal identifiers in the
 * Java programming language (JLS {@jls 3.8}).
 *
 * @throws MalformedParametersException if the class file contains
 * a MethodParameters attribute that is improperly formatted.
 */
public Parameter[] getParameters() {
    // TODO: This may eventually need to be guarded by security
    // mechanisms similar to those in Field, Method, etc.
    //
    // Need to copy the cached array to prevent users from messing
    // with it.  Since parameters are immutable, we can
    // shallow-copy.
    return privateGetParameters().clone();
}

getParameters 方法最终会调用 privateGetParameter 来获取参数列表,并返回一个对应结果的浅拷贝。

 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
private transient volatile Parameter[] parameters;
private native Parameter[] getParameters0();
// ...
private Parameter[] synthesizeAllParams() {
    final int realparams = getParameterCount();
    final Parameter[] out = new Parameter[realparams];
    for (int i = 0; i < realparams; i++)
        // TODO: is there a way to synthetically derive the
        // modifiers?  Probably not in the general case, since
        // we'd have no way of knowing about them, but there
        // may be specific cases.
        out[i] = new Parameter("arg" + i, 0, this, i);
    return out;
}
private Parameter[] privateGetParameters() {
    // Use tmp to avoid multiple writes to a volatile.
    Parameter[] tmp = parameters;
    if (tmp == null) {
        // Otherwise, go to the JVM to get them
        try {
            tmp = getParameters0();
        } catch(IllegalArgumentException e) {
            // Rethrow ClassFormatErrors
            throw new MalformedParametersException("Invalid constant pool index");
        }
        // If we get back nothing, then synthesize parameters
        if (tmp == null) {
            hasRealParameterData = false;
            tmp = synthesizeAllParams();
        } else {
            hasRealParameterData = true;
            verifyParameters(tmp);
        }
        parameters = tmp;
    }
    return tmp;
}

privateGetParameter 会先尝试访问 parameters 成员变量,若该变量为null,则会进行初始化流程,否则直接返回。从这段代码可以看到,初始化参数信息时,会先尝试从JVM获取(getParameters0() 是native方法)参数信息,若JVM获取到的参数信息为空,则会标记 hasRealParameterData 为false,并调用 synthesizeAllParams() 方法,hasRealParameterData 会导致 Parameter#isNamePresent 方法返回false,这也是一开始碰到空指针异常的原因了。

而另一个 synthesizeAllParams 方法的作用,就是在JVM获取的参数信息为空时,以 arg 为前缀尝试注入参数名信息,这也是为什么增强后参数丢失,看到的参数名是arg0的原因。

Method 获取参数名缓存

在尝试复现的过程中我发现,只有在服务启动后首次访问接口前增强会导致后续该接口调用失败,如果已经调用过接口再增强,就不会失败了。

回顾 privateGetParameters 方法后,这个问题的原因也就自然的浮出水面了!

Method 是有一个成员变量来缓存参数信息结果的,如果该成员变量不为空,就不会进行初始化,也就不会再从JVM获取参数信息了。

总结

从JDK的bug单来看,这个问题在 JDK19+ 才会修复,因此在19之前,涉及到使用了 Instrumentation 增强类的工具,需要谨慎使用。另外,封装组件还是要尽量从代码的角度上避免向上抛Exception,自身问题不能影响业务功能。

Arthas 增强命令

arthas增强类的命令,都继承自 EnhancerCommand 类,可以看到涉及到增强的命令如下:

EnhancerCommand

参考链接