avatar


7.多线程 [1/2]

多线程是一个很宏大的话题。
这一章和下一章,我们会用两章的篇幅来讨论多线程。
在这一章,我们会讨论:

  1. 什么是多线程
  2. 线程创建
  3. 线程传递入参
  4. 线程控制
  5. 线程调度
  6. 线程生命周期
  7. 线程安全
  8. 线程死锁
  9. 线程通信

在下一章《8.多线程 [1/2]》,我们会讨论

  1. Java内存模型
  2. 多线程特性
  3. ThreadLocal
  4. 原子类
  5. Lock类
  6. Volatile关键字
  7. 并发容器
  8. 线程池

什么是多线程

现在,让我们进入第一个话题,什么是多线程。

很多资料一提到多线程,就拿Windows的那个任务管理器举例子。
就像这样。

多线程

然后说,你们看,Code.exe开了多线程吧。
实际上,还真不是,这里Code.exe就是有多个不一样的,开的是多进程。

还有些资料,喜欢用Windows窗体来举例子。
就像这样。

Windows窗体

这个在Winform中的确有,只有一个主线程,而且主线程在处理数据的时候,Winform窗体可能无法拖动。
但是呢,如果说这时候无法编辑,而且页面设置那个弹窗在抖动提示。是因为只有一个线程导致的话,这就不太对了。
Winform的窗体设置有一个属性就可以配置这个,具体实现原理我不太了解,但绝不是什么只有一个线程导致的。

我们在《6.网络编程》讨论的那个BIO的缺陷的多线程解决方案时候,举了一个例子,在游戏《牧场物语:矿石镇的伙伴们》中,玩家经营了一个牧场,养鸡、养牛、种菜等,如果玩家忙不过来了,可以把某些活委托给小矮人。
这是一个比较通俗的多线程的例子。
牧场物语:矿石镇的伙伴们

但是,也存在不恰当之处。我们在上一章也讲过,这个例子会让大家误以为多线程就是快,实际上多线程不一定快,这个我们会在《8.多线程 [2/2]》做详细的讨论。
同时,我们还说了,当时有一个线程都已经阻塞了,用多线程就是会快。这个我们会在本章讨论线程生命周期的时候就解释。

进程与线程
进程:
是执行中一段程序,即一旦程序被载入到内存中并准备执行,它就是一个进程。进程是表示资源分配的的基本概念,又是调度运行的基本单位,是系统中的并发执行的单位。
线程:
单个进程中执行中每个任务就是一个线程。线程是进程中执行运算的最小单位。
一个线程只能属于一个进程,但是一个进程可以拥有多个线程。
多线程处理就是允许一个进程中在同一时刻执行多个任务。
线程是一种轻量级的进程,与进程相比,线程给操作系统带来侧创建、维护、和管理的负担要轻,意味着线程的代价或开销比较小。

比如,现在大家在看这个博客,这时候会有一个或多个和浏览器相关的进程,然后每个进程内容又有一个或多个的线程。

线程创建

Java中线程有四种创建方式:

  1. 继承Thread类
  2. 实现Runnable接口
  3. 实现Callable接口
  4. 线程池

我们分别讨论。

继承Thread类

相关方法

方法名 说明
void run() 在线程开启后,此方法将被调用执行。
void start() 使此线程开始执行,Java虚拟机会调用run方法()。

步骤:

  1. 定义一个类MyThread继承Thread类
  2. 在MyThread类中重写run()方法
  3. 创建MyThread类的对象
  4. 启动线程

那么,来吧!
示例代码:

1
2
3
4
5
6
7
8
9
10
package com.kakawanyifan;

