跳至主要內容

基础 面试题


基础概念

Java语言有什么特点?

Java是一门面向对象(封装,继承,多态)的编程语言

Java具有平台无关性和移植性

  • Java有一句口号:Write once, run anywhere,一次编写、到处运行。这也是Java的魅力所在。而实现这种特性的正是Java虚拟机JVM。已编译的Java程序可以在任何带有JVM的平台上运行。你可以在windows平台编写代码,然后拿到linux上运行。只要你在编写完代码后,将代码编译成.class文件,再把class文件打成Java包,这个jar包就可以在不同的平台上运行了。
  • 但是实际上,跨平台已经不是 Java 最大的卖点了,各种 JDK 新特性也不是。目前市面上虚拟化技术已经非常成熟,比如通过 Docker 就很容易实现跨平台了,因此事实上,Java 强大的生态才是!

Java具有稳健性

  • Java是一个强类型语言,它允许扩展编译时检查潜在类型不匹配问题的功能。Java要求显式的方法声明,它不支持C风格的隐式声明。这些严格的要求保证编译程序能捕捉调用错误,这就导致更可靠的程序。
  • 异常处理是Java中使得程序更稳健的另一个特征。异常是某种类似于错误的异常条件出现的信号。使用try/catch/finally语句,程序员可以找到出错的处理代码,这就简化了出错处理和恢复的任务。

Java是如何实现跨平台的?

Java是通过JVM(Java虚拟机)实现跨平台的。

JVM可以理解成一个软件,不同的平台有不同的版本。Java代码在编译后会生成.class 文件(字节码文件)。Java虚拟机就是负责将字节码文件翻译成特定平台下的机器码,通过JVM翻译成机器码之后才能运行。不同平台下编译生成的字节码是一样的,但是由JVM翻译成的机器码却不一样。

只要在不同平台上安装对应的JVM,就可以运行字节码文件,也就是运行Java程序。

因此,运行Java程序必须有JVM的支持,因为编译的结果不是机器码,必须要经过JVM的翻译才能执行。

Java 与 C++ 的区别

  • Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态
  • Java 是纯粹的面向对象语言,所有的对象都继承自 java.lang.Object,C++ 兼容 C ,不但支持面向对象也支持面向过程
  • Java 通过虚拟机从而实现跨平台特性, C++ 依赖于特定的平台。
  • Java 不提供指针来直接访问内存,程序内存更加安全,而 C++ 具有和 C 一样的指针。
  • Java 支持自动垃圾回收,而 C++ 需要手动回收。
  • Java 不支持多重继承,只能通过实现多个接口来达到相同目的,而 C++ 支持多重继承。但Java接口可以多继承。
  • C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。
  • ......

面向对象和面向过程的区别?

面向对象和面向过程是一种软件开发思想。

  • 面向过程是一种以过程为中心的编程思想,它是一种基础的顺序的思维方式,面向对象方法的基础实现中也包含面向过程思想。

  • 面向对象是按人们认识客观世界的系统思维方式,采用基于对象(实体)的概念建立模型,模拟客观世界分析、设计、实现软件的办法。通过面向对象的理念使计算机软件系统能与现实世界中的系统一一对应。

以“把大象装进冰箱” 为例,面向过程的设计思路就是首先分析问题的步骤:

  1. 把冰箱门打开(得到打开门的冰箱)
  2. 把大象装进去(打开门后,得到里面装着大象的冰箱)
  3. 把冰箱门关上(打开门、装好大象后,获得关好门的冰箱)

而面向对象的设计则是从另外的思路来解决问题。

为了把大象装进冰箱,需要做三个动作(或者叫行为),每个动作有一个执行者,它就是类对象。首先制作一个冰箱类,冰箱里有自己的行为,可以开门、关门;制作一个大象类,类里面用变量定义大象的描述(大象的头、身体、四条腿、尾巴),大象有自己的行为,可以 钻到冰箱里去。步骤如下:

  1. 冰箱,把门打开。
  2. 冰箱,把大象装进去(或者说:大象,钻到冰箱里去)。
  3. 冰箱,把门关上。

面向对象有哪些特性?

面向对象四大特性:封装,继承,多态,抽象

  1. 封装就是将类的信息隐藏在类内部,不允许外部程序直接访问,而是通过该类的方法实现对隐藏信息的操作和访问。 良好的封装能够减少耦合,以private关键字来表现。

  2. 继承是从已有的类中派生出新的类,新的类继承父类的属性和行为,并能扩展新的能力,大大增加程序的重用性和易维护性。在Java中是单继承的,也就是说一个子类只有一个父类。

  3. 多态是同一个行为具有多个不同表现形式的能力。在不修改程序代码的情况下改变程序运行时绑定的代码。实现多态的三要素:继承、重写、父类引用指向子类对象。多态分为两种:

    1. 静态多态性:通过重载实现,相同的方法有不同的參数列表,可以根据参数的不同,做出不同的处理。
    2. 动态多态性:在子类中重写父类的方法。运行期间判断所引用对象的实际类型,根据其实际类型调用相应的方法。
  4. 抽象:把客观事物用代码抽象出来。

软件设计原则?

  • 单一职责原则:设计创建的对象,必须职责明确,比如商品类,里面相关的属性和方法都必须跟商品相关,不能出现订单等不相关的内容。这里的类可以是模块、类库、程序集,而不单单指类。
  • 里式代换原则:子类能够完全替代父类,反之则不行。通常用于实现接口时运用。因为子类能够完全替代基(父)类,那么这样父类就拥有很多子类,在后续的程序扩展中就很容易进行扩展,程序完全不需要进行修改即可进行扩展。比如IA的实现为A,因为项目需求变更,现在需要新的实现,直接在容器注入处更换接口即可。
  • 迪米特法则,也叫最小原则,或者说最小耦合。通常在设计程序或开发程序的时候,尽量要高内聚,低耦合。当两个类进行交互的时候,会产生依赖。而迪米特法则就是建议这种依赖越少越好。就像构造函数注入父类对象时一样,当需要依赖某个对象时,并不在意其内部是怎么实现的,而是在容器中注入相应的实现,既符合里式替换原则,又起到了解耦的作用。
  • 开闭原则:开放扩展,封闭修改。当项目需求发生变更时,要尽可能的不去对原有的代码进行修改,而在原有的基础上进行扩展。
  • 依赖倒置原则:高层模块不应该直接依赖于底层模块的具体实现,而应该依赖于底层的抽象。接口和抽象类不应该依赖于实现类,而实现类依赖接口或抽象类。
  • 接口隔离原则:一个对象和另外一个对象交互的过程中,依赖的内容最小。也就是说在接口设计的时候,在遵循对象单一职责的情况下,尽量减少接口的内容。
  • 合成复用原则:尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。

简洁版

  • 单一职责:对象设计要求独立,不能设计万能对象。
  • 开闭原则:对象修改最小化。
  • 里式替换:程序扩展中抽象被具体可以替换(接口、父类、可以被实现类对象、子类替换对象)
  • 迪米特:高内聚,低耦合。尽量不要依赖细节。
  • 依赖倒置:面向抽象编程。也就是参数传递,或者返回值,可以使用父类类型或者接口类型。从广义上讲:基于接口编程,提前设计好接口框架。
  • 接口隔离:接口设计大小要适中。过大导致污染,过小,导致调用麻烦。
  • 合成复用:少用继承,减少耦合

JDK/JRE/JVM三者的关系

JVM

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

所有的java程序会首先被编译为.class的类文件,这种类文件可以在虚拟机上执行。也就是说class文件并不直接与机器的操作系统交互,而是经过虚拟机间接与操作系统交互,由虚拟机将程序解释给本地系统执行。

针对不同的系统有不同的 jvm 实现,有 Linux 版本的 jvm 实现,也有Windows 版本的 jvm 实现,只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。但是同一段代码在编译后的字节码是一样的。这就是Java能够跨平台,实现一次编写,多处运行的原因所在。

JRE

英文名称(Java Runtime Environment),就是Java 运行时环境。Java程序必须要在JRE才能运行。它主要包含两个部分,JVM 和 Java 核心类库。

JRE是Java的运行环境,并不是一个开发环境,所以没有包含任何开发工具,如编译器和调试器等。

如果你只是想运行Java程序,而不是开发Java程序的话,那么你只需要安装JRE即可。

JDK

英文名称(Java Development Kit),就是 Java 开发工具包

当安装完JDK之后,目录结构是这样的

可以看到,JDK目录下有个JRE,也就是JDK中已经集成了 JRE,不用单独安装JRE。

另外,JDK中还有一些好用的工具,如jinfo,jps,jstack等。

最后,总结一下JDK/JRE/JVM,他们三者的关系

JRE = JVM + Java 核心类库

JDK = JRE + Java工具 + 编译器 + 调试器

不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。

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

先看看什么是编译型语言和解释型语言。

  • 编译型语言

