feat: 新增 java run,按 JDK 版本自动套用调优参数运行 Java 应用

- 自动识别 JDK 8/11/17/21 与宿主机 CPU、可用内存;容器内以 cgroup
  限额为准;堆默认取可用内存 50%,-Xms 与 -Xmx 同值
- 按版本分支生成参数:JDK 8 用 -Xloggc 一组,JDK 9+ 用 -Xlog:;
  堆超过 30g 时自动省略压缩指针参数
- 支持 --jdk/--java/--heap/--xms/--env/--gc-log/--heap-dump-on-oom/
  --print-flags/--dry-run;jar 之后的参数原样交给应用
- 前台运行:stdio 直连、退出码透传、SIGINT/SIGTERM 转发给 JVM
- BuiltinRequest 增加 Stdin,使内置命令可以读取调用方输入
- 修正帮助中的用法行重复(自定义 Usage 不再追加通用透传尾巴)
- 文档:新增 README.java.md,主 README 补充导航与示例
This commit is contained in:
2026-11-01 09:16:19 +08:00
parent 14817bcfe7
commit f62c1f4872
16 changed files with 2139 additions and 7 deletions
+204
View File
@@ -0,0 +1,204 @@
package executor
import (
"fmt"
"os"
"strconv"
"strings"
)
// javaTuning is everything BuildJavaFlags needs to describe one JVM launch.
type javaTuning struct {
JDK string // normalised release: "8", "11", "17", "21"
CPUs int // usable CPUs, 0 when unknown
Memory int64 // usable memory in bytes, 0 when unknown
Heap string // -Xmx value; empty means "compute from Memory"
Initial string // -Xms value; empty means "same as -Xmx"
Env string // used for -Dconfig.file=application-<Env>.yaml
GCDir string // directory for GC logs; empty means no GC log
HeapDump bool // add -XX:+HeapDumpOnOutOfMemoryError
}
// javaHeapRatio is the fraction of available memory used for the heap when the
// user does not pick a size: half leaves room for metaspace, thread stacks,
// direct buffers and the OS page cache.
const javaHeapRatioPercent = 50
// compressedOopsLimit is the largest heap for which compressed oops help; JVM
// warns and effectively disables them beyond this, so kopsc stops emitting the
// flags at 30 GiB (the commonly recommended ceiling, below the 32 GiB limit).
const compressedOopsLimit = int64(30) << 30
// BuildJavaFlags renders the ordered java arguments for one launch.
//
// The order mirrors a hand-written command line so `--print-flags` output can
// be pasted into a Dockerfile or systemd unit unchanged: JVM ergonomics first,
// then heap, then GC and logging, then application properties.
func BuildJavaFlags(t javaTuning) []string {
flags := []string{"-server"}
// Heap: -Xms and -Xmx are equal to keep the heap from resizing at runtime.
heapBytes, heapText := resolveHeap(t)
initialText := t.Initial
if initialText == "" {
initialText = heapText
}
if heapText != "" {
flags = append(flags, "-Xmx"+heapText)
}
if initialText != "" {
flags = append(flags, "-Xms"+initialText)
}
flags = append(flags,
"-Xss256k",
"-XX:MaxDirectMemorySize="+directMemory(heapBytes),
)
// Compressed oops only make sense while the heap fits under the limit.
if heapBytes <= 0 || heapBytes <= compressedOopsLimit {
flags = append(flags, "-XX:+UseCompressedOops", "-XX:+UseCompressedClassPointers")
}
flags = append(flags,
"-XX:+SegmentedCodeCache",
"-XX:+UseG1GC",
"-XX:+ExplicitGCInvokesConcurrent",
)
if jdkMajor(t.JDK) >= 9 {
flags = append(flags, "-XX:+PrintCommandLineFlags")
if t.GCDir != "" {
flags = append(flags, "-Xlog:gc*,safepoint:"+gcLogPath(t.GCDir)+":time,uptime:filecount=100,filesize=50M")
}
} else {
// JDK 8 has no -Xlog:; it uses -Xloggc plus its own rotation options.
// Emitting -Xlog: on JDK 8 makes the JVM refuse to start.
flags = append(flags, "-verbose:gc", "-XX:+PrintCommandLineFlags", "-XX:+PrintGCDetails", "-XX:+PrintGCDateStamps")
if t.GCDir != "" {
flags = append(flags,
"-Xloggc:"+gcLogPath(t.GCDir),
"-XX:+UseGCLogFileRotation",
"-XX:NumberOfGCLogFiles=100",
"-XX:GCLogFileSize=50M",
)
}
}
flags = append(flags, "-Djava.security.egd=file:/dev/./urandom")
if t.HeapDump {
flags = append(flags, "-XX:+HeapDumpOnOutOfMemoryError")
}
flags = append(flags, "-Dconfig.file="+configFile(t.Env))
return flags
}
// configFile renders the Spring-style config property: --env wins, then the
// APP_ENV environment variable, then "prod".
func configFile(env string) string {
if env == "" {
env = os.Getenv("APP_ENV")
}
if env == "" {
env = "prod"
}
return "application-" + env + ".yaml"
}
// resolveHeap decides the heap size: an explicit value wins, otherwise half of
// the available memory.
func resolveHeap(t javaTuning) (int64, string) {
if t.Heap != "" {
return parseJVMSize(t.Heap), t.Heap
}
if t.Memory <= 0 {
return 0, ""
}
// Multiply first: dividing by 100 up front truncates, turning 8 GiB into
// 3.5g because the remainder is lost before the ratio is applied.
bytes := t.Memory * javaHeapRatioPercent / 100
return bytes, formatJVMSize(bytes)
}
// directMemory returns MaxDirectMemorySize as a quarter of the heap (the JVM's
// own default), at least 64 MiB so small heaps still work with NIO.
func directMemory(heapBytes int64) string {
if heapBytes <= 0 {
return "256m"
}
quarter := heapBytes / 4
if quarter < 64<<20 {
quarter = 64 << 20
}
return formatJVMSize(quarter)
}
// gcLogPath joins the GC directory and the standard file name.
func gcLogPath(dir string) string {
return strings.TrimRight(dir, `/\`) + "/gc.log"
}
// jdkMajor parses the normalised release into a number; "8" when unknown so
// the conservative JDK 8 flag set is used.
func jdkMajor(jdk string) int {
n, err := strconv.Atoi(strings.TrimSpace(jdk))
if err != nil {
return 8
}
return n
}
// parseJVMSize converts a user-supplied size ("2g", "512m", "1024k", "1073741824")
// into bytes. An unparsable value returns 0.
func parseJVMSize(value string) int64 {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return 0
}
multiplier := int64(1)
switch value[len(value)-1] {
case 'k':
multiplier, value = 1<<10, value[:len(value)-1]
case 'm':
multiplier, value = 1<<20, value[:len(value)-1]
case 'g':
multiplier, value = 1<<30, value[:len(value)-1]
case 't':
multiplier, value = 1<<40, value[:len(value)-1]
}
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || n <= 0 {
return 0
}
return n * multiplier
}
// formatJVMSize renders bytes the way -Xmx expects: "512m", "2g". Sizes are
// rounded down to whole mebibytes (or gibibytes when large enough) so the
// printed flag stays short.
func formatJVMSize(bytes int64) string {
if bytes <= 0 {
return ""
}
const (
mib = int64(1) << 20
gib = int64(1) << 30
)
if bytes >= gib {
if bytes%gib == 0 {
return fmt.Sprintf("%dg", bytes/gib)
}
// Round down to 0.5g so "6.4g" becomes "6.5g"-free output (6.4g),
// keeping the flag short while never asking for more than available.
halfG := int64(1) << 29
halves := bytes / halfG
if halves%2 == 0 {
return fmt.Sprintf("%dg", halves/2)
}
return fmt.Sprintf("%.1fg", float64(halves)/2)
}
return fmt.Sprintf("%dm", bytes/mib)
}