《Java编程思想》读书笔记(三)

2022/9/3 1:24:40

本文主要是介绍《Java编程思想》读书笔记(三),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言:三年之前就买了《Java编程思想》这本书,但是到现在为止都还没有好好看过这本书,这次希望能够坚持通读完整本书并整理好自己的读书笔记,上一篇文章是记录的第十一章到第十六章的内容,这一次记录的是第十七章到第十八章的内容,主要是集合和I/O内容太多,限于篇幅本文先记录两章内容,本文还是会把自己感兴趣的知识点记录一下,相关示例代码放在码云上了,码云地址:https://gitee.com/reminis_com/thinking-in-java

第十七章:容器深入研究

完整的容器分类法:这张图是把工作中常用到的实现类和相关接口使用UML类图辨识出来

Java SE5新添加了:

  • Queue接口及其实现PriorityQueue和各种风格的BlockingQueue
  • ConcurrentMap接口及其实现ConcurrentHashMap,它们也是用于多线程机制的
  • CopyOnWriteArrayList和CopyOnWriteArraySet,它们也是用于多线程机制的
  • EnumSet和EnumMap,为使用enum而设计的Set和Map的特殊实现

Collection的功能方法

方法 描述
boolean add(T e); 确保容器持有具有泛型类型T的参数。如果没有则将此参数添加进容器,则返回false
boolean addAll(Collection<? extends T> c); 添加参数中的所有元素,只要添加了任意元素就返回true
void clear(); 移除容器中的所有元素
boolean contains(T e); 如果容易已持有具有泛型类型T此参数,则返回true
boolean containsAll(Collection<?> c); 如果容器持有参数中的所有元素,则返回true
boolean isEmpty(); 容器中没有元素时返回true
Iterator iterator(); 返回一个Iterator,可以用来遍历容器中的元素
boolean remove(Object o); 如果参数在容器中,则移除此元素的一个实例。如果做了移除动作,则返回true
boolean removeAll(Collection<?> c); 移除参数中的所有元素,只要有移除动作则返回true
boolean retainAll(Collection<?> c); 只保存参数中的元素(相当于"交集"的概念),只要Collection发生了改变就返回true
int size(); 返回容器中元素的数目
Object[] toArray(); 返回一个数组,该数组包含容器中的所有元素
T[] toArray(T[] a); 返回一个数组,该数组包含容器中的所有元素。返回结果的运行时类型与参数数组a的类型相同,而不是单纯的Object

注意:Collection的方法中不包括随机访问所选择元素的get()。因为Collection包含Set,而Set是自己维护内部顺序的(这使得随机访问变得没有意义)。因此,如果想检查Collection中的元素,那就必须使用迭代器。

  • UnsupportedOperationException:最常见的未获支持的操作,都来源于背后由固定尺寸的数据结构支持的容器。当你用Arrays.asList()将数组转为List时,就会得到这样的容器。因为Arrays.asList()生成的List是基于一个固定大小的数组,仅支持那些不会改变数组大小的操作。任何会引起对底层数据结构的尺寸进行修改的方法都会产生一个UnsupportedOperationException异常。而Collections.unmodifiableList()会产生一个不可修改的列表。

Set和存储顺序

集合对象 描述
Set(interface) 存入Set的每个元素都必须是唯一的,因为Set不保存重复元素。加入Set的元素必须定义equals()方法以确保对象的唯一性。Set和Collection有完全一样的接口。Set接口不保证维护元素的次序
HashSet 为了快速查找而设计的Set。存入HashSet的元素必须定义hashCode()
TreeSet 保证次序的Set,底层为树结构。使用它可以从Set中提取有序的序列。元素必须实现Comparable接口
LinkedHashSet 具有HashSet的查询速度,且内部使用链表维护元素的顺序(插入的次序),于是在使用迭代器遍历Set时,结果会按元素的插入次序显示。元素也必须定义hashCode()方法。

注意: 对于良好的编程风格而言,你在覆盖equals()方法时,需要总是同时覆盖hashCode()方法。

理解Map

  Map也叫映射表(或者关联数组),其基本思想是它维护的是键-值(对)关联,因此你可以用键来查找值。

Map 描述
HashMap Map基于散列表的实现(它取代了Hashtable),插入和查询”键值对“的开销是固定的。可以通过构造器设置容量和负载因子,以调整容器的性能
LinkedHashMap 类似于HashMap,但是迭代遍历它时,取得”键值对“的顺序是其插入次序,或者是最近最少使用(LRU)的次序。只比HashMap慢一点,而在迭代访问时反而更快,因为它使用链表维护内部次序
TreeMap 基于红黑树的实现。查看”键“或”键值对“时,它们会被排序(次序由Comparable或Comprator决定)。TreeMap的特点在于,所得到的结果是经过排序的。TreeMap是唯一带有subMap()方法的Map,它可以返回一个子树。
WeakHashMap 弱键(weak key)映射,需要释放映射所指向的对象,这是为解决某类特殊问题而设计的。如果映射之外没有引用指向某个”键“,则此”键“可以被垃圾回收器回收
ConcurrentHashMap 一种线程安全的Map,它不涉及同步加锁。将在后面”并发“继续讨论
IdentityHashMap 使用 == 代替 equals()对”键“进行比较的散列映射。专为解决某类特殊问题而设计的。

注意:对Map中使用的键的要求与对Set的元素要求一样。

SortedMap

使用SortedMap(TreeMap是其现阶段的唯一实现),可以确保键处于排序状态,这使得它具有额外的功能,这些功能由SortedMap接口中的下列方法提供:

  • Comparator comparator(): 返回当前Map使用的Comparator;或者返回null,表示以自然方式排序
  • T firstKey()返回Map中的第一个键
  • T lastKey()返回Map中的最末一个键
  • SortedMap subMap(fromKey, toKey)生成此Map的子集,范围由formKey(包含)到toKey(不包含)的键确定
  • SortedMap headMap(toKey)生成此Map的子集,由键小于toKey的所有键值对组成。
  • SortedMap tailMap(fromKey)生成此Map的子集,由键大于或等于formKey的所有键值对组成。