在程序运行之前,通过编译器将源程序编译成机器码可运行的二进制,以后执行这个程序时,就不用再进行编译了。

优点:编译器一般会有预编译的过程对代码进行优化。因为编译只做一次,运行时不需要编译,所以编译型语言的程序执行效率高,可以脱离语言环境独立运行。

缺点:编译之后如果需要修改就需要整个模块重新编译。编译的时候根据对应的运行环境生成机器码,不同的操作系统之间移植就会有问题,需要根据运行的操作系统环境编译不同的可执行文件。

总结:执行速度快、效率高;依靠编译器、跨平台性差些。

代表语言:C、C++、Pascal、Object-C以及Swift。

  • 解释型语言

定义:解释型语言的源代码不是直接翻译成机器码,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。在运行的时候才将源程序翻译成机器码,翻译一句,然后执行一句,直至结束。

优点:

  1. 有良好的平台兼容性,在任何环境中都可以运行,前提是安装了解释器(如虚拟机)。
  2. 灵活,修改代码的时候直接修改就可以,可以快速部署,不用停机维护。

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

总结:解释型语言执行速度慢、效率低;依靠解释器、跨平台性好。

代表语言:JavaScript、Python、Erlang、PHP、Perl、Ruby。

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

这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先会先通过javac编译成字节码,再通过jvm将字节码转换成机器码执行,即解释运行 和编译运行配合使用,所以可以称为混合型或者半编译型。

Java是值传递和还是引用传递?

  • 值传递是对基本型变量而言的,传递的是该变量的一个副本,改变副本不影响原变量。
  • 引用传递一般是对于对象型变量而言的,传递的是该对象地址的一个副本,并不是原对象本身,两者指向同一片内存空间。所以对引用对象进行操作会同时改变原对象。

java中不存在引用传递,只有值传递。即不存在变量a指向变量b,变量b指向对象的这种情况。

谈谈你对内部类的理解

内部类是Java中一种特殊的类,它定义在其他类或方法中,并且可以访问外部类的成员,包括私有成员。

内部类分为如下几种:

  • 成员内部类:定义在一个类的内部,并且不是静态的。成员内部类可以访问外部类的所有成员,包括私有成员。在创建内部类对象时,需要先创建外部类对象,然后通过外部类对象来创建内部类对象。
  • 静态内部类:定义在一个类的内部,并且是静态的。与成员内部类不同,静态内部类不能访问外部类的非静态成员,但可以访问外部类的静态成员。在创建静态内部类对象时,不需要先创建外部类对象,可以直接通过类名来创建。
  • 局部内部类:定义在一个方法或作用域块中的类,它的作用域被限定在方法或作用域块中。局部内部类可以访问外部方法或作用域块中的 final 变量和参数。
  • 匿名内部类:没有定义名称的内部类,通常用于创建实现某个接口或继承某个类的对象。匿名内部类会在定义时立即创建对象,因此通常用于简单的情况,而不用于复杂的类结构。

内部类的主要作用是实现更加灵活和封装的设计。需要注意的是,过度使用内部类会增加代码的复杂性,降低可读性和可维护性。因此,在使用内部类时要考虑其是否真正有必要,并且仔细进行设计和命名。

基础语法

注释有哪几种形式?

Java 中的注释有三种:

  1. 单行注释:通常用于解释方法内某单行代码的作用。

  2. 多行注释:通常用于解释一段代码的作用。

  3. 文档注释:通常用于生成 Java 开发文档。

用的比较多的还是单行注释和文档注释,多行注释在实际开发中使用的相对较少。

标识符和关键字的区别是什么?

在编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符 。简单来说, 标识符就是一个名字

有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这些特殊的标识符就是 关键字 。简单来说,关键字是被赋予特殊含义的标识符

Java有哪些关键字?

分类关键字
访问控制privateprotectedpublic
类,方法和变量修饰符abstractclassextendsfinalimplementsinterfacenative
newstaticstrictfpsynchronizedtransientvolatileenum
程序控制breakcontinuereturndowhileifelse
forinstanceofswitchcasedefaultassert
错误处理trycatchthrowthrowsfinally
包相关importpackage
基本类型booleanbytechardoublefloatintlong
short
变量引用superthisvoid
保留字gotoconst

注意:所有的关键字都是小写的,在 IDE 中会以特殊颜色显示。

default 这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符。

  • 在程序控制中,当在 switch 中匹配不到任何情况时,可以使用 default 来编写默认匹配的情况。
  • 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。

注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。

官方文档:https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html

continue、break 和 return 的区别是什么?

在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后 ,提前终止循环,这就需要用到下面几个关键词:

  1. continue:指跳出当前的这一次循环,继续下一次循环。
  2. break:指跳出整个循环体,继续执行循环下面的语句。

return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:

  1. return;:直接使用 return 结束方法执行,用于没有返回值函数的方法
  2. return value;:return 一个特定值,用于有返回值函数的方法

思考一下:下列语句的运行结果是什么?

    public static void main(String[] args) {
        boolean flag = false;
        for (int i = 0; i <= 3; i++) {
            if (i == 0) {
                System.out.println("0");
            } else if (i == 1) {
                System.out.println("1");
                continue;
            } else if (i == 2) {
                System.out.println("2");
                flag = true;
            } else if (i == 3) {
                System.out.println("3");
                break;
            } else if (i == 4) {
                System.out.println("4");
            }
            System.out.println("xixi");
        }
        if (flag) {
            System.out.println("haha");
            return;
        }
        System.out.println("heihei");
    }

运行结果:

0
xixi
1
2
xixi
3
haha

接口与抽象类区别?

抽象类和接口是Java中的两种机制,用于实现类之间的继承和多态性。

1、语法层面上的区别

  • 定义和设计:抽象类是使用abstract关键字定义的类,可以包含抽象方法和非抽象方法,可以有实例变量和构造方法;接口通过interface关键字定义,只能包含抽象方法、默认方法和静态方法,不包含实例变量或构造方法。
  • 继承关系:一个类只能继承自一个抽象类,但可以实现多个接口。继承抽象类体现的是"is-a"关系,而实现接口体现的是"can-do"关系。
  • 构造方法:抽象类可以有构造方法,子类可以通过super()调用父类的构造方法;接口没有构造方法。
  • 默认实现:抽象类可以包含非抽象方法,子类可以直接使用;接口可以包含默认方法,提供通用实现,子类可以选择重写或者使用默认实现。
  • 设计目的:抽象类的设计目的是提供类的继承机制,实现代码复用,适用于拥有相似行为和属性的类;接口的设计目的是定义一组规范或契约,实现类遵循特定的行为和功能,适用于不同类之间的解耦和多态性实现。

2、设计层面上的区别

  • 抽象层次不同。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口只是对类行为进行抽象。继承抽象类是一种"是不是"的关系,而接口实现则是 "有没有"的关系。如果一个类继承了某个抽象类,则子类必定是抽象类的种类,而接口实现则是具备不具备的关系,比如鸟是否能飞。
  • 继承抽象类的是具有相似特点的类,而实现接口的却可以不同的类。

门和警报的例子:

class AlarmDoor extends Door implements Alarm {
    //code
}

class BMWCar extends Car implements Alarm {
    //code
}

总之,抽象类和接口是实现继承和多态性的两种机制。抽象类和接口的设计目的、定义和使用方法等方面都有所区别,需要根据实际情况选择合适的方式进行设计和使用。

Java支持多继承吗?

java中,类不支持多继承。接口才支持多继承。接口的作用是拓展对象功能。当一个子接口继承了多个父接口时,说明子接口拓展了多个功能。当一个类实现该接口时,就拓展了多个的功能。

Java不支持多继承的原因:

  • 出于安全性的考虑,如果子类继承的多个父类里面有相同的方法或者属性,子类将不知道具体要继承哪个。
  • Java提供了接口和内部类以达到实现多继承功能,弥补单继承的缺陷。

说说类实例化的顺序

  1. 父类中的static 代码块,当前类的static 代码块
  2. 父类的普通代码块
  3. 父类的构造函数
  4. 当前类普通代码块
  5. 当前类的构造函数
public class LifeCycle {
    // 静态属性
    private static String staticField = getStaticField();

    // 静态代码块
    static {
        System.out.println(staticField);
        System.out.println("静态代码块初始化");
    }

    // 普通属性
    private String field = getField();

    // 普通代码块
    {
        System.out.println(field);
        System.out.println("普通代码块初始化");
    }

    // 构造方法
    public LifeCycle() {
        System.out.println("构造方法初始化");
    }

    // 静态方法
    public static String getStaticField() {
        String statiFiled = "静态属性初始化";
        return statiFiled;
    }

    // 普通方法
    public String getField() {
        String filed = "普通属性初始化";
        return filed;
    }

    public static void main(String[] argc) {
        new LifeCycle();
    }

