分类 Java 相关 下的文章

诊断错误的正则表达式导致的 StackOverflowError

在生产环境中, 见过很多错误的正则表达式导致的各种错误, 比如: 导致CPU 100%(完整占用一个CPU), 导致StackOverflowError, 导致性能下降很严重. 这里以一个错误的正则表达式导致StackOverflowError 为例子, 看如何诊断并找到根本原因.

案例

我们看到某个应用的错误最近非常多的 StackOverflowError, 并且CPU 使用率明显上升, 下面是我们在错误日志看到的:

st=java.lang.StackOverflowError 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4340) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$GroupHead.match(Pattern.java:4807) 
at java.base/java.util.regex.Pattern$Loop.match(Pattern.java:4944) 
at java.base/java.util.regex.Pattern$GroupTail.match(Pattern.java:4866) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$GroupHead.match(Pattern.java:4807) 
at java.base/java.util.regex.Pattern$Loop.match(Pattern.java:4944) 
at java.base/java.util.regex.Pattern$GroupTail.match(Pattern.java:4866) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347)
...

这是截取的部份在日志中的栈, 但是即便日志中的栈有 1024 行, 但是这1024行都是上面最后面5行的重复. 也就是说尽管抛出 StackOverflowError 错误的时候, 打印出最后的1024行栈, 但是这1024 行都是正则表达式处理的栈, 没有我们期望看到的到底是我们的哪段代码调用了这个正则.

问题分析

要想找到这个错误的正则表达式, 我们必须找到我们自己的代码, 如果我是代码开发者, 并且这个应用仅有一处使用正则表达式的地方, 那么很容易就能找到这个正则表达式. 如果做为SRE, 对这个应用代码不熟悉, 可能就要想其它方法了.

如果找到了这个正则表达式, 除非这个正则表达式的错误看起来很明显, 否则, 我们都需要一个被处理的字符串样本, 如何拿到一个样本?

StackOverflowError 栈的深度

  1. 多深的栈会发生 StackOverflowError?
  2. 发生 StackOverflowError 时, 会打印最内层多少层栈?
    我们可以通过命令来看一下(当前JDK是 MAC 上 JDK17):

    java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep Stack
      intx CompilerThreadStackSize                  = 1024                                   {pd product} {default}
      intx MaxJavaStackTraceDepth                   = 1024                                      {product} {default}
      bool OmitStackTraceInFastThrow                = true                                      {product} {default}
      bool StackTraceInThrowable                    = true                                      {product} {default}
      intx ThreadStackSize                          = 1024                                   {pd product} {default}
      intx VMThreadStackSize                        = 1024                                   {pd product} {default}
    openjdk version "17.0.4.1" 2022-08-12 LTS

上面各个选项的说明:

  1. CompilerThreadStackSize JVM 编译器的栈大小, 单位是 Kbytes, 所以是栈的大小, 不是深度.
  2. MaxJavaStackTraceDepth 当有异常的时候, 打印的Java 栈的深度的行数, 0 表示所有行, 默认1024行.
  3. OmitStackTraceInFastThrow 如果一个异常抛出太快太多, 那就省略它的栈, 直接打印异常名字. 一般开始还打印栈, 后面就直接打印异常名字了(因为太热, 编译成二进制). 笔者在生产环境见过多次这样的问题, 都是到最开始出错的地方去看原始栈, 或重启它.
  4. StackTraceInThrowable 在 Throwable 抛出时, 带着栈.
  5. ThreadStackSize 线程栈的大小, 单位是 Kbytes, 所以不是深度.
  6. VMThreadStackSize 非 Java 栈的大小, 单位是 Kbytes, 所以不是深度.

所以, 我们可以回答上面的2个问题: 能运行栈的深度是由 ThreadStackSize 决定的, 它的单位是字节, 在上面的 JVM 中, 它最大允许 1024 KB, 而出异常后能打印的栈, 是由 MaxJavaStackTraceDepth 决定的, 它默认只打1024行.

如何找到错误使用正则表达式的代码以及错误的正则表达式?

通过设置 -XX:MaxJavaStackTraceDepth=0 就能打印出所有栈, 那么就能逐级向上找到错误正则.

如何找到一个导致 StackOverflowError 的样本?

