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
对象,它无法直接访问实例变量和实例方法,在静态方法内部也无法使用this
或super
关键字(因为此时可能尚未有对象实例,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.add
或list.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
类通常出现在两种场景:
- 设计上不需要也不希望被继承:有些类天然就是最终形态,比如工具类、常量类,继承它们没有意义或者可能导致错误。
- 安全和不可变需求:有些类为了保证不可变性或安全性,禁止继承,防止子类破坏其性质。例如
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比较字符序列
这里s1
和s2
是两个不同的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()
使其根据id
和name
来判断两个Person是否相等。p1
和p2
内容完全相同,因此equals
返回true,而p1 == p2
是false,因为它们不是同一对象。对于p1
和p3
,尽管姓名相同但身份证不同,被视为不同人,equals
返回false。
重要提示: 重写equals()
时务必也重写hashCode()
方法,这是下面将讨论的内容。否则,在将对象放入哈希集合(如HashMap
、HashSet
)时会出现逻辑错误。
hashCode() 方法
作用: hashCode()
返回对象的哈希码,表现为一个整数。哈希码用于在哈希集合(如HashSet
、HashMap
、Hashtable
等)中快速查找、检索对象。哈希码与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()
未重写,pt1
和pt2
的哈希码不同(来源于Object.hashCode()
),因此插入HashSet时被放入了不同的桶,集合认为它们是不同的对象,导致集合大小为2而不是1。这违反了集合的预期用法。
正确的做法是重写hashCode()
,例如对于Point类可实现为:return 31 * x + y;
(这是一个简单的哈希计算,把x, y组合)。一旦重写,pt1
和pt2
将产生相同的hashCode=33,两者被放入HashSet时会先比较哈希发现一致,再调用equals确认等价,从而认定为重复元素,集合最终大小为1。
总结:
- 如果重写
equals()
,请务必重写hashCode()
,保证相等的对象哈希码也相等。 hashCode()
返回值可以相等即使对象不相等,但要尽量减少这种冲突发生,提高散列效率。- 在
HashMap
、HashSet
这类集合中,会先比对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
的地址也变成了“上海”。这说明stu1
和stu2
的addr
引用指向同一个Address
对象(浅拷贝行为)。因此修改其中一个的地址会影响另一个。如果这不是我们想要的效果,那么浅拷贝就不够,需要深拷贝来使stu1
和stu2
拥有独立的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.Cleaner
或PhantomReference
等更可控的机制进行终结操作。
小结: finalize()
曾被作为Java提供的一个“对象临终钩子”,但由于其固有问题,现在几乎已经退出历史舞台。了解它是为了理解Java内存管理机制的一部分,但编写新代码时尽量不要使用。
包与访问权限控制
包(Package)是Java用于组织类和接口的一种命名空间机制。通过包,我们可以将功能相关的类归组,避免命名冲突,并控制类的访问范围。访问权限控制(Access Control)是Java提供的限定类、变量、方法可见性的机制,包括private
、default
(无修饰符)、protected
、public
四种级别,从最严格到最开放。下面我们依次介绍包的声明与导入、各访问修饰符的作用范围,并讨论不同包和继承情况下的访问权限。
包的声明与导入语法
包的声明: 在一个Java源文件开头,可以使用package
语句声明该文件中定义的类所属的包。例如:
package com.example.utils;
public class StringUtil {// 类的定义
}
以上表示StringUtil
类属于com.example.utils
包。包名通常使用公司域名倒置+项目名+模块名等方式组织,以确保全局唯一性。例如java.util
、org.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
包分别导入了Date
和List
、ArrayList
类,然后就可以直接使用这些类而无需每次写全称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,其余一些辅助类可以包可见来隐藏实现。
示例代码演示各权限: 我们通过两个包pack1
和pack2
来演示各种访问修饰符的作用。
// 文件: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.pub
、a.prot
、a.def
都成功,因为B
和A
同包,包内可以访问protected和默认成员。而a.priv
无法访问,private仅限A
内部。 - 类
C
在pack2
包并继承自A
。在C.test()
中:a.pub
可访问,因为public全局可见。a.def
不可访问,因为def
在pack1
包可见,对pack2
来说不可见(无论继承与否,a
是A
的引用,在pack2
外包环境)。a.prot
也不可直接通过a
访问。尽管prot
是protected,C
是A
的子类,但这里使用的是父类引用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方法让用户在子类中扩展功能,但保持框架整体行为一致。
最后,需要注意的是,在实际项目中包的划分和访问控制的选用,应根据设计原则来:尽量降低各模块之间的耦合,保证内部实现的私密性和外部接口的易用性。在团队协作中,遵守约定使用正确的访问修饰符可以避免很多错误和混淆。