    /**
     *      静态属性初始化
     *      静态代码块初始化
     *      普通属性初始化
     *      普通代码块初始化
     *      构造方法初始化
     */
}

了解为什么日志打印不能用System.out.printf吗?

最重要的一点是性能问题,可以看到带有传参的println方法当中,里面都是通过同步synchronized来修饰,这说明System.out.println其实是线程安全的。在多线程情况下,当大量方法执行同一个println打印时,其synchronized同步性能效率都可能出现严重性能问题。因此,在实际生产上,普遍是用log.info()类似方式来打印日志而不会用到System.out.println。

public void println(String x) {
     synchronized (this) {
		 print(x);
		 newLine();
	 }
}

同时,标准输出通常会写入控制台或者重定向到文件(如 catalina.out),这涉及 I/O 操作,速度较慢。在高负载下频繁使用 System.out 进行日志打印,同样会对系统性能产生明显影响。

构造器能被重写吗?

构造器在Java中是一种特殊的方法,用于创建和初始化对象。与其他普通方法不同,构造器的名称必须与类名一致,并且没有返回类型。

在Java中,构造器不能被直接重写。子类无法定义与父类相同名称和参数的构造器。这是因为构造器是用于创建对象并初始化其状态的特殊方法,它与类的实例化密切相关。如果允许子类重写构造器,那么可能会导致对象的创建和初始化过程出现混乱,破坏了类的结构和设计原则。

然而,子类可以通过调用父类的构造器来完成对继承的父类的初始化操作。在子类的构造器中可以使用关键字super来调用父类的构造器,并传递相应的参数。这样可以确保父类的构造器得到正确地执行,从而完成对父类属性的初始化。

总结起来,构造器本身不能被重写,但子类可以通过调用父类的构造器来实现对父类的初始化操作。

基本数据类型

Java的基本数据类型有哪些?

Java 中有 8 种基本数据类型,分别为:

  • 6 种数字类型:
    • 4 种整数型:byteshortintlong
    • 2 种浮点型:floatdouble
  • 1 种字符类型:char
  • 1 种布尔型:boolean

为什么不能用浮点型表示金额?

浮点数运算精度丢失代码演示:

float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

为什么会出现这个问题?

这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。

就比如说十进制下的 0.2 就没办法精确转换成二进制小数:

// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

关于浮点数的更多内容,建议看一下计算机系统基础(四)浮点数这篇文章。

如何解决浮点数运算的精度丢失问题?

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */

关于 BigDecimal 的详细介绍,具体可以看这篇文章

超过 long 整型的数据应该如何表示?

基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。

在 Java 中,64 位 long 整型是最大的整数类型。

long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true

BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。

相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。

了解Java的包装类型吗?为什么需要包装类?

Java 是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将 int 、double 等类型放进去的。因为集合的容器要求元素是 Object 类型。

为了让基本类型也具有对象的特征,就出现了包装类型。相当于将基本类型包装起来,使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

简单类型booleanbytecharshortIntlongfloatdouble
二进制位数18161632643264
包装类BooleanByteCharacterShortIntegerLongFloatDouble

在Java规范中,没有明确指出boolean的大小。在《Java虚拟机规范》给出了单个boolean占4个字节,和boolean数组1个字节的定义,具体 还要看虚拟机实现是否按照规范来,因此boolean占用1个字节或者4个字节都是有可能的。

  • 用途:除了定义一些常量和局部变量之外,在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
  • 存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,几乎所有对象实例都存在于堆中。
  • 占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
  • 默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null
  • 比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。

为什么说是几乎所有对象实例都存在于堆中呢? 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存

注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆中。具体可以看这篇文章

包装类型的缓存机制了解么?

下面看一道常见的面试题:

Integer a = 100;
Integer b = 100;
System.out.println(a == b);

Integer c = 200;
Integer d = 200;
System.out.println(c == d);

输出:

true
false

为什么第二个输出是false?看看 Integer 类的源码就知道啦。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

Integer c = 200; 会调用Integer.valueOf(200)。而从Integer的valueOf()源码可以看到,这里的实现并不是简单的new Integer,而是用IntegerCache做一个cache。

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;
    }
    ...
}

这是IntegerCache静态代码块中的一段,默认Integer cache 的下限是-128,上限默认127。当赋值100给Integer时,刚好在这个范围内,所以从cache中取对应的Integer并返回,所以a和b返回的是同一个对象,所以==比较是相等的,当赋值200给Integer时,不在cache 的范围内,所以会new Integer并返回,当然==比较的结果是不相等的。

注意:所有整型包装类对象之间值的比较,全部使用 equals 方法比较

自动装箱与拆箱了解吗?原理是什么?

装箱:将基础类型转化为包装类型。

拆箱:将包装类型转化为基础类型。

当基础类型与它们的包装类有如下几种情况时,编译器会自动进行装箱或拆箱:

  • 赋值操作(装箱或拆箱)
  • 进行加减乘除混合运算 (拆箱)
  • 进行>,<,>=,<=,==比较运算(拆箱)
  • 调用equals进行比较(装箱)
  • ArrayList、HashMap等集合类添加基础类型数据时(装箱)

注意:使用==运算符时,需要一边是基本数据类型才会自动拆箱,如果两边都是引用数据类型,是不会自动拆箱的。

示例代码:

Integer i = 10; // 装箱;等价于 Integer i = Integer.valueOf(10)
int n = i; // 拆箱;等价于 int n = i.intValue()

上面这两行代码对应的字节码为:

   L1
    LINENUMBER 8 L1
    ALOAD 0
    BIPUSH 10
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;
   L2
    LINENUMBER 9 L2
    ALOAD 0
    ALOAD 0
    GETFIELD AutoBoxTest.i : Ljava/lang/Integer;
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    PUTFIELD AutoBoxTest.n : I
    RETURN

从字节码中可以看出,装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。

int和Integer的区别

  • 数据类型:int是Java的基本数据类型,而Integer是int的包装类,属于引用类型。
  • 可空性:int是基本数据类型,它不能为null。而Integer是一个对象,可以为null。
  • 自动装箱与拆箱:int可以直接赋值给Integer,这个过程称为自动装箱;而Integer也可以直接赋值给int,这个过程称为自动拆箱。
  • 性能和内存开销:由于int是基本数据类型,它的值直接存储在栈内存中,占用的空间较小且访问速度快。而Integer是对象,它的值存储在堆内存中,占用的空间相对较大,并且访问速度较慢。因此,频繁使用的整数推荐使用int,不需要使用对象特性时可以避免使用Integer。

总的来说,int是基本数据类型,适用于简单的整数运算和存储,没有对象的特性和可空性。而Integer是int的包装类,可以作为对象使用,具有更多的方法和一些方便的功能,如转换、比较等,但相对会带来一些性能和内存开销。

三目运算符拆箱空指针问题

int var1 = 20; 
Integer var2 = null; 
boolean condition = true; 
// 三目运算符拆箱问题,发生 NullPointerException 
num = condition ? var2 : var1;

这里:condition 为 true,所以三目运算符选择了 var2(即 null)。即: var2Integer 类型)赋值给 numInteger 类型)。理论上在这里应该是 num 被赋值为 null

但在 Java 中,三目运算符的返回类型需要通过类型来推导:

  • 如果 var1 是 int 类型,而 var2 是 Integer 类型,三目运算符会将它们的类型推导合并,令返回值为 int 类型。
  • 这意味着,如果 condition 为 true,则会尝试将 var2null)拆箱成 int。由于 null 不能拆箱成 int,因此会抛出 NullPointerException

为什么在阿里巴巴Java开发手册中强制要求使用包装类型定义属性呢?

以布尔字段为例,当我们没有设置对象的字段的值的时候,Boolean类型的变量会设置默认值为null,而boolean类型的变量会设置默认值为false

也就是说,包装类型的默认值都是null,而基本数据类型的默认值是一个固定值,如boolean是false,byte、short、int、long是0,float是0.0f等。

举一个例子,比如有一个扣费系统,扣费时需要从外部的定价系统中读取一个费率的值,我们预期该接口的返回值中会包含一个浮点型的费率字段。当我们取到这个值得时候就使用公式:金额*费率=费用 进行计算,计算结果进行划扣。

  • 如果由于计费系统异常,他可能会返回个默认值,如果这个字段是Double类型的话,该默认值为null,如果该字段是double类型的话,该默认值为0.0。
  • 如果扣费系统对于该费率返回值没做特殊处理的话,拿到null值进行计算会直接报错,阻断程序。拿到0.0可能就直接进行计算,得出接口为0后进行扣费了。这种异常情况就无法被感知。

**那可以对0.0做特殊判断,如果是0就阻断报错,这样是否可以呢?**不对,这时候就会产生一个问题,如果允许费率是0的场景又怎么处理呢?使用基本数据类型只会让方案越来越复杂,坑越来越多。

