Java核心技术读书笔记11- 1 Java流与读写器


1.流与读写器

在Java API中,读入或写出一个字节序列的对象分别叫做输入流与输出流,这些字节序列的来源和目的地可以是文件、网络结构与内存块,抽象类InputStream与OutputStream构成了输入/输出(I/O)类层次结构的基础。
面向字节的流不适合与处理Unicode形式存储的信息,所以从抽象类Reader和Writer中继承出来一个专用于处理Unicode字符的类层次结构,这些类操作针对与两个字节的Unicode代码单元(一个char的表示范围)而不是的单个字节。

1.1 读写字节
read读入一个字节并返回,当读取到输入源结尾时返回-1。
write方法想输出位置写出一个字节。
无论是read还是write在执行时都将产生阻塞,直到字节确实被读入或写出。因此,如果产生了一些原因使流不能立即被访问(如网络连接慢),那么当前线程将被阻塞,这就使得该线程放弃处理机,是其它线程有机会执行有用的工作。
available方法返回不阻塞情况下可获取的字节数,所以可以先调用此方法检查可以读入的字节数再进行读入将不会将不会发生阻塞。
close方法应该在流使用完毕被关闭以释放操作系统资源。关闭一个输出流还会冲刷用于该流的缓冲区(也可以使用flush方法手动冲刷)。实际上,如果不关闭流,那么写出字节的最后一个包将可能永远也得不到传递。

        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("file/Test.txt"));
        bos.write('a');
        bos.write('b');
//        fio.flush(); //没有关闭流或手动冲刷缓冲区,上面两个字符将不会出现在文件中

使用mark方法打标记的位置是在read()方法返回的字节前一个位置。

1.2 Java中的流家族
输入/输出流

Reader/Writer

四种与流关系密切的接口——Closeable、Flushable、Readable、Appendable

Closeable:该接口继承自AutoCloseable,接口中的close方法用于关闭Closeable,关闭失败时抛出IOException,而AutoCloseable接口的close方法可以抛出任何异常,两种接口都可以用于try-with-resource语句。

Flushable:该接口的flush方法代表冲刷Flushable,只有带缓冲区的输出流与Writer实现了真正的冲刷逻辑。

Readable:该接口的read(CharBuffer cb)方法代表向cb中读入cb可以持有数量的char,返回读入的char数量,若Readble中无可用char时返回-1。由CharBuffer和Reader继承。CharBuffer类可以按顺序或随机的进行读写访问。
CharBuffer代码单元序列:

Appendable:该接口的两个方法,append(char c)与append(CharSequence cs)向Appendable追加码元或给定序列中的所有码元。由Writer、StringBuilder、CharBuffer类实现。

1.3 组合流过滤器
你可以通过组合使用多个流已获得更强大的输入输出功能,例如:

        ObjectOutputStream  outputStream= new ObjectOutputStream(new DataOutputStream(new BufferedOutputStream(new FileOutputStream("file/Test.txt"))));
        outputStream.write(65); //写字节
        outputStream.writeDouble(2.01D); //写数值
        outputStream.writeObject(new Employee(1000,"王工")); //写对象

上述返回的流可以读取磁盘文件、具有缓冲区、可以写出字节、数值、对象的流。
组合流的原则是特性会随着组合累加,例如组合缓冲流后,最后的流就会带有缓冲区。但是想使用某些的独特方法需要将其放置于最外层,例如writeObject是ObjectOutputStream的方法,若想使用这个方法,必须将其置于最外层。

        DataOutputStream  outputStream= new DataOutputStream(new ObjectOutputStream(new FileOutputStream("file/Test.txt")));
        outputStream.writeObject(new Employee(1000,"王工")); //出错 Cannot resolve method 'writeObject' in 'DataOutputStream'

java.io中的类都将相对路径名解释为与用户工作目录开始,其值为:System.getProperty("user.dir")返回结果。一般为类文件所在文件夹开始,使用IDE则是项目文件夹开始。
在Windows系统下使用路径名时最后使用两个反斜杠\作为分隔符。

