synchronized关键字确保一次只有一个线程可以执行一个方法或块。许多程序员认为同步只是一种互斥的方法,以防止一个线程在另一个线程修改对象时看到对象处于不一致的状态。在这个观点中,对象以一致的状态创建(item17),并由访问它的方法锁定。这些方法观察状态,并可选地引起状态转换,将对象从一个一致的状态转换为另一个一致的状态。正确使用同步可以保证没有方法会观察到处于不一致状态的对象。
这种观点是正确的,但它只是故事的一半。没有同步,一个线程的更改可能对其他线程不可见。同步不仅阻止线程观察处于不一致状态的对象,而且确保每个进入同步方法或块的线程都能看到由同一锁保护的所有先前修改的效果。
语言规范保证读取或写入变量是原子性的,除非变量的类型是long或double [JLS, 17.4, 17.7]。换句话说,读取long或double之外的变量将保证返回某个线程存储在该变量中的值,即使多个线程同时修改该变量,并且没有同步。
您可能听说过,为了提高性能,在读取或写入原子数据时应该避免同步。这种建议大错特错。虽然语言规范保证线程在读取字段时不会看到任意值,但它不保证由一个线程编写的值对另一个线程可见。同步是线程之间可靠通信以及互斥所必需的。这是由于语言规范中称为内存模型的一部分,它指定了一个线程所做的更改何时以及如何对其他线程可见[JLS, 17.4;Goetz06, 16)。
即使数据是原子可读和可写的,无法同步访问共享可变数据的后果也可能是可怕的。考虑从一个线程停止到另一个线程的任务。.库提供Thread.stop方法,但是这个方法很久以前就被弃用了,因为它本质上是不安全的——使用它会导致数据损坏。不要使用Thread.stop.从一个线程停止到另一个线程的推荐方法是让第一个线程轮询一个布尔字段,该字段最初为false,但第二个线程可以将其设置为true,以指示第一个线程将停止自身。由于读写布尔字段是原子性的,一些程序员在访问该字段时不需要同步:
您可能希望这个程序运行大约一秒钟,之后主线程将stoprequired设置为true,从而导致后台线程的循环终止。
然而,在我的机器上,程序永远不会终止:后台线程永远循环!
问题是,在缺乏同步的情况下,无法保证后台线程何时(如果有的话)看到主线程所做的stoprequest值的更改。在缺乏同步的情况下,虚拟机可以很好地转换这段代码:
image.png
变成
image.png
这种优化称为提升,这正是OpenJDK服务器虚拟机所做的。其结果是活力丧失:这个程序没有取得进展。解决此问题的一种方法是同步对stoprequired字段的访问。程序在大约一秒内结束,正如预期:
注意,写方法(requestStop)和读方法(stoprequired)都是同步的。仅同步写方法是不够的!除非读和写操作同步,否则不能保证同步工作。有时,只同步写(或读)的程序可能在某些机器上显示有效,但在这种情况下,外观是具有欺骗性的。
即使没有同步,StopThread中同步方法的操作也是原子性的。换句话说,这些方法上的同步仅用于其通信效果,而不是互斥。虽然在循环的每个迭代上同步的成本很小,但是有一种正确的替代方法,它不那么冗长,而且性能可能更好。如果stoprequest声明为volatile,则可以省略StopThread的第二个版本中的锁定。虽然volatile修饰符不执行互斥,但它保证任何读取字段的线程都会看到最近写入的值:
在使用volatile时一定要小心。考虑下面的方法,它应该生成序列号:
image.png
该方法的目的是确保每次调用返回一个惟一的值(只要不超过2^32次调用)。方法的状态由一个原子可访问的字段nextSerialNumber组成,该字段的所有可能值都是合法的。因此,不需要同步来保护它的不变量。不过,如果没有同步,该方法将无法正常工作。
问题是增量运算符(++)不是原子的。它对nextSerialNumber字段执行两个操作:首先读取值,然后返回一个新值,等于旧值加1。如果第二个线程在读取旧值和写入新值之间读取字段,则第二个线程将看到与第一个线程相同的值,并返回相同的序列号。这是一个安全故障:程序计算错误的结果。
修复generateSerialNumber的一种方法是将synchronized修饰符添加到它的声明中。这确保了不会交叉调用多个调用,并且该方法的每次调用都将看到以前所有调用的效果.一旦您这样做了,您就可以并且应该从nextSerialNumber中删除volatile修饰符。要防弹方法,使用long而不是int,或者在nextSerialNumber即将换行时抛出异常。
更好的方法是,遵循项目59中的建议并使用类AtomicLong,它是java.util.concurrent.atomic的一部分。这个包为单变量上的无锁、线程安全编程提供了基本类型。虽然volatile只提供同步的通信效果,但是这个包也提供原子性。这正是我们想要的generateSerialNumber,它很可能优于同步版本:
避免本项目中讨论的问题的最佳方法是不共享可变数据。要么共享不可变数据(item17),要么完全不共享。换句话说,将可变数据限制在一个线程中。如果您采用此策略,重要的是对其进行文档化,以便随着程序的发展维护该策略。深入了解您正在使用的框架和库也很重要,因为它们可能会引入您不知道的线程。
一个线程可以暂时修改一个数据对象,然后与其他线程共享它,只同步共享对象引用的操作。其他线程无需进一步同步就可以读取对象,只要它不再被修改。这些对象被认为是有效不可变的(Goetz06, 3.5.4)。将这样的对象引用从一个线程转移到其他线程称为安全发布[Goetz06, 3.5.3]。有很多方法可以安全地发布对象引用:您可以将它存储在静态字段中,作为类初始化的一部分;您可以将其存储在易失性字段、final字段或使用普通锁定访问的字段中;或者您可以将其放入并发集合中( item81 )。
总之,当多个线程共享可变数据时,每个读取或写入数据的线程必须执行同步。在缺乏同步的情况下,不能保证一个线程的更改对另一个线程可见。同步共享可变数据失败的代价是活动和安全失败。这些故障是最难调试的故障之一。它们可能是间歇性的,并且依赖于时间,并且程序行为可能在不同VM之间发生根本的变化。如果只需要线程间通信,而不需要互斥,那么volatile修饰符是一种可接受的同步形式,但是正确使用它可能会比较棘手。
本文写于2019.7.22,历时2天
网友评论