bigywater.tech
Java · 350

MessageFormat:带参数的本地化消息

带参数的消息是国际化里最常用的东西,也是最容易出隐蔽 bug 的地方:数字被加了千分位、撇号吞掉占位符、复数规则表达不了、实例不能共享。这篇逐项实测这些行为。

2026 → 2,026
整数年份直接放进 {0} 的输出
80146 / 160000
8 个线程共用一个 MessageFormat 时结果错误的次数
3 / 11
choice 对俄语复数规则给出错误形式的数(101、102、1001)

Locale 决定格式

实测 · 本机执行输出
  [locale]
      en_US   amy 于 October 4, 2026 购买 1,234,567 件,共 $1,234.50(26%)
      de_DE   amy 于 4. Oktober 2026 购买 1.234.567 件,共 1.234,50 €(26 %)
      fr_FR   amy 于 4 octobre 2026 购买 1 234 567 件,共 1 234,50 €(26 %)
      zh_CN   amy 于 2026年10月4日 购买 1,234,567 件,共 ¥1,234.50(26%)
      ja_JP   amy 于 2026年10月4日 购买 1,234,567 件,共 ¥1,234(26%)
      ar_EG   amy 于 ٤ أكتوبر ٢٠٢٦ 购买 ١٬٢٣٤٬٥٦٧ 件,共 ‏١٬٢٣٤٫٥٠ ج.م.‏(٢٦٪؜)
    默认 Locale(format):en_CN;new MessageFormat(pattern) 不带 Locale 用它
    同一个 pattern,{1,date} 不带样式 / short / medium / long / full(Locale.US):
        {0,date}                   → Oct 4, 2026
        {0,date,short}             → 10/4/26
        {0,date,medium}            → Oct 4, 2026
        {0,date,long}              → October 4, 2026
        {0,date,full}              → Sunday, October 4, 2026
        {0,time}                   → 3:30:00 PM
        {0,date,yyyy-MM-dd HH:mm}  → 2026-10-04 15:30

整数的千分位

