Java文件总结

  Seves

文件注意点

UTF-8文件可以在头部添加BOM(字节序标记),即三个特殊字节(0XEF 0XBB 0XBF)来表示该文件采用UTF-8编码,带BOM头的文件不是所有应用程序都支持的,PHP就不支持BOM。

Windows系统中,换行符一般是两个字符”\r\n”,Linux系统中,换行符一般是一个字符”\n”

读文件,需要先从硬盘拷贝到操作系统内核,再从内核拷贝到应用程序分配的内存中,操作系统运行所在的环境和应用程序是不一样的,操作系统所在的环境是内核态,应用程序的是用户态,应用程序调用操作系统的功能,需要两次的环境切换,先从用户态切到内核态,再从内核态切到用户态。

操作系统操作文件一般有打开和关闭的概念,打开文件会在操作系统内核建立一个有关该文件的内存结构,这个结构一般通过一个整数索引来引用,这个索引一般称为文件描述符,这个结构是消耗内存的,操作系统能同时打开的文件一般也是有限的,在不用文件的时候,应该记住关闭文件,关闭文件一般会同步缓冲区内容到硬盘,并释放占据的内存结构。

操作系统一般支持一种称之为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件,在内存映射文件中,只有访问到的数据才会被实际拷贝到内存,且数据只会拷贝一次,被操作系统以及多个应用程序共享。

Java使用统一的概念(流)处理所有的IO,包括键盘、显示终端、网络等。

文件操作相关类

Java IO的基本类大多位于包java.io中,类InputStream表示输入流,OutputStream表示输出流,而FileInputStream表示文件输入流,FileOutputStream表示文件输出流。

FilterInputStream和FilterOutputStream的子类
- 对流起缓冲装饰的子类是BufferedInputStream和BufferedOutputStream。
- 可以按八种基本类型和字符串对流进行读写的子类是DataInputStream和DataOutputStream。
- 可以对流进行压缩和解压缩的子类有GZIPInputStream, ZipInputStream, GZIPOutputStream, ZipOutputStream。
- 可以将基本类型、对象输出为其字符串表示的子类有PrintStream。

Reader/Writer的常见子类(字符流)
- 读写文件的子类是FileReader和FileWriter。
- 起缓冲装饰的子类是BufferedReader和BufferedWriter。
- 将字符数组包装为Reader/Writer的子类是CharArrayReader和CharArrayWriter。
- 将字符串包装为Reader/Writer的子类是StringReader和StringWriter。
- 将InputStream/OutputStream转换为Reader/Writer的子类是InputStreamReader OutputStreamWriter。
- 将基本类型、对象输出为其字符串表示的子类PrintWriter。

File用于文件路径、文件元数据、文件目录、临时文件、访问权限管理等

Java NIO代表一种不同的看待IO的方式,它们更接近操作系统的概念,某些操作的性能也更高。比如,拷贝文件到网络,通道可以利用操作系统和硬件提供的DMA机制(Direct Memory Access,直接内存存取) ,不用CPU和应用程序参与,直接将数据从硬盘拷贝到网卡。除了看待方式不同,NIO还支持一些比较底层的功能,如内存映射文件、文件加锁、自定义文件系统、非阻塞式IO、异步IO等。

二进制文件和字节流

InputStream/OutputStream

InputStream的主要方法:
- read():从流中读取下一个字节,返回类型int,但取值在0到255之间,返回值为-1,如果流中没有数据,read方法会阻塞直到数据到来、流关闭、或异常出现。
- read(byte b[],int off,int len):一次读入若干个字节
- close():关闭输入流
- skip(long n)跳过输入流中n个字节,因为输入流中剩余的字节个数可能不到n,所以返回值为实际略过的字节个数。
- available()返回下一次不需要阻塞就能读取到的大概字节个数。但在从网络读取数据时,可以根据该方法的返回值在网络有足够数据时才读,以避免阻塞。
- markSupported()判断流是否支持mark/reset
- mark/reset:用于支持从读过的流中重复读取,但并不是可以在任意位置标记,mark有一个readLimit参数,表示设置了标记后,能够继续往后读的最多字节数。

