将二进制文件读入内存
可以通过Files.readAllBytes()
将整个二进制文件读入内存:
byte[] bytes = Files.readAllBytes(binaryFile);
类似的方法也存在于InputStream类中。
虽然这些方法对于相对较小的文件非常方便,但对于较大的文件来说并不是一个好的选择。尝试将大文件提取到内存中很容易出现 OOM 错误,而且显然会消耗大量内存。或者,对于大文件(例如 200GB),我们可以关注内存映射文件(MappedByteBuffer。MappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为一个非常大的数组。他们看起来像是在记忆中,即使他们不是。一切都发生在本机级别:
try (FileChannel fileChannel = (FileChannel.open(binaryFile, EnumSet.of(StandardOpenOption.READ)))) { MappedByteBuffer mbBuffer = fileChannel.map( FileChannel.MapMode.READ_ONLY, 0, fileChannel.size()); System.out.println("\nRead: " + mbBuffer.limit() + " bytes"); }
对于大型文件,建议在缓冲区内遍历固定大小的缓冲区,如下所示:
private static final int MAP_SIZE = 5242880; // 5 MB in bytes try (FileChannel fileChannel = FileChannel.open( binaryFile, StandardOpenOption.READ)) { int position = 0; long length = fileChannel.size(); while (position < length) { long remaining = length - position; int bytestomap = (int) Math.min(MAP_SIZE, remaining); MappedByteBuffer mbBuffer = fileChannel.map( MapMode.READ_ONLY, position, bytestomap); ... // do something with the current buffer position += bytestomap; } }
写入二进制文件
写入二进制文件的一种有效方法是使用BufferedOutputStream
。例如,将byte[]
写入文件可以如下完成:
final byte[] buffer...; Path classFile = Paths.get( "build/classes/modern/challenge/Main.class"); try (BufferedOutputStream bos = newBufferedOutputStream( Files.newOutputStream(classFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE))) { bos.write(buffer); }
如果您正在逐字节写入数据,请使用write(int b)方法,如果您正在写入数据块,请使用write(byte[] b, int off, int len)方法。
向文件写入byte[]的一种非常方便的方法是Files.write(Path path, byte[] bytes, OpenOption... options)。例如,让我们编写前面缓冲区的内容:
Path classFile = Paths.get( "build/classes/modern/challenge/Main.class"); Files.write(classFile, buffer, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
通过MappedByteBuffer
写入二进制文件可以如下完成(这对于写入大型文本文件非常有用):
Path classFile = Paths.get( "build/classes/modern/challenge/Main.class"); try (FileChannel fileChannel = (FileChannel) Files.newByteChannel( classFile, EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE))) { MappedByteBuffer mbBuffer = fileChannel .map(FileChannel.MapMode.READ_WRITE, 0, buffer.length); if (mbBuffer != null) { mbBuffer.put(buffer); } }
最后,如果我们正在写一段数据(不是原始的二进制数据),那么我们可以依赖于DataOutputStream
。这个类为不同类型的数据提供了writeFoo()
方法。例如,让我们将几个浮点值写入一个文件:
Path floatFile = Paths.get("float.bin"); try (DataOutputStream dis = new DataOutputStream( new BufferedOutputStream(Files.newOutputStream(floatFile)))) { dis.writeFloat(23.56f); dis.writeFloat(2.516f); dis.writeFloat(56.123f); }
140 在大文件中搜索
搜索和计算文件中某个字符串的出现次数是一项常见的任务。尽可能快地实现这一点是一项强制性要求,尤其是当文件很大(例如 200GB)时。
注意,以下实现假设字符串11
在111
中只出现一次,而不是两次。此外,前三个实现依赖于第 1 章“字符串、数字和数学”节的“在另一个字符串中对字符串进行计数”的帮助方法:
private static int countStringInString(String string, String tofind) { return string.split(Pattern.quote(tofind), -1).length - 1; }
既然如此,让我们来看看解决这个问题的几种方法。
基于BufferedReader
的解决方案
从前面的问题中我们已经知道,BufferedReader对于读取文本文件是非常有效的。因此,我们也可以用它来读取一个大文件。在读取时,对于通过BufferedReader.readLine()获得的每一行,我们需要通过countStringInString()计算所搜索字符串的出现次数:
public static int countOccurrences(Path path, String text, Charset ch) throws IOException { int count = 0; try (BufferedReader br = Files.newBufferedReader(path, ch)) { String line; while ((line = br.readLine()) != null) { count += countStringInString(line, text); } } return count; }
基于Files.readAllLines()的解决方案
如果内存(RAM)对我们来说不是问题,那么我们可以尝试将整个文件读入内存(通过Files.readAllLines()并从那里处理它。将整个文件放在内存中支持并行处理。因此,如果我们的硬件可以通过并行处理突出显示,那么我们可以尝试依赖parallelStream(),如下所示:
public static int countOccurrences(Path path, String text, Charset ch) throws IOException { return Files.readAllLines(path, ch).parallelStream() .mapToInt((p) -> countStringInString(p, text)) .sum(); }
如果parallelStream()
没有任何好处,那么我们可以简单地切换到stream()
。这只是一个基准问题。
基于Files.lines()
的解决方案
我们也可以尝试通过Files.lines()
利用流。这一次,我们将文件作为一个懒惰的Stream<String>
来获取。如果我们可以利用并行处理(基准测试显示出更好的性能),那么通过调用parallel()
方法来并行化Stream<String>
就非常简单了:
public static int countOccurrences(Path path, String text, Charset ch) throws IOException { return Files.lines(path, ch).parallel() .mapToInt((p) -> countStringInString(p, text)) .sum(); }
基于扫描器的解决方案
从 JDK9 开始,Scanner
类附带了一个方法,该方法返回分隔符分隔的标记流Stream<String> tokens()
。如果我们将要搜索的文本作为Scanner
的分隔符,并对tokens()
返回的Stream
的条目进行计数,则得到正确的结果:
public static long countOccurrences( Path path, String text, Charset ch) throws IOException { long count; try (Scanner scanner = new Scanner(path, ch) .useDelimiter(Pattern.quote(text))) { count = scanner.tokens().count() - 1; } return count; }
JDK10 中添加了支持显式字符集的扫描器构造器。
基于MappedByteBuffer
的解决方案
我们将在这里讨论的最后一个解决方案是基于 JavaNIO.2、MappedByteBuffer和FileChannel的。此解决方案从给定文件上的一个FileChannel打开一个内存映射字节缓冲区(MappedByteBuffer)。我们遍历提取的字节缓冲区并查找与搜索字符串的匹配(该字符串被转换为一个byte[]并逐字节进行搜索)。
对于小文件,将整个文件加载到内存中会更快。对于大型/大型文件,以块(例如,5 MB 的块)的形式加载和处理文件会更快。一旦我们加载了一个块,我们就必须计算所搜索字符串的出现次数。我们存储结果并将其传递给下一个数据块。我们重复这个过程,直到遍历了整个文件。
让我们看一下这个实现的核心行(看一下与本书捆绑在一起的源代码以获得完整的代码):
private static final int MAP_SIZE = 5242880; // 5 MB in bytes public static int countOccurrences(Path path, String text) throws IOException { final byte[] texttofind = text.getBytes(StandardCharsets.UTF_8); int count = 0; try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) { int position = 0; long length = fileChannel.size(); while (position < length) { long remaining = length - position; int bytestomap = (int) Math.min(MAP_SIZE, remaining); MappedByteBuffer mbBuffer = fileChannel.map( MapMode.READ_ONLY, position, bytestomap); int limit = mbBuffer.limit(); int lastSpace = -1; int firstChar = -1; while (mbBuffer.hasRemaining()) { // spaghetti code omitted for brevity ... } } } return count; }
这个解决方案非常快,因为文件直接从操作系统的内存中读取,而不必加载到 JVM 中。这些操作在本机级别进行,称为操作系统级别。请注意,此实现仅适用于 UTF-8 字符集,但也可以适用于其他字符集。
141 将 JSON/CSV 文件读取为对象
如今,JSON 和 CSV 文件无处不在。读取(反序列化)JSON/CSV 文件可能是一项日常任务,通常位于业务逻辑之前。编写(序列化)JSON/CSV 文件也是一项常见的任务,通常发生在业务逻辑的末尾。在读写这些文件之间,应用将数据用作对象。
将 JSON 文件读/写为对象
让我们从三个文本文件开始,它们代表典型的类似 JSON 的映射:
在melons_raw.json中,每行有一个 JSON 条目。每一行都是一段独立于前一行的 JSON,但具有相同的模式。在melons_array.json中,我们有一个 JSON 数组,而在melons_map.json中,我们有一个非常适合 JavaMap的 JSON 数组。
对于这些文件中的每一个,我们都有一个Path,如下所示:
Path pathArray = Paths.get("melons_array.json"); Path pathMap = Paths.get("melons_map.json"); Path pathRaw = Paths.get("melons_raw.json");
现在,让我们看看三个专用库,它们将这些文件的内容作为Melon
实例读取:
public class Melon { private String type; private int weight; // getters and setters omitted for brevity }
使用 JSON-B
JavaEE8 附带了一个类似 JAXB 的声明性 JSON 绑定,称为 JSON-B(JSR-367)。JSON-B 与 JAXB 和其他 JavaEE/SE API 保持一致。JakartaEE 将 JavaEE8 JSON-P/B 提升到了一个新的层次。其 API 通过javax.json.bind.Jsonb和javax.json.bind.JsonbBuilder类公开:
Jsonb jsonb = JsonbBuilder.create();
对于反序列化,我们使用Jsonb.fromJson()
,而对于序列化,我们使用Jsonb.toJson()
:
- 让我们把
melons_array.json
读作Melon
的Array
:
Melon[] melonsArray = jsonb.fromJson(Files.newBufferedReader( pathArray, StandardCharsets.UTF_8), Melon[].class);
让我们把melons_array.json
读作Melon
的List
:
List<Melon> melonsList = jsonb.fromJson(Files.newBufferedReader( pathArray, StandardCharsets.UTF_8), ArrayList.class);
让我们把melons_map.json读作Melon的Map:
Map<String, Melon> melonsMap = jsonb.fromJson(Files.newBufferedReader( pathMap, StandardCharsets.UTF_8), HashMap.class);
让我们把melons_raw.json
逐行读成Map
:
Map<String, String> stringMap = new HashMap<>(); try (BufferedReader br = Files.newBufferedReader( pathRaw, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { stringMap = jsonb.fromJson(line, HashMap.class); System.out.println("Current map is: " + stringMap); } }
让我们把melons_raw.json
逐行读成Melon
:
try (BufferedReader br = Files.newBufferedReader( pathRaw, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { Melon melon = jsonb.fromJson(line, Melon.class); System.out.println("Current melon is: " + melon); } }
让我们将一个对象写入一个 JSON 文件(melons_output.json
):
Path path = Paths.get("melons_output.json"); jsonb.toJson(melonsMap, Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE));
使用 Jackson
Jackson 是一个流行且快速的库,专门用于处理(序列化/反序列化)JSON 数据。Jackson API 依赖于com.fasterxml.jackson.databind.ObjectMapper。让我们再看一次前面的例子,但这次使用的是 Jackson:
ObjectMapper mapper = new ObjectMapper();
反序列化使用ObjectMapper.readValue()
,序列化使用ObjectMapper.writeValue()
:
- 让我们把
melons_array.json
读作Melon
的Array
:
Melon[] melonsArray = mapper.readValue(Files.newBufferedReader( pathArray, StandardCharsets.UTF_8), Melon[].class);
让我们把melons_array.json读作Melon的List:
List<Melon> melonsList = mapper.readValue(Files.newBufferedReader( pathArray, StandardCharsets.UTF_8), ArrayList.class);
让我们把melons_map.json读作Melon的Map:
Map<String, Melon> melonsMap = mapper.readValue(Files.newBufferedReader( pathMap, StandardCharsets.UTF_8), HashMap.class);
让我们把melons_raw.json
逐行读成Map
:
Map<String, String> stringMap = new HashMap<>(); try (BufferedReader br = Files.newBufferedReader( pathRaw, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { stringMap = mapper.readValue(line, HashMap.class); System.out.println("Current map is: " + stringMap); } }
让我们把melons_raw.json
逐行读成Melon
:
try (BufferedReader br = Files.newBufferedReader( pathRaw, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { Melon melon = mapper.readValue(line, Melon.class); System.out.println("Current melon is: " + melon); } }
让我们将一个对象写入一个 JSON 文件(melons_output.json
):
Path path = Paths.get("melons_output.json"); mapper.writeValue(Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE), melonsMap);
使用 Gson
Gson 是另一个专门用于处理(序列化/反序列化)JSON 数据的简单库。在 Maven 项目中,它可以作为依赖项添加到pom.xml
。它的 API 依赖于类名com.google.gson.Gson
。本书附带的代码提供了一组示例。
将 CSV 文件作为对象读取
最简单的 CSV 文件类似于下图中的文件(用逗号分隔的数据行):
反序列化这种 CSV 文件的简单而有效的解决方案依赖于BufferedReader和String.split()方法。我们可以通过BufferedReader.readLine()读取文件中的每一行,并通过Spring.split()用逗号分隔符将其拆分。结果(每行内容)可以存储在List<String>中。最终结果为List<List<String>>,如下所示:
public static List<List<String>> readAsObject( Path path, Charset cs, String delimiter) throws IOException { List<List<String>> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { String[] values = line.split(delimiter); content.add(Arrays.asList(values)); } } return content; }
如果 CSV 数据有 POJO 对应项(例如,我们的 CSV 是序列化一堆Melon
实例的结果),那么它可以反序列化,如下例所示:
public static List<Melon> readAsMelon( Path path, Charset cs, String delimiter) throws IOException { List<Melon> content = new ArrayList<>(); try (BufferedReader br = Files.newBufferedReader(path, cs)) { String line; while ((line = br.readLine()) != null) { String[] values = line.split(Pattern.quote(delimiter)); content.add(new Melon(values[0], Integer.valueOf(values[1]))); } } return content; }
对于复杂的 CSV 文件,建议使用专用库(例如 OpenCSV、ApacheCommons CSV、Super CSV 等)。
142 使用临时文件/文件夹
JavaNIO.2API 支持使用临时文件夹/文件。例如,我们可以很容易地找到临时文件夹/文件的默认位置,如下所示:
String defaultBaseDir = System.getProperty("java.io.tmpdir");
通常,在 Windows 中,默认的临时文件夹是C:\Temp, %Windows%\Temp,或者是Local Settings\Temp中每个用户的临时目录(这个位置通常通过TEMP环境变量控制)。在 Linux/Unix 中,全局临时目录是/tmp和/var/tmp。前一行代码将返回默认位置,具体取决于操作系统。
在下一节中,我们将学习如何创建临时文件夹/文件。