【Java开发】Java IO详解

基础

Java IO(输入/输出)是 Java 编程语言中处理输入和输出操作的一部分,它位于 java.io 包中。这个包提供了丰富的类和接口,用于读写数据到文件、网络连接或其他源。

Java IO 的主要组成

Java IO 主要可以分为两大类:流(Stream)和读写器(Reader/Writer)。其实也就是按照传输方式划分可以分为:按字节传输、按字符传输,通常来说,一个字母或一个字符占用一个字节,一个汉字占用两个字节,具体还要看字符编码,比如说在 UTF-8 编码下,一个英文字母(不分大小写)为一个字节,一个中文汉字为三个字节;在 Unicode 编码中,一个英文字母为一个字节,一个中文汉字为两个字节。

字节流用来处理二进制文件,比如说图片啊、MP3 啊、视频啊。
字符流用来处理文本文件,文本文件可以看作是一种特殊的二进制文件,只不过经过了编码,便于人们阅读。
换句话说就是,字节流可以处理一切文件,而字符流只能处理文本。字节流本身没有缓冲区,缓冲字节流相对于字节流,效率提升非常高。而字符流本身就带有缓冲区,缓冲字符流相对于字符流效率提升就不是那么大了。

  1. 流(Stream)

    • 字节流:以字节为单位处理数据。主要类包括 InputStreamOutputStream
    • 过滤流:在输入或输出流上提供额外的功能,如缓冲、数据转换等。常见的过滤流包括 BufferedInputStreamBufferedOutputStream
  2. 读写器(Reader/Writer)

    • 字符流:以字符为单位处理数据,更适合处理文本数据。主要类包括 ReaderWriter
    • 转换流InputStreamReaderOutputStreamWriter,它们可以将字节流和字符流之间进行转换。

InputStream 类

  • int read():读取数据
  • int read(byte b[], int off, int len):从第 off 位置开始读,读取 len 长度的字节,然后放入数组 b 中
  • long skip(long n):跳过指定个数的字节
  • int available():返回可读的字节数
  • void close():关闭流,释放资源

OutputStream 类

  • void write(int b): 写入一个字节,虽然参数是一个 int 类型,但只有低 8 位才会写入,高 24 位会舍弃
  • void write(byte b[], int off, int len): 将数组 b 中的从 off 位置开始,长度为 len 的字节写入
  • void flush(): 强制刷新,将缓冲区的数据写入
  • void close():关闭流

Reader 类

  • int read():读取单个字符
  • int read(char cbuf[], int off, int len):从第 off 位置开始读,读取 len 长度的字符,然后放入数组 b 中
  • long skip(long n):跳过指定个数的字符
  • int ready():是否可以读了
  • void close():关闭流,释放资源

