Skip to content

面向对象基础

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

两者的主要区别在于解决问题的方式不同:

  • 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
  • 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。

另外,面向对象开发的程序一般更易维护、易复用、易扩展。

相关 issue : 面向过程:面向过程性能比面向对象高??

对象实体与对象引用有何不同?

new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。

  • 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
  • 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。

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

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

一个类可以没有声明构造方法?

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

我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。

构造方法有哪些特点?是否可被 override?

构造方法特点如下:

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

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

面向对象三大特征

封装

  • 数据隐藏:封装使得对象内部的状态(数据)被保护起来,只能通过特定的方法(如getter和setter)来访问或修改,从而防止了外部代码对内部数据的随意操作,提高了程序的安全性。
  • 接口明确:通过封装,每个类都提供了一个清晰的接口,使得类的使用者能够清晰地知道如何与这个类的对象进行交互,提高了代码的可读性和易用性。
  • 易于维护:由于封装了内部实现细节,当需要修改类的内部实现时,只要接口保持不变,就不会影响到使用这个类的代码,从而降低了维护成本。
  • 控制访问:通过封装,类可以严格控制对成员变量的访问,确保数据的完整性和一致性。例如,可以通过setter方法设置数据的有效性检查,防止不合法的数据被设置到成员变量中(比如身高一定是大于0的,虽然很少实际在setter中用到)。

继承

  • 代码复用:通过继承,子类可以自动获得父类的属性和方法,无需重新定义,从而减少了代码量,提高了代码复用性。
    • 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有
  • 扩展性:子类可以在继承父类的基础上,添加新的属性和方法,或者覆盖(重写)父类中的方法,以实现更具体或更复杂的功能,增强了类的扩展性。
  • 多态性的基础:继承是多态性的基础,通过继承,可以定义多个具有相似行为但具体实现不同的类,从而为多态性提供了可能。

多态

多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

  • 接口统一:多态性允许我们定义一个统一的接口,使得不同的类可以实现这个接口,并在运行时根据对象的实际类型来调用相应的方法,实现了接口的统一性。
  • 灵活性和可扩展性:多态性使得程序能够很容易地扩展新的功能,而不需要修改现有的代码。只要新的类实现了统一的接口,就可以被现有的系统所使用,提高了程序的灵活性和可扩展性。
  • 减少代码冗余:多态性通过统一的接口来处理不同类型的对象,减少了代码中重复的部分,提高了代码的可读性和可维护性。
  • 注意:
    • 多态不能调用“只在子类存在但在父类不存在”的方法;
    • 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;(重写的生效期)
    • 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。

接口和抽象类有什么共同点和区别?

共同点

  1. 定义抽象方法:两者都可以定义抽象方法,这些方法在子类或实现类中必须被重写(实现)。
  2. 不能直接实例化:接口和抽象类都不能直接实例化对象,它们必须通过子类或实现类来间接使用。
  3. 强制实现:它们的子类或实现类必须实现或重写它们定义的所有抽象方法,否则子类或实现类也必须声明为抽象。
  4. 类文件生成:在Java等语言中,接口和抽象类都可以生成.class文件,表示它们是类的一部分,但具有特殊的用途和限制。
  5. 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)

区别

接口抽象类
定义关键字使用interface关键字定义使用abstract关键字(在类定义前)定义
实现/继承方式使用implements关键字实现使用extends关键字继承
成员变量默认是public static final的,即公共的、静态的、常量可以是任意的访问修饰符,包括私有(private)、保护(protected)、默认(无修饰符)和公共(public)
构造方法接口不能有构造方法抽象类可以有构造方法,但主要用于被子类调用或初始化代码块
方法只能包含抽象方法(Java 8及以后允许默认方法和静态方法)可以包含抽象方法,也可以包含具体实现的方法
多继承一个类可以实现多个接口一个类只能继承一个抽象类(Java不支持多继承类,但可以通过接口实现类似效果)
设计理念强调规范和行为契约,是一组方法的集合强调类的层次结构和继承关系,是一种不完全的类定义
使用场景当需要定义一个完全由抽象方法组成的规范时,使用接口当需要定义一个包含部分实现和部分抽象方法的类时,使用抽象类
访问控制成员变量和方法默认是public成员变量和方法可以有不同的访问控制级别
静态代码块接口中不能使用静态代码块抽象类中可以使用静态代码块

深拷贝和浅拷贝区别了解吗?什么是引用拷贝?

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

浅拷贝

浅拷贝的示例代码如下,我们这里实现了 Cloneable 接口,并重写了 clone() 方法。

clone() 方法的实现很简单,直接调用的是父类 Objectclone() 方法。