在 上面我们自己的代码处 加上 try {} catch (StackOverflowError e) {}, 当捕获着异常的时候, 打印导致出错的样本.

服务延迟变长的思考

最近有人咨询这么一个问题: 他们提供某个服务, 这个服务的耗时偶尔突然变长. 比如平时这个服务的P99的延迟是800毫秒左右, 而最近这个服务的P99延迟已经接近4秒. 它们自己也做了很多功课, 发现延迟开始增加的大概时间就是他们有新客户接入的时间, 所以他们觉得是请求数量增加导致P99延迟变长. 于是他们去问容量规划团队去加机器, 但是容量规划团队看了他们的CPU使用率也不高, 内存使用量也不高, 拒绝了他们的要求. 他们又去查看变慢请求的具体日志, 发现了某些特征的日志: 有些2行日志中间平时应该很短, 但是一旦出问题, 这二行日志中间会有比平时长的多的时间间隙.

下面是一个具体的例子:

06:48:31.28    /api/query/v1/metrics?responseFormat=FLAT
06:48:34.48    Total Attempt #1 for client MyEventClient for AsyncRetrier

可以看到这2行日志中间隔了3.2秒, 而正常情况下, 这2行的中间通常少于10毫秒.

对于这类问题, 通常有哪些解题思路呢?

问题分析

对于延迟变长的问题, 通常我们会主要考虑下面情况:

  1. 服务本身的问题, 比如关键资源的竞争, 锁竞争, JVM 的GC, 新加了大量的耗时逻辑等;
  2. 外部依赖延迟变长, 比如依赖的数据库服务器变慢, 依赖的下游服务变慢, 依赖的网络变慢等;

服务本身问题分析

对于服务本身造成的延迟变长, 也有很多情况.

  1. 假如说服务本身增加了很多耗时的业务逻辑, 这种延迟会体现在所有的请求上面, 而不是偶尔的某些请求.
  2. 假如说是JVM GC 造成的变长, 尤其像这种3秒的延迟, 通常会有更明显的特征:

    1. GC 日志会很好的体现, CPU使用率也会体现.
    2. 凡是经历当时GC的所有请求都会遇到延迟变长的情况.

    而我们看到的实际情况并不符合.

  3. 假如说有锁竞争导致延迟变长, 并且会导致延迟3秒, 那么当时着锁竞争应该很激烈, 会体现在CPU上面, 事实并没有体现.
  4. 假如其它关键资源的竞争导致延迟变长, 那么应该有另外一个线程拥有这种关键资源, 并且占用了3秒, 但我们并不知道有没有这种情况, 所以暂时无法判断. 但是一般对于一个繁忙的应用服务器, 这种情况不会偶尔发生, 会频繁复现.

所以, 如若服务本身的情况, 大多数都会有其它指标体现出来, 也有些情况比较复杂, 比较难判断.

外部依赖问题

通常对于外部依赖, 我们都有2处度量的点:

  1. 在当前服务调用外部服务的点, 这里的延迟包含自己服务IO的调度, 网络延迟, 以及下游真正的延迟.
  2. 在下游服务的入口点, 也就是下游服务自己暴露的延迟信息, 这里仅是下游的服务的延迟.

如果对于#2已经可以看到延迟增加, 那么可以初步定位就是下游服务造成的.
如果下游服务延迟没有增加, 反而#1增加了, 那么可能是网络延迟或者服务器本身IO调度的延迟. 如果是网络延迟造成的, 那么通常网络会影响到所有经过这段网络的服务, 也就是不单单是这个服务受到了影响, 而是很多服务会受到影响. 如果是本身IO调度的问题, 那么通常可能跟大多数从这个网络出入的下游服务都会受到影响, 主要考虑跟有症状的下游相近流量的下游服务.

noise neighbor 影响

如今很多服务都是在 container 里面部署的, 一个宿主机可能部署很多container. 尽管当前应用服务器的CPU使用率很低, 但是那个时间点可能遇到相同宿主机的请它container 在大量使用CPU, 导致当前container 虽然CPU使用率很低, 但是它的进程/线程很难拿到CPU的时间片, 导致延迟变长.

但是这种情况要求延迟变长的应用很多都和 noise neighbor 在一块, 导致经常出这种问题.

Java 为什么我要的锁不见了

