目录

  • 1.Java 程序设计概述
  • 1.1.Java 跨平台运行的原理是什么?
  • 1.2.Java 的安全性体现在哪些方面?
  • 1.3.Java 语言有哪些特点?
  • 1.4.Java 与 C++ 有什么区别?
  • 1.5.✨Java 8 有哪些新特性?
  • 2.Java 程序设计环境
  • 2.1.JDK 和 JRE 有什么区别?JDK 中有哪些常用的工具?
  • 2.2.JVM 是什么?
  • 2.3.编译性语言和解释性语言有什么区别?
  • 2.4.什么是字节码?采用字节码的好处是什么?
  • 2.5.为什么说 Java 语言“编译与解释并存”?
  • 3.Java 的基本程序设计结构
  • 3.1.谈谈 Java 中的关键字和标识符。
  • 3.2.✨谈谈 Java 中的基本数据类型与包装类。
  • 3.3.谈谈 Java 中的运算符。
  • 3.4.介绍一下 Java 中的可变参数。
  • 3.5.return 与 finally 的执行顺序对返回值有什么影响?
  • 3.6.== 和 equals 的相同点和不同点是什么?
  • 3.7.hashCode() 与 equals() 的区别是什么?
  • 3.8.✨有一个用户类,字段有用户 id、名称、邮箱、手机等,如果要去重的话,hashCode() 和 equals() 方法怎么重写?你会用哪些字段去拼接 hashCode?
  • 4.✨面向对象
  • 5.反射与注解
  • 5.1.什么是反射?
  • 5.2.什么是注解?
  • 5.3.什么是 Lambda 表达式?
  • 6.4.什么是动态代理?其应用场景有哪些?
  • 6.异常
  • 7.泛型程序设计
  • 8.1.什么是泛型?为什么要使用泛型程序设计?其优缺点是什么?
  • 8.2.泛型的使用方式有哪几种?
  • 8.2.1.泛型类
  • 8.2.2.泛型接口
  • 8.2.3.泛型方法
  • 8.3.什么是类型擦除?
  • 8.4.介绍一下常用的通配符。
  • 8.5.泛型的桥方法是指什么?
  • 8.6.项目中哪里用到了泛型?
  • 8.✨集合
  • 9.I/O
  • 10.其它常用类


① 以下 Java 基础面试题均基于Java 8,并且大部分题目来自网络。
② 由于本人水平有限,所以本文难免会出现一些错误或者不准确的地方,恳请读者在评论区指正。

1.Java 程序设计概述

1.1.Java 跨平台运行的原理是什么?

(1)Java 源文件要先编译成与操作系统无关的 .class 字节码文件,然后字节码文件再通过 Java 虚拟机解释成机器码运行。
(2).class 字节码文件面向虚拟机,不面向任何具体操作系统。
(3)不同平台的虚拟机是不同的,但它们给 JDK 提供了相同的接口。
(4)Java 的跨平台依赖于不同系统的 Java 虚拟机

1.2.Java 的安全性体现在哪些方面?

(1)Java 使用引用取代了指针,指针的功能虽然强大,但是也容易造成错误,如数组越界问题。
(2)拥有一套异常处理机制,使用关键字 throw、throws、try、catch、finally。
(3)强制类型转换需要符合一定规则
(4)字节码传输使用了加密机制
(5)运行环境提供保障机制:字节码校验器 → 类装载器 → 运行时内存布局→ 文件访问限制。
(6)不用程序员显示控制内存释放,JVM 有垃圾回收机制

1.3.Java 语言有哪些特点?

简单性

Java 语法是 C++ 语法的一个"纯净"版本,这里没有头文件、 指针运算(甚至指针语法)、结构、 联合、 操作符重载、 虚基类等。

面向对象

开发 Java 时面向对象技术已经相当成熟。 Java 的面向对象特性与 C++ 旗鼓相当。Java 与 C++ 的主要不同点在于多重继承,在 Java 中,取而代之的是更简单的接口概念。与 C++相比,Java 提供了更丰富的运行时自省功能。

分布式

Java 有一个丰富的例程库,用于处理像 HTTP 和 FIT 之类的 TCP/IP 协议。Java 应用程序能够通过 URL 打开和访问网络上的对象,其便捷程度就好像访问本地文件一样。

