《 Java并发实践》一书

图片您好,habrozhiteli!流是Java平台的基本组成部分。多核处理器是司空见惯的,有效地使用并发已成为创建任何高性能应用程序的必要条件。改进的Java虚拟机,对高性能类的支持以及用于并行化任务的丰富构建基块一次是并行应用程序开发中的一项突破。在Java Concurrency in Practice中,突破性技术的创造者自己不仅解释了它们的工作方式,而且还讨论了设计模式。创建看起来可行的竞争性计划很容易。但是,多线程程序的开发,测试和调试带来了许多问题。该代码仅在最重要的时候停止工作:在重负载下。在“ Java并发实践”中,您将找到用于创建可靠,可扩展和受支持的并行应用程序的理论和特定方法。作者没有提供API和并行机制的列表;他们介绍了独立于Java版本并且多年来保持相关和有效的设计规则,模式和模型。

摘抄。线程安全


您可能会惊讶于竞争性编程与线程或锁(1)相关联,而与土木工程与铆钉和工字梁相关联的情况不多。当然,桥梁的建造需要正确使用大量的铆钉和工字梁,竞争性项目的建造也必须正确使用螺纹和锁具。但这只是机制-实现目标的手段。实质上,编写线程安全代码是在控制对状态的访问,尤其是对可变状态的访问。

通常,对象的状态是其数据存储在状态变量中,例如实例和静态字段或其他从属对象的字段。 HashMap哈希的状态部分存储在HashMap本身中,但也存储在许多Map.Entry对象中。对象的状态包括任何可能影响其行为的数据。

(1) lock block, «», , . blocking. lock «», « ». lock , , , «». — . , , , . — . . .

多个线程可以访问一个共享变量,该变量是可变的-更改其值。实际上,我们正在尝试保护数据(而不是代码)免受不受控制的竞争访问。

创建线程安全对象需要同步以协调对突变状态的访问,执行失败可能会导致数据损坏和其他不良后果。

每当一个以上的线程访问一个状态变量并且其中一个线程可能写入该状态变量时,所有线程都必须使用同步来协调对其的访问。 Java中的同步是由synced关键字提供的,该关键字提供独占锁定,易失性和原子变量以及显式锁。

抵制诱惑,以为存在不需要同步的情况。该程序可以运行并通过其测试,但是随时会出现故障并崩溃。

如果多个线程以突变状态访问同一变量而没有正确的同步,则表明程序正在运行。有三种解决方法:

  • 不要在所有线程中共享状态变量
  • 使状态变量不可变;
  • 每次访问状态变量时都使用状态同步。

更正可能需要进行重大的设计更改,因此,立即设计类线程安全的类比稍后进行升级要容易得多。

很难确定是否有多个线程将访问该变量或那个变量。幸运的是,面向对象的技术解决方案有助于创建组织良好且易于维护的类(例如封装和隐藏数据),也有助于创建线程安全的类。可以访问特定变量的线程越少,则越容易确保同步并设置可访问该变量的条件。 Java语言不会强迫您封装状态-将状态存储在公共字段(甚至公共静态字段)中或发布指向内部对象的链接是完全可以接受的-但程序的状态封装得越好,使程序线程安全并帮助维护人员保持这种方式越容易。

在设计线程安全类时,将为您提供良好的面向对象技术解决方案:封装,可变性和明确的不变式规范。

如果良好的面向对象设计技术解决方案与开发人员的需求背道而驰,则出于性能或与旧代码的向后兼容性的考虑,有必要牺牲良好设计的规则。有时,抽象和封装会影响性能-尽管并不是很多开发人员相信的那样频繁-但最佳实践是首先使代码正确,然后快速进行。仅在对生产率和需求的测量表明必须进行优化时才尝试使用优化(2)

(2)在竞争代码中,您应该比平时更遵守这种做法。由于竞争错误极难重现且不容易调试,因此与程序在运行条件下崩溃的风险相比,在一些很少使用的代码分支上获得较小性能提升的优势可能微不足道。

如果您决定需要破坏封装,则不会丢失所有内容。您的程序仍然可以设置为线程安全的,但是该过程将更加复杂和昂贵,并且结果将不可靠。第4章介绍了可以安全减轻状态变量封装的条件。

到目前为止,我们几乎可以互换使用术语“线程安全类”和“线程安全程序”。线程安全程序是否完全由线程安全类构建?可选:完全由线程安全类组成的程序可能不是线程安全的,并且线程安全程序可能包含不是线程安全的类。第4章还将讨论与线程安全类的布局有关的问题。无论如何,线程安全类的概念只有在该类封装了自己的状态时才有意义。术语“线程安全”可以应用于代码,但是它表示状态,并且只能应用于封装了其状态的代码数组(可以是对象或整个程序)。

2.1。什么是线程安全?


