Effective Java笔记

一、创建和销毁对象

1.静态工厂方法和公有构造函数

示例代码:

1
2
3
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}

优点:

  • 静态工厂方法名相对构造函数,方便指示使用者选择合适的方法。
  • 静态工厂方法可以控制不创建新对象,而是重复使用一些对象。如示例代码所示。
  • 静态工厂方法可返回返回类型的子类对象,设计模式中的工厂模式。

缺点:

  • 静态工厂方法一般会把构造函数非公有,导致类不能被继承。但是这样鼓励我们使用组合而非继承。

总结:

  • 如果需要重用对象(单例),请使用静态工厂方法。
  • 如果需要提供不同的具体实现(子类),或者说面向接口编程,请使用静态工厂方法。

2.多个构造函数与Builder(构建者)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
public MyView(Context context) {
this(context, 100, 100);
}
public MyView(Context context, int width, int height) {
this(context, width, height);
}
public MyView(Context context, int width, int height, int margin) {
...
}

像这种参数比较多,并且有可选参数时,建议使用Builder模式。
另外,Builder不建议使用Java Bean的getter、setter形式,建议使用链式调用写法,代码更简洁。

优点:

  • 将传入的参数处理职责分离出去,类专注于功能,Builder专注于构建类参数,这样代码可读性跟拓展性增强。

3.避免创建不必要的对象

比如SimpleDateFormat对象。

4.清除过期的对象引用

  • 适当的时候将引用置为null。
  • 使用WeakReference。

5.避免使用finalize方法

原因:

  • jvm不保证对象的finalize方法及时执行。
  • jvm不保证对象的finalize方法一定执行。

建议:

  • 在适当的时机,如try-catch中finally代码块调用close、recycle等方法。

二、方法的实现

1.重写equals

性质:

  • 自反性(reflexive) x.equals(x) = true
  • 对称性(symmetric) x.equals(y) <=> y.equals(x)
  • 传递性(transitive) x.equals(y) = y.equals(z) 那么 x.equals(z)结果应为一样
  • 一致性(consistent)x,y不变时,x.equals(y)也不变
  • 非null的x,x.equals(null) = false

由于参数固定为Object类型(否则就是方法重载了),有必要做类型检查(instanceOf)。

1
2
3
4
@Override
public boolean equals(Object o) {
return super.equals(o);
}

2.重写hashCode

重写了equals方法后,最好也重写hashCode方法。

原因:

  • 两个对象equals方法返回true,不代表两个对象的hashCode返回值相等。
  • 默认hashCode本质上是这个对象的散列值,是以整个对象所有对象生成的,而我们重写equals方法可能只用到对象的关键成员,并不是全体成员。

常用的类比如HashMap,我们使用自定义的类作为key时,HashMap会调用key的hashCode方法作为判定key相等的依据之一,并不是重写了equals方法就万事大吉了。

3.重写toString

可以准备好一份模版或者使用脚本,生成toString函数。
比如可以利用toString将Java Bean转换为json字符串。

三、类和接口

1.组合优于继承

对于接口的实现和继承不适用这条。

父类有可能因为版本迭代而修改某些方法,这样子类很可能也要做出改变,但是我们可能对具体实现不敏感,只是想增加额外方法。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 接口定义
*/
public interface PriceCalculator {
float getPrice(int sum);
}
/**
* 包装类
*/
public PriceCalculatorWrapper implements PriceCalculator {
private PriceCalculator mCalculator;
//唯一构造函数
public PriceCalculatorWrapper(PriceCalculator calculator) {
this.mCalculator = calculator;
}
@override
public float getPrice(int sum) {
return mCalculator.getPrice(sum);
}
}
/**
* 具体实现类
*/
public ShopPriceCalculator implements PriceCalculator {
@override
public float getPrice(int sum) {
...
}
}

可以跟设计模式中的策略模式关联起来。

2.接口优于抽象类

  • 一个类能实现多个接口,只能继承一个类。
  • 面向接口编程,外界知道得越少越好。
  • 接口定义了一类方法的集合,做一件事可能有几个步骤,但是是通过一个接口完成的。

3.接口最好只定义方法

接口虽然可以有静态常量成员变量,但是并不是每个接口的实现类都会用到这些变量。虽然这并不影响运行效率,但是从代码可读性而言,在接口定义静态常量成员变量不是明智之举。

4.使用函数对象实现策略模式

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Comparator<T> {
int compare(T t1, T t2);
}
class StringLengthComparator implements Comparator<String> {
@override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
//每一次排序均要创建一个比较函数对象
Array.sort(stringArray, new StringLengthComparator());

C++支持函数指针,即把一个函数体用指针作为参数传入别的作用域。这样可以达到面向接口编程的效果,不关注方法的具体实现。而java中,最相似的案例就是回调监听的实现了,只不过java没有指针,而是通过一个实现了监听接口的对象来传入别的作用域。

由于策略模式的方法具体实现是无状态的(上一次调用与下一次调用无关),所以可以考虑将函数对象做成单例。

建议:

  • 如果需要频繁使用该函数,那么最好做成一个单例,而不是每一次都创建新对象。
  • 如果使用次数不多,做成单例会导致函数对象生命周期过长,对于内存敏感的程序要慎重,虽然这个单例占用内存可能不多。

5.慎用非静态内部类

匿名内部类也属于非静态内部类。

示例代码:

1
2
3
4
5
6
7
8
9
public class MainActivty extends ... {
public void work() {
new Thread() {
Toast.makeText(MainActivity.this, "hello world" ,Toast.LENGTH_SHORT);
//do something
...
}
}
}

在work函数里面,创建了一个匿名内部类,它持有了外部类的强引用(MainActivity.this)。如果内部类的生命周期比外部类长的话,就会发生内存泄漏。

不仅是内存泄漏,由于内部类有外部类的强引用,也会占用一定内存空间。比如HashMap中的Entry如果不是静态内部类,那么每个Entry都会拥有HashMap的强引用,一旦节点数量多了,也会影响性能。

建议:

  • 如果不需要外部类的引用(调用方法),请加上static关键字修饰内部类。
  • 如果需要使用非静态内部类,请谨慎管理非静态内部类对象的生命周期。

四、泛型

1.不要使用原生态类型

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//原生态类型
List list = new ArrayList();
//非原生态类型
List<String> list = new ArrayList<>();
--------------------------------------------------------------------
//错误代码
List<String> list = new ArrayList<>();
unsafeAdd(list, new Integer(1));
//crash
list.get(0);
--------------------------------------------------------------------
public static void unsafeAdd(List list, Object o){
list.add(o);
}
  • unsafeAdd方法参数list使用了原生态类型,并没有对插入元素做类型检查。所以在编译期不会报错。
  • 等到调用了get方法时,程序尝试将Integer强转为String,抛出异常。

注意!有两种特殊情况:

1
2
3
4
5
6
7
8
9
//1.获取class类
List.class,String[].class,int.class 合法
List<String>,List<?>.class 不合法
//2.类型判定
if (o instanceOf Set) {
Set<?> set = (Set<?>) o;
...
}

总结:
我们应该使用带泛型参数的类,让编译器帮助我们暴露程序的问题,而不是等到程序运行异常才暴露问题。

2.泛型列表优于泛型数组

示例代码:

1
2
3
4
5
6
7
//运行时报错
Object[] objects = new Long[1];
objects[0] = "hello world";
//编译不通过
List<Object> list = new ArrayList<Long>();
list.add("hello world");

原因:

  • Object[]是Long[]的父类,因此分配内存给objects时不会抛异常。
  • List并不是ArrayList的父类,也不是List的父类,所以编译不通过。
  • Java泛型使用的是类型擦除机制实现泛型的,运行时把具体类型替换掉。编译期在编译时会做类型检查,这也是使用非原生态类型的好处。

五、枚举和注解

1.用enum代替int常量

Java枚举类型基本想法:通过public static final为每个枚举常量导出实例的类。

  • 枚举没有公有构造函数,所以程序员无法手动创建枚举实例。
  • 枚举是线程安全的(final),也是防序列化反序列化的,很适合做单例。
  • 枚举常量是一个个对象,功能上肯定比一个int常量要强多了,相对的开销也大了。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
public enum Sample {
SOLO(1), DUET(2), TRIO(3);
private final int order;
Sample(int order) {
this.order = order;
}
public int getOrder() {
return order;
}
}

不要使用默认顺序和ordinal()函数,而是使用一个成员变量记录序号。因为这样便于维护和检查。

2.坚持使用override注解

示例代码:

1
2
3
4
5
6
public class Test {
private int order;
public boolean equals (Test t) {
return this.order == t.order;
}
}

equals没有使用override注解,导致编译期编译时没有“检查”。事实上,这个equals方法看似是重写,实际上是重载。因为equals方法原型是public boolean equals(Object o)

3.常用注解

1
@callSuper //声明子类若重写该方法,必须调用父类方法

六、函数方法

1.必要时进行保护性拷贝

示例代码:

1
2
3
4
5
6
7
8
9
10
public class Adapter {
private List<String> mList;
public Adapter(List<String> list) {
mList = list;//没有保护性拷贝
mList = new ArrayList(list);//进行保护性拷贝
...
//检查参数有效性
...
}
}

这一条主要按业务逻辑分的,如果想共用同一个参数对象,那么保存它的引用就好,否则我们应该新创建一个对象对参数对象进行拷贝。

保护性拷贝场景:

  • 不希望参数对象在类外部变化时能影响类时。
  • 对并发场景有要求。

2.检查参数有效性

示例代码:

1
2
3
4
5
6
7
@override
public boolean equals(Object o) {
if(o instanceOf String) {
String s = (String) o;
...
}
}

检查参数有效性一般在保护性拷贝之后。方法调用前检查参数合法性,减少调试的复杂性。

3.谨慎增加快捷方法

示例代码:

1
2
3
4
5
6
7
8
public void showWindow(int width, int height, long time) {
...
}
//快捷方法,宽高默认100
public void showWindow(long time) {
...
}

除非快捷方法经常被调用,否则最好不要写快捷方法。因为增加快捷方法会使类的方法数上升,对于用户的理解难度相对增加;对于维护者,增加了维护默认参数和文档的职责。

4.谨慎使用重载

这里需要强调重载和重写的运行时机区别:

  • 重载方法运行时机是在编译期确定好的,一个参数对象编译期是什么类型,决定了它会走哪个重载方法,并不会它运行时是子类对象而发生改变。
  • 重写方法运行时机是在运行时才确定的,是根据对象的实际类型决定走哪个类的重写方法,不受编译时类型影响。

5.谨慎使用可变参数

弊端:

  • 方法调用前得检查参数有效性稍麻烦。
  • 可变参数方法的每次调用都会导致进行一次数组分配和初始化。

建议:

  • 根据调用频率,给频率高的方法提供单独方法,对于频率低的可以使用可变参数方法。

示例代码:

1
2
3
4
5
public void foo() { }
public void foo(int a1) { }
public void foo(int a1, int a2) { }
public void foo(int a1, int a2, int a3) { }
public void foo(int a1, int a2, int a3, int... rest) { }

6.返回长度为零的数组或集合,而不是null

原因:

  • 返回null调用者需要对null作额外处理。当然调用者也需要对零长度作处理。
  • 零长度的数组或集合创建开销并不大。
  • 零长度的数组属于不可变的(final),可以共享。

七、通用程序设计

1.尽量使用for-each而不是for循环和使用Iterator

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//for-each
for(String s : list) {
doSomething(s);
}
//for循环
for(int i = 0;i < a.length; i++) {
doSomething(a[i]);
}
//Iterator
for(Iterator<String> i = list.iterator(); i.hasNext(); ) {
doSomething(i.next());
}

优点:

  • 代码简洁。
  • 性能较好,只计算一次size。

2.基本类型优于装箱基本类型

示例代码:

1
2
3
4
//自动装箱
Integer integer = 10;
//自动拆箱
int i = integer;

对照关系:

1
2
3
4
5
6
byte - Byte
int - Integer
long - Long
boolean - Boolean
float - Float
double - Double
  • 自动装箱是基本数据类型自动转换为装箱基本类型,是java语法糖。它伴随着新对象的生成,当然有性能损耗。

  • 自动拆箱是调用装箱基本类型对象的取值方法。

3.如果需要精确值,避免使用float和double

  • float和double执行二进制浮点运算,这是为了在广泛的数值范围上提供较为精确的快速近似计算而精心设计的。

  • 然而他们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合。

需要准确结果,请使用BigDecimal类,它额外提供了几种四舍五入的模式。

4.慎用String连接

示例代码:

1
String str = "hello" + "world";

这里涉及到三个对象生成。

  • String是不可变的(final),一旦一个String对象生成,它本身是不会发生改变的。

  • String相加其实是语法糖,运算符重载。

  • 三个对象分别为”hello”、”world”、”helloworld”。

八、异常

1.不要滥用异常机制

有时代码抛出异常,如果无法快速找到修复方法,我们为了尽快修复,可能会使用try-catch来暂时避免程序崩溃。我们应尽量不使用try-catch来捕获处理异常,而是通过代码逻辑来判断异常发生。

2.使用try-catch时,请尽量缩小代码块范围

异常机制是在jvm层面实现的,try-catch代码块会阻止jvm执行某些特定优化,导致程序运行效率下降。

最常见的情况就是打开文件做文件操作时,某些程序员为了省事直接用try-catch把方法开始到方法结束包住,因为他们并不知道这样的写法会导致程序运行效率下降。

正确的做法应该是尽量减少try-catch代码块的长度,宁愿多用几个try-catch代码块,也不要一个try-catch代码块将全部代码包住。

3.throw明确异常,catch明确异常

对异常进行分类抛出和处理,便于日后维护。

九、序列化

1.Serializable

需要序列化的类(如java bean)才实现Serializable,因为实现一个接口总是有代价的。

2.Parcelable

这个接口是android特有的,出现的原因之一是解决因为本地序列化反序列化耗时较长导致某些重要操作耗时变长。它不能本地化(序列化到本地文件),而是序列化到内存中。相对于Serializable,其实不能相提并论,因为使用场景不一样。

比如,Activity#onSaveInstanceState(Bundle state)、Activity#onRestoreInstanceState(Bundle state)这两个方法,需要对Activity的状态保存或重建,对速度要求高,对本地化存储没有要求。