健壮性

Java 的设计目标之一在于使得 Java 编写的程序具有多方面的可靠性。Java 投入了大量的精力进行早期的问题检测、 后期动态的 (运行时)检测,并消除了容易出错的情况。Java 和 C++ 最大的不同在于 Java 采用的指针模型可以消除重写内存和损坏数据的可能性。Java 编译器能够检测许多在其他语言中仅在运行时才能够检测出来的问题。

安全性

Java 适用于网络 / 分布式环境。 为了达到这个目标,在安全方面投入了很大精力。使用 Java 可以构建防病毒、 防篡改的系统。从一开始,Java 就设计成能够防范各种攻击,其中包括:运行时堆栈溢出(如蠕虫和病毒常用的攻击手段)、破坏自己的进程空间之外的内存、未经授权读写文件等。

体系结构中立

编译器生成一个体系结构中立的目标文件格式,这是一种编译过的代码, 只要有 Java 运行时系统, 这些编译后的代码可以在许多处理器上运行。Java 编译器通过生成与特定的计算机体系结构无关的字节码指令来实现这一特性。 精心设计的字节码不仅可以很容易地在任何机器上解释执行,而且还可以动态地翻译成本地机器代码。

可移植性

与 C 和 C++ 不同,Java 规范中没有“ 依赖具体实现” 的地方基本教据类型的大小以及有关运算都做了明确的说明。作为系统组成部分的类库, 定义了可移植的接口例如,有一个抽象的 Window类, 并给出了在 UNIX、 Windows 和 Macintosh 环境下的不同实现。

解释性

Java 解释器可以在任何移植了解释器的机器上执行 Java 字节码。由于链接是一个增量式且轻量级的过程, 所以, 开发过程也变得更加快捷,更加具有探索性。

高性能

尽管对解释后的字节码性能已经比较满意,但在有些场合下还需要更加高效的性能。字节码可以(在运行时刻)动态地翻译成对应运行这个应用的特定 CPU 的机器码。

多线程

多线程可以带来更好的交互响应和实时行为。

动态性

从各种角度看, Java 与 C 或 C++ 相比更加具有动态性。它能够适应不断发展的环境库中可以自由地添加新方法和实例变量, 而对客户端却没有任何影响。在 Java 中找出运行时类型信息十分简单。

1.4.Java 与 C++ 有什么区别?

(1)Java 的类是单继承的,C++ 支持多重继承(Java 的接口可以多继承)。
(2)Java 不提供指针来直接访问内存,程序内存更加安全。
(3)Java 有自动内存管理垃圾回收机制 (GC),不需要程序员手动释放无用内存。
(4)C++ 同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。

1.5.✨Java 8 有哪些新特性?

Java 8 是 Java 编程语言的一个版本,它引入了许多新特性。以下是 Java 8 的一些新特性:

  • 默认方法:在 Java 8 中,接口中的方法可以用 defaultstatic 修饰,这样就可以有方法体,实现类也不必重写此方法。
  • 函数式接口:Java 8 引入了函数式接口的概念,函数式接口是指只有一个抽象方法,但可以有多个非抽象方法的接口。
  • Lambda 表达式:Lambda 表达式是一个匿名函数,它可以作为参数传递给其他函数。Lambda 表达式使得编写简洁、易读的代码变得更加容易。
  • Stream API:Stream API 提供了一种新的方式来处理集合数据。使用 Stream API,可以更加轻松地进行筛选、映射、过滤、排序等操作。
  • 方法引用:方法引用是一种新的语言特性,它使得代码更加简洁。方法引用可以替代一些常用的 Lambda 表达式,例如使用方法的引用来替代实现接口中的方法。
  • 新的日期和时间 API:Java 8 引入了一个新的日期和时间 API,java.time.LocalDate,它提供了更加简单、易用的方式来处理日期和时间。
  • Optional:用来解决 NPE (java.lang.NullPointerException) 问题;
  • 类型注解:Java 8 引入了类型注解的概念,类型注解可以帮助开发人员更好地理解代码中的类型信息。

