Java后端系统学习路线--Java基础(二),码云仓库地址:gitee.com/qinstudy/ja…
1、Java基础之异常
小汪:榜哥,上次看你重新复习了Java基础中的封装、类的组合、类的继承和面向接口编程等知识点,还附上了对应的示例代码,接下来还有什么Java基础要回炉学习的呢?
大榜:上次分享了Java后端学习路线图中的Java基础(一),这次会学习Java基础(二),这一部分包括了异常、容器与泛型、I/Ol流、注解。
对于异常,会回顾基本概念,特别是受检异常和非受检异常,做到心里有数;容器和泛型是我们经常要使用了,有一些容易掉坑里面的地方,所以我会多花点时间去介绍和使用;I/O流,是Java语言对读和写操作的抽象封装,也很重要;注解,是JDK 5引入的功能,我们在Java后端学习路线图的初级α阶段,有一个入门项目实战,项目中很多地方都会使用注解。
小汪:嗯嗯,异常我知道,就是程序抛出的异常信息嘛,而且Java有专门的异常体系的,不过我有点忘了,哈哈哈。
大榜:忘了不打紧,我们一起回顾下,温故而知新嘛!Java的异常体系中,Throwable是所有异常的基类,它有2个子类:一个叫Error,另一个叫Exception。
小汪:想起来了,Error表示系统错误或资源耗尽,是Java系统自己使用,我们编写的应用程序不应该抛出和处理。Exception表示应用程序异常,我记得Exception分为受检异常和非受检异常两类的,但我不记得这二者的区别了。
大榜:受检异常和非受检异常的区别在于Java如何处理这两种异常。你看哈,受检 二字,顾名思义就是受到编译器检查的异常,也就是说受检异常,Java会强制要求程序员进行处理,否则编译器不会让我们通过,比如常见的受检异常FileNotFoundException。
小汪:受检 二字表示受编译器检查,这个很好记忆。那非受检异常,应该就是不受编译器检查,非受检异常会在程序运行时抛出异常,如我们经常写Bug的代码中,会有非受检异常NullPointerException。
大榜:你刚刚将受检异常、非受检异常,进行了类比理解记忆,是不是感觉异常的知识点一下子就理解了,这就是对比差异学习法,哈哈哈。对了,整个Java的异常体系,大概是下面这张图(图片来源于JavaGuide)。
小汪:上图中,Throwable表示基类,基类下面有Error、Exception这2个子类;Exception中又分为受检异常和非受检异常。我记得程序有异常了,我们程序员是可以catch捕获异常,或者选择不捕获异常的。
大榜:是的了,对于异常的处理,我们一般会使用try/catch/finally,catch是捕获异常进行处理,处理代码中可以选择打印错误日志信息,然后再将异常抛出去,防止原先的异常被吃掉。
小汪:异常重新抛出,确实可以防止异常被吃掉,代码是下面这样。
public static void main(String[] args) {
try {
// 业务代码
} catch (Exception e) {
log.error("有异常了,", e);
// 重新抛出异常,防止异常被吃掉
throw e;
}
}
大榜:小伙子,基础很牢,不错哈。对于finally代码块,一般总是会被执行,所以我们将释放资源的代码放在finally代码块中,来保证IO资源、数据库连接资源的释放,这样可以防止资源耗尽。
小汪:对于Exception异常,我们程序员应该也可以实现自定义的异常,比如我编写一个类,继承RuntimeException,接着编写构造函数,来实现自定义的异常类,代码可以是下面这样。
public class AppException extends RuntimeException {
/**
* 定义异常类型、异常发生的位置
*/
private int type;
private String location;
public AppException() {
super();
}
public AppException(String message) {
super(message);
}
public AppException(String message, Throwable cause) {
super(message, cause);
}
public AppException(Throwable cause) {
super(cause);
}
}
大榜:自定义异常确实就是这么做的。汪老弟,你有没有发现异常知识点虽然很少,但是我们总是不知道如何使用异常,怎样才能用好异常?
小汪:我也有这个感觉,自己学习了Java的异常,但有时候就是随意捕获异常进行处理,处理过程中也不抛出异常,导致异常被吃掉,上次我就写了个大Bug,导致业务流程没有正常进行下去。
大榜:是的,我们程序员很多时候没有用好异常。其实,异常可以分为3种来源:用户、程序员、第三方。用户是指用户的输入有问题;程序员是指编码错误;第三方泛指其他情况,如I/O错误、网络、数据库、第三方服务。
而且,总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,也可能是主程序,也有可能是面对用户的代码。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解的且对用户有帮助的信息,如下异常状态码、请求url信息,代码是这样的:
// 封装异常状态码
ResponseData<RequestInfo> responseData = new ResponseData(exception.getErrorCodeEnum());
// 封装请求url信息
RequestInfo requestInfo = RequestJsonUtils.getRequestInfo(request);
requestInfo.setMessage(exception.getMessage());
responseData.setData(requestInfo);
return responseData;
对于运维和开发人员,则应该输出详细的异常链和异常栈到日志中,代码是下面这样的:
log.error(exception.toString(),exception); // 堆栈信息和错误码记录日志
2、Java基础之泛型
小汪:我们刚刚一起复习了Java异常,后面应该到了Java泛型把?
大榜:哈哈哈,是的。泛型就是广泛的类型,泛型包含2类,类型参数T、通配符? 这两类。
我们首先来看类型参数T的示例代码。
package com.programming.logic.p8;
/**
*
* 对于类 public class Pair<T> {...}, T表示类型参数
*
* @author qinxubang
* @Date 2022/5/18 10:13
*/
public class Pair2<U, V> {
U first;
V second;
public Pair2(U first, V second) {
this.first = first;
this.second = second;
}
public U getFirst() {
return first;
}
public V getSecond() {
return second;
}
}
class Pair2Test {
public static void main(String[] args) {
Pair2<String, Integer> pair2 = new Pair2<>("老马", 28);
System.out.println(pair2.getFirst() + " " + pair2.getSecond());
}
}
由上面的代码可以,类Pair2<U, V>中,U、V就是类型参数;我们看下测试类Pair2Test中的代码。
Pair2<String, Integer> pair2 = new Pair2<>("老马", 28);
U表示了String,V表示了Integer,然后将结果打印到控制台。其实U、V可以为任意类型,测试类中可以随意指定,比如我们可以指定如下代码。
Pair2<Double, Double> doublePair2 = new Pair2<>(13.2, 16.8);
可以看出,使用泛型后,代码变得更灵活,Pair2类可以接受任意类型的参数。个人觉得,这也是面向接口编程的思想。
小汪:Pair2使用泛型后,变成了Pair2<U, V>,我们可以在测试类中编写任意类型的参数,好厉害的样子,代码是这样的。
// 测试类
class Pair2Test {
public static void main(String[] args) {
Pair2<String, Integer> pair2 = new Pair2<>("老马", 28);
System.out.println(pair2.getFirst() + " " + pair2.getSecond());
Pair2<Double, Double> doublePair2 = new Pair2<>(13.2, 16.8);
System.out.println(doublePair2.getFirst() + " " + doublePair2.getSecond());
}
}
那如果我们不使用泛型,会有什么坏处呢?毕竟泛型增加了学习成本,是吧,榜哥。
大榜:我刚开始接触泛型时,感觉很抽象,当时就疑惑一个类型参数T,为什么就可以接收任意类型的参数了呢。不使用泛型的坏处,我这儿正好有一个,代码如下:
package com.programming.logic.p8;
/**
* 未使用泛型 Pair2<U, V> ,当程序员用错类型后,会报错:ClassCastException;
* 但如果我们使用泛型Pair2<U, V>的话,编译器能确保不会用错类型,为程序多设置一层安全防护网,泛型可以进行类型安全检查。
*
* @author qinxubang
* @Date 2022/5/18 10:18
*/
public class Pair3 {
private Object first;
private Object second;
public Pair3(Object first, Object second) {
this.first = first;
this.second = second;
}
public Object getFirst() {
return first;
}
public Object getSecond() {
return second;
}
}
class Pair3Test {
public static void main(String[] args) {
// Pair3 minmax = new Pair3(1, 100);
// Integer min = (Integer) minmax.getFirst();
// Integer max = (Integer) minmax.getSecond();
// System.out.println(min + " " + max);
//
// Pair3 kv = new Pair3("name", "老马");
// String key = (String) kv.getFirst();
// String value = (String) kv.getSecond();
// System.out.println(key + " " + value);
Pair3 pair3 = new Pair3("老马", 27);
// 报错:java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
// 但如果我们使用泛型Pair2<U, V> 的话,编译器能确保不会用错类型,为程序多设置一层安全防护网,泛型可以进行类型安全检查。
Integer id = (Integer) pair3.getFirst();
String name = (String) pair3.getSecond();
String first = (String) pair3.getFirst();
Integer second = (Integer) pair3.getSecond();
System.out.println(first + " " + second);
}
}
上面的测试类,运行时,会报错:java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer。原因是测试类中Pair3 pair3 = new Pair3("老马", 27); 第一次参数中字符串,第二次参数是整型。而获取值的时候粗心大意写反了,报错的代码。
Integer id = (Integer) pair3.getFirst();
String name = (String) pair3.getSecond();
正确的写法应该是这样的。
String first = (String) pair3.getFirst();
Integer second = (Integer) pair3.getSecond();
所以说,当我们Pair3未使用泛型时,如果测试类中,程序员用错类型后,会报错:类型转换异常ClassCastException。但如果我们使用泛型Pair2<U, V>的话,编译器能确保不会用错类型,也就是说为程序多设置一层安全防护网,说人话就是,泛型可以进行类型安全检查,防止程序员用错类型。
小汪:这个不使用泛型的例子(Pair3),当程序员用错类型后,就会抛出ClassCastException异常,那我以后还是使用泛型,防止自己用错类型。那泛型的实现原理是怎样的呢?
大榜:其实,泛型也是语法糖,和我们经常使用的增强for循环一样,它们都是Java编译器在后面发挥作用。具体来说,对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,将类型参数T擦除,替换为Object,插入必要的强制类型转换。也就是说:Java泛型是通过类型擦除来实现的,类定义中的类型参数T会被替换为Object;在程序运行过程中,JVM是不知道泛型的实际类型参数 Pair,程序运行时只知道Pair,而不知道Integer。
小汪:哦哦,是这样啊,Java编程器在编译期间,帮我们将类型参数T擦除,替换为Object,然后做了强制类型转换,内部做了很多工作啊,应该给Java编译器发个奖状,哈哈哈。泛型包含类型参数T和通配符? ,通配符是干什么的?
大榜:通配符用于实例化泛型变量中的类型参数,只是这个具体类型是未知的。对于 <? extends E> ,该语义表示 这个具体类型是未知的,只知道是E或者E的某个子类型。
小汪:榜哥能说人话吗,或者举个栗子也行?
大榜:使用通配符的代码,是这样的。
/**
* ? 表示通配符 <? extends E>,表示有限定通配符,匹配E或者E的子类型。
* @param c
*/
public void addAllGeneric(DynamicArray<? extends E> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}
使用类型参数的代码,是下面这样的。
/**
* E是DynamicArray的类型参数,T是addAll的类型参数,T的上界限定为E
* @param c
* @param <T>
*/
public <T extends E> void addAll(DynamicArray<T> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}
关于通配符、类型参数这一块的知识点,和你讨论了之后,我感觉自己对泛型也是模糊不清,先挖个坑,等以后来填把,哈哈哈!
小汪:哈哈哈,那等着榜哥以后来填坑了哟。对了,通配符和类型参数有什么区别和联系呢?
大榜:对于通配符和类型参数,如果我们暂时搞不懂,可以把泛型称之为语法规则,我们程序员按照泛型的语法规则来开发,一般也不会出问题。至于,二者的区别如下。
1)通配符都可以用类型参数的形式来替代,通配符能做的,类型参数也可以做;
2)通配符形式可以减少类型参数,形式上往往更为简单,可读性也更好,能用通配符的地方就用通配符;
3)如果类型参数之间有依赖关系,或者返回值依赖类型参数,或者需要写操作,那么我们就只能使用类型参数。代码是这样的:
public T parseHeader(byte[] msg, Header header) throws ProtocolParseException
{
}
3、Java基础之容器
小汪:感觉Java泛型的语法规则有点特殊,我们用泛型的时候去查资料。下面应该到了我最喜欢的Java容器把。
大榜:哈哈哈,没想到你小子钟情于Java容器啊,她为什么吸引你呢?
小汪:容器是面向接口编程的设计,而且封装复杂操作,对外提供简单的接口,所以API很好用,上手也贼快。
大榜:嗯嗯,看来你是Java容器的深度使用者。容器类有2个根接口,分别是Collection和Map,Collection表示单个元素的集合,Map表示键值对的集合;Collection表示数据集合有基本的增删改查、遍历等方法,但没有定义元素间的顺序或者位置,也没有规定是否有重复元素;Collection有3个实现类:List、Set、Queue。类的继承图是下面这样:
3.1、Collection容器(List、Set、Queue)
3.1.1、List(ArrayList、LinkedList)
小汪:我记得List接口有两个主要的实现类,ArrayList集合、LinkedList链表。
大榜:ArrayList提供了常见的增删改查的API,但有2个坑需要注意,一个坑是使用增强for循环遍历集合,可能抛出并发修改异常;另一个大坑是集合和数组之间的相互转化。
小汪:增强for循环遍历集合,如果容器发生结构性变化,就会抛出异常,我之前踩过这个坑,代码是这样的:
/**
* 在foreach的增强for循环中,当容器发生结构性变化,会抛出并发修改异常:ConcurrentModificationException异常
* @param list
*/
public static void removeToConcurrentModificationException(List<Integer> list) {
for (Integer integer : list) {
if (integer < 100) {
list.remove(integer);
}
}
}
// 测试方法
public static void main(String[] args) {
Integer[] arr = {1, 2, 3, 103};
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
removeToConcurrentModificationException(list);
// iteratorRemove(list);
System.out.println("打印删除之后的集合:" + list);
}
上面的增强for循环遍历时,当容器中删除元素时,会抛出并发修改异常java.util.ConcurrentModificationException。
大榜:容器中删除元素,是容器发生结构性变化的一种,总的来说,所谓结构性变化就是添加、插入和删除元素,只是修改元素内容不会发生结构性变化。
小汪:使用增强for循环时,若容器发生结构性变化,就会抛出异常,导致程序终止。如果我现在的需求就是,在遍历容器的时候,根据某个条件,删除容器中的元素。榜哥,老大甩了这么一个需求,该怎么办?
大榜:这个需求,使用增强for循环,会抛出异常,导致程序终止。所以,我们需要使用迭代器,也就是使用Iterator迭代器来遍历元素,并删除相关元素。代码是下面这样的:
/**
* 引用Iterator接口,采用迭代器模式进行遍历,然后删除容器中的元素
* @param list
*/
public static void iteratorRemove(List<Integer> list) {
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
// 迭代器删除元素,需要调用迭代器的next、remove方法。如果只使用iterator.remove(),则会抛出IllegalStateException异常
Integer integer = iterator.next();
System.out.println("迭代器遍历容器中的元素:" + integer);
iterator.remove();
}
}
使用Iterator迭代器的remove方法后,就不会抛出异常,整个程序正常执行。迭代器的语法更通用,它不仅使用于ArrayList,也适用于各种容器类(List、Set、Queue)。迭代器表示的是一种关注点分离的思想,将数据的实际组织方式与数据的迭代遍历相分离,是一种常见的设计模式。如果我们程序员想要访问容器中的元素,只需要一个Iterator接口的引用,不需要关注数据的实际组织方式,程序员可以使用统一的方式遍历容器中的元素,是不是很香。
小汪:迭代器好香啊,可以解决这个需求,如果我们需要在迭代过程中进行添加和删除,应该调用迭代器相关的方法如remove方法。
大榜:我们讨论了ArrayList的第一个大坑:在增强for循环遍历中 直接调用容器类提供的add/remove方法,会导致容器发生结构性变化,就会抛出ConcurrentModificationException异常。接下来介绍第二个大坑:集合和数组之间的相互转化,它包含集合转化为数组、数组转化为集合两种。
小汪:集合转化为数组,说白了,就是先创建数组,长度为集合的长度,然后遍历集合,将集合的元素一个个赋值给数组。我们直接调用ArrayList的toArray方法,将集合转成数组,代码是下面这样的。
List<Integer> list = new ArrayList<>();
list.add(123);
list.add(456);
list.add(789);
// 将集合转换为数组,推荐使用该种类型.new Integer[0],确保数组转化为Integer类型的,0没有实际意义,表示类型是数组类型
Integer[] arrB = list.toArray(new Integer[0]);
大榜:小伙子,集合的基础知识不错啊。那数组转化为集合呢?
小汪:数组转换为集合,其实很简单,就是先创建集合,接着遍历数组,然后将数组中的元素添加到集合中。我记得有个Arrays.asList方法,将数组转化为集合,代码是下面这样的:
Integer[] arr = {1, 2, 3};
List<Integer> innerList = Arrays.asList(arr);
System.out.println("Arrays.asList,返回Arrays的内部类:" + innerList);
大榜:你通过Arrays.asList()方法,将数组转化为集合,是可以返回集合,但是Arrays.asList方法返回的是Arrays的内部类,也叫做Arrays$ArrayList。如果我们往这个innerList集合中,添加一个元素呢,代码是这样的:
Integer[] arr = {1, 2, 3};
List<Integer> innerList = Arrays.asList(arr);
System.out.println("Arrays.asList,返回Arrays的内部类:" + innerList);
innerList.add(66);
测试结果,往innerList集合中添加一个元素66,会抛出UnsupportedOperationException异常。从测试结果,我们可以得出当调用Arrays.asList方法返回的内部集合,当内部集合发生结构性改变时,就会抛出UnsupportedOperationException异常。
小汪:啊啊啊,这个是我史料未及的,那怎么才能返回 支持增加和删除元素的ArrayList呢?
大榜:如果需要返回可以支持增删功能的集合,其实也很简单,我们只需要创建一个ArrayList,然后把innerList内部集合作为参数传递,就可以返回新的集合。因为这个新集合不是Arrays的内部集合,所以它是支持增加、删除元素的。代码是下面这样的:
Integer[] arr = {1, 2, 3};
List<Integer> innerList = Arrays.asList(arr);
System.out.println("Arrays.asList,返回Arrays的内部类:" + innerList);
try {
innerList.add(66);
} catch (Exception e) {
System.out.println("调用Arrays.asList方法,当有结构性改变时,会抛出UnsupportedOperationException异常:" + innerList);
}
// 使用Arrays的内部类作为参数,新建一个ArrayList
List<Integer> completeList = new ArrayList<>(Arrays.asList(arr));
completeList.add(77);
System.out.println("打印新建的集合:" + completeList);
小汪:我懂了,总结一下,就是数组转成集合:
//使用Arrays的内部类作为参数,新建一个ArrayList
Integer[] arr = {1, 2, 3};
List<Integer> completeList = new ArrayList<>(Arrays.asList(arr));
大榜:总结得很好。ArrayList集合本质上是一个数组,占用连续的内存空间,因为集合是支持下标索引随机访问得,所以根据索引位置访问效率高,如get(i)方法的效率为 O(1); 但按照内容本身查找效率比较低,如contains(Object o)方法的效率为 O(n);且增加元素、删除元素效率比较低,为O(n)。
小汪:讨论完ArrayList,接下来我们看看LinkedList链表。
大榜:LinkedList是实现了List、Deque接口,因为实现了List接口,所以它可以作为集合来使用,比如使用add方法来添加元素;由于也实现了Deque接口,它也可以作为先进先处的队列、先进后出的栈来使用。
小汪:ArrayList、LinkedList都实现了List接口,它两有什么区别呢?
大榜:ArrayList基于数组实现随机访问效率,但从中间插入和删除元素需要移动元素,效率比较低; LinkedList是基于双向链表实现,随机访问效率低,但增删元素只需要调整相邻节点的链接,增删元素的效率高。所以说:如果列表长度未知,添加、删除操作又比较多,而且经常从两端进行操作,按照索引位置访问相对比较少,我们就选用LinkedList;反之就使用ArrayList。
3.1.2、Set(HashSet、LinkedHashSet、TreeSet)
小汪:我们一起复习了List后,接下来轮到Set出场了把?
大榜:Set也是Collection的子接口,保证不含重复元素,有2个主要的实现类:HashSet和TreeSet。
HashSet是基于哈希表实现,要求元素重写hashCode、equals方法,但HashSet集合中的元素之间没有顺序;HashSet还有一个子类LinkedHashSet,可以按插入有序。TreeSet是基于排序二叉树实现,元素按比较有序,元素需要实现Comparable接口,或者创建TreeSet时提供一个Comparator对象。
小汪:榜哥,你又不说人话了。
大榜:哈哈哈,那我举个栗子,先说HashSet把,代码是这样的:
package com.programming.logic.p10;
import java.util.HashSet;
import java.util.Set;
/**
* 如果元素是自定义的类,HashSet对元素去重,必须要求元素重写hashCode、equals方法,否则HashSet是无法判断元素是否重复的;
* 当重写了Spec类的hashCode、equals方法后,HashSet就可以根据这2个方法,对重复元素进行去重。
*
* 总结:HashSet实现了Set接口,内部实现使用了HashMap。HashSet的特点如下:
* 1)没有重复元素;
* 2)可以高效地增、删、查找,也就是说判断元素是否存在,效率为O(1);
* 3)没有顺序。如果要保持添加的顺序,可以使用LinkedHashSet; 要实现元素的排序,可以使用TreeSet。
*
* @author qinxubang
* @Date 2022/5/19 10:56
*/
public class SetTest {
public static void main(String[] args) {
Set<Spec> set = new HashSet<>();
set.add(new Spec("M", "red"));
set.add(new Spec("M", "red"));
System.out.println(set);
}
}
/**
* 如果元素是自定义的类,HashSet对元素去重,必须要求元素重写hashCode、equals方法,否则HashSet是无法判断元素是否重复的。
*
* 当重写了Spec类的hashCode、equals方法后,HashSet就可以根据这2个方法,对重复元素进行去重。
*/
class Spec {
String size;
String color;
public Spec(String size, String color) {
this.size = size;
this.color = color;
}
@Override
public String toString() {
return "Spec{" +
"size='" + size + ''' +
", color='" + color + ''' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Spec spec = (Spec) o;
if (size != null ? !size.equals(spec.size) : spec.size != null) return false;
return color != null ? color.equals(spec.color) : spec.color == null;
}
@Override
public int hashCode() {
int result = size != null ? size.hashCode() : 0;
result = 31 * result + (color != null ? color.hashCode() : 0);
return result;
}
}
当重写了Spec类的hashCode、equals方法后,HashSet就可以根据这2个方法,对重复元素进行去重,所以输出为:[Spec{size='M', color='red'}]。
如果我们把Spec类的hashCode、equals方法去掉后,HashSet是无法判断元素是否是重复的,于是输出为:
[Spec{size='M', color='red'}, Spec{size='M', color='red'}]。
小汪:我懂了,总结一下就是,如果元素是自定义的类,HashSet对元素去重,必须要求元素重写hashCode、equals方法,否则HashSet是无法判断元素是否重复的。
大榜:HashSet实现了Set接口,特点如下:
1)没有重复元素;
2)可以高效地增、删、查找,也就是说判断元素是否存在,效率为O(1);
3)没有顺序。如果要保持添加的顺序,可以使用LinkedHashSet; 要实现元素的排序,可以使用TreeSet。
3.1.3、Queue(Deque、LinkedList、PriorityQueue)
小汪:Set接口的语义就是保证不含重复元素,只是如果元素是自定义的类,必须要求元素重写hashCode、equals方法,否则HashSet是无法判断元素是否重复的。接下来我们该讨论Queue接口了。
大榜:Queue也是Collection的子接口,表示先进先出的队列,在尾部添加,在头部查看(peek)或者删除(poll)。Deque是Queue的子接口,表示更为通用的双端队列,可以实现先进后出的栈。Queue还有一个特殊的实现类PriorityQueue,表示优先级队列,内部是用堆实现的。
而且,堆与一般队列的区别是:它有优先级的概念,每个元素都有优先级,队头的元素永远都是优先级最高的。堆这种数据结构常用来求前K个最大的元素和、求中值。
小汪:对于Collection接口的父接口List、Set、Queue,我们程序员都可以使用统一的迭代器方式来遍历容器中的元素,如需要在迭代过程中添加和删除,我们可以调用迭代器相关的方法如remove方法。
3.2、Map容器(HashMap、TreeMap、LinkedHashMap)
大榜:上面介绍的ArrayList、LinkedList,按照内容来查找的效率都比较低,都需要逐个进行比较,于是就有了Map映射这种数据结构,Map表示键值对集合,经常根据键进行操作。Map映射的使用场景:统计一本书中所有单词出现的次数,我们可以以单词为键,以出现的次数为值;或者是管理配置文件中的配置项,配置项就是典型的键值对。
Map有3个主要的实现类:HashMap、TreeMap和LinkedHashMap。
a)HashMap基于哈希表实现,要求键重写hashCode、equals方法,操作效率高,但元素之间没有顺序。
b)TreeMap基于排序二叉树实现,键需要实现Comparable接口,或者创建TreeMap时提供一个Comparator对象。
c)HashMap还有一个子类LinkedHashMap,它支持两种顺序:插入顺序和访问顺序。保证有序的原因:每个元素加入到了双向链表中。如果键本来就是有序的,则使用LinkedHashMap来保证插入有序,即保证插入时的顺序性;按照访问有序的特点,可以实现LRU缓存,详见 com.programming.logic.p10.linkedhashmap.LRUCache。
小汪:榜哥,说说人话啊,你光把结论抛出来,大家也只能混个眼熟。
大榜:那好吧,我还是举栗子开路。HashMap没什么好说的,我说下 TreeMap的用法,加深理解。代码是这样的:
package com.programming.logic.p10;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Comparator;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* TreeMap,按键有序,为了实现有序,要求键去实现Comparable接口,或者通过构造方法提供一个Comparator对象。
*
* @author qinxubang
* @Date 2022/5/19 11:07
*/
public class TreeMapTest {
public static void main(String[] args) {
// 从小到大排列
// Map<String, String> map = new TreeMap<>();
// 从大到小排列
Map<String, String> map = new TreeMap<>(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
});
map.put("a", "123");
map.put("b", "456");
map.put("c", "789");
map.put("T", "000");
// T排在最前面,是因为大写字母的ASCII码 都小于 小写字母。
for (Map.Entry<String, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + " --> " + entry.getValue());
}
}
}
上面的代码中,我们通过构造方法提供一个Comparator对象,要求按键来从大到小排序,代码是下面这样的:
Map<String, String> map = new TreeMap<>(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
});
小汪:我懂了,TreeMap是按键有序,所以为了实现有序,我们需要键去实现Comparable接口,或者通过构造方法提供一个Comparator对象。
大榜:LinkedHashMap它支持两种顺序:插入顺序和访问顺序。插入顺序:先添加的在前面,后添加的在后面,修改操作不影响顺序;访问顺序:对一个键执行get/put操作后,其对应的键值对 会移动到链表末尾,所以,最末尾的是最近访问的,链表最开始的是最久没有被访问的,可以采用LRU算法删除掉,这就是访问顺序,LinkedHashMap实现LRU算法,详见 com.programming.logic.p10.linkedhashmap.LRUCache类。
3.3、容器的工具类Collections
小汪:前面我们介绍了Java容器,它有Collection、Map两大接口组成。下面我们讨论啥呢?
大榜:下面我们讨论容器工具类Collections,它以静态方法的方式,提供了很多通用的功能,简化我们程序员来操作容器。Collections工具类平常用得最多的是Collections.sort方法,对元素按照规则排序。Collections主要包含以下2类操作。
1)对容器接口对象进行操作
查找和替换;排列和调整顺序;添加和修改。这是面向接口编程,只要对象实现了这些接口Collection,就可以使用查找和替换等算法。 2)返回一个容器接口对象
适配器:将一种类型的接口转换成另一种接口,类似于电子设备中的各种USB转接口。Collections接口其他类型的数据,转换为一个容器接口,目的使其他类型的数据更为方便地参与到容器类协作体系中。
装饰器:修饰一个给定容器的接口对象,增加某种性质。经过Collections的装饰后,它们更为安全了,分别是写安全、类型安全和线程安全。
a)写安全:Collections的unmodifiableXXX方法,使得容器对象变为只读,写入会抛出UnsupportedOperationException异常。只读的典型应用场景:需要传递一个容器对象给一个方法,这个方法可能是第三方提供的,为避免第三方误写,所以在传递前,转换为可读的。
b)类型安全:指的是确保容器中不会保存错误类型的对象。checkXXX方法,是为了避免JDK 5以前的代码用错类型,确保在泛型机制失灵的情况下,保证类型安全。
c)线程安全:若多个线程同时读写同一个容器对象,是不安全的。Collections提供了synchronizedXXX方法,将一个容器对象变为线程安全的。
小汪:感觉日常开发中用到了Collections,再去查看API文档也不迟,目前我们只需要记住Collections工具类,混个眼熟,它可以方便我们操作容器。
大榜:随用随查,出了问题看下输入输出来解决。小汪,你有没有觉得,日常开发中,解决一个特定问题时,经常需要综合使用多种容器类,比如要统计一本书中出现次数最多的前10个单词,可以先使用HashMap统计每个单词出现的次数,然后使用TopK类用PriorityQueue求前10个单词,或者使用Collections提供的sort方法排列。而且,我们在容器类中,看到了迭代器、工厂方法、适配器、装饰器等设计模式。
4、Java基础之文件
小汪:榜哥,咱们终于把容器讨论完了,接下来该文件出场了把。
大榜:理解文件,要有二进制思维。所有文件,不管是可执行文件、图片文件、Word文件、压缩文件,它们都是以0和1的二进制形式保存的。我们看到的图片、文本,都是应用程序对二进制的解析结果。例如 Word文件中,有文本、图片、表格、字体颜色等,doc文件类型 定义了这些类型和二进制表示之间的映射关系,我们称之为文件格式。而且,文件类型可以粗略分为2类:文本文件和二进制文件。
1)文本文件,如普通的文本文件(.txt)、程序源代码文件(.java)、HTML文件(.html)等;
2)二进制文件,如压缩文件(.zip) 、PDF文件(.pdf)、Excel文件(.xlsx)等。
小汪:常用的功能是文件的读和写,我记得操作系统上学过,读文件时需要2次复制操作。读文件,需要先从硬盘复制到操作系统内核,再从操作系统内核复制到应用程序分配的内存中。
大榜:是的,一般读写文件都是需要2次复制操作。因为操作系统运行的环境和应用程序是不一样的,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切到内核态,再从内核态切回到用户态。这种用户态/内核态的切换是有开销的,所以我们应该尽量减少这样的切换。
小汪:那怎么才能减少用户态到内核态之间的切换呢?
大榜:程序处理文件时,一次读取多条数据到内存中,这也是批处理思想的应用。
小汪:我记得Java中,有二进制字节流和字符流,它们有什么区别?
大榜:区别如下所示:
1)字节流是按字节读取的,而字符流是按char读取的,一个char在文件中保存的是几个字节,与编码有关;在文本文件中,编码非常重要,同一个字符,不同编码方式对应的二进制形式可能是不一样的。
2)二进制字节流的操作,使用InputStream/OutputStream相关的流,BufferedInputStream装饰类,提供缓冲,FileInputStream应该尽量用 BufferedInputStream类来装饰。代码是这样的:
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"));
3)字符流的操作,InputStreamReader是适配器,用于将InputStream类型转换为Reader类型,也就是将字节流转换为字符流。代码是这样的:
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
小汪:你刚刚提到了二进制字节流、字符流,二进制字节流主要用于.dat二进制文件,字符流用于.txt文本文件格式。像日常开发中,还有.doc、.csv、.zip等文件格式的读写,这个该如何处理呢?
大榜:我记得doc、csv这两种格式,业界有Apache POI库、alilibaba的easy-excel库;对于.zip文件格式,业务有Apache Commons Compress库,应该能够满足我们平时的开发需求了。
5、Java基础之注解
小汪:文件这一块,我们基本上一带而过啊,感觉就是混个脸熟。下面应该到了Java注解了把。
大榜:Java注解是在JDK 5引入的,注解是一种声明式编程风格。程序都由3个组件构成:
1)声明的关键字和语法本身;
2)系统/框架/库,它们负责解析、执行声明式的语句。
3)应用程序,使用声明式风格编写程序。
小汪:我懂了,对于应用程序来说,我们只需要了解如何使用的就可以了,比如常见的@Override、@Deprecated注解。
大榜:是的,我们后面要一起学习的MVC框架中,典型的需求就是:配置哪个方法处理哪个URL请求(@RequestMapping),然后将HTTP请求参数 统一映射为Java方法的入参(@RequestParam、@RequestBody)。
小汪:感觉使用@Override注解,Java底层帮我们做了很多工作,我们这些应用程序员只需要专注于应用功能,通过简单的声明式注解,就可以与Java底层进行协作。
大榜:是这样的,Java底层它们负责解析、执行声明式的@Override注解。总的来说,注解提升了Java语言的表达能力,有效地实现了应用程序和底层功能的分离,框架/库的程序员可以专注于底层实现,借助反射实现通用功能,提供注解给应用程序员使用;对于我们这些应用程序员来说,只需要专注于应用功能,通过简单的声明式注解与框架进行协作。
小汪:既然注解这么强大,如果我想要实现自定义的注解,并且想要注解起作用,该如何做呢? 大榜:这个也不难,分2步,首先我们定义一个注解,然后编写程序,负责解析该注解。自定义的注解Format:
package com.programming.logic.p22.serialization;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 日期格式化的注解
* @author qinxubang
* @Date 2022/5/23 9:17
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Format {
String pattern() default "yyyy-MM-dd HH:mm:ss";
String timezone() default "GMT+8";
}
自定义的注解Label:
package com.programming.logic.p22.serialization;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author qinxubang
* @Date 2022/5/23 9:14
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Label {
String value() default "";
}
负责解析Format注解,代码是这样的:
private static Object formatDate(Field f, Object value) {
Format format = f.getAnnotation(Format.class);
if (format != null) {
// format.pattern():获取Format注解上的字段值
SimpleDateFormat sdf = new SimpleDateFormat(format.pattern());
// 设置时区为东八区(北京时间),即"GMT+8"
sdf.setTimeZone(TimeZone.getTimeZone(format.timezone()));
return sdf.format(value);
}
return value;
}
负责解析该Label注解,代码如下:
public static String format(Object obj) {
try {
Class<?> cls = obj.getClass();
StringBuilder sb = new StringBuilder();
for (Field f : cls.getDeclaredFields()) {
if (!f.isAccessible()) {
f.setAccessible(true);
}
Label label = f.getAnnotation(Label.class);
String name = label != null ? label.value() : f.getName();
Object value = f.get(obj);
// 判断字段是否是Date类型
if (value != null && f.getType() == Date.class) {
value = formatDate(f, value);
}
sb.append(name + ":" + value + "\n");
}
return sb.toString();
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
定义好了注解、解析注解的方法后,接下来我们编写一个测试类:
package com.programming.logic.p22.serialization;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @author qinxubang
* @Date 2022/5/23 9:19
*/
public class SimpleFormatterDemo {
// 静态内部类
static class Student {
@Label("姓名")
String name;
@Label("出生日期")
@Format(pattern = "yyyy/MM/dd HH:mm:ss", timezone = "GMT+8")
// @Format(pattern="yyyy/MM/dd")
Date born;
@Label("分数")
double score;
public Student() {
}
public Student(String name, Date born, Double score) {
super();
this.name = name;
this.born = born;
this.score = score;
}
@Override
public String toString() {
return "Student [name=" + name + ", born=" + born + ", score=" + score + "]";
}
}
/**
* 输出:调用SimpleFormatter类,它负责解析、执行声明式的注解
* 姓名:张三
* 出生日期:1994/11/15 15:30:10
* 分数:80.9
*
* @param args
* @throws ParseException
*/
public static void main(String[] args) throws ParseException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Student zhangsan = new Student("张三", sdf.parse("1994-11-15 15:30:10"), 80.9d);
// 未调用解析注解的SimpleFormatter类
System.out.println("打印原生的student对象:" + zhangsan);
// 调用SimpleFormatter类,它负责解析、执行声明式的注解
System.out.println("打印解析注解后的 student对象," + "\n" + SimpleFormatter.format(zhangsan));
}
}
输出结果:
打印原生的student对象:Student [name=张三, born=Tue Nov 15 15:30:10 CST 1994, score=80.9]
打印解析注解后的 student对象,
姓名:张三
出生日期:1994/11/15 15:30:10
分数:80.9
小汪:从输出结果可以看出,Student类的实例变量name、born、score都标注上了@Label注解,程序运行时,会解析该@Label注解,输出 姓名、出生日期、分数。而且,因为实例变量born上标注了我们自定义的@Format(pattern = "yyyy/MM/dd HH:mm:ss", timezone = "GMT+8")注解,解析器会按照"yyyy/MM/dd HH:mm:ss"格式对日期进行格式化,所以输出为:1994/11/15 15:30:10。
大榜:是的,就是这样的。比如SpringMVC框架的程序员专注于@RequestMapping的底层实现,提供注解给我们程序员使用;对于我们这些应用程序员来说,只需要声明@RequestMapping注解,SpringMVC框架就帮我们实现请求URL路径的映射绑定关系,这个我们后文再说把。
6、总结
通过小汪和大榜的对话,我们在Java基础(一)的基础上,对Java基础(二)进行了讨论和回顾,重点介绍了Java异常中的受检异常和非受检异常,以及如何处理异常;接着介绍了Java泛型中的类型参数和通配符;趁热打铁,我们大篇幅介绍了Java容器,并讨论了需要踩坑的地方,它包含了Collection和Map,Collection提供了统一的遍历方式-迭代器,Map表示键值对集合,经常根据键来查找值;然后,我们一起讨论了Java文件,涉及到二进制字节流、字符流的基本概念;最后,我们实现了自定义的注解,并编写了解析注解的方法。
码云仓库地址: gitee.com/qinstudy/ja…
7、参考内容
《Java编程的逻辑》-马俊昌,第二部分(面向对象)、第三部分(泛型与容器)、第四部分(文件)、第六部分(注解)。