这种使用包装类型定义变量的方式,通过异常来阻断程序,进而可以被识别到这种线上问题。如果使用基本数据类型的话,系统可能不会报错,进而认为无异常。因此,建议在POJO和RPC的返回值中使用包装类型。

变量

Java中变量和常量有什么区别

在Java中,变量和常量是两个不同的概念,它们有以下 几点 区别:

  1. 可变性:
  • 变量是可以被修改的,其值可以在程序的执行过程中改变。
  • 常量是不可被修改的,其值在定义后不能再被改变。
  1. 声明与赋值:
  • 变量需要先声明,并可以在声明后进行赋值。声明时需要指定变量的类型
  • 常量在定义时需要使用final关键字进行修饰
  1. 内存空间:
  • 变量在内存中占用一块存储空间,可以改变这个存储空间中的值。
  • 常量通常会被编译器在编译时直接替换为对应的值,所以在内存中不会为常量分配额外的存储空间,而是直接使用常量的值。
  1. 使用场景:
  • 变量用于存储会发生变化的数据,例如计数器、临时结果等,在程序的执行过程中可以根据需要改变其值。
  • 常量用于表示不可变的数据,例如数学常数、配置项等,在程序中通常希望保持其固定的值,避免误操作导致值的变化。

总结来说,变量是可变的并且需要先声明后赋值,而常量是不可变的并且需要在定义时进行初始化赋值。变量占用内存空间且值可以改变,而常量通常会被编译器直接替换为对应的值,不占用额外的内存空间。变量用于存储会发生变化的数据,常量用于表示不可变的数据。

为什么成员变量有默认值?

  1. 先不考虑变量类型,如果没有默认值会怎样?变量存储的是内存地址对应的任意随机值,程序读取该值运行会出现意外。

  2. 默认值有两种设置方式:手动和自动,根据第一点,没有手动赋值一定要自动赋值。成员变量在运行时可借助反射等方法手动赋值,而局部变量不行。

  3. 对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。

静态变量有什么作用?

静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。

静态变量是通过类名来访问的,例如StaticVariableExample.staticVar(如果被 private关键字修饰就无法这样访问了)。

public class StaticVariableExample {
    // 静态变量
    public static int staticVar = 0;
}

通常情况下,静态变量会被 final 关键字修饰成为常量。

public class ConstantVariableExample {
    // 常量
    public static final int constantVar = 0;
}

静态变量可能会存在什么问题?

静态变量尤其要注意并发问题。因为静态变量在Java中是类级别的变量,它们被所有类的实例共享。由于静态变量是共享资源,当多个线程同时访问和修改静态变量时,就会引发并发问题。

静态方法和变量能否被继承?

  • 子类会继承父类的静态方法和静态变量,但是无法对静态方法进行重写
  • 子类中可以直接调用父类的静态方法和静态变量
  • 子类可以直接修改(如果父类中没有将静态变量设为private)静态变量,但这是子类自己的静态变量。
  • 子类可以拥有和父类同名的,同参数的静态方法,但是这并不是对父类静态方法的重写,是子类自己的静态方法,子类只是把父类的静态方法隐藏了。
  • 当父类的引用指向子类时,使用对象调用静态方法或者静态变量,是调用的父类中的静态方法或者变量(这比较好理解,因为静态方法或变量是属于类的,而引用指向的是一个对象,对象中并不会包含静态的方法和属性)。也就是说,失去了多态。
  • 当子类的引用指向子类时,使用对象调用静态方法或者静态变量,就是调用的子类中自己的的静态方法或者变量了。

详细证明过程可以看这篇文章

静态内部类与非静态内部类有什么区别

在Java中,静态内部类和非静态内部类都是一种嵌套在其他类中的内部类。它们之间有以下几点区别:

  • 实例化方式:静态内部类可以直接通过外部类名来实例化,而非静态内部类必须要通过外部类的实例来实例化。
  • 对外部类的引用:静态内部类不持有对外部类实例的引用,而非静态内部类则会持有对外部类实例的引用。这意味着在静态内部类中不能直接访问外部类的非静态成员(方法或字段),而非静态内部类可以。
  • 生命周期:静态内部类的生命周期与外部类相互独立,即使外部类实例被销毁,静态内部类仍然存在。非静态内部类的生命周期与外部类实例绑定,只有在外部类实例存在时才能创建非静态内部类的实例。
  • 访问权限:静态内部类对外部类的访问权限与其他类一样,根据访问修饰符而定。非静态内部类可以访问外部类的所有成员,包括私有成员。

静态内部类、静态代码块、静态变量和静态方法都可以被看作是“静态家族”中的一员,因为它们都与类本身相关,而不是与类的实例相关。这个家族的成员有一些共同的特性:

  • 与类本身相关:静态成员不依赖于类的实例而存在,它们属于类本身。无需创建类的实例即可访问静态成员。
  • 共享性:静态成员在内存中只有一份副本,被所有类的实例共享。这意味着当一个实例修改了静态成员的值,其他实例也会看到这个变化。
  • 生命周期:静态成员的生命周期与类的生命周期相同,它们在类被加载时被初始化,且在类被卸载时被销毁。
  • 直接访问:静态成员可以直接通过类名来访问,无需通过类的实例。这使得静态成员在整个类中都是可见的,并且可以在没有类实例的情况下使用。
    在上面的基础上,静态内部类不过是多了一些类本身的特性罢了。

字符型常量和字符串常量的区别?

  • 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
  • 含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置)。
  • 占内存大小:字符常量只占 2 个字节;字符串常量占若干个字节。

注意 char 在 Java 中占两个字节。

方法

静态方法为什么不能调用非静态成员?

这个需要结合 JVM 的相关知识,主要原因如下:

  1. 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
  2. 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
public class Example {
    // 定义一个字符型常量
    public static final char LETTER_A = 'A';

    // 定义一个字符串常量
    public static final String GREETING_MESSAGE = "Hello, world!";

    public static void main(String[] args) {
        // 输出字符型常量的值
        System.out.println("字符型常量的值为:" + LETTER_A);

        // 输出字符串常量的值
        System.out.println("字符串常量的值为:" + GREETING_MESSAGE);
    }
}

静态方法和实例方法有何不同?

  • 调用方式不同

在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象

不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。

因此,一般建议使用 类名.方法名 的方式来调用静态方法。

public class Person {
    public void method() {
      //......
    }

    public static void staicMethod(){
      //......
    }
    public static void main(String[] args) {
        Person person = new Person();
        // 调用实例方法
        person.method();
        // 调用静态方法
        Person.staicMethod()
    }
}
  • 访问类成员是否存在限制

静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。

重载和重写有什么区别?

  • 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理
  • 重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法

重载

发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

重载一般我们也认为是静态多态性

重写

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  1. 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
  2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
  3. 构造方法无法被重写

只有返回值类型不同是重载吗?

不是

JVM是通过方法签名来决定去调用哪一个方法的,而从方法签名的组成部分来说,返回类型并不在方法签名中,所以当一个类中出现方法名参数列表相同,只有返回类型不同时,JVM无法根据方法签名来确定该调用哪一个方法,所以会报错。

而返回类型不能作为方法签名也是因为程序员在调用函数时只会写方法名和参数,还是无法分辨。

重写的返回值类型可以修改吗?

如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。

构造方法有哪些特点?可以被 override吗?

构造方法特点如下:

  • 名字与类名相同。
  • 没有返回值,但不能用 void 声明构造函数。
  • 生成类的对象时自动执行,无需调用。

构造方法不能被 override(重写),但是可以 overload(重载),所以可以看到一个类中有多个构造函数的情况。

如果一个类没有声明构造方法,程序能正确执行吗?

构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。

如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。

狐疑:如果重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以在创建对象的时候少踩坑。

Object

Object常用方法有哪些?

Java面试经常会出现的一道题目,Object的常用方法。下面给大家整理一下。

/**
 * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
 */
public final native Class<?> getClass()
/**
 * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
 */
public native int hashCode()
/**
 * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
 */
public boolean equals(Object obj)
/**
 * native 方法,用于创建并返回当前对象的一份拷贝。
 */
protected native Object clone() throws CloneNotSupportedException
/**
 * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
 */
public String toString()
/**
 * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
 */
public final native void notify()
/**
 * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
 */
public final native void notifyAll()
/**
 * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
 */
public final native void wait(long timeout) throws InterruptedException
/**
 * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
 */
public final void wait(long timeout, int nanos) throws InterruptedException
/**
 * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
 */
public final void wait() throws InterruptedException
/**
 * 实例被垃圾回收器回收的时候触发的操作
 */
protected void finalize() throws Throwable { }

equals和==有什么区别?

  • 对于基本数据类型,==比较的是他们的值。基本数据类型没有equal方法;

  • 对于引用数据类型,==比较的是它们的存放地址(是否是同一个对象)。equals()默认比较地址值,重写的话按照重写逻辑去比较。