线上遇到问题: 有些 Tomcat 线程卡住了, 卡住的越来越多, 重启虽然能暂时解决, 但不是长期解决办法, 如下图:
TomcatBusyThread.png

确定卡住的线程

随机找一个症状还在的服务器, 获取 thread dump, 看到如下卡住的线程 (截取部分):

"MyTaskExecutor-127" #407 daemon prio=5 os_prio=0 tid=0x00007ff6d0019000 nid=0x1da waiting on condition [0x00007ff4159d7000]
   java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    - parking to wait for  <0x000000075e438328> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:837)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:872)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1202)
    at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:213)
    at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:290)
    at sun.security.ssl.SSLSocketImpl$AppInputStream.read(SSLSocketImpl.java:951)
    at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
    at java.io.BufferedInputStream.read1(BufferedInputStream.java:286)
    at java.io.BufferedInputStream.read(BufferedInputStream.java:345)
    - locked <0x000000075e5b2488> (a java.io.BufferedInputStream)
    at sun.net.www.MeteredStream.read(MeteredStream.java:134)
    - locked <0x000000075e5b4400> (a sun.net.www.http.KeepAliveStream)
    at java.io.FilterInputStream.read(FilterInputStream.java:133)
    at sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.read(HttpURLConnection.java:3471)

可以看出这是一个使用 HTTPS 访问外部请求的操作, 现在卡在了 SSLSocketImpl$AppInputStream.read() 上面, 现在它需要一把锁.

环境信息

Open JDK 1.8.362. 为什么要强调 JDK 版本, 后面会看到JDK涉及到这块的代码改动量非常大, 每个版本代码都不一样.

初步分析

一开始认为是没有设置 read timeout, 导致一直死等. 但是看了应用程序配置, 发现是设置的, 查看heapd dump 里面, 却是也是设置的. 如下图:
timeout.png

为什么设置了 connect 和 read timeout 还死等

根据这个栈可以看出, 连接已经建立(新建或者使用KeepLiveCache里面的), 所以, connect timeout 阶段已经过了, 不管用了.
同时, read time 是在使用 poll() 或者在它的外层 c代码 Java_java_net_SocketInputStream 里面才会计算 read timeout, 所以这里还没到.

这个线程等的锁被谁占用

通过 thread dump 很容易可以看到, 这个锁被下面的线程占着.

"Finalizer" #3 daemon prio=8 os_prio=0 tid=0x00007ff8508d3000 nid=0x26 in Object.wait() [0x00007ff80f427000]
   java.lang.Thread.State: WAITING (on object monitor)
    at java.lang.Object.wait(Native Method)
    - waiting on <0x00000007570016f8> (a java.lang.ref.ReferenceQueue$Lock)
    at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:144)
    - locked <0x00000007570016f8> (a java.lang.ref.ReferenceQueue$Lock)
    at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:165)
    at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:188)

   Locked ownable synchronizers:
    - <0x000000075e438328> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e438410> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e613df8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e6682c0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e6a3e90> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e6b4070> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075e6c51f8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075ee84098> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000075f636998> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x00000007610a3e08> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x0000000766af21d0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x00000007759c8fd8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078c1bed50> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078d6fb888> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078e2b8ff0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078f448fc8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078f592e50> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078f5a9430> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
    - <0x000000078f5bed40> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

从上面的信息中看到, 这个Finalizer 线程的 Locked ownable synchronizers部分看, 它占有了很多锁. 其中第一个就是我们之前线程正想要获取的锁.

那么这个 Finalizer 在干嘛?

从上面的栈信息, 结合具体的源代码, 可以看到这个 Finalizer 线程其实在等下一个需要 finalize() 方法的对象. 并且当前没有在排队的对象(从heap dump)可以看到:
lock.png

矛盾的现象

这个线程拥有了这把锁(其实它拥有很多把锁, 从上面Locked ownable synchronizers可以看到), 却没有在使用这把锁做事情, 反而现在没有任何事情在做. 那么它就没有可能释放这把锁. 也就是说, 它曾经获得了这把锁, 但是没办法释放这把锁.

那么任何在等这把锁的对象, 都面临着永远等不来的情况.

为什么会造成这种情况