2.Java 程序设计环境

2.1.JDK 和 JRE 有什么区别?JDK 中有哪些常用的工具?

在回答这个问题之前,先看下面这张图:

java 阅读代码时 如果有疑问 如何打标记_总结

(1)JDK 和 JRE 的具体区别如下:

JDK

JRE

英文全称

Java Development Kit,即 Java 开发工具包

Java Runtime Environment,即 Java 运行时环境

组成部分

包含 JRE、同时还包括 Java 源码的编译器 javac、监控工具 JConsole、分析工具 Java VisualVM 等

JRE 包含 Java 虚拟机,Java 基础类库等

面向人群

程序员

想运行 Java 程序的用户

使用场景

需要编写并运行 Java 程序

只需要运行 .class 文件

注意:JRE 只能运行已经编译的字节码文件,而不能直接运行 Java 源文件。为了编译 Java 源文件,你需要使用 JDK 中的 javac 命令将源代码编译为字节码文件,然后再使用 JRE 运行生成的字节码文件。

(2)JDK 中常用的工具如下:

  • javac:Java 编译器,用于将 Java 源代码编译成字节码文件。
  • java:Java 虚拟机,用于执行 Java 程序。
  • javadoc:Java 文档生成器,用于根据 Java 源代码生成文档。
  • jar:Java 归档工具,用于创建和管理 Java 库。
  • jdb:Java 调试器,用于调试 Java 程序。
  • jps:Java 进程状态工具,用于显示 Java 进程的状态信息。
  • jstack:Java 堆栈跟踪工具,用于显示 Java 线程的堆栈信息。
  • jmap:Java 内存映射工具,用于生成 Java 进程和垃圾回收器的内存快照。
  • jstat:Java 统计信息监视工具,用于监视 Java 进程的性能指标。
  • jconsole:Java 监视和管理控制台,用于监视和管理 Java 进程。

2.2.JVM 是什么?

Java 虚拟机 (Java Virtual Machine, JVM) 是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现 (Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

注意:JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM。

2.3.编译性语言和解释性语言有什么区别?

编译性语言

解释性语言

概述

需要通过编译器,将源代码编译成机器码之后才能执行的语言。一般是有编译链接两个步骤,编译是将我们的程序编译成机器码,链接是程序和依赖库等串联起来

解释型语言不需要编译,相比于编译型语言省了一道工序,在运行程序的时候才逐行进行翻译

优点

编译器一般会通过预编译的过程对代码进行优化,因为编译只做了一次,运行时不会再编译,所以效率较高

有良好的平台兼容性(只要安装了虚拟机就可以),易于维护,方便快速部署

缺点

编译之后如果想要修改某一个功能,就需要整个模块重新编译。编译的时候根据对应的运行环境生成不同的机器码。不同的操作系统之间,可能会出现问题。需要根据环境的不同,生成不同的可执行文件

每次运行的时候都要解释一遍,性能上不如编译型语言

代表语言

C、C++、Pascal

JavaScript、Python、Erlang、PHP、Perl、Ruby

2.4.什么是字节码?采用字节码的好处是什么?

(1)Java 中 JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向 JVM。
(2)Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

2.5.为什么说 Java 语言“编译与解释并存”?

(1)我们可以将高级编程语言按照程序的执行方式分为两种:编译性语言和解释性语言(具体细节见2.3)。

(2)因为 Java 程序要经过先编译,后解释两个步骤,即:

  • 由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件);
  • 这种字节码必须由 Java 解释器来解释执行。

所以 Java 语言既具有编译型语言的特征,也具有解释型语言的特征,即 Java 语言“编译与解释并存”。


3.Java 的基本程序设计结构

3.1.谈谈 Java 中的关键字和标识符。

与关键字和标识符有关的知识可以参考 Java 基础面试题——关键字与标识符这篇文章。

3.2.✨谈谈 Java 中的基本数据类型与包装类。

与基本数据类型与包装类有关的知识可以参考 Java 基础面试题——基本数据类型与包装类这篇文章。

3.3.谈谈 Java 中的运算符。

与运算符有关的知识可以参考 Java 基础面试题——运算符这篇文章。

3.4.介绍一下 Java 中的可变参数。

