本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接http://item.jd.com/12299018.html


57节介绍了字节流, 58节介绍了字符流,它们都是以流的方式读写文件,流的方式有几个限制:

  • 要么读,要么写,不能同时读和写
  • 不能随机读写,只能从头读到尾,且不能重复读,虽然通过缓冲可以实现部分重读,但是有限制

Java中还有一个类RandomAccessFile,它没有这两个限制,既可以读,也可以写,还可以随机读写,它是一个更接近于操作系统API的封装类。

本节,我们介绍就来介绍这个类,同时,我们介绍它的一个应用,实现一个简单的键值对数据库,怎么实现数据库呢?我们先来看RandomAccessFile的用法。

RandomAccessFile

构造方法

RandomAccessFile有如下构造方法:

public RandomAccessFile(String name, String mode) throws FileNotFoundException
public RandomAccessFile(File file, String mode) throws FileNotFoundException

参数name和file容易理解,表示文件路径和File对象,mode是什么意思呢?它表示打开模式,可以有四个取值:

  • "r": 只用于读
  • "rw": 用于读和写
  • "rws": 和"rw"一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上
  • "rwd": 和"rw"一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和"rws"的区别是,元数据的更新不要求同步

DataInput/DataOutput接口

RandomAccessFile虽然不是InputStream/OutputStream的子类,但它也有类似于读写字节流的方法,另外,它还实现了DataInput/DataOutput接口,这些方法我们之前基本都介绍过,这里列举部分方法,以增强直观感受:

//读一个字节,取最低八位,0到255
public int read() throws IOException
public int read(byte b[]) throws IOException
public int read(byte b[], int off, int len) throws IOException
public final double readDouble() throws IOException
public final int readInt() throws IOException
public final String readUTF() throws IOException
public void write(int b) throws IOException
public final void writeInt(int v) throws IOException
public void write(byte b[]) throws IOException
public void write(byte b[], int off, int len) throws IOException
public final void writeUTF(String str) throws IOException
public void close() throws IOException

RandomAccessFile还有另外两个read方法:

public final void readFully(byte b[]) throws IOException
public final void readFully(byte b[], int off, int len) throws IOException

与对应的read方法的区别是,它们可以确保读够期望的长度,如果到了文件结尾也没读够,它们会抛出EOFException异常。

随机访问

RandomAccessFile内部有一个文件指针,指向当前读写的位置,各种read/write操作都会自动更新该指针,与流不同的是,RandomAccessFile可以获取该指针,也可以更改该指针,相关方法是:

//获取当前文件指针
public native long getFilePointer() throws IOException;
//更改当前文件指针到pos
public native void seek(long pos) throws IOException;

RandomAccessFile是通过本地方法,最终调用操作系统的API来实现文件指针调整的。

InputStream有一个skip方法,可以跳过输入流中n个字节,默认情况下,它是通过实际读取n个字节实现的,RandomAccessFile有一个类似方法,不过它是通过更改文件指针实现的:

public int skipBytes(int n) throws IOException

RandomAccessFile可以直接获取文件长度,返回文件字节数,方法为:

public native long length() throws IOException;

它还可以直接修改文件长度,方法为:

public native void setLength(long newLength) throws IOException;

如果当前文件的长度小于newLength,则文件会扩展,扩展部分的内容未定义。如果当前文件的长度大于newLength,则文件会收缩,多出的部分会截取,如果当前文件指针比newLength大,则调用后会变为newLength。

需要注意的方法

RandomAccessFile中有如下方法:

public final void writeBytes(String s) throws IOException
public final String readLine() throws IOException

看上去,writeBytes可以直接写入字符串,而readLine可以按行读入字符串,实际上,这两个方法都是有问题的,它们都没有编码的概念,都假定一个字节就代表一个字符,这对于中文显然是不成立的,所以,应避免使用这两个方法。

BasicDB的设计

在日常的一般文件读写中,使用流就可以了,但在一些系统程序中,流是不适合的,RandomAccessFile因为更接近操作系统,更为方便和高效。

下面,我们来看怎么利用RandomAccessFile实现一个简单的键值数据库,我们称之为BasicDB。

功能

BasicDB提供的接口类似于Map接口,可以按键保存、查找、删除,但数据可以持久化保存到文件上。

此外,不像HashMap/TreeMap,它们将所有数据保存在内存,BasicDB只把元数据如索引信息保存在内存,值的数据保存在文件上。相比HashMap/TreeMap,BasicDB的内存消耗可以大大降低,存储的键值对个数大大提高,尤其当值数据比较大的时候。BasicDB通过索引,以及RandomAccessFile的随机读写功能保证效率。

接口

对外,BasicDB提供的构造方法是:

public BasicDB(String path, String name) throws IOException

path表示数据库文件所在的目录,该目录必须已存在。name表示数据库的名称,BasicDB会使用以name开头的两个文件,一个存储元数据,后缀是.meta,一个存储键值对中的值数据,后缀是.data。比如,如果name为student,则两个文件为student.meta和student.data,这两个文件不一定存在,如果不存在,则创建新的数据库,如果已存在,则加载已有的数据库。

BasicDB提供的公开方法有:

//保存键值对,键为String类型,值为byte数组
public void put(String key, byte[] value) throws IOException
//根据键获取值,如果键不存在,返回null
public byte[] get(String key) throws IOException
//根据键删除
public void remove(String key)
//确保将所有数据保存到文件
public void flush() throws IOException
//关闭数据库
public void close() throws IOException

为便于实现,我们假定值即byte数组的长度不超过1020,如果超过,会抛出异常,当然,这个长度在代码中可以调整。

在调用put和remove后,修改不会马上反映到文件中,如果需要确保保存到文件中,需要调用flush。

使用

在BasicDB中,我们设计的值为byte数组,这看上去是一个限制,不便使用,我们主要是为了简化,而且任何数据都可以转化为byte数组保存。对于字符串,可以使用getBytes()方法,对于对象,可以使用之前介绍的流转换为byte数组。

比如说,保存一些学生信息到数据库,代码可以为:

private static byte[] toBytes(Student student) throws IOException {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);
dout.writeUTF(student.getName());
dout.writeInt(student.getAge());
dout.writeDouble(student.getScore());
return bout.toByteArray();
} public static void saveStudents(Map<String, Student> students)
throws IOException {
BasicDB db = new BasicDB("./", "students");
for (Map.Entry<String, Student> kv : students.entrySet()) {
db.put(kv.getKey(), toBytes(kv.getValue()));
}
db.close();
}

保存学生信息到当前目录下的students数据库,toBytes方法将Student转换为了字节。

后续章节,我们会介绍序列化,如果有序列化知识,我们可以将byte数组替换为任意可序列化的对象。即使也是使用byte数组,使用序列化,toBytes方法的代码也可以更为简洁。

设计

我们采用如下简单的设计:

  1. 将键值对分为两部分,值保存在单独的.data文件中,值在.data文件中的位置和键称之为索引,索引保存在.meta文件中。
  2. 在.data文件中,每个值占用的空间固定,固定长度为1024,前4个字节表示实际长度,然后是实际内容,实际长度不够1020的,后面是补白字节0。
  3. 索引信息既保存在.meta文件中,也保存在内存中,在初始化时,全部读入内存,对索引的更新不立即更新文件,调用flush才更新。
  4. 删除键值对不修改.data文件,但会从索引中删除并记录空白空间,下次添加键值对的时候会重用空白空间,所有的空白空间也记录到.meta文件中。

我们暂不考虑由于并发访问、异常关闭等引起的一致性问题。

这个设计显然是比较粗糙的,主要用于演示一些基本概念,下面我们来看代码。

BasicDB的实现

内部组成

BasicDB有如下静态变量:

private static final int MAX_DATA_LENGTH = 1020;
//补白字节
private static final byte[] ZERO_BYTES = new byte[MAX_DATA_LENGTH];
//数据文件后缀
private static final String DATA_SUFFIX = ".data";
//元数据文件后缀,包括索引和空白空间数据
private static final String META_SUFFIX = ".meta";

内存中表示索引和空白空间的数据结构是:

//索引信息,键->值在.data文件中的位置
Map<String, Long> indexMap;
//空白空间,值为在.data文件中的位置
Queue<Long> gaps;

表示文件的数据结构是:

//值数据文件
RandomAccessFile db;
//元数据文件
File metaFile;

构造方法

构造方法的代码为:

public BasicDB(String path, String name) throws IOException{
File dataFile = new File(path + name + DATA_SUFFIX);
metaFile = new File(path + name + META_SUFFIX); db = new RandomAccessFile(dataFile, "rw"); if(metaFile.exists()){
loadMeta();
}else{
indexMap = new HashMap<>();
gaps = new ArrayDeque<>();
}
}

元数据文件存在时,会调用loadMeta将元数据加载到内存,我们先假定不存在,先来看其他代码。

保存键值对

put方法的代码是:

public void put(String key, byte[] value) throws IOException{
Long index = indexMap.get(key);
if(index==null){
index = nextAvailablePos();
indexMap.put(key, index);
}
writeData(index, value);
}

先通过索引查找键是否存在,如果不存在,调用nextAvailablePos()为值找一个存储位置,并将键和存储位置保存到索引中,最后,调用writeData将值写到数据文件中。