在测试TreeMap的新增功能之前为了更好创建测试数据,自定义了CountingMapData这个类,它经过预初始化,并且都是唯一的Integer和String的Map,它可以具有任意尺寸。CountingMapData实现如下:

public class CountingMapData extends AbstractMap<Integer, String> {

    private int size;
    private static String[] chars = ("A B C D E F G H I J K L M N O P Q R S T " +
            "U V W X Y Z").split(" ");

    public CountingMapData(int size) {
        if (size < 0) {
            this.size = 0;
        }
        this.size = size;
    }

    public static class Entry implements Map.Entry<Integer, String> {
        int index;
        public Entry(int index) {
            this.index = index;
        }

        @Override
        public boolean equals(Object obj) {
            return Integer.valueOf(index).equals(obj);
        }

        @Override
        public int hashCode() {
            return Integer.valueOf(index).hashCode();
        }

        @Override
        public Integer getKey() {
            return index;
        }

        @Override
        public String getValue() {
            return chars[index % chars.length] + index / chars.length;
        }

        @Override
        public String setValue(String value) {
            throw new UnsupportedOperationException();
        }
    }

    @Override
    public Set<Map.Entry<Integer, String>> entrySet() {
        // LinkedHashSet维护了初始化顺序
        Set<Map.Entry<Integer, String>> entries = new LinkedHashSet<>();
        for (int i = 0; i < size; i++) {
            entries.add(new Entry(i));
        }
        return entries;
    }

    public static void main(String[] args) {
        System.out.println(new CountingMapData(100));
    }
}

下面的例子演示了TreeMap新增的功能:

public class SortedMapDemo {

    /**
     * 演示 TreeMap 新增的功能
     */
    public static void main(String[] args) {
        TreeMap<Integer, String> sortedMap = new TreeMap<>(
                new CountingMapData(10));
        System.out.println(sortedMap);

        Integer firstKey = sortedMap.firstKey();
        System.out.println(firstKey);

        Integer lastKey = sortedMap.lastKey();
        System.out.println(lastKey);

        Iterator<Integer> iterator = sortedMap.keySet().iterator();
        for (int i = 0; i <= 6; i++) {
            if (i == 3) {
                firstKey = iterator.next();
            }
            if (i == 6) {
                lastKey = iterator.next();
            } else {
                iterator.next();
            }
        }

        System.out.println(firstKey);
        System.out.println(lastKey);
        System.out.println(sortedMap.subMap(firstKey, lastKey));
        System.out.println(sortedMap.headMap(lastKey));
        System.out.println(sortedMap.tailMap(firstKey));
    }
}

运行结果如下图:

此外,键值对是按照键的次序排列的。TreeMap中的次序是由意义的,因为”位置“的概念才有意义,所以才能取得第一个和最后一个元素,并且可以提取Map的子集。

LinkedHashMap

为了提高速度,LinkedHashMap散列化所有元素,但是在遍历键值对时,却又以键值对的插入顺序返回键值对。此外,可以在构造器中设定LinkedHashMap,使之采用最近最少用(LRU)算法,于是没有被访问过的(可看作需要被删除的)元素,就会出现在队列的前面。对于需要定期清理元素以节省空间的程序的来说,此功能使得程序容易实现。下面这个简单的例子演示了LinkedHashMap的这两种特点:

public class LinkedHashMapDemo {

    public static void main(String[] args) {
        LinkedHashMap<Integer, String> linkedHashMap = new LinkedHashMap<>(
                new CountingMapData(9)
        );
        System.out.println(linkedHashMap);

        // LRU顺序
        linkedHashMap = new LinkedHashMap<>(16, 0.75f, true);
        linkedHashMap.putAll(new CountingMapData(9));
        System.out.println(linkedHashMap);

        for (int i = 0; i < 6; i++) {
            linkedHashMap.get(i);
        }
        System.out.println(linkedHashMap);

        linkedHashMap.get(0);
        System.out.println(linkedHashMap);
    }

}

执行结果如下:

从输出中可以看到,键值对是以插入的顺序进行遍历的,甚至LRU算法版本也是如此。但是在LRU版本中,在(只)访问过前面6个元素后,最后三个元素移动到了队列前面,然后在访问一次元素”0“时,它就被移动到队列后端了。

散列与散列码

正确的equals()方法必须满足系列5个条件:

  • 自反性:对任意x,x.equals(x)一定返回true
  • 对称性:对任意的x和y,如果y.equals(x)为true,则x.equals(y)也返回true
  • 传递性:对任意的x、y、z,如果x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)一定返回true
  • 一致性:对任意x和y,如果对象中用于等价比较的信息没有改变,那么无论调用x.equals(y)多少次,返回的结果都应该保持一致,要么一直是true,要么一直是false。
  • 对于任何不是null的x,x.equals(null)一定返回false。

