From 4e675c11719ae28afa3b4f385c876ac10bacfd7e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:46:22 +0800 Subject: [PATCH] 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 --- crates/ide-sdk/src/java.rs | 13 +- crates/ide-sdk/src/jdtls.rs | 151 +++++++++++++++++- .../settings/languages/LanguagePages.tsx | 4 +- src/platform/locales/text/zh.json | 2 +- 4 files changed, 161 insertions(+), 9 deletions(-) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index ae228dfc..f7265416 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -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 = 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 = 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"); diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs index f4f35724..f9ee7239 100644 --- a/crates/ide-sdk/src/jdtls.rs +++ b/crates/ide-sdk/src/jdtls.rs @@ -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 { + 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 { + let user_keys: Vec = 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) { + 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 { @@ -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 { + default_vm_args(&strings(user)) + } + + fn without(drop: &[&str]) -> Vec { + 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 = + 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 { diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index eca7dfe9..7ef5718d 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -249,14 +249,14 @@ export function JavaPage() { setVmArgs(e.target.value)} /> diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cb84e750..9c197ca2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "恢复索引",