nextAvailablePos方法的代码是:

private long nextAvailablePos() throws IOException{
if(!gaps.isEmpty()){
return gaps.poll();
}else{
return db.length();
}
}

它首先查找空白空间,如果有,则重用,否则定位到文件末尾。

writeData方法实际写值数据,它的代码是:

private void writeData(long pos, byte[] data) throws IOException {
if (data.length > MAX_DATA_LENGTH) {
throw new IllegalArgumentException("maximum allowed length is "
+ MAX_DATA_LENGTH + ", data length is " + data.length);
}
db.seek(pos);
db.writeInt(data.length);
db.write(data);
db.write(ZERO_BYTES, 0, MAX_DATA_LENGTH - data.length);
}

它先检查长度,长度满足的情况下,定位到指定位置,写实际数据的长度、写内容、最后补白。

可以看出,在这个实现中,索引信息和空白空间信息并没有实时保存到文件中,要保存,需要调用flush方法,待会我们再看这个方法。

根据键获取值

get方法的代码为:

public byte[] get(String key) throws IOException{
Long index = indexMap.get(key);
if(index!=null){
return getData(index);
}
return null;
}

如果键存在,就调用getData获取数据,getData的代码为:

private byte[] getData(long pos) throws IOException{
db.seek(pos);
int length = db.readInt();
byte[] data = new byte[length];
db.readFully(data);
return data;
}

代码也很简单,定位到指定位置,读取实际长度,然后调用readFully读够内容。

删除键值对

remove方法的代码为:

public void remove(String key){
Long index = indexMap.remove(key);
if(index!=null){
gaps.offer(index);
}
}

从索引结构中删除,并添加到空白空间队列中。

同步元数据flush

flush方法的代码为:

public void flush() throws IOException{
saveMeta();
db.getFD().sync();
}

回顾一下,getFD()会返回文件描述符,其sync方法会确保文件内容保存到设备上,saveMeta方法的代码为:

private void saveMeta() throws IOException{
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(metaFile)));
try{
saveIndex(out);
saveGaps(out);
}finally{
out.close();
}
}

索引信息和空白空间保存在一个文件中,saveIndex保存索引信息,代码为:

private void saveIndex(DataOutputStream out) throws IOException{
out.writeInt(indexMap.size());
for(Map.Entry<String, Long> entry : indexMap.entrySet()){
out.writeUTF(entry.getKey());
out.writeLong(entry.getValue());
}
}

先保存键值对个数,然后针对每条索引信息,保存键及值在.data文件中的位置。

saveGaps保存空白空间信息,代码为:

private void saveGaps(DataOutputStream out) throws IOException{
out.writeInt(gaps.size());
for(Long pos : gaps){
out.writeLong(pos);
}
}

也是先保存长度,然后保存每条空白空间信息。

我们使用了之前介绍的流来保存,这些代码比较啰嗦,如果使用后续章节介绍的序列化,代码会更为简洁。

加载元数据

在构造方法中,我们提到了loadMeta方法,它是saveMeta的逆操作,代码为:

private void loadMeta() throws IOException{
DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(metaFile)));
try{
loadIndex(in);
loadGaps(in);
}finally{
in.close();
}
}

loadIndex加载索引,代码为:

private void loadIndex(DataInputStream in) throws IOException{
int size = in.readInt();
indexMap = new HashMap<String, Long>((int) (size / 0.75f) + 1, 0.75f);
for(int i=0; i<size; i++){
String key = in.readUTF();
long index = in.readLong();
indexMap.put(key, index);
}
}

loadGaps加载空白空间,代码为:

private void loadGaps(DataInputStream in) throws IOException{
int size = in.readInt();
gaps = new ArrayDeque<>(size);
for(int i=0; i<size; i++){
long index = in.readLong();
gaps.add(index);
}
}

关闭

数据库关闭的代码为:

public void close() throws IOException{
flush();
db.close();
}

就是同步数据,并关闭数据文件。

小结

本节介绍了RandomAccessFile的用法,它可以随机读写,更为接近操作系统的API,在实现一些系统程序时,它比流要更为方便高效。利用RandomAccessFile,我们实现了一个非常简单的键值对数据库,我们演示了这个数据库的用法、接口、设计和实现代码。在这个例子中,我们同时展示了之前介绍的容器和流的一些用法。

这个数据库虽然简单粗糙,但也具备了一些优良特点,比如占用的内存空间比较小,可以存储大量键值对,可以根据键高效访问值等。完整代码,可以从github下载:https://github.com/swiftma/program-logic。

访问文件还有一种方式,那就是内存映射文件,它有什么特点?有什么用途?让我们下节继续探索。

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

