3.1 异常
Java 异常类层次结构图概览:
1.Exception 和 Error 有什么区别?
在 Java 中,java.lang 包下的 Throwable 类是所有错误和异常的基类。Throwable 类有两个重要的子类,分别是 Exception 和 Error:
Exception:Exception 是程序本身可以处理的异常,可以通过 try-catch 来进行捕获并处理。Exception 可以分为编译时异常和运行时异常。
Error:Error 属于程序无法处理的错误,Error 发生时,Java 虚拟机一般会选择终止所有线程。常见的 Error 类型的错误有:
-
Virtual MachineError
:
Java 虚拟机运行错误 -
OutOfMemoryError
:
虚拟机内存不够错误 -
NoClassDefFoundError
:
类定义错误
2.什么是编译时异常、运行时异常,有什么区别?
编译时异常是指 Java 代码在编译过程中,必须通过 try-catch 或者 throws 关键字处理的异常。在Exception的子类中,除了 RuntimeException 及其子类以外,其它子类都属于编译时异常。常见的编译时异常有:
-
IOException(输入输出异常):在处理输入输出操作时可能会出现的异常,比如文件读写操作中的异常。
-
FileNotFoundException(文件未找到异常):在尝试打开一个不存在的文件时抛出的异常。
-
ClassNotFoundException(类未找到异常):在使用反射或类加载机制时,如果指定的类不存在,则会抛出该异常。
-
SQLException(SQL异常):在进行数据库操作时可能会出现的异常,比如连接数据库、执行 SQL 语句等过程中的异常。
-
NoSuchMethodException(方法未找到异常):在使用反射调用方法时,如果指定的方法不存在,则会抛出该异常。
-
ParseException(解析异常):表示解析操作发生异常,例如日期格式转换错误、JSON 解析错误等。
比如下面这段 IO 操作的代码:
运行时异常是指 Java 代码在编译过程中,即使不处理也可以正常通过编译,但在运行时会报错的异常。RuntimeException 及其子类都是运行时异常,常见的运行时异常有:
-
NullPointerException(空指针异常):当应用程序试图在需要对象的地方使用 null 时,抛出该异常。
-
ArrayIndexOutOfBoundsException(数组下标越界异常):当数组元素的下标为负数或大于等于数组大小时,抛出该异常。
-
ArithmeticException(算术异常):当出现除数为0的情况时,抛出该异常。
-
IllegalArgumentException(非法参数异常):当方法接收到不合法的参数时,抛出该异常。
-
ClassCastException(类型转换异常):当试图将对象强制类型转换为不相关的类,抛出该异常。
-
NumberFormatException(数字格式异常):当应用程序试图将字符串转换为数值类型,但字符串的格式不合法时,抛出该异常。
区别:在编写代码时,编译时异常必须处理,运行时异常可以不处理。
3.Throwable 类的常用方法有哪些?
-
String getMessage():返回异常发生时的简要描述
-
String toString():返回异常发生时的详细信息
-
String getLocalizedMessage():返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage
()
返回的结果相同 -
void printStackTrace():在控制台上打印 Throwable 对象封装的异常信息
4.try-catch-finally 的作用?
-
try 块:用于捕获异常。后面可以跟零个或者多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
-
catch 块:用于处理 try 块捕获到的异常。
-
finally 块:finally 块中的代码一般用来关闭资源,除了一些极端情况,finally 块里的代码都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
代码示例:
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
} finally {
System.out.println("Finally");
}
输出:
Try to do something
Catch Exception -> RuntimeException
Finally
注意:不要在 finally 语句块中使用 return,当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个暂存在本地变量的值就变为了 finally 语句中的 return 返回值。
代码示例:
public static void main(String[] args) {
System.out.println(f(2));
}
public static int f(int value) {
try {
return value * value;
} finally {
if (value == 2) {
return 0;
}
}
}
输出:
0
5.finally 中的代码一定会执行吗?
不一定,在某些情况下,finally 中的代码不会被执行,例如以下几种情况:
-
finally 中的代码执行之前虚拟机被终止运行
-
finally 代码块处理的时候又抛出了异常,导致异常后的代码不会被执行
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
// 终止当前正在运行的Java虚拟机
System.exit(1);
} finally {
System.out.println("Finally");
}
输出:
Try to do something
Catch Exception -> RuntimeException
6.try-catch-finally 和 try-with-resources 的区别
try-with-resources 和 try-catch-finally 一样都是用来捕获并处理异常,但是 try-with-resources 可以将创建资源的操作写在 try 后面的括号中,在 try 中的代码执行完后资源会自动关闭,不需要在 finally 中编写关闭资源的操作。此外,try-with-resources 中想要自动关闭的资源对象必须实现 java.lang.AutoCloseable 或者 java.io.Closeable 接口。
Java 中类似于 InputStream、OutputStream、Scanner、PrintWriter 等资源都需要我们调用 close方法来手动关闭,一般情况下我们都是通过 try-catch-finally 语句来实现这个需求,如下:
//读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}
使用 Java 7 之后的 try-with-resources 语句改造上面的代码:
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}
当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用 try-catch-finally 可能会带来很多问题。
通过使用分号分隔,可以在 try-with-resources 块中声明多个资源。
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
3.2 泛型
1.什么是泛型?
泛型是 JDK 5 中引入的一个新特性。通过使用泛型,我们可以把不同的数据类型传递给一个类或者一个方法。
2.为什么要使用泛型?
没有泛型之前,我们要针对每一种类型编程,哪怕处理他们的逻辑都是相同的。有了泛型以后,就相当于为我们提供了一个模板,创建类或方法的时候只需要考虑通用的部分,等到使用的时候再分配具体的类型,极大提高了代码的复用性。
通过使用泛型,我们还可以在编译时就检测到类型不匹配的错误,从而避免在运行时出现 ClassCastException 等类型转换异常,提高类型安全性。
3.泛型的使用方式有哪几种?
泛型一般有三种使用方式,分别是:泛型类、泛型接口、泛型方法。
泛型类:
//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public class Generic<T>{
private T key;
public Generic(T key) {
this.key = key;
}
public T getKey(){
return key;
}
}
如何实例化泛型类:
Generic<Integer> genericInteger = new Generic<Integer>(123456);
泛型接口:
public interface Generator<T> {
public T method();
}
实现泛型接口,不指定类型:
class GeneratorImpl<T> implements Generator<T>{
@Override
public T method() {
return null;
}
}
实现泛型接口,指定类型:
class GeneratorImpl<T> implements Generator<String>{
@Override
public String method() {
return "hello";
}
}
泛型方法:
public static <E> void printArray(E[] inputArray){
for (E element : inputArray){
System.out.printf( "%s ", element );
}
System.out.println();
}
使用:
// 创建不同类型数组:Integer, Double 和 Character
Integer[] intArray = {1, 2, 3};
String[] stringArray = {"Hello", "World"};
printArray(intArray);
printArray(stringArray);
注意:
public static <E> void printArray(E[] inputArray)
上述方法一般被称为静态泛型方法,在 Java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的 <E>。
4.什么是泛型擦除
泛型擦除是指在编译期间将代码中的泛型类型都替换为泛型上界或 Object 类型。Java 虚拟机中没有泛型的概念,所以通过泛型擦除来保证程序在虚拟机中的正常执行。例如:
-
对于 List<String> 类型的变量,在编译后会被擦除成 List 类型,List 内部的元素都会变为 Object 类型。
-
对于具有多个类型参数的泛型类型,例如 Map<Integer, String> ,则会被擦除为 Map 类型,并且 Map 内部的键和值会都会变为 Object 类型。
泛型上界使用 extends 关键字来指定。例如,SomeClass<T extends Number>,这里的 Number 就是 T 的泛型上界。
5.项目中哪些地方用到了泛型?
-
创建集合时,一般都需要指定泛型。
-
定义统一结果封装类 CommonResult<T> 时,通过泛型可以指定返回结果中数据的具体类型。
3.3 反射
1.何为反射?
反射是指在运行时获取类的字节码文件对象,然后通过字节码文件对象解析类中的全部成分。例如,通过反射可以获取到一个类当中的属性、方法、构造器。
2.反射的优缺点?
优点:反射让我们在运行时有了获取类中成分的能力
缺点:
-
增加了安全问题,比如可以无视编译期泛型参数的安全检查。
-
反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
3.获取Class对象的四种方式
如果我们想要动态获取到类中的成分,我们需要依靠 Class 对象,Java 提供了四种方式获取 Class 对象。
知道具体类的情况下可以使用:
Class alunbarClass = 类名.class;
通过 Class.forName()
传入类的全路径获取:
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
通过对象实例 instance.getClass()
获取:
TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass();
通过类加载器 xxxClassLoader.loadClass()
传入类路径获取:
通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");
4.反射的应用场景?
我们平时大部分时候所写的业务代码,很少会接触到直接使用反射机制的场景。但是,这并不代表反射没有用。反射的主要应用场景如下:
-
通用框架的底层原理:正是因为反射,我们才能这么轻松地使用各种框架。像 Spring、SpringBoot、MyBatis 等框架中都大量使用了反射机制。这些框架中也大量使用了动态代理,而动态代理的实现也是依赖反射。
-
Java 中注解的实现也用到了反射:为什么使用 Spring 的时候 ,一个 @Component 注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value 注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?这些都是因为你可以基于反射分析类,然后获取到类、属性、方法、方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method
来调用指定的方法。
public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
private final Object target;
public DebugInvocationHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
System.out.println("before method " + method.getName());
Object result = method.invoke(target, args);
System.out.println("after method " + method.getName());
return result;
}
}
3.4 注解
1.什么是注解?
注解可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,用来提供某些信息供程序在编译或者运行时使用。
注解本质是一个继承了 Annotation 的特殊接口:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
public interface Override extends Annotation{
}
JDK 提供了很多内置的注解(比如 @Override、@Deprecated),同时,我们还可以自定义注解。
2.注解的解析方法有哪几种?
注解只有被解析之后才会生效,常见的解析方法有两种:
-
编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理。比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
-
运行期通过反射处理:框架中自带的注解都是通过反射来进行处理的。
3.5 SPI
1.什么是SPI
SPI(Service Provider Interface,服务提供者接口)将接口和具体的实现分开了,由第三方厂商来实现接口。例如,JDBC就是采用了 SPI 机制,Java官方来提供一套标准接口,各个数据库厂商分别按照自己的方式来实现。
很多框架都也使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
2.SPI 和 API 有什么区别?
在 SPI 中,接口和实现是分开的,由不同的厂商定义;在 API 中,接口的定义和接口的实现都是在同一个模块。如果不明白可通过下述文本理解。
一般模块之间都是通过接口进行通讯,那我们在服务调用方和服务实现方之间引入一个“接口”。
-
API:当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
-
SPI:当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
Note:在 SPI 中,服务调用方一般指的是第三方插件、框架或组件,它们利用Java标准库来加载并调用第三方提供的具体类的实例。而服务提供者,则是提供了具体实现类的第三方插件、框架或组件。
3.6 序列化和反序列化
1.什么是序列化和反序列化?它们的应用场景是什么?
简单来说:
-
序列化:将对象转换成二进制字节流的过程
-
反序列化:将序列化过程中所生成的二进制字节流转换成对象的过程
应用场景:序列化主要用来通过网络传输对象或者将对象存储到文件系统、数据库、内存中,反序列化主要用来将网络传输过来的数据或文件系统、数据库、内存中的数据恢复成对象。
序列化协议对应于 TCP/IP 模型的哪一层?
如下图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。所以,表示层对应的就是序列化和反序列化。因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
2.如果对象的某些变量不想进行序列化怎么办?
对于不想进行序列化和反序列化的变量,使用 transient 关键字修饰。被 transient 修饰的变量其变量值不会被持久化和恢复。
关于 transient 还有几点注意:
-
transient 只能修饰变量,不能修饰类和方法。
-
transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int 类型,那么反序列后结果就是
0
。 -
static 变量因为不属于任何对象,所以无论有没有 transient 关键字修饰,均不会被序列化。
3.常见序列化协议有哪些?
比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。像 JSON 和 XML 这种属于文本序列化协议,虽然可读性比较好,但是性能较差,一般不会选择。
Note:二进制序列化协议则是将数据直接序列化为二进制流进行传输和存储,文本序列化方式相比二进制序列化方式,首先将数据转换为可读性较好的文本,其次,再将文本转为二进制传输。
4.为什么不推荐使用 JDK 自带的序列化?
因为JDK自带的序列化方式存在一些问题,比如:
-
不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
-
性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
-
存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
3.7 I/O
1.Java 中的 IO 流了解吗?
IO 是 Input 和 Output 的缩写,即输入和输出,数据输入到计算机内存的过程即输入,反之从内存输出到文件系统、数据库、网络等外部区域的过程即输出。IO流在数据传输过程中类似于水流,因此称为 IO 流。IO 流按照流的方向分为输入流和输出流,按照数据的处理方式又分为字节流和字符流。
Java 中 IO 流的 40 多个类都是从 4 个抽象类基类中派生出来的。
-
InputStream
、
Reader:是所有输入流的基类,前者是字节输入流,后者是字符输入流。 -
OutputStream
、
Writer:是所有输出流的基类,前者是字节输出流,后者是字符输出流。
2.I/O 流为什么要分为字节流和字符流呢?
-
字节流适合于处理二进制数据,如图像、音频、视频等。
-
字符流适合于处理文本数据,因为字符流在处理文本数据时可以根据指定的字符编码自动进行字符集的转换,避免了使用字节流处理文本数据时,手动进行编码和解码的繁琐操作。
3.常用的 IO 流有哪些
-
文件流:就是操作文件的IO流,文件流的类有 FileInputStream 、FileOutputStream 、FileReader、FileWriter
-
缓冲流:缓冲流自带缓冲区,可以提高原始字节流、字符流读写数据的性能。缓冲流的类有BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWrite
-
转换流:转换流可以在文件和代码编码不一致时使读取到的文件内容不会乱码。转换流的类有InputStreamReader、OutputStreamWriter
-
打印流:字符打印流可以方便、高效的打印数据到文件中去。可以实现打印什么数据就是什么数据。打印流的类有 PrintStream、PrintWriter
3.Java IO 中的设计模式有哪些?
4.BIO、NIO 和 AIO 的区别?
3.8 语法糖
1.什么是语法糖?
语法糖指的是编程语言为了方便程序员编写程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简洁且更易阅读。例如:
Java 中的 for-each 就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。
-
当使用foreach对数组进行遍历时,其底层是for循环。
-
当使用for-each对集合遍历时,其底层是迭代器。
String[] strs = {"JavaGuide", "公众号:JavaGuide", "博客:https://javaguide.cn/"};
for (String s : strs) {
System.out.println(s);
}
Note:不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。
2.Java 中有哪些常见的语法糖?
Java 中最常用的语法糖主要有泛型、自动拆装箱、可变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。