OutputStream的主要方法:
- write(int b)
- write(byte b[],int off,int len)
- flush()将缓冲区而未实际写的数据进行写入
- close()一般会首先调用flush,然后再释放流占用的系统资源。

FileInputStream/FileOutputStream

new一个FileOutputStream对象会实际打开文件,操作系统会分配相关资源。如果当前用户没有写权限,会抛出SecurityException是一种运行时异常,如果指定的文件是目录,或者由于其他原因无法打开文件抛出FileNotFoundException

ByteArrayInputStream/ByteArrayOutputStream

ByteArrayOutputStream主要方法:
- toByteArray()
- toString(String charsetName)
- writeTo(OutputStream out)
- reset()
- size():返回写入的字节个数

//读取所有内容生成一个byte数组
InputStream input = new FileInputStream("hello.txt");
try{
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    byte[] buf = new byte[1024];
    int bytesRead = 0;
    while((bytesRead = input.read(buf)) != -1){
        output.write(buf,0,bytesRead);
    }
    byte[] bArr = output.toByteArray();
}finally{
    input.close();
}

DataInputStream/DataOutputStream

实现了DataOutput和DataInput接口可以以各种基本类型和字符串写入数据。

DataOutputStream方法举例
- writeBoolean: 写入一个字节,如果值为true,则写入1,否则0
- writeInt: 写入四个字节,最高位字节先写入,最低位最后写入
- writeUTF: 将字符串的UTF-8编码字节写入,这个编码格式与标准的UTF-8编码略有不同,不过,我们不用关心这个细节。

DataInputStream先按字节读进来,然后转换为对应的类型。

实用方法

/**
 * 拷贝
 */
public static void copy(InputStream input,OutputStream output)throws IOException{
    byte[] buf = new byte[4096];
    int bytesRead = 0;
    while((bytesRead = input.read(buf)) != -1){
        output.write(buf,0,bytesRead);
    }
}
/**
 * 将文件读入字节数组
 */
public static byte[] readFileToByteArray(String fileName)throws IOException{
    InputStream input = new FileInputStream(fileName);
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    try{
        copy(input,output);
        return output.toByteArray();
    }finally{
        input.close();
    }
}
/**
 * 将字节数组写入到文件
 */
public static void writeByteArrayToFile(String fileName,byte[] data)throws IOException{
    OutputStream output = new FileOutputStream(fileName);
    try{
        output.write(data);
    }finally{
        output.close();
    }
}

文本文件和字符流

Java中的字符流是按char(两个字符,表示unicode编码)而不是一个完整字符处理的,意味着增补字符集中的字符需要占用多个char

Reader/Writer

Reader方法的名称和含义与InputStream中的对应方法基本类似,但Reader中处理的单位是char,比如read读取的是一个char,取值范围为0到65535。Reader没有available方法,对应的方法是ready()

Writer与OutputStream的对应方法基本类似,但Writer处理的单位是char,Writer还接受String类型,String的内部就是char数组,处理时,会调用String的getChar方法先获取char数组。

InputStreamReader/OutputStreamWriter

用于将InputStream/OutputStream,转换为Reader/Writer并且可以设置编码。

FileReader/FileWriter

FileReader/FileWriter不能指定编码类型,只能使用默认编码,如果需要指定编码类型,可以使用InputStreamReader/OutputStreamWriter

CharArrayReader/CharArrayWriter

CharArrayWriter与ByteArrayOutputStream类型类似,都是便于将流转化为char或byte
- toCharArray() 将输出流的内容转化为字符数组
- toString() 将输出流的内容转化为字符串

BufferedReader/BufferedWriter

提供缓冲读写的功能
- newLine输出特定平台的换行符
- readLine返回一行内容,但不会包含换行符(字符’\r’或’\n’或’\r\n’被视为换行符),当读到流结尾时,返回null。

PrintWriter

