阅读《Effective Java》每条tips的理解和总结(1)

时间:2023-03-09 01:45:22
阅读《Effective Java》每条tips的理解和总结(1)

《Effective Java》这本书的结构是90来条tips,有长有短,每条tip都值的学习。这里根据对书中每条tip的理解做简短的总结,方便日后回顾。持续更新~

1. 考虑用静态方法代替构造方法

要考虑使用静态方法返回对象的理由如下:

  (1)构造方法名字没有特殊含义,只能是类名。而静态方法可以自己取名,让人一看就知道这个方法的作用。如:Executors.newFixedThreadPool();

  (2)构造方法没有静态方法灵活,构造方法一旦调用就必然生成了对象,而静态方法里则可以自己写代码控制什么时候生成、怎么生成、生成什么样的对象;

为什么是让我们考虑而不是直接摒弃构造方法全部使用静态方法呢?

  (1)没有构造方法的类无法被继承。但是也鼓励我们使用组合而不是继承;

  (2)程序员不好找静态方法,而构造方法在文档中一目了然;(有点强行凑数)

2. 类的属性过多时使用Builder代替构造方法和setter方法

Builder是这个类的内部类,这个内部类具有和外部类相同的属性,在内部类中对各个属性进行链式初始化,最后再使用这个各个属性已经初始化好的内部类对象去构造一个外部类对象。具体不再赘述,进行开发时甚至可以使用某些插件的@Builder注解,直接为类生成Builder内部类。

使用Builder其实实质上与setter,构造方法初始类属性没什么性能上的差异,只是Builder看起来代码简洁、优雅一些。如果一个类的属性很多时,使用Builder确实是一个不错的选择。

3. 使用私有构造方法、枚举实现单例模式

使用私有构造方法实现单例模式很简单,其中的花样也很多,包括:懒汉式(使用时才创建)、饿汉式(类加载时就创建)。为了避免内存浪费,一般要懒汉式,但又有线程安全问题,直接加Synchronized效率又太低,为此又有双重条件检测+volatile修饰法、静态内部类方法解决线程安全问题并兼备懒加载。看起来似乎很完美,但实际上,用private修饰构造方法根本不能保证构造方法就不能被其他类调用了,比如通过反射把构造方法setAccessible(true),然后调用构造方法生成对象,又或者使用序列化把这个单例对象作为数据流存到文件中然后从这个文件中读取数据生成对象,这样一来二去就不是同一个对象了...

怎么解决呢,实际上有一种优雅的方法保证单例模式,那就是使用枚举类,然后只在里面生成一个枚举对象。枚举类是不可以作为反射目标的,可以看反射的源码:

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");

当一个类被ENUM修饰,尝试使用反射时就直接抛出异常。另外,枚举类在序列化后再反序列化生成的对象还是同一个(后续会讲为什么)。总之,相比私有化构造方法,枚举严格保证单例。作者直接下了结论:单元素的枚举类型已经成为实现Singleton的最佳方法

4. 使用私有构造方法防止实例化

为什么会有防止实例化这个需求呢?看看Math、Collections、Arrays等类就知道了,这些类的属性、方法都是静态的,是起一个工具类的作用,自然也就无实例化的必要。为了防止实例化,我们可以把构造方法设置为私有的。

不要想着将类声明为抽象类来达到防止实例化的目的,因为抽象类还可以被继承,然后实例化。另外,为了以防万一我们还可以再构造方法中抛出异常,当构造方法被意外调用马上抛出异常。

5.  依赖注入优于硬链接资源

我们在开发时会使用到很多工具类等资源,我们自己也会编写这种工具类。但是如果工具类需要使用其他资源时,要优先考虑使用依赖注入,例如:

class MyUtil {
private static final Resource resource = new Resource();
private Myutil(){
}
}
//工具类需要Resource资源的支持,但优先使用下面这种方法--------------------
class MyUtil {
private static final Resource resource;
public MyUtil(Resource r){
this.resource = r;
}
}

为什么要使用依赖注入,在构造方法中传入后赋给resource呢?除了构造方法更灵活外,上一种方式直接 这个资源每次都是new一个全新的,而第二种依赖注入则是将本类的引用指向那个已存在的对象,不是重新生成,可以说有点浪费了内存的意思。

总之,一个类依赖于一个或多个底层资源,这些资源的行为会影响类的行为,所以不要直接创建这些资源。将资源或工厂传递给构造方法, 这种称为依赖注入的实践将极大地增强类的灵活性、可重用性和可测试性。

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

