承接前篇
Java常见面试题--后端——JavaSE前篇-CSDN博客
String 在 Java 中被设计为不可变的,这是出于多方面的考虑:
安全性:不可变的字符串是线程安全的。在多线程环境下,如果字符串是可变的,多个线程可能同时修改字符串,导致不确定的行为。通过使字符串不可变,可以避免这种情况。
缓存:由于字符串不可变,可以被缓存,例如字符串常量池。多个字符串变量如果指向相同的字符串常量,它们可以共享相同的内存地址,节约内存空间。
优化:字符串不可变使得编译器可以进行优化,例如在拼接字符串时可以使用常量折叠,直接合并为一个新的字符串。
安全性和哈希码:由于字符串不可变,它们的哈希码在创建后就不会改变。这使得字符串适合用作 Map 的键,保证了哈希码的一致性。
参数传递:字符串作为方法参数传递时,不可变性确保了传递的参数不会被改变。
因此,不可变的特性带来了安全性、线程安全性、性能优化以及更简单的代码编写和维护。
在 Java 中判断两个 String
对象相等,应该使用 equals()
方法而不是 ==
操作符。equals()
方法用于比较两个字符串对象的内容是否相同,而 ==
操作符用于比较两个对象的引用是否相同(即是否指向同一个内存地址)。
示例代码如下:
String str1 = "Hello";
String str2 = "Hello";
String str3 = new String("Hello");
?
// 使用 equals() 方法比较字符串内容是否相等
boolean isEqual1 = str1.equals(str2); // true
boolean isEqual2 = str1.equals(str3); // true
?
// 使用 == 操作符比较字符串对象的引用是否相同
boolean isSameReference = str1 == str2; // true
boolean isSameReference2 = str1 == str3; // false
在上面的例子中,equals()
方法比较了字符串对象的内容,而 ==
操作符比较了对象的引用。通常情况下,我们想要比较两个字符串的内容是否相等,所以应该使用 equals()
方法。
在 Java 中,由于浮点数的精度问题,直接使用 ==
操作符来比较两个 Double
类型的数可能不够准确。通常建议使用以下方法来判断两个 Double
类型的数是否相等:
使用 Double 类的 equals 方法:
Double num1 = 0.1 + 0.2;
Double num2 = 0.3;
?
boolean isEqual = num1.equals(num2); // 使用 Double 的 equals 方法比较
使用比较的误差范围(epsilon):
由于浮点数计算可能存在精度问题,可以定义一个很小的误差范围(通常称为 epsilon),在比较两个浮点数时,判断它们的差值是否小于这个误差范围。
double num1 = 0.1 + 0.2;
double num2 = 0.3;
?
double epsilon = 0.0000001; // 定义一个很小的误差范围
?
boolean isEqual = Math.abs(num1 - num2) < epsilon; // 比较差值是否小于误差范围
第二种方法更常用,因为它允许您指定比较的精度范围,尤其是在涉及浮点数计算时。
StringBuilder
和 StringBuffer
是用于处理可变字符串的类,它们之间的主要区别在于线程安全性和性能:
线程安全性:
StringBuilder
不是线程安全的,因此在多线程环境下使用时需要注意同步问题。
StringBuffer
是线程安全的,所有公共方法都是同步的,可以在多线程环境下安全使用。
性能:
由于 StringBuffer
的所有方法都是同步的(使用了 synchronized 关键字),在单线程环境下其性能比 StringBuilder
差一些。
StringBuilder
没有线程安全的保证,但因为不需要进行同步,所以在单线程环境下其性能更好。
应用场景:
如果在单线程环境下进行字符串操作,并且对性能有较高要求,可以使用 StringBuilder
。
如果在多线程环境下进行字符串操作,或者需要线程安全性,可以使用 StringBuffer
。
一般情况下,由于现代应用程序更倾向于使用不可变对象和线程安全的集合类,StringBuilder
在大多数情况下是更常用的选择,因为它在单线程环境下提供了更好的性能。只有在需要线程安全性时才应该使用 StringBuffer
。
Integer x = new Integer(123);
Integer y = new Integer(123);
System.out.println(x == y);
输出的结果,为什么?
Integer z = Integer.valueOf(123);
Integer k = Integer.valueOf(123);
System.out.println(z == k);
输出的结果,为什么?
在 Java 中,对于 Integer
类型的对象,范围在 -128 到 127 之间的整数对象会被缓存,以提高性能和节省内存。所以在这个范围内,两个对象指向同一个缓存对象,超出这个范围则会创建新的对象。
对于第一个问题:
Integer x = new Integer(123);
Integer y = new Integer(123);
System.out.println(x == y);
输出结果是 false
。这是因为 new Integer(123)
每次都会创建一个新的对象,所以 x
和 y
指向不同的对象,即使它们的值相同。
对于第二个问题:
Integer z = Integer.valueOf(123);
Integer k = Integer.valueOf(123);
System.out.println(z == k);
输出结果是 true
。这是因为在使用 Integer.valueOf()
方法时,会先检查范围,如果在 -128 到 127 之间,则会从缓存中返回已有的对象,因此 z
和 k
实际上指向了同一个缓存对象,所以 ==
比较返回 true
。
在 Java 中,equals()
方法是用于比较两个对象的内容是否相等的方法,它的默认实现是比较对象的引用是否相等(即比较对象的地址)。然而,大部分类都会重写 equals()
方法以根据自身的需求进行对象内容的比较。
在重写 equals()
方法时,需要遵循以下原则:
自反性:对于任何非空引用值 x
,x.equals(x)
应返回 true
。
对称性:对于任何非空引用值 x
和 y
,如果 x.equals(y)
返回 true
,那么 y.equals(x)
也应返回 true
。
传递性:对于任何非空引用值 x
、y
和 z
,如果 x.equals(y)
返回 true
,并且 y.equals(z)
返回 true
,那么 x.equals(z)
也应返回 true
。
一致性:如果两个对象的内容没有发生变化,多次调用 equals()
应该始终返回相同的结果。
非空性:对于任何非空引用值 x
,x.equals(null)
应返回 false
。
对于实现 equals()
方法的算法,通常会检查对象的类型、比较对象的字段或属性值是否相等,以确定对象是否相等。例如,在比较自定义类的对象时,常见的做法是逐个比较对象的属性值。需要注意的是,重写 equals()
方法时需要同时重写 hashCode()
方法,以确保对象在放入哈希集合(如 HashMap
、HashSet
)等集合类时能够正确地定位对象。
面向对象编程(OOP)的特性包括以下几个方面:
封装(Encapsulation):
将数据和操作数据的方法封装在对象内部,隐藏对象的内部实现细节,只暴露必要的接口给外部使用。
继承(Inheritance):
允许一个类(子类)继承另一个类(父类)的属性和方法,子类可以重用父类的特性,提高代码的复用性和扩展性。
多态(Polymorphism):
允许不同类的对象对同一消息做出响应,即同样的方法调用可以在不同的对象上产生不同的行为。包括方法重载和方法重写。
抽象(Abstraction):
将复杂的现实世界抽象为类和对象,只关注对象的行为和特性,隐藏不必要的细节,简化复杂性。
这些特性共同构成了面向对象编程的核心理念,并提供了一种有效的方法来组织和管理复杂的软件系统,使得代码更易于理解、扩展和维护。
重写(Override)和重载(Overload)是面向对象编程中的两个概念,它们指的是不同类型的方法操作。
重载(Overload):
重载发生在同一个类中,指的是在同一个类中定义多个方法,它们具有相同的名称但参数列表不同(参数类型、参数个数或参数顺序不同)。
重载的目的是为了提供多种方法来处理相似的操作,便于程序员记忆和使用。
示例:
public class Calculator {
? ?public int add(int a, int b) {
? ? ? ?return a + b;
? }
?
? ?public double add(double a, double b) {
? ? ? ?return a + b;
? }
}
重写(Override):
重写发生在子类和父类之间,指的是子类定义了一个与父类中某个方法名称、返回类型和参数列表完全相同的方法。
重写的目的是改变父类方法的实现,以适应子类的需求,实现多态性。
示例:
class Animal {
? ?public void makeSound() {
? ? ? ?System.out.println("Animal is making a sound");
? }
}
?
class Dog extends Animal {
? ?@Override
? ?public void makeSound() {
? ? ? ?System.out.println("Dog is barking");
? }
}
总结:
重载是在同一个类中,方法名称相同但参数列表不同;
重写是在子类和父类之间,子类重新定义了父类的方法,方法名称、返回类型和参数列表完全相同。
在 Java 中,编译器根据方法的参数列表来区分重载的方法,而根据方法的签名(名称、参数列表、返回类型)来确定是否重写父类的方法。
在 Java 中,try-catch-finally
是用于处理异常的结构。其执行顺序如下:
try 块:包含可能会抛出异常的代码块。当异常被抛出时,会立即跳出 try 块,并将异常抛给相应的 catch 块。
catch 块:用于捕获和处理 try 块中抛出的异常。当 try 块中的代码抛出异常时,会在对应的 catch 块中进行异常处理。如果捕获到异常,则执行 catch 块中的代码,然后执行 finally
块。
finally 块:不管是否发生异常,都会执行的代码块。finally
块通常用于执行清理工作或确保资源的释放。即使在 try 块中有 return 语句,finally
块也会在 return 之前执行。
执行顺序如下:
如果没有异常抛出:
先执行 try 块中的代码。
然后执行 finally 块中的代码。
如果发生异常:
先执行 try 块中抛出异常前的代码。
找到匹配的 catch 块处理异常,执行 catch 块中的代码。
最后执行 finally 块中的代码。
无论是否发生异常,finally
块中的代码都会被执行。如果有 return 语句,会在 finally
块执行后才返回。
接口在面向对象编程中起着重要作用,其主要好处包括:
实现多态性(Polymorphism):
接口允许类通过实现接口来定义行为,一个类可以实现多个接口。这种多态性使得对象能够被视为多种类型,提高了灵活性和扩展性。
解耦合(Decoupling):
接口定义了类与类之间的契约,而不是具体的实现细节。这种分离允许代码模块化,降低了代码的耦合度,使得代码更易于维护和修改。
实现代码重用(Code Reusability):
接口定义了一组方法规范,多个类可以通过实现同一个接口来重用接口定义的方法,避免了重复编写相似的代码。
实现面向对象设计原则:
接口有助于实现面向对象编程中的抽象、封装、继承和多态等基本原则。它们促进了良好的设计实践和模式的应用。
提供规范和约束:
接口定义了类应该具有的方法和行为,为开发者提供了一种规范和约束,使得代码更易于理解和协作开发。
总的来说,接口是一种重要的抽象工具,能够提供灵活性、可扩展性和代码重用性,有助于实现良好的面向对象设计。通过接口,程序员可以定义统一的契约和规范,使得系统更易于扩展和维护。
反射(Reflection)是指程序在运行时能够检查、获取和修改其自身状态或行为的能力。在 Java 中,反射允许程序在运行时检查和操作类、对象、方法、字段等元数据信息。
一些关于反射的基本概念和用法包括:
获取 Class 对象:
可以通过对象的 getClass()
方法或者类名的 .class
属性来获取对应类的 Class 对象。
也可以使用 Class.forName("ClassName")
方法来根据类的全限定名获取 Class 对象。
获取类的信息:
可以使用 Class 类的方法来获取类的信息,如获取类的名称、方法、字段、构造器等。
通过 Class 对象可以获取类的构造方法、成员方法、字段等信息,并可以动态调用它们。
创建对象:
可以使用 Class 类的 newInstance()
方法创建类的实例。在 Java 9 之后,推荐使用 Class.getDeclaredConstructor().newInstance()
方法。
调用方法和操作字段:
可以使用反射机制动态调用类的方法,通过 Method.invoke()
实现方法的调用。
也可以使用反射机制操作类的字段,通过 Field.get()
和 Field.set()
获取和设置字段的值。
处理权限和安全性:
反射允许绕过访问权限的限制,可以访问私有方法、字段等。这种能力需要小心使用,可能会降低代码的安全性。
反射是一种强大的特性,它允许程序在运行时动态地获取和操作类的信息,但同时也增加了代码的复杂性和运行时性能开销。因此,在使用反射时需要慎重考虑,合理利用其功能,并注意性能和安全方面的问题。
在 Java 中,方法的内存溢出通常是指方法区(Method Area)的内存溢出,这里存储着类的信息、静态变量、常量池等。撑爆方法区的几种方式包括:
大量动态生成类:
如果在程序运行过程中大量动态生成类(比如使用 CGLIB、动态代理等),可能会导致方法区内存不足。
大量字符串常量:
大量字符串常量的生成也会占用方法区内存。特别是使用大量字符串常量的场景,例如大量的类加载、大量的动态字符串拼接等。
无限递归调用:
在方法中进行无限递归调用,导致方法调用栈无限增长,最终导致栈溢出异常(StackOverflowError)。
类加载器泄漏:
如果自定义类加载器在加载类时没有正确地进行垃圾回收,可能会导致类加载器本身及其加载的类无法被回收,导致方法区内存溢出。
避免方法区内存溢出可以采取以下措施:
避免过多的动态生成类和字符串常量。
注意递归调用的结束条件,避免无限递归。
合理设计程序结构,确保自定义类加载器能够被垃圾回收。
需要注意的是,方法区内存溢出相对比较少见,而更常见的是堆内存溢出(Heap Overflow)或栈内存溢出(Stack Overflow),针对不同类型的内存溢出,解决方案也不同。
要查看 JVM 的 GC(垃圾回收)日志,你可以使用以下方法:
启用 GC 日志:
在启动 Java 应用程序时,可以使用以下参数启用 GC 日志记录:
-Xloggc:<file-path>
这将把 GC 日志输出到指定文件中。
选择日志级别:
可以选择不同的日志级别,如:
-XX:+PrintGCDetails
:打印详细的 GC 信息。
-XX:+PrintGCDateStamps
:打印 GC 发生的日期时间。
-XX:+PrintHeapAtGC
:在每次 GC 时打印堆信息。
日志文件分析:
分析 GC 日志时,关注以下内容:
GC 的类型(Minor GC、Major GC、Full GC)。
GC 发生的时间点和频率。
内存的使用情况(堆内存、永久代/元空间等)。
GC 的停顿时间(Pause Time)。
内存回收的效果(回收了多少内存)。
使用分析工具:
你也可以使用一些专门的分析工具来分析 GC 日志,比如 jstat
、jvisualvm
、VisualGC
等。
GC 日志的分析有助于了解程序的内存使用情况、垃圾回收的频率和效率等信息,有助于优化程序性能和内存的使用。