注意:Object默认的equals()方式只是比较对象的地址,如果要使用自己的类作为HashMap的键,必须同时重载equlas()和hashCode()方法。
  理解hashCode():首先,使用散列的目的在于:想要使用一个对象来查找另一个对象。

  为速度而散列:散列的价值在于速度,散列使得查询得以快速进行。由于瓶颈位于键的查询速度,因此解决方案之一就是键的排序状态,然后使用Collections.binarySearch()进行查询。
  散列则更进一步,它将键保存在某处,以便很快能够找到。存储一组元素最快的数据结构是数组,所以使用它来表示键的信息(请小心留意,我是说键的信息,而不是键本身)。但是因为数组不能调整容量,因此就有一个问题:我们希望在Map中保存不确定数量的值,但是如果键的数量被数组的容量限制了,该怎么办呢?
  答案就是:数组并不保存键本身,而是通过键对象产生一个数字,将其作为数组的下标,这个数字就是散列码,由定义在Object中的,且可能由你的类覆盖的hashCode()方法(也叫散列函数)生成。
  为解决数组容量固定的问题,不通的键可以产生相同的下标。也就是说可能会有冲突,因此,数组多大就不重要了,任何键总能在数组中找到它的位置。
  于是查询一个值的过程首先就是计算散列码,然后使用散列码查询数组,如果能够保证没有冲突(如果值的数量是固定的,那么就有可能),那可就有了一个完美的散列函数,但是这种情况只是特例。通常,冲突由外部链接处理:数组并不直接保存值,而是保存值的list。然后对list中的值使用equals()进行线性的查询。这部分的查询自然会很慢,但是,如果散列函数好的话,数组的每个位置就只有比较少的值。因此,不是查询整个list,而是快速跳到数组的某个位置,只对很少的元素进行比较。这便是HashMap会如此快的原因。

  由于散列表中的”槽位“(slot)通常称为(bucket),因此我们将表示实际散列表的数组命名为bucket。为使散列均匀分布,桶的数量通常使用质数(事实证明:质数实际上并不是散列桶的理想容量,近来,经广泛的测试,,Java的散列函数都使用2的整数次幂。对现代的处理器来说,除法与求余数是最慢的操作。使用2的整数次方长度的散列表,可用掩码代替除法,因为get()是使用最多的操作,求余数的%操作是其开销最大的部分, 而使用2的整数次方可以消除此开销)。
  对于put()方法,hashCode()将针对键而被调用,并且其结果将被强制转换为正数。为了使产生的数字适合bucket数组的大小,取模操作符将按照该数组的尺寸取模,如果数组的某个位置是null,这表示还没有元素被散列至此,所以为了保存刚散列到该定位的对象,需要创建一个新的LinkedList。一般的过程是,查看当前位置的list是都有相同的元素,如果有,则将旧的值赋给oldValue,然后用新的值取代旧的值。标记found用来跟踪是否找到(相同的)旧的键值对,如果没有,则将新的键值对添加到list的末尾。

覆盖hashCode()

  在明白了如何散列之后,编写自己的hashCode()方法就更有意义了。设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都应该生成同样的值。如果在将一个对象用put()添加进HashMap时产生一个hashCode()值,而用get()取出时却产生了另一个hashCode()值,那么就无法重新获得该对象了。所以,如果你的hashCode()方法依赖于对象中易变的数据,用户就要当心了,因为此数据发生变化时,hashCode()就会生成一个不通的散列码,相当于产生了一个不同的键。此外,也不应该使hashCode()依赖于具有唯一性的对象信息,尤其是使用this的值,这只能产生很糟糕的hashCode()。因为这样做无法生成一个新的键,使之与put()中原始的键值对中的键相同。

选择接口的不同实现

  容器之间的区别通常归结为由什么在背后“支持”它们。也就是说,所使用的接口是由什么样的数据结构实现的,例如,因为ArrayList和LinkedList都实现了List接口,所以无论选择哪个,基本的List操作都是相同的。然而,ArrayList底层由数组支持;而LinkedList是由双向链表实现的,其中的每个对象包含数据的同时还包含指向链表中前一个与后一个元素的引用。因此,如果要经常在表中插人或删除元素,LinkedList比较合适(LinkedList还有建立在 AbstractSequentialList基础上的其他功能),否则,应该使用速度更快的ArrayList。
  再举个例子,Set可被实现为TreeSet、HashSet或LinkedHashSet。每一种都有不同的行为 HashSet是最常用的,查询速度最快;LinkedHashSet保持元素插入的次序;TreeSet基于 TreMap,生成一个总是处于排序状态的Set。你可以根据所需的行为来选择不同的接口实现。

第十八章:Java I/O系统

对程序语言的设计者而言,创建一个好的输入/输出(I/O)系统是一项艰难的任务。

  1. File类既能代表一个特定文件的名称,又能代表一个目录下一组文件的名称。下面展示了如何使用“目录过滤器”显示我们符合条件的File对象
// Args: "D.*\.java"
public class DirList {

    public static void main(String[] args) {
        File path = new File("G:\\demo");
        String[] list;
        // File的list()方法会为此目录对象下的每个文件名调用accept(),来判断该文件是否包含在内,判断结果由accept()返回的布尔值表示
        if (args.length == 0) {
            list = path.list();
        } else {
            list = path.list(new DirFilter(args[0]));
        }
        // 按照字母排序
        Arrays.sort(list, String.CASE_INSENSITIVE_ORDER);
        for (String dirItem : list) {
            System.out.println(dirItem);
        }
    }
}

class DirFilter implements FilenameFilter {

    private Pattern pattern;

    public DirFilter(String regex) {
        this.pattern = Pattern.compile(regex);
    }

    @Override
    public boolean accept(File dir, String name) {
        return pattern.matcher(name).matches();
    }
}  
/* Output:
DirectoryDemo.java
DirList.java
DirList2.java
DirList3.java
 *///:~
  1. File类不仅仅只代表存在的文件或目录,也可以用File对象来创建新的目录或尚不存在的整个目录路径。我们还可以查看文件的特性(如:大小,最后修改日期,读/写),检查某个File对象代表的是一个文件还是一个目录,并可以删除文件。下面展示了File类的一些其它方法:
// {Args: MakeDirectoriesTest}
public class MakeDirectories {
    private static void usage() {
        System.err.println(
                "Usage:MakeDirectories path1 ...\n" +
                        "Creates each path\n" +
                        "Usage:MakeDirectories -d path1 ...\n" +
                        "Deletes each path\n" +
                        "Usage:MakeDirectories -r path1 path2\n" +
                        "Renames from path1 to path2");
        System.exit(1);
    }