(1)可变参数的作用是在不确定参数的个数时,可以使用可变参数。即 Java 允许将同一个类中多个同名同功能但参数个数不同的方法封装成一个方法。其基本语法如下:

访问修饰符 返回类型 方法名(数据类型... 形参名){
	//方法体
}

(2)案例
① 利用方法的重载,实现三个方法 sum,用于求2 ~ 4个数的和。

class Solution{
    // 两数之和
    public void sum(int a, int b){
        System.out.println("2数之和: " + (a + b));
    }
    // 三数之和
    public void sum(int a, int b, int c){
        System.out.println("3数之和: " + (a + b + c));
    }
    // 四数之和
    public void sum(int a, int b, int c, int d){
        System.out.println("4数之和: " + (a + b + c + d));
    }
    
    public static void main(String[] args) {
        Solution my = new Solution();
        my.sum(1,2);
        my.sum(1,2,3);
        my.sum(1,2,3,4);
    }
}

② 使用可变参数进行优化

class Solution{
    //n数之和
    public void sum(int... nums){
        // 可变参数可以当作数组使用
        int sum = 0;
        for (int i = 0; i < nums.length; i++) {
            sum += nums[i];
        }
        System.out.println(nums.length + "数之和为: " + sum);
    }
    
    public static void main(String[] args) {
        Solution my = new Solution();
        my.sum(1,2);
        my.sum(1,2,3);
        my.sum(1,2,3,4);
    }
}

(3)注意事项
① 可变参数的实参可以为 0个或任意多个;
② 可变参数的实参可以为数组;
③ 可变参数的本质是数组;
④ 一个形参列表只能出现一个可变参数;
⑤ 可变参数可以和普通类型的参数一起放在形参列表,但必须保证可变参数在最后

3.5.return 与 finally 的执行顺序对返回值有什么影响?

对于 try 和 finally 至少有一个语句块包含 return 语句的情况:
① 正常情况下(例如没有强制系统退出)finally 语句块会执行,否则不会执行;

public class Test {
    public static void main(String args[]) {
        try {
            int a = 10 / 0;
            System.out.println(a);
        } catch (Exception e) {
            e.printStackTrace();
            //退出程序,finally 中的代码不会被执行
            System.exit(0);
        } finally {
            System.out.println("finally...");
        }
    }
}

结果如下:

java.lang.ArithmeticException: / by zero
	at test.Test.main(Test.java:8)

② 如果 finally 语句块中没有 return 语句,则 finally 对 return 语句中变量的重新赋值修改无效

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

public static int getInt() {
    int a;
    try {
        a = 200;
        return a;
    } finally {
        System.out.println("finally...");
        a = 300;
    }
}

输出结果如下:

finally...
200

③ 如果 try 和 finally 语句块中都包含 return 语句,则 return 值会以 finally 语句块中的 return 值为准

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

public static int getInt() {
    int a;
    try {
        a = 200;
        return a;
    } finally {
        System.out.println("finally...");
        a = 300;
        return a;
    }
}

输出结果如下:

finally...
300

3.6.== 和 equals 的相同点和不同点是什么?

(1)相同点

  • 都用于完成比较操作;
  • 结果都返回布尔值;
  • 对于 Object 来说,== 和 equals() 比较的都是内存地址值,它们的作用相同;

(2)不同点

  • == 是关系运算符,而 equals() 是方法
  • 对于 == 来说:
  • 如果比较对象都是基本类型,则比较它们的是否相等;
  • 如果比较对象都是引用类型,则比较它们的内存地址值是否相等;

注意:不能比较没有父子关系的两个对象;

public class Solution {
    public static void main(String[] args) {
        int a = 1;
        int b = 1;
        Solution s1 = new Solution();
        Solution s2 = new Solution();
        HashMap<Integer, Integer> hashMap = new HashMap<>();
        System.out.println(a == b);             // true
        System.out.println(s1 == s2);           // false
        System.out.println(s1 == hashMap);      // 提示报错
    }
}
  • 对于 equals 来说:JDK 中的类一般已经重写了 equals(),比较的是内容;自定义类如果没有重写 equals(),将调用父类(默认 Object 类)的 equals() 方法,Object 的 equals() 比较使用了 this == obj;可以按照需求逻辑,重写对象的 equals() 方法,重写 equals() ,一般须重写 hashCode() ;
public class Solution {
    public static void main(String[] args) {
        Solution s1 = new Solution();
        Solution s2 = new Solution();
        HashMap<Integer, Integer> hashMap = new HashMap<>();
        String str1 = "abc";
        String str2 = "abc";					
        String str3 = new String("abc"); 		// 在堆中创建一个新的对象,str3 指向堆中的对象,而不是堆中的常量池
        System.out.println(s1 == s2);           // false,比较内存地址值
        System.out.println(s1.equals(s2));      // false,自定义类,未重写 equals(),故通过 this == obj 比较内存地址值
        System.out.println(str1 == str2);       // true,str1 和 str2 都指向的是堆中的常量池中的同一个值,所以内存地址值一样
        System.out.println(str1.equals(str2));  // true,String 为 JDK 中的类,故比较内容
        System.out.println(str1 == str3);       // false,str1 指向堆中的常量池中的 "abc",str3 指向堆中的对象,内存地址值不一样
        System.out.println(str1.equals(str3));  // true,String 为 JDK 中的类,故比较内容
    }
}

3.7.hashCode() 与 equals() 的区别是什么?

(1)hashCodeequals 方法都是 Java 中用于判断对象相等的方法,但是它们的作用和实现方式是不同的。

  • hashCode() 方法的返回值是对象的 hash 值(int 类型,生成效率较高),大部分情况下不同对象的 hash 值不相同,但该方法并不是完全可靠,有时候该方法会为不同的对象生成相同的哈希码(生成 hash 值的公式可能存在问题)。
  • equals() 方法用于判断两个对象是否相等,其默认实现是比较两个对象的引用地址是否相同,即是否是同一个对象。但是在很多情况下,我们需要根据对象的属性来判断两个对象是否相等,因此需要重写 equals() 方法。

(2)有关结论如下:

  • equals() 比较结果相等的两个对象它们的 hashCode() 肯定相等,也就是用 equals() 对比是绝对可靠的,但是效率较低
  • hashCode() 相等的两个对象它们的 equals() 不一定相等,也就是虽然 hashCode() 比较的效率较高,但不是绝对可靠的
  • 所以在比较两个对象时,首先用 hashCode() 去对比:
  • 如果 hashCode() 不同,则表示这两个对象肯定不相等,也就是不必再用 equals()去再对比了;
  • 如果 hashCode() 相同,此时再用 equals() 来比较,如果 equals() 也相同,则表示这两个对象是真的相同了,这样既能提高效率,也保证了对比的绝对正确性

(3)一般来说,如果重写了 equals() 方法,就需要同时重写 hashCode() 方法,以保证对象相等的两个对象具有相同的 hash 值。这是因为,如果一个对象的 hashCode() 方法返回的 hash 值与另一个相等的对象的 hashCode() 方法返回的 hash 值不同(但这两个对象的属性是完全相同的,我们实际上认为它们是相等的),那么这两个对象就可能被哈希表误认为是不同的对象,从而引起一些问题,例如哈希表中存在重复元素等。因此,为了保证对象相等的两个对象具有相同的哈希码,一般需要同时重写 hashCode() 方法和 equals() 方法,使得两个方法的实现方式能够一致。

(4)阿里巴巴开发手册中规定:

java 阅读代码时 如果有疑问 如何打标记_面试题_02

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

	//...
	
	public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

	public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
}

参考文章:
hashCode() 和 equals() 的区别

3.8.✨有一个用户类,字段有用户 id、名称、邮箱、手机等,如果要去重的话,hashCode() 和 equals() 方法怎么重写?你会用哪些字段去拼接 hashCode?

(1)在用户类中,如果要去重,需要重写 hashCode() 和 equals() 方法。以下是一种可能的实现方式:

public class User {
    private int id;
    private String name;
    private String email;
    private String phone;

    // 构造方法、getter和setter等其他代码...

    @Override
    public int hashCode() {
        int result = Integer.hashCode(id);
        result = 31 * result + (name != null ? name.hashCode() : 0);
        result = 31 * result + (email != null ? email.hashCode() : 0);
        result = 31 * result + (phone != null ? phone.hashCode() : 0);
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        User other = (User) obj;
        return id == other.getId()
                && Objects.equals(name, other.getName())
                && Objects.equals(email, other.getEmail())
                && Objects.equals(phone, other.getPhone());
    }
}

(2)在 hashCode() 方法中,可以选择使用用户 id、名称、邮箱和手机拼接计算 hashCode,并使用 Objects 类的静态方法 hashCode() 来计算每个字段的 hashCode,然后通过乘以 31 累加到结果中,这是一种常用的 HashCode 计算方式。

(3)在 equals() 方法中,首先判断引用是否相同,如果相同则返回 true。然后判断对象是否属于 User 类,以及字段是否相等,可以使用Objects类的 equals() 方法进行比较。需要注意使用 Objects.equals() 方法来比较可能为 null 的字段,避免空指针异常。

(4)这种实现方式保证了相同字段的用户对象具有相同的 hashCode 值,同时重写的 equals 方法会在去重操作中进行调用,确保只有不同的用户对象才会被判断为不相等。需要根据具体的业务需求和判断的字段选择合适的 hashCode 拼接方式,保证在去重操作中能够正确识别相同的用户对象。

在上述代码中,hashCode() 方法中计算 result 时的乘法因子为什么是 31?
① 乘法因子选择 31 是为了提高哈希码的散列性能和效率。31 是一个质数(素数),而且它有一个特性:31 = 2^5 - 1。具体来说,乘以31 相当于将 hashCode 结果左移 5 位,然后再减去自身,这样的操作在二进制运算中效率更高。此外,31 是个奇数,这样可以保证哈希函数中的乘法结果奇数偶数性的变化,更好地分散数据。
② 综上所述,乘法因子 31 的选择能够提高哈希码的散列性能和效率,对于大多数对象的哈希码生成都是比较合适的选择。但需要注意的是,在某些特定的应用场景中,可能需要根据具体情况选择不同的乘法因子来生成哈希码。


4.✨面向对象

与面向对象有关的知识可以查看 Java 基础面试题——面向对象这篇文章。


5.反射与注解

5.1.什么是反射?

与反射有关的知识可以查看 Java 基础——反射这篇文章。

5.2.什么是注解?

与注解有关的知识可以查看 Java 基础——注解这篇文章。

5.3.什么是 Lambda 表达式?

与 Lambda 表达式有关的知识可以查看 Java 基础——Lambda 表达式这篇文章。

6.4.什么是动态代理?其应用场景有哪些?

与动态代理有关的知识可以参考 Java 设计模式——代理模式这篇文章。


6.异常

有关 Java 异常的相关面试题可以参考 Java 基础面试题——异常这篇文章。


7.泛型程序设计

8.1.什么是泛型?为什么要使用泛型程序设计?其优缺点是什么?

(1)Java 泛型 (Generics) 是 JDK 1.5 中引入的一个新特性。泛型的本质是把参数的类型参数化,也就是所操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中。泛型程序设计 (Generic programming) 意味着编写的代码可以被很多不同类型的对象所重用。例如, 我们并不希望为聚集 String 和 File 对象分别设计不同的类。实际上,也不需要这样做,因为一个 ArrayList 类可以聚集任何类型的对象。这是一个泛型程序设计的实例。

(2)使用泛型参数,可以增强代码的可读性以及稳定性。编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如下面这行代码就指明了该 ArrayList 对象只能传入 Person 对象,如果传入其他类型的对象就会报错。

ArrayList<Person> persons = new ArrayList<Person>();

(3)泛型用于在编译时提供更强的类型安全检查和更灵活的代码设计。泛型的优点和缺点如下:

  • 优点:
  • 类型安全:使用泛型可以在编译时进行类型检查,减少了在运行时发生类型转换或类型错误的可能性,提高了程序的稳定性安全性
  • 代码重用:使用泛型可以编写通用的代码,适用于多种数据类型,避免了重复编写相似的代码,提高了代码的重用性可维护性
  • 更好的代码抽象和封装:泛型可以将算法或数据结构与特定的数据类型解耦,使代码更加通用和可扩展,实现了更好的抽象和封装。
  • 提高代码可读性和可理解性:使用泛型可以使代码更加清晰和易读,明确表达了代码的意图和限制。
  • 缺点:
  • 语法复杂:泛型的语法相对复杂,尤其是对于初学者而言,会增加学习和理解的难度。
  • 增加编译时间:由于泛型需要进行类型擦除和类型推断等处理,在编译时会增加额外的开销,导致编译时间稍长。
  • 限制类型灵活性:泛型虽然提供了更好的类型安全,但有时也会限制类型的灵活性。例如,无法直接使用基本数据类型作为泛型参数,需要使用对应的包装类。

8.2.泛型的使用方式有哪几种?

泛型一般有三种使用方式:泛型类泛型接口泛型方法

8.2.1.泛型类

public class TestGeneric {
    public static void main(String[] args) {
    	//实例化泛型类
        // T 为 Integer 类型
        GenericDemo<Integer> g1 = new GenericDemo<Integer>(10);
        // T 为 String 类型
        GenericDemo<String> g2 = new GenericDemo<String>("10");
    }
}

/*
* (1) 此处 T 可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
* (2) 在实例化泛型类时,必须指定 T 的具体类型
* */
class GenericDemo<T>{
    
    private T key;
    
    public GenericDemo(T key) {
        this.key = key;
    }
    
    public T getKey(){
        return key;
    }
}

8.2.2.泛型接口

定义一个泛型接口:

interface GInterface<T> {
    public T method();
}

实现泛型接口,不指定类型:

class GClass<T> implements GInterface<T>{
    @Override
    public T method() {
        return null;
    }
}

实现泛型接口,指定类型:

class GClass<T> implements GInterface<String>{
    @Override
    public String method() {
        return "hello";
    }
}

8.2.3.泛型方法

public class TestGeneric {
    public static <E> void printArray(E[] inputArray) {
        for (E element : inputArray) {
            System.out.printf("%s ", element);
        }
        System.out.println();
    }
    
    public static void main(String[] args) {
        //创建不同类型数组: Integer 和 Character
        Integer[] intArray = {1, 2, 3};
        String[] stringArray = {"Hello", "World"};
        printArray(intArray);
        printArray(stringArray);
    }
}

输出结果如下:

1 2 3 
Hello World

8.3.什么是类型擦除?

(1)Java 中的类型擦除是指在编译器将泛型代码生成字节码的时候,将泛型中的类型参数擦除,并将泛型代码转换为普通的非泛型代码。在 Java 中,泛型类型在编译期间被擦除,因此在运行时无法保留泛型类型信息。例如,在以下代码中,泛型参数 T 在编译器中会被擦除:

public class Stack<T> {
    private T[] data;
    public void push(T t) {
        // ...
    }
    public T pop() {
        // ...
    }
}

上述代码中,Stack 类是一个泛型类,其类型参数为 T。在编译期间,类型参数 T 会被擦除,编译器会将代码转换为以下形式:

public class Stack {
    private Object[] data;
    public void push(Object o) {
        // ...
    }
    public Object pop() {
        // ...
    }
}

可以看到,在编译器中,泛型中的类型参数 T 被替换成了 Object 类型。因此,在运行时,无法通过获取类的类型信息得到 Stack 类中具体的泛型类型。这就是 Java 中类型擦除的特性。

(2)类型擦除的优势在于对于可重用的数据结构和算法,可以避免为每个不同的类型都声明一个新的类。Java 的集合类就是通过类型擦除来实现泛型特性的。需要注意的是,类型擦除也带来了一些缺点,比如可能会导致类型安全问题或运行时错误。因此,在使用泛型时需要注意类型擦除带来的影响,并进行相应的类型检查和处理。

(3)思考:既然存在类型擦除,那么 Java 是如何保证在 ArrayList<Integer> 中添加字符串会报错?
答:Java 编译器是通过先检查代码中泛型的类型,然后进行类型擦除,最后再进行编译的方式来保证的。

8.4.介绍一下常用的通配符。