PrintWriter是一个非常方便的类,可以直接指定文件名作为参数,可以指定编码类型,可以自动缓冲,可以自动将多种类型转换为字符串,在输出到文件时,可以优先选择该类。

PrintWriter具有许多打印方法,printf()格式化打印、print()普通打印、println()换行打印

Scanner

它是一个简单的文本扫描器,能够分析基本类型和字符串,它需要一个分隔符来将不同数据区分开来,默认是使用空白符,可以通过useDelimiter方法进行指定。Scanner有很多形式的next方法,可以读取下一个基本类型或行,如:nextFloat(),nextLine(),nextInt()
使用示例:

public static List<Student> readStudents() throws IOException{
    BufferedReader reader = new BufferedReader(
        new FileReader("student.txt"));
    try{
        List<Student> students = new ArrayList<Student>();
        String line = reader.readLine();
        while(line != null){
            Student s = new Student();
            Scanner scanner = new Scanner(line).useDelimiter(".");
            s.setName(scanner.next());
            s.setScore(scanner.nextDouble());
            students.add(s);
            line = reader.readLine();
        }
        return students;
    }finally{
        reader.close();
    }
}

标准流

System.in标准输入是一个InputStream对象,输入源经常是键盘
System.out标准输出流是一个PrintStream对象
System.err表示标准错误流,一般异常和错误信息输出到这个流,它也是一个PrintStream对象,输出目标默认与System.out一样,一般也是屏幕
Java中使用System类的setIn,setOut,setErr进行重定向

System.setIn(new ByteArrayInputStream("helllo".getBytes("UTF-8")))
System.setOut(new PrintStream("out.txt"))
System.setErr(new PrintStream("err.txt"))

实用方法

包括:拷贝文件、将文件全部内容读入到一个字符串、按行将文件内容读到一个列表中、按行将多行数据写到文件、将字符串写到文件

public class Utils {
    /**
     * 拷贝
     */
    public static void copy(final Reader input,final Writer output) throws IOException{
        char[] buf = new char[4096];
        int charsRead = 0;
        while((charsRead = input.read(buf)) != -1){
            output.write(buf,0,charsRead);
        }
        output.flush();
    }
    /**
     * 将文件全部内容读入到一个字符串
     */
    public static String readFileToString(final String fileName,final String encoding) throws IOException{
        BufferedReader reader = null;
        try{
            reader = new BufferedReader(new InputStreamReader(new FileInputStream(fileName), encoding));
            StringWriter writer = new StringWriter();
            copy(reader,writer);
            return writer.toString();
        }finally{
            if(reader != null){
                reader.close();
            }
        }
    }
    /**
     * 将字符串写到文件
     */
    public static void writeStringToFile(final String fileName,final String data,final String encoding) throws IOException{
        Writer writer = null;
        try{
            writer = new OutputStreamWriter(new FileOutputStream(fileName),encoding);
            writer.write(data);
        }finally{
            if(writer != null){
                writer.close();
            }
        }
    }
    /**
     * 按行将多行数据写到文件
     */
    public static void writeLines(final String fileName,final String encoding,final Collection<?> lines) throws IOException{
        PrintWriter writer = null;
        try{
            writer = new PrintWriter(fileName,encoding);
            for(Object line:lines){
                writer.println(line);
            }
        }finally{
            if(writer != null){
                writer.close();
            }
        }
    }
    /**
     * 按行将文件内容读到一个列表中
     */
    public static List<String> readLines(final String fileName,final String encoding) throws IOException{
        BufferedReader reader = null;
        try{
            reader = new BufferedReader(new InputStreamReader(new FileInputStream(fileName), encoding));
            List<String> list = new ArrayList<>();
            String line = reader.readLine();
            while(line != null){
                list.add(line);
                line = reader.readLine();
            }
            return list;
        }finally{
            if(reader!=null){
                reader.close();
            }
        }
    }
}

文件和目录操作

文件元数据

文件名与路径信息