    private static void fileData(File f) {
        System.out.println(
                "绝对路径: " + f.getAbsolutePath() +
                        "\n 可读: " + f.canRead() +
                        "\n 可写: " + f.canWrite() +
                        "\n 文件名称: " + f.getName() +
                        "\n 上级目录: " + f.getParent() +
                        "\n 文件路径: " + f.getPath() +
                        "\n 文件大小: " + f.length() +
                        "\n 最后修改时间: " + f.lastModified());
        if(f.isFile()) {
            System.out.println("这是一个文件");
        } else if(f.isDirectory()) {
            System.out.println("这是一个目录");
        }
    }

    public static void main(String[] args) {
        if(args.length < 1) {
            usage();
        }

        if(args[0].equals("-r")) {
            if(args.length != 3) {
                usage();
            }

            File old = new File(args[1]), rname = new File(args[2]);
            old.renameTo(rname);
            fileData(old);
            fileData(rname);
            return; // Exit main
        }

        int count = 0;
        boolean del = false;
        if(args[0].equals("-d")) {
            count++;
            del = true;
        }
        count--;
        while(++count < args.length) {
            File f = new File(args[count]);
            if(f.exists()) {
                System.out.println(f + " exists");
                if(del) {
                    System.out.println("deleting..." + f);
                    f.delete();
                }
            }
            else { // Doesn't exist
                if(!del) {
                    f.mkdirs();
                    System.out.println("created " + f);
                }
            }
            fileData(f);
        }
    }
}

运行结果如下图:

输入和输出

  编程语言的I/O类库中常使用流这个抽象概念,它代表任何有能力产出数据的数据源对象或者是有能力接收数据的接收端对象。“流”屏蔽了实际的I/O设备中处理数据的细节。Java类库中的I/O类分成输入和输出两部分,可以在JDK文档里的类层次结构中查看到。通过继承,任何自Inputstream或Reader派生而来的类都含有名为read()的基本方法,用于读取单个字节或者字节数组。同样,任何自OutputStream或Writer派生而来的类都含有名为write()的基本方法,用于写单个字节或者字节数组。但是,我们通常不会用到这些方法,它们之所以存在是因为别的类可以使用它们,以便提供更有用的接口。因此,我们很少使用单一的类来创建流对象,而是通过叠合多个对象来提供所期望的功能(这是装饰器设计模式,你将在本节中看到它)。实际上,Java中“流”类库让人迷惑的主要原因就在于∶创建单一的结果流,却需要创建多个对象。

  在Java1.0中,类库的设计者首先限定与输入有关的所有类都应该从InputStream继承,而与输出有关的类都应该从OutputStream继承。但Java 1.1对基本的I/O流类库进行了重大的修改。当我们初次看见Reader和Writer类时,可能会以为这是两个用来替代InputStream和OutputStreamt的类;但实际上并非如此。尽管一些原始的“流”类库不再被使用(如果使用它们,则会收到编译器的警告信息),但是ImputStream 和OutputStreamt在以面向字节形式的I/O中仍可以提供极有价值的功能,Reader和Writer则提供兼容Unicode与面向字符的I/O功能。另外∶
1)Java 1.1向InputStream和OutputStreamt继承层次结构中添加了一些新类,所以显然这两个类是不会被取代的。
2)有时我们必须把来自于“字节”层次结构中的类和“字符”层次结构中的类结合起来使用。为了实现这个目的,要用到“适配器”(adapter)类∶InputStreamReader可以把InputStream转换为Reader,而OutputStreamWriter可以把OutputStream转换为Writer。
设计Reader和Writer继承层次结构主要是为了国际化。老的I/O流继承层次结构仅支持8位字节流,并且不能很好地处理16位的Unicode字符。由于Unicode用于字符国际化(Java本身的char也是16位的Unicode),所以添加Reader和Writer继承层次结构就是为了在所有的I/O操作中都支持Unicode。另外,新类库的设计使得它的操作比旧类库更快。

我们有必要按照功能对这些类进行分类:

数据的来源和去处

  几乎所有原始的Java I/O流类都有相应的Reader和Writer类来提供天然的Unicode操作。然而在某些场合,面向字节的InputStream和OutputStream才是正确的解决方案;特别是java.util.zip类库就是面向字节的而不是面向字符的。因此,最明智的做法是尽量尝试使用Reader 和Writer,一旦程序代码无法成功编译,我们就会发现自己不得不使用面向字节类库。

自我独立的类:RandomAccessFile

  RandomAccessFile适用于由大小已知的记录组成的文件,所以我们可以使用seek()将记录从一处转移到另一处,然后读取或者修改记录。文件中记录的大小不一定都相同,只要我们能够确定那些记录有多大以及它们在文件中的位置即可。

  最初,我们可能难以相信RandomAccessFile不是InputStream或者OutputStream继承层次结构中的一部分。除了实现了DataInput和DataOutput接口(DataInputStream和DataOutputStream 也实现了这两个接口)之外,它和这两个继承层次结构没有任何关联。它甚至不使用InputStream和OutputStream类中已有的任何功能。它是一个完全独立的类,从头开始编写其所有的方法(大多数都是本地的)。这么做是因为RandomAccessFlle拥有和别的I/O类型本质不同的行为,因为我们可以在一个文件内向前和向后移动。在任何情况下,它都是自我独立的,直接从Object派生而来。

  从本质上来说,RandomAccessFile的工作方式类似于把DatalnputStream和DataOutStream 组合起来使用,还添加了一些方法。其中方法getFilePointer()用于查找当前所处的文件位置,seek()用于在文件内移至新的位置,length()用于判断文件的最大尺寸。另外,其构造器还需要第二个参数(和C中的fopen()相同)用来指示我们只是“随机读”(r)还是“既读又写”(rw)。它并不支持只写文件,这表明RandomAccessFile若是从DatalnputStream继承而来也可能会运行得很好。

  只有RandonAccessFile支持搜寻方法,并且只适用于文件。BufferedInputStream却能允许标注(mark0)位置(其值存储于内部某个简单变量内)和重新设定位置(reset)),但这些功能很有限,不是非常有用。
  在JDK1.4中,RandomAccessFile的大多数功能(但不是全部)由nio存储映射文件所取代。

