Java编程笔记2:初始化和清理
图源:Java Switch语句(用法详解)-java教程-PHP中文网
构造器
构造器,在编程领域也会被称作构造函数。事实上我觉得这个名称并不是很恰当,可能相当一部分人想当然地认为构造器的作用是用来创建对象。这话对也不对,对是因为当你使用new
关键字时,的确对应类的构造函数会被调用,参与到的对象创建中。但实际上执行构造函数的时候,对象其实已经创建,这也是为什么在构造函数中可以使用this
关键字来调用当前对象的引用。
所以准确地讲,构造器应当被称作“初始化函数”,其用途是初始化对象。
这点在Python中显得尤为明显,在Python中,参与对象创建的魔术方法主要有两个:
__new__
,构造函数__init__
,初始化函数
来看一个Python示例:
from typing import Any
class MyClass:
def __new__(cls: Any) -> Any:
print("MyClass is build.")
return super().__new__(cls)
def __init__(self):
print("MyClass is inited.")
mc = MyClass()
# MyClass is build.
# MyClass is inited.
在这个示例中,真正负责在MyClass()
调用时创建一个MyClass
实例的函数是__new__
而非__init__
,__init__
仅仅起到接收__new__
创建的实例(这里体现为self
参数)并初始化的作用。
类比到Java,Java中的构造函数的作用就是__init__
方法,只不过因为语言风格的关系,不需要显式地接收self
参数,而是使用this
引用来指向当前实例。
- 实际上Python的
__new__
函数灵活的多,甚至可以根据情况创建一个非当前类型的实例并返回,和Python的元编程结合使用可以实现一些普通编程语言的开发者无法实现的功能,这就是Python一直提倡的“开发者拥有语言的创建者同样的工具”。从这一点讲,很多不明所以的开发者宣称的“Python是一门简单浅显的语言”这种说法是不负责任和可笑的。- 更多关于
__new__
函数的内容可以阅读Python学习笔记36:动态属性和特性 - 魔芋红茶’s blog (icexmoon.xyz)。
Java的构造器很简单:
package ch2;
public class MyClass {
public MyClass() {
System.out.println("MyClass is build.");
}
public static void main(String[] args) {
MyClass mc = new MyClass();
// MyClass is build.
}
}
只需要创建一个名称与所在类完全相同的方法即可,需要注意的是,没有任何返回值类型,包括void
。
方法重载
方法重载是Java和C++中一项很有意思的特性,而许多其他的编程语言并没有采用(Python、PHP、Go等)。
利用方法重载,我们可以定义多个构造器,并实现多种方式来生成实例:
package ch2;
import java.util.Random;
public class MyInteger{
private int number;
public MyInteger(){
Random random = new Random();
number = random.nextInt(100);
}
public MyInteger(int number){
this.number = number;
}
@Override
public String toString() {
return Integer.toString(this.number);
}
public static void main(String[] args) {
MyInteger mi = new MyInteger();
MyInteger mi2 = new MyInteger(10);
System.out.println(mi);
System.out.println(mi2);
// 99
// 10
}
}
实际上方法重载并非必须,其它不支持方法重载的语言中,同样可以利用参数默认值和变长参数来实现类似的效果。
示例中实现了两个不同的构造函数,一个用随机的int
值来生成MyInteger
实例,一个则用指定的int
来生成实例。
区分重载方法
就像上面展示的那样,重载的方法之间用参数列表的不同来区分,具体包括:
- 参数数量的不同
- 参数类型的不同
- 参数定义顺序不同
一般来说这样做是没有什么歧义的,但是Java中存在一些“类型提升”的例子,比如在某些时候,char
型会被转化为int
型,这样做是安全的,所以编译器会在需要的时候“自动”进行这种类型提升:
package ch2;
import java.util.Random;
public class MyInteger2 {
...
public static void main(String[] args) {
MyInteger2 mi = new MyInteger2('a');
System.out.println(mi);
// 97
}
}
可以看到这里new MyInteger2('a')
的方式依然是合法的,因为编译器会将char
型参数a
转换为int
以满足构造器MyInteger2(int number)
。
但如果我们添加一个接收char
型的构造函数:
package ch2;
import java.util.Random;
public class MyInteger3 {
...
public MyInteger3(char number) {
System.out.println("char constructor is called.");
this.number = number;
}
@Override
public String toString() {
return Integer.toString(this.number);
}
public static void main(String[] args) {
MyInteger3 mi = new MyInteger3('a');
System.out.println(mi);
// char constructor is called.
// 97
}
}
可以看到,在这种情况下“优先”使用新添加的构造函数,可以简单地理解为“在多个重载方法都可以满足参数的情况下,编译器优先选择形参类型最接近的那个”。
这点同样适用于包装器类或者继承和接口。
默认构造器
如果没有显式地给类添加构造器,编译器就会“自动”给该类添加一个默认构造器:
package ch2;
public class MyClass2 {
public static void main(String[] args) {
MyClass2 mc = new MyClass2();
}
}
this
在构造器中可以使用this
关键字来指向当前实例,换句话说,this
就是一个当前对象的引用。
package ch2;
import java.util.Random;
public class MyClass3 {
private int number;
public MyClass3(){
Random rand = new Random();
this.number = rand.nextInt(100);
}
@Override
public String toString() {
return Integer.toString(this.number);
}
public static void main(String[] args) {
MyClass3 mc = new MyClass3();
System.out.println(mc);
// 25
}
}
事实上大多数情况下并不一定需要显式地使用this
:
package ch2;
import java.util.Random;
public class MyClass4 {
private int number;
public MyClass4(){
Random rand = new Random();
number = rand.nextInt(100);
}
@Override
public String toString() {
return Integer.toString(number);
}
public static void main(String[] args) {
MyClass4 mc = new MyClass4();
System.out.println(mc);
// 30
}
}
只有属性与局部变量命名冲突时才一定需要使用this
来明确指向属性:
package ch2;
import java.util.Random;
public class MyClass5 {
...
public MyClass5(int number){
this.number = number;
}
...
}
关于是不是尽可能使用this
来明确代码,是存在争议的,或许在项目组内应当形成一个统一意见。对此我的看法是,鉴于现代IDE都提供“智能联想”功能,所以开发者也倾向于更多地使用this
来借用智能联想以提高编码效率,这样做并没有什么问题。
用this调用构造器
this
除了充当当前对象的引用以外,还可以用来在构造器中调用其他构造器:
package ch2;
import java.util.Random;
public class MyClass6 {
private int number;
public MyClass6(){
this(100);
}
...
public static void main(String[] args) {
MyClass6 mc = new MyClass6();
System.out.println(mc);
// 100
}
}
但这样做有很多限制,比如说你可能希望像之前那样,先生成一个随机数,用随机数来初始化:
package ch2;
import java.util.Random;
public class MyClass7 {
private int number;
public MyClass7(){
Random rand = new Random();
int randInt = rand.nextInt(100);
this(randInt);
}
...
}
遗憾的是这样是不可以的,无法通过编译。所以通过this
调用其他构造器存在很多限制:
- 只有在构造器中才能使用。
- 必须在方法的第一行使用。
- 一个构造器中只能使用一次。
这种限制的原因还不清楚,毕竟支持构造器重载的语言不多。但这种后果就导致Java的构造器重载时很难互相进行复用。
垃圾回收
C/C++编写程序面临的一大问题就是"内存泄露",这是由于开发者没有及时地释放内存造成的。事实上程序出现这样或那样的bug是一件相当常见的额事情,但很少会有严重到导致系统内存不足崩溃的程度。很大原因是因为后来在C/C++基础上发展起来的程序都具备某种程度上的“垃圾回收机制”。
但垃圾回收也存在一些问题,比如说拖慢程序的运行,这也是Java最受诟病的一点,相当一部分程序员会抱怨Java的运行速度远低于C/C++,但从另一个角度看,Java因为有垃圾回收,开发者节省了相当的时间在排查类似“内存泄露”的问题上,这加快了程序开发的用时,尤其是在计算机性能大大提升的当代,人力成本在绝大多数情况下是要高于程序执行速度的,所以这是值得的。
此外,垃圾回收和语言本身也是可以优化的,尤其是Go语言,同样是在C/C++基础上发展起来的具有垃圾回收机制的编译型语言,就号称有接近于C的执行速度。
finalize
C++有一个相对于构造函数的"析构函数",会在实例从内存中销毁时被调用,用于完成一些额外的清理工作。Java中也有一个“类似”的函数finalize
:
package ch2.finalize;
public class MyClass {
public MyClass() {
System.out.println("MyClass is created.");
}
@Override
protected void finalize() throws Throwable {
System.out.println("MyClass is finalized.");
super.finalize();
}
public static void main(String[] args) {
MyClass mc = new MyClass();
mc = null;
System.gc();
// MyClass is created.
// MyClass is finalized.
}
}
这里System.gc()
的作用是强制启动垃圾回收,一般情况下垃圾回收只有在内存耗尽等情况时才会进行。
但finalize
存在一些问题,因为Java和C++不同,垃圾回收由JVM自动进行而非开发者指定,这就导致垃圾回收的时间是不确定的,并且在多线程下还可能存在一些其他问题,比如内存泄露。
但是某些时候在对象被回收时进行一些额外工作却是必须的,比如在Java中调用C++编写的类库等。所以从JDK1.6开始,finalize()
方法被弃用,提供了一种额外方式:
package ch2.clean;
import java.lang.ref.Cleaner;
import java.util.LinkedList;
import java.util.List;
public class MyClass {
private int number;
public MyClass(int number) {
this.number = number;
System.out.println("MyClass(" + this.number + ") is build.");
}
@Override
public String toString() {
return "MyClass(" + this.number + ")";
}
public static void main(String[] args) {
Cleaner cleaner = Cleaner.create();
List<MyClass> mcList = new LinkedList<>();
for (int i = 0; i < 10; i++) {
MyClass mc = new MyClass(i);
mcList.add(mc);
String mcName = mc.toString();
cleaner.register(mc, new Runnable() {
@Override
public void run() {
System.out.println(mcName + " is destructed.");
}
});
}
mcList.clear();
System.gc();
}
}
// MyClass(0) is build.
// MyClass(1) is build.
// MyClass(2) is build.
// MyClass(3) is build.
// MyClass(4) is build.
// MyClass(5) is build.
// MyClass(6) is build.
// MyClass(7) is build.
// MyClass(8) is build.
// MyClass(9) is build.
// MyClass(6) is destructed.
// MyClass(9) is destructed.
// MyClass(8) is destructed.
// MyClass(7) is destructed.
// MyClass(5) is destructed.
// MyClass(4) is destructed.
// MyClass(3) is destructed.
// MyClass(2) is destructed.
// MyClass(1) is destructed.
// MyClass(0) is destructed.
这里使用java.lang.ref.Cleaner
来实现对象在被销毁时执行某些额外操作。
主要的方式是:
- 通过
Cleaner.create
静态方法创建一个Cleaner
实例。 - 使用
Cleaner
对象的register
方法,将“监控”的对象和要执行的操作进行注册。
在使用System.gc()
显式执行垃圾回收后就能发现,MyClass
对象都被销毁,并且Cleaner
实例在对应MyClass
对象销毁的时候调用了注册的Runable
实例。
这里需要注意的是,Runable
实例中不能直接使用MyClass
对象,否则就会建立一个额外引用,导致mcList
清理后依然存在MyClass
对象的引用,gc就无法正常清理MyClass
对象,自然也观察不到相应的输出。所以这里使用一个外部的mcName
字符串。
GC算法
GC(garbage collection)的具体实现有很多不同的算法。最简单的一种是“引用计数”,通常用于介绍垃圾回收的基本原理。
引用计数
引用计数的原理很简单,程序运行时每创建一个对象的引用,就在这个对象的引用计数上+1,每删除一个对象,就在引用计数上-1,进行垃圾回收的时候,只要清理那些引用计数为0的对象即可。
但这种算法有个问题,无法处理“循环引用”:
package ch2.reference;
public class Recycle {
Recycle r;
private static void createRecycle() {
Recycle r1 = new Recycle();
Recycle r2 = new Recycle();
r1.r = r2;
r2.r = r1;
}
public static void main(String[] args) {
createRecycle();
}
}
在上面这个例子中,createRecycle
静态方法执行后会创建两个互相引用的局部变量,这就导致虽然从全局而言,局部变量r1
和r2
应当随着createRecycle
的调用结束而变得没用,应当在GC时被清理,但实际上检查它们的引用计数就会发现,它们的引用计数都是1(因为互相引用),所以无法被清理。
所以引用计数通常用作解释GC原理,但真实的GC并不会使用这种算法。
停止-复制
停止-复制算法的原理也很简单,将内存中可以分配的堆(heap)分为两块,在需要GC的时候,将程序暂停,然后检查当前程序中“活着”的对象。然后将这些“活着”的对象复制到内存中的另一块堆中,然后将原来的堆进行清理。完成整个“停止-复制”过程后,我们会得到一个空的堆和一个“整齐排列”的堆,新申请的对象将在已经“整齐排列”的堆中,所以分配新空间的开销会很小。
对于前面所说的那种循环引用的对象,显然是程序主干已经无法访问的情况,所以属于应当被清理的部分。
整个过程可以用下图表示:
这种算法的缺点在于:
- 可用的堆容量缩小一半,有空间浪费。
- 每次GC都需要进行数据复制,性能较差。
事实上GC时不仅涉及数据复制,还需要重新处理引用,让它们能正确指向GC后分配的新地址。
标记-清理
标记-清理算法的原理是,在进行GC时,停止程序后检查存活对象,但并不进行复制动作,仅对存活对象进行标记,并清除堆的其它部分。整个过程可以用下图表示:
需要注意的是,为了能让清理后的空间再次利用,这种算法下堆会被划分为较大空间组成的“块”,较大的对象会占满一个块,较小的会有剩余,虽然有内存浪费,但好处在于清理后的空间是块构成的,可以很容易被重新分配,否则容易出现零碎空间无法被再次利用。
但这样依然有问题,运行时间长了以后,容易出现未使用内存过于分散的情况,这样会带来空间利用和分配新内存性能降低的后果,所以需要对整个堆进行整理,将存活对象整齐排列。这个过程可以用下图表示:
而垃圾回收器会根据情况来决定什么时候进行彻底整理,什么时候仅进行标记-清理。
成员初始化
在Java编程笔记1:基础 - 魔芋红茶’s blog (icexmoon.xyz)中提到过,Java会对声明但没有显式初始化的属性进行初始化,对于基础类型,会使用相应的“0值”(bool
是false
),对于对象,会使用null
:
package ch2.init;
public class MyClass {
MyClass2 mc2;
int intMember;
String strMember;
public static void main(String[] args) {
MyClass mc = new MyClass();
System.out.println(mc.mc2);
System.out.println(mc.intMember);
System.out.println(mc.strMember);
// null
// 0
// null
}
}
class MyClass2 {
}
当然很多情况下应当显式地初始化:
package ch2.init2;
public class MyClass {
MyClass2 mc2 = new MyClass2();
int intMember = 10;
String strMember = "";
...
}
...
初始化块
有些时候可能需要使用复杂的方式来进行初始化,比如将int
属性初始化为随机数,但又不想在构造函数中这样做(比如有多个重载的构造函数),这种情况下可以使用代码块来完成属性的初始化工作:
package ch2.init3;
import java.util.Random;
public class MyClass {
MyClass2 mc2 = new MyClass2();
int intMember;
{
Random random = new Random();
intMember = random.nextInt(100);
}
String strMember = "";
public static void main(String[] args) {
MyClass mc = new MyClass();
System.out.println(mc.mc2);
System.out.println(mc.intMember);
System.out.println(mc.strMember);
// null
// 68
// null
}
}
class MyClass2 {
}
静态初始化块
类似的,对于类的静态属性,同样可以使用代码块初始化,不过要加上static
关键字:
package ch2.init4;
import java.util.Random;
public class MyClass {
...
static int sintMember;
static {
Random random = new Random();
sintMember = random.nextInt(100);
}
public static void main(String[] args) {
...
System.out.println(MyClass.sintMember);
// 89
}
}
class MyClass2 {
}
数组初始化
Java中的数组其实是对象,一种特殊的对象。所以Java的数组甚至可以在运行时决定长度:
package ch2.array;
import java.util.Arrays;
import java.util.Random;
public class Main {
public static void main(String[] args) {
Random random = new Random();
int[] arr = new int[random.nextInt(9) + 1];
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(100);
}
System.out.println(Arrays.toString(arr));
// [35, 42, 54, 77, 74, 31, 7, 74]
}
}
Arrays
是可以java.util
包下数组相关的工具类,借助它可以打印数组。
当然,数组的赋值其实是引用传递:
package ch2.array2;
import java.util.Arrays;
import java.util.Random;
public class Main {
public static void main(String[] args) {
Random random = new Random();
int[] arr = new int[random.nextInt(9) + 1];
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(100);
}
System.out.println(Arrays.toString(arr));
int[] arr2 = arr;
arr2[0] = 101;
System.out.println(Arrays.toString(arr));
// [47, 7, 32, 9, 70, 45, 39]
// [101, 7, 32, 9, 70, 45, 39]
}
}
为了方便,很多语言都支持某种对数组快捷初始化的写法,Java也一样:
package ch2.array3;
public class Main {
public static void main(String[] args) {
int[] arr = { 1, 2, 3, 4, 5 };
Integer[] arr2 = { 1, 2, 3, 4, 5 };
Integer[] arr3 = new Integer[] { 1, 2, 3 };
}
}
可以看到,new Integer[]
是可以省略的。
和其它语言类似,这种“数组字面量”除了可以初始化数组以外,还可以在函数调用时作为参数:
package ch2.array4;
public class Main {
private static void printArr(int[] arr) {
System.out.print('[');
for (int num : arr) {
System.out.print(num + ", ");
}
System.out.print(']');
System.out.println();
}
public static void main(String[] args) {
printArr(new int[] { 1, 2, 3 });
// [1, 2, 3, ]
}
}
需要注意的是,这里必须使用new int[]
,不能省略后直接使用{...}
。
可变参数列表
因为Java是单根继承,也就是说Object
是所有类的基类,这就意味着可以使用Object
类型的数组来实现一个接收变长参数的函数:
package ch2.multi_params;
public class Main {
private static void multiParamsMethod(Object[] params) {
for (Object param : params) {
System.out.print(param + ", ");
}
}
public static void main(String[] args) {
Object[] params = new Object[3];
params[0] = 1;
params[1] = "Hello world!";
params[2] = 2.5;
multiParamsMethod(params);
// 1, Hello world!, 2.5,
}
}
当然,这只不过是JavaSE5之前因为不支持变长参数的一种“变通方式”,JavaSE5之后都可以用真正的可变参数方式实现:
package ch2.multi_params3;
public class Main {
private static void multiParamsMethod(Object... params) {
for (Object param : params) {
System.out.print(param + ", ");
}
System.out.println();
}
public static void main(String[] args) {
multiParamsMethod(1, 2, 3);
multiParamsMethod("hello", "world");
// 1, 2, 3,
// hello, world,
}
}
需要注意的是,如果待传递的参数本身就包含在一个数组中,要将这个数组作为参数传递,熟悉Python和Go的朋友一定以为会使用multiParamsMethod(...params)
这样的写法,但Java并不是这样:
package ch2.multi_params2;
public class Main {
private static void multiParamsMethod(Object... params) {
for (Object param : params) {
System.out.print(param + ", ");
}
}
public static void main(String[] args) {
Object[] params = new Object[3];
params[0] = 1;
params[1] = "Hello world!";
params[2] = 2.5;
multiParamsMethod(params);
// 1, Hello world!, 2.5,
}
}
在这种情况下编译器会将传递过来的数组拆分后作为参数来使用。
可变参数列表对比Object[]
作为参数的写法无疑更灵活,因为后者是不能满足空参数的调用方式的。
因为Java具有函数重载的特性,而函数重载结合变长参数列表,就会产生一些棘手的问题:
package ch2.multi_params4;
public class Main {
private static void multiParamsMethod(Object... params) {
for (Object param : params) {
System.out.print(param + ", ");
}
System.out.println();
}
private static void multiParamsMethod(){
System.out.println("multiParamsMethod() is called.");
}
public static void main(String[] args) {
multiParamsMethod();
}
}
理论上两个重载的multiParamsMethod
都满足空参数的调用,但是实际使用时,编译器优先使用了非可变参数的函数,所以在这种情况下依然可以进行区分。
但如果有两个重载的可变参数函数:
package ch2.multi_params6;
public class Main {
private static void multiParamsMethod(Object... params) {
for (Object param : params) {
System.out.print(param + ", ");
}
System.out.println();
}
private static void multiParamsMethod(String... params) {
System.out.println("multiParamsMethod(String...) is called.");
}
public static void main(String[] args) {
multiParamsMethod();
// multiParamsMethod(String...) is called.
}
}
虽然依然可以通过编译执行,但实际上这是因为Object
是String
的基类,在这种情况下子类的实现是优先被调用的,所以编译器依然可以区分。但如果可变参数类型本身没有继承关系:
package ch2.multi_params5;
public class Main {
private static void multiParamsMethod(Integer... params) {
System.out.println("multiParamsMethod(Integer...) is called.");
}
private static void multiParamsMethod(String... params) {
System.out.println("multiParamsMethod(String...) is called.");
}
public static void main(String[] args) {
multiParamsMethod();
}
}
就无法通过编译,因为编译器不清楚要调用哪个方法。
解决这种问题的方式之一是修改其中的某个函数,让其参数列表不是纯粹的可变参数构成:
package ch2.multi_params7;
public class Main {
private static void multiParamsMethod(Integer integer,Integer... params) {
System.out.println("multiParamsMethod(Integer...) is called.");
}
private static void multiParamsMethod(String... params) {
System.out.println("multiParamsMethod(String...) is called.");
}
public static void main(String[] args) {
multiParamsMethod();
// multiParamsMethod(String...) is called.
}
}
但是更推荐的方式是:不要在函数重载时定义超过一个可变参数函数。
枚举
很多编程语言都支持枚举,相对于简单的用常量来作为替代,真正的枚举本身是一个封闭的类型,可以直接作为类型来使用。Java中定义枚举的方式很简单:
enum Color {
BLACK, BLUE, RED, GREEN
}
工具方法
枚举实际上是一种特殊的类,枚举值实际上是该类的单例。枚举创建后,Java会给枚举值添加一些有用的工具方法:
package ch2.enum1;
enum Color {
BLACK, BLUE, RED, GREEN
}
public class Main {
public static void main(String[] args) {
for (Color color : Color.values()) {
int key = color.ordinal();
String name = color.toString();
System.out.println(key + "=" + name);
}
// 0=BLACK
// 1=BLUE
// 2=RED
// 3=GREEN
}
}
枚举的静态方法values
可以返回包含所有枚举值的一个数组。枚举值的ordinal
方法可以返回枚举值的内部索引值,toString
方法返回枚举值的名称。
switch
在Java编程笔记1:基础 - 魔芋红茶’s blog (icexmoon.xyz)中我们讨论过switch
在Java中的限制,万幸的是,switch
语句是支持枚举类型的,对于一种有限集来说,这相当有用:
package ch2.enum2;
enum Color {
BLACK, BLUE, RED, GREEN
}
public class Main {
private static boolean isLightColor(Color color) {
switch (color) {
case RED:
case BLUE:
case GREEN:
return true;
default:
return false;
}
}
public static void main(String[] args) {
System.out.println(isLightColor(Color.BLACK));
System.out.println(isLightColor(Color.GREEN));
// false
// true
}
}
谢谢阅读。