为什么要编写并发程序?

线程是 Java 语言中不可或缺的重要功能,它们能够使复杂的异步代码变得更简单,从而极大地简化了复杂系统的开发。此外,要想充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。

第一部分 基础知识

同步的基础设施

  • synchronized
  • volatile
  • 显式锁(Explicit Lock)
  • 原子变量

如果当多个线程访问同一可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件。
  • 在访问变量时不需要加锁。

You can use volatile variables only when all the following criteria are met:

  • Writes to the variable do not depend on its current value, or you can ensure that only a single thread ever
    updates the value;
  • The variable does not participate in invariants with other state variables; and
  • Locking is not required for any other reason while the variable is being accessed.

不可变对象一定是线程安全的。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型。
  • 对象时正确创建的(在对象的创建期间,this引用没有逸出)。

安全发布对象

要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。(JVM同步机制保证)
  • 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中。
  • 将对象的引用保存到某个正确构造对象的 final 类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

To publish an object safely, both the reference to the object and the object’s state must be made visible to other threads at the same time. A properly constructed object can be safely published by:

  • Initializing an object reference from a static initializer;
  • Storing a reference to it into a volatile field or AtomicReference;
  • Storing a reference to it into a final field of a properly constructed object; or
  • Storing a reference to it into a field that is properly guarded by a lock.

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

The publication requirements for an object depend on its mutability:

  • Immutable objects can be published through any mechanism;
  • Effectively immutable objects must be safely published;
  • Mutable objects must be safely published, and must be either thread-safe or guarded by a lock.

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来经行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

The most useful policies for using and sharing objects in a concurrent program are:

  • Thread-confined. A thread-confined object is owned exclusively by and confined to one thread, and can be modified by its owning thread.
  • Shared read-only. A shared read-only object can be accessed concurrently by multiple threads without additional synchronization, but cannot be modified by any thread. Shared read-only objects include immutable and effectively immutable objects.
  • Shared thread-safe. A thread-safe object performs synchronization internally, so multiple threads can freely access it through its public interface without further synchronization.
  • Guarded. A guarded object can be accessed only with a specific lock held. Guarded objects include those that are encapsulated within other thread-safe objects and published objects that are known to be guarded by a specific lock.

设计线程安全的类

在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发访问管理策略。

The design process for a thread-safe class should include these three basic elements:

  • Identify the variables that form the object’s state;
  • Identify the invariants that constrain the state variables;
  • Establish a policy for managing concurrent access to the object’s state.

第一部分小结

这个 “ 并发技巧清单 ” 列举了在第一部分中介绍的主要概念和规则。

  • 可变状态是至关重要的(It‘s the mutable state, stupid)
  • 所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
  • 尽量将域声明为 final 类型,除非需要它们是可变的。
  • 不可变对象一定是线程安全的。
  • 不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或者保护性复制等机制。
  • 封装有助于管理复杂性。
  • 再编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
  • 用锁来保护每个可变变量。
  • 当保护同一个不变性条件中的所有变量时,要使用同一个锁。
  • 在执行复合操作期间,要持有锁。
  • 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
  • 不要故作聪明地推断出不需要使用同步。
  • 在设计过程中考虑线程安全,或者在文档中明确指出它不是线程安全的。
  • 将同步策略文档化。

We’ve covered a lot of material so far! The following “concurrency cheat sheet” summarizes the main concepts and rules presented in Part I.

  • It’s the mutable state, stupid.
  • All concurrency issues boil down to coordinating access to mutable state. The less mutable state, the easier it is to ensure thread safety.
  • Make fields final unless they need to be mutable.
  • Immutable objects are automatically thread-safe.
  • Immutable objects simplify concurrent programming tremendously. They are simpler and safer, and can be shared freely without locking or defensive copying.
  • Encapsulation makes it practical to manage the complexity.
  • You could write a thread-safe program with all data stored in global variables, but why would you want to? Encapsulating data within objects makes it easier to preserve their invariants; encapsulating synchronization within objects makes it easier to comply with their synchronization policy.
  • Guard each mutable variable with a lock.
  • Guard all variables in an invariant with the same lock.
  • Hold locks for the duration of compound actions.
  • A program that accesses a mutable variable from multiple threads without synchronization is a broken program.
  • Don’t rely on clever reasoning about why you don’t need to synchronize.
  • Include thread safety in the design processor explicitly document that your class is not thread-safe.
  • Document your synchronization policy.

第二部分 结构化并发应用程序

通过将任务的提交和执行解耦开来,就很容易修改执行策略。

执行策略

