关于 PromQL 的 histogram_quantile 的算法

关于 PromQL 的 histogram_quantile 的算法. 很长一段时间内, 我都以为是使用正态分布算的, 其实并没有那么复杂.

假如我们有11个桶, 我们的数据是从0开始的, 第一个桶是接收落在(0~10]区间的数量, 第二个桶是接收落在(10 ~ 20]区间的数量, 以此类推, 第10个桶接收落在(90~100]区间的数量, 那么第11个桶接收(100 ~ +Inf) 的区间, 通常最后一个区间应该没有值或非常少.

那么如果要求第 95分位等值, 我们就要统计这时总共有多少samples, 其中第95th/100 落在那个桶. 假如我们收到100个samples, 那么第95th/100 就是第95个sample, 看它落在哪个桶. 如果我们收到10000个samples, 那就是第9500个sample, 看它落在哪个桶.

当我们看到它落到那个桶之后, 就在数一下这个桶共有多少个数, 然后算一下这个桶占据了第多少分位(低)到第多少分位(高), 所以就知道了这个桶占据了从多少分位到多少分位.

然后按照这个桶内的数据是平均分布的, 然后算出第nth(95th)是到底处于哪个值.

这篇问答很好的解释了这个算法是如何工作的:
https://stackoverflow.com/questions/55162093/understanding-histogram-quantile-based-on-rate-in-prometheus/69938239#69938239

官方的开源代码: https://github.com/prometheus/prometheus/blob/main/promql/quantile.go

其中要注意的是:

  1. 分位数值在桶内假定为线性分布进行插值计算
  2. 如果分位数落在最高的桶内,将返回第二高桶的上界值
  3. 如果最低桶的上界大于0,则假定自然下界为0

设计好桶是很关键的一步:

  1. 尽量在较低的桶内平均分布;
  2. 最大值不要超过第二高桶的上界;

对于上面提到的 如果分位数落在最高的桶内,将返回第二高桶的上界值, 下面是一个演示: 其中从 10240 到正无穷也有一些samples, 但是不论我们使用多少9999, 这里最多返回10240.

http_server_requests_duration_ms_bucket{le="5",method="GET",commandName="SigninLegacyView"} 0
http_server_requests_duration_ms_bucket{le="20",method="GET",commandName="SigninLegacyView"} 0
http_server_requests_duration_ms_bucket{le="80",method="GET",commandName="SigninLegacyView"} 30
http_server_requests_duration_ms_bucket{le="320",method="GET",commandName="SigninLegacyView"} 5881
http_server_requests_duration_ms_bucket{le="640",method="GET",commandName="SigninLegacyView"} 8567
http_server_requests_duration_ms_bucket{le="1280",method="GET",commandName="SigninLegacyView"} 8831
http_server_requests_duration_ms_bucket{le="2560",method="GET",commandName="SigninLegacyView"} 8865
http_server_requests_duration_ms_bucket{le="5120",method="GET",commandName="SigninLegacyView"} 8865
http_server_requests_duration_ms_bucket{le="10240",method="GET",commandName="SigninLegacyView"} 8867
http_server_requests_duration_ms_bucket{le="+Inf",method="GET",commandName="SigninLegacyView"} 8885

histogram.png

eBPF 例子

介绍

What Is eBPF? 是一本非好的入门书.
Learning eBPF 是同一个作者的另外一本进阶书.

