多任务(multitasking):在同一时刻运行多个程序的能力。
并发执行的进程数目并不是由CPU数目制约的。操作系统将CPU的时间片分配给每一个进程,给人并发处理的感觉。
多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。通常,每一个任务称为线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)。
多进程与多线程有哪些区别呢?本质的区别在于每个进程拥有自己的一整套变量,而线程则共享数据。
14.1 什么是线程
调用Thread.sleep不会创建一个新线程,sleep是Thread类的静态方法,用于暂停当前线程的活动。
14.2 中断线程
当线程的run方法执行方法体中最后一条语句后,并经由执行return语句返回时,或者出现了在方法中没有捕获的异常时,线程将终止。
注释:Interrupted方法是一个静态方法,它监测当前的线程是否被中断。而且,调用interrupted方法会清除该线程的中断状态。另一方面,isInterrupted方法是一个实例方法,可用来检验是否有线程被中断。调用这个方法不会改变中断的状态。
线程状态
线程可以有如下6种状态:
- New(新创建)
- Runnable(可运行)
- Blocked(被阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(被终止)
新创建线程
当用new操作符创建一个新线程时,如new Thread(r),该线程还没有开始运行。这意味着它的状态是new。当一个线程处于新创建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
可运行线程
一旦调用start方法,线程处于runnable状态。一个可运行的线程可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。现在所有的桌面以及服务器操作系统都是用抢占式调度。
记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行)。
被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。细节取决于它是怎样达到非活动状态的。
当一个线程试图获取一个内部的对象锁(而不是java.util.concurrent库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态
当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。在调用Object.wait方法或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,被阻塞状态与等待状态是很大不同的。
有几个方法有一个超时参数。调用它们导致线程进入计时等待(timed waiting)状态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep和Object.wait、Thread.join、Lock.tryLock以及Condition.await的计时版。
被终止的线程
线程因如下两个原因之一而被终止:
- 因为run方法正常退出而自然死亡;
- 因为一个没有捕获的异常终止了run方法而意外死亡。
14.4 线程的属性
同步
14.5 同步
synchronized关键字
总结一些有关锁和条件的关键之处:
- 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 锁可以拥有一个或多个相关的条件对象。
- 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock和Condition接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到Java语言内部的机制。从1.0版开始,Java中的每一个对象都有一个内部锁。如果一个方法用了synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,
public synchronized void method(){
method body;
}
等价于
public void method(){
this.intrinsicLock.lock();
try{
method body;
}
finally{this.intrinsicLock.unlock();}
}
将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限。包括:
- 不能中断一个正在试图获得锁的线程。
- 试图获得锁时不能设定超时。
- 每个锁仅有单一的条件,可能时不够的。
在代码中应该使用哪一种?Lock和Condition对象还是同步方法?下面是一些建议:
- 最好即不适用Lock/Condition也不适用synchronized关键字。在许多情况下你可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁。
- 如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
- 如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition。
同步阻塞
正如刚刚讨论的,每一个Java对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:
synchronized(obj)
{
critical setion
}
于是它获得obj的锁。
有时会发现“特殊的”锁,例如:
public class Bank
{
private double[] accounts;
private Object lock = new Object();
...
public void transfer(int from,int to, int amount){
synchronized(lock){
accounts[from] -= amount;
accounts[to] += amount;
}
System.out.println(...);
}
}
在此,lock对象被创建仅仅是用来使用每个Java对象持有的锁。
有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定(clientside locking)。
监视器概念
锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。
用Java的术语来讲,监视器具有如下特性:
- 监视器是只包含私有域的类。
- 每个监视器类的对象有一个相关的锁。
- 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用obj.method(),那么obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对像操作时,没有其他线程能访问该域。
- 该锁可以有任意多个相关条件。
Java设计者以不是很精确的方式采用了监视器概念,Java中的每一个对象有一个内部锁和内部的条件。如果一个方法用synchronized关键字声明,那么,它表现的就是一个监视器方法。通过调用wait/notifyAll/notify来访问条件变量。
然而,在下述的3个方面Java对象不同于监视器,从而使得线程的安全性下降:
- 域不要求必须是private。
- 方法不要求必须是synchronized。
- 内部锁对客户是可用的。
volatile域
有时,仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。毕竟,什么地方能出错呢?遗憾的是,使用现代的处理器与编译器,出错的可能性很大。
- 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
- 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显示的修改指令时才会改变。然而,内存的值可以被另一个线程改变!
如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。编译器被要求通过在必要的时候刷新本地缓存来保护锁的效应,并且不能不正当地重新排序指令。
注释:Brian Goetz给出了下述“同步格言”:“如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者,从一个变量读值,而这个变量可能时之前被另一个线程写入的,此时必须使用同步”。
volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
final变量
除非使用锁或volatile修饰符,否则无法从多个线程安全地读取一个域。
还有一种情况可以安全地访问一个共享域,即这个域声明为final时。
原子性
java.util.concurrent.atomic包中有很多类使用了很高效地机器级指令(而不是锁)来保证其他操作的原子性。
原子性(Atomic)
一个事务包含多个操作,这些操作要么全部执行,要么全都不执行。实现事务的原子性,要支持回滚操作,在某个操作失败后,回滚到事务执行之前的状态。
回滚实际上是一个比较高层抽象的概念,大多数DB在实现事务时,是在事务操作的数据快照上进行的(比如,MVCC),并不修改实际的数据,如果有错并不会提交,所以很自然的支持回滚。
而在其他支持简单事务的系统中,不会在快照上更新,而直接操作实际数据。可以先预演一边所有要执行的操作,如果失败则这些操作不会被执行,通过这种方式很简单的实现了原子性。
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。Java SE 8提供了LongAdder和LongAccumulator类来解决这个问题。LongAdder包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。性能会有显著的提升。
死锁
考虑下面的情况:
账户1:¥200
账户2:¥300
线程1:从账户1转移¥300到账户2
线程2:从账户2转移¥400到账户1
如图所示,线程1和线程2都被阻塞了。因为账户1以及账户2中余额都不足以进入转账,两个线程都无法执行下去。
有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞。这样的状态称为死锁(deadlock)。
导致死锁的另一种途径是让第i个线程负责向第i个账户存钱,而不是从第i个账户取钱。这样一来,有可能将所有的线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。
线程局部变量
有时可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例。
例如,SimpleDateFormat类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
结果可能很混乱,因为dateFormat使用的内部数据结构可能被并发的访问所破坏。当然可以使用同步,但开销很大;或者也可以在需要时构造一个局部SimpleDateFormat对象,不过这也太浪费了。
要为每个线程构造一个实例,可以使用以下代码:
public static final ThreadLocal dateFormat = ThreadLocal.withInitial(()->new SimpleDateFormat(
"yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dateStamp = dataFormat.get().format(new Date());
在一个给定线程中首次调用get时,会调用initialValue方法。在此之后,get方法会返回属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题。java.util.Random类是线程安全的。但是如果多个线程需要等待一个共享的随机数生成器,这会很低效。
可以使用ThreadLocal辅助类为各个线程提供一个单独的生成器,不过Java SE 7还另外提供了一个便利类。只需要做一下调用:
int random = ThreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()调用会返回特定于当前线程的Random类实例。
读/写锁
如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话,后者是十分有用的。在这种情况下,允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。
使用读/写锁的必要步骤:
- 构造一个ReentrantReadWriteLock对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
- 抽取读锁和写锁:
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
- 对所有的获取方法加读锁:
public double getTotalBalance(){
readLock.lock();
try{...}
finally { readLock.unlock();}
}
- 对所有的修改方法加写锁:
public void transfer(...){
writeLock.lock();
try{...}
finally {writeLock.unlock();}
}
为什么弃用stop和suspend方法
stop、suspend和resume方法已经弃用。stop方法天生就不安全,经验证明suspend方法会经藏导致死锁。
注释:一些作者声称stop方法被弃用是因为它会导致对象被一个已停止的线程永远锁定。但是,这一说法是错误的。从技术上讲,被停止的线程通过抛出ThreadDeath异常退出所有它调用的同步方法。结果是,该线程释放了它持有的内部对象锁。