美文网首页
ITEM 78: 对共享数据添加同步控制

ITEM 78: 对共享数据添加同步控制

作者: rabbittttt | 来源:发表于2020-08-11 08:54 被阅读0次

    ITEM 78: SYNCHRONIZE ACCESS TO SHARED MUTABLE DATA
      synchronized 关键字确保了一次只能有一个线程执行一个方法或代码块。许多程序员认为同步仅仅是互斥的一种方式,防止一个线程看到一个对象处于不一致的状态,而另一个线程正在修改它。在这个视图中,对象以一致的状态创建(item 17),并由访问它的方法锁定。这些方法观察状态并可选择导致状态转换,将对象从一种一致状态转换为另一种一致状态。正确使用同步可以保证任何方法都不会观察到处于不一致状态的对象。
      这种观点是正确的,但这只是故事的一半。如果没有同步,一个线程的更改可能对其他线程不可见。同步不仅可以防止线程观察处于不一致状态的对象,还可以确保进入同步方法或块的每个线程看到由同一锁保护的以前所有修改的效果。
      语言规范保证读或写变量是原子的,除非变量是 long 或 double 类型[JLS, 17.4, 17.7]。换句话说,读取 long 或 double 以外的变量保证会返回某个线程存储到该变量中的值,即使多个线程并发地修改该变量而没有同步。
      您可能听说过,为了提高性能,在读写原子数据时应该避免同步。这个建议是危险的错误。虽然语言规范保证线程在读取字段时不会看到任意值,但它不能保证一个线程写入的值对另一个线程可见。线程之间的可靠通信和互斥都需要同步。这是由于被称为内存模型的语言规范的一部分,它指定了一个线程所做的改变何时以及如何对其他线程可见[JLS, 17.4;Goetz06, 16)。
      即使数据是原子可读和可写的,未能同步访问共享可变数据的后果也可能是可怕的。考虑从一个线程停止另一个线程的任务。库提供 Thread.stop 方法,但是这种方法很久以前就不推荐使用了,因为它本身就不安全 —— 使用它可能会导致数据损坏。不要使用 Thread.stop。让一个线程停止另一个线程的一个推荐方法是让第一个线程轮询一个布尔字段,该字段最初为假,但可以被第二个线程设置为真,以指示第一个线程停止自己。
      因为读取和写入布尔字段是原子的,一些程序员在访问字段时免除了同步:

    // 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; 
      }
    }
    
    

      您可能希望这个程序运行大约一秒,在此之后,主线程将stoprequest设置为true,从而导致后台线程的循环终止。然而,在我的机器上,程序永远不会终止:后台线程永远循环!
      问题是,在没有同步的情况下,无法保证后台线程何时(如果有的话)会看到主线程stopRequested值的变化。在没有同步的情况下,虚拟机转换以下代码是可以接受的:
    while (!stopRequested) i++;
    转换为

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

      这种优化称为提升,而这正是 OpenJDK Server VM 所做的。其结果是活性失败:程序无法取得进展。解决这个问题的一种方法是同步对 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)和读方法(stopRequested)都是同步的。仅仅同步写方法是不够的!除非读和写操作同步,否则同步不能保证有效。偶尔,一个只同步写(或读)操作的程序可能看起来在某些机器上工作,但在这种情况下,外观是欺骗性的。
      即使没有同步,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++;
    }
    

      该方法的目的是保证每次调用都返回一个惟一的值(只要不超过2^32次调用)。该方法的状态由单个原子可访问的字段 nextSerialNumber 组成,该字段的所有可能值都是合法的。因此,不需要同步来保护它的不变量。不过,如果没有同步,该方法将无法正常工作。
      问题在于递增操作符(++)不是原子的。它对 nextSerialNumber 字段执行两个操作:首先读取值,然后写回一个等于旧值加1的新值。如果在一个线程读取旧值并写回新值的时间间隔内,另一个线程读取字段,那么第二个线程将看到与第一个线程相同的值并返回相同的序列号。这是一个安全故障:程序计算了错误的结果。
      修复 generateSerialNumber 的一种方法是在其声明中添加同步修饰符。这确保了多个调用不会交织在一起,并且该方法的每次调用都将看到以前所有调用的效果。一旦你这样做了,你可以并且应该从 nextSerialNumber 删除 volatile 修饰符。要使方法防弹,使用 long 而不是 int,或者在 nextSerialNumber 即将换行时抛出异常。
      更好的方法是,遵循item 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(); 
    }
    

      避免本项目中讨论的问题的最佳方法是不共享可变数据。共享不可变数据(item 17)或者根本不共享。换句话说,将可变数据限制在单个线程中。如果采用此策略,务必对其进行文档化,以便在程序发展时维护策略。深入了解您正在使用的框架和库也很重要,因为它们可能会引入您没有意识到的线程。
      一个线程在一段时间内修改一个数据对象,然后与其他线程共享它,只同步共享对象引用的行为,这是可以接受的。其他线程无需进一步同步就可以读取该对象,只要它没有被再次修改。这样的对象被认为是有效的不可变对象[Goetz06, 3.5.4]。将这样一个对象引用从一个线程传输到其他线程称为安全发布[Goetz06, 3.5.3]。有很多方法可以安全地发布对象引用:您可以将其存储在静态字段中,作为类初始化的一部分;您可以将它存储在volatile字段、final字段或使用普通锁定访问的字段中;或者您可以将其放入并发集合中(item 81)。
      总之,当多个线程共享可变数据时,每个读或写数据的线程都必须执行同步。在没有同步的情况下,不能保证一个线程的更改对另一个线程可见。未能同步共享的可变数据的惩罚是活性和安全故障。这些故障是最难调试的故障之一。它们可能是间歇性的,并且依赖于时间,而且程序行为在不同的虚拟机之间可能完全不同。如果您只需要线程间通信,而不需要互斥,那么volatile修饰符是一种可接受的同步形式,但是正确使用它可能需要一些技巧。

    相关文章

      网友评论

          本文标题:ITEM 78: 对共享数据添加同步控制

          本文链接:https://www.haomeiwen.com/subject/bwkedktx.html