public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i<10; i++){
System.out.println(Thread.currentThread().getName() + " "+ i);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
package com.kakawanyifan;

public class MyThreadDemo {
public static void main(String[] args) {
System.out.println("主线程名字:" + Thread.currentThread().getName());
MyThread my1 = new MyThread();
MyThread my2 = new MyThread();

my1.run();
my2.run();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
主线程名字:main
main 0
main 1
main 2
main 3
main 4
main 5
main 6
main 7
main 8
main 9
main 0
main 1
main 2
main 3
main 4
main 5
main 6
main 7
main 8
main 9

有没有问题?
有!
我们上文的代码有Thread.currentThread().getName(),现在子线程和主线程同名?而且,这个开多线程了吗?看起来是my1执行完成之后,执行了my2啊。

来,我们把run换成start

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
package com.kakawanyifan;

public class MyThreadDemo {
public static void main(String[] args) {
System.out.println("主线程名字:" + Thread.currentThread().getName());
MyThread my1 = new MyThread();
MyThread my2 = new MyThread();

my1.start();
my2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
主线程名字:main
Thread-0 0
Thread-1 0
Thread-1 1
Thread-0 1
Thread-0 2
Thread-0 3
Thread-0 4
Thread-0 5
Thread-0 6
Thread-0 7
Thread-0 8
Thread-0 9
Thread-1 2
Thread-1 3
Thread-1 4
Thread-1 5
Thread-1 6
Thread-1 7
Thread-1 8
Thread-1 9

这回似乎就对了。

解释说明:
为什么要重写run()方法?
因为run()是用来封装被线程执行的代码。

run()方法和start()方法的区别?
run():封装线程执行的代码,直接调用,相当于普通方法的调用。
start():启动线程;然后由JVM调用此线程的run()方法。

最后解释一下我们线程名的方法,顺带说一下设置线程名的方法。
设置线程名称和获取线程名称。

方法名 说明
void setName(String name) 将此线程的名称更改为等于参数name
String getName() 返回此线程的名称
Thread currentThread() 返回对当前正在执行的线程对象的引用

实现Runnable接口

第二种创建线程的方法是实现Runnable接口。
步骤:

  1. 定义一个类MyRunnable实现Runnable接口
  2. 在MyRunnable类中重写run()方法
  3. 创建MyRunnable类的对象
  4. 创建Thread类的对象,把MyRunnable对象作为构造方法的参数
  5. 启动线程

Thread构造方法

方法名 说明
Thread(Runnable target) 分配一个新的Thread对象
Thread(Runnable target, String name) 分配一个新的Thread对象,同时设置名称。

方法是新方法,但其实最后殊途同归,还是实例化了一个Thread。但其实这两种方法有区别,我们在下文很快就会进行比较讨论。

示例代码:

1
2
3
4
5
6
7
8
9
package com.kakawanyifan;

public class MyRunnable implements Runnable {
public void run() {
for (int i=0; i<10; i++){
System.out.println(Thread.currentThread().getName() + " "+ i);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kakawanyifan;

public class MyRunnableDemo {
public static void main(String[] args) {
//创建MyRunnable类的对象
MyRunnable my = new MyRunnable();

//创建Thread类的对象,把MyRunnable对象作为构造方法的参数
Thread t1 = new Thread(my);
Thread t2 = new Thread(my);

//启动线程
t1.start();
t2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread-0  0
Thread-1 0
Thread-0 1
Thread-1 1
Thread-0 2
Thread-0 3
Thread-1 2
Thread-0 4
Thread-1 3
Thread-0 5
Thread-1 4
Thread-0 6
Thread-1 5
Thread-0 7
Thread-1 6
Thread-0 8
Thread-1 7
Thread-0 9
Thread-1 8
Thread-1 9

实现Callable接口

Callabel和Runable相比,在名称方面一个是调用,一个是运行。既然强调是调用呢,那么就一定有不同于运行的特点,有返回值。

步骤:

  1. 定义一个类MyCallabel继承Callabel接口
    两个注意:1、在范型处定义返回值类型;2、重写的方法是call方法,而不是run方法。
  2. 利用FutureTask对MyCallabel的对象进行包装。
  3. 实例化一个线程,参数是FutureTask对象。
  4. 启动线程。
  5. 获取返回对象。

来,试一下。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.kakawanyifan;

import java.time.LocalDateTime;
import java.util.concurrent.Callable;

public class MyCallable implements Callable<String> {
public String call() throws Exception {
for (int i=0; i<10; i++){
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " " + i);
}
return LocalDateTime.now().toString();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.kakawanyifan;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class MyCallableDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask futureTask = new FutureTask(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
Thread-0 0
Thread-0 1
Thread-0 2
Thread-0 3
Thread-0 4
Thread-0 5
Thread-0 6
Thread-0 7
Thread-0 8
Thread-0 9
2021-09-10T16:02:49.870

有没有问题?
我们在MyCallable的call方法中写了sleep(1000)。但是,我们在MyCallableDemo中,启动线程后,立马去获取返回。那还能获取到吗?必须抛出异常啊。
然而,并没有。
为什么?
因为在futureTask.get()这里阻塞了。
此方法会阻塞主线程直到获取到结果!

我们可以看一下FutureTask这个类的源码

示例代码:

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
package java.util.concurrent;
import java.util.concurrent.locks.LockSupport;

public class FutureTask<V> implements RunnableFuture<V> {

private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;

【部分代码略】

public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}

【部分代码略】

}

如果没有完成,就await(同步等待)。

特别的,我们还看到FutureTask类的是接口RunnableFuture的实现类。
示例代码:

1
public class FutureTask<V> implements RunnableFuture<V>

再来看一下RunnableFuture的源代码。继承自Runnable, Future。
示例代码:

1
2
3
4
5
6
7
public interface RunnableFuture<V> extends Runnable, Future<V> {
/**
* Sets this Future to the result of its computation
* unless it has been cancelled.
*/
void run();
}

同时继承了两个接口?
这个没问题。
一个接口可以继承多个接口,一个类可以实现多个接口。但是,一个类只能继承一个类。

需要注意的是,继承了两个接口,其中一个就是我们上文讨论的Runnable。
结构如下
RunnableFuture

FutureTask还有其他方法

  1. 判断任务是否完成:isDone()
  2. 能够中断任务:cancel()
  3. 能够获取任务执行结果:get()

现在问,这些方法由哪个接口继承来,当然是由Future接口提供。

Callable方法与Runnable一样,方法是新方法,但其实最后殊途同归,还是实例化了一个Thread。

线程池

但是,上述三种方法,其实我们都不常用。更多的时候,我们通过线程池来创建线程。
线程池就像一个容器,这个容器中有很多线程,我们要用的时候,直接拿过来用,用完在放回去。
这样就不需要人工去创建和维护线程了。

关于线程池,我们会在下一章进行更详细的讨论。

线程池相关接口和类的关系图

这里,我们先来了解一下线程池相关接口和类的关系图。

线程池相关接口和类的关系图

  1. 最顶级的是Executor接口:
    声明了execute(Runnable runnable)方法,执行任务代码
  2. ExecutorService接口:
    继承Executor接口,声明方法:submit、invokeAll、invokeAny以及shutDown等
  3. AbstractExecutorService抽象类:
    实现ExecutorService接口,基本实现ExecutorService中声明的所有方法
  4. ScheduledExecutorService接口:
    继承ExecutorService接口,声明定时执行任务方法。
  5. ThreadPoolExecutor类:
    继承类AbstractExecutorService,实现execute、submit、shutdown、shutdownNow方法;是线程池的核心实现类,用来执行被提交的任务
  6. ScheduledThreadPoolExecutor类:
    继承ThreadPoolExecutor类,实现ScheduledExecutorService接口并实现其中的方法;可以进行延迟或者定期执行任务。

但是呢,通过ThreadPoolExecutor创建线程池的操作比较多。Java提供了一个工具类:Executors类。(注意有s)

在下一章《8.多线程 [2/2]》,我们就会见到ThreadPoolExecutor创建线程池的操作比较多,到底多在哪里。
这一章,我们暂时只演示用Executors创建线程的简单方法。

基于线程池创建线程

步骤如下

  1. 使用Executors获取线程池对象。
  2. 通过线程池对象获取线程,并执行。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.kakawanyifan;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecutorsDemo {
public static void main(String[] args) {
//1.使用Executors创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
//2.通过线程池执行线程
executorService.execute(new MyRunnable());
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
pool-1-thread-1  0
pool-1-thread-1 1
pool-1-thread-1 2
pool-1-thread-1 3
pool-1-thread-1 4
pool-1-thread-1 5
pool-1-thread-1 6
pool-1-thread-1 7
pool-1-thread-1 8
pool-1-thread-1 9

特别的,假如我们线程池只有两个线程,但是我们执行三个,会怎么样?

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.kakawanyifan;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecutorsDemo {
public static void main(String[] args) {
//1.使用Executors创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(2);
//2.通过线程池执行线程
executorService.execute(new MyRunnable());
executorService.execute(new MyRunnable());
executorService.execute(new MyRunnable());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.kakawanyifan;

import java.time.LocalDateTime;

public class MyRunnable implements Runnable {
public void run() {
System.out.println(LocalDateTime.now());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

运行结果:

1
2
3
2021-09-10T16:56:42.192
2021-09-10T16:56:42.192
2021-09-10T16:56:45.197

排队。
在下一章《8.多线程 [2/2]》,我们就会解释为什么会这样。

现在问,这个程序结束了吗?
没有
线程池还在呢。
线程池关闭方法

  1. executorService.shutdown();
    等正在进行任务执行完,进行停止
  2. executorService.shutdownNow();
    不用等待正在进行任务执行完,立即停止

除了这种手动的方法,还有其他的自动的线程池关闭方法,我们会在下一章《8.多线程 [2/2]》讨论。

比较

实现Runnable接口和继承Thread类比较

在上文我们说了,实现Runnable接口,这个方法是新方法,虽然最后殊途同归,还是实例化了一个Thread,但其实这两种方法有区别。
现在我们讨论。

实现Runnable接口和继承Thread类比较

  1. Runnable接口更适合多个相同的程序代码的线程去共享同一个资源。
    稍后我们就会看到多个线程共享同一个资源。
  2. Runnable接口可以避免Java中的单继承的局限性。
  3. Runnable接口代码可以被多个线程共享,代码和线程独立。
  4. 线程池只能放入实现Runable或Callable接口的线程,不能直接放入继承Thread的类。

所以呢,实现Runnable接口比继承Thread类好。

Runnable和Callable接口比较

相同点:

  1. 两者都是接口。
  2. 两者都可用来编写多线程程序。
  3. 两者都需要调用Thread.start()启动线程。

不同点:

  1. 实现Callable接口的线程能返回执行结果;而实现Runnable接口的线程不能返回结果。
  2. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的不允许抛异常。
  3. 实现Callable接口的线程可以调用Future.cancel取消执行,而实现Runnable接口的线程不能。

那么,Runnable和Callable,哪个好?Callable的确功能更强大,毕竟实在Runable基础上进行了拓展。但是,这个还是根据具体的需求来吧。

题外话
在java中,每次程序运行至少启动需要启动几个线程?
两个线程。一个是main线程,一个是垃圾回收线程。
(关于垃圾回收,我们在《2.面向对象》那一章提到过。)

线程传递入参

通过上文的讨论,我们已经知道了怎么获取线程任务执行的返回结果,也就是线程传递出参。
那么,我们怎么给线程传递入参呢?
我们怎么创建线程的?是不是实例化了一个Thread对象,会在Runable接口的实现类的对象,又或者Callable接口的实现类的对象。
所以呢,就有两种方法给线程传递入参。

  1. 通过构造方法传递入参。
  2. 通过变量的Set方法传递入参。

我们分别举例子说明。

通过构造方法传递入参

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.kakawanyifan;

public class MyThreadPara extends Thread {
private String name;

public MyThreadPara(String name) {
this.name = name;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " hello " + name);
}

public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
Thread thread = new MyThreadPara("world");
thread.start();
}
}

运行结果:

1
2
main
Thread-0 hello world

通过变量的Set方法传递入参

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.kakawanyifan;

public class MyThreadPara implements Runnable {
private String name;

public void setName(String name) {
this.name = name;
}

public void run() {
System.out.println("hello " + name);
}

public static void main(String[] args) {
MyThreadPara myThreadPara = new MyThreadPara();
myThreadPara.setName("world");
Thread thread = new Thread(myThreadPara);
thread.start();
}
}

运行结果:

1
hello world

很多资料会说,还有一种传递入参的方式,是通过回调函数。但是我个人不认为在这个过程中进入了入参传递,而且这种方式应该也不常用。

线程控制

在之前的章节中,我们多次使用到了sleep这个方法,这个方法可以让线程停留指定的毫秒数,期间CPU不会有去执行这个线程。
这就是一种线程控制的方法。除了这种方法,常见线程控制方法还有还有两种:

  1. join()
  2. setDaemon(boolean on)
方法名 说明
static void sleep(long millis) 使当前正在执行的线程停留(暂停执行)指定的毫秒数
void join() 只有这个线程死亡了,其他线程才可以执行。
void setDaemon(boolean on) 被标记为守护线程的线程,在主线程结束的时候,也会结束运行。
  • 此外,还有suspend()方法和stop()方法,但是容易导致线程死锁,不推荐使用。

我们来举例子说明join和setDaemon。

join

示例代码:

1
2
3
4
5
6
7
8
9
10
package com.kakawanyifan;

public class ThreadJoin extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(getName() + ":" + i);
}
}
}
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
package com.kakawanyifan;

public class ThreadJoinDemo {
public static void main(String[] args) {
ThreadJoin tj1 = new ThreadJoin();
ThreadJoin tj2 = new ThreadJoin();
ThreadJoin tj3 = new ThreadJoin();

tj1.setName("周天子");
tj2.setName("魏");
tj3.setName("齐");

System.out.println(tj1.getPriority()); //5
System.out.println(tj2.getPriority()); //5
System.out.println(tj3.getPriority()); //5

tj1.start();
try {
tj1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
tj2.start();
tj3.start();
}
}

运行结果:

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
5
5
5
周天子:0
周天子:1
周天子:2
周天子:3
周天子:4
周天子:5
周天子:6
周天子:7
周天子:8
周天子:9
魏:0
魏:1
齐:0
魏:2
齐:1
魏:3
齐:2
魏:4
齐:3
魏:5
齐:4
魏:6
齐:5
魏:7
魏:8
魏:9
齐:6
齐:7
齐:8
齐:9

一定要tj1结束之后,其他线程才会执行。

当时尚有周天子

setDaemon

示例代码:

1
2
3
4
5
6
7
8
9
10
package com.kakawanyifan;

public class ThreadDaemon extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName() + ":" + i);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.kakawanyifan;

public class ThreadDaemonDemo {
public static void main(String[] args) {
ThreadDaemon td = new ThreadDaemon();
td.setName("毛");

Thread.currentThread().setName("皮");

//设置守护线程
td.setDaemon(true);
td.start();

for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}

运行结果:

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
皮:0
皮:1
皮:2
皮:3
皮:4
皮:5
毛:0
皮:6
皮:7
皮:8
皮:9
毛:1
毛:2
毛:3
毛:4
毛:5
毛:6
毛:7
毛:8
毛:9
毛:10
毛:11
毛:12
毛:13
毛:14
毛:15
毛:16
毛:17
毛:18
毛:19
  • 注意!子线程并没有执行完。

皮之不存

线程调度

我们注意看上述第一个方法,当"周天子"这个线程结束之后,"魏"和"齐这两个线程是怎么执行的?是轮流吗?不是,似乎是一种无序的状态,是两个线程在竞争CPU。
这就涉及到线程的两种调度方式

  1. 分时调度模型:所有线程轮流使用CPU的使用权,平均分配每个线程占用CPU的时间片。
  2. 抢占式调度模型:优先让优先级高的线程使用CPU,如果线程的优先级相同,那么会随机选择一个,优先级高的线程获取的 CPU 时间片相对多一些。

Java使用的是抢占式调度模型

这里提到了CPU的时间片,CPU的执行时间分成了很多个细小的时间窗口,一个细小的时间窗口只执行一个线程。在Java中线程需要去抢CPU的时间窗口,就像我们抢订会议室一样。

还提到了线程的优先级,我们列举一下两个线程优先级相关方法。

方法名 说明
final int getPriority() 返回此线程的优先级。
final void setPriority(int newPriority) 更改此线程的优先级。(线程默认优先级是5;线程优先级的范围是:1-10)

示例代码:

1
2
3
4
5
6
7
8
9
10
package com.kakawanyifan;

public class ThreadPriority extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(getName() + ":" + i);
}
}
}
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
package com.kakawanyifan;

public class ThreadPriorityDemo {
public static void main(String[] args) {
ThreadPriority tp1 = new ThreadPriority();
ThreadPriority tp2 = new ThreadPriority();
ThreadPriority tp3 = new ThreadPriority();

tp1.setName("飞机");
tp2.setName("高铁");
tp3.setName("汽车");

// public final int getPriority():返回此线程的优先级
System.out.println(tp1.getPriority()); //5
System.out.println(tp2.getPriority()); //5
System.out.println(tp3.getPriority()); //5

// public final void setPriority(int newPriority):更改此线程的优先级
// tp1.setPriority(10000); //IllegalArgumentException
System.out.println(Thread.MAX_PRIORITY); //10
System.out.println(Thread.MIN_PRIORITY); //1
System.out.println(Thread.NORM_PRIORITY); //5

//设置正确的优先级
tp1.setPriority(10);
tp2.setPriority(5);
tp3.setPriority(1);

tp3.start();
tp2.start();
tp1.start();
}
}

运行结果:

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
5
5
5
10
1
5
高铁:0
高铁:1
高铁:2
飞机:0
飞机:1
飞机:2
飞机:3
飞机:4
飞机:5
汽车:0
飞机:6
高铁:3
高铁:4
高铁:5
高铁:6
高铁:7
高铁:8
高铁:9
汽车:1
飞机:7
飞机:8
飞机:9
汽车:2
汽车:3
汽车:4
汽车:5
汽车:6
汽车:7
汽车:8
汽车:9

注意看,线程优先级高,只是说明其获取CPU时间片的几率高。但并不是每次都跑在前面。

线程的生命周期

在讨论了线程控制和线程调度,以及之前的阻塞之后。我们终于可以讨论线程的生命周期了。

线程的生命周期

解释一下这张图。
首先,新建,在这个过程,我们创建了线程对象。然后我们调用start方法,线程启动,但是并没有立即执行。
进入了就绪状态,这个时候有执行资格,但是没有执行权。
在抢到CPU的执行权之后,进入运行状态,有执行资格也有执行权,CPU开始执行线程。
接下来,如果run方法结束或者通过stop方法停止,或者出现异常等情况,线程就进入了死亡状态。
如果在运行的时候,被其他线程抢走了CPU的执行权,重新回到就绪的状态。为什么会被其他CPU抢占执行权?我们上文讨论了,一次只抢到了CPU的一个时间片。
如果在运行的时候,调用了sleep或者其他的阻塞方法,回到阻塞状态。没有执行资格,没有执行权。直到阻塞方法结束,回到就绪状态。
注意,没有执行资格,也没有执行权。那么CPU这时候在干啥呢?如果没有其他线程进来,CPU啥事也不做。这就解释了在上一章《6.网络编程》我们讨论的,线程都阻塞了,这时候开多线程肯定更快。

小结一下,一共5个状态。

新建:
new关键字创建了一个线程之后,该线程就处于新建状态。JVM为线程分配内存,初始化成员变量值。

就绪:
当线程对象调用了start()方法之后,该线程处于就绪状态。JVM为线程创建方法栈和程序计数器,等待线程调度器调度。

关于什么是方法栈,什么是程序计数器。我们会在下一章《8.多线程 [2/2]》做详细的讨论。

运行:
就绪状态的线程获得CPU资源,开始执行run()方法,该线程进入运行状态

阻塞:
当发生如下情况时,线程将会进入阻塞状态:

  1. 线程调用sleep()方法主动放弃所占用的处理器资源。
  2. 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  3. 线程试图获得一个同步锁,但该同步锁正被其他线程所持有。
  4. 线程在等待某个通知(notify)。
  5. 程序调用了线程的suspend()方法将该线程挂起。

死亡:
线程会以如下3种方式结束,结束后就处于死亡状态:

  1. run()或call()方法执行完成,线程正常结束。
  2. 线程抛出一个未捕获的Exception或Error。
  3. 调用该线程stop()方法来结束该线程。

特别注意,suspend()方法和stop()容易导致线程死锁,不推荐使用。
那么,什么是线程死锁呢?
在讨论线程死锁之前,我们要先讨论一下线程安全。为了解决线程安全,引入锁,但是如果锁应用不当,就会导致死锁。

线程安全

现象

在上文我们讨论了,线程每次只能抢到CPU的一个时间片,如果下次被抢了或者自己调用了阻塞方法,都会失去CPU的执行权。

接下来,我们来看一个很有趣的现象。

以这么一个例子为例。
卖票。
这个太常见了。
12306或者双十一等等,都有这个场景。

我们假设有100张票,有三个窗口(线程)在卖票。

我们来模拟这个过程。

  1. 定义一个类SellTicket实现Runnable接口,里面定义一个成员变量:private int tickets = 100
  2. 在SellTicket类中重写run()方法实现卖票,代码步骤如下
    1. 判断票数大于0,就卖票,并告知是哪个窗口卖的
    2. 卖了票之后,总票数要减1
  3. 定义一个测试类SellTicketDemo,里面有main方法,代码步骤如下
    1. 创建SellTicket类的对象
    2. 创建三个Thread类的对象,把SellTicket对象作为构造方法的参数,并给出对应的窗口名称
    3. 启动线程

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.kakawanyifan;

public class SellTicket implements Runnable {
private int tickets = 100;
//在SellTicket类中重写run()方法实现卖票,代码步骤如下
@Override
public void run() {
while (true) {
if (tickets > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--;
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.kakawanyifan;

public class SellTicketDemo {
public static void main(String[] args) {
//创建SellTicket类的对象
SellTicket st = new SellTicket();

//创建三个Thread类的对象,把SellTicket对象作为构造方法的参数,并给出对应的窗口名称
Thread t1 = new Thread(st,"窗口1");
Thread t2 = new Thread(st,"窗口2");
Thread t3 = new Thread(st,"窗口3");

//启动线程
t1.start();
t2.start();
t3.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
窗口2正在出售第100张票
窗口1正在出售第100张票
窗口3正在出售第100张票
窗口3正在出售第97张票
窗口1正在出售第97张票
窗口2正在出售第97张票

【部分运行结果略】

窗口2正在出售第4张票
窗口1正在出售第4张票
窗口3正在出售第4张票
窗口2正在出售第1张票
窗口3正在出售第1张票
窗口1正在出售第1张票

出问题了。

  1. 相同的票出现了多次
  2. 出现了负数的票(实际打印的记录行数大于100)

原因

为什么会这样?
我们来分析一下。
首先,肯定会有一个线程优先抢到CPU的执行权,比如就是t1线程抢到了CPU的执行权。然后t1线程执行到Thread.sleep(10),t1线程进入阻塞状态,这时候t1没有执行资格没有执行权,CPU暂时空出来了。马上t2线程抢到了CPU的执行权,t2线程就开始执行,执行到Thread.sleep(10),t2线程进入阻塞状态。t3线程抢到了CPU的执行权,t3线程就开始执行,同样,t3线程进入阻塞。
到这一步没有任何问题。
sleep结束后,线程进入就绪状态,继续抢CPU的执行权。
假如t1首先抢到CPU的执行权,这时候t1会马上执行System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");,输出正在出售第100张票
理论上接下来应该执行tickets--。但是假如,这时候CPU的执行权被抢了?
t2抢到了CPU的执行权,t2也输出正在出售第100张票,t2接下来应该执行tickets--,假如又被抢了?
t3同理。

这就是为什么相同的票被卖了多次。
出现了负数票(实际打印的记录行数大于100)的原因类似。

那么为什么会有线程安全问题?
两个原因,第一个原因,线程一次只能抢占CPU的一个时间片。这个是外界原因,

内在原因

那么内在原因呢?
tickets这个变量,多个线程在共享。在共享没问题,如果大家都只读的话,一点问题都没有。问题在于有线程在写,有线程在写其实也没问题。有线程在写也没问题,如果每个线程都只执行一个赋值的写操作,比如tickets = 99这种。问题在于线程执行了多个操作。
在这里一个线程执行了三个操作,打印是一个,然后打印结束之后的tickets--其实是三个操作,tp1 = ticketstp2 = tp1+1tockets = tp2

综上所述,线程安全问题的内在原因或者根本原因是:

  1. 多个线程在操作共享的数据。
  2. 多个线程对共享数据有写操作。
  3. 操作共享数据的线程执行的操作有多个。

解决

解决

如何解决多线程安全问题呢?

让线程们不要共享数据了,这是一个办法,如果可以不共享的话,ThreadLocal,在下一章《8.多线程 [2/2]》,我们就会讨论这种。

让线程不要写数据了,这个做不到,业务逻辑就是要写数据。
让线程执行的操作只有一个,这个也做不到。业务逻辑就是要有多个。
但是?
如果某个线程修改共享资源的时候,其他线程不能修改该资源,只有等修改完毕同步之后,才能去抢夺CPU资源,完成对应的操作呢?
这样是不是就解决了线程不安全的现象。

为了保证每个线程都能正常执行共享资源操作,Java引入了7种线程同步机制。

  1. 同步代码块(synchronized)
  2. 同步方法(synchronized)
  3. 同步锁(ReenreantLock)
  4. 特殊域变量(volatile)
  5. 局部变量(ThreadLocal)
  6. 阻塞队列(LinkedBlockingQueue)
  7. 原子变量(Atomic*)

这一章我们讨论前面三种。剩下的四种我们会我们暂时只讨论前三种,剩下的四种会在下一章《8.多线程 [2/2]》讨论。

同步代码块

第一种,同步代码块(synchronized)。
同步代码块格式:

1
2
3
synchronized(任意对象) {
多条语句操作共享数据的代码
}

synchronized(任意对象):就相当于给代码加锁了,任意对象就可以看成是一把锁。如果某个线程试图获得一个同步锁,但该同步锁正被其他线程所持有,则该线程会进入阻塞状态,直到锁释放。

那么!来吧!

示例代码:

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
package com.kakawanyifan;

public class SellTicket implements Runnable {
private int tickets = 100;
private Object obj = new Object();

//在SellTicket类中重写run()方法实现卖票,代码步骤如下
@Override
public void run() {
while (true) {
synchronized (new Object()) {
if (tickets > 0) {
try {
// 准备出票
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--;
}
}
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
窗口3正在出售第100张票
窗口2正在出售第100张票
窗口1正在出售第100张票
窗口1正在出售第97张票
窗口2正在出售第97张票
窗口3正在出售第95张票

【部分运行结果略】

窗口3正在出售第4张票
窗口2正在出售第4张票
窗口1正在出售第4张票
窗口1正在出售第1张票
窗口3正在出售第1张票
窗口2正在出售第1张票

这看起来也没解决啊。
注意看synchronized (new Object()),每个线程运行到这里都去创建一个新的对象,获得了一把新的锁,然后用完就释放。
这样这把锁根本没有效果,并没有把其他线程锁住。

我们要这么写。

示例代码:

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
package com.kakawanyifan;

public class SellTicket implements Runnable {
private int tickets = 100;
private Object obj = new Object();

//在SellTicket类中重写run()方法实现卖票,代码步骤如下
@Override
public void run() {
while (true) {
// synchronized (new Object()) 这种方法不行
synchronized (obj) {
if (tickets > 0) {
try {
// 准备出票
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--;
}
}
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
窗口1正在出售第100张票
窗口1正在出售第99张票
窗口1正在出售第98张票
窗口1正在出售第97张票
窗口1正在出售第96张票
窗口1正在出售第95张票

【部分运行结果略】

窗口3正在出售第6张票
窗口3正在出售第5张票
窗口3正在出售第4张票
窗口3正在出售第3张票
窗口2正在出售第2张票
窗口2正在出售第1张票

这样的锁,才是同一个SellTicket的对象的同一个成员变量。

如果我们按照如下代码的方式进行调用呢?new Thread(new SellTicket(),"窗口1"),为每一个线程都实例化一个新的"Runnable"。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kakawanyifan;

public class SellTicketDemo {
public static void main(String[] args) {

//创建三个Thread类的对象,把SellTicket对象作为构造方法的参数,并给出对应的窗口名称
Thread t1 = new Thread(new SellTicket(),"窗口1");
Thread t2 = new Thread(new SellTicket(),"窗口2");
Thread t3 = new Thread(new SellTicket(),"窗口3");

//启动线程
t1.start();
t2.start();
t3.start();
}
}
运行结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
窗口2正在出售第100张票
窗口3正在出售第100张票
窗口1正在出售第100张票
窗口3正在出售第99张票
窗口1正在出售第99张票

【部分运行结果略】

窗口2正在出售第3张票
窗口1正在出售第1张票
窗口3正在出售第1张票
窗口2正在出售第2张票
窗口2正在出售第1张票
  • 共300行。

同步方法

第二种,同步方法。
格式如下:

1
2
3
修饰符 synchronized 返回值类型 方法名(方法参数) { 
方法体;
}

在上文我们讨论了,同步代码块的锁,需要是同一个对象。
那么,同步方法的锁对象是什么呢?
对于非static方法,同步锁的对象就是this。
对于static方法,同步锁的对象是当前方法所在类的字节码(类名.class)。

示例代码:

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
package com.kakawanyifan;

public class SellTicket implements Runnable {
private static int tickets = 100;

//在SellTicket类中重写run()方法实现卖票,代码步骤如下
@Override
public void run() {
while (true) {
sellTicket();
}
}

private static synchronized void sellTicket() {
if (tickets > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--;
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
窗口1正在出售第100张票
窗口1正在出售第99张票
窗口1正在出售第98张票
窗口1正在出售第97张票
窗口1正在出售第96张票
窗口1正在出售第95张票

【部分运行结果略】

窗口3正在出售第6张票
窗口3正在出售第5张票
窗口3正在出售第4张票
窗口3正在出售第3张票
窗口2正在出售第2张票
窗口2正在出售第1张票

Lock锁

第三种方法,Lock锁。
Lock是接口不能直接实例化,这里采用它的实现类ReentrantLock(重入锁)来实例化。

ReentrantLock构造方法

方法名 说明
ReentrantLock() 创建一个ReentrantLock的实例
ReentrantLock(boolean fair) 创建一个是否是公平锁的ReentrantLock的实例
  • 公平锁:多个线程都公平拥有执行权,每个线程执行一段时间。
    非公平锁,那么就需要抢了,默认是false,非公平锁。

加锁解锁方法

方法名 说明
void lock() 获得锁
void unlock() 释放锁

示例代码:

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
package com.kakawanyifan;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SellTicket implements Runnable {
private int tickets = 100;
private Lock lock = new ReentrantLock();

@Override
public void run() {
while (true) {
try {
lock.lock();
if (tickets > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--;
}
} finally {
lock.unlock();
}
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
窗口1正在出售第100张票
窗口1正在出售第99张票
窗口1正在出售第98张票
窗口1正在出售第97张票
窗口1正在出售第96张票
窗口1正在出售第95张票

【部分运行结果略】

窗口3正在出售第6张票
窗口3正在出售第5张票
窗口3正在出售第4张票
窗口3正在出售第3张票
窗口2正在出售第2张票
窗口2正在出售第1张票

线程安全的类

在讨论了锁之后,我们来看看几个线程安全的类。
在这里,我们会看到锁在JDK中的应用。

举几个例子

  • StringBuffer,线程安全的"StringBuilder"。
  • Vector,线程安全的"List"。
  • Hashtable,线程安全的"Hashmap"。
    关于Hashmap为什么线程不安全,我们在《4.集合》就已经讨论过,就是那个并发修改异常。

例如,StringBuffer。
示例代码:

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
package java.lang;

【部分代码略】

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence{

【部分代码略】

@Override
public synchronized int length() {
return count;
}

@Override
public synchronized int capacity() {
return value.length;
}


@Override
public synchronized void ensureCapacity(int minimumCapacity) {
super.ensureCapacity(minimumCapacity);
}

@Override
public synchronized void trimToSize() {
super.trimToSize();
}

@Override
public synchronized void setLength(int newLength) {
toStringCache = null;
super.setLength(newLength);
}

@Override
public synchronized char charAt(int index) {
if ((index < 0) || (index >= count))
throw new StringIndexOutOfBoundsException(index);
return value[index];
}

【部分代码略】

}

解释说明:
相关方法是加了synchronized关键字。

再比如,Hashtable。

示例代码:

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
54
55
56
57
package java.util;

public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable {

【部分代码略】

public synchronized int size() {
return count;
}

public synchronized boolean isEmpty() {
return count == 0;
}

public synchronized Enumeration<K> keys() {
return this.<K>getEnumeration(KEYS);
}

public synchronized Enumeration<V> elements() {
return this.<V>getEnumeration(VALUES);
}

public synchronized boolean contains(Object value) {
if (value == null) {
throw new NullPointerException();
}

Entry<?,?> tab[] = table;
for (int i = tab.length ; i-- > 0 ;) {
for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) {
if (e.value.equals(value)) {
return true;
}
}
}
return false;
}

public boolean containsValue(Object value) {
return contains(value);
}

public synchronized boolean containsKey(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return true;
}
}
return false;
}

【部分代码略】

}

其特点也是相关方法加上了synchronized关键字。

还有一种方法是Collections中的Collections.synchronizedList()等方法。

我们来看看源码。

1
2
3
4
5
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}

不是同步方法?
来看SynchronizedList这个类的成员方法。

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
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {

【部分代码略】

final List<E> list;
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex);
this.list = list;
}
public boolean equals(Object o) {
if (this == o)
return true;
synchronized (mutex) {return list.equals(o);}
}
public int hashCode() {
synchronized (mutex) {return list.hashCode();}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
public int indexOf(Object o) {
synchronized (mutex) {return list.indexOf(o);}
}
public int lastIndexOf(Object o) {
synchronized (mutex) {return list.lastIndexOf(o);}
}
public boolean addAll(int index, Collection<? extends E> c) {
synchronized (mutex) {return list.addAll(index, c);}
}
public ListIterator<E> listIterator() {
return list.listIterator(); // Must be manually synched by user
}

【部分代码略】
}

其相关方法也不是同步方法,但其相关方法中的代码是同步代码块。即是通过同步代码块的方式实现的。

所以这些也被称为同步容器,下一章《7.多线程 [1/2]》,我们还会讨论并发容器,其实现原理不一样。

synchronized与Lock的区别

有如下区别:

  1. 存在层次
  2. 锁的释放
  3. 锁的获取
  4. 锁状态
  5. 锁类型

存在层次

  • synchronized:Java的关键字,在jvm层面上
  • Lock:一个类(这么说不准确,在下一章《8.多线程 [2/2],我们会对其进行更详细的分析。)

锁的释放

  • synchronized:获取锁的线程执行完同步代码,释放锁。或者线程执行发生异常,jvm会让线程释放锁
  • Lock:在finally中必须释放锁,不然容易造成线程死锁。

锁的获取

  • synchronized:假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。
  • Lock:分情况而定,可以尝试获得锁,得不到可以不用一直等待,例如tryLock。

这里介绍一下tryLock。tryLock方法重载了两种。

  1. tryLock(): 如果获取锁的时候锁被占用就返回false,否则返回true。
  2. tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,等待给定的时间。

示例代码:

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
package com.kakawanyifan;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
private Lock lock = new ReentrantLock();

//需要参与同步的方法
private void method(Thread thread) {

if (lock.tryLock()) {
try {
System.out.println("线程名" + thread.getName() + "获得了锁");
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("线程名" + thread.getName() + "释放了锁");
lock.unlock();
}
} else {
System.out.println("我是" + Thread.currentThread().getName() + "有人占着锁,我就不要啦");
}
}

public static void main(String[] args) {
LockTest lockTest = new LockTest();

//线程1
Thread t1 = new Thread(new Runnable() {

@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t1");

Thread t2 = new Thread(new Runnable() {

@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t2");

t1.start();
t2.start();
}
}

运行结果:

1
2
3
线程名t1获得了锁
我是t2有人占着锁,我就不要啦
线程名t1释放了锁

锁状态

  • synchronized:无法判断
  • Lock:可以判断

锁类型

  • synchronized:可重入,不可中断,非公平。
  • Lock:可重入,可中断,可公平(两者皆可),类型更丰富。

关于各种类型的锁,我们会在下一章《8.多线程 [2/2]》做详细的讨论。

线程死锁

什么是死锁

通过上文的讨论,我们已经知道了解决线程安全一个方法是锁。那么什么是死锁呢?
死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

举个例子。
死锁

  • R1和R2,大家可以理解为资源,或者锁对象。
  • P1和P2,代表两个线程。

这就是线程死锁。

让子弹飞

现象

接下来,我们来看具体的现象。
示例代码:

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
package com.kakawanyifan;

public class DeadLockRunnable implements Runnable {
// 比如是static,实例共享。
private static Object obj1 = new Object();
// 比如是static,实例共享。
private static Object obj2 = new Object();

// 决定线程走向
public int flag = 0;

public DeadLockRunnable(int flag) {
this.flag = flag;
}

public void run() {
if (flag == 1) {
synchronized (obj1) {
try {
System.out.println(Thread.currentThread().getName() + " 已经获取Obj1,正在请求Obj2。");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj2) {
System.out.println(Thread.currentThread().getName() + " 已经获取Obj2。");
}
}
}
if (flag == 2) {
synchronized (obj2) {
try {
System.out.println(Thread.currentThread().getName() + " 已经获取Obj2,正在请求Obj1。");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj1) {
System.out.println(Thread.currentThread().getName() + " 已经获取Obj1。");
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.kakawanyifan;

public class DeadLockDemo {
public static void main(String[] args) {
DeadLockRunnable deadLockRunnable1 = new DeadLockRunnable(1);
DeadLockRunnable deadLockRunnable2 = new DeadLockRunnable(2);

Thread thread1 = new Thread(deadLockRunnable1);
Thread thread2 = new Thread(deadLockRunnable2);

thread1.start();
thread2.start();
}
}

运行结果:

1
2
Thread-0 已经获取Obj1,正在请求Obj2。
Thread-1 已经获取Obj2,正在请求Obj1。
  • 大家都不能继续往下执行。

死锁产生的必要条件

以下这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

  1. 互斥条件
  2. 不可剥夺条件
  3. 请求与保持条件
  4. 循环等待条件

互斥条件

要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一个线程所占有。此时若有其他线程请求该资源,则请求线程只能等待。

不可剥夺条件

线程所获得的资源在未使用完毕之前,不能被其他线程强行夺走,即只能由获得该资源的线程自己来释放(只能是主动释放)。

请求与保持条件

线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已经获得的资源保持不放。

循环等待条件

存在一种线程资源的循环等待链,链中每一个线程已获得的资源同时被链中下一个线程所请求。如图所示:

循环等待条件

  • 特别注意:如果pn等待p0或者pk,这时候虽然有循环等待,但是不一定会死锁。

那么,死锁怎么解决呢?
或者在设计的时候进行避免,或者在已经发生后借助外力方法。这个我们不做太多讨论。

回到上文,我们说线程控制的两个方法不建议使用,suspend()stop(),因为容易导致锁资源没被释放,从而引起死锁。

线程通信

线程间通信常用方式如下:

  1. 休眠唤醒方式:
    1. Object的wait、notify、notifyAll
    2. Condition的await、signal、signalAll
  2. CountDownLatch:用于某个线程A等待若干个其他线程执行完之后,它才执行。
  3. CyclicBarrier:一组线程等待至某个状态之后再全部同时执行。
  4. Semaphore:用于控制对某组资源的访问权限。

我们分别讨论。

休眠唤醒方式

我们以两个线程打印奇偶数为例。

Object的wait、notify、notifyAll

示例代码:

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
54
55
56
57
58
59
package com.kakawanyifan;

public class WaitNotifyDemo {
private Object obj = new Object();
private Integer i = 0;

public void odd() {
while (i < 10) {
synchronized (obj) {
if (i % 2 == 1) {
System.out.println(Thread.currentThread().getName() + " " + i);
i++;
obj.notify();
} else {
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

public void even() {
while (i < 10) {
synchronized (obj) {
if (i % 2 == 0) {
System.out.println(Thread.currentThread().getName() + " " + i);
i++;
obj.notify();
} else {
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

public static void main(String[] args) {
final WaitNotifyDemo runnable = new WaitNotifyDemo();
Thread t1 = new Thread(new Runnable() {
public void run() {
runnable.odd();
}
}, "奇数线程");
Thread t2 = new Thread(new Runnable() {
public void run() {
runnable.even();
}
}, "偶数线程");

t1.start();
t2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
偶数线程 0
奇数线程 1
偶数线程 2
奇数线程 3
偶数线程 4
奇数线程 5
偶数线程 6
奇数线程 7
偶数线程 8
奇数线程 9

Condition的await、signal、signalAll

这种方法依赖Lock。

示例代码:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.kakawanyifan;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class WaitNotifyDemo {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private Integer i = 0;

public void odd() {
while (i < 10) {
lock.lock();
try {
if (i % 2 == 1) {
System.out.println(Thread.currentThread().getName() + " " + i);
i++;
// 唤醒
condition.signal();
} else {
// 等待
condition.await();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

public void even() {
while (i < 10) {
lock.lock();
try {
if (i % 2 == 0) {
System.out.println(Thread.currentThread().getName() + " " + i);
i++;
condition.signal();
} else {
condition.await();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

public static void main(String[] args) {
final WaitNotifyDemo runnable = new WaitNotifyDemo();
Thread t1 = new Thread(new Runnable() {
public void run() {
runnable.odd();
}
}, "奇数线程");
Thread t2 = new Thread(new Runnable() {
public void run() {
runnable.even();
}
}, "偶数线程");

t1.start();
t2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
偶数线程 0
奇数线程 1
偶数线程 2
奇数线程 3
偶数线程 4
奇数线程 5
偶数线程 6
奇数线程 7
偶数线程 8
奇数线程 9

生产者消费者模式

上述的线程通信方式,有一个很典型的应用,就是生产者消费者模式。

所谓生产者消费者问题,实际上主要是包含了两类线程:
一类是生产者线程用于生产数据
一类是消费者线程用于消费数据

为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库。
生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。
消费者只需要从共享数据区中去获取数据,并不需要关心生产者的行为。

理想情况下,生产者生产数据之后,消费者立马消费数据。完美配合,天衣无缝。
但是,可能会生产者生产数据之后,消费者还没来消费,就需要提醒消费者快来消费。或者消费者准备消费数据,发现没有数据,所以消费者需要提醒生产者赶紧生产。
这时候就需要线程通信了。

我们举个例子。生产牛奶和消费牛奶。
包含的类:

  1. 奶箱类(Box):定义一个成员变量,表示第x瓶奶,提供存储牛奶和获取牛奶的操作
  2. 生产者类(Producer):实现Runnable接口,重写run()方法,调用存储牛奶的操作
  3. 消费者类(Customer):实现Runnable接口,重写run()方法,调用获取牛奶的操作
  4. 测试类(BoxDemo):里面有main方法,main方法中的代码步骤如下
    1. 创建奶箱对象,这是共享数据区域
    2. 创建消费者创建生产者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用存储牛奶的操作
    3. 对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用获取牛奶的操作
    4. 创建2个线程对象,分别把生产者对象和消费者对象作为构造方法参数传递
    5. 启动线程

示例代码:

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
package com.kakawanyifan;

public class Box {
//定义一个成员变量,表示第x瓶奶
private int milk;
//定义一个成员变量,表示奶箱的状态
private boolean state = false;

//提供存储牛奶和获取牛奶的操作
public synchronized void put(int milk) {
//如果有牛奶,等待消费
if (state) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

//如果没有牛奶,就生产牛奶
this.milk = milk;
System.out.println("送奶工将第" + this.milk + "瓶奶放入奶箱");

//生产完毕之后,修改奶箱状态
state = true;

//唤醒其他等待的线程
notifyAll();
}

public synchronized void get() {
//如果没有牛奶,等待生产
if (!state) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

//如果有牛奶,就消费牛奶
System.out.println("用户拿到第" + this.milk + "瓶奶");

//消费完毕之后,修改奶箱状态
state = false;

//唤醒其他等待的线程
notifyAll();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kakawanyifan;

public class Producer implements Runnable {
private Box b;

public Producer(Box b) {
this.b = b;
}

@Override
public void run() {
for(int i=1; i<=30; i++) {
b.put(i);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kakawanyifan;

public class Customer implements Runnable {
private Box b;

public Customer(Box b) {
this.b = b;
}

@Override
public void run() {
while (true) {
b.get();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.kakawanyifan;

public class BoxDemo {
public static void main(String[] args) {
//创建奶箱对象,这是共享数据区域
Box b = new Box();

//创建生产者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用存储牛奶的操作
Producer p = new Producer(b);
//创建消费者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用获取牛奶的操作
Customer c = new Customer(b);

//创建2个线程对象,分别把生产者对象和消费者对象作为构造方法参数传递
Thread t1 = new Thread(p);
Thread t2 = new Thread(c);

//启动线程
t1.start();
t2.start();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
送奶工将第1瓶奶放入奶箱
用户拿到第1瓶奶
送奶工将第2瓶奶放入奶箱
用户拿到第2瓶奶
送奶工将第3瓶奶放入奶箱
用户拿到第3瓶奶

【部分运行结果略】

送奶工将第28瓶奶放入奶箱
用户拿到第28瓶奶
送奶工将第29瓶奶放入奶箱
用户拿到第29瓶奶
送奶工将第30瓶奶放入奶箱
用户拿到第30瓶奶

CountDownLatch方式

CountDownLatch是在java1.5被引入的,存在于java.util.concurrent包下。
CountDownLatch这个类能够使一个线程等待其他线程完成各自的工作后再执行。
CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。

每当一个线程完成了自己的任务后,计数器的值就会减1。当计数器值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务。

比如,我们需要并发的去表里查数据。

示例代码:

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
package com.kakawanyifan;

import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CountDownLatchDemo {
public static void main(String[] args) {
// 所有的查询结构都组装到这个map中
ConcurrentHashMap<String,Object> map = new ConcurrentHashMap<>();
// 需要查询的表
String[] tables = {"a","b","c","d","e"};
// 计数器
CountDownLatch countDownLatch = new CountDownLatch(tables.length);
// 线程池
ExecutorService executorService = Executors.newFixedThreadPool(tables.length);
// 遍历需要查询的表
for (String table:tables) {
Runnable runnable = new Runnable() {
@Override
public void run() {
// 查询
// 假设这里在查表
map.put(table, UUID.randomUUID());
// 计数器减一
countDownLatch.countDown();
}
};
executorService.execute(runnable);
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
executorService.shutdown();
System.out.println(map.toString());
}
}

运行结果:

1
{a=8f2e175b-ecd2-4542-b18c-88a44c8ca0f9, b=93a8e610-93a8-4c84-8b14-e0b67b1d7ccd, c=698ae836-a9e0-474c-bfc0-ecc843e116bc, d=9817c94c-b499-44bb-9a95-de262891c448, e=85030da5-2897-49ab-9cf9-68e732aa2241}

CyclicBarrier方式

CyclicBarrier实现让一组线程等待至某个状态之后再全部同时执行。
类似于,枪响之后,所有的运动员同时起泡。

三个线程同时启动,示例代码如下。

示例代码:

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
54
package com.kakawanyifan;

import java.util.Date;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
public static void main(String[] args){
// 3是参与同时启动的线程数
final CyclicBarrier cyclicBarrier = new CyclicBarrier(3);

new Thread(new Runnable() {
public void run() {
System.out.println(Thread.currentThread().getName()+":准备...");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"启动完毕:"+new Date().getTime());
}
},"线程1").start();

new Thread(new Runnable() {
public void run() {
System.out.println(Thread.currentThread().getName()+":准备...");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"启动完毕:"+new Date().getTime());
}
},"线程2").start();

new Thread(new Runnable() {
public void run() {
System.out.println(Thread.currentThread().getName()+":准备...");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"启动完毕:"+new Date().getTime());
}
},"线程3").start();
}
}

运行结果:

1
2
3
4
5
6
线程1:准备...
线程2:准备...
线程3:准备...
线程3启动完毕:1631522569844
线程1启动完毕:1631522569844
线程2启动完毕:1631522569844

Semaphore方式

Semaphore用于控制对某组资源的访问权限。
工人使用机器工作,示例代码如下:
示例代码:

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
package com.kakawanyifan;

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {

static class Machine implements Runnable{
private int num;
private Semaphore semaphore;

public Machine(int num, Semaphore semaphore) {
this.num = num;
this.semaphore = semaphore;
}

public void run() {
try {
semaphore.acquire();//请求机器
System.out.println(Thread.currentThread().getName() + " 工人"+this.num+"请求机器,正在使用机器");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " 工人"+this.num+"使用完毕,已经释放机器");
semaphore.release();//释放机器
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args){
int worker = 8;//工人数
Semaphore semaphore = new Semaphore(3);//机器数
for (int i=0; i< worker; i++){
new Thread(new Machine(i, semaphore)).start();
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Thread-1 工人1请求机器,正在使用机器
Thread-2 工人2请求机器,正在使用机器
Thread-0 工人0请求机器,正在使用机器
Thread-0 工人0使用完毕,已经释放机器
Thread-3 工人3请求机器,正在使用机器
Thread-2 工人2使用完毕,已经释放机器
Thread-5 工人5请求机器,正在使用机器
Thread-1 工人1使用完毕,已经释放机器
Thread-6 工人6请求机器,正在使用机器
Thread-5 工人5使用完毕,已经释放机器
Thread-6 工人6使用完毕,已经释放机器
Thread-3 工人3使用完毕,已经释放机器
Thread-7 工人7请求机器,正在使用机器
Thread-4 工人4请求机器,正在使用机器
Thread-4 工人4使用完毕,已经释放机器
Thread-7 工人7使用完毕,已经释放机器
文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/10807
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Kaka Wan Yifan

留言板