VS Code Java 调试源码路径解析全链路剖析

项目背景

本文以一个真实的 Spring Boot 调试场景为样本,追踪调用堆栈渲染的完整链路,定位一个隐蔽的问题:launch.json 中明明配置了 sourcePaths,调试时却完全不生效。

样本项目是一个单模块 Maven 工程,基于 Spring Boot 2.7.18,Java 8。pom.xml 声明了 spring-boot-starter-web 和 spring-boot-starter-test 两个依赖,artifactId 为 spring-boot-demo。项目下有一个 external 目录,存放从 sources jar 解压出来的第三方源码,作为只读参考区。同时,项目通过类名覆盖机制,在 src/main/java/org/springframework/boot/ 下放置了一份 SpringApplication.java,用于局部调试 Spring Boot 启动流程。

调试采用 Attach 模式:目标 Spring Boot 进程以 JDWP 调试参数独立启动,监听 5015 端口,IDE 作为调试客户端连接上去。

调试配置

项目的调试行为由两份配置文件决定。

settings.json 中与 Java 相关的关键配置:

{"java.jdt.ls.vmargs":"-Xmx2G -Dlog.level=ALL -Dlog.protocol=true -Djdt.ls.debug=true","java.trace.server":"verbose","java.import.generatesMetadataFilesAtProjectRoot":true,"java.debug.settings.logLevel":"verbose"}

其中 java.import.generatesMetadataFilesAtProjectRoot 设为 true,使得 .classpath 文件生成在项目根目录而非 JDT 工作区数据目录,便于直接查看。jdt.ls.vmargs 开启了全量日志和协议追踪,为后续链路分析提供了完整的运行时日志。

launch.json 的 Attach 调试配置:

{"type":"java","name":"Attach to Spring Boot (5015)","request":"attach","hostName":"localhost","port":5015,"sourcePaths":["${workspaceFolder}/external/spring-boot-2.7.18-sources","${workspaceFolder}/external/spring-boot-autoconfigure-2.7.18-sources","${workspaceFolder}/external/spring-web-5.3.31-sources","${workspaceFolder}/external/spring-webmvc-5.3.31-sources","${workspaceFolder}/external/spring-beans-5.3.31-sources","${workspaceFolder}/external/spring-context-5.3.31-sources","${workspaceFolder}/external/spring-aop-5.3.31-sources","${workspaceFolder}/external/spring-core-5.3.31-sources","${workspaceFolder}/external/tomcat-embed-core-9.0.83-sources"]}

sourcePaths 指向 external 目录下九个解压后的源码目录,期望调试时调用堆栈里第三方库的栈帧能定位到这些裸 .java 文件。实际运行中,断点命中后调用堆栈输出如下:

DefaultApplicationArguments.<init>(String[]) (\spring-boot-2.7.18.jar\org.springframework.boot\DefaultApplicationArguments.java:41) SpringApplication.run(String[]) (d:\project\external\java-project\src\main\java\org\springframework\boot\SpringApplication.java:301) SpringApplication.run(Class[],String[]) (d:\project\external\java-project\src\main\java\org\springframework\boot\SpringApplication.java:1300) SpringApplication.run(Class,String[]) (d:\project\external\java-project\src\main\java\org\springframework\boot\SpringApplication.java:1289) Application.main(String[]) (d:\project\external\java-project\src\main\java\com\example\Application.java:12)

问题就藏在这份堆栈里:DefaultApplicationArguments 的路径指向 jar 包内部(\spring-boot-2.7.18.jar…),而不是 external/spring-boot-2.7.18-sources 下的源文件。sourcePaths 配了九个目录,却完全没有参与路径解析。

调试链路全景

要理解 sourcePaths 为何失效,需要先看清整条调试链路如何运转。

LSP 进程启动

VS Code 启动时加载 redhat.java 扩展,读取 settings.json 配置,随后启动 JDT Language Server 进程。启动命令的核心参数包括:

JRE: ...\redhat.java-1.55.0\jre\21.0.11\bin\java -Djava.import.generatesMetadataFilesAtProjectRoot=true -Xmx2G -Dlog.level=ALL -Dlog.protocol=true -javaagent:...\lombok-1.18.39.jar -jar ...\org.eclipse.equinox.launcher_1.7.200.jar -configuration ...\globalStorage\redhat.java\1.55.0\config_win -data ...\workspaceStorage\...\redhat.java\jdt_ws

JDT LS 基于 Eclipse OSGi 容器运行,启动时会加载一批 bundle,包括 org.eclipse.jdt.core(JDT 核心)、org.eclipse.m2e.core(Maven 集成)、org.eclipse.jdt.ls.core(语言服务器核心),以及 com.microsoft.java.debug.plugin(Java 调试插件)。这个调试插件对应 VS Code 的 Debugger for Java 扩展(vscjava.vscode-java-debug),正是后续 sourcePaths 逻辑的承载者,也是问题根源所在。排查过程中,从 GitHub 下载了该扩展的 java-debug 源码项目进行参考研究,版本为 pre-release0.59.2026072407,后续会看到这份源码与实际运行的版本存在关键差异。

项目导入与 classpath 生成

JDT LS 收到 initialize 请求后,解析 settings 中的 java.home 和 configuration.runtimes,注册 JavaSE-1.8 对应的 JDK 路径。随后 ProjectManager 检测到工作区根目录下的 pom.xml,选择 MavenProjectImporter 进行导入。

导入过程解析 pom.xml 的依赖模型,创建 Eclipse 项目,设置 java nature 和 maven nature,并生成 .classpath 文件。由于 generatesMetadataFilesAtProjectRoot 为 true,.classpath 直接写到项目根目录。

.classpath 中的关键条目:

kind="src" → src/main/java (源码根,K_SOURCE) kind="src" → src/test/java (测试源码) kind="con" → JRE_CONTAINER (JavaSE-1.8) kind="con" → MAVEN2_CLASSPATH_CONTAINER (所有 Maven 依赖 jar) kind="output" → target/classes

其中 src/main/java 这个源码根既包含项目自身的 com.example.Application.java,也包含通过类名覆盖放进去的 org.springframework.boot.SpringApplication.java。MAVEN2_CLASSPATH_CONTAINER 则包含 spring-boot-2.7.18.jar 等所有依赖 jar,每个 jar 若有对应的 sources jar,会作为源码附件关联。

调试会话启动

用户点击调试按钮后,VS Code 先发送 updateDebugSettings 命令同步调试参数,随后发送 startDebugSession 命令。JDT LS 端的 JavaDebugServer 在一个空闲端口(本次为 2087)创建 ServerSocket,VS Code 作为 DAP 客户端连接上来。

连接建立后,JdtProviderContextFactory 创建 ProviderContext,注册一系列 Provider,其中关键的是 JdtSourceLookUpProvider,负责后续的源码查找。DebugAdapter 随后注册所有 DAP 请求处理器,包括 AttachRequestHandler、StackTraceRequestHandler 等。

AttachRequestHandler 处理 attach 请求时,解析 launch.json 传入的参数,随后通过 JDWP 连接到目标 JVM,创建 DebugSession,并将 sourcePaths 注入到 DebugAdapterContext 中。到这里,sourcePaths 已经正确传入运行时上下文,DAP 协议层也确认了这九个路径。

断点命中与栈帧解析

目标 JVM 在 Application.main 第 12 行命中断点,线程暂停,通过 JDWP 通知调试器。VS Code 收到 stopped 事件后发送 stackTrace 请求。

StackTraceRequestHandler 的处理流程:

1. 通过 JDWP 获取线程引用和总帧数(本次 5 帧) 2. 通过 JDWP 加载栈帧列表 3. 对每个栈帧解析 JDI 元信息: - declaringType.name() → 全限定类名 - sourceName → 源文件名(如 "DefaultApplicationArguments.java") - sourcePath → 包相对路径(如 "org/springframework/boot/DefaultApplicationArguments.java") - lineNumber → 行号 4. 对每个栈帧调用 convertDebuggerSourceToClient 解析最终源码路径

第四步的 convertDebuggerSourceToClient 是整条链路的核心,也是 sourcePaths 生效与否的分水岭。

源码路径解析的两条分支

convertDebuggerSourceToClient 接收全限定类名、源文件名、包相对路径和上下文,内部先通过 ISourceLookUpProvider 查找源码元素,得到一个 URI,然后根据 URI 的协议类型走不同分支。

分支一:file 协议直接返回

当类位于项目源码根 src/main/java 中时,JdtUtils.findSourceElement 会在 JavaProjectSourceContainer 的 K_SOURCE root 里找到 IResource(即一个 IFile),返回 file 协议的 URI。

本项目里 SpringApplication.java 通过类名覆盖放在 src/main/java/org/springframework/boot/ 下,属于项目源码。三个 SpringApplication 栈帧都走这条路径:

ISourceLookUpProvider.getSource("org.springframework.boot.SpringApplication", "org/springframework/boot/SpringApplication.java") └─ JdtUtils.findSourceElement() └─ JavaProjectSourceContainer.findSourceElements() └─ 在 K_SOURCE root: src/main/java 中查找 └─ 找到 IFile → file:///d:/.../src/main/java/org/springframework/boot/SpringApplication.java uri = "file:///..." → startsWith("file:") = true → 直接返回 file:// 路径

Application.java 同理,它是项目自身源码,也走 file 协议直接返回。这条分支不涉及 sourcePaths。

分支二:jdt 协议与 sourcePaths 回退

当类不在项目源码根中,而是来自 Maven 依赖 jar 时,findSourceElement 在 K_SOURCE root 中找不到,转而在 K_BINARY roots(MAVEN2_CLASSPATH_CONTAINER)中查找,找到的是 IClassFile(编译后的 class 文件),返回 jdt 协议的 URI。

DefaultApplicationArguments 就是这种情况,它只在 spring-boot-2.7.18.jar 中,src/main/java 下没有同名覆盖:

ISourceLookUpProvider.getSource("org.springframework.boot.DefaultApplicationArguments", "org/springframework/boot/DefaultApplicationArguments.java") └─ JdtUtils.findSourceElement() └─ JavaProjectSourceContainer.findSourceElements() ├─ 在 K_SOURCE root: src/main/java 中查找 → 未找到 └─ 在 K_BINARY roots 中查找 └─ 在 spring-boot-2.7.18.jar 中找到 IClassFile → 返回 jdt://contents/spring-boot-2.7.18.jar/...URI uri = "jdt://..." → startsWith("file:") = false

此时进入关键逻辑:uri 不以 file: 开头,理应尝试用 sourcePaths 做回退查找。这正是 sourcePaths 配置的意义所在——当 JDT 只能找到 jar 内的 class 文件时,用 sourcePaths 指向的裸源码目录兜底,把 jdt:// URI 替换成 file:// 路径。

sourcePaths 为何没有生效

按上述设计,DefaultApplicationArguments 走到 jdt:// 分支后,应该触发 sourcePaths 回退逻辑:

resolveSourceFromSourcePaths( "DefaultApplicationArguments.java", "org/springframework/boot/DefaultApplicationArguments.java", context) └─ AdapterUtils.sourceLookup(context.getSourcePaths(), relativeSourcePath) └─ 遍历 9 个 sourcePaths 目录: Path fullpath = Paths.get( "d:/.../external/spring-boot-2.7.18-sources", "org/springframework/boot/DefaultApplicationArguments.java") → 文件确实存在 → 返回完整路径 → 返回 file:// 路径,调用堆栈显示 external 下的源文件