因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。

equals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。

对象的相等和引用相等的区别

  • 对象的相等一般比较的是内存中存放的内容是否相等。
  • 引用相等一般比较的是他们指向的内存地址是否相等。

这里举一个例子:

String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// 使用 == 比较字符串的引用相等
System.out.println(str1 == str2);
System.out.println(str1 == str3);
// 使用 equals 方法比较字符串的相等
System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));

输出结果:

false
true
true
true

从上面的代码输出结果可以看出:

  • str1str2 不相等,而 str1str3 相等。这是因为 == 运算符比较的是字符串的引用是否相等。
  • str1str2str3 三者的内容都相等。这是因为equals 方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要它们的内容相等,就认为它们是相等的。

hashCode() 有什么用?

  1. hashcode的作用主要体现在它的查找效率上,O(1)的复杂度,在Set和Map这种使用哈希表结构存储数据的集合中。hashCode方法的就大大体现了它的价值,主要用于在这些集合中确定对象在整个哈希表中存储的区域。
  2. 如果两个对象相同,则这两个对象的equals方法返回的值一定为true,两个对象的hashCode方法返回的值也一定相同。(equals相同,hashcode一定相同,因为重写的hashcode就是计算属性的hashcode值)
  3. 如果两个对象返回的HashCode的值相同,但不能够说明这两个对象的equals方法返回的值就一定为true,只能说明这两个对象在存储在哈希表中的同一个桶中

2个不相等的对象有可能具有相同hashCode吗

有可能

两个不相等的对象有可能具有相同的哈希码。哈希码是由对象的哈希函数生成的一个整数值,用于支持快速查找和比较对象。

然而,由于哈希码的范围通常比对象的数量小得多,因此不同的对象可能会产生相同的哈希码。这种情况被称为哈希冲突。

两个对象的hashCode()相同,则 equals()是否也一定为 true?

equals与hashcode的关系:

  1. 如果两个对象调用equals比较返回true,那么它们的hashCode值一定要相同;(equals相同,hashcode一定相同,因为重写的hashcode就是计算属性的hashcode值)
  2. 如果两个对象的hashCode相同,它们并不一定相同。

hashcode方法主要是用来提升对象比较的效率,先进行hashcode()的比较,如果不相同,那就不必在进行equals的比较,这样就大大减少了equals比较的次数,当比较对象的数量很大的时候能提升效率。

为什么重写hashCode一定也要重写equals方法?

如果两个对象的hashCode相同,它们是并不一定相同的,因为equals方法不相等而hashCode方法返回的值却有可能相同的,比如两个不同的对象hash到同一个桶中。

hashCode方法实际上是通过一种算法得到一个对象的hash码,这个hash码是用来确定该对象在哈希表中具体的存储区域的。返回的hash码是int类型的所以它的数值范围为 [-2147483648 - +2147483647] 之间的,而超过这个范围,实际会产生溢出,溢出之后的值实际在计算机中存的也是这个范围的。比如最大值 2147483647 + 1 之后并不是在计算机中不存储了,它实际在计算机中存储的是-2147483648。在java中hash码也是通过特定算法得到的,所以很难说在这个范围内情况下不会不产生相同的hash码的。也就是说常说的哈希碰撞,因此不同对象可能有相同的hashCode的返回值

因此equals方法返回结果不相等,而hashCode方法返回的值却有可能相同!

为什么重写 equals 时一定要重写 hashCode?

这个是针对set和map这类使用hash值的对象来说的

  1. 只重写equals方法,不重写hashCode方法:
    • 有这样一个场景有两个Person对象,可是如果没有重写hashCode方法只重写了equals方法,equals方法认为如果两个对象的name相同则认为这两个对象相同。这对于equals判断对象相等是没问题的。
    • 对于set和map这类使用hash值的对象来说,由于没有重写hashCode方法,此时返回的hash值是不同的,因此不会去判断重写的equals方法,此时也就不会认为是相同的对象。
  2. 重写hashCode方法不重写equals方法
    • 不重写equals方法实际是调用Object方法中的equals方法,判断的是两个对象的堆内地址。而hashCode方法认为相等的两个对象在equals方法处并不相等。因此也不会认为是用一个对象
    • 因此重写equals方法时一定也要重写hashCode方法,重写hashCode方法时也应该重写equals方法。

总结:对于普通判断对象是否相等来说,只equals是可以完成需求的,但是如果使用set,map这种需要用到hash值的集合时,不重写hashCode方法,是无法满足需求的。尽管如此,也一般建议两者都要重写,几乎没有见过只重写一个的情况

讲讲深拷贝和浅拷贝?

关于深拷贝和浅拷贝区别,先给结论:

  • 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
  • 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

浅拷贝

当类中含有引用类型的属性时,新对象和旧对象的引⽤类型属性指向的是同⼀个对象,即为浅拷贝

public class Animal implements Cloneable {
    String category;
    Person person;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Animal a1 = new Animal();
        Person person = new Person("旧对象");
        a1.category = "人类";
        a1.person = person;

        Animal a2 = (Animal) a1.clone();
        a2.category = "新人类";
        a2.person.name = "新对象";

        System.out.println(a1.category + ":" + a1.person.name);//人类:新对象
    }
}

深拷贝

以下例子,在clone函数中不仅调用了super.clone,而且调用Person对象的clone方法(Person也要实现Cloneable接口并重写clone方法),从而实现了深拷贝。可以看到,拷贝对象的值不会受到原对象的影响。

public class Animal implements Cloneable {
    String category;
    Person person;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        Animal animal = null;
        animal = (Animal) super.clone();
        animal.person = (Person) person.clone();
        return animal;
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Animal a1 = new Animal();
        Person person = new Person("旧对象");
        a1.category = "人类";
        a1.person = person;

        Animal a2 = (Animal) a1.clone();
        a2.person.name = "新对象";

        System.out.println(a1.person.name);//旧对象
    }
}

如何实现对象克隆?

  • 实现Cloneable接口,重写 clone() 方法。这种方式是浅拷贝,即如果类中属性有自定义引用类型,只拷贝引用,不拷贝引用指向的对象。如果对象的属性的Class也实现 Cloneable 接口,那么在克隆对象时也会克隆属性,即深拷贝。
  • 结合序列化,深拷贝。
  • 通过org.apache.commons中的工具类BeanUtilsPropertyUtils进行对象复制。

String

String 为什么不可变?

Java中的String是不可变对象

在面向对象及函数编程语言中,不可变对象(英语:Immutable object)是一种对象,在被创造之后,它的状态就不可以被改变。至于状态可以被改变的对象,则被称为可变对象(英语:mutable object)。-- 来自百度百科

接着来看Java8 String类的源码:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];//Java 9已经优化为byte数组了

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    
    ...
}

显然String字符串内部是使用char[]数组来存储。

而这个char[]数组是用 private final来修饰的,private就体现着面向对象的封装特性,并且String没有提供供外部访问的方法,这就意味着这个属性无法被外部访问;final则意味着这个属性无法修改,无法重新指向其他对象。且String 类没有提供/暴露修改这个字符串的方法。

因此,String是不可变对象

为什么String要设计成不可变的?

主要有以下几点原因:

  1. 线程安全。同一个字符串实例可以被多个线程共享,因为字符串不可变,本身就是线程安全的。
  2. 支持hash映射。因为String的hash值经常会使用到,比如作为 Map 的键,不可变的特性也就使得hash值不会变,不需要重新计算。
  3. 字符串常量池优化。String对象创建之后,会缓存到字符串常量池中,下次需要创建同样的对象时,可以直接返回缓存的引用。

既然我们的String是不可变的,它内部还有很多substring, replace, replaceAll这些操作的方法。这些方法好像会改变String对象?其实并不是改变了String对象,实际上每次调用replace等方法,其实会在堆内存中创建了一个新的不可变String对象。

String一定不可变吗?

事实上,可以通过反射来改变String中的值

String str = "abcdef";
System.out.println("修改前的地址值:" + str + ",hash值"+ str.hashCode());
Class<? extends String> aClass = str.getClass();
Field value = aClass.getDeclaredField("value");
value.setAccessible(true);
value.set(str,"seven".getBytes());
System.out.println("修改后的地址值:" + str + ",hash值"+ str.hashCode());

显然修改后的还是同一个地址和hash值

修改前的地址值:abcdef,hash值-1424385949
修改后的地址值:seven,hash值-1424385949

查看源码可以看到,计算hashcode后hash值是由一个常量缓存下来的,所以通过反射修改后hashCode并不会变,除非进行重新计算。

注意:用反射修改String的值破坏了String的immutable特征,可能会带来各种问题,以上只是提供一个思路,不建议这么做。

不可变类都建议参考String类一样,写个变量缓存hashcode,从而防止高并发下的计算

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

String类能被继承吗?

