Java并发编程笔记1-线程基础

什么是线程?

谈到线程首先要说的就是进程,因为线程是进程中的一个实体,而且线程是不能独立存在的。进程是系统进行资源分配和调度的基本单位。

这里还想到了一个更加生动的例子,比如对于一个Javaweb项目来说,它一般占用一个进程,所以有的时候非正常关闭我们是去CMD下查询进程号,然后kill掉它。对于这样一个项目来说,里面有很多的线程,比如main函数启动的JVM主线程,再比如自己启动的若干线程进行工作。

看下面的一张进程图,可以很好的理解进程内部的东西。

Alt text

可以看到所有的线程都共享同一个堆和方法区,堆不就是我们申请的对象的存储位置嘛,方法区里放的是进程中的代码片段,这两个东西都是所有线程共享的。

每一个线程里又包含了一个程序计数器

为什么程序计数器是线程独有呢?
因为线程才是占用CPU执行的基本单位。CPU一般是使用时间片轮转的方式,让线程轮循占用的(写到这里,好熟悉的感觉,想起当年考研的时候的操作系统了),那么当前线程还没执行完,但是CPU轮到别的线程了咋办,等再轮到我的时候我程序还要跑啊,所以当前跑到哪了,就用程序计数器来记一下。

另外每个线程有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其它线程是访问不了的,另外栈还用来存放线程的调用栈帧。

Java中线程的创建和运行

Java目前创建一个多线程程序最基础的方式有三种:

  1. 继承 Thread 类并重写 run 方法
  2. 实现 Runnable 接口的run方法
  3. 使用 FutureTask 方式

2和1比较,由于Java不支持多继承,所以如果继承了一个类,那么就不能继承别的类了,所以使用的是一种接口方式来实现。

1、2都没有返回值,3是可以有返回值的。

1、2写法都比较常见,看一下3是怎么实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//创任务类,类似Runable
public static class CallerTask implements Callable<String>{

@Override
public String call() throws Exception {

return "hello";
}

}

public static void main(String[] args) throws InterruptedException {
// 创建异步任务
FutureTask<String> futureTask = new FutureTask<>(new CallerTask());
//启动线程
new Thread(futureTask).start();
try {
//等待任务执行完毕,并返回结果
String result = futureTask.get();
System.out.println(result);
} catch (ExecutionException e) {
e.printStackTrace();
}
}

线程的通知和等待

Java再Object中放了一些函数,因为Java中Object是所有类的父类,所以,这些函数所有类都可以调用。

线程的状态

我们先来看一下线程的几个状态:
Alt text
线程共包括以下5种状态。

  1. 新建状态(New) : 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
  2. 就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
  3. 运行状态(Running) : 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
  4. 阻塞状态(Blocked) : 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    • 等待阻塞 — 通过调用线程的wait()方法,让线程等待某工作的完成。
    • 同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
    • 其他阻塞 — 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  5. 死亡状态(Dead) : 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

Object中提供的几个方法:

Object中定义了wait(), notify()和notifyAll()等方法。

  • wait()的作用是让当前线程进入等待(阻塞)状态,同时,wait()也会让当前线程释放它所持有的锁
  • notify()和notifyAll()的作用,则是唤醒当前对象上的等待线程;notify()是唤醒单个线程,而notifyAll()是唤醒所有的线程。

Object类中关于等待/唤醒的API详细信息如下:

  • notify(): 唤醒在此对象监视器上等待的单个线程。
  • notifyAll():唤醒在此对象监视器上等待的所有线程。
  • wait():让当前线程处于“等待(阻塞)状态”,“直到其他线程调用此对象的 notify() 方法或notifyAll() 方法”,当前线程被唤醒(进入“就绪状态”)。
  • wait(long timeout):让当前线程处于“等待(阻塞)状态”,“直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者超过指定的时间量”,当前线程被唤醒(进入“就绪状态”)。
  • wait(long timeout, int nanos):让当前线程处于“等待(阻塞)状态”,“直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,或者其他某个线程中断当前线程,或者已超过某个实际时间量”,当前线程被唤醒(进入“就绪状态”)。

虚假唤醒和循环检查

另外需要注意的是一个线程可以从挂起状态变为可以运行状态(也就是被唤醒)即使该线程没有被其它线程调用 notify(),notifyAll() 进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒。

虽然虚假唤醒在应用实践中很少发生,但是还是需要防范于未然的,做法就是不停的去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中去调用 wait() 方法进行防范,退出循环的条件是条件满足了唤醒该线程。

1
2
3
4
5
synchronized (obj) {
while (条件不满足){
obj.wait();
}
}

等待线程执行终止的join方法

在项目实践时候经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如多个线程去加载资源,当多个线程全部加载完毕后在汇总处理,Thread 类中有个 join 方法就可以做这个事情,前面介绍的等待通知方法是属于 Object 类的,而 join 方法则是直接在 Thread 类里面提供的,join 是无参,返回值为 void 的方法。

让线程睡眠的sleep方法

Thread 类中有一个静态的 sleep 方法,当一个执行中的线程调用了 Thread 的 sleep 方法后,调用线程会暂时让出指定时间的执行权,也就是这期间不参与 CPU 的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。当指定的睡眠时间到了该函数会正常返回,线程就处于就绪状态,然后参与 CPU 的调度,当获取到了 CPU 资源就可以继续运行了。如果在睡眠期间其它线程调用了该线程的 interrupt() 方法中断了该线程,该线程会在调用 sleep 的地方抛出 InterruptedException 异常返回。

线程中断

