欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 新闻 > 会展 > 类的高级特性与语法细节

类的高级特性与语法细节

2025/4/27 16:01:15 来源:https://blog.csdn.net/2401_83830408/article/details/147543697  浏览:    关键词:类的高级特性与语法细节

static 静态关键字

Java中的static关键字用于修饰类的成员(属性或方法),表示“静态”的含义,即属于类本身,而非某个对象。静态成员在内存中只有一份,在类加载时初始化,生命周期贯穿程序运行始终。下面分别介绍静态变量、静态方法和静态代码块,并结合示例说明其作用和特性。

静态变量(类变量)

定义与特性: 静态变量也称为“类变量”,由static修饰。与实例变量不同,静态变量属于类本身,对所有实例来说只有一份拷贝。也就是说,无论创建多少对象,静态变量都指向同一块存储空间,所有对象共享这一变量。当其中一个对象修改静态变量的值时,其他对象访问该静态变量时也能看到更新后的值。静态变量在类加载时就被分配内存(存储于方法区或元空间中),在程序结束或类卸载时才会释放。

生命周期比喻: 可以把类比作一座学校,类的实例对象就是学校里的每一个学生,那么静态变量就好比学校的公共公告板。公告板是属于整所学校的(属于类),所有学生(实例)都共享同一块公告板的信息。如果公告板上写着当前在校学生总数,那么每新增或减少一个学生,这个数字都会更新,所有人看到的都是同一个总人数。这块公告板在学校建立时就挂好了(类加载时初始化),直到学校关闭(程序结束/类卸载)才移除。无论有多少学生进出,这个公告板始终存在且只有一块。

示例: 假设我们有一个学生类,每当创建一个学生对象时,希望自动累加学生总数。这时就适合使用静态变量来记录学生总数:

class Student {String name;                  // 实例变量,每个对象都有自己的namestatic int totalStudents = 0; // 静态变量,所有对象共享public Student(String name) {this.name = name;totalStudents++; // 每创建一个学生对象,累计总数加1}
}public class TestStaticVar {public static void main(String[] args) {Student s1 = new Student("小明");Student s2 = new Student("小红");System.out.println("学生总数: " + Student.totalStudents); // 输出学生总数}
}

运行上面的代码,会输出:

学生总数: 2

这里我们通过Student.totalStudents访问静态变量,得到总数2。注意可以直接用类名访问静态变量,而不需要实例对象。这段代码演示了静态变量的共享特性:无论创建多少个Student对象,totalStudents在内存中只有一份,其值随每次构造函数调用而更新。

类变量 vs 实例变量:

  • 内存位置: 实例变量存储在堆内存的对象中;静态变量存储在方法区(或者JVM的元数据区)中。
  • 生命周期: 实例变量随着对象创建而存在,对象被垃圾回收后消失;静态变量随着类加载而存在,直到类卸载或JVM退出才消失。
  • 作用域: 实例变量必须通过对象引用访问;静态变量可以通过类名直接访问(当然也能通过对象访问,但会收到警告不建议这样用)。
  • 共享性: 每个对象都有自己的一份实例变量,互不影响;静态变量在类级别共享,所有实例共用同一份数据。
静态方法(类方法)

定义与特性: 静态方法是使用static修饰的方法,又称“类方法”。静态方法同样属于类本身,而不是某个对象,所以无需创建对象就可以调用。调用方式一般是ClassName.methodName()。静态方法常用于工具类或不依赖于实例状态的方法。例如Math.max(a,b)Arrays.sort(array)都是静态方法。静态方法在类加载时就可用,且由于没有this对象,它无法直接访问实例变量和实例方法,在静态方法内部也无法使用thissuper关键字(因为此时可能尚未有对象实例,this无所指)。静态方法只能访问本类的静态变量或调用静态方法,这一点需要特别注意。

使用场景比喻: 如果把类比作一个工厂模具,那么实例方法好比每个具体产品上的操作按钮,只能操作该产品自身的数据;而静态方法则像是工厂车间里公共可用的工具,不针对某个产品,它可以直接被拿来使用。例如,一个温度转换工具类可以提供静态方法把摄氏度转换为华氏度,这种操作不需要也不依赖特定的对象状态,就适合定义为静态方法。

示例: 我们在前述Student类中加入一个静态方法,用于打印当前学生总数:

class Student {// ...(前略,仍包含name和totalStudents等定义)// 静态方法:打印学生总数static void printTotalStudents() {System.out.println("当前学生总数: " + totalStudents);// 注意:静态方法中无法直接访问非静态成员,例如不能访问 name}
}public class TestStaticMethod {public static void main(String[] args) {// 未创建对象也可以调用静态方法Student.printTotalStudents(); // 输出: 当前学生总数: 0Student s1 = new Student("Alice");Student s2 = new Student("Bob");Student.printTotalStudents(); // 输出: 当前学生总数: 2}
}

在上面的代码中,Student.printTotalStudents()可以直接调用而不需要学生对象。第一次调用输出0(此时还没有创建学生实例,totalStudents初始为0),后续创建了两个学生再调用时输出2。静态方法无法访问实例变量,比如在printTotalStudents()中我们不能直接访问name属性,否则编译错误。这验证了静态方法只能操作静态数据或通过参数获取所需信息。

静态代码块

定义与作用: 静态代码块(Static Initialization Block)是用static { ... }包裹的一段代码块,位于类定义中。静态代码块在类加载时执行,并且只执行一次。它通常用于初始化静态变量的复杂逻辑,或执行类级别的一次性设置操作。静态块会在类被加载后、对象创建前就执行,比构造函数更早。若一个类中有多个静态块,执行顺序按照它们在类中出现的先后顺序。并且如果静态代码块和主方法在同一个类中,静态块的执行优先级高于main方法。

执行时机比喻: 把类加载想象成剧院开演前的准备阶段,静态代码块就是提前布置舞台的过程。观众(对象)还没入场之前,先把音响、灯光等准备好。这些准备工作(静态块)在开场时执行一次即可,所有场次通用,而不需要每个观众进场都重新布置舞台。

示例: 演示静态代码块的执行顺序:

class Example {static int staticVar;static {// 静态代码块System.out.println("静态代码块初始化");staticVar = 100;}public Example() {System.out.println("构造函数执行");}static {// 第二个静态代码块(如果有多个,按顺序执行)System.out.println("第二个静态代码块,被调用时staticVar=" + staticVar);}
}public class TestStaticBlock {public static void main(String[] args) {System.out.println("main方法开始");Example ex = new Example(); // 首次创建Example对象Example ex2 = new Example(); // 再次创建Example对象}
}

运行结果可能如下:

静态代码块初始化  
第二个静态代码块,被调用时staticVar=100  
main方法开始  
构造函数执行  
构造函数执行  

从输出可以看出:当Example类第一次被使用(这里是在main中首次创建对象)时,两个静态代码块按照顺序被执行了一次,静态变量staticVar在静态块中被初始化为100。随后进入main方法,创建第一个对象时调用构造函数,输出“构造函数执行”。创建第二个对象时,静态代码块没有再次执行(因为类已加载过),只调用构造函数。这说明静态代码块仅在类加载时运行一次。并且,静态代码块在main开始前已经执行完毕(即使它在代码中写在后面)。如果没有创建对象而直接使用类的静态成员,静态代码块也会在类加载时执行。例如若我们仅调用Example.staticVar,静态块依然会执行一次。

总结 static 要点
  • 加载时机: 类首次被加载(调用静态成员或创建对象时),静态变量分配内存并初始化,静态代码块执行。静态成员存在于类的生命周期中。
  • 共享特性: 静态成员在所有实例中共享一份,适合描述整个类的公共属性(如计数器、常量等)。
  • 访问方式: 建议通过类名.静态成员访问静态变量和方法。静态方法中不能直接使用实例成员或this关键字。
  • 典型应用: 工具类方法(如Math类),记录全局状态的数据(如缓存、计数),定义常量(与final结合)等。

理解了static关键字的行为,可以编写出更高效和语义清晰的代码。例如,将不随对象变化的属性设计为静态,可以减少每个对象的存储开销;将与对象无关的功能方法设计为静态,可以直接用类名调用,方便快捷。

final 关键字

Java中的final关键字表示“最终的、不可改变的”含义,可用于修饰变量、方法和类。被final修饰的元素具有以下含义:

  • final变量:值一旦初始化之后就无法更改(相当于常量)。
  • final方法:不能被子类重写(override)。
  • final类:不能被继承。

下面我们分别讲解这三种用法,并重点说明final在基本类型和引用类型变量上的区别。

final 修饰变量(常量)

final用于修饰变量时,该变量的值在初始化后便不可再修改。根据变量类型不同,其含义稍有区别:

  • 基本数据类型:被final修饰后,其数值在初始化后无法改变。
  • 引用类型:被final修饰后,引用在初始化后将一直指向同一个对象,不能指向别的对象。但引用指向的对象本身是可变的(除非对象自身是不可变类),也就是说可以修改对象内部的状态。

初始化要求: final变量必须在声明时或构造函数中被初始化一次。一旦赋值完成,就不能再重新赋值。如果试图在后续代码中修改其值,编译器会报错。

常量命名约定: 一般将final静态变量(即类常量)命名为全大写字母并用下划线分隔,例如:public static final int MAX_VALUE = 100;。这是一种代码规范,提示阅读者此变量是常量。

示例(基本类型):

class Constants {public static final double PI = 3.14159; // 定义一个常量
}
public class TestFinalVar {public static void main(String[] args) {System.out.println("圆周率: " + Constants.PI);// Constants.PI = 3.14; // 编译错误,无法给final变量赋值final int num = 5;// num = 6; // 编译错误,final基本类型值不可更改}
}

以上代码中,Constants.PI被定义为final且初始化为3.14159,之后无法再修改。尝试赋值Constants.PI = 3.14会导致编译错误。同样,局部变量num如果声明为final且赋值5,后续也不能重新赋值为6。final保证了这些值的不可变性。

示例(引用类型):

public class TestFinalReference {public static void main(String[] args) {final ArrayList<String> list = new ArrayList<>();list.add("Hello");list.add("World");System.out.println(list); // 输出: [Hello, World]// 修改引用本身:// list = new ArrayList<>(); // 编译错误,无法改变final引用指向// 但是可以修改对象内容:list.set(1, "Java");System.out.println(list); // 输出: [Hello, Java]}
}

在这个例子中,我们将list声明为final,并初始化为一个新的ArrayList对象。final保证了list引用会一直指向这个ArrayList对象。但通过list.addlist.set我们仍可以向列表中添加、修改元素——这些操作改变的是对象内部的数据,并不违反final约束。唯独尝试令list指向一个新的ArrayList实例会导致编译错误,因为list引用不可变。用生活中的比喻来说,final引用好比一张绑定的“车票”,一旦指定了目的地,就不能改签到别的地点,但你在目的地的活动(对象内部状态改变)不受影响。

特殊情况: 如果需要一个既是final又不可变的对象,那么对象本身也需要设计成不可变类(例如String类就是不可变的且引用常用final来修饰)。final关键字本身并不使对象内容不可变,它仅保证引用不改变或基本类型值不改变。

final 修饰方法

当一个方法被声明为final时,表示子类无法重写该方法的实现。父类的final方法对所有子类是封闭的,子类只能继承使用,不能修改行为。这在需要保持方法逻辑不被改变时很有用,例如一些安全性要求高或框架底层的方法,防止子类意外改变其功能。

特点:

  • final方法仍然可以被子类继承调用,但不能有与之同签名的override方法出现于子类,否则编译错误。
  • 将方法声明为final可能有助于编译器做性能优化(早期Java中有这个考虑,不过在现代JVM中,方法内联优化已经不依赖final关键字了)。主要原因还是出于设计和安全考虑。

示例:

class Animal {public final void sleep() {System.out.println("动物正在睡觉");}
}
class Dog extends Animal {// 试图重写sleep方法会导致编译错误// @Override// public void sleep() {//     System.out.println("小狗睡觉");// }
}
public class TestFinalMethod {public static void main(String[] args) {Dog dog = new Dog();dog.sleep(); // 调用继承自Animal的final方法}
}

在上述代码中,Animal类的sleep()方法被声明为final,因此Dog子类无法重写它。如果取消注释Dog中的sleep()方法,会出现编译错误:“无法重写最终方法”。运行dog.sleep()将直接调用父类Animal中定义的实现,输出“动物正在睡觉”。通过将方法设为final,保证了sleep方法的行为对所有动物子类都一致,不会被修改。

何时使用final方法: 当你设计一个类并希望某些方法的实现对子类是“固定的”或者出于安全考虑不想让子类改变它,就可以将该方法声明为final。例如java.lang.Object中的getClass()方法就是final的,保证了它始终返回正确的类信息,不能被篡改。

final 修饰类

当一个类被声明为final时,表示该类不能被继承final类通常出现在两种场景:

  1. 设计上不需要也不希望被继承:有些类天然就是最终形态,比如工具类、常量类,继承它们没有意义或者可能导致错误。
  2. 安全和不可变需求:有些类为了保证不可变性或安全性,禁止继承,防止子类破坏其性质。例如java.lang.String就是一个final类,任何人都不能定义一个子类去修改String的行为。这确保了字符串不可变的特性。

特点:

  • 所有的final类中的方法默认也隐式地是final(因为既然类无法继承,就不存在重写方法的问题)。因此final类的方法不需要显式声明为final(虽然语法上可以加,但没有意义)。
  • final类仍然可以实例化使用,但不能被作为父类。试图继承final类会导致编译错误。

示例:

final class Utility {public void doSomething() {System.out.println("执行某个操作");}
}
// 下面的代码如果取消注释将无法编译,因为Utility是final的
// class SubUtility extends Utility {}  // 编译错误:无法从最终类继承

Utility被声明为final,意味着不允许有子类。任何试图extends Utility的行为都会得到编译器错误提示。“最终类”在Java标准库中也很常见,例如java.lang.Math类就是final的,里面全是静态方法和常量;包装类如Integer, Double等也是final的,确保了它们的可靠性。

需要谨慎使用final来修饰类。因为一旦将类定义为final,就剥夺了其扩展的可能性。在框架设计中,一般只在必要时才将类设为final,过度使用可能降低代码的灵活性。

final 对基本类型和引用类型的不同点

这一点之前在final变量部分已阐述,总结如下:

  • 对于基本数据类型final变量,赋值后其值不可改变。这就是真正的常量,例如final int DAYS_IN_WEEK = 7;
  • 对于引用类型final变量,一旦引用指向某个对象后,就不能再改指向别的对象。但引用指向的对象的内部状态如果允许修改(对象是可变的),那么这种修改是被允许的。要获得真正不可变的对象,需要对象本身设计为不可变类(所有属性也用final且不提供修改方法)。

误区澄清: final和“不变”之间有关联但不完全相同。final保证的是引用不可变或值不可变,但如果希望一个对象完全不可变,需要将对象的所有字段也声明为final且不提供修改这些字段的方法。例如String类的实现中,内部字符数组是final且不提供修改方法,所以String对象一经创建内容就无法改变。换句话说,final关键字是构建不可变类的一块基石,但仅有final关键字并不足以确保对象不可变,设计不可变类还需遵守其他原则。

Object 类的核心方法

所有Java类都直接或间接继承自java.lang.Object类。Object是Java类层次的根,在它里面定义了一些非常重要的方法,几乎每个类都会用到或需要重写它们。核心的几个Object方法包括:

  • toString():返回对象的字符串表示。
  • equals(Object obj):判断两个对象是否“内容相等”。
  • hashCode():返回对象的哈希码值。
  • clone():创建对象的拷贝(克隆)。
  • finalize():对象被垃圾回收前的回调方法(已过时,不建议使用)。

下面我们逐一介绍这些方法的作用、默认行为,并通过示例说明如何正确地重写和使用它们。同时会讨论==运算符与equals()方法的区别,以及hashCode()equals()的契约关系,浅拷贝与深拷贝的区别等。

注意: 由于这些方法都是Object类定义的,所以所有Java对象都拥有这些方法。在实际编码中,根据需要可以覆盖(override)其中的一些方法以改变默认行为。

toString() 方法

作用: toString()方法用于返回对象的字符串表示形式。它常用于打印、日志或调试,方便我们查看对象的内容。默认情况下,Object.toString()返回的是类名@对象的哈希码的十六进制字符串,例如Car@6d06d69c。这样的信息对用户而言没有实际意义,因此通常我们会在自己的类中重写toString()方法,使其返回更友好的内容描述。

默认实现:Object类中,toString()被实现为:

public String toString() {return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

可见默认行为并不显示对象内部信息。我们可以覆盖它以提供对象状态的文本描述。

重写原则: toString()应当返回简明易读能够代表对象主要信息的字符串。比如一个Person对象,可以返回包含姓名和年龄的字符串;一个集合对象可以返回其元素列表。很多IDE能够自动生成toString()方法实现,或可以使用Objects.toStringHelper等工具帮助生成。良好的toString()实现对调试程序非常有帮助。

示例: 定义一个Person类并重写toString()

class Person {private String name;private int age;public Person(String name, int age) {this.name = name;this.age = age;}@Overridepublic String toString() {return "Person{name='" + name + "', age=" + age + "}";}
}
public class TestToString {public static void main(String[] args) {Person p = new Person("张三", 25);System.out.println(p);               // 等价于 System.out.println(p.toString());String info = "人员信息: " + p;      // 在字符串连接时会自动调用 toString()System.out.println(info);}
}

运行输出:

Person{name='张三', age=25}  
人员信息: Person{name='张三', age=25}

可以看到,我们自定义的toString()返回了Person对象的姓名和年龄,格式为Person{name='张三', age=25},比默认的类名@哈希码易读多了。当我们直接打印对象p时,Java会自动调用它的toString()方法,因此能看到定制的信息。同样,将对象与字符串拼接时也会隐式调用toString()

建议: 在开发中,养成重写toString()的习惯,有助于日志输出和调试。尤其是在集合、实体类中打印内容,可以快速洞察对象状态。要确保toString()不会引发NullPointerException等异常,并避免在toString()中执行复杂逻辑或改变对象状态——通常应仅用于返回信息。

equals() 方法 与 “==” 运算符

作用: equals(Object obj)方法用于判断当前对象与另一对象是否“内容相等”。需要强调的是,“相等”可以有不同的语义:默认实现中,equals()==效果相同,都是比较两个引用是否指向同一个对象实例。但许多类会重写equals()使其表示对象内容的等价,例如字符串内容相等、业务主键相等等。

== 运算符 vs equals():

  • == 运算符:对基本类型,==直接比较值是否相等;对引用类型,==比较的是两引用是否指向同一个对象(即内存地址是否相同)。
  • equals() 方法:是一个实例方法,默认实现也是比较引用相同(即调用==)。但是类可以重写它,自定义“相等”逻辑,使之比较对象的关键字段是否相同,以表示内容相等。

默认实现: Object.equals(Object obj)内部其实就是简单地 return (this == obj);。因此,如果不重写,equals==是等价的,都要求是同一对象才返回true。

为何要重写equals: 因为在很多情况下,我们更关心对象所代表的数据是否相同,而非是否同一对象。例如,两个内容完全相同的字符串应该被视为相等,即使它们是不同的对象实例;两个Person对象只要姓名和身份证号相同,也可以认为是同一个人。在这些情形下,需要重写equals()方法来实现“内容比较”。

equals()重写契约: 重写equals()时应遵守自反性、对称性、传递性、一致性,以及对任何非null x,x.equals(null)应返回false。这是《Effective Java》等著作强调的内容。简单来说:

  • 自反性:x.equals(x)必须返回true。
  • 对称性:如果x.equals(y)返回true,则y.equals(x)也应返回true。
  • 传递性:如果x.equals(y)和y.equals(z)为true,则x.equals(z)也应为true。
  • 一致性:如果对象参与比较的信息未改变,多次调用equals结果不变。
  • 非空性:任何对象都不应等于null(x.equals(null)应返回false)。

示例1(字符串的equals):

String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2);       // false,因为不是同一对象
System.out.println(s1.equals(s2));  // true,因为String类重写了equals比较字符序列

这里s1s2是两个不同的String对象,==比较结果为false。但String.equals()被重写为比较字符串内容,两个都包含"hello",所以返回true。这说明对于引用类型,一般应使用.equals()来比较内容而不是==,除非你确实想判断是否同一对象。

示例2(自定义类的equals):

class Person {String id;String name;public Person(String id, String name) {this.id = id;this.name = name;}@Overridepublic boolean equals(Object o) {if (this == o) return true;              // 同一个对象if (!(o instanceof Person)) return false; // 类型不匹配Person other = (Person) o;// 判断身份证号和姓名是否相等(假设身份证号相同则视为同一人)return this.id.equals(other.id) && this.name.equals(other.name);}@Overridepublic int hashCode() {// 简单组合两个字段计算hash(重写equals时通常也要重写hashCode,下面会详细说)return id.hashCode() * 31 + name.hashCode();}
}
public class TestEquals {public static void main(String[] args) {Person p1 = new Person("110101199001011234", "李四");Person p2 = new Person("110101199001011234", "李四");Person p3 = new Person("110101199002029999", "李四");System.out.println(p1 == p2);          // false,不同对象System.out.println(p1.equals(p2));     // true,身份证和姓名都相同System.out.println(p1.equals(p3));     // false,身份证不同}
}

在这个示例中,我们重写了Person.equals()使其根据idname来判断两个Person是否相等。p1p2内容完全相同,因此equals返回true,而p1 == p2是false,因为它们不是同一对象。对于p1p3,尽管姓名相同但身份证不同,被视为不同人,equals返回false。

重要提示: 重写equals()务必也重写hashCode()方法,这是下面将讨论的内容。否则,在将对象放入哈希集合(如HashMapHashSet)时会出现逻辑错误。

hashCode() 方法

作用: hashCode()返回对象的哈希码,表现为一个整数。哈希码用于在哈希集合(如HashSetHashMapHashtable等)中快速查找、检索对象。哈希码与equals紧密相关:根据Java规范,如果两个对象根据equals()比较是相等的,那么它们的hashCode()必须相等。反之,如果equals()不相等,则hashCode()可以不同。但如果hashCode()也碰巧相同(不同对象可能出现相同hash,这称为哈希冲突),equals仍需最终区分开它们。

默认实现: Object.hashCode()通常根据对象的内存地址计算出一个整数(不是实际地址,但可以认为是基于地址的散列结果)。所以默认情况下,不同对象哪怕内容一样,hashCode通常也不同。

为何要重写hashCode: 当我们重写equals()判定两个对象可以相等时,必须相应重写hashCode()使得相等的两个对象返回相同的哈希码。否则,这些对象放在例如HashSet中时,会被散列到不同桶,导致集合认为它们是不同的元素,违背我们对相等的定义。

equals 和 hashCode 契约:

  • 如果a.equals(b)为true,那么a.hashCode()必须等于b.hashCode()
  • 如果a.equals(b)为false,a.hashCode()b.hashCode()可以相等也可以不相等(哈希冲突允许存在,只是会降低散列容器性能)。
  • 反过来,没有要求hashCode相等的两个对象必须equals返回true——hashCode相等可能是碰巧冲突,也可能是设计如此,但equals可以进一步严格判断。

常见做法: 重写hashCode()时,通常选取equals中用到的关键字段来计算hash值。比如上例Person用了id和name计算哈希。计算方法可以参考JDK中Objects.hash(Object... values)或IDE自动生成的方式,确保得到一个尽量均匀分布且与equals一致的结果。

示例(hashCode影响集合行为):

class Point {int x, y;public Point(int x, int y) { this.x = x; this.y = y; }@Overridepublic boolean equals(Object o) {if (!(o instanceof Point)) return false;Point other = (Point) o;return this.x == other.x && this.y == other.y;}// 注意:故意不重写hashCode()
}
public class TestHashCode {public static void main(String[] args) {Point pt1 = new Point(1, 2);Point pt2 = new Point(1, 2);System.out.println(pt1.equals(pt2)); // true,内容相等HashSet<Point> set = new HashSet<>();set.add(pt1);set.add(pt2);System.out.println("set大小: " + set.size());}
}

在这个例子中,Point.equals()判断两个点坐标都相等就返回true,但我们没有重写hashCode()。运行程序将输出:

true  
set大小: 2

这表明虽然pt1.equals(pt2)返回true,按理它们应被视为同一个元素,但由于hashCode()未重写,pt1pt2的哈希码不同(来源于Object.hashCode()),因此插入HashSet时被放入了不同的桶,集合认为它们是不同的对象,导致集合大小为2而不是1。这违反了集合的预期用法。

正确的做法是重写hashCode(),例如对于Point类可实现为:return 31 * x + y;(这是一个简单的哈希计算,把x, y组合)。一旦重写,pt1pt2将产生相同的hashCode=33,两者被放入HashSet时会先比较哈希发现一致,再调用equals确认等价,从而认定为重复元素,集合最终大小为1。

总结:

  • 如果重写equals(),请务必重写hashCode(),保证相等的对象哈希码也相等。
  • hashCode()返回值可以相等即使对象不相等,但要尽量减少这种冲突发生,提高散列效率。
  • HashMapHashSet这类集合中,会先比对hashCode,若不同直接认定不相等;若相同才进一步用equals()判断真伪。因此保持两者契约非常重要,以避免逻辑错误。

Java提供的许多类都正确地重写了equals()hashCode()(例如String、包装类、各种集合类),在实际开发自定义类时也应遵循这个规范。

clone() 方法

作用: clone()方法用于创建对象的拷贝,也称“克隆”。通过clone(),可以生成一个新对象,其内容与原对象相同。克隆有两种类型:

  • 浅拷贝(shallow copy):拷贝对象自身,但不拷贝内部引用指向的对象。也就是说,原对象和克隆对象内部的引用都指向同一个子对象。
  • 深拷贝(deep copy):拷贝对象自身,并递归地拷贝它引用的所有子对象。最终得到的克隆对象完全独立,不共享任何原始对象的可变部分。

使用前提: 要使对象可克隆,类需要实现java.lang.Cloneable接口(这是一个标记接口,没有方法)并重写clone()方法,通常调用super.clone()来实现原始的按位拷贝。如果一个类未实现Cloneable却调用Object.clone(),将抛出CloneNotSupportedException异常。

默认行为: Object.clone()是受保护的(protected)方法,默认实现是浅拷贝:即创建一个新的对象,将原对象的每个字段值都拷贝过去(对于基本类型就是复制值,引用类型就是复制引用)。因此默认clone产生的拷贝对象与原对象共享所有可变引用对象。

何时深拷贝: 如果对象包含引用类型字段,且希望克隆结果与原对象在各层级都相互独立(修改克隆不影响原对象),就需要深拷贝。这通常需要在clone()方法中手工实现:调用子对象的clone或者重新创建子对象,使得新对象拥有原对象子对象的副本。

示例(浅拷贝):

class Address implements Cloneable {String city;public Address(String city) { this.city = city; }@Overrideprotected Object clone() throws CloneNotSupportedException {return super.clone(); // Address只有基本字段,直接浅拷贝即可}@Overridepublic String toString() { return city; }
}
class Student implements Cloneable {String name;Address addr; // 引用类型字段public Student(String name, Address addr) {this.name = name;this.addr = addr;}@Overrideprotected Object clone() throws CloneNotSupportedException {return super.clone(); // 浅拷贝,addr引用会被直接复制}@Overridepublic String toString() {return name + " @ " + addr;}
}
public class TestClone {public static void main(String[] args) throws CloneNotSupportedException {Address address = new Address("北京");Student stu1 = new Student("小明", address);Student stu2 = (Student) stu1.clone();   // 克隆stu1得到stu2(浅拷贝)System.out.println("克隆前: stu1=" + stu1 + ", stu2=" + stu2);// 修改原对象的地址stu1.addr.city = "上海";System.out.println("修改原对象地址后: stu1=" + stu1 + ", stu2=" + stu2);}
}

输出结果:

克隆前: stu1=小明 @ 北京, stu2=小明 @ 北京  
修改原对象地址后: stu1=小明 @ 上海, stu2=小明 @ 上海

可以看到,克隆前两个学生对象的地址都是“北京”,克隆是成功的。随后我们修改了原对象stu1的地址城市为“上海”,结果stu2的地址也变成了“上海”。这说明stu1stu2addr引用指向同一个Address对象(浅拷贝行为)。因此修改其中一个的地址会影响另一个。如果这不是我们想要的效果,那么浅拷贝就不够,需要深拷贝来使stu1stu2拥有独立的Address对象。

示例(深拷贝): 为了实现深拷贝,我们需要修改Student.clone()方法,在克隆自身后,手动克隆其内部的Address对象:

@Override
protected Object clone() throws CloneNotSupportedException {// 先浅拷贝Student对象Student cloned = (Student) super.clone();// 再克隆Address对象,赋给新Student的addrcloned.addr = (Address) this.addr.clone();return cloned;
}

做了以上修改后,再次运行刚才的测试:此时修改stu1.addr.city为“上海”后,stu2.addr.city仍然保持“北京”,各自独立,达到了深拷贝的效果。

需要注意的是深拷贝的实现较为繁琐,当对象结构复杂时需要递归地克隆每一层对象。选择浅拷贝还是深拷贝取决于具体需求。很多情况下浅拷贝已经足够,而且效率更高;但如果共享可变对象会带来问题,就必须实现深拷贝或采取其他复制手段(比如通过序列化来复制对象)。

使用克隆的替代方案: 克隆在Java中有些争议,因为Cloneable接口机制被认为设计不够优雅,易出错。替代方案包括:

  • 提供拷贝构造函数或拷贝工厂方法。例如new Person(originalPerson),在构造函数中手动复制需要的字段。
  • 使用序列化:将对象写到流中再读出,得到一份深拷贝(代价较高,一般不推荐)。
  • 使用第三方库或自行实现通用的深拷贝工具等。

总之,clone()方法可以快捷地复制对象,但需要小心正确实现Cloneable接口和遵循浅/深拷贝策略,否则可能导致意想不到的共享或性能问题。

finalize() 方法

提示: finalize()方法在Java 9后已被标记为过时(Deprecated),不建议在新代码中使用。它存在诸多问题,包括无法保证及时执行、性能开销大、不确定性强等。这里介绍其作用是为了完整性,但实际开发应尽量避免依赖finalize()进行资源回收,推荐使用try-with-resources或显式关闭模式来管理资源。

作用: finalize()是定义在Object类中的一个保护方法:

protected void finalize() throws Throwable { }

它的设计目的是,当垃圾回收器(GC)准备回收某个对象时,如果该对象覆盖了finalize()方法,就会调用此方法让对象有一次执行清理操作的机会。通常用于释放非内存资源,比如关闭文件、网络连接等。可以将finalize()看做对象的终结器,在对象生命的尽头被调用。

调用时机: finalize()的调用由垃圾回收线程决定,而且不确定什么时候执行。一个对象被判定为垃圾后,GC可能立即回收它,也可能在稍后的某次GC才回收。在回收前如果存在finalize(),GC会执行它。如果执行缓慢,会拖延垃圾回收该对象甚至影响整体GC性能。此外,一个对象的finalize()只会被调用一次,即使对象在finalize()中被“拯救”(使自己重新有引用)后来又变成垃圾,也不会再第二次调用。

默认实现: Object.finalize()默认什么也不做。因此只有我们在子类中重写这个方法时,才会赋予其实际行为。

示例:

class Resource implements AutoCloseable {private String name;public Resource(String name) { this.name = name; }@Overrideprotected void finalize() throws Throwable {System.out.println("Finalize 被调用,正在清理资源: " + name);super.finalize();}@Overridepublic void close() {System.out.println("关闭资源: " + name);}
}
public class TestFinalize {public static void main(String[] args) throws InterruptedException {Resource res = new Resource("数据库连接");res = null;       // 使对象成为垃圾System.gc();      // 提示JVM执行垃圾回收Thread.sleep(1000); // 等待一会儿,确保GC完成System.out.println("程序结束");}
}

运行可能输出:

Finalize 被调用,正在清理资源: 数据库连接  
程序结束

可以看到,当我们将res设为null并调用System.gc()请求垃圾回收后,Resource对象的finalize()被执行了(输出了清理资源的信息)。但要强调,这种调用是不确定的。如果不调用System.gc()强制触发,有可能程序结束时都没有执行finalize,因为JVM可能没有发生垃圾回收。而我们通过Thread.sleep等待来增加finalize执行的机会,也不能100%保证所有环境下都奏效。

警告: 正因finalize()的这种不确定性,我们不应该依赖它来释放重要资源。例如文件句柄、数据库连接,应该使用try...finally或实现AutoCloseable然后用try-with-resources机制来确保及时释放。上例Resource实现了AutoCloseable.close()用于手动关闭,就是更好的模式。实际上,在Java 9及以后,finalize()被废弃,建议改用java.lang.ref.CleanerPhantomReference等更可控的机制进行终结操作。

小结: finalize()曾被作为Java提供的一个“对象临终钩子”,但由于其固有问题,现在几乎已经退出历史舞台。了解它是为了理解Java内存管理机制的一部分,但编写新代码时尽量不要使用。

包与访问权限控制

包(Package)是Java用于组织类和接口的一种命名空间机制。通过包,我们可以将功能相关的类归组,避免命名冲突,并控制类的访问范围。访问权限控制(Access Control)是Java提供的限定类、变量、方法可见性的机制,包括privatedefault(无修饰符)、protectedpublic四种级别,从最严格到最开放。下面我们依次介绍包的声明与导入、各访问修饰符的作用范围,并讨论不同包和继承情况下的访问权限。

包的声明与导入语法

包的声明: 在一个Java源文件开头,可以使用package语句声明该文件中定义的类所属的包。例如:

package com.example.utils;
public class StringUtil {// 类的定义
}

以上表示StringUtil类属于com.example.utils包。包名通常使用公司域名倒置+项目名+模块名等方式组织,以确保全局唯一性。例如java.utilorg.apache.commons.io都是包名。注意: 包声明必须是文件的第一条语句(紧随可能的注释或版权声明之后),且每个源文件至多只能有一个package声明。如果不写package,则类处于默认包(unnamed package),这一般只在最简单的示例程序中使用,实际项目应当明确定义包。

包的导入: 为了在一个类中使用不同包下的类,我们需要导入它们。导入通过import语句完成,有两种用法:

  • 导入指定类,例如:import java.util.Date; 表示导入java.util包中的Date类。
  • 导入整个包下所有类,使用通配符*,例如:import java.util.*; 导入java.util包中的所有公共类。(通配符不会导入子包中的类)

import语句通常放在包声明下面,类定义之前。需要注意,如果代码中直接使用类的全名(如java.util.Date date = new java.util.Date();),可以不import。但import可以简化书写。还有一种静态导入import static用于导入类的静态成员,便于直接使用(如导入Math.PI),这里不展开。

示例:

package com.myapp.model;
import java.util.Date;
import java.util.List;
import java.util.ArrayList;
public class User {private String name;private Date   birthDate;private List<String> tags = new ArrayList<>();// ...
}

这段代码说明:User类位于com.myapp.model包,我们从java.util包分别导入了DateListArrayList类,然后就可以直接使用这些类而无需每次写全称java.util.Date等。

四种访问修饰符的可见性范围

Java提供了四种访问权限修饰符,用来限制类、成员被访问的范围:

  • private:私有权限,最严格,只能在自身类内部访问。
  • (无修饰符)默认(包私有,package-private):在同一个包内部可访问,在其他包不可访问。也称为“friendly”或“package”访问权限。
  • protected:受保护权限,在同一个包内可以访问;在不同包的子类中也可以访问(有限制条件,见下文);其他情况下不能访问。
  • public:公共权限,最开放,任何地方(任何包)都可以访问。

这四种权限从小到大排列为:private < default < protected < public。可以把它想象成圈层:private仅类自身,default扩大到包,protected扩大到子类,public对所有开放。

下面详细说明各修饰符对类、本包、子类、外包的可见性:

  • private(私有):
    私有成员仅能在定义它的类内部访问,连同包的其他类也不行,子类也不行。常用于封装对象的内部状态,避免外界任意更改。如类的属性大多声明为private,然后通过公共的getter/setter访问。
  • 默认(包级):
    如果一个成员没有显式的访问修饰符,则它具有默认的包访问权限。这样的成员(或类)对同一包内的其他类可见,对包外是不可见的。包访问适合一些只应在内部使用的类或方法,不希望公开给整个世界,但同一团队模块内又需要互相调用的场景。
  • protected(受保护):
    受保护成员可被同包内的任何类访问(跟默认一样),此外还允许不同包的子类访问。但是对不同包的非子类依然是不可见的。需要注意,在子类中访问父类的protected成员,有一个限制:只能通过子类自身引用或子类类型的对象来访问,不能通过父类引用直接访问父类的protected成员(因为那属于外包访问场景)。简单来说,protected对于子类而言,就像是提高了可见性到子类的内部,但并不向整个外部包公开。
  • public(公共):
    公共成员没有访问限制,任何包的任何类都可以访问(只要能拿到引用)。公共类也可以被任何地方使用。大部分对外接口、API都是public的。

顶层类的访问权限: Java的顶级类(非内部类)只能是public或包级(默认)两种权限。也就是说,一个.java文件里定义的非内部类,要么声明为public(类名必须与文件名相同),要么不写修饰符表示包可见。不能将顶层类声明为protected或private——那是非法的。在内部类、成员上才有4种选项,而顶层类只有2种。通常,一个模块的入口点类会是public,其余一些辅助类可以包可见来隐藏实现。

示例代码演示各权限: 我们通过两个包pack1pack2来演示各种访问修饰符的作用。

// 文件:pack1/A.java
package pack1;
public class A {public    int pub    = 1;protected int prot   = 2;int def    = 3;  // 默认权限private   int priv   = 4;public void testAccess() {// 类内部可以访问所有自己的成员System.out.println("A.pub = " + pub);System.out.println("A.prot = " + prot);System.out.println("A.def = " + def);System.out.println("A.priv = " + priv);}
}
// 文件:pack1/B.java
package pack1;
class B {  // 默认访问级别的类B(包内可见,包外不可见)public void test() {A a = new A();// 同一个包中,B可以访问A的 pub, prot, def成员,但不能访问privSystem.out.println("B sees A.pub = " + a.pub);    // OKSystem.out.println("B sees A.prot = " + a.prot);  // OK (同包可以访问protected)System.out.println("B sees A.def = " + a.def);    // OK (同包访问默认权限)// System.out.println(a.priv); // ERROR: priv在A中是私有的,B无法访问}
}
// 文件:pack2/C.java
package pack2;
import pack1.A;
public class C extends A {public void test() {A a = new A();System.out.println("C sees a.pub = " + a.pub);    // OK, public随处可见// System.out.println("C sees a.prot = " + a.prot); // ERROR: 不在同包,通过父类引用无法访问protected// System.out.println("C sees a.def = " + a.def);   // ERROR: default权限,不同包不可见// System.out.println("C sees a.priv = " + a.priv); // ERROR: private不可见// 子类中可以通过继承获得prot属性访问权:System.out.println("C sees this.prot = " + this.prot); // OK, C继承了A.protSystem.out.println("C sees super.prot = " + super.prot); // OK, 等价于this.prot}
}

现在,让我们总结上述示例体现的规则:

  • A定义在pack1包中,具有public、protected、默认、private四种成员。A.testAccess()在类内部能够访问所有属性,验证了类对自身成员访问无障碍。
  • B也在pack1包,由于未声明public,所以B是包级可见的——意味着pack1包外无法使用B类(无法import pack1.B在其他包中)。在B.test()中,通过A a = new A();可以正常创建A实例,因为A是public的。然后访问a.puba.prota.def都成功,因为BA同包,包内可以访问protected和默认成员。而a.priv无法访问,private仅限A内部。
  • Cpack2包并继承自A。在C.test()中:
    • a.pub可访问,因为public全局可见。
    • a.def不可访问,因为defpack1包可见,对pack2来说不可见(无论继承与否,aA的引用,在pack2外包环境)。
    • a.prot也不可直接通过a访问。尽管prot是protected,CA的子类,但这里使用的是父类引用a(类型A)来访问,在pack2中这相当于“不同包非子类”情形,不允许。
    • 然而,C作为A的子类,继承了prot成员,因而可以在自身代码中通过this.prot或直接prot访问。这表示protected成员在子类内部是可见的,相当于子类拥有这个属性。
    • private依旧不可见,子类完全无权访问父类的私有成员。

从以上分析,可以归纳:

  • private:仅限本类内部。
  • 默认(包权限):本包内部的所有类可以访问,包外的类(无论是不是子类)都不行。
  • protected:本包内部可以访问。跨包情况下,只有该类的子类能访问,但访问时需要注意使用子类自身引用或子类继承得到的成员,而不能用父类实例去访问父类protected成员。
  • public:所有地方都能访问,没有限制。
访问控制对继承和跨包的影响

对于继承来说,需要关注父类成员在子类中的可见性

  • 子类继承了父类所有非private成员(构造方法除外),但是如果成员是default且子类处于不同包,那么虽然子类继承了该字段或方法,却因为包限制无法在子类中使用——从语义上可以理解为这个成员对子类是存在但不可见的,实际编译时会把default当做private对待,因为子类在不同包无法访问它。同理,包级的父类方法在子类中也无法调用。
  • 子类对父类的protected成员有访问权,即使在不同包。受保护成员对跨包子类可见,就像上例C可以访问prot,但必须是通过子类自身。比如在C中可以直接用prot(编译器会解析为this.prot),但不能用父类对象a.prot

访问控制对类本身和外部的影响:

  • 顶层类如果是默认访问,则只能在同包使用。比如上例B类,在pack1内可以使用,但pack2想使用B会发现无法导入(编译错误)。
  • public类可以被跨包使用,但这也要求它所在包被适当import或者全限定名引用。
  • 内部类(nested class)的访问遵循成员的访问规则,它本身相当于成员,可以被声明为private/protected等,限制其在外部的可见性。但这是更高级的话题了。

小结: 通过包和访问修饰符,我们可以实现良好的封装和模块化设计:

  • 将不需要公开给外部的类或成员声明为包级或private,保证模块内部的实现细节不泄露。
  • 仅将必要的接口声明为public供外部使用。
  • 使用protected来允许子类的定制扩展,同时对非子类隐藏实现。

掌握访问控制有助于编写安全、清晰的代码接口。例如,一个库的API会把实现类隐藏在包内部,只暴露接口供用户使用;框架会使用protected方法让用户在子类中扩展功能,但保持框架整体行为一致。

最后,需要注意的是,在实际项目中包的划分和访问控制的选用,应根据设计原则来:尽量降低各模块之间的耦合,保证内部实现的私密性和外部接口的易用性。在团队协作中,遵守约定使用正确的访问修饰符可以避免很多错误和混淆。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词