在Java中,String类是被final关键字修饰的,即不可继承。final关键字表示一个类不允许被其他类继承,也就是说,String类不能被任何其他类继承。

如果String类能被继承,子类有可能修改原字符串的值,这将破坏字符串对象的不可变性。此外,String类的方法和变量都被设计成private、final和static的,这说明它们不能被重写或隐藏。如果String类可以被继承,这些设计决策将被打破,可能产生更多的问题。

因此,尽管我们不能从String类派生出新的子类,但我们可以使用String类提供的方法来操作和处理字符串。例如,我们可以使用String类的concat()方法连接两个字符串,或使用indexOf()方法查找子串在字符串中的位置等。String类已经包含了大量的方法,可以满足大多数字符串操作的需求。

为何JDK9要将String的底层实现由char[]改成byte[]?

主要是为了节约String占用的内存

在大部分Java程序的堆内存中,String占用的空间最大,并且绝大多数String只有Latin-1字符,这些Latin-1字符只需要1个字节就够了。

而在JDK9之前,JVM因为String使用char数组存储,每个char占2个字节,所以即使字符串只需要1字节,它也要按照2字节进行分配,浪费了一半的内存空间。

到了JDK9之后,对于每个字符串,会先判断它是不是只有Latin-1字符,如果是,就按照1字节的规格进行分配内存,如果不是,就按照2字节的规格进行分配,这样便提高了内存使用率,同时GC次数也会减少,提升效率。

不过Latin-1编码集支持的字符有限,比如不支持中文字符,因此对于中文字符串,用的是UTF16编码(两个字节),所以用byte[]和char[]实现没什么区别。

String, StringBuffer 和 StringBuilder区别

1. 可变性

  • String 不可变
  • StringBuffer 和 StringBuilder 可变

2. 线程安全

  • String 不可变,因此是线程安全的
  • StringBuilder 不是线程安全的
  • StringBuffer 是线程安全的,内部使用 synchronized 进行同步

String字符串使用"+"拼接的本质是什么?

JDK 8下的字符串拼接实现

class Demo {
    public static String concatIndy(int i) {
        return  "value " + i;
    }
}

javap输出的字节码:

class Demo {
  Demo();
    Code:
       0: aload_0
       1: invokespecial #1 // Method java/lang/Object."<init>":()V
       4: return

  public static java.lang.String concatIndy(int);
    Code:
       0: new           #2 // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
       7: ldc           #4 // String value
       9: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      12: iload_0
      13: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      16: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      19: areturn
}

反编译后的Java代码

public static String concatIndy(int i) {
    return new StringBuilder("value ")
            .append(i)
            .toString();
}

可以看出,+ 号操作符其实就是一种语法糖,让字符串的拼接变得更简便了。可以看到编译器自动将 + 转换成了 StringBuilder.append() 方法,拼接之后再调用 StringBuilder.toString() 方法转换成字符串。

字符串拼接用“+” 还是 StringBuilder?

在非循环体内:在JDK 8中,在非循环体内(少数拼接)使用"+"实现字符串拼接和使用StringBuilder是一样的,用"+"做拼接代码更简洁,推荐使用"+"

拼接次数较多,如循环体内拼接

  • String之所以慢是因为,大部分cpu资源都被浪费在分配资源,新建StringBuilder对象,拷贝资源的部分了,相比StringBuilder有更多的内存消耗。

  • StringBuilder快就快在,相比String,他在运算的时候分配内存次数小,所以拷贝次数和内存占用也随之减少,当有大量字符串拼接时,StringBuilder创建char[]的次数会少很多。

  • 由于GC的机制,即使原来的char[]没有引用了,那么也得等到GC触发的时候才能回收,String运算过多的时候就会产生大量垃圾,消耗内存。

因此:

  • 如果目标字符串需要大量拼接的操作,那么这个时候应当使用StringBuilder。

  • 反之,如果目标字符串操作次数极少,或者是常量,那么就直接使用String。

详细原理可以看这篇文章

对于三者使用的总结:

  • 操作少量的数据: 适用 String
  • 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  • 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

StringJoiner是什么?

StringJoiner是 Java 8 新增的一个 API,它基于 StringBuilder 实现,用于实现对字符串之间通过分隔符拼接的场景。

StringJoiner 有两个构造方法,第一个构造要求依次传入分隔符、前缀和后缀。第二个构造则只要求传入分隔符即可(前缀和后缀默认为空字符串)。

StringJoiner(CharSequence delimiter, CharSequence prefix, CharSequence suffix)
StringJoiner(CharSequence delimiter)

有些字符串拼接场景,使用 StringBuffer 或 StringBuilder 则显得比较繁琐。

比如下面的例子:

List<Integer> values = Arrays.asList(1, 3, 5);
StringBuilder sb = new StringBuilder("(");

for (int i = 0; i < values.size(); i++) {
	sb.append(values.get(i));
	if (i != values.size() -1) {
		sb.append(",");
	}
}

sb.append(")");

而通过StringJoiner来实现拼接List的各个元素,代码看起来更加简洁。

List<Integer> values = Arrays.asList(1, 3, 5);
StringJoiner sj = new StringJoiner(",", "(", ")");

for (Integer value : values) {
	sj.add(value.toString());
}

另外,像平时经常使用的Collectors.joining(","),底层就是通过StringJoiner实现的。

源码如下:

public static Collector<CharSequence, ?, String> joining(
    CharSequence delimiter,CharSequence prefix,CharSequence suffix) {
    return new CollectorImpl<>(
            () -> new StringJoiner(delimiter, prefix, suffix),
            StringJoiner::add, StringJoiner::merge,
            StringJoiner::toString, CH_NOID);
}

String 类的常用方法有哪些?

  • indexOf():返回指定字符的索引。
  • charAt():返回指定索引处的字符。
  • replace():字符串替换。
  • trim():去除字符串两端空白。
  • split():分割字符串,返回一个分割后的字符串数组。
  • getBytes():返回字符串的 byte 类型数组。
  • length():返回字符串长度。
  • toLowerCase():将字符串转成小写字母。
  • toUpperCase():将字符串转成大写字符。
  • substring():截取字符串。
  • equals():字符串比较。

String#equals() 和 Object#equals() 有什么区别?

String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Objectequals 方法是比较的对象的内存地址。

new String("seven")会创建几个对象?

会创建 1 或 2 个字符串对象。

  • 如果字符串常量池中不存在字符串对象“seven”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。
  • 如果字符串常量池中已存在字符串对象“seven”的引用,则只会在堆中创建 1 个字符串对象“seven”。
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");

String#intern 方法有什么作用?

String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:

  • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
  • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

String最大长度是多少?

String类提供了一个length方法,返回值为int类型,而int的取值上限为2^31 -1。

所以理论上String的最大长度为2^31 -1。

但是能达到这个长度的字符串值吗

String内部是使用一个char数组来维护字符序列的,一个char占用两个字节。如果说String最大长度是2^31 -1的话,那么最大的字符串占用内存空间约等于4GB。也就是说,需要有大于4GB的JVM运行内存才行。

并且javac编译器做了限制,需要length < 65535。所以字符串常量在常量池中的最大长度是65535 - 1 = 65534。

String在不同的状态下,具有不同的长度限制。

  • 字符串常量长度不能超过65534
  • 堆内字符串的长度不超过2^31-1
  • JVM内存限制

具体可以看这篇文章

泛型

泛型的详细内容可以查看:泛型详解

什么是泛型?有什么作用?

Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。

泛型最⼤的好处是可以提⾼代码的复⽤性。以List接口为例,我们可以将String、 Integer等类型放⼊List中, 如不⽤泛型, 存放String类型要写⼀个List接口, 存放Integer要写另外⼀个List接口, 泛型可以很好的解决这个问题。

  • 在编译的时候检查类型安全:使用泛型可以在编译时期进行类型检查,从而避免在运行时期发生类型错误。泛型可以在编译时期捕获错误,从而提高了程序的稳定性和可靠性。
  • 避免了源代码中的许多强制类型转换,增加可读性:使用泛型可以使代码更加可读、清晰。通过泛型,可以更好地表达代码的意图,避免了使用Object等不具有明确含义的类型。
  • 提高了代码的重用性;可以在不改变代码的情况下创建多个不同类型的对象。例如,使用List可以创建不同类型的列表,而不需要为每种类型编写不同的代码

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

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

  1. 泛型类:
//此处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);
  1. 泛型接口:
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";
    }
}
  1. 泛型方法:
   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>

了解泛型擦除吗?

泛型的代码只存在于编译阶段,在进入JVM之前,与泛型相关的信息会被擦除掉,称之为类型擦除。

  • 无限制类型擦除:当在类的定义时没有进行任何限制,那么在类型擦除后将会被替换成Object,例如<T><?> 都会被替换成Object。
  • 有限制类型擦除:当类定义中的参数类型存在上下限(上下界),那么在类型擦除后就会被替换成类型参数所定义的上界或者下界,例如<? extend Person>会被替换成Person,而<? super Person> 则会被替换成Object。