计算机程序的思维逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库的更多相关文章

  1. Java编程的逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  2. Java编程的逻辑 (61) - 内存映射文件及其应用 - 实现一个简单的消息队列

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  3. 计算机程序的思维逻辑 (64) - 常见文件类型处理: 属性文件/CSV/EXCEL/HTML/压缩文件

    对于处理文件,我们介绍了流的方式,57节介绍了字节流,58节介绍了字符流,同时,也介绍了比较底层的操作文件的方式,60节介绍了随机读写文件,61节介绍了内存映射文件,我们也介绍了对象的序列化/反序列化 ...

  4. Perl IO:随机读写文件

    随机读写 如果一个文件句柄是指向一个实体文件的,那么就可以对它进行随机数据的访问(包括随机读.写),随机访问表示可以读取文件中的任何一部分数据或者向文件中的任何一个位置处写入数据.实现这种随机读写的功 ...

  5. 计算机程序的思维逻辑 (8) - char的真正含义

    看似简单的char 通过前两节,我们应该对字符和文本的编码和乱码有了一个清晰的认识,但前两节都是与编程语言无关的,我们还是不知道怎么在程序中处理字符和文本. 本节讨论在Java中进行字符处理的基础 - ...

  6. 计算机程序的思维逻辑 (29) - 剖析String

    上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比 ...

  7. 计算机程序的思维逻辑 (38) - 剖析ArrayList

    从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要 ...

  8. 计算机程序的思维逻辑 (40) - 剖析HashMap

    前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用? ...

  9. Java基础之读文件——使用通道随机读写文件(RandomReadWrite)

    控制台程序,使用通道随机读写primes_backup.bin文件. import static java.nio.file.StandardOpenOption.*; import java.nio ...

随机推荐

  1. .NET 串口通信

    这段时间做了一个和硬件设备通信的小项目,涉及到扫描头.输送线.称重机.贴标机等硬件.和各设备之间通信使用的是串口或网络(Socket)的方式.扫描头和贴标机使用的网络通信,输送线和称重机使用的是串口通 ...

  2. 【探索】利用 canvas 实现数据压缩

    前言 HTTP 支持 GZip 压缩,可节省不少传输资源.但遗憾的是,只有下载才有,上传并不支持.如果上传也能压缩,那就完美了.特别适合大量文本提交的场合,比如博客园,就是很好的例子. 虽然标准不支持 ...

  3. 谈一下关于CQRS架构如何实现高性能

    CQRS架构简介 前不久,看到博客园一位园友写了一篇文章,其中的观点是,要想高性能,需要尽量:避开网络开销(IO),避开海量数据,避开资源争夺.对于这3点,我觉得很有道理.所以也想谈一下,CQRS架构 ...

  4. 微软Azure 经典模式下创建内部负载均衡(ILB)

    微软Azure 经典模式下创建内部负载均衡(ILB) 使用之前一定要注意自己的Azure的模式,老版的为cloud service模式,新版为ARM模式(资源组模式) 本文适用于cloud servi ...

  5. logstash file输入,无输出原因与解决办法

    1.现象 很多同学在用logstash input 为file的时候,经常会出现如下问题:配置文件无误,logstash有时一直停留在等待输入的界面 2.解释 logstash作为日志分析的管道,在实 ...

  6. 缓存工厂之Redis缓存

    这几天没有按照计划分享技术博文,主要是去医院了,这里一想到在医院经历的种种,我真的有话要说:医院里的医务人员曾经被吹捧为美丽+和蔼+可亲的天使,在经受5天左右相互接触后不得不让感慨:遇见的有些人员在挂 ...

  7. Oracle创建表空间

    1.创建表空间 导出Oracle数据的指令:/orcl file=C:\jds.dmp owner=jds 导入Oracle数据的指令:imp zcl:/orcl file=C:\jds.dmp fu ...

  8. 【MySql】查询数据库中所有表及列的信息

    SELECT TABLE_NAME, -- 表名 COLUMN_NAME, -- 字段名 DATA_TYPE, -- 字段类型 COLUMN_COMMENT -- 字段注释 FROM INFORMAT ...

  9. 在配有英特尔® Iris™ 显卡的系统上通过优化对 Just Cause 3 进行增强

    高端 PC 继续通过高性能显卡驱动桌面游戏. 一流的"梦想机器"基于第六代智能 英特尔® 酷睿™ 处理器i7-6700K等 CPU,通常与高端独立显卡配合使用以运行要求最严苛的游戏 ...

  10. hadoop 2.4 遇到的问题

    不管出什么问题,首先查看日志. 在启动过hadoop的前提下,打开浏览器,输入http://localhost:50070 点击Utilities下的logs,选择hadoop-root-datano ...