Writer 类

  • void write(int c): 写入一个字符
  • `void write( char cbuf[], int off, int len): 将数组 cbuf 中的从 off 位置开始,长度为 len 的字符写入
  • void flush(): 强制刷新,将缓冲区的数据写入
  • void close():关闭流

常用的 Java IO 类

  • File:代表文件和目录路径名的抽象表示形式。
  • FileInputStream/FileOutputStream:用于读取和写入文件数据的字节流。
  • BufferedReader/BufferedWriter:提供缓冲的字符流,增加效率。
  • PrintWriter:具有自动行刷新的字符输出流,可以输出不同数据类型的格式化表示。

示例代码

下面是使用 Java IO 类进行文件读写的基本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.*;

public class JavaIOExample {
public static void main(String[] args) {
// 写入文件
try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter("output.txt")))) {
writer.println("Hello Java IO!");
writer.println("Here is the second line.");
} catch (IOException e) {
e.printStackTrace();
}

// 读取文件
try (BufferedReader reader = new BufferedReader(new FileReader("output.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

在算法中的使用

Java IO 在算法中常用于数据的读取和写入,比如从文件读取数据进行处理,或将处理结果写入文件。例如,在数据分析、日志处理和大数据处理中,频繁的文件读写操作是必不可少的。使用缓冲流(如 BufferedReaderBufferedWriter)可以显著提高读写效率。

操作对象划分


文件操作算是 IO 中最典型的操作了,也是最频繁的操作。那其实你可以换个角度来思考,比如说按照 IO 的操作对象来思考,IO 就可以分类为:文件、数组、管道、基本数据类型、缓冲、打印、对象序列化/反序列化,以及转换等。

Java Flie

java.io.File 类是专门对文件进行操作的类,注意只能对文件本身进行操作,不能对文件内容进行操作,想要操作内容,必须借助输入输出流。

构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件路径名
String path = "/Users/username/123.txt";
File file1 = new File(path);
// 文件路径名
String path2 = "/Users/username/1/2.txt";
File file2 = new File(path2); -------------相当于/Users/username/1/2.txt
// 通过父路径和子路径字符串
String parent = "/Users/username/aaa";
String child = "bbb.txt";
File file3 = new File(parent, child); --------相当于/Users/username/aaa/bbb.txt
// 通过父级File对象和子路径字符串
File parentDir = new File("/Users/username/aaa");
String child = "bbb.txt";
File file4 = new File(parentDir, child); --------相当于/Users/username/aaa/bbb.txt
  1. 一个 File 对象代表硬盘中实际存在的一个文件或者目录。
  2. File 类的构造方法不会检验这个文件或目录是否真实存在,因此无论该路径下是否存在文件或者目录,都不影响 File 对象的创建。

常用方法

File 的常用方法主要分为获取功能、获取绝对路径和相对路径、判断功能、创建删除功能的方法。

  1. 获取功能的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    File f = new File("/Users/username/aaa/bbb.java");
    System.out.println("文件绝对路径:"+f.getAbsolutePath());
    System.out.println("文件构造路径:"+f.getPath());
    System.out.println("文件名称:"+f.getName());
    System.out.println("文件长度:"+f.length()+"字节");

    File f2 = new File("/Users/username/aaa");
    System.out.println("目录绝对路径:"+f2.getAbsolutePath());
    System.out.println("目录构造路径:"+f2.getPath());
    System.out.println("目录名称:"+f2.getName());
    System.out.println("目录长度:"+f2.length());
  2. 绝对路径和相对路径
    1
    2
    3
    4
    5
    6
    7
    // 绝对路径示例
    File absoluteFile = new File("/Users/username/example/test.txt");
    System.out.println("绝对路径:" + absoluteFile.getAbsolutePath());

    // 相对路径示例
    File relativeFile = new File("example/test.txt");
    System.out.println("相对路径:" + relativeFile.getPath());
  3. 判断功能的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    File file = new File("/Users/username/example");

    // 判断文件或目录是否存在
    if (file.exists()) {
    System.out.println("文件或目录存在");
    } else {
    System.out.println("文件或目录不存在");
    }

    // 判断是否是目录
    if (file.isDirectory()) {
    System.out.println("是目录");
    } else {
    System.out.println("不是目录");
    }

    // 判断是否是文件
    if (file.isFile()) {
    System.out.println("是文件");
    } else {
    System.out.println("不是文件");
    }
  4. 创建、删除功能的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 创建文件
    File file = new File("/Users/username/example/test.txt");
    if (file.createNewFile()) {
    System.out.println("创建文件成功:" + file.getAbsolutePath());
    } else {
    System.out.println("创建文件失败:" + file.getAbsolutePath());
    }

    // 删除文件
    if (file.delete()) {
    System.out.println("删除文件成功:" + file.getAbsolutePath());
    } else {
    System.out.println("删除文件失败:" + file.getAbsolutePath());
    }

    // 创建多级目录
    File directory = new File("/Users/username/example/subdir1/subdir2");
    if (directory.mkdirs()) {
    System.out.println("创建目录成功:" + directory.getAbsolutePath());
    } else {
    System.out.println("创建目录失败:" + directory.getAbsolutePath());
    }
  5. 遍历目录
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    File directory = new File("/Users/itwanger/Documents/Github/paicoding");

    // 列出目录下的文件名
    String[] files = directory.list();
    System.out.println("目录下的文件名:");
    for (String file : files) {
    System.out.println(file);
    }

    // 列出目录下的文件和子目录
    File[] filesAndDirs = directory.listFiles();
    System.out.println("目录下的文件和子目录:");
    for (File fileOrDir : filesAndDirs) {
    if (fileOrDir.isFile()) {
    System.out.println("文件:" + fileOrDir.getName());
    } else if (fileOrDir.isDirectory()) {
    System.out.println("目录:" + fileOrDir.getName());
    }
    }
  6. 递归遍历
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public static void main(String[] args) {
    File directory = new File("/Users/itwanger/Documents/Github/paicoding");

    // 递归遍历目录下的文件和子目录
    traverseDirectory(directory);
    }

    public static void traverseDirectory(File directory) {
    // 列出目录下的所有文件和子目录
    File[] filesAndDirs = directory.listFiles();

    // 遍历每个文件和子目录
    for (File fileOrDir : filesAndDirs) {
    if (fileOrDir.isFile()) {
    // 如果是文件,输出文件名
    System.out.println("文件:" + fileOrDir.getName());
    } else if (fileOrDir.isDirectory()) {
    // 如果是目录,递归遍历子目录
    System.out.println("目录:" + fileOrDir.getName());
    traverseDirectory(fileOrDir);
    }
    }
    }

RandomAccessFile

RandomAccessFile 是 Java 中一个非常特殊的类,它既可以用来读取文件,也可以用来写入文件。与其他 IO 类(如 FileInputStream 和 FileOutputStream)不同,RandomAccessFile 允许您跳转到文件的任何位置,从那里开始读取或写入。这使得它特别适用于需要在文件中随机访问数据的场景,如数据库系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.io.IOException;
import java.io.RandomAccessFile;

public class RandomAccessFileDemo {

public static void main(String[] args) {
String filePath = "logs/javabetter/itwanger.txt";

try {
// 使用 RandomAccessFile 写入文件
writeToFile(filePath, "Hello, 沉默王二!");

// 使用 RandomAccessFile 读取文件
String content = readFromFile(filePath);
System.out.println("文件内容: " + content);
} catch (IOException e) {
e.printStackTrace();
}
}

private static void writeToFile(String filePath, String content) throws IOException {
try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "rw")) {
// 将文件指针移动到文件末尾(在此处追加内容)
randomAccessFile.seek(randomAccessFile.length());

// 写入内容
randomAccessFile.writeUTF(content);
}
}

private static String readFromFile(String filePath) throws IOException {
StringBuilder content = new StringBuilder();

try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "r")) {
// 将文件指针移动到文件开始处(从头开始读取)
randomAccessFile.seek(0);

content.append(randomAccessFile.readUTF());
}

return content.toString();
}
}

RandomAccessFile 主要有两个构造方法:

  • RandomAccessFile(File file, String mode):使用给定的文件对象和访问模式创建一个新的 RandomAccessFile 实例。
  • RandomAccessFile(String name, String mode):使用给定的文件名和访问模式创建一个新的 RandomAccessFile 实例。

访问模式 mode 的值可以是:

  • “r”:以只读模式打开文件。调用结果对象的任何 write 方法都将导致 IOException。
  • “rw”:以读写模式打开文件。如果文件不存在,它将被创建。
  • “rws”:以读写模式打开文件,并要求对内容或元数据的每个更新都被立即写入到底层存储设备。这种模式是同步的,可以确保在系统崩溃时不会丢失数据。
  • “rwd”:与“rws”类似,以读写模式打开文件,但仅要求对文件内容的更新被立即写入。元数据可能会被延迟写入。

主要方法

  • long getFilePointer():返回文件指针的当前位置。
  • long length():返回此文件的长度。
  • int read():从该文件中读取一个字节数据。
  • int read(byte[] b):从该文件中读取字节数据并将其存储到指定的字节数组中。
  • int read(byte[] b, int off, int len):从该文件中读取字节数据并将其存储到指定的字节数组中,从偏移量 off 开始,最多读取 len 个字节。
  • String readLine():从该文件中读取一行文本。
  • readUTF():从文件读取 UTF-8 编码的字符串。此方法首先读取两个字节的长度信息,然后根据这个长度读取字符串的 UTF-8 字节。最后,这些字节被转换为 Java 字符串。这意味着当你使用 readUTF 方法读取字符串时,需要确保文件中的字符串是使用 writeUTF 方法写入的,这样它们之间的长度信息和编码方式才能保持一致。
  • void seek(long pos):将文件指针设置到文件中的 pos 位置。
  • void write(byte[] b):将指定的字节数组的所有字节写入该文件。
  • void write(byte[] b, int off, int len):将指定字节数组的部分字节写入该文件,从偏移量 off 开始,写入 len 个字节。
  • void write(int b):将指定的字节写入该文件。
  • writeUTF(String str):将一个字符串以 UTF-8 编码写入文件。此方法首先写入两个字节的长度信息,表示字符串的 UTF-8 字节长度,然后写入 UTF-8 字节本身。因此,当你使用 writeUTF 写入字符串时,实际写入的字节数会比字符串的 UTF-8 字节长度多两个字节。这两个字节用于在读取字符串时确定正确的字符串长度。

再来看一个示例,结合前面的讲解,就会彻底掌握 RandomAccessFile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
File file = new File("logs/javabetter/itwanger.txt");

try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
// 写入文件
raf.writeUTF("Hello, 沉默王二!");

// 将文件指针移动到文件开头
raf.seek(0);

// 读取文件内容
String content = raf.readUTF();
System.out.println("内容: " + content);

} catch (IOException e) {
e.printStackTrace();
}

Apache FileUtils 类

FileUtils 类是 Apache Commons IO 库中的一个类,提供了一些更为方便的方法来操作文件或目录。

1)复制文件或目录:

1
2
3
4
5
6
File srcFile = new File("path/to/src/file");
File destFile = new File("path/to/dest/file");
// 复制文件
FileUtils.copyFile(srcFile, destFile);
// 复制目录
FileUtils.copyDirectory(srcFile, destFile);

2)删除文件或目录:

1
2
3
File file = new File("path/to/file");
// 删除文件或目录
FileUtils.delete(file);

需要注意的是,如果要删除一个非空目录,需要先删除目录中的所有文件和子目录。

3)移动文件或目录:

1
2
3
4
File srcFile = new File("path/to/src/file");
File destFile = new File("path/to/dest/file");
// 移动文件或目录
FileUtils.moveFile(srcFile, destFile);

4)查询文件或目录的信息:

1
2
3
4
5
6
7
File file = new File("path/to/file");
// 获取文件或目录的修改时间
Date modifyTime = FileUtils.lastModified(file);
// 获取文件或目录的大小
long size = FileUtils.sizeOf(file);
// 获取文件或目录的扩展名
String extension = FileUtils.getExtension(file.getName());

Hutool FileUtil 类

FileUtil 类是 Hutool 工具包中的文件操作工具类,提供了一系列简单易用的文件操作方法,可以帮助 Java 开发者快速完成文件相关的操作任务。

FileUtil 类包含以下几类操作工具:

  • 文件操作:包括文件目录的新建、删除、复制、移动、改名等
  • 文件判断:判断文件或目录是否非空,是否为目录,是否为文件等等。
  • 绝对路径:针对 ClassPath 中的文件转换为绝对路径文件。
  • 文件名:主文件名,扩展名的获取
  • 读操作:包括 getReader、readXXX 操作
  • 写操作:包括 getWriter、writeXXX 操作

下面是 FileUtil 类中一些常用的方法:

1、copyFile:复制文件。该方法可以将指定的源文件复制到指定的目标文件中。

1
2
File dest = FileUtil.file("FileUtilDemo2.java");
FileUtil.copyFile(file, dest);

2、move:移动文件或目录。该方法可以将指定的源文件或目录移动到指定的目标文件或目录中。

1
FileUtil.move(file, dest, true);

3、del:删除文件或目录。该方法可以删除指定的文件或目录,如果指定的文件或目录不存在,则会抛出异常。

1
FileUtil.del(file);

4、rename:重命名文件或目录。该方法可以将指定的文件或目录重命名为指定的新名称。

1
FileUtil.rename(file, "FileUtilDemo3.java", true);

5、readLines:从文件中读取每一行数据。

1
FileUtil.readLines(file, "UTF-8").forEach(System.out::println);

Java 缓冲流

Java 的缓冲流是对字节流和字符流的一种封装,通过在内存中开辟缓冲区来提高 I/O 操作的效率。Java 通过 BufferedInputStream 和 BufferedOutputStream 来实现字节流的缓冲,通过 BufferedReader 和 BufferedWriter 来实现字符流的缓冲。

缓冲流的工作原理是将数据先写入缓冲区中,当缓冲区满时再一次性写入文件或输出流,或者当缓冲区为空时一次性从文件或输入流中读取一定量的数据。这样可以减少系统的 I/O 操作次数,提高系统的 I/O 效率,从而提高程序的运行效率。

字节缓冲流

BufferedInputStream 和 BufferedOutputStream 属于字节缓冲流,强化了字节流 InputStream 和 OutputStream,关于字节流,我们前面已经详细地讲过了,可以戳这个链接去温习。

1)构造方法

  • BufferedInputStream(InputStream in) :创建一个新的缓冲输入流,注意参数类型为InputStream
  • BufferedOutputStream(OutputStream out): 创建一个新的缓冲输出流,注意参数类型为OutputStream

代码示例如下:

1
2
3
4
5
6
7
8
9
// 创建字节缓冲输入流,先声明字节流
FileInputStream fps = new FileInputStream(b.txt);
BufferedInputStream bis = new BufferedInputStream(fps)

// 创建字节缓冲输入流(一步到位)
BufferedInputStream bis = new BufferedInputStream(new FileInputStream("b.txt"));

// 创建字节缓冲输出流(一步到位)
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("b.txt"));

2)缓冲流的高效

我们通过复制一个 370M+ 的大文件,来测试缓冲流的效率。为了做对比,我们先用基本流来实现一下,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
try (FileInputStream fis = new FileInputStream("py.mp4");//exe文件够大
FileOutputStream fos = new FileOutputStream("copyPy.mp4")){
// 读写数据
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("普通流复制时间:"+(end - start)+" 毫秒");

不好意思,我本机比较菜,10 分钟还在复制中。切换到缓冲流试一下,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("py.mp4"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copyPy.mp4"));){
// 读写数据
int b;
while ((b = bis.read()) != -1) {
bos.write(b);
}
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("缓冲流复制时间:"+(end - start)+" 毫秒");

只需要 8016 毫秒,如何更快呢?

可以换数组的方式来读写,这个我们前面也有讲到,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("py.mp4"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copyPy.mp4"));){
// 读写数据
int len;
byte[] bytes = new byte[8*1024];
while ((len = bis.read(bytes)) != -1) {
bos.write(bytes, 0 , len);
}
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("缓冲流使用数组复制时间:"+(end - start)+" 毫秒");

这下就更快了,只需要 521 毫秒。

3)为什么字节缓冲流会这么快?

传统的 Java IO 是阻塞模式的,它的工作状态就是“读/写,等待,读/写,等待。。。。。。”

字节缓冲流解决的就是这个问题:一次多读点多写点,减少读写的频率,用空间换时间

  • 减少系统调用次数:在使用字节缓冲流时,数据不是立即写入磁盘或输出流,而是先写入缓冲区,当缓冲区满时再一次性写入磁盘或输出流。这样可以减少系统调用的次数,从而提高 I/O 操作的效率。
  • 减少磁盘读写次数:在使用字节缓冲流时,当需要读取数据时,缓冲流会先从缓冲区中读取数据,如果缓冲区中没有足够的数据,则会一次性从磁盘或输入流中读取一定量的数据。同样地,当需要写入数据时,缓冲流会先将数据写入缓冲区,如果缓冲区满了,则会一次性将缓冲区中的数据写入磁盘或输出流。这样可以减少磁盘读写的次数,从而提高 I/O 操作的效率。
  • 提高数据传输效率:在使用字节缓冲流时,由于数据是以块的形式进行传输,因此可以减少数据传输的次数,从而提高数据传输的效率。

我们来看 BufferedInputStream 的 read 方法:

1
2
3
4
5
6
7
8
public synchronized int read() throws IOException {
if (pos >= count) { // 如果当前位置已经到达缓冲区末尾
fill(); // 填充缓冲区
if (pos >= count) // 如果填充后仍然到达缓冲区末尾,说明已经读取完毕
return -1; // 返回 -1 表示已经读取完毕
}
return getBufIfOpen()[pos++] & 0xff; // 返回当前位置的字节,并将位置加 1
}

这段代码主要有两部分:

  • fill():该方法会将缓冲 buf 填满。
  • getBufIfOpen()[pos++] & 0xff:返回当前读取位置 pos 处的字节(getBufIfOpen()返回的是 buffer 数组,是 byte 类型),并将其与 0xff 进行位与运算。这里的目的是将读取到的字节 b 当做无符号的字节处理,因为 Java 的 byte 类型是有符号的,而将 b 与 0xff 进行位与运算,就可以将其转换为无符号的字节,其范围为 0 到 255。

byte & 0xFF 我们一会再细讲。

再来看 FileInputStream 的 read 方法:

在这段代码中,read0() 方法是一个本地方法,它的实现是由底层操作系统提供的,并不是 Java 语言实现的。在不同的操作系统上,read0() 方法的实现可能会有所不同,但是它们的功能都是相同的,都是用于读取一个字节

再来看一下 BufferedOutputStream 的 write(byte b[], int off, int len) 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public synchronized void write(byte b[], int off, int len) throws IOException {
if (len >= buf.length) { // 如果写入的字节数大于等于缓冲区长度
/* 如果请求的长度超过了输出缓冲区的大小,
先刷新缓冲区,然后直接将数据写入。
这样可以避免缓冲流级联时的问题。*/
flushBuffer(); // 先刷新缓冲区
out.write(b, off, len); // 直接将数据写入输出流
return;
}
if (len > buf.length - count) { // 如果写入的字节数大于空余空间
flushBuffer(); // 先刷新缓冲区
}
System.arraycopy(b, off, buf, count, len); // 将数据拷贝到缓冲区中
count += len; // 更新计数器
}

首先,该方法会检查写入的字节数是否大于等于缓冲区长度,如果是,则先将缓冲区中的数据刷新到磁盘中,然后直接将数据写入输出流。这样做是为了避免缓冲流级联时的问题,即缓冲区的大小不足以容纳写入的数据时,可能会引发级联刷新,导致效率降低。

级联问题(Cascade Problem)是指在一组缓冲流(Buffered Stream)中,由于缓冲区的大小不足以容纳要写入的数据,导致数据被分割成多个部分,并分别写入到不同的缓冲区中,最终需要逐个刷新缓冲区,从而导致性能下降的问题。

其次,如果写入的字节数小于缓冲区长度,则检查缓冲区中剩余的空间是否足够容纳要写入的字节数,如果不够,则先将缓冲区中的数据刷新到磁盘中。然后,使用 System.arraycopy() 方法将要写入的数据拷贝到缓冲区中,并更新计数器 count。

最后,如果写入的字节数小于缓冲区长度且缓冲区中还有剩余空间,则直接将要写入的数据拷贝到缓冲区中,并更新计数器 count。

也就是说,只有当 buf 写满了,才会 flush,将数据刷到磁盘,默认一次刷 8192 个字节。

1
2
3
public BufferedOutputStream(OutputStream out) {
this(out, 8192);
}

如果 buf 没有写满,会继续写 buf。

对比一下 FileOutputStream 的 write 方法,同样是本地方法,一次只能写入一个字节。

当把 BufferedOutputStream 和 BufferedInputStream 配合起来使用后,就减少了大量的读写次数,尤其是 byte[] bytes = new byte[8*1024],就相当于缓冲区的空间有 8 个 1024 字节,那读写效率就会大大提高。

4)byte & 0xFF

byte 类型通常被用于存储二进制数据,例如读取和写入文件、网络传输等场景。在这些场景下,byte 类型的变量可以用来存储数据流中的每个字节,从而进行读取和写入操作。

byte 类型是有符号的,即其取值范围为 -128 到 127。如果我们希望得到的是一个无符号的 byte 值,就需要使用 byte & 0xFF 来进行转换。

这是因为 0xFF 是一个无符号的整数,它的二进制表示为 11111111。当一个 byte 类型的值与 0xFF 进行位与运算时,会将 byte 类型的值转换为一个无符号的整数,其范围为 0 到 255。

0xff 是一个十六进制的数,相当于二进制的 11111111,& 运算符的意思是:如果两个操作数的对应位为 1,则输出 1,否则为 0;由于 0xff 有 8 个 1,单个 byte 转成 int 其实就是将 byte 和 int 类型的 255 进行(&)与运算。

例如,如果我们有一个 byte 类型的变量 b,其值为 -1,那么 b & 0xFF 的结果就是 255。这样就可以将一个有符号的 byte 类型的值转换为一个无符号的整数。

& 运算是一种二进制数据的计算方式, 两个操作位都为1,结果才为1,否则结果为0. 在上面的 getBufIfOpen()[pos++] & 0xff 计算过程中, byte 有 8bit, OXFF 是16进制的255, 表示的是 int 类型, int 有 32bit.

如果 getBufIfOpen()[pos++] 为 -118, 那么其原码表示为

1
00000000 00000000 00000000 10001010

反码为

1
11111111 11111111 11111111 11110101

补码为

1
11111111 11111111 11111111 11110110

0XFF 表示16进制的数据255, 原码, 反码, 补码都是一样的, 其二进制数据为

1
00000000 00000000 00000000 11111111

0XFF 和 -118 进行&运算后结果为

1
00000000 00000000 00000000 11110110

还原为原码后为

1
00000000 00000000 00000000 10001010

其表示的 int 值为 138,可见将 byte 类型的 -118 与 0XFF 进行与运算后值由 -118 变成了 int 类型的 138,其中低8位和byte的-118完全一致。

顺带聊一下 原码、反码和补码。

①、原码

原码就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值。比如如果是8位二进制:

1
2
3
[+1]原 = 0000 0001

[-1]原 = 1000 0001

第一位是符号位。因为第一位是符号位,所以8位二进制数的取值范围就是:

1
[1111 1111 , 0111 1111]

1
[-127 , 127]

②、反码

反码的表示方法是:

  • 正数的反码是其本身
  • 负数的反码是在其原码的基础上,符号位不变,其余各个位取反。

例如:

1
2
3
[+1] = [00000001]原 = [00000001]反

[-1] = [10000001]原 = [11111110]反

可见如果一个反码表示的是负数,人脑无法直观的看出来它的数值。通常要将其转换成原码再计算。

③、补码

补码的表示方法是:

  • 正数的补码就是其本身
  • 负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1。(即在反码的基础上+1)
1
2
3
[+1] = [00000001]原 = [00000001]反 = [00000001]补

[-1] = [10000001]原 = [11111110]反 = [11111111]补

对于负数,补码表示方式也是人脑无法直观看出其数值的。通常也需要转换成原码在计算其数值。

从上面可以看到:

  • 对于正数:原码,反码,补码都是一样的
  • 对于负数:原码,反码,补码都是不一样的

02、字符缓冲流

BufferedReader 类继承自 Reader 类,提供了一些便捷的方法,例如 readLine() 方法可以一次读取一行数据,而不是一个字符一个字符地读取。

BufferedWriter 类继承自 Writer 类,提供了一些便捷的方法,例如 newLine() 方法可以写入一个系统特定的行分隔符。

1)构造方法

  • BufferedReader(Reader in) :创建一个新的缓冲输入流,注意参数类型为Reader
  • BufferedWriter(Writer out): 创建一个新的缓冲输出流,注意参数类型为Writer

代码示例如下:

1
2
3
4
// 创建字符缓冲输入流
BufferedReader br = new BufferedReader(new FileReader("b.txt"));
// 创建字符缓冲输出流
BufferedWriter bw = new BufferedWriter(new FileWriter("b.txt"));

2)字符缓冲流特有方法

字符缓冲流的基本方法与普通字符流调用方式一致,这里不再赘述,我们来看字符缓冲流特有的方法。

  • BufferedReader:String readLine(): 读一行数据,读取到最后返回 null
  • BufferedWriter:newLine(): 换行,由系统定义换行符。

来看 readLine()方法的代码示例:

1
2
3
4
5
6
7
8
9
10
11
// 创建流对象
BufferedReader br = new BufferedReader(new FileReader("a.txt"));
// 定义字符串,保存读取的一行文字
String line = null;
// 循环读取,读取到最后返回null
while ((line = br.readLine())!=null) {
System.out.print(line);
System.out.println("------");
}
// 释放资源
br.close();

再来看 newLine() 方法的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建流对象
BfferedWriter bw = new BufferedWriter(new FileWriter("b.txt"));
// 写出数据
bw.write("沉");
// 写出换行
bw.newLine();
bw.write("默");
bw.newLine();
bw.write("王");
bw.newLine();
bw.write("二");
bw.newLine();
// 释放资源
bw.close();

03、字符缓冲流练习

来欣赏一下我写的这篇诗:

6.岑夫子,丹丘生,将进酒,杯莫停。
1.君不见黄河之水天上来,奔流到海不复回。
8.钟鼓馔玉不足贵,但愿长醉不愿醒。
3.人生得意须尽欢,莫使金樽空对月。
5.烹羊宰牛且为乐,会须一饮三百杯。
2.君不见高堂明镜悲白发,朝如青丝暮成雪。
7.与君歌一曲,请君为我倾耳听。
4.天生我材必有用,千金散尽还复来。

如何才能按照正确的顺序来呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 创建map集合,保存文本数据,键为序号,值为文字
HashMap<String, String> lineMap = new HashMap<>();

// 创建流对象 源
BufferedReader br = new BufferedReader(new FileReader("logs/test.log"));
//目标
BufferedWriter bw = new BufferedWriter(new FileWriter("logs/test1.txt"));

// 读取数据
String line;
while ((line = br.readLine())!=null) {
// 解析文本
if (line.isEmpty()) {
continue;
}
String[] split = line.split(Pattern.quote("."));
// 保存到集合
lineMap.put(split[0], split[1]);
}
// 释放资源
br.close();

// 遍历map集合
for (int i = 1; i <= lineMap.size(); i++) {
String key = String.valueOf(i);
// 获取map中文本
String value = lineMap.get(key);
// 写出拼接文本
bw.write(key+"."+value);
// 写出换行
bw.newLine();
}
// 释放资源
bw.close();

Java转换流

转换流可以将一个字节流包装成字符流,或者将一个字符流包装成字节流。这种转换通常用于处理文本数据,如读取文本文件或将数据从网络传输到应用程序。

01、编码和解码

在计算机中,数据通常以二进制形式存储和传输。

  • 编码就是将原始数据(比如说文本、图像、视频、音频等)转换为二进制形式。
  • 解码就是将二进制数据转换为原始数据,是一个反向的过程。
1
2
3
4
5
6
7
8
9
10
String str = "沉默王二";
String charsetName = "UTF-8";

// 编码
byte[] bytes = str.getBytes(Charset.forName(charsetName));
System.out.println("编码: " + bytes);

// 解码
String decodedStr = new String(bytes, Charset.forName(charsetName));
System.out.println("解码: " + decodedStr);

在这个示例中,首先定义了一个字符串变量 str 和一个字符集名称 charsetName。然后,使用 Charset.forName() 方法获取指定字符集的 Charset 对象。接着,使用字符串的 getBytes() 方法将字符串编码为指定字符集的字节数组。最后,使用 new String() 方法将字节数组解码为字符串。

02、字符集

Charset:字符集,是一组字符的集合,每个字符都有一个唯一的编码值,也称为码点。
常见的字符集包括 ASCII、Unicode 和 GBK,而 Unicode 字符集包含了多种编码方式,比如说 UTF-8、UTF-16。

ASCII 字符集

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)字符集是一种最早的字符集,包含 128 个字符,其中包括控制字符、数字、英文字母以及一些标点符号。ASCII 字符集中的每个字符都有一个唯一的 7 位二进制编码(由 0 和 1 组成),可以表示为十进制数或十六进制数。

ASCII 编码方式是一种固定长度的编码方式,每个字符都使用 7 位二进制编码来表示。ASCII 编码只能表示英文字母、数字和少量的符号,不能表示其他语言的文字和符号,因此在全球范围内的应用受到了很大的限制。

Unicode 字符集

Unicode 包含了世界上几乎所有的字符,用于表示人类语言、符号和表情等各种信息。Unicode 字符集中的每个字符都有一个唯一的码点(code point),用于表示该字符在字符集中的位置,可以用十六进制数表示。

为了在计算机中存储和传输 Unicode 字符集中的字符,需要使用一种编码方式。UTF-8、UTF-16 和 UTF-32 都是 Unicode 字符集的编码方式,用于将 Unicode 字符集中的字符转换成字节序列,以便于存储和传输。它们的差别在于使用的字节长度不同。

  • UTF-8 是一种可变长度的编码方式,对于 ASCII 字符(码点范围为 0x00~0x7F),使用一个字节表示,对于其他 Unicode 字符,使用两个、三个或四个字节表示。UTF-8 编码方式被广泛应用于互联网和计算机领域,因为它可以有效地压缩数据,适用于网络传输和存储。
  • UTF-16 是一种固定长度的编码方式,对于基本多语言平面(Basic Multilingual Plane,Unicode 字符集中的一个码位范围,包含了世界上大部分常用的字符,总共包含了超过 65,000 个码位)中的字符(码点范围为 0x0000~0xFFFF),使用两个字节表示,对于其他 Unicode 字符,使用四个字节表示。
  • UTF-32 是一种固定长度的编码方式,对于所有 Unicode 字符,使用四个字节表示。

GBK 字符集

GBK 包含了 GB2312 字符集中的字符,同时还扩展了许多其他汉字字符和符号,共收录了 21,913 个字符。GBK 采用双字节编码方式,每个汉字占用 2 个字节,其中高字节和低字节都使用了 8 位,因此 GBK 编码共有 2^16=65536 种可能的编码,其中大部分被用于表示汉字字符。

GBK 编码是一种变长的编码方式,对于 ASCII 字符(码位范围为 0x00 到 0x7F),使用一个字节表示,对于其他字符,使用两个字节表示。GBK 编码中的每个字节都可以采用 0x81 到 0xFE 之间的任意一个值,因此可以表示 2^15=32768 个字符。为了避免与 ASCII 码冲突,GBK 编码的第一个字节采用了 0x81 到 0xFE 之间除了 0x7F 的所有值,第二个字节采用了 0x40 到 0x7E 和 0x80 到 0xFE 之间的所有值,共 94 个值。

GB2312 的全名是《信息交换用汉字编码字符集基本集》,也被称为“国标码”。采用了双字节编码方式,每个汉字占用 2 个字节,其中高字节和低字节都使用了 8 位,因此 GB2312 编码共有 2^16=65536 种可能的编码,其中大部分被用于表示汉字字符。GB2312 编码中的每个字节都可以采用 0xA1 到 0xF7 之间的任意一个值,因此可以表示 126 个字符。

GB2312 是一个较为简单的字符集,只包含了常用的汉字和符号,因此对于一些较为罕见的汉字和生僻字,GB2312 不能满足需求,现在已经逐渐被 GBK、GB18030 等字符集所取代。

GB18030 是最新的中文码表。收录汉字 70244 个,采用多字节编码,每个字可以由 1 个、2 个或 4 个字节组成。支持中国国内少数民族的文字,同时支持繁体汉字以及日韩汉字等。

03、乱码

当使用不同的编码方式读取或者写入文件时,就会出现乱码问题,来看示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String s = "沉默王二!";

try {
// 将字符串按GBK编码方式保存到文件中
OutputStreamWriter out = new OutputStreamWriter(
new FileOutputStream("logs/test_utf8.txt"), "GBK");
out.write(s);
out.close();

FileReader fileReader = new FileReader("logs/test_utf8.txt");
int read;
while ((read = fileReader.read()) != -1) {
System.out.print((char)read);
}
fileReader.close();
} catch (IOException e) {
e.printStackTrace();
}

在上面的示例代码中,首先定义了一个包含中文字符的字符串,然后将该字符串按 GBK 编码方式保存到文件中,接着将文件按默认编码方式(UTF-8)读取,并显示内容。此时就会出现乱码问题,显示为“��Ĭ������”。

这是因为文件中的 GBK 编码的字符在使用 UTF-8 编码方式解析时无法正确解析,从而导致出现乱码问题。

那如何才能解决乱码问题呢?
这就引出我们今天的主角了——转换流。

04、InputStreamReader

java.io.InputStreamReader 是 Reader 类的子类。它的作用是将字节流(InputStream)转换为字符流(Reader),同时支持指定的字符集编码方式,从而实现字符流与字节流之间的转换。

1)构造方法

  • InputStreamReader(InputStream in): 创建一个使用默认字符集的字符流。
  • InputStreamReader(InputStream in, String charsetName): 创建一个指定字符集的字符流。

代码示例如下:

1
2
InputStreamReader isr = new InputStreamReader(new FileInputStream("in.txt"));
InputStreamReader isr2 = new InputStreamReader(new FileInputStream("in.txt") , "GBK");

2)解决编码问题

下面是一个使用 InputStreamReader 解决乱码问题的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
String s = "沉默王二!";

try {
// 将字符串按GBK编码方式保存到文件中
OutputStreamWriter outUtf8 = new OutputStreamWriter(
new FileOutputStream("logs/test_utf8.txt"), "GBK");
outUtf8.write(s);
outUtf8.close();

// 将字节流转换为字符流,使用GBK编码方式
InputStreamReader isr = new InputStreamReader(new FileInputStream("logs/test_utf8.txt"), "GBK");
// 读取字符流
int c;
while ((c = isr.read()) != -1) {
System.out.print((char) c);
}
isr.close();
} catch (IOException e) {
e.printStackTrace();
}

由于使用了 InputStreamReader 对字节流进行了编码方式的转换,因此在读取字符流时就可以正确地解析出中文字符,避免了乱码问题。

05、OutputStreamWriter

java.io.OutputStreamWriter 是 Writer 的子类,字面看容易误以为是转为字符流,其实是将字符流转换为字节流,是字符流到字节流的桥梁。

  • OutputStreamWriter(OutputStream in): 创建一个使用默认字符集的字符流。
  • OutputStreamWriter(OutputStream in, String charsetName):创建一个指定字符集的字符流。

代码示例如下:

1
2
OutputStreamWriter isr = new OutputStreamWriter(new FileOutputStream("a.txt"));
OutputStreamWriter isr2 = new OutputStreamWriter(new FileOutputStream("b.txt") , "GBK");

通常为了提高读写效率,我们会在转换流上再加一层缓冲流,来看代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
try {
// 从文件读取字节流,使用UTF-8编码方式
FileInputStream fis = new FileInputStream("test.txt");
// 将字节流转换为字符流,使用UTF-8编码方式
InputStreamReader isr = new InputStreamReader(fis, "UTF-8");
// 使用缓冲流包装字符流,提高读取效率
BufferedReader br = new BufferedReader(isr);
// 创建输出流,使用UTF-8编码方式
FileOutputStream fos = new FileOutputStream("output.txt");
// 将输出流包装为转换流,使用UTF-8编码方式
OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF-8");
// 使用缓冲流包装转换流,提高写入效率
BufferedWriter bw = new BufferedWriter(osw);

// 读取输入文件的每一行,写入到输出文件中
String line;
while ((line = br.readLine()) != null) {
bw.write(line);
bw.newLine(); // 每行结束后写入一个换行符
}

// 关闭流
br.close();
bw.close();
} catch (IOException e) {
e.printStackTrace();
}

在上面的示例代码中,首先使用 FileInputStream 从文件中读取字节流,使用 UTF-8 编码方式进行读取。然后,使用 InputStreamReader 将字节流转换为字符流,使用 UTF-8 编码方式进行转换。接着,使用 BufferedReader 包装字符流,提高读取效率。然后,创建 FileOutputStream 用于输出文件,使用 UTF-8 编码方式进行创建。接着,使用 OutputStreamWriter 将输出流转换为字符流,使用 UTF-8 编码方式进行转换。最后,使用 BufferedWriter 包装转换流,提高写入效率。

06、小结

InputStreamReader 和 OutputStreamWriter 是将字节流转换为字符流或者将字符流转换为字节流。通常用于解决字节流和字符流之间的转换问题,可以将字节流以指定的字符集编码方式转换为字符流,或者将字符流以指定的字符集编码方式转换为字节流。

InputStreamReader 类的常用方法包括:

  • read():从输入流中读取一个字符的数据。
  • read(char[] cbuf, int off, int len):从输入流中读取 len 个字符的数据到指定的字符数组 cbuf 中,从 off 位置开始存放。
  • ready():返回此流是否已准备好读取。
  • close():关闭输入流。

OutputStreamWriter 类的常用方法包括:

  • write(int c):向输出流中写入一个字符的数据。
  • write(char[] cbuf, int off, int len):向输出流中写入指定字符数组 cbuf 中的 len 个字符,从 off 位置开始。
  • flush():将缓冲区的数据写入输出流中。
  • close():关闭输出流。

在使用转换流时,需要指定正确的字符集编码方式,否则可能会导致数据读取或写入出现乱码。

Java序列流

Java 的序列流(ObjectInputStream 和 ObjectOutputStream)是一种可以将 Java 对象序列化和反序列化的流。

01、ObjectOutputStream

java.io.ObjectOutputStream 继承自 OutputStream 类,因此可以将序列化后的字节序列写入到文件、网络等输出流中。

来看 ObjectOutputStream 的构造方法:
ObjectOutputStream(OutputStream out)

该构造方法接收一个 OutputStream 对象作为参数,用于将序列化后的字节序列输出到指定的输出流中。例如:

1
2
FileOutputStream fos = new FileOutputStream("file.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);

一个对象要想序列化,必须满足两个条件:

  • 该类必须实现java.io.Serializable 接口,否则会抛出NotSerializableException
  • 该类的所有字段都必须是可序列化的。如果一个字段不需要序列化,则需要使用transient 关键字进行修饰。static 和 transient 修饰的字段是不会被序列化的。

使用示例如下:

1
2
3
4
5
public class Employee implements Serializable {
public String name;
public String address;
public transient int age; // transient瞬态修饰成员,不会被序列化
}

接下来,来聊聊 writeObject (Object obj) 方法,该方法是 ObjectOutputStream 类中用于将对象序列化成字节序列并输出到输出流中的方法,可以处理对象之间的引用关系、继承关系、静态字段和 transient 字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class ObjectOutputStreamDemo {
public static void main(String[] args) {
Person person = new Person("沉默王二", 20);
try {
FileOutputStream fos = new FileOutputStream("logs/person.dat");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
class Person implements Serializable {
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

上面的代码中,首先创建了一个 Person 对象,然后使用 FileOutputStream 和 ObjectOutputStream 将 Person 对象序列化并输出到 person.dat 文件中。在 Person 类中,实现了 Serializable 接口,表示该类可以进行对象序列化。

02、ObjectInputStream

ObjectInputStream 可以读取 ObjectOutputStream 写入的字节流,并将其反序列化为相应的对象(包含对象的数据对象的类型对象中存储的属性等信息)。

说简单点就是,序列化之前是什么样子,反序列化后就是什么样子。

来看一下构造方法:ObjectInputStream(InputStream in) : 创建一个指定 InputStream 的 ObjectInputStream。

其中,ObjectInputStream 的 readObject 方法用来读取指定文件中的对象,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
String filename = "logs/person.dat"; // 待反序列化的文件名
try (FileInputStream fileIn = new FileInputStream(filename);
ObjectInputStream in = new ObjectInputStream(fileIn)) {
// 从指定的文件输入流中读取对象并反序列化
Object obj = in.readObject();
// 将反序列化后的对象强制转换为指定类型
Person p = (Person) obj;
// 打印反序列化后的对象信息
System.out.println("Deserialized Object: " + p);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}

03、Kryo

实际开发中,很少使用 JDK 自带的序列化和反序列化,这是因为:

  • 可移植性差:Java 特有的,无法跨语言进行序列化和反序列化。
  • 性能差:序列化后的字节体积大,增加了传输/保存成本。
  • 安全问题:攻击者可以通过构造恶意数据来实现远程代码执行,从而对系统造成严重的安全威胁。相关阅读:Java 反序列化漏洞之殇

Kryo 是一个优秀的 Java 序列化和反序列化库,具有高性能、高效率和易于使用和扩展等特点,有效地解决了 JDK 自带的序列化机制的痛点。

GitHub 地址:https://github.com/EsotericSoftware/kryo

使用示例:

第一步,在 pom.xml 中引入依赖。

1
2
3
4
5
6
<!-- 引入 Kryo 序列化工具 -->
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.4.0</version>
</dependency>

第二步,创建一个 Kryo 对象,并使用 register() 方法将对象进行注册。然后,使用 writeObject() 方法将 Java 对象序列化为二进制流,再使用 readObject() 方法将二进制流反序列化为 Java 对象。最后,输出反序列化后的 Java 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class KryoDemo {
public static void main(String[] args) throws FileNotFoundException {
Kryo kryo = new Kryo();
kryo.register(KryoParam.class);

KryoParam object = new KryoParam("沉默王二", 123);

Output output = new Output(new FileOutputStream("logs/kryo.bin"));
kryo.writeObject(output, object);
output.close();

Input input = new Input(new FileInputStream("logs/kryo.bin"));
KryoParam object2 = kryo.readObject(input, KryoParam.class);
System.out.println(object2);
input.close();
}
}

class KryoParam {
private String name;
private int age;

public KryoParam() {
}

public KryoParam(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

@Override
public String toString() {
return "KryoParam{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}

打印流

System.out.println() 的使用频率恐怕不亚于 main 方法的使用频率。其中 System.out 返回的正是打印流 PrintStream
除此之外,还有它还有一个孪生兄弟,PrintWriter。PrintStream 是 OutputStream 的子类,PrintWriter 是 Writer 的子类,也就是说,一个字节流,一个是字符流

打印流具有以下几个特点:

  • 可以自动进行数据类型转换:打印流可以将各种数据类型转换为字符串,并输出到指定的输出流中。
  • 可以自动进行换行操作:打印流可以在输出字符串的末尾自动添加换行符,方便输出多个字符串时的格式控制。
  • 可以输出到控制台或者文件中:打印流可以将数据输出到控制台或者文件中,方便调试和日志记录(尽管生产环境下更推荐使用 Logback、ELK 等)。

PrintStream 类的常用方法包括:

  • print():输出一个对象的字符串表示形式。
  • println():输出一个对象的字符串表示形式,并在末尾添加一个换行符。
  • printf():使用指定的格式字符串和参数输出格式化的字符串。
1
2
3
4
5
6
7
8
9
PrintStream ps = System.out;
ps.println("沉默王二");
ps.print("沉 ");
ps.print("默 ");
ps.print("王 ");
ps.print("二 ");
ps.println();

ps.printf("姓名:%s,年龄:%d,成绩:%f", "沉默王二", 18, 99.9);

在这个示例中,我们创建了一个 PrintStream 对象 ps,它输出到控制台。我们使用 ps 的 print 和 println 方法输出了一些字符串。

使用 printf 方法输出了一个格式化字符串,其中 %s、%d 和 %.2f 分别表示字符串、整数和浮点数的格式化输出。我们使用逗号分隔的参数列表指定了要输出的值。

来详细说说 printf 方法哈。

1
public PrintStream printf(String format, Object... args);

其中,format 参数是格式化字符串,args 参数是要输出的参数列表。格式化字符串包含了普通字符和转换说明符。普通字符是指除了转换说明符之外的字符,它们在输出时直接输出。转换说明符是由百分号(%)和一个或多个字符组成的,用于指定输出的格式和数据类型。

下面是 Java 的常用转换说明符及对应的输出格式:

  • %s:输出一个字符串。
  • %d%i:输出一个十进制整数。
  • %x%X:输出一个十六进制整数,%x 输出小写字母,%X 输出大写字母。
  • %f%F:输出一个浮点数。
  • %e%E:输出一个科学计数法表示的浮点数,%e 输出小写字母 e,%E 输出大写字母 E。
  • %g%G:输出一个浮点数,自动选择 %f%e/%E 格式输出。
  • %c:输出一个字符。
  • %b:输出一个布尔值。
  • %h:输出一个哈希码(16进制)。
  • %n:换行符。

除了转换说明符之外,Java 的 printf 方法还支持一些修饰符,用于指定输出的宽度、精度、对齐方式等。

  • 宽度修饰符:用数字指定输出的最小宽度,如果输出的数据不足指定宽度,则在左侧或右侧填充空格或零。
  • 精度修饰符:用点号(.)和数字指定浮点数或字符串的精度,对于浮点数,指定小数点后的位数,对于字符串,指定输出的字符数。
  • 对齐修饰符:用减号(-)或零号(0)指定输出的对齐方式,减号表示左对齐,零号表示右对齐并填充零。

下面是一些示例:

1
2
3
4
5
6
7
8
9
10
11
12
int num = 123;
System.out.printf("%5d\n", num); // 输出 " 123"
System.out.printf("%-5d\n", num); // 输出 "123 "
System.out.printf("%05d\n", num); // 输出 "00123"

double pi = Math.PI;
System.out.printf("%10.2f\n", pi); // 输出 " 3.14"
System.out.printf("%-10.4f\n", pi); // 输出 "3.1416 "

String name = "沉默王二";
System.out.printf("%10s\n", name); // 输出 " 沉默王二"
System.out.printf("%-10s\n", name); // 输出 "沉默王二 "

具体来说,

  • 我们使用 %5d 来指定输出的整数占据 5 个字符的宽度,不足部分在左侧填充空格。
  • 使用 %-5d 来指定输出的整数占据 5 个字符的宽度,不足部分在右侧填充空格。
  • 使用 %05d 来指定输出的整数占据 5 个字符的宽度,不足部分在左侧填充 0。
  • 使用 %10.2f 来指定输出的浮点数占据 10 个字符的宽度,保留 2 位小数,不足部分在左侧填充空格。
  • 使用 %-10.4f 来指定输出的浮点数占据 10 个字符的宽度,保留 4 位小数,不足部分在右侧填充空格。
  • 使用 %10s 来指定输出的字符串占据 10 个字符的宽度,不足部分在左侧填充空格。
  • 使用 %-10s 来指定输出的字符串占据 10 个字符的宽度,不足部分在右侧填充空格。

接下来,我们给出一个 PrintWriter 的示例:

1
2
3
4
PrintWriter writer = new PrintWriter(new FileWriter("output.txt"));
writer.println("沉默王二");
writer.printf("他的年纪为 %d.\n", 18);
writer.close();

首先,我们创建一个 PrintWriter 对象,它的构造函数接收一个 Writer 对象作为参数。在这里,我们使用 FileWriter 来创建一个输出文件流,并将其作为参数传递给 PrintWriter 的构造函数。然后,我们使用 PrintWriter 的 println 和 printf 方法来输出两行内容,其中 printf 方法可以接收格式化字符串。最后,我们调用 PrintWriter 的 close 方法来关闭输出流。

我们也可以不创建 FileWriter 对象,直接指定文件名。

1
2
3
4
PrintWriter pw = new PrintWriter("output.txt");
pw.println("沉默王二");
pw.printf("他的年纪为 %d.\n", 18);
pw.close();