使用 UTF-16 将文件读取为字符串时,由 MalformedInputException 导致的 "shouldn't happen" 错误

Error which "shouldn't happen" caused by MalformedInputException when reading file to string with UTF-16

Path file = Paths.get("New Text Document.txt");
try {
    System.out.println(Files.readString(file, StandardCharsets.UTF_8));
    System.out.println(Files.readString(file, StandardCharsets.UTF_16));
} catch (Exception e) {
    System.out.println("yep it's an exception");
}

可能会产生

some text
Exception in thread "main" java.lang.Error: java.nio.charset.MalformedInputException: Input length = 1
    at java.base/java.lang.String.decodeWithDecoder(String.java:1212)
    at java.base/java.lang.String.newStringNoRepl1(String.java:786)
    at java.base/java.lang.String.newStringNoRepl(String.java:738)
    at java.base/java.lang.System.newStringNoRepl(System.java:2390)
    at java.base/java.nio.file.Files.readString(Files.java:3369)
    at test.Test2.main(Test2.java:13)
Caused by: java.nio.charset.MalformedInputException: Input length = 1
    at java.base/java.nio.charset.CoderResult.throwException(CoderResult.java:274)
    at java.base/java.lang.String.decodeWithDecoder(String.java:1205)
    ... 5 more

这个错误“不应该发生”。这是 java.lang.String 方法:

private static int decodeWithDecoder(CharsetDecoder cd, char[] dst, byte[] src, int offset, int length) {
    ByteBuffer bb = ByteBuffer.wrap(src, offset, length);
    CharBuffer cb = CharBuffer.wrap(dst, 0, dst.length);
    try {
        CoderResult cr = cd.decode(bb, cb, true);
        if (!cr.isUnderflow())
            cr.throwException();
        cr = cd.flush(cb);
        if (!cr.isUnderflow())
            cr.throwException();
    } catch (CharacterCodingException x) {
        // Substitution is always enabled,
        // so this shouldn't happen
        throw new Error(x);
    }
    return cb.position();
}

编辑:正如@user16320675 所指出的,当具有奇数个字符的 UTF-8 文件被读取为 UTF-16 时,就会发生这种情况。对于偶数个字符,ErrorMalformedInputException 都不会发生。为什么 Error?

这里发生了不同的事情。但是,是的,看起来您 发现了一个 JVM 错误! 恭喜,我想 :)

但是,一些上下文可以准确解释正在发生的事情以及您发现了什么。我认为你的代码有更大的问题是你自己造成的,一旦你解决了这些问题,JVM 错误将不再是你的问题(但是,一定要报告它!)。我会尽力解决所有问题:

  1. 您的代码已损坏,因为 UTF-8 和 UTF-16 从根本上不兼容。结果是,将偶数个字符保存为 UTF-8 可能会产生可以用 UTF-16 无错误读取的内容,尽管您读取的内容将完全是官话。对于奇数个字符,您将 运行 解码错误。

  2. JVM 有问题!您发现了一个 JVM 错误 - 解码错误的影响应该 而不是 而不是抛出 Error。具体的错误是替换实际上并没有涵盖所有失败条件,但代码是在假设它会的情况下编写的。

  3. 该错误似乎与宽松模式应用不当有关,需要解释什么是替换和下溢。

UTF-8 与 UTF-16

  • 当您将字符转换为字节或将字符转换为字节时,您使用的是字符集编码。
  • 文件是字节序列,不是字符。
  • 这些规则没有例外。

因此,如果您在输入字符并保存时没有选择字符集编码?有人是。如果您在 notepad.exe 中敲击键盘并保存,那么记事本会为您挑选一个。你不能没有编码。

为了解释这里发生的细微差别,暂时忘掉编程。

我们决定一个协议:你想办法用一个形容词来描述一个人;你把它写在一张纸上(只是形容词)然后交给我。然后我读了它并猜测您要描述的是我们的哪个朋友圈。我碰巧是双语的,能说流利的荷兰语和英语。你不知道,或者你知道,但我们从未讨论过我们两个协议的这一部分。

你开始,想到一个特别瘦长的人,所以你决定在纸条上写下“苗条”。你出房间,我进去,我拿纸条。

我做了一个错误的假设,我假设你是用荷兰语写的,所以我读了这篇笔记,并且,以为你是用荷兰语写的,我读了'slim',是一个实际的荷兰语单词,但它的意思是“聪明”。如果你在你的笔记上写下,比如说,“高”,这就不会发生:“高”不在荷兰语词典中,因此我知道你做了一个 'error' (你写了一个无效的word。它对你有效,但我正在假设它是荷兰语,所以我认为你犯了一个错误)。但是,“slim”,这 4 个完全相同的字母,所以恰好是有效的荷兰语和有效的英语,但它根本不是同一回事。