key notes:

  1. eBPF 程序分为用户态程序和内核态程序. 内核态使用C或Rust编写,然后clang编译成eBPF 字节码,当内核遇到某种event之后,就会执行这些内核态的eBPF程序, 然后生成数据, 放到一些eBPF程序定义的Map中. 用户态程序主要用来加载eBPF程序并且获取内核态写入Map的数据, 然后整理分析展现这些数据.
  2. 内核态的程序编写成代码, 然后编译成字节码, 然后提交给内核, 然后eBPF虚拟机验证(Verify)这些代码,如果安全, 则加载运行这些代码, 等待事件处罚, 执行Action,写入接口文件.
  3. eBPF 为什么需要一个虚拟机? 运行时动态编译, 验证, 不是预先编译链接. 它不是通用型, 而是特定场景.
  4. eBPF bpf_trace_printk()/sys/kernel/debug/tracing/trace_pipe.
  5. eBPF Map 是内核态和用户态共享数据的渠道. 用户态写入配置, 内核态保存中间结果, 最终输出.
    各种类型的 BPF Map: https://elixir.bootlin.com/linux/v5.15.86/source/include/uapi/linux/bpf.h#L878
    Linux Kernel 关于BPF Map的文档: https://docs.kernel.org/bpf/maps.html
  6. eBPF 程序不允许使用其它函数, 除了helper 函数, 所以要 __always_inline .

bpftrace

经典的 one liner 的例子: https://github.com/iovisor/bpftrace/blob/master/docs/tutorial_one_liners.md

教程: https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md

关于网络的部分: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_and_managing_networking/network-tracing-using-the-bpf-compiler-collection_configuring-and-managing-networking#doc-wrapper

内置变量: comm, pid, tid, args(所有参数: args.filename)
内置函数: str() 把指针的值变成string.
probe 详细信息, 包括参数: sudo bpftrace -vvl kfunc:vmlinux:tcp_set_state

列出所有的 tracepoint:

sudo bpftrace -l 

https://github.com/lizrice/learning-ebpf

关于 Java SocketReadTimeout

在诊断Java应用程序的诊断过程中, 竟然会遇到 Connect timeout, Socket read timeout. 但是经常会遇到有些有些开发人员对这些概念有些误解. 本文就涉及到一些细节使用一些例子做些说明, 使大家更容易理解.

一个简单的例子

Jersey 做为 Jax-RS 的参考实现, 被广泛用于 Java 应用开发. 下面使用 Jersey 开发一个客户端的例子.

import java.net.URI;

import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;

import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
...

public String callRestSvc() {
        ClientConfig config = new ClientConfig();
                config.property(ClientProperties.CONNECT_TIMEOUT, 500);
        config.property(ClientProperties.READ_TIMEOUT, 3000);
        Client client = ClientBuilder.newClient(config);
        WebTarget target = client.target(UriBuilder.fromUri("http://localhost:8080/").build());
        try {
            return target.path("rest").
                    request().
                    accept(MediaType.TEXT_PLAIN).
                    async().
                    get(String.class).get(10, TimeUnit.SECONDS);
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        } catch (TimeoutException e) {
            throw new RuntimeException(e);
        }
    }

服务端的 Socket Read/Write timeout

Tomcat NIO

SocketTimeoutException 的初始化方法上设置断点, 然后可以看到下面的 Tomcat 异常栈.

SocketTimeoutException.<init>() (java.net.SocketTimeoutException:49)
NioEndpoint$Poller.timeout() (org.apache.tomcat.util.net.NioEndpoint$Poller:1086)
NioEndpoint$Poller.run() (org.apache.tomcat.util.net.NioEndpoint$Poller:852)
Thread.run() (java.lang.Thread:829)

如何判断是不是 timeout

Tomcat 每次在读取(read())的时候, 记录当前时间. 每次循环的时候(link)就检查是不是有 timeout: 检查当前时间和上次读的时间的差值, 如果大于设置的 timeout 值, 就设置 error: timeout exception.
code: https://github.com/apache/tomcat/blob/ec8ef7a3a2fa56afb4db4261ebdc0aba848f23ff/java/org/apache/tomcat/util/net/NioEndpoint.java#L1017-L1050

