并发的本质是多个线程同时处理某个任务,不同于进程,线程可以访问同一共享资源(临界资源),当程序不够健壮时,使用多线程就可能带来问题,这是要反复讨论并发的原因之一。
在Java中,必须明白一点:线程由Thread类启动,但Thread类并不执行任何操作,它只是驱动赋予它的任务。因此将线程与任务的概念区分开,有利于理解并发。
实际上,开发过程中线程与任务(的联系)被隔离的更加明显,往往不需要显式地声明(创建)线程,然后将任务(声明任务是必须的)分配给线程,并由线程负责驱动( allocate task to thread to execute ),这一过程通常由线程池完成 。
...第一篇文章中,讨论了线程与任务的概念,以及利用任务(Runnable接口)来创建线程。
同时,讨论了线程的生命周期。此外,介绍了线程的优先级以及守护线程这两个实用性不高的概念。
最后,讨论了线程的中断状态这个概念。线程的中断状态以及如何响应中断,对于理解线程的运行机制很重要。
这一篇,继续讨论几个线程相关的概念,包括:
...在
线程与任务文中,虽然创建了多线程,并且线程之间出现了一些不可预测的CPU调度,但是由于线程之间是相互隔离的——线程没有访问共同的资源,尽管在执行任务的过程可能被CPU剥夺运行权,但是当它们再次获得运行权时对运行结果并没有影响,它们是安全的。
实际上,上篇文章通过join()
方法演示了
一种安全访问共享资源的方法。
考虑一种情况,如果多个线程访问同一资源,并对资源内容进行修改,会发生什么情况?
对于非原子性操作,多线程下会出现竞争条件。例如,对于操作accounts[to] += amount
,可以被拆分为多个CPU指令:
- 加载accounts[to]到寄存器
- 增加amount
- 将结果写回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()
时会被阻塞,直到第一个线程释放锁。
...自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提供了Callable
和Future
接口,使任务有提供返回值的能力。
...Java有能力使任务为等待某些条件成立而进入阻塞状态,所以就有可能出现这样一种情况:某个任务在等待另一个任务,而后者又在等待其他的任务,这样一直等待下去,直到等待链上的最后一个任务又在等待第一个任务释放锁,这样就出现了任务之间相互等待的连续循环现象,这种情况出现之后,没有哪个任务能够执行,于是 死锁 出现。
死锁之所以难以规避,其重要的原因就在于其不确定性,可能程序运行良好,但是有潜在的死锁风险,这个风险在某些域的初始条件变化时,变得特别大,导致程序很快死锁。同时,死锁难以复现,当程序出现死锁时,往往只能通过jvm的堆栈日志来探究原因。
...终结任务
#
一般地,如果程序运行良好,任务执行完所需操作后自然结束,任务终结。
如果任务执行时出现异常,任务也会终结。
在设计多个线程协同工作的任务时,需要判断任务终结的条件,以便合适地终结任务,这点尤为重要。
在本节中主要讨论在多线程协同工作的情况下,如何合适的终结任务。
...