在执行策略中定义了任务执行的 What、Where、When、How;

  • 在什么(What)线程中执行任务?
  • 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
  • 有多少个(How Many)任务能并行执行?
  • 在队列中有多少个(How Many)任务在等待执行?
  • 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个(Which)任务?另外,如何(How)通知应用程序有任务被拒绝?
  • 在执行一个任务之前或之后,应该进行哪些(What)动作?

根据硬件资源选择最匹配的执行策略。

  • In what thread will tasks be executed?
  • In what order should tasks be executed (FIFO, LIFO, priority order)?
  • How many tasks may execute concurrently?
  • How many tasks may be queued pending execution?
  • If a task has to be rejected because the system is overloaded, which task should be selected as the victim, and
    how should the application be notified?
  • What actions should be taken before or after executing a task?

每当看到下面这种形式的代码时:

new Thread(runnable).start()

并且你希望获得一种更灵活的执行策略时,请考虑使用 Executor 来代替 Thread

任务执行小结:

  • 通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。
  • Executor 框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。
  • 当需要创建线程来执行任务时,可以考虑使用 Executor。
  • 要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。
  • 某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。

Structuring applications around the execution of tasks can simplify development and facilitate concurrency. The Executor framework permits you to decouple task submission from execution policy and supports a rich variety of execution policies; whenever you find yourself creating threads to perform tasks, consider using an Executor instead. To maximize the benefit of decomposing an application into tasks, you must identify sensible task boundaries. In someapplications, the obvious task boundaries work well, whereas in others some analysis may be required to uncover finergrained exploitable parallelism.

生命周期结束

生命周期结束(End-of-Lifecycle)的问题会使任务、服务以及程序的设计和实现等过程变得复杂,而这个在程序设计中非常重要的要素却经常被忽略。

一个在行为良好的软件与勉强运行的软件之间的最主要区别就是,行为良好的软件能很完善地处理失败、关闭和取消等过程

在 Java 的 API 或语言规范中,并没有将中断与任何取消语义关联起来,但实际上,如果在取消之外的其他操作中使用中断,那么都是不合适的,并且很难支撑起更大的应用。

调用 interrupt 并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。

通常,中断是实现取消的最合理方式。

最合理的中断策略是:

某种形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作。

尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。

为什么大多数可阻塞的库函数都只是抛出 InterruptedException 作为中断响应?

因为它们永远不会在某个由自己拥有的线程中运行,为此,它们为任务或库代码实现了最合理的取消策略:

尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。

由于每个线程拥有各自的中断策略,因此除非你知道中断对该线程的含义,否则就不应该中断这个线程。

只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。

停止基于线程的服务

线程池是起工作线程的所有者,如果要中断这些线程,那么应该使用线程池。

如果其他封装对象一样,线程的所有权是不可传递的:

应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。

相反,服务应该提供生命周期方法(Lifecycle Method)来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。

在 ExecutorService 中提供了 shutdown 和 shutdownNow 等方法。

同样,在其他拥有线程的服务中也应该提供类似的关闭机制。

守护线程通常不能用来替代应用程序管理程序中各个服务的证明周期。

线程池使用

在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖于其他的任务,那么会要求线程池足够大,从而确保它们依赖任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。通过将这些需求吸入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性或活跃性。

每当提交了一个有依赖性的 Executor 任务时,要清楚地知道可能会出现 线程 “ 饥饿 ” 死锁,因此需要在代码或配置 Executor 的配置文件中记录线程池的大小限制或配置限制。

对于Executor,newCachedThreadPool 工厂方法是一种很好的默认选择,它能提供比固定大小的线程池更好的排队性能。当需要限制当前任务的数量以满足资源管理需求时,那么可以选择固定大小的线程池,就像在接受网络客户请求的服务器应用程序中,如果不进行限制,那么很容易发送过载问题。

类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用 Executors 中的静态工厂方法之一来创建一个线程池:

  • newFixedThreadPool。将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的 Exception 而结束,那么线程池会补充一个新的线程)。
  • newCachedThreadPool。将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
  • newSingleThreadExecutor。是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。 newSingleThreadExecutor 能确保依照任务在队列中的顺序来串行执行(例如 FIFO、LIFO、优先级)。
  • newScheduledThreadPool。创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。

线程池饱和策略

当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor 的饱和策略可以通过调用 setRejectedExecutionHandler 来修改。

