分类 Java 相关 下的文章

bpftrace 探测 Java 运行时栈-实践

开发Java应用的时候, 有时候我们想知道某个函数到底在哪里被调用的. 我们可以采取的方法有:

  1. 若是本地开发, 可以在函数体上加断点, 每当函数被调用, 都会暂停.
  2. 若是我们可以改动的代码, 我们可以加日志打印栈, 这样就能发现在哪里被调用.
  3. 不论是不是我们自己的Java代码, 我们都可以通过 Btrace 进行注入脚本, 在脚本打印运行时栈.

但是有时候, 我们想知道我们的 Java 代码是哪里调用了系统的 native 代码, 比如某些系统调用(syscall), 那么该如何获取这些栈呢?

这时候, 使用Java 提供的这些方法, 都无法达到目的, 所以, 我们要借用系统层级的 tracing 方式. 如今最流行且最简单的方式就是使用 bpftrace. 本文接下来将用一个例子来说明, 如何使用 bpftrace 来查找我们的Java应用是如何调用 recvfrom 这个系统调用的.

Java 代码

下面是我们用来演示的代码, 它的意图就是不断的循环去获得某个网页. 不断循环的目的是为了给我们的手工操作留有足够的时间. 这段代码有网络操作, 所以会调用系统调用 recvfrom 来拿到响应(response).(感谢 chatGPT 给我们演示代码)

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class URLTest {
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1000; i++) {
            try {
                URL url = new URL("http://www.tianxiaohui.com");
                HttpURLConnection con = (HttpURLConnection) url.openConnection();
                con.setRequestMethod("GET");
                    con.setConnectTimeout(5000); // 连接超时时间 5000ms
                    con.setReadTimeout(10000); // 读取超时时间 10000ms
                System.out.println(i + "Response code: " + con.getResponseCode());
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
                String line;
                StringBuilder response = new StringBuilder();
    
                while ((line = in.readLine()) != null) {
                    response.append(line);
                }
                in.close();
                //System.out.println(response.toString());
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println(e.getMessage());
            }
            Thread.sleep(2000);
        }
    }
}

本地编译并运行

使用 javac 编译源代码, 生成 URLTest.class. 然后启动这个带有 main 函数的类. 这里添加参数 -XX:+PreserveFramePointer 是为了在运行方法的时候保留栈指针寄存器, 这样就能使 bpftrace 获得运行时的栈. 运行开始, 输出返回的 response code.

$ javac URLTest.java
$ java -XX:+PreserveFramePointer URLTest
Response code: 301

安装编译 perf-map-agent

为了使用 bpftrace 能获得Java JIT 编译后的代码的符号, 需要通过 perf-map-agent Java agent 去获取运行时 Java 应用的符号表, 更确切来说, 是获得通过 JIT 编译后的的代码的符号表.

perf-map-agent 是一个Java agent, 它在运行时attach到目标Java进程, 然后获取JVM运行时内部JIT编译后代码的区域内存, 然后通过这个区域获取符号表, 然后把这些符号表以 Linux perf 能认识的格式放到 /tmp/perf-<pid>.map 文件中. bpftrace 底层ye shi

下载 perf-map-agent

可以直接克隆这个 git repo:

$ git clone https://github.com/jvm-profiling-tools/perf-map-agent.git

或者直接下载最新的代码

$ curl https://github.com/jvm-profiling-tools/perf-map-agent/archive/refs/heads/master.zip --output perf-map-agent.zip
$ unzip perf-map-agent.zip

编译

这个项目里面包含一些 C 代码, 需要先编译成 binary. 并且这个项目提供了一些脚本帮助我们快速生成符号表.

$ cd perf-map-agent
$ cmake .
$ make 

生成JIT编译后符号表

准备工作已经完成, 那么我们现在可以生成符号表了. 首先获取目标Java 进程的进程号, 然后设置 JAVA_HOME 环境变量, 因为 perf-map-agent 需要这个环境变量. 最后运行 bin/create-java-perf-map.sh 生成符号表.

# 获取 java 进程号
$ jcmd 
3408161 jdk.jcmd/sun.tools.jcmd.JCmd
3406489 URLTest

