Java基础IO

#字节流 #字符流

modified-time: 2026-01-06 21:07:23

字节流和字符流

  • 字符流文件: .txt、.json、.csv、.xml 等
  • 二进制文件(字节流):图片、视频、.pdf、.docx、.jar 等

本质上都是字节,只是字符流通过字符集编码,二进制文件不能直接当成文本去解码

读写字符流

  1. 处理流

    • BufferedReaderBufferedWriter 处理读取到的文件。
  2. 转换流

    • 使用 InputStreamReaderOutputStreamWriter 将读取到的字节流转换为正确编码的 字符流 需要指定明确的编码 可以直接字符串也可以使用 StandardCharsets.xxx
    new InputStreamReader(new FileInputStream(inputFile), StandardCharsets.UTF_8 );  
      
    new InputStreamReader(new FileInputStream(inputFile), "UTF-8");  
    

为什么不使用 FileReaderFileWriter? new FileReader(filePath) // 内部实际是 new InputStreamReader(new FileInputStream(…), 平台默认编码) 他们内部使用的是当前平台默认编码,无法显式指定读取写入的编码方式。 结论:永远不要用 FileReader / FileWriter,改用 FileInputStream + InputStreamReader(指定编码)

  1. 节点流 FileInputStreamFileOutputSteam 直接读取
//  示例
try (BufferedReader reader = new BufferedReader(
		new InputStreamReader(new FileInputStream(inputFile), StandardCharsets.UTF_8));
	BufferedWriter writer = new BufferedWriter(
	new OutputStreamWriter(new FileOutputStream(outputFile),StandardCharsets.UTF_8))) {
	// do sth...
	}catch (IOException e) {
		e.printStackTrace();
	}

读写字节流(二进制)

  1. 处理流
    • BufferedInputStreamBufferedOutputStream
    • 数据类型读写:DataInputStreamDataOutputStream(读写 int、double 等基本类型)
    • 对象序列化:ObjectInputStreamObjectOutputStream
  2. 转换流 (因为是二进制文件所以可以不使用转换流)
  3. 字节流
    • FileInputStreamFileOutputStream
//  示例
try (BufferedInputStream reader = new BufferedInputStream(new FileInputStream(inputPath));
	BufferedOutputStream writer = new BufferedOutputStream(new FileOutputStream(outputPath))) {
	// do sth...
	}catch (IOException e) {
		e.printStackTrace();
	}

缓存使用

使用缓存可以手动控制单次读入的数据量可以避免:

  1. 如果单行长文本使用 readLine() 可能 GC OOM
  2. 如果是多行但换行频率高会不断循环增加网络 RTT 硬盘 IO 等
  3. 如果是字节流使用 readAllBytes() 读取大文件也会导致内存被大量占用降低效率甚至 OOM. 使用步骤:
  • 字节流:

    byte[] buffer = new byte[81920];
    while ((len = reader.read(buffer)) != -1) { 
    	writer.write(buffer, 0, len);
    }
    
  • 字符流

    char[] buffer = new char[8192];
    while ((len = reader.read(buffer)) != -1) { //如果是 readLine 的话用 != null
    	writer.write(buffer, 0, len);
    }
    
  1. 缓冲区长度陷阱(核心):
    • 注意你的 writer.write(buffer, 0, len)。
    • 使用 BufferedInputStream/BufferedReader 时,它们 内部本来就有一层缓冲,默认大小 8KB;
    • 思考: buffer 的长度是 81920。如果文件的最后一次读取只剩 100 个字节,那么 len 就会等于 100。
    • 如果写成 writer.write(buffer),程序会将整个 81920 字节全部写入文件,导致 文件末尾多出大量的空白或旧数据残余。这就是为什么要用 len 的原因。

流家族 抽象基类 常见节点流示例 常见处理流示例
字节流 InputStream / OutputStream FileInputStream / FileOutputStream BufferedInputStream / BufferedOutputStream, DataInputStream / DataOutputStream, ObjectInputStream / ObjectOutputStream
字符流 Reader / Writer FileReader / FileWriter BufferedReader / BufferedWriter, InputStreamReader / OutputStreamWriter

RandomAccessFile

用于解决无法随机访问读写的问题而诞生,但因为其比较底层,现在被 FileChannel + MappedByteBuffer 代替

  • RAF 的本质: 它是一个带“指针(File Pointer)”的状态机。
  • 并发冲突点: 指针是共享资源。多线程必须通过 new 多个 RAF 实例来实现“逻辑上的无状态”,或者在单实例下使用 synchronized(但会退化为单线程)。

如何使用