JDK 提供了四种不同的饱和策略,实现 RejectedExecutionHandler 接口:

  • AbortPolicy。中止策略,默认的饱和策略,该策略将抛出未检查的 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  • DiscardPolicy。抛弃策略,会悄悄抛弃该任务,当新提交的任务无法保存到队列中等待执行时。
  • DiscardOldestPolicy。抛弃最旧的策略,会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么该策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用)
  • CallerRunsPolicy。调用者运行策略,实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池中执行新的提交任务,而是在一个调用了 execute 的线程中执行该任务。

如果在应用程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过 Executors 中的 privilegedThreadFactory 工厂来定制自己的线程工厂。通过这种方式创建出来的线程,将与创建 privilegedThreadFactory 的线程拥有相同的访问权限、AccessControlContext 和 contextClassLoader。如果不使用 privilegedThreadFactory,线程池创建的线程将从在需要更新线程时调用 execute 或 submit 的客户程序中继承访问权限,从而导致令人困惑的安全性异常。

第三部分 活跃性、性能与测试

死锁(Deadlock)

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

同步方法,不如同步代码块好。同步块能将锁信息封装在对象内,避免锁泄漏出去,保护哪些涉及共享状态的操作。

程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

  • 锁顺序死锁
  • 动态的锁顺序死锁
  • 在协作对象之间发生的死锁
  • 开放调用
  • 资源死锁

死锁的避免与诊断

  • 支持定时的锁
  • 通过线程转储信息来分析死锁

在使用细粒度锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy)来检查代码中的死锁:

首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这个些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。

尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。

饥饿(Starvation):

要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。

活锁(Livelock):

当多个相互协作的线程都彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。

这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都让出对方的路,然后又在另一条路上相遇了。因此他们就这样反复地避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。通过等待随机长度的时间和回退可以有效地避免活锁的发生。

活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障中恢复过来。

最常见的活跃性故障就是锁顺序死锁。

在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。

最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。

性能与可伸缩性

可伸缩性指的是:当增加计算资源时(例如 CPU、内存、存储容量或者 I/O 带宽),程序的吞吐量或者处理能力能相应地增加。

在几乎所有的工程决策中都会涉及某些形式的权衡。

大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确的需求。

避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。

再大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案 “ 更快 ” 之前,首先问自己一些问题:

  • “ 更快 ” 的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载得情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?

对性能的提升可能时并发错误的最大来源。

有人认为同步机制 ” 太慢 “,因而采用一些看似聪明实则危险的方法来减少同步使用(例如双重检查锁),这也通常作为不遵守同步规则的一个常用借口。然而,由于并发错误是很难追踪和消除的错误,因此对于任何可能会引入这类错误的措施,都需要谨慎实施。

Amdahl 定律

Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上额能实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。

在所有并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。

线程引入的开销:

  • 上下文切换
  • 内存同步
  • 阻塞

不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且 JVM 还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。

减少锁的竞争

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。两者乘积越小,获取锁的操作的竞争就越小。

有 3 种方式可以降低锁的竞争程度:

  • 减少锁的持有时间。
  • 降低锁的请求频率。
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。
  • 缩小锁的范围( ” 快进快出 “ )
  • 减小锁的粒度
  • 锁分段
  • 避免热点域
  • 一些替代独占锁的方法(使用并发容器、读 - 写锁、不可变对象以及原子变量)

由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通过更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl 定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为 Java 程序中串行操作的主要来源是独占式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。

并发程序的测试

难点:潜在的错误的发生并不具备确定性,而是随机的。

并发测试大致分为两类:安全性测试、活跃性测试

安全性:不发生任何错误的行为。活跃性:某个良好的行为终究会发生。

活跃性测试的几个衡量:

  • 吞吐量:指一组并发任务中已完成任务所占的比例。
  • 响应性:指请求从发出到完成之间的时间(也称为延迟)。
  • 可伸缩性:指在增加更多资源的情况下(通常指 CPU),吞吐量(或者缓解短缺)的提升情况。

开发人员会使用 Thread.getState 来验证线程能否在一个条件等待上阻塞,但这种方式并不可靠。被阻塞线程并不需要进入 WAITING 或 TIMED_WAITING 等状态,因为 JVM 可以选择通过自旋等待来阻塞。类似地,由于在 Object.wait 或 Condition.await 等方法上存在伪唤醒,因此,即使一个线程等待的条件尚未成真,也可能从 WAITING 或 TIMED_WAITING 等状态临时性地转换到 RUNNABLE 状态。即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。Thread.getState 的返回结果不能用于并发控制,它将限制测试的有效性——其主要作用还是作为调试信息的来源。

在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出哪些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为地限制并发性。理想情况是,在测试属性中不需要任何同步机制。

