具有负字节值的字节数组不能使用 UTF-8 转换为字符串

Byte array with negative byte values can't be converted to String using UTF-8

提问人:Ekanath S M R 提问时间:2/17/2023 最后编辑:Joachim SauerEkanath S M R 更新时间:2/20/2023 访问量:684

问:

考虑这是字节数组,byte[] by = [2, 126, 33, -66, -100, 4, -39, 108]

然后,如果我们执行下面的代码并打印它,

String utf8_str = new String(by, StandardCharsets.UTF_8);
System.out.println(utf8_str);

输出为:

\~!���l

其中所有负值都转换为 ' ',这意味着具有 -ve 值的字节不在 UTF-8 字符集中。 但 UTF-8 字符集的范围为 0 到 255。

如果只有 0-127 可以以字节数据类型的形式以 +ve 形式显示,那么在编码为 UTF-8 字符集时,大于 127 的数字永远不能使用,因为 Java 不支持无符号字节值。

有什么解决方案吗?

我需要将字节数组编码为 UTF-8 字符串,然后从 UTF-8 字符串中取回字节数组。

但是除了 ' ' 之外,所有字符都已正确编码和检索。

当我尝试检索 ' ' (即打印它是 UTF-8 Unicode) 时,它会给出一些其他 Unicode 而不是编码字符的 Unicode。

Java 字符串 编码 UTF-8 字节

评论

2赞 user16320675 2/17/2023
-66或作为 UTF-8 中的第一个字节无效(UTF-8 不是字符集,而是编码标准)0xBE
1赞 user16320675 2/17/2023
顺便说一句,负值不是问题(例如 将转换为 ),并且是 Java 将大于 127 的字节解释为负数byte[] { -61, -97 }"ß"
1赞 Sweeper 2/17/2023
你很困惑。似乎您的真正问题是关于“我需要将字节数组编码为 UTF8 字符串并从 UTF8 字符串中取回字节数组”。你能举一个最小的可重现的例子来代替这个问题吗?我可以向你保证,这与消极或积极无关。

答:

1赞 Joachim Sauer 2/17/2023 #1

TL的;dr:您不能将任意字节解码为 UTF-8,因为某些字节流不符合 UTF-8 流。如果需要将任意字节表示为 String,请使用类似 Base64 的内容:

String base64 = Base64.getEncoder().encodeToString(arbitraryBytes);

并非所有字节序列都是有效的 UTF-8

UTF-8 对允许使用哪些字节序列有非常具体的规则。简短的版本是:

  • 0x00-0x7F范围内的字节可以独立存在(并表示与其 ASCII 编码等效的字符)。
  • 0xC2-0xF4范围内的字节是启动多字节序列的前导字节,其确切值指示延续字节数
  • 0x80-0xBF范围内的字节是一个延续字节,它必须在前导字节和其他一些延续字节之后。

它还有一些规则和细微差别,但这是基本思想。

正如你所看到的,有几个字节值(0xC0、0xC1、0xF5-0xFF)根本不会出现在格式正确的 UTF-8 流中。此外,其他一些字节只能出现在特定序列中。例如,一个前导字节后永远不能跟着另一个前导字节或独立字节。同样,独立字节后绝不能跟着延续字节。

关于“负值”的注意事项:在 Java 中是一种有符号的数据类型。但是签名/未签名的辩论与此主题无关,因为它仅在计算值或打印值时才重要。它是在 Java 中使用的 8 位类型,字节在 Java 中表示的事实主要是一种视觉上的区别。就本讨论而言,“负值”等同于“0x80和0xFF之间的字节值”。碰巧的是,非负值恰好是 UTF-8 中的独立字节,并且转换得很好。byte0xBE-66

所有这些都意味着在大多数情况下,将任意字节 [] 解码为 UTF-8 是行不通的!

那为什么不抛出异常呢?new String(...)

但是,如果包含一个无效的 UTF-8,那么为什么 new String(arbitraryBytes, StandardCharsets.UTF_8) 不抛出异常?arbitraryBytesbyte[]

