Effective Java 第三版——78. 同步访问共享的可变数据

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。

Effective Java, Third Edition

并发

线程允许多个活动同时进行。 并发编程比单线程编程更难,因为更多的事情可能会出错,并且失败很难重现。 你无法避免并发。 它是平台中固有的,也是要从多核处理器获得良好性能的要求,现在无处不在。本章包含的建议可帮助你编写清晰,正确,文档完备的并发程序。

78. 同步访问共享的可变数据

synchronized关键字确保一次只有一个线程可以执行一个方法或代码块。许多程序员认为同步只是一种互斥的方法,以防止一个线程在另一个线程修改对象时看到对象处于不一致的状态。在这个观点中,对象以一致的状态创建(条目 17),并由访问它的方法锁定。这些方法观察状态,并可选地引起状态转换,将对象从一个一致的状态转换为另一个一致的状态。正确使用同步可以保证没有任何方法会观察到处于不一致状态的对象。

这种观点是正确的,但它只说明了一部分意义。如果没有同步,一个线程的更改可能对其他线程不可见。同步不仅阻止线程观察处于不一致状态的对象,而且确保每个进入同步方法或块的线程都能看到由同一锁保护的所有之前修改的效果。

语言规范保证读取或写入变量是原子性(atomic)的,除非变量的类型是long或double [JLS, 17.4, 17.7]。换句话说,读取long或double以外的变量,可以保证返回某个线程存储到该变量中的值,即使多个线程在没有同步的情况下同时修改变量也是如此。

你可能听说过,为了提高性能,在读取或写入原子数据时应该避免同步。这种建议大错特错。虽然语言规范保证线程在读取属性时不会看到任意值,但它不保证由一个线程编写的值对另一个线程可见。同步是线程之间可靠通信以及互斥所必需的。这是语言规范中称之为内存模型(memory model)的一部分,它规定了一个线程所做的更改何时以及如何对其他线程可见[JLS, 17.4;Goetz06, 16)。

即使数据是原子可读和可写的,未能同步对共享可变数据的访问的后果也是可怕的。 考虑从另一个线程停止一个线程的任务。 Java类库提供了Thread.stop方法,但是这个方法很久以前就被弃用了,因为它本质上是不安全的——它的使用会导致数据损坏。 不要使用Thread.stop。 从另一个线程中停止一个线程的推荐方法是让第一个线程轮询一个最初为false的布尔类型的属性,但是第二个线程可以设置为true以指示第一个线程要自行停止。 因为读取和写入布尔属性是原子的,所以一些程序员在访问属性时不需要同步:

// Broken! - How long would you expect this program to run?
public class StopThread {
    private static boolean stopRequested;

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

你可能希望这个程序运行大约一秒钟,之后主线程将stoprequired设置为true,从而导致后台线程的循环终止。然而,在我的机器上,程序永远不会终止:后台线程永远循环!

问题是在没有同步的情况下,无法确保后台线程何时(如果有的话)看到主线程所做的stopRequested值的变化。 在没有同步的情况下,虚拟机将下面代码:

   while (!stopRequested)
        i++;

转换成这样:

if (!stopRequested)
    while (true)
        i++;

这种优化称为提升(hoisting,它正是OpenJDK Server VM所做的。 结果是活泼失败( liveness failure):程序无法取得进展。 解决问题的一种方法是同步对stopRequested属性的访问。 正如预期的那样,该程序大约一秒钟终止:

// Properly synchronized cooperative thread termination
public class StopThread {
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested())
                i++;
        });

        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

注意,写方法(requestStop)和读方法(stop- required)都是同步的。仅同步写方法是不够的!除非读和写操作同步,否则不能保证同步工作。有时,只同步写(或读)的程序可能在某些机器上显示有效,但在这种情况下,表面的现象是具有欺骗性的。

即使没有同步,StopThread中同步方法的操作也是原子性的。换句话说,这些方法上的同步仅用于其通信效果,而不是互斥。虽然在循环的每个迭代上同步的成本很小,但是有一种正确的替代方法,它不那么冗长,而且性能可能更好。如果stoprequest声明为volatile,则可以省略StopThread的第二个版本中的锁定。虽然volatile修饰符不执行互斥,但它保证任何读取属性的线程都会看到最近写入的值:

// Cooperative thread termination with a volatile field
public class StopThread {
    private static volatile boolean stopRequested;

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

在使用volatile时一定要小心。考虑下面的方法,该方法应该生成序列号:

// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
    return nextSerialNumber++;
}

该方法的目的是保证每次调用都返回一个唯一值(只要调用次数不超过232次)。 方法的状态由单个可原子访问的属性nextSerialNumber组成,该属性的所有可能值都是合法的。 因此,不需要同步来保护其不变量。 但是,如果没有同步,该方法将无法正常工作。

问题是增量运算符(++)不是原子的。 它对nextSerialNumber属性执行两个操作:首先它读取值,然后它写回一个新值,等于旧值加1。 如果第二个线程在线程读取旧值并写回新值之间读取属性,则第二个线程将看到与第一个线程相同的值并返回相同的序列号。 这是安全性失败(safety failure):程序计算错误的结果。

修复generateSerialNumber的一种方法是将synchronized修饰符添加到其声明中。 这确保了多个调用不会交叉读取,并且每次调用该方法都会看到所有先前调用的效果。 完成后,可以并且应该从nextSerialNumber中删除volatile修饰符。 要保护该方法,请使用long而不是int,或者在nextSerialNumber即将包装时抛出异常。

更好的是,遵循条目 59条中建议并使用AtomicLong类,它是java.util.concurrent.atomic包下的一部分。 这个包为单个变量提供了无锁,线程安全编程的基本类型。 虽然volatile只提供同步的通信效果,但这个包还提供了原子性。 这正是我们想要的generateSerialNumber,它可能强于同步版本的代码:

// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber() {
    return nextSerialNum.getAndIncrement();
}

避免此条目中讨论的问题的最佳方法是不共享可变数据。 共享不可变数据(条目 17)或根本不共享。 换句话说,将可变数据限制在单个线程中。 如果采用此策略,则必须对其进行文档记录,以便在程序发展改进时维护此策略。 深入了解正在使用的框架和类库也很重要,因为它们可能会引入你不知道的线程。

一个线程可以修改一个数据对象一段时间后,然后与其他线程共享它,只同步共享对象引用的操作。然后,其他线程可以在不进一步同步的情况下读取对象,只要不再次修改该对象。这些对象被认为是有效不可变的( effectively immutable)[Goetz06, 3.5.4]。将这样的对象引用从一个线程转移到其他线程称为安全发布(safe publication )[Goetz06, 3.5.3]。安全地发布对象引用的方法有很多:可以将它保存在静态属性中,作为类初始化的一部分;也可以将其保存在volatile属性、final属性或使用正常锁定访问的属性中;或者可以将其放入并发集合中(条目 81)。

总之,当多个线程共享可变数据时,每个读取或写入数据的线程都必须执行同步。 在没有同步的情况下,无法保证一个线程的更改对另一个线程可见。 未能同步共享可变数据的代价是活性失败和安全性失败。 这些失败是最难调试的。 它们可以是间歇性的和时间相关的,并且程序行为可能在不同VM之间发生根本的变化。如果只需要线程间通信,而不需要互斥,那么volatile修饰符是一种可接受的同步形式,但是正确使用它可能会比较棘手。

posted @ 2019-03-31 21:39  林本托  阅读(646)  评论(0编辑  收藏  举报