File f = new File("../io/test/students.txt");
System.out.println(System.getProperty("user.dir"));
System.out.println("name:" + f.getName);
System.out.println("path: " + f.getPath());
System.out.println("absolutePath: " + f.getAbsolutePath());
System.out.println("canonicalPath: " + f.getCanonicalPath());

/**
 * 输出结果:
 * /Users/majunchang/io
 * name: students.txt
 * path: ../io/test/students.txt
 * absolutePath: /Users/majunchang/io/../io/test/students.txt
 * canonicalPath: /Users/majunchang/io/test/students.txt
 */

如果File对象是相对路径,则这些方法可能得不到父目录。可以先获得绝对路径表示的文件对象,在获得父目录。

Java运行加载class文件时,会从classpath指定的路径中寻找类文件。

文件基本信息

没有返回创建时间的方法,因为创建时间不是一个公共概念,Linux/Unix中就没有创建时间的概念。

//文件或目录是否存在
public boolean exists()
//是否为目录
public boolean isDirectory() 
//是否为文件
public boolean isFile()
//文件长度,字节数
public long length()
//最后修改时间,从纪元时开始的毫秒数
public long lastModified()
//设置最后修改时间,设置成功返回true,否则返回false
public boolean setLastModified(long time)

安全和权限信息

//是否为隐藏文件
public boolean isHidden()
//是否可执行
public boolean canExecute() 
//是否可读
public boolean canRead()
//是否可写
public boolean canWrite()
//设置文件为只读文件
public boolean setReadOnly()
//修改文件读权限
public boolean setReadable(boolean readable, boolean ownerOnly)
public boolean setReadable(boolean readable)
//修改文件写权限
public boolean setWritable(boolean writable, boolean ownerOnly)
public boolean setWritable(boolean writable)
//修改文件可执行权限
public boolean setExecutable(boolean executable, boolean ownerOnly)
public boolean setExecutable(boolean executable)

文件操作

/*创建*/
public boolean createNewFile() throws IOException 
//创建临时文件,prefix是必须的,且至少要三个字符,suffix为null后缀为.tmp
public static File createTempFile(String prefix, String suffix) throws IOException
public static File createTempFile(String prefix, String suffix, File directory) throws IOException

/*删除*/
//删除文件或目录,成功返回true,否则返回false。
//删除目录时,目录应为空
public boolean delete()
//将文件放入待删目录,java虚拟机退出时进行删除。
public void deleteOnExit()

/*重命名*/
public boolean renameTo(File dest)

目录操作

/*创建*/
//不会创建中间目录
public boolean mkdir() 
//会创建中间目录
public boolean mkdirs()

/*遍历*/
//返回的是文件名
public String[] list()
public String[] list(FilenameFilter filter)
//返回File对象
public File[] listFiles()
//FileFilter、FilenameFilter接口的accept函数返回true时才返回文件名或文件对象。
public File[] listFiles(FileFilter filter)
public File[] listFiles(FilenameFilter filter)

随机读写文件及其应用

RandomAccessFile

构造方法

/**
 * mode的取值
 * "r": 只用于读
 * "rw": 用于读和写
 * "rws":和"rw"一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上
 * "rwd": 和"rw"一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和"rws"的区别是,元数据的更新不要求同步
 */
public RandomAccessFile(String name,String mode) throws FileNotFoundException
public RandomAccessFile(File file,String mode) throws FileNotFoundException

实现了DataInput/DataOutut接口,实现了许多与流类似的方法,RandomAccessFile有两个额外的read方法:

/**
 * 它们确保读够期望的长度,如果到了文件结尾也没读够,它们会抛出EOFException异常。
 */
public final void readFully(byte b[]) throws IOException
public final void readFully(byte b[],int off,int len) throws IOException

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

//通过本地方法调用操作系统的API实现的
//获取当前文件指针
public native long getFilePointer() throws IOException
//更改当前文件指针到pos
public native void seek(long pos) throws IOException
//获取文件长度
public native long length() throws IOException
//修改文件长度
public native void setLength(long newLength) throws IOException

需要注意的方法