从现在的数据来看, 这种情况发生的几率很小, 没几天才能发生一次. 从已有的数据看, 很有可能是 Finalizer 线程在执行 sun.security.ssl.SSLSocketImplfinalize() 方法的时候, 获得了这把锁, 然而却没释放.

于是去查看这个版本的sun.security.ssl.SSLSocketImpl的源代码, 发现几乎每处使用这个锁的地方都是 try{}finally{} 方式, 在 finally{} 代码块里去释放的锁. 所以正常执行完不可能不释放.

唯一的可能性就是: finalize() 方法没有正常执行完. 在获得锁还没有释放锁的位置, finalize() 方法被中断了. 在JDK 里面, 根本不保证 finalize() 一定被执行, 什么时候被执行, 以及是不是执行完. 所以在 JDK 9 之后 finalize() 就被 deprecated 了.

思考

如果这个 sun.security.ssl.SSLSocketImpl 已经被开始执行 finalize() 方法, 那么它在某个时间点, 已经被 JVM GC 判定为不可达. 那么肯定有一种神秘的力量把它从死神哪里拉回来了. 并且现在正在被另外一个线程使用.
当一个 AbstractOwnableSynchronizer 的锁被一个线程使用的时候, 它会记录拥有锁的线程名字到它的 exclusiveOwnerThread 字段. 从heap dump, 我们可以证实这个锁也是被 Finalizer 拥有.
lock2.png
这里的线程是 Finalizer, state 是1, 表示这个 ReentrantLock$NonfairSync 被进入一次.

是哪个神秘的力量救活了它?

探索 Java URL 连接的 read timeout 如何实现的

使用最朴实的 java.net.HttpURLConnection 来获取网页数据, 设置 timeout 然后通过OS层面的 tracing 工具去获取代码栈. 运行于 Ubuntu 22.04 和 OpenJDK 11. 由于JDK 11 使用了系统调用 poll 去获取网络事件, 然后使用了系统调用 recvfrom 来取的数据, 所以会拦截这2个系统调用. 另外可以看到下面使用perf 注入符号表, perf 和 bpftrace 都能看到 Java 编译的方法. 但是 strace 和 gdb 不行.
依次使用的工具:

  1. strace
  2. perf
  3. bpftrace
  4. gdb

java 代码

还是使用之前 chatGPT 给的这段代码. 注意这里设置的 connect timeoutread timeout 参数, 后面会看到.

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(997); // 连接超时时间 997ms
                con.setReadTimeout(719); // 读取超时时间 719ms
                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.java
$ java -XX:+PreserveFramePointer -XX:-TieredCompilation -XX:CompileThreshold=1 URLTest

生成JIT 编译的代码符号表. 参看: bpftrace 探测 Java 运行时栈-实践

strace

直接对这个java程序使用 trace pollrecvfrom event. 可以看到 poll 系统调用的timeout参数是997和719. 分别来自于JDK里面的C 代码 Java_java_net_PlainSocketImpl_socketConnectJava_java_net_SocketInputStream_socketRead0

$ sudo strace --stack-trace -f -e trace=poll  -p 1097556
strace: Process 1097556 attached with 18 threads
[pid 1097557] poll([{fd=5, events=POLLOUT}], 1, 997 <unfinished ...>
[pid 1097670] +++ exited with 0 +++
[pid 1097557] <... poll resumed>)       = 1 ([{fd=5, revents=POLLOUT}])
 > /usr/lib/x86_64-linux-gnu/libc.so.6(__poll+0x4f) [0x118dbf]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(NET_Poll+0xab) [0xff2b]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(Java_java_net_PlainSocketImpl_socketConnect+0x1ec) [0xd24c]
 > unexpected_backtracing_error [0x7f93742cbd95]
[pid 1097557] poll([{fd=5, events=POLLIN|POLLERR}], 1, 719) = 1 ([{fd=5, revents=POLLIN}])
 > /usr/lib/x86_64-linux-gnu/libc.so.6(__poll+0x4f) [0x118dbf]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(NET_Timeout+0xed) [0x1019d]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(Java_java_net_SocketInputStream_socketRead0+0xdf) [0xe75f]
 > unexpected_backtracing_error [0x7f93742daa34]


