一、问题的前因后果
如果你是 Java 后端开发,日常用 Maven 管理项目,手头维护或参与的项目又比较多,大概率会遇到这样的情况:某天打开磁盘占用分析工具,发现 ~/.m2/repository 这个文件夹已经悄悄涨到了几十 GB。
原因也很好理解:
- 每个项目的依赖版本可能不一样,Maven 不会自动清理旧版本,只会不断"新增";
- 项目迭代过程中依赖升级(比如 SpringBoot 从 2.x 升到 3.x),旧版本的一堆 jar 包永远留在仓库里;
- 参与过的临时项目、POC 项目、已经归档不再维护的项目,它们当初拉下来的依赖也从未被清理过;
- 很多依赖还会连带一堆传递依赖(transitive dependencies),一个版本升级可能牵连出几十个 jar。
于是仓库越滚越大,但你其实并不知道里面哪些还在用、哪些早就是"僵尸文件"。
二、直觉方案 & 踩坑记录
在真正解决问题之前,我尝试过几种"直觉上应该可行"的方案,这里记录一下踩过的坑,避免大家走弯路。
坑 1:整个仓库删掉重新下载
最简单粗暴的想法是把 ~/.m2/repository 整个移走,让 Maven 重新构建时自动下载。
mv ~/.m2/repository ~/.m2/repository.bak
这个方法在"所有项目都能顺畅访问远程仓库"的前提下没问题,但如果你有项目依赖来自:
- 已经下线或无法访问的内网私服;
- 版本已经被覆盖或从远程仓库撤下的 jar(这种情况比想象中常见);
那么删除后重新构建就会直接失败,而且很难第一时间定位是"网络问题"还是"依赖真的没了"。这个方法本质上是拿"能不能正常构建"做赌注,风险偏大,不建议在依赖来源复杂的团队环境里用。
坑 2:按文件访问时间(atime)清理
第二个直觉方案是按"最后访问时间"清理,思路是:很久没被访问过的 jar,大概率就是没人用了。
find ~/.m2/repository -name "*.jar" -atime +180
这里踩了一个坑:macOS 的文件系统(APFS)默认的 atime 更新策略并不精确,很多情况下走的是 relatime 语义,只有在明显早于修改时间时才会更新访问时间。也就是说,即便你上周刚编译过某个用到某个 jar 的项目,它的 atime 也可能显示很久以前,导致误判。
结论:atime 在 Mac 上不可靠,这个方案基本作废,除非你能确认自己的文件系统精确记录访问时间。
坑 3:mvn dependency:purge-local-repository
Maven 自带一个 dependency:purge-local-repository 目标,可以清理"当前项目"用到的依赖缓存:
mvn dependency:purge-local-repository -DreResolve=false
问题在于:它只能针对单个项目当前用到的依赖生效,对于"清理其他项目遗留下来的旧版本"完全无能为力。如果你手头有几十个项目,逐个跑一遍不仅低效,而且它解决的根本不是"仓库里到底哪些版本没人用"这个问题。
三、真正有效的思路:交叉比对"实际在用的依赖" vs "仓库里存在的依赖"
踩完前面几个坑后,思路逐渐清晰:真正安全的清理方式,是先把手头所有项目"实际用到的依赖版本"枚举出来,再和本地仓库里"实际存在的依赖版本"做差集,差集里的内容才是真正可以放心删除的"僵尸依赖"。
具体拆解成三步:
- 找到所有项目的根目录:项目通常放在一个统一的代码目录下,但目录结构可能不规整,有的项目直接在顶层(
<git-root>/project-a/pom.xml),有的在分组子目录下(<git-root>/group-name/project-b/pom.xml)。所以不能简单假设固定的目录深度,需要用"最外层含pom.xml的目录"作为项目根这个规则来自动识别,这样多模块项目的子模块 pom 会被自动跳过(因为已经嵌套在根目录下面,跑一次 reactor 构建就能覆盖到所有子模块)。 - 对每个项目根跑
mvn dependency:list,把解析出来的groupId:artifactId:version汇总去重,得到"全公司/全部门实际在用的依赖版本全集"。 - 遍历本地仓库目录结构(
groupId对应多层目录、下面是artifactId、再下面是version),列出仓库里实际存在的所有版本目录,与第 2 步的集合做差集,差集即为候选删除项。
这个思路的好处是:不依赖不精确的文件系统元数据(如 atime),也不用赌网络能否重新下载,完全基于"当前所有项目真实需要什么"来判断,安全性高很多。
四、踩坑提醒:这个方案本身也有需要注意的地方
- 有些项目可能编译失败(比如临时切换了 JDK 版本、依赖了无法访问的私服、pom 本身有问题等),这类项目的依赖扫描会失败。一定要单独记录这些失败的项目,不能把"扫描失败"等同于"没有依赖",否则会把它们真实在用的依赖误判为"未使用"从而被清理掉,这是最容易埋雷的地方。
- 多模块项目(parent pom + 多个子模块)要在根目录跑一次完整的 reactor 构建,而不是逐个子模块目录单独跑,否则子模块引用父 pom 的相对路径可能解析不到。
- 建议默认只生成"待删除清单 + 删除脚本",人工确认后再手动执行删除,不要一上来就自动删除,给自己留一道复查的机会。
五、示例代码:项目依赖扫描 + 仓库比对脚本
下面是一个可以直接使用的 Python 脚本(无需额外依赖,Python 3 自带库即可运行),思路完全按照上面第三节展开:
#!/usr/bin/env python3
"""
scan_and_clean_m2.py
用途:
扫描指定目录下所有 Maven 项目(自动识别项目根目录,支持多层子文件夹结构),
收集这些项目实际用到的依赖 (groupId:artifactId:version),
然后与本地 ~/.m2/repository 中实际存在的依赖版本做比对,
找出"仓库里有,但没有任何项目在用"的版本,生成一份可审查的删除清单。
用法:
python3 scan_and_clean_m2.py --repos-root /path/to/your/projects
产出(写在当前目录下的 m2_clean_report/ 文件夹里):
- used_artifacts.txt 所有项目实际使用到的 groupId:artifactId:version 清单
- failed_projects.txt mvn 扫描失败的项目列表(请人工检查,不会被当成"未使用")
- unused_candidates.txt 仓库里存在但没有项目在用的版本,及各自占用空间
- delete_candidates.sh 对应的删除脚本(默认不会自动执行,需要你自己运行)
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
MARKER_FILE = ".mvn-deps-scan.txt"
def find_project_roots(repos_root: Path):
"""
找出所有"项目根目录":即包含 pom.xml,且没有任何上层目录
(在 repos_root 范围内)也包含 pom.xml 的目录。
这样多模块项目只会在最外层根目录被识别一次。
"""
all_pom_dirs = []
for dirpath, dirnames, filenames in os.walk(repos_root):
dirnames[:] = [d for d in dirnames if d not in (
"target", ".git", "node_modules", ".idea")]
if "pom.xml" in filenames:
all_pom_dirs.append(Path(dirpath))
all_pom_dirs.sort(key=lambda p: len(str(p)))
roots = []
for d in all_pom_dirs:
is_nested = False
for r in roots:
try:
d.relative_to(r)
is_nested = True
break
except ValueError:
continue
if not is_nested:
roots.append(d)
return roots
def run_mvn_scan(project_root: Path, timeout_sec: int):
"""在项目根目录跑 mvn dependency:list,结果写到标记文件中"""
cmd = [
"mvn", "-q", "-B",
"dependency:list",
f"-DoutputFile={MARKER_FILE}",
"-DincludeScope=test",
]
try:
result = subprocess.run(
cmd, cwd=str(project_root),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout_sec, text=True,
)
if result.returncode != 0:
return False, result.stdout
return True, ""
except subprocess.TimeoutExpired:
return False, f"超时(>{timeout_sec}秒)"
except FileNotFoundError:
return False, "找不到 mvn 命令,请确认 Maven 已安装并在 PATH 中"
def collect_marker_files(project_root: Path):
found = []
for dirpath, dirnames, filenames in os.walk(project_root):
if MARKER_FILE in filenames:
found.append(Path(dirpath) / MARKER_FILE)
return found
def parse_deps_file(path: Path):
"""
解析 mvn dependency:list 生成的文件,每行形如:
groupId:artifactId:packaging:version:scope
或带 classifier:
groupId:artifactId:packaging:classifier:version:scope
"""
triples = set()
if not path.exists():
return triples
for line in path.read_text(errors="ignore").splitlines():
line = line.strip()
if not line or ":" not in line:
continue
parts = line.split(":")
if len(parts) == 5:
g, a, _packaging, v, _scope = parts
elif len(parts) == 6:
g, a, _packaging, _classifier, v, _scope = parts
else:
continue
triples.add((g.strip(), a.strip(), v.strip()))
return triples
def cleanup_marker_files(files):
for f in files:
try:
f.unlink()
except OSError:
pass
def scan_all_projects(repos_root: Path, timeout_sec: int):
roots = find_project_roots(repos_root)
print(f"发现 {len(roots)} 个项目根目录")
used = set()
failed = []
for i, root in enumerate(roots, 1):
print(f"[{i}/{len(roots)}] 扫描 {root} ...")
ok, msg = run_mvn_scan(root, timeout_sec)
if not ok:
print(f" 失败:{msg[-300:] if msg else '未知错误'}")
failed.append(str(root))
markers = collect_marker_files(root)
for m in markers:
used |= parse_deps_file(m)
cleanup_marker_files(markers)
return used, failed
def index_local_repo(m2_repo: Path):
"""遍历 ~/.m2/repository,找出所有 (groupId, artifactId, version) 三元组及对应目录"""
triples = {}
for dirpath, dirnames, filenames in os.walk(m2_repo):
has_artifact_file = any(
f.endswith((".jar", ".pom", ".war", ".aar")) for f in filenames
)
if not has_artifact_file:
continue
version_dir = Path(dirpath)
artifact_dir = version_dir.parent
group_dir = artifact_dir.parent
try:
rel = group_dir.relative_to(m2_repo)
except ValueError:
continue
group_id = ".".join(rel.parts)
artifact_id = artifact_dir.name
version = version_dir.name
if not group_id or not artifact_id or not version:
continue
triples[(group_id, artifact_id, version)] = version_dir
return triples
def dir_size(path: Path) -> int:
total = 0
for dirpath, _dirnames, filenames in os.walk(path):
for f in filenames:
fp = Path(dirpath) / f
try:
total += fp.stat().st_size
except OSError:
pass
return total
def human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}PB"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repos-root", required=True,
help="存放所有项目的根目录")
parser.add_argument("--m2-repo", default=str(Path.home() / ".m2" / "repository"))
parser.add_argument("--skip-mvn", action="store_true")
parser.add_argument("--timeout", type=int, default=300)
parser.add_argument("--execute", action="store_true",
help="真正执行删除(默认只生成报告,不删除任何文件)")
args = parser.parse_args()
repos_root = Path(args.repos_root).expanduser().resolve()
m2_repo = Path(args.m2_repo).expanduser().resolve()
if not repos_root.is_dir():
sys.exit(f"错误:项目根目录不存在:{repos_root}")
if not m2_repo.is_dir():
sys.exit(f"错误:.m2 仓库目录不存在:{m2_repo}")
out_dir = Path.cwd() / "m2_clean_report"
out_dir.mkdir(exist_ok=True)
used_file = out_dir / "used_artifacts.txt"
failed_file = out_dir / "failed_projects.txt"
if args.skip_mvn and used_file.exists():
used = set()
for line in used_file.read_text().splitlines():
parts = line.strip().split(":")
if len(parts) == 3:
used.add(tuple(parts))
failed = failed_file.read_text().splitlines() if failed_file.exists() else []
else:
used, failed = scan_all_projects(repos_root, args.timeout)
used_file.write_text(
"\n".join(f"{g}:{a}:{v}" for g, a, v in sorted(used)) + "\n"
)
failed_file.write_text("\n".join(failed) + "\n")
print(f"共收集到 {len(used)} 个被使用的依赖版本")
if failed:
print(f"⚠️ 有 {len(failed)} 个项目扫描失败,请人工检查 {failed_file}")
repo_triples = index_local_repo(m2_repo)
print(f"本地仓库中共有 {len(repo_triples)} 个版本目录")
unused = {t: p for t, p in repo_triples.items() if t not in used}
candidates_file = out_dir / "unused_candidates.txt"
delete_script = out_dir / "delete_candidates.sh"
total_size = 0
lines = []
delete_lines = ["#!/bin/bash", "# 请先人工检查本文件内容,再执行!", "set -e", ""]
for (g, a, v), p in sorted(unused.items()):
size = dir_size(p)
total_size += size
lines.append(f"{human_size(size):>10} {g}:{a}:{v} -> {p}")
delete_lines.append(f'rm -rf "{p}"')
candidates_file.write_text("\n".join(lines) + "\n")
delete_script.write_text("\n".join(delete_lines) + "\n")
os.chmod(delete_script, 0o755)
print(f"发现 {len(unused)} 个未被使用的版本目录,合计占用空间:{human_size(total_size)}")
print(f"清单:{candidates_file}")
print(f"删除脚本:{delete_script}(默认不会自动执行)")
if args.execute:
for (g, a, v), p in unused.items():
subprocess.run(["rm", "-rf", str(p)], check=False)
print("删除完成。")
else:
print("本次为 dry-run,未删除任何文件,确认无误后自行运行删除脚本。")
if __name__ == "__main__":
main()
使用步骤
# 1. 进入一个用来存放报告的目录(不需要放在项目目录里) cd ~/wherever-you-like # 2. 执行扫描(把路径换成你实际存放项目代码的根目录) python3 scan_and_clean_m2.py --repos-root /path/to/your/projects # 3. 查看生成的报告 cat m2_clean_report/unused_candidates.txt cat m2_clean_report/failed_projects.txt # 一定要看这个,人工确认失败原因 # 4. 确认无误后,手动执行删除 bash m2_clean_report/delete_candidates.sh
六、实际效果
用这套方法跑下来,成功识别出了大量"仓库里存在、但当前所有项目都没有用到"的旧版本依赖目录,清理后释放出了相当可观的磁盘空间,而且因为整个判断逻辑是基于"当前项目真实需要什么"来做差集,不依赖不准确的文件访问时间,也不需要靠删了重下这种赌网络的方式,清理过程比较安心。
七、总结 & 建议
- 不要依赖文件访问时间(atime)来判断依赖是否还在用,尤其是 macOS 上默认的文件系统语义并不精确。
- "删了重新下载"看似简单,但对依赖来源复杂(私服、内网、版本下线)的团队风险较高,不建议作为首选方案。
- 最稳妥的思路是"实际在用的依赖" vs "仓库里存在的依赖"做差集,谁在用什么、仓库里有什么,两边一比对,差集才是真正能安全删除的部分。
- 扫描失败的项目一定要单独记录、人工复核,不能把"扫描不到"等同于"没有依赖",这是最容易埋雷、导致误删的地方。
- 清理动作建议保留"先生成清单,人工确认后再执行"的习惯,给自己留复查空间,尤其是在团队共享的开发机或者 CI 构建机上操作时更要谨慎。
希望这篇记录能帮到同样在为本地 Maven 仓库瘦身的你。如果你有更好的清理思路或者踩过其他坑,欢迎交流。
文章评论