1.4 文本输入与输出、读写器
在保存数据时,选择二进制格式会更高速且高效。但是二进制格式不适合人类阅读。因此,可以选择直接存储文本,文本存储时实际上是按照字符编码方式将其存储为二进制格式,不过当我们打开文件或使用流读取时会按照字符编码翻译回文本。我们可以使用Writer或Reader包装流,然后以文本的形式操作数据。在包装时可以指定编码方式,若不指定则使用主机系统的默认编码方式(使用Charset.defaultCharset()方法查看)。

        OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("file/Test.txt"), Charset.forName("UTF-8"));
        writer.write("这是UTF-8编码格式\n");
        writer.close();
        OutputStreamWriter writer2 = new OutputStreamWriter(new FileOutputStream("file/Test.txt", true), Charset.forName("ISO8859_1"));
        writer2.write("这是ISO8859-1编码格式");
        writer2.close();

使用UTF-8格式打开文件后会看到识别不了的编码会变成问号(两种编码格式都兼容ASCII码,所以英文字符不会出问题):

PrintWriter
PrintWriter是一个打印写出器,其可以接受输出流、其它写出器、File对象、文件路径作为构建参数,PrintWriter的主要功能是使用print、printf、println方法向目的地写出文本。他和我们熟悉的System.out对应方法类似(System.out实际上是一个输出流),都是将传入参数转换成字符串(对于对象调用toStrin方法),调用println方法还可以在转换后的字符串尾添加一个换行符。在构建该写出器时可以指定一个布尔类型的可选参数autoBlush,选择在调用println方法时是否会自动冲刷缓冲区。

System.in标准输入流是一个InputStream,其主要作用是将控制台作为输入源。
System.out标准输出流是一个PrintStream,其主要作用是将控制台作为输出目的地。
System.err标准错误流也是一个PrintStream,与System.out类似,主要打印错误信息,为了启提示作用,输出到控制台的文本为红色。
PrintStream也是一个流,但是其用法已经十分接近PrintWriter,同时还具有输出原生字节和字节数组的功能。
这三个流均会在JVM启动的时候初始化完成。

BufferedReader
在Java 5之前处理文本输入的唯一方式就是使用BufferedReader。该写入器提供了readLine方法可以读入一行文本。当没有输入时readLine为空,因此其一般用法为:

        BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("file/Test.txt"), "UTF-8"););
        String line = "";
        while ((line = in.readLine()) != null){
            //do something with line
        }

现在,对于文本输入的读取建议使用Scanner完成。

1.5 字符集与编码方式
什么是Unicode字符集与编码方式?见
在Java中,自从1.4引入java.nio之后,字符集的转换得到了由Charser类的统一处理。Charset建立了Unicode字符集的码元与字符编码方式之间的映射,通过调用该类的forName方法,传递一个编码方式的官方名称或别名来获得对应的Charset对象,如:Charset cset = Charset.forName("UTF-8");
通过调用该对象的aliases()方法,可以获得它的所有别名构成的Set集合。Charset名称大小写不敏感。
通过Charset的availableCharset()方法可以获得当前所有可用的字符集名称与对应的Charset构成的Map。

使用字符集完成字符串与字节数组相互转换
字符串转换成字节数组:

        Charset cset = Charset.forName("UTF-8");
        String str = "这是一个字符串";
        ByteBuffer bb = cset.encode(str);//可以传入String或CharBuffer
        byte[] bytes = bb.array(); //字节序列得到字节数组

字节数组转换成字符串:

        ByteBuffer bb2 = ByteBuffer.wrap(bytes, 0, bytes.length); //将字节数组转换成字节序列
        CharBuffer cb = cset.decode(bb2);
        String s = cb.toString(); //字符序列转换成字符串

可见,encode与decode方法直接操作的对象为字节序列、字符序列或字符串。