Java 中线程中断是一种线程间协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是需要被中断的线程根据中断状态自行处理。

  • void interrupt() 方法
    中断线程,例如当线程 A 运行时,线程 B 可以调用线程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设置标志,线程 A 并没有实际被中断,会继续往下执行的。如果线程 A 因为调用了 wait 系列函数或者 join 方法或者 sleep 函数而被阻塞挂起,这时候线程 B 调用了线程 A 的 interrupt() 方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异常而返回。
  • boolean isInterrupted()
    检测当前线程是否被中断,如果是返回 true,否者返回 false。
  • boolean interrupted()
    检测当前线程是否被中断,如果是返回 true,否者返回 false,与 isInterrupted 不同的是该方法如果发现当前线程被中断后会清除中断标志,并且该函数是 static 方法,可以通过 Thread 类直接调用。另外从下面代码可以知道 interrupted() 内部是获取当前调用线程的中断标志而不是调用 interrupted() 方法的实例对象的中断标志。

注:中断一个线程仅仅是设置了该线程的中断标志,也就是设置了线程里面的一个变量的值,本身是不能终止当前线程运行的,一般程序里面是检查这个标志的状态来判断是否需要终止当前线程。

理解线程上下文切换

在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行,CPU 资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,在时间片内占用 CPU 执行任务。当前线程的时间片使用完毕后当前就会处于就绪状态并让出 CPU 让其它线程占用,这就是上下文切换,从当前线程的上下文切换到了其它线程。

那么就有一个问题让出 CPU 的线程等下次轮到自己占有 CPU 时候如何知道之前运行到哪里了?所以在切换线程上下文时候需要保存当前线程的执行现场,当再次执行时候根据保存的执行现场信息恢复执行现场。

线程上下文切换时机:

  • 当前线程的 CPU 时间片使用完毕处于就绪状态时候;
  • 当前线程被其它线程中断时候。

注:由于线程切换是有开销的,所以并不是开的线程越多越好,比如如果机器是4核心的,你开启了100个线程,那么同时执行的只有4个线程,这100个线程会来回切换线程上下文来共享这四个 CPU。

死锁

  • 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其它进行请求获取该资源,则请求者只能等待,直至占有资源的线程用毕释放。
  • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其其它线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其它线程抢占,只有在自己使用完毕后由自己释放。
  • 环路等待条件:指在发生死锁时,必然存在一个线程——资源的环形链,即线程集合{T0,T1,T2,···,Tn}中的 T0 正在等待一个 T1 占用的资源;T1 正在等待 T2 占用的资源,……Tn正在等待已被 T0 占用的资源。

看一个死锁的例子,来理解四个条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class DeadLockTest2 {

// 创建资源
private static Object resourceA = new Object();
private static Object resourceB = new Object();

public static void main(String[] args) {

// 创建线程A
Thread threadA = new Thread(new Runnable() {
public void run() {
synchronized (resourceA) {
System.out.println(Thread.currentThread() + " get ResourceA");

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread() + "waiting get ResourceB");
synchronized (resourceB) {
System.out.println(Thread.currentThread() + "get ResourceB");
}
}
}
});

// 创建线程B
Thread threadB = new Thread(new Runnable() {
public void run() {
synchronized (resourceB) {
System.out.println(Thread.currentThread() + " get ResourceB");

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread() + "waiting get ResourceA");
synchronized (resourceA) {
System.out.println(Thread.currentThread() + "get ResourceA");
}
};
}
});

// 启动线程
threadA.start();
threadB.start();
}
}

首先资源 resourceA 和 resourceB 都是互斥资源,当线程 A 调用 synchronized(resourceA) 获取到 resourceA 上的监视器锁后释放前,线程 B 在调用 synchronized(resourceA) 尝试获取该资源会被阻塞,只有线程 A 主动释放该锁,线程 B 才能获得,这满足了资源互斥条件。

线程 A 首先通过 synchronized(resourceA) 获取到 resourceA 上的监视器锁资源,然后通过synchronized(resourceB) 等待获取到 resourceB 上的监视器锁资源,这就构造了持有并等待。

线程 A 在获取 resourceA 上的监视器锁资源后,不会被线程 B 掠夺走,只有线程 A 自己主动释放 resourceA 的资源时候,才会放弃对该资源的持有权,这构造了资源的不可剥夺条件。

线程 A 持有 objectA 资源并等待获取 objectB 资源,而线程 B 持有 objectB 资源并等待 objectA 资源,这构成了循环等待条件。

所以线程 A 和 B 就形成了死锁状态。

如何避免线程死锁

要想避免死锁,需要破坏构造死锁必要条件的至少一个即可,但是学过操作系统童鞋应该都知道目前只有持有并等待和循环等待是可以被破坏的。

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁,那么什么是资源的有序性呢,对于上面的代码,修改一下线程B获取资源的顺序,先获取A再获取B,即可打破死锁。

注:编写并发程序,多个线程进行共享多个资源时候要注意采用资源有序分配法避免死锁的产生。

守护线程与用户线程

Java 中线程分为两类,分别为 Daemon 线程(守护线程)和 User 线程(用户线程),在 JVM 启动时候会调用 main 函数,main 函数所在的线程是一个用户线程,这个是我们可以看到的线程,其实 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程(严格说属于 JVM 线程)。

那么守护线程和用户线程有什么区别那?区别之一是当最后一个非守护线程结束时候,JVM 会正常退出,而不管当前是否有守护线程;也就是说守护线程是否结束并不影响 JVM 的退出。言外之意是只要有一个用户线程还没结束正常情况下 JVM 就不会退出。

参考

Java多线程之线程状态转换图