Java基础常见知识点总结(中)
一、面向对象基础
面向对象与面向过程的区别
面向对象编程(OOP)和面向过程编程(POP)是两种常见的编程范式,它们在解决问题的思路、方法组织和代码设计上有着显著的不同。
1. 编程思想
- 面向过程(POP):将任务分解为一系列的步骤或过程,通过这些步骤(函数、方法)顺序执行来实现任务。重点在于操作数据和步骤。
- 面向对象(OOP):围绕对象展开,程序的功能通过操作对象中的数据和行为来完成。对象通过方法进行交互,强调数据和行为的封装。
2. 数据和功能的组织
- 面向过程:程序主要通过函数或过程来组织,数据通常是全局的,函数通过传入参数来操作数据。
- 面向对象:数据和方法封装成类和对象。对象是数据和方法的封装体,功能通过方法在对象之间进行调用。
3. 代码复用
- 面向过程:代码复用依赖于函数的调用,较为简单,但代码可能会重复。
- 面向对象:通过继承、接口和多态等机制实现代码的复用和扩展。对象之间可以进行灵活的组合和复用。
4. 扩展性与维护性
- 面向过程:程序逻辑清晰,适合小规模、简单的任务,但随着需求的增加,程序会变得难以维护,尤其是在处理复杂任务时。
- 面向对象:通过封装、继承和多态,使得系统更具扩展性和可维护性,修改时对原有代码的影响较小,适合处理复杂和大规模的系统。
5. 性能
- 面向过程通常执行效率较高,因为它的执行是线性的,而面向对象通过对象的创建和方法调用可能稍微增加开销。
- 但是,性能差异往往取决于具体的实现和应用场景,而非编程范式的本质。大部分现代编程语言如 Java,允许同时支持这两种编程方式。
6. 示例对比:求圆的面积和周长
面向对象编程(OOP)
在面向对象的设计中,我们将“圆”作为一个对象来处理,封装其数据(半径)和方法(计算面积和周长):
public class Circle {
// 圆的半径
private double radius;
// 构造方法,初始化半径
public Circle(double radius) {
this.radius = radius;
}
// 计算圆的面积
public double getArea() {
return Math.PI * radius * radius;
}
// 计算圆的周长
public double getPerimeter() {
return 2 * Math.PI * radius;
}
public static void main(String[] args) {
// 创建一个圆对象,半径为3
Circle circle = new Circle(3.0);
// 获取圆的面积和周长
System.out.println("圆的面积为:" + circle.getArea());
System.out.println("圆的周长为:" + circle.getPerimeter());
}
}
在这个例子中,我们定义了一个 Circle
类,封装了圆的半径属性和相关方法。通过对象来调用方法,方便扩展和维护。
面向过程编程(POP)
面向过程编程直接将任务的步骤进行拆解,通常没有封装和继承的概念,所有数据都是全局的,计算过程直接通过函数实现:
public class Main {
public static void main(String[] args) {
// 定义圆的半径
double radius = 3.0;
// 计算圆的面积
double area = Math.PI * radius * radius;
// 计算圆的周长
double perimeter = 2 * Math.PI * radius;
// 输出结果
System.out.println("圆的面积为:" + area);
System.out.println("圆的周长为:" + perimeter);
}
}
在这个例子中,直接定义了一个半径变量,然后计算圆的面积和周长。没有对象封装,数据和逻辑是直接联系的,适合简单的任务,但不利于扩展和维护。
总结
特点 | 面向过程编程(POP) | 面向对象编程(OOP) |
---|---|---|
核心思想 | 强调过程和函数,任务通过过程步骤完成 | 强调对象和类,通过对象来管理数据和行为 |
代码组织 | 代码围绕过程(函数)展开 | 代码围绕类和对象展开 |
代码复用 | 通过函数复用,可能有重复代码 | 通过继承、接口、多态等机制复用代码 |
扩展与维护 | 随着项目增大,扩展和维护变得困难 | 通过封装和继承,易于扩展和维护 |
适用场景 | 适合简单、任务明确的程序 | 适合复杂的系统开发,适应变更的需求 |
性能 | 一般执行效率较高 | 可能存在少许性能开销(如对象创建、方法调用) |
- 面向过程:适用于简单的程序设计,强调过程的执行顺序,适合短期的小规模开发。
- 面向对象:适用于复杂的程序开发,强调对象之间的交互,适合长期维护和扩展的系统开发。
创建一个对象用什么运算符?对象实体与对象引用有何不同?
1. 创建对象的运算符
在 Java 中,使用 new
运算符来创建一个对象实例。new
运算符会分配内存并初始化对象。
MyClass obj = new MyClass();
new MyClass()
:会在堆内存中分配一个新的MyClass
对象实例,并返回该对象的引用。obj
:是对象引用,存储了指向该对象实例的内存地址,通常保存在栈内存中。
2. 对象实体与对象引用的区别
对象实体(对象实例):是指在内存(通常是堆内存)中实际创建的对象,它包含了实际的数据和方法。
- 例如,通过
new MyClass()
创建的对象obj
,这个对象实体在堆内存中,它存储了类的属性(字段)和方法(行为)。
- 例如,通过
对象引用:是指在栈内存中存储的变量,指向堆内存中实际对象的内存地址。引用本身不包含对象数据,只是指向堆内存中的对象实体。
- 例如,
MyClass obj
是一个对象引用,它存储的是堆内存中new MyClass()
创建的对象的地址,而不包含对象的实际数据。
- 例如,
3. 引用与对象实体的关系
一个对象引用可以指向 0 个或 1 个对象:即对象引用变量可以为空(
null
),或者它指向一个特定的对象实例。MyClass obj1 = null; // 引用为空 MyClass obj2 = new MyClass(); // 引用指向一个对象实例
一个对象可以有 n 个引用指向它:多个引用可以指向同一个对象,这就是共享对象的方式。
MyClass obj1 = new MyClass(); MyClass obj2 = obj1; // obj2 也指向同一个对象实体
在这个例子中,
obj1
和obj2
都指向堆内存中同一个对象实例。
4. 内存结构
- 堆内存:用于存储创建的对象实体。每个
new
运算符创建的对象都会在堆内存中分配空间。 - 栈内存:用于存储局部变量,包括对象引用。对象引用是指向堆内存中实际对象的指针。
5. 总结
new
运算符用于创建对象实例,分配堆内存空间,并返回对象引用。- 对象实体是堆内存中的实际对象数据。
- 对象引用是栈内存中的变量,指向堆内存中的对象实体。
MyClass obj1 = new MyClass(); // "new MyClass()" 是对象实体,"obj1" 是对象引用
对象的相等和引用相等的区别
在 Java 中,对象的相等和引用相等有明显的区别,主要表现在比较的内容和方式上。
1. 引用相等(==
)
==
运算符用来比较两个对象的引用是否指向同一个内存地址,即判断它们是否是同一个对象实例。- 如果两个引用指向同一个内存位置(即同一个对象),则
==
会返回true
,否则返回false
。
2. 对象相等(.equals()
)
equals()
方法用来比较两个对象的内容是否相等。它通常被用来比较对象的状态(例如字符串的字符序列是否一致)。- 需要注意的是,
equals()
方法的默认实现(在Object
类中)与==
相同,比较的是对象的引用。如果需要按内容比较,类通常会重写equals()
方法(例如,String
类就重写了equals()
方法来比较字符串内容)。
3. 代码示例
public class StringComparison {
public static void main(String[] args) {
String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// 使用 == 比较字符串的引用相等
System.out.println(str1 == str2); // false
System.out.println(str1 == str3); // true
// 使用 equals 方法比较字符串的内容是否相等
System.out.println(str1.equals(str2)); // true
System.out.println(str1.equals(str3)); // true
}
}
4. 输出结果
false
true
true
true
5. 解释
str1 == str2
返回false
:str1
和str2
是两个不同的对象实例(一个是字符串池中的对象,一个是通过new
创建的新的String
对象),它们的引用不同,==
比较的是引用地址。str1 == str3
返回true
:str1
和str3
都指向字符串池中的同一个"hello"
字符串对象,它们的引用相同。str1.equals(str2)
返回true
:equals()
方法比较的是字符串的内容,str1
和str2
的内容相同,虽然它们是不同的对象实例,但内容相同,所以equals()
返回true
。str1.equals(str3)
返回true
:str1
和str3
的内容相同,因此equals()
返回true
。
6. 总结
- 引用相等:使用
==
来比较两个对象的引用是否指向同一个内存地址。 - 对象相等:使用
equals()
方法来比较两个对象的内容是否相等(通常需要根据类的需求重写equals()
方法)。
需要特别注意的是,String
类已经重写了 equals()
方法来根据内容比较两个字符串,因此在字符串比较时通常使用 equals()
。
如果一个类没有声明构造方法,该程序能正确执行吗?
答案是 可以执行,但有一些细节需要理解。
1. 默认构造方法
- 如果一个类没有显式地声明任何构造方法,Java 编译器会自动为这个类提供一个 默认的无参构造方法。这个默认的构造方法没有参数,且不做任何特殊的初始化工作,它只会调用父类的构造方法并初始化类的实例变量为默认值(例如,
int
类型为0
,boolean
为false
,对象引用为null
)。
public class MyClass {
// 没有声明构造方法
}
public class Main {
public static void main(String[] args) {
MyClass obj = new MyClass(); // 会调用默认构造方法
}
}
在这个例子中,MyClass
没有显式声明构造方法,但我们仍然可以通过 new MyClass()
创建该类的对象。编译器会自动提供一个默认的无参构造方法。
2. 自定义构造方法的影响
- 如果你为类显式声明了一个带参数的构造方法(或者多个构造方法),那么 默认的无参构造方法将不会被自动提供,除非你显式地声明一个无参构造方法。
public class MyClass {
// 声明了带参数的构造方法,默认的无参构造方法不会自动生成
public MyClass(int a) {
System.out.println("构造方法调用:" + a);
}
}
public class Main {
public static void main(String[] args) {
MyClass obj = new MyClass(10); // 调用带参数的构造方法
// MyClass obj2 = new MyClass(); // 编译错误,因为没有无参构造方法
}
}
在这个例子中,MyClass
只有一个带参数的构造方法,没有无参构造方法。如果需要无参构造方法,你必须显式声明它:
public class MyClass {
// 显式声明无参构造方法
public MyClass() {
// 初始化逻辑
}
// 带参数构造方法
public MyClass(int a) {
System.out.println("构造方法调用:" + a);
}
}
3. 总结
- 没有声明构造方法:编译器会自动提供一个默认的无参构造方法。
- 声明了构造方法:如果你声明了带参数的构造方法,编译器就不再自动提供默认的无参构造方法。若需要无参构造方法,必须显式声明。
- 无论是默认构造方法还是自定义构造方法,都可以帮助我们初始化对象,确保类的实例能够正确创建。
构造方法的特点与是否可被 override?
1. 构造方法的特点
- 名称与类名相同:构造方法的名称必须与类名完全一致。
- 没有返回值:构造方法不具有返回类型,不能声明为
void
。 - 自动执行:在使用
new
关键字创建对象时,构造方法会自动被调用,无需显式调用。 - 初始化对象:构造方法主要作用是初始化对象的成员变量,确保对象处于一个合理的状态。
2. 构造方法是否可被 override
构造方法不能被重写(override)。这是因为构造方法是与类的实例化过程紧密相关的,而重写通常发生在继承体系中,是方法继承的行为。
- 构造方法是实例化类时自动调用的,它并不在继承体系中参与重写的过程。每个类都有自己的构造方法,它并不从父类继承构造方法。
- 如果子类没有显式定义构造方法,子类会自动调用父类的构造方法(如果父类提供了无参构造方法),但父类的构造方法并不会被“重写”。相反,子类可以通过调用父类的构造方法来初始化父类部分。
3. 构造方法可以被重载(overload)
虽然构造方法不能被重写,但它是可以重载的。重载构造方法意味着同一个类可以定义多个构造方法,它们的参数列表不同。
public class MyClass {
private int a;
private String b;
// 无参构造方法
public MyClass() {
this.a = 0;
this.b = "default";
}
// 带参数的构造方法
public MyClass(int a, String b) {
this.a = a;
this.b = b;
}
public void printValues() {
System.out.println("a: " + a + ", b: " + b);
}
public static void main(String[] args) {
MyClass obj1 = new MyClass(); // 调用无参构造方法
MyClass obj2 = new MyClass(10, "Hello"); // 调用带参构造方法
obj1.printValues();
obj2.printValues();
}
}
输出:
a: 0, b: default
a: 10, b: Hello
4. 总结
- 构造方法的特点:
- 名称与类名相同。
- 没有返回类型。
- 自动执行以初始化对象。
- 是否可重写(override):
- 不能重写。构造方法与对象的创建和初始化紧密关联,不参与继承关系中的方法重写。
- 是否可以重载(overload):
- 可以重载。可以定义多个构造方法,通过不同的参数列表来初始化对象。
面向对象三大特征
1. 封装
封装是面向对象编程的核心特征之一。封装的主要目的是隐藏对象的内部实现细节,仅暴露必要的接口(方法)给外部,控制对对象内部数据的访问。
- 保护数据:通过将类的属性声明为
private
来隐藏数据,确保外部无法直接访问和修改数据。 - 提供访问接口:通过公开的
public
方法(如 getter 和 setter)来访问和修改数据。 - 增强代码的可维护性和安全性:外部只能通过定义好的方法来操作对象的状态,从而避免数据的不当修改。
示例代码:
public class Student {
private int id; // 私有属性,不能直接访问
private String name;
// 获取id的方法
public int getId() {
return id;
}
// 设置id的方法
public void setId(int id) {
this.id = id;
}
// 获取name的方法
public String getName() {
return name;
}
// 设置name的方法
public void setName(String name) {
this.name = name;
}
}
通过 getId()
和 setId()
方法,外部代码可以安全地访问和修改 id
属性,确保对象数据的完整性和一致性。
2. 继承
继承是面向对象的第二大特征,它允许一个类从另一个类继承属性和方法。继承使得我们能够重用现有代码,创建新的类,并在其上添加特性或功能。
- 代码重用:子类可以继承父类的属性和方法,避免重复编写相同的代码。
- 扩展功能:子类可以增加新的属性和方法,也可以重写父类的方法来扩展或修改功能。
- 增强可维护性:继承关系清晰,可以很方便地进行扩展和维护。
例如,假设我们有一个父类 Person
和两个子类 Student
和 Teacher
,它们都有共同的特性(如 name
和 age
),但是又各自有不同的行为。
// 父类
public class Person {
protected String name;
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void introduce() {
System.out.println("I am " + name + ", " + age + " years old.");
}
}
// 子类
public class Student extends Person {
public Student(String name, int age) {
super(name, age);
}
public void study() {
System.out.println(name + " is studying.");
}
}
public class Teacher extends Person {
public Teacher(String name, int age) {
super(name, age);
}
public void teach() {
System.out.println(name + " is teaching.");
}
}
在这个例子中,Student
和 Teacher
都继承了 Person
类,重用 name
和 age
属性,并且各自拥有独特的行为(study()
和 teach()
方法)。
3. 多态
多态指的是同一个方法调用,表现出不同的行为。它允许一个父类引用指向子类的对象,并且根据实际对象类型调用对应的子类方法。
- 实现方式:多态通过方法重载和方法重写实现。
- 动态绑定:程序运行时,Java 会根据对象的实际类型决定调用哪个类的方法。
- 好处:多态可以提高代码的灵活性和可扩展性,允许通过父类引用操作不同子类的对象,简化代码的维护。
多态的特点包括:
- 继承关系:多态发生在继承或实现关系中。
- 方法调用的动态性:方法调用是在运行时决定的,而不是在编译时确定。
- 子类方法重写:如果子类重写了父类的方法,执行的是子类的方法。
示例代码:
// 父类
public class Animal {
public void sound() {
System.out.println("Animal makes a sound");
}
}
// 子类
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("Dog barks");
}
}
public class Cat extends Animal {
@Override
public void sound() {
System.out.println("Cat meows");
}
}
// 测试多态
public class Test {
public static void main(String[] args) {
Animal myDog = new Dog(); // 父类引用指向子类对象
Animal myCat = new Cat();
myDog.sound(); // Dog barks
myCat.sound(); // Cat meows
}
}
在这个例子中,Animal
类有一个 sound()
方法,Dog
和 Cat
类分别重写了该方法。当我们用 Animal
类型的引用调用 sound()
方法时,实际调用的是子类 Dog
或 Cat
的方法,这就是多态的体现。
总结
面向对象的三大特征:
- 封装:通过隐藏内部实现,提供对外接口,提高代码的安全性和可维护性。
- 继承:子类继承父类的属性和方法,增强代码重用性,便于扩展和维护。
- 多态:通过父类引用指向子类对象,根据实际类型执行相应的方法,提高代码的灵活性和可扩展性。
接口和抽象类的共同点和区别
共同点
- 不能实例化:接口和抽象类都不能直接实例化。它们只能被实现或继承后,才可以创建对象。
- 包含抽象方法:接口和抽象类都可以包含抽象方法,抽象方法没有方法体,必须在子类或实现类中提供实现。
区别
设计目的:
- 接口:主要用于定义行为规范,强制实现类遵循某些行为。例如,接口主要用于行为约束(如
Runnable
接口定义了run()
方法,任何实现该接口的类都必须提供run()
方法的实现)。 - 抽象类:用于共享代码,强调的是继承关系。抽象类用于提供类的共性实现,可以包含部分已实现的方法,允许子类继承这些方法。
- 接口:主要用于定义行为规范,强制实现类遵循某些行为。例如,接口主要用于行为约束(如
继承和实现:
- 接口:一个类可以实现多个接口,支持多重继承。
- 抽象类:一个类只能继承一个抽象类,Java 不支持多重继承。
成员变量:
- 接口:所有成员变量默认是
public static final
,必须初始化且不可修改。 - 抽象类:成员变量可以有不同的访问修饰符(
private
,protected
,public
),并且可以被修改、赋值或重新定义。
- 接口:所有成员变量默认是
方法:
- 接口:
- Java 8之前:接口中的方法默认是
public abstract
,即只能声明没有实现的抽象方法。 - Java 8及以后:接口支持
default
(默认方法),可以为接口提供方法的默认实现;支持static
(静态方法),可以在接口中定义静态方法;支持private
方法,用于接口内部的代码共享。
- Java 8之前:接口中的方法默认是
- 抽象类:可以包含抽象方法和非抽象方法。非抽象方法可以有具体实现,也可以是空方法。抽象类中的方法可以是
public
、protected
、private
等。
- 接口:
接口中的新特性(Java 8及以后)
默认方法:接口中可以定义具有默认实现的方法,允许在不修改实现类的情况下,向接口添加新方法。
public interface MyInterface { default void defaultMethod() { System.out.println("This is a default method."); } }
静态方法:接口可以定义静态方法,不能被实现类重写,只能通过接口名调用。
public interface MyInterface { static void staticMethod() { System.out.println("This is a static method in the interface."); } }
私有方法:接口可以定义私有方法,仅供接口内部使用,不能被外部类调用。
public interface MyInterface { // 默认方法 default void defaultMethod() { commonMethod(); } // 静态方法 static void staticMethod() { commonMethod(); } // 私有静态方法 private static void commonMethod() { System.out.println("This is a private method used internally."); } }
设计上的考量
- 使用接口:当你需要定义行为并希望不同的类实现这些行为时,使用接口。例如,多个不相关的类(如
Dog
和Car
)可以实现同一个接口(如Runnable
或Comparable
)。 - 使用抽象类:当你需要在类之间共享代码时,使用抽象类。抽象类允许部分实现,并通过继承提供通用功能的重用。
总结
特性 | 接口 | 抽象类 |
---|---|---|
设计目的 | 用于定义行为规范 | 用于共享代码和定义类的共性 |
继承/实现 | 一个类可以实现多个接口 | 一个类只能继承一个抽象类 |
成员变量 | 只能是 public static final 类型,且必须初始化 | 可以有任何访问修饰符,可以被修改 |
方法 | Java 8及以后可包含 default 、static 和 private 方法 | 可以有抽象方法和非抽象方法,有方法体 |
构造函数 | 不能有构造函数 | 可以有构造函数 |
多继承支持 | 支持多个接口的多继承 | 不支持多继承,不能继承多个抽象类 |
深拷贝与浅拷贝的区别与引用拷贝
在 Java 中,深拷贝和浅拷贝是两种不同的对象复制方式,主要体现在复制对象时是否复制其中的引用类型字段(即对象内部的对象)。而引用拷贝则是对对象引用的简单赋值,导致多个引用指向同一个对象。
浅拷贝
浅拷贝创建了一个新的对象,但这个新对象的字段(如果是引用类型)仍然指向原对象的内存地址。换句话说,浅拷贝只会复制对象本身,而不会递归地复制该对象引用的其他对象,因此原对象和新对象共享内部的引用类型字段。
浅拷贝的实现:
- 在 Java 中,
clone()
方法通常用于实现浅拷贝。通过super.clone()
方法克隆对象时,系统会复制对象本身,并复制其中基本数据类型字段的值。 - 对于引用类型字段,浅拷贝仅复制引用地址,导致原对象和拷贝对象共享同一内存中的引用。
class Person implements Cloneable {
private Address address;
public Person(Address address) {
this.address = address;
}
@Override
public Person clone() {
try {
return (Person) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
public Address getAddress() {
return address;
}
}
class Address implements Cloneable {
private String city;
public Address(String city) {
this.city = city;
}
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Test {
public static void main(String[] args) {
Address address = new Address("New York");
Person person1 = new Person(address);
Person person2 = person1.clone();
System.out.println(person1.getAddress() == person2.getAddress()); // true
}
}
解析:
person1
和person2
的Address
引用指向相同的内存地址。- 对
person2
的修改会影响person1
,因为它们共享同一个Address
对象。
深拷贝
深拷贝则创建一个全新的对象,并且递归地复制对象内部的所有引用类型字段。深拷贝保证了原对象与拷贝对象在堆内存中完全独立,修改拷贝对象中的任何字段(包括引用类型字段)不会影响原对象。
深拷贝的实现:
- 在实现深拷贝时,我们通常会手动调用内部对象的
clone()
方法或通过其他方法确保深层次的对象也被复制。
class Person implements Cloneable {
private Address address;
public Person(Address address) {
this.address = address;
}
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
person.address = person.address.clone(); // 手动拷贝地址对象,确保深拷贝
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
public Address getAddress() {
return address;
}
}
class Address implements Cloneable {
private String city;
public Address(String city) {
this.city = city;
}
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Test {
public static void main(String[] args) {
Address address = new Address("New York");
Person person1 = new Person(address);
Person person2 = person1.clone();
System.out.println(person1.getAddress() == person2.getAddress()); // false
}
}
解析:
person1
和person2
的Address
引用指向不同的内存地址。person2
和person1
在地址信息上的修改是互不影响的,因为person2
内部的Address
对象是通过深拷贝得到的。
引用拷贝
引用拷贝并不是一种真正的拷贝,而是简单的引用赋值。它使得两个引用指向同一个对象。引用拷贝不会创建新对象,只是使多个引用变量指向同一个内存位置,因此修改其中一个引用的内容会直接影响另一个引用。
引用拷贝的示例:
Address address1 = new Address("New York");
Address address2 = address1; // 引用拷贝
address2.city = "Los Angeles"; // 修改 address2,会影响 address1
System.out.println(address1.city); // "Los Angeles"
解析:
address1
和address2
是两个不同的引用,但它们指向相同的Address
对象。- 修改
address2
的city
属性,address1
的city
属性也会发生变化,因为它们实际上引用的是同一个对象。
总结
拷贝类型 | 描述 | 内存行为 | 特点 |
---|---|---|---|
引用拷贝 | 不创建新对象,只是引用的拷贝 | 两个引用指向同一个对象 | 修改其中一个引用的内容会影响另一个引用。 |
浅拷贝 | 创建一个新对象,引用类型字段仅复制地址 | 新对象的引用类型字段指向与原对象相同的内存地址 | 原对象和拷贝对象的引用类型字段共享同一内存。 |
深拷贝 | 创建新对象,递归复制所有引用类型字段 | 新对象的引用类型字段指向新的内存地址,拷贝所有嵌套对象的内容 | 原对象和拷贝对象完全独立,修改拷贝对象不会影响原对象。 |
深拷贝和浅拷贝的选择依据:
- 如果对象中没有嵌套引用类型字段(即没有复杂对象),那么浅拷贝通常足够。
- 如果对象包含嵌套对象或复杂的数据结构,需要确保完全独立的对象,选择深拷贝。
深拷贝和浅拷贝的实现方法多种多样,通常使用 clone()
方法来实现拷贝,但深拷贝的实现通常需要递归地处理内部引用对象,保证每个对象都得到独立的复制。
二、Object
Object
类的常见方法
Object
类是 Java 中的所有类的父类,它提供了许多方法,这些方法对所有的 Java 对象都有效。以下是 Object
类中常见的 11 个方法,简要描述及其作用:
1. getClass()
public final native Class<?> getClass()
- 作用:返回当前对象的
Class
对象,表示当前对象所属的类。 - 备注:
Class
对象包含了该类的所有信息,可以用来反射操作。
2. hashCode()
public native int hashCode()
- 作用:返回对象的哈希码(hash code)。哈希码是一个整数,用于标识对象在哈希表中的位置。
- 备注:
hashCode
方法通常和equals
方法一起重写。两个相等的对象应该具有相同的哈希码。
3. equals(Object obj)
public boolean equals(Object obj)
- 作用:比较两个对象的内存地址是否相等,或者根据业务逻辑判断两个对象是否“相等”。
- 备注:
String
类重写了equals
方法,使其比较的是字符串内容而不是引用地址。自定义类如果需要逻辑上的相等比较,通常需要重写该方法。
4. clone()
protected native Object clone() throws CloneNotSupportedException
- 作用:创建并返回当前对象的一个拷贝。默认实现是浅拷贝。
- 备注:
clone
方法是protected
,所以需要通过子类继承或在当前类中声明为public
。若对象实现了Cloneable
接口,可以调用此方法。
5. toString()
public String toString()
- 作用:返回当前对象的字符串表示,默认返回对象的类名加对象的哈希码(例如:
ClassName@hashcode
)。 - 备注:通常会重写该方法,用于返回更具可读性的对象描述。
6. notify()
public final native void notify()
- 作用:唤醒一个在该对象监视器上等待的线程。
- 备注:通常和
wait()
方法一起使用,notify()
只能唤醒一个等待线程,如果有多个线程等待,则唤醒哪个线程无法预测。
7. notifyAll()
public final native void notifyAll()
- 作用:唤醒所有在该对象监视器上等待的线程。
- 备注:和
notify()
相似,但是notifyAll()
会唤醒所有等待的线程。
8. wait(long timeout) throws InterruptedException
public final native void wait(long timeout) throws InterruptedException
- 作用:使当前线程等待指定时间,期间释放对象锁,直到超时或被唤醒。
- 备注:该方法会抛出
InterruptedException
异常,常见于多线程编程中,用来控制线程的执行时间。
9. wait(long timeout, int nanos) throws InterruptedException
public final void wait(long timeout, int nanos) throws InterruptedException
- 作用:使当前线程等待指定的时间和额外的纳秒时间,期间释放对象锁。
- 备注:该方法允许指定更精确的等待时间(纳秒级)。
10. wait() throws InterruptedException
public final void wait() throws InterruptedException
- 作用:使当前线程永久地等待,直到被其他线程唤醒。
- 备注:该方法没有超时参数,通常和
notify()
、notifyAll()
配合使用。
11. finalize()
protected void finalize() throws Throwable { }
- 作用:在对象被垃圾回收器回收之前调用的方法,通常用于清理资源。
- 备注:
finalize()
方法已经被弃用,建议使用try-with-resources
或手动管理资源。它是为那些需要进行清理工作(例如关闭文件流、数据库连接等)的对象提供的。
总结
Object
类是所有 Java 类的根类,因此它提供了许多通用的方法,包括对象的克隆、比较、哈希码生成、字符串表示、线程同步、对象销毁等。尽管这些方法在所有 Java 类中都可以使用,但根据实际需求,我们通常会根据业务场景重写一些方法(如 toString()
、equals()
和 hashCode()
)。
==
和 equals()
的区别
==
和 equals()
都用于比较对象,但它们的比较方式不同,主要体现在基本数据类型与引用类型的比较以及不同对象间的比较。
1. ==
运算符
- 基本数据类型:
==
比较的是值是否相等。例如,int a = 5; int b = 5;
,a == b
为true
,因为它们的值相同。 - 引用数据类型:
==
比较的是对象的引用(即内存地址),而不是对象的内容。例如:在上面的例子中,String a = new String("ab"); String b = new String("ab"); System.out.println(a == b); // false
a
和b
虽然内容相同,但它们是两个不同的对象,位于不同的内存地址,因此==
返回false
。
2. equals()
方法
equals()
方法用于比较两个对象的内容是否相等。equals()
方法是Object
类的一个方法,所有类都可以重写它。默认情况下,Object
类的equals()
方法实现与==
运算符相同,即比较对象的引用地址。如果子类重写了equals()
方法,通常会比较对象的内容(属性值)。- 如果类没有重写
equals()
方法,那么默认行为是通过引用地址来比较对象。 - 如果类重写了
equals()
方法,则通常比较对象的属性值来判断是否“相等”。
例如,
String
类重写了equals()
方法,以比较字符串的内容:String a = new String("ab"); String b = new String("ab"); System.out.println(a.equals(b)); // true
在这里,
a.equals(b)
返回true
,因为String
类的equals()
方法比较的是字符串的内容。- 如果类没有重写
3. String
中 equals()
方法的实现
String
类重写了 equals()
方法,使其比较字符串的内容而非引用地址。其实现代码大致如下:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String) anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i]) {
return false;
}
i++;
}
return true;
}
}
return false;
}
this == anObject
:首先检查两个引用是否指向同一个对象(即地址相同),如果是,则返回true
。anObject instanceof String
:确保anObject
是String
类型。- 比较两个
String
的字符数组value
是否完全相同。
4. 使用示例
public class EqualsTest {
public static void main(String[] args) {
String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b 为另一个引用,内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb); // true
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
System.out.println(42 == 42.0); // true
}
}
输出结果:
true
false
true
true
5. 总结
==
比较的是 基本数据类型的值 或 引用类型的内存地址(即对象的引用)。equals()
比较的是 对象的内容(对于String
和其他重写了equals()
的类来说,是对象的属性值)。- 对于自定义类,如果需要按照属性值比较对象是否相等,通常需要重写
equals()
方法。
hashCode()
的作用是什么?
hashCode()
是 Object
类中的一个方法,其返回值是一个整数值(int
类型),用于表示对象的哈希值。哈希值在许多集合类(如 HashMap
、HashSet
)中有重要作用,特别是在 哈希表 数据结构的实现中,hashCode()
用于确定对象在集合中的存储位置。
1. 哈希码的用途
哈希表(Hash Table):哈希表存储数据时通过 哈希码 来确定对象存储的位置。哈希码决定了该对象的存储桶(bucket)位置,哈希表可以利用该位置快速访问对象,从而实现常数时间复杂度(
O(1)
)的快速查找。集合类:许多 Java 集合类(如
HashMap
、HashSet
)都依赖于hashCode()
来优化性能。在这些集合中,元素是通过其哈希码快速定位的。- 在
HashSet
中,集合内的元素是唯一的,hashCode()
用于确定元素是否已存在。 - 在
HashMap
中,hashCode()
用于确定键值对存储的位置,并结合equals()
方法来处理哈希冲突。
- 在
2. hashCode()
的定义与实现
hashCode()
方法定义在 Object
类中,因此每个 Java 对象都继承了该方法。在默认实现中,hashCode()
返回的是对象的内存地址的散列值,但是这个值并不一定总是根据对象的内容生成。如果你需要对象内容一致的判断,通常会重写 hashCode()
和 equals()
方法。
public native int hashCode();
3. 重写 hashCode()
的规则
如果你重写了 equals()
方法,你通常也应该重写 hashCode()
方法,以确保哈希表的行为符合预期。
重写 hashCode()
时有几个规则需要遵守:
- 一致性:如果对象的属性没有发生变化,那么每次调用
hashCode()
方法应该返回相同的值。 - 相等对象的哈希码必须相等:如果两个对象通过
equals()
方法比较返回true
,则它们的hashCode()
方法返回值也必须相等。 - 不同对象的哈希码可以不同:不同的对象通过
hashCode()
比较时,不一定需要返回不同的值,但相同的hashCode
并不意味着对象相等。 - 性能:
hashCode()
方法应尽量避免发生冲突,若不同对象具有相同的哈希码(即哈希冲突),会降低哈希表查找的效率。
4. 重写 hashCode()
示例
如果你有一个 Person
类,表示一个人,包含 name
和 age
两个字段,你可以根据这两个字段的内容来重写 hashCode()
和 equals()
方法。
import java.util.Objects;
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && name.equals(person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
在上面的代码中:
equals()
用于判断两个Person
对象是否相等,比较它们的name
和age
是否相同。hashCode()
使用Objects.hash(name, age)
方法生成一个基于name
和age
的哈希码,这样可以确保相同的name
和age
的Person
对象有相同的哈希码。
5. hashCode()
和 equals()
的关系
hashCode()
和 equals()
是紧密相关的:
- 如果两个对象通过
equals()
比较相等(即equals()
返回true
),那么它们的hashCode()
也必须相等。 - 如果两个对象的
hashCode()
相等,这并不意味着它们一定相等,哈希冲突可能会发生,这时候需要通过equals()
来进一步判断。
6. 示例:hashCode()
在集合中的应用
import java.util.HashSet;
public class HashCodeExample {
public static void main(String[] args) {
Person p1 = new Person("John", 25);
Person p2 = new Person("John", 25);
HashSet<Person> set = new HashSet<>();
set.add(p1);
set.add(p2);
System.out.println(set.size()); // 输出 1
}
}
在上面的代码中,p1
和 p2
内容相同,hashCode()
返回相同的值,且 equals()
返回 true
,因此 HashSet
中认为它们是相等的,最终集合的大小为 1。
7. 总结
hashCode()
方法返回的是对象的哈希值,通常用于快速查找对象(例如哈希表中的存储位置)。- 在自定义类中,如果重写了
equals()
方法,则应该重写hashCode()
方法,确保equals()
相等的对象拥有相等的哈希码。 - 哈希冲突是
hashCode()
和equals()
配合使用时的一个挑战,设计时需要特别注意两者的一致性。
为什么要有 hashCode()
?
hashCode()
作为 Object
类的一部分,主要是为了优化 Java 中一些容器类(如 HashSet
、HashMap
)的性能。通过使用哈希码,容器可以更加高效地定位元素,而不需要每次都进行全量比较。我们可以通过 哈希表 的查找机制来理解为什么要有 hashCode()
方法。
1. 哈希表的工作原理
哈希表(如 HashSet
和 HashMap
)依赖于 哈希函数 来将对象映射到表的一个位置,这个位置称为“桶”。每个对象都有一个哈希码,该哈希码通过哈希函数映射到表中的一个位置。
- 快速定位:哈希码使得容器可以快速定位元素的位置,减少了查找的时间复杂度。
- 避免全量比较:通过
hashCode()
,容器不需要在每次查找时对所有对象进行逐一比较(即equals()
),从而显著提高了效率。
2. 哈希碰撞与 equals()
的作用
虽然 hashCode()
的主要目的是帮助容器快速定位元素,但 不同的对象可能会有相同的哈希码(这称为 哈希碰撞)。因此,当两个对象的哈希码相同时,容器需要使用 equals()
方法来进一步判断它们是否相等。这个过程叫做 冲突解决。
哈希碰撞:由于哈希算法的有限性,不同的对象可能会产生相同的哈希值。即使它们有相同的哈希码,
equals()
也必须用来确保这两个对象的内容是相等的。效率优化:通过首先比较
hashCode()
,容器能够快速排除大多数不相等的对象,只对哈希码相等的对象进一步调用equals()
,大大减少了比较次数,从而提高了性能。
3. 总结为什么需要 hashCode()
和 equals()
两者配合
hashCode()
用于快速查找:它帮助容器快速定位对象的大致位置,通过哈希表的结构减少了比较的次数。equals()
用于解决哈希碰撞:当多个对象的哈希码相同时,容器会使用equals()
来确保这些对象是否真正相等。
这是 hashCode()
和 equals()
必须同时存在的原因。它们是配合使用的:
- 如果两个对象的
hashCode()
不相等,则它们一定不相等,容器可以直接排除。 - 如果两个对象的
hashCode()
相等,则必须使用equals()
来判断它们是否真正相等。
4. 举例:HashSet
如何检查重复
以下是一个使用 HashSet
的例子,帮助理解为什么要有 hashCode()
和 equals()
:
import java.util.HashSet;
public class HashCodeExample {
public static void main(String[] args) {
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
HashSet<Person> set = new HashSet<>();
set.add(p1); // 加入 p1
set.add(p2); // 加入 p2, 检查是否重复
System.out.println(set.size()); // 输出 1,因为 p1 和 p2 相等
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && name.equals(person.name);
}
@Override
public int hashCode() {
return name.hashCode() * 31 + age;
}
}
- 在这个例子中,
p1
和p2
的name
和age
相同,因此equals()
会返回true
,hashCode()
也会返回相同的值。 HashSet
通过hashCode()
方法首先检查p1
和p2
是否具有相同的哈希值。由于它们的哈希值相等,HashSet
会进一步调用equals()
方法来检查它们是否相等。- 由于
equals()
返回true
,HashSet
会认为p1
和p2
是重复的,不会重复加入。
5. 为什么 hashCode()
和 equals()
必须一致
根据 Java 的合同,如果你重写了 equals()
方法,通常你也应该重写 hashCode()
方法。它们的关系如下:
- 如果两个对象通过
equals()
比较相等,那么它们的hashCode()
也必须相等。 - 如果两个对象的
hashCode()
不相等,它们一定不相等。
如果不遵循这一契约,可能会导致容器行为不正确,特别是在 HashSet
和 HashMap
这样的集合类中,它们依赖 hashCode()
来定位元素,错误的 hashCode()
实现可能导致无法正确查找或存储元素。
总结
hashCode()
和 equals()
的配合使用,旨在提高集合类(如 HashSet
、HashMap
)的效率。通过 hashCode()
可以快速定位对象的位置,减少比较次数;而 equals()
则用来解决哈希碰撞问题,确保相同哈希码的对象是否真的是相等的。因此,它们是容器中高效查找和去重的关键方法。
为什么重写 equals()
时必须重写 hashCode()
方法?
在 Java 中,如果你重写了 equals()
方法,那么通常也需要重写 hashCode()
方法。这是因为 hashCode()
和 equals()
方法之间有一个 基本契约,它们必须相互配合才能确保容器类(如 HashSet
、HashMap
)正确工作。
hashCode() 和 equals() 的基本契约
Java 的 Object
类定义了 hashCode()
和 equals()
方法,并且它们之间有以下规则:
- 如果两个对象通过
equals()
方法相等,那么这两个对象的hashCode()
值也必须相等。 - 如果两个对象的
hashCode()
值不相等,那么这两个对象一定不相等,即它们的equals()
方法会返回false
。
为什么重写 equals()
时必须重写 hashCode()
?
如果只重写了 equals()
方法而没有重写 hashCode()
,就可能导致以下问题:
错误的容器行为:例如,在
HashMap
或HashSet
中,如果两个对象通过equals()
判断是相等的,但它们的hashCode()
不相等,那么当你查询这些对象时,容器会认为它们是不同的对象,导致无法正确的查找、更新或去重。例如:
假设有两个
Person
对象,它们的属性相同(即equals()
方法返回true
),但是它们的hashCode()
值不同。此时如果将它们放入HashSet
中,由于hashCode()
不相等,HashSet
会认为它们是不同的元素,导致无法正确判断是否重复。
思考:重写 equals()
时没有重写 hashCode()
,使用 HashMap
可能会出现什么问题?
如果我们在 HashMap
中使用了只重写了 equals()
而没有重写 hashCode()
的类,可能会出现以下问题:
相同的键不能覆盖值:由于
equals()
判断两个对象相等,但hashCode()
值不同,HashMap
可能会认为它们是两个不同的键。这会导致相同内容的对象不能覆盖原有值,增加了冗余数据。查找不准确:当查找一个对象时,
HashMap
会使用对象的hashCode()
来确定桶的位置。如果两个对象的hashCode()
不一致,可能导致找不到正确的桶,从而无法正确查询对象。
总结:
- 如果两个对象通过
equals()
方法判断相等,那么它们的hashCode()
值也必须相等。 - 如果不遵守这个契约,可能导致在集合类(如
HashMap
、HashSet
)中出现不正确的行为,导致元素无法正确插入、查找或去重。 - 哈希碰撞:两个对象的
hashCode()
值相等并不意味着它们相等,哈希碰撞是不可避免的,仍然需要通过equals()
方法进一步判断。
因此,为了确保容器类(如 HashMap
和 HashSet
)的正确性和高效性,在重写 equals()
方法时,必须同时重写 hashCode()
方法。
三、String
String、StringBuffer、StringBuilder 的区别
在 Java 中,String
、StringBuffer
和 StringBuilder
都用于处理字符串,但它们之间有一些关键区别,主要体现在可变性、线程安全性、性能等方面。
1. 可变性
- String:不可变类,每次对
String
的修改都会生成一个新的String
对象。这意味着如果你频繁地对String
对象进行拼接或修改,会导致大量的内存和时间开销。 - StringBuffer 和 StringBuilder:这两个类是可变的。它们内部使用字符数组来存储数据,并且提供了多种修改字符串内容的方法,如
append
、insert
等。它们在内存中直接修改字符串的内容,而不需要生成新的对象。
// String 使用不可变机制
String str = "Hello";
str += " World"; // 会创建一个新的 String 对象
// StringBuilder 和 StringBuffer 使用可变机制
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 修改的是原有对象
2. 线程安全性
String:由于
String
对象是不可变的,所以它是线程安全的,不需要同步机制。StringBuffer:
StringBuffer
是线程安全的,因为它的所有方法都加了同步锁(synchronized
)。这意味着多个线程可以安全地操作同一个StringBuffer
对象,但同步机制带来了性能上的开销。StringBuilder:
StringBuilder
不具备线程安全性,它没有加锁机制,因此适用于单线程环境。由于没有同步,StringBuilder
的性能相较于StringBuffer
更高。
// StringBuffer 中的方法是线程安全的
StringBuffer sbf = new StringBuffer("Hello");
sbf.append(" World"); // 会加锁确保线程安全
// StringBuilder 中的方法不是线程安全的
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 不会加锁
3. 性能
String:由于不可变性,每次修改都会创建新的
String
对象,因此在对字符串进行频繁修改(如拼接)时会影响性能,尤其是在循环中频繁使用+
运算符时。StringBuffer 和 StringBuilder:由于它们是可变的,对字符串的修改是在原对象上进行的,因此在多次修改字符串时,性能较好。相比之下,
StringBuilder
在单线程环境下性能更高,因为它没有线程安全的开销。StringBuffer
的方法是线程安全的,但由于同步机制,它的性能较StringBuilder
稍差。StringBuilder
的方法没有同步机制,因此在多线程环境下使用时不安全,但在单线程环境中性能更优。
// String 在拼接时会频繁创建新对象,性能较差
String str = "";
for (int i = 0; i < 1000; i++) {
str += "a";
}
// StringBuilder 在拼接时不会创建新对象,性能较高
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("a");
}
4. 使用场景总结
String:适用于少量的字符串操作或常量字符串。由于它是不可变的,且性能较低,适合用于无需修改字符串的情况。
StringBuilder:适用于单线程环境中,需要频繁修改字符串的情况,如字符串拼接、动态构建字符串等。其性能优于
StringBuffer
,因为它不涉及同步。StringBuffer:适用于多线程环境中,需要频繁修改字符串的情况。由于其线程安全的特性,
StringBuffer
更适合并发场景,但相应的性能较StringBuilder
略低。
总结表格:
特性 | String | StringBuffer | StringBuilder |
---|---|---|---|
可变性 | 不可变 | 可变 | 可变 |
线程安全 | 是(因为不可变) | 是(方法加了 synchronized ) | 否(没有同步机制) |
性能 | 较差,频繁修改时会产生大量对象 | 较好,线程安全但较慢 | 最优,适合单线程环境 |
使用场景 | 不变的常量字符串 | 多线程环境中使用 | 单线程中频繁修改字符串 |
通过这些区别,我们可以根据实际需求选择合适的类。例如,如果是在多线程环境中处理大量数据并需要频繁修改字符串,StringBuffer
会是一个较好的选择;而在单线程环境中,StringBuilder
会带来更高的性能。
String 为什么是不可变的?
String
类的不可变性是 Java 设计中的一个重要特性,它有助于提高性能、保证安全性并简化编程。具体原因如下:
1. 不可变的核心原因
String
的不可变性并不是因为 final
修饰了存储字符串的字符数组(char[]
)。虽然 String
中的 value
数组被 final
修饰,但这并不意味着数组中的内容不能更改。final
仅保证了该数组引用不可重新指向其他对象,但数组本身是可以修改的。然而,String
的真正不可变性来自以下几个原因:
value
数组被private
和final
修饰:value
数组用于存储字符串数据,它是私有的,并且没有提供任何直接访问或修改该数组的公共方法。由于该数组是final
修饰的,因此它不能指向其他数组,但这并不限制数组中的内容被修改。没有修改字符串的方法:
String
类没有提供任何方法来修改字符串的内容。比如没有提供像append()
、replace()
等直接修改字符串内容的方法。任何修改字符串的操作都会创建一个新的String
对象。String
类本身是final
:String
被final
修饰,这意味着它不能被继承。这进一步避免了子类破坏String
类不可变的特性。
2. 不可变性带来的优势
线程安全:由于
String
是不可变的,它是线程安全的。在多线程环境中,多个线程共享同一个String
对象时,不需要加锁就能保证安全性。这在性能要求较高的并发场景中尤其重要。缓存和共享:不可变性使得
String
可以被安全地共享和缓存。例如,Java 中的字符串常量池就是利用了String
的不可变特性,多个String
引用可以指向同一个内存中的字符串常量,而不需要担心内容被修改。性能优化:不可变性允许 JVM 进行一些优化,如字符串的常量池机制,避免了重复创建相同的
String
对象。在字符串池中,字符串常量会被缓存,如果其他地方需要相同的字符串,直接返回该字符串引用,而不是创建新的对象。防止潜在的错误:如果
String
可变,开发人员可能无意中修改了正在使用的字符串,导致程序行为不可预测。不可变性保证了String
对象的内容一旦创建后就不可更改,减少了错误的发生。
3. String 类在 Java 9 的变化
从 Java 9 开始,String
类的底层实现改为使用 byte[]
来存储字符串,而不是之前的 char[]
数组。这是为了提高内存效率,特别是在包含大量 Latin-1 字符(如 ASCII 字符)的字符串时。
- 使用
byte[]
存储字符:byte[]
比char[]
占用的内存更少,因为byte
类型仅占 1 个字节,而char
占用 2 个字节。在多数字符串只包含 Latin-1 字符时,使用byte[]
可以节省一半的内存空间。 - 支持两种编码方式:
String
支持两种编码方案:Latin-1
(每个字符用 1 字节表示)和UTF-16
(每个字符用 2 字节表示)。当字符串中的字符都在 Latin-1 范围内时,JVM 使用byte[]
来存储,并使用 1 字节表示每个字符。当包含更广泛的字符集(如汉字)时,String
会使用UTF-16
编码,仍然使用byte[]
进行存储。
4. 为什么 String
是不可变的设计?
安全性:不可变的对象在多线程环境中更安全。多个线程共享一个字符串时,不需要担心内容被改变。
性能优化:不可变的字符串可以被共享和缓存,减少内存开销。JVM 可以利用这一点进行字符串常量池优化。
简化开发:不可变对象更易于使用,避免了修改对象状态时可能产生的各种错误。
总结
String
的不可变性主要来源于:value
数组是private
和final
,并且String
类没有提供修改字符串内容的方法。String
本身是final
类,不能被继承,这进一步保证了不可变性。Java 9 开始,
String
的底层实现使用了byte[]
数组,采用了更高效的内存存储方式,并支持Latin-1
和UTF-16
两种编码。String
的不可变性提供了线程安全、性能优化和代码简洁性的多重好处,是 Java 设计中非常重要的特性。
字符串拼接用“+” 还是 StringBuilder
?
在 Java 中,字符串拼接可以使用“+”运算符或 StringBuilder
。虽然两者在功能上相似,但它们在性能和使用场景上有所不同。下面详细说明这两者的区别:
1. 使用“+”拼接字符串
在 Java 中,+
运算符用于拼接字符串时,实际上会依赖于 StringBuilder
类来进行内部处理。Java 编译器会将所有的字符串拼接操作转换为 StringBuilder
的 append()
方法调用,然后通过 toString()
方法生成最终的字符串。
例如:
String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;
编译器会将上述代码转换为:
String str4 = new StringBuilder().append(str1).append(str2).append(str3).toString();
这种方式对于少量的字符串拼接(尤其是直接拼接几个字符串常量)是可以接受的,但对于大量拼接操作(如在循环中)则可能导致性能问题。
2. 使用“+”在循环中的问题
当使用“+”在循环中进行字符串拼接时,编译器并不会复用 StringBuilder
对象,而是每次都会创建一个新的 StringBuilder
对象。每次拼接都会创建新的对象,最终生成的字符串会逐步追加到新的 StringBuilder
对象中。这种方式会造成性能上的损失,特别是在拼接大量字符串时,内存分配和对象创建的开销会显著增加。
示例:
String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
s += arr[i]; // 每次都会创建一个新的 StringBuilder 对象
}
System.out.println(s);
上述代码会被编译为类似下面的字节码:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < arr.length; i++) {
builder.append(arr[i]); // 每次都创建一个新的 StringBuilder 对象
}
String s = builder.toString();
3. 使用 StringBuilder
进行拼接
与直接使用 +
不同,StringBuilder
是设计用于执行多次字符串拼接的,它允许在原有的字符串上进行修改,不会创建新的对象。通过手动使用 StringBuilder
,我们可以避免在循环中创建过多的中间对象,从而提高性能。
示例:
String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
s.append(value); // 在一个 StringBuilder 对象上进行拼接
}
System.out.println(s);
通过这种方式,只有一个 StringBuilder
对象被创建,所有拼接操作都在这个对象上进行,避免了创建多个临时对象,从而节省了内存和提高了性能。
4. JDK 9 的优化
在 JDK 9 中,字符串拼接使用 +
运算符的方式已经做了一些优化。字符串拼接不再是通过简单的 StringBuilder
实现,而是使用一个动态方法 makeConcatWithConstants()
来优化字符串拼接。这个方法会在编译时就确定字符串的拼接过程,并提前分配空间,减少了临时对象的创建。
然而,尽管 JDK 9 对 +
运算符做了优化,这种优化主要针对简单的字符串拼接(如 a + b + c
)。如果是在循环中进行字符串拼接,JDK 9 的优化效果并不明显,性能上仍然不如手动使用 StringBuilder
来进行拼接。
总结:
少量拼接:对于少量的字符串拼接,使用
+
是可以接受的,特别是对于常量拼接,JVM 会进行优化。大量拼接:在循环或大量字符串拼接的场景下,应该优先使用
StringBuilder
。这不仅能避免创建大量临时对象,还能提高代码的执行效率。JDK 9 优化:虽然 JDK 9 对
+
运算符进行了优化,但对于大量拼接的情况,手动使用StringBuilder
仍然是最佳选择。
String#equals()
和 Object#equals()
的区别
String#equals()
和 Object#equals()
都是用来比较对象是否相等的方法,但它们的实现方式不同,导致了它们在行为上的显著差异。
1. Object#equals()
方法:
Object
类中的 equals()
方法是比较两个对象的内存地址,默认实现如下:
public boolean equals(Object obj) {
return (this == obj); // 判断对象的内存地址是否相同
}
- 作用:
Object#equals()
方法默认比较的是两个对象的内存地址(即它们是否指向同一个对象)。 - 结果:如果两个对象是同一个引用或指向同一个内存地址,则返回
true
;否则返回false
。
这种比较方式并不关心对象内容是否相同,而是关注引用是否相同。
2. String#equals()
方法:
String
类重写了 Object#equals()
方法,目的就是根据字符串的内容来判断两个 String
对象是否相等。String#equals()
方法的实现如下:
public boolean equals(Object anObject) {
if (this == anObject) {
return true; // 如果是同一个引用,直接返回 true
}
if (anObject instanceof String) {
String anotherString = (String) anObject;
return value.equals(anotherString.value); // 比较字符数组的内容
}
return false; // 如果不是 String 类型,返回 false
}
- 作用:
String#equals()
方法通过比较两个字符串的字符序列是否完全相同(即字符的内容),来判断两个String
对象是否相等。 - 内容比较:会逐字符地比较两个字符串的内容,只有当两个字符串的长度相同且每个字符都相等时,返回
true
。
区别总结:
- 内存地址 vs 内容:
Object#equals()
比较的是对象的内存地址(即引用是否相同),而String#equals()
比较的是字符串内容(字符序列)是否相同。 - 默认行为 vs 重写:
String#equals()
是Object#equals()
的重写版本,针对String
类型的数据做了内容的比较,而Object#equals()
默认只进行引用比较。
示例代码:
public class EqualsExample {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
String str3 = str1;
// 使用 Object#equals(),默认比较内存地址
System.out.println(str1.equals(str2)); // true (内容相等)
System.out.println(str1 == str2); // false (引用不同)
// 使用 String#equals(),比较内容
System.out.println(str1.equals(str3)); // true (引用相同)
System.out.println(str2.equals(str3)); // true (内容相同)
// 使用 == 判断内存地址
System.out.println(str1 == str3); // true (引用相同)
}
}
输出:
true
false
true
true
true
结论:
- 如果你想判断两个
String
对象的内容是否相同,应该使用String#equals()
。 - 如果你仅仅关心两个对象的引用是否相同(即它们是否指向同一个内存地址),可以使用
==
或Object#equals()
。
字符串常量池的作用
字符串常量池 是 JVM 提供的一个特殊的内存区域,用于存储字符串字面量。它的主要作用是避免在程序中创建多个相同内容的字符串对象,从而节省内存并提高性能。
作用和实现机制
共享存储:字符串常量池存储的是常量字符串字面量。如果程序中使用相同的字符串字面量,JVM 会从常量池中查找并返回已存在的对象,而不是重新创建一个新的字符串对象。这意味着对于常见的字符串,内存中只会有一个实例,避免了重复存储相同内容的字符串。
节省内存:因为常量池中只存储一份相同的字符串,可以有效减少内存的占用。尤其是对于很多重复使用的字符串(如
"ab"
、"hello"
等),这种优化尤为显著。提高效率:通过复用字符串常量池中的对象,减少了创建和垃圾回收的开销。常量池的查找是非常高效的,通常使用哈希表或类似的数据结构。
示例代码:
public class StringPoolExample {
public static void main(String[] args) {
// 字符串常量池中的对象
String str1 = "hello";
String str2 = "hello"; // 直接引用常量池中的字符串对象
// 创建新的字符串对象
String str3 = new String("hello");
System.out.println(str1 == str2); // true, 因为 str1 和 str2 指向同一个常量池中的对象
System.out.println(str1 == str3); // false, str3 是通过 new 关键字创建的,指向堆内存中的对象
}
}
输出:
true
false
解释:
str1
和str2
:这两个字符串指向的是常量池中的同一个对象,因为"hello"
是一个字符串字面量,JVM 会在常量池中查找该字符串并重用它。str3
:这个字符串是通过new
关键字创建的,虽然它的内容是"hello"
,但是new String()
会在堆内存中创建一个新的对象,因此它与常量池中的"hello"
不同。
JVM 中字符串常量池的位置
字符串常量池位于 方法区(Method Area),在 JDK 7 之前,它与类信息(如类的字段、方法)共同存储在方法区中。从 JDK 7 开始,字符串常量池被移到了 堆内存 中进行管理,但它仍然具有特殊的存储和管理机制。
注意事项
new String("hello")
与直接赋值"hello"
:使用new
关键字创建的字符串对象不会直接使用字符串常量池中的对象,它会在堆内存中创建一个新的String
对象。通过
intern()
方法手动将字符串加入常量池:如果你想确保某个字符串进入常量池,可以调用String
的intern()
方法,它会检查常量池中是否存在相同内容的字符串,如果存在就返回池中的对象,否则将该字符串加入常量池。String str4 = new String("hello"); String str5 = str4.intern(); // 通过 intern() 方法将 str4 加入常量池 System.out.println(str1 == str5); // true, str5 引用常量池中的 "hello"
总结
- 目的:字符串常量池通过共享字符串对象,避免了重复创建相同内容的字符串,节省内存并提高效率。
- 原理:相同内容的字符串字面量会共享同一个对象实例,只有通过
new
关键字创建的字符串对象才会在堆中创建新实例。 - 优化:使用字符串常量池可以提高程序的性能,特别是在处理大量相同字符串时。
String s1 = new String("abc"); 创建了几个字符串对象?
这句话会根据字符串常量池的状态创建 1 或 2 个字符串对象。具体情况如下:
情况 1:字符串常量池中不存在 "abc"
步骤:
- 常量池:首先,JVM 会检查字符串常量池中是否存在字符串
"abc"
。如果不存在,JVM 会将"abc"
加入常量池。 - 堆内存:接下来,JVM 会使用
new String("abc")
在堆内存中创建一个新的String
对象,并使用常量池中的"abc"
初始化该堆对象。
- 常量池:首先,JVM 会检查字符串常量池中是否存在字符串
最终创建的对象数量:
- 1 个常量池中的字符串对象("abc")
- 1 个堆内存中的
String
对象(通过new
创建)
总结:总共会创建 2 个对象。
情况 2:字符串常量池中已存在 "abc"
步骤:
- 常量池:如果常量池中已经有了字符串
"abc"
,JVM 不会重新创建该对象,而是直接使用常量池中已有的"abc"
。 - 堆内存:通过
new String("abc")
,JVM 会在堆内存中创建一个新的String
对象,这个对象的内容是"abc"
,并且它引用常量池中的字符串"abc"
。
- 常量池:如果常量池中已经有了字符串
最终创建的对象数量:
- 1 个堆内存中的
String
对象(通过new
创建) - 常量池中
abc
对象不会被重新创建,已存在
- 1 个堆内存中的
总结:总共会创建 1 个对象(堆中的
String
对象)。
字节码分析
String s1 = new String("abc");
对应的字节码:
0 new #2 <java/lang/String> // 创建一个新的 String 对象(在堆内存中)
3 dup // 复制栈顶的对象引用
4 ldc #3 <abc> // 将常量池中的 "abc" 加载到栈顶
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V> // 使用常量池中的 "abc" 初始化堆中的 String 对象
9 astore_1 // 将堆中的 String 对象引用存储到局部变量表
- 解释:在这个过程中,
ldc #3 <abc>
会检查常量池中是否已经有"abc"
,如果没有,JVM 会将"abc"
加入常量池;然后,new String("abc")
会创建堆内存中的新字符串对象。
总结
- 如果常量池中不存在
"abc"
,会创建 2 个字符串对象(一个常量池中的"abc"
和一个堆内存中的新字符串对象)。 - 如果常量池中已经有
"abc"
,会创建 1 个新的堆内存中的字符串对象,并复用常量池中的"abc"
。
因此,最终创建的对象数量取决于字符串常量池的状态。
String#intern 方法的作用是什么?
String.intern()
是一个 native
(本地)方法,用于操作字符串常量池中的字符串对象引用。它的主要作用是确保字符串常量池中只有一个唯一的字符串实例。调用 intern()
方法时,JVM 会检查常量池中是否已经存在该字符串,如果存在则返回常量池中的引用,否则将该字符串添加到常量池中并返回其引用。
工作原理
- 常量池中已有相同内容的字符串:如果常量池中已经存在与该字符串内容相同的对象,
intern()
方法会直接返回常量池中该对象的引用,而不需要再创建新的字符串对象。 - 常量池中没有相同内容的字符串:如果常量池中没有与该字符串内容相同的对象,
intern()
方法会将当前字符串添加到常量池中,并返回该字符串的引用。
示例代码解析
// s1 指向字符串常量池中的 "Java" 对象
String s1 = "Java";
// s2 也指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
String s2 = s1.intern();
// 在堆中创建一个新的 "Java" 对象,s3 指向它
String s3 = new String("Java");
// s4 指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
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
解释
s1 = "Java": 字符串字面量
"Java"
会被自动添加到常量池中,s1
引用指向常量池中的"Java"
。s2 = s1.intern(): 调用
intern()
方法时,JVM 会检查常量池中是否已经有"Java"
。由于常量池中已经存在"Java"
,s2
也会引用常量池中的那个"Java"
对象。因此,s1 == s2
返回true
。s3 = new String("Java"): 使用
new String("Java")
创建一个新的String
对象,它位于堆内存中,s3
引用指向这个新的对象。s4 = s3.intern():
s3
引用的"Java"
是一个堆中的对象,调用intern()
方法时,JVM 会将其内容与常量池中的字符串进行比较。如果常量池中已有"Java"
,则s4
会引用常量池中的"Java"
对象。因此,s1 == s4
返回true
,而s3 == s4
返回false
,因为s3
引用堆内存中的对象,而s4
引用常量池中的对象。
总结
intern()
方法确保字符串常量池中的字符串对象唯一。- 如果常量池中已有相同内容的字符串,
intern()
返回常量池中的引用。 - 如果常量池中没有相同内容的字符串,
intern()
会将该字符串添加到常量池并返回其引用。
String 类型的变量和常量做“+”运算时发生了什么?
在 Java 中,当字符串做拼接操作时,尤其是使用 +
运算符时,JVM 会根据操作数是否为常量进行优化。对于常量字符串,编译器会进行常量折叠优化,将字符串拼接直接计算为常量并存储在常量池中。而对于运行时才能确定值的字符串,JVM 会通过 StringBuilder
来实现拼接操作。
示例代码:
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing"; // 编译时常量拼接
String str4 = str1 + str2; // 运行时拼接
String str5 = "string"; // 直接赋值
System.out.println(str3 == str4); // false
System.out.println(str3 == str5); // true
System.out.println(str4 == str5); // false
解释:
str3 = "str" + "ing"
:由于"str"
和"ing"
都是常量,编译器可以在编译时进行常量折叠(常量求值优化),因此str3
会直接赋值为常量"string"
,并存储在字符串常量池中。str4 = str1 + str2
:这里str1
和str2
是在运行时才能确定其值的字符串,因此拼接会在运行时通过StringBuilder
来实现,并不会被优化为常量。此时会创建一个新的String
对象。str5 = "string"
:直接赋值的"string"
直接引用常量池中的字符串对象。
由于 str3
在编译时就已经是常量池中的对象,而 str4
是运行时拼接出来的,所以它们指向不同的对象,str3 == str4
为 false
。同理,str4
和 str5
也不是相同的对象,str4 == str5
为 false
,而 str3 == str5
为 true
,因为它们都是常量池中的 "string"
对象。
常量折叠与优化
Java 编译器(Javac)会进行常量折叠优化。当字符串常量可以在编译时计算出来时,它会直接将拼接结果存放在常量池中。例如:
String str3 = "str" + "ing"; // 编译时已知结果
这会被优化为:
String str3 = "string"; // 编译期直接给出结果
运行时拼接与 StringBuilder
当拼接操作中包含了不可确定的字符串时,JVM 会通过 StringBuilder
来执行拼接。具体来说,+
运算符在背后被编译为对 StringBuilder
的调用。
例如:
String str4 = str1 + str2; // 运行时拼接
背后会被编译为:
StringBuilder sb = new StringBuilder();
sb.append(str1);
sb.append(str2);
String str4 = sb.toString();
这种拼接方式避免了创建过多的 String
对象,因此更为高效。
使用 final
修饰字符串
当字符串被 final
修饰时,它变成了常量,编译器可以在编译时确定其值,从而进行优化。此时,编译器会将其视为常量并直接拼接:
final String str1 = "str";
final String str2 = "ing";
String c = "str" + "ing"; // 编译时直接计算为常量
String d = str1 + str2; // 也可以在编译时优化
System.out.println(c == d); // true
此时,由于 str1
和 str2
都是 final
且其值可以在编译时确定,编译器会将 str1 + str2
视为常量表达式,直接拼接为 "string"
,因此 c
和 d
会指向相同的常量池对象。
编译时常量与运行时变量
如果其中某个字符串在运行时才能确定(比如调用方法生成的字符串),编译器无法进行常量折叠优化,那么拼接的结果就会在堆内存中创建新的对象:
final String str1 = "str";
final String str2 = getStr(); // getStr() 在运行时确定值
String c = "str" + "ing"; // 编译时常量
String d = str1 + str2; // 运行时拼接,创建新的堆对象
System.out.println(c == d); // false
这里 d
会是堆上的一个新对象,因为 str2
的值是在运行时动态确定的,不能在编译时优化。
总结:
- 编译时常量拼接:常量字符串在编译时可以被优化,拼接结果会被直接存入字符串常量池中。
- 运行时拼接:如果拼接中的变量是运行时决定的,JVM 会使用
StringBuilder
来进行拼接,并且不会直接存储在常量池中。 final
修饰的字符串:在final
修饰的字符串变量拼接时,编译器能够将其视为常量,进行常量折叠优化。