☰
Java 17 HexFormat:byte[]与十六进制字符串互转的标准工具
2026/10/14 5:37:32 网站建设 项目流程

你有没有遇到过这种场景:调一个外部接口,返回的是byte[],想打印出来肉眼核对一下内容;或者刚算完 SHA-256 摘要,想把结果贴到群里跟同事对比。在很长一段时间里,我的解决方式是打开某个历史项目,复制粘贴那段每次写都差不多的byte[]转十六进制字符串的工具方法,改改就用。直到 Java 17 把java.util.HexFormat放进标准库,我才意识到:这个能力真的早该内置了。它不是那种让人眼前一亮的重型 API,而是一个把十六进制格式化与解析都做透的小工具,干净、克制、正好够用。

HexFormat解决的问题很具体:格式化和解析十六进制字符串。适合 Java 服务端、客户端开发、网络协议调试、加解密摘要展示,以及任何需要把二进制数据变成可读文本的场景。无论是刚学 Java 的新人,还是写了多年老项目的开发者,把它的 API 和几个使用边界搞清楚,都能实打实省掉不少重复劳动。

1. 为什么 Java 17 的 HexFormat 值得放进日常工具箱

1.1 在 HexFormat 出现之前,我们是怎么打印一堆 byte 的

做过二进制调试的人,多半都写过类似的代码:

private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(Character.forDigit((b >> 4) & 0xF, 16)); sb.append(Character.forDigit(b & 0xF, 16)); } return sb.toString(); }

这段代码看着简单,但真到了一线开发里,坑不少。最经典的一个:byte是有符号的,值范围是 -128 到 127,如果不对& 0xFF,正数倒是正常,负数就会出现一长串fffffff5之类的结果。我见过不少同事直接调Integer.toHexString(b),输出里全是f前缀,日志活活被刷成了乱码。另一个常见问题是前导零,0x0F如果处理不当会输出单个f,导致后面解析时长度对不上。

还有个隐性成本是维护。每个项目里都有一份差不多的toHex方法,有的返回大写、有的返回小写,有的支持分隔符、有的不支持。你从 A 项目复制到 B 项目,经常还要顺手改一下格式,因为两边约定不一样。如果团队里有人把byte[]转成了十六进制字符串,另一个人却忘了处理符号位,排查起来非常浪费时间。

第三方库当然也能解决一部分问题。项目里如果有 Apache Commons Codec,可以直接用Hex.encodeHexString;有 Guava 的话,BaseEncoding.base16()也挺好用。但这俩我实际用下来,会发现一个问题:为了打日志或做一次简单转换,就把一个第三方依赖拖进依赖树,在依赖审查比较严格的环境里很别扭。更不用说三方库之间的大小写规则、分隔符支持程度还不一样,格式化用一套、解析用另一套,脑子得记两套 API。

1.2 什么叫“正好够用”:HexFormat 的设计思路

java.util.HexFormat的定位非常专一:只做十六进制字符串与字节数组之间的双向转换。默认情况下,它以小写字母输出,不带任何分隔符、前缀或后缀。核心操作只有两类,一个是formatHex,把byte[]变成字符串;另一个是parseHex,把字符串还原成byte[]。

这个类给我最大的好感,是它对不可变和线程安全的坚持。所有with开头的方法,比如withUpperCase()、withPrefix(),返回的都是一个新实例,而不是在原来的对象上做修改。正因为不可变,它天然线程安全,可以放心定义一个static final常量供全项目使用。

再一个特点是可配置性。默认格式确实太素了,但你可以组合出各种适合阅读的样式,比如带冒号分隔的1f:a5:00,带0x前缀的0x01 0x02,或者全大写的1FA500。这些配置都是用链式方法一层层叠出来的,代码意图很清楚,审查的人一眼就能明白你要什么。

当然要泼一盆冷水:HexFormat是 Java 17 才加入的。如果项目还在 Java 8 或 Java 11,用不了这个类,要么升级,要么继续用工具类。这点我在文章最后还会专门说。

2. HexFormat 核心 API 逐项拆解:大小写、分隔符与前后缀

2.1 最常用的两个方法:formatHex 与 parseHex

先看最基础的用法:

HexFormat hex = HexFormat.of(); byte[] data = new byte[] {0x1f, (byte) 0xa5, 0x00, 0x7b}; String encoded = hex.formatHex(data); System.out.println(encoded); // 1fa5007b byte[] decoded = hex.parseHex(encoded); System.out.println(Arrays.equals(data, decoded)); // true

HexFormat.of()返回一个默认配置的实例:小写、无分隔符。formatHex接收byte[],返回一个字符串,每个字节都会输出恰好两个十六进制字符。这里有个隐藏的好处:formatHex内部已经帮你处理了符号位。你把(byte)0xa5传进去,它输出的是a5,而不是ffffffa5,对日志阅读非常友好。

对应的parseHex则把十六进制字符串还原成byte[]。它接受大写也可以、小写也可以,所以1fa5007b和1FA5007B都能解析。但有一个硬性要求:字符串长度必须是偶数,因为一个字节必须由两位十六进制字符表示。你传一个"abc"进去,它会直接抛IllegalArgumentException,不会帮你补零,也不会只解析前两个字符。这个边界很多人第一次用都会撞上。

2.2 大小写控制:withUpperCase 返回的是新实例

默认输出是小写,但十六进制字符串在很多场合习惯用大写,比如证书指纹、MAC 地址。这时候就要用withUpperCase():

HexFormat base = HexFormat.of(); HexFormat upper = base.withUpperCase(); System.out.println(base.formatHex(new byte[] {0x1f})); // 1f System.out.println(upper.formatHex(new byte[] {0x1f})); // 1F

注意,base仍然是小写,它的值没有被改变。withUpperCase()返回的是一个全新的HexFormat实例。这个不可变语义是整个类设计的基石,理解不了这一点,很容易写出“我明明调了 withUpperCase 怎么输出还是小写”的诡异 bug。

我见过最典型的一次踩坑:同事在类里定义了一个private static final HexFormat HEX = HexFormat.of();,然后在某个方法里写HEX.withUpperCase().formatHex(data);,每次调用都生成一个新实例,但又不接住返回值,输出自然还是小写。正确写法要么是把链式结果直接赋给变量,要么干脆在静态常量定义的时候就写好:

private static final HexFormat HEX = HexFormat.of().withUpperCase();

2.3 分隔符与前后缀:如何组合出易读的十六进制串

如果不带分隔符,连续输出 32 个字节的十六进制字符串,人眼基本无法阅读。调试网络协议或者打印二进制日志时,我更习惯用ofDelimiter来加分隔符:

HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase(); String s = hex.formatHex(new byte[] {0x1f, (byte) 0xa5, 0x01}); System.out.println(s); // 1F A5 01

这里要注意,创建带分隔符的实例用的是静态工厂方法ofDelimiter,而不是of()。of()创建的实例没有分隔符概念。分隔符可以是一个空格、一个冒号、一个短横线,也可以是一个空字符串,但空字符串没有意义,所以实际使用时一般传非空字符串。

再看前后缀。withPrefix("0x")会在每个字节前面加上0x,withSuffix类似,在字节后面加内容:

HexFormat hex = HexFormat.ofDelimiter(" ").withPrefix("0x"); String s = hex.formatHex(new byte[] {0x01, 0x02}); System.out.println(s); // 0x01 0x02

这个输出形式在 C 语言风格的数据定义、或者某些硬件手册里很常见,看着非常亲切。但这里埋着一个大坑:前缀和后缀只影响格式化输出,parseHex不会自动帮你把它们剥掉。你尝试用这个实例去解析"0x01 0x02",它会直接抛异常,因为x不是合法的十六进制字符。解析带前缀的字符串时,要么用另一个不带前缀的实例,要么提前把前缀剔除。

2.4 单字节与数值补位:toHexDigits、hexToLong 等冷门方法

除了formatHex和parseHex,HexFormat还提供了一些零散但实用的辅助方法,平时容易被忽略。

toHexDigits(byte)可以直接把单个字节转成两位十六进制字符串,省去创建一个byte[]数组的开销:

HexFormat hex = HexFormat.of(); System.out.println(hex.toHexDigits((byte) 0x0f)); // 0f System.out.println(hex.toHexDigits((byte) 0xa5)); // a5

