把 1234 转成一千二百三十四,看起来就是个填空题。真要在 Java 里把阿拉伯数字转中文数字,你马上会撞上 1001 到底该读“一千零一”还是“一千零零一”、10000000 和 100000000 中间要补几个零、以及金额大写里 10 必须写成“壹拾”而不是“拾”这种细节。我最早写这个工具,是在一个报表导出模块里,甲方要求把所有统计数字转成中文放在 Word 正文里,当时我以为写个数组映射就够了,结果一测 100010000 这种数字直接傻眼。后来陆续在几个项目里又干了三次类似的事,才把这套逻辑彻底理顺。
这篇文章就把我自己沉淀下来的方案完整拆一遍:先分清你要的是哪种“中文数字”,再讲透中文数字的读数规则,然后给出可直接抄的整数转换工具类,最后扩展到财务场景的金额大写,附带我踩过的坑和排查思路。适合正准备写工具类的 Java 开发,也适合把这道题当面试题准备的朋友。
1. 先搞清楚你到底需要哪种“中文数字”
我第一次写这个功能时犯的错,就是上来就写代码。实际工作中“阿拉伯数字转中文数字”至少有三类完全不同的需求,规则、边界、验收标准都不一样。
1.1 场景一:报表、大屏展示的小写中文数字
这类场景最常见:导出的 Excel、Word 或 PDF 里,把“12345”显示成“一万二千三百四十五”;大屏上把某个统计值转成“三千二百一十万”。特点是只要读数自然、不带歧义,不需要考虑金额合规,也不用管“壹贰叁”那套大写。
这种场景对“一十”的省略比较宽容。100000 读“十万”或者“一十万”都能接受,1010 读“一千零一十”也是通的。但连续零的合并必须做好,10001 不能读成“一万零零一”,更不能读成“一零零零一”。大多数第三方工具、网上的 demo,目标都是这一类。
1.2 场景二:财务、合同里的金额大写
这一类是另一个物种。发票、合同、报销单里要写“壹佰贰拾叁元肆角伍分”,数字必须用大写,单位必须齐全,10 必须写作“壹拾”,不能省成“拾”。财务上对大小写金额一致性有严格要求,如果你只是拿普通中文数字的映射表去替换,很容易产出“拾万元整”这种不合规的结果。
更关键的是,金额有角分。0.50 元要写成“零元伍角整”?不对,严格说是“零元伍角”,如果没有角且有分,比如 0.05,要写“零元零伍分”。这些尾巴逻辑,报表场景根本用不到,但财务场景一条都不能少。
1.3 场景三:面试题里的隐藏考点
这是 Java 面试里出现频率相当高的“基础题”,热词里的 java面试题、java基础、java八股文都经常带它。面试官通常不会只问你“会不会转换”,而是会追问:
- 100001 你是怎么处理连续零的?
- 如果数字超出 long 范围怎么办?
- 金额大写为什么不能直接用普通中文数字?
- StringBuilder 拼接和 String 拼接在循环里的区别是什么?
所以这篇文章里的代码我会尽量写出工程可用级别,而不是面试背答案级别。面试官看到你能把边界情况和扩展方案讲清楚,印象分会比背一段网上的代码好很多。
2. 中文数字的规则,比你想的复杂一点
先别急着写代码,把规则梳理清楚,后面所有代码都是这堆规则的翻译。中文数字大约有三层规则:数字映射、零的读法、分段与单位。第三层里还藏着一个“一十”的省略问题。
2.1 数字映射只是第一步
数字映射非常简单:0 到 9 对应“零一二三四五六七八九”,单位对应“十百千万亿”。这是最底层的砖,但只做映射远远不够,因为中文数字不是简单的“每个阿拉伯数字替换成汉字”。比如 1001,如果你一位一位替换,会得到“一零零一”,而正确读法是“一千零一”。为什么?因为 1001 的 1 占了千位,中间的 0 只读一个,并且末尾的 0 不读。
所以转换的本质是对“数位”的翻译,不是对“数字字符”的翻译。每个数字不仅要转成汉字,还要带上它所在的位权单位。
2.2 零的处理:连续零合并、末尾零省略
零是整个转换里最容易翻车的地方。中文读数对零有三条核心规则:
- 一组数字中间的零要读出来,比如 1001 是“一千零一”,这里的 0 要读。
- 连续的多个零只读一个,比如 10001 是“一万零一”,不是“一万零零一”,中间四个 0 只合成一个“零”。
- 末尾的零不读,比如 1000 是“一千”,最后三个 0 都不读。
这三条规则看起来简单,但和分段单位叠加之后,段与段之间的零就变得非常棘手。比如 100100000,按四位一组拆是 1 0010 0000,正确读法是“一亿零一十万”(这里按保留“一十”的读法),亿和万之间要补一个零;而 110000000 拆成 1 1000 0000,读“一亿一千万”,中间不需要零。同样是亿段后面接万段,一个有零一个没零,区别就在万段的值是否小于 1000,以及中间有没有整段为 0 的空档。
这块我当时调试了很久,最后得出的可靠方案是:分段之后用一个 pendingZero 标志记录是否遇到了全零的段,再配合“当前段数值是否小于 1000”来判断是否要补零。
2.3 四位分段与“万亿亿”扩展
中文读数是四位一断的,跟英文的三位一断完全不同。个十百千是一段,往上“万”一段,“亿”一段,再往上“万亿”“亿亿”……每四位数属于同一个段,段内用“千百十”做位权,段间用“万亿亿”做衔接。
很多人写转换工具只支持到亿,long 一超过 1 亿就乱了。而实际上 Java 的 long 最大值是 9223372036854775807,要完整支持它,至少得准备“亿亿、万亿、亿、万”四个段。我在自己的工具里就是按四个段做的分组:
9223372036854775807 分组后是: [922] [3372] [0368] [5477] [5807] 对应单位:亿亿 万亿 亿 万 (个位段无单位)有人习惯把“亿亿”叫“兆”,但“兆”在中文语境下歧义太严重,计算机里提到兆都是 10^6,大数读法里又有人说是 10^12。为了避免争论,干脆用复合单位“亿亿”,语义最直观,代码注释里写清楚就行。
2.4 “一十”到底能不能省略
这是个小细节,但坑过不少人。比如 10 读“十”还是“一十”?100000 读“十万”还是“一十万”?口语里两种都有人讲,但更规范的书面读法是“十”和“十万”。而在金额大写里,必须写作“壹拾”“壹拾万”,省掉“壹”是不合规的。
所以工具类里最好把这个行为做成参数:普通中文数字默认省略最高段的“一十”,金额转换时关闭省略。比如 10 在普通场景输出“十”,在金额场景输出“一十”,再映射成“壹拾”。这个开关很小,但能同时满足两类需求。
3. 通用整数转换:一份可以抄作业的 Java 工具类
规则讲完,开始写代码。我直接给出一个完整可运行的版本,支持负数、支持 long 全范围、连续零正确合并、最高段十位自动省略“一”。
3.1 设计思路:从高位到低位分段
整体思路分三步:先把 long 按四位一组塞进数组,数组下标对应“亿亿、万亿、亿、万、个位”;然后从高位往低位遍历,跳过全 0 段,遇到非 0 段就把段内转换成“千百十”表达;最后把段单位和段之间的零补上。为什么要从高位往低位?因为零的补位方向是从高往低看的,正向遍历用 pendingZero 判断最直观。如果从低位往高位拼,补零逻辑要多绕一层,我第一版就是这么写的,回头改 bug 时后悔没一开始就用正向。
3.2 完整代码
import java.math.BigDecimal; import java.math.RoundingMode; public class NumberToChineseUtil { private static final String[] DIGITS = { "零", "一", "二", "三", "四", "五", "六", "七", "八", "九" }; private static final String[] UNITS = {"千", "百", "十", ""}; // 从高到低。Long.MAX_VALUE = 9223372036854775807,需要“亿亿”段 private static final String[] SECTION_UNITS = {"亿亿", "万亿", "亿", "万", ""}; private NumberToChineseUtil() { } /** * 阿拉伯数字转中文数字,普通展示场景。 * 10 -> 十,100000 -> 十万 */ public static String toChinese(long num) { return toChinese(num, true); } /** * 阿拉伯数字转中文数字。 * * @param num 目标数字 * @param omitHighestTen true 表示最高段十位省略“一”,比如十万、十二 * false 表示保留,比如一十万、一十二 */ private static String toChinese(long num, boolean omitHighestTen) { if (num == 0) { return "零"; } if (num < 0) { return "负" + toChinese(-num, omitHighestTen); } long[] sections = new long[SECTION_UNITS.length]; for (int i = sections.length - 1; i >= 0 && num > 0; i--) { sections[i] = num % 10000; num /= 10000; } StringBuilder result = new StringBuilder(); boolean isHighestSection = true; boolean pendingZero = false; for (int i = 0; i < sections.length; i++) { long section = sections[i]; if (section == 0) { pendingZero = true; continue; } // 段与段之间补零: // 1. 中间出现过整段为0,必须补 // 2. 当前段带前导0,比如 0100,也要补 if (result.length() > 0 && (pendingZero || section < 1000)) { result.append("零"); } String sectionStr = convertSection((int) section); // 只有最高有效段才允许省略“一十” if (isHighestSection && omitHighestTen && section >= 10 && section < 20 && sectionStr.startsWith("一十")) { sectionStr = sectionStr.substring(1); } result.append(sectionStr).append(SECTION_UNITS[i]); pendingZero = false; isHighestSection = false; } return result.toString(); } /** * 转换 0~9999 之间的段内数字。 * 1001 -> 一千零一,1010 -> 一千零一十,1100 -> 一千一百 */ private static String convertSection(int section) { int[] digits = new int[4]; for (int i = 3; i >= 0; i--) { digits[i] = section % 10; section /= 10; } StringBuilder sb = new StringBuilder(); boolean zeroFlag = false; for (int i = 0; i < 4; i++) { if (digits[i] == 0) { zeroFlag = true; } else { if (sb.length() > 0 && zeroFlag) { sb.append("零"); } sb.append(DIGITS[digits[i]]).append(UNITS[i]); zeroFlag = false; } } return sb.toString(); } }这块代码不长,但基本把 long 全范围覆盖了。实测 9223372036854775807 会输出“九百二十二亿亿三千三百七十二万亿零三百六十八亿五千四百七十七万五千八百零七”,长是长了点,但分段和零位都正确。
3.3 关键代码逐段拆解
先看分段这一步。sections 数组长度是 5,从低位开始num % 10000依次填入,最后 sections[0] 就是最高段,sections[4] 是个位段。整个过程等价于把数字按四位一组拆开。
再看补零逻辑。pendingZero记录从上一个非零段到当前段之间,是否出现过整个段都是 0 的情况。如果出现过,说明中间有断层,必须在段前补一个零。同时,即便没有整段为 0,当前段自身的值小于 1000,也说明这段四位里有前导零,比如 0100 万,那么前面也要补零。两个条件用||连接,覆盖了段间零的两种来源。
convertSection里最核心的是 zeroFlag。它只在数字为 0 时置为 true,之后遇到非零数字时,如果之前有过零,就补一个“零”,然后立刻把 zeroFlag 重置。这样连续多个 0 就只会产生一个“零”,而且段末尾的 0 因为后面没有非零数字,根本不会触发。1001 走一遍:千位 1 直接拼接“一千”,百位 0 置 zeroFlag,十位 0 保持 zeroFlag,个位 1 发现 zeroFlag 为 true 就补零,得到“一千零一”。
3.4 测试用例:直接对着看
我建议你把下面这组用例直接扔进单元测试,这是我调这个工具时积累的最小回归集。
| 输入数值 | 期望输出 | 说明 |
|---|---|---|
| 0 | 零 | 边界 |
| 10 | 十 | 最高段“一十”省略 |
| 14 | 十四 | 同上 |
| 100 | 一百 | 普通百位 |
| 101 | 一百零一 | 段内零 |
| 1001 | 一千零一 | 连续零合并 |
| 1010 | 一千零一十 | 零不在末尾 |
| 10000 | 一万 | 整万 |
| 10001 | 一万零一 | 段间补零 |
| 10010 | 一万零一十 | 段间补零 |
| 100000 | 十万 | 万段十位省略 |
| 100100000 | 一亿零一十万 | 亿段后补零 |
| 110000000 | 一亿一千万 | 无零 |
| 100000001 | 一亿零一 | 中间整段为 0 |
| -1234 | 负一千二百三十四 | 负数 |
数字 100100000 这段,按照“保留一十”的默认读法是“一亿零一十万”。如果你习惯读“一亿零十万”,把 omitHighestTen 的逻辑去掉一部分就能得到,但金融场景千万别这么干。
4. 扩展:金额大写转换,直接支撑合同场景
日常报表只要小写中文数字,但财务场景需要的是另一套输出。我把它做成一个独立方法,内部复用上面的转换逻辑,只是换了大写映射、关掉“一十”省略、再加上“元角分”和“整”。
4.1 大写转换和普通中文数字的差异
差异主要有三点:
- 数字映射换成壹贰叁肆伍陆柒捌玖。
- 单位换成拾佰仟万亿。
- 十位前面必须有“壹”,不允许省略。
- 整数部分结束要加“元”,没有角分要加“整”。
其中“壹拾”是硬性要求。合同上写“拾万元整”会被人挑毛病,正确写法是“壹拾万元整”。所以复用普通转换时必须传入 omitHighestTen=false,让 100000 先转成“一十万”,再映射成“壹拾万”。
4.2 代码实现
public class AmountToChineseUtil { private static final String[] UPPER_DIGITS = { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" }; private AmountToChineseUtil() { } /** * 金额转中文大写,支持角分。 * 123.45 -> 壹佰贰拾叁元肆角伍分 * 100.00 -> 壹佰元整 * 0.05 -> 零元零伍分 */ public static String toChineseAmount(BigDecimal amount) { if (amount == null) { throw new IllegalArgumentException("金额不能为空"); } amount = amount.setScale(2, RoundingMode.HALF_UP); long yuanPart = amount.longValue(); int centPart = amount.subtract(BigDecimal.valueOf(yuanPart)) .movePointRight(2).intValue(); int jiao = centPart / 10; int fen = centPart % 10; StringBuilder sb = new StringBuilder(); if (yuanPart == 0) { sb.append("零元"); } else { // 第二个参数 false:金额大写不允许省略“壹拾” String intCn = NumberToChineseUtil.toChineseForAmount(yuanPart); sb.append(toUpper(intCn)).append("元"); } if (jiao == 0 && fen == 0) { sb.append("整"); } else { if (jiao != 0) { sb.append(UPPER_DIGITS[jiao]).append("角"); } else if (fen != 0) { // 角为 0 但分不为 0,需要补一个零 sb.append("零"); } if (fen != 0) { sb.append(UPPER_DIGITS[fen]).append("分"); } } return sb.toString(); } private static String toUpper(String chinese) { StringBuilder sb = new StringBuilder(chinese.length() * 2); for (int i = 0; i < chinese.length(); i++) { char c = chinese.charAt(i); switch (c) { case '零': sb.append("零"); break; case '一': sb.append("壹"); break; case '二': sb.append("贰"); break; case '三': sb.append("叁"); break; case '四': sb.append("肆"); break; case '五': sb.append("伍"); break; case '六': sb.append("陆"); break; case '七': sb.append("柒"); break; case '八': sb.append("捌"); break; case '九': sb.append("玖"); break; case '十': sb.append("拾"); break; case '百': sb.append("佰"); break; case '千': sb.append("仟"); break; case '万': sb.append("万"); break; case '亿': sb.append("亿"); break; case '负': sb.append("负"); break; default: sb.append(c); } } return sb.toString(); } }我在 NumberToChineseUtil 里需要暴露一个给金额用的方法,因为普通toChinese(long)会省略“壹拾”,而金额不能省。可以直接把之前的私有方法改成包级可见,名字就叫toChineseForAmount:
static String toChineseForAmount(long num) { return toChinese(num, false); }4.3 “整”和“零”的边界
这个方法是整个金额转换里最容易出错的部分。没有角分,结尾必须加“整”,这是票据习惯。有角分,结尾就不要“整”,而是按“X角Y分”或“零Y分”输出。角位为 0 但分位不为 0 时,要在分前面补一个“零”,比如 5.05 是“伍元零伍分”,不能写成“伍元伍分”。0.50 和 0.05 的区别也必须由这套逻辑体现出来,前者是“零元伍角”,后者是“零元零伍分”。
我之前踩过的一个坑是用 double 直接运算金额。0.1 加 0.2 在 double 里都不是干净值,金额字段用了 double 后,subtract出来经常是 0.29999999999999998,再 movePointRight 取整就会得到错误的角分。后来统一改成 BigDecimal 并在入口处 setScale(2),这个坑才算填平。
5. 常见问题与排查技巧实录
写这类工具,真正花时间的不是主流程,而是边界情况的排查。我把这几年攒下的问题整理成一份速查表,每条后面附结论和原因,方便你遇到同类问题直接对号入座。
5.1 连续多个零输出又长又乱
症状:10001 输出“一万零零一”或“一零零零一”。
原因:一位位硬拼,没有做零的合并。解决方案是段内用 zeroFlag、段间用 pendingZero 双重控制。记住一个核心判断:只有“当前已经有内容、并且刚才遇到过零”时才补一个“零”,补完立即重置标志。
5.2 十位到底该不该省略“一”
症状:100000 输出“一十万”,你觉得不对;或者合同里把“壹拾万”写成了“拾万”。
这不是 bug,是场景没分清。普通展示场景,100000 写成“一十万”也不算大错,但读起来啰嗦,所以默认省略。金额大写场景必须保留“壹拾”。解决方案就是给转换方法加布尔参数,而不是写两套逻辑。
5.3 金额用 double 导致角分错乱
症状:金额 9.99 转大写变成“玖元玖角玖分”偶尔变成“玖元玖角玖分”,但某些小数值会多出一分或少一分。
原因:double 的二进制浮点表示无法精确表达 0.1、0.2 这类十进制小数。金额相关字段一律用 BigDecimal,并且统一 setScale(2, RoundingMode.HALF_UP),在入口就规范化。
5.4 负数、零、long 溢出
负数我在工具里直接输出“负中文数字”,比如 -1234 转“负一千二百三十四”。这个行为可以根据业务决定,如果业务上不允许负数,应当在入口直接参数校验抛异常。
零单独返回“零”,不能走循环,否则 0 会返回空字符串。long 溢出属于输入校验问题,我用五位段数组天然覆盖了 Long.MAX_VALUE,所以不需要额外判断;如果你按网上常见的三段式(亿、万、个)实现,超过 99999999999999 就会出错,记得加范围校验。
5.5 StringBuilder 比 String 好用在哪
热词里单独出现了 java stringbuilder,这里多说一句。字符串循环拼接时 JVM 会反复创建新的 String 对象,代码写result = result + section虽然简洁,但数据量大时性能很差,可能触发频繁 GC。用 StringBuilder 的 append 是在同一个可变对象上操作,性能和内存都稳定得多。这个工具类虽然每次都 new 一个 StringBuilder,但因为转换频次低,完全没问题;倒是如果你在一个大循环里调用十万次转换,建议把方法设计成无状态静态方法,方便未来做缓存。
| 问题 | 症状 | 根因 | 解决方案 |
|---|---|---|---|
| 连续零 | 一万零零一 | 没有合并零 | zeroFlag 状态机 |
| 十位省略 | 十万 / 一十万混淆 | 场景需求不同 | 布尔开关控制 |
| 金额不准 | 0.05 变成伍分 | double 精度 | BigDecimal |
| 空结果 | 0 返回空串 | 忽略零边界 | 单独返回“零” |
| 溢出 | 超亿范围乱掉 | 段位不够 | 扩展亿亿段 |
6. 最后分享一点实际经验
我在真实项目里用这套工具的感觉是:方向比技巧重要。第一次写的时候,我满脑子都是“怎么把数字拆开、怎么映射”,结果卡在零的处理上两天。后来把中文读数的规则一条条列清楚,再回到代码,逻辑一下就顺了。建议你也先写规则清单,再写代码,不要反过来。
另外,这个工具类应该当成工程组件维护,而不是一次性脚本。我通常会在项目的 common 模块里放一个 NumberToChineseUtil,再配一组单元测试,把 0、10、1010、100000001、Long.MAX_VALUE、负数、金额小数值这些边界全都锁死。后面任何人再遇到数字转中文的需求,直接复用就行,不用重新造轮子。
如果后续想继续扩展,可以考虑两个方向:一个是支持 BigDecimal 转中文数字的小数部分,比如 3.14 转“三点一四”,这个在报表里经常遇到;另一个是支持序数场景,比如第一、第二,原理和基数转换不同,但同样值得做。每次扩展都把这些边界用例带上,你会发现自己对“中文数字化”这件事的掌握会越来越系统。