知道类型擦除会造成多态的冲突吗?如何解决?

JVM解决方法就是桥接方法

详情看这篇文章泛型的桥接方法

项目中哪里用到了泛型?

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

注解

详细内容可以查看:注解详解

什么是注解?

Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。

注解本质是一个继承了Annotation 的特殊接口:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {

}

public interface Override extends Annotation{

}

JDK 提供了很多内置的注解(比如 @Override@Deprecated),同时,我们还可以自定义注解。

注解的解析方法有哪几种?

注解只有被解析之后才会生效,常见的解析方法有两种:

  • 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
  • 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value@Component)都是通过反射来进行处理的。

注解能被继承吗?

不支持继承:不能使用关键字extends来继承某个@interface,但注解在编译后,编译器会自动继承java.lang.annotation.Annotation接口。

虽然反编译后发现注解继承了Annotation接口,但即使Java的接口可以实现多继承,但定义注解时依然无法使用extends关键字继承@interface。

区别于注解的继承,被注解的子类继承父类注解可以用@Inherited: 如果某个类使用了被@Inherited修饰的Annotation,则其子类将自动具有该注解。

谈谈自定义注解的场景及实现

自定义注解是Java语言的一个强大特性,可以为代码添加元数据信息,提供额外配置或标记。它适用于多种场景。

  • 配置和扩展框架:通过自定义注解,可以为框架提供配置参数或进行扩展。例如,Spring框架中的@Autowired注解用于自动装配依赖项,@RequestMapping注解用于映射请求到控制器方法。
  • 运行时检查:自定义注解可在运行时对代码进行检查,并进行相应处理。例如,JUnit框架的@Test注解标记测试方法,在运行测试时会自动识别并执行这些方法。
  • 规范约束:自定义注解用于规范代码风格和约束。例如,Java代码规范检查工具Checkstyle可使用自定义注解标记违规行为。

实现自定义注解的步骤如下:

  1. 使用@interface关键字定义注解。
  2. 可在注解中定义属性,并指定默认值。
  3. 根据需求,可添加元注解来控制注解的使用方式。
  4. 在代码中使用自定义注解。
  5. 使用反射机制解析注解信息。

通过合理运用自定义注解,可提高代码的可读性、可维护性和可扩展性。

异常

详细内容可以查看:异常详解

Java 异常类层次结构图概览

Throwable 类常用方法有哪些?

  • String getMessage(): 返回异常发生时的简要描述
  • String toString(): 返回异常发生时的详细信息
  • String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
  • void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息

常见的Exception有哪些?

常见的RuntimeException:

  1. ClassCastException //类型转换异常
  2. IndexOutOfBoundsException //数组越界异常
  3. NullPointerException //空指针
  4. ArrayStoreException //数组存储异常
  5. NumberFormatException //数字格式化异常
  6. ArithmeticException //数学运算异常

checked Exception:

  1. NoSuchFieldException //反射异常,没有对应的字段
  2. ClassNotFoundException //类没有找到异常
  3. IllegalAccessException //安全权限异常,可能是反射时调用了private方法

Error和Exception的区别?

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:

  • Error:JVM 无法解决的严重问题,如栈溢出StackOverflowError、内存溢出OOM等。程序无法处理的错误。

  • Exception:其它因编程错误或偶然的外在因素导致的一般性问题。可以在代码中进行处理。如:空指针异常、数组下标越界等。

运行时异常和非运行时异常(checked)的区别?

  1. RuntimeException由程序错误导致,应该修正程序避免这类异常发生。Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
  2. checked Exception由具体的环境(读取的文件不存在或文件为空或sql异常)导致的异常。必须进行处理,不然编译不通过,可以catch或者throws。常见的受检查异常有:IO 相关的异常、ClassNotFoundExceptionSQLException...。

Java中的异常处理机制是怎样的

异常是在程序执行过程中可能出现的错误或意外情况。它们通常表示了程序无法正常处理的情况,如除零错误、空指针引用、文件不存在等。

Java中的异常处理机制通过使用try-catch-finally语句块来捕获和处理异常。具体的处理过程如下:

  1. 使用try块包裹可能会抛出异常的代码块。一旦在try块中发生了异常,程序的控制流会立即跳转到与之对应的catch块。
  2. 在catch块中,可以指定捕获特定类型的异常,并提供相应的处理逻辑。如果发生了指定类型的异常,程序会跳转到相应的catch块进行处理。一个try块可以有多个catch块,分别处理不同类型的异常。
  3. 如果某个catch块成功处理了异常,程序将继续执行catch块之后的代码。
  4. 在catch块中,可以通过throw语句重新抛出异常,将异常交给上一级的调用者处理。
  5. 可以使用finally块来定义无论是否发生异常都需要执行的代码。finally块中的代码始终会被执行,无论异常是否被捕获。

通过合理使用异常处理机制,可以使程序更具健壮性和容错性。在处理异常时,应根据具体情况选择是恢复正常执行、报告错误给用户,还是终止程序运行。同时,应避免过度捕获异常和不处理异常导致的问题,以及使用异常替代正常程序流程控制的做法。

Java中的finally一定会被执行吗?

答案是不一定。

有以下两种情况finally不会被执行:

  • 程序未执行到try代码块
  • 如果当一个线程在执行 try 语句块或者 catch 语句块时被打断(interrupted)或者被终止(killed),与其相对应的 finally 语句块可能不会执行。还有更极端的情况,就是在线程运行 try 语句块或者 catch 语句块时,突然死机或者断电,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 catch finally这个语法糖背后的实现原理

throw和throws的区别?

  • throw:用于抛出一个具体的异常对象。

  • throws:用在方法签名中,用于声明该方法可能抛出的异常。子类方法抛出的异常范围更加小,或者根本不抛异常。

final关键字的作用?

  • final 修饰的类不能被继承。
  • final 修饰的方法不能被重写。
  • final 修饰的变量叫常量,常量必须初始化,初始化之后值就不能被修改。

final, finally, finalize 的区别

  • final 用于修饰属性、方法和类, 分别表示属性不能被重新赋值,方法不可被覆盖,类不可被继承。
  • finally 是异常处理语句结构的一部分,一般以try-catch-finally出现,finally代码块表示总是被执行。
  • finalize 是Object类的一个方法,该方法一般由垃圾回收器来调用,当我们调用System.gc()方法的时候,由垃圾回收器调用finalize()方法,回收垃圾,JVM并不保证此方法总被调用。

try catch 应该在 for 循环里面还是外面?

  • try catch 在 for 循环 外面 的时候, 如果 for循环过程中出现了异常, 那么for循环会终止。
  • try catch 在 for 循环 里面 的时候, 如果 for循环过程中出现了异常,异常被catch抓掉,不影响for循环 继续执行。

因此,这是要看业务的。需要出现异常就终止循环的,就放外头;不需要终止循环,就放循环里。

反射

详细内容可以查看:反射详解

什么是反射?

动态获取的信息以及动态调用对象的方法的功能称为Java语言的反射机制。

在运行状态中,对于任意一个类,能够知道这个类的所有属性和方法。对于任意一个对象,能够调用它的任意一个方法和属性。

反射有哪些应用场景呢?

  1. 框架设计:在框架设计中,通常需要使用反射技术来解耦,使框架可扩展和灵活。
  2. 单元测试:在单元测试中,我们可以使用反射技术来访问私有或受保护的类成员,使测试更加全面。
  3. 动态代理:使用反射技术可以创建动态代理对象,从而可以在运行时期代理任意一个实现了接口的对象,实现AOP等功能。
  4. 序列化和反序列化:许多Java序列化和反序列化工具都是基于Java反射机制实现的,例如Java的ObjectInputStream和ObjectOutputStream。

Java反射技术可以在很多场景中应用,尤其是在框架设计和组件化开发中,反射技术可以提高代码的灵活性和可扩展性,减少代码耦合性,简化代码的编写。但是,反射机制也增加了程序的复杂度,因此必须谨慎使用。

如何获取?

//1. 通过类名.class获取 
class s = SubjectService.class//2.通过Class.forName名获取
class s = Class.forName(SubjectService)//3.通过对象.getclass获取
class s = data.getClass()//4.通过类加载器获取
ClassLoader classLoader = Practice0.class.getClassLoader();
Class s = classLoader.loadClass("全类名");