public class UsingRandomAccessFile {

    private static String file = "rtest.dat";

    private static void display() throws IOException {
        RandomAccessFile rf = new RandomAccessFile(file, "r");
        for (int i = 0; i < 7; i++) {
            System.out.println("Value " + i + ": " + rf.readDouble() );
        }
        System.out.println(rf.readUTF());
        rf.close();
    }

    public static void main(String[] args) throws IOException {
        RandomAccessFile rf = new RandomAccessFile(file, "rw");
        for (int i = 0; i < 7; i++) {
            rf.writeDouble(i*1.414);
        }
        rf.writeUTF("The end of the file");
        rf.close();
        display();

        rf = new RandomAccessFile(file, "rw");
        rf.seek(5*8);
        rf.writeDouble(47.0001);
        rf.close();
        display();
    }
}

  display()方法打开了一个文件,并以double值的形式显示了其中的七个元素。在main()中,首先创建了文件,然后打开并修改了它。因为double总是8字节长,所以为了用seek()查找第5个双精度值,你只需用5*8来产生查找位置。
  正如先前所指,RandomAccessFile除了实现Datalnput和DataOutput接口之外,有效地与I/O继承层次结构的其他部分实现了分离。因为它不支持装饰,所以不能将其与InputStream及OutputStream子类的任何部分组合起来。我们必须假定RandomAccessFile已经被正确缓冲,因为我们不能为它添加这样的功能。
  可以自行选择的是第二个构造器参数∶我们可指定以“只读”(r)方式或“读写”(rw)方式打开一个RandomAccessFile文件。
你可能会考虑使用“内存映射文件”来代替RandomAccessFile。

I/O流的典型使用方式

缓冲输入文件

  如果想要打开一个文件用于字符输入,可以使用以String或File对象作为文件名的FlleInputReader。为了提高速度,我们希望对那个文件进行缓冲,那么我们将所产生的引用传给一个BufferedReader构造器。由于BufferedReader也提供readLine()方法,所以这是我们的最终对象和进行读取的接口。当readLine()将返回null时,你就达到了文件的末尾。

public class BufferInputFile {

    /**
     * 读取文件
     */
    public static String read(String filename) throws IOException {
        BufferedReader in = new BufferedReader(new FileReader(filename));
        String s;
        // 字符串sb用来累计文件的全部内容(包括必须添加的换行符,因为readline已将它们删掉)
        StringBuilder sb = new StringBuilder();
        while ((s = in.readLine()) != null) {
            sb.append(s + "\n");
        }
        // 最后,调用close()关闭文件
        in.close();
        return sb.toString();
    }

    public static void main(String[] args) throws IOException {
        System.out.println(read("src/io/BufferInputFile.java"));
    }
}

从内存输入

  在下面的示例中,BufferedInputFile.read()读入的Stirng结果被用来创建一个StringReader。然后调用read()每次读取一个字符,并把它发送到控制台。

public class MemoryInput {
    public static void main(String[] args) throws IOException {
        StringReader in = new StringReader(BufferInputFile.read("src/io/MemoryInput.java"));
        int c;
        // 注意read()方法是以int形式返回下一个字节,因此必须类型转换为char才能正确打印
        while ((c = in.read()) != -1) {
            System.out.print((char)c);
        }
        in.close();
    }
}

格式化的内存输入

  要读取格式化数据,可以使用DatalnputStream,它是一个面向字节的I/O类(不是面向字符的)。因此我们必须使用InputStream类而不是Reader类。当然,我们可以用InputStream以字节的形式读取任何数据(例如一个文件),不过,在这里使用的是字符串。

public class FormatMemoryInput {

    public static void main(String[] args) throws IOException {
        DataInputStream in = null;
        try {
            in = new DataInputStream(new ByteArrayInputStream(
                    BufferInputFile.read("src/io/FormatMemoryInput.java").getBytes()));

            while (true) {
                System.out.print((char) in.readByte());
            }
        } catch (EOFException e) {
            System.err.println("End of stream");
        } finally {
            if (Objects.nonNull(in)) {
                in.close();
            }
        }
    }
}

  必须为ByteArrayInputStream提供字节数组,为了产生该数组String包含了一个可以实现此项工作的getBytes()方法。所产生的ByteArrayInputStrem是一个适合传递给DatalnputStream的InputStream。如果我们从DataImputStream用readByte()一次一个字节地读取字符,那么任何字节的值都是合法的结果,因此返回值不能用来检测输入是否结束。相反,我们可以使用available()方法查看还有多少可供存取的字符。下面这个例子演示了怎样一次一个字节地读取文件∶

public class TestEOFE {

    public static void main(String[] args) throws IOException {
        DataInputStream in = new DataInputStream(new ByteArrayInputStream(
                BufferInputFile.read("src/io/TestEOFE.java").getBytes()));

        while (in.available() != 0) {
            System.out.print((char) in.readByte());
        }
    }
}

  注意,available()的工作方式会随着所读取的媒介类型的不同而有所不同;字面意思就是“在没有阻塞的情况下所能读取的字节数”。对于文件,这意味着整个文件;但是对于不同类型的流,可能就不是这样的,因此要谨慎使用。
我们也可以通过捕获异常来检测输入的末尾。但是,使用异常进行流控制,被认为是对异常特性的错误使用。

文件读写的实用工具

  一个很常见的程序化任务就是读取文件到内存,修改,然后再写出。下面的TextFile类包含的static方法可以像简单字符串那样读写文本文件,并且我们可以创建一个TextFile对象,他用一个ArrayList来保存文件的若干行(如此,当我们操纵文件内容时,就可以使用ArrayList的所有功能)。

package io;

import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.TreeSet;

/**
 * @author Mr.Sun
 * @date 2022年08月31日 22:24
 *
 *  文件读写的实用工具
 */
