并发

线程与任务(一)

并发的本质是多个线程同时处理某个任务1不同于进程,线程可以访问同一共享资源(临界资源),当程序不够健壮时,使用多线程就可能带来问题,这是要反复讨论并发的原因之一。

在Java中,必须明白一点:线程由Thread类启动,但Thread类并不执行任何操作,它只是驱动赋予它的任务。因此将线程与任务的概念区分开,有利于理解并发。

实际上,开发过程中线程与任务(的联系)被隔离的更加明显,往往不需要显式地声明(创建)线程,然后将任务(声明任务是必须的)分配给线程,并由线程负责驱动( allocate task to thread to execute ),这一过程通常由线程池完成 。

...

线程与任务(二)

第一篇文章中,讨论了线程与任务的概念,以及利用任务(Runnable接口)来创建线程。

同时,讨论了线程的生命周期。此外,介绍了线程的优先级以及守护线程这两个实用性不高的概念。

最后,讨论了线程的中断状态这个概念。线程的中断状态以及如何响应中断,对于理解线程的运行机制很重要。

这一篇,继续讨论几个线程相关的概念,包括:

  • 线程的让步
  • 等待线程加入
  • 自管理线程
  • 处理线程的异常
...

资源访问受限--引论

线程与任务文中,虽然创建了多线程,并且线程之间出现了一些不可预测的CPU调度,但是由于线程之间是相互隔离的——线程没有访问共同的资源,尽管在执行任务的过程可能被CPU剥夺运行权,但是当它们再次获得运行权时对运行结果并没有影响,它们是安全的。

实际上,上篇文章通过join()方法演示了 一种安全访问共享资源的方法

考虑一种情况,如果多个线程访问同一资源,并对资源内容进行修改,会发生什么情况?

对于非原子性操作,多线程下会出现竞争条件。例如,对于操作accounts[to] += amount,可以被拆分为多个CPU指令:

  1. 加载accounts[to]到寄存器
  2. 增加amount
  3. 将结果写回acounts[to]

上述3个步骤中,线程执行到任一步骤时都可能被剥夺运行权。

如此一来,最后的结果就变得不可预测。

...

资源访问受限-锁和条件

可重入锁 #

Java SE 5之后提供了位于java.util.concurrent.locks包下的显式互斥机制——Lock对象(显式锁),Lock对象必须被显式的创建,锁定和释放

一般情况下 ,ReentrantLock保护代码块的基本结构是:

1myLock.lock(); // 可重入锁
2try{
3  // 临界区代码
4}finally{
5  myLock.unlock();
6}

这个结构可以确保同一时间只有一个线程进入临界区( critical section ),其他线程调用lock()时会被阻塞,直到第一个线程释放锁。

...

synchronized关键字

自Java 1.0开始,每一个对象都有一个隐式内部锁intrinsic lock ),在Java API Specification中通常被称为监视器monitor )。这个内部锁由synchronized关键字提供支持。synchronized关键字的语义就是“同步的”,这意味着使用这个关键字可以处理共享资源的冲突。

当访问被synchronized关键字保护的方法或代码块时,它将检查锁能否获得——这个锁可以是当前类对象的锁,也可以是一个临时锁( ad-hoc lock ),取决你如何使用,任务执行完成之后会释放锁。

ReentrantLock一样,synchronized关键字获取的锁也是独占锁,并且也是“可重入”的,某个任务可以多次获得对象的锁,并由计数器维护获得锁的次数,当退出一个方法时,计数器-1,完全退出时,才释放锁,这和可重入锁的机制是一样的。

...

原子性和可见性

原子性一般指原子操作,原子操作不能被线程调度机制中断,一旦操作开始,那么它一定可以在可能发生的上下文切换之前完成。Java语言规范规定了对基本对象(long和double除外)的读写操作是原子的。

不能将原子性和同步划等号!更不能使用原子性来代替同步,当你想使用原子性代替同步写出无锁代码时,思考 Brain Goetz 的建议:

If you can write a high-performance JVM for a modern microprocessor, then you are qualified to think about whether you can avoid synchronizing.

...

线程本地存储

使用synchronized关键字对整个方法加锁(防止其他线程访问整个方法)往往会带来更大的性能开销,如果你只想保护某些代码块,可以使用同步代码块,这一段被锁保护的代码块就称为临界区critical section ),前面的显式锁所保护的区域以及使用synchronized保护的代码块都是临界区。

...

获取任务的返回值

获取任务的返回值 #

要创建一个任务,通常实现Runnable接口。不幸的是,Runnable接口的run()方法返回void,因此,其并不适合处理计算任务。

考虑一个经典的问题:用多线程分段计算0-100的加和,我们需要把每个线程计算的值汇总,然后再求和,那么应该怎样获取每个任务返回值呢?

Java提供了CallableFuture接口,使任务有提供返回值的能力。

...

死锁问题2例

Java有能力使任务为等待某些条件成立而进入阻塞状态,所以就有可能出现这样一种情况:某个任务在等待另一个任务,而后者又在等待其他的任务,这样一直等待下去,直到等待链上的最后一个任务又在等待第一个任务释放锁,这样就出现了任务之间相互等待的连续循环现象,这种情况出现之后,没有哪个任务能够执行,于是 死锁 出现。

死锁之所以难以规避,其重要的原因就在于其不确定性,可能程序运行良好,但是有潜在的死锁风险,这个风险在某些域的初始条件变化时,变得特别大,导致程序很快死锁。同时,死锁难以复现,当程序出现死锁时,往往只能通过jvm的堆栈日志来探究原因。

...

终结任务

终结任务 #

一般地,如果程序运行良好,任务执行完所需操作后自然结束,任务终结。

如果任务执行时出现异常,任务也会终结。

在设计多个线程协同工作的任务时,需要判断任务终结的条件,以便合适地终结任务,这点尤为重要。

在本节中主要讨论在多线程协同工作的情况下,如何合适的终结任务。

...