《Java线程与并发编程实践》—— 第2章 同步 2.1 线程中的问题

简介: Java线程与并发编程实践 线程交互通常是通过共享变量完成的,当线程之间没有交互时,开发多线程的应用程序会变得简单许多。一旦发生了交互,很多诱发线程不安全(在多线程环境下不正确)的因素就会暴露出来。在这一章中,你将会认识到这些问题,同时也会学习如何正确地使用Java面向同步的特性来克服它们。

本节书摘来异步社区《Java线程与并发编程实践》一书中的第2章,第2.1节,作者: 【美】Jeff Friesen,更多章节内容可以访问云栖社区“异步社区”公众号查看。

第2章 同步

Java线程与并发编程实践
线程交互通常是通过共享变量完成的,当线程之间没有交互时,开发多线程的应用程序会变得简单许多。一旦发生了交互,很多诱发线程不安全(在多线程环境下不正确)的因素就会暴露出来。在这一章中,你将会认识到这些问题,同时也会学习如何正确地使用Java面向同步的特性来克服它们。

2.1 线程中的问题

Java对线程的支持促进了响应式、可扩展应用程序的发展。不过,这样的支持是以增加复杂性作为代价的。如果不多加小心,你的代码就会到处充斥着极难以察觉的bug,而这些bug多和竞态条件、数据竞争以及缓存变量有关。

2.1.1 竞态条件

当计算的正确性取决于相对时间或者调度器所控制的多线程交叉时,竞态条件就会发生。下面的代码片段描述了只要满足一个特定的前置条件,就会触发计算的场景:
``
if (a == 10.0)
b = a / 2.0;
``
在单线程的环境中,这段程序没有任何问题。在多线程环境下,如果a和b都是局部变量,那么也没有问题。 但是, 假设a和b是实例变量或者类(static)变量,并且有两条线程同时访问这段代码,就有问题了。

假设一条线程已经执行完if (a == 10.0),在即将执行b = a / 2.0时,被调度器暂停了,与此同时,调度器恢复了另一条线程改变了a的值;当前一条线程恢复执行,变量b却不会等于5.0(如果a和b是局部变量,因为每个线程都会有自己的局部变量拷贝,所以竞态条件不会发生)。

这段代码就是竞态条件中称为check-then-act的一个经典例子。在这种竞态条件下,很可能会用过时的观测状态来决定下一步的动作。在前面的代码片段中,“检查”是if (a == 10.0),“动作”则是b = a / 2.0;。

另外一种类型的竞态条件就是read-modify-write,这种情况下,新状态继承自旧状态。旧状态被读取,然后更改,最后更新,通过这3个不可分割的操作来得到更改后的结果。只不过,这些操作的组合并非不可分割。

典型的read-modify-write的例子就是用一个递增的变量来生成唯一的数字标识。在下面的代码片段中,假设counter变量是一个类型为int(初始化为1)的实例变量,两条线程同时访问这段代码:

public int getID()
{
   return counter++;
}

尽管看上去这是个单一操作,但事实上,表达式counter++是3个单独的操作:读取counter的值,给值加1,然后把更新之后的值存储到counter中。当时读取的值就是整个表达式的返回值。

假设在被调度器阻断之前,线程1调用了getID()方法,同时读取了counter的值,此时其值是1。现在,假设线程2运行,调用了getID()方法,读取了counter的值(1),对这个值加1,把结果(2)存储到counter中,然后将1返回给调用者。

在这种情况下,假设线程1恢复过来了,对之前读到的值(1)加1,然后把结果(2)存储到counter变量中,然后将1返回给调用者。由于线程1撤销了线程2的动作,我们就会错过一次递增并生成了一个重复的ID。所以这个方法是无效的。

2.1.2 数据竞争

竞态条件经常会和数据竞争相混淆。数据竞争指的是两条或两条以上的线程(在单个应用中)并发地访问同一块内存区域,同时其中至少有一条是为了写,而且这些线程没有协调对那块内存区域的访问。当满足这些条件的时候,访问顺序就是不确定的。依据这种顺序,每次运行都可能会产生不同的结果。看下面的例子:

private static Parser parser;

public static Parser getInstance()
{
   if (parser == null)
      parser = new Parser();
   return parser;
}

假设线程1首先调用了getInstance()方法。由于它检测到属性parser是空值,线程1就会实例化Parser并且将引用赋给变量parser。随后,当线程2调用getInstance()方法时,它可能检测到parser已经包含了一个非空的引用,于是简单地返回了parser的值;另一种可能是,线程2检测到parser的值仍然是空,于是创建了一个新的Parser的对象。由于线程1写parser变量和线程2读parser变量之间没有happens-before ordering(一个动作先于另一个动作发生)的保证(这里不存在对parser访问顺序的协同),数据竞争产生了。

2.1.3 缓存变量

为了提升性能,编译器Java虚拟机(JVM)以及操作系统会协调在寄存器中或者处理器缓存中缓存变量,而不是依赖主存。每条线程都会有其自己的变量拷贝。当线程写入这个变量的时候,其实是写入自己的拷贝;其他线程不太可能在看到自己的变量拷贝发生更改。

第1章给出的ThreadDemo的应用程序(参见清单1-3)暴露了这个问题。这里我重新列出部分源码以供参考:

private static BigDecimal result;