if (socketWrapper.interestOpsHas(SelectionKey.OP_READ)) {
    long delta = now - socketWrapper.getLastRead();
    long timeout = socketWrapper.getReadTimeout();
    if (timeout > 0 && delta > timeout) {
        readTimeout = true;
    }
}
// Check for write timeout
if (!readTimeout && socketWrapper.interestOpsHas(SelectionKey.OP_WRITE)) {
    long delta = now - socketWrapper.getLastWrite();
    long timeout = socketWrapper.getWriteTimeout();
    if (timeout > 0 && delta > timeout) {
        writeTimeout = true;
    }
}                          

Tomcat NIO 2

Tomcat NIO 2 的 Endpoint 直接使用的是 java.util.concurrent.Future 的 timeout 设置:
link: https://github.com/apache/tomcat/blob/ec8ef7a3a2fa56afb4db4261ebdc0aba848f23ff/java/org/apache/tomcat/util/net/Nio2Endpoint.java#L1130-L1162

if (block) {
    try {
        integer = getSocket().read(to);
        long timeout = getReadTimeout();
        if (timeout > 0) {
            nRead = integer.get(timeout, TimeUnit.MILLISECONDS).intValue();
        } else {
            nRead = integer.get().intValue();
        }
    } catch (ExecutionException e) {
        if (e.getCause() instanceof IOException) {
            throw (IOException) e.getCause();
        } else {
            throw new IOException(e);
        }
    } catch (InterruptedException e) {
        throw new IOException(e);
    } catch (TimeoutException e) {
        integer.cancel(true);
        throw new SocketTimeoutException();
    } finally {
        // Blocking read so need to release here since there will
        // not be a callback to a completion handler.
        readPending.release();
    }
} else {
    startInline();
    getSocket().read(to, toTimeout(getReadTimeout()), TimeUnit.MILLISECONDS, to,
            readCompletionHandler);
    endInline();
    if (readPending.availablePermits() == 1) {
        nRead = to.position();
    }
}

客户端的 Socket read timeout

BIO

HttpUrlConnection

HttpUrlConnection 就是使用的 BIO, 它自己是使用native 代码实现的 timeout.

NIO

例子的改正

这样即实现了最长等多少秒, 又不忘在后来的response 时候消费掉 entity, 保证连接释放.

public String asyncQuery() throws UnsupportedEncodingException, ExecutionException, InterruptedException, TimeoutException {
        WebTarget target = logsTarget.path("/").queryParam("style", "increase");
        System.out.println("url " + target.getUri().toString());
        CompletableFuture<String> result = new CompletableFuture<>();

        target.request(MediaType.APPLICATION_JSON).async().get(new InvocationCallback<String>() {

            @Override
            public void completed(String response) {
                System.out.println("I get response: " + response);
                result.complete(response);
            }

            @Override
            public void failed(Throwable throwable) {
                System.out.println("faied with URL " + target.getUri().toString() + " " + throwable.getMessage());
                result.completeExceptionally(throwable);
            }
        });

        return result.get(10, TimeUnit.SECONDS);
    }

如何从未读的 Socket Buffer 中读出数据

在诊断Java应用的过程中, 经常发现有些 Socket 连接还没读, 然后就被放弃了, 然后应用程序对这些连接就置之不理, 导致连接泄漏.

当发生连接泄漏之后, 要去诊断是那个地方导致的连接泄漏, 于是就是一个逆向的过程. 从开始知道连接泄漏的URL找到泄漏的代码. 如果这些泄漏的连接还在 Java 内存, 可以从 heap 当中找到这些 Socket, 然后读取其中未读的 Request/Response, 这样就能很容易的找到当时访问的什么请求, 对方发回的什么响应, 然后去审察代码.

如何找到这些 Socket

假如你知道要访问的 URL, 一般一个 OQL 就能查询到对应的socket, 比如:

SELECT * FROM org.apache.http.impl.conn.DefaultClientConnection c WHERE (toString(c.targetHost.hostname) like ".*.online-metrix.net")![request.png][1]

从Socket 读取请求数据

