Java中的static关键字用于修饰类的成员(属性或方法),表示“静态”的含义,即属于类本身,而非某个对象。静态成员在内存中只有一份,在类加载时初始化,生命周期贯穿程序运行始终。下面分别介绍静态变量、静态方法和静态代码块,并结合示例说明其作用和特性。
定义与特性: 静态变量也称为“类变量”,由static修饰。与实例变量不同,静态变量属于类本身,对所有实例来说只有一份拷贝。也就是说,无论创建多少对象,静态变量都指向同一块存储空间,所有对象共享这一变量。当其中一个对象修改静态变量的值时,其他对象访问该静态变量时也能看到更新后的值。静态变量在类加载时就被分配内存(存储于方法区或元空间中),在程序结束或类卸载时才会释放。
生命周期比喻: 可以把类比作一座学校,类的实例对象就是学校里的每一个学生,那么静态变量就好比学校的公共公告板。公告板是属于整所学校的(属于类),所有学生(实例)都共享同一块公告板的信息。如果公告板上写着当前在校学生总数,那么每新增或减少一个学生,这个数字都会更新,所有人看到的都是同一个总人数。这块公告板在学校建立时就挂好了(类加载时初始化),直到学校关闭(程序结束/类卸载)才移除。无论有多少学生进出,这个公告板始终存在且只有一块。
示例: 假设我们有一个学生类,每当创建一个学生对象时,希望自动累加学生总数。这时就适合使用静态变量来记录学生总数:
class Student {
String name; // 实例变量,每个对象都有自己的name
static 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 实例变量:
定义与特性: 静态方法是使用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(); // 输出: 当前学生总数: 0
Student 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,静态块依然会执行一次。
类名.静态成员访问静态变量和方法。静态方法中不能直接使用实例成员或this关键字。Math类),记录全局状态的数据(如缓存、计数),定义常量(与final结合)等。理解了static关键字的行为,可以编写出更高效和语义清晰的代码。例如,将不随对象变化的属性设计为静态,可以减少每个对象的存储开销;将与对象无关的功能方法设计为静态,可以直接用类名调用,方便快捷。
Java中的final关键字表示“最终的、不可改变的”含义,可用于修饰变量、方法和类。被final修饰的元素具有以下含义:
final变量:值一旦初始化之后就无法更改(相当于常量)。final方法:不能被子类重写(override)。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方法仍然可以被子类继承调用,但不能有与之同签名的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类通常出现在两种场景:
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 int DAYS_IN_WEEK = 7;。final变量,一旦引用指向某个对象后,就不能再改指向别的对象。但引用指向的对象的内部状态如果允许修改(对象是可变的),那么这种修改是被允许的。要获得真正不可变的对象,需要对象本身设计为不可变类(所有属性也用final且不提供修改方法)。误区澄清: final和“不变”之间有关联但不完全相同。final保证的是引用不可变或值不可变,但如果希望一个对象完全不可变,需要将对象的所有字段也声明为final且不提供修改这些字段的方法。例如String类的实现中,内部字符数组是final且不提供修改方法,所以String对象一经创建内容就无法改变。换句话说,final关键字是构建不可变类的一块基石,但仅有final关键字并不足以确保对象不可变,设计不可变类还需遵守其他原则。
所有Java类都直接或间接继承自java.lang.Object类。Object是Java类层次的根,在它里面定义了一些非常重要的方法,几乎每个类都会用到或需要重写它们。核心的几个Object方法包括:
toString():返回对象的字符串表示。equals(Object obj):判断两个对象是否“内容相等”。hashCode():返回对象的哈希码值。clone():创建对象的拷贝(克隆)。finalize():对象被垃圾回收前的回调方法(已过时,不建议使用)。下面我们逐一介绍这些方法的作用、默认行为,并通过示例说明如何正确地重写和使用它们。同时会讨论==运算符与equals()方法的区别,以及hashCode()与equals()的契约关系,浅拷贝与深拷贝的区别等。
注意: 由于这些方法都是Object类定义的,所以所有Java对象都拥有这些方法。在实际编码中,根据需要可以覆盖(override)其中的一些方法以改变默认行为。
作用: 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;
}
@Override
public 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(Object obj)方法用于判断当前对象与另一对象是否“内容相等”。需要强调的是,“相等”可以有不同的语义:默认实现中,equals()和==效果相同,都是比较两个引用是否指向同一个对象实例。但许多类会重写equals()使其表示对象内容的等价,例如字符串内容相等、业务主键相等等。
== 运算符 vs equals():
==直接比较值是否相等;对引用类型,==比较的是两引用是否指向同一个对象(即内存地址是否相同)。==)。但是类可以重写它,自定义“相等”逻辑,使之比较对象的关键字段是否相同,以表示内容相等。默认实现: Object.equals(Object obj)内部其实就是简单地 return (this == obj);。因此,如果不重写,equals和==是等价的,都要求是同一对象才返回true。
为何要重写equals: 因为在很多情况下,我们更关心对象所代表的数据是否相同,而非是否同一对象。例如,两个内容完全相同的字符串应该被视为相等,即使它们是不同的对象实例;两个Person对象只要姓名和身份证号相同,也可以认为是同一个人。在这些情形下,需要重写equals()方法来实现“内容比较”。
equals()重写契约: 重写equals()时应遵守自反性、对称性、传递性、一致性,以及对任何非null x,x.equals(null)应返回false。这是《Effective Java》等著作强调的内容。简单来说:
示例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;
}
@Override
public 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);
}
@Override
public 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()返回对象的哈希码,表现为一个整数。哈希码用于在哈希集合(如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()可以相等也可以不相等(哈希冲突允许存在,只是会降低散列容器性能)。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; }
@Override
public 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(),可以生成一个新对象,其内容与原对象相同。克隆有两种类型:
使用前提: 要使对象可克隆,类需要实现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; }
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // Address只有基本字段,直接浅拷贝即可
}
@Override
public String toString() { return city; }
}
class Student implements Cloneable {
String name;
Address addr; // 引用类型字段
public Student(String name, Address addr) {
this.name = name;
this.addr = addr;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 浅拷贝,addr引用会被直接复制
}
@Override
public 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的addr
cloned.addr = (Address) this.addr.clone();
return cloned;
}
做了以上修改后,再次运行刚才的测试:此时修改stu1.addr.city为“上海”后,stu2.addr.city仍然保持“北京”,各自独立,达到了深拷贝的效果。
需要注意的是深拷贝的实现较为繁琐,当对象结构复杂时需要递归地克隆每一层对象。选择浅拷贝还是深拷贝取决于具体需求。很多情况下浅拷贝已经足够,而且效率更高;但如果共享可变对象会带来问题,就必须实现深拷贝或采取其他复制手段(比如通过序列化来复制对象)。
使用克隆的替代方案: 克隆在Java中有些争议,因为Cloneable接口机制被认为设计不够优雅,易出错。替代方案包括:
new Person(originalPerson),在构造函数中手动复制需要的字段。总之,clone()方法可以快捷地复制对象,但需要小心正确实现Cloneable接口和遵循浅/深拷贝策略,否则可能导致意想不到的共享或性能问题。
提示:
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; }
@Override
protected void finalize() throws Throwable {
System.out.println("Finalize 被调用,正在清理资源: " + name);
super.finalize();
}
@Override
public 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:私有权限,最严格,只能在自身类内部访问。protected:受保护权限,在同一个包内可以访问;在不同包的子类中也可以访问(有限制条件,见下文);其他情况下不能访问。public:公共权限,最开放,任何地方(任何包)都可以访问。这四种权限从小到大排列为:private < default < protected < public。可以把它想象成圈层:private仅类自身,default扩大到包,protected扩大到子类,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成员,但不能访问priv
System.out.println("B sees A.pub = " + a.pub); // OK
System.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.prot
System.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成员在子类内部是可见的,相当于子类拥有这个属性。从以上分析,可以归纳:
对于继承来说,需要关注父类成员在子类中的可见性:
C可以访问prot,但必须是通过子类自身。比如在C中可以直接用prot(编译器会解析为this.prot),但不能用父类对象a.prot。访问控制对类本身和外部的影响:
B类,在pack1内可以使用,但pack2想使用B会发现无法导入(编译错误)。小结: 通过包和访问修饰符,我们可以实现良好的封装和模块化设计:
掌握访问控制有助于编写安全、清晰的代码接口。例如,一个库的API会把实现类隐藏在包内部,只暴露接口供用户使用;框架会使用protected方法让用户在子类中扩展功能,但保持框架整体行为一致。
最后,需要注意的是,在实际项目中包的划分和访问控制的选用,应根据设计原则来:尽量降低各模块之间的耦合,保证内部实现的私密性和外部接口的易用性。在团队协作中,遵守约定使用正确的访问修饰符可以避免很多错误和混淆。