//这两个方法没有编码的概念,假定一个字节就是一个字符,这对于中文显然不成立,应该避免使用这两个方法。
public final void writeBytes(String s) throws IOException
public final String readLine() throws IOException 

键值数据库BasicDB实现

功能

利用RandomAccessFile实现一个简单的键值对数据库,BasicDB提供的接口类似于Map接口,可以按键保存、查找、删除,但数据可以持久化保存到文件上。

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

接口

public BasicDB(String path, String name) throws IOException
//保存键值对,键为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

使用

public class Student {
    private String name;
    private int age;
    private double score;
    public static BasicDB db = null;
    
    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;
    }

    public double getScore() {
        return score;
    }

    public void setScore(double score) {
        this.score = score;
    }
    
    public Student(String name,int age,double score){
        this.name = name;
        this.age = age;
        this.score = score;
    }
    
    public Student(){
        
    }

    private static BasicDB  getDb(){
        if(null == db){
            try {
                db = new BasicDB("C:/Users/KINGBOOK/Desktop/","students");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return db;
    }
    
    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();
    }
    
    private static Student toStudent(byte[] bArr){
        Student std = new Student();
        ByteArrayInputStream bin = new ByteArrayInputStream(bArr);
        DataInputStream din = new DataInputStream(bin);
        try {
            std.setName(din.readUTF());
            std.setAge(din.readInt());
            std.setScore(din.readDouble());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return std;
    }

    public static void saveStudents(Map<String, Student> students) throws IOException{
        db = getDb();
        for(Map.Entry<String,Student> kv: students.entrySet()){
            db.put(kv.getKey(),toBytes(kv.getValue()));
        }
        db.close();
    }
    
    public static Student loadStudent(String str){
        db = getDb();
        Student std = null;
        try {
            byte[] bArr = db.get(str);
            std = toStudent(bArr);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return std;
    }

    @Override
    public String toString() {
        return "Student [name=" + name + ", age=" + age + ", score=" + score + "]";
    }
    
    
}

设计

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

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

BasicDB的实现

public class 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<>();
        }
    }

    //保存键值对
    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);
    }

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

    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);
    }

    //根据键获取值
    public byte[] get(String key) throws IOException{
        Long index = indexMap.get(key);
        if(index != null){
            return getData(index);
        }
        return null;
    }

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

    /*删除键值对*/
    public void remove(String key){
        Long index = indexMap.remove(key);
        if(index != null){
            gaps.offer(index);
        }
    }

    /*同步元数据flush*/
    public void flush() throws IOException{
        saveMeta();
        db.getFD().sync();
    }

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

    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());
        }
    }

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

    /*加载元数据*/
    private void loadMeta() throws IOException{
        DataInputStream in = new DataInputStream(
            new BufferedInputStream(new FileInputStream(metaFile)));
        try{
            loadIndex(in);
            loadGaps(in);
        }finally{
            in.close();
        }
    }

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

    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();
    }
}

内存映射文件及其应用

内存映射文件不是Java引入的概念,而是操作系统提供的一种功能,大部分操作系统都支持。

基本概念

所谓内存映射文件,就是将文件映射到内存,文件对应于内存中的一个字节数组,对文件的操作变为对这个字节数组的操作,而字节数组的操作直接映射到文件上。这种映射可以是映射文件全部区域,也可以是只映射一部分区域。但操作系托不是将全部的文件内容都加载到内存中(而是按页加载,页的大小与操作系统和硬件相关),应用程序对文件的操作都改为对字节数组的操作。

在一般的文件读写中,会有两次数据拷贝,一次是从硬盘拷贝到操作系统内核,另一次是从操作系统内核拷贝到用户态的应用程序。而在内存映射文件中,一般情况下,只有一次拷贝,且内存分配在操作系统内核,应用程序访问的就是操作系统的内核内存空间,这显然要比普通的读写效率更高。

内存映射文件的另一个重要特点是,它可以被多个不同的应用程序共享,多个程序可以映射同一个文件,映射到同一块内存区域,一个程序对内存的修改,可以让其他程序也看到,这使得它特别适合用于不同应用程序之间的通信。