$ sudo strace --stack-trace -f -e trace=recvfrom  -p 1097556
strace: Process 1097556 attached with 18 threads
[pid 1097557] recvfrom(5, "HTTP/1.1 301 Moved Permanently\r\n"..., 8192, MSG_DONTWAIT, NULL, NULL) = 242
 > /usr/lib/x86_64-linux-gnu/libc.so.6(recv+0x6e) [0x1278ae]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(NET_NonBlockingRead+0xb0) [0xf1e0]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(Java_java_net_SocketInputStream_socketRead0+0xf9) [0xe779]
 > unexpected_backtracing_error [0x7f93742daa34]

perf

使用 perf 要先记录数据, 然后生成命令行report, 进入单个结果, 就能看到完整的栈. 这个栈是从上往下的, 最新的在下面.

$ sudo perf record -p 1097556  -e "syscalls:sys_enter_poll" -ag
$ ls -lah perf.data
$ sudo perf report 
# 选择一个sample, enter 键进入detail 如下
Samples: 8  of event 'syscalls:sys_enter_poll', Event count (approx.): 8
  Children      Self  Trace output
-   37.50%    37.50%  ufds: 0x7f937753c438, nfds: 0x00000001, timeout_msecs: 0x000002cf                                                                                                 ▒
     start_thread                                                                                                                                                                       ▒
     ThreadJavaMain                                                                                                                                                                     ▒
     JavaMain                                                                                                                                                                           ▒
     jni_CallStaticVoidMethod                                                                                                                                                           ▒
     jni_invoke_static                                                                                                                                                                  ▒
     JavaCalls::call_helper                                                                                                                                                             ▒
     call_stub                                                                                                                                                                          ▒
     Interpreter                                                                                                                                                                        ▒
     Ljava/net/HttpURLConnection;::getResponseCode                                                                                                                                      ▒
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream                                                                                                                      ▒
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0                                                                                                                     ▒
     Lsun/net/www/http/HttpClient;::parseHTTP                                                                                                                                           ▒
     Lsun/net/www/http/HttpClient;::parseHTTPHeader                                                                                                                                     ▒
     Ljava/io/BufferedInputStream;::read                                                                                                                                                ▒
     Ljava/io/BufferedInputStream;::read1                                                                                                                                               ▒
     Ljava/io/BufferedInputStream;::fill                                                                                                                                                ▒
     Ljava/net/SocketInputStream;::read                                                                                                                                                 ▒
     Ljava/net/SocketInputStream;::socketRead0                                                                                                                                          ▒
     Java_java_net_SocketInputStream_socketRead0                                                                                                                                        ▒
     __poll                                                                                                                                                                             ▒
+   25.00%    25.00%  ufds: 0x7f937754c378, nfds: 0x00000001, timeout_msecs: 0x000003e5                                                                                                 ▒
+   12.50%    12.50%  ufds: 0x7f937754a888, nfds: 0x00000001, timeout_msecs: 0x00000000                                                                                                 ▒
+   12.50%    12.50%  ufds: 0x7f937754a888, nfds: 0x00000001, timeout_msecs: 0x00001385                                                                                                 ▒
+   12.50%    12.50%  ufds: 0x7f937754a888, nfds: 0x00000001, timeout_msecs: 0x00001388
$ sudo perf record -p 1097556  -e "syscalls:sys_enter_recvfrom" -ag
$ sudo perf report 
Samples: 2  of event 'syscalls:sys_enter_recvfrom', Event count (approx.): 2
  Children      Self  Trace output
-  100.00%   100.00%  fd: 0x00000005, ubuf: 0x7f937753c4c0, size: 0x00002000, flags: 0x00000040, addr: 0x00000000, addr_len: 0x00000000                                                 ◆
     start_thread                                                                                                                                                                       ▒
     ThreadJavaMain                                                                                                                                                                     ▒
     JavaMain                                                                                                                                                                           ▒
     jni_CallStaticVoidMethod                                                                                                                                                           ▒
     jni_invoke_static                                                                                                                                                                  ▒
     JavaCalls::call_helper                                                                                                                                                             ▒
     call_stub                                                                                                                                                                          ▒
     Interpreter                                                                                                                                                                        ▒
     Ljava/net/HttpURLConnection;::getResponseCode                                                                                                                                      ▒
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream                                                                                                                      ▒
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0                                                                                                                     ▒
     Lsun/net/www/http/HttpClient;::parseHTTP                                                                                                                                           ▒
     Lsun/net/www/http/HttpClient;::parseHTTPHeader                                                                                                                                     ▒
     Ljava/io/BufferedInputStream;::read                                                                                                                                                ▒
     Ljava/io/BufferedInputStream;::read1                                                                                                                                               ▒
     Ljava/io/BufferedInputStream;::fill                                                                                                                                                ▒
     Ljava/net/SocketInputStream;::read                                                                                                                                                 ▒
     Ljava/net/SocketInputStream;::socketRead0                                                                                                                                          ▒
     Java_java_net_SocketInputStream_socketRead0                                                                                                                                        ▒
     __libc_recv

