在这一篇文章中,我们将讨论并发,并发能够让程序任务并行执行。在当今这个时代,大多数处理器都有多个内核,你往往希望这些内核都在工作,并发是 Java 技术的一个重要而且令人振奋的应用。


1. 什么是线程

我们都清楚计算机能够同时运行多个程序。事实上,每个CUP在某一时刻只会处理某一个进程。之所以计算机能在同时运行多个程序,是因为计算机将CPU的时间片分配给每一个进程,给人并行处理的感觉。

进程可以理解为正在运行的程序。有些程序会同时执行多个任务,通常每一个任务称之为一个线程。也就是说,一个进程可能同时处理多个线程,与上面的例子一样,进程获得CPU的时间片会分配给线程,使进程也能达到并行处理的感觉。

比如,一个单线程的下载程序。我们开始一项下载任务后,直到下载结束,我们都不能对程序进行其他操作。因为该程序只有一个线程,并且在处理下载任务,没有其他线程能处理我们的其他操作。而在一个多线程的下载程序中。我们有其他的线程进行第二个下载任务,或者处理我们暂停下载的操作。


2. 创建线程与终止线程

2.1 创建线程

在 Java 中创建线程涉及到 Thread 类与 Runnable 接口。Thread 类,用于启动线程,同时包含该线程的所有信息。Runnable 接口,只有一个 run() 方法,在这里实现该线程的任务逻辑。

在以下示例中,存在两个线程,一个主线程和一个我们创建的新线程,它们会并行处理任务:

// 实现 Runnable 接口
class MyThread interface Runnable{
void run(){
// 获取当前线程名
String name = Thread.currentThread().getName();
System.out.println(name + "-正在运行");
}
}

// 主线程
public class App{
public static void main(String[] args){
Runnable myThread = new MyThread();
// 创建线程,第一个参数为 Runnable 实例,第二个参数为线程名
Thread thread = new Thread(myThread, "我的线程");
// 启动线程
thread.start();
}
}

由于 Runnable 是一个函数式接口,可以使用 lambda 表达式创建一个实例:

Runnable r = () -> { 
//task code
};

也可以通过构建一个 Thread 类的子类定义一个线程:

不推荐使用这种方法定义创建线程,应该将并行运行的任务与运行机制解耦合。如果有很多任务,要为每一个任务创建一个独立的线程所付出的代价太大了。可以使用线程池来解决这个问题。

class MyThread extends Thread{
public void run(){
//task code
}
}

public class App{
public static void main(String[] args){
Thread thread = new MyThread();
thread.start();
}
}

是否能直接调用 run 方法?

直接调用 run 方法,只会在当前线程执行线程任务,而不会启动新线程。

2.2 终止线程

当线程的 run 方法执行到最后一条语句并且执行 return 语句时,线程就会正常终止。当线程出现没有捕获的异常时,线程就会意外终止。

有没有主动终止线程的方法?

在早期的 Java 版本中,提供了一个 stop() 方法,其他进程可以调用它终止线程。但是,这个方法已经弃用了。


3. 线程的状态(生命周期)

线程拥有 6 种状态:

  • New(新创建)
  • Runnable(可执行)
  • Blocked(被阻塞)
  • Waiting(等待)
  • Timed waiting(计时等待)
  • Terminated(被终止)

可以调用 getState() 方法,确定当前线程状态。

3.1 New 创建线程

当使用 new 关键字创建一个线程实例时,该线程就处于 new 状态。注意,该线程还没有启动。

3.2 Runnable 可运行线程

当调用 start() 方法后该线程就处于 runnable 状态。实际上,这不意味着 CUP 正在处理该线程,也可能没有在运行。这只是意味着它“可”运行,它可能在等待 CPU 分配一个时间片来执行任务。这就是为什么将这个状态称为“可运行”而不是“运行”。

3.3 Blocked 被阻塞的线程

当线程尝试获取一个内部的对象锁,但该锁被其他线程持有,则该线程进入了阻塞状态。当其他线程释放锁,并且线程调度器允许该线程持有它时,该线程将变成非阻塞状态。

3.4 Waiting 等待线程

当线程获得了锁,但是,线程需要等待另一个线程通知调度器通知时,该线程处于等待状态。

3.5 Timed Waiting 计时等待状态

与等待状态类似,但计时等待状态有一个超时时间。也就是说,处于等待状态的时间不会超过超时时间。

3.6 Terminated 被终止的线程

线程有三个原因会被终止:

  • run 方法正常退出而自然死亡。
  • 被没有捕获的异常的终止而意外死亡。
  • 调用 stop 方法,抛出 ThreadDeath 对象杀死线程(不要在自己的代码中调用此方法)。

3.7 线程的状态图(生命周期)


4. 线程的属性

4.1 线程优先级

