快速带你看完豆瓣9.8分神作《Effective Java》—— 创建和销毁对象篇

「时光不负,创作不停,本文正在参加2021年终总结征文大赛


豆瓣评分9.8的图书《Effective Java》,是当今世界顶尖高手Josh Bloch的著作,在我之前的文章里我也提到过,编程就像练武,既需要外在的武功招式(编程语言、工具、中间件等等),也需要修炼心法(设计模式、源码等等)学霸、学神OR开挂

在这里插入图片描述

我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。

最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。

接下来就针对此书列举一下我的收获与思考。

不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展 就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。

没有时间读原作的同学可以参考我这篇文章。


1 静态工厂代替构造器

这一节其实也是在说明工厂设计模式的优秀之处,它相对于构造函数的优势在于:

  • 静态工厂方法有名称

比如BigInteger.probablePrime这个静态方法让人一眼就能看出构造的对象可能是素数

  • 可以不在每次调用时都创建一个新对象

可以参考Spring里面创建单例的逻辑,将构建好的实例存在缓存里返回;而我们调用构造函数时总会创建一个新对象。

  • 静态工厂可以返回原类的子类
class A{
	// 构造函数只能得到本类的一个对象
	public A(){...}
	
	// 静态构造方法可以得到A的子类
	public static A的子类 XXXFactory(){...}
}
复制代码

此外,Spring源码里的BeanFactory是工厂设计模式很好的实践,有异曲同工之妙~

  • 可以使用静态工厂方法的入参来控制返回的对象类型

例如EnumSet类的静态工厂方法可以根据入参的元素个数来决定返回RegularEnumSet实例还是JumboEnumSet实例

  • 方法返回的对象所属的类,在编写包含该静态工厂方法的类时可以不存在

这个乍一看有点拗口,但实际上说的还是返回值可以是子类,举个例子:JDBC数据库连接API大家肯定都用过吧,里面的Driver,Connection,Statement都是借口,当我们真正使用时其实用的是厂商的实现类,在编写JDBC时,某些数据库可能根本还没有被研发出来,但后来我们都能使用的原因就是数据库厂商做了对接。


聊完了静态工厂方法的优点,我们可以再聊一聊缺点,这样才能更清楚作者为什么提倡我们这样做:

  • 类如果不含有public的或protected的构造器,就不能被子类化。

通俗一点讲就是,静态工厂方法的本质是,通过返回这个函数返回值的子类,实现灵活性。但是由于子类继承父类,子类的构造函数会默认调用父类的无参构造函数,如果没有就需要显示调用同参的父类构造函数,因此如果父类的构造函数不是共有的或者protected那么就无法实例化子类,那么静态工厂方法就没有意义。

不过作者也介绍了这或许可以“因祸得福”,因为这样鼓励程序员使用复合而不是继承

  • 在API文档里,他们没有想构造函数那样被明确标记出来。

在类的注释里将静态工厂标注出来可以解决,并且javadoc日后必定会增加这一块内容。


2 构造函数有多个参数时要考虑使用构建器

多参数构造函数:

假设一个类的构造函数有多个参数时,如果想要创建实例时,就会出现这样的代码:

NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
复制代码

这个调用通常需要许多你根本不想设置的参数,但却不得不设置,比如第三个传入的0。这种方式是可行的,但如果有很多参数的时候,客户端代码会很难编写,程序员会因为要避免传错参而小心翼翼。

记得之前在公司里面用了其他人封装的日志工具类LogUtil,里面的构造函数就是这样的风格~~,不仅传的参数多,还提供了各种各样不同参数组合的构造函数,最后当然逃不过被重写的命运 : )

JavaBeans模式:

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
复制代码

这种模式下先调用无参构造器创建对象,再调用setter方法来设置每个必要的参数,这样的代码可读性要更高,但却有严重的缺点:

  • 创建对象的过程不是原子的,于是在高并发场景下可能产生不一致的对象
  • JavaBeans模式无法将类做成不可变的,因为调用了setter方法本身就意味着“可变”了

建造者(Builder)模式: Mybatis中的SqlSessionFactoryBuilder就是建造者模式的体现,在类的定义里面,builder通常是类的静态成员类,并且调用无参的build方法生成的通常是不可变的对象,使用示例:

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build();
复制代码

