Effective Java读书笔记-16

避免过度同步

通常来说,应该在同步区域内做尽可能少的工作。获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面。

自Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的CPU时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。如果方法修改了静态域,并且该方法很可能要被多个线程调用,那么也必须在内部同步对这个域的访问。

为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法更通俗地讲,要尽量将同步区字段内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中。

线程安全性的文档化

当一个类的方法被并发使用的时候,这个类的行为如何,是该类与其客户端程序建立的约定的重要组成部分。如果没有在一个类的文档中描述其行为的并发性情况,使用这个类的程序员将不得不做出某些假设。如果这些假设是错误的,所得到的程序就可能缺少足够的同步,或者过度同步。无论属于这其中的哪一种情况,都可能会发生严重的错误。

通过查看文档中是否出现synchronized修饰符,可以确定一个方法是否是线程安全的。这种说法从几个方面来说都是错误的。 在正常的操作中,Javadoc并没有在它的输出中包含synchronized修饰符。因为在一个方法声明中出现 synchronized修饰符,这是个实现细节,并不是导出的API的一部分。它并不一定表明这个方法是线程安全的。

而且“出现了synchronized关键字就足以用文档说明线程安全性”的这种说法隐含了一个错误的观念,即认为线程安全性是一种“要么全有要么全无”的属性。实际上,线程安全性有多种级别。一个类为了可被多个线程安全地使用,必须在文档中清楚地说明它所支持的线程安全性级别。下述分项概括了线程安全性的几种级别。这些分项并没有涵盖所有的可能,只是列出了常见的情形:

  • 不可变的(immutable)——这个类的实例是不变的。所以,不需要外部的同步。这样的例子包括String、Long和BigInteger。

  • 无条件的线程安全(unconditionally
    thread-safe)
    ——这个类的实例是可变的,但是这个类有着足够的内部同步,所以它的实例可以被并发使用,无须任何外部同步。其例子包括AtomicLong和ConcurrentHashMap。

  • 有条件的线程安全(conditionally
    thread-safe)
    ——除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。这样的例子包括Collections.synchronized包装返回的集合,它们的迭代器要求外部同步。

  • 非线程安全(not
    thread-safe)
    ——这个类的实例是可变的。为了并发地使用它们,客户端必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。这样的例子包括通用的集合实现,例如ArrayList和HashMap。

  • 线程对立的(thread-hostile)——这种类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生的后果。当一个类或者方法被发现是线程对立的,一般会得到修正,或者被标注为"不再建议使用"。

Lock字段应该始终声明为 final。无论使用普通的监视器锁还是 java.util.concurrent 包中的锁。

每个类都应该利用字斟句酌的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。synchronized修饰符与这个文档毫无关系。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应该考虑使用私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,让你能够在后续的版本中灵活地对并发控制采用更加复杂的方法。

慎用延迟初始化

延迟初始化(lazy initialization)是指延迟到需要域的值时才将它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。虽然延迟初始化主要是一种优化,但它也可以用来破坏类中的有害循环和实例初始化。

就像大多数的优化一样,对于延迟初始化,最好建议"除非绝对必要,否则就不要这么做"。 延迟初始化就像一把双刃剑。它降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域的哪个部分最终需要初始化、初始化这些域要多少开销,以及每个域多久被访问一次,延迟初始化(就像其他的许多优化一样)实际上降低了性能。

如果域只在类的实例部分被访问,并且初始化这个域的开销很高,可能就值得进行延迟初始化。要确定这一点,唯一的办法就是测量类在用和不用延迟初始化时的性能差别。

在大多数情况下,正常的初始化要优先于延迟初始化。下面是正常初始化的实例域的一个典型声明。注意其中使用了final修饰符:

    // Normal initialization of an instance field
    private final FieldType field = computeFieldValue();

如果利用延迟优化来破坏初始化的循环,就要使用同步访问方法,因为它是最简单、最清楚的替代方法:

    // Lazy initialization of instance field-synchronized accessor 
    private FieldType field;
    private synchronized FieldType getField() {
        if (field == null) {
            field = computeFieldValue();
            return field;
        }
    }

如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式(double-check idiom)。这种模式避免了在域被初始化之后访问这个域时的锁定开销。 这种模式背后的思想是:两次检查域的值,因此名字叫双重检查(double-check),第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会对这个域进行初始化。因为如果域已经被初始化就不会有锁定,这个域被声明为volatile就很重要了。下面就是这种习惯模式:

    // Double-check idiom for lazy initialization of instance fields
    private volatile FieldType field;
    private FieldType getField() {
        FieldType result = field;
        if (result == null) { // First check (no locking)
        synchronized(this){
            if (field == null) { // Second check (with locking)
                field = result = computeFieldValue();
            }
        }
        }
        return result;
    }

双重检查模式的两个变量值得一提。有时可能需要延迟初始化一个可以接受重复初始化的实例域。如果处于这种情况,就可以使用单重检查模式(single-check idiom)。下面就是这样的一个例子。注意field仍然被声明为volatile:

    // Single-check idiom-can cause repeated initialization!
    private volatile FieldType field;

    private FieldType getField() {
        FieldType result = field;
        if (result == null) {
            field = result = computeFieldValue();
        }
        return result;
    }

大多数的域应该正常地进行初始化,而不是延迟初始化。 如果为了达到性能目标,或者为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以使用相应的延迟初始化方法。对于实例域,就使用双重检查模式(double-check idiom);对于静态域,则使用lazy initialization holder class idiom。对于可以接受重复初始化的实例域,也可以考虑使用单重检查模式(single-check idiom)。

不要依赖于线程调度器

当有多个线程可以运行时,由线程调度器(thread scheduler)决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。

要编写出健壮、响应良好、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。这使得线程调度器没有更多的选择:它只需要运行这些可运行的线程,直到它们不再可运行为止。

保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义的工作。如果线程没有在做有意义的工作,就不应该运行。

线程不应该一直处于忙-等(busy-wait)的状态,即反复地检查一个共享对象,以等待某些事情发生。除了使程序易受到调度器的变化影响之外,忙-等这种做法也会极大地增加处理器的负担,降低了同一机器上其他进程可以完成的有用工作量。作为不该做的一个极端的反面例子,考虑下面这个CountDownLatch的不正当的重新实现:

    // Awful CountDownLatch implementation-busy-waits incessantly!
    public class SlowCountDownLatch {
        private int count;

        public SlowCountDownLatch(int count) {
            if (count < 0) {
                throw new IllegalArgumentException(count + "< 0");
                this.count = count;
            }
            public void await() {
                while (true) {
                    synchronized (this) {
                        if (count == 0) {
                            return;
                        }
                    }
                }
            }
            public synchronized void countDown () {
                if (count != 0) {
                    count--;
                }
            }
        }
    }

总而言之,不要让应用程序的正确性依赖于线程调度器。否则,得到的应用程序将既不健壮,也不具有可移植性。 同样,不要依赖Thread.yield或者线程优先级。这些机制都只是影响到调度器。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来"修正"一个原本并不能工作的程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值