通常线程的优先级继承于父线程的优先级,也可以使用 setPriority() 方法设置优先级,优先级的范围在 1 到 10 之间。当线程调度器选择新线程时,会优先选择高优先级的线程。

注意

避免过度依赖线程优先级,高优先级线程可能会导致低优先级线程永远也不能执行。

4.2 守护线程

守护线程的唯一用途就是服务于用户线程,当所有的用户线程终止时,守护线程也就随之终止。在启动线程之前,可以使用 setDeamon(true) 方法将线程设置为守护线程。

用户线程是什么?

用户线程就是除了守护线程之外的普通线程,主要处理的业务逻辑。

守护线程主要处理什么?

守护线程主要服务于用户线程,比如 Java 的垃圾回收机制就是守护线程。需要注意的是,不能使用守护线程去处理业务逻辑、访问文件和数据库。因为守护线程会在任何时候甚至在一个操作的中间发生中断。


5. 同步

5.1 竞争条件

在大多数的多线程应用中,需要两个或以上的线程共享同一数据。这就有可能多个线程同时修改同一数据,引起共享数据的批误。

5.2 锁对象

为了防止代码块受到访问的干扰,那么就需要使用到了锁对象。Java 提供了 ReentrantLock 类,使用 ReentrantLock 保护代码的基本结构如下:

myLock.lock();
try{
//critical section
}finally{
myLock.unlock();
}

锁对象保证了在任何时刻,只会有一个线程进入临界区。一旦该线程获得了锁,其他线程都无法通过 lock 语句。在该线程释放锁对象之前,其他调用 lock 的线程都会被阻塞。

注意

为了保证代码块发生异常仍能正常释放锁对象,将释放锁对象操作放在 finally 子句中。否则,无法正常释放锁对象,其他线程将永远被阻塞。

5.3 条件对象

当线程获得锁对象进入临界区后,发现需要满足某一条件才能继续执行。这时我们需要一个条件对象来管理那些已经获得锁对象但不能工作的线程。

比如说,一个线程对银行账户取款,但是发现没有足够的金额,需要等待其他的线程对账户注入足够的金额。但是该线程持有锁对象,导致其他线程无法进行存款操作。这时,就需要使用条件对象进行解决:

class Bank{
private Condition sufficientFunds;
public Bank(){
// 创建一个条件对象
sufficientFunds = bankLock.newCondition();
}
}
while(! (ok to proceed)){
// 不满足条件时,调用 await 方法
sufficientFunds.await();
}

调用 await 方法后,该线程被阻塞并且放弃了锁对象。这样,其他线程就能进行存款操作了。

为什么不使用 if 语句对金额进行判断?

如果仅仅使用 if 语句进行判断,线程有可能完成判断,并且转账操作之前被中断。这导致,在判断后金额是充足的,但是线程被中断,线程再次运行时,金额可能已经低于转账金额了。

if (判断金额是否充足){
-(线程可能在此处中断)-
进行转账操作
}

当锁对象被释放后,被条件对象阻塞的线程不能马上解除阻塞,需要其他线程调用 signalAll() 方法才能激活被该条件对象阻塞的线程。

public void transfer(int from, int to, int amount){
bankLock.lock();
try{
// 判断条件
while(account[from] < amount){
sufficientFunds.await();
}
// 转账操作
...
// 激活被阻塞的线程
sufficientFunds.signalAll();
}finally{
bankLock.unlock();
}
}

5.4 synchronized 关键字

在每一个对象都有一个内部锁,如果一个方法使用 synchronized 关键字进行声明,那么对象的锁就会保护整个方法:

public synchronized void method(){
// method body
}

等价于:

public void method(){
this.intrinsicLock.lock();
try{
// method body
}finally{
this.intrinsicLock.unlock();
}
}

在内部对象锁中只有一个条件对象。wait() 方法添加一个线程到等待集中,notifyAll()/notify() 方法解除等待线程的阻塞状态。与条件对象的方法进行类比:

// 注意,这三个方法是 Object 类的方法,通过对象进行调用
wait(); //相当于 await();
notify(); //相当于 signal();
notifyAll(); //相当于 signalAll();

5.5 死锁

注意一种情况,当所有的线程都需要等待更多的金额存入账户,导致所有的线程都被阻塞。这种情况称为死锁。

遗憾的是,Java 编程语言中没有任何东西可以避免或打破这种死锁现象。必须仔细设计程序,以确保不会出现死锁。


6. Thread API

这里对几个 Thread 常用的 API 进行介绍:

  • void join():等待指定线程终止。
  • void join(long millis):等待指定线程终止或经过指定时间。
  • static void yield():使当前执行线程处于让步状态。如果又其他的可运行线程具有至少与该线程同样高的优先级,那么这些线程接下来会被调度。

参考文献:Java 核心技术 卷一(第十版) —— 机械工业出版社

评论