Java 8 中 BufferedWriter 写入的字符串为何偶尔显示为空白或 NUL?
结论
现有代码无法证明 Java 8 或 BufferedWriter 会随机把正确的字符串写成 NUL。先确认异常位置实际是 0x00、空格 0x20,还是字符串没有出现在预期偏移处。
在单线程、独占文件且没有其他写入者的前提下,先执行 fileWriter.write(str) 和 fileWriter.flush(),再通过底层流写入图像字节,这一顺序可以成立。混用两层流会增加维护难度,但仅凭这一点还不能认定故障原因。
建议先检查文件字节和并发访问情况,再将输出统一为一个字节流,明确文本编码和记录长度。
日志正确为什么仍不能确认文件正确?
日志只能说明记录日志时 str 的内容正确,不能确认后续使用的是哪个输出流,也不能证明其他线程或进程没有同时修改文件。
还要区分以下几种现象:
- Java 的
null引用:传给BufferedWriter.write(String)会抛出NullPointerException,不会自动写成一串零字节。 - NUL 字节:十六进制值为
00,在二进制图像中可能正常出现。 - 空格:十六进制值为
20,原代码明确使用它填充图像块。 - 字符串缺失或偏移错误:预期的头部位置可能实际落在图像数据内。
因此,Notepad++ 中显示为空白,不能单独作为“字符串被写成 NUL”的证据。
先按字节确认异常位置
保留一份正常文件和一份异常文件,按格式计算每个文本头部的起始偏移,再用十六进制查看器比较:
- 异常区域究竟包含
00、20,还是其他图像字节。 - 预期字符串是否出现在附近的其他偏移处。
- 异常记录前后的长度是否符合格式。
- 文件是否已有历史内容,或包含其他任务写入的数据。
如果格式没有额外分隔符,一条记录的字节长度应为:
记录字节长度 = 文本头部编码后的字节数 + BLOCK_SIZE_IMAGE
原描述中的“12455 个字符”需要进一步核实:文件格式约束的是字符数还是字节数?图像部分是原始字节,不能按文本字符数定位。如果文本头部长度变化,也不能简单地按固定步长查找。
日志中可以增加任务标识、线程名、输出文件绝对路径、块编号和头部编码后的字节长度。偏移应按实际输出的字节数累计,不要用 str.length() 代替。
检查共享流和同路径并发追加
这段代码使用了追加模式:
new FileOutputStream(getPathExit(), true)
它会保留已有文件内容。需要确认每次执行是否确实应该追加,以及多个任务是否可能得到相同的输出路径。
代码中没有显示 outputFileStream 和 fileWriter 的声明位置。如果它们是可被并发调用访问的实例字段,一个任务可能在另一个任务写入期间替换字段引用。此时日志中的字符串仍然正确,但实际使用的输出流可能已经变化。
即使每个任务都有自己的流,只要它们同时向同一个文件追加数据,其他任务的数据仍可能插入头部和图像块的两次写入之间。flush() 不会把整条记录变成一个不可分割的写入事务。
建议将流设为方法局部变量,并让一个文件由单一写入者负责。如果必须共享文件,应协调所有写入者;仅对某个 BufferedWriter 加锁,无法覆盖其他流或其他进程。
明确编码,并统一使用字节流
原代码没有指定字符集:
new OutputStreamWriter(outputFileStream)
它使用运行环境的默认字符集。机器或运行配置变化后,不能假定默认编码仍然相同。不过,编码差异本身不足以解释偶发缺失,还需要结合字节证据判断。
对于文本头部加二进制载荷的格式,可以先将头部编码成字节,再通过同一个 OutputStream 顺序写入。下面的方法可替换原循环中的字符串写入、填充和图像块写入部分。
示例假设文件协议要求头部使用 US_ASCII,并要求每个图像块填充到 block.length 字节。实际编码应以文件协议为准。
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
private static void writeBlock(
OutputStream out,
String outputData,
int total,
int orderNumberImg,
byte[] block,
int bytesRead) throws IOException {
if (bytesRead < 0 || bytesRead > block.length) {
throw new IllegalArgumentException("Invalid bytesRead");
}
String header = outputData
+ String.format(Locale.ROOT, "%08d%03d",
total, orderNumberImg);
ByteBuffer encoded = StandardCharsets.US_ASCII
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(header));
byte[] headerBytes = new byte[encoded.remaining()];
encoded.get(headerBytes);
Arrays.fill(block, bytesRead, block.length, (byte) 0x20);
out.write(headerBytes);
out.write(block);
}
示例中,无法编码的字符会直接触发异常,便于发现头部与协议不符的情况。Locale.ROOT 用于避免数字格式受默认 Locale 影响。这些设置明确了格式约束,但不能作为已经找到故障原因的证据。
在原来的分块循环中调用:
writeBlock(out, outputData, total, orderNumberImg, block, bytesRead);
orderNumberImg++;
out 应在整个文件写入期间保持为同一个局部变量,并使用 try-with-resources 管理。例如,下面是资源结构示意,原有的页面循环和分块循环应放在 try 内:
try (OutputStream out = new java.io.BufferedOutputStream(
new java.io.FileOutputStream(getPathExit(), true))) {
// 在此执行原有页面循环和分块循环,调用 writeBlock(...)
}
示例保留了原来的追加模式。如果业务要求每次生成一个全新文件,应按实际需求调整,避免误将旧文件内容当成当前输出。
还有哪些细节需要修正?
%08d 和 %03d 指定的是最小宽度,数值超出宽度时不会截断。如果文件格式要求固定长度,应检查 total、orderNumberImg 的范围,并验证最终头部的字节长度。
原代码先关闭 outputFileStream,再关闭 fileWriter,资源关闭顺序也应调整。保留 Writer 方案时应关闭外层 Writer,由它完成刷新并关闭底层流;统一为字节流后,只需管理最外层输出流。
逐块 flush() 可以将缓冲数据提交给底层流,但不能保证整条记录不被其他写入者打断,也不等于数据已经持久化到物理介质。是否逐块刷新,应由业务需求决定。
图像资源也应及时释放:关闭 ImageInputStream,并在使用完毕后调用 reader.dispose()。还需要检查 ImageIO.write(...) 的布尔返回值;返回 false 表示没有找到适用的图像写入器,应报告失败,避免继续生成缺少图像数据的文件。
目前需要收集的证据包括异常文件的实际字节、准确的记录偏移,以及所有可能访问同一输出路径的任务信息。测试环境内存更少但无法复现,不能据此认定或排除内存因素;同样,也不能仅凭升级时间就认定是 Java 8 的缺陷。
备注:内容仅供参考。
