Java 基础体系 · 第 49/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 国际化:Locale、ResourceBundle、日期数字和消息格式
国际化(internationalization,常写作 i18n)是让程序能够根据用户的语言、地区、文字系统、数字习惯、日期习惯和货币习惯展示内容,而不必为每一种语言复制一套程序逻辑。Java 标准库把这项能力拆成几个相互配合、但职责不同的组件:
Locale:描述“面向谁、采用哪种区域规则”;ResourceBundle:根据Locale选择本地化资源;java.time与DateTimeFormatter:格式化日期、时间和时区;NumberFormat:格式化数字、百分比、货币和紧凑数字;MessageFormat:把本地化文本与参数组合起来。
它们共同解决的是展示问题,不是把任意字符串“翻译”成另一种语言。日期和数字的本质值仍应以类型化数据保存,语言文本也应与程序逻辑分离。
一、先区分语言、地区、时区和编码
国际化中最容易出现的错误,是把几个不同概念混成一个“语言设置”。
1. Locale 不等于时区
Locale 主要描述:
- 语言,例如
zh、en、fr; - 地区,例如
CN、TW、US; - 文字系统,例如
Hans、Hant; - 可选的变体和扩展。
时区描述的是某个时间点如何转换为本地民用时间,例如:
Asia/ShanghaiAmerica/New_YorkEurope/Paris
同一个 Locale.US 的用户可能身处纽约、洛杉矶或东京。反过来,身处东京的用户可能选择英文界面。
因此,展示一个时间通常至少需要两个信息:
Locale -> 月份名称、星期名称、日期顺序、数字符号
ZoneId -> Instant 转换成哪个当地时间
不能用 Locale 推断时区,也不能用时区推断语言。
2. Locale 不等于字符编码
字符编码决定字节和字符如何转换,例如 UTF-8;Locale 决定语言和区域规则。例如:
UTF-8 + Locale.CHINA
表示“用 UTF-8 传输或存储字符,并按照中国地区的语言和格式规则展示”。UTF-8 本身不会决定日期写成什么样,也不会把英文翻译成中文。
Java 源文件、类路径资源、HTTP 响应和数据库连接各自都有编码边界。ResourceBundle 的 .properties 资源在现代 Java 中默认按 UTF-8 读取,但这不意味着所有外部输入输出都会自动采用 UTF-8。
二、Locale:区域规则的描述值
1. Locale 的组成
以下几个 Locale 的含义不同:
Locale zh = Locale.forLanguageTag("zh");
Locale zhCn = Locale.forLanguageTag("zh-CN");
Locale zhTw = Locale.forLanguageTag("zh-TW");
Locale zhHans = Locale.forLanguageTag("zh-Hans");
Locale zhHant = Locale.forLanguageTag("zh-Hant");
zh只有语言,未指定地区或文字;zh-CN指中文和中国地区;zh-TW指中文和中国台湾地区;zh-Hans指简体中文文字系统;zh-Hant指繁体中文文字系统。
地区和文字系统并不完全等价。zh-CN 通常会得到简体中文习惯,但“地区”和“文字系统”在模型上是两个独立字段。
可以检查各部分:
Locale locale = Locale.forLanguageTag("zh-Hant-TW");
System.out.println(locale.getLanguage()); // zh
System.out.println(locale.getScript()); // Hant
System.out.println(locale.getCountry()); // TW
System.out.println(locale.toLanguageTag()); // zh-Hant-TW
对于来自 HTTP Accept-Language、浏览器设置或用户配置的语言标签,优先使用:
Locale locale = Locale.forLanguageTag("en-GB");
而不是手工拆分字符串后调用构造器。旧式构造器仍然可用:
Locale locale = new Locale("en", "GB");
但它不能完整表达 BCP 47 语言标签中的脚本和扩展。需要逐步构建复杂标签时,可以使用 Locale.Builder:
Locale locale = new Locale.Builder()
.setLanguage("zh")
.setScript("Hans")
.setRegion("CN")
.build();
2. Locale.ROOT 与默认 Locale
Locale.ROOT 是一个不带语言和地区的中性 Locale。它适合表示协议、机器数据和与用户语言无关的规则,例如:
String normalized = input.toLowerCase(Locale.ROOT);
不能用默认 Locale 做机器协议的大小写转换。土耳其语就是典型反例:
"TITLE".toLowerCase(Locale.ENGLISH); // "title"
"TITLE".toLowerCase(new Locale("tr")); // 可能得到 "tıtle",首字母是无点小写 ı
默认 Locale 是进程或线程环境中的隐含状态。可以查询和设置:
Locale defaultLocale = Locale.getDefault();
Locale.setDefault(Locale.US);
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
Locale.setDefault(Locale.Category.DISPLAY, Locale.CHINA);
Java 将默认 Locale 分成两个类别:
FORMAT:日期、数字、货币等格式化;DISPLAY:显示语言名称、国家名称等元数据。
例如:
Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
Locale displayLocale = Locale.getDefault(Locale.Category.DISPLAY);
Locale.setDefault 改变全局默认状态,会影响未显式传入 Locale 的后续操作。服务器应用不应把它当作每个请求的用户语言开关,否则一个请求可能改变另一个请求看到的格式。
3. Locale 是值对象,但不是“翻译结果”
Locale 本身是不可变值对象。它只回答“采用什么区域规则”,不包含:
- 文案资源;
- 用户时区;
- 用户偏好的货币余额;
- 翻译服务;
- 某个具体字符串的翻译结果。
完整的格式化输入通常可以抽象为:
展示结果 = Format(数据值, Locale, ZoneId, 资源)
其中:
数据值是LocalDate、Instant、BigDecimal等类型;Locale提供语言和区域规则;ZoneId负责时间点到当地时间的转换;资源提供固定文案和消息模板。
三、ResourceBundle:按 Locale 查找本地化资源
1. 资源文件的组织方式
假设基础名称为:
i18n.Messages
资源文件可以组织为:
src/
└── main/
├── java/
│ └── demo/Main.java
└── resources/
└── i18n/
├── Messages.properties
├── Messages_zh.properties
├── Messages_zh_CN.properties
└── Messages_en_GB.properties
基础名称使用 Java 包名和类名,不带 .properties:
ResourceBundle bundle =
ResourceBundle.getBundle("i18n.Messages", Locale.forLanguageTag("zh-CN"));
资源内容示例:
# Messages.properties
welcome=Welcome, {0}!
itemCount={0,number} items
balance=Balance: {0,number,currency}
# Messages_zh.properties
welcome=你好,{0}!
itemCount=共 {0,number} 项
balance=余额:{0,number,currency}
# Messages_zh_CN.properties
welcome=你好,{0}!
itemCount=共 {0,number} 项
balance=余额:{0,number,currency}
# Messages_en_GB.properties
welcome=Hello, {0}!
itemCount={0,number} items
balance=Balance: {0,number,currency}
在 Java 9 及之后,基于 .properties 的 PropertyResourceBundle 默认使用 UTF-8 读取资源,因此上面的中文文件可以直接保存为 UTF-8。实际项目仍应检查构建工具、编辑器和打包过程,避免资源在进入 JAR 前被转换成错误编码。
2. 候选资源与回退
调用:
ResourceBundle.getBundle("i18n.Messages", Locale.forLanguageTag("zh-CN"));
时,Java 会根据请求 Locale 生成候选资源,并尝试查找更具体到更通用的版本。典型路径是:
Messages_zh_CN.properties
Messages_zh.properties
Messages.properties
因此:
Messages_zh_CN.properties可以覆盖中国地区特有的文案;Messages_zh.properties可以作为所有中文地区的通用资源;Messages.properties是最终的根资源。
这个过程的核心不是“把 zh-CN 字符串拼到文件名上”,而是由 ResourceBundle 的候选 Locale 和父级 Bundle 规则共同完成。
如果请求 en-GB,但只有 Messages.properties,则程序可以使用根资源。如果配置了默认 Locale,资源查找还可能经过默认 Locale 的回退路径。需要严格控制回退时,可以使用 ResourceBundle.Control 的自定义策略;但模块化应用和命名模块对自定义加载策略有额外限制,不能简单假设所有类路径时代码都能原样迁移。
3. 缺失键和缺失 Bundle 是两类故障
以下代码:
String text = bundle.getString("welcome");
可能出现两种不同问题:
- 没有任何候选 Bundle 可加载,抛出
MissingResourceException; - Bundle 已加载,但没有
welcome键,读取该键时抛出MissingResourceException。
这两者的修复路径不同:
- Bundle 缺失:检查基础名称、包路径、构建产物和类路径;
- 键缺失:检查资源文件中的键、拼写和回退资源是否完整。
可以在启动阶段验证必需键:
static void requireKeys(ResourceBundle bundle, String... keys) {
for (String key : keys) {
if (!bundle.containsKey(key)) {
throw new IllegalStateException(
"Missing i18n key: " + key + " in " + bundle.getBaseBundleName());
}
}
}
生产系统不应把资源键缺失静默变成空字符串。空字符串会让错误延迟到用户界面,诊断成本更高。
4. ResourceBundle 的缓存和生命周期
ResourceBundle 会缓存加载结果。缓存键与基础名称、Locale、类加载器等信息有关,目的是避免每次请求都读取资源文件。
这意味着:
- 修改磁盘上的
.properties文件,不一定会立即影响已经运行的进程; - 热部署或插件卸载时,类加载器和 Bundle 缓存需要一并考虑;
- 测试中需要重新加载时,可以调用:
ResourceBundle.clearCache();
或者清理指定类加载器的缓存。
ResourceBundle 的缓存行为不等于“资源文件永远不变”。具体重新加载时机还受缓存策略和 ResourceBundle.Control 影响。默认资源适合随应用版本发布,而不适合直接当作实时配置中心。
5. ResourceBundle、Formatter 和 MessageFormat 的关系
它们的职责链可以表示为:
flowchart LR
A[用户 Locale] --> B[ResourceBundle]
B --> C[消息模板]
A --> D[NumberFormat / DateTimeFormatter]
E[类型化参数] --> D
C --> F[MessageFormat]
D --> F
E --> F
F --> G[最终展示文本]
关键路径是:
Locale选择资源 Bundle;- Bundle 返回带占位符的消息模板;
MessageFormat解析模板;- 参数中的数字或
java.util.Date使用消息的 Locale 格式化; - 日期时间若使用
java.time,通常先由DateTimeFormatter格式化,再作为字符串传给消息模板。
四、日期和时间:值、时区、格式必须分开
1. java.time 类型表达不同语义
Java 8 引入的 java.time 是现代日期时间 API,Java 25 继续使用这一模型。常见类型的语义不同:
| 类型 | 表示 |
|---|---|
LocalDate |
没有时间和时区的日期 |
LocalTime |
没有日期和时区的时间 |
LocalDateTime |
没有时区的日期时间 |
Instant |
UTC 时间线上的一个瞬时点 |
ZonedDateTime |
某个时区中的日期时间 |
OffsetDateTime |
带固定 UTC 偏移量的日期时间 |
例如:
Instant instant = Instant.parse("2025-09-01T12:00:00Z");
ZonedDateTime shanghai =
instant.atZone(ZoneId.of("Asia/Shanghai"));
ZonedDateTime newYork =
instant.atZone(ZoneId.of("America/New_York"));
两个结果代表同一个瞬时点,但当地日期和时间可能不同。若直接拿 Instant 做“当地日期”展示,必须先指定 ZoneId;若只有 LocalDateTime,则它本身无法说明对应哪个瞬时点。
2. 本地化日期格式
DateTimeFormatter 可以按 Locale 选择本地化日期样式:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
LocalDate date = LocalDate.of(2025, 9, 1);
DateTimeFormatter formatter = DateTimeFormatter
.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.CHINA);
System.out.println(formatter.format(date));
FormatStyle 有:
FULLLONGMEDIUMSHORT
具体输出由 JDK 的 Locale 数据决定。不能把某个 JDK 版本、操作系统或 Locale Provider 下的样式字符串当作跨版本永久保证。比如 FULL 通常包含星期名称,但具体标点和顺序属于区域格式数据的一部分。
日期和 Locale 是在格式化时结合的:
DateTimeFormatter formatter = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.FULL)
.withLocale(Locale.US);
String text = formatter.format(
instant.atZone(ZoneId.of("America/New_York")));
这里的格式器需要能够访问日期、时间和时区信息,因此传入 ZonedDateTime 比传入 LocalDate 更合适。
3. 固定协议格式与用户展示格式不能混用
用户展示:
DateTimeFormatter display =
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.GERMANY);
机器协议:
DateTimeFormatter protocol =
DateTimeFormatter.ISO_LOCAL_DATE;
String wireValue = protocol.format(LocalDate.of(2025, 9, 1));
// 2025-09-01
协议格式需要稳定、可解析、与用户 Locale 无关;用户展示需要根据 Locale 改变。把 09/01/2025 直接写入数据库或接口,会产生“1 月 9 日还是 9 月 1 日”的歧义。
4. Calendar 和 DateFormat 的历史边界
旧 API 仍然存在:
DateFormat format =
DateFormat.getDateInstance(DateFormat.LONG, Locale.US);
String text = format.format(new Date());
DateFormat 是可变对象,通常不是线程安全的。Calendar 也有复杂的可变状态和旧式字段语义。新代码一般应使用:
Instant表示时间线瞬时点;LocalDate表示业务日期;ZonedDateTime表示带时区的日期时间;DateTimeFormatter负责格式化。
java.util.Date 与 java.time 仍可互操作:
Date legacy = Date.from(instant);
Instant modern = legacy.toInstant();
MessageFormat 的日期参数主要基于 java.util.Date 和相关旧式格式类型,因此当消息模板直接格式化日期时,经常需要把 Instant 转成 Date,或者先使用 DateTimeFormatter 生成字符串。
五、数字、百分比和货币格式
1. NumberFormat 的职责
NumberFormat 根据 Locale 格式化数字:
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.Locale;
BigDecimal amount = new BigDecimal("1234567.89");
NumberFormat number =
NumberFormat.getNumberInstance(Locale.GERMANY);
NumberFormat currency =
NumberFormat.getCurrencyInstance(Locale.US);
NumberFormat percent =
NumberFormat.getPercentInstance(Locale.CHINA);
System.out.println(number.format(amount));
System.out.println(currency.format(amount));
System.out.println(percent.format(0.256));
典型结果可能类似:
1.234.567,89
$1,234,567.89
26%
但货币符号、空格、负号、分组符号和小数位都由 Locale 及货币规则决定,实际输出应以当前 JDK 的区域数据为准。
百分比有一个重要的数值语义:
format(0.256) -> 26%
百分比格式通常把数值乘以 100 后再显示。因此传入 26 往往得到 2,600%,不能把已经乘过 100 的展示值再次传入。
2. 货币 Locale 不一定等于业务货币
NumberFormat currency =
NumberFormat.getCurrencyInstance(Locale.CHINA);
通常会使用中国地区关联的人民币货币,但这表达的是“按中国地区的货币展示规则格式化”,不一定表达订单的真实结算货币。
如果订单明确是 EUR,应显式设置:
NumberFormat currency =
NumberFormat.getCurrencyInstance(Locale.US);
currency.setCurrency(Currency.getInstance("EUR"));
System.out.println(currency.format(new BigDecimal("1234.50")));
这里有两个独立决定:
Currency:金额属于哪种货币;Locale:货币符号、位置、分组和小数展示习惯如何表示。
不能仅根据用户 Locale 推断数据库金额的货币。
3. 精度和类型选择
double 是二进制浮点数,不适合作为财务金额的精确存储类型:
System.out.println(0.1 + 0.2);
// 不是精确的十进制 0.3
金额计算通常使用 BigDecimal,并从字符串构造:
BigDecimal amount = new BigDecimal("0.10");
格式化不会修复前面的计算误差。NumberFormat 只负责把已有数值转换成展示文本;舍入规则应在业务计算或明确的展示边界中定义。
4. DecimalFormat 的区域化和可变性
可以基于 Locale 创建更具体的数字格式:
NumberFormat format =
NumberFormat.getNumberInstance(Locale.US);
format.setMinimumFractionDigits(2);
format.setMaximumFractionDigits(2);
System.out.println(format.format(new BigDecimal("12.5")));
// 12.50
DecimalFormat 和 NumberFormat 通常是可变的、非线程安全的。不要把同一个实例无保护地放入单例服务中并发使用。可以:
- 每次创建;
- 使用
ThreadLocal; - 在不共享实例的作用域内使用;
- 或改用不可变、线程安全的
DateTimeFormatter处理日期。
5. 紧凑数字格式
Java 还提供紧凑数字格式:
NumberFormat compact =
NumberFormat.getCompactNumberInstance(
Locale.US,
NumberFormat.Style.SHORT);
System.out.println(compact.format(1_200_000));
// 1.2M(具体细节依 Locale 数据而定)
紧凑格式适合用户界面中的“1.2M”一类展示,不适合账单、审计日志或需要精确阅读的业务数据,因为它可能丢失部分数量信息。
六、MessageFormat:把模板和参数组合起来
1. 基本模式
MessageFormat 使用带编号的参数:
String pattern = "Hello, {0}! You have {1,number} messages.";
MessageFormat messageFormat =
new MessageFormat(pattern, Locale.US);
String result = messageFormat.format(
new Object[] {"Alice", 1234});
System.out.println(result);
输出可能是:
Hello, Alice! You have 1,234 messages.
模式中的 {0} 表示第一个参数,{1,number} 表示第二个参数按数字格式化。参数编号可以重复:
welcome=用户 {0} 于 {1} 登录,当前余额为 {2,number,currency}。
对应调用:
MessageFormat mf = new MessageFormat(
bundle.getString("welcome"),
Locale.CHINA);
String text = mf.format(new Object[] {
"张三",
"2025-09-01",
new BigDecimal("1234.50")
});
2. 日期参数的类型边界
经典 MessageFormat 的 date 和 time 子格式主要面向 java.util.Date:
Date date = Date.from(
Instant.parse("2025-09-01T12:00:00Z"));
MessageFormat mf = new MessageFormat(
"Time: {0,date,full} {0,time,short}",
Locale.US);
System.out.println(mf.format(new Object[] {date}));
如果直接传入 LocalDate 或 Instant,不能假设 MessageFormat 会自动理解其时区和格式语义。更清晰的做法是先使用 java.time 确定时区并格式化:
Instant instant = Instant.parse("2025-09-01T12:00:00Z");
String dateText = DateTimeFormatter
.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.CHINA)
.format(instant.atZone(ZoneId.of("Asia/Shanghai")));
MessageFormat mf = new MessageFormat(
"会议日期:{0}",
Locale.CHINA);
String result = mf.format(new Object[] {dateText});
这里的时序很明确:
Instant是时间线上的点;ZoneId把它转换成上海当地日期;DateTimeFormatter按中文 Locale 生成日期文本;MessageFormat只负责把文本插入消息。
3. 撇号是 MessageFormat 的语法字符
MessageFormat 使用撇号转义大括号和其他模式字符。要输出一个字面撇号,通常需要写成两个连续撇号:
String pattern = "用户 ''{0}'' 已创建";
MessageFormat mf = new MessageFormat(pattern, Locale.CHINA);
System.out.println(mf.format(new Object[] {"alice"}));
// 用户 'alice' 已创建
如果翻译人员把普通英文撇号直接放入包含占位符的模板,可能导致参数不再被解析,表现为:
- 原样显示
{0}; - 参数消失;
IllegalArgumentException;- 文本在某些语言下被截断或含义改变。
因此本地化资源应像代码一样进行模板校验,而不是只检查是否存在对应键。
4. MessageFormat 不是完整的复数规则引擎
传统 MessageFormat 支持:
- 普通参数;
number;date;time;choice。
例如:
String pattern = "{0,choice,0#没有文件|1#有一个文件|1<有多个文件}";
MessageFormat mf = new MessageFormat(pattern, Locale.CHINA);
System.out.println(mf.format(new Object[] {2}));
// 有多个文件
但 choice 是基于数值区间的简单选择,不等于完整的语言复数系统。英语的 one/other 已经比简单区间更复杂,阿拉伯语、俄语等语言的复数类别更加丰富。不要把下面这种模式当作所有语言的通用方案:
{0,choice,1#file|1<files}
如果应用需要复杂复数、性别、嵌套选择或 ICU MessageFormat 语法,应使用明确支持这些规则的国际化库,并确认其版本、资源格式和部署方式。Java SE 的经典 MessageFormat 本身不能自动把一个整数正确翻译成所有语言的复数类别。
七、一个可运行的端到端示例
下面的示例展示完整数据流:
- 根据 Locale 载入资源;
- 使用
NumberFormat格式化金额; - 使用
DateTimeFormatter格式化Instant; - 使用
MessageFormat组合最终消息; - 显式处理缺失资源。
1. 项目文件
src/
├── Main.java
└── i18n/
├── Messages.properties
├── Messages_zh_CN.properties
└── Messages_en_US.properties
src/i18n/Messages.properties:
welcome=Welcome, {0}!
summary=Balance: {0,number,currency}; date: {1}
src/i18n/Messages_zh_CN.properties:
welcome=你好,{0}!
summary=余额:{0,number,currency};日期:{1}
src/i18n/Messages_en_US.properties:
welcome=Hello, {0}!
summary=Balance: {0,number,currency}; date: {1}
src/Main.java:
import java.math.BigDecimal;
import java.text.MessageFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.ResourceBundle;
public class Main {
public static void main(String[] args) {
Locale locale = Locale.forLanguageTag(
args.length == 0 ? "zh-CN" : args[0]);
ZoneId zone = ZoneId.of(
args.length < 2 ? "Asia/Shanghai" : args[1]);
ResourceBundle bundle;
try {
bundle = ResourceBundle.getBundle("i18n.Messages", locale);
} catch (java.util.MissingResourceException e) {
throw new IllegalStateException(
"Cannot load i18n.Messages for " + locale, e);
}
requireKeys(bundle, "welcome", "summary");
String welcome = new MessageFormat(
bundle.getString("welcome"), locale)
.format(new Object[] {"Alice"});
BigDecimal balance = new BigDecimal("1234567.89");
String dateText = DateTimeFormatter
.ofLocalizedDate(FormatStyle.LONG)
.withLocale(locale)
.format(Instant.parse("2025-09-01T12:00:00Z")
.atZone(zone));
String summary = new MessageFormat(
bundle.getString("summary"), locale)
.format(new Object[] {balance, dateText});
System.out.println(welcome);
System.out.println(summary);
}
private static void requireKeys(
ResourceBundle bundle, String... keys) {
for (String key : keys) {
if (!bundle.containsKey(key)) {
throw new IllegalStateException(
"Missing resource key: " + key);
}
}
}
}
2. 编译和运行
在项目根目录执行:
javac -d out src/Main.java
cp -R src/i18n out/
java -cp out Main zh-CN Asia/Shanghai
可能得到:
你好,Alice!
余额:¥1,234,567.89;日期:2025年9月1日
执行:
java -cp out Main en-US America/New_York
可能得到:
Hello, Alice!
Balance: $1,234,567.89; date: September 1, 2025
日期的具体标点、货币符号和空格可能因 Java 版本使用的 Locale 数据而不同,但以下因果关系是稳定的:
zh-CN选择中文资源;en-US选择英文资源;Asia/Shanghai和America/New_York影响同一Instant对应的当地日期;MessageFormat使用传入的 Locale 格式化{0,number,currency};- 日期文本已经在
DateTimeFormatter阶段完成本地化。
如果把资源目录漏复制到 out/i18n,运行时会出现 MissingResourceException。如果只漏掉 summary 键,则 getString("summary") 处失败,而 welcome 仍然可以正常显示。
八、线程安全、缓存和并发边界
这些 API 的并发属性不同,不能因为它们都属于标准库就混用。
通常可以共享的对象
Locale:不可变;DateTimeFormatter:不可变且线程安全;ZoneId:不可变,适合共享;ResourceBundle:资源 Bundle 通常作为只读对象使用,加载和缓存由标准库管理。
不应无保护共享的对象
NumberFormat;DecimalFormat;MessageFormat;- 旧式
DateFormat; Calendar。
例如,不要这样写:
class FormatterHolder {
static final NumberFormat FORMAT =
NumberFormat.getCurrencyInstance(Locale.US);
}
然后让所有请求并发调用 FORMAT.format(...)。同一个实例内部可能保存可变的格式状态,结果可能错误或出现并发问题。
较简单的做法是按调用创建:
String formatMoney(BigDecimal value, Locale locale) {
return NumberFormat
.getCurrencyInstance(locale)
.format(value);
}
如果性能分析证明创建成本需要优化,应在明确的线程隔离策略下使用 ThreadLocal 或对象池,并测试清理、Locale 切换和请求生命周期。不要在没有测量的情况下通过共享可变格式器换取所谓的性能。
九、常见误解与失败表现
1. “把默认 Locale 设置成用户 Locale 就完成国际化了”
错误原因是默认 Locale 是全局隐含状态。并发服务器中,请求 A 设置中文,可能影响请求 B 的英文格式化。
正确做法是让 Locale 显式流经调用链:
String render(Invoice invoice, Locale locale, ZoneId zone) {
// 使用 locale 和 zone 创建或选择格式器
}
默认 Locale 更适合命令行程序、桌面程序或应用启动时的默认值,不适合表示每个请求的独立用户偏好。
2. “语言代码就是 Locale”
en、en-US 和 en-GB 可能使用不同的日期顺序、货币和数字规则。只保存语言而丢弃地区,会让程序无法表达区域差异。
3. “日期格式化只需要一个 Locale”
对于 Instant,还需要 ZoneId。如果只传 Locale,程序无法知道 UTC 时间点要转换成哪个当地日期。
反例:
Instant instant = ...;
// 直接把 Instant 当成用户当地日期,没有 ZoneId
这会在跨时区用户或夏令时切换时产生日期错误。
4. “数字字符串可以参与业务计算”
以下代码丢失了类型语义:
String price = "1,234.50";
逗号、小数点和货币符号都可能随 Locale 改变。数据库和业务层应保存 BigDecimal 等类型,只有在输出边界才调用 NumberFormat。
5. “资源文件有了,所有消息自然都会正确”
资源文件只提供文本,不会自动检查:
- 占位符数量是否一致;
- 参数编号是否正确;
{0,number}是否真的传入数字;{0,date}是否传入可识别的日期类型;- 撇号是否破坏模板;
- 翻译是否改变了业务含义。
应在构建或测试阶段解析每个 Locale 的模板,并用代表性参数执行一次格式化。
6. “把用户输入拼进翻译模板最灵活”
直接拼接存在两个问题:
"用户 " + name + " 有 " + count + " 个项目"
第一,语序无法适应所有语言;第二,参数可能带来 HTML、日志注入或界面转义问题。
更适合的方式是:
itemCount=用户 {0} 有 {1,number} 个项目
但最终输出进入 HTML、JSON、日志或 SQL 时,仍需在对应边界进行转义。MessageFormat 不是 HTML 转义器,也不是安全模板引擎。
十、生产诊断与取舍
1. 资源加载失败的排查顺序
出现 MissingResourceException 时,可以按以下路径检查:
- 基础名称是否写成
i18n.Messages,而不是文件名; - 资源是否位于类路径或模块资源中;
- 资源文件是否被打包进 JAR;
- Locale 的语言、地区和脚本是否符合命名约定;
- 是否存在根资源
Messages.properties; - 键是否拼写一致;
- 是否因为模块边界或自定义类加载器导致资源不可见;
- 文件编码是否正确。
可以查看 JAR 内容:
jar tf app.jar | grep 'i18n/Messages'
预期至少能看到类似:
i18n/Messages.properties
i18n/Messages_zh_CN.properties
如果资源存在但中文变成乱码,应检查资源实际编码和构建过程,而不是先修改 Locale。
2. Locale 数据不是完全静态的业务契约
Java 的区域格式数据来自 JDK 支持的 Locale 数据提供者。不同 JDK 版本、Locale Provider 配置或数据更新可能导致:
- 货币符号变化;
- 窄空格和普通空格变化;
- 日期标点变化;
- 月份、星期名称变化;
- 某些地区的默认货币或格式规则更新。
因此测试本地化输出时,应区分:
- 必须稳定的协议字符串;
- 允许随 Locale 数据变化的用户展示文本。
对用户展示文本做精确字符串断言时,要固定 Java 版本和 Locale 数据环境,或者测试结构性性质,例如包含年份、货币值和关键参数,而不是把所有标点都写死。
3. 资源回退是可用性机制,不是翻译质量保证
拥有 Messages.properties 可以避免资源完全缺失,但如果根资源是英文,中文用户可能看到未翻译的英文。工程上可以选择:
- 根资源作为英文;
- 每种受支持语言都必须提供完整资源;
- 启动时扫描资源键集合并报告缺失;
- 对缺失翻译使用显式监控,而不是静默回退。
回退路径应是可解释的,否则用户看到混合语言时很难知道实际使用的是哪个 Bundle。调试时记录请求 Locale、实际加载的资源层级和缺失键,但不要把敏感业务参数直接写入日志。
十一、一个清晰的设计边界
一个典型请求的国际化数据流可以整理为:
请求语言偏好
↓
解析并规范化 Locale
↓
选择 ResourceBundle
↓
取得消息模板
↓
业务层提供类型化参数
├── Instant + ZoneId → ZonedDateTime
│ → DateTimeFormatter → 日期文本
└── BigDecimal + Locale → NumberFormat → 数字文本
↓
MessageFormat 组合模板与参数
↓
根据输出环境进行 HTML/JSON/日志等专门转义
其中每个组件都不应越权:
Locale不负责时区;ZoneId不负责翻译;ResourceBundle不负责数字计算;DateTimeFormatter不负责消息语序;NumberFormat不负责货币业务逻辑;MessageFormat不负责 HTML 安全;- 默认 Locale 不应替代请求级 Locale。
掌握这些边界后,国际化不再是给字符串加几个语言后缀,而是把“类型化数据、区域规则、语言资源和最终展示”按明确的数据流连接起来。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS
- 下一篇:Java 序列化边界:原生序列化风险、JSON、Schema 与版本演进
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论