还有一个接收int的重载,但它只取低 8 位。什么意思?toHexDigits(0x1ff)返回的不是1ff,而是ff,因为高位的1超出了八位,被截掉了。这个行为知道就行,别在需要完整整数值的场景里误用。

另外两个方法hexToInteger和hexToLong也值得记住。它们可以把十六进制字符串直接解析成int或long,并且在溢出时抛异常,而不会像Long.parseLong那样静默截断。举个例子:

long v1 = HexFormat.of().hexToLong("ffff"); // 65535 long v2 = HexFormat.of().hexToLong("FFFFFFFFFFFFFFFF"); // -1,但这是无符号解析后的结果

hexToLong的行为可以理解为“按无符号数解析”,所以FFFFFFFFFFFFFFFF会被解析成-1。如果你想要的是一个真正的大正数,记得结合BigInteger或者用Long.parseUnsignedLong来处理。这个细节在写底层协议解析时容易遇到。

3. 三个高频实战场景复现:摘要打印、协议调试、自定义编解码

3.1 打印 SHA-256 摘要并做安全比对

最经典的使用场景,没有之一:算完摘要后,把字节数组转成十六进制字符串打印出来。

MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(content.getBytes(StandardCharsets.UTF_8)); HexFormat hex = HexFormat.of().withUpperCase(); String hexHash = hex.formatHex(hash); System.out.println(hexHash);

输出是一串长度为 64 的大写字母和数字,看起来和你在各种文档里见到的摘要格式完全一致。以前这段逻辑需要手写循环或引第三方库,现在两行搞定。

实际操作中要特别提醒一点:如果你拿两个摘要做比对,不要直接用字符串的equals去比。字符串比较在遇到第一个不相同的字符时就会提前返回,这个时间差理论上会泄露部分信息。密码学场景推荐使用MessageDigest.isEqual,它会在常数时间内完成比较:

boolean matched = MessageDigest.isEqual(hash, receivedHash);

这是一个老生常谈但总有人忘记的细节。哪怕你的业务只是一个普通登录功能,从形成习惯的角度出发,也应该用常量时间比较。摘要打印用HexFormat,摘要比较用MessageDigest.isEqual,两者组合起来就是一套标准的哈希处理流程。

3.2 协议调试:给一段二进制帧做格式化 dump

做网络协议调试时,我最常干的事就是把收发的原始字节打出来,逐字节分析。假设有这样一个帧结构:版本号占 1 字节,类型占 1 字节,长度占 2 字节,后面跟着负载数据。

ByteBuffer buffer = ByteBuffer.allocate(8); buffer.put((byte) 0x02); buffer.put((byte) 0x00); buffer.putShort((short) 4); buffer.put(new byte[] {0x11, 0x22, 0x33, 0x44}); byte[] frame = buffer.array();

直接用HexFormat.ofDelimiter(" ").withUpperCase()格式化输出,结果是:

02 00 00 04 11 22 33 44

每个字节一目了然,字段边界也清晰,对着协议文档看就非常方便。如果数据量很大,一次性格式化整个byte[]会得到一个超长的字符串,日志系统通常会截断。我一般会加上截断逻辑,只显示前 N 个字节,并打上省略标记:

HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase(); StringBuilder sb = new StringBuilder(256); int showBytes = Math.min(data.length, 32); hex.formatHex(sb, data, 0, showBytes); if (data.length > showBytes) { sb.append("..."); }

这里用到了formatHex(Appendable, byte[], int, int)这个重载,它有一个fromIndex包含、toIndex不包含的语义,可以把格式化结果直接追加到已有的StringBuilder中。注意这类方法签名上会抛出IOException,当然在实际往StringBuilder写的时候基本不会触发,但编译时你得处理一下。

3.3 自定义二进制编码:带分隔符的帧数据编解码

有时候我们希望把二进制数据编码成字符串,用于传输或存储,比如把一段配置文件的字节内容转成十六进制、加上版本号和分隔符,拼成一个紧凑的自定义字符串。反过来,从字符串解析回二进制时,parseHex可以直接用。

假设我们要编码的格式是“版本号 + 分隔符 + 十六进制数据”,例如:

01:1fa5007b

编码侧很简单:

byte[] payload = new byte[] {0x1f, (byte) 0xa5, 0x00, 0x7b}; HexFormat hex = HexFormat.of(); String body = hex.formatHex(payload); String encoded = "01:" + body;

解码侧稍微注意一下:先按冒号切出数据部分,再调用parseHex。因为HexFormat.of()创建的实例没有分隔符概念,解析"1fa5007b"这种连续的字符串完全没有问题。

String[] parts = encoded.split(":", 2); byte version = Byte.parseByte(parts[0]); byte[] payload = HexFormat.of().parseHex(parts[1]);

如果你在编码时用的是ofDelimiter(":")这种带分隔符的实例,那解析时也必须用同一个实例,因为它会按照间隔符去识别字节边界。这是格式化与解析的对称性,少一头都对不上。

4. 性能对比与大批量格式化策略

4.1 手写、第三方库和 HexFormat 的差异在哪

聊性能之前,先说结论:在绝大多数业务场景里,十六进制转换都不是性能瓶颈。它属于 CPU 亲和型操作,数据量通常也就几十字节到几兆字节,而真正的开销往往在日志 IO 或网络传输上。

我自己在几个项目里对比过手写查表法、Apache Commons Codec 的Hex和 JDK 的HexFormat。手写查表法性能确实不错,因为预先定义了 256 个字符的两两组合,循环里直接查表即可。但它的实现代码并不短,而且你要自己维护大小写和分隔符逻辑。HexFormat底层实现也做了优化,又是 JDK 原生类,JIT 可以放心大胆地优化,实际跑起来差距非常小。

如果你非要在“性能极致”和“代码可读性”之间做个取舍,我的建议是:日志、调试、摘要展示这类低频操作,直接用HexFormat;如果你真的在做每秒百万级的转换,并且 profile 结果显示转换本身占了显著 CPU,再去考虑手写查表。绝大多数项目根本没到那个量级。

为了让你更直观地选型,我把几个方案整理成了表格:

方案依赖可配置性解析能力代码维护成本
手写工具类无需自己扩展需自己实现高,每个项目一份
Apache Commons Codec引入第三方较弱有编码解码中
Guava BaseEncoding引入第三方较强有编码解码中
JDK HexFormat无强(分隔符/大小写/前后缀)有 format 和 parse低

表格里最值得关注的是最后一行:JDKHexFormat零依赖,而且格式化和解析是配套的,不会出现“能格式化却不知道该怎么解析回来”的尴尬。

4.2 大数据量格式化:怎么避免一次生成超大字符串

如果一段二进制数据有几兆字节,直接formatHex(data)会生成一个长度为data.length * 2的字符串。如果data.length是 5 兆,字符串就是 10 兆,这在内存里不是一个小数字。更麻烦的是,很多日志框架输出超大字符串时还会做二次拷贝,内存瞬间就上去了。

这时候更好地做法是复用输出对象,用formatHex(Appendable, byte[], int, int)这种按块输出的方式。比如按 1KB 分块,逐步写入一个StringBuilder,或者直接写到一个Writer里,让调用方决定最终的输出目的地。

另外一个实用技巧是:预分配容量。如果你知道要输出多少字节,算好n * 2 + 分隔符数量之后提前初始化StringBuilder,可以减少扩容次数。比如:

int numBytes = data.length; int delimiterCount = numBytes > 0 ? numBytes - 1 : 0; int capacity = numBytes * 2 + delimiterCount * separator.length(); StringBuilder sb = new StringBuilder(capacity); HexFormat hex = HexFormat.ofDelimiter(" "); hex.formatHex(sb, data);

虽然StringBuilder自己会扩容,但提前给足容量,在高频调用场景里确实能省掉一些不必要的数组复制。

5. 高频报错与解析陷阱整理

5.1 问题速查表:症状、原因、解决

我把自己和身边同事在实际使用中踩过的坑整理成了一张速查表,希望能帮你少走弯路:

症状根本原因解决办法
调了withUpperCase()但输出还是小写HexFormat不可变,with方法返回新实例,不修改原实例用变量接住返回值,或直接链式定义静态常量
parseHex("0x01")抛异常前缀只影响格式化,解析时不会自动剔除手动去掉0x,或解析时使用不带前缀的实例
parseHex遇到奇数长度字符串抛异常一个字节必须由两位十六进制字符表示确保字符串长度为偶数,不能指望自动补零
报NoClassDefFoundErrorJDK 版本低于 17,没有HexFormat类升级 JDK,或继续使用手写工具/第三方库
中文getBytes()之后格式化,输出一长串String.getBytes()默认按 UTF-8 编码,中文字符会变成多字节这是预期行为,解码时用相同编码即可
输出出现ffffffa5之类的长串没有正确处理byte符号位使用HexFormat内部自带无符号处理,或手动& 0xFF

这里我想单独展开一下第 4 行。有些开发者在 Java 8 项目里就试着重构,结果编译报错或运行时类找不到,还以为是包名写错了。HexFormat在java.util包下,类名是java.util.HexFormat,但它确实是 Java 17 的新成员。如果你的项目因为历史原因必须停留在 Java 8 或 11,这种重构想法可以先放一放,继续用现在的工具类就好。

5.2 我的避坑心得:静态常量、前缀剥离与字节符号

我现在的习惯是,每个涉及二进制格式化的类里,优先定义一个全局静态常量:

private static final HexFormat HEX = HexFormat.ofDelimiter(" ").withUpperCase();

定义成static final有两个好处:一是全类共用同一个实例,省得每次方法调用都创建一个新对象;二是配置清晰,后续维护时改一行就能调整整个类的输出风格。

处理前缀,是我重点想强调的边界。用withPrefix("0x")格式化出来的"0x01 0x02",如果直接解析,会被x卡住。真正需要解析时,比较好的做法是准备一个不带前缀的实例,或者按照分隔符切分后再去掉每个切片的前缀。我遇到过一个真实问题:用带前缀的实例格式化后,把字符串传给另一个团队,对方直接拿HexFormat.of().parseHex()去解,结果抛异常。后来我们约定:传输和解码必须使用同一种格式配置,不在中间手动改格式。希望我的这些心得能让你避开同样的坑。

还有一个小细节,关于byte和int的转换。许多人从其他语言转来,写了习惯性的Integer.toHexString(b),结果在负数上翻车。使用HexFormat后这个坑基本被抹平了,但我还是建议你在做底层协议解析时理解它背后发生了什么:formatHex在内部会取b & 0xFF,把这个字节转成 0 到 255 的无符号值,再换算成十六进制字符。所以不管传入的是正数还是负数,输出永远是正确的两位十六进制。对见过太多手写转换 bug 的我来说,这个设计省下的心智负担不止一点点。

6. 关于 HexFormat 的个人取舍建议

我对HexFormat的定位是:它不是炫技工具,而是让人安心的标准工具。就像String.format、Integer.parseInt一样,你知道它在,就可以少写很多重复代码。

如果项目已经升级到 Java 17 及以上,我会建议团队把十六进制转换统一到HexFormat。原因很简单:标准类的行为边界清清楚楚,代码审查时不需要额外解释“这段循环是做什么的”,也不存在谁用大写、谁用小写的风格分裂。定义好一个静态常量,全项目使用,日志格式自然就统一了。

有些场景我个人还是会选择更直接的方式。比如只是单纯地把一个int数字变成十六进制字符串,用Integer.toHexString就够了,没必要为了它引入HexFormat。反过来,只要涉及byte[]的批量转换、带分隔符的格式化、或者需要解析回字节数组,我基本默认用HexFormat。这个选择逻辑很简单:数据量小且格式随意,怎么简单怎么写;数据量大或格式要求明确,交给标准类。

最后分享一个让我印象深刻的细节。有一次排查线上一个二进制协议的问题,需要把客户端发来的一帧数据完整打出来。我加了临时日志,用HexFormat.ofDelimiter(" ").withUpperCase()输出了整帧内容,然后在日志里一眼看到了某个字节比预期多了0x10。那个瞬间我特别庆幸有这样一个标准工具,它让我把精力完全放在问题本身,而不是一个字节一个字节地去拼字符串。这种“工具就该如此顺手”的体验,才是HexFormat真正给我的价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询