但实际调用堆栈显示的是 \spring-boot-2.7.18.jar…,说明 sourcePaths 回退根本没有执行。通过 Arthas 在运行时验证:

watch AdapterUtils.sourceLookup → 未被调用(sourceLookup 从未执行) sm StackTraceRequestHandler * -d → resolveSourceFromSourcePaths 方法不存在 jad StackTraceRequestHandler.convertDebuggerSourceToClient → 反编译确认走旧版逻辑

运行时加载的 java-debug 代码中,convertDebuggerSourceToClient 的实际逻辑是:

if(!StringUtils.isBlank(uri)){if(uri.startsWith("file:")){returnnewTypes.Source(sourceName,clientPath,sourceReference);}// 直接返回 jdt:// URI,完全没有 sourcePaths 回退returnnewTypes.Source(sourceName,uri,sourceReference);}// 只有 URI 为空时才走 sourceLookupStringabsoluteSourcepath=AdapterUtils.sourceLookup(context.getSourcePaths(),relativeSourcePath);

也就是说,uri 不为空且不以 file: 开头时,直接返回 jdt:// URI,sourcePaths 被完全跳过。sourcePaths 只有在 URI 恰好为空时才会被使用,而 JDT 几乎总能从 jar 中找到 IClassFile 并返回非空 URI,导致 sourcePaths 形同虚设。

根因:release 版本落后于 pre-release

排查过程中,从 GitHub 下载了 Debugger for Java 扩展的 java-debug 源码项目进行参考研究,版本为 pre-release0.59.2026072407。这份源码中,convertDebuggerSourceToClient 的逻辑包含了 resolveSourceFromSourcePaths 回退:

if(!StringUtils.isBlank(uri)){if(uri.startsWith("file:")){returnnewTypes.Source(sourceName,clientPath,sourceReference);}else{// 先尝试 sourcePaths 回退Types.SourcesourceInSourcePaths=resolveSourceFromSourcePaths(sourceName,relativeSourcePath,context);if(sourceInSourcePaths!=null){returnsourceInSourcePaths;}returnnewTypes.Source(sourceName,uri,sourceReference);}}

但这份源码只是参考研究对象,并非调试时实际执行的代码。实际执行的是用户扩展目录中安装的 Debugger for Java 扩展,VS Code 默认安装的是 release 版本。release 版本落后于 pre-release 版本,其内嵌的 com.microsoft.java.debug.core-0.53.2.jar 中,convertDebuggerSourceToClient 的逻辑缺少 resolveSourceFromSourcePaths 回退:

if(!StringUtils.isBlank(uri)){if(uri.startsWith("file:")){returnnewTypes.Source(sourceName,clientPath,sourceReference);}// 直接返回 jdt:// URI,完全没有 sourcePaths 回退returnnewTypes.Source(sourceName,uri,sourceReference);}// 只有 URI 为空时才走 sourceLookupStringabsoluteSourcepath=AdapterUtils.sourceLookup(context.getSourcePaths(),relativeSourcePath);

两个版本的对比如下:

版本来源resolveSourceFromSourcePathssourcePaths 是否生效
pre-release 0.59.2026072407GitHub 下载的源码项目(参考研究)
release 版本VS Code 默认安装(实际执行)

问题本质在于:VS Code 默认使用 release 版本,而 release 版本落后于 pre-release,尚未包含 sourcePaths 回退的修复。排查时参考的 pre-release 源码让人误以为修复已存在,但实际运行的 release 版本并没有这段逻辑。

此外,OSGi 缓存机制会进一步固化这个问题。JDT LS 基于 Eclipse OSGi 容器运行,首次启动时会将扩展目录中的 plugin JAR 安装到 OSGi 缓存中。后续即使更新了扩展目录中的 JAR,只要 bundle 版本号不变(都是 0.53.2),OSGi 就会复用缓存中的旧版,不会重新安装。通过 StackTraceRequestHandler.class 的 SHA256 比对可以确认:

来源SHA256是否含修复
OSGi 缓存(运行时实际加载)E896B36F…
pre-release 源码编译版本1F3D6403…
release 扩展目录E896B36F…

OSGi 缓存中的类与 release 版本字节级一致。这意味着即使后续把扩展切换到包含修复的版本,不清除 OSGi 缓存的话,运行时仍会加载旧版。

修复方案

问题根源在于 VS Code 默认安装的 release 版本落后于 pre-release,缺少 sourcePaths 回退修复。在 VS Code 扩展面板中找到 Debugger for Java,将其从 release 版本切换到 pre-release 版本即可获得包含 resolveSourceFromSourcePaths 的代码。

VS Code 默认最新版使用的是 release 版本,pre-release 版本包含尚未发布到 release 通道的最新修复。切换方式:在扩展面板搜索 Debugger for Java,点击齿轮图标选择"切换到预发布版本"。

切换扩展版本后,JDT LS 不会自动重新加载新版的 plugin JAR,因为 OSGi 缓存仍持有旧版。需要配合方案二清除 OSGi 缓存,确保运行时加载到 pre-release 版本的代码。

修复后的预期效果

修复生效后,DefaultApplicationArguments 的解析路径变为:

uri = "jdt://..." → 不以 file: 开头 └─ resolveSourceFromSourcePaths() └─ AdapterUtils.sourceLookup(sourcePaths, "org/springframework/boot/DefaultApplicationArguments.java") └─ Paths.get("d:/.../external/spring-boot-2.7.18-sources", "org/springframework/boot/DefaultApplicationArguments.java") → 文件存在 → 返回完整路径 → 返回 file:// 路径 调用堆栈显示: DefaultApplicationArguments.<init>(String[]) (d:\project\external\java-project\external\spring-boot-2.7.18-sources\ org\springframework\boot\DefaultApplicationArguments.java:41)

此时 sourcePaths 真正参与解析,调用堆栈中的第三方栈帧定位到 external 目录下的裸源码文件,可在 IDE 中直接编辑、断点、查看变量。

链路总结

整条调试链路涉及四层协作:

VS Code 前端 ├─ settings.json → 控制 JDT LS 行为 └─ launch.json → 传递 sourcePaths 到 DAP 层 │ ▼ LSP 层(JDT LS) ├─ OSGi 容器启动 → 加载 java-debug plugin bundle ├─ Maven 项目导入 → 生成 .classpath └─ 注册 Debug 命令处理器 │ ▼ DAP 层(java-debug) ├─ AttachRequestHandler → 注入 sourcePaths 到上下文 └─ StackTraceRequestHandler.convertDebuggerSourceToClient ├─ ISourceLookUpProvider → JDT 查找源码元素 │ ├─ 项目源码 → file:// URI → 直接返回 │ └─ jar 内 class → jdt:// URI └─ jdt:// URI 时 → resolveSourceFromSourcePaths 回退(需 pre-release 版本) ├─ pre-release:遍历 sourcePaths 找到裸源码 → 返回 file:// └─ release(OSGi 缓存固化):跳过 sourcePaths → 返回 jdt:// │ ▼ JDK/JDI 层 ├─ ThreadReference.frames() → 栈帧列表 ├─ Location.declaringType() → 全限定类名 ├─ ReferenceType.sourceName() → 源文件名 └─ Location.lineNumber() → 行号

sourcePaths 失效的根因不在配置,而在于 VS Code 默认使用的 release 版本落后于 pre-release,缺少 sourcePaths 回退逻辑;OSGi 缓存机制又进一步固化了旧版代码,即使更新扩展也未必加载新版。这类问题的隐蔽之处在于:配置层面一切正常,参考的 pre-release 源码也显示修复已存在,但实际运行的 release 版本并没有这段逻辑,只有深入到运行时字节码层面才能发现参考源码与实际执行代码并非同一份。