// 创建
RandomAccessFile raf = new RandomAccessFile(inputPath, mode); 
//基础读写
raf.read(buffer);
raf.write(buffer, start, end);

//获取文件长度与申请空间
raf.length();
raf.setLength();
// 预分配是防止文件碎片化和频繁扩容的关键

//刷盘
raf.getFD.sync();

❓ 提问

  1. 为什么这里不需要向上面一样包装 BufferedInput/OutputStream

    BufferedOutputStream 的存在是为了给那些“一次只写一个字节/少量字节”,进行缓冲减少频繁直接调用 native 方法写入。它在内存里开辟一个 8KB 的数组,凑够了再调一次系统内核(System Call)。

  2. 为什么要避免多次直接调用系统内核去写入?

    调用系统内核需要从用户态转变为内核态,该过程耗费大量时间会消耗 CPU 周期,要减少不必要的损耗。 这也就是为什么在非强一致性操作中常使用 raf.getFD.sync() 而不使用 RandomAccessFile(inputPath, "rwd/rws");

  3. 为什么要刷盘?

    write() 成功不代表数据落盘,只代表数据进入了内核缓存。

  4. 如果我在 FileOutputStream 套了 BufferedputStream 我每次读取少量数据后就 write 是否会让其失效?

    只要 buffer 没满、没手动 flush、没 close,数据就停留在用户态的 byte [] 数组里,不触发系统调用。

触发时机 是否切换内核态
write(b) 但 buffer 未满 ❌ 否,纯内存操作
write(b) 导致 buffer 满了 ✅ 是,自动调用底层 write
手动调用 flush() ✅ 是
调用 close() ✅ 是(内部先 flush)

实战示例(多线程复制文件)

要求:

  • 使用 RAF 读取原数据获取长度,预分配空间,为每个子线程分配任务
  • 使用线程池和 CompletableFuture 完成子线程调用与任务执行
  • 使子线程只工作在自己被分配到的区间内
try (RandomAccessFile raf = new RandomAccessFile(input, "rw")) {
            //获取长度,为预分配空间和切片做准备
            long length = raf.length();
            //根据线程数量切片
            long splitSize = length / 16;
            //分配空间后写入刷盘
            raf.setLength(length);
            raf.getFD().sync();
            ThreadPoolExecutor executor = threadpool();

            try {
                List<CompletableFuture<String>> cf = new ArrayList<>();
				
				//步长为每个子线程获得的工作量
                for (long i = 0; i < length; i += splitSize) {

                    if (i > length) {
                        break;
                    }
                    long start = i;
                    //如果剩余数据不足分片大小,则只设置剩余大小
                    long end = i + splitSize > length ? length : i + splitSize;
                    cf.add(CompletableFuture.supplyAsync(() -> singleCopy(new Task(start, end, input, output)),
                            executor));

                }

                CompletableFuture.allOf(cf.toArray(CompletableFuture[]::new)).join();
            } finally {
                executor.shutdown();
                try {
                    if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                        executor.shutdownNow();
                    }
                } catch (InterruptedException e) {
                    executor.shutdownNow();
                    Thread.currentThread().interrupt();
                }
            }

        }
    }
class Task {
    public long startOffset;
    public long endOffset;
    public String inputPath;
    public String outputPath;
   


    public Task(long startOffset, long endOffset, String inputPath, String outputPath) {
        this.startOffset = startOffset;
        this.endOffset = endOffset;
        this.inputPath = inputPath;
        this.outputPath = outputPath;

    }

}
public static String singleCopy(Task task) {
        try (RandomAccessFile read = new RandomAccessFile(task.inputPath, "r");
                RandomAccessFile target = new RandomAccessFile(task.outputPath, "rw")) {
            //设置读取和写入的偏移量,也就是开始坐标
            read.seek(task.startOffset);
            target.seek(task.startOffset);
            
            int len;
            byte[] buffer = new byte[81920];
            //使用局部变量记录当前读取到的位置
            long currentPos = task.startOffset;
            while (currentPos < task.endOffset) { 
	            //如果剩余量小于 buffer 里读取到的长度,则只读取 剩余工作量
	            //避免污染其他线程的工作区间
                long remaining = task.endOffset - currentPos;
                int maxRead = (int) Math.min(buffer.length, remaining);

                len = read.read(buffer, 0, maxRead);
                if (len == -1) {
                    break;
                }
                target.write(buffer, 0, len);
                currentPos += len;
            }
            target.getFD().sync();
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        return "success!";
    }

⚠️ 注意 为什么这里不使用 getFilePointer() 去直接获取当前偏移量位置,而是使用局部变量? 答案依旧是 getFilePointer() 是用的 native 方法,会从用户态切换到内核态

Reference