操作系统自身在加载可执行文件的时候,一般都利用了内存映射文件,比如:
- 按需加载代码,只有当前运行的代码在内存,其他暂时用不到的代码还在硬盘
- 同时启动多次同一个可执行文件,文件代码在内存也只有一份
- 不同应用程序共享的动态链接库代码在内存也只有一份

不太适合处理小文件,它是按页分配内存的,对于小文件,会浪费空间,另外,映射文件要消耗一定的操作系统资源,初始化比较慢

用法

需要使用FileInputStream/FileOutputStream或RandomAccessFile,它们都有一个方法getChannel()


RandomAccessFile file = new RandomAccessFile("abc.dat","rw");
try{
    /*
    MapMode.READ_WRITE表示映射模式,受限于文件打开方式、文件权限,0表示起始位置,file.length()表示映射长度
     */
    MappedByteBuffer buf = file.getChannel().map(MapMode.READ_WRITE,0,file.length());
    //使用buf...
}catch(IOException e){
    e.printStackTrace();
}finally{
    file.close();
}

ByteBuffer的相关方法

//获取当前读写位置
public final int position()
//修改当前读写位置
public final Buffer position(int newPosition)

/*这些方法读写后自动增加position*/
//从当前位置获取一个字节
public abstract byte get();
//从当前位置拷贝dst.length长度的字节到dst
public ByteBuffer get(byte[] dst)
//从当前位置读取一个int
public abstract int getInt();
//从当前位置读取一个double
public abstract double getDouble();
//将字节数组src写入当前位置
public final ByteBuffer put(byte[] src)
//将long类型的value写入当前位置
public abstract ByteBuffer putLong(long value);

/*可以直接指定position的方法,不会改变当前位置position*/
//从index处读取一个int
public abstract int getInt(int index);
//从index处读取一个double
public abstract double getDouble(int index);
//在index处写入一个double
public abstract ByteBuffer putDouble(int index, double value);
//在index处写入一个long
public abstract ByteBuffer putLong(int index, long value);


/*加载与同步相关方法*/
//检查文件内容是否真实加载到了内存,这个值是一个参考值,不一定精确
public final boolean isLoaded()
//尽量将文件内容加载到内存
public final MappedByteBuffer load() 
//将对内存的修改强制同步到硬盘上
public final MappedByteBuffer force()

BasicQueue

基于内存映射文件,实现的一个简单消息队列

功能

BasicQueue是一个先进先出的循环队列,长度固定,接口主要是出队和入队,与之前介绍的容器类的区别是:
- 消息持久化保存在文件中,重启程序消息不会丢失
- 可以供不同的程序进行协作,典型场景是,有两个不同的程序,一个是生产者,另一个是消费者,生成者只将消息放入队列,而消费者只从队列中取消息,两个程序通过队列进行协作,这种协作方式更灵活,相互依赖性小,是一种常见的协作方式。

BasicQueue的队列长度是有限的,如果满了,调用enqueue会抛出异常,消息的最大长度也是有限的,不能超过1020,如果超了,也会抛出异常。如果队列为空,dequeue返回null。

设计

  • 使用两个文件来保存消息队列,一个为数据文件,后缀为.data,一个是元数据文件.meta。
  • 在.data文件中使用固定长度存储每条信息,长度为1024,前4个字节为实际长度,后面是实际内容,每条消息的最大长度不能超过1020。
  • 在.meta文件中保存队列头和尾,指向.data文件中的位置,初始都是0,入队增加尾,出队增加头,到结尾时,再从0开始,模拟循环队列。
  • 为了区分队列满和空的状态,始终留一个位置不保存数据,当队列头和尾一样的时候表示队列为空,当队列尾的下一个位置是队列头的时候表示队列满。
  • BasicQueue实现

public class BasicQueue {
    // 队列最多消息个数,实际个数还会减1
    private static final int MAX_MSG_NUM = 1020*1024;

