Java 微服务网络连接优化

在如今的大型互联网络中, 微服务基本是最流行的架构. 采用微服务架构, 自然少不了内部各个服务之间的相互调用, 那么其中的网络连接处理, 自然成了要重点考虑的对象. 下面就一步步仔细看看其中每一步要考虑的细节.

基本的网络连接

下面的代码使用了 JDK 最基础的代码, 告诉我们如何使用 URLHttpURLConnection 来进行网络连接.

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

public class HttpClientExample {

    public static void main(String[] args) {
        String urlString = "https://api.example.com/v1/search";
        HttpURLConnection connection = null;

        try {
            // 创建 URL 对象
            URL url = new URL(urlString);
            
            // 打开连接
            connection = (HttpURLConnection) url.openConnection();
            
            // 设置请求方法
            connection.setRequestMethod("GET");
            
            // 设置请求属性(如有需要)
            connection.setRequestProperty("Content-Type", "application/json");
            
            // 设置连接超时和读取超时
            connection.setConnectTimeout(5000);
            connection.setReadTimeout(5000);
            
            // 发起请求
            int responseCode = connection.getResponseCode();
            
            // 检查响应码
            if (responseCode == HttpURLConnection.HTTP_OK) {
                // 正常响应,读取数据
                BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
                StringBuilder response = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    response.append(line);
                }
                reader.close();
                
                // 打印响应
                System.out.println("Response: " + response.toString());
            } else {
                // 错误处理
                System.err.println("Error: Received HTTP code " + responseCode);
                BufferedReader errorReader = new BufferedReader(new InputStreamReader(connection.getErrorStream()));
                StringBuilder errorResponse = new StringBuilder();
                String errorLine;
                while ((errorLine = errorReader.readLine()) != null) {
                    errorResponse.append(errorLine);
                }
                errorReader.close();
                
                // 打印错误响应
                System.err.println("Error Response: " + errorResponse.toString());
            }
        } catch (Exception e) {
            // 异常处理
            System.err.println("Exception occurred: " + e.getMessage());
            e.printStackTrace();
        } finally {
            if (connection != null) {
                // 断开连接
                connection.disconnect();
            }
        }
    }
}

设置 timeout

上面的代码中, 我们可以看到, 连接 timeout 和 read timeout 都是通过 setConnectTimeoutsetReadTimeout 来设置的. 下面我们就来看看这两个 timeout 的区别.

连接 timeout

连接 timeout 是指在建立连接时, 客户端等待服务器响应的时间. 如果在这个时间内没有建立连接, 就会抛出 SocketTimeoutException. 这个 timeout 主要是为了防止网络延迟过长导致的连接阻塞. 具体来说, 就是 TCP 三次握手的时间. 如果在这个时间内没有完成三次握手, 就会抛出异常. 这个数值通常根据网络环境来设置, 比如在局域网中, 可以根据测试来设置为500或1000毫秒, 而在广域网中, 可以设置为为更长时间.

read timeout

read timeout 是指在连接建立后, 客户端等待服务器响应数据的时间. 如果在这个时间内没有收到数据, 就会抛出 SocketTimeoutException. 这个 timeout 主要是为了防止服务器长时间不响应导致的连接阻塞. 对于客户端这方具体来说, 当它发出去请求, 它就开始等带从 Socket 读取数据, 每次开始读, 就开始计时, 当到达 read timeout 的时间还没有任何数据收到, 就会因读取超时而抛出异常. 有时候从服务端返回的数据很多, 要分好几次读取, 每当当前一次 Socket.read() 读完, 就开始下一次read(), 直到所有的数据都读完. 每次重新读取(开始read()), 都会重新计时. 这个 timeout 通常设置为几秒到几十秒不等, 具体数值可以根据实际情况来设置.

对于 read timeout 的误解

很多人会误解 read timeout 是指在连接建立后, 客户端等待服务器响应数据的时间. 其实不是这样的, 它是指在连接建立后, 客户端等待服务器响应数据的时间. 如果在这个时间内没有收到数据, 就会抛出 SocketTimeoutException. 意思是如果从服务端读取的数据分多次, 每次都会重新计时, 每次都可能timeout . 整体的读数据时间, 可能是设置的 timeout 的好几倍的时间. 比如我们下载一个几G的镜像文件, 设置timeout 是5秒, 但是整体下载镜像的时间可能是半小时, 只要每次读取数据的时间不超过5秒, 就不会抛出异常. 最后顺利下载完成.

