Java八股文——Java基础「面向对象篇」

​参考小林coding和Java Guide

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

面向对象(Object-Oriented)和面向过程(Procedural-Oriented)是两种不同的编程范式,它们在设计思想、代码结构和问题解决方式上有显著的区别。

1. 面向对象(OOP):

面向对象是一种通过对象来组织代码的编程方式。它把问题分解成一组对象,每个对象都是数据(属性)和行为(方法)的封装体。面向对象的核心概念包括类、对象、继承、多态、封装和抽象。

核心特征:

  • 类和对象:类是对象的蓝图,而对象是类的实例。通过定义类,程序员可以创建具有特定属性和行为的对象。
  • 封装:通过将数据和方法封装在对象中,减少了外部对数据的直接访问,提高了安全性。
  • 继承:一个类可以继承另一个类的属性和方法,增强代码的复用性。
  • 多态:同一个方法或属性可以作用于不同类型的对象,通过动态绑定实现多态性,使得代码更加灵活。
  • 抽象:隐藏复杂的实现细节,暴露必要的接口。

优点:

  • 模块化:通过对象和类的组织方式,能够更好地划分和管理代码,增强了可维护性。
  • 代码复用性:继承和多态使得代码可以更方便地复用。
  • 灵活性:代码易于扩展,可以通过增加新类或方法来实现新功能,而不必修改现有代码。

2. 面向过程(POP):

面向过程是一种通过**“过程”“函数”**来组织代码的编程方式。在面向过程编程中,程序是由一系列按顺序执行的函数组成的。程序的核心是处理数据的步骤,而不是数据本身。

核心特征:

  • 函数/过程:编程的核心是函数,通过定义函数来描述程序执行的步骤。
  • 数据和操作分离:数据通常在程序中是单独存储的,函数通过操作这些数据来实现功能。
  • 线性执行:程序按照步骤顺序执行,通常没有封装的概念,数据的处理是开放的。

优点:

  • 简洁:程序结构较为简单,适合小型、任务单一的应用。
  • 高效:由于操作较为直接,执行速度相对较快,适用于对性能要求较高的场景。

3. 主要区别:

特性 面向对象 面向过程
核心 对象(类、实例) 函数(过程)
数据与行为 数据和行为封装在对象中 数据和行为分离,行为是函数
代码结构 代码通过类和对象组织 代码通过函数和过程组织
复用性 支持继承、接口和多态等机制,代码复用性高 代码复用性较低,通常需要通过复制粘贴来实现
扩展性 易于扩展和维护,适合复杂系统 扩展性差,修改代码可能会影响整个程序
适用场景 适用于大规模、复杂的系统 适用于小型、功能简单的程序

总结:

  • 面向对象更侧重于数据和行为的封装,通过对象和类组织代码,更适合处理复杂问题和大规模应用。
  • 面向过程则是通过函数步骤来实现功能,适合简单问题和程序。

这两种编程范式各有优缺点,选择使用哪种方式通常取决于项目的复杂性和需求。

怎么理解面向对象?简单说说封装继承多态

面试官您好,对于面向对象编程(OOP),我的理解是它是一种编程范式。它试图将现实世界中的事物抽象成程序中的“对象”,每个对象都有自己的状态(数据)和行为(方法)。通过对象之间的协作来完成复杂的任务。这样做的好处是能让我们的代码更模块化、更容易理解、维护和扩展。

核心思想可以概括为“万物皆对象”,我们通过描述对象的属性和行为来定义一个“类”(Class),然后通过类来创建具体的“实例”(Instance/Object)。

关于您提到的封装、继承和多态,这是面向对象的三个基本特性:

  • 封装 (Encapsulation)
    • 简单来说:就是把数据(属性)和操作这些数据的方法(行为)捆绑到一个单元(也就是对象)里,并且对对象的内部细节进行隐藏,只暴露一些必要的接口给外部访问。
    • 打个比方:就像我们用电视遥控器。我们只需要按“音量+”按钮(这就是暴露的接口),电视音量就会增加,我们不需要知道遥控器内部的电路是怎么工作的(内部细节被隐藏了)。这样做的好处是,遥控器内部的电路升级了,只要“音量+”按钮的功能不变,我们用户的使用方式就完全不受影响。
    • 在Java中:主要通过访问修饰符(如 private, protected, public)来实现。通常我们会把类的属性设为 private,然后提供 public 的 getter 和 setter 方法来控制对属性的访问和修改,这样可以保护数据不被随意篡改,也方便在设值或取值时加入一些逻辑控制。
  • 继承 (Inheritance)
    • 简单来说:就是允许一个类(子类/派生类)获取另一个类(父类/基类)的属性和方法。这是一种“is-a”(是一个)的关系。
    • 打个比方:比如“狗”类可以继承自“动物”类。“动物”类有“吃”、“睡”这些行为,那么“狗”类就自动拥有了这些行为,不需要再重复定义。同时,“狗”类还可以有自己特有的行为,比如“看门”。
    • 在Java中:通过 extends 关键字来实现。继承的好处是代码复用,并且可以形成清晰的类层次结构,方便管理和扩展。子类可以重写(Override)父类的方法来实现自己的特定行为。
  • 多态 (Polymorphism)
    • 简单来说:就是“多种形态”的意思。同一个行为,作用在不同的对象上,会产生不同的具体表现。或者说,允许我们使用父类类型的引用来指向其子类的对象,当调用同一个方法时,会根据引用所指向的实际子类对象的类型来执行相应的方法。
    • 打个比方:还是“动物”和“狗”、“猫”的例子。我们有一个行为叫“叫”(makeSound())。当一个指向“狗”对象的“动物”引用调用 makeSound() 时,狗会“汪汪”叫;当一个指向“猫”对象的“动物”引用调用 makeSound() 时,猫会“喵喵”叫。我们只需要发出“叫”这个指令,具体怎么叫由实际的动物对象来决定。
    • 在Java中:多态主要依赖于方法的重写(Override)和向上转型(父类引用指向子类对象)。它的好处是大大提高了程序的灵活性和可扩展性。我们可以编写更通用的代码,处理一系列不同但相关的对象,而不需要为每种对象都写一套重复的逻辑。

总的来说,封装是基础,它保证了对象的独立性和安全性;继承是手段,它实现了代码的复用和层级关系;而多态是目标,它让程序更加灵活,能够适应变化,更容易扩展。这三者共同构成了面向对象编程的核心支柱。

多态体现在哪几个方面?

在我看来,Java中的多态性是一个核心的面向对象特性,它允许我们以统一的方式处理不同类型的对象,具体体现在以下几个主要方面:

  • 方法重写 (Overriding) - 这是运行时多态的核心体现
    • 当子类继承了父类,并且对父类中已有的某个方法(方法名、参数列表、返回类型都相同或兼容)提供了自己的特定实现时,就发生了方法重写。
    • 在运行时,当我们通过一个父类类型的引用去调用这个被重写的方法时,JVM会根据该引用实际指向的子类对象的类型,来动态地决定执行哪个版本的方法(是父类的还是子类的)。
    • 例如:假设有一个Animal父类,它有一个makeSound()方法。Dog子类和Cat子类都继承了Animal并重写了makeSound()方法。那么:
Animal myDog = new Dog(); // 向上转型
Animal myCat = new Cat(); // 向上转型
myDog.makeSound(); // 运行时调用的是Dog类的makeSound(),输出 "汪汪"
myCat.makeSound(); // 运行时调用的是Cat类的makeSound(),输出 "喵喵"

这里,虽然myDog和myCat都是Animal类型引用,但它们调用的makeSound()方法却表现出了不同的行为,这就是运行时多态。

  • 接口实现 (Interface Implementation) - 同样是运行时多态的重要形式
    • 一个接口可以被多个不同的类实现。这些实现类会根据接口定义的方法签名,提供各自具体的实现逻辑。
    • 我们可以使用接口类型的引用来指向任何一个实现了该接口的类的对象。当通过这个接口引用调用接口中定义的方法时,实际执行的是该引用所指向的具体实现类中的方法。
    • 例如:还是用Animal作为接口,它定义了makeSound()方法。Dog类和Bird类都实现了Animal接口。
Animal myDog = new Dog(); // Dog实现了Animal接口
Animal myBird = new Bird(); // Bird实现了Animal接口
myDog.makeSound(); // 调用Dog的实现
myBird.makeSound(); // 调用Bird的实现

这同样展示了同一接口引用,根据实际对象的不同,调用了不同的方法实现。

  • 方法重载 (Overloading) - 这是编译时多态(或静态多态)的体现
    • 方法重载允许在同一个类中定义多个同名的方法,但它们的参数列表必须不同(参数的类型、数量或顺序至少有一个不同)。
    • 编译器在编译代码的时候,就会根据调用方法时传入的参数的具体类型和数量,来确定到底应该链接到哪个重载方法。因为在编译期就能确定,所以也叫静态多态。
    • 例如:在一个计算器类中,我们可以有多个add方法:
public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
public String add(String s1, String s2) { return s1 + s2; }

当我们调用calculator.add(1, 2)时,编译器知道要调用第一个add方法;调用calculator.add(1.0, 2.0)时,会调用第二个。

  • 向上转型 (Upcasting) 与 向下转型 (Downcasting) - 向上转型是实现运行时多态的前提
    • 向上转型:指的是将一个子类类型的对象赋值给一个父类类型的引用变量,或者将一个实现类的对象赋值给一个接口类型的引用变量。这是自动进行的,也是安全的。正是因为有了向上转型,我们才能通过父类或接口类型的引用来统一处理不同的子类或实现类对象,从而体现运行时多态。
    • 向下转型:指的是将一个父类类型的引用强制转换回其真实的子类类型。这样做通常是为了调用子类特有的方法或访问子类特有的属性。向下转型需要显式进行,并且有风险,如果引用实际指向的对象并非目标子类的实例(或其子类的实例),就会在运行时抛出 ClassCastException。因此,在进行向下转型之前,通常会使用 instanceof 操作符进行检查。

总结来说,多态的核心价值在于提高了代码的灵活性、可扩展性和可维护性。方法重写和接口实现是实现运行时多态的主要手段,而方法重载则提供了编译时的多态性。向上转型是运行时多态得以实现的基础机制。

多态解决了什么问题?

面试官您好,多态这个特性,在我看来,主要解决了软件开发中一个非常核心的问题:如何让我们编写的程序能够灵活地应对变化和扩展,同时保持代码的简洁和可维护性。