实测 · 本机执行输出
  [number]
    {0} 直接放整数:
        2026           → 2,026
        1234567        → 1,234,567
        1234567.891    → 1,234,567.891
        12345678901234 → 12,345,678,901,234
        3.0            → 3
        1.0E-7         → 0
        2147483647     → 2,147,483,647
    年份 2026 在 pattern 里:"{0}年" → 2,026年;德语 → 2.026年;法语 → 2 026年(法语分组分隔符的码点:8239)
    {0,number,#} → 2026;{0,number,integer} → 1,234,567;{0,number,#.##} 对 3.14159 → 3.14;传 String.valueOf → 2026年
    BigDecimal 与 BigInteger 也按数字格式化:12,345,678,901,234,567,890.123;String 原样:12345
    Boolean / null / 数组:true|null|[I@…

单引号转义

实测 · 本机执行输出
  [quote]
        It's {0}           → Its {0}
        It''s {0}          → It's ok
        '{0}' 是占位符         → {0} 是占位符
        ''{0}''            → 'ok'
        '{'0'}'            → {0}
        a '' b             → a ' b
        {0} don't {1}      → ok dont {1}
        'It''s' {0}        → It's ok
        {{0}}              → IllegalArgumentException: can't parse argument number: {0}
        5 { 6              → IllegalArgumentException: Unmatched braces in the pattern.
    JDK 里 MessageFormat.format("It's {0}", "ok") = Its {0}
    MessageFormat.format("{0}\\n", ..) 反斜杠不是转义:a\nb

ChoiceFormat 做复数

实测 · 本机执行输出
  [choice]
        n=0     → 没有文件
        n=1     → 一个文件
        n=2     → 2 个文件
        n=1000  → 1,000 个文件
        n=-1    → 没有文件
    n=0.5(1# 之前、0# 之后)→ 没有文件;n=1.0 → 一个文件;n=1.5 → 2 个文件;n=NaN → 没有文件;n=-Infinity → 没有文件
    ChoiceFormat(limits {0,1,2}) → 0.9: zero | 1: one | 2: other | 5: other | -3: zero
    极限写法 ChoiceFormat.nextDouble(1) = 1.0000000000000002;'1<' 等价于下限 nextDouble(1)
    俄语复数需要 one/few/many 三种(1, 2-4, 5-20 …),choice 只能用数值区间:
        1     → файл    俄语规则应为 файл
        2     → файла   俄语规则应为 файла
        5     → файлов  俄语规则应为 файлов
        11    → файлов  俄语规则应为 файлов
        21    → файл    俄语规则应为 файл
        22    → файла   俄语规则应为 файла
        25    → файлов  俄语规则应为 файлов
        101   → файлов  俄语规则应为 файл    ×
        102   → файлов  俄语规则应为 файла   ×
        111   → файлов  俄语规则应为 файлов
        1001  → файлов  俄语规则应为 файл    ×
      11 个数里 choice 给出错误形式的有 3 个(区间模型只能写到有限个界限,25 以后一律是 файлов)
    嵌套:1,234 files in 3 folders

参数与非法 pattern

实测 · 本机执行输出
  [args]
    参数个数不够:1 + 2 = {2}
    参数多于占位符:1
    同一个参数多次:x x x
    参数乱序:B 在 A 之前
    null:null;整个 args 为 null:{0}
        pattern {0                   → IllegalArgumentException: Unmatched braces in the pattern.
        pattern {a}                  → IllegalArgumentException: can't parse argument number: a
        pattern {-1}                 → IllegalArgumentException: negative argument number: -1
        pattern {0,foo}              → IllegalArgumentException: unknown format type: foo
        pattern {0,number,#.##.#}    → IllegalArgumentException: Multiple decimal separators in pattern "#.##.#"
        pattern }                    → }
        pattern {0,choice,abc}       → ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
        pattern {1000}               → {1000}
    MessageFormat.toPattern() = {2} {0} {1};getFormatsByArgumentIndex().length = 3;getFormats().length = 3
    toPattern() 的往返:{0,number,#0.#} and {1,date,short};解析 parse("3.5 and 10/4/26") 得到 [3.5, Sun Oct 04 00:00:00 CST 2026]

线程安全

实测 · 本机执行输出
  [thread]
    8 个线程共用同一个 MessageFormat 实例,各格式化 20000 次(共 160000 次):结果不对 80146 次,抛异常 114 次;第一个错误:n=400000 date=2001-09-14  (期望 n=400000 date=2006-01-26)
    每次新建 MessageFormat:结果不对 0 次
    共用实例但用 synchronized 包住:结果不对 0 次

复用与每次解析的耗时

实测 · 本机执行输出
  [perf]
    第 4 轮:MessageFormat.format(静态,每次解析) 1078 ns;复用实例(单线程)313 ns;String.format 389 ns
    第 5 轮:MessageFormat.format(静态,每次解析) 1102 ns;复用实例(单线程)327 ns;String.format 491 ns
    第 6 轮:MessageFormat.format(静态,每次解析) 1094 ns;复用实例(单线程)319 ns;String.format 507 ns
    (自写计时循环,不是 JMH;sink=true)

分步演示

MessageFormat
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,CLDR 区域数据,默认 Locale en_CN,时区 Asia/Shanghai;第 6、7 节为自写并发与计时代码,不是 JMH
    • 没有验证:ICU4J 的 MessageFormat(支持 plural/select)、JDK 17 / 27 上数字与日期输出的细微差别(CLDR 版本不同)、ThreadLocal 复用的实测、format 与 java.util.Formatter 的内存分配。注意:本文第 6 节调试时有一次运行里「共用 + synchronized」的那一组超时,当时它用的是第一组已被并发破坏过的同一个实例,原因没有深究;最终实验改为新建一个实例,之后 3 次运行都没有再卡住
    京ICP备2026067502号-1京公网安备11010502063487号