# 设置 JAVA_HOME
$ export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64

# 生成符号表
$ bin/create-java-perf-map.sh 3406489

# 查看符号表
ls -lah /tmp/perf-3406489.map
-rw-rw-r-- 1 root root 42K Nov 15 05:08 /tmp/perf-3406489.map

bpftrace 获取调用栈

万事俱备, 现在我们就可以通过 bpftrace 获取目标进程是如何调用 recvfrom 找个系统调用的了.
我们使用的probe event 是 tracepoint:syscalls:sys_enter_recvfrom, 设置过滤条件是我们的目标进程pid==3406489, 然后统计用户栈的出现的次数. 这里我们只截取用户栈的20行.

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_recvfrom /pid==3406489/{ @[ustack(20)] = count(); }'
Attaching 1 probe...
^C

@[
    recvfrom+116
]: 2
@[
    __GI___recv+110
    Java_java_net_SocketInputStream_socketRead0+434
    Interpreter+28336
    Interpreter+4352
    Interpreter+4352
    Interpreter+4352
    Interpreter+4352
    Interpreter+5875
    Interpreter+4352
    Interpreter+4352
    Interpreter+3728
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+3828
    call_stub+138
    JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, Thread*)+883
    jni_invoke_static(JNIEnv_*, JavaValue*, _jobject*, JNICallType, _jmethodID*, JNI_ArgumentPusher*, Thread*) [clone .constprop.1]+682
    jni_CallStaticVoidMethod+352
    JavaMain+3441
    ThreadJavaMain+13
    start_thread+755
]: 3

上面的结果里面, 我们可以看到2处不同的调用栈, 第一个调用栈只有一行, 不是我们代码调用的. 第二个栈是从我们的代码发出的, 共被调用了 3次.

最上面的一行 __GI___recv+110 找个代码是 glibc 里面的. __GI__ 表示这是 Linux 上的标准C库 GNU C Library (glibc)里面的代码. “GI”前缀用于由 glibc 的动态链接器内部重定向到其在库中实现的函数。 这些函数称为“global indirect”或“indirect”函数,它们的实际实现驻留在运行时加载的动态链接库中。所以__GI___recv 是glibc 里面的一个函数, 它调用了系统调用 recvfrom.

接着一行Java_java_net_SocketInputStream_socketRead0 是JDK 中的C 代码, 我们可以在 JDK 原代码中找到它.

然后接着是一些 Java 的代码, 只不过这些都是运行时翻译的, 所以没有符号给我们看, 只能看到关键字 Interpreter.

然后 Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0 这是Java 代码通过JIT 编译后产生的代码, 然后程序通过 /tmp/perf-3406489.map 获得了符号表, 然后展示在这.

为了搞清楚到底是怎么一层层到 Java_java_net_SocketInputStream_socketRead0的, 我们需要把翻译的这部份也通过 JIT 编译成native 代码.

把翻译代码变成编译代码

在 Java 运行时, 当一个方法在一个滑动时间窗口内, 达到了足够的执行次数之后, 就会被编译成native 代码. 所以, 为了让我们看到的被翻译的代码也被编译, 需要关掉分层编译 -XX:-TieredCompilation, 并且把需要编译的最低次数设置的足够低 -XX:CompileThreshold=1, 这里我们设置成1次.

再次运行找个 Java 应用, 并且再次执行 bpftrace, 我们获得了如下的代码栈.

$ java -XX:+PreserveFramePointer -XX:-TieredCompilation -XX:CompileThreshold=1 URLTest

$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_recvfrom /pid==<pid>/{ @[ustack(20)] = count(); }'
    __GI___recv+110
    Java_java_net_SocketInputStream_socketRead0+434
    Ljava/net/SocketInputStream;::socketRead0+244
    Ljava/net/SocketInputStream;::read+224
    Ljava/io/BufferedInputStream;::fill+784
    Ljava/io/BufferedInputStream;::read1+176
    Ljava/io/BufferedInputStream;::read+252
    Lsun/net/www/http/HttpClient;::parseHTTPHeader+444
    Lsun/net/www/http/HttpClient;::parseHTTP+1004
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+1420
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream+196
    Ljava/net/HttpURLConnection;::getResponseCode+96