具体来说,多态主要解决了以下几个方面的问题:

  • 提高了代码的扩展性(Extensibility)
    • 多态的核心在于“子类可以替换父类”。这意味着当我们系统需要引入新的子类型(新的实现方式)时,只要这个新的子类型遵循了与父类或接口相同的约定(比如实现了相同的方法),那么已有的、依赖于这个父类或接口的代码通常不需要做任何修改,就能自然地与这些新的类型协同工作。
  • 提高了代码的复用性(Reusability)
    • 通过多态,我们可以编写出更通用的代码模块。一个方法或类可以设计为操作一个抽象的父类型或接口,而不是具体的子类型。这样一来,这个模块就能被复用于处理所有符合该抽象定义的子类型对象。
    • 比如,Java集合框架中的List接口,我们可以定义一个方法接收List参数,这个方法既可以处理ArrayList对象,也可以处理LinkedList对象,因为它们都实现了List接口,并提供了相应的方法实现。
  • 简化了复杂的条件逻辑(减少冗长的 if-else 或 switch-case)
    • 这是多态一个非常实用的优点。在没有多态或者不善用多态的情况下,我们可能需要写大量的 if-else if-else 或者 switch-case 语句来根据对象的具体类型执行不同的操作。
    • 而通过多态,我们将这种判断逻辑分散到各个子类的具体方法实现中。调用方只需要统一调用父类或接口中定义的方法,具体执行哪个实现由JVM在运行时根据对象的实际类型来决定。这使得调用方的代码更加简洁,也更容易维护。例如处理不同类型的支付方式,我们可以定义一个Payment接口和pay()方法,然后让AlipayPayment、WeChatPayment等具体支付方式类去实现它。调用方只需要payment.pay()即可,无需关心具体是哪种支付。
  • 促进了程序解耦(Decoupling)
    • 多态鼓励我们“面向接口编程,而不是面向实现编程”(programming to an interface, not an implementation),以及遵循“依赖倒置原则”。这意味着高层模块不应该依赖于低层模块的具体实现,两者都应该依赖于抽象。
    • 通过依赖于抽象(父类或接口),调用方与具体实现类之间解耦。调用方不需要知道具体是哪个子类在工作,只需要知道它们都遵循某个共同的契约。这样,当具体实现发生变化时,只要接口不变,调用方的代码就不会受到影响。
  • 是许多设计原则和设计模式的基础
    • 正如您所说,多态是很多重要设计原则(如里氏替换原则LSP、依赖倒置原则DIP)和设计模式(如策略模式、工厂模式、模板方法模式、状态模式等)得以实现的技术基石。这些原则和模式都是为了帮助我们构建更灵活、可维护、可扩展的软件系统。

总而言之,多态通过允许不同类的对象对同一消息做出响应(即调用相同的方法名),但执行各自特定的行为,极大地增强了软件设计的灵活性和弹性,使得程序更容易适应未来的变化。

面向对象的设计原则你知道有哪些吗

在面向对象设计中,有一些广为人知的设计原则,它们能够帮助我们编写出更健壮、更灵活、更易于维护和扩展的软件系统。我了解到的主要有以下这些,其中最著名的可能是 SOLID 原则:

  • S - 单一职责原则 (Single Responsibility Principle - SRP)
    • 核心思想:一个类或者一个模块应该有且只有一个引起它变化的原因。也就是说,一个类只应该负责一项职责。
    • 我的理解:如果一个类承担了太多的职责,那么当其中一个职责发生变化时,可能会影响到其他职责的实现,导致这个类变得不稳定且难以修改。将职责分离到不同的类中,可以使每个类更内聚、更简单,也更容易被复用和测试。
    • 比如:一个User类不应该既负责用户信息的管理,又负责用户数据的持久化到数据库。应该将数据持久化功能分离到另一个类,如UserRepository。
  • O - 开放/封闭原则 (Open/Closed Principle - OCP)
    • 核心思想:软件实体(类、模块、函数等)应该对于扩展是开放的,但对于修改是封闭的。
    • 我的理解:这意味着当我们需要增加新功能时,应该通过添加新的代码(比如新的子类、新的实现类)来实现,而不是去修改已有的、经过测试的稳定代码。这样可以减少引入bug的风险。通常会通过抽象(接口、抽象类)和多态来实现。
    • 比如:如果我们要增加一种新的支付方式,不应该去修改已有的OrderProcessor类,而是可以定义一个PaymentStrategy接口,然后为每种支付方式提供一个实现类,OrderProcessor依赖这个接口。
  • L - 里氏替换原则 (Liskov Substitution Principle - LSP)
    • 核心思想:所有引用基类(父类)的地方必须能够透明地使用其子类的对象,而不会导致程序出错。也就是说,子类对象应该能够替换掉任何父类对象,并且程序的行为保持不变或符合预期。
    • 我的理解:子类在继承父类时,不应该改变父类已有的行为(特别是方法的约定)。如果子类替换父类后导致了行为异常,那就违反了这个原则。这要求子类在重写方法时,要遵循父类方法的契约。
    • 比如:如果一个Bird类有fly()方法,而我们创建了一个Penguin子类,企鹅不能飞,那么在Penguin的fly()方法中抛出异常或什么都不做,就可能违反LSP,因为调用者期望Bird能飞。
  • I - 接口隔离原则 (Interface Segregation Principle - ISP)
    • 核心思想:客户端不应该被强迫依赖它不使用的方法。一个类对另一个类的依赖应该建立在最小的接口上。
    • 我的理解:这意味着我们应该设计更小、更具体的接口,而不是设计一个庞大臃肿的接口包含所有可能的方法。如果一个接口太“胖”,那么实现它的类可能只需要其中一部分方法,但却不得不实现所有方法(或者空实现),这会造成不必要的耦合和浪费。
    • 比如:不要创建一个巨大的Worker接口包含eat(), work(), manageReport()等方法,而是可以拆分为Eatable, Workable, ReportManageable等小接口,类按需实现。
  • D - 依赖倒置原则 (Dependency Inversion Principle - DIP)
    • 核心思想:高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
    • 我的理解:这意味着我们应该面向接口编程,而不是面向实现编程。通过引入抽象层(接口或抽象类),高层模块和低层模块都依赖这个抽象层,从而解耦它们之间的直接依赖。这样,低层模块的改变不容易影响到高层模块。依赖注入(DI)是实现DIP的一种常见方式。
    • 比如:一个OrderService(高层)不应该直接依赖一个具体的MySQLOrderRepository(低层),而应该依赖一个OrderRepository接口,MySQLOrderRepository是这个接口的一个实现。

除了SOLID原则,还有一些其他也很有影响力的原则,比如:

  • 迪米特法则 (Law of Demeter - LoD) / 最少知识原则 (Principle of Least Knowledge)
    • 一个对象应该对其他对象有尽可能少的了解。也就是说,一个类应该只和它的“直接朋友”交谈(成员变量、方法参数、方法内部创建的对象),不要和“朋友的朋友”说话。这有助于降低类之间的耦合。
  • 组合/聚合复用原则 (Composition/Aggregation Reuse Principle - CARP) / 多用组合少用继承
    • 优先使用对象组合(has-a关系)或聚合(contains-a关系)来达到代码复用的目的,而不是通过继承(is-a关系)。组合通常比继承更灵活,耦合度更低。
  • KISS原则 (Keep It Simple, Stupid)
    • 设计时应坚持简约原则,保持简单,避免不必要的复杂性。
  • YAGNI原则 (You Ain’t Gonna Need It)
    • 你不会需要它。不要添加当前不需要的功能,避免过度设计。
  • DRY原则 (Don’t Repeat Yourself)
    • 不要重复自己。系统中的每一部分知识都应该有一个单一的、明确的、权威的表示。避免代码重复。

这些原则并不是孤立的,它们往往是相辅相成,共同指导我们设计出高质量的面向对象系统。在实际应用中,需要根据具体场景权衡和选择。

重载与重写有什么区别?

面试官您好,重载(Overloading)和重写(Overriding)是Java中两个非常重要但也容易混淆的概念,它们都与方法有关,但目的和规则完全不同。

首先,我们来看方法重载(Overloading)

  • 定义:重载指的是在同一个类中,允许存在一个以上的同名方法,但这些同名方法的参数列表必须不同。这里的参数列表不同,指的是参数的个数、参数的类型或者参数的顺序至少有一个不相同。
  • 目的:重载主要是为了提高代码的可读性和易用性。通过为功能相似但处理不同数据类型或不同数量参数的方法提供相同的名字,使得调用者更容易记忆和使用。
  • 规则
    • 必须在同一个类中。
    • 方法名必须相同。
    • 参数列表必须不同(个数、类型、顺序)。
    • 注意:方法的返回值类型对于构成重载不是决定性因素,也就是说,仅仅返回值类型不同是不能构成重载的。访问修饰符不同也不能构成重载。
  • 多态性:重载是编译时多态(也叫静态多态)。编译器在编译代码的时候,就会根据调用方法时传入的参数类型、数量和顺序来确定具体应该调用哪个重载的方法。
  • 例如
class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    public double add(double a, double b) {
        return a + b;
    }
    public int add(int a, int b, int c) {
        return a + b + c;
    }
}

接下来是方法重写(Overriding),也叫覆盖:

  • 定义:重写指的是子类可以重新定义(提供自己的特定实现)从父类继承过来的某个方法。这个方法在父类中必须是可见的(非private),并且子类重写的方法需要与父类中被重写的方法有相同的名称、参数列表。
  • 目的:重写是实现多态性的关键机制之一。它允许子类根据自身的特性来改变或扩展从父类继承来的行为。
  • 规则(“两同两小一大”原则可以帮助记忆)
    • 方法名必须相同
    • 参数列表必须相同
    • 返回类型:可以与父类方法相同,或者是父类方法返回类型的子类型(这被称为协变返回类型,从Java 5开始支持)。
    • 访问修饰符:重写方法的访问权限不能严于(即不能比父类更严格)父类中被重写方法的访问权限。比如,如果父类方法是protected,子类重写方法可以是protected或public,但不能是private或默认(包访问权限)。
    • 抛出的异常:子类方法声明抛出的异常类型,必须是父类方法声明抛出的异常类型的子集(即子类方法可以不抛出任何受检异常,或者抛出父类方法声明的异常的子类异常),或者不抛出。不能抛出比父类方法更宽泛或新的受检异常。
  • @Override注解:强烈推荐在重写方法时使用@Override注解。这个注解能让编译器帮助我们检查是否真的正确重写了父类的方法(比如方法名或参数列表写错了,编译器会报错),而不是意外地创建了一个新方法。
  • 多态性:重写是运行时多态(也叫动态多态)的核心。当通过父类引用调用一个被重写的方法时,JVM会在运行时根据该引用实际指向的子类对象的类型来动态地决定执行哪个版本的方法(是父类的还是子类的)。
  • 例如
class Animal {
    public void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

class Dog extends Animal {
    @Override // 明确这是重写父类的方法
    public void makeSound() {
        System.out.println("Dog barks");
    }
}

总结一下核心区别

  • 位置:重载发生在同一个类中;重写发生在子类和父类之间。
  • 参数列表:重载要求参数列表必须不同;重写要求参数列表必须相同。
  • 返回值:重载对返回值类型无特殊要求(不作为区分依据);重写时返回值类型可以相同或是父类返回类型的子类型。
  • 访问权限和异常:重载无此限制;重写时访问权限不能更严格,抛出异常不能更宽泛。
  • 多态类型:重载是编译时多态;重写是运行时多态。

简单来说:重载是指在同一个类中定义多个同名但参数列表不同的方法,而重写是指子类重新定义父类中已有的方法以实现自己的特定行为。

抽象类和普通类区别?

面试官您好,抽象类(Abstract Class)和普通类(Concrete Class)是Java中两种不同类型的类,它们在设计目的和使用方式上有着显著的区别。我的理解主要体现在以下几个方面:

  • 实例化 (Instantiation)
    • 普通类:可以被直接实例化(创建对象)。例如,我们可以 new Person() 来创建一个Person类的对象。普通类是完整的、可用的“蓝图”。
    • 抽象类不能被直接实例化。抽象类是一种不完整的、抽象的概念,它可能包含抽象方法(没有具体实现的方法),因此它不能直接用于创建对象。它存在的意义主要是为了被其他类继承,提供一个通用的模板或者规范。
  • 方法实现 (Method Implementation)
    • 普通类:所有方法都必须有具体的实现(即有方法体),不能包含抽象方法。
    • 抽象类
      • 可以包含抽象方法(用abstract关键字修饰,没有方法体,只有方法签名)。抽象方法强制其非抽象子类必须提供实现。
      • 也可以包含普通方法(有具体实现的方法)。这使得抽象类可以在提供通用行为的同时,将一些具体实现推迟到子类。
  • 继承与实现 (Inheritance and Implementation)
    • 普通类
      • 一个普通类只能继承一个普通类(因为Java是单继承)。
      • 但它可以同时实现多个接口(implements 关键字)。
    • 抽象类
      • 一个类只能继承一个抽象类(同样因为Java是单继承)。
      • 但它也可以同时实现多个接口
      • 值得注意的是,如果一个普通类继承了一个抽象类,那么它必须实现抽象类中所有的抽象方法,除非它自己也是一个抽象类。
  • 设计目的与使用限制 (Design Purpose and Usage Restrictions)
    • 普通类:通常用于表示一个具体的实体,可以直接创建对象并执行其所有功能。它们是程序中实际可用的组件。
    • 抽象类
      • 主要用作基类,作为一种模板或者规范,为子类提供共同的属性和行为,并强制子类实现某些特定的抽象方法。
      • 它代表的是一个抽象的概念,不适合实例化,因为它的某些行为是未完成的。
      • 它在多态中发挥重要作用,允许我们通过抽象类型引用来操作不同子类的具体实现。
      • 例如,Shape可以是一个抽象类,它可能有getColor()的普通方法和calculateArea()的抽象方法,具体的Circle或Rectangle类继承Shape后必须实现calculateArea()。

总结表格化对比:

特性 普通类 (Concrete Class) 抽象类 (Abstract Class)
关键字 无特定关键字(无需abstract) 必须使用 abstract 关键字修饰类本身
实例化 可以直接实例化(new对象) 不能直接实例化
抽象方法 不能包含抽象方法 可以包含(也可以不包含)抽象方法
普通方法 必须有具体实现 可以有具体实现,也可以有抽象方法
继承 只能单继承一个类,可实现多个接口 只能单继承一个类,可实现多个接口(子类必须实现所有抽象方法)
设计目的 表示具体实体,直接使用 作为基类,提供模板或规范,强制子类实现
构造方法 可以有构造方法 可以有构造方法(但不能直接调用,供子类调用super())

理解这些区别,有助于我们根据实际需求,合理选择使用普通类还是抽象类来设计和构建Java应用程序。

Java抽象类和接口的区别是什么?

面试官您好,抽象类和接口是Java中两种非常重要的抽象机制,它们在面向对象设计中扮演着不同的角色。理解它们的区别对于合理地设计软件结构至关重要。

首先,我们可以简单总结一下它们各自的特点和主要用途

  • 抽象类 (Abstract Class)
    • 主要用于描述类的共同特性和行为。它代表一种“is-a”(是一个)的关系,适用于有明显继承层次结构的场景。
    • 抽象类可以有成员变量(包括实例变量和静态变量)、构造方法、具体方法(带方法体)以及抽象方法(不带方法体)。
    • 它提供了一种部分实现的能力,可以为子类提供通用的、默认的实现,同时强制子类实现某些特有的、抽象的行为。
  • 接口 (Interface)
    • 主要用于定义行为规范能力。它代表一种“has-a”(拥有一个)或“can-do”(能够做某事)的关系,适用于定义类的功能契约
    • 在Java 8之前,接口只能包含常量和抽象方法。
    • 从Java 8开始,接口引入了默认方法(default method)和静态方法(static method),使得接口在提供行为规范的同时,也能提供一些默认的实现或工具方法。
    • 从Java 9开始,接口甚至可以包含private方法。

基于这些特点,它们之间存在以下几个核心的区别

  • 实现与继承方式
    • 关键字:类实现接口使用 implements 关键字,而继承抽象类使用 extends 关键字。
    • 多重性:一个类可以实现多个接口,这是Java实现“多重继承”(针对行为)的方式。但是,一个类只能继承一个抽象类(因为Java是单继承语言)。
  • 构造器 (Constructor)
    • 抽象类:可以有构造器。尽管抽象类不能直接实例化,但它的构造器会在子类实例化时被调用,用于初始化抽象类中定义的成员变量。
    • 接口:不能有构造器,因为接口不涉及状态的初始化。
  • 方法定义
    • 抽象类:可以包含抽象方法(必须用abstract修饰,没有方法体)和具体方法(有方法体)。
    • 接口
      • 在Java 8之前,接口中的方法默认都是public abstract的,不允许有具体实现。
      • 从Java 8开始,接口可以包含default方法(提供默认实现,子类可以选择重写)和static方法(工具方法,直接通过接口名调用)。
      • 从Java 9开始,还可以包含private方法和private static方法,它们用于辅助default和static方法的实现。
  • 成员变量
    • 抽象类:可以定义各种类型的成员变量,包括实例变量、静态变量,以及final修饰的常量。它们可以被赋初值,也可以在子类中被重新定义或重新赋值(非final变量)。
    • 接口:在接口中定义的变量,默认且隐式地都是 public static final 类型(即常量)。它们必须在定义时就赋初值,并且不能被修改。
  • 访问修饰符
    • 抽象类:其内部的成员(变量、方法、构造器)都可以使用Java中所有的访问修饰符(public, protected, default, private)。但抽象方法不能是private的,因为它需要被子类实现。
    • 接口:在接口中,所有成员方法(包括抽象方法、默认方法、静态方法、私有方法)默认都是 public 的(除了Java 9+的private方法)。所有成员变量(常量)默认都是 public static final。
  • 设计目的
    • 抽象类:更侧重于代码复用模板模式。它提供了一组共同的、部分实现好的功能,并通过抽象方法强制子类完成剩余的特定功能。适用于“是-a”关系,比如Animal抽象类与Dog、Cat子类。
    • 接口:更侧重于定义契约实现多态。它定义了一组类的行为规范,任何实现该接口的类都必须遵循这个契约。适用于“能-a”关系,比如Flyable接口可以被Bird和Airplane实现。

选择使用场景的简要考量

  • 当你在一个类层次结构中,需要共享一些公共代码(包括字段和具体方法),并且这些类之间存在明确的“is-a”关系时,通常会选择抽象类
  • 当你需要定义一种能力或行为契约,而且这种行为可以由不相关或不同的类实现,并且你希望实现多重行为继承时,通常会选择接口

总的来说,抽象类和接口都是实现抽象和多态的工具,但它们在灵活性、功能范围和所代表的关系类型上有所不同,开发者应根据具体的业务场景和设计意图来选择最合适的抽象方式。

抽象类能加final修饰吗?

面试官您好,答案是不能,抽象类是不能用 final 修饰的。

我的理由是基于它们各自在Java设计中的目的:

  • 抽象类(abstract class)的目的是什么?
    抽象类的主要设计意图就是作为基类,用于被其他子类继承。它通常包含抽象方法(没有具体实现的方法),这些抽象方法必须由其非抽象子类来提供具体的实现。所以,继承是抽象类存在的前提和核心目的。
  • final 关键字修饰类的作用是什么?
    当 final 关键字用于修饰一个类时,它意味着这个类不能被继承(即不能有子类)。这通常是为了防止类被修改或扩展,确保其行为的稳定性。
  • 冲突点
    很明显,abstract 和 final 这两个修饰符在修饰类时是互斥的。一个类既被声明为 abstract(意味着它必须被继承才能完整和使用),又被声明为 final(意味着它不能被继承),这在逻辑上是矛盾的。Java编译器会检测到这种冲突,并会抛出编译错误。

因此,abstract 和 final 不能同时修饰一个类。abstract 是为了让类能够被扩展,而 final 是为了阻止类被扩展,它们的意义是完全相反的。

Java 中 final 作用是什么?

final 这个关键字在Java中确实有非常重要的作用,它代表着“最终的”、“不可改变的”含义。根据它修饰的目标不同,其具体作用也会有所区别。主要可以分为以下三个方面:

  • 修饰类 (Final Class)
    • 当 final 用来修饰一个类时,这个类就成为了最终类,它不能被任何其他类继承
    • 这样做的主要目的是为了保证类的行为不会被子类修改或扩展,确保其稳定性和安全性
    • 一个非常典型的例子就是Java标准库中的 String 类。String 被声明为 final,这保证了它的不可变性(immutability),使得字符串在用作哈希表的键、多线程环境下的共享数据等方面非常安全和高效。如果 String 可以被继承,子类就可能改变其 equals() 或 hashCode() 等方法的行为,从而破坏其核心特性。
  • 修饰方法 (Final Method)
    • 当 final 用来修饰一个方法时,这个方法就成为了最终方法,它不能在子类中被重写(override)
    • 这通常用于两种情况:
      • 确保方法的行为不被子类改变:如果一个方法的核心逻辑对于整个类继承体系来说是至关重要的,并且不希望子类去修改它,那么可以将其声明为 final。
      • 性能考虑(早期JVM):在早期的Java版本中,将方法声明为 final 有时被认为可以帮助编译器进行内联等优化,因为编译器知道这个方法不会有其他实现。但现代JVM的JIT编译器已经非常智能,通常不再需要依赖 final 来进行这类优化。
    • 例如,java.lang.Object 类中的 getClass() 方法就是 final 的,因为获取一个对象的运行时类信息这个行为是由JVM底层保证的,不应该被子类修改。
  • 修饰变量 (Final Variable)
    当 final 用来修饰一个变量时,这个变量就成为了一个常量(或者说是一个只能被赋值一次的变量)。具体行为根据变量是基本数据类型还是引用数据类型有所不同:
    • 修饰基本数据类型的变量
      • 一旦这个 final 基本数据类型变量被赋值之后,它的值就不能再被改变。任何试图重新给它赋值的操作都会导致编译错误。
      • 例如:final int MAX_COUNT = 100; 这里的 MAX_COUNT 就是一个编译时常量(如果是在声明时或静态初始化块中初始化的 static final 变量)或运行时常量(如果是在构造函数或实例初始化块中初始化的实例 final 变量)。
    • 修饰引用数据类型的变量
      • 当 final 修饰一个引用变量时,它意味着这个引用变量本身不能再指向其他的对象实例,也就是说,它的引用地址是不可变的。
      • 但是,这并不意味着该引用所指向的对象的内容是不可变的。如果这个对象本身是可变的(比如一个 StringBuilder 对象或者一个普通的自定义对象),我们仍然可以调用该对象的方法来修改其内部的状态或数据。
      • 例如:final StringBuilder sb = new StringBuilder(“Hello”); 这行代码表示 sb 这个引用将永远指向这个初始创建的 StringBuilder 对象,你不能再写 sb = new StringBuilder(“World”);。但是,你完全可以执行 sb.append(" World"); 来修改 sb 所指向的那个 StringBuilder 对象的内容,使其变为 “Hello World”。
    • final 修饰方法参数:当 final 修饰方法参数时,表示在这个方法内部,该参数的值(对于基本类型)或引用(对于引用类型)不能被改变。这有时可以提高代码的可读性,表明这个参数在方法内部是只读的。

总结来说,final 关键字是Java中实现不可变性、确保行为一致性和安全性的重要工具。它通过限制继承、限制方法重写以及限制变量的再次赋值,来帮助我们构建更稳定和可靠的程序。

接口里面可以定义哪些方法?

面试官您好,关于Java接口中可以定义哪些方法,这在Java的不同版本中是有演进的。我会分阶段来阐述:

1. Java 8 以前的版本

在Java 8之前,接口中只有:

  • 抽象方法 (Abstract Methods)
    • 这是接口最核心的功能。它们只有方法签名(方法名、参数列表、返回类型),没有方法体。
    • 默认且隐式地被 public abstract 修饰,所以即使不写这两个关键字,方法也是抽象的和公开的。
    • 所有实现该接口的非抽象类都必须实现这些抽象方法。
2. Java 8 及更高版本

从Java 8开始,接口的功能得到了显著增强,引入了两种新的方法类型:

  • 抽象方法 (Abstract Methods)
    • 与之前相同,仍然是接口的核心。
  • 默认方法 (Default Methods)
    • 使用 default 关键字修饰,并且必须有方法体(提供具体实现)。
    • 目的:为了在不破坏现有实现类的情况下,向接口中添加新的方法。当一个接口添加了默认方法后,所有已有的实现类不需要强制性地实现这个新方法,它们会自动继承默认实现。
    • 当子类重写默认方法时,可以保留默认行为,也可以提供自己的新行为。
    • 示例
default void log(String message) {
    System.out.println("Logging: " + message);
}
  • 静态方法 (Static Methods)
    • 使用 static 关键字修饰,并且必须有方法体
    • 目的:提供与接口相关联的工具方法,这些方法可以直接通过接口名调用,而不需要通过接口的实现类或对象来调用。
    • 示例
static int getMax(int a, int b) {
    return Math.max(a, b);
}
3. Java 9 及更高版本

从Java 9开始,接口的特性再次得到扩展,引入了私有方法:

  • 抽象方法、默认方法、静态方法
    • 这些在Java 8中引入的类型仍然存在,功能不变。
  • 私有方法 (Private Methods)
    • 使用 private 关键字修饰,必须有方法体
    • 目的:主要用于辅助默认方法和静态方法,将它们内部的共同逻辑抽取出来,避免代码重复。私有方法不能被接口外部(包括实现类)访问或调用。
    • 示例
private void commonHelper() {
    // common logic for default/static methods
}
default void doComplexTask() {
commonHelper(); // call private method
// ...
}
  • 私有静态方法 (Private Static Methods)
    • 使用 private static 关键字修饰,必须有方法体
    • 目的:与私有方法类似,但它专门用于辅助接口内部的静态方法,同样不能被接口外部访问。
    • 示例
private static void validateInput(String input) {
if (input == null || input.isEmpty()) {
    throw new IllegalArgumentException("Input cannot be empty");
}
}
static void processData(String data) {
    validateInput(data); // call private static method
    // ...
}
总结表格:
方法类型 Java 8 以前 Java 8+ Java 9+ 描述
抽象方法 无方法体,强制子类实现
默认方法 有方法体,提供默认实现,不强制子类实现
静态方法 有方法体,通过接口名直接调用,不能被子类继承或重写
私有方法 有方法体,接口内部使用,辅助默认方法实现
私有静态方法 有方法体,接口内部使用,辅助静态方法实现

所以,现代Java(Java 9+)的接口功能已经非常强大和灵活,能够满足更多复杂的设计需求。

只有抽象方法没有方法体。

抽象类可以被实例化吗?

抽象类是不能被直接实例化的。

核心原因在于:

  • 抽象类的设计目的:抽象类主要用作基类,为子类提供一个共同的模板或部分实现。它往往包含一些抽象方法,这些方法只有声明没有具体的实现。
  • 不完整性:如果一个类有抽象方法,那么它本身就是不完整的。如果允许实例化这样的类,当调用这些没有实现的抽象方法时,程序将无法确定该如何执行,这在逻辑上是不通的。

因此,要使用抽象类,您必须创建一个继承该抽象类的具体子类,并且这个子类必须实现抽象类中所有未被实现的抽象方法。然后,您可以实例化这个具体的子类。

接口可以包含构造函数吗?

接口(Interface)在Java中是不能包含构造函数的。

我理解这背后的原因主要有以下几点:

  • 接口不能被实例化
    接口的核心设计目的是定义一套行为规范或契约,它描述了一个类应该具备哪些方法(“能做什么”),但它本身并不提供这些方法的具体实现(除了Java 8以后的默认方法和静态方法)。因为接口代表的是一种抽象的行为集合,而不是一个具体的实体,所以我们不能直接使用 new 操作符来创建一个接口的实例
  • 构造函数的用途与调用时机
    构造函数的主要职责是在使用 new 操作符创建类的实例时,对新创建的对象进行初始化工作,比如设置成员变量的初始状态。构造函数是在对象实例化那一刻被自动调用的。
  • 逻辑上的矛盾
    既然接口本身无法被实例化(即我们不能 new 一个接口),那么也就意味着没有任何机会去调用它的构造函数。如果在接口中允许定义构造函数,那么这个构造函数将永远无法被执行,这在逻辑上是没有意义的。因此,Java语言规范规定了接口不能有构造函数。

简单来说,接口更关注“定义能力”,而不是“创建具体对象”。对象的创建和初始化是其具体实现类(Concrete Class)的责任,实现类会有自己的构造函数来完成这些工作。

所以,因为接口不能 new,所以它不需要也不能有构造函数,编译器也会在尝试为接口定义构造函数时给出错误提示。

解释Java中的静态变量和静态方法

面试官您好,Java中的static关键字用于修饰类的成员(变量和方法),它表示这些成员是属于类本身的,而不是属于类的某个特定实例(对象)的。

下面我分别解释一下静态变量和静态方法:

1. 静态变量 (Static Variables / Class Variables)
  • 定义与归属
    静态变量是使用static关键字声明的变量。它们不属于任何单个对象,而是属于整个类。这意味着,无论这个类创建了多少个对象,或者即使没有创建任何对象,静态变量都只有一份拷贝存在于内存中(在方法区或元空间中,具体取决于JVM实现)。
  • 生命周期与初始化
    静态变量在类加载(Class Loading)的时候就被初始化了,并且其生命周期与类的生命周期相同,直到程序结束或者类被卸载。它们通常在声明时直接初始化,或者在静态初始化块(static { … })中初始化。
  • 共享性
    由于静态变量是类级别的,所以它被该类的所有对象所共享。任何一个对象对静态变量的修改,都会影响到其他所有对象访问该静态变量时的值。
  • 访问方式
    • 可以通过类名直接访问,例如:ClassName.staticVariableName。这是推荐的访问方式,因为它清晰地表明了变量的静态属性。
    • 也可以通过类的对象引用来访问,例如:objectReference.staticVariableName。但这种方式不推荐,因为它容易让人误以为这个变量是实例变量。编译器实际上还是会通过对象的类去定位这个静态变量。
  • 用途举例
    • 常量:经常与final一起使用(public static final)来定义类级别的常量,如 Math.PI。
    • 计数器:比如记录一个类创建了多少个对象实例。
    • 共享配置信息:当类的所有实例都需要访问同一份配置数据时。
class Car {
    public static int numberOfCarsCreated = 0; // 静态变量,记录创建的汽车数量
    public String color;

    public Car(String color) {
        this.color = color;
        numberOfCarsCreated++; // 每创建一个对象,静态变量加1
    }
}

// 使用:
Car redCar = new Car("Red");
Car blueCar = new Car("Blue");
System.out.println("Total cars created: " + Car.numberOfCarsCreated); 
// 输出:Total cars created: 2
2. 静态方法 (Static Methods / Class Methods)
  • 定义与归属
    静态方法是使用static关键字声明的方法。它们同样属于整个类,而不是类的某个特定实例。
  • 调用方式
    • 可以通过类名直接调用,例如:ClassName.staticMethodName()。这是推荐的调用方式。
    • 也可以通过对象引用调用,但不推荐。
  • 访问限制
    • 不能直接访问实例变量:因为静态方法在调用时可能没有任何对象实例存在,所以它不能直接访问非静态的实例变量(成员变量)。如果需要访问,必须通过一个明确的对象引用。
    • 不能直接调用实例方法:同理,静态方法也不能直接调用非静态的实例方法。
    • 可以访问静态成员:静态方法可以直接访问类的其他静态变量和调用其他静态方法。
    • 不能使用 this 或 super 关键字:this 指向当前对象实例,super 指向父类对象实例。由于静态方法不依赖于任何特定实例,所以这两个关键字在静态方法中是无效的,使用它们会导致编译错误。
  • 用途举例
    • 工具方法/辅助方法:提供一些不依赖于对象状态的通用功能,例如 Math.max()、Arrays.sort() 等。
    • 工厂方法:用于创建和返回类的实例,例如 Integer.valueOf()。
    • main 方法:Java程序的入口点 public static void main(String[] args) 必须是静态的,因为JVM在启动程序时需要能够不创建对象就调用这个方法。

总结一下
static 关键字使得成员(变量或方法)与类本身关联,而不是与类的实例关联。静态变量是所有对象共享的单一数据副本,而静态方法则提供了不依赖于特定对象状态的功能。理解静态成员的特性对于编写结构良好、高效的Java代码非常重要。

成员变量与局部变量的区别?

面试官您好,Java中成员变量和局部变量区别的总结,涵盖它们在语法形式、存储方式、生存时间和默认值这几个核心方面的差异。

  • 语法形式 (Declaration and Scope)
    • 成员变量 (Member Variables / Fields)
      • 是定义在类的主体内部,但在任何方法、构造器或代码块之外的变量。它们是类的一部分,描述了类的状态或属性。
      • 可以被多种修饰符修饰:
        • 访问控制修饰符:public, protected, default (包私有), private,用于控制其可见性。
        • static:如果被static修饰,则成为类变量(静态成员变量),属于类本身,所有对象共享。如果不被static修饰,则成为实例变量,每个对象都有一份独立的拷贝。
        • final:如果被final修饰,则表示该变量的值一旦被初始化后就不能再改变(对于基本类型是值不变,对于引用类型是引用地址不变)。
        • transient:指示该变量不参与对象的序列化过程。
        • volatile:确保多线程环境下的可见性和禁止指令重排序。
    • 局部变量 (Local Variables)
      • 是定义在方法内部、构造器内部、或者代码块(如if块、for循环块)内部的变量。
      • 也包括方法的参数
      • 它们的作用域仅限于其被定义的那个方法、构造器或代码块。
      • 不能被访问控制修饰符(public, private等)修饰。
      • 不能被 static 修饰(因为它们不属于类,而是与特定的方法调用或代码块执行相关联)。
      • 可以被 final 修饰,表示该局部变量在初始化后其值(或引用)不能再改变。
  • 存储方式 (Memory Allocation)
    • 成员变量
      • 实例变量(非static):作为对象的一部分,存储在 **堆内存 (Heap)**中。每个对象实例都有自己的一份实例变量。
      • 类变量(static):不属于任何特定对象,而是属于类本身。它们存储在方法区 (Method Area) 或称为元空间 (Metaspace) (在Java 8及以后版本中,具体位置取决于JVM实现和版本,但概念上是与堆分开的、类级别的数据存储区)。所有该类的对象共享同一份类变量。
    • 局部变量
      • 存储在**栈内存 (Stack)**中,具体来说是存储在方法调用时创建的栈帧(Stack Frame)中的局部变量表里。
  • 生存时间 (Lifetime)
    • 成员变量
      • 实例变量:其生命周期与对象的生命周期相同。当对象通过 new 创建时,实例变量被分配内存并初始化;当对象不再被任何引用指向,并被垃圾回收器回收时,实例变量的内存才被释放。
      • 类变量:其生命周期与类的生命周期相同。当类被加载到JVM时,类变量被分配内存并初始化;当类被卸载时(通常是程序结束或类加载器被回收时),类变量的内存才被释放。
    • 局部变量
      • 其生命周期与定义它的方法、构造器或代码块的执行周期相关。当方法被调用或代码块开始执行时,局部变量被创建并分配内存;当方法调用结束或代码块执行完毕时,局部变量就会被销毁,其占用的栈内存空间会被自动释放。
  • 默认值 (Default Initialization)
    • 成员变量
      • 如果成员变量在声明时没有被显式地赋初始值,Java编译器会自动为其赋予对应数据类型的默认初始值
        • 数值类型(byte, short, int, long):0
        • 浮点类型(float, double):0.0
        • 字符类型(char):‘\u0000’ (空字符)
        • 布尔类型(boolean):false
        • 引用类型(包括类、接口、数组):null
      • 例外:如果成员变量被 final 修饰,那么它必须在声明时或者在构造函数中(对于实例final变量)或者在静态初始化块中(对于静态final变量)被显式地赋初始值,否则编译器会报错。它没有默认值。
    • 局部变量
      • 没有默认初始值。Java编译器要求局部变量在使用之前必须被显式地初始化(赋值)。如果在未初始化的情况下尝试使用局部变量,编译器会报错。

一个额外的细微差别(虽然与核心区别关联不大):

  • 命名冲突:如果在方法内部定义的局部变量与类的成员变量同名,那么在方法内部,局部变量会“遮蔽”(shadow)成员变量。如果想在方法内部访问被遮蔽的同名实例成员变量,需要使用 this.成员变量名;如果想访问被遮蔽的同名静态成员变量,需要使用 类名.静态成员变量名。

理解这些区别对于编写正确、高效且易于维护的Java代码非常重要。它们直接影响到变量的可见性、生命周期、内存管理以及程序的行为。

非静态内部类和静态内部类的区别?

关于非静态内部类(通常也叫成员内部类或简称内部类)和静态内部类(更准确地称为静态嵌套类)的主要区别:

  • 与外部类实例的依赖关系
    • 非静态内部类:它的核心特点是依赖于外部类的一个具体实例而存在。也就是说,非静态内部类的每个对象都会隐式地持有一个对其外部类对象的引用。你不能在没有外部类实例的情况下创建非静态内部类的实例。
    • 静态内部类:它则不依赖于外部类的任何实例。它更像是一个恰好被定义在另一个类命名空间下的普通顶层类。因此,它可以独立于外部类实例被创建。
  • 对外部类成员的访问权限
    • 非静态内部类:由于它与外部类实例关联,它可以直接访问外部类的所有成员,包括实例变量、实例方法,甚至是private修饰的实例成员。它通过那个隐式的外部类引用(通常写作 OuterClassName.this)来访问。
    • 静态内部类:因为它不依赖于外部类实例,所以它只能直接访问外部类的静态成员(静态变量和静态方法,包括private static的)。如果它想访问外部类的实例成员,就必须先显式地创建一个外部类的对象,然后通过这个对象来访问。
  • 定义静态成员的能力
    • 非静态内部类:通常情况下,不能定义静态变量或静态方法(唯一的例外是它可以定义static final的编译时常量)。这是因为静态成员是属于类的,而非静态内部类本身是与对象实例关联的。
    • 静态内部类:则完全可以像普通的顶层类一样,自由地定义自己的静态成员(静态变量、静态方法等)。
  • 实例化方式
    • 非静态内部类:它的实例化必须先有一个外部类的实例。创建非静态内部类对象的语法通常是 outerClassInstance.new InnerClass();。
    • 静态内部类:可以独立于外部类实例进行实例化,语法类似于创建普通类对象,但需要带上外部类的名称作为限定,例如 new OuterClassName.StaticInnerClass();。
  • 访问外部类的私有成员
    • 非静态内部类:正如前面所说,它可以访问外部类的所有成员,包括私有成员(实例私有和静态私有)。
    • 静态内部类
      • 它可以访问外部类的静态私有成员
      • 但它不能直接访问外部类的实例私有成员,因为没有外部类实例的上下文。如果确实需要访问(比如通过外部类提供的公有方法间接访问),那也是通过先实例化外部类对象来实现的,而不是直接访问。

总结来说,选择使用非静态内部类还是静态内部类,主要取决于内部类是否需要访问外部类的实例成员,以及它是否需要独立于外部类实例存在。

  • 如果内部类逻辑上属于外部类的一个部分,并且需要紧密地与外部类实例的数据和行为交互,那么通常使用非静态内部类(比如事件监听器、迭代器实现等)。
  • 如果内部类只是为了组织代码,或者作为外部类的一个辅助类,并且它不需要访问外部类的实例状态,那么使用静态内部类会更好,因为它更独立,也避免了非静态内部类持有外部类引用的潜在内存开销。

非静态内部类可以直接访问外部方法,编译器是怎么做到的?

非静态内部类之所以能够直接访问外部类的成员(包括方法和变量),其核心机制在于编译器在背后做了一些“手脚”,确保内部类对象在创建时能够持有一个指向其外部类实例的引用。

我来详细解释一下编译器是如何做到这一点的:

  • 隐式的外部类引用
    • 当我们定义一个非静态内部类时,编译器在编译这个内部类的时候,会自动为这个内部类添加一个私有的、指向外部类类型的实例变量。这个变量通常是以某种编译器内部约定的方式命名的(比如 this$0 或类似的名称),我们开发者通常是感知不到它的显式存在的。
    • 这个隐式引用就是连接内部类实例和其外部类实例的桥梁。
  • 构造函数的修改
    • 当编译器生成非静态内部类的构造函数时(即使我们没有显式定义构造函数,编译器也会生成默认构造函数),它会自动在构造函数的参数列表的开头添加一个额外的参数,这个参数的类型就是外部类的类型。
    • 当我们通过外部类的实例来创建内部类实例时(例如 OuterClass.InnerClass inner = outerInstance.new InnerClass();),编译器会自动将这个外部类实例的引用(outerInstance)作为这个额外的参数传递给内部类的构造函数
    • 在内部类的构造函数内部,这个传递过来的外部类实例引用就会被赋给前面提到的那个隐式添加的私有实例变量。
  • 访问外部类成员的转换
    • 当我们在非静态内部类的方法中直接访问外部类的成员(比如调用外部类的方法 outerMethod() 或访问外部类的实例变量 outerVariable)时,编译器在生成字节码时,会将这些直接访问转换为通过那个隐式的外部类引用来进行的访问
    • 例如,如果我们在内部类中写 outerMethod();,编译器生成的字节码可能类似于 this.this$0.outerMethod();(这里的this$0就是那个指向外部类实例的隐式引用)。

所以,总结来说,非静态内部类能够访问外部类方法和变量,并不是什么魔法,而是编译器在编译阶段通过以下步骤巧妙实现的:

  • 为内部类自动添加一个指向外部类实例的成员变量。
  • 修改内部类的构造函数,使其接收一个外部类实例的引用,并用它来初始化那个隐式成员变量。
  • 将内部类中对外部类成员的直接访问,在字节码层面转换为通过这个隐式引用进行的间接访问。

正是因为这个隐式的外部类引用,非静态内部类的实例总是与一个特定的外部类实例相关联,并且能够访问其状态和行为。这也解释了为什么非静态内部类不能独立于外部类实例而存在。

java创建对象有哪些方式?

面试官您好,在Java中创建对象的方式,我了解到的主要有以下几种:

  • 使用 new 关键字
    • 这是最常用也是最直接的方式。它会调用类的构造函数来创建一个新的对象实例,并在堆内存中为这个对象分配空间。
    • 例如:MyClass obj = new MyClass(); 或者 MyClass obj = new MyClass(param1, param2);
  • 使用反射机制
    Java的反射API提供了在运行时动态创建对象的能力。主要有两种方式:
    • 通过 Class.newInstance() 方法
      • 这个方法会调用类的无参构造函数来创建对象。如果类没有无参构造函数,或者构造函数是private的(且没有设置可访问性),则会抛出异常。
      • 需要注意的是,从Java 9开始,Class.newInstance() 方法被标记为已过时 (deprecated),推荐使用 Constructor.newInstance()。
      • 例如
Class<?> clazz = Class.forName("com.example.MyClass"); 
MyClass obj = (MyClass) clazz.newInstance(); 	// (旧版写法)
  • 通过 java.lang.reflect.Constructor.newInstance() 方法
    • 这种方式更加灵活,它可以调用类的任意构造函数(包括有参构造函数和私有构造函数,前提是获取了对应的Constructor对象并可能需要设置可访问性 setAccessible(true))。
    • 例如
Constructor<MyClass> constructor = MyClass.class.getDeclaredConstructor(String.class);
// constructor.setAccessible(true); // 如果构造函数是私有的
MyClass obj = constructor.newInstance("someArgument");
  • 使用 clone() 方法
    • clone() 方法是定义在 java.lang.Object 类中的一个 protected 方法。要使用它,目标类必须实现 java.lang.Cloneable 标记接口,并重写 clone() 方法(通常将其访问权限改为 public)。
    • clone() 方法会创建一个现有对象的副本。它不会调用任何构造函数。
    • 需要注意的是,Object.clone() 默认执行的是浅拷贝。如果需要深拷贝,必须在重写的 clone() 方法中显式处理引用类型的成员变量。
    • 例如
// 假设 MyClass 实现了 Cloneable 并重写了 clone()
MyClass original = new MyClass();
MyClass cloned = (MyClass) original.clone();
  • 通过反序列化 (Deserialization)
    • 当一个对象从它的序列化状态(通常是一个字节流)被恢复时,也会创建一个新的对象。这个过程通过 java.io.ObjectInputStream 类的 readObject() 方法实现。
    • 被反序列化的类必须实现 java.io.Serializable 接口。
    • 反序列化创建对象时,通常不会调用类的构造函数(一个例外是,它会调用其第一个非可序列化超类的无参构造函数,如果存在的话)。对象的状态是直接从字节流中恢复的。
    • 例如
// 假设之前已将一个MyClass对象序列化到文件 "object.ser"
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.ser"));
MyClass obj = (MyClass) ois.readObject();
ois.close();
  • 使用工厂方法 (Factory Methods) - 一种设计模式
    • 虽然这不是Java语言层面的一种独立的对象创建“机制”,但它是一种非常常见的创建对象的“模式”。工厂方法可以是静态的或实例的,它封装了对象的创建逻辑,并返回新创建的对象实例。
    • 内部实现上,工厂方法最终还是会使用上述的 new 关键字或者反射等机制来创建对象。
    • 例如:Integer i = Integer.valueOf(10); (valueOf 是一个静态工厂方法)或者 Calendar cal = Calendar.getInstance();

总结一下,核心的Java对象创建机制主要是 new关键字、反射、clone()和反序列化。工厂方法则是一种更高级的封装了这些机制的设计模式。

New出的对象什么时候回收?

通过new关键字创建的对象,其内存回收由Java的垃圾回收器(Garbage Collector, GC)自动负责的。这个过程对于开发者来说是透明的,我们不需要像C++那样手动释放内存。

具体来说,一个Java对象什么时候会被回收,主要取决于它是否不再被任何“活跃”的引用所指向,也就是当它从GC Roots(垃圾回收根节点)开始通过引用链变得不可达时,它就成为垃圾回收的候选对象。

  • 引用计数法 (Reference Counting)
    • 原理:给每个对象设置一个引用计数器。每当有一个引用指向该对象时,计数器加1;当引用失效(比如被置为null或超出作用域)时,计数器减1。当对象的引用计数器为0时,就认为该对象不再被使用,可以被回收。
    • 优点:实现简单,判定效率高。在对象变成垃圾时可以很快被发现。
    • 缺点:它有一个致命的缺陷,就是无法解决循环引用的问题。比如对象A引用对象B,对象B也引用对象A,但这两个对象都没有其他外部引用了,它们的引用计数都不为0,导致它们永远无法被回收,造成内存泄漏。
    • 在主流JVM中的使用现代主流的Java虚拟机(如HotSpot)通常不采用引用计数法作为主要的垃圾判断算法,正是因为循环引用的问题。
  • 可达性分析算法 (Reachability Analysis)
    • 原理:这是现代JVM(包括HotSpot)实际采用的判断对象是否存活的核心算法。
      • 该算法首先会确定一系列的 “GC Roots”。GC Roots是一些肯定存活的引用,它们可以包括:
        • 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如方法内声明的局部变量所引用的对象。
        • 方法区中类静态属性引用的对象:比如类的static成员变量引用的对象。
        • 方法区中常量引用的对象:比如字符串常量池里的引用。
        • 本地方法栈中JNI(即一般说的Native方法)引用的对象
        • Java虚拟机内部的引用:如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。
        • 所有被同步锁(synchronized关键字)持有的对象
      • 然后,从这些GC Roots节点开始,向下搜索,遍历所有可达的对象(即通过引用链能够访问到的对象)。
      • 如果一个对象从任何GC Root出发都无法到达,那么这个对象就被认为是不可达的,即它是垃圾,可以被回收。
    • 优点:可以有效地解决引用计数法无法处理的循环引用问题。只要对象不与任何GC Root相连,即使它们之间存在循环引用,也会被判定为垃圾。
  • finalize() 方法 (Finalizer)
    • 角色:如果一个对象重写了从Object类继承来的finalize()方法,并且这个对象在可达性分析后被判定为不可达,那么在垃圾回收器真正回收这个对象之前,有机会会调用这个对象的finalize()方法。
    • 目的:finalize()方法设计的初衷是给对象一个在被销毁前进行最后资源清理(比如关闭文件句柄、释放非Java堆内存等)的机会。
    • 重要提醒与不推荐使用
      • 执行时机不确定:finalize()方法的执行时机非常不确定,甚至不保证一定会被执行(比如程序退出时)。GC何时运行,何时调用finalize()都是由JVM决定的。
      • 性能问题:finalize()的执行通常会使对象的回收过程变慢,因为它涉及到额外的处理队列和可能的线程切换。
      • 可能“复活”对象:在finalize()方法中,对象是有可能通过重新建立与GC Roots的连接来“复活”自己的,这会使得垃圾回收过程变得复杂。
      • 只会被调用一次:即使对象在finalize()中复活了,下一次它再变成不可达时,其finalize()方法也不会再被调用。
      • 已不推荐:由于上述种种问题,finalize()机制的使用是强烈不推荐的。从Java 9开始,finalize()方法已被正式废弃(deprecated)。推荐使用try-with-resources语句来管理需要关闭的资源,或者使用java.lang.ref.Cleaner (Java 9+) 和 java.lang.ref.PhantomReference来处理更复杂的对象清理任务。

总结一下对象回收的时机

  • 当一个对象通过可达性分析被确定为从任何GC Root都不可达时,它就成为垃圾回收的候选者。
  • 如果这个对象没有重写finalize()方法,或者finalize()已经被调用过,那么它就可以被垃圾回收器在合适的时机(比如内存不足时、GC周期性运行时等)回收。
  • 如果对象重写了finalize()方法且尚未被调用,它会被放入一个特殊的队列,等待一个低优先级的Finalizer线程去调用它的finalize()方法。在finalize()执行后(如果对象没有复活),它才真正可以被回收。

所以,对于“new出的对象什么时候回收”,最核心的答案是: **当它不再被任何活跃的GC Root引用链引用,并且垃圾回收器决定执行回收操作时。**我们开发者通常不直接控制这个时间点,而是通过管理好对象的引用,确保不再需要的对象能够自然地变得不可达。

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

在 Java 中,如果一个类没有显式声明构造方法,程序仍然能够正常执行。因为 Java 会为每个类自动提供一个默认的无参构造方法。这个默认构造方法的作用是初始化对象,并执行基本的对象创建过程。

然而,如果我们在类中显式定义了任何构造方法(无论是有参还是无参),Java 将不再自动提供默认的无参构造方法。如果我们需要无参构造方法,必须显式地声明它。

例如,如果我们重载了构造方法,提供了有参构造方法,而没有显式声明无参构造方法,那么在创建对象时就无法使用无参构造方法。为了避免这种情况,建议在重载构造方法时,仍然显式写出无参构造方法,尤其是在某些框架或库需要使用无参构造方法进行对象实例化时,这样可以减少潜在的错误。

结论:

即使没有显式声明构造方法,Java 也会提供一个默认的无参构造方法。但如果声明了有参构造方法,默认的无参构造方法将不再自动提供,因此我们需要根据需要手动定义无参构造方法。这有助于确保代码的灵活性,特别是在需要无参构造方法的场景中(如反射或某些框架使用)。

构造方法有哪些特点?

构造方法具有以下几个显著特点:

  1. 名称与类名相同:构造方法的名称必须与类名完全一致,这是它的一个显著特征。
  2. 没有返回值:构造方法没有返回类型,不能使用 void 声明。它的作用是初始化对象,而不是返回任何值。
  3. 自动执行:构造方法在创建类的实例时会自动执行,无需显式调用。当我们使用 new 关键字创建对象时,构造方法会自动被调用进行对象的初始化。
  4. 不能被重写(Override):构造方法不能被重写,因为它是与类的实例创建相关联的。Java 中的重写(Override)适用于继承关系中的方法,而构造方法并不具备继承关系,因此无法重写。
  5. 可以被重载(Overload):构造方法可以被重载,即一个类中可以有多个构造方法。它们可以有不同的参数列表,用于提供不同的对象初始化方式。这为对象的创建提供了更大的灵活性。

结论:

  • 构造方法 是用来初始化对象的特殊方法,它的名称与类名相同,且没有返回类型。
  • 不能被重写(Override),但是可以通过 重载(Overload) 来提供多个不同参数的构造方法。
  • 这些特点使得构造方法在对象创建时具有自动执行和灵活初始化的能力。

如何获取私有对象?

我的理解是,这通常指的是在某些特定情况下,我们需要访问一个类中被 private 修饰符隐藏的成员(比如一个私有的字段,它本身是一个对象引用),或者是需要通过一个 private 的构造方法来创建这个类的实例。

在Java中,实现这种操作的主要技术手段是反射(Reflection)机制。通过反射,我们可以在运行时动态地检查和操作类、方法、字段等,即使它们是私有的。

具体来说,可以分为以下几种情况和步骤:

  • 获取一个类的私有字段(该字段是一个对象实例)
    • 获取Class对象:首先,我们需要获取到目标类的 Class 对象。这可以通过 ClassName.class,或者一个已有对象的 obj.getClass(),或者 Class.forName(“fully.qualified.ClassName”) 来实现。
    • 获取Field对象:然后,通过 Class 对象的 getDeclaredField(String fieldName) 方法获取到指定的私有字段的 Field 对象。这里使用 getDeclaredField() 而不是 getField(),是因为前者可以获取所有声明的字段(包括私有的),而后者只能获取公有字段。
    • 设置可访问性:接下来,非常关键的一步是调用 field.setAccessible(true)。这个方法的作用是取消Java语言的访问检查,使得我们可以访问这个私有字段。
    • 获取字段值:最后,如果这个字段是实例字段,我们需要一个持有该字段的外部类实例 targetObject,然后通过 field.get(targetObject) 方法就可以获取到这个私有字段所引用的对象了。如果字段是静态的,那么 get() 方法的第一个参数可以传 null。
  • 通过一个类的私有构造器创建该类的实例
    • 获取Class对象:同样,先获取目标类的 Class 对象。
    • 获取Constructor对象:然后,通过 Class 对象的 getDeclaredConstructor(Class… parameterTypes) 方法获取到指定的私有构造器的 Constructor 对象。如果需要调用无参私有构造器,则不传递参数给 getDeclaredConstructor();如果是有参构造器,则需要传入对应参数类型的 Class 对象。
    • 设置可访问性:调用 constructor.setAccessible(true) 来使其可访问。
    • 创建实例:最后,通过 constructor.newInstance(Object… initargs) 方法,传入构造器所需的实际参数(如果有的话),就可以创建该类的对象实例了。

在讨论这个技术时,我通常还会考虑到以下几点

  • 适用场景:反射主要用于一些底层的框架开发(比如Spring的依赖注入、ORM框架中的对象映射)、编写单元测试(当需要测试私有方法或状态时)、或者一些需要动态加载和操作类结构的工具类中。
  • 破坏封装性:需要明确的是,反射确实破坏了Java的封装性,这与面向对象提倡的信息隐藏原则是相悖的。因此,在常规的业务代码开发中,我们应该尽量避免直接使用反射来访问私有成员,优先考虑通过类提供的公有接口进行交互。
  • 性能开销:相比于直接的方法调用或字段访问,反射操作通常会带来一定的性能开销,因为它涉及到更多的动态解析和检查。
  • 安全性:如果Java程序运行在受限的安全环境中(比如启用了Security Manager),安全管理器可能会限制反射操作的权限。

总的来说,Java通过反射机制确实提供了访问和操作“私有对象”(即私有成员或通过私有构造器创建的对象)的能力,但这是一种强大但也需要审慎使用的特性。在面试中,我会强调理解其原理和适用场景,并意识到其潜在的风险。

== 与 equals 有什么区别?

面试官您好,== 和 equals() 方法在Java中都用于比较,但它们比较的“东西”和行为在不同情况下有所不同。您的描述点出了核心区别,我来详细解释一下:

1. == 操作符

  • 对于基本数据类型 (Primitive Types)
    • 当 == 用于比较基本数据类型(如 int, char, boolean, double 等)时,它比较的是它们的值是否相等
    • 例如:int a = 10; int b = 10; System.out.println(a == b); 会输出 true。
  • 对于引用数据类型 (Reference Types / Objects)
    • 当 == 用于比较引用数据类型时,它比较的是这两个引用变量所指向的对象的内存地址是否相同。也就是说,它判断这两个引用是否指向堆内存中的同一个对象实例
    • 例如
String s1 = new String("hello");
String s2 = new String("hello");
String s3 = s1;
System.out.println(s1 == s2); // false,因为s1和s2分别指向堆中两个不同的String对象
System.out.println(s1 == s3); // true,因为s3和s1指向堆中同一个String对象

2. equals() 方法

  • equals() 方法是定义在 java.lang.Object 类中的一个方法,因此Java中所有的类都继承了这个方法。
  • Object 类中 equals() 方法的默认行为
    • 在 Object 类中,equals() 方法的默认实现与 == 操作符对于引用类型的行为是完全相同的,即它也是比较两个引用是否指向同一个对象实例。
    • 源码大致如下:public boolean equals(Object obj) { return (this == obj); }
    • 所以,如果一个类没有重写equals() 方法,那么调用它的 equals() 方法就等同于使用 == 进行比较。
  • 许多类重写了 equals() 方法
    • Java中很多核心类(如 String, Integer, Date 等包装类,以及集合类)都重写了 equals() 方法,以实现基于对象内容的逻辑相等性比较,而不是仅仅比较对象的内存地址。
    • String 类的 equals() 方法
      • 它被重写为比较两个字符串对象的字符序列内容是否完全相同
      • 例如:String s1 = new String(“hello”); String s2 = new String(“hello”); System.out.println(s1.equals(s2)); 会输出 true,因为它们的内容相同,即使它们是不同的对象实例。
    • 自定义类中的 equals() 方法
      • 当我们创建自己的类时,如果希望能够根据对象的“逻辑内容”来判断它们是否相等(而不仅仅是看它们是不是同一个内存中的对象),我们就需要重写 equals() 方法
      • 在重写 equals() 方法时,通常还需要同时重写 hashCode() 方法,以保证当两个对象通过 equals() 比较为 true 时,它们的 hashCode() 值也必须相同。这是使用基于哈希的集合(如 HashMap, HashSet)的约定。

总结一下核心区别:

特性 == 操作符 equals() 方法
作用于基本类型 比较是否相等 不能直接用于基本类型(基本类型没有方法)
作用于引用类型 比较两个引用是否指向同一个内存地址 (同一个对象实例) 默认行为 (未重写时,即 Object.equals()):与 == 相同,比较内存地址。
重写后:通常比较对象的内容或逻辑状态是否相等。
性质 是一个操作符 是一个方法
对于字符串的比较 比较的是字符串对象的内存地址(除非是字符串常量池中的相同字面量,这时可能为true) 比较的是字符串的字符序列内容是否相同

所以:

  • ==:比较的是两个字符串内存地址(堆内存)的数值是否相等,属于数值比较
  • equals():比较的是两个字符串的内容,属于内容比较

对于非字符串变量来说,如果没有对equals()进行重写的话,== 和 equals()方法的作用是相同的,都是用来比较对象在堆内存中的首地址,即用来比较两个引用变量是否指向同一个对象。

在实际编程中,当我们想要判断两个对象在逻辑上是否相等时,应该使用 equals() 方法(并确保它被正确重写了)。当我们确实需要判断两个引用是否指向同一个对象实例时,才使用 ==。

hashCode() 有什么用?

hashCode() 方法在 Java 中的作用是返回一个对象的哈希码,这个哈希码是一个整数,用于在基于哈希的集合中(如 HashMap、HashSet、Hashtable 等)有效地存储和查找对象。

  • hashCode() 在 Java 中的主要作用是支持基于哈希的数据结构(如 HashMap、HashSet 等)快速查找、存储和去重。
  • 它和 equals() 方法紧密配合,确保具有相同哈希码的对象在逻辑上是相等的。
  • 良好的 hashCode() 实现可以确保集合的高效性和正确性,避免哈希冲突对性能产生重大影响。

hashcode和equals方法有什么关系?

hashCode() 和 equals() 这两个方法在Java中是紧密相关的,特别是在我们将对象存入一些基于哈希表的数据结构(如 HashMap, HashSet, Hashtable)时,它们之间的正确配合至关重要。

它们之间的关系和约定:

核心约定:

正如您所说,Java规范对 hashCode() 和 equals() 方法有以下强制性的约定:

  • 一致性 (Equals implies same hashCode)
    • 如果两个对象通过 equals() 方法比较后返回 true(即它们在逻辑上是相等的),那么这两个对象的 hashCode() 方法必须返回相同(相等)的整数值。
    • 公式化表达:如果 obj1.equals(obj2) 为 true,那么 obj1.hashCode() == obj2.hashCode() 必须也为 true。
  • 非一致性 (Same hashCode does NOT imply equals) - 或者说,哈希冲突是允许的
    • 如果两个对象的 hashCode() 方法返回相同的整数值,它们通过 equals() 方法比较的结果不一定为 true。
    • 公式化表达:如果 obj1.hashCode() == obj2.hashCode() 为 true,那么 obj1.equals(obj2) 可能是 true,也可能是 false。
    • 当两个不同的对象(即 equals() 比较为 false)却拥有相同的哈希码时,这种情况就被称为哈希冲突 (Hash Collision)。哈希表数据结构自身有机制来处理哈希冲突(比如链地址法或开放寻址法)。

为什么需要这个约定?

这个约定的主要目的是为了保证基于哈希表的数据结构(如 HashMap, HashSet)能够正确和高效地工作:

  • HashMap 如何工作(简要)
    • 当我们向 HashMap 中 put(key, value) 一个键值对时,它首先会计算 key 的 hashCode() 值。
    • 然后,它会用这个哈希码(可能经过一些内部处理)来决定这个键值对应该存储在哈希表的哪个“桶”(bucket)或者说哪个位置。
    • 当我们通过 get(key) 来查找一个值时,HashMap 同样会先计算 key 的 hashCode(),找到对应的桶。
    • 如果桶中只有一个元素,并且这个元素的键与我们要查找的键通过 equals() 比较为 true,那么就找到了。
    • 如果桶中有多个元素(发生了哈希冲突),HashMap 就会遍历这个桶中的所有元素,逐个使用 equals() 方法与我们要查找的键进行比较,直到找到一个相等的键,或者遍历完都没有找到。
  • 如果约定被违反会怎样?
    • 只重写 equals() 而不重写 hashCode()(或者 hashCode() 实现不符合约定):
      假设我们有两个对象 obj1 和 obj2,它们的内容相同(因此 obj1.equals(obj2) 为 true),但由于没有正确重写 hashCode(),导致 obj1.hashCode() 和 obj2.hashCode() 返回了不同的值。
      如果我们先 map.put(obj1, value1),然后再尝试 map.get(obj2),HashMap 会因为 obj2 的哈希码与 obj1 不同而定位到不同的桶,从而很可能找不到 obj1(即使它们 equals() 相等)。这就导致了逻辑上的错误,我们无法通过一个“等价的”键来取回之前存入的值。
    • hashCode() 实现得不好,导致大量哈希冲突
      即使 hashCode() 的实现符合约定(相等的对象哈希码相同),但如果它设计得很差,导致很多不相等的对象也经常产生相同的哈希码,那么哈希表的性能就会严重下降。因为大量的元素会集中在少数几个桶中,查找时就需要进行大量的线性遍历(equals()比较),使得哈希表退化成链表,失去了O(1)的平均查找效率。

重写 equals() 时必须重写 hashCode() 的原因:

正是为了遵守上述的核心约定。如果你改变了 equals() 方法的判定逻辑(比如从比较对象地址改为比较对象的内容),那么你就必须相应地调整 hashCode() 方法的计算逻辑,确保所有被 equals() 方法认为是相等的对象,它们的 hashCode() 也必须相等。

hashCode() 方法的良好实践:

  • 一致性:对于同一个对象,在它参与 equals() 比较的关键信息没有被修改期间,多次调用 hashCode() 方法必须始终返回同一个整数。
  • 高效性:计算 hashCode() 的过程不应该太复杂,以免影响性能。
  • 分散性:尽量让不同的对象(特别是那些 equals() 比较为 false 的对象)产生不同的哈希码,以减少哈希冲突。

总结来说,hashCode() 和 equals() 是Java对象身份和相等性判断的两个重要方面。equals() 定义了对象的逻辑相等性,而 hashCode() 则主要服务于哈希表等数据结构,通过提供一个整数“摘要”来帮助快速定位对象。两者必须协同工作,遵循上述约定,才能保证程序的正确性和哈希表的性能。因此,重写 equals() 时,几乎总是需要同时重写 hashCode()。

String、StringBuffer、StringBuilder的区别和联系

String、StringBuffer 和 StringBuilder 这三者都是Java中用来处理字符串的类,但它们在可变性、线程安全性和性能方面有着非常显著的区别。

它们之间的区别和联系:

共同点(联系)
它们都是用来表示和操作字符序列的。

主要区别

  • 可变性 (Mutability)
    • String:是不可变的 (Immutable)。一旦一个String对象被创建,它内部的字符序列就不能被修改。任何对String对象的“修改”操作(比如拼接、替换等),实际上都会创建一个全新的String对象来存储结果,而原始对象保持不变。
    • StringBuffer 和 StringBuilder:都是可变的 (Mutable)。它们提供了一系列的方法(如 append(), insert(), delete(), replace())可以直接修改其内部的字符序列内容,而不会创建新的对象(除非内部存储字符的数组容量不足需要扩容)。
  • 线程安全性 (Thread Safety)
    • String:由于其不可变性,String对象是天然线程安全的。因为它们的状态不会改变,所以多个线程可以同时访问同一个String对象而不会产生数据竞争或不一致的问题。
    • StringBuffer:是线程安全的。它的几乎所有公开的主要方法(如 append(), insert() 等)都使用了 synchronized 关键字进行同步。这意味着在多线程环境下,对同一个StringBuffer对象的操作是互斥的,可以安全地被多个线程共享和修改。
    • StringBuilder:是非线程安全的。它的方法没有进行同步处理。因此,在单线程环境下使用StringBuilder通常比StringBuffer效率更高,但在多线程环境下,如果多个线程同时修改同一个StringBuilder对象,就可能会导致数据不一致或其他并发问题。
  • 性能 (Performance)
    • String
      • 频繁修改字符串内容的场景下,性能是最低的。因为每次修改都会生成新的String对象,这不仅涉及到对象的创建开销,还会产生大量的临时对象,给垃圾回收器带来压力。
      • 但是,如果字符串内容是固定的或者修改不频繁,String的性能表现是可以接受的,并且由于其不可变性和字符串常量池的存在,在某些情况下(如共享常量字符串)反而有优势。
    • StringBuilder
      • 单线程环境下进行大量字符串拼接或修改操作时,性能是最高的。因为它直接在原对象上操作,避免了创建新对象的开销,并且没有同步的额外开销。
    • StringBuffer
      • 性能介于String(频繁修改时)和StringBuilder之间。由于其方法是同步的,在进行字符串操作时会引入同步锁的开销,所以通常比StringBuilder慢一些。但在多线程环境下,这种同步开销是为了保证线程安全所必需的。
  • 适用场景 (Use Cases)
    • String
      • 适用于字符串内容基本固定不变,或者修改操作非常少的场景。
      • 当需要将字符串作为常量、Map的键、或者在多线程环境下共享且不希望被修改时。
      • 例如:配置信息、固定的提示语、URL等。
    • StringBuilder
      • 适用于单线程环境下,需要进行大量字符串拼接、修改等动态操作的场景。
      • 例如:在循环中构建复杂的字符串、解析文本并拼接结果等。
    • StringBuffer
      • 适用于多线程环境下,需要共享和修改同一个字符串序列的场景,并且需要保证线程安全。
      • 例如:多个线程可能同时向一个共享的日志缓冲区追加日志信息。

总结一下:

特性 String StringBuilder StringBuffer
可变性 不可变 (Immutable) 可变 (Mutable) 可变 (Mutable)
线程安全 是 (因其不可变性) 否 (非线程安全) 是 (方法同步,线程安全)
性能 低 (频繁修改时,产生大量对象) 高 (单线程下,直接操作原对象) 中 (多线程安全,但有同步开销)
适用场景 静态字符串,少量修改 单线程动态构建/修改字符串 多线程共享动态构建/修改字符串

在实际开发中,选择哪个类主要取决于对字符串可变性、线程安全以及性能的具体需求。一般情况下:

  • 如果字符串不怎么变,用 String。
  • 如果在单线程环境下需要大量拼接或修改字符串,用 StringBuilder。
  • 如果在多线程环境下需要共享并修改字符串,且需要保证线程安全,用 StringBuffer。

从Java 5开始,编译器在处理字符串字面量拼接时(如 String s = “a” + “b” + “c”;),通常会自动优化为使用StringBuilder来提高效率。但对于循环中的字符串拼接,还是建议显式使用StringBuilder或StringBuffer。

Object类的常用方法

Object 类是 Java 中所有类的父类,因此所有 Java 类都直接或间接地继承自 Object 类。Object 类提供了一些常见的方法,这些方法在任何 Java 对象中都可以使用。以下是 Object 类的常见方法及其功能:

1. toString():

  • 功能:返回当前对象的字符串表示。默认情况下,toString() 方法返回的是对象的类名和哈希码,但通常可以被重写,提供更有意义的对象描述。

示例

@Override
public String toString() {
    return "Car [brand=" + brand + ", year=" + year + "]";
}

2. equals(Object obj):

  • 功能:判断当前对象与指定对象是否相等。默认情况下,equals() 比较的是对象的内存地址,即两个对象是否是同一个实例。如果需要根据对象的内容进行比较,通常需要重写 equals() 方法。

示例

@Override
public boolean equals(Object obj) {
if (this == obj) {
    return true;
}
if (obj == null || getClass() != obj.getClass()) {
    return false;
}
Car car = (Car) obj;
return brand.equals(car.brand) && year == car.year;
}

3. hashCode():

  • 功能:返回当前对象的哈希码。哈希码是对象的内存地址的一个整数表示。hashCode() 和 equals() 方法应当配合使用,如果两个对象通过 equals() 比较相等,它们的哈希码也应当相等。

示例

@Override
public int hashCode() {
    return Objects.hash(brand, year);
}

4. getClass():

  • 功能:返回一个 Class 对象,表示当前对象的类信息。通过 getClass() 可以获取类的元数据,通常用于反射。

示例

public void printClassName() {
    System.out.println(this.getClass().getName());
}

5. clone():

  • 功能:创建并返回当前对象的副本。默认情况下,clone() 会执行浅拷贝,但可以通过实现 Cloneable 接口和重写 clone() 方法来执行深拷贝。

示例

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

6. finalize():

  • 功能:在垃圾回收器回收对象之前被调用,用于清理资源。这个方法很少使用,因为垃圾回收的行为不可预测。它通常被重写来释放对象持有的外部资源(如文件句柄、数据库连接等)。

示例

@Override
protected void finalize() throws Throwable {
try {
    // 释放资源
} finally {
    super.finalize();
}
}

7. notify()、notifyAll()、wait():

  • 功能:这些方法与线程的同步控制有关。wait() 会使当前线程等待,notify() 和 notifyAll() 会唤醒处于等待状态的线程。它们通常在多线程编程中使用。
    • wait():使当前线程进入等待状态,直到被其他线程唤醒。
    • notify():唤醒一个正在等待该对象监视器的线程。
    • notifyAll():唤醒所有正在等待该对象监视器的线程。

示例

synchronized (this) {
    while (condition) {
        wait();
    }
    // 处理任务
}

总结:

Object 类提供了一些基础方法,这些方法在所有 Java 类中都可以使用。对于实际应用中的自定义类,通常会重写 toString()、equals()、hashCode() 等方法,以使对象的比较、输出和存储更加符合需求。clone() 和 finalize() 等方法也可以根据需要进行重写,但这些方法的使用较为特殊。

你可能感兴趣的:(Java八股文,java,开发语言)