Java SE
注意:括号中为八股在每次面试中出现的概率
面向对象编程有哪些特性?(371/1759=21.1%)
面向对象编程(Object-Oriented Programming,简称 OOP)是一种以对象为核心的编程范式,它通过模拟现实世界中的事物及其关系来组织代码。OOP 具有三大核心特性:封装、继承、多态。接下来我会逐一详细说明这些特性。
第一,封装(Encapsulation)。
封装是指将数据(属性)和行为(方法)捆绑在一起,并对外隐藏对象的内部实现细节。通过访问修饰符(如 private、protected 和 public),我们可以控制哪些部分是对外可见的,哪些是内部私有的。这种机制提高了代码的安全性和可维护性。例如,在 Java 中,我们通常会将类的属性设置为 private,并通过 getter 和 setter 方法提供受控的访问方式。
第二,继承(Inheritance)。
继承允许一个类(子类)基于另一个类(父类)来构建,从而复用父类的属性和方法。通过继承,子类不仅可以拥有父类的功能,还可以扩展或重写父类的行为。Java 中使用 extends 关键字实现继承。例如,我们可以通过定义一个通用的 Animal 类,然后让 Dog 和 Cat 类继承它,这样就避免了重复编写相同的代码。继承体现了“is-a”的关系,比如“狗是一个动物”。
第三,多态(Polymorphism)。
多态是指同一个方法调用可以根据对象的实际类型表现出不同的行为。多态分为两种形式:编译时多态(方法重载)和运行时多态(方法重写)。运行时多态是通过动态绑定实现的,即程序在运行时决定调用哪个方法。例如,如果父类 Animal 有一个 makeSound() 方法,子类 Dog 和 Cat 可以分别重写这个方法,当调用 animal.makeSound() 时,具体执行的是 Dog 或 Cat 的实现。多态使得代码更加灵活和可扩展。
如何记忆:
1.口诀记忆法
口诀:封继多,三特合一。
封 → 封装
继 → 继承
多 → 多态
解释:
“封继多”直接对应三个特性,简单好记。“三特合一”强调这是面向对象编程的三大特性,整体性强。当你需要快速回忆这三个特性时,默念“封继多”,瞬间就能想起它们的顺序和内容。
2.谐音记忆法
谐音:小明继承家业,多才多艺。
小明 → 封装(把自己包起来)
继承家业 → 继承(从父类继承财产)
多才多艺 → 多态(多种表现形式)
联想场景:
小明是一个富二代,他继承了父亲的公司,但他不仅会做生意,还会唱歌、跳舞、画画。通过这个故事,轻松记住三个特性。
拓展:
1.面向对象和面向过程的区别
(1)面向对象编程(OOP)
定义:
面向对象编程是一种以对象为核心的编程范式,它将现实世界中的事物抽象为对象,并通过对象之间的交互来完成任务。OOP 的核心思想是“万物皆对象”。
特点:
以对象为中心,强调数据和行为的封装。
使用类(Class)作为模板来创建对象(Object)。
支持三大特性:封装、继承和多态。
优点:
模块化:代码结构清晰,易于维护和扩展。
复用性高:通过继承和组合可以复用已有代码。
灵活性强:支持多态,能够适应复杂需求的变化。
可读性强:贴近现实世界的思维方式,便于理解和协作。
缺点:
性能开销较大:由于引入了类、对象等概念,运行效率可能不如面向过程高效。
学习曲线较陡:需要理解类、对象、继承等概念,初学者可能觉得复杂。
(2)面向过程编程(POP)
定义:
面向过程编程是一种以过程(函数)为核心的编程范式,它将程序分解为一系列函数或步骤,按照顺序执行任务。
特点:
以函数为中心,强调逻辑流程。
数据和函数分离,通常通过参数传递数据。
程序由一个个独立的函数组成,函数之间通过调用关系协作。
优点:
简单直接:逻辑清晰,适合小型项目或简单任务。
性能较高:没有额外的对象开销,运行效率更高。
易于实现:不需要复杂的类设计,开发速度快。
缺点:
扩展性差:随着项目规模增大,代码容易变得混乱,难以维护。
复用性低:函数之间的耦合度高,难以复用代码。
不适合复杂系统:面对复杂业务逻辑时,代码会显得冗长且难以管理。
(3)举例说明
假设我们要编写一个程序来模拟一个简单的银行账户管理系统,包含以下功能: 创建账户、 存款、 取款、查询余额。
面向过程的实现:
public class BankAccountProcedural { // 全局变量表示账户余额 private static float balance = 0; // 存款方法 public static void deposit(float amount) { if (amount > 0) { balance += amount; System.out.printf("存款成功!当前余额:%.2f\n", balance); } else { System.out.println("存款金额无效!"); } } // 取款方法 public static void withdraw(float amount) { if (amount > 0 && amount <= balance) { balance -= amount; System.out.printf("取款成功!当前余额:%.2f\n", balance); } else { System.out.println("取款失败!余额不足或金额无效。"); } } // 查询余额方法 public static void checkBalance() { System.out.printf("当前余额:%.2f\n", balance); } public static void main(String[] args) { deposit(1000); // 存款 1000 withdraw(500); // 取款 500 checkBalance(); // 查询余额 } }
分析:
数据(balance)和函数(deposit、withdraw、checkBalance)是分离的。
程序逻辑清晰,但随着功能增加(如添加多个账户),代码会变得难以维护。
面向对象的实现:
class BankAccount { private float balance; // 封装账户余额 // 构造方法 public BankAccount(float initialBalance) { this.balance = initialBalance; } // 存款方法 public void deposit(float amount) { if (amount > 0) { balance += amount; System.out.printf("存款成功!当前余额:%.2f\n", balance); } else { System.out.println("存款金额无效!"); } } // 取款方法 public void withdraw(float amount) { if (amount > 0 && amount <= balance) { balance -= amount; System.out.printf("取款成功!当前余额:%.2f\n", balance); } else { System.out.println("取款失败!余额不足或金额无效。"); } } // 查询余额方法 public void checkBalance() { System.out.printf("当前余额:%.2f\n", balance); } } public class Main { public static void main(String[] args) { BankAccount account = new BankAccount(0); // 创建账户 account.deposit(1000); // 存款 1000 account.withdraw(500); // 取款 500 account.checkBalance(); // 查询余额 } }
分析:
数据(balance)和行为(deposit、withdraw、checkBalance)被封装在 BankAccount 类中。
如果需要添加多个账户,只需创建多个 BankAccount 对象即可,代码扩展性强。
更符合现实世界的思维模式,便于理解和维护。
接口、普通类和抽象类有什么区别和共同点(412/1759=23.4%)
在 Java 中,接口、普通类和抽象类是构建面向对象程序的三种重要结构。普通类用于描述具体的对象,抽象类用于定义具有共性的基类,而接口则用于定义行为规范。它们各自有不同的用途和特点,但也有一定的共同点。接下来,我会从定义、方法实现、继承关系以及成员变量这4个方面详细讲解它们的区别,然后再总结它们的共同点。
第一个是定义上的区别。
普通类是一个完整的、具体的类,可以直接实例化为对象。它包含属性和方法,并且可以有构造方法。
抽象类是一个不能直接实例化的类,通常用来作为其他类的基类。它可以包含抽象方法(没有实现的方法)和具体方法(有实现的方法)。
接口是一种完全抽象的结构,用于定义行为规范。它只包含抽象方法(Java 8 之后可以包含默认方法和静态方法)。
第二个是方法实现上的区别。
普通类的所有方法都可以有具体实现(即方法体)。
抽象类可以包含具体方法和抽象方法。
接口默认只包含抽象方法(Java 8 后可以包含默认方法和静态方法)。
第三是继承关系上的区别。
普通类支持单继承(一个类只能继承一个父类)。
抽象类也支持单继承(一个类只能继承一个抽象类)。
接口支持多实现(一个类可以实现多个接口)。
第四是成员变量上的区别。
普通类和抽象类都可以有各种类型的成员变量(实例变量、静态变量等)。
接口只能有常量(public static final)。
接下来讲一下共同点,一共有3点。
首先,它们都是面向对象编程的基础结构,都可以用来组织代码,实现封装、继承和多态等特性。
其次,它们都可以包含方法,尽管接口中的方法默认是抽象的。
最后,它们都可以被继承或实现,普通类可以通过继承扩展功能,抽象类和接口则需要子类继承或实现后才能使用。
如何记忆:
1.口诀记忆法
口诀:普抽接,四不同;定方继变,共三点。
“普抽接” → 普通类、抽象类、接口(三种结构)。
“四不同” → 定义、方法实现、继承关系、成员变量(四个方面的区别)。
“共三点” → 共同点有三个(面向对象基础、包含方法、可被继承或实现)。
2.联想记忆法
普通类 → 孩子(具体的人,可以直接行动)。
抽象类 → 父母(提供指导和支持,但需要孩子来完成具体任务)。
接口 → 家规(规定家庭成员必须遵守的行为规范)。
联想场景:
在家庭中,孩子是具体的执行者,父母是孩子的引导者,而家规则是所有家庭成员的行为准则。通过家庭角色分工,轻松理解普通类、抽象类和接口的功能。
拓展:
1.为什么会有普通类、抽象类和接口?
普通类、抽象类和接口的诞生,是计算机科学和软件工程发展过程中对编程范式不断优化的结果。它们的出现不仅是为了满足不同层次的需求,更是为了应对软件开发中的复杂性、可维护性和扩展性挑战。
(1)普通类的诞生:从机器语言到面向对象
在计算机发展的早期,程序设计主要依赖于机器语言和汇编语言,程序员需要直接操作硬件资源(如寄存器、内存地址等)。这种方式虽然高效,但代码难以阅读、维护和复用。
随着高级语言(如 Fortran、C)的出现,函数和结构化编程逐渐成为主流。程序员可以通过函数封装逻辑,通过结构体组织数据,从而提高了代码的模块化程度。然而,这种基于过程的编程方式仍然存在局限性:数据和行为分离,导致代码耦合度高,难以管理复杂的系统。 缺乏对现实世界中“事物”的直观建模能力。
为了解决这些问题,面向对象编程(OOP)应运而生。普通类作为 OOP 的核心构建单元,将数据(属性)和行为(方法)封装在一起,模拟了现实世界中的实体或功能模块。普通类的特点是具体性,即它描述了一个可以直接使用的对象。
普通类的意义:
直接实例化: 普通类可以直接创建对象,用于表示具体的实体(如 Car、Person)或功能模块(如 Calculator)。
简单易用: 对于不需要复用或扩展的场景,普通类是最合适的选择。
历史背景: 普通类的诞生标志着从过程式编程向面向对象编程的转变,使得代码更加贴近人类的思维方式。
(2)抽象类的诞生:从代码复用到部分抽象
随着软件规模的增长,程序员发现许多类之间存在共同的行为或属性。例如,Car 和 Bike 都属于交通工具(Vehicle),它们共享某些属性(如品牌、速度)和行为(如移动)。然而,这些类的具体实现可能有所不同(如汽车靠轮子移动,飞机靠翅膀飞行)。
在这种情况下,普通类无法很好地满足需求,因为普通类要求所有方法都必须有具体实现。于是,抽象类被引入作为一种中间层的设计工具。抽象类允许定义一组共同的行为或属性,同时保留部分未实现的方法供子类实现。
抽象类的意义:
代码复用: 抽象类可以包含已实现的方法(如通用的 displayBrand() 方法),供子类复用。
灵活扩展: 子类只需实现特定的抽象方法(如 move()),从而避免重复编写相同的代码。
历史背景: 抽象类的出现是对普通类的一种补充,解决了“部分共性”的问题,使得代码更加模块化和可扩展。
(3)接口的诞生:从单继承到多行为规范
尽管抽象类提供了一种强大的设计工具,但它有一个重要的限制:Java 中的类只能继承一个父类(单继承)。这意味着如果一个类需要遵循多个行为规范(如 Flyable 和 Swimmable),抽象类就无法满足需求。
为了解决这一问题,接口被引入作为一种完全抽象的类型。接口只定义行为规范,而不关心具体的实现细节。通过实现接口,一个类可以同时具备多种行为能力,从而突破了单继承的限制。
接口的意义:
行为规范: 接口定义了一组行为标准(如 Flyable 的 fly() 方法),供实现类遵循。
多继承支持: 一个类可以实现多个接口,从而具备多种行为能力(如既能飞又能游泳的鸟类)。
解耦设计: 接口将行为规范与具体实现分离,使得代码更加灵活和可维护。
历史背景: 接口的引入是对抽象类的进一步抽象,弥补了 Java 单继承的不足,同时也推动了面向接口编程(Interface-Oriented Programming)的发展。
(4)三者的协同作用:从单一到多层次设计
普通类、抽象类和接口并不是孤立存在的,而是相辅相成,共同构成了 Java 面向对象编程的强大设计能力:
普通类用于描述具体的实体或功能模块,是面向对象的基础。
抽象类用于描述一组具有共同特征的对象,提供了代码复用和扩展的能力。
接口用于定义行为规范,实现了多继承和解耦设计。
协同示例:
假设我们需要设计一个动物世界,其中包含鸟类、鱼类和昆虫类。
使用普通类定义具体的动物(如 Sparrow、Shark)。
使用抽象类定义一组共同的行为(如 Animal 的 eat() 方法)。
使用接口定义特定的行为规范(如 Flyable 的 fly() 方法和 Swimmable 的 swim() 方法)。
// 定义接口 public interface Flyable { void fly(); } public interface Swimmable { void swim(); } // 定义抽象类 public abstract class Animal { protected String name; public Animal(String name) { this.name = name; } public abstract void eat(); // 抽象方法 } // 定义普通类 public class Sparrow extends Animal implements Flyable { public Sparrow(String name) { super(name); } @Override public void eat() { System.out.println(name + " is eating seeds."); } @Override public void fly() { System.out.println(name + " is flying."); } }
深拷贝和浅拷贝区别了解吗?(603/1759=34.3%)
深拷贝和浅拷贝的核心区别在于是否递归地复制对象内部的引用类型数据,接下来,我会从定义、实现方式以及使用场景三个方面详细讲解它们的区别。
首先是定义上的区别,
浅拷贝是指创建一个新对象,但新对象中的引用类型字段仍然指向原对象中引用类型的内存地址。换句话说,浅拷贝只复制了对象本身,而没有复制对象内部的引用类型数据。修改新对象中的引用类型数据会影响原对象。
深拷贝是指创建一个新对象,并且递归地复制对象内部的所有引用类型数据。换句话说,深拷贝不仅复制了对象本身,还复制了对象内部的所有引用类型数据。修改新对象中的引用类型数据不会影响原对象。
其次是实现方式上的区别,
浅拷贝可以使用 Object 类的 clone() 方法,也可以使用实现 Cloneable 接口并重写 clone() 的方法。
深拷贝可以手动对引用类型字段进行递归拷贝,也可以使用序列化(Serialization)的方式将对象序列化为字节流,再反序列化为新对象。
最后是使用场景上的区别,
浅拷贝适用于当对象内部的引用类型数据不需要独立复制的情况。
深拷贝适用于当对象内部的引用类型数据需要完全独立的情况。
如何记忆:
1.口诀记忆法
口诀:浅拷贝共享,深拷贝独立;浅克隆简单,深递归复杂。
“浅拷贝共享” → 浅拷贝中引用类型字段共享内存地址,修改会影响原对象。
“深拷贝独立” → 深拷贝中引用类型字段完全独立,修改不会影响原对象。
“浅克隆简单” → 浅拷贝实现简单,使用 clone() 即可。
“深递归复杂” → 深拷贝需要递归复制或序列化,实现较复杂。
2.联想记忆法
联想:在租房的场景中,租客和房东共享房子的使用权,任何改动都会影响对方;而在买房的场景中,买方拥有独立的房子,可以自由改造而不影响他人。通过租房和买房的类比,轻松理解浅拷贝和深拷贝的不同。
浅拷贝 → 租房(租客和房东共享房子,改动家具会影响房东)。
深拷贝 → 买房(买房后拥有完全独立的房子,改动家具不会影响房东)。
拓展:
1.如何使用深拷贝
class Person implements Cloneable { String name; Address address; public Person(String name, Address address) { this.name = name; this.address = address; } @Override protected Object clone() throws CloneNotSupportedException { Person cloned = (Person) super.clone(); cloned.address = (Address) this.address.clone(); // 手动深拷贝引用类型 return cloned; } } class Address implements Cloneable { String city; public Address(String city) { this.city = city; } @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } } public class Main { public static void main(String[] args) throws CloneNotSupportedException { Address address = new Address("New York"); Person person1 = new Person("Alice", address); Person person2 = (Person) person1.clone(); System.out.println(person1.address.city); // 输出: New York person2.address.city = "Los Angeles"; System.out.println(person1.address.city); // 输出: New York } }
2.如何使用浅拷贝
class Person implements Cloneable { String name; Address address; public Person(String name, Address address) { this.name = name; this.address = address; } @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); // 默认是浅拷贝 } } class Address { String city; public Address(String city) { this.city = city; } } public class Main { public static void main(String[] args) throws CloneNotSupportedException { Address address = new Address("New York"); Person person1 = new Person("Alice", address); Person person2 = (Person) person1.clone(); System.out.println(person1.address.city); // 输出: New York person2.address.city = "Los Angeles"; System.out.println(person1.address.city); // 输出: Los Angeles } }
3.什么是引用拷贝?一图说明三者的关系和区别
在 Java 中,引用拷贝(Reference Copy)是指将一个对象的引用赋值给另一个变量。换句话说,两个变量指向同一个对象,而不是创建一个新的对象。这种操作不会复制对象本身,而是复制对象的引用地址。
(1)引用拷贝的工作原理
当我们在 Java 中操作对象时,变量实际上存储的是对象的引用(即内存地址),而不是对象本身。引用拷贝的核心在于:原始变量和新变量都指向同一个对象。 对其中一个变量的操作会影响另一个变量,因为它们共享同一个对象。
举个例子:
// 创建一个对象 Person person1 = new Person("Alice"); // 引用拷贝 Person person2 = person1; // 修改 person2 的属性 person2.setName("Bob"); // 输出 person1 的属性 System.out.println(person1.getName()); // 输出 "Bob"
在这个例子中,person2 是 person1 的引用拷贝。它们指向同一个 Person 对象,因此对 person2 的修改会反映到 person1 上。
(2)引用拷贝的特点
(1)共享对象:引用拷贝并不会创建新的对象,而是让两个变量共享同一个对象。因此,对对象的任何修改都会影响所有引用该对象的变量。
(2)节省内存:因为没有创建新的对象,引用拷贝不会额外占用内存空间。
(3)潜在风险:如果多个变量共享同一个对象,可能会导致意外的副作用。例如,一个变量对对象的修改可能会影响到其他变量。
(3)引用拷贝的应用场景
(1)性能优化:在需要频繁传递对象的场景中,引用拷贝可以避免创建新对象,从而提高性能。
(2)共享状态:当多个变量需要共享同一个对象的状态时,引用拷贝是一个简单且高效的选择。
(3)不可变对象:如果对象是不可变的(如 String
),引用拷贝不会带来副作用,因为对象本身无法被修改。
(4)一图说明三者的关系和区别
int和Integer的区别(576/1759=32.7%)
int 和 Integer 是 Java 中用于表示整数的两种类型,接下来我会从定义、使用方式以及使用场景这3个方面来详细说明。
第一个是定义上的区别,
int 是 Java 的基本数据类型,直接存储数值,占用固定的 4 字节内存空间,范围是从 -2,147,483,648 到 2,147,483,647。
而 Integer 是 int 的包装类,它是一个对象,通过引用指向存储的数值,因此除了存储数值本身外,还需要额外的内存开销。
第二个是使用方式上的区别,
int 是一种原始类型,可以直接声明和赋值。
而 Integer 必须实例化后才能使用,它提供了更多的功能,比如支持泛型、序列化、缓存以及一些实用方法。
第三个是使用场景上的区别,
当需要高效处理整数时,优先使用 int。
当需要将整数作为对象使用时,选择 Integer。
如何记忆:
1.口诀记忆
口诀:
“基本 int 简单快,包装 Integer 多功能;原始类型直接用,对象需要实例化;高效处理选 int,泛型场景用 Integer。”
解释:
第一句“基本 int 简单快”强调了 int 是基本数据类型,性能高效。“包装 Integer 多功能”说明了 Integer 提供更多功能(如泛型支持)。
第二句“原始类型直接用,对象需要实例化”总结了使用方式的区别。
最后一句“高效处理选 int,泛型场景用 Integer”概括了使用场景。
2.谐音记忆
谐音:“int 就是‘硬’,Integer 就是‘硬个儿’。”
解释:
int 是‘硬’ :表示 int 是“硬核”的基本数据类型,直接存储数值,性能高效。
Integer 是‘硬个儿’ :表示 Integer 是“硬核的家伙”,功能强大但复杂,适合需要对象的场景。
谐音示例:
想象一个“硬邦邦的铁块”代表 int,而一个“硬气的大块头”代表 Integer。铁块轻便好用,大块头虽然重但能干更多活。
拓展:
1.为什么需要 int 和 Integer?
(1) 基本数据类型的需求
在计算机底层,整数是最基础的数据类型之一,直接映射到硬件层面的操作(如寄存器运算)。为了高效处理整数运算,Java 引入了基本数据类型 int,它直接存储数值,占用固定的 4 字节内存空间。
这种设计使得 int 非常高效,但它的局限性也很明显:无法作为对象使用,不能参与面向对象编程中的某些操作(如泛型、集合类等)。
(2) 面向对象的需求
Java 是一门面向对象的语言,很多场景需要将数据封装成对象。例如:泛型(Generics)要求参数必须是对象类型,而不能是基本数据类型。序列化(Serialization)需要对象支持,以便将数据持久化或通过网络传输。缓存机制需要对整数进行复用,以提高性能和节省内存。
因此,Java 设计了 Integer 作为 int 的包装类,解决了这些面向对象的需求。
什么是自动拆箱/装箱?(587/1759=33.4%)
自动拆箱和装箱是为了提高代码的简洁性,它简化了基本数据类型与对应的包装类之间的转换。接下来我会详细解释什么是自动装箱和自动拆箱,以及它们的注意事项。
首先说一下自动装箱,
自动装箱是指将基本数据类型(如 int、double、boolean 等)自动转换为对应的包装类对象(如 Integer、Double、Boolean 等)。这个过程由编译器自动完成,无需手动调用包装类的构造方法或静态方法。
当存储一个基本数据类型到需要用到对象的场景中(例如集合),Java 编译器会检测到基本数据类型需要被转换为包装类对象,编译器会自动调用包装类的 valueOf() 方法来创建对应的包装类对象,生成的对象会被存储到目标位置。
接下来说一下自动拆箱,
自动拆箱是指将包装类对象(如 Integer、Double、Boolean 等)自动转换为对应的基本数据类型(如 int、double、boolean 等)。同样,这个过程也是由编译器自动完成的。
当你从一个需要对象的场景中取出值并赋给基本数据类型时,Java 编译器会检测到目标变量是一个基本数据类型。编译器会自动调用包装类的 xxxValue() 方法,比如 intValue()、doubleValue() 等,来获取基本数据类型的值。返回的基本数据类型值会被赋给目标变量。
最后说一下注意事项,一共有3点需要注意
第一个是性能问题,频繁的自动装箱和拆箱可能会导致额外的性能开销,因为每次都需要创建或转换对象。
第二个是空指针异常,如果对一个 null 的包装类对象进行自动拆箱操作,会抛出 NullPointerException。
第三个是缓存机制,某些包装类(如 Integer、Boolean 等)会对常用值进行缓存。
如何记忆:
1.联想记忆
联想场景:超市购物
自动装箱 :你在超市买了一瓶水(基本数据类型 int),但超市要求所有商品必须装进购物袋(包装类 Integer)。于是收银员(编译器)帮你把水放进袋子(调用 valueOf() 方法)。
自动拆箱 :回到家后,你想喝水,于是从购物袋里拿出水瓶(调用 intValue() 方法)。
注意事项 :
如果你频繁地装袋和取物,会浪费时间和精力(性能问题)。
如果袋子是空的(null),你伸手去拿东西会摔倒(空指针异常)。
超市对某些常用商品(如矿泉水)有库存管理(缓存机制),只有特定编号的商品才会重复使用。
拓展:
1.发展过程:从 JDK 1.0 到现代 Java
(1) JDK 1.0:引入 int 和 Integer
在 Java 的最初版本(JDK 1.0)中,int 和 Integer 被同时引入。int 用于高效处理整数。Integer 提供了基本的对象封装功能,但当时的功能相对有限。
(2) JDK 5:自动装箱/拆箱机制
在 JDK 5 中,Java 引入了 自动装箱(Autoboxing)和自动拆箱(Unboxing) 机制。
自动装箱:将 int 自动转换为 Integer,例如 Integer num = 100;。
自动拆箱:将 Integer 自动转换为 int,例如 int value = num;。
这一改进极大地简化了代码编写,减少了手动转换的繁琐。
(3) JDK 8:优化缓存机制
在 JDK 8 中,Integer 的缓存机制得到了进一步优化。
默认情况下,Integer 缓存范围是 -128 到 127,可以通过 JVM 参数调整缓存范围。
这一优化提高了性能,尤其是在频繁使用小整数的场景中。
(4) 现代 Java:更高效的使用方式
随着 Java 的不断发展,在性能敏感的场景下,优先使用 int。在需要面向对象功能的场景下,使用 Integer。同时,Java 的编译器和运行时环境也不断优化,使得 int 和 Integer 的切换更加高效。
重载和重写的区别?(655/1759=37.2%)
重载常用于提供多种调用方式,而重写则用于实现多态性,增强代码的灵活性和可扩展性。接下来我会从6个方面详细说一下它们的区别。
第一是发生位置的不同,重载发生在同一个类中,而重写发生在父子类之间 。
第二是方法签名的不同,重载要求方法名相同,但参数列表必须不同。重写要求方法名和参数列表完全相同。
第三是返回值类型的不同,重载的返回值类型可以不同,而重写的返回值类型必须相同或是父类返回值类型的子类型。
第四是访问修饰符的不同,重载对访问修饰符没有限制,而重写的访问修饰符不能比父类更严格。
第五是异常声明的不同,重载对异常声明没有限制,而重写时,子类方法抛出的异常不能比父类方法抛出的异常范围更大。
第六是绑定关系的不同,重载是静态绑定 ,编译时确定调用哪个方法,而重写是动态绑定 ,运行时根据对象的实际类型决定调用哪个方法。
如何记忆:
1.口诀记忆
口诀:
重载同班不同参,重写父子不改签。返回值可变装,修饰符放宽限。异常范围别超父,绑定方式看时机。
解释:
第一句:“重载同班不同参,重写父子不改签”——重载发生在同一个类中(同班),参数列表必须不同;重写发生在父子类之间(父子),方法签名不能改变。
第二句:“返回值可变装,修饰符放宽限”——重载的返回值类型可以不同(变装);重写的访问修饰符不能比父类更严格(放宽限制)。
第三句:“异常范围别超父,绑定方式看时机”——重写时子类抛出的异常范围不能超过父类;重载是静态绑定,重写是动态绑定。
2.联想记忆
联想场景:
重载(餐厅点餐) :一家餐厅提供多种点餐方式,比如你可以点一份披萨(order(String pizza)),也可以点一杯饮料(order(String drink))。虽然都是“点餐”,但参数不同(披萨还是饮料)。这就是重载,强调的是多样性。
重写(家庭继承) :父亲会做一道菜(cook()),儿子继承了父亲的手艺,但他用自己的方式重新做了这道菜(@Override cook())。这就是重写,强调的是对父类行为的修改或扩展。
联想扩展:
餐厅点餐的方式(重载)可以在同一地点完成,而家庭继承(重写)需要父子两代人参与。
点餐的结果(返回值)可以不同,但家庭继承的规则(返回值、异常等)必须遵守父辈的规定。
拓展:
1.什么是重载?
重载是指在同一个类中,允许存在多个同名方法,但这些方法的参数列表必须不同。重载的核心在于方法签名的不同,而返回值类型不影响重载。
当定义一个重载方法时,
首先,方法名必须相同。
其次,参数列表必须不同,包括参数的数量、类型或顺序。
然后,返回值类型可以相同也可以不同,但它不影响重载的判断。
最后,访问修饰符(如 public
、private
等)和异常声明也不影响重载。
举个例子:
public class Example { public void display(int a) { System.out.println("Integer: " + a); } public void display(String a) { System.out.println("String: " + a); } }
在这个例子中,display 方法被重载了两次,分别接受 int 和 String 类型的参数。
2.什么是重写?
重写是指子类对父类中已有的方法进行重新定义,以提供特定的实现。重写的核心在于**继承关系**,并且要求方法签名完全一致。
当定义一个重写方法时,
首先,方法名必须与父类中的方法名相同。
其次,参数列表必须与父类中的方法完全一致。
然后,返回值类型必须相同或是父类返回值类型的子类型(协变返回类型)。
最后,访问修饰符不能比父类更严格(例如,父类方法是 protected,子类方法可以是 protected 或 public,但不能是 private)。
举个例子:
class Parent { public void show() { System.out.println("Parent's show method"); } } class Child extends Parent { @Override public void show() { System.out.println("Child's show method"); } }
在这个例子中,子类 Child 重写了父类 Parent 的 show 方法。
==和 equals 的区别?(667/1759=37.9%)
== 和 equals 是 Java 中用于比较的两种方式,接下来我会从5个方面来说一下它们的区别。
第一个是比较内容上,== 比较的是内存地址(引用类型)或实际值(基本数据类型),而equals 比较的是逻辑上的相等性,具体取决于类是否重写了 equals 方法。
第二个是适用范围上,== 可用于基本数据类型和引用数据类型,而 equals 只能用于引用数据类型。
第三个是默认行为上,== 始终比较的是内存地址或实际值,而equals 在未重写时与 == 行为一致,但在某些类中(如 String、Integer 等)被重写以实现内容比较。
第四个是可扩展性上,== 是操作符,无法被修改或扩展,而equals 是方法,可以在自定义类中重写以实现特定的比较逻辑。
第五个是性能上,== 性能更高,因为它直接比较内存地址或值,而equals 性能可能较低,尤其是在复杂对象中需要逐个比较属性值。
如何记忆:
1.图像化记忆
图像:通过钥匙和锁、打开箱子的图像化表达,直观地理解 == 和 equals 的过程及区别。
==(钥匙和锁) :两把钥匙是否相同,取决于它们的形状(内存地址或值)。如果形状一样,就能开同一把锁。
equals(打开的箱子) :两个箱子是否相同,取决于里面装的东西(内容)。即使箱子外观不同,只要内容相同,就认为它们相等。
图像扩展:
钥匙和锁的匹配是固定的(== 的行为固定),而箱子的内容可以自定义(equals 可以重写)。
钥匙的匹配速度快(== 性能高),而打开箱子检查内容需要更多时间(equals 性能低)。
拓展:
1.什么是 ==?
== 是一个操作符,用于比较两个对象或基本数据类型的值是否相等。它的核心在于直接比较内存地址或数值本身。
当使用 == 进行比较时,首先,对于基本数据类型(如 int、double 等),== 比较的是它们的实际值。其次,对于引用数据类型(如对象、数组等),== 比较的是它们的内存地址,即判断两个引用是否指向同一个对象。
举个例子:
int a = 5; int b = 5; System.out.println(a == b); // true,比较的是值 String s1 = new String("hello"); String s2 = new String("hello"); System.out.println(s1 == s2); // false,比较的是内存地址
2.什么是 equals?
equals 是一个方法,定义在 Object 类中,默认实现与 == 类似,比较的是对象的内存地址。但在某些类(如 String、Integer 等)中,equals 方法被重写以实现逻辑上的相等性。
当使用 equals 进行比较时,
首先,如果类没有重写 equals 方法,则默认行为是调用 Object 类的实现,比较的是内存地址。
其次,如果类重写了 equals 方法(如 String 或自定义类),则会根据重写的逻辑来判断两个对象是否“相等”。
然后,常见的重写逻辑是基于对象的属性值进行比较,而不是内存地址。
举个例子:
String s1 = new String("hello"); String s2 = new String("hello"); System.out.println(s1.equals(s2)); // true,比较的是内容
3.hashCode() 的作用
hashCode() 是 Java 中 Object 类的一个方法,用于返回对象的哈希码(Hash Code),它是一个整数值。这个哈希码的主要作用是确定对象在基于哈希表的数据结构中的存储位置。
(1) 哈希码的核心作用:定位对象
快速定位:
在基于哈希表的数据结构(如 HashMap、HashSet 和 Hashtable)中,hashCode() 被用来计算对象的存储位置。
哈希表通过将对象的哈希值映射到特定的“桶”(Bucket)中,从而实现高效的插入、查找和删除操作。
这种机制使得哈希表的时间复杂度通常为 O(1),即常数时间复杂度,极大提升了性能。
散列冲突处理:
不同的对象可能会生成相同的哈希码(即哈希冲突)。为了应对这种情况,哈希表会采用链表、红黑树等数据结构来存储多个对象,并通过 equals() 方法进一步比较这些对象是否真正相等。
(2) 示例:HashSet 插入过程
假设我们有一个 HashSet,其中已经存储了 1000 个元素。当插入第 1001 个元素时,HashSet 的工作流程如下:
计算哈希码:首先调用新元素的 hashCode() 方法,计算出该元素的哈希码。
确定存储位置:根据哈希码计算出该元素在底层数组中的索引位置。
处理冲突:如果该位置已经有其他元素(即发生哈希冲突),则通过 equals() 方法逐一比较这些元素,判断是否存在重复。
插入元素:如果没有重复,则将新元素插入;否则忽略。
通过这种方式,HashSet 能够高效地插入和去重,避免了逐个比较所有元素的低效操作。
4.为什么散列表需要哈希码?
为了更好地理解 hashCode() 的作用,我们需要了解散列表的基本原理:
(1)散列表的本质:散列表是一种基于数组的数据结构,通过哈希函数将键映射到数组的某个位置。键的哈希码决定了它在数组中的索引位置。
(2)快速检索:当我们需要根据键查找对应的值时,可以通过哈希函数直接定位到数组中的目标位置,从而避免了逐个遍历的低效操作。
(3)冲突解决:如果两个键的哈希码相同(即哈希冲突),散列表会通过链表或红黑树等方式存储多个键值对,并通过 equals() 方法进一步比较这些键是否真正相等。
什么是泛型?有什么作用?(501/1759=28.5%)
泛型(Generics)是 Java 中一种重要的编程特性,它允许我们在定义类、接口和方法时使用类型参数,从而使代码更加通用、灵活且安全。接下来我会详细解释泛型的定义和作用。
首先说一下什么是泛型,
泛型是一种在编译时提供类型安全检查的机制,它允许我们将类型作为参数传递给类、接口或方法,从而避免硬编码具体的类型。通过泛型,我们可以编写适用于多种数据类型的代码,同时确保类型安全。
泛型的作用主要有4点,
第一点是提高代码的复用性,它允许我们编写与类型无关的通用代码。
第二点是增强类型安全性,在没有泛型的情况下,集合类(如 ArrayList)默认存储的是 Object 类型,取出元素时需要手动进行类型转换,容易引发 ClassCastException。而泛型在编译时就会进行类型检查,避免了运行时的类型错误。
第三点是简化代码,使用泛型后,我们无需显式地进行类型转换,减少了冗余代码,提高了代码的可读性和维护性。
第四点是支持复杂的类型约束,泛型可以通过通配符(如 ? extends T 和 ? super T)实现更复杂的类型限制,满足特定场景下的需求。
如何记忆:
1.联想记忆
联想场景:定制盒子
泛型(定制盒子) :假设你需要一个可以装任意物品的盒子,但希望确保每次只装一种类型的物品(如水果盒、书本盒)。泛型就像一个“定制盒子”,你可以在定义时指定它可以装什么类型的东西,从而避免混装(类型安全)。
复用性 :同一个盒子设计可以用于装不同类型的物品(如水果盒、书本盒),而无需重新设计盒子。
安全性 :如果有人试图往水果盒里装书本,系统会立即提醒(编译时类型检查)。
简化代码 :你无需手动检查或转换物品类型,直接取出即可使用。
类型约束 :可以通过规则限制盒子只能装某种类型或其子类型(通配符)。
拓展:
1.如何定义泛型
泛型类:通过在类名后添加类型参数(如 <T>)来定义泛型类。
public class Pair<K, V> { private K key; private V value; public Pair(K key, V value) { this.key = key; this.value = value; } public K getKey() { return key; } public V getValue() { return value; } }
泛型方法:通过在方法返回值前添加类型参数(如 <T>)来定义泛型方法。
public static <T> void printArray(T[] array) { for (T element : array) { System.out.println(element); } }
2.如何使用泛型(其中之一)
实例化泛型类时,指定具体的类型参数。
Pair<String, Integer> pair = new Pair<>("Age", 25); String key = pair.getKey(); Integer value = pair.getValue();
调用泛型方法时,编译器会自动推断类型参数。
Integer[] numbers = {1, 2, 3}; printArray(numbers); // 编译器推断 T 为 Integer
3.泛型为什么被引入
JDK 1.5引入,泛型解决了一个类每次只能传入同一种对象的问题,还解决了编译能过,运行不能过的问题,泛型的原理是擦除机制,如果 Java 在运行时保留泛型类型信息,那么每个泛型类型的实例都将占用额外的内存,并且 JVM 需要维护这些类型信息。类型擦除使得在运行时不需要保存额外的类型信息,从而节省了内存开销。类型擦除还避免了为每个泛型类型创建多个类的需求,从而减少了类加载器的负担。
什么是反射?应用?(607/1759=34.5%)
反射(Reflection)是 Java 中一种强大的机制,它允许程序在运行时动态地获取类的信息并操作类的属性、方法和构造器。接下来我会详细解释反射的定义和应用场景。
首先说一下什么是反射,
反射是一种在运行时动态获取类信息的能力。通过反射,我们可以在程序运行时加载类、获取类的结构(如字段、方法、构造器等),甚至可以调用类的方法或修改字段的值。
其次,反射主要应用在这5个场景,
第一个是框架开发,很多 Java 框架都有使用反射,比如如 Spring、Hibernate 等。
第二个是动态代理,动态代理是反射的一个重要应用,常用于 AOP(面向切面编程)。通过反射,我们可以在运行时动态生成代理类,拦截方法调用并添加额外逻辑。
第三个是注解处理,注解本身不会对程序产生任何影响,但通过反射,我们可以在运行时读取注解信息并执行相应的逻辑。
第四个是插件化开发,在某些场景下,我们需要动态加载外部的类或模块。反射可以帮助我们在运行时加载这些类并调用其方法,从而实现插件化开发。
第五个是测试工具,单元测试框架(如 JUnit)利用反射来发现和运行测试方法,而无需手动指定每个测试用例。
如何记忆:
1.口诀记忆
口诀:
反射动态真强大,运行时获取类信息。框架代理注解用,插件测试也轻松。
解释:
第一句:“反射动态真强大,运行时获取类信息”——反射是一种在运行时动态获取类信息的强大机制。
第二句:“框架代理注解用,插件测试也轻松”——反射的主要应用场景包括框架开发、动态代理、注解处理、插件化开发和测试工具。
2.联想记忆
联想场景:镜子
反射(镜子) :反射就像一面镜子,可以动态地“照出”类的信息(字段、方法、构造器等)。
框架开发 :镜子可以帮助开发者看到框架内部的结构,从而实现动态注入和管理。
动态代理 :镜子可以生成一个“虚拟对象”,拦截方法调用并添加额外逻辑,就像魔法书中的咒语一样。
注解处理 :镜子可以读取注解上的“魔法符号”,并在运行时执行相应的逻辑。
插件化开发 :镜子可以动态加载外部的“魔法道具”(类或模块),实现灵活扩展。
测试工具 :镜子可以自动找到所有标记为“测试”的方法,并逐一运行它们。
拓展:
1.为什么引入反射
(1)问题背景:动态加载类和调用方法
在传统的 Java 编程中,所有的类和方法调用必须在编译时确定。例如:
MyClass obj = new MyClass(); obj.myMethod();
这种模式虽然安全且高效,但在某些场景下显得不够灵活。例如:
问题:
假设我们正在开发一个插件系统,允许用户通过配置文件指定需要加载的类和调用的方法。例如,配置文件的内容如下:
className=com.example.MyPlugin methodName=execute
我们的程序需要根据配置文件的内容动态加载 com.example.MyPlugin 类,并调用其 execute 方法。
(2)传统方式的局限性:
在传统编程中,我们必须提前知道 MyPlugin 类的存在,并将其硬编码到程序中。
如果用户更换了插件(即更改了配置文件中的类名或方法名),我们需要重新编译整个程序,这显然不符合动态性和灵活性的要求。
(3)解决方案:使用反射实现动态加载和调用
Java 的反射机制允许程序在运行时动态加载类并调用其方法,从而解决了上述问题。
动态加载类
反射提供了 Class.forName(String className) 方法,可以根据类的全限定名(Fully Qualified Name)动态加载类。
示例代码:
String className = "com.example.MyPlugin"; // 从配置文件读取 Class<?> clazz = Class.forName(className); // 动态加载类 Object instance = clazz.getDeclaredConstructor().newInstance(); // 创建实例
动态调用方法
反射还提供了 Method 类,用于表示类中的方法,并允许在运行时调用这些方法。
示例代码:
String methodName = "execute"; // 从配置文件读取 Method method = clazz.getMethod(methodName); // 获取方法 method.invoke(instance); // 调用方法
(4)为什么反射能解决这个问题?
动态性:反射允许程序在运行时根据外部输入(如配置文件)动态加载类和调用方法,而无需在编译时确定。这使得程序可以适应不断变化的需求,例如用户更换插件或扩展功能。
灵活性:反射不依赖于具体的类或方法名称,而是通过字符串参数动态操作类和方法。这种灵活性非常适合框架开发、插件系统等需要高度可扩展性的场景。
解耦:使用反射后,程序与具体的类和方法解耦,用户只需提供符合约定的类和方法即可,无需修改主程序代码。
2.反射的优点和缺点
(1)反射的优点:
提供了动态性和灵活性,适用于框架开发、插件系统等需要高度可扩展性的场景。
实现了解耦设计,提高了代码的可维护性和扩展性。
在调试和测试中非常有用,可以访问私有成员。
(2)反射的缺点:
性能开销较大,不适合高频调用。
存在安全性问题,可能绕过访问控制机制。
可读性和可维护性较低,代码难以追踪和调试。
编译时检查失效,增加了运行时错误的风险。
请说说 StringBuffer 的特点(383/1759=21.8%)
StringBuffer 是 Java 中用于处理可变字符串的一个重要类,它在多线程环境下表现出色,能够高效地进行字符串拼接和修改操作。接下来我会详细解释 StringBuffer 的定义以及它的特点。
首先说一下什么是 StringBuffer?
StringBuffer 是一个可变的字符序列,与 String 不同,StringBuffer 的内容是可以被修改的。它的核心特点是线程安全和高效的字符串操作。
然后说一下 StringBuffer 的4个特点,
第一个是它具有可变性,我们可以在原有对象上直接修改字符串内容,而无需创建新的对象。
第二个它是线程安全的,StringBuffer 的所有方法都通过 synchronized 关键字修饰,因此它是线程安全的。 在多线程环境下,多个线程可以同时操作同一个 StringBuffer 对象,而不会引发数据竞争或不一致问题。
第三个是性能相对较好,StringBuffer 内部使用一个可扩容的字符数组来存储数据,当容量不足时会自动扩展。相比于 String 的不可变性(每次修改都会生成新对象),StringBuffer 在频繁修改字符串时性能更高。而相比于非线程安全的 StringBuilder ,性能略低。
第四个是包含丰富的 API,比如:append():追加内容到字符串末尾。 insert():在指定位置插入内容。delete():删除指定范围的内容。 reverse():反转字符串内容。 toString():将 StringBuffer 转换为 String。
如何记忆:
1.图像化记忆
想象图像:工厂流水线
StringBuffer(工厂流水线) :StringBuffer 就像一条高效的工厂流水线,可以动态地加工产品(字符串)。
可变性 :流水线上的产品可以随时修改(追加、插入、删除等),而手工制作的产品(String)一旦完成就无法修改。
线程安全 :流水线有专人管理(synchronized),多个工人(线程)可以同时操作,但不会发生混乱。
性能较好 :流水线有一个自动扩展的仓库(可扩容字符数组),当产品太多时会自动扩容,确保高效生产。
丰富的 API :流水线配备了多种工具(append()、insert() 等),可以完成复杂的加工任务。
拓展:
1.StringBuffer 的应用场景
StringBuffer 的特点决定了它在以下场景中非常适用:
多线程环境下的字符串操作:当需要在多线程环境中频繁修改字符串时,StringBuffer 是首选,因为它提供了线程安全性。
频繁的字符串拼接操作:在需要大量拼接字符串的场景下(如日志记录、文件读写等),StringBuffer 比 String 更高效,因为它避免了频繁创建新对象的开销。
复杂的字符串修改操作:如果需要对字符串进行插入、删除、反转等复杂操作,StringBuffer 提供了丰富的 API 来满足这些需求。
2.String、StringBuffer、StringBuilder 的区别?
特性 | String | StringBuffer | StringBuilder |
可变性 | 不可变 | 可变 | 可变 |
线程安全 | 线程安全(不可变性保证) | 线程安全(方法加同步锁) | 非线程安全 |
性能 | 频繁修改时性能低 | 性能较高,但低于 StringBuilder | 单线程环境下性能最高 |
使用场景 | 字符串内容不经常变化 | 多线程环境下频繁修改字符串 | 单线程环境下频繁修改字符串 |
以下代码展示了三者在性能上的差异:
public class StringComparison { public static void main(String[] args) { int n = 100000; // 使用 String long startTime = System.currentTimeMillis(); String str = ""; for (int i = 0; i < n; i++) { str += "a"; // 每次都会生成新的 String 对象 } long endTime = System.currentTimeMillis(); System.out.println("String 耗时:" + (endTime - startTime) + "ms"); // 使用 StringBuffer startTime = System.currentTimeMillis(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < n; i++) { sb.append("a"); // 修改原有对象 } endTime = System.currentTimeMillis(); System.out.println("StringBuffer 耗时:" + (endTime - startTime) + "ms"); // 使用 StringBuilder startTime = System.currentTimeMillis(); StringBuilder sbuilder = new StringBuilder(); for (int i = 0; i < n; i++) { sbuilder.append("a"); // 修改原有对象 } endTime = System.currentTimeMillis(); System.out.println("StringBuilder 耗时:" + (endTime - startTime) + "ms"); } }
运行结果(示例)
String 耗时:1200ms StringBuffer 耗时:10ms StringBuilder 耗时:5ms
从结果可以看出:
String 的性能最差,因为每次修改都会生成新对象。
StringBuffer 和 StringBuilder 的性能显著优于 String。
StringBuilder 的性能略高于 StringBuffer,因为没有同步开销。
神哥引路,稳稳起步!!限时特惠,截至2025年2月15日,提供超值首发大礼包。 核心亮点: 1.数据驱动,精准高频:基于1759篇面经、24139道八股题,精准提炼真实高频八股。 2.科学记忆,高效掌握:融合科学记忆法和面试表达技巧,记得住,说得出。 3.提升思维,掌握财商:不仅可学习八股,更可教你变现,3个月赚不回购买价,全额退。 适宜人群: 在校生、社招求职者及自学者。