在Java中,通配符是一种用于泛型类型的特殊类型参数。通配符通过限制范围来增加泛型的灵活性。Java中常用的通配符有问号通配符 ?、上界通配符 extends 和下界通配符 super

(1)问号通配符 ?:问号通配符表示任意类型。它用于表示你不关心实际的类型参数,或者在某些情况下,你无法确定类型参数。问号通配符可以用于方法的参数、返回类型、变量声明等位置。示例:

public static void processList(List<?> list) {
    // 可以接受任意类型的列表
}

List<?> wildcardList = new ArrayList<>();
// 可以存储任意类型的列表引用

问号通配符表示我们对类型参数没有具体要求,可以接受任何类型的参数,但在方法内部不能对通配符表示的类型进行添加操作。

(2)上界通配符 extends:上界通配符用于限制泛型类型参数的上界。它表示某个特定类型或其子类型。示例:

public static void processList(List<? extends Number> list) {
    // 可以接受 Number 类型或其子类型的列表
}

List<? extends Number> numberList = new ArrayList<>();
// 可以存储 Number 类型或其子类型的列表引用

上界通配符表示我们要求类型参数是特定类型或特定类型的子类型。我们可以通过上界通配符接受特定类型或其子类型的参数,并在方法内部对参数进行读取操作。

(3)下界通配符 super:下界通配符用于限制泛型类型参数的下界。它表示某个特定类型或其父类型。示例:

public static void processList(List<? super Integer> list) {
    // 可以接受 Integer 类型或其父类型的列表
}

List<? super Integer> integerList = new ArrayList<>();
// 可以存储 Integer 类型或其父类型的列表引用

下界通配符表示我们要求类型参数是特定类型或特定类型的父类型。我们可以通过下界通配符接受特定类型或其父类型的参数,并在方法内部对参数进行写入操作。

需要注意的是,通配符类型参数主要用于提高泛型的灵活性和通用性。在使用时,需要根据具体的需求和场景来选择合适的通配符类型。

8.5.泛型的桥方法是指什么?

(1)泛型的桥方法 (Bridge Method) 是由 Java 编译器自动生成的一种方法,用于在泛型类或泛型接口继承、实现或覆盖的情况下,保持编译后的字节码与源代码的兼容性。在 Java 的泛型机制中,由于类型擦除 (Type Erasure) 的存在,泛型类型参数在编译后会被擦除,从而导致一些类型安全性问题。为了解决这个问题,编译器会自动生成桥方法来确保在类型擦除后仍然能够正确使用泛型相关的方法

(2)桥方法的生成条件如下:

  • 当一个类或接口含有泛型类型参数,而且其子类或实现类被擦除了泛型类型参数,并且继承、实现或覆盖了父类或接口的方法时,编译器会自动生成桥方法。
  • 桥方法会在父类或接口的原始方法与子类或实现类的方法签名不一致时生成。

(3)桥方法的特点如下:

  • 桥方法的名称与原始方法名称相同。
  • 桥方法使用桥接类型 (bridge type) 来代替泛型类型参数。
  • 桥方法的返回类型与原始方法相同。
  • 桥方法的参数类型与原始方法相同,在参数位置上使用桥接类型。

(4)通过桥方法,编译器可以在类型擦除后正确地重写和调用泛型相关的方法,从而保持编译后的字节码与源代码的兼容性。以下是一个示例:

public class MyClass<T> {
    public void method(T t) {
        // ...
    }
}

// 擦除泛型后的代码
public class MyClass {
    public void method(Object obj) {
        // ...
    }
}

在这个例子中,编译器会为泛型类 MyClass 生成一个桥方法,使得在原始类中调用泛型方法时能够正确地使用。要注意的是,桥方法是由编译器在编译阶段自动生成的,不需要手动编写。它们对于开发者来说是透明的,但在字节码层面能够保证泛型的正确性和兼容性。

8.6.项目中哪里用到了泛型?

(1)自定义接口通用返回结果 CommonResult 通过参数 T 可根据具体的返回类型动态指定结果的数据类型。
(2)定义 Excel 处理类 ExcelUtil 用于动态指定 Excel 导出的数据类型。
(3)构建集合工具类(参考 Collections 中的 sort, binarySearch 方法)。