request.png

从 Socket 读取响应数据

response.png

java.lang.NoSuchMethodError

最近有个应用上线, 在本地环境和测试环境运行的好好的, 可是发布到生产环境竟然跑不通. 每次就报下面的这个错误:

java.lang.NoSuchMethodError: 'io.grpc.netty.NettyChannelBuilder io.grpc.netty.NettyChannelBuilder.maxInboundMessageSize(int)'
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.createNewConnection(GrpcConnectionPool.java:775)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.fillConnectionPool(GrpcConnectionPool.java:596)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.createNewConnections(GrpcConnectionPool.java:649)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.connect(GrpcConnectionPool.java:684)

出错信息分析

从上面的信息看, 还是说很明确的. 就是找不到 NettyChannelBuildermaxInboundMessageSize 方法. 这个方法传入一个 int 类型的参数, 返回一个 NettyChannelBuilder 实例对象.

然后我们在开发工具里面很快就找到了这个类 NettyChannelBuilder, 虽然它没有声明这样一个方法, 但是它实现的抽象父类 AbstractManagedChannelImplBuilder 确实有这个方法. 也就是它有这么一个期望的方法.

public final class NettyChannelBuilder
    extends AbstractManagedChannelImplBuilder<NettyChannelBuilder>

初步分析

先是问了谷歌, 确实有人遇到过类似的问题, 答案是当时版本不一致造成的. 于是看看本地的jar包版本, 分别是:
grpc-core-1.31.1.jar1.31.1, 它包含 AbstractManagedChannelImplBuilder.
grpc-netty-1.31.1.jar1.31.1, 它包含 NettyChannelBuilder.

于是远程登录到生产环境, 解压开总的jar包, 核对一下上面的2个 jar 包, 发现一模一样. 奇怪.

还有其它不同版本的jar包?

为了确认一定加载的是上面提及的两个版本的jar包, 于是我去审查了这个进程的启动参数. 原因是在生产环境使用的启动命令和本地不一样, 生产环境配置了更多的参数. 对比下来, 发现生产环境并没有多加额外的jar包进去.

于是在生产环境的启动参数里面添加了 -verbose:class 的启动参数, 这样就能打印出加载的所有类来自于那个jar包.

INFO   | jvm 1    | 2023/09/22 08:04:22 | [86.958s][info][class,load] io.grpc.netty.NettyChannelBuilder source: jar:file:/tmp/myapp-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/grpc-netty-1.31.1.jar!/
INFO   | jvm 1    | 2023/09/22 08:04:22 | [86.958s][info][class,load] io.grpc.internal.AbstractManagedChannelImplBuilder source: jar:file:/tmp/myapp-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/grpc-core-1.31.1.jar!/

统一版本不同的jar包内容?

于是从生产环境把这些 jar包复制到本地, 然后使用反编译软件查看内容, 是一样的.

该类没加载成功?

上面的类加载日志可以看到, 类其实是加载了的. 并且做了 heap dump 能够看到这两个类.
abs.png
builder.png

反射查看类的方法