bpftrace

使用 bpftrace 给出的 event 获取用户态的栈.

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

@[
    poll+79
    Java_java_net_SocketInputStream_socketRead0+223
    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
    Interpreter+4352
    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
]: 1
$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_recvfrom /pid==1097556/{ @[ustack(20)] = count(); }'
Attaching 1 probe...
^C

@[
    recvfrom+116
]: 2
@[
    __GI___recv+110
    Java_java_net_SocketInputStream_socketRead0+249
    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
    Interpreter+4352
    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
]: 4

gdb

使用gdb连接, 然后通过设置段点来找到我们期望的栈. 这个看到的更多一些, 包括从 Java_java_net_SocketInputStream_socketRead0 到 glibc 的 __GI___poll 中间的2步: NET_ReadWithTimeoutNET_Timeout.

# gdb 连接
$ sudo gdb --pid=1097556
# 找到我们需要的线程
$ thread 2
#设置断点, 先设置一个 socketRead的, 如果直接设置
$ break Java_java_net_SocketInputStream_socketRead0
$ cont # 到上面这个断点停止
$ bt # 查看是不是我们想要的栈
$ break __GI___poll
$ cont # 这样就会到我们想要的点
(gdb) break __GI___poll
Breakpoint 5 at 0x7f9378c6dd70: file ../sysdeps/unix/sysv/linux/poll.c, line 27.
(gdb) cont
Continuing.

Thread 2 "java" hit Breakpoint 5, __GI___poll (fds=fds@entry=0x7f937753c438, nfds=nfds@entry=1, timeout=719, timeout@entry=<error reading variable: That operation is not available on integers of more than 8 bytes.>) at ../sysdeps/unix/sysv/linux/poll.c:27
27    ../sysdeps/unix/sysv/linux/poll.c: No such file or directory.
(gdb) bt
#0  __GI___poll (fds=fds@entry=0x7f937753c438, nfds=nfds@entry=1, timeout=719,
    timeout@entry=<error reading variable: That operation is not available on integers of more than 8 bytes.>) at ../sysdeps/unix/sysv/linux/poll.c:27
#1  0x00007f937402419d in poll (__timeout=719, __nfds=1, __fds=0x7f937753c438) at /usr/include/x86_64-linux-gnu/bits/poll2.h:39
#2  NET_Timeout (env=env@entry=0x7f937001bb48, s=s@entry=5, timeout=<error reading variable: That operation is not available on integers of more than 8 bytes.>,
    nanoTimeStamp=nanoTimeStamp@entry=1643674031468882) at ./src/java.base/linux/native/libnet/linux_close.c:433
#3  0x00007f937402275f in NET_ReadWithTimeout (timeout=<optimized out>, len=8192, bufP=0x7f937753c4c0 "", fd=5, env=0x7f937001bb48)
    at ./src/java.base/unix/native/libnet/SocketInputStream.c:55
#4  Java_java_net_SocketInputStream_socketRead0 (env=0x7f937001bb48, this=<optimized out>, fdObj=<optimized out>, data=0x7f937754c518, off=0, len=8192, timeout=719)
    at ./src/java.base/unix/native/libnet/SocketInputStream.c:127
#5  0x00007f93742daa34 in ?? ()
#6  0x00000000000002cf in ?? ()

timeout 的实现