连接池

连接池是为了提高性能, 减少连接的创建和销毁的开销. 连接池会预先创建一定数量的连接, 当需要使用连接时, 从连接池中获取一个可用的连接, 使用完毕后将其放回连接池. 这样可以避免频繁地创建和销毁连接, 提高性能.

在 Java 11 中,JDK 引入了新的 HttpClient API,它提供了更现代化和更灵活的 HTTP 客户端功能。HttpClient 默认支持连接池,这意味着它可以重用连接以提高性能和效率。

HttpClient 默认连接池的特点:

  1. 连接复用:HttpClient 会自动管理和复用连接,这样可以减少每次请求都重新建立连接的开销。连接复用对于 HTTP/1.1 和 HTTP/2 都有效。
  2. 线程安全:HttpClient 是线程安全的,可以在多个线程之间共享一个 HttpClient 实例。这意味着多个请求可以并发地使用同一个 HttpClient 实例进行发送。
  3. 自动管理:连接池的管理是自动的,用户无需手动配置连接池的大小或其他参数。HttpClient 会根据需要动态调整连接池的大小。
  4. 异步支持:HttpClient 支持异步请求,这可以更高效地利用连接池,因为异步请求不会阻塞线程。
  5. HTTP/2 支持:HttpClient 默认支持 HTTP/2 协议,这使得连接复用更加高效,因为 HTTP/2 允许在单个连接上并发多个请求和响应。