为了确认该方法确实存在, 于是使用反射机制去查看它声明的方法:

      try {
            Class<?> c = NettyChannelBuilder.class;
            Method[] declaredMethods = c.getDeclaredMethods();
            for (Method method : declaredMethods) {
                System.out.println(method.getName() + " is declared in NettyChannelBuilder.");
                System.out.println("params: " + Arrays.asList(method.getParameterTypes()));
                System.out.println("return type: " + method.getReturnType());
            }

            System.out.println("NettyChannelBuilder super class is: " + c.getSuperclass());

            //c = AbstractManagedChannelImplBuilder.class;
            c = c.getSuperclass();
            declaredMethods = c.getDeclaredMethods();
            for (Method method : declaredMethods) {
                System.out.println(method.getName() + " is declared in AbstractManagedChannelImplBuilder.");
                System.out.println("params: " + Arrays.asList(method.getParameterTypes()));
                System.out.println("return type: " + method.getReturnType());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

确实是存在的:

2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: [int]
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: class io.grpc.internal.AbstractManagedChannelImplBuilder
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: []
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: int
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: [int]
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: class io.grpc.ManagedChannelBuilder

手动修改调用类

根据出错栈, 出错的方法是: GrpcConnectionPool.createNewConnection(GrpcConnectionPool.java:775). 根据source code 代码可以看出, 其 775 行确实是调用了 maxInboundMessageSize 方法.

因为本地环境无法重现, 所以把这个代码复制到本地, 写一个同样包名类名的类, 然后放到 classes 目录, 这样 classes 目录里的优先级高先会被加载, 本来在 jar 包里面的就无法被加载. 如此就能在复制的这个类里面加一些日志代码, 方便打印一些信息.

加入一些打印日志信息后, 上传到服务器 classes 目录, 然后在测试, 竟然没在报错. 神奇!

在复制覆盖的这个类的过程中, 只有2处疑点:

  1. 原来包含 GrpcConnectionPool 类的jar包是使用 JDK 8 编译的, 而我本地和生产环境都是 JDK 11.
  2. source jar 中包含的 GrpcConnectionPool 使用了 lombok.extern.slf4j.Slf4j 的注解 @Slf4j, 但是我本地开发环境没设置 lombok, 所以报错. 只能去除 @Slf4j, 手工加入该类的 private static final Logger log = LoggerFactory.getLogger(GrpcConnectionPool.class)

也就是说重新编译上传的是可以运行的.

重新编译的差别在哪?

猜测之一是: 重新编译使用的是 JDK 11编译, 所以没报错. 于是单独对复制过来的类使用 JDK 8 编译, 然后重新调用, 发现还是好的.

于是对出错的版本和刚编译的新版本进行反编译, 然后对比, 竟然发现了差别:

// 之前出错版本的反编译:
((NettyChannelBuilder)(
    (NettyChannelBuilder)(
        (NettyChannelBuilder)
            NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())
                .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
                .maxInboundMessageSize(messageSizelimit)
                .intercept(new ClientInterceptor[] { clientInterceptor })
        ).overrideAuthority(strAuthorityOverride)
    ).idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
).build()
// 现在运行正确的版本反编译:
((NettyChannelBuilder)
    ((NettyChannelBuilder)
        ((NettyChannelBuilder)
            ((NettyChannelBuilder)
                NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())
                .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
                .maxInboundMessageSize(messageSizelimit)
            ).intercept(new ClientInterceptor[]{clientInterceptor})
        ).overrideAuthority(strAuthorityOverride)
    ).idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
).build();

仔细对比2个版本, 就会发现差别. 之前的代码少一次强制转换, 新的代码在字节码中多一次转换. 但是2次的原代码都是一样的, 原代码如下:

NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())                    .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
    .maxInboundMessageSize(messageSizelimit)
    .intercept(clientInterceptor)
    .overrideAuthority(strAuthorityOverride)
    .idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
    .build();

对比2次编译结果, 可以发现在出错的版本里面, 编译器认为执行完 maxInboundMessageSize(messageSizelimit) 方法后返回的是 NettyChannelBuilder, 不需要强转, 而后面的执行正常的版本任务执行完maxInboundMessageSize(messageSizelimit) 方法后返回的是 AbstractManagedChannelImplBuilder, 需要一次强转. 这都是编译起自动做的工作, 源代码体现不出来.

为什么那次编译不需要强转

最新的编译版本需要强转, 是因为那个方法声明在 AbstractManagedChannelImplBuilder 类, 返回的值是它的之类, 但是它不知道子类的具体类型, 所以加一个强转(这也是我之前由于对这块不熟悉, 不能做为推理依据的原因).

