大家好,我是栗筝i,从 2022 年 10 月份开始,我持续梳理出了全面的 Java 技术栈内容,一方面是对自己学习内容进行整合梳理,另一方面是希望对大家有所帮助,使我们一同进步。得到了很多读者的正面反馈。
而在 2023 年 10 月份开始,我将推出 Java 面试题/知识点系列内容,期望对大家有所助益,让我们一起提升。
本篇是对 Java 基础系列的面试题 / 知识点的总结的上篇
系列相关链接:
Java基础面试题问题(上篇)
String str = "aaa"
与 String str = new String("i")
一样吗 ?解答:Object 类在 Java 中被视为所有类的基础和起点。这是因为在 Java 中,所有的类都默认继承自 Object 类,无论是 Java 内置的类,还是用户自定义的类。这种设计使得所有的 Java 对象都能够调用一些基本的方法,例如 equals(), hashCode(), toString() 等,这些方法都在 Object 类中被定义。
解答:Object 类中的方法可以分为两类:native 方法和非 native 方法。
非 native 方法是:
equals()
:判断与其他对象是否相等。clone()
:创建并返回此对象的一个副本。toString()
:返回该对象的字符串表示。finalize()
:当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。native 方法是:
getClass()
:返回此 Object 的运行时类。hashCode()
:返回该对象的哈希码值。notify()
:唤醒在此对象监视器上等待的单个线程。notifyAll()
:唤醒在此对象监视器上等待的所有线程。wait(long timeout)
:在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。解答:native 方法和非 native 方法的主要区别在于它们的实现方式和运行环境。
在 Java 中,==
和 equals()
方法用于比较两个对象,但它们的比较方式和使用场景有所不同。
==
:对于基本数据类型,==
比较的是值是否相等;对于引用类型,==
比较的是两个引用是否指向同一个对象,即它们的地址是否相同。
equals()
:这是一个方法,不是操作符。它的行为可能会根据它在哪个类中被调用而变化。在 Object 类中,equals()
方法的行为和 ==
相同,比较的是引用是否指向同一个对象。但是在一些类(如 String、Integer 等)中,equals()
方法被重写,用于比较两个对象的内容是否相等。因此,如果你想比较两个对象的内容是否相等,应该使用 equals()
方法。
解答:在 Java 中,equals()
和 hashCode()
两个方法是密切相关的。如果你重写了 equals()
方法,那么你也必须重写 hashCode()
方法,以保证两个相等的对象必须有相同的哈希码。这是因为在 Java 集合框架中,特别是哈希表相关的数据结构(如 HashMap
、HashSet
等)在存储和检索元素时,会使用到对象的 hashCode()
方法。
以下是 Java 中 equals()
和 hashCode()
方法的一般约定:
如果两个对象相等(即,equals(Object)
方法返回 true
),那么调用这两个对象中任一对象的 hashCode()
方法都必须产生相同的整数结果。
如果两个对象不等(即,equals(Object)
方法返回 false
),那么调用这两个对象中任一对象的 hashCode()
方法,不要求必须产生不同的整数结果。但是,程序员应该意识到,为不相等的对象生成不同的整数结果可能提高哈希表的性能。
因此,如果你重写了 equals()
方法但没有重写 hashCode()
方法,可能会导致违反上述的第一条约定,从而影响到哈希表相关数据结构的正确性和性能。
解答:深拷贝和浅拷贝是编程中常见的两种复制对象的方式,主要区别在于是否复制对象内部的引用对象。
浅拷贝(Shallow Copy):当进行浅拷贝时,如果对象中的字段是基本类型,会直接复制其值;如果对象中的字段是引用类型,那么只复制其引用,而不复制引用指向的对象。因此,原对象和拷贝对象会共享同一个引用对象。这就意味着,如果其中一个对象改变了这个引用对象的内容,那么另一个对象的这个引用对象的内容也会随之改变。
深拷贝(Deep Copy):当进行深拷贝时,无论对象中的字段是基本类型还是引用类型,都会创建一个新的副本。对于引用类型,会复制引用指向的对象,而不仅仅是复制引用。因此,原对象和拷贝对象不会共享任何一个引用对象。这就意味着,无论哪一个对象改变了引用对象的内容,都不会影响到另一个对象。
需要注意的是,实现深拷贝可能会比较复杂,特别是当对象的引用结构很复杂时,例如存在循环引用。此外,深拷贝可能会消耗更多的计算和存储资源。
解答:在 Java 中,clone()
方法默认进行的是浅拷贝。
这意味着,如果你的对象中包含了对其他对象的引用,那么 clone()
方法只会复制这个引用,而不会复制引用指向的对象。因此,原对象和克隆对象会共享这个引用指向的对象,这就是所谓的浅拷贝。
如果你想实现深拷贝,即完全复制一个新的对象,包括其引用的所有对象,那么你需要重写 clone()
方法,手动复制这些对象。
解答:在实现深拷贝时,如果遇到循环引用,需要特别小心,否则可能会导致无限递归,最终导致栈溢出。
处理循环引用的一种常见方法是使用一个哈希表来跟踪已经复制过的对象。具体来说,每当你复制一个对象时,都将原对象和复制的新对象放入哈希表中。然后,在复制一个对象之前,先检查这个对象是否已经在哈希表中。如果已经在哈希表中,那么就直接返回哈希表中的复制对象,而不再进行复制。
以下是一个简单的示例:
public class Node {
public Node next;
// ...
}
public class DeepCopy {
private HashMap<Node, Node> visited = new HashMap<>();
public Node clone(Node node) {
if (node == null) {
return null;
}
if (visited.containsKey(node)) {
return visited.get(node);
}
Node cloneNode = new Node();
visited.put(node, cloneNode);
cloneNode.next = clone(node.next);
return cloneNode;
}
}
在这个示例中,DeepCopy
类使用了一个 visited
哈希表来跟踪已经复制过的 Node
对象。在 clone()
方法中,每次复制一个 Node
对象之前,都会先检查这个对象是否已经在 visited
哈希表中。这样就可以避免因为循环引用而导致的无限递归。
解答:在实现深拷贝时,对于数组和集合类的处理需要特别注意,因为它们都可能包含引用类型的元素。
数组:如果数组的元素是基本类型,那么可以直接使用 clone()
方法或 System.arraycopy()
方法来复制数组。如果数组的元素是引用类型,那么需要遍历数组,对每个元素进行深拷贝。
MyClass[] copy = new MyClass[array.length];
for (int i = 0; i < array.length; i++) {
copy[i] = array[i].clone();
}
集合类:对于集合类,如 ArrayList
、HashSet
等,需要创建一个新的集合,然后遍历原集合,对每个元素进行深拷贝,并添加到新集合中。
ArrayList<MyClass> copy = new ArrayList<>();
for (MyClass item : list) {
copy.add(item.clone());
}
需要注意的是,实现深拷贝可能会比较复杂,特别是当对象的引用结构很复杂时,例如存在循环引用。此外,深拷贝可能会消耗更多的计算和存储资源。
解答:Cloneable
接口在 Java 中被称为标记接口(Marker Interface),它本身并没有定义任何方法,但是它对于 Java 的对象克隆机制来说非常重要。
当一个类实现了 Cloneable
接口后,它就表明它的对象是可以被克隆的,即它的 clone()
方法可以被合法地调用。如果一个类没有实现 Cloneable
接口,但是调用了它的 clone()
方法,那么将会在运行时抛出 CloneNotSupportedException
异常。
实现 Cloneable
接口的目的是为了让 Object
的 clone()
方法知道它可以对这个类的对象进行字段对字段的复制。
需要注意的是,虽然 Cloneable
接口本身并没有定义任何方法,但是实现 Cloneable
接口的类通常需要重写 Object
类的 clone()
方法,以提供公开的克隆方法并实现类特定的克隆行为,例如深拷贝。
Cloneable
接口被称为标记接口,是因为它本身并没有定义任何方法,它的作用主要是为了标记一个类的对象可以被克隆。
在 Java 中,Cloneable
接口的主要作用是告诉 Object
的 clone()
方法,它可以对实现了 Cloneable
接口的类的对象进行字段对字段的复制。
如果一个类没有实现 Cloneable
接口,但是调用了它的 clone()
方法,那么将会在运行时抛出 CloneNotSupportedException
异常。
因此,Cloneable
接口虽然没有定义任何方法,但是它对于 Java 的对象克隆机制来说非常重要,它是一种标记,表明一个类的对象可以被克隆。
解答:Java 的序列化(Serialization)和反序列化(Deserialization)是 Java 对象持久化的一种机制。
序列化:序列化是将对象的状态信息转换为可以存储或传输的形式的过程。在序列化过程中,对象将其当前状态写入到一个输出流中。
反序列化:反序列化是从一个输入流中读取对象的状态信息,并根据这些信息创建对象的过程。
序列化和反序列化在很多场景中都非常有用,例如:
在 Java 中,如果一个类的对象需要支持序列化和反序列化,那么这个类需要实现 java.io.Serializable
接口。这个接口是一个标记接口,没有定义任何方法,只是用来表明一个类的对象可以被序列化和反序列化。
解答:在 Java 中,Serializable
接口是一个标记接口,用于表明一个类的对象可以被序列化和反序列化。
序列化是将对象的状态信息转换为可以存储或传输的形式的过程。反序列化则是从一个输入流中读取对象的状态信息,并根据这些信息创建对象的过程。
如果一个类实现了 Serializable
接口,那么它的对象可以被序列化,即可以将对象的状态信息写入到一个输出流中。然后,这个输出流可以被存储到磁盘,或通过网络发送到另一个运行 JVM 的机器。在需要时,可以从这个输出流中读取对象的状态信息,并通过反序列化重新创建对象。
需要注意的是,Serializable
接口本身并没有定义任何方法,它只是一个标记接口。实际的序列化和反序列化过程是由 JVM 通过一些特殊的机制来完成的。
在 Java 中,如果你不希望对象的某个字段被序列化,你可以使用 transient
关键字来修饰这个字段。
transient
是 Java 的一个关键字,用来表示一个字段不应该被序列化。在对象序列化的过程中,被 transient
修饰的字段会被忽略,不会被写入到输出流中。因此,这个字段的状态信息不会被持久化。
例如:
public class MyClass implements Serializable {
private int field1;
private transient int field2;
// ...
}
在这个例子中,field1
字段会被序列化,而 field2
字段则不会被序列化。
需要注意的是,如果一个字段被标记为 transient
,那么在反序列化的过程中,这个字段的值会被初始化为其类型的默认值,例如 null
、0
或 false
。
解答:在 Java 中,虽然默认的序列化机制已经足够强大,但在某些情况下,你可能需要自定义序列化过程。例如,你可能需要对某些敏感信息进行加密,或者需要以特定的格式写入对象的状态信息。
要自定义序列化过程,你可以在类中添加一个名为 writeObject()
的方法。这个方法必须接受一个 ObjectOutputStream
类型的参数,并且返回 void
:
private void writeObject(ObjectOutputStream out) throws IOException {
// 自定义序列化过程
}
在这个方法中,你可以自定义序列化过程。例如,你可以选择只序列化部分字段,或者对某些字段进行特殊处理。
需要注意的是,writeObject()
方法必须是 private
的,这是因为序列化机制会忽略 public
和 protected
的 writeObject()
方法。
同样,如果你需要自定义反序列化过程,你可以添加一个名为 readObject()
的方法:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
// 自定义反序列化过程
}
在这个方法中,你可以自定义反序列化过程。例如,你可以选择只反序列化部分字段,或者对某些字段进行特殊处理。
同样,readObject()
方法必须是 private
的。
解答:静态字段不能被序列化。这是因为静态字段不属于对象,而是属于类。
在 Java 中,静态字段是类级别的,所有的对象实例共享同一个静态字段。因此,静态字段的状态不应该被看作是对象的一部分,所以在序列化对象时,静态字段会被忽略。
序列化的主要目的是为了保存对象的状态,以便在需要时可以恢复这个状态。但是,静态字段的状态是与特定的对象无关的,所以无需在序列化过程中保存和恢复。
如果你需要保存和恢复静态字段的状态,你需要通过其他方式来实现,例如,你可以在序列化和反序列化过程中手动处理静态字段。
解答:在 Java 中,对象的默认序列化机制是通过实现 java.io.Serializable
接口来完成的。Serializable
是一个标记接口,它本身并没有定义任何方法,但是它告诉 Java 虚拟机(JVM)这个对象是可以被序列化的。
当一个对象被序列化时,JVM 会将该对象的类信息、类的签名以及非静态和非瞬态字段的值写入到一个输出流中。这个过程是自动的,不需要程序员进行任何特殊处理。
具体来说,序列化过程如下:
如果对象的类定义了 writeObject
方法,那么 JVM 会调用这个方法进行序列化。否则,JVM 会默认地进行序列化。
JVM 会检查每个需要序列化的字段。如果字段是基本类型,那么 JVM 会直接写入其值。如果字段是引用类型,那么 JVM 会递归地对这个字段指向的对象进行序列化。
如果对象的类实现了 Externalizable
接口,那么 JVM 会调用 writeExternal
方法进行序列化。
反序列化过程与序列化过程相反。当一个对象被反序列化时,JVM 会从输入流中读取类信息和字段的值,然后根据这些信息创建新的对象。
需要注意的是,静态字段和用 transient
关键字修饰的字段不会被序列化。静态字段属于类,而不是对象。transient
关键字告诉 JVM 该字段不应该被序列化。
解答:Java 中的数据类型可以分为 4 类 8 种,4 类分别为:整型、浮点型、字符型和布尔型。
byte:占用 1 字节,取值范围为 -128 到 127
short:占用 2 字节,取值范围为 -32768 到 32767
int:占用 4 字节,取值范围为 -2147483648 到 2147483647
long:占用 8 字节,取值范围为 -9223372036854775808 到 9223372036854775807
float:单精度浮点型,占用 4 字节,取值范围为 1.4E-45 到 3.4028235E38
double:双精度浮点型,占用 8 字节,取值范围为 4.9E-324 到 1.7976931348623157E308
char:占用 2 字节,取值范围为 0 到 65535,可以表示 Unicode 编码中的任何字符。
boolean:只有两个取值,即 true 和 false。
解答:自动装箱和拆箱是 Java 5.0 版本引入的新特性,主要用于基本数据类型和对应的包装类之间的自动转换。
自动装箱和拆箱是 Java 编译器的特性,它在编译阶段会自动为我们插入必要的代码来实现基本数据类型和包装类之间的转换。
int i = 10;
Integer integer = i; // 自动装箱 编译器会将这行代码转换为:Integer integer = Integer.valueOf(i);
Integer integer = new Integer(10);
int i = integer; // 自动拆箱 编译器会将这行代码转换为:int i = integer.intValue();
这两个特性使得基本数据类型和包装类在很多情况下可以互相替代,大大提高了编程的便利性。
解答:
double d = 10.5;
int i = (int) d; // 将 double 类型转换为 int 类型
int i = 10;
double d = i; // 将 int 类型自动转换为 double 类型
解答:Java 中的字符串被设计为不可变的,这意味着一旦创建字符串对象,其内容无法更改。这个设计决策具有一些重要的优势:
线程安全性: 不可变字符串是线程安全的,因为多个线程可以同时访问一个字符串对象而无需担心并发修改导致的问题。这对于多线程应用程序来说是非常重要的。
安全性: 不可变字符串可以用作参数传递给方法,而不必担心方法在不经意间更改了字符串的内容。
性能优化: 因为字符串不可变,可以在运行时对其进行缓存,以减少内存占用和提高性能。例如,多个字符串变量可以共享相同的字符串字面值,从而节省内存。
哈希码缓存: 字符串的哈希码可以在创建时计算并缓存,这样在后续哈希比较(如在哈希表中查找字符串)时会更加高效。
字符串池: 不可变字符串使得字符串池的实现更容易,从而可以共享字符串字面值,减少内存占用。
安全性: 不可变字符串对于安全性是有帮助的。例如,当字符串用于密码或其他敏感数据时,不可变性可以确保这些数据不会在内存中不经意地被修改。
简化字符串操作: 不可变性简化了字符串操作。例如,当你连接两个字符串时,实际上是创建了一个新的字符串,而不是修改原始字符串。
尽管不可变字符串有很多优势,但它们也有一些劣势,例如在频繁修改字符串内容时可能会导致性能下降,因为每次修改都会创建新的字符串对象。为了解决这个问题,Java 提供了 StringBuilder
和 StringBuffer
等可变字符串类,以便更高效地进行字符串拼接和修改。然而,在大多数情况下,不可变字符串的优点远远超过了其劣势,因此它们在 Java中得到广泛应用。
解答:Java 中的字符串池(String Pool)是 Java 堆内存中的一个特殊区域,用于存储所有由字面量创建的字符串对象。
当我们创建一个字符串字面量(例如,String str = "Hello";
),JVM 首先会检查字符串池中是否已经存在 “Hello” 这个字符串。如果存在,那么 str
就会指向这个已存在的 “Hello” 字符串;如果不存在,JVM 就会在字符串池中创建一个新的 “Hello” 字符串,然后 str
会指向这个新创建的字符串。
通过这种方式,字符串池可以帮助我们节省内存,因为它允许相同的字符串字面量共享同一个存储空间。
String str = "aaa"
与 String str = new String("i")
一样吗 ?解答:String str = "aaa";
和 String str = new String("aaa");
在 Java 中并不完全相同。
String str = "aaa";
:这种方式创建的字符串会被放入字符串池中。如果字符串池中已经存在 “aaa” 这个字符串,那么 str
就会指向这个已存在的字符串;如果不存在,JVM 就会在字符串池中创建一个新的 “aaa” 字符串,然后 str
会指向这个新创建的字符串。
String str = new String("aaa");
:这种方式会在堆内存中创建一个新的字符串对象,然后 str
会指向这个新创建的对象。这时,无论字符串池中是否存在 “aaa” 这个字符串,都不会影响 str
的创建。
所以,虽然这两种方式创建的字符串内容相同,但是他们在内存中的存储位置可能不同。如果你使用 ==
操作符比较这两个字符串,可能会得到 false
,因为 ==
操作符比较的是对象的引用,而不是内容。如果你使用 equals()
方法比较这两个字符串,会得到 true
,因为 equals()
方法比较的是字符串的内容
解答:在 Java 中,主要有以下几种创建字符串的方式:
字符串字面量:这是最常见的创建字符串的方式,例如 String str = "Hello";
。这种方式创建的字符串会被放入字符串池中。
使用 new
关键字:例如 String str = new String("Hello");
。这种方式会在堆内存中创建一个新的字符串对象。
通过字符数组:例如 char[] array = {'H', 'e', 'l', 'l', 'o'}; String str = new String(array);
。这种方式会创建一个新的字符串,内容是字符数组的内容。
通过 StringBuilder
或 StringBuffer
:例如 StringBuilder sb = new StringBuilder("Hello"); String str = sb.toString();
。这种方式可以创建一个可变的字符串,然后再转换为不可变的 String
。
通过 String.format()
方法:例如 String str = String.format("Hello %s", "World");
。这种方式可以创建一个格式化的字符串。
以上就是在 Java 中创建字符串的主要方式。
解答:
String
:在 Java 中,String
是不可变的,也就是说一旦一个 String
对象被创建,我们就不能改变它的内容。每次对 String
类型进行修改,都会生成一个新的 String
对象。这在需要大量修改字符串时,会导致内存的大量占用和效率的降低。
StringBuffer
:StringBuffer
是线程安全的可变字符序列。每个方法都是同步的,可以被多个线程安全地调用。但是,这种线程安全带来的缺点是效率相对较低。
StringBuilder
:StringBuilder
是一个可变字符序列,它提供了 append
、insert
、delete
、reverse
、setCharAt
等方法来修改字符串。与 StringBuffer
相比,StringBuilder
不是线程安全的,因此在单线程环境下,StringBuilder
的效率更高。
总结一下,他们之间的区别主要在于:
String
是不可变的,而 StringBuffer
和 StringBuilder
是可变的。StringBuffer
是线程安全的,而 StringBuilder
是非线程安全的。StringBuilder
。在多线程环境下,应使用 StringBuffer
来确保线程安全。如果字符串不需要修改,那么使用 String
是最好的选择。