    // 消息体最大长度
    private static final int MAX_MSG_BODY_SIZE = 1020;

    // 每条消息占用的空间
    private static final int MSG_SIZE = MAX_MSG_BODY_SIZE + 4;
        
    // 队列消息体数据文件大小
    private static final int DATA_FILE_SIZE = MAX_MSG_NUM * MSG_SIZE;

    // 队列元数据文件大小 (head + tail)
    private static final int META_SIZE = 8;
    
    private MappedByteBuffer dataBuf;
    private MappedByteBuffer metaBuf;
    
    public BasicQueue(String path, String queueName) throws IOException {
        if (path.endsWith(File.separator)) {
            path += File.separator;
        }
        RandomAccessFile dataFile = null;
        RandomAccessFile metaFile = null;
        try {
            dataFile = new RandomAccessFile(path + queueName + ".data", "rw");
            metaFile = new RandomAccessFile(path + queueName + ".meta", "rw");

            dataBuf = dataFile.getChannel().map(MapMode.READ_WRITE, 0,
                    DATA_FILE_SIZE);
            metaBuf = metaFile.getChannel().map(MapMode.READ_WRITE, 0,
                    META_SIZE);
        } finally {
            if (dataFile != null) {
                dataFile.close();
            }
            if (metaFile != null) {
                metaFile.close();
            }
        }
    }
    
    private int head() {
        return metaBuf.getInt(0);
    }

    private void head(int newHead) {
        metaBuf.putInt(0, newHead);
    }

    private int tail() {
        return metaBuf.getInt(4);
    }

    private void tail(int newTail) {
        metaBuf.putInt(4, newTail);
    }
    
    private boolean isEmpty(){
        return head() == tail();
    }

    private boolean isFull(){
        return ((tail() + MSG_SIZE) % DATA_FILE_SIZE) == head();
    }
    
    public void enqueue(byte[] data) throws IOException {
        if (data.length > MAX_MSG_BODY_SIZE) {
            throw new IllegalArgumentException("msg size is " + data.length
                    + ", while maximum allowed length is " + MAX_MSG_BODY_SIZE);
        }
        if (isFull()) {
            throw new IllegalStateException("queue is full");
        }
        int tail = tail();
        dataBuf.position(tail);
        dataBuf.putInt(data.length);
        dataBuf.put(data);

        if (tail + MSG_SIZE >= DATA_FILE_SIZE) {
            tail(0);
        } else {
            tail(tail + MSG_SIZE);
        }
    }
    
    public byte[] dequeue() throws IOException {
        if (isEmpty()) {
            return null;
        }
        int head = head();
        dataBuf.position(head);
        int length = dataBuf.getInt();
        byte[] data = new byte[length];
        dataBuf.get(data);

        if (head + MSG_SIZE >= DATA_FILE_SIZE) {
            head(0);
        } else {
            head(head + MSG_SIZE);
        }
        return data;
    }
}
  • Consumer和Producer