1.6 读写二进制数据
DataOutput/DataInput接口:DataOutput接口定义了一些以二进制格式写数组、字符、boolean和字符串的方法。

例如,writeInt方法接收一个int,在输出时总是把参数写出为一个4字节的二进制值。而writeDouble总是将一个double输出为8字节的二进制值。因此对于给定类型的每个值所需空间是相同的,这样将其读回的速度也比解析文本更快。

writeUTF方法使用修订版的8位Unicode转换格式写出字符串,规则为:首先使用UTF-16的规则表示码元序列,然后再用UTF-8的规则进行编码。一般不会有其它地方使用这个UTF-8的修订编码,所以应该是只在写出到用于JVM的字符串时才使用writeUTF方法。

相应的,读回数据时使用DataInput接口的对应方法:

这两个接口的实现类分别是DataOutputStream与DataInputStream。

RandomAccessFile
由于磁盘文件都是支持随机访问的,而网络来的数据流却不是。RandomAccessFile类可以允许你在文件中任何位置查找或写入数据,使用该类可以打开一个文件并指定对该文件进行读入或同时读写。
该类的seek方法可以将一个文件指针设置到文件中的任意字节位置,其参数是一个long类型的整数,代表指针到文件起始位置跨过的字节数。通过getFilePointer方法将返回文件指针的当前位置。文件指针指示了可以读入或写出的下一个字符位置。
RandomAccessFile类同时实现了DataInput和DataOutput接口。将随机访问类和这两个接口组合使用可以很容易的知道要插入或读出的记录位置。

public static void main(String[] args) throws IOException {
        int record_size = 2;
        DataOutputStream dos = new DataOutputStream(new FileOutputStream("file/CharSequenceFile.dat"));
        dos.writeChar('a');
        dos.writeChar('b');
        dos.writeChar('c');
        dos.writeChar('e');
        dos.close();  //当前文件存储'a''b''c''e'

        //此时我们希望对该文件最后一个字符替换为d,由于写出时长度固定,所以可以很容易找到插入位置。
        RandomAccessFile raf = new RandomAccessFile("file/CharSequenceFile.dat", "rw");//标记为读写,否则若是只读则不允许操作文件
        System.out.println(raf.getFilePointer()); //当前指针在0号位
        long totalBytes = raf.length(); //该方法返回总字节数 8
        long totalRecord = totalBytes / record_size; //总记录数 4
        raf.seek((totalRecord - 1) * record_size); //越过三个记录对应的字节数
        System.out.println(raf.getFilePointer()); //6
        raf.writeChar('d'); //在该位置完成写入

        raf.seek(0);
        for (int i = 0; i < totalRecord; i++) {
            System.out.print(raf.readChar()+" "); //a b c d
        }
    }

1.7 ZIP文档
读取ZIP
ZIP文档(通常)以压缩格式存储了一个或多个文件,每个ZIP文档都有一个头,包含诸如文件名称和所使用的压缩方式的信息。在Java中可以使用ZipInputStream来读入ZIP文档。对于文档中存储的每一个项使用getNextEntry方法可以返回描述一个项的ZipEntry类型的对象。
使用ZipInputStream获取数据是以entry为单位的,也就是说必须在获取时确保已经打开了一个entry,该流的read方法被修改为读取到一个entry尾部的时候返回-1而不是文件尾部。当你读取到entry尾部时,如果想要继续读取下一个entry(如果存在),必须关闭当前entry,然后重新调用getNextEntry打开下一个entry。

    public static void main(String[] args) throws IOException {
        ZipInputStream zip = new ZipInputStream(new FileInputStream("file\\zipFile.zip")); //在zip文件中存在两个txt文件,内容分别是abc与def
        System.out.println(zip.read()); //返回-1,必须先打开一个entry
        zip.getNextEntry();
        System.out.println(zip.read()); //100 对应字符 d
        System.out.println(zip.read()); //101 对应字符 e
        System.out.println(zip.read()); //102 对应字符 f 实际上上面三条应该写在循环里,可循环条件为zip.read() != -1
        zip.closeEntry(); //到达结尾,必须关闭entry否则无法继续读取

        while(zip.getNextEntry() != null){ //遍历entry也应写成循环
            Scanner scanner = new Scanner(zip);
            while(scanner.hasNextLine()){ //使用scanner直接输出文本内容
                System.out.println(scanner.nextLine()); //abc
            }
            System.out.println(zip.read()); //-1 已经到达该entry的尾部
            zip.closeEntry(); 
        }
        zip.close();
    }

