集合类与IO 前面我们已经把基础介绍完了,从这节课开始,我们就正式进入到集合类的讲解中。
集合类 集合类是Java中非常重要的存在,使用频率极高。集合其实与我们数学中的集合是差不多的概念,集合表示一组对象,每一个对象我们都可以称其为元素。不同的集合有着不同的性质,比如一些集合允许重复的元素,而另一些则不允许,一些集合是有序的,而其他则是无序的。
集合类其实就是为了更好地组织、管理和操作我们的数据而存在的,包括列表、集合、队列、映射等数据结构。从这一块开始,我们会从源码角度给大家讲解(先从接口定义对于集合需要实现哪些功能开始说起,包括这些集合类的底层机制是如何运作的)不仅仅是教会大家如何去使用。
集合跟数组一样,可以表示同样的一组元素,但是他们的相同和不同之处在于:
它们都是容器,都能够容纳一组元素。
不同之处:
数组的大小是固定的,集合的大小是可变的。
数组可以存放基本数据类型,但集合只能存放对象。
数组存放的类型只能是一种,但集合可以有不同种类的元素。
集合根接口 Java中已经帮我们将常用的集合类型都实现好了,我们只需要直接拿来用就行了,比如我们之前学习的顺序表:
1 2 3 4 5 6 7 8 import java.util.ArrayList; public class Main { public static void main (String[] args) { ArrayList<String> list = new ArrayList <>(); list.add("树脂666" ); } }
当然,我们会在这一部分中认识大部分Java为我们提供的集合类。所有的集合类最终都是实现自集合根接口的,比如我们下面就会讲到的ArrayList类,它的祖先就是Collection接口:
这个接口定义了集合类的一些基本操作,我们来看看有哪些方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 public interface Collection <E> extends Iterable <E> { int size () ; boolean isEmpty () ; boolean contains (Object o) ; Iterator<E> iterator () ; Object[] toArray(); <T> T[] toArray(T[] a); boolean add (E e) ; boolean remove (Object o) ; boolean containsAll (Collection<?> c) ; boolean addAll (Collection<? extends E> c) ; boolean removeAll (Collection<?> c) ; default boolean removeIf (Predicate<? super E> filter) { Objects.requireNonNull(filter); boolean removed = false ; final Iterator<E> each = iterator(); while (each.hasNext()) { if (filter.test(each.next())) { each.remove(); removed = true ; } } return removed; } boolean retainAll (Collection<?> c) ; void clear () ; boolean equals (Object o) ; int hashCode () ; @Override default Spliterator<E> spliterator () { return Spliterators.spliterator(this , 0 ); } default Stream<E> stream () { return StreamSupport.stream(spliterator(), false ); } default Stream<E> parallelStream () { return StreamSupport.stream(spliterator(), true ); } }
可以看到,在这个接口中对于集合相关的操作,还是比较齐全的,那么我们接着就来看看它的实现类。
List列表 首先我们需要介绍的是List列表(线性表),线性表支持随机访问,相比之前的Collection接口定义,功能还会更多一些。首先介绍ArrayList,我们已经知道,它的底层是用数组实现的,内部维护的是一个可动态进行扩容的数组,也就是我们之前所说的顺序表,跟我们之前自己写的ArrayList相比,它更加的规范,并且功能更加强大,同时实现自List接口。
List是集合类型的一个分支,它的主要特性有:
是一个有序的集合,插入元素默认是插入到尾部,按顺序从前往后存放,每个元素都有一个自己的下标位置
列表中允许存在重复元素
在List接口中,定义了列表类型需要支持的全部操作,List直接继承自前面介绍的Collection接口,其中很多地方重新定义了一次Collection接口中定义的方法,这样做是为了更加明确方法的具体功能,当然,为了直观,我们这里就省略掉:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 public interface List <E> extends Collection <E> { ... boolean addAll (int index, Collection<? extends E> c) ; ... default void replaceAll (UnaryOperator<E> operator) { Objects.requireNonNull(operator); final ListIterator<E> li = this .listIterator(); while (li.hasNext()) { li.set(operator.apply(li.next())); } } @SuppressWarnings({"unchecked", "rawtypes"}) default void sort (Comparator<? super E> c) { Object[] a = this .toArray(); Arrays.sort(a, (Comparator) c); ListIterator<E> i = this .listIterator(); for (Object e : a) { i.next(); i.set((E) e); } } ... E get (int index) ; E set (int index, E element) ; void add (int index, E element) ; E remove (int index) ; int indexOf (Object o) ; int lastIndexOf (Object o) ; ListIterator<E> listIterator () ; ListIterator<E> listIterator (int index) ; List<E> subList (int fromIndex, int toIndex) ; ... }
可以看到,在List接口中,扩展了大量列表支持的操作,其中最突出的就是直接根据下标位置进行的增删改查操作。而在ArrayList中,底层就是采用数组实现的,跟我们之前的顺序表思路差不多:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class ArrayList <E> extends AbstractList <E> implements List <E>, RandomAccess, Cloneable, java.io.Serializable { private static final int DEFAULT_CAPACITY = 10 ; ... transient Object[] elementData; private int size; public ArrayList (int initialCapacity) { if (initialCapacity > 0 ) { this .elementData = new Object [initialCapacity]; } else if (initialCapacity == 0 ) { this .elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException ("Illegal Capacity: " + initialCapacity); } } ... public boolean add (E e) { ensureCapacityInternal(size + 1 ); elementData[size++] = e; return true ; } ... private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8 ; private void grow (int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1 ); if (newCapacity - minCapacity < 0 ) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0 ) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); } }
一般的,如果我们要使用一个集合类,我们会使用接口的引用:
1 2 3 4 5 6 public static void main (String[] args) { List<String> list = new ArrayList <>(); list.add("科技与狠活" ); list.add("上头啊" ); System.out.println(list); }
可以看到,打印集合类的效果,跟我们使用Arrays工具类是一样的:
集合的各种功能我们都可以来测试一下,特别注意一下,我们在使用Integer时,要注意传参问题:
1 2 3 4 5 6 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); list.add(10 ); list.remove((Integer) 10 ); System.out.println(list); }
那要是这样写呢?
1 2 3 4 5 6 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); list.add(new Integer (10 )); list.remove(new Integer (10 )); System.out.println(list); }
可以看到,结果依然是删除成功,这是因为集合类在删除元素时,只会调用equals
方法进行判断是否为指定元素,而不是进行等号判断,所以说一定要注意,如果两个对象使用equals
方法相等,那么集合中就是相同的两个对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 public boolean remove (Object o) { if (o == null ) { ... } else { for (int index = 0 ; index < size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true ; } } return false ; }
列表中允许存在相同元素,所以说我们可以添加两个一模一样的:
1 2 3 4 5 6 7 public static void main (String[] args) { List<String> list = new ArrayList <>(); String str = "哟唉嘛干你" ; list.add(str); list.add(str); System.out.println(list); }
那要是此时我们删除对象呢,是一起删除还是只删除一个呢?
1 2 3 4 5 6 7 8 public static void main (String[] args) { List<String> list = new ArrayList <>(); String str = "哟唉嘛干你" ; list.add(str); list.add(str); list.remove(str); System.out.println(list); }
可以看到,这种情况下,只会删除排在前面的第一个元素。
集合类是支持嵌套使用的,一个集合中可以存放多个集合,套娃嘛,谁不会:
1 2 3 4 5 public static void main (String[] args) { List<List<String>> list = new LinkedList <>(); list.add(new LinkedList <>()); System.out.println(list.get(0 ).isEmpty()); }
在Arrays工具类中,我们可以快速生成一个只读的List:
1 2 3 4 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); System.out.println(list); }
注意,这个生成的List是只读的,不能进行修改操作,只能使用获取内容相关的方法,否则抛出 UnsupportedOperationException 异常。要生成正常使用的,我们可以将这个只读的列表作为参数传入:
1 2 3 4 public static void main (String[] args) { List<String> list = new ArrayList <>(Arrays.asList("A" , "B" , "C" )); System.out.println(list); }
当然,也可以利用静态代码块:
1 2 3 4 5 6 7 8 public static void main (String[] args) { List<String> list = new ArrayList <String>() {{ add("A" ); add("B" ); add("C" ); }}; System.out.println(list); }
这里我们接着介绍另一个列表实现类,LinkedList同样是List的实现类,只不过它是采用的链式实现,也就是我们之前讲解的链表,只不过它是一个双向链表,也就是同时保存两个方向:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class LinkedList <E> extends AbstractSequentialList <E> implements List <E>, Deque<E>, Cloneable, java.io.Serializable { transient int size = 0 ; transient Node<E> first; transient Node<E> last; public LinkedList () { } ... private static class Node <E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this .item = element; this .next = next; this .prev = prev; } } ... }
LinkedList的使用和ArrayList的使用几乎相同,各项操作的结果也是一样的,在什么使用使用ArrayList和LinkedList,我们需要结合具体的场景来决定,尽可能的扬长避短。
只不过LinkedList不仅可以当做List来使用,也可以当做双端队列使用,我们会在后面进行详细介绍。
迭代器 我们接着来介绍迭代器,实际上我们的集合类都是支持使用foreach
语法的:
1 2 3 4 5 6 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); for (String s : list) { System.out.println(s); } }
但是由于仅仅是语法糖,实际上编译之后:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); Iterator var2 = list.iterator(); while (var2.hasNext()) { String s = (String)var2.next(); System.out.println(s); } }
那么这个迭代器是一个什么东西呢?我们来研究一下:
1 2 3 4 5 6 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); Iterator<String> iterator = list.iterator(); }
通过使用迭代器,我们就可以实现对集合中的元素的进行遍历,就像我们遍历数组那样,它的运作机制大概是:
一个新的迭代器就像上面这样,默认有一个指向集合中第一个元素的指针:
每一次next
操作,都会将指针后移一位,直到完成每一个元素的遍历,此时再调用next
将不能再得到下一个元素。至于为什么要这样设计,是因为集合类的实现方案有很多,可能是链式存储,也有可能是数组存储,不同的实现有着不同的遍历方式,而迭代器则可以将多种多样不同的集合类遍历方式进行统一,只需要各个集合类根据自己的情况进行对应实现就行了。
我们来看看这个接口的源码定义了哪些操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public interface Iterator <E> { boolean hasNext () ; E next () ; default void remove () { throw new UnsupportedOperationException ("remove" ); } default void forEachRemaining (Consumer<? super E> action) { Objects.requireNonNull(action); while (hasNext()) action.accept(next()); } }
在ArrayList和LinkedList中,迭代器的实现也不同,比如ArrayList就是直接按下标访问:
1 2 3 4 5 public E next () { ... cursor = i + 1 ; return (E) elementData[lastRet = i]; }
LinkedList就是不断向后寻找结点:
1 2 3 4 5 6 public E next () { ... next = next.next; nextIndex++; return lastReturned.item; }
虽然这两种列表的实现不同,遍历方式也不同,但是都是按照迭代器的标准进行了实现,所以说,我们想要遍历一个集合中所有的元素,那么就可以直接使用迭代器来完成,而不需要关心集合类是如何实现,我们该怎么去遍历:
1 2 3 4 5 6 7 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { System.out.println(iterator.next()); } }
注意,迭代器的使用是一次性的,用了之后就不能用了,如果需要再次进行遍历操作,那么需要重新生成一个迭代器对象。为了简便,我们可以直接使用foreach
语法来快速遍历集合类,效果是完全一样的:
1 2 3 4 5 6 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); for (String s : list) { System.out.println(s); } }
在Java8提供了一个支持Lambda表达式的forEach方法,这个方法接受一个Consumer,也就是对遍历的每一个元素进行的操作:
1 2 3 4 public static void main (String[] args) { List<String> list = Arrays.asList("A" , "B" , "C" ); list.forEach(System.out::println); }
这个效果跟上面的写法是完全一样的,因为forEach方法内部本质上也是迭代器在处理,这个方法是在Iterable接口中定义的:
1 2 3 4 5 6 default void forEach (Consumer<? super T> action) { Objects.requireNonNull(action); for (T t : this ) { action.accept(t); } }
那么我们来看一下,Iterable这个接口又是是什么东西?
我们来看看定义了哪些内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public interface Iterable <T> { Iterator<T> iterator () ; default void forEach (Consumer<? super T> action) { Objects.requireNonNull(action); for (T t : this ) { action.accept(t); } } default Spliterator<T> spliterator () { return Spliterators.spliteratorUnknownSize(iterator(), 0 ); } }
得益于Iterable提供的迭代器生成方法,实际上只要是实现了迭代器接口的类(我们自己写的都行),都可以使用foreach
语法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Test implements Iterable <String>{ @Override public Iterator<String> iterator () { return new Iterator <String>() { @Override public boolean hasNext () { return true ; } @Override public String next () { return "测试" ; } }; } }
可以看到,直接就支持这种语法了,虽然我们这个是自己写的,并不是集合类:
1 2 3 4 5 6 public static void main (String[] args) { Test test = new Test (); for (String s : test) { System.out.println(s); } }
是不是感觉集合类的设计非常巧妙?
我们这里再来介绍一下ListIterator,这个迭代器是针对于List的强化版本,增加了更多方便的操作,因为List是有序集合,所以它支持两种方向的遍历操作,不仅能从前向后,也可以从后向前:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public interface ListIterator <E> extends Iterator <E> { boolean hasNext () ; E next () ; boolean hasPrevious () ; E previous () ; int nextIndex () ; int previousIndex () ; void remove () ; void set (E e) ; void add (E e) ; }
我们来测试一下吧:
1 2 3 4 5 6 7 public static void main (String[] args) { List<String> list = new ArrayList <>(Arrays.asList("A" , "B" , "C" )); ListIterator<String> iterator = list.listIterator(); iterator.next(); iterator.set("X" ); System.out.println(list); }
这种迭代器因为能够双向遍历,所以说可以反复使用。
Queue和Deque 通过前面的学习,我们已经了解了List的使用,其中LinkedList除了可以直接当做列表使用之外,还可以当做其他的数据结构使用,可以看到它不仅仅实现了List接口:
1 2 3 4 public class LinkedList <E> extends AbstractSequentialList <E> implements List <E>, Deque<E>, Cloneable, java.io.Serializable {
这个Deque接口是干嘛的呢?我们先来看看它的继承结构:
我们先来看看队列接口,它扩展了大量队列相关操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public interface Queue <E> extends Collection <E> { boolean add (E e) ; boolean offer (E e) ; E remove () ; E poll () ; E element () ; E peek () ; }
我们可以直接将一个LinkedList当做一个队列来使用:
1 2 3 4 5 6 7 public static void main (String[] args) { Queue<String> queue = new LinkedList <>(); queue.offer("AAA" ); queue.offer("BBB" ); System.out.println(queue.poll()); System.out.println(queue.poll()); }
我们接着来看双端队列,实际上双端队列就是队列的升级版,我们一个普通的队列就是:
普通队列中从队尾入队,队首出队,而双端队列允许在队列的两端进行入队和出队操作:
利用这种特性,双端队列既可以当做普通队列使用,也可以当做栈来使用,我们来看看Java中是如何定义的Deque双端队列接口的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 public interface Deque <E> extends Queue <E> { void addFirst (E e) ; void addLast (E e) ; boolean offerFirst (E e) ; boolean offerLast (E e) ; E removeFirst () ; E removeLast () ; E pollFirst () ; E pollLast () ; E getFirst () ; E getLast () ; E peekFirst () ; E peekLast () ; boolean removeFirstOccurrence (Object o) ; boolean removeLastOccurrence (Object o) ; ... void push (E e) ; E pop () ; ... Iterator<E> descendingIterator () ; }
我们可以来测试一下,比如我们可以直接当做栈来进行使用:
1 2 3 4 5 6 7 public static void main (String[] args) { Deque<String> deque = new LinkedList <>(); deque.push("AAA" ); deque.push("BBB" ); System.out.println(deque.pop()); System.out.println(deque.pop()); }
可以看到,得到的顺序和插入顺序是完全相反的,其实只要各位理解了前面讲解的数据结构,就很简单了。我们来测试一下反向迭代器和正向迭代器:
1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { Deque<String> deque = new LinkedList <>(); deque.addLast("AAA" ); deque.addLast("BBB" ); Iterator<String> descendingIterator = deque.descendingIterator(); System.out.println(descendingIterator.next()); Iterator<String> iterator = deque.iterator(); System.out.println(iterator.next()); }
可以看到,正向迭代器和反向迭代器的方向是完全相反的。
当然,除了LinkedList实现了队列接口之外,还有其他的实现类,但是并不是很常用,这里做了解就行了:
1 2 3 4 public static void main (String[] args) { Deque<String> deque = new ArrayDeque <>(); Queue<String> queue = new PriorityQueue <>(); }
这里需要介绍一下优先级队列,优先级队列可以根据每一个元素的优先级,对出队顺序进行调整,默认情况按照自然顺序:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Queue<Integer> queue = new PriorityQueue <>(); queue.offer(10 ); queue.offer(4 ); queue.offer(5 ); System.out.println(queue.poll()); System.out.println(queue.poll()); System.out.println(queue.poll()); }
可以看到,我们的插入顺序虽然是10/4/5,但是出队顺序是按照优先级来的,类似于VIP用户可以优先结束排队。我们也可以自定义比较规则,同样需要给一个Comparator的实现:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Queue<Integer> queue = new PriorityQueue <>((a, b) -> b - a); queue.offer(10 ); queue.offer(4 ); queue.offer(5 ); System.out.println(queue.poll()); System.out.println(queue.poll()); System.out.println(queue.poll()); }
只不过需要注意的是,优先级队列并不是队列中所有的元素都是按照优先级排放的,优先级队列只能保证出队顺序是按照优先级 进行的,我们可以打印一下:
想要了解优先级队列的具体是原理,可以在《数据结构与算法》篇视频教程中学习大顶堆和小顶堆。
Set集合 前面我们已经介绍了列表,我们接着来看Set集合,这种集合类型比较特殊,我们先来看看Set的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public interface Set <E> extends Collection <E> { int size () ; boolean isEmpty () ; boolean contains (Object o) ; Iterator<E> iterator () ; Object[] toArray(); <T> T[] toArray(T[] a); boolean add (E e) ; boolean remove (Object o) ; boolean containsAll (Collection<?> c) ; boolean addAll (Collection<? extends E> c) ; boolean retainAll (Collection<?> c) ; boolean removeAll (Collection<?> c) ; void clear () ; boolean equals (Object o) ; int hashCode () ; @Override default Spliterator<E> spliterator () { return Spliterators.spliterator(this , Spliterator.DISTINCT); } }
我们发现接口中定义的方法都是Collection中直接继承的,因此,Set支持的功能其实也就和Collection中定义的差不多,只不过:
不允许出现重复元素
不支持随机访问(不允许通过下标访问)
首先认识一下HashSet,它的底层就是采用哈希表实现的(我们在这里先不去探讨实现原理,因为底层实质上是借用的一个HashMap在实现,这个需要我们学习了Map之后再来讨论)我们可以非常高效的从HashSet中存取元素,我们先来测试一下它的特性:
1 2 3 4 5 6 public static void main (String[] args) { Set<String> set = new HashSet <>(); System.out.println(set.add("AAA" )); System.out.println(set.add("AAA" )); System.out.println(set); }
在Set接口中并没有定义支持指定下标位置访问的添加和删除操作,我们只能简单的删除Set中的某个对象:
1 2 3 4 5 6 public static void main (String[] args) { Set<String> set = new HashSet <>(); System.out.println(set.add("AAA" )); System.out.println(set.remove("AAA" )); System.out.println(set); }
由于底层采用哈希表实现,所以说无法维持插入元素的顺序:
1 2 3 4 5 public static void main (String[] args) { Set<String> set = new HashSet <>(); set.addAll(Arrays.asList("A" , "0" , "-" , "+" )); System.out.println(set); }
那要是我们就是想要使用维持顺序的Set集合呢?我们可以使用LinkedHashSet,LinkedHashSet底层维护的不再是一个HashMap,而是LinkedHashMap,它能够在插入数据时利用链表自动维护顺序,因此这样就能够保证我们插入顺序和最后的迭代顺序一致了。
1 2 3 4 5 public static void main (String[] args) { Set<String> set = new LinkedHashSet <>(); set.addAll(Arrays.asList("A" , "0" , "-" , "+" )); System.out.println(set); }
还有一种Set叫做TreeSet,它会在元素插入时进行排序:
1 2 3 4 5 6 7 public static void main (String[] args) { TreeSet<Integer> set = new TreeSet <>(); set.add(1 ); set.add(3 ); set.add(2 ); System.out.println(set); }
可以看到最后得到的结果并不是我们插入顺序,而是按照数字的大小进行排列。当然,我们也可以自定义排序规则:
1 2 3 4 5 6 7 public static void main (String[] args) { TreeSet<Integer> set = new TreeSet <>((a, b) -> b - a); set.add(1 ); set.add(3 ); set.add(2 ); System.out.println(set); }
目前,Set集合只是粗略的进行了讲解,但是学习Map之后,我们还会回来看我们Set的底层实现,所以说最重要的还是Map。本节只需要记住Set的性质、使用即可。
Map映射 什么是映射?我们在高中阶段其实已经学习过映射(Mapping)了,映射指两个元素的之间相互“对应”的关系,也就是说,我们的元素之间是两两对应的,是以键值对的形式存在。
而Map就是为了实现这种数据结构而存在的,我们通过保存键值对的形式来存储映射关系,就可以轻松地通过键找到对应的映射值,比如现在我们要保存很多学生的信息,而这些学生都有自己的ID,我们可以将其以映射的形式保存,将ID作为键,学生详细信息作为值,这样我们就可以通过学生的ID快速找到对应学生的信息了。
在Map中,这些映射关系被存储为键值对,我们先来看看Map接口中定义了哪些操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 public interface Map <K,V> { int size () ; boolean isEmpty () ; boolean containsKey (Object key) ; boolean containsValue (Object value) ; V get (Object key) ; V put (K key, V value) ; V remove (Object key) ; void putAll (Map<? extends K, ? extends V> m) ; void clear () ; Set<K> keySet () ; Collection<V> values () ; Set<Map.Entry<K, V>> entrySet(); interface Entry <K,V> { K getKey () ; V getValue () ; V setValue (V value) ; boolean equals (Object o) ; int hashCode () ; ... } ... }
当然,Map中定义了非常多的方法,尤其是在Java 8之后新增的大量方法,我们会在后面逐步介绍的。
我们可以来尝试使用一下Map,实际上非常简单,这里我们使用最常见的HashMap,它的底层采用哈希表实现:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "小明" ); map.put(2 , "小红" ); System.out.println(map.get(2 )); }
注意,Map中无法添加相同的键,同样的键只能存在一个,即使值不同。如果出现键相同的情况,那么会覆盖掉之前的:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "小明" ); map.put(1 , "小红" ); System.out.println(map.get(1 )); }
为了防止意外将之前的键值对覆盖掉,我们可以使用:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "小明" ); map.putIfAbsent(1 , "小红" ); System.out.println(map.get(1 )); }
还有,我们在获取一个不存在的映射时,默认会返回null作为结果:
1 2 3 4 5 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "小明" ); System.out.println(map.get(3 )); }
我们也可以为这种情况添加一个预备方案,当Map中不存在时,可以返回一个备选的返回值:
1 2 3 4 5 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "小明" ); System.out.println(map.getOrDefault(3 , "备胎" )); }
同样的,因为HashMap底层采用哈希表实现,所以不维护顺序,我们在获取所有键和所有值时,可能会是乱序的:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Map<String , String> map = new HashMap <>(); map.put("0" , "十七张" ); map.put("+" , "牌" ); map.put("P" , "你能秒我" ); System.out.println(map); System.out.println(map.keySet()); System.out.println(map.values()); }
如果需要维护顺序,我们同样可以使用LinkedHashMap,它的内部对插入顺序进行了维护:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Map<String , String> map = new LinkedHashMap <>(); map.put("0" , "十七张" ); map.put("+" , "牌" ); map.put("P" , "你能秒我" ); System.out.println(map); System.out.println(map.keySet()); System.out.println(map.values()); }
实际上Map的使用还是挺简单的,我们接着来看看Map的底层是如何实现的,首先是最简单的HashMap,我们前面已经说过了,它的底层采用的是哈希表,首先回顾我们之前学习的哈希表,我们当时说了,哈希表可能会出现哈希冲突,这样保存的元素数量就会存在限制,而我们可以通过连地址法解决这种问题,最后哈希表就长这样了:
实际上这个表就是一个存放头结点的数组+若干结点,而HashMap也是这样的,我们来看看这里面是怎么定义的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class HashMap <K,V> extends AbstractMap <K,V> implements Map <K,V>, Cloneable, Serializable { ... static class Node <K,V> implements Map .Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; ... } ... transient Node<K,V>[] table; final float loadFactor; public HashMap () { this .loadFactor = DEFAULT_LOAD_FACTOR; } ... }
可以看到,实际上底层大致结构跟我们之前学习的差不多,只不过多了一些特殊的东西:
HashMap支持自动扩容,哈希表的大小并不是一直不变的,否则太过死板
HashMap并不是只使用简单的链地址法,当链表长度到达一定限制时,会转变为效率更高的红黑树结构
我们来研究一下它的put方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); } final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this , tab, hash, key, value); else { for (int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
是不是感觉只要前面的数据结构听懂了,这里简直太简单。根据上面的推导,我们在正常插入一个键值对时,会得到null返回值,而冲突时会得到一个被覆盖的值:
1 2 3 4 5 public static void main (String[] args) { Map<String , String> map = new HashMap <>(); System.out.println(map.put("0" , "十七张" )); System.out.println(map.put("0" , "慈善家" )); }
现在我们知道,当HashMap的一个链表长度过大时,会自动转换为红黑树:
但是这样始终治标不治本,受限制的始终是底层哈希表的长度,我们还需要进一步对底层的这个哈希表进行扩容才可以从根本上解决问题,我们来看看resize()
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0 ; if (oldCap > 0 ) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) newCap = oldThr; else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int )(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } ... threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node [newCap]; table = newTab; if (oldTab != null ) { ... } }
是不是感觉自己有点了解HashMap的运作机制了,其实并不是想象中的那么难,因为这些东西再怎么都是人写的。
而LinkedHashMap是直接继承自HashMap,具有HashMap的全部性质,同时得益于每一个节点都是一个双向链表,在插入键值对时,同时保存了插入顺序:
1 2 3 4 5 6 static class Entry <K,V> extends HashMap .Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super (hash, key, value, next); } }
这样我们在遍历LinkedHashMap时,顺序就同我们的插入顺序一致。当然,也可以使用访问顺序,也就是说对于刚访问过的元素,会被排到最后一位。
当然还有一种比较特殊的Map叫做TreeMap,就像它的名字一样,就是一个Tree,它的内部直接维护了一个红黑树(没有使用哈希表)因为它会将我们插入的结点按照规则进行排序,所以说直接采用红黑树会更好,我们在创建时,直接给予一个比较规则即可,跟之前的TreeSet是一样的:
1 2 3 4 5 6 7 public static void main (String[] args) { Map<Integer , String> map = new TreeMap <>((a, b) -> b - a); map.put(0 , "单走" ); map.put(1 , "一个六" ); map.put(3 , "**" ); System.out.println(map); }
现在我们倒回来看之前讲解的HashSet集合,实际上它的底层很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class HashSet <E> extends AbstractSet <E> implements Set <E>, Cloneable, java.io.Serializable { private transient HashMap<E,Object> map; private static final Object PRESENT = new Object (); public HashSet () { map = new HashMap <>(); } ... public Iterator<E> iterator () { return map.keySet().iterator(); } public int size () { return map.size(); } public boolean isEmpty () { return map.isEmpty(); } }
通过观察HashSet的源码发现,HashSet几乎都在操作内部维护的一个HashMap,也就是说,HashSet只是一个表壳,而内部维护的HashMap才是灵魂!就像你进了公司,在外面花钱请别人帮你写公司的业务,你只需要坐着等别人写好然后你自己拿去交差就行了。所以说,HashSet利用了HashMap内部的数据结构,轻松地就实现了Set定义的全部功能!
再来看TreeSet,实际上用的就是我们的TreeMap:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class TreeSet <E> extends AbstractSet <E> implements NavigableSet <E>, Cloneable, java.io.Serializable { private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object (); ... public TreeSet () { this (new TreeMap <E,Object>()); } ... }
同理,这里就不多做阐述了。
我们接着来看看Map中定义的哪些杂七杂八的方法,首先来看看compute
方法:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "A" ); map.put(2 , "B" ); map.compute(1 , (k, v) -> { return v+"M" ; }); map.computeIfPresent(1 , (k, v) -> { return v+"M" ; }); System.out.println(map); }
也可以使用computeIfAbsent
,当不存在Key时,计算并将键值对放入Map中:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Map<Integer, String> map = new HashMap <>(); map.put(1 , "A" ); map.put(2 , "B" ); map.computeIfAbsent(0 , (k) -> { return "M" ; }); System.out.println(map); }
merge方法用于处理数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public static void main (String[] args) { List<Student> students = Arrays.asList( new Student ("yoni" , "English" , 80 ), new Student ("yoni" , "Chiness" , 98 ), new Student ("yoni" , "Math" , 95 ), new Student ("taohai.wang" , "English" , 50 ), new Student ("taohai.wang" , "Chiness" , 72 ), new Student ("taohai.wang" , "Math" , 41 ), new Student ("Seely" , "English" , 88 ), new Student ("Seely" , "Chiness" , 89 ), new Student ("Seely" , "Math" , 92 ) ); Map<String, Integer> scoreMap = new HashMap <>(); students.forEach(student -> scoreMap.merge(student.getName(), student.getScore(), Integer::sum)); scoreMap.forEach((k, v) -> System.out.println("key:" + k + "总分" + "value:" + v)); } static class Student { private final String name; private final String type; private final int score; public Student (String name, String type, int score) { this .name = name; this .type = type; this .score = score; } public String getName () { return name; } public int getScore () { return score; } public String getType () { return type; } }
replace
方法可以快速替换某个映射的值:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer , String> map = new HashMap <>(); map.put(0 , "单走" ); map.replace(0 , ">>>" ); System.out.println(map); }
也可以精准匹配:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer , String> map = new HashMap <>(); map.put(0 , "单走" ); map.replace(0 , "巴卡" , "玛卡" ); System.out.println(map); }
包括remove方法,也支持键值同时匹配:
1 2 3 4 5 6 public static void main (String[] args) { Map<Integer , String> map = new HashMap <>(); map.put(0 , "单走" ); map.remove(0 , "单走" ); System.out.println(map); }
是不是感觉学习了Map之后,涨了不少姿势?
Stream流 Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。Stream 使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。这种风格将要处理的元素集合看作一种流, 流在管道中传输, 并且可以在管道的节点上进行处理, 比如筛选, 排序,聚合等。元素流在管道中经过中间操作(intermediate operation)的处理,最后由最终操作(terminal operation)得到前面处理的结果。
它看起来就像一个工厂的流水线一样!我们就可以把一个Stream当做流水线处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public static void main (String[] args) { List<String> list = new ArrayList <>(); list.add("A" ); list.add("B" ); list.add("C" ); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()){ if (iterator.next().equals("B" )) iterator.remove(); } list = list .stream() .filter(e -> !e.equals("B" )) .collect(Collectors.toList()); System.out.println(list); }
可能从上述例子中还不能感受到流处理带来的便捷,我们通过下面这个例子来感受一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); list.add(1 ); list.add(2 ); list.add(3 ); list.add(3 ); list = list .stream() .distinct() .sorted((a, b) -> b - a) .map(e -> e+1 ) .limit(2 ) .collect(Collectors.toList()); System.out.println(list); }
当遇到大量的复杂操作时,我们就可以使用Stream来快速编写代码,这样不仅代码量大幅度减少,而且逻辑也更加清晰明了(如果你学习过SQL的话,你会发现它更像一个Sql语句)
注意 :不能认为每一步是直接依次执行的!我们可以断点测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 List<Integer> list = new ArrayList <>(); list.add(1 ); list.add(2 ); list.add(3 ); list.add(3 ); list = list .stream() .distinct() .sorted((a, b) -> b - a) .map(e -> { System.out.println(">>> " +e); return e+1 ; }) .limit(2 ) .collect(Collectors.toList());
实际上,stream会先记录每一步操作,而不是直接开始执行内容,当整个链式调用完成后,才会依次进行,也就是说需要的时候,工厂的机器才会按照预定的流程启动。
接下来,我们用一堆随机数来进行更多流操作的演示:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { Random random = new Random (); random .ints(-100 , 100 ) .limit(10 ) .filter(i -> i < 0 ) .sorted() .forEach(System.out::println); }
我们可以生成一个统计实例来帮助我们快速进行统计:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { Random random = new Random (); IntSummaryStatistics statistics = random .ints(0 , 100 ) .limit(100 ) .summaryStatistics(); System.out.println(statistics.getMax()); System.out.println(statistics.getCount()); System.out.println(statistics.getAverage()); }
普通的List只需要一个方法就可以直接转换到方便好用的IntStream了:
1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); list.add(1 ); list.add(1 ); list.add(2 ); list.add(3 ); list.add(4 ); list.stream() .mapToInt(i -> i) .summaryStatistics(); }
我们还可以通过flat
来对整个流进行进一步细分:
1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { List<String> list = new ArrayList <>(); list.add("A,B" ); list.add("C,D" ); list.add("E,F" ); list = list .stream() .flatMap(e -> Arrays.stream(e.split("," ))) .collect(Collectors.toList()); System.out.println(list); }
我们也可以只通过Stream来完成所有数字的和,使用reduce
方法:
1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); list.add(1 ); list.add(2 ); list.add(3 ); int sum = list .stream() .reduce((a, b) -> a + b) .get(); System.out.println(sum); }
可能,作为新手来说,一次性无法接受这么多内容,但是在各位以后的开发中,就会慢慢使用到这些东西了。
Collections工具类 我们在前面介绍了Arrays,它是一个用于操作数组的工具类,它给我们提供了大量的工具方法。
既然数组操作都这么方便了,集合操作能不能也安排点高级的玩法呢?那必须的,JDK为我们准备的Collocations类就是专用于集合的工具类,比如我们想快速求得List中的最大值和最小值:
1 2 3 4 5 public static void main (String[] args) { List<Integer> list = new ArrayList <>(); Collections.max(list); Collections.min(list); }
同样的,我们可以对一个集合进行二分搜索(注意,集合的具体类型,必须是实现Comparable接口的类):
1 2 3 4 public static void main (String[] args) { List<Integer> list = Arrays.asList(2 , 3 , 8 , 9 , 10 , 13 ); System.out.println(Collections.binarySearch(list, 8 )); }
我们也可以对集合的元素进行快速填充,注意这个填充是对集合中已有的元素进行覆盖:
1 2 3 4 5 public static void main (String[] args) { List<Integer> list = new ArrayList <>(Arrays.asList(1 ,2 ,3 ,4 ,5 )); Collections.fill(list, 6 ); System.out.println(list); }
如果集合中本身没有元素,那么fill
操作不会生效。
有些时候我们可能需要生成一个空的集合类返回,那么我们可以使用emptyXXX
来快速生成一个只读的空集合:
1 2 3 4 5 public static void main (String[] args) { List<Integer> list = Collections.emptyList(); list.add(10 ); }
我们也可以将一个可修改的集合变成只读的集合:
1 2 3 4 5 public static void main (String[] args) { List<Integer> list = new ArrayList <>(Arrays.asList(1 ,2 ,3 ,4 ,5 )); List<Integer> newList = Collections.unmodifiableList(list); newList.add(10 ); }
我们也可以寻找子集合的位置:
1 2 3 4 public static void main (String[] args) { List<Integer> list = new ArrayList <>(Arrays.asList(1 ,2 ,3 ,4 ,5 )); System.out.println(Collections.indexOfSubList(list, Arrays.asList(4 , 5 ))); }
得益于泛型的类型擦除机制,实际上最后只要是Object的实现类都可以保存到集合类中,那么就会出现这种情况:
1 2 3 4 5 6 public static void main (String[] args) { List list = new ArrayList <>(Arrays.asList(1 ,2 ,3 ,4 ,5 )); list.add("aaa" ); System.out.println(list); }
没错,由于泛型机制上的一些漏洞,实际上对应类型的集合类有可能会存放其他类型的值,泛型的类型检查只存在于编译阶段,只要我们绕过这个阶段,在实际运行时,并不会真的进行类型检查,要解决这种问题很简单,就是在运行时进行类型检查:
1 2 3 4 5 6 public static void main (String[] args) { List list = new ArrayList <>(Arrays.asList(1 ,2 ,3 ,4 ,5 )); list = Collections.checkedList(list, Integer.class); list.add("aaa" ); System.out.println(list); }
checkedXXX
可以将给定集合类进行包装,在运行时同样会进行类型检查,如果通过上面的漏洞插入一个本不应该是当前类型集合支持的类型,那么会直接抛出类型转换异常:
是不是感觉这个工具类好像还挺好用的?实际上在我们的开发中,这个工具类也经常被使用到。
Java I/O 注意: 这块会涉及到操作系统 和计算机组成原理 相关内容。
I/O简而言之,就是输入输出,那么为什么会有I/O呢?其实I/O无时无刻都在我们的身边,比如读取硬盘上的文件,网络文件传输,鼠标键盘输入,也可以是接受单片机发回的数据,而能够支持这些操作的设备就是I/O设备。
我们可以大致看一下整个计算机的总线结构:
常见的I/O设备一般是鼠标、键盘这类通过USB进行传输的外设或者是通过Sata接口或是M.2连接的硬盘。一般情况下,这些设备是由CPU发出指令通过南桥芯片间接进行控制,而不是由CPU直接操作。
而我们在程序中,想要读取这些外部连接的I/O设备中的内容,就需要将数据传输到内存中。而需要实现这样的操作,单单凭借一个小的程序是无法做到的,而操作系统(如:Windows/Linux/MacOS)就是专门用于控制和管理计算机硬件和软件资源的软件,我们需要读取一个IO设备的内容时,就可以向操作系统发出请求,由操作系统帮助我们来和底层的硬件交互以完成我们的读取/写入请求。
从读取硬盘文件的角度来说,不同的操作系统有着不同的文件系统(也就是文件在硬盘中的存储排列方式,如Windows就是NTFS、MacOS就是APFS),硬盘只能存储一个个0和1这样的二进制数据,至于0和1如何排列,各自又代表什么意思,就是由操作系统的文件系统来决定的。从网络通信角度来说,网络信号通过网卡等设备翻译为二进制信号,再交给系统进行读取,最后再由操作系统来给到程序。
(传统的SATA硬盘就是通过SATA线与电脑主板相连,这样才可以读取到数据)
JDK提供了一套用于IO操作的框架,为了方便我们开发者使用,就定义了一个像水流一样,根据流的传输方向和读取单位,分为字节流InputStream和OutputStream以及字符流Reader和Writer的IO框架,当然,这里的Stream并不是前面集合框架认识的Stream,这里的流指的是数据流,通过流,我们就可以一直从流中读取数据,直到读取到尽头,或是不断向其中写入数据,直到我们写入完成,而这类IO就是我们所说的BIO,
字节流一次读取一个字节,也就是一个byte
的大小,而字符流顾名思义,就是一次读取一个字符,也就是一个char
的大小(在读取纯文本文件的时候更加适合),有关这两种流,会在后面详细介绍,这个章节我们需要学习16个关键的流。
文件字节流 要学习和使用IO,首先就要从最易于理解的读取文件开始说起。
首先介绍一下FileInputStream,我们可以通过它来获取文件的输入流:
1 2 3 4 5 6 7 8 public static void main (String[] args) { try { FileInputStream inputStream = new FileInputStream ("路径" ); } catch (FileNotFoundException e) { e.printStackTrace(); } }
相对路径是在当前运行目录(就是你在哪个目录运行java命令启动Java程序的)的路径下寻找文件,而绝对路径,是从根目录开始寻找。路径分割符支持使用/
或是\\
,但是不能写为\
因为它是转义字符!比如在Windows下:
1 2 C://User/lbw/nb 这个就是一个绝对路径,因为是从盘符开始的 test/test 这个就是一个相对路径,因为并不是从盘符开始的,而是一个直接的路径
在Linux和MacOS下:
1 2 /root/tmp 这个就是一个绝对路径,绝对路径以/开头 test/test 这个就是一个相对路径,不是以/开头的
当然,这个其实还是很好理解的,我们在使用时注意一下就行了。
在使用完成一个流之后,必须关闭这个流来完成对资源的释放,否则资源会被一直占用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static void main (String[] args) { FileInputStream inputStream = null ; try { inputStream = new FileInputStream ("路径" ); } catch (FileNotFoundException e) { e.printStackTrace(); } finally { try { if (inputStream != null ) inputStream.close(); } catch (IOException e) { e.printStackTrace(); } } }
虽然这样的写法才是最保险的,但是显得过于繁琐了,尤其是finally中再次嵌套了一个try-catch块,因此在JDK1.7新增了try-with-resource语法,用于简化这样的写法(本质上还是和这样的操作一致,只是换了个写法)
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (FileInputStream inputStream = new FileInputStream ("路径" )) { } catch (IOException e) { e.printStackTrace(); } }
之后为了方便,我们都使用此语法进行教学。
现在我们拿到了文件的输入流,那么怎么才能读取文件里面的内容呢?我们可以使用read
方法:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (FileInputStream inputStream = new FileInputStream ("test.txt" )) { System.out.println((char ) inputStream.read()); System.out.println(inputStream.read()); }catch (IOException e){ e.printStackTrace(); } }
使用read可以直接读取一个字节的数据,注意,流的内容是有限的,读取一个少一个。我们如果想一次性全部读取的话,可以直接使用一个while循环来完成:
1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { try (FileInputStream inputStream = new FileInputStream ("test.txt" )) { int tmp; while ((tmp = inputStream.read()) != -1 ){ System.out.println((char )tmp); } }catch (IOException e){ e.printStackTrace(); } }
使用available
方法能查看当前可读的剩余字节数量(注意:并不一定真实的数据量就是这么多,尤其是在网络I/O操作时,这个方法只能进行一个预估也可以说是暂时能一次性可以读取的数量,当然在磁盘IO下,一般情况都是真实的数据量)
1 2 3 4 5 try (FileInputStream inputStream = new FileInputStream ("test.txt" )) { System.out.println(inputStream.available()); }catch (IOException e){ e.printStackTrace(); }
当然,一个一个读取效率太低了,那能否一次性全部读取呢?我们可以预置一个合适容量的byte[]数组来存放:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (FileInputStream inputStream = new FileInputStream ("test.txt" )) { byte [] bytes = new byte [inputStream.available()]; System.out.println(inputStream.read(bytes)); System.out.println(new String (bytes)); }catch (IOException e){ e.printStackTrace(); } }
也可以控制要读取数量:
1 System.out.println(inputStream.read(bytes, 1 , 2 ));
注意 :一次性读取同单个读取一样,当没有任何数据可读时,依然会返回-1
通过skip()
方法可以跳过指定数量的字节:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { try (FileInputStream inputStream = new FileInputStream ("test.txt" )) { System.out.println(inputStream.skip(1 )); System.out.println((char ) inputStream.read()); }catch (IOException e){ e.printStackTrace(); } }
注意:FileInputStream是不支持reset()
的,虽然有这个方法,但是这里先不提及。
既然有输入流,那么文件输出流也是必不可少的:
1 2 3 4 5 6 7 8 public static void main (String[] args) { try (FileOutputStream outputStream = new FileOutputStream ("output.txt" )) { }catch (IOException e){ e.printStackTrace(); } }
输出流没有read()
操作而是write()
操作,使用方法同输入流一样,只不过现在的方向变为我们向文件里写入内容:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (FileOutputStream outputStream = new FileOutputStream ("output.txt" )) { outputStream.write('c' ); outputStream.write("lbwnb" .getBytes()); outputStream.write("lbwnb" .getBytes(), 0 , 1 ); outputStream.flush(); }catch (IOException e){ e.printStackTrace(); } }
那么如果是我只想在文件尾部进行追加写入数据呢?我们可以调用另一个构造方法来实现:
1 2 3 4 5 6 7 8 public static void main (String[] args) { try (FileOutputStream outputStream = new FileOutputStream ("output.txt" , true )) { outputStream.write("lb" .getBytes()); outputStream.flush(); }catch (IOException e){ e.printStackTrace(); } }
利用输入流和输出流,就可以轻松实现文件的拷贝了:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { try (FileOutputStream outputStream = new FileOutputStream ("output.txt" ); FileInputStream inputStream = new FileInputStream ("test.txt" )) { byte [] bytes = new byte [10 ]; int tmp; while ((tmp = inputStream.read(bytes)) != -1 ){ outputStream.write(bytes, 0 , tmp); } }catch (IOException e){ e.printStackTrace(); } }
文件字符流 字符流不同于字节,字符流是以一个具体的字符进行读取,因此它只适合读纯文本的文件,如果是其他类型的文件不适用:
1 2 3 4 5 6 7 8 public static void main (String[] args) { try (FileReader reader = new FileReader ("test.txt" )){ reader.skip(1 ); System.out.println((char ) reader.read()); }catch (IOException e){ e.printStackTrace(); } }
同理,字符流只支持char[]
类型作为存储:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { try (FileReader reader = new FileReader ("test.txt" )){ char [] str = new char [10 ]; reader.read(str); System.out.println(str); }catch (IOException e){ e.printStackTrace(); } }
既然有了Reader肯定也有Writer:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (FileWriter writer = new FileWriter ("output.txt" )){ writer.getEncoding(); writer.write('牛' ); writer.append('牛' ); writer.flush(); }catch (IOException e){ e.printStackTrace(); } }
我们发现不仅有write()
方法,还有一个append()
方法,但是实际上他们效果是一样的,看源码:
1 2 3 4 public Writer append (char c) throws IOException { write(c); return this ; }
append支持像StringBuilder那样的链式调用,返回的是Writer对象本身。
练习 :尝试一下用Reader和Writer来拷贝纯文本文件。
这里需要额外介绍一下File类,它是专门用于表示一个文件或文件夹,只不过它只是代表这个文件,但并不是这个文件本身。通过File对象,可以更好地管理和操作硬盘上的文件。
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { File file = new File ("test.txt" ); System.out.println(file.exists()); System.out.println(file.length()); System.out.println(file.isDirectory()); System.out.println(file.canRead()); System.out.println(file.canWrite()); System.out.println(file.canExecute()); }
通过File对象,我们就能快速得到文件的所有信息,如果是文件夹,还可以获取文件夹内部的文件列表等内容:
1 2 3 4 5 File file = new File ("/" );System.out.println(Arrays.toString(file.list())); for (File f : file.listFiles()){ System.out.println(f.getAbsolutePath()); }
如果我们希望读取某个文件的内容,可以直接将File作为参数传入字节流或是字符流:
1 2 3 4 5 6 File file = new File ("test.txt" );try (FileInputStream inputStream = new FileInputStream (file)){ System.out.println(inputStream.available()); }catch (IOException e){ e.printStackTrace(); }
练习 :尝试拷贝文件夹下的所有文件到另一个文件夹
缓冲流 虽然普通的文件流读取文件数据非常便捷,但是每次都需要从外部I/O设备去获取数据,由于外部I/O设备的速度一般都达不到内存的读取速度,很有可能造成程序反应迟钝,因此性能还不够高,而缓冲流正如其名称一样,它能够提供一个缓冲,提前将部分内容存入内存(缓冲区)在下次读取时,如果缓冲区中存在此数据,则无需再去请求外部设备。同理,当向外部设备写入数据时,也是由缓冲区处理,而不是直接向外部设备写入。
要创建一个缓冲字节流,只需要将原本的流作为构造参数传入BufferedInputStream即可:
1 2 3 4 5 6 7 public static void main (String[] args) { try (BufferedInputStream bufferedInputStream = new BufferedInputStream (new FileInputStream ("test.txt" ))){ System.out.println((char ) bufferedInputStream.read()); }catch (IOException e){ e.printStackTrace(); } }
实际上进行I/O操作的并不是BufferedInputStream,而是我们传入的FileInputStream,而BufferedInputStream虽然有着同样的方法,但是进行了一些额外的处理然后再调用FileInputStream的同名方法,这样的写法称为装饰者模式
,我们会在设计模式篇中详细介绍。我们可以来观察一下它的close
方法源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 public void close () throws IOException { byte [] buffer; while ( (buffer = buf) != null ) { if (bufUpdater.compareAndSet(this , buffer, null )) { InputStream input = in; in = null ; if (input != null ) input.close(); return ; } } }
实际上这种模式是父类FilterInputStream提供的规范,后面我们还会讲到更多FilterInputStream的子类。
我们可以发现在BufferedInputStream中还存在一个专门用于缓存的数组:
1 2 3 4 5 6 protected volatile byte buf[];
I/O操作一般不能重复读取内容(比如键盘发送的信号,主机接收了就没了),而缓冲流提供了缓冲机制,一部分内容可以被暂时保存,BufferedInputStream支持reset()
和mark()
操作,首先我们来看看mark()
方法的介绍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public synchronized void mark (int readlimit) { in.mark(readlimit); }
当调用mark()
之后,输入流会以某种方式保留之后读取的readlimit
数量的内容,当读取的内容数量超过readlimit
则之后的内容不会被保留,当调用reset()
之后,会使得当前的读取位置回到mark()
调用时的位置。
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { try (BufferedInputStream bufferedInputStream = new BufferedInputStream (new FileInputStream ("test.txt" ))){ bufferedInputStream.mark(1 ); System.out.println((char ) bufferedInputStream.read()); System.out.println((char ) bufferedInputStream.read()); bufferedInputStream.reset(); System.out.println((char ) bufferedInputStream.read()); System.out.println((char ) bufferedInputStream.read()); }catch (IOException e) { e.printStackTrace(); } }
我们发现虽然后面的部分没有保存,但是依然能够正常读取,其实mark()
后保存的读取内容是取readlimit
和BufferedInputStream类的缓冲区大小两者中的最大值,而并非完全由readlimit
确定。因此我们限制一下缓冲区大小,再来观察一下结果:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { try (BufferedInputStream bufferedInputStream = new BufferedInputStream (new FileInputStream ("test.txt" ), 1 )){ bufferedInputStream.mark(1 ); System.out.println((char ) bufferedInputStream.read()); System.out.println((char ) bufferedInputStream.read()); bufferedInputStream.reset(); System.out.println((char ) bufferedInputStream.read()); System.out.println((char ) bufferedInputStream.read()); }catch (IOException e) { e.printStackTrace(); } }
了解完了BufferedInputStream之后,我们再来看看BufferedOutputStream,其实和BufferedInputStream原理差不多,只是反向操作:
1 2 3 4 5 6 7 8 public static void main (String[] args) { try (BufferedOutputStream outputStream = new BufferedOutputStream (new FileOutputStream ("output.txt" ))){ outputStream.write("lbwnb" .getBytes()); outputStream.flush(); }catch (IOException e) { e.printStackTrace(); } }
操作和FileOutputStream一致,这里就不多做介绍了。
既然有缓冲字节流,那么肯定也有缓冲字符流,缓冲字符流和缓冲字节流一样,也有一个专门的缓冲区,BufferedReader构造时需要传入一个Reader对象:
1 2 3 4 5 6 7 public static void main (String[] args) { try (BufferedReader reader = new BufferedReader (new FileReader ("test.txt" ))){ System.out.println((char ) reader.read()); }catch (IOException e) { e.printStackTrace(); } }
使用和reader也是一样的,内部也包含一个缓存数组:
相比Reader更方便的是,它支持按行读取:
1 2 3 4 5 6 7 public static void main (String[] args) { try (BufferedReader reader = new BufferedReader (new FileReader ("test.txt" ))){ System.out.println(reader.readLine()); }catch (IOException e) { e.printStackTrace(); } }
读取后直接得到一个字符串,当然,它还能把每一行内容依次转换为集合类提到的Stream流:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) { try (BufferedReader reader = new BufferedReader (new FileReader ("test.txt" ))){ reader .lines() .limit(2 ) .distinct() .sorted() .forEach(System.out::println); }catch (IOException e) { e.printStackTrace(); } }
它同样也支持mark()
和reset()
操作:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { try (BufferedReader reader = new BufferedReader (new FileReader ("test.txt" ))){ reader.mark(1 ); System.out.println((char ) reader.read()); reader.reset(); System.out.println((char ) reader.read()); }catch (IOException e) { e.printStackTrace(); } }
BufferedReader处理纯文本文件时就更加方便了,BufferedWriter在处理时也同样方便:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { try (BufferedWriter reader = new BufferedWriter (new FileWriter ("output.txt" ))){ reader.newLine(); reader.write("汉堡做滴彳亍不彳亍" ); reader.flush(); }catch (IOException e) { e.printStackTrace(); } }
合理使用缓冲流,可以大大提高我们程序的运行效率,只不过现在初学阶段,很少会有机会接触到实际的应用场景。
转换流 有时会遇到这样一个很麻烦的问题:我这里读取的是一个字符串或是一个个字符,但是我只能往一个OutputStream里输出,但是OutputStream又只支持byte类型,如果要往里面写入内容,进行数据转换就会很麻烦,那么能否有更加简便的方式来做这样的事情呢?
1 2 3 4 5 6 7 public static void main (String[] args) { try (OutputStreamWriter writer = new OutputStreamWriter (new FileOutputStream ("test.txt" ))){ writer.write("lbwnb" ); }catch (IOException e){ e.printStackTrace(); } }
同样的,我们现在只拿到了一个InputStream,但是我们希望能够按字符的方式读取,我们就可以使用InputStreamReader来帮助我们实现:
1 2 3 4 5 6 7 public static void main (String[] args) { try (InputStreamReader reader = new InputStreamReader (new FileInputStream ("test.txt" ))){ System.out.println((char ) reader.read()); }catch (IOException e){ e.printStackTrace(); } }
InputStreamReader和OutputStreamWriter本质也是Reader和Writer,因此可以直接放入BufferedReader来实现更加方便的操作。
打印流 打印流其实我们从一开始就在使用了,比如System.out
就是一个PrintStream,PrintStream也继承自FilterOutputStream类因此依然是装饰我们传入的输出流,但是它存在自动刷新机制,例如当向PrintStream流中写入一个字节数组后自动调用flush()
方法。PrintStream也永远不会抛出异常,而是使用内部检查机制checkError()
方法进行错误检查。最方便的是,它能够格式化任意的类型,将它们以字符串的形式写入到输出流。
1 public final static PrintStream out = null ;
可以看到System.out
也是PrintStream,不过默认是向控制台打印,我们也可以让它向文件中打印:
1 2 3 4 5 6 7 public static void main (String[] args) { try (PrintStream stream = new PrintStream (new FileOutputStream ("test.txt" ))){ stream.println("lbwnb" ); }catch (IOException e){ e.printStackTrace(); } }
我们平时使用的println
方法就是PrintStream中的方法,它会直接打印基本数据类型或是调用对象的toString()
方法得到一个字符串,并将字符串转换为字符,放入缓冲区再经过转换流输出到给定的输出流上。
因此实际上内部还包含这两个内容:
1 2 3 4 5 6 private BufferedWriter textOut;private OutputStreamWriter charOut;
与此相同的还有一个PrintWriter,不过他们的功能基本一致,PrintWriter的构造方法可以接受一个Writer作为参数,这里就不再做过多阐述了。
而我们之前使用的Scanner,使用的是系统提供的输入流:
1 2 3 public static void main (String[] args) { Scanner scanner = new Scanner (System.in); }
我们也可以使用Scanner来扫描其他的输入流:
1 2 3 public static void main (String[] args) throws FileNotFoundException { Scanner scanner = new Scanner (new FileInputStream ("秘制小汉堡.txt" )); }
相当于直接扫描文件中编写的内容,同样可以读取。
数据流 数据流DataInputStream也是FilterInputStream的子类,同样采用装饰者模式,最大的不同是它支持基本数据类型的直接读取:
1 2 3 4 5 6 7 public static void main (String[] args) { try (DataInputStream dataInputStream = new DataInputStream (new FileInputStream ("test.txt" ))){ System.out.println(dataInputStream.readBoolean()); }catch (IOException e) { e.printStackTrace(); } }
用于写入基本数据类型:
1 2 3 4 5 6 7 public static void main (String[] args) { try (DataOutputStream dataOutputStream = new DataOutputStream (new FileOutputStream ("output.txt" ))){ dataOutputStream.writeBoolean(false ); }catch (IOException e) { e.printStackTrace(); } }
注意,写入的是二进制数据,并不是写入的字符串,使用DataInputStream可以读取,一般他们是配合一起使用的。
对象流 既然基本数据类型能够读取和写入基本数据类型,那么能否将对象也支持呢?ObjectOutputStream不仅支持基本数据类型,通过对对象的序列化操作,以某种格式保存对象,来支持对象类型的IO,注意:它不是继承自FilterInputStream的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public static void main (String[] args) { try (ObjectOutputStream outputStream = new ObjectOutputStream (new FileOutputStream ("output.txt" )); ObjectInputStream inputStream = new ObjectInputStream (new FileInputStream ("output.txt" ))){ People people = new People ("lbw" ); outputStream.writeObject(people); outputStream.flush(); people = (People) inputStream.readObject(); System.out.println(people.name); }catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } static class People implements Serializable { String name; public People (String name) { this .name = name; } }
在我们后续的操作中,有可能会使得这个类的一些结构发生变化,而原来保存的数据只适用于之前版本的这个类,因此我们需要一种方法来区分类的不同版本:
1 2 3 4 5 6 7 8 9 static class People implements Serializable { private static final long serialVersionUID = 123456 ; String name; public People (String name) { this .name = name; } }
当发生版本不匹配时,会无法反序列化为对象:
1 2 3 4 5 6 7 8 9 java.io.InvalidClassException: com.test.Main$People; local class incompatible : stream classdesc serialVersionUID = 123456 , local class serialVersionUID = 1234567 at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699 ) at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2003 ) at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1850 ) at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2160 ) at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1667 ) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:503 ) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:461 ) at com.test.Main.main(Main.java:27 )
如果我们不希望某些属性参与到序列化中进行保存,我们可以添加transient
关键字:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static void main (String[] args) { try (ObjectOutputStream outputStream = new ObjectOutputStream (new FileOutputStream ("output.txt" )); ObjectInputStream inputStream = new ObjectInputStream (new FileInputStream ("output.txt" ))){ People people = new People ("lbw" ); outputStream.writeObject(people); outputStream.flush(); people = (People) inputStream.readObject(); System.out.println(people.name); }catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } static class People implements Serializable { private static final long serialVersionUID = 1234567 ; transient String name; public People (String name) { this .name = name; } }
其实我们可以看到,在一些JDK内部的源码中,也存在大量的transient关键字,使得某些属性不参与序列化,取消这些不必要保存的属性,可以节省数据空间占用以及减少序列化时间。
实战:图书管理系统 要求实现一个图书管理系统(控制台),支持以下功能:保存书籍信息(要求持久化),查询、添加、删除、修改书籍信息。