CentOS下Java 10中RandomAccessFile.setLength性能骤降问题排查与解决
首先贴出测试代码,方便大家复现问题:
public class Main { public static void main(String[] args) throws IOException { File tmp = File.createTempFile("deleteme", "dat"); tmp.deleteOnExit(); RandomAccessFile raf = new RandomAccessFile(tmp, "rw"); for (int t = 0; t < 10; t++) { long start = System.nanoTime(); int count = 5000; for (int i = 1; i < count; i++) raf.setLength((i + t * count) * 4096); long time = System.nanoTime() - start; System.out.println("Average call time " + time / count / 1000 + " us."); } } }
问题现象回顾
这段代码在Java 8的tmpfs环境下表现极佳,平均调用时间仅0-1us;但在CentOS的Java 10环境中,setLength的耗时会随着文件体积增大持续飙升,从311us一路涨到5129us。而Windows 10环境下,Java 8和Java 10的性能都很稳定,没有明显上升趋势。通过strace追踪发现,核心差异在于:Java 8内部调用ftruncate系统调用,而Java 10改用了fallocate——后者在tmpfs上的耗时和文件长度正相关,远慢于ftruncate。
一、此类问题的排查思路
遇到这种跨版本、跨环境的性能差异,我通常会按以下步骤排查:
- 系统调用追踪:用
strace(Linux)或Process Monitor(Windows)跟踪JVM进程的系统调用,对比不同Java版本的底层调用差异。这是定位JVM实现变化最直接的方式,比如这次就是通过strace发现了ftruncate和fallocate的区别。 - JVM源码对比:去OpenJDK仓库查看对应版本的
RandomAccessFile或FileChannelImpl源码,对比Java 8和Java 10的实现逻辑,确认底层调用的变更原因。 - 环境隔离测试:在不同操作系统、不同文件系统(比如tmpfs、ext4、NTFS)下分别测试,缩小问题范围。比如这次发现只有CentOS的tmpfs环境出问题,说明和操作系统对系统调用的实现直接相关。
- 性能 profiling:用async-profiler、JProfiler等工具分析JVM方法耗时,明确是底层系统调用占了主要开销,而不是JVM层面的其他逻辑导致的性能下降。
二、Java 10下的高效替代方案
既然不想通过写入文件末尾(还需要加锁)的方式,这里分享几个更优雅的方案:
1. 可靠的反射+JNA调用ftruncate
之前的临时方案可以优化得更健壮,适配多数OpenJDK版本:
import com.sun.jna.Library; import com.sun.jna.Native; import java.io.FileDescriptor; import java.io.RandomAccessFile; import java.lang.reflect.Field; public class FtruncateHelper { private interface CLibrary extends Library { CLibrary INSTANCE = Native.load("c", CLibrary.class); int ftruncate(int fd, long length); } public static void safeSetLength(RandomAccessFile raf, long targetLength) throws Exception { // 反射获取RandomAccessFile的FileDescriptor实例 Field fdField = RandomAccessFile.class.getDeclaredField("fd"); fdField.setAccessible(true); FileDescriptor fd = (FileDescriptor) fdField.get(raf); // 反射获取FileDescriptor的整数文件描述符 Field intFdField = FileDescriptor.class.getDeclaredField("fd"); intFdField.setAccessible(true); int intFd = intFdField.getInt(fd); // 调用C库的ftruncate int result = CLibrary.INSTANCE.ftruncate(intFd, targetLength); if (result != 0) { throw new RuntimeException("ftruncate failed with error code: " + Native.getLastError()); } } }
注意:这个方案需要引入JNA依赖(比如Maven的net.java.dev.jna:jna:5.13.0),并且要考虑JDK版本的兼容性(比如某些商业JDK可能对字段做了混淆,但OpenJDK系列通用)。
2. 尝试使用FileChannel.truncate
部分Java 10版本中,FileChannel.truncate的底层实现可能和RandomAccessFile.setLength不同,可以测试一下这个替代方式:
try (RandomAccessFile raf = new RandomAccessFile(tmp, "rw")) { FileChannel channel = raf.getChannel(); // 用channel.truncate替代raf.setLength channel.truncate(targetLength); }
如果这个方法在你的CentOS tmpfs环境下仍然使用ftruncate,那这就是最优雅的方案,不需要任何反射或第三方依赖。
3. 配置JVM参数强制回退到ftruncate
某些OpenJDK版本提供了系统属性来控制是否使用fallocate,可以尝试设置以下参数启动JVM:
-Djdk.io.useFallback=true
具体参数需要参考你使用的Java 10版本的文档,这个参数可以让JVM在不支持高效fallocate的文件系统上自动回退到ftruncate。
4. 内存映射文件优化(针对tmpfs场景)
因为tmpfs本身是内存文件系统,你可以考虑使用MappedByteBuffer来操作文件,直接通过映射缓冲区扩展文件长度,性能会更接近内存操作:
try (RandomAccessFile raf = new RandomAccessFile(tmp, "rw")) { FileChannel channel = raf.getChannel(); // 直接映射到目标长度的缓冲区 MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, targetLength); // 确保缓冲区被初始化(tmpfs下可能不需要,视情况测试) buffer.put(targetLength - 1, (byte) 0); buffer.force(); }
这个方案适合大文件场景,性能表现会和Java 8的setLength接近。
内容的提问来源于stack exchange,提问作者Peter Lawrey

