背景

最近工作中遇到了需要用Java来执行shell命令的场景,刚开始参考网上的一些实现写了一个简单的工具类,项目使用的是Spring Boot框架,部署运行在k8s容器中,在测试时偶现容器内程序卡死的情况,本地测试时又一切正常。参考了几篇博文后了解到问题所在,于是进行改进优化,封装出了一个较为通用的工具类,遂在此记录之。

Java执行shell命令的API

Runtime.getRuntime().exec(command)实现

Runtime.getRuntime()可以获得当前虚拟机的运行时环境,其实现是典型的饿汉式单例模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Runtime {
private static Runtime currentRuntime = new Runtime();

/**
* Returns the runtime object associated with the current Java application.
* Most of the methods of class <code>Runtime</code> are instance
* methods and must be invoked with respect to the current runtime object.
*
* @return the <code>Runtime</code> object associated with the current
* Java application.
*/
public static Runtime getRuntime() {
return currentRuntime;
}

/** Don't let anyone else instantiate this class */
private Runtime() {}

......


}

exec方法是执行指定的字符串命令,包含多个重载方法,可以指定环境变量和工作目录。

这里我以一个curl命令为例,简单写法如下:

1
2
3
4
5
6
public static void main(String[] args) throws IOException, InterruptedException {
Process process = Runtime.getRuntime().exec("curl https://lilu.org.cn/");
int waitFor = process.waitFor();
process.destroy();
System.out.println("process退出值:" + waitFor);
}

查看exec及其重载的方法可知,其内部是利用ProcessBuilder类运作的:

1
2
3
4
5
6
7
public Process exec(String[] cmdarray, String[] envp, File dir)
throws IOException {
return new ProcessBuilder(cmdarray)
.environment(envp)
.directory(dir)
.start();
}

所以接下来我们重点看下ProcessBuilder的实现。

ProcessBuilder实现

从类名就可以看出,该类使用了建造者模式,可不依赖Runtime类单独使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws IOException, InterruptedException {
ProcessBuilder builder = new ProcessBuilder("/bin/sh", "-c", "curl https://lilu.org.cn/");

Map<String, String> env = new HashMap<>();
env.put("env", "dev");
builder.environment().putAll(env);

builder.directory(new File("/Users/sunchaser/workspace/"));

Process process = builder.start();

int waitFor = process.waitFor();
process.destroy();
System.out.println("process退出值:" + waitFor);
}

以上就是Java执行shell命令的API的使用方式,存在的一个问题是无法获取命令执行后的输出。

获取shell命令的输出

我们知道,在cmd或终端中执行shell命令后,或多或少会有一些字符输出在终端窗口上,例如curl https://lilu.org.cn/命令会输出获取到的html字符,那在java中如何获取这些输出内容呢?

我们分析下ProcessBuilder#start()方法,发现其调用了ProcessImpl.start方法返回一个Process类对象,而ProcessImpl.start方法中用new关键字创建了一个UNIXProcess对象。UNIXProcess类是Process的一个子类,在其构造器中调用了native方法forkAndExec,其底层是fork了一个子线程来执行命令,而shell命令的输出被分为两个标准输入流存储在缓冲区中,想获得输出只需读取缓冲区中对应的流即可。

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.sunchaser.sparrow.javase.shell;

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* @author sunchaser admin@lilu.org.cn
* @since JDK8 2022/2/23
*/
@Slf4j
public class ProcessBuilderTest {
public static void main(String[] args) throws IOException, InterruptedException {
ProcessBuilder builder = new ProcessBuilder("/bin/sh", "-c", "curl https://lilu.org.cn/");

Map<String, String> env = new HashMap<>();
env.put("env", "dev");
builder.environment().putAll(env);

builder.directory(new File("/Users/sunchaser/workspace/"));

Process process = builder.start();

List<String> execInfoList = new ArrayList<>();
List<String> execErrList = new ArrayList<>();
collectStreamInfo(process.getInputStream(), execInfoList);
collectStreamInfo(process.getErrorStream(), execErrList);

int waitFor = process.waitFor();
process.destroy();
System.out.println("process退出值:" + waitFor);
}

/**
* 收集命令执行信息:将流中的信息一行一行读取存入List容器
*
* @param is 输入流
* @param collector 收集器
* @throws IOException exception
*/
private static void collectStreamInfo(InputStream is, List<String> collector) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String line;
while ((line = br.readLine()) != null) {
log.info("CommandExecutor collectStreamInfo: " + line);
collector.add(line);
}
}

}

以上代码就是我最初在业务代码中的核心实现,部署在容器环境中时会偶现应用卡死的情况。

卡死原因简单分析:首先k8s分配的资源是1c1gfork出的子线程执行命令后不断地向缓冲区写入字符,而主线程中同步读取缓冲区中的字符,由于是单核CPU,底层操作系统是流水线作业,如果恰好主线程将缓冲区中的字符读取完(子线程执行命令并未完成),按代码逻辑顺序,主线程会调用waitFor()方法阻塞挂起,而此时CPU切换回子线程继续执行命令向缓冲区写字符,当缓冲区写满的时候,子线程无法继续写,等待主线程读取缓冲区,而主线程此时在挂起等待子线程执行结束。于是,子线程等待主线程清理缓冲区,主线程等待子线程执行完毕,两个进程相互等待,导致死锁。

可以在start之前调用ProcessBuilder#redirectErrorStream(true)方法让shell命令的两个输出合并成一个标准输出流,即合并成一个缓冲区,以便进行读取。

