如果你在 Debian 或 Ubuntu 上同时装过两个版本的 Java,大概率遇到过这种场面:明明你自己把新版 JDK 的路径加进了 PATH,输入java -version打出来的还是旧版本;或者用 apt 老老实实装好了 OpenJDK 11 和 OpenJDK 17,想切默认版本时只能手动改/usr/bin/java的符号链接,改完发现java和javac对不上,编译直接报错。这种时候,系统其实已经给你准备好了一个标准的“默认版本管理器”,名字就叫update-alternatives。
这篇文章我会从头到尾讲清楚update-alternatives的完整用法,包括它是怎么工作的、核心命令参数怎么理解、Java/Python/编辑器这些常见场景怎么配置,以及我在实际运维中踩过的坑。无论你是刚接触 Linux 的新手,还是要应付多版本开发环境的运维,看完应该都能直接上手用。
1. 为什么需要 update-alternatives:多版本共存的版本管理痛点
先说个最典型的场景。你在一台 Ubuntu 服务器上同时需要 JDK 8 和 JDK 17,JDK 8 跑老项目,JDK 17 跑新服务。你会怎么做?最简单的想法是:把两个 JDK 解压到不同目录,然后在~/.bashrc里改JAVA_HOME和PATH。但这只是单用户级别的临时方案,换个用户登录又变回去了;而且系统里很多服务、脚本依赖的是/usr/bin/java这个全局路径,你改了 PATH 根本不影响它们。
另一个常见做法是直接用ln -sfn强制改/usr/bin/java符号链接指向。这确实简单粗暴,但问题在于:一旦你通过 apt 安装、升级、卸载任何 Java 相关软件包,dpkg 会重建/usr/bin/java这个链接,你手动改的配置立刻被覆盖。到时候新旧版本切换变成了一场拉锯战,排查起来特别痛苦。
update-alternatives解决的正是这个痛点。它是 Debian 系发行版(包括 Ubuntu)里维护符号链接的一套标准机制,相当于给系统的“默认命令”做了一个统一的管理层。软件包安装时不需要直接去动/usr/bin/java,而是把自己注册成某个“候选版本”,系统再去决定最终哪个版本生效。这样一来,软件包之间的竞争被解耦了,切换默认版本变成了一条命令的事情,而且不会因为其他包的操作而悄悄失效。
除了 Java,Python、编辑器、浏览器、编译工具链等只要存在多版本共存的场景,这套机制都能用。它本质上是在回答一个问题:当系统里有多个程序提供同一个命令时,这个命令到底该指向谁?谁说了算?
1.1 update-alternatives 的链路原理
要理解它为什么稳定,你得先看清它管理的符号链接层级。以 Java 为例,实际生效的路径大概是这样一条链:
/usr/bin/java ↓ 符号链接 /etc/alternatives/java ↓ 符号链接 /usr/lib/jvm/java-17-openjdk-amd64/bin/java第一跳/usr/bin/java->/etc/alternatives/java,这个链接是由 update-alternatives 自动维护的,你不需要也不应该手动去改它。第二跳/etc/alternatives/java-> 真正生效的 JDK 路径,这才是由 update-alternatives 切换的。
软件包安装时会把候选版本注册进来,dpkg 并不会去覆盖/usr/bin/java,它只操作/var/lib/dpkg/alternatives/下的数据库文件和/etc/alternatives/下的链接。所以手动改/usr/bin/java的方式很容易被系统重置,而通过 update-alternatives 管理就是系统目前唯一“正规”的方式。
1.2 适用场景与边界在哪里
我用下来感觉比较适用的场景有三类:
- 同一命令的多个版本共存:典型就是 JDK,还有 Python、GCC、Clang 这一类编译器和解释器。
- 同一条命令的不同实现:比如系统里装了多个文本编辑器,
editor这个命令应该默认调用哪一个;又比如装了多个浏览器,x-www-browser应该打开谁。 - 自定义安装的软件接入系统管理:自己编译安装的软件,如果想让系统里的其他命令通过统一方式引用,也可以注册进 alternatives。
它不适合的场景也很明确:如果你需要的是相互隔离、互不干扰的环境,比如每个项目一套完全独立的 Python 环境,那么应该用 pyenv、conda、virtualenv 这类更重度的环境管理工具。update-alternatives管理的是“全局默认命令”这一层,而不是“隔离环境”这一层。两者不冲突,甚至可以配合使用,但你要清楚各自的边界,别指望着用 alternatives 解决环境隔离的问题。
2. 核心细节解析:命令语法与优先级机制
update-alternatives的命令本体不长,核心子命令就五个:--install、--config、--set、--remove、--display,再加上一个--list看数据。别被它的参数吓住,拆开看其实非常清晰。
2.1 --install:把候选版本注册进系统
这是最核心的一条命令,语法如下:
update-alternatives --install <链接路径> <组名> <候选程序绝对路径> <优先级> [--slave <链接> <组名> <绝对路径>]四个必填参数的意思:
<链接路径>:最终要生成的符号链接路径,比如/usr/bin/java。<组名>:这一组候选版本的标识,同一个软件的所有备选版本用同一个组名,比如java。<候选程序绝对路径>:你安装的这个版本实际执行文件在哪里,必须是绝对路径。<优先级>:一个整数,数字越大,在 auto 模式下越优先选择它。
举个例子,把 JDK 17 注册为java的候选版本:
sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-17-openjdk-amd64/bin/java 100这里优先级给 100。为什么是 100?其实没有硬性规定,纯粹是一个习惯用法,主要看你想让它在 auto 模式下的排位如何。如果你希望这个版本默认胜出,优先级就比其他候选给得高;如果只是备选,就调低一点。关键是理解这个数字只影响 auto 模式下的选择,不影响手动切换。
2.2 --slave 与同组联动命令
Java 不只是java一条命令,还有javac、jar、javadoc等一堆配套命令。只切换java不切换javac会出现灾难性后果:java已经是 17 了,javac还在 8,编译用的字节码版本和运行环境对不上。--slave就是用来解决这个问题的,它把一组命令绑定在一起切换。
sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-17-openjdk-amd64/bin/java 100 \ --slave /usr/bin/javac javac /usr/lib/jvm/java-17-openjdk-amd64/bin/javac \ --slave /usr/bin/jar jar /usr/lib/jvm/java-17-openjdk-amd64/bin/jar \ --slave /usr/bin/javadoc javadoc /usr/lib/jvm/java-17-openjdk-amd64/bin/javadoc这样你切java版本时,javac、jar这些会跟着一起切。我在实际维护多套 JDK 环境时,最怕的就是切换后版本不一致,这个参数基本属于必用项。不过要小心:--slave的组名不能和主组名重复,而且同一个路径不能同时作为多个组的主链接或从链接,否则会报错。
2.3 --config、--set、--remove 与模式机制
注册好候选版本后,查看或切换默认版本用--config:
sudo update-alternatives --config java执行后会列出这个组里所有候选版本,让你输入序号选择。这个交互式界面在手动调试时很好用,但是不适合脚本自动化。想静默切换,用--set:
sudo update-alternatives --set java /usr/lib/jvm/java-8-openjdk-amd64/bin/java--set不需要交互,直接指定生效的候选路径,我写自动化脚本时基本只用它。
移除某个候选版本用--remove:
sudo update-alternatives --remove java /usr/lib/jvm/java-8-openjdk-amd64/bin/java注意,--remove如果删的是当前生效的那个版本,系统会自动从剩下的候选里重新选一个,不会让你陷入“没有默认版本”的尴尬。查看当前状态可以用--display:
update-alternatives --display java输出里会显示当前是 auto 模式还是 manual 模式、链接指向哪个路径、有哪些候选版本。--list则只列出所有组名,没有细节。
关于 auto 和 manual 模式,这里有一个我一开始没注意的重要点。当你用--config或--set手动指定过版本之后,这个组就变成 manual 模式了;之后就算有更高优先级的软件包装了进来,也不会自动切换。想让系统重新按照优先级自动选择,用:
sudo update-alternatives --auto java举个真实例子:有一次我装了 OpenJDK 17,系统里原本有 OpenJDK 11,因为我之前手动切过 11,所以装好 17 后什么都没发生。后来排查下,用--auto java切回自动模式,才让优先级发挥作用。这个细节很容易被忽略。
3. 实操过程与核心环节实现:从 Java 到 Python 到自定义程序
讲了半天原理,下面直接上案例。我会以一台 Ubuntu 22.04 服务器为例,完整跑一遍注册、切换、移除的流程。同时把常见坑位标出来。
3.1 场景一:JDK 8 / 11 / 17 三版本切换
假设我已经通过 apt 装好了三个版本的 JDK:
sudo apt update sudo apt install openjdk-8-jdk openjdk-11-jdk openjdk-17-jdk但实际上 Debian 系仓库不一定三个版本都齐全,更接近真实的做法是手动解压每个 JDK 到/usr/local/jdk-8、/usr/local/jdk-11、/usr/local/jdk-17。无论哪种方式,先看当前状态:
java -version ls -l /usr/bin/java如果之前装过某一个 JDK,/usr/bin/java已经存在且指向/etc/alternatives/java,那我们不需要额外创建。如果/usr/bin/java不存在,注册的第一个候选版本会自动先顶上。
把三个版本都注册进来,注意用不同的优先级,我把 17 的优先级调最高,表示希望默认使用新版:
sudo update-alternatives --install /usr/bin/java java /usr/local/jdk-17/bin/java 200 \ --slave /usr/bin/javac javac /usr/local/jdk-17/bin/javac \ --slave /usr/bin/jar jar /usr/local/jdk-17/bin/jar \ --slave /usr/bin/javadoc javadoc /usr/local/jdk-17/bin/javadoc sudo update-alternatives --install /usr/bin/java java /usr/local/jdk-11/bin/java 150 \ --slave /usr/bin/javac javac /usr/local/jdk-11/bin/javac \ --slave /usr/bin/jar jar /usr/local/jdk-11/bin/jar \ --slave /usr/bin/javadoc javadoc /usr/local/jdk-11/bin/javadoc sudo update-alternatives --install /usr/bin/java java /usr/local/jdk-8/bin/java 100 \ --slave /usr/bin/javac javac /usr/local/jdk-8/bin/javac \ --slave /usr/bin/jar jar /usr/local/jdk-8/bin/jar \ --slave /usr/bin/javadoc javadoc /usr/local/jdk-8/bin/javadoc说明一点:如果java、javac这类命令已经被 openjdk 包注册过,上面命令再执行一次也没事,相当于更新候选列表,不会破坏已有配置。
查看当前生效版本:
sudo update-alternatives --display java想手动切到 JDK 11:
sudo update-alternatives --set java /usr/local/jdk-11/bin/java验证一下三条命令是否一致:
java -version javac -version which java which javacwhich java输出/usr/bin/java,然后ls -l /usr/bin/java能看到它指向/etc/alternatives/java,再ls -l /etc/alternatives/java指向/usr/local/jdk-11/bin/java。链路完整,没有断裂,切换成功。这个验证流程也是我在排查问题时的标准动作:一级一级看符号链接指向哪里。
3.2 场景二:Python 版本的切换
在 Ubuntu 20.04 之后系统里默认是 Python 3,但很多老脚本还在用 Python 2。这时也可以用 alternatives 做默认python命令的切换。
注册 Python 3 和 Python 2:
sudo update-alternatives --install /usr/bin/python python /usr/bin/python3 200 sudo update-alternatives --install /usr/bin/python python /usr/bin/python2 150然后用--config切:
sudo update-alternatives --config python执行后界面里会列出两个候选版本,输入序号回车即可。但这里我要多说一句:Python 的版本管理我其实更推荐 pyenv 或 conda,因为 Python 不是只有python一条命令,还有pip、pip3、python-config等一堆配套,而且很多项目依赖的包版本跟解释器版本强相关。用 alternatives 管理 Python 只适合“全局默认 python 指向谁”这种粗粒度需求,细粒度的项目级隔离就别用它了。
实际项目里我见过更稳妥的方案:python由 alternatives 管理,pip则通过python -m pip调用。这样就可以保证你用哪个 python,就会用对应的那个 pip,不会出现 pip 装到了另一个版本去的诡异问题。这个思路也推荐给你:注册python时,别急着给pip单独建一个 alternatives 组,最好让 pip 跟随主 python 走。
3.3 场景三:editor 命令与自定义程序接入
还有一个很实用的例子。很多 Linux 系统里editor是一个通用命令,默认通常指向vim或nano。查看当前编辑器:
sudo update-alternatives --display editor通过--config editor可以直接在 nano、vim、vim.tiny 之间切换,这也是系统安装时给用户选择默认编辑器的底层机制。
如果自己编译安装了一个程序,想让全系统都能直接用,并且以后可切换版本,也可以手动注册。比如我编译安装了某个自定义工具链到/opt/mytool/bin/mytool:
sudo update-alternatives --install /usr/bin/mytool mytool /opt/mytool/bin/mytool 100 \ --slave /usr/bin/mytool-config mytool-config /opt/mytool/bin/mytool-config这样mytool就进入系统标准的命令管理范围了。对运维同学来说,这种做法的好处是:以后卸载软件时,通过update-alternatives --remove mytool /opt/mytool/bin/mytool就能把相关符号链接全部清理干净,不用自己一个个手工找。
3.4 与 RHEL 系 alternatives 的区别
这里补充一个容易被绕晕的点:RHEL、CentOS、Rocky 上也有alternatives命令,而且语法和 Debian 系几乎一样。区别主要在配置存储路径:Debian/Ubuntu 存在/var/lib/dpkg/alternatives/,RHEL 系存在/var/lib/alternatives/。另外 RHEL 系的命令可能是alternatives,需要安装chkconfig或alternatives包。其余像--install、--config、--set这些参数基本通用。如果你两套系统都在维护,学会一套命令另一套基本能直接上手。
4. 常见问题与排查技巧实录
用的时间久了,总会遇到几个反复出现的坑。我整理了一份速查表,基本都是我实际踩过的。
| 现象 | 原因 | 解决方式 |
|---|---|---|
提示alternative path is not absolute | 注册时候选路径没有写绝对路径 | --install里的候选程序路径必须是/开头的绝对路径 |
--config只显示部分候选版本 | 有些包没在 alternatives 注册,或注册时用了不同组名 | 用update-alternatives --list查看所有组名 |
切换以后java -version没变化 | shell 缓存了命令路径,或者 PATH 里有更靠前的同命令目录 | 执行hash -r清缓存;检查echo $PATH中是否有/usr/local/bin之类的优先路径 |
手动改了/usr/bin/java,升级软件后又变回去 | 直接改动绕过了 alternatives 系统 | 用update-alternatives --set重新指定,别手动改符号链接 |
| 新装了更高优先级的包,但默认版本没变 | 该组处于 manual 模式 | 先--auto <组名>切回自动模式 |
| 删除候选后没有默认版本 | 理论上不会发生,但链接可能已损坏 | 用--install重新注册,或检查/etc/alternatives/下对应链接 |
4.1 注册时路径写错,怎么修复
有一次我注册 JDK 时把路径写成了/usr/local/jdk-11/bin/java,但实际上该目录里没有 java 可执行文件。命令执行不报错,但后面一旦真正切换就会出现符号链接断裂。
排查方法很简单:
ls -l /etc/alternatives/java如果指向一个不存在的文件,直接重新注册正确的路径覆盖掉即可:
sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-11-openjdk-amd64/bin/java 100这里有个坑:系统不会校验候选程序是否真的存在。所以注册前最好先手动执行一下候选路径确认有可执行权限:
/usr/lib/jvm/java-11-openjdk-amd64/bin/java -version确认能跑再注册,能省掉不少后续排查时间。
4.2 切换后命令版本不一致的排查思路
如果你切换完java后,javac还是旧版本,第一反应要检查注册时有没有配--slave。很多人只注册了主命令,没把配套命令绑进去,导致它们各自独立切换。
检查方法:
update-alternatives --display java输出里会列出跟随主命令切换的 slave 链接。如果发现从命令不在列表里,说明当时没有用--slave注册。解决办法是把全套命令重新注册一遍,或者单独把javac也注册成一个独立的 alternatives 组。但注意,独立组的切换跟java组是分开的,以后要切就得多排一条命令,不如重新注册成 slave 干净。
4.3 alternatives 被破坏后的手动修复
极端情况下,比如误删了/etc/alternatives下的文件,或者 dpkg 中途异常中断,就会出现update-alternatives --config java提示“没有可用的候选版本”这种情况。
我的修复步骤是:
# 先查看数据库里还有没有这个组的记录 ls /var/lib/dpkg/alternatives/ # 如果有记录,尝试强制重新触发 sudo update-alternatives --remove java /usr/local/jdk-17/bin/java sudo update-alternatives --install /usr/bin/java java /usr/local/jdk-17/bin/java 200如果数据库文件本身也损坏,最粗暴但有效的办法是从备份恢复/var/lib/dpkg/alternatives/,或者干脆把所有候选版本重新注册一遍。重启后一般就恢复正常了。
4.4 容器和脚本里的非交互式切换
在 Dockerfile 或 CI 脚本里,--config这种交互式命令完全没法用。我通常会用--set配合绝对路径来固定版本:
RUN update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java但如果脚本执行的用户没有 root 权限,或者 /usr/bin 目录是只读的,切换会直接失败。尽量避免在只读根文件系统的容器里依赖 alternatives 切换,最好在构建镜像时就把默认版本写死。
另外,自动化脚本里如果判断当前默认版本是否符合预期,可以用这条命令:
readlink -f /usr/bin/java | grep -q "java-17"readlink -f会把符号链接一路解析到最终真实路径,比只看一层/etc/alternatives/java可靠得多。
5. 写在最后:一套命令解决全系统默认版本的治理
我在多台服务器上维护过好几套 Java、Python 和编译工具链混合的环境,update-alternatives算不上花哨,但它把“全局默认命令”这件事系统化了。以前我靠改 PATH、改符号链接管理多版本,每次升级软件包都会惊出一身冷汗,现在全部走--install注册、--set切换、--display查询,链路一眼看穿,出问题也容易回滚。
最后分享一个我个人的使用习惯:注册任何候选版本时,永远把配套命令用--slave绑好。哪怕当时觉得某个配套命令用不上,也绑上,免得以后切换主版本时留下一个半新半旧的环境。这个习惯救过我很多次,建议你从一开始就养成。