问得好!也许应该这样,但 Java 的设计者已经决定,这种将 a 解码为 a 的特定方式应该是宽松的:byte[]String

此方法始终将格式错误的输入和不可映射的字符序列替换为此字符集的默认替换字符串。当需要对解码过程进行更多控制时,应使用该类。CharsetDecoder

在这种情况下,“默认替换字符串”只是 Unicode 字符 U+FFFD 替换字符,它看起来像填充菱形中的问号:

正如文档所述,当然有一种方法可以将 a 解码为 a,并在不正常时获得真正的异常:byte[]String

byte[] arbitraryBytes = new byte[] { 2, 126, 33, -66, -100, 4, -39, 108 };
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT);
String string = decoder.decode(ByteBuffer.wrap(arbitraryBytes)).toString();

此代码将引发异常:

Exception in thread "main" java.nio.charset.MalformedInputException: Input length = 1
    at java.base/java.nio.charset.CoderResult.throwException(CoderResult.java:274)
    at java.base/java.nio.charset.CharsetDecoder.decode(CharsetDecoder.java:820)
    at org.example.Main.main(Main.java:13)

好的,但我真的需要一个字符串!

我们已经意识到使用 UTF-8 解码是行不通的。可以使用 ISO-8859-1,它将所有 256 个字节的值映射到字符,但这会导致字符串包含许多不可打印的控制字符,处理起来会非常麻烦。byte[]String

使用 Base64

通常的解决方案是使用 Base64

// encode byte[] to Base64
String base64 = Base64.getEncoder().encodeToString(arbitraryBytes);
System.out.println(base64);
// decode Base64 to byte[]
byte[] decoded = Base64.getDecoder().decode(base64);
System.out.println(Arrays.equals(arbitraryBytes, decoded));

与之前相同,这将打印arbitraryBytes

An4hvpwE2Ww=
true

Base64 是一个常见的选择,因为它能够用合理数量的字符表示任意字节(平均而言,它需要的字符比输入字节多三分之一左右,具体取决于使用的确切格式和/或填充)。

Base64 有一些变体,用于各种情况。特别常见的是使用 URL 和文件名安全变体,这可确保不使用 URL 和文件名中具有任何特殊含义的字符。幸运的是,Java 直接支持它。

格式化为十六进制字符串

Base64 简洁实用,但它在某种程度上混淆了各个字节值。有时,我们需要一种允许我们以某种方式解释值的格式。为此,数据的十六进制表示可能更有用,即使它占用的字符数比 Base64 多:

// encode byte[] to hex
String hexFormatted = HexFormat.of().formatHex(arbitraryBytes);
System.out.println(hexFormatted);
// decode hex to byte[]
byte[] decoded = HexFormat.of().parseHex(hexFormatted);
System.out.println(Arrays.equals(arbitraryBytes, decoded));

这将打印

027e21be9c04d96c
true

这种十六进制格式(不带分隔符)每个输入字节正好需要 2 个字符,使这种格式比 Base64 更详细。

如果您还没有使用 Java 17 或更高版本,还有很多其他方法可以做到这一点

但是我已经将我的转换为使用 UTF-8,我真的需要我的原始数据回来。byte[]String

对不起,但你很可能做不到。除非您非常幸运并且您的原始数据恰好是格式良好的 UTF-8 流,否则转换为将丢失一些数据,并且您只能恢复原始数据的一小部分。byte[]Stringbyte[]

String badString = new String(arbitraryBytes, StandardCharsets.UTF_8);
byte[] recoveredBytes = badString.getBytes(StandardCharsets.UTF_8);

这将为您提供一些东西,但每次您的输入包含编码错误时,这将包含字节序列0xEF 0xBF 0xBD(或 -17 -65 -67,当解释为有符号字节并以十进制打印时)。该字节序列是 UTF-8 对 U+FFFD 替换字符进行编码的。

根据特定的输入(甚至是 UTF-8 解码器的具体实现!),每个替换字符可以替换一个或多个字节,因此您甚至无法像这样可靠地判断原始输入数组的大小。