public class TextFile extends ArrayList<String> {

    /**
     * 读取单个文件
     *
     * @param filename 文件名称
     * @return 文件内容
     */
    public static String read(String filename) {
        StringBuilder sb = new StringBuilder();
        try {
            BufferedReader in = new BufferedReader(new FileReader(
                    new File(filename).getAbsoluteFile()));
            try {
                String s;
                while ((s = in.readLine()) != null) {
                    sb.append(s);
                    sb.append("\n");
                }
            } finally {
                in.close();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return sb.toString();
    }

    /**
     * 向文件中写内容
     *
     * @param filename 文件名称
     * @param text 内容
     */
    public static void write(String filename, String text) {
        try {
            PrintWriter out = new PrintWriter(new File(filename).getAbsoluteFile());
            try {
                out.print(text);
            } finally {
                out.close();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 读取文件,由任何正则表达式拆分
     *
     * @param filename 文件名称
     * @param splitter 分隔符
     */
    public TextFile(String filename, String splitter) {
        super(Arrays.asList(read(filename).split(splitter)));
        // 正则表达式split() 通常在第一个位置留下一个空字符串
        if (get(0).equals("")) {
            remove(0);
        }
    }

    public TextFile(String filename) {
        this(filename, "\n");
    }

    public void write(String filename) {
        try {
            PrintWriter out = new PrintWriter(new File(filename).getAbsoluteFile());
            try {
                for (String item : this) {
                    out.println(item);
                }
            } finally {
                out.close();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        String filename = "src/io/TextFile.java";
        String file = read(filename);
        write("test.txt", file);
        TextFile text = new TextFile("test.txt");
        text.write("test2.txt");
        // 拆分为单词的唯一排序列表
        TreeSet<String> words = new TreeSet<>(
                new TextFile(filename, "\\W+"));

        // 显示大写的单词
        System.out.println(words.headSet("a"));
    }
}

运行结果如下:

  read()将每行添加到StringBuffer,并且为每行加上换行符,因为在读的过程中换行符会被去除掉。接着返回一个包含整个文件的字符串。write()打开文本并将其写入文件,在这两个方法完成时,都要记着调用close()关闭文件。注意,在任意打开文件的代码在finaly子句中,作为防卫措施都添加了对文件的close()方法调用,以保证文件将会被正确关闭。

  • 读取二进制文件,这个工具与TextFile类似,因为它简化了读取二进制文件的过程:
package io;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

/**
 * @author Mr.Sun
 * @date 2022年09月02日 10:24
 *
 * 读取二进制文件
 */
public class BinaryFile {

    public static byte[] read(File bfile) throws IOException {
        BufferedInputStream in = new BufferedInputStream(new FileInputStream(bfile));
        try {
            byte[] data = new byte[in.available()];
            in.read(data);
            return data;
        } finally {
            in.close();
        }
    }

    public static byte[] read(String bfile) throws IOException {
        return read(new File(bfile).getAbsoluteFile());
    }
}

  其中一个重载方法接受File参数,第二个重载方法接受表示文件名的String参数,这两个方法都返回产生的byte[]数组,available()方法被用来产生恰当的数组尺寸,并且read()方法的特定的重载版本填充了这个数组。

新I/O

  JDK 1.4的java.nio.*包中引入了新的Java I/O类库,其目的在于提供速度。实际上,旧的I/O包已经使用nio重新实现过,以便充分利用这种速度提高,因此,即使我们不显示地用nio编写代码,也能从中受益。

  速度的提高来自于所使用的结构更接近于操作系统执行I/O的方式∶通道和缓冲器。我们可以把它想像成一个煤矿,通道是一个包含煤层(数据)的矿藏,而缓冲器则是派送到矿藏的卡车。卡车载满煤炭而归,我们再从卡车上获得煤炭。也就是说,我们并没有直接和通道交互;我们只是和缓冲器交互,并把缓冲器派送到通道。通道要么从缓冲器获得数据,要么向缓冲器发送数据。
  唯一直接与通道交互的缓冲器是ByteBuffer————也就是说,可以存储未加工字节的缓冲器。当我们查询JDK文档中的java.nio.ByteBuffer时,会发现它是相当基础的类∶通过告知分配多少存储空间来创建一个ByteBuffer对象,并且还有一个方法选择集,用于以原始的字节形式或基本数据类型输出和读取数据。但是,没办法输出或读取对象,即使是字符串对象也不行。这种处理虽然很低级,但却正好,因为这是大多数操作系统中更有效的映射方式。
  旧I/O类库中有三个类被修改了,用以产生FileChannel。这三个被修改的类是FilelnputStream、FileOutputStream以及用于既读又写的RandomAccessFile。注意这些是字节操纵流,与低层的nio性质一致。Reader和Writer这种字符模式类不能用于产生通道;但是javanio.channels.Channels类提供了实用方法,用以在通道中产生Reader和Writer。
下面的简单实例演示了上面三种类型的流,用以产生可写的、可读可写的及可读的通道。

public class GetChannel {

    private static final int BSIZE = 1024;

    public static void main(String[] args) throws Exception {
        FileChannel fc = new FileOutputStream("data.txt").getChannel();
        fc.write(ByteBuffer.wrap("Some text ".getBytes()));
        fc.close();

        // 在文件末尾追加点数据
        fc = new RandomAccessFile("data.txt", "rw").getChannel();
        // 移动到文件结尾
        fc.position(fc.size());
        fc.write(ByteBuffer.wrap("Some more".getBytes()));
        fc.close();

        // 读取文件
        fc = new FileInputStream("data.txt").getChannel();
        ByteBuffer buff = ByteBuffer.allocate(BSIZE);
        fc.read(buff);
        buff.flip();

        while (buff.hasRemaining()) {
            System.out.print((char) buff.get());
        }
    }
}

  对于这里所展示的任何流类,getChannel()将会产生一个FileChannel。通道是一种相当基础的东西∶可以向它传送用于读写的ByteBuffer,并且可以锁定文件的某些区域用于独占式访问。
  将字节存放于ByteBuffer的方法之一是∶使用一种"put"方法直接对它们进行填充,填入一个或多个字节,或基本数据类型的值。不过,正如所见,也可以使用warp()方法将已存在的字节数组“包装”到ByteBuffer中。一旦如此,就不再复制底层的数组,而是把它作为所产生的ByteBuffer的存储器,我们称之为数组支持的ByteBuffer。
  data.txt文件用RandomAccessFile被再次打开。注意我们可以在文件内随处移动FlleChannel;在这里,我们把它移到最后,以便附加其他的写操作。
对于只读访问,我们必须显式地使用静态的allocate()方法来分配ByteBuffer。nio的目标就是快速移动大量数据,因此ByteBuffer的大小就显得尤为重要————实际上,这里使用的1K可能比我们通常要使用的小一点(必须通过实际运行应用程序来找到最佳尺寸)。
  甚至达到更高的速度也有可能,方法就是使用allocateDirect()而不是allocate(),以产生一个与操作系统有更高耦合性的“直接”缓冲器。但是,这种分配的开支会更大,并且具体实现也随操作系统的不同而不同,因此必须再次实际运行应用程序来查看直接缓冲是否可以使我们获得速度上的优势。
  一旦调用read()来告知FileChannel向ByteBuffer存储字节,就必须调用缓冲器上的flip(),让它做好让别人读取字节的准备(是的,这似乎有一点拙劣,但是请记住,它是很拙劣的,但却适用于获取最大速度)。如果我们打算使用缓冲器执行进一步的read()操作,我们也必须得调用clear()来为每个read()做好准备。这在下面这个简单文件复制程序中可以看到∶

// {Args: src/io/ChannelCopy.java test.txt}
public class ChannelCopy {

    private static final int BSIZE = 1024;

    public static void main(String[] args) throws Exception {
        if(args.length != 2) {
            System.out.println("arguments: sourcefile destfile");
            System.exit(1);
        }

        FileChannel in = new FileInputStream(args[0]).getChannel(),
                out = new FileOutputStream(args[1]).getChannel();

        ByteBuffer buffer = ByteBuffer.allocate(BSIZE);
        while(in.read(buffer) != -1) {
            // 为写做准备
            buffer.flip();
            out.write(buffer);
            // 为读做准备
            buffer.clear();
        }
    }
}

  可以看到,打开一个FileChannel以用于读,而打开另一个以用于写。ByteBuffer被分配了空间,当FileChannel.read(返回-1时(一个分界符,毋庸置疑,它源于Unix和C),表示我们已经到达了输入的末尾。每次read()操作之后,就会将数据输入到缓冲器中,flip()则是准备缓冲器以便它的信息可以由write()提取。write()操作之后,信息仍在缓冲器中,接着clear()操作则对所有的内部指针重新安排,以便缓冲器在另一个read()操作期间能够做好接受数据的准备。

缓冲器的细节

  如果想把一个字节数组写到文件中去,那么就应该使用ByteBuffer.wrap()方法把字节数组包装起来,然后用getChannel()方法在FileOutputStream上打开一个通道,接着将来自于ByteBuffer的数据写到FileChannel中。

  注意∶ByteBuffer是将数据移进移出通道的唯一方式,并且我们只能创建一个独立的基本类型缓冲器,或者使用“as”方法从ByteBuffer中获得。也就是说,我们不能把基本类型的缓冲器转换成ByteBuffer。然而,由于我们可以经由视图缓冲器将基本类型数据移进移出ByteBuffer,所以这也就不是什么真正的限制了。

  Buffer由数据和可以高效地访问及操纵这些数据的四个索引组成,这四个索引是∶mark (标记),position(位置),limit(界限)和capacity(容量)。下面是用于设置和复位索引以及查询它们的值的方法。

压缩

  Java I/O类库中的类支持读写压缩格式的数据流。你可以用它们对其他的I/O类进行封装,以提供压缩功能。

  这些类不是从Reader和Writer类派生而来的,而是属于InputStream和OutputStream继承层次结构的一部分。这样做是因为压缩类库是按字节式而不是字符方式处理的。不过有时我们可能会被迫要混合使用两种类型的数据流(注意我们可以使用InputStreamReader和OutputStreamWriter 在两种类型间方便地进行转换)。

  尽管存在许多种压缩算法,但是ZIP和GZIP可能是最常用的,因此我们可以很容易使用多种可读写这些格式的工具来操作我们的压缩数据。

使用GZIP进行简单压缩

  GZIP接口非常简单,因此如果我们只想对单个数据流(而不是一系列互异数据)进行压缩,那么它可能是比较适合的选择。下面是对单个文件进行压缩的例子:

// {args: io/src/GZIPcompress.java}
public class GZIPcompress {
    public static void main(String[] args) throws IOException {
        if(args.length == 0) {
            System.out.println(
                    "Usage: \nGZIPcompress file\n" +
                            "\tUses GZIP compression to compress " +
                            "the file to test.gz");
            System.exit(1);
        }
        BufferedReader in = new BufferedReader(new FileReader(args[0]));
        BufferedOutputStream out = new BufferedOutputStream(
                new GZIPOutputStream(new FileOutputStream("test.gz")));
        System.out.println("Writing file");
        int c;
        while((c = in.read()) != -1)
            out.write(c);
        in.close();
        out.close();
        System.out.println("Reading file");
        BufferedReader in2 = new BufferedReader(
                new InputStreamReader(new GZIPInputStream(
                        new FileInputStream("test.gz"))));
        String s;
        while((s = in2.readLine()) != null)
            System.out.println(s);
    }
}


  压缩类的使用非常直观——直接将输出流封装成GZIPOutputStream或ZipOutputStream,并将输入流封装成GZIPInputStream或ZipInputStream即可。其他全部操作就是通常的I/O读写。这个例子把面向字符的流和面向字节的流混合了起来;输入(in)用Reader类,而GZIPOutputStream的构造器只能接受OutputStream对象,不能接受Writer对象。在打开文件时,GZIPImputStream就会被转换成Reader。

使用Zip进行多文件保存

  支持Zip格式的Java库更加全面。利用该库可以方便地保存多个文件,它甚至有一个独立的类,使得读取Zip文件更加方便。这个类库使用的是标准Zip格式,所以能与当前那些可通过因特网下载的压缩工具很好地协作。下面这个例子具有与前例相同的形式,但它能根据需要来处理任意多个命令行参数。另外,它显示了用Checksum类来计算和校验文件的校验和的方法。一共有两种Checksum类型∶Adler32(它快一些)和CRC32(慢一些,但更准确)。

package io;

import java.io.*;
import java.util.Enumeration;
import java.util.zip.*;

/**
 * @author Mr.Sun
 * @date 2022年09月02日 11:21
 *
 * 使用zip进行多文件保存
 * {args: src/io/ZipCompress.java}
 */
public class ZipCompress {
    public static void main(String[] args) throws Exception {
        FileOutputStream fos = new FileOutputStream("test.zip");
        CheckedOutputStream cos = new CheckedOutputStream(fos, new Adler32());
        ZipOutputStream zos = new ZipOutputStream(cos);
        BufferedOutputStream out = new BufferedOutputStream(zos);
        // 只有setComment(),没有getComment()
        zos.setComment("A test of Java Zipping");
        for(String arg : args) {
            System.out.println("Writing file " + arg);
            BufferedReader in = new BufferedReader(new FileReader(arg));
            zos.putNextEntry(new ZipEntry(arg));
            int c;
            while((c = in.read()) != -1)
                out.write(c);
            in.close();
            out.flush();
        }
        out.close();
        // 校验和仅在文件关闭后有效!
        System.out.println("Checksum: " + cos.getChecksum().getValue());

        // 现在提取文件
        System.out.println("Reading file");
        FileInputStream fi = new FileInputStream("test.zip");
        CheckedInputStream cis = new CheckedInputStream(fi, new Adler32());
        ZipInputStream in2 = new ZipInputStream(cis);
        BufferedInputStream bis = new BufferedInputStream(in2);
        ZipEntry ze;
        while((ze = in2.getNextEntry()) != null) {
            System.out.println("Reading file " + ze);
            int x;
            while((x = bis.read()) != -1) {
                System.out.write(x);
            }
        }
        if(args.length == 1) {
            System.out.println("Checksum: " + cis.getChecksum().getValue());
        }

        bis.close();
        // Alternative way to open and read Zip files:
        ZipFile zf = new ZipFile("test.zip");
        Enumeration e = zf.entries();
        while(e.hasMoreElements()) {
            ZipEntry ze2 = (ZipEntry)e.nextElement();
            System.out.println("File: " + ze2);
        }
    }
}

  对于每一个要加入压缩档案的文件,都必须调用putNextEntry(),并将其传递给一个ZipEntry对象。ZipEntry对象包含了一个功能很广泛的接口,允许你获取和设置Zip文件内该特定项上所有可利用的数据∶名字、压缩的和未压缩的文件大小、日期、CRC校验和、额外字段数据、注释、压缩方法以及它是否是一个目录入口等等。然而,尽管Zip格式提供了设置密码的方法,但Java的Zip类库并不提供这方面的支持。虽然CheckedInputStream和CheckedOutputStream 都支持Adler32和CRC32两种类型的校验和,但是ZipEntry类只有一个支持CRC的接口。虽然这是一个底层Zip格式的限制,但却限制了人们不能使用速度更快的Adler32。

  为了能够解压缩文件,ZipInputStream提供了一个getNextEntry()方法返回下一个ZipEntry (如果存在的话)。解压缩文件有一个更简便的方法————利用ZipFile对象读取文件。该对象有一个entries()方法用来向ZipEntries返回一个Enumeration(枚举)。

  为了读取校验和,必须拥有对与之相关联的Checksum对象的访问权限。在这里保留了指向CheckedOutputStream和CheckedInputStream对象的引用。但是,也可以只保留一个指向Checksum对象的引用。

  Zip流中有一个令人困惑的方法setComment()。正如前面ZipCompress.java中所示,我们可以在写文件时写注释,但却没有任何方法恢复ZipInputStream内的注释。似乎只能通过ZipEntry,才能以逐条方式完全支持注释的获取。

  当然,GZIP或Zip库的使用并不仅仅局限于文件——它可以压缩任何东西,包括需要通过网络发送的数据。

Java档案文件

  Zip格式也被应用于JAR(Java ARchive,Java档案文件)文件格式中。这种文件格式就像Zip 一样,可以将一组文件压缩到单个压缩文件中。同Java中其他任何东西一样,JAR文件也是跨平台的,所以不必担心跨平台的问题。声音和图像文件可以像类文件一样被包含在其中。
  JAR文件非常有用,尤其是在涉及因特网应用的时候。如果不采用JAR文件,Web浏览器在下载构成一个应用的所有文件时必须重复多次请求Web服务器;而且所有这些文件都是未经压缩的。如果将所有这些文件合并到一个JAR文件中,只需向远程服务器发出一次请求即可。同时,由于采用了压缩技术,可以使传输时间更短。另外,出于安全的考虑,JAR文件中的每个条目都可以加上数字化签名。

  第十七章到第十八章的内容就在这里告一段落了,其实关于I/O书中还介绍了有关进程控制、对象序列化和XML等相关详细内容,想要了解更多关于Java I/O基础知识的读者建议查阅《Java编程思想》原书籍,虽然该书可能的确不适合入门的同学,但是对于有经验的同学将它作为词典来巩固基础知识还是有必要的。



这篇关于《Java编程思想》读书笔记(三)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程