定义线程安全性并不容易。快速的Google搜索为您提供了许多类似的选项:

...可以从多个程序线程中调用...,而不会在线程之间进行不必要的交互。

...可以同时由两个或多个线程调用,而无需调用方进行任何其他操作。

有了这样的定义,我们发现线程安全令人困惑就不足为奇了!如何区分线程安全类和不安全类?“安全”一词是什么意思?

线程安全性的任何合理定义的核心是正确性的概念。

正确性意味着一个类符合其规范。该规范定义了限制对象状态的不变量和描述操作效果的后置条件。您怎么知道类规范是正确的?没办法,但是这并不能阻止我们在确信代码有效后使用它们。因此,我们假设单线程正确性是可见的。现在我们可以假设线程安全类在从多个线程进行访问期间的行为正确。

如果一个类在从多个线程进行访问期间正确运行,则该线程是线程安全的,而不管工作环境如何调度或交错这些线程,并且在调用代码部分没有其他同步或其他协调。

即使在单线程环境(3)中,多线程程序也不正确,它也不是线程安全的如果对象实现正确,则没有操作序列-访问公共方法以及对公共字段进行读取或写入-都不应违反其不变性或后置条件。在线程安全类的实例上顺序执行或竞争执行的一组操作均不会导致实例处于无效状态。

(3) 如果在此处对术语“正确性”的宽松使用使您感到困扰,那么您可以将线程安全类视为在竞争环境以及单线程环境中存在问题的类。

线程安全类本身封装了所有必要的同步,并且不需要客户端的帮助。

2.1.1。示例:不具有内部状态支持的servlet


在第1章中,我们列出了创建线程并从中调用负责线程安全的组件的结构。现在,我们打算开发一个servlet分解服务,并逐步扩展其功能,同时保持线程安全。

清单2.1显示了一个简单的servlet,该servlet从查询中解压缩数字,将其分解,然后将结果包装在响应中。

清单2.1。没有内部状态支持的Servlet

@ThreadSafe
public class StatelessFactorizer implements Servlet {
      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            encodeIntoResponse(resp, factors);
      }
}

与大多数Servlet一样,StatelessFactorizer类没有内部状态:它不包含字段,也不引用其他类中的字段。特定计算的状态仅存在于存储在流堆栈中且仅对执行流可用的局部变量中。一个访问StatelessFactorizer的线程不能影响另一个执行相同操作的线程的结果,因为这些线程不共享状态。

没有内部状态支持的对象始终是线程安全的。

大多数Servlet都可以在没有内部状态支持的情况下实现,这一事实大大减轻了对Servlet自身进行线程化的负担。并且只有当servlet需要记住一些东西时,它们的线程安全性要求才会提高。

2.2。原子性


将状态项添加到没有内部状态支持的对象时会发生什么?假设我们要添加一个计数器来衡量处理的请求数。您可以向servlet添加一个long类型的字段,并随每个请求对其递增,如清单2.2中的UnsafeCountingFactorizer所示。

清单2.2。无需请求同步即可计数请求的Servlet。不应这样做。

图片

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
      private long count = 0;

      public long getCount() { return count; }

      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            ++count;
            encodeIntoResponse(resp, factors);
      }
}

不幸的是,UnsafeCountingFactorizer类不是线程安全的,即使它在单线程环境中也可以正常工作。与UnsafeSequence一样,它容易丢失更新。尽管递增操作++ count具有紧凑的语法,但它不是原子的(即不可分割的),而是三个操作的序列:传递当前值,向其添加一个值并将新值写回。在“读取,更改,写入”操作中,结果状态是从前一个状态导出的。

在图。1.1显示了如果两个线程在没有同步的情况下尝试同时增加计数器会发生什么情况。如果计数器为9,则由于时间协调不成功,两个线程都将看到值9,将其加1,然后将该值设置为10。因此,命中计数器将开始滞后1。

您可能会认为,Web服务中的命中计数器稍微不准确是可以接受的损失,有时是这样。但是,如果使用计数器创建对象的序列或唯一标识符,则多次激活返回相同的值可能会导致严重的数据完整性问题。在比赛条件下,可能会由于时间协调不成功而出现错误结果。

2.2.1。比赛条件


UnsafeCountingFactorizer类具有几个竞争条件(4)。竞赛条件最常见的类型是“先检查后行动”的情况,在这种情况下,可能过时的观察结果用于决定下一步要做什么。

(4) (data race). , . , , , , , . Java. , , . UnsafeCountingFactorizer . 16.

