前言
最近在做一个需求时,需要在 Linux 服务器上获取某个磁盘上挂载点的大小,写了一段逻辑但是发现有些问题(没有显式的关闭资源)。为了排查是否会造成影响,仔细分析了下这段代码,也加深了对 Java 中流资源的理解。这里记录下。
实现
代码
以下是两段代码,第一段是问题代码,第二段是修复后的代码。可以看到两者的区别在于是否显式地关闭了资源。
针对 InputStream 流,使用 try-with-resources 语法来关闭资源;针对 Process 资源,在 finally 语法块中显示地调用 process.destroy() 关闭资源。
问题代码
private static String getDiskUsageDetail1(String diskName) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder("df", "--block-size=1M", diskName);
Process start = processBuilder.start();
BufferedReader br = new BufferedReader(new InputStreamReader(start.getInputStream()));
// 去掉表头
br.readLine();
String s = br.readLine();
start.waitFor();
return s.split("\\s+")[2];
}
修复后的代码
private static String getDiskUsageDetail(String diskName) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder("df", "--block-size=1M", diskName);
Process process = processBuilder.start();
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
// 跳过表头
br.readLine();
String line = br.readLine();
// 等待进程执行完毕,获取返回值
int code = process.waitFor();
return line.split("\\s+")[2];
} finally {
// 显式销毁进程,释放相关流资源
process.destroy();
}
}
分析
Stream流是什么?
Stream 流可以形象地理解为一个“水管”。需要注意这里还没有读取实际内容,也就是没有“水”。
以上面的代码为例:
当执行 df 命令时,数据并不是直接飞进 String line 里的。它经历了一个类似“水箱传水”的过程:
- 内核缓冲区(水库): df 命令执行后,Linux 内核会开辟一块内存区域(
Pipe Buffer)。df 把结果写进去。此时,数据在内核手里。 - Java 输入流(抽水机):
process.getInputStream()就像连接到水库的管子。 readLine()命令(抽水动作): 关键点就在这里! 只有执行br.readLine()时,Java 才会向内核发起系统调用,把数据从内核的“水库”搬运到 Java 进程的内存里。
如果不调用 read 或 readLine,数据就会一直堆积在内核的缓冲区里,哪儿也不去。
如果代码只写了 start() 而没有写 br.readLine():
• 内核缓冲区会被填满。
• 一旦填满(通常是 64KB),df 命令就会被内核“挂起”(暂停执行),因为它没地方写了。
• Java 程序如果不去“抽水”,这些数据永远不会出现在 Java 内存中。
缓冲区写满会怎么样?
上一小节中提到 Java 需要通过 readLine() 方法去“抽水”,如果没有 readLine() 去“抽水”,把缓冲区写满会发生什么呢?
是仅输出存在阻塞还是整个进程都阻塞?
- 整个进程被操作系统挂起,不再占用 CPU 资源。
可能造成的严重问题?
- 可能会造成死锁,进程永远无法完成。
如何处理缓冲区中的内容?
简单法则:要么吃掉输出,要么倒掉输出,但绝不能放着不管!
吃掉输出
使用 readLine() 去读取是最常用的方式,这里获取的内容可以不处理。
示例代码段:
while ((line = reader.readLine()) != null) {
// 可以选择处理或直接丢弃
// 这里只是读取,防止缓冲区满
}
注:
- “吃掉输出”相对来讲较为复杂,当服务器负载较高的时候,很可能会出现消耗的速度比产生的速度慢,这样同样存在写满缓冲区的风险。
倒掉输出
重定向到 DISCARD。
示例代码段:
// 方法1:重定向到 DISCARD(完全丢弃)
ProcessBuilder pb = new ProcessBuilder("your-command");
// 重定向输出到黑洞
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
Process process = pb.start();
int exitCode = process.waitFor(); // 不会阻塞
注:
- “倒掉输出”是一种处理逻辑,并不一定要在 Java 代码中处理,也可以在 Shell 命令中处理,例如使用
>重定向到指定的文件中。
process.waitFor()应该放在哪里?
process.waitFor() 是获取命令的执行结果。那么它应该在 readLine() 之前还是之后执行呢?
前面已经写过,readLine() 方法是读取输出防止缓冲区写满的。此时存在两种情况:
- 输出很多。此时应该先
readLine(),则应该把waitFor()方法放在后面(但需要注意由于输出读取和产生的速度不一致,依然有写满缓冲区的风险)。 - 输出少于64K。此时无需关注两者的先后顺序,因为永远不会写满缓冲区。
其它的特殊情形
很多时候我们在实现功能时,都需要结合实际场景去考虑。例如上面的“问题代码”,也不见得一定会出现问题。在服务器负载很低时,Java 方法很快会执行结束,而且 process 构建的 Shell 命令也很简单,也会很快结束。在并发较低时,JVM 发生 GC 时会帮我们回收资源,同时服务器资源也基本不会有消耗。
上述这种场景,就不会出现问题。
当然了,问题代码还是要修复,但结合实际情况分析问题更重要。
句柄/文件描述符
FD 的全称是 File Descriptor,在 Windows 系统中称为“句柄”,在 Linux 系统中称为“文件描述符”。
在 Linux 中,FD 不仅仅代表硬盘上的文件。以下资源在 Linux 眼里都是“文件”,都会占用 FD:
- 普通文件(磁盘上的 .txt, .log 等)。
- 网络套接字 (Sockets)(每一个 TCP 连接都是一个 FD)。
- 管道 (Pipes):这就是你代码中产生 FD 的原因。当你启动 df 命令,Java 需要通过一个管道来读取 df 的输出,这个管道的“读端”就是一个 FD。
- 标准输入/输出:每个进程默认占用 3 个 FD:
• 0: 标准输入 (stdin)
• 1: 标准输出 (stdout)
• 2: 标准错误 (stderr)
Linux 系统中,给每个进程分配的默认 FD 个数为 1024,超过就会报错,例如我们常见的 Too many open files。但例如我们在 Linux 系统中启动了两个 Java 服务 A 和 B,如果服务 A 超出上限,并不会影响服务 B。每个进程是独享 1024 个 FD 的。
总结
记录下 Java 中流资源的知识点,加深理解。