但是为什么之前那个版本不需要强转呢? 尝试使用了不同版本的 JDK 去编译, 然后在反编译, 也不能找出答案.

于是把那jar所在的项目 clone 下来, 然后本地建立工程, 再去查看源代码, 竟然发现它依赖的 grpc-coregrpc-netty 竟然和我依赖的不一样. 在那个版本里面 maxInboundMessageSize(messageSizelimit) 这个方法竟然是声明在 NettyChannelBuilder 里面的, 当然不需要强转. 然而我现在使用的版本, 这个方法是声明在 AbstractManagedChannelImplBuilder 里面的, 当然需要强转.

所以问题就出在依赖的 grpc-coregrpc-netty 的版本不一致造成的, 出错的那个 jar 依赖的grpc版本较新, 而我的项目里面依赖的grpc 版本较旧.

为什么依赖的版本不一致?

按照依赖传递的原则, 我的项目依赖那个出错的jar, 它把它所依赖的版本传递进来, 应该是一致, 可是现在我这边看到的却是老版本. 我这个项目没有直接依赖 grpc, 间接依赖的共2处. 就是出错的这个 graph-xxx.jar 和 jetch-xxx.jar. 仔细察看这两个 jar, 他们依赖的 grpc 相关的jar 都是新版本, 为什么我的项目却依赖的一个旧版本呢?

为了排除干扰, 分别去除 graph-xxx.jar 或 jetch-xxx.jar, 每个依赖的仍然是旧版本. 使用 mvn dependency:tree 去单独查看这两个jar 包的 pom 文件, 看到的每个依赖的都是新版本. 怪异.

修改自己项目的 pom.xml, 单独排除

为了彻底查找到底是哪里引入的旧版本, 于是二分法去除其它依赖的jar, 最后发现即便仅仅依赖 graph-xxx.jar 或 jetch-xxx.jar, 仍旧是老版本, 但是单独查看这两个jar的pom.xml, 却都是新版本.

最后发现自己的项目还有 parent 项目

<parent>
  <groupId>com.tianxiaohui.platform</groupId>
  <artifactId>raptor-io-parent</artifactId>
  <version>0.18.1-RELEASE</version>
  <relativePath></relativePath>
</parent>

若是去掉做个parent project, 那么依赖都变成了新版本, 也就说由于有个 parent project, 它管制了 grpc 版本的依赖, 导致依赖到了老版本.

查看 parent project 依赖

当有parent project 的时候, 如果父子project 对某个jar 都有依赖, 就会使用 parent project 使用的. 所以要找出是哪个 parent project 使用了旧版本.

使用下面命令能查看当前项目的依赖:

# 自己体会下面3个不同
apache-maven-3.9.1/bin/mvn  dependency:tree 
apache-maven-3.9.1/bin/mvn  dependency:tree -Dincludes=io.grpc:grpc-netty 
apache-maven-3.9.1/bin/mvn  dependency:tree -Dincludes=io.grpc:grpc-netty -Dverbose

但是它不能显示 parent project 依赖.

方法的签名

在最早的 Error message java.lang.NoSuchMethodError 后面, 给出了缺少的方法名字. 但是我们看到当时那个版本的类的父类是包含做个方法签名的.

在Java 里面, 一个方法的签名是指由 方法名, 参数类型, 参数顺序, 参数个数 这几个因素决定的. 方法的返回值并不能决定方法签名.

但是在字节码中, 方法的签名是包含包含返回值的. 因为字节码支持的其它动态语言是需要返回值做签名的.

如果 类A extends 非抽象类 B, 那么类A 的方法(A.getClass().getMethods())会包含 类B 的所有实例方法(非static).
如果 类A extends 抽象类 B, 那么类A 的方法(A.getClass().getMethods())会包含 类B 的所有实例方法, 若A override 了某个方法, 并且返回了不一样的值类型, 那么会出现2个不同的方法, 一个属于A, 一个属于B.