Builder模式十分灵活,可以利用单个builder构建多个对象;builder的参数可以在调用build方法来创建对象期间进行调整,甚至可以填充某些域,比如创建新对象是自动增加序号值。

3 使用private构造器或枚举类型强化Singleton属性

相信接触过设计模式的同学都知道,单例的一种经典实现方式就是private的构造函数,但直到我看到这一章节内容的时候,才突然顿悟原来枚举类型也是可以强化单例属性的,自愧个人的融会贯通能力还有待加强。

实现单例方式一:

// Singleton with public final field
public class Elvis {
	public static final Elvis INSTANCE= new Elvis();
	private Elvis() { .. . }
}
复制代码

这种实现方式有一个缺点就是可以借助反射机制里的AccessibleObject.setAccessible来强制修改构造函数成为public的


实现单例方式二:

// Singleton with static factory
public class Elvis {
private static final Elvis INSTANζE = new Elvis();
	private Elvis() { ... }
	public static Elvis getInstance() {return INSTANCE; }
}
复制代码

在实际抉择中,如果用到了以下某种优势,则优先考虑第二种实现,否则考虑第一种单例实现:

  1. 可以很容易被修改成非单例的(修改getInstance的返回语句即可)
  2. 可以使用Elvis::instance语法

此外,为了防止每次序列化和反序列化都创建一个新实例而破坏了单例特性,需要在Elvis类中添加一个方法:

private Object readResolve () {
	return INSTANCE;
}
复制代码

实现单例方式三: 这种方式也是我原先没有想到的,声明包含单个元素的枚举类型来实现:

public enum Elvis {
	INSTANCE;
	public void leaveTheBuilding() { .. . }
}
复制代码

其中Elvis.INSTANCE可以获得单例,Elvis.INSTANCE.leaveTheBuilding();调方法。


这种方式也是作者极力推荐的一种方式,无偿地提供了序列化机制,绝对防止多次实例化。单元素的枚举类型经常成为实现Singleton的最佳方法。

4 使用privete的构造函数强化不可实例化的能力

这一条主要讨论的是在编写工具类时,往往这些类是不希望其被实例化出来的,例如java.lang.Math,一个好的做法就是手动编写一个private的构造函数。

缺点是这个类就不能被子类化了,因为子类没有可以调用的超类构造器了。

5 引用资源时应优先考虑依赖注入

举个例子说明引用资源:拼写检查器需要依赖词典,这个“词典”就是所谓的资源。


有两种常见的引用资源的方式:

  1. 封装成静态工具类
private static final Lexicon dictionary;
public static boolean isValid(String word){...};
复制代码
  1. 设计成单例类
private 构造函数;
private final Lexicon dictionary;
public boolean isValid(String word){...};
复制代码

实际上,上面两种实现方式是错误的,因为它们都假定了只有一本词典可用,现实生活里每一种语言都需要自己的词典。


通过上面的讨论,我们可以用一个最简单的思路去解这个问题:每创建一个新实例时,都将其依赖的资源传到构造器里即可。

public class SpellChecke {
	private final Lexicon dictionary;
	
	public SpellChecker(Lexicon dictionary) {...}
	public boolean isValid(String word) {...}
}
复制代码

其实这就是依赖注入的一种形式。

依赖注入的一个变体是将资源工厂(factory)作为参数传给构造器,例如: Mosaic create(Supplier<? extends Tile> tileFactory){...}

对了,Spring就是一种经典的依赖注入框架!


6 避免创建不必要的对象

从字面意思上来看,大家肯定都知道创建不必要的对象是错误的做法。但这一节其实主要是提醒我们避免无意识的创建不必要对象的代码写法。

例1:

String s = new String("abc");
复制代码

是错误的写法,正确的写法应该是:

String s = "abc";
复制代码

原因是第一种写法每次被执行的时候都会创建一个新的String实例,但这些全都是重复的!


例2:

我们要优先使用静态工厂方法而不是构造器来避免创建不必要的对象,如Boolean.valueOf(String)总是要优先于构造器Boolean(String)使用。因为构造器每次被调用都会创建一个新对象,静态工厂不这样。


例3: 创建成本昂贵的对象时,应该将其缓存起来。

例如正则表达式匹配的代码中,String.matches方法内部创建了一个Pattern实例,这个创建的成本很高,因为需要将正则表达式编译成有限状态机,所以应该将其缓存起来:

public class RomanNumerals {
	private static final Pattern ID = Pattern.compile("^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$");
	
	static boolean isRomanNumeral(String s){
		return ID.matcher(s).matches();
	}
}
复制代码

这样一来,每次调用isRomanNumeral时都会重用同一个ID实例


例4: 上面的Pattern实例是不变的,但在某些场景下实例是可变的,这时就可以考虑适配器。适配器是这样一个对象:它将功能委托给一个后备对象,为后备对象提供一个替代前面功能的接口。

例如Map接口的KeySet方法,每次调用返回的都是同一个Set实例,虽然Set实例是可变的,但其中一个变化时其他的也会跟着变,因为他们本身就是一个。


例5: 优先使用基本类型而不是装箱类型,原因在于下面这个例子:

private static long sum(){
	Long sum = 0L;
	for(long i = 0; i <= Integer.MAX_VALUE; i ++)
		sum += i;
		
	return sum;
}
复制代码

这段程序执行起来没有任何问题,但实际情况会慢一点,因为sum的类型是Long而不是long,所以程序构造了大约2^31个Long实例。


这一点在我记忆中和工作里的要求不一致,为此我专门去翻阅了阿里巴巴Java开发手册,里面是这样描写的: 在这里插入图片描述 可见公司在这个问题的考虑上是业务优先了,所以小伙伴们可以斟酌使用时的取舍,我个人还是推荐使用包装类型的。

避免一个误区: 不要看完这一章节就陷入了创建对象的代价非常昂贵的逻辑怪圈里去了,反之维护自己的对象池来避免创建对象是一种错误的做法。因为现代JVM的实现里有高度优化的垃圾收集器,其性能很容易就超过了轻量级对象池的性能。

一个正确的示例是数据库连接池,因为建立一个数据库的连接是非常昂贵的。


7 消除过期的对象引用

这一条建议主要讲的是要规避内存泄漏。因为像Java这种具有垃圾回收机制的语言,内存泄漏一般都是比较隐蔽的。

例如:

package com.wjw;

import java.util.Arrays;
import java.util.EmptyStackException;

/**
 * 2 * @Author: 小王同学
 * 3 * @Date: 2021/11/23 20:50
 * 4
 */
public class Stack {
    private Object[] elements;

    private int size = 0;

    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack(){
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e){
        ensureCapacity();
        elements[size ++] = e;
    }

    public Object pop(){
        if (size == 0)
            throw new EmptyStackException();
        return elements[-- size];
    }

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements,  2 * size + 1);
    }

}
复制代码

上述代码中存在着内存泄漏,如果向栈中先添加元素再弹出元素,弹出来的对象不会被回收,因为栈内部维护着弹出对象的过期引用。

解决这个问题很简单,将出栈元素的引用设为过期即可: 在这里插入图片描述


内存泄漏的其他来源:

  • 缓存

原因是被放入缓存的对象引用容易被我们遗忘。利用缓存中存储数据的价值与存储时间的长短成反比的特点,可以开一个后台线程及时清理掉失效项。

  • 监听器和其他回调

原因是客户端在我们提供的API中注册回调,但却没有取消回调时,它们就会堆积起来。如果我们希望回调立即被回收的话可以只保留它们的弱引用(WeakHashMap中的键)。

这里还要补充一点关于WeakHashMap的知识:WeakHashMap其实是一种弱引用Map,key会存储为弱引用,当GC时,如果这些key没有外部强引用存在的话(当回调对应的强引用被不存在了时),就会被垃圾回收掉。它的这个特性也多被用来实现缓存,如果外面对某个key的引用不存在了,缓存中key对应的这一项就会被自动删除。

例如:使用WeakHashMap存储BigImage实例,key是ImageName类型,value是BigImage实例,如果令imageName = null ,这样就没有强引用指向这一个key了,BigImage实例就会在GC时被回收掉

WeakHashMap<UniqueImageName, BigImage> map = new WeakHashMap<>();
BigImage bigImage = new BigImage("image_id");
UniqueImageName imageName = new UniqueImageName("name_of_big_image"); // 强引用

map.put(imageName, bigImage);
assertTrue(map.containsKey(imageName));

imageName = null; //map中的values对象成为弱引用对象
System.gc(); //主动触发一次GC
复制代码