优雅封装Shell命令执行器ShellCommandExecutor

由于读取缓冲区字符不是执行shell命令的必要步骤,只是为了查看执行的过程及结果,所以我们可以使其异步化。然而即使异步化缓冲区的读取,单核CPU极端情况下还是会出现上述死锁问题,为了以防万一,还要设置命令执行的超时时间。

封装待执行的shell命令

shell命令可指定环境变量和工作目录,经过上面分析我们还要加上一个超时时间,所以Command类封装如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* Command
*/
@Data
public static class Command {

/**
* 待执行的shell命令
*/
private String[] command;

/**
* 环境变量
* env for the command execution
*/
private Map<String, String> environment;

/**
* shell执行目录
*/
private File dir;

/**
* 执行超时时间
*/
private Long timeOutInterval;
}

封装shell命令的执行结果

一方面要收集缓冲区中的字符,另一方面要记录waitFor方法返回的状态码,同时还要提供执行是否成功的判断方法及打印缓冲区内容的方法。所以Result类封装如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 执行的结果
*/
@EqualsAndHashCode(callSuper = true)
@Data
public static class Result extends Command {

/**
* 执行命令输出的信息
*/
private final StringBuilder execInfoBuilder = new StringBuilder();

/**
* 状态码
*/
private Integer exitCode;

/**
* 状态码=0表示执行成功
*
* @return CommandExecutorStatus.name();
*/
public String status() {
return exitCode == 0 ? SUCCESS.toString() : WARN.toString();
}

/**
* 打印输出执行的shell命令和缓冲区内容
*/
public void print() {
log.info("command: {}, exitCode: {}, timeout={}ms, execute result:\n{}", Arrays.toString(this.getCommand()), exitCode, this.getTimeOutInterval(), this.getExecInfoBuilder().toString());
}
}

/**
* 执行状态
*/
public enum Status {
SUCCESS, WARN,
;
}

封装Shell命令执行器核心执行方法

主要是利用Future#get方法让线程池中的任务具有超时时间,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@Slf4j
public class ShellCommandExecutor {

private static final ExecutorService SHELL_COMMAND_EXECUTOR;

static {
int cpu = Runtime.getRuntime().availableProcessors();
log.info("Runtime.getRuntime().availableProcessors() = {}", cpu);
SHELL_COMMAND_EXECUTOR = Executors.newFixedThreadPool(
cpu - 1,
new ThreadFactory() {
private final ThreadFactory defaultFactory = Executors.defaultThreadFactory();
private final AtomicInteger threadNumber = new AtomicInteger(1);

@Override
public Thread newThread(@Nonnull Runnable r) {
Thread thread = this.defaultFactory.newThread(r);
if (!thread.isDaemon()) {
thread.setDaemon(true);
}
thread.setName("shell-command-" + this.threadNumber.getAndIncrement());
return thread;
}
}
);
}

private static final String LINE_SEPARATOR = "line.separator";

/**
* command
*/
private final Command command;

/**
* result
*/
private final Result result;

public Result execute() {
Future<Result> future = SHELL_COMMAND_EXECUTOR.submit(() -> {
InputStream is = null;
Process process = null;
try {
process = prepareProcessBuilder().start();
is = process.getInputStream();
StringBuilder execInfoBuilder = result.getExecInfoBuilder();
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
execInfoBuilder.append(line);
execInfoBuilder.append(System.getProperty(LINE_SEPARATOR));
}
int exitCode = process.waitFor();
result.exitCode = exitCode;
if (exitCode == 0) {
if (log.isDebugEnabled()) {
log.debug("ShellCommandExecutor执行shell命令的子线程正常执行完成结束. exitCode == 0");
}
} else {
if (log.isDebugEnabled()) {
log.debug("ShellCommandExecutor执行shell命令的子线程执行失败异常结束. exitCode == {}", exitCode);
}
}
result.print();
} catch (Exception e) {
log.error("[ShellCommandExecutor] execute error", e);
throw new ShellCommandExecutorException(e);
} finally {
IoUtil.close(is);
if (Objects.nonNull(process)) {
process.destroy();
}
}
return result;
});
Long timeOutInterval = command.getTimeOutInterval();
if (timeOutInterval > 0) {
try {
future.get(timeOutInterval, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
log.error("[ShellCommandExecutor] future get timeout", e);
future.cancel(true);
} catch (Exception e) {
log.error("[ShellCommandExecutor] future get error", e);
}
}
return result;
}

private ProcessBuilder prepareProcessBuilder() {
ProcessBuilder builder = new ProcessBuilder(command.getCommand()).redirectErrorStream(true);

Map<String, String> environment = command.getEnvironment();
File dir = command.getDir();
if (Objects.nonNull(environment)) {
builder.environment().putAll(environment);
}

if (Objects.nonNull(dir)) {
builder.directory(dir);
}
return builder;
}
}

提供静态方法对外使用

核心封装基本介绍完毕,接下来只需提供静态方法对外使用。

主要代码如下:

1
2
3
public static Result execute(String[] commands, File dir, Map<String, String> env, Long timeout) {
return new ShellCommandExecutor(commands, dir, env, timeout).execute();
}

完整代码可查看 Github地址

使用示例

下面以执行npm install命令为例,使用我们封装好的工具类,代码如下:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
List<String> commands = Lists.newArrayList(
"acorn@^8.6.0",
"antd@^4.16.13"
);
for (String command : commands) {
ShellCommandExecutors.execute("npm install " + command);
}
}

参考