其实这个建议很空,谁都知道要少创建对象减少GC的工作量,和节约内存。下面看几个反面教材,和几个建议吧:

String s = "sq";
String s = new String("sq");
//优先采用第一种,会优先使用常量池的字符串,常量池没有才创建
===============================》
Long s = 0L;
for(int i = 0;i < 10;i++ ){
s = s+i;
}
//把s声明为一个包装类Long,每次循环都会发生装箱,包成一个对象。使用包装类型时要注意这种情况,优先使用基本数据类型
===============================》
String s = "sq";
s.matches("某正则表达式");
//上面这样每次匹配时都要解析正则式,优先考虑下面写法:
Pattern p = Pattern.compile("某正则表达式");
p.macther(s).macthes();

虽然看起来只是一两个对象创建的开销,但是如果这些语句被上万次调用时坏后果就出来了,因此尤其是循环里的语句要注意别生成无用的对象。当然,一般对象的创建到回收开销是很小的,没必要把创建对象看作洪水猛兽,需要注意的是重型对象,比如:正则表达式需要解析,数据库连接,线程等等。最最需要注意的还是重型对象的大量创建,这时候就可以考虑使用连接池了。

7. 消除过期对象的引用

  这时为了防止内存泄漏,但是手动去清除引用应该是一个例外而不是规范,大都数情况都是让引用超出作用域就行,例如:执行一个方法,则压入栈帧中有一个引用指向堆中某对象,当方法执行完毕后,栈帧销毁,自然执行那个对象的引用也没了,对象也就被回收了。

一般只有一个类自己管理内存时、或者使用缓存时要注意内存泄漏。推荐使用WeakHashMap来实现缓存。

8. 消避免使用Finalizer和Cleaner机制

  Java不同于c++,不需要用析构函数回收内存,而是由gc自动完成内存回收。不要使用Finalizer机制的原因:

  (1)finalizer执行时机难以捉摸,它开始运行后,运行时间是任意长的,这就导致资源回收有极大的延迟性。(System.gc()、System.runFinalization() 等方法也只是提高回收概率)

  (2)会有finalizer攻击,如果对象的回收依赖finalizer,那么就可以继承这个类并重写析构函数,在 finalizer() 上搞破坏,从而让对象无法被回收。

Finalizer和Cleaner机制的作用就是起一个安全网的作用,防止资源拥有者忽略了它的close()方法;以及对于一些与Java对象对应的本地对象(Native peers),gc无法回收,可以使用Finalizer、Cleaner。

9.使用try-with-resource代替try-finally

  很多人坚定的认为 try-finally是最好的关闭资源的方式,其实不是,它存在如下问题:

try{
OutPutStream out = new FileOutPutStream(dst); //line1
}finally {
out.close(); //line2
}

(1) 如果line1在执行时出了异常,接下来执行2如果也抛出异常,那么line2出的异常就会覆盖掉line1的异常,使得调试很困难      (2)如果连续两行使用资源用try-finally,那么代码会非常难看

而使用try-with-resource则不会有这些问题,直接将使用资源语句放到try()中:

try( OutPutStream out = new FileOutPutStream(dst);
InPutStream in = new FileInPutStream(src); ) //这种写法close()方法只是隐藏了,还是会执行的
{
in.read(...);
out.write(...); //直接使用打开的资源,不用处理善后
}

无论多少资源使用语句,都很简洁。而且,资源使用和close()方法同时出异常时,会抛出后面的异常,前面的异常会被抑制,但是还是会打印在堆栈中并标记为“suppressed”。注意,资源类要实现AutoCloseable接口,才可以 : try(资源使用语句)这样用。

10. 重写equals()方法注意

equals() 方法时Object类的方法,创建类似时可以重写它。表示值的类可以考虑重写equals(),如String,实现其他逻辑的相等;表示活动的类一般就不用重写equals了,如Thread,使用Object自带的逻辑:两者地址相等即是同一个对象时才认为相等。重写equals() 方法时应该遵守一些约定(JDK、大家都遵守这个约定,你不遵守你就不能正常使用JDK、别人的库):

阅读《Effective Java》每条tips的理解和总结(1)

11. 重写equals方法时也要重写hashCode方法

还是因为约定,为了能正常使用JDK。JDK里不少地方用到了equals 和 hashCode的特性,比如:HashMap里面根据key的hashCode确定存储在数组中的位置,根据equals方法判断两个key内容是否一样决定是更新value还是插入新键值对。重写hashCode时应该满足如下要求:

(1)equals方法相等的两个对象,hashCode方法返回的哈希码也应该相等。否则会 出现很多问题,如HashMap中出现两个及以上相同的key

(2)hashCode相等的对象,equals不一定相等。当然我们的hashCode方法应该尽量保证不一样的对象不要返回相等的hashCode,提高散列表的性能

总之,equals方法根据什么逻辑判断,hashCode就应该根据什么逻辑计算哈希码。如Object根据地址判断,哈希码就应该根据地址生成;而String类的equals根据字符内容判断,哈希码就应该根据内容即每一个字符生成。保证equals相等,哈希码相等。

12. 始终重写 toString() 方法

Object的 toString 方法打印的信息有限:类名@ 由地址生成的十六进制哈希码。自己定义的类如果直接用Object类的toString方法,看不出什么有用的信息。一般推荐toString方法把类的属性值挨个打印出来。

13. 谨慎的重写clone方法

clone方法是Object类的protected权限的native方法,这就决定了子类对象如果继承它的clone()方法,是不能在其他类的方法中调用的,而是必须重写为public权限的方法时才能在各个类的方法中使用clone()方法。这是因为:Object类的clone()方法是一个浅拷贝,只会拷贝对象并把成员属性对象的引用拷贝一份,这会出现很多问题。因此我们要使用拷贝功能时就要重写clone方法,进行深拷贝处理。如:

public Stack clone() {
try {
Stack s = (Stack)super.clone();
s.elements = elements.clone(); //深拷贝
return s;S
}catch(CloneNotSupprotedException e) {
....
}
}

上面在拷贝一个栈对象时,还把栈的存放元素的数组也拷贝了一份新的,这就是深拷贝了。当然,重写的clone方法,首先要调用super.clone(),一层一层形成一个链式调用,最终调用Object的本地方法,之后再自己进行深拷贝处理。此外,重写了clone方法调用了Object的clone方法的类还应该实现Cloneable接口,否则会抛出CloneNotSupported异常,这个异常是受检的。

注意:我们不必将elements.clone的结果转换为Object[]数组。在数组上调用clone会返回一个数组, 其运行时和编译时类型与被克隆的数组相同。这是复制数组的首选习语。事实上,数组是clone机制的唯一有力的用途。

clone方法实际上往往有更好的替代,如果一个类不是继承了某个实现了Cloneable接口的父类,我们就不必为这个类重写clone方法。可以使用构造方法、静态方法实现复制功能,而且更简洁、更安全,这些方法的参数还可以是接口、父类,更灵活。

14.考虑实现comparable接口

compareTo()方法并不是Object的方法而是Comparable接口的方法,但创建某些有值的类需要重写他,以便这个类的对象可以使用一些工具类的sort()方法,十分方便,总之就是这时实现Comparable接口重写compareTo()方法是十分值得的。compareTo方法返回的值是int类型的:

public int compareTo(ClassA a)
{
.... //-1表示本对象比参数的对象小,0表示二者相等,1表示本对象比参数对象a大
}

当一个类有多个值的属性时,我们可以重写时根据每个属性的重要程度决定优先靠那些属性排序:

public int compareTo(PhoneNumber pn) {
int result = Short.compare(areaCode,pn.areaCode); //首先根据电话号码的区域码比较
if (result == 0) {
result = Short.compare(prefix, pn.prefix); // 相等再根据中间号码
if (result == 0)
result = Short.compare(lineNum,pn.lineNum);//都相等则根据尾号判断
}
return result;
}

要注意的是:不要在compareTo方法中直接使用‘<’ ,'>','-'这些运算符来比较两个属性,可能有null、溢出等问题。推荐使用包装类的compare()方法,不会出错;也可以使用Comparator比较器,性能要差一点但胜在方便。

还有个强烈建议:遵循equals()方法的准则。a.equals(b) 为true表明二者逻辑相等,则使a.cmpareTo(b)返回0。不然会有一些问题,如反面教材BigDecimal类:new BigDecimal("1.0"), new BigDecimal("1.0000")是两个不同的对象,放入HashSet时是看作两个不同的对象;但是TreeSet判断元素是否重复时是根据compareTo返回的值是否为0,因为这个类型集合的元素是不重复但要排序的,这两个对象放入TreeSet时只能存储一个,因为BigDecimal的compareTo时二者是相等的。如果遵循上述建议则可以避免程序中发生一些奇怪的事。