/*Consumer*/
public class Consumer {
    public static void main(String[] args) throws InterruptedException {
        try {
            BasicQueue queue = new BasicQueue("C:/Users/KINGBOOK/Desktop/", "task");
            Random rnd = new Random();
            while (true) {
                byte[] bytes = queue.dequeue();
                if (bytes == null) {
                    Thread.sleep(rnd.nextInt(1000));
                    continue;
                }
                System.out.println("consume: " + new String(bytes, "UTF-8"));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

/*Producer*/
public class Producer {
    public static void main(String[] args) throws InterruptedException {
        try {
            BasicQueue queue = new BasicQueue("C:/Users/KINGBOOK/Desktop/", "task");
            int i = 0;
            Random rnd = new Random();
            while (true) {
                String msg = new String("task " + (i++));
                queue.enqueue(msg.getBytes("UTF-8"));
                System.out.println("produce: " + msg);
                Thread.sleep(rnd.nextInt(1000));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

神奇的序列化

序列化对象需要实现Serializable接口,默认的序列化机制能够处理循环引用的情况

定制序列化

定制序列化的意义,对于有些字段,它的值可能与内存位置有关,比如默认的hashCode()方法的返回值,当恢复对象后,内存位置肯定变了,基于原内存位置的值也就没有了意义。还有一些字段,可能与当前时间有关,比如表示对象创建时的时间,保存和恢复这个字段就是不正确的。

还有一些情况,如果类中的字段表示的是类的实现细节,而非逻辑信息,那默认序列化也是不适合的。为什么不适合呢?因为序列化格式表示一种契约,应该描述类的逻辑结构,而非与实现细节相绑定,绑定实现细节将使得难以修改,破坏封装。

两种定制序列化机制,一种是transient关键字,另外一种是实现writeObject和readObject方法。

//声明为transient,告诉Java默认的序列化机制,不要自动保存该字段
transient int size = 0;

//类可以实现writeObject方法,以自定义该类对象的序列化过程,其声明必须为
//LinkedList序列化代码
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
    //write out any hidden serialization magic
    //调用默认的序列化逻辑,该方法不仅保存数据信息,还保存元数据描述等隐藏信息。
    s.defaultWriteObject();

    //write out size
    s.writeInt(size);

    //write out all elements in the proper order
    for(Node<E> x=first;x!=null;x=x.next){
        s.writeObject(x.item);
    }
}

//LinkedList的反序列化代码
private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    // Read in any hidden serialization magic
    s.defaultReadObject();

    // Read in size
    int size = s.readInt();

    // Read in all elements in the proper order.
    for (int i = 0; i < size; i++)
        linkLast((E)s.readObject());
}

writeObject的基本逻辑是:
- 如果对象没有实现Serializable,抛出异常NotSerializableException。
- 每个对象都有一个编号,如果之前已经写过该对象了,则本次只会写该对象的引用,这可以解决对象引用和循环引用的问题。
- 如果对象实现了writeObject方法,调用它的自定义方法。
- 默认是利用反射机制(反射我们留待后续文章介绍),遍历对象结构图,对每个没有标记为transient的字段,根据其类型,分别进行处理,写出到流,流中的信息包括字段的类型即完整类名、字段名、字段值等。

readObject的基本逻辑是:
- 不调用任何构造方法。
- 它自己就相当于是一个独立的构造方法,根据字节流初始化对象,利用的也是反射机制。
- 在解析字节流时,对于引用到的类型信息,会动态加载,如果找不到类,会抛出ClassNotFoundException。

版本问题

Java会给类定义一个版本号,这个版本号是根据类中一系列的信息自动生成的。在反序列化时,如果类的定义发生了变化,版本号就会变化,与流中的版本号就会不匹配,反序列化就会抛出异常,类型为java.io.InvalidClassException。

自定义版本号,一方面为了更好的控制版本,一方面为了性能

private static final long serialVersionUID = 1L;

如果版本号一样,但实际的字段不匹配。Java会分情况自动进行处理,以尽量保持兼容性,大概分为三种情况:
- 字段删掉了:即流中有该字段,而类定义中没有,该字段会被忽略。
- 新增了字段:即类定义中有,而流中没有,该字段会被设为默认值。
- 字段类型变了:对于同名的字段,类型变了,会抛出InvalidClassException。

高级自定义

与writeObject/readObject的区别是,如果对象实现了Externalizable接口,则序列化过程会由writeExternal/readExternal这两个方法控制,默认序列化机制中的反射等将不再起作用,不再有类似defaultWriteObject和defaultReadObject调用,另一个区别是,反序列化时,会先调用类的无参构造方法创建对象,然后才调用readExternal。默认的序列化机制由于需要分析对象结构,往往比较慢,通过实现Externalizable接口,可以提高性能。

//Externalizable接口
void writeExternal(ObjectOutput out) throws IOException
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException

//反序列化后额外调用该方法,返回值被当作反序列化后的结果
Object readResolve()  
//序列化后额外调用该方法,返回值当作序列化后的结果
Object writeResolve()

手在键盘敲很轻,我写的代码很小心。
26