下面是一个使用 HttpClient 的示例代码:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class HttpClientExample {

    public static void main(String[] args) {
        try {
            // 创建 HttpClient 实例
            HttpClient client = HttpClient.newBuilder()
                    .version(HttpClient.Version.HTTP_2)
                    .connectTimeout(Duration.ofSeconds(10))
                    .build();

            // 创建 HttpRequest 实例
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(new URI("https://api.example.com/v1/search"))
                    .GET()
                    .build();

            // 发送请求并接收响应
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

            // 打印响应状态码和响应体
            System.out.println("Response Code: " + response.statusCode());
            System.out.println("Response Body: " + response.body());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

连接池连接 timeout

连接池的连接 timeout 是指在连接池中获取连接时, 客户端等待连接可用的时间. 如果在这个时间内没有获取到连接, 就会抛出 TimeoutException. 这个 timeout 主要是为了防止连接池中的连接都被占用导致的阻塞. 这个 timeout 通常设置为几秒到几十秒不等, 具体数值可以根据实际情况来设置.

Apache HttpClient

Apache HttpClient 是 Apache 提供的一个功能强大的 HTTP 客户端库,广泛用于 Java 应用程序中进行 HTTP 请求和响应处理。它提供了丰富的功能,包括连接池、异步请求、自动重试、代理支持等。
Apache HttpClient 的连接池管理是通过 PoolingHttpClientConnectionManager 实现的。这个连接池可以管理多个连接,并且可以配置最大连接数、每个路由的最大连接数等参数。下面是一个使用 Apache HttpClient 的示例代码:

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;

public class ApacheHttpClientExample {

    public static void main(String[] args) {
        // 创建一个 CloseableHttpClient 实例
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            // 创建一个 HttpGet 请求
            HttpGet request = new HttpGet("https://api.example.com/v1/search");
            
            // 发送请求并获取响应
            try (CloseableHttpResponse response = httpClient.execute(request)) {
                // 检查响应状态码
                int statusCode = response.getStatusLine().getStatusCode();
                System.out.println("Response Code: " + statusCode);

                // 获取响应实体
                HttpEntity entity = response.getEntity();
                if (entity != null) {
                    // 将响应实体转换为字符串
                    String responseBody = EntityUtils.toString(entity);
                    System.out.println("Response Body: " + responseBody);
                }
            }
        } catch (Exception e) {
            // 处理异常
            System.err.println("Exception occurred: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

连接池配置

Apache HttpClient 的连接池配置可以通过 PoolingHttpClientConnectionManager 来实现。但是配置的时候一定要注意, 除了配置连接池的最大连接数外, 还要注意每个路由的最大连接设置, 否则每个路由只有2个最大连接. 下面是一个使用 Apache HttpClient 的连接池配置示例代码:

import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;

public class ApacheHttpClientConfigExample {

    public static void main(String[] args) {
        // 创建一个连接池管理器
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        
        // 设置整个连接池的最大连接数
        connectionManager.setMaxTotal(100);  // 设置最大连接数为100

        // 设置每个路由的最大连接数
        connectionManager.setDefaultMaxPerRoute(20);  // 设置每个路由的最大连接数为20

        // 创建一个 HttpClient 实例,并使用连接池管理器
        try (CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .build()) {
            
            // 使用 httpClient 发送请求...
            // 这里可以添加发送请求的代码逻辑
            
            // 示例结束,不发送实际请求
            System.out.println("HttpClient configured with connection pool.");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Java 远程 debug

本地 Debug 很容易, 那么远程 debug 是怎么做的, 又有哪些坑呢?

一般应用程序 debug

这里的一般应用程序就是一个 main 函数, 不是 web 应用程序. 首先, 这个应用在远程某主机上能运行, 只要启动的时候, 添加远程 debug 参数就可以了.

比如有如下代码, 为了让它慢一点可以 debug, 加入了一个循环和睡眠:

public class Debugger {
    
    public int loop(int count) {
        int sum = 0;
        for (int i = 0; i < count; i++) {
            sum += i;
            try {
                System.out.println(i + "/" + count + " current sum: " + sum);
                Thread.sleep(5L);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return sum;
    }
    
    public static void main(String[] args) {
        Debugger debugger = new Debugger();
        int sum = debugger.loop(10000);
        System.out.println("sum is " + sum);
    }

}

在远程编译, 启动, 并且加入监听端口为了方便远程debug:

$ javac Debugger.java
$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8000 Debugger 

这里注意的是, 我们对于地址使用的是: 0.0.0.0:8000, 有些地方你看到的直接是 8000 端口, 没有主机ip部分. 这会导致这个端口只是开在 localhost, 你如果使用 netstat 查看, 能看到 8000 端口是开着的, 但是远程无法连接.
在远程可以使用 telnet 或者 curl 来测试这个端口是不是可以远程连接. 但是注意这个端口只能有一个远端能连接成功, 假如你已经使用 curl 连接上, 那么你远端的其它 IDE 就不能连接了.

我本地的 Eclipse 连接的过程是: 在菜单 Run 里面选择 Debug Configurations, 然后新建 Remote Java Application, 然后输入远程的 IP 和 端口. 如下:
remote_debug.png

连接后, Eclipse 会显示连接成功, 然后点击 暂停, 就会暂停远程的应用了.
debug_pause.png

debug 远程 tomcat 里面的应用

在 Tomcat 的 catalina.sh 里面, 添加如下环境变量设置:

export JPDA_ADDRESS=0.0.0.0:8000
export JPDA_TRANSPORT=dt_socket

然后启动时候, 添加 jdpa 参数, 就可以了

./catalina.sh jpda start

debug 远程基于 Spring boot 的应用

其实 Spring boot 是做的 fat jar, 它的远程debug 方式跟一般的应用是一样的, 就是在启动参数里面加上. 例子如下:

java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8000 -jar myApp.jar 

隧道技术

有时候远程机器隔着防火墙, 你不能直接连, 但是你可以通过 ssh 登录, 那么就可以用过隧道技术, 使用 ssh 做隧道, 然后远程 debug.
首先, 我们假设远程机器的 debug 开在 8000 端口上:

java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8000 -jar myApp.jar 

然后在本地机器上建立隧道:

ssh -L 5000:localhost:8000 user@remote_ip

这里的意思是: 在本地启动5000端口, 它所有的数据原封不动的转到远程 remote_ip 的8000 端口. 我们登录远程 remote_ip 的用户名是 user, 这里它会让你输入 ssh user 的密码. 连接成功后, 隧道建立.

这时候, 在本地和之前的一样, 只是把远程机器的ip 改成本地机器localhost, 端口改成 5000. 就能借助隧道进行远程 debug 了.

selenium 截屏 设置带用户名密码的代理和注入cookie

今天想对一个metrics 数据做截屏自动保存, 想到使用 selenium 来做. 于是让 chatGPT 给写了一个脚本. 发现其中有2处需要注意的地方.

  1. 一般的设置代理方法(selenium.webdriver.common.proxy不包含代理的用户名密码)不管用, 必须使用 seleniumwire.
  2. 注入 cookie. 为什么不通过登录获得 cookie 而使用注入? 默认的登录使用 2FA, 其中需要MAC 的指纹, 所以很麻烦, 索性一开始先从浏览器拿一个cookie注入.

python 代码如下:

from selenium import webdriver
from seleniumwire import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.proxy import Proxy, ProxyType
from PIL import Image
import time

def init_chrome_driver():
    # 设置代理
    options = {
        'proxy': {
            'http': 'http://user:password@proxy.tianxiaohui.com:8080', 
            'https': 'http://user:password@proxy.tianxiaohui.com:8080',
            'no_proxy': 'localhost,127.0.0.1' # excludes
        }
    }

    # 设置Chrome选项
    chrome_options = Options()
    chrome_options.add_argument("--headless")  # 无头模式
    chrome_options.add_argument("--disable-gpu")
    chrome_options.add_argument("--window-size=1920x1080")

    # 设置Chrome驱动路径
    chrome_driver_path = '/Users/supra/work/tools/chromedriver-mac-x64/chromedriver'  # 请替换为你的chromedriver路径

    # 初始化Chrome驱动
    service = Service(chrome_driver_path)
    driver = webdriver.Chrome(service=service, options=chrome_options, seleniumwire_options=options)

    return driver

def setup_driver(driver):
    # 打开一个空白页
    driver.get('http://mydash.tianxiaohui.com/?orgId=1')
    # 等待页面加载完成
    time.sleep(5)

    # 添加现有的cookie
    cookies = [
        {
            'name': 'grafana_session',
            'value': '5c74f90284d363050b63c20c86a2d336',
            'path': '/',
            'domain': 'mydash.tianxiaohui.com'
        }
    ]

    for cookie in cookies:
        driver.add_cookie(cookie)

    return driver

def take_screenshot(driver, url, screenshot_abs_file):
    # 打开网址
    driver.get(url)
    # 等待页面加载完成
    time.sleep(30)
    # 截图并保存到本地
    driver.save_screenshot(screenshot_abs_file)

    return driver

driver = init_chrome_driver()
driver = setup_driver(driver)
url = 'http://mydash.tianxiaohui.com/d/test_dash'
print(url)
screenshot_path = f'/Users/supra/Downloads/screenshot.png'
driver = take_screenshot(driver, url, screenshot_path)

# 关闭浏览器
driver.quit()

# 使用Pillow库打开截图并显示
#image = Image.open(screenshot_path)
#image.show()

移动光猫探索

移动光猫 H2-2

解锁 telnet 教程: https://www.cnblogs.com/dingshaohua/p/17388270.html

查看开启端口和服务:

$ nmap -O -sV 192.168.1.1
Starting Nmap 7.80 ( https://nmap.org ) at 2025-01-15 10:33 GMT
Nmap scan report for 192.168.1.1 (192.168.1.1)
Host is up (0.0031s latency).
Not shown: 996 closed ports
PORT     STATE SERVICE
23/tcp   open  telnet     BusyBox telnetd 1.00-pre7 - 1.14.0
80/tcp   open  http       Mini web server 1.0 (ZTE ZXV10 W300 ADSL router http config)
5080/tcp open  onscreen?
8080/tcp open  http-proxy
17998/tcp open  unknown
17999/tcp open  unknown

Service Info: OS: Linux 2.4.17; Device: broadband router; CPE: cpe:/h:zte:zxv10_w300, cpe:/o:montavista:linux_kernel:2.4.17
$ uname -a
Linux zxic 4.1.25 #3 SMP PREEMPT Fri Oct 13 16:33:00 CST 2023 armv7l GNU/Linux

#cpu
/ # cat /proc/cpuinfo
processor       : 0
model name      : ARMv7 Processor rev 1 (v7l)
BogoMIPS        : 1987.37
Features        : half thumb fastmult edsp tls
CPU implementer : 0x41
CPU architecture: 7
CPU variant     : 0x4
CPU part        : 0xc09
CPU revision    : 1

processor       : 1
model name      : ARMv7 Processor rev 1 (v7l)
BogoMIPS        : 1993.93
Features        : half thumb fastmult edsp tls
CPU implementer : 0x41
CPU architecture: 7
CPU variant     : 0x4
CPU part        : 0xc09
CPU revision    : 1

Hardware        : ZTE ZX279128S (Device Tree)
Revision        : 0000
Serial          : 0000000000000000

# 内存
/ # free -m
             total         used         free       shared      buffers
Mem:           450          243          207            6            0
-/+ buffers:                243          207
Swap:            0            0            0

谁打开的 80, 5080, 8080?

/proc/1214 # netstat -tlnp | grep 80
tcp        0      0 192.168.1.1:80          0.0.0.0:*               LISTEN      1214/httpd
tcp        0      0 :::8080                 :::*                    LISTEN      1750/java
tcp        0      0 fe80::1:80              :::*                    LISTEN      1214/httpd
tcp        0      0 ::ffff:192.168.1.1:5080 :::*                    LISTEN      1750/java

关于 80 端口, 也就是管理页面, 使用的是 apache http server.

jps 无法显示 Java 进程号

对于 Java 应用的诊断, 一直以来都是使用 JDK 自带的 Jps 命令来查看 Java 进程 ID, 最近在一个生产环境的的某个 Kubernetes pod 里面 Jps 一直无法给出对应的进程 ID, 什么输出都没有.

I have no name!@mypod:~$ jps
I have no name!@mypod:~$

初步检查

在 JDK 里面, 很多小工具的命令都搬迁到 jcmd 命令, 如果 jcmd 命令不带任何参数, 其实就相当于 Jps 命令. 于是尝试 jcmd 命令, 结果是一样的.

I have no name!@mypod:~$ jcmd
I have no name!@mypod:~$

以前在 VM 时代, 遇到过类似的问题. 原因是当前运行 bash 的用户和对应的 Java 进程不属于同一个用户, 要切换到对应 Java 进程运行的用户才能看到该进程 ID. 但是这次当前 Bash 的用户和对应 Java 进程的用户本来就是同一个用户.

I have no name!@mypod:~$ ps aux
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
84289        53  1.7  1.5 48923744 6226704 ?    Sl   Dec02 527:44 /prod/app/jre/bin/java -Xms4096m -Xmx4096m myApp

I have no name!@mypod:~$ id
uid=84289 gid=84677 groups=84677

怀疑当前 pod 的 JDK 文件有啥特别设置(为了减小JDK 大小和不必要文件, 他们把 JDK bin 去掉了, 有需要的移动到了 /jre/bin/ 目录去了). 于是从本地复制一个同版本的 JDK 到目标 pod, 并解压使用, 发现 JpsJcmd 是一样的结果.

但是这时候发现, 如果给 jcmd 命令一个进程ID, 它就能正常运行, 只是无法发现当前 Pod 的其它 Java 进程.

I have no name!@mypod:~$ jcmd 53 help
53:
The following commands are available:
Compiler.CodeHeap_Analytics
GC.class_histogram
GC.finalizer_info
GC.heap_dump
GC.heap_info
GC.run
GC.run_finalization
Thread.print
...

进一步检查

这时候去 Google 了一下, 发现有人说要给 Jps 命令添加一个参数: -J-Djava.io.tmpdir=/tmp/ 告诉它临时文件夹所在的地方. 去试了一下, 发现没有任何改进.
https://stackoverflow.com/questions/3805376/jps-returns-no-output-even-when-java-processes-are-running

于是想使用源码 debug 一下, 于是找到 Jps 的代码: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.jcmd/share/classes/sun/tools/jps/Jps.java
其实就是2个类, 于是合并一下, 并入到一个类里面, 然后复制到到远程 pod 里面, 命名为: JpsTest.java

import java.util.*;
import java.io.*;
import java.net.*;
import sun.jvmstat.monitor.*;

/**
 * Application to provide a listing of monitorable java processes.
 *
 * @author Brian Doherty
 * @since 1.5
 */
public class JpsTest {

    private static Arguments arguments;

    public static void main(String[] args) {
        try {
            arguments = new Arguments(args);
        } catch (IllegalArgumentException e) {
            System.err.println(e.getMessage());
            Arguments.printUsage(System.err);
            System.exit(1);
        }

        if (arguments.isHelp()) {
            Arguments.printUsage(System.err);
            System.exit(0);
        }
    System.err.println(arguments.hostId());
        try {
            HostIdentifier hostId = arguments.hostId();
            MonitoredHost monitoredHost =
                    MonitoredHost.getMonitoredHost(hostId);
        System.err.println(monitoredHost);
            // get the set active JVMs on the specified host.
            Set<Integer> jvms = monitoredHost.activeVms();
        System.err.println(jvms);
            for (Integer jvm: jvms) {
                StringBuilder output = new StringBuilder();
                Throwable lastError = null;
        System.err.println(jvm);
                int lvmid = jvm;

                output.append(String.valueOf(lvmid));

                if (arguments.isQuiet()) {
                    System.out.println(output);
                    continue;
                }

                MonitoredVm vm = null;
                String vmidString = "//" + lvmid + "?mode=r";

                String errorString = null;

                try {
                    // Note: The VM associated with the current VM id may
                    // no longer be running so these queries may fail. We
                    // already added the VM id to the output stream above.
                    // If one of the queries fails, then we try to add a
                    // reasonable message to indicate that the requested
                    // info is not available.

                    errorString = " -- process information unavailable";
                    VmIdentifier id = new VmIdentifier(vmidString);
                    vm = monitoredHost.getMonitoredVm(id, 0);

                    errorString = " -- main class information unavailable";
                    output.append(' ').append(MonitoredVmUtil.mainClass(vm,
                            arguments.showLongPaths()));

                    if (arguments.showMainArgs()) {
                        errorString = " -- main args information unavailable";
                        String mainArgs = MonitoredVmUtil.mainArgs(vm);
                        if (mainArgs != null && mainArgs.length() > 0) {
                            output.append(' ').append(mainArgs);
                        }
                    }
                    if (arguments.showVmArgs()) {
                        errorString = " -- jvm args information unavailable";
                        String jvmArgs = MonitoredVmUtil.jvmArgs(vm);
                        if (jvmArgs != null && jvmArgs.length() > 0) {
                          output.append(' ')
                            .append(
                                // multi-line args are permitted
                                jvmArgs.replace("\n", "\\n").replace("\r", "\\r")
                            );
                        }
                    }
                    if (arguments.showVmFlags()) {
                        errorString = " -- jvm flags information unavailable";
                        String jvmFlags = MonitoredVmUtil.jvmFlags(vm);
                        if (jvmFlags != null && jvmFlags.length() > 0) {
                            output.append(' ').append(jvmFlags);
                        }
                    }

                    errorString = " -- detach failed";
                    monitoredHost.detach(vm);

                    System.out.println(output);

                    errorString = null;
                } catch (URISyntaxException e) {
                    // unexpected as vmidString is based on a validated hostid
                    lastError = e;
                    assert false;
                } catch (Exception e) {
                    lastError = e;
                } finally {
                    if (errorString != null) {
                        /*
                         * we ignore most exceptions, as there are race
                         * conditions where a JVM in 'jvms' may terminate
                         * before we get a chance to list its information.
                         * Other errors, such as access and I/O exceptions
                         * should stop us from iterating over the complete set.
                         */
                        output.append(errorString);
                        if (arguments.isDebug()) {
                            if ((lastError != null)
                                    && (lastError.getMessage() != null)) {
                                output.append("\n\t");
                                output.append(lastError.getMessage());
                            }
                        }
                        System.out.println(output);
                        if (arguments.printStackTrace()) {
                            lastError.printStackTrace();
                        }
                        continue;
                    }
                }
            }
        } catch (MonitorException e) {
            if (e.getMessage() != null) {
                System.err.println(e.getMessage());
            } else {
                Throwable cause = e.getCause();
                if ((cause != null) && (cause.getMessage() != null)) {
                    System.err.println(cause.getMessage());
                } else {
                    e.printStackTrace();
                }
            }
            System.exit(1);
        }
    }
}

/**
 * Class for processing command line arguments and providing method
 * level access to the command line arguments.
 *
 * @author Brian Doherty
 * @since 1.5
 */
class Arguments {

    private static final boolean debug = Boolean.getBoolean("jps.debug");
    private static final boolean printStackTrace = Boolean.getBoolean(
            "jps.printStackTrace");

    private boolean help;
    private boolean quiet;
    private boolean longPaths;
    private boolean vmArgs;
    private boolean vmFlags;
    private boolean mainArgs;
    private String hostname;
    private HostIdentifier hostId;

    public static void printUsage(PrintStream ps) {
      ps.println("usage: jps [--help]");
      ps.println("       jps [-q] [-mlvV] [<hostid>]");
      ps.println();
      ps.println("Definitions:");
      ps.println("    <hostid>:      <hostname>[:<port>]");
      ps.println("    -? -h --help -help: Print this help message and exit.");
    }

    public Arguments(String[] args) throws IllegalArgumentException {
        int argc = 0;

        if (args.length == 1) {
            if ((args[0].compareTo("-?") == 0)
                || (args[0].compareTo("-h")== 0)
                || (args[0].compareTo("--help")== 0)
                // -help: legacy.
                || (args[0].compareTo("-help")== 0)) {
              help = true;
              return;
            }
        }

        for (argc = 0; (argc < args.length) && (args[argc].startsWith("-"));
                argc++) {
            String arg = args[argc];

            if (arg.compareTo("-q") == 0) {
              quiet = true;
            } else if (arg.startsWith("-")) {
                for (int j = 1; j < arg.length(); j++) {
                    switch (arg.charAt(j)) {
                    case 'm':
                        mainArgs = true;
                        break;
                    case 'l':
                        longPaths = true;
                        break;
                    case 'v':
                        vmArgs = true;
                        break;
                    case 'V':
                        vmFlags = true;
                        break;
                    default:
                        throw new IllegalArgumentException("illegal argument: "
                                                           + args[argc]);
                    }
                }
            } else {
                throw new IllegalArgumentException("illegal argument: "
                                                   + args[argc]);
            }
        }

        switch (args.length - argc) {
        case 0:
            hostname = null;
            break;
        case 1:
            hostname = args[args.length - 1];
            break;
        default:
            throw new IllegalArgumentException("invalid argument count");
        }

        try {
            hostId = new HostIdentifier(hostname);
        } catch (URISyntaxException e) {
            IllegalArgumentException iae =
                    new IllegalArgumentException("Malformed Host Identifier: "
                                                 + hostname);
            iae.initCause(e);
            throw iae;
        }
    }
    public boolean isDebug() {
        return debug;
    }
    public boolean printStackTrace() {
        return printStackTrace;
    }
    public boolean isHelp() {
        return help;
    }
    public boolean isQuiet() {
        return quiet;
    }
    public boolean showLongPaths() {
        return longPaths;
    }
    public boolean showVmArgs() {
        return vmArgs;
    }
    public boolean showVmFlags() {
        return vmFlags;
    }
    public boolean showMainArgs() {
        return mainArgs;
    }
    public String hostname() {
        return hostname;
    }
    public HostIdentifier hostId() {
        return hostId;
    }
}

当前的 JDK 版本是17, 涉及JDK 内部类: sun.jvmstat.monitor, 所以需要特殊的编译运行命令:

# 编译
/tmp/jdk/bin/javac --add-exports jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED JpsTest.java
# 运行
/tmp/jdk/bin/java --add-exports jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED  JpsTest

在上面的类代码里面加入了几处打印信息, 发现对于 //localhost 给出的进程 ID 是一个空列表. 顺着代码去找, 发现这些代码: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.internal.jvmstat/share/classes/sun/jvmstat/perfdata/monitor/protocol/local/LocalVmManager.java#L129

仔细研究一下, 其实是从 临时文件夹去找特定的文件. 文件夹的命名习惯是: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.internal.jvmstat/share/classes/sun/jvmstat/perfdata/monitor/protocol/local/PerfDataFile.java#L61C54-L61C65

于是找到一个本地 Java 应用, 然后去对应的临时文件夹去找对应文件夹, 结果找到如下(本地运行在 container 里面的 Neo4j 的应用):
/tmp/hsperfdata_neo4j/7

hsperfdata_neo4j 是一个文件夹, 里面只有 7 这个文件, 它里面有很多该进程的元数据信息(截取部分如下):

�����7�R �8J0sun.rt._sync_Inflations@8J0sun.rt._sync_Deflations@J8sun.rt._sync_ContendedLockAttempts8J0sun.rt._sync_FutileWakeups0J(sun.rt._sync_Parks~8J0sun.rt._sync_Notificationsw8J0sun.rt._sync_MonExtant8J0sun.rt.createVmBeginTime�bs�8J0sun.rt.createVmEndTimebds�8J0sun.rt.vmInitDoneTime�cs�8J0java.threads.started*0J(java.threads.live'8J0java.threads.livePeak(0J(java.threads.daemon8J0sun.rt.safepointSyncTime�wJ0J(sun.rt.safepoints)8J0sun.rt.safepointTime�c�8J0sun.rt.applicationTimeߪ*DE�0J(sun.rt.jvmVersiopAB+sun.rt.jvmCapabilities11000000000000000000000000000000000000000000000000000000000000008J0java.cls.loadedClasses�(8J0java.cls.unloadedClasses@J8java.cls.sharedLoadedClasses�@J8java.cls.sharedUnloadedClasses0J(sun.cls.loadedBytes��G8J0sun.cls.unloadedBytes8J0sun.cls.sharedLoadedBytes�-8J0sun.cls.sharedUnloadedBytes0J(sun.cls.methodBytes��0J(sun.cls.timet�D[8J0sun.cls.classInitTime��*K8J0sun.cls.classInitTime.
......
sel@sun.classloader.parentDelegationTime8J0sun.classloader.findClasses@J8sun.classloader.findClassTimeHJ@sun.urlClassLoader.readClassBytesTime0J(sun.zip.zipFiles�8J0sun.zip.zipFile.openTime

于是想到对应的 pod 里面在 /tmp/目录新建了 /tmp/hsperfdata_test/53 空文件, 并再次运行 jps. 这次给出了 53 这个进程ID, 只不过没有更多信息.

I have no name!@mypod:~$ jps
53 -- process information unavailable

这时候才发现一开始就忽略了一个信息: 以前运行 Jps 的时候, 至少会出现当前 Jps 本身一行, 一般还有另外一个目标 Java 进程.

回过头来, 我们研究临时文件夹里面的这个文件夹: hsperfdata_neo4j, 它的后半部分其实是运行进程的用户名, 而一开始, 我们就看到我们在生产环境里面的, bash 给我们一直抱怨 I have no name!. 正是因为无法确认当前用户名, 只有用户ID, 才导致在临时目录的 hsperfdata_<user name> 无法创建出来, 最终导致 Jps 失效.

于是去问 chatGPT 关于临时文件夹里面的 hsperfdata_ 文件夹:

在 Java 进程运行的系统上,hsperfdata_ 开头的目录是用于存储 Java 虚拟机(JVM)性能数据的临时目录。这些目录和文件由 JVM 创建,用于支持 Java 性能监控和分析工具,例如 jstat。

这些文件的产生时机和机制:

创建时机:
当 JVM 启动时,它会在系统的临时目录中创建一个以 hsperfdata_ 开头的目录。这个目录的名称通常是 hsperfdata_<username>,其中 <username> 是运行 Java 进程的用户的用户名。
在这个目录中,JVM 会为每个 Java 进程创建一个性能数据文件,其文件名为该进程的 PID(进程标识符)。
创建机制:

JVM 使用这些文件来存储与进程相关的性能数据,例如内存使用情况、垃圾回收统计信息和线程状态等。
这些文件是通过共享内存的方式实现的,允许性能监控工具在不干扰 Java 应用程序运行的情况下访问这些数据。
这些文件在 JVM 进程终止时通常会被自动删除。不过,在某些情况下(例如非正常退出),这些文件可能会残留在系统中。

使用这些文件的工具:

jstat: Java 统计监控工具,用于显示 Java 应用程序的各种运行时统计信息。jstat 工具利用这些性能数据文件来获取 JVM 的性能指标。
其他可能使用这些数据文件的工具包括 jps(Java 进程状态工具)和 jconsole(Java 监控与管理控制台)。
这些性能数据文件对于调试和性能分析非常有用,但在某些情况下,可能需要手动清理残留的 hsperfdata_ 目录,特别是在非正常关闭的情况下。

所以, 正如之前 Google 到的那个问答一样, 最终原因是在临时文件夹下面无法创建出 hsperfdata_ 目录导致的. 这是在早期写 Java 性能监控和分析工具的人无法预料到竟然有的系统上面只能看到用户ID, 不能获得用户名.