根据上面的栈信息, 我们很容易就知道了我们期望的 timeout 如何使用的:

  1. Java_java_net_SocketInputStream_socketRead0 - https://github.com/openjdk/jdk11/blob/37115c8ea4aff13a8148ee2b8832b20888a5d880/src/java.base/unix/native/libnet/SocketInputStream.c#L91C1-L91C44
  2. NET_ReadWithTimeout - https://github.com/openjdk/jdk11/blob/37115c8ea4aff13a8148ee2b8832b20888a5d880/src/java.base/unix/native/libnet/SocketInputStream.c#L50
  3. NET_Timeout - https://github.com/openjdk/jdk11/blob/master/src/java.base/linux/native/libnet/linux_close.c#L415
  4. poll - https://github.com/openjdk/jdk11/blob/master/src/java.base/linux/native/libnet/linux_close.c#L441

其中真正使用 timeout 地方在于 poll 和 Java_java_net_SocketInputStream_socketRead0 里面的循环.

如果改成 https

改成 https 连接 connect 的栈:

    poll+79
    Java_java_net_PlainSocketImpl_socketConnect+492
    Ljava/net/PlainSocketImpl;::socketConnect+213
    Ljava/net/AbstractPlainSocketImpl;::doConnect+328
    Ljava/net/AbstractPlainSocketImpl;::connect+444
    Ljava/net/SocksSocketImpl;::connect+2528
    Ljava/net/Socket;::connect+592
    Lsun/security/ssl/SSLSocketImpl;::connect+96
    Lsun/net/NetworkClient;::doConnect+432
    Lsun/net/www/http/HttpClient;::openServer+72
    Lsun/net/www/http/HttpClient;::openServer+348
    Lsun/net/www/protocol/https/HttpsClient;::<init>+972
    Lsun/net/www/protocol/https/HttpsClient;::New+1444
    Lsun/net/www/protocol/http/HttpURLConnection;::plainConnect0+3272
    Lsun/net/www/protocol/http/HttpURLConnection;::plainConnect+256
    Lsun/net/www/protocol/https/AbstractDelegateHttpsURLConnection;::connect+100
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+1996
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream+192
    Ljava/net/HttpURLConnection;::getResponseCode+116
    LURLTest;::main+660

改成 https 连接 read 的栈:

    poll+79
    Java_java_net_SocketInputStream_socketRead0+223
    Ljava/net/SocketInputStream;::socketRead0+244
    Ljava/net/SocketInputStream;::read+204
    Lsun/security/ssl/SSLSocketInputRecord;::read+52
    Lsun/security/ssl/SSLSocketInputRecord;::readHeader+144
    Lsun/security/ssl/SSLSocketInputRecord;::bytesInCompletePacket+72
    Lsun/security/ssl/SSLSocketImpl;::readApplicationRecord+348
    Lsun/security/ssl/SSLSocketImpl$AppInputStream;::read+552
    Ljava/io/BufferedInputStream;::fill+760
    Ljava/io/BufferedInputStream;::read+676
    Lsun/net/www/http/ChunkedInputStream;::fastRead+116
    Lsun/net/www/http/ChunkedInputStream;::read+584
    Lsun/net/www/protocol/http/HttpURLConnection$HttpInputStream;::read+116
    Lsun/nio/cs/StreamDecoder;::readBytes+444
    Lsun/nio/cs/StreamDecoder;::implRead+392
    Lsun/nio/cs/StreamDecoder;::read+432
    Ljava/io/BufferedReader;::fill+724
    Ljava/io/BufferedReader;::readLine+1132
    LURLTest;::main+304

Java 里面的 connect timeout 的实现

在如今的微服务应用中, 有很多的服务之间相互调用. 在服务调用的时候, 我们都会设置 connect timeoutread timeout, 那么这个 connect timeout 的逻辑是什么样子的? JDK 里面到底是怎么实现的?

下面我们以一个简单的URL连接的例子, 来看这个 connect timeout 是如何工作的.

连接例子

下面是我们用来演示的代码, 它的意图就是不断的循环去获得某个网页. 不断循环的目的是为了给我们的手工操作留有足够的时间. 这段代码有网络操作, 所以会调用系统调用 connect 来拿到响应(response), 对应 bpftrace 里面的 tracepoint:syscalls:sys_enter_connect.(感谢 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);
        }
    }
}

获取调用栈

我们可以通过拦截系统调用 connect 的时候, 去获取调用栈, 然后依次查看调用栈里面的方法, 来确认哪里使用了 timeout, 啥如何使用的.

通过 bpftrace 获取的 connect 调用栈

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