点击查看完整代码实现
点击查看完整代码实现
java
public class Address implements Cloneable{
    private String name;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Person implements Cloneable {
    private Address address;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Person clone() {
        try {
            Person person = (Person) super.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

:::

测试:

java
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());

从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。

深拷贝

这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。

java
@Override
public Person clone() {
    try {
        Person person = (Person) super.clone();
        person.setAddress(person.getAddress().clone());
        return person;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

测试:

java
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());

从输出结构就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。

那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。

我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:

浅拷贝、深拷贝、引用拷贝示意图

Object

Object 类的常见方法有哪些?

Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:

点击查看完整代码实现
点击查看完整代码实现
java
/**
 * 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() 的区别

== 对于基本类型和引用类型的作用效果是不同的:

  • 对于基本数据类型来说,== 比较的是值。
  • 对于引用数据类型来说,== 比较的是对象的内存地址。

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

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

equals() 方法存在两种使用情况:

  • 类没有重写 equals()方法:通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Objectequals()方法。
  • 类重写了 equals()方法:一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。

hashCode() 有什么用?

hashCode() 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。

hashCode() 方法

hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:ObjecthashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的。

⚠️ 注意:该方法在 Oracle OpenJDK8 中默认是 "使用线程局部状态来实现 Marsaglia's xor-shift 随机数生成", 并不是 "地址" 或者 "地址转换而来", 不同 JDK/VM 可能不同在 Oracle OpenJDK8 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。

为什么要有 hashCode?

  • 这是因为在一些容器(比如 HashMapHashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)!

  • 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。

  • 但是如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞,也就是指的是不同的对象得到相同的 hashCode)。

  • 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。

    • 这样才会放弃添加这个新对象

为什么重写 equals() 时必须重写 hashCode() 方法?

总结

  • equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
  • 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。
  • 如果只是实现了equals没有重写hashCode,那么如果两个对象通过equals()比较是相等的,但它们的hashCode()方法返回了不同的哈希值,那么这两个对象会被存储在HashMap的不同桶中。(无法正确添加)
    • 当你尝试通过其中一个对象作为键来检索时,由于哈希值不匹配,HashMap会在错误的桶中查找,导致找不到另一个对象,即使它们通过equals()是相等的。(无法正确删除或修改)

String

String、StringBuffer、StringBuilder 的区别?

可变性

  • String 是不可变的。
  • StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 finalprivate 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。

线程安全性

  • String 中的对象是不可变的,也就可以理解为常量,线程安全。
  • StringBuffer 对操作方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
  • StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

  • 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。
  • StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。
  • 相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

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

String 为什么是不可变的?

  • 保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。
    • 仅被final修饰依旧能更改数组中的值,但是私有就不能更改
    • 如果没有final,那么直接将数组指向一个新的数组也能导致可变
    • 两者结合就能保证不会被修改
  • String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。
  • Java8及之前,StringStringBuilderStringBuffer 的实现用 char 数组存储字符串。
  • Java8及之前,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。
    • 新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。
  • 设计初衷是因为:字符串值是被保留在常量池中的,也就是说假若字符串对象允许改变,那么将会导致各种逻辑错误(虽然后续放入到堆中了)

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

可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象

如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。

不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了,所以效率提高很多。(参考:https://juejin.cn/post/7182872058743750715)

String#equals() 和 Object#equals() 有何区别?

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

String s1 = new String("abc");这句话创建了几个字符串对象?

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

1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。

2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。

String#intern 方法有什么作用?

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

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

示例代码(JDK 1.8) :

点击查看完整代码实现
点击查看完整代码实现
java
// 在堆中创建字符串对象”Java“
// 将字符串对象”Java“的引用保存在字符串常量池中
String s1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s2 = s1.intern();
// 会在堆中在单独创建一个字符串对象
String s3 = new String("Java");
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s4 = s3.intern();
// s1 和 s2 指向的是堆中的同一个对象
System.out.println(s1 == s2); // true
// s3 和 s4 指向的是堆中不同的对象
System.out.println(s3 == s4); // false
// s1 和 s4 指向的是堆中的同一个对象
System.out.println(s1 == s4); //true

:::

String 类型的变量和常量做“+”运算时发生了什么?

对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。

对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  • 基本数据类型( bytebooleanshortcharintfloatlongdouble)以及字符串常量。
  • final 修饰的基本数据类型和字符串变量
  • 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

引用的值在程序编译期是无法确定的,编译器无法对其进行优化。

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

java
String str4 = new StringBuilder().append(str1).append(str2).toString();

我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer

不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。

示例代码:

java
final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true

final 关键字修饰之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。

如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。

示例代码(str2 在运行时才能确定其值):

java
final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
      return "ing";
}

正在精进