调大code cache

在Java中,您可以通过设置-XX:ReservedCodeCacheSize标志来调整JIT代码缓存的大小。该标志控制JIT编译器可用来存储生成的代码的本机内存的最大数量。默认情况下,代码高速缓存的大小由平台确定 - 32位JVM通常为240MB,而64位JVM通常为480MB。

要调整JIT代码缓存区的大小,可以将-XX:ReservedCodeCacheSize标志设置为自定义值。该值应以字节为单位指定,并且可以是2的幂次方或2048(2KB)的倍数。以下是将标志设置为512MB的示例:

java -XX:ReservedCodeCacheSize=536870912 <your_program>
这将将JIT代码缓存的最大大小设置为512MB。

请注意,增加JIT代码缓存的大小可以通过允许JIT编译器在内存中存储更多的生成代码来提高性能。但是,这也会增加应用程序的内存使用量。你应仔细调整代码缓存的大小,并监视应用程序的内存使用情况,以确保它不超过系统上的可用内存。

关于 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.

Java HttpURLConnection

从访问一个网站看起

当你从浏览器敲入一个网址 www.tianxiaohui.com, 浏览器就自动给你建立一个到该服务器的 TCP 连接, 然后发送请求(request), 然后接受回应(response), 浏览器然后展示返回的结果.

你比较关心返回的页面, 但这次我们关心这个建立的 TCP 连接的命运. 它会被立马关掉吗? 还是一直开着就不管它了, 还是等个10分钟再关掉它?

了解一下 HTTP 的历史

在 HTTP 1.0 及之前, 每当发送一个请求, 必须新建一个 TCP 连接, 接收到响应之后, 立马关掉做个连接. 也就是每个 TCP 连接只能用一次, 这种模型称之为 Short-lived connections.

当然, 人们很快意识这种低效和浪费, 于是在原有的 HTTP 协议基础上新加了 Connection header. 常见的情况下, 它有2个值:

  1. close 一次请求/回应之后立马关掉, 指示浏览器/服务器这是 Short-lived connections.
  2. keep-alive 可以多次复用的连接, 后续请求/回应可以继续使用, 称之为 persistent connections.

在 HTTP 1.0, 没有 Connection header, 则默认是 close, 要持久连接必须添加 header Connection: keep-alive.
在 HTTP 1.1, 没有 Connection header, 则默认是 keep-alive, 要立马关掉连接必须添加 header Connection: close.

HTTP header Connection, Keep-Alive

上面提到的 Connection header, 当 Connection 的值是 keep-alive 的时候, 可以添加另外一个 header Keep-Alive, 它的值通常是这种格式: timeout=5, max=1000.
timeout: 表示可以容忍这个connection 空闲最少多少秒, 然后就关闭它. 一般大于这个数字关闭.
max: 通过这个连接最多可以发送的 request/response 的数量.
请注意, 这个2个数值只是提示两端的连接管理器, 并不一定要求两端连接管理器一定这么做.

Connection: Keep-Alive
Keep-Alive: timeout=5, max=1000

为什么要有 timeoutmax

既然持久连接高效又节省资源, 那么为什么还需要有 Keep-Alive header 的 timeoutmax.
假如一直保持连接, 可是客户端发送完1个或几个连接后, 可能再也不用了, 那么这个连接保持在无论客户端还是服务器端都没有任何意义, 只能带来资源的浪费. 所以要想办法在它空闲一段时间后, 就干掉它. 所以, 就有了 timeout 这个值.

那么为什么又要设置一个连接最多可以发送多少请求/响应呢? 岂不是通过它发送的请求/响应越多越好?

Java Socket close vs shutdown

https://www.baeldung.com/cs/sockets-close-vs-shutdown

HttpClient

KeepAliveCache

https://github.com/openjdk/jdk/blob/e1870d360e05c372e672b519d7de2a60c333675b/src/java.base/share/classes/sun/net/www/http/KeepAliveCache.java#L342

环境变量 http.maxConnections

when close the connection

refer:

  1. https://developer.mozilla.org/en-US/docs/Web/HTTP/Connection_management_in_HTTP_1.x
  2. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection