本节书摘来异步社区《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的值)被打印出来。