本文对Effective Java一书中提到的多个最佳实践进行分析,并结合代码实例进行讲解。
1、优先考虑使用静态工厂方法而不是公有构造函数
"优先考虑使用静态工厂方法而不是公有构造函数"的意思是说,在编写类的时候,应该优先考虑使用静态工厂方法来创建类的实例,而不是直接使用公有构造函数。
这种方式的优点有:
- 静态工厂方法可以拥有自己的名称,更好地描述了创建对象的含义和目的,提高了代码的可读性和可维护性。
- 静态工厂方法可以进行必要的参数检查和验证,并返回不同的子类或缓存的对象,从而提高了代码的灵活性和性能。
- 静态工厂方法可以隐藏类的实现细节,尤其在类的实现变化时,客户端代码不需要做出太多的改动。
以下是一个简单的代码示例,演示了使用静态工厂方法创建对象的好处:
public class Person {
private String name;
private int age;
private Person(String name, int age) {
this.name = name;
this.age = age;
}
public static Person createPerson(String name, int age) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Name cannot be empty");
}
if (age < 0 || age > 120) {
throw new IllegalArgumentException("Age must be between 0 and 120");
}
return new Person(name, age);
}
}
在这个例子中,我们使用一个静态工厂方法createPerson()来创建Person对象。这个方法可以对参数进行检查和验证,确保创建出的Person对象是有效的。此外,如果我们需要在以后的版本中更改Person对象的实现,我们只需要改变静态工厂方法的实现,而客户端代码则无需改变。
2、当构造函数的参数过多时考虑使用构建器
当构造函数的参数过多时,使用构建器(Builder)是一种优秀的替代方案。构建器模式是一种创建对象的设计模式,它允许使用者逐步构建复杂对象,同时保持代码简洁易读。
构建器模式通常包括一个Builder类和一个要创建的类。Builder类通常包括与要创建的类相同的属性,但它们都有默认值。Builder类还包括一系列的setter方法,以便客户端代码可以逐步设置这些属性。最后,Builder类包括一个build()方法,该方法将Builder对象转换为要创建的类的实例。
以下是一个使用构建器模式的例子,用于创建一个Coffee对象:
public class Coffee {
private final String type;
private final int size;
private final boolean withMilk;
private final boolean withSugar;
private final boolean withWhip;
private Coffee(Builder builder) {
this.type = builder.type;
this.size = builder.size;
this.withMilk = builder.withMilk;
this.withSugar = builder.withSugar;
this.withWhip = builder.withWhip;
}
public static class Builder {
private final String type;
private int size = 8;
private boolean withMilk = false;
private boolean withSugar = false;
private boolean withWhip = false;
public Builder(String type) {
this.type = type;
}
public Builder size(int size) {
this.size = size;
return this;
}
public Builder withMilk(boolean withMilk) {
this.withMilk = withMilk;
return this;
}
public Builder withSugar(boolean withSugar) {
this.withSugar = withSugar;
return this;
}
public Builder withWhip(boolean withWhip) {
this.withWhip = withWhip;
return this;
}
public Coffee build() {
return new Coffee(this);
}
}
}
在这个例子中,我们使用了一个Coffee.Builder类,它包含了Coffee对象的所有属性,并提供了一系列的setter方法,用于逐步设置这些属性。最后,Coffee.Builder类包含了一个build()方法,该方法将Builder对象转换为Coffee对象。
使用构建器模式,我们可以非常方便地创建一个Coffee对象,而不必手动为每个属性设置值,例如:
Coffee coffee = new Coffee.Builder("Latte")
.size(12)
.withMilk(true)
.withSugar(false)
.withWhip(true)
.build();
使用构建器模式可以让代码更加简洁易读,同时也可以避免构造函数的参数过多所带来的复杂性和不便。
3、使用私有构造函数强化不可实例化的能力
Java中的类默认情况下是可实例化的,即可以通过调用其公有构造函数来创建对象。但有时候,我们希望某个类不可实例化,而只提供静态方法和静态属性,例如工具类(Utility Class),此时我们可以通过私有构造函数来强化该类的不可实例化能力。
具体来说,我们可以在该类中显式声明一个私有构造函数,由于私有构造函数只能在该类的内部调用,因此外部无法直接调用该构造函数来创建对象。这样,如果有人尝试实例化该类,就会在编译时或运行时得到一个错误,从而达到了强化不可实例化的能力的目的。
下面是一个例子:
public final class UtilityClass {
// 显式声明私有构造函数,防止外部调用
private UtilityClass() {
throw new AssertionError("UtilityClass cannot be instantiated");
}
// 提供静态方法和静态属性
public static int add(int a, int b) {
return a + b;
}
}
在这个例子中,我们将UtilityClass类声明为final类,并且显式声明了私有构造函数。由于UtilityClass类是final类,因此无法被继承;由于私有构造函数只能在该类的内部调用,因此无法被外部直接调用创建实例。这样,我们就实现了一个只包含静态方法和静态属性的不可实例化的工具类。
4、避免创建不必要的对象
在Java程序中,创建对象是一个开销比较大的操作,它需要在堆内存中分配内存空间,并且要进行垃圾回收等操作。为了提高程序的性能和效率,我们需要尽量避免创建不必要的对象。
具体来说,我们可以采用以下几种方法来避免创建不必要的对象:
-
重用对象:在某些场景下,我们可以重用已经创建过的对象,而不是每次需要时都重新创建一个对象。例如,我们可以使用对象池(Object Pool)来管理一组可重用的对象,当需要使用时从对象池中获取,使用完毕后再放回对象池,而不是每次需要时都重新创建对象。
-
使用不可变对象:不可变对象是指创建后不能被修改的对象。由于不可变对象的值不会发生变化,因此可以安全地共享和重用,而不需要每次都创建新的对象。例如,String和Integer等基本数据类型的封装类就是不可变对象。
-
使用静态工厂方法:静态工厂方法是指在类中提供一个静态方法来创建该类的对象。与公有构造函数不同的是,静态工厂方法可以缓存已经创建的对象,从而避免创建不必要的对象。例如,Java中的valueOf方法就是一个静态工厂方法,它可以重用已经创建过的对象。
-
使用基本数据类型:在某些场景下,我们可以使用基本数据类型(int、double等)来代替对象。由于基本数据类型是值类型,而不是引用类型,因此创建和销毁的开销更小,可以提高程序的性能和效率。
总之,避免创建不必要的对象是提高Java程序性能和效率的重要方法之一。我们需要结合具体的场景,选择合适的方法来减少对象的创建和销毁。
当我们创建对象时,往往会使用new关键字,例如:
String str = new String("hello");
这样的语句会在堆内存中创建一个新的String对象,然后将该对象的引用赋值给str变量。但是,如果我们频繁地使用这种方式创建对象,会导致程序的性能和效率下降。
下面是一个示例代码,用于演示如何避免创建不必要的对象:
public class Test {
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,因为str1和str3指向不同的对象
System.out.println(str1.equals(str3)); // true,因为str1和str3的值相同
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}
}
在这个示例代码中,我们首先使用字符串字面量创建两个String对象str1和str2,然后使用new关键字创建一个新的String对象str3。由于字符串字面量在编译时就会被解析为常量,在运行时会被重用,因此str1和str2指向同一对象,而str3则指向新创建的对象。
接下来,我们比较了str1和str2的引用是否相等(使用==运算符)以及它们的值是否相等(使用equals方法)。由于str1和str2指向同一对象,因此它们的引用相等,而它们的值也相等。
最后,我们使用基本数据类型long计算从0到Integer.MAX_VALUE的和。在这个过程中,我们使用了基本数据类型long,而不是使用Long对象,从而避免了不必要的Long对象的创建和销毁。
5、小心使用终结方法和清除方法
具体来说,终结方法和清除方法的问题和限制包括:
- 不可预测性:终结方法和清除方法的执行时间是不可预测的,可能会导致性能问题和资源泄漏。
- 不保证执行:终结方法和清除方法并不保证会被执行,因为它们的执行取决于JVM的垃圾回收策略。
- 不安全性:终结方法和清除方法的执行时刻是不确定的,可能会访问已经被回收的对象,导致安全问题。
- 限制性:在终结方法和清除方法中不能使用try-catch块,不能抛出检查异常,也不能进行IO操作等。
下面是一个示例代码,说明了使用终结方法可能会导致的问题:
public class FinalizerExample {
private static int count = 0;
private final int id;
public FinalizerExample() {
id = count++;
}
@Override
protected void finalize() throws Throwable {
System.out.println("Finalizing " + id);
super.finalize();
}
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
new FinalizerExample();
}
System.gc();
}
}
在上面的代码中,我们创建了100000个FinalizerExample对象,并在main方法中调用了System.gc()方法来强制触发垃圾回收。由于终结方法的执行时刻是不确定的,因此有可能会出现以下情况:
- 终结方法没有被执行,程序立即退出。
- 终结方法被执行,程序运行缓慢,因为垃圾回收器在执行终结方法时会暂停程序的运行。
综上所述,我们应该尽量避免使用终结方法和清除方法,而是使用try-with-resources或者类似的机制来管理资源的生命周期。
6、考虑使用依赖注入来减少类之间的耦合性
依赖注入(Dependency Injection,简称DI)是一种设计模式,用于减少类之间的耦合性。在DI模式中,一个对象不直接创建和管理其依赖的对象,而是通过构造函数、工厂方法、注解等方式,将其依赖的对象通过参数传递进来。这样可以使得类之间的耦合度降低,代码的可重用性和可测试性也得到了提升。
下面是一个使用依赖注入的示例代码:
public class MessageService {
private final MessageSender messageSender;
public MessageService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String message) {
messageSender.sendMessage(message);
}
}
public interface MessageSender {
void sendMessage(String message);
}
public class EmailMessageSender implements MessageSender {
@Override
public void sendMessage(String message) {
// 发送邮件
}
}
public class SmsMessageSender implements MessageSender {
@Override
public void sendMessage(String message) {
// 发送短信
}
}
public class Main {
public static void main(String[] args) {
MessageSender messageSender = new EmailMessageSender();
MessageService messageService = new MessageService(messageSender);
messageService.sendMessage("Hello, world!");
}
}
在上面的代码中,我们定义了一个MessageService类和一个MessageSender接口。MessageService类依赖于MessageSender接口来发送消息,但它并不知道具体使用哪个MessageSender实现类来发送消息。在MessageService的构造函数中,我们通过依赖注入的方式将具体的MessageSender实现类传递进来。这样,在main方法中,我们就可以根据需要选择使用EmailMessageSender或SmsMessageSender来发送消息,而不需要修改MessageService类的代码。
通过使用依赖注入,我们可以将类之间的耦合度降低,使得代码更加灵活和可扩展。同时,依赖注入还可以方便地进行单元测试,因为我们可以轻松地替换依赖的对象为mock对象。
7、避免使用单例,除非它是真正需要的
在Java中,单例模式是一种常见的设计模式,它可以确保一个类只有一个实例,并提供全局访问点。但是,单例模式也有一些缺点,比如它可能会导致代码的可测试性和可扩展性下降,因为单例模式隐藏了对象的创建和生命周期管理,使得代码更难以重构和测试。因此,Joshua Bloch在《Effective Java》中建议我们避免使用单例,除非它是真正需要的。
下面是一个使用单例模式的示例代码:
public class Singleton {
private static Singleton instance;
private Singleton() {
// 私有构造函数
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public class Main {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2); // true
}
}
在上面的代码中,我们定义了一个Singleton类,它使用私有构造函数和一个静态方法getInstance()来确保只有一个实例。在main方法中,我们创建了两个Singleton对象,并通过==运算符来比较它们的引用是否相等。由于Singleton类只有一个实例,因此s1和s2的引用应该相等。
虽然单例模式可以确保一个类只有一个实例,但它也有一些缺点,比如:
- 可测试性:由于单例模式隐藏了对象的创建和生命周期管理,使得代码更难以重构和测试。如果我们需要对某个类进行单元测试,那么使用单例模式可能会使得测试更加困难。
- 扩展性:单例模式往往是通过全局静态变量实现的,这使得扩展性和灵活性降低。如果我们需要在应用程序中引入新的功能或修改现有功能,那么单例模式可能会使得代码更加难以扩展和修改。
因此,在实现Java类时,我们应该慎重考虑是否需要使用单例模式,只有在确实需要确保一个类只有一个实例并且全局访问点时,才应该使用单例模式。
8、如果实现了Serializable接口,要考虑实现readResolve方法
在Java中,如果我们将一个对象序列化到磁盘或通过网络传输,那么我们需要实现Serializable接口。但是,序列化和反序列化过程中可能会出现一些问题,比如对象的多次创建和状态的不一致。为了解决这些问题,建议实现readResolve()方法,它可以确保在反序列化过程中只创建一个对象,并且保证状态的一致性。
下面是一个示例代码,我们可以看到在实现Serializable接口的类中实现readResolve()方法:
import java.io.*;
public class Singleton implements Serializable {
private static Singleton instance = new Singleton();
private Singleton() {
// 私有构造函数
}
public static Singleton getInstance() {
return instance;
}
protected Object readResolve() {
return instance;
}
public static void main(String[] args) throws Exception {
Singleton s1 = Singleton.getInstance();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("test.ser"));
out.writeObject(s1);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("test.ser"));
Singleton s2 = (Singleton) in.readObject();
in.close();
System.out.println(s1 == s2); // true
}
}
在上面的代码中,我们实现了一个Singleton类,它是一个单例类,并且实现了Serializable接口。我们还实现了readResolve()方法,它返回单例实例,并在反序列化过程中确保只创建一个对象。在main方法中,我们首先创建一个Singleton对象s1,并将其序列化到文件中。然后,我们从文件中读取对象并将其赋值给s2。最后,我们使用==运算符来比较s1和s2的引用是否相等,如果相等,则说明readResolve()方法确实起到了作用。
总之,实现Serializable接口时,我们应该考虑实现readResolve()方法,以确保在反序列化过程中只创建一个对象,并保证状态的一致性。