8 避免使用终结方法和清除方法

终结方法(finalizer)和清除方法(cleaner)不可预测,也有一定的危险性,应该避免使用

它们的缺点主要有以下几点:

1. 不能保证会被及时执行 终结方法线程优先级比应用其他线程的优先级低得多,甚至还会有程序终止时都还没来得及执行的情况。所以如果使用它们来释放共享资源上的锁时,很容易让系统崩溃。

2. 不处理终结过程中抛出的异常时,终结过程会停止 正常情况下程序如果出异常了会打印异常信息,但如果异常出现在终结方法里面则什么都不会打印,根本无法下手去debug。

3. 终结方法和清除方法有一个非常严重的性能损失 主要原因是因为终结方法阻止了有效的垃圾回收。

4. 终结方法有一个严重的安全问题 黑客可以利用终结方法发起攻击。

如果构造函数抛异常了,恶意子类的终结方法就可以在构造了一部分的对象上运行,阻止该对象被垃圾回收。这样就可以在这个对象上调用原本不允许出现在这里的方法。

正常情况下,构造函数抛异常了,对象也就创建失败了,使用终结方法的话就没有这个特性了。为了防止受此攻击,要写一个空的final的finalize方法。如果对象中封装的资源确实需要终止,绕过编写终结方法或清除方法的方式是让类implements AutoCloseable,客户端在每个实例不再需要时调用close方法。


当然存在即合理,在下面两个场景里终结方法和清除方法还是很有用的:

1. 充当“安全网”,防止忘记调用close方法 安全网这个词看似高大上,实际上这里就是兜底逻辑的意思

2. 终止非关键的本地资源 本地对等体是一个native的对象,Java对象会通过native方法委托给一个本地对象,这个本地对象JVM是无法回收的,所以可以用清除方法来进行回收,当然前提是回收的不能是关键资源。

9 try-with-resources优先于try-finally

主要讲的是关闭资源的方法。

在Java7之前,关闭资源使用的是try-finally语句,但它有两个明显的缺点:

  1. 如果有多个资源需要关闭,代码会非常丑陋,比如下面这样:
static void copy(String src, String dst) throws IOException {
        InputStream in = new FileInputStream(src);
        try {
            OutputStream out = new FileOutputStream(dst);
            try {
                byte[] buf = new byte[10];
                int n;
                while ((n = in.read(buf)) >= 0)
                    out.write(buf, 0, n);
            } finally {
                out.close();
            }
        } finally {
            in.close();
        }
    }
复制代码
  1. 异常信息会覆盖
static String firstLineOfFile(String path) throws Exception{
        BufferedReader br = new BufferedReader(new FileReader(path));
        try {
            return br.readLine();
        } finally {
            br.close();
        }
    }
复制代码

如果底层物理设备异常了,br.readLine();就会抛出异常,调用close也会出现异常,此时第二个异常会覆盖第一个异常,这会导致调试起来很麻烦,因为第一个异常才是真正诊断问题的入口。


正是因为存在上面两个问题,当Java7引入try-with-resources时,上面两个问题就迎刃而解了。

使用这个语法时,需要先实现AutoCloseable,接口Java类库与第三方类库中很多类都实现了这个接口。

  1. 当有多个资源需要被关闭时
static void copy(String src, String dst) throws IOException {
        try (InputStream in = new FileInputStream(src);
             OutputStream out = new FileOutputStream(dst)){
            byte[] buf = new byte[10];
            int n;
            while ((n = in.read(buf)) >= 0)
                out.write(buf, 0, n);
        }
    }
复制代码
  1. 当有方法抛出异常时
static String firstLineOfFile(String path) throws IOException {
        try(BufferedReader br = new BufferedReader(new FileReader(path))) {
            return br.readLine();
        }
    }
复制代码

如果readLine和close方法都抛出异常,前面的异常仍然会被打印出来,而不会覆盖。

  1. 还可以使用catch子句来处理异常
static String firstLineOfFile(String path, String defaultVal) throws IOException {
        try(BufferedReader br = new BufferedReader(new FileReader(path))) {
            return br.readLine();
        } catch (IOException e){
            return defaultVal;
        }
    }
复制代码

这时出异常就不会打印堆栈信息了,而是返回一个默认值。

猜你喜欢

转载自juejin.im/post/7040699857463410695