在现实生活中我们经常遇到种族条件。假设您计划在中午在Universitetskiy Prospekt的星巴克咖啡厅见朋友。但是您会发现大学大道上有两个星巴克。在12:10,您在咖啡馆A中没有看到您的朋友去咖啡馆B,但是他也不在那里。您的朋友迟到了,或者您离开后立即到达了A咖啡馆,或者他在B咖啡馆,但是一直在寻找您,现在正前往A咖啡馆。我们接受后者,即最坏的情况。现在是12:15,你们俩都想知道您的朋友是否信守诺言。你会回到另一个咖啡馆吗?您会来回几次?如果您尚未达成协议,则可以花一整天的时间在咖啡因刺激下沿着大学大道散步。
“散步看看他是否在那儿”的方法存在的问题是,沿着两个咖啡馆之间的街道散步需要几分钟,并且在此期间系统状态可能会发生变化。

星巴克的示例说明了结果对事件的相对时间协调性的依赖性(取决于您在咖啡馆等朋友的时间等)。他不在咖啡厅A的观察可能无效:一旦您退出前门,他便可以通过后门进入。大多数竞争条件会导致问题,例如意外的异常,数据被覆盖和文件损坏。

2.2.2。示例:延迟初始化中的竞争条件


使用“先检查后行动”方法的常见技巧是延迟初始化(LazyInitRace)。其目的是将对象的初始化推迟到需要时才进行初始化,并确保仅初始化一次。在清单2.3中,getInstance方法确保ExpensiveObject初始化并返回一个现有实例,否则,创建一个新实例并在维护对它的引用后返回它。

清单2.3。竞争条件处于延迟初始化中。不应这样做。

图片

@NotThreadSafe
public class LazyInitRace {
      private ExpensiveObject instance = null;

      public ExpensiveObject getInstance() {
            if (instance == null)
                instance = new ExpensiveObject();
            return instance;
      }
}

LazyInitRace类包含竞争条件。假设线程A和B同时执行getInstance方法。看到实例字段为空,并创建一个新的ExpensiveObject。线程B还检查实例字段是否为相同的null。此时字段中是否存在null取决于时间协调,包括计划的可变性以及创建ExpensiveObject实例并在instance字段中设置值所需的时间。如果B对其进行检查时,实例字段为null,则即使假定getInstance方法始终返回相同的实例,调用getInstance方法的两个代码元素也可以得到两个不同的结果。

UnsafeCountingFactorizer中的命中计数器还包含竞争条件。 “读取,更改,写入”方法意味着,为了递增计数器,流必须知道其先前的值,并确保在更新过程中没有其他人更改或使用此值。

像大多数竞争性错误一样,竞赛条件并不总是导致失败:临时协调是成功的。但是,如果使用LazyInitRace类实例化整个应用程序的注册表,则当它将从多次激活中返回不同的实例时,注册将丢失,或者操作将接收到一组已注册对象的冲突表示。或者,如果将UnsafeSequence类用于在数据保留结构中生成实体标识符,则两个不同的对象可以具有相同的标识符,这违反了身份限制。

2.2.3。复合动作


LazyInitRace和UnsafeCountingFactorizer都包含一系列必须是原子的操作序列。但是,为了防止出现竞争状况,当一个线程修改变量时,其他线程使用该变量必须存在障碍。

如果从执行操作A的线程的角度来看,如果操作B是完全由另一个线程执行的,或者甚至不是部分执行的,则操作A和B是原子的。

UnsafeSequence中递增操作的原子性将避免图5所示的竞争情况。 1.1。 “检查然后采取行动”和“读取,更改,写入”操作应始终是原子操作。它们称为复合动作-必须顺序执行的一系列操作,以保持线程安全。在下一节中,我们将考虑锁定-一种内置于Java中的提供原子性的机制。同时,我们将通过应用现有的线程安全类以另一种方式解决问题,如清单2.4中的Countingfactorizer所示。

清单2.4。 Servlet使用AtomicLong计数请求

@ThreadSafe
public class CountingFactorizer implements Servlet {
      private final AtomicLong count = new AtomicLong(0);

      public long getCount() { return count.get(); }

      public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            count.incrementAndGet();
            encodeIntoResponse(resp, factors);
      }
}

java.util.concurrent.atomic软件包包含用于管理类状态的原子变量。将计数器类型从long替换为AtomicLong,我们保证所有引用计数器状态的动作都是atomic1。由于servlet的状态是计数器的状态,并且计数器是线程安全的,因此我们的servlet成为线程安全的。

当将单个状态元素添加到不支持内部状态的类中时,如果状态由线程安全对象完全控制,则生成的类将是线程安全的。但是,正如我们将在下一节中看到的那样,从一个状态变量到下一个状态的转换不会像从零到一的转换那样简单。

在方便的地方,使用现有的线程安全对象(例如AtomicLong)来控制类的状态。与任意状态变量相比,现有线程安全对象的可能状态及其向其他状态的转换更易于维护和检查线程安全性。

»有关这本书的更多信息,请访问出版商的网站
» 目录
» Khabrozhiteley的节录:

优惠券可享受25%的折扣-Java

支付纸本版本的书后,会通过电子邮件发送电子书。

All Articles