fix(java): Start jdtls with a bounded heap that returns memory to the OS (#210)

jdtls was launched with -Xms1G and no heap cap or GC choice, so the JVM grew to a quarter of physical RAM during import and kept it. Default VM args are now ParallelGC with GCTimeRatio/AdaptiveSizePolicyWeight, -Xmx2G -Xms100m, disabled memory-mapped zips and VM-install detection, prepended as --jvm-arg so both the direct and launcher-script paths get them. User JVM options replace the matching default (heap, -D/-XX by name, any -Xlog, any GC flag drops the whole GC group).

Fixes #193

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
l0ng-ai
2026-10-29 11:46:22 +08:00
committed by GitHub
parent 8ebeaa2663
commit 4e675c1171
4 changed files with 161 additions and 9 deletions
+9 -4
View File
@@ -336,6 +336,7 @@ pub fn configure_jdtls(
{
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
}
crate::jdtls::add_default_vm_args(&mut cfg.args);
let mut all: Vec<Jdk> = Vec::new();
for j in project_jdk.into_iter().chain(jdks).chain(server_jdk) {
if !all.contains(j) {
@@ -529,9 +530,12 @@ mod tests {
&bundles,
None,
);
assert_eq!(cfg.args[0], "-data");
assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]);
assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
let defaults: Vec<String> = crate::jdtls::DEFAULT_VM_ARGS.iter().map(|a| format!("--jvm-arg={a}")).collect();
let n = defaults.len();
assert_eq!(cfg.args[..n], defaults, "our JVM options come first");
assert_eq!(cfg.args[n], "-data");
assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]);
assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
@@ -600,7 +604,8 @@ mod tests {
assert!(cfg.args.contains(&agent), "{:?}", cfg.args);
let mut plain = jdtls();
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent");
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
+149 -2
View File
@@ -105,6 +105,80 @@ fn split_script_args(args: &[String]) -> ScriptArgs {
out
}
/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector
/// tuned to give memory back to the OS (resident memory stays near what is live instead of
/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical
/// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at
/// start (they are passed as `java.configuration.runtimes`).
pub const DEFAULT_VM_ARGS: &[&str] = &[
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-Dsun.zip.disableMemoryMapping=true",
"-Xmx2G",
"-Xms100m",
"-Xlog:disable",
"-DDetectVMInstallationsJob.disabled=true",
"-Daether.dependencyCollector.impl=bf",
];
/// What a JVM option sets, so two options setting the same thing can be told apart:
/// `-Xmx2G` and `-XX:MaxRAMPercentage=50` both set the heap limit.
fn option_key(arg: &str) -> Option<String> {
let key = if let Some(name) = arg.strip_prefix("-XX:") {
let name = name.trim_start_matches(['+', '-']);
let name = name.split_once('=').map_or(name, |(n, _)| n);
match name {
"MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(),
"InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(),
_ if is_collector(name) => "collector".to_owned(),
_ => format!("XX:{name}"),
}
} else if let Some(prop) = arg.strip_prefix("-D") {
format!("D:{}", prop.split_once('=').map_or(prop, |(k, _)| k))
} else if arg.starts_with("-Xlog") {
"Xlog".to_owned()
} else if arg.starts_with("-Xmx") {
"Xmx".to_owned()
} else if arg.starts_with("-Xms") {
"Xms".to_owned()
} else {
return None;
};
Some(key)
}
/// `UseParallelGC`, `UseG1GC`, `UseZGC`, `UseShenandoahGC`, `UseSerialGC`, `UseEpsilonGC`…
fn is_collector(name: &str) -> bool {
name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len()
}
/// Our tuning of the parallel collector, meaningless (or rejected) with another one.
const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"];
/// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line)
/// sets itself. Choosing a collector drops our collector and its tuning altogether.
pub fn default_vm_args(user: &[String]) -> Vec<String> {
let user_keys: Vec<String> = user.iter().filter_map(|a| option_key(a)).collect();
let own_collector = user_keys.iter().any(|k| k == "collector");
DEFAULT_VM_ARGS
.iter()
.filter(|arg| {
let key = option_key(arg).expect("default JVM options are keyed");
let tuning = COLLECTOR_TUNING.iter().any(|t| key == format!("XX:{t}"));
!user_keys.contains(&key) && !(own_collector && (key == "collector" || tuning))
})
.map(|a| a.to_string())
.collect()
}
/// Adds the [`DEFAULT_VM_ARGS`] the launch doesn't override to `args` (meant for the
/// launcher script, so as `--jvm-arg=`), ahead of the user's own.
pub fn add_default_vm_args(args: &mut Vec<String>) {
let defaults = default_vm_args(&split_script_args(args).jvm);
args.splice(0..0, defaults.into_iter().map(|a| format!("--jvm-arg={a}")));
}
/// The JVM command line `bin/jdtls` builds: its system properties and module
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
@@ -127,7 +201,6 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
[
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
@@ -222,7 +295,6 @@ mod tests {
&format!(r"-Dosgi.sharedConfiguration.area={tools}\config_win"),
"-Dosgi.sharedConfiguration.area.readOnly=true",
"-Dosgi.configuration.cascaded=true",
"-Xms1G",
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
@@ -239,6 +311,81 @@ mod tests {
);
}
fn keep(user: &[&str]) -> Vec<String> {
default_vm_args(&strings(user))
}
fn without(drop: &[&str]) -> Vec<String> {
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
}
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
#[test]
fn user_options_override_the_defaults() {
assert_eq!(keep(&[]), without(&[]));
assert_eq!(keep(&["-javaagent:/opt/lombok.jar", "-Dlog.level=ALL"]), without(&[]), "unrelated options");
let cases: &[(&[&str], &[&str])] = &[
(&["-Xmx4G"], &["-Xmx2G"]),
(&["-Xmx512m"], &["-Xmx2G"]),
(&["-XX:MaxHeapSize=3g"], &["-Xmx2G"]),
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
(&["-Xms1G"], &["-Xms100m"]),
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
(&["-XX:+UseG1GC"], PARALLEL),
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
(&["-XX:+UseShenandoahGC"], PARALLEL),
(&["-XX:+UseSerialGC"], PARALLEL),
(&["-XX:-UseParallelGC"], PARALLEL),
(&["-XX:+UseParallelGC"], PARALLEL),
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
];
for (user, dropped) in cases {
assert_eq!(keep(user), without(dropped), "{user:?}");
}
// Not collectors: they keep ours.
assert_eq!(keep(&["-XX:+UseStringDeduplication", "-XX:+UseCompressedOops"]), without(&[]));
}
#[test]
fn defaults_go_ahead_of_the_users_options_once() {
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
let mut args = user.clone();
add_default_vm_args(&mut args);
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
let expected: Vec<String> =
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
assert_eq!(args, expected);
add_default_vm_args(&mut args);
assert_eq!(args, expected, "idempotent");
}
#[test]
fn windows_command_line_with_defaults() {
// JDK 25 on Windows, "-Xmx3G" in the settings' JVM options.
let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls";
let install = JdtlsInstall {
launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar")),
config: PathBuf::from(format!(r"{tools}\config_win")),
};
let mut script = strings(&["-data", r"C:\Users\张 三\ws", "--jvm-arg=-Xmx3G"]);
add_default_vm_args(&mut script);
let args = java_args(&install, 25, &script);
let jar = args.iter().position(|a| a == "-jar").unwrap();
let jvm = &args[..jar];
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
}
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
}
#[test]
fn unix_command_line() {
let install = JdtlsInstall {
@@ -249,14 +249,14 @@ export function JavaPage() {
<Row
label="JVM options"
htmlFor={id}
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches."
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseG1GC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches."
>
<Input
id={id}
mono
className={styles.pathInput}
value={vmArgs}
placeholder="-Xmx2g"
placeholder="-Xmx4g"
onChange={(e) => setVmArgs(e.target.value)}
/>
</Row>
+1 -1
View File
@@ -1328,7 +1328,7 @@
"Passed as -Dmaven.repo.local.": "以 -Dmaven.repo.local 传入。",
"Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。",
"Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。会自动添加 Lombok 代理:优先使用在“语言服务器”中安装的版本;否则当项目使用 Lombok(构建文件、继承的父 POM 或 Gradle 构建、源码中的 import)时,使用 Maven / Gradle 缓存中最新的版本。",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseG1GC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx4g。jdtls 默认使用 -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m 运行;此处设置的选项会替换同一设置的默认值,指定其他垃圾回收器(例如 -XX:+UseG1GC)时会替换默认的回收器选项。会自动添加 Lombok 代理:优先使用在“语言服务器”中安装的版本;否则当项目使用 Lombok(构建文件、继承的父 POM 或 Gradle 构建、源码中的 import)时,使用 Maven / Gradle 缓存中最新的版本。",
"Pop stash": "弹出储藏",
"Preview Source": "预览源代码",
"Reinstate index": "恢复索引",