避免性能测试的陷阱

  • 垃圾回收
  • 动态编译
  • 对代码路径的不真实采样
  • 不真实的竞争程度
  • 无用代码的消除

测试的目标不是更多地发现错误,而是提高代码能按照预期方式工作的可信度。

The goal of testing is not so much to find errors as it is to increase confidence that the code works as expected.

FingBugs 包含的检查器:

  • 不一致的同步
  • 调用 Thread.run
  • 未被释放的锁
  • 空的同步块
  • 双重检查加锁
  • 再构造函数中启动一个线程
  • 通知错误
  • 条件等待中的错误
  • 对 Lock 和 Condition 的误用
  • 在休眠或者等待的同时持有一个锁
  • 自旋循环

第四部分 高级主题

显式锁

在 Lock 的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。

ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。

为什么要创建一种与内置锁如此相似的新加锁机制?

在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性。

例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限地等待下去。

内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。

  • 轮询锁与定时锁
  • 可中断的锁获取操作
  • 非块结构的加锁

在 synchronized 和 ReentrantLock 之间进行选择

ReentrantLock 在加锁和内存上提供的语义与内置锁相同,同时提供了包含:

  • 定时的锁等待、
  • 可中断的锁等待、
  • 公平性
  • 实现非块结构的加锁

那为什么还建议使用内置锁?

synchronized 使用方便,简单,不易出现解锁遗忘。

synchronized 在性能在 JDK6 开始,并不比 ReentrantLock 差多少。

synchronized 可以使用 JVM 上锁的优化

synchronized 毕竟是 ” 亲儿子 “,将来优化的空间更多。

除非用到 ReentrantLock 额外的功能,一般不建议使用。

构建自定义的同步工具

对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下不会失败,但通常由一种更好的选择,即等待前提条件为真。

可阻塞的对象依赖操作的形式如下:

acquire lock on object state;
while(precondition does not hold){
    release lock;
    wait until precondition might hold;
    optionally fail if interrupted or time out expires;
    reaquire lock;
}
perform action
    release lock;

这种加锁模式有些不同寻常,因为是在操作的执行过程中被释放与重新获取的。构成前提条件的状态变量必须由对象的锁来包含,从而使它们在测试前提条件的同时保持不变。如果前提条件尚未满足,就必须释放锁,以便其他线程可以修改对象的状态,否则,前提条件就永远无法变成真的。在再次测试前提条件之前,必须重新获得锁。

客户代码必须要在二者之间进行选择:

要么容忍自旋导致的 CPU 时钟周期浪费,要么容忍由于休眠而导致的相应性。

除了忙等待与休眠之外,还有一种选择就是调用 Thread.yield,这相当于给调度器一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个 CPU 调度时间片,那么可以使整体的执行过程变快。

条件队列 ” 这个名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。

传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

正如每个 Java 对象都可以作为一个锁,每个对象同样可以作为也给条件队列,并且 Object 中的 wait、notfiy 和 notifyAll 方法就构成了内部条件队列的 API。对象的内置锁与其内部条件队列是相互关联的,要调用对象 X 中条件队列的任何一个方法,必须持有对象 X 上的锁。这是因为 “ 等待由状态构成的条件 ” 与 “ 维护状态一致性 ” 这两种机制必须被紧密地绑定在一起:

只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从状态等待中释放另一个线程。

与使用 “ 休眠 ” 的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了优化:CPU 效率、上下文切换开销和响应性等。

如果某个功能无法通过 “ 轮询和休眠 ” 来实现,那么使用条件队列也无法实现(除了非公平性),但条件队列使得在表达和管理状态依赖性时更加简单和高效。

将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档。

Document the condition predicate(s) associated with a condition queue and the operations that wait on them.

每一次 wait 调用都回隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的 wait 时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。

每次线程从 wait 中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么久继续等待(或者失败)。

条件等待的标准形式

void stateDependentMethod() throws InterruptedException{
    // 必须通过一个锁来保护条件谓词
    synchronized(this){
        while(!conditionPredicate())
            lock.wait();
        // 现在对象处于合适的状态
    }
}

当使用条件等待时(例如 Object.wait 或 Condition.await):

  • 通常都由一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
  • 在调用 wait 之前测试条件谓词,并且从 wait 中返回时再次进行测试。
  • 在一个循环中调用 wait。
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
  • 当调用 wait、notify 或 notifyAll 等方法时,一定要持有与条件队列相关的锁。
  • 在检查条件谓词之后以及开始执行相应操作之前,不要释放锁。

丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。(面包机坏了,还在等待响铃)

要实现一个依赖状态的类——如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式式基于现有的类库来构建,例如 CountDownLatch 等。

然而,有时候现有的库类不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的 Condition 对象或者 AbstractQueuedSynchronizer 来构建自己的同步器。

内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制管理起来。

同样,显式的 Condition 与显式的 Lock 也是紧密地绑定到一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对应于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。

原子变量与非阻塞同步机制

竞争级别过高而有些不切实际:任何一个真实的程序都不会除了竞争锁或原子变量,其他什么工作都不做。

如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法

如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁(Lock-Free)算法

非阻塞链表:

我们需要使用一些技巧:

  • 第一个技巧,即使在一个包含多个步骤的更新操作中,也要确保数据结构总是处于一致的状态。
  • 第二个技巧,如果当 B 到达时发现 A 正在修改数据结构,那么在数据结构中应该有足够多的信息,使得 B 能完成 A 的更新操作。

实现这两个技巧时的关键点在于:当队列处于稳定状态时,尾节点的 next 域将为空,如果队列处于中间状态,那么 tail.next 将非空。因此,如何线程都能够通过检查 tail.next 来获取队列当前的状态。而且,当队列处于中间状态时,可以通过将尾节点向前移动一个节点,从而结束其他线程正在执行的插入元素操作,并使得队列恢复为稳定状态。

非阻塞算法通过底层的并发原语(例如比较并交换而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类向外公开,这些类也用做一种 “ 更好的 volatile 变量 ”,从而为整数和对象引用提供原子的更新操作。

非阻塞算法的设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性事故的方法。

Java 内存模型

什么是内存模型,为什么需要它?

假设一个线程为变量 aVariable 赋值:

aVariable = 3;

内存模型需要解决这个问题:“ 在什么条件下,读取 aVariable 的线程将看到这个值为 3 ?”。

这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行等方式来执行指令;缓存可能会改变将写入变量提交到内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。

在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。Java 语言规范要求 JVM 在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。这确实是一件好事情,因为在最近几年中,计算性能的提升在很大程度上要归功于这些重新排序措施。当然,时钟频率的提高同样提升了性能,此外还有不断提升的并行性——采用流水线的超标量执行单元,动态指令调度,猜测执行以及完备的多级缓存。随着处理变得越来越强大,编译器也在不断地改进:通过对指令重新排序来实现优化执行,以及使用成熟的全局寄存器分配算法。由于时钟频率越来越难以提高,因此许多处理器制造厂商都开始转而生产多核处理器,因为能够提高的只有硬件并行性。

在多线程环境中,维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且 JVM 依赖程序通过同步操作来找出这些协调操作将在何时发生。

JMM 规定了 JVM 必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM 在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各中主流的处理器体系架构上能实现高性能的 JVM。如果你不了解在现代处理器和编译器中使用的程序性能提升措施,那么在刚刚接触 JMM 的某些方面时会感到困惑。

为了使 Java 开发人员无须关心不同架构上内存模型之间的差异,Java 还提供了自己的内存模型,并且 JVM 通过在适当的位置上插入内存栅栏来屏蔽在 JMM 与底层平台在内存模型之间的差异。

JMM 为程序中所有的操作定义了一个偏序关系,称之为 Happens-Before

如果两个操作之间缺乏 Happens-Before 关系,那么 JVM 可以对它们任意地排序。

除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。

既然 JMM 已经提供了这种更强大的 Happens-Before 关系,那么为什么还要介绍安全发布呢?

与内存写入操作的可见性相比,从转移对象的所有权以及对象公布等角度来看,它们更符合大多数的程序设计。Happens-Before 排序是在内存访问级别上操作的,它是一种 “ 并发级汇编语言 ”,而安全发布的运行级别更接近程序设计。

静态块初始化安全性

在初始器重采用了特殊的方式来处理静态域,并提供了额外的线程安全性保证。静态初始化器是由 JVM 在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于 JVM 将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然后,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。

初始化安全性

初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个 final 域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个 final 域到达的任意变量(例如某个 final 数组中的元素,或者由一个 final 域引用的 HashMap 的内存)都同样对于其他线程是可见的。

对于含有 final 域的对象,初始化安全性可以防止对对象的初始化引用被重排序到构造函数之前。当构造函数完成时,构造函数对 final 域的所有写入操作,以及对通过这些域可以到达的任意变量的写入操作,都将被 “ 冻结 ”,并且任意获得该对象引用的线程至少能确保看到被冻结的值。对于通过 final 域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。

初始化安全性只能保证通过 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final 域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。