public static void main(String[] args)
{
   Runnable r = () ->
                {
                   result = computePi(50000);
                };
   Thread t = new Thread(r);
   t.start();
   try
   {
      t.join(); 
   }
   catch (InterruptedException ie)
   {
      // Should never arrive here because interrupt() is never
      // called. 
    }
   System.out.println(result);
}

类属性result示范了缓存变量的问题。该属性在lambda表达式的上下文当中被一条工作线程访问并执行代码result = computePi(50000);,然后默认主线程执行System.out.println(result);

这个工作线程能够将computePi()的返回值存储到自己的result变量的拷贝中。默认主线程很可能无法看到result = computePi(50000);的赋值,并且它的本地拷贝会保持原来默认的null值。这个null值会取代result的字符串表示(即计算好的pi的值)被打印出来。

相关文章
|
21小时前
|
安全 算法 Java
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第13天】 在Java开发中,并发编程是一个复杂且重要的领域。它不仅关系到程序的线程安全性,也直接影响到系统的性能表现。本文将探讨Java并发编程的核心概念,包括线程同步机制、锁优化技术以及如何平衡线程安全和性能。通过分析具体案例,我们将提供实用的编程技巧和最佳实践,帮助开发者在确保线程安全的同时,提升应用性能。
8 1
|
21小时前
|
Java 编译器 开发者
Java并发编程中的锁优化策略
【5月更文挑战第13天】在Java并发编程中,锁是一种重要的同步机制,用于保证多线程环境下数据的一致性。然而,不当的使用锁可能会导致性能下降,甚至产生死锁等问题。本文将介绍Java中锁的优化策略,包括锁粗化、锁消除、锁降级等,帮助开发者提高程序的性能。
|
1天前
|
安全 调度 Python
探索Python中的并发编程:协程与多线程的比较
本文将深入探讨Python中的并发编程技术,重点比较协程与多线程的特点和应用场景。通过对协程和多线程的原理解析,以及在实际项目中的应用案例分析,读者将能够更好地理解两种并发编程模型的异同,并在实践中选择合适的方案来提升Python程序的性能和效率。
|
1天前
|
Java 调度
Java一分钟之线程池:ExecutorService与Future
【5月更文挑战第12天】Java并发编程中,`ExecutorService`和`Future`是关键组件,简化多线程并提供异步执行能力。`ExecutorService`是线程池接口,用于提交任务到线程池,如`ThreadPoolExecutor`和`ScheduledThreadPoolExecutor`。通过`submit()`提交任务并返回`Future`对象,可检查任务状态、获取结果或取消任务。注意处理`ExecutionException`和避免无限等待。实战示例展示了如何异步执行任务并获取结果。理解这些概念对提升并发性能至关重要。
15 5
|
1天前
|
安全 Java 调度
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第12天】 在现代软件开发中,多线程编程是提升应用程序性能和响应能力的关键手段之一。特别是在Java语言中,由于其内置的跨平台线程支持,开发者可以轻松地创建和管理线程。然而,随之而来的并发问题也不容小觑。本文将探讨Java并发编程的核心概念,包括线程安全策略、锁机制以及性能优化技巧。通过实例分析与性能比较,我们旨在为读者提供一套既确保线程安全又兼顾性能的编程指导。
|
2天前
|
Java
Java一分钟:线程协作:wait(), notify(), notifyAll()
【5月更文挑战第11天】本文介绍了Java多线程编程中的`wait()`, `notify()`, `notifyAll()`方法,它们用于线程间通信和同步。这些方法在`synchronized`代码块中使用,控制线程执行和资源访问。文章讨论了常见问题,如死锁、未捕获异常、同步使用错误及通知错误,并提供了生产者-消费者模型的示例代码,强调理解并正确使用这些方法对实现线程协作的重要性。
11 3
|
2天前
|
安全 算法 Java
Java一分钟:线程同步:synchronized关键字
【5月更文挑战第11天】Java中的`synchronized`关键字用于线程同步,防止竞态条件,确保数据一致性。本文介绍了其工作原理、常见问题及避免策略。同步方法和同步代码块是两种使用形式,需注意避免死锁、过度使用导致的性能影响以及理解锁的可重入性和升级降级机制。示例展示了同步方法和代码块的运用,以及如何避免死锁。正确使用`synchronized`是编写多线程安全代码的核心。
54 2
|
2天前
|
安全 Java 调度
Java一分钟:多线程编程初步:Thread类与Runnable接口
【5月更文挑战第11天】本文介绍了Java中创建线程的两种方式:继承Thread类和实现Runnable接口,并讨论了多线程编程中的常见问题,如资源浪费、线程安全、死锁和优先级问题,提出了解决策略。示例展示了线程通信的生产者-消费者模型,强调理解和掌握线程操作对编写高效并发程序的重要性。
41 3
|
2天前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第11天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个方面,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。我们将通过实例和代码片段来说明这些概念和技术。
3 0
|
2天前
|
Java 调度
Java并发编程:深入理解线程池
【5月更文挑战第11天】本文将深入探讨Java中的线程池,包括其基本概念、工作原理以及如何使用。我们将通过实例来解释线程池的优点,如提高性能和资源利用率,以及如何避免常见的并发问题。我们还将讨论Java中线程池的实现,包括Executor框架和ThreadPoolExecutor类,并展示如何创建和管理线程池。最后,我们将讨论线程池的一些高级特性,如任务调度、线程优先级和异常处理。