@[
    __connect+75
    Java_java_net_PlainSocketImpl_socketConnect+687
    Ljava/net/PlainSocketImpl;::socketConnect+213
    Ljava/net/AbstractPlainSocketImpl;::doConnect+332
    Ljava/net/AbstractPlainSocketImpl;::connect+444
    Ljava/net/Socket;::connect+596
    Lsun/net/NetworkClient;::doConnect+1208
    Lsun/net/www/http/HttpClient;::openServer+72
    Lsun/net/www/http/HttpClient;::openServer+332
    Lsun/net/www/http/HttpClient;::<init>+924
    Lsun/net/www/http/HttpClient;::New+1284
    Lsun/net/www/protocol/http/HttpURLConnection;::plainConnect0+3156
    Lsun/net/www/protocol/http/HttpURLConnection;::plainConnect+260
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+2000
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream+196
    Ljava/net/HttpURLConnection;::getResponseCode+96
    Interpreter+4352
    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
]: 1

通过 strace 获取的调用栈

$ sudo strace --stack-trace  -f -e trace=connect  -p  288407
[pid 288408] connect(5, {sa_family=AF_INET6, sin6_port=htons(80), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::ffff:23.222.245.123", &sin6_addr), sin6_scope_id=0}, 28) = 0
 > /usr/lib/x86_64-linux-gnu/libc.so.6(__connect+0x4b) [0x12771b]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(NET_Connect+0xaa) [0xfd0a]
 > /usr/lib/jvm/java-11-openjdk-amd64/lib/libnet.so(Java_java_net_PlainSocketImpl_socketConnect+0x2af) [0xd30f]
 > unexpected_backtracing_error [0x7fd780780995]

哪里使用了 timeout

根据 bpftrace 给出的调用栈, 我们依次排查每个方法, 发现从 Ljava/net/Socket;::connect 开始, 就获取了当前设置的 connect timeout 值, 然后依次向下传递, 直到 JDK 里面的 C 代码 Java_java_net_PlainSocketImpl_socketConnect. 真正使用 timeout 的地方, 就在这个方法里面. 代码如下 (JDK11) https://github.com/openjdk/jdk11/blob/37115c8ea4aff13a8148ee2b8832b20888a5d880/src/java.base/unix/native/libnet/PlainSocketImpl.c#L344-L372

while (1) {
    jlong newNanoTime;
    struct pollfd pfd;
    pfd.fd = fd;
    pfd.events = POLLOUT;

    errno = 0;
    connect_rv = NET_Poll(&pfd, 1, nanoTimeout / NET_NSEC_PER_MSEC);

    if (connect_rv >= 0) {
        break;
    }
    if (errno != EINTR) {
        break;
    }

    /*
        * The poll was interrupted so adjust timeout and
        * restart
        */
    newNanoTime = JVM_NanoTime(env, 0);
    nanoTimeout -= (newNanoTime - prevNanoTime);
    if (nanoTimeout < NET_NSEC_PER_MSEC) {
        connect_rv = 0;
        break;
    }
    prevNanoTime = newNanoTime;

} /* while */

这是一个循环, 只有满足某些条件, 才会跳出. 涉及到 timeout 的地方, 有2处, 一处是 NET_Poll, 它的定义在: https://github.com/openjdk/jdk11/blob/37115c8ea4aff13a8148ee2b8832b20888a5d880/src/java.base/linux/native/libnet/linux_close.c#L407, 可以看到, 它其实是调用了 系统调用 poll, 这个 poll 可以传入一个timeout, 当timeout的时候, 返回0. 另外一处是下面的每次 nanoTimeout -= (newNanoTime - prevNanoTime), 相当于把已经过去的时间剪掉, 看 timeout 还剩余多少. 但是不论那种方式timeout, connect_rv 的值都会是0.

所以, 我们可以看到接着就有了下面的代码:

if (connect_rv == 0) {
   JNU_ThrowByName(env, JNU_JAVANETPKG "SocketTimeoutException",
                            "connect timed out");
   ...省略 ...
}

所以, 真正的 timeout 的处理, 要么是在 poll 的系统调用, 要么是在一个循环里面每次循环递减, 直到减没.

在结尾时候, 我们可以思考这吗一个问题: 当不设置timeout, 真的就永远等下去吗?