写出ZIP
同理,写出ZIP文件也应该以entry为单位,每次写入应该建立一个entry,写入完毕后关闭entry。

        ZipOutputStream zip = new ZipOutputStream(new FileOutputStream("file\\myZipFile.zip"));
        ZipEntry entry1 = new ZipEntry("1.txt"); //第一个文件
        zip.putNextEntry(entry1); //开启一个entry准备写入
        zip.write(97);
        zip.write(98);
        zip.write(99);
        zip.closeEntry();

        ZipEntry entry2 = new ZipEntry("2.txt"); //第二个文件
        zip.putNextEntry(entry2); //开启一个entry准备写入
        zip.write(65);
        zip.write(66);
        zip.write(67);
        zip.closeEntry();
        zip.close(); //写入两个文件到zip文档中,内容分别是abc与ABC

1.JAR文件是带有特殊项的ZIP文件,可以使用JarInputStream或JarOutputStream类来读写
2.ZIP文件在读取时不用担心边解压边读取的问题,因为Java流可以直接访问其数据,同时ZIP格式的字节源也并非必须是文件,也可以是来自网络连接的ZIP数据。

1.8 对象流与序列化
对于对象,我们或许可以使用可以记录固定长度的Data流来存储。但是由于对象具有多态性,可能存储的同类型对象大小并不相同(如Employee和Manager),因此如果我们需要存储对象,那么可以使用Java提供的对象序列化机制。
我们首先需要对想要进行序列化的类实现Serializable接口,该接口没有任何方法需要实现。

    public static void main(String... args) {
        File file = new File("file\\EmployeeObj.dat");
        try (ObjectOutputStream oops = new ObjectOutputStream(new FileOutputStream(file))){ //构建一个对象输出流
            Employee e = new Employee();
            e.setSalary(1000);
            e.setName("小王");

            Manager m = new Manager();
            m.setSecretary(e);
            m.setSalary(10000);
            m.setName("李总"); //创建了两个对象,其中一个对象是两外一个对象的域


            oops.writeObject(m);
            oops.writeObject(e);
            oops.writeObject("str"); //接着存储一个字符串对象
        }catch (Exception e){
            e.printStackTrace();
        }

        File file2 = new File("file\\EmployeeObj.dat");
        try (ObjectInputStream oops = new ObjectInputStream(new FileInputStream(file2))){
            Manager m = (Manager) oops.readObject();
            System.out.println(m.getName()+"的秘书是:"+m.getSecretary().getName()); //按写入对象的顺序读回 李总的秘书是:小王

            Employee e = (Employee) oops.readObject();
            System.out.println(e.getName()); //小王

            String s = (String) oops.readObject();
            System.out.println(s); //str
        }catch (Exception e){
            e.printStackTrace();
        }
    }

在进行序列化时,对象输出流会浏览对象的所有域,并存储他们的内容。对于每个对象,序列化机制都为其分配一个序列号,当一个对象的某个域引用了其它的对象时:
· 每个对象存储时还会存储一个相应的序列号
· 若引用的对象第一次遇到,则将其保存到流中
· 若引用的对象之前已经被保存过,则只关联引用对象的序列号即可
读回对象的顺序正好相反:
· 若第一次遇到对象的序列号时,构建它,然后使用流中的数据进行初始化,然后记住这个序列号和对象的关联
· 若遇到了与之前保存过的对象相同的序列号,则获取这个对象引用