比较四种获取方式的区别?

  1. 类名.class:JVM将使用类装载器,将类装入内存(前提是:类还没有装入内存),不做类的初始化工作,返回Class的对象。
  2. Class.forName(“类名字符串”):Class.forName是一个静态方法,通过提供类的完全限定名,在运行时加载类。此方法还会执行类的静态初始化块。如果类名不存在或无法访问,将抛出ClassNotFoundException异常。
  3. 实例对象.getClass():对类进行静态初始化、非静态初始化;返回引用运行时真正所指的对象(子对象的引用会赋给父对象的引用变量中)所属的类的Class的对象。
  4. ClassLoader:这是一个抽象类,用于加载类的工具。每个Java类都有关联的ClassLoader对象,负责将类文件加载到Java虚拟机中。ClassLoader可以动态加载类,从不同来源加载类文件,如本地文件系统、网络等。ClassLoade提供了更灵活的类加载机制,可以自定义类加载过程,从不同来源加载类文件。

反射的优缺点?

优点:

  1. 提高了程序的灵活性:可以在运行时获取和操作类的信息,使程序具有更好的灵活性和扩展性。
  2. 减少了代码的重复性:可以动态地获取和操作类的信息,减少了代码的重复性。
  3. 提高了程序的可维护性:可以使程序的结构更加清晰明了,提高了程序的可维护性。

缺点:

  1. 性能较低:Java反射机制是通过运行时动态获取和操作类的信息,性能较低。
  2. 安全性问题:Java反射机制可以访问和操作类的所有信息,存在安全性问题。
  3. 可读性和维护性: 反射代码通常比直接调用代码更加复杂和难以理解,降低了代码的可读性和可维护性。

因此,在选择是否使用反射时,需要根据具体情况权衡利弊。如果确实需要在运行时动态创建对象,并且对性能影响可以接受,那么可以考虑使用反射。但对于一些已知的类和对象,最好还是直接使用 new 关键字来创建对象,这样能够获得更好的性能和类型安全性。

序列化和反序列化

什么是Java的序列化

  • 序列化:把对象转换为字节序列的过程称为对象的序列化.
  • 反序列化:把字节序列恢复为对象的过程称为对象的反序列化.

序列化的主要目的是实现对象的持久化存储和传输,让对象可以在不同的计算机或不同的时间点被重建和使用。通过序列化,可以将对象的状态以字节的形式保存下来,并且在需要的时候进行恢复,从而实现了对象的跨平台传输和持久化存储。

在Java中,要使一个类可序列化,需要满足以下条件:

  1. 实现java.io.Serializable接口,该接口是一个标记接口,没有任何方法。
  2. 所有的非静态、非瞬态的字段都可以被序列化。

使用Java的序列化机制,可以通过ObjectOutputStream将对象转换为字节流并写入文件或网络流中。反之,通过ObjectInputStream可以从字节流中读取数据并还原为对象。

需要注意的是,在进行序列化和反序列化时,对象的类和字段的定义必须保持一致,否则可能会导致序列化版本不匹配或字段丢失的问题。

什么时候需要用到序列化和反序列化?

当只在本地 JVM 里运行下 Java 实例,这个时候是不需要什么序列化和反序列化的,但当我们需要将内存中的对象持久化到磁盘,数据库中时,当我们需要与浏览器进行交互时,当我们需要实现 RPC 时,这个时候就需要序列化和反序列化了.

前两个需要用到序列化和反序列化的场景,是不是让我们有一个很大的疑问? 我们在与浏览器交互时,还有将内存中的对象持久化到数据库中时,好像都没有去进行序列化和反序列化,因为我们都没有实现 Serializable 接口,但一直正常运行.

下面先给出结论:

只要我们对内存中的对象进行持久化或网络传输,这个时候都需要序列化和反序列化.

理由:服务器与浏览器交互时真的没有用到 Serializable 接口吗? JSON 格式实际上就是将一个对象转化为字符串,所以服务器与浏览器交互时的数据格式其实是字符串,我们来看来 String 类型的源码:

public final class String
    implements java.io.SerializableComparable<String>CharSequence {
    /\*\* The value is used for character storage. \*/
    private final char value\[\];

    /\*\* Cache the hash code for the string \*/
    private int hash; // Default to 0

    /\*\* use serialVersionUID from JDK 1.0.2 for interoperability \*/
    private static final long serialVersionUID = -6849794470754667710L;

    ......
}

String 类型实现了 Serializable 接口,并显示指定 serialVersionUID 的值.

再来看对象持久化到数据库中时的情况,Mybatis 数据库映射文件里的 insert 代码:

<insert id="insertUser" parameterType="org.tyshawn.bean.User">
    INSERT INTO t\_user(name,age) VALUES (#{name},#{age})
</insert>

实际上我们并不是将整个对象持久化到数据库中,而是将对象中的属性持久化到数据库中,而这些属性(如Date/String)都实现了 Serializable 接口。

实现序列化和反序列化为什么要实现 Serializable 接口?

在 Java 中实现了 Serializable 接口后, JVM 在类加载的时候就会发现我们实现了这个接口,然后在初始化实例对象的时候就会在底层帮我们实现序列化和反序列化。

如果被写对象类型不是String、数组、Enum,并且没有实现Serializable接口,那么在进行序列化的时候,将抛出NotSerializableException。源码如下:

// remaining cases
if (obj instanceof String) {
    writeString((String) obj, unshared);
} else if (cl.isArray()) {
    writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
    writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
    writeOrdinaryObject(obj, desc, unshared);
} else {
    if (extendedDebugInfo) {
        throw new NotSerializableException(
            cl.getName() + "\n" + debugInfoStack.toString());
    } else {
        throw new NotSerializableException(cl.getName());
    }
}

实现 Serializable 接口之后,为什么还要显示指定 serialVersionUID 的值?

如果不显示指定 serialVersionUID,JVM 在序列化时会根据属性自动生成一个 serialVersionUID,然后与属性一起序列化,再进行持久化或网络传输. 在反序列化时,JVM 会再根据属性自动生成一个新版 serialVersionUID,然后将这个新版 serialVersionUID 与序列化时生成的旧版 serialVersionUID 进行比较,如果相同则反序列化成功,否则报错.

如果显示指定了 serialVersionUID,JVM 在序列化和反序列化时仍然都会生成一个 serialVersionUID,但值为我们显示指定的值,这样在反序列化时新旧版本的 serialVersionUID 就一致了.

如果我们的类写完后不再修改,那么不指定serialVersionUID,不会有问题,但这在实际开发中是不可能的,我们的类会不断迭代,一旦类被修改了,那旧对象反序列化就会报错。 所以在实际开发中,我们都会显示指定一个 serialVersionUID。

static 属性为什么不会被序列化?

因为序列化是针对对象而言的,而 static 属性优先于对象存在,随着类的加载而加载,所以不会被序列化.

看到这个结论,是不是有人会问,serialVersionUID 也被 static 修饰,为什么 serialVersionUID 会被序列化? 其实 serialVersionUID 属性并没有被序列化,JVM 在序列化对象时会自动生成一个 serialVersionUID,然后将我们显示指定的 serialVersionUID 属性值赋给自动生成的 serialVersionUID.

serialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?

static 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。但是,serialVersionUID 的序列化做了特殊处理,在序列化时,会将 serialVersionUID 序列化到二进制字节流中;在反序列化时,也会解析它并做一致性判断。

官方说明如下:

A serializable class can declare its own serialVersionUID explicitly by declaring a field named "serialVersionUID" that must be static, final, and of type long;

如果想显式指定 serialVersionUID ,则需要在类中使用 staticfinal 关键字来修饰一个 long 类型的变量,变量名字必须为 "serialVersionUID"

也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。

如果有些字段不想进行序列化怎么办?transient关键字的作用?

Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。

也就是说被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。

为什么不推荐使用 JDK 自带的序列化?

我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:

  • 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
  • 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
  • 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读:应用安全:JAVA 反序列化漏洞之殇

Java创建对象有几种方式?

Java创建对象有以下几种方式:

  • 用new语句创建对象。
  • 使用反射,使用Class.newInstance()创建对象。
  • 调用对象的clone()方法。
  • 运用反序列化手段,调用java.io.ObjectInputStream对象的readObject()方法。

SPI

关于 SPI 的详细解读,请看这篇文章 Java SPI 机制详解

什么是SPI?

SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。

很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。

SPI 和 API 有什么区别?

说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:

SPI是一种跟API相对应的反向设计思想:API由实现方确定标准规范和功能,调用方无权做任何干预; 而SPI是由调用方确定标准规范,也就是接口,然后调用方依赖此接口,第三方实现此接口,这样做就可以方便的进行扩展,类似于插件机制,这是SPI出现的需求背景。

SPI : “接口”位于“调用方”所在的“包”中

  • 概念上更依赖调用方。
  • 组织上位于调用方所在的包中。
  • 实现位于独立的包中。
  • 常见的例子是:插件模式的插件。

API : “接口”位于“实现方”所在的“包”中

  • 概念上更接近实现方。
  • 组织上位于实现方所在的包中。
  • 实现和接口在一个包中。

JDK SPI 的缺陷?

通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:

  • 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。

  • 多个并发多线程使用 ServiceLoader 类的实例是不安全的