UTF-8 与 UTF-16 完全一样:您可以使用 UTF-16 编码的字符序列产生字节流,这恰好也是完全有效的 UTF-8,但它意味着某些东西完全不同,反之亦然!但是也有一些字符序列,如果保存为 UTF-16 然后读取为 UTF-8(反之亦然)将是无效的。

所以,会出现“瘦”的情况,也会出现“高”的情况。任何一个对你来说几乎都是无用的:当我读到你的笔记并看到“Slim”时,我认为那意味着 'smart',我们仍然 'lost' 而且我选错了朋友 - 没有更好的结果。那有什么意义呢,对吧?任何时候您将字符转换为字节并再次转换回来,路径上的每个转换步骤都需要对所有 之前 或它永远不会工作的所有内容使用完全相同的编码。

但它是如何失败的 - 这就是问题所在:当你写“苗条”时 - 我只是选错了朋友。当你写“高”时,我惊呼发生错误,因为它不是荷兰语单词。

UTF-16 将每个字符转换为 2、3 或 4 个字节的序列,具体取决于字符。当您将纯 jane ascii 字符保存为 UTF-8 时,它们最终都是 1 个字节,通常任何 2 个这样的字节,被解码为单个 UTF-16 字符,'is valid'(但完全不同的字符,完全与输入无关!),因此如果您将 8 个 ASCII 字符保存为 UTF-8(或 ASCII - 归结为相同的字节流),然后将其读取为 UTF-16,则很可能不会引发任何异常。不过,您会得到一个 4 长的官话字符串。

我们来试试吧!

String test = "gerikg";
byte[] saveAsUtf8 = test.getBytes(StandardCharsets.UTF_8);
String readAsUtf16 = new String(saveAsUtf8, StandardCharsets.UTF_16);
System.out.println(test);
System.out.println(readAsUtf16);

... results in:

gerikg
来物歧

看到了吗?完整官方文档 - 不相关的汉字出来了。

但是,现在让我们选择奇数:

String test = "gerikgw";
byte[] saveAsUtf8 = test.getBytes(StandardCharsets.UTF_8);
String readAsUtf16 = new String(saveAsUtf8, StandardCharsets.UTF_16);
System.out.println(test);
System.out.println(readAsUtf16);

gerikgw
来物歧�

注意奇怪的问号:那是一个字形(字形是字体中的一个条目:用作表示某个字符的符号)表示:这里出了点问题 - 这不是真正的字符,而是解码错误。

但是,将 gerikgw 塞入文本文件(确保它没有尾随输入,因为那也是一个符号),以及 运行 您的代码,实际上 - JVM BUG!不错的发现!

替换

那个奇怪的问号符号是 'substitution'。 UTF 编码器可以对任何 32 位值进行编码。 unicode 系统有 32 位的可寻址字符(实际上,不完全是,它更少,一些槽被故意标记为未使用并且永远不会被使用,出于有趣的原因但太不相关而无法进入),但不是每一个可用的是 'filled'。如果我们以后需要新角色,还有新角色的空间。此外,并非每个字节序列都是有效的 UTF-8。

那么,当'invalid'检测到输入时该怎么办?在严格解析模式下,一种选择是崩溃(扔东西)。另一种方法是 'read' 错误作为 'error' 字符(当您将其打印到屏幕时显示为问号字形)并从我们中断的地方继续。 UTF 是一个非常酷的格式化系统,当一个新字符开始时 'knows',因此,你永远不会遇到偏移问题(我们 'offset by half' 并且由于未对齐而导致读取错误)。

JVM 错误

这解释了您粘贴的代码:根据评论,格式错误的编码内容 'cannot occur',因为宽松模式已打开,所以任何错误都应该导致替换。除了 它就在那里 ,这是一个非常愚蠢的错误,其中一个真正导致此代码的作者在可见和可听的情况下纯粹羞愧地拍打他们的前额:

在这种情况下,剩余字节序列中只剩下一个字节,但在 UTF-16 世界中,所有有效字节表示至少为 2 个字节。这种情况称为 下溢 并且解码器 (CharsetDecoder cd) 没有错误 - 它正确地检测到这种情况,因此,if (!cr.isUnderflow()) cr.throwException(); 导致 cr.throwException()正在执行,这自然会抛出 MalformedInputException,它是 CharacterCodingException 的子类型,因此,代码会直接跳到下面的 catch 4 行,然后说“这不可能发生”。

结论,作者脑洞大开。只有两件事可以是真的:

  1. 这里永远不会发生下溢。大脑放屁是那里有一个 if 检查不可能的事情,那是没有意义的。
  2. 此处可能会发生下溢,因此 catch 块中的注释不正确。替换不能解决这个问题。

正确的代码大概是:

private static int decodeWithDecoder(CharsetDecoder cd, char[] dst, byte[] src, int offset, int length) {
    ByteBuffer bb = ByteBuffer.wrap(src, offset, length);
    CharBuffer cb = CharBuffer.wrap(dst, 0, dst.length);
    try {
        CoderResult cr = cd.decode(bb, cb, true);
        if (!cr.isUnderflow())
            cr.throwException();
        cr = cd.flush(cb);
        if (!cr.isUnderflow()) cb.write(SUBSTITUTION_CHAR);
    } catch (CharacterCodingException x) {
        // Substitution is always enabled,
        // so this shouldn't happen
        throw new Error(x);
    }
    return cb.position();
}

换句话说 - 如果发生下溢,则发出一个替换字符(表示 'un-character' 由那个没有任何意义的悬空单字节表示),并且只是 return 结果。毕竟,这符合宽松模式的策略,评论说我们显然处于宽松模式(“启用替代”)。

我建议你在打开的 JDK 项目中提交错误,或者先搜索这个。

解决它直到它被修复...

解决方法

替换:

Files.readString(file, StandardCharsets.UTF_16);

与:

fixedReadString(file, StandardCharsets.UTF_16);

...

public static String fixedReadString(Path file, Charset charset) {
  try {
   Files.readString(file, StandardCharsets.UTF_16);
  } catch (Error e) {
    if (!(e.getCause() instanceof MalformedInputException)) throw e;
    // see notes
  }
}

剩下的一个问题是发生这种情况时您想做什么。输入肯定有问题,我一般鄙视'lenient'模式。所以我只是 throw new MalformedInputException 并且通常重写它以使用严格模式。但是,如果您想复制 intended 效果(即:"来物歧�" - 这没有用,但它 什么代码应该是 return),这实际上并不那么容易重新创建。你可以祈祷只要在末尾添加一个随机字符(比如 space)和 re-parsing 将有望至少产生 something,你可以重写整个Files.readString 本身的功能(不是 复杂),或者只是 return "�"; - 扔掉整个字符串,只留下一个替换字符,这至少应该有所帮助有人调试成:啊,对了,我使用了错误的字符集来读取这个文件。

这是 JDK17 中引入的错误。

在此版本之前,此 Error 抛出代码仅用于 String 构造函数,它确实永远不会遇到 CharacterCodingException 因为它将解码器配置为替换非法内容。

例如,当您使用

String s = new String(new byte[] { 50 }, StandardCharsets.UTF_16);
System.out.println(s.chars()
    .mapToObj(c -> String.format(" U+%04x", c)).collect(Collectors.joining("", s, "")));

你会得到

� U+fffd

在 JDK17 中,代码已重构并删除了代码重复。现在,相同的方法 decodeWithDecoder 将用于 String 构造函数和 Files.readString。但是 Files.readString 应该报告编码错误而不是替换有问题的内容。因此,解码器尚未配置为有意替换格式错误的内容。

当你运行

Path p = Files.write(Files.createTempFile("charset", "test"), new byte[] { 50 });
try(Closeable c = () -> Files.delete(p)) {
    String s = Files.readString(p, StandardCharsets.UTF_16);
}

在 JDK16 下,您将正确地得到

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.lang.StringCoding.newStringNoRepl1(StringCoding.java:1053)
        at java.base/java.lang.StringCoding.newStringNoRepl(StringCoding.java:1003)
        at java.base/java.lang.System.newStringNoRepl(System.java:2265)
        at java.base/java.nio.file.Files.readString(Files.java:3353)
        at first.test17.CharsetProblem.main(CharsetProblem.java:23)

now-removed 专用例程将 MalformedInputException 封装在 IllegalArgumentException 中。直接调用者看起来像

/*
 * Throws CCE, instead of replacing, if unmappable.
 */
static byte[] getBytesNoRepl(String s, Charset cs) throws CharacterCodingException {
    try {
        return getBytesNoRepl1(s, cs);
    } catch (IllegalArgumentException e) {
        //getBytesNoRepl1 throws IAE with UnmappableCharacterException or CCE as the cause
        Throwable cause = e.getCause();
        if (cause instanceof UnmappableCharacterException) {
            throw (UnmappableCharacterException)cause;
        }
        throw (CharacterCodingException)cause;
    }
}

问题就在这里。当代码被重构为对 String 构造函数和 Files.readString 使用相同的例程时,此调用程序未被改编。它仍然需要一个 IllegalArgumentException,而普通方法现在会抛出一个 Error。或者应该对通用方法进行调整以更好地适应这两种情况,例如通过有一个参数告诉 CharacterCodingException 异常是否应该是可能的。


值得注意的是,字符集解码代码对常用字符集进行了大量优化和快捷方式。这就是为什么您很少使用这种特定方法的原因。 UTF-16 似乎是一种(如果不是)使用这种方法的罕见情况。