对象序列化可以将对象集合通过网络传输到另外一台计算机上,也可以将对象保存到本地磁盘上。同时,对象序列化无法保存类的静态域和瞬时域的值。

修改默认的序列化机制
transient修饰符:被该修饰符修饰的域不会被序列化。
writeObject/readObject方法:在待序列化的类中重写private void readObject(ObjectInputStream in)与private void writeObject(ObjectOutputStream out)方法,进行序列化读写操作时不会再直接默认序列化对象而是调用这两个方法。

public class Manager extends Employee {
    private Employee secretary;
    private transient String name;

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject(); //读取对象并使用可序列化域为其赋值
        String str = (String) in.readObject();
        name = str;
    }
    private void writeObject(ObjectOutputStream out)throws IOException {
        Manager manager = new Manager();
        out.defaultWriteObject(); //写出对象描述符和可序列化域
        out.writeObject("指定序列化名");
    }
    ...
}
public class ObjSerializationTest {
    public static void main(String... args) {
        File file = new File("file\\EmployeeObj.dat");
        try (ObjectOutputStream oops = new ObjectOutputStream(new FileOutputStream(file))){ //构建一个对象输出流
            Manager m = new Manager(10000,"李总");
            oops.writeObject(m); //写出对象,执行的是自定义的方法
        }catch (Exception e){
            e.printStackTrace();
        }
        try (ObjectInputStream oops = new ObjectInputStream(new FileInputStream(file))){
            Manager m = (Manager) oops.readObject(); //读入对象,不过将会执行自定义的方法
            System.out.println(m.getName()); //指定序列化名 原name域不可序列化,读取的是手动在writeObject方法中指定的
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

Externalizable接口:Externalizable接口中提供了两个序列化和反序列化的方法分别是writeExternal与readExternal,在这两个方法中,你必须手动指定要读写哪些域,且读写的顺序必须一致。

public class Manager extends Employee implements Externalizable{
    private Employee secretary;
    private transient String name;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        System.out.println("执行writeExternal");
        out.writeObject(secretary);
        out.writeUTF(getName()); //直接手动设置name,所以与普通序列化机制的transient无关
        out.writeDouble(getSalary());
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        System.out.println("执行readExternal");
        secretary = (Employee) in.readObject();
        name = in.readUTF();
        setSalary(in.readDouble()); //对域的读写顺序必须一致
    }

    public Manager(){
        System.out.println("执行了无参数的构造器");
    }
    ...
}

public class ObjSerialization {
    public static void main(String... args) {
        File file = new File("file\\EmployeeObj.dat");
        try (ObjectOutputStream oops = new ObjectOutputStream(new FileOutputStream(file))){ //构建一个对象输出流
            Employee e = new Employee(100, "员工");
            Manager m = new Manager(10000, "管理者");
            m.setSecretary(e);
            oops.writeObject(m); //写出对象,执行的是writeExternal方法
        }catch (Exception e){
            e.printStackTrace();
        }
        try (ObjectInputStream oops = new ObjectInputStream(new FileInputStream(file))){
            Manager m = (Manager) oops.readObject(); //读入对象,执行readExternal方法
            System.out.println(m);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

Serializable搭配transient与Externalizable都能实现自定义的对象序列化,那么他们的主要区别是什么呢?
1.Serializable反序列化时构建对象使用的是反射机制,而Externalizable是调用无参构造器创建对像,所以使用该接口时你也要必须确保类中包含无参构造器。
2.Externalizable无须产生序列化ID
3.Externalizable序列化与反序列化的速度更快、占用内存更少。所以如果需要频繁序列化与反序列化可以使用Externalizable或者其他第三方工具。

序列化单例与枚举
我们知道,序列化机制是使用反射机制创建对象,既然是创建对像,如何保证单实例类的安全呢?对于枚举类(相当于一个单实例类),这种机制是安全的。但对于单例类——不提供public构造器,所有对象调用静态方法返回,这可能就会带来问题。因为反射机制会自行创建一个新的对象,这个问题由定义一个readSolve方法解决,在反序列化时,调用readObject之后可以自动调用该方法,且该方法存在返回值,返回的就是流最终返回的结果。因此,你可以在这个方法中保护单例模式的实现。

public class Orientation implements Serializable {
    public static final Orientation HORIZONTAL = new Orientation(1);
    public static final Orientation VERTICAL = new Orientation(2); //该类只会有两个的对象(value为1或2),且已经初始化完成,不能自己构造,只能通过静态变量取得

    private int value; //域

    private Orientation(int v){ //私有构造器,保证了不允许用户创建对象
        value = v;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        System.out.println("执行writeObject");
        out.defaultWriteObject();
    }
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        System.out.println("执行readObject");
        in.defaultReadObject();
    }
    private Object readResolve()throws ObjectStreamException{
        System.out.println("执行readResolve");
        //读取反序列化写回的新对象,根据其值返回单例对象,以保证单例模式
        if(value == 1){
            return HORIZONTAL; //保证返回的
        }else {
            return VERTICAL;
        }
    }
}

public class SingletonIOTest {
    public static void main(String[] args) {
        File file = new File("file\\EmployeeObj.dat");
        try (ObjectOutputStream oops = new ObjectOutputStream(new FileOutputStream(file));
             ObjectInputStream oips = new ObjectInputStream(new FileInputStream(file))){
            Orientation h = Orientation.HORIZONTAL;
            Orientation v = Orientation.VERTICAL;
            oops.writeObject(h);
            Orientation orientation = (Orientation) oips.readObject();
            //若类编写了readResolve方法,则在反序列化对象时会调用这个方法,根据这个方法的结果返回值。
            System.out.println(orientation == h); //true 堆地址相同,是一个对象
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

序列化实现深克隆(深拷贝)
既然使用流创建出来的对象是一个新对象,那我们可不可以使用流来进行深拷贝?之前使用流时都是将对象写入到文件在读出,如果用这种方式进行拷贝或许开销大了些。实际上,我们可以使用字节数组流将对象写出到字节数组,再读出,这样就可简单的实现对象的深拷贝。不过,这种方式要比直接创建一个相同的对象完成拷贝的方式慢得多。

public class Manager extends Employee implements Externalizable, Cloneable{
    private Employee secretary;
    private transient String name;

    @Override
    public Object clone() throws CloneNotSupportedException {
        Manager m = new Manager(this.getSalary(), this.getName());
        m.setSecretary(this.getSecretary());
        return m;
    }
    ...
}

public class DeepCloneTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException, CloneNotSupportedException {
        Manager m = new Manager();
        m.setName("克隆主体");
        m.setSalary(10000);

        Employee e = new Employee();
        e.setName("克隆人的秘书");
        m.setSecretary(e);

        ByteArrayOutputStream baos = new ByteArrayOutputStream(); //字节数组流
        ObjectOutputStream oops = new ObjectOutputStream(baos); //对象输出流
        oops.writeObject(m); //将对象写入字节数组流
        oops.close();
        baos.close();
        byte[] objBytes = baos.toByteArray(); //流中内容转成数组
        ByteArrayInputStream bais = new ByteArrayInputStream(objBytes); //有数组构建输入流
        ObjectInputStream oips = new ObjectInputStream(bais); //从流中读取
        Manager clone1 = (Manager) oips.readObject();
        Manager clone2 = (Manager) m.clone();
        //检查克隆出来的对象,其域引用的对象是不是一个新对象
        System.out.println(clone1.getSecretary() == m.getSecretary()); //false 使用流进行拷贝执行的是深拷贝
        System.out.println(clone2.getSecretary() == m.getSecretary()); //true 重写的clone方法没有重新构建引用类型的域,所以是一个浅拷贝
        oips.close();
        bais.close();
    }
}