本文介绍Java中的多进程和多线程编程。
1. 概述
什么是进程?什么是线程?
进程是一个应用程序,线程则是一个进程中的执行场景/执行单元。一个进程可以启动多个线程。
比如对于Java程序,当在DOS命令窗口中执行时,会先启动JVM,而JVM就是一个进程。之后,JVM再启动一个主线程调用main方法,同时再启动一个垃圾回收线程负责看护,回收垃圾。最起码,Java程序中至少有两个线程并发,一个是main方法主线程,一个是垃圾回收线程。
因此,使用多线程机制后,main方法结束后只代表主线程结束了,主栈空了,但并不意味着其他线程也结束了,所以程序不一定结束。
进程和线程的关系?
进程可以看做是现实生活中的公司,线程可以看做是公司中的某个部门/员工。
进程和进程的关系?
进程和进程之间是相互独立的,不共享资源。在Java中内存独立不共享。
线程和线程的关系?
线程和线程之间有一定关联,在Java中同一个进程下的线程之间堆内存和方法区内存共享,栈内存独立,一个线程对应一个栈内存。(注意,内存中有三个区:方法区内存、堆内存、栈内存)
多个线程一起运行(操作系统,宏观上),这就是并发。因为有时候某个线程A在等待某种资源,此时占着CPU不用,所以这对CPU来说是浪费的。因此此时可以让另一个线程B来运行,当线程B等待的时候或者时间片轮到到了A,A再执行。之所以有多线程机制,目的就是为了提供程序的处理效率。
2. 线程的实现方式
注意,下面的两种方法,重写run()方法,该方法里面不能抛出异常,因为父类中的run()方法没有抛出异常,又因为子类不能比父类抛出更宽泛的异常,所以重写后的run()方法也不能抛出异常。
Java语言中,实现线程有四种方式:
编写类,继承java.lang.Thread,重写run方法。
我们知道main方法是主线程,那么在里面怎么创建线程对象呢?怎么启动线程呢?和创建普通对象一样,只不过启动线程需要
对象.start()
来运行run()方法里面的代码。start方法的作用就是启动一个分支线程,在JVM中开辟一个新的栈空间,这条语句瞬间就结束了,本质上这段调用start方法的代码仍然是主线程代码。启动成功的线程会自动调用run方法(JVM线程调度机制),并且run方法在分支栈的底部(即run方法相当于主栈的main方法)。注意,如果直接调用mt.run方法,不会启动新线程,而是就是普通调用。本质上,start方法就是为了开辟空间,开启线程而已。
注意,主线程的for循环一定是在mt.start()这行代码语句结束之后才会执行。但是mt.start语句仅仅是开辟空间而已,很快就结束了。至于开辟之后,该空间的run方法(JVM线程调度机制,在该空间内调用run方法)执行就和主线程没关系了【因为是开辟了分支线程】。
代码如下所示:
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
31public class ThreadTest01 {
public static void main(String[] args) {
// 主线程
// 启动分支线程
MyThread mt = new MyThread();
mt.start();
// 继续主线程操作
for (int i = 0; i < 1000; i++) {
System.out.println("主线程--->" + i);
}
}
}
/**
* 自定义类,继承Thread
*/
class MyThread extends Thread {
// 重写run方法,该方法内的程序运行在新线程中
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("分支线程--->" + i);
}
}
}编写类,实现java.lang.Runnable接口,实现run方法。(常用)
之后,创建该类对象,将其作为参数传入到Thread构造方法中,利用Thread的start()方法启动分支线程。本质上和上面的方法差不多,只不过这种方法是以接口对象形式传参。
注意,实现Runnable接口的类,本质上并不是线程,只是一个可运行的类。需要将该对象传入Thread类,创建Thread对象。即将可运行的对象封装成一个线程对象。本质上说,Thread就是一个可运行的线程类。
代码如下所示:
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
31public class ThreadTest02 {
public static void main(String[] args) {
// 主线程
// 启动分支线程
MyRunnable mr = new MyRunnable();
Thread t = new Thread(mr);
t.start();
// 继续主线程操作
for (int i = 0; i < 1000; i++) {
System.out.println("主线程--->" + i);
}
}
}
/**
* 自定义类,实现Runnable接口以及run方法。
*/
class MyRunnable implements Runnable {
// 实现run方法
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("分支线程--->" + i);
}
}
}实现Callable接口(JDK5新特性)
这种方式实现的线程可以获得线程的返回值,即call方法有返回值,可以抛出异常,支持泛型的返回值。前面的两种方法,返回值都是void,是无法获得线程返回值结果的。前面的Thread和Runnable都是属于java.lang包下,而Callable接口属于java.util.concurrent包中。通过该方法创建线程有以下步骤:
- 创建Callable接口实现类对象,重写call()方法,返回值类型为Object。
- 创建FutureTask类对象,参数为上面的接口实现类对象。【FutureTask类是Future接口的唯一实现类,另外,FutureTask也实现了Runnable接口,它既可以作为Runnable被线程执行,也可以作为Future得到Callable的返回值】【本质上是实现了RunnableFuture接口,而这个接口继承了上面两个接口】
- 创建线程对象,参数为FutureTask类对象。
- 主线程调用线程对象的start()方法启动线程。
- 主线程FutureTask类对象.get()就是子线程的返回结果【注意,这里是在主线程中获取子线程的返回值,那么此语句必然会导致主线程阻塞,等待子线程执行完毕获取结果,所以,get方法必须放在主线程的最后一行。这样,只要不调用get,主线程就无需等待子线程,只有最后在等待子线程,也就意味着主线程get之前和子线程其实就是并行的。】
注意,一个FutureTask对象,如果有多个线程都传入了该对象,多个线程,本质上只会运行一次FutureTask的call方法。即对于一个FutureTask对象,无论有几个线程,call方法都只会执行一次,但是可调用多次get方法获得返回值。
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
34public class ThreadTest15 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
System.out.println();
MyCall myCall = new MyCall();
FutureTask futureTask = new FutureTask(myCall);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
System.out.println("主线程结束");
}
}
class MyCall implements Callable {
public Object call() throws Exception {
int sum = 0;
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
sum += i;
}
return sum;
}
}使用线程池
上面都是一个个地创建线程,。需要时就创建一个线程,不用了就销毁它。但是经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。
可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中,不销毁它。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。线程池有以下优点:
提高响应速度(减少了创建新线程的时间)
降低资源消耗(重复利用线程池中线程,不需要每次都创建)
便于线程管理
- corePoolSize:核心池的大小
- maximumPoolSize:最大线程数
- keepAliveTIme:线程没有任务时最多保持多长时间后会终止
- …
注意,上面的三种方法,都是创建类继承Thread、Runnable、Callable,重写run方法。也就是说,线程具体的任务是需要自己写的,然后调用start()方法来开启新线程。run()方法执行结束,线程自动销毁。
而线程池,则无需手动调用start(),只需要把上面的任务类交给线程池即可。由线程池来分配线程执行任务。
线程池主要涉及到java.util.concurrent.Executor
接口,java.util.concurrent.ExecutorService
。ExecutorService继承Executor,是真正的线程池接口,ThreadPoolExecutor是其常见的实现类。另外,还有java.util.concurrent.Executors
工具类,用于创建并返回不同类型的线程池。
ExecutorService接口中的常用方法如下:
方法名 | 描述 |
---|---|
void execute(Runnable command) | 执行任务/命令,没有返回值,一般用来执行Runnable。 |
T Future |
执行任务,有返回值,一般用来执行Callable。 |
void shutdown() | 关闭线程池。 |
Executors工具类中的常用方法如下(静态方法):
方法名 | 描述 |
---|---|
Executors.newCachedThreadPool() | 创建一个可根据需要创建新线程的线程池。可扩容 |
Executors.newFixedThreadPool(n) | 创建一个可重用固定线程数的线程池。 |
Executors.newSingleThreadExecutor() | 创建一个只有一个线程的线程池。 |
Executors.newScheduledThreadPool(n) | 创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。 |
下面是反编译后的源码: | |
1 | public static ExecutorService newFixedThreadPool(int nThreads) { |
可以看到,底层就是创建ThreadPoolExecutor线程池对象。
案例如下所示:
1 | public class ThreadTest25 { |
2.1 方法对比
第一种方法采用继承的方式,第二种方法采用实现接口的方式,第二种方法更加常用一些,面向接口编程,比较灵活,并且不受Java单继承的限制。
2.2 线程的生命周期
主要分为5个阶段
- 新建状态:调用start方法进入就绪状态
- 就绪状态(可运行状态):等待时间片轮转运行,进入运行状态
- 运行状态:CPU运行该线程(run方法)。时间片结束之后,返回到就绪状态
- 阻塞状态:处于运行状态的线程需要其他资源,如接收用户输入等等,进入阻塞状态。当资源准备好之后,就会进入就绪状态。
- 死亡状态:线程执行完毕,run方法执行结束。
3. 线程的基本操作
3.1 线程的基本设置
获取当前线程对象
public static Thread currentThread()
,该方法是在Thread包下,是静态方法,注意,是获取当前线程,即该函数所在代码所在的线程。获取线程对象的名字
线程对象.getName()
。注意如果没有设置线程对象的名字,则会采用默认值:Thread-0
、Thread-1
等等。修改线程对象的名字
线程对象.setName()
线程的sleep()方法
public static void sleep()
,该方法是在Thread包下,是静态方法,参数是毫秒,让当前线程进入休眠,进入“阻塞状态”,放弃占优的CPU时间片,让给其他线程使用。注意,该方法是静态方法。虽然可以用线程对象调用,但也不会让该对象所表示的线程睡眠,而是对当前调用所在代码的线程起作用。
线程唤醒(针对sleep()方法睡眠时间太长,需要提前唤醒,终止睡眠)
public void interrupt()
,该方法是在Thread包下,是成员方法,通过对象调用,来中止该对象的睡眠。本质上是通过触发sleep方法的异常,导致不再睡眠,进而try{}catch(){}异常捕获,进而跳出sleep()。代码如下所示: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
35public class ThreadTest05 {
public static void main(String[] args) {
System.out.println("主线程 ---> " + Thread.currentThread().getName());
// 创建分支线程并启动,注意,启动之后就会睡眠
Thread t = new Thread(new MyThread05());
t.start();
// 模拟主线程工作
try {
Thread.sleep(1000 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 唤醒分支线程
t.interrupt();
}
}
class MyThread05 implements Runnable {
public void run() {
try {
Thread.sleep(1000 * 60 * 60 * 24);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("分支线程 ---> " + Thread.currentThread().getName());
}
}当然,如果不希望异常信息输出,可以在catch(){}语句块中不打印异常信息。
终止线程的执行
t.stop()
,这种方法相当于任务管理器中的结束进程,相当于强制终止线程。这种方法容易丢失数据,没有保存数据,类似突然断电,不建议使用。示例如下所示: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
35public class ThreadTest06 {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable06());
t.setName("t");
t.start();
try {
Thread.sleep(1000 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 5秒钟之后,强行终止t线程
t.stop(); // 已过时,不建议使用,有点类似,任务管理器中强行结束进程
}
}
class MyRunnable06 implements Runnable {
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "--->" + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}合理地终止线程
上面的stop方法,是因为我们无法控制结束前的操作,因此,我们可以手动结束线程,在结束前程的前面进行数据保存等相关操作。那么怎么结束线程呢?可手动设置一个布尔标记,在线程运行的时候,每次都判断该标记。此时,我们可在外部设置该标记为false,这样,线程就不会再运行了,即run方法结束就行了,直接return手动结束线程即可。代码如下所示:
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
52public class ThreadTest07 {
public static void main(String[] args) {
MyRunnable07 mr = new MyRunnable07();
Thread t = new Thread(mr);
t.setName("t");
t.start();
try {
Thread.sleep(1000 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 这里将标记改为false即可
mr.run = false;
}
}
class MyRunnable07 implements Runnable {
boolean run = true;
public void run() {
for (int i = 0; i < 10; i++) {
if(run) {
System.out.println(Thread.currentThread().getName() + "--->" + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 在此处可以进行数据保存等相关操作,
// 返回,结束run方法,本线程结束。
return;
}
}
}
}
3.2 线程调度(了解)
我们知道,为了多线程运行,需要对线程进行调度,合理的分配各线程的运行时间。常见的线程调度模型有以下几种:
抢占式调度模型
哪个线程的优先级比较高,抢到CPU时间片的概率就高一些【抢的时间片长一些】。Java采用的就是这种调度模型。
均分式调度模型
平均分配CPU时间片,每个线程占有的CPU时间片长度一样,各个线程平均分配,一切平等。
Java提供的线程调度相关的方法有如下几种
void setPripority(int new Pripority)
:设置线程的优先级int getPripority()
:获取线程的优先级static void yield()
:暂停当前正在执行的线程对象,并执行其他线程,注意,这不是阻塞,只是将运行状态转换为就绪状态,放弃本次的时间片,但是在回到就绪状态后,有可能仍然会抢到。void join()
:等待该线程终止,使得调用者所在线程进入阻塞状态。主要用于合并线程,也就是在一个线程A中,调用另一个线程B的join,那么线程A会进入阻塞状态,当B执行结束后,A线程才会继续。【因此,因为A只有B结束之后,A才会可能结束,那么就可认为AB线程二者合并了,但是仍然是两个线程】
备注,最低优先级为1,最高优先级为10,默认优先级为5。
java.lang.Thread
public static final int MAX_PRIPORITY = 10
public static final int MIN_PRIPORITY = 1
public static final int NORM_PRIPORITY = 5
3.3 线程安全(重点)
因为是多线程,对于数据的读取和写入,那么此时就需要考虑多个线程读取到的数据是否是一致的,多次读取是否是统一的。因此,这就需要考虑数据安全,即线程安全。【比如线程A读取了一遍数据,然后线程B突然写入更新数据,之后A再读取就不一致了。最经典的就是两人同时刷银行卡购物】,和数据库中的事务是类似的。
线程安全问题:当多线程并发的环境下,有共享数据,并且这个数据还会被修改,此时就存在线程安全问题,怎么解决这个问题呢?线程排队执行,将并发改为不并发,用排队执行解决线程安全问题,这种机制被称为线程同步机制。
异步就是多线程各自运行,互补干扰;而同步则是线程之间有顺序,必须等待一个线程执行结束之后,另一个线程才能执行。
3.3.1 模拟两个人对同一个账户进行操作
1 | public class ThreadTest11 { |
3.3.2 解决线程安全问题
在Java语言中,任何一个对象都有一把锁,其实这个锁就是一个标记。此时,可用synchronized关键字限制一下,要想执行针对某个对象的操作代码,就必须获取到该对象的对象锁。当代码结束之后,再释放该对象锁。
因此,当有多个线程对同一个数据进行操作的时候,必然要抢该对象锁,只有拿到的线程才会执行某段代码,而这段代码可以是引起数据不安全的代码。对上述问题的改进代码如下所示,【为什么偏偏是下面的几行代码呢?因为引起数据不安全的就是:一个线程获取到的数据是另一个线程更新前的数据。因此只要保证一个线程在读取之前获取到的是另一个线程跟新后的数据就行。】:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 public void withDraw(double money){
// synchronized关键字,包括的语句块,表明里面的语句块每次只能由一个线程执行
// 括号中的参数,必须是多线程共享的数据。即需要指明这个对象,由哪些线程共享,即限制哪些线程执行下面的语句块。【这里的共享数据,并不是代码块中操作的那个数据】
// 比如有t1、t2、t3三个线程,而只需要t1、t2两个线程共享,t3无需共享该对象,而是操作的另一个对象,那么在执行的时候,t3就不需要该对象锁也能执行本代码。【因此,下面的this最好是当前线程操作的对象】
// 本质上说,synchronized会占用该共享数据的对象锁。此时另一个线程,就拿不到对象锁。当语句块结束之后,就会释放对象锁。
synchronized (this) {
double before = this.getBalance();
double after = before - money;
try {
Thread.sleep(1000 * 1);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.setBalance(after);
}
}其实上面的this就是一个标记,尽量是要操作的数据对象,但是也不强制。这个this就相当于一个钥匙,只要哪个线程拿到了这个标记,那么就意味着可以执行synchronized语句块。但是必须保证,多个线程在执行本方法的时候,参数this是公用的一个this。
1
2
3
4
5
6
7 Account account1 = new Account("act-001", 10000);
Account account2 = new Account("act-002", 10000);
AccountThread at1 = new AccountThread(account1);
AccountThread at2 = new AccountThread(account1);
AccountThread at3 = new AccountThread(account2);上面,at1和at2传入的账户都是account1,那么在调用方法的时候,二者的this就是一个。此时这两个线程在执行取钱的时候,就是同步的。而at3传入的账户是account2,即this和前面的this不是一个,该线程和上面的两个线程就没有关系,是异步的。
- 局部变量不会出现线程安全问题。【因此,如果局部变量,可以采用StringBuilder,无需考虑线程安全】
- 实例变量和静态变量有可能出现线程安全问题
3.3.3 synchronized关键字
其实除了修饰代码,也可以修饰调用方法时的代码,即扩大了同步范围。但是范围越大,效率就越低。除此之外,还可以直接修饰在方法上面,锁对象就是this。
1 | // 当修饰实例方法时,锁共享只能是this,无法设置其他的。如果默认要求就是共享this,那么可采用本形式 |
StringBuffer就是将synchronized修饰了整个方法。总体来说:
- ArrayList是非线程安全的
- Vector是线程安全的
- HashMap、HashSet是非线程安全的
- HashTable是线程安全的
3.3.4 synchronized总结
修饰代码块:灵活,可设置线程共享对象
1
2
3synchronized(线程共享对象) {
同步代码块
}在实例方法上使用synchronized:表示共享对象是this,且整个方法体是同步代码块
1
2
3权限修饰符 synchronized 返回值类型 方法名(形参列表){
方法体
}在静态方法上使用synchronized:表示类锁,因为是静态方法,所以说,类锁只有一把。针对该类的操作只能是同步的,保证静态变量的安全。
注意,当执行同步代码块时,需要某个锁,如果拿不到这个锁,那么就不会被执行。【因为这个锁可能被其他线程调用其他代码块拿到了】,如下所示,【因为这两个方法都会锁住MyClass对象,只要两个线程锁获取到的MyClass对象是一个,那么这两个线程即使调用的不是下面同一个方法,也会同步】:
1
2
3
4
5
6
7
8
9class MyClass {
public synchronized void doOther(){
// 同步代码块
}
public synchronized void doSome(){
// 同步代码块
}
}注意类锁和对象锁的区别。
3.4 解决线程安全问题
解决线程安全问题,Java提供了多种方式,上面的synchronized属于其中一种,主要有以下几种:
同步代码块:synchronized修饰代码块
同步方法:synchronized修饰方法
Lock(锁):
从JDK5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当。
3.4.1 Lock(锁)
java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制汇总,比较常用的是ReentrantLock,可以显式加锁、释放锁。
1 | // 1. 实例化ReentrantLock对象 |
这种方式,需要三步,第一步是创建锁对象,注意,多线程之间要共享这把锁对象。然后将同步代码块上下分别加锁和解锁,也就是步骤2和3,这样就相当于划定了一个范围,处于这两个语句之间的代码是同步的。
为了避免中间出现异常,而导致只加锁成功,但是异常退出,却没来及解锁,导致本线程一直持有锁,一般情况下,unlock操作是在finally语句块中释放。
1 | Lock lock = new ReentrantLock(); |
Lock和synchronized的异同?
- 相同点:二者都可解决线程安全问题
- 不同点:synchronized是自动加锁和解锁的【隐式锁】;而Lock则必须手动加锁和解锁【显式锁】,并且必须解锁,如果不解锁,显然其他线程就无法获得到该锁(经过测试,如果一个线程已经加了该锁了,仍然可以再次加该锁【就是不知道是否真正再次拿到了该锁,感觉没有真正拿到,而是判断已经有该锁了,所以就不会再拿】,这就是可重入锁),后续线程只能等待,也就是说,此时只是单线程了。
另外:
- Lock只有代码块锁,synchronized有代码块锁和方法锁
- 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
两者优先使用顺序:Lock -> 同步代码块(已经进入了方法体,分配了相应资源) -> 同步方法(在方法体之外)
另外,对于lock,不只有lock()以及unlock()方法,还有lockInterruptibly()、tryLock()等方法。
3.4.2 Condition
既然wait()、notify()、notifyAll()只能用在synchronized,那么必须也有与Lock对象配套的释放锁资源的类。即java.util.concurrent.locks.Condition
接口,接口中主要的方法就是await()
、signal()
、signalAll()
。
1 | class Containers { |
那么既然有synchronized了,为什么还要有Lock呢,以及还要配套Condition接口呢?这是为了精准唤醒某个阻塞的线程。针对一个lock对象,可以创建多个Condition对象,就相当于为一把锁配了多把钥匙。
另外,线程执行与否,需要某个条件(比如仓库容量是否充足),如果条件不满足,那么就本condition阻塞。否则执行任务,执行完之后,可通知对应的condition。对比上面的代码案例,该案例只创建了一个Condition。
其实这里,可以想到,本质上,await()以及wait()都是跳出同步代码块,释放资源。而signal以及notify则是让线程在获得锁之后可以直接进入到阻塞语句的下一句。
但是synchronized,锁对象只有一个,而且只能是该对象来调用notify。即释放了当前锁。显然,仅仅是释放了对象锁,但是没有规定哪个线程可以抢到。
那么能不能释放执行线程的锁呢?显然是不可以的,因为synchronized规定了多个线程使用的锁必须是同一个,这样才能保证线程安全。
那么Lock,虽然对象锁也是只有一个,但是可以创建多个与之匹配的condition。这样的话,通过condition来调用await()和signal,即通过调用指定对象的condition的await()来使之阻塞释放对象锁;通过调用指定对象的condition的signal()来唤醒指定线程。
案例如下:
三个线程,A线程打印5次,B线程打印10次,C线程打印15次。线程的执行顺序按照ABC来一遍。
先简单用3个不同的方法表示三个不同的线程执行方法,代码如下所示:
1 | class Containerss { |
但是实际上,可以用一个方法来解决的,此时需要判断当前执行到哪一步了,即打印的是5次的,还是10次的,还是15次的。可根据线程传入参数来判断。
1 | class Containerss { |
3.5 死锁
和操作系统中一样,死锁就是线程A已经占有了线程B的某个资源1,此时需要另一个资源2。而线程B已经占有了资源2,需要另一个资源1。此时,二者就只能一直等下去。代码如下所示:
1 | public class ThreadTest12 { |
4. 守护线程
在Java中,除了上面的用户线程之外,还有守护线程(如垃圾回收)。一般情况下,用户线程是一个死循环,所有的用户线程只要结束,守护线程自动结束。注意,主线程main方法是一个用户线程。
可以在启动线程之前采用线程对象.setDaemon(true)
来将该线程对象设置为守护线程,即当所有的用户线程结束之后,该线程对象自动结束【一般情况下为了使得该线程持续工作,将代码实现设置为死循环】。
1 | public class ThreadTest13 { |
5. 定时器
定时器就是间隔一定的时间,执行特定的程序。比如定时备份数据等等。可直接使用sleep方法睡眠特定的时长【这是最原始的方式】。在Java中,已经实现了一个定时器java.util.Timer
【用的较少】,但Spring框架中的SpringTask底层就是该工具类。
java.uti.Timer中有一个方法:void schedule(TimeTask task, Date firstTime, long period)
。period是毫秒,TimeTask是抽象类,实现了Runnable接口,可认为是一个线程,即定时任务,firstTime是第一次执行的时间。
代码如下所示:
1 | public class ThreadTest14 { |
6. Object类中的wait和notify
这两个方法不是线程中的方法,而是Object类中自带的,也就意味着任何一个类对象都有着两个方法。因此,这两个方法不是通过线程对象来调用的,【虽然线程对象继承了Object,也可以调用】。
6.1 wait()方法
调用该方法,表示正在该对象上活动的线程进入等待状态,无期限等待,直到被唤醒为止;其实就是会让当前线程进入等待状态。进入等待状态之后,会释放之前占优的对象锁。
1 | Object o = new Object(); |
6.2 notify()方法
调用该方法,唤醒正在o对象上等待的线程。还有notifyAll()方法,表示唤醒o对象上处于等待的所有线程。notify方法只通知等待的线程可以醒了,但是不会给该线程当前对象锁。
注意,此时唤醒的是处于该对象上等待的线程,比如上面调用的wait。而并不是唤醒当前线程。
6.3 生产者消费者模式
一个线程负责生产,一个线程负责消费,二者对同一块仓库数据区域操作。生产者负责在该区域放数据,消费者负责在该区域拿数据,满足供需,不能满了之后还放,也不能空了之后还拿。
因此仓库除了考虑线程安全问题之外,还需要调用wait和notify来保证供需。
1 | public class ThreadTest16 { |
注意,上面只是一个生产者,一个消费者,当仓库满或者空的时候,就会阻塞,等待生产或消费。之后就会唤醒线程。但是如果有两个消费者,两个生产者。此时就会发生问题。
因为当仓库为空的时候,显然消费者就会阻塞,此时生产者可生产,生产完之后,就会唤醒所有等待的线程,即两个消费者,以及剩余的一个生产者。
显然,消费者如果抢到锁资源,这是没问题的;但是。如果是另一个生产者抢到资源,那么就会继续生产导致超出容量。同理,如果消费者抢到了锁,消费完之后,会唤醒其他线程,当前也包括另一个消费者,此时就会出现-1的情况。
这样被称为虚假唤醒。虚假唤醒,就是当前资源不足,但是你唤醒了,或者说,唤醒的线程数量大于资源数量。那么该怎么解决呢?本质上wait()阻塞之后,唤醒时,在抢到锁之后直接从上一次阻塞的下一句开始执行。
- 在操作之前,wait之后,再判断一次。
- 循环判断wait(),不是if判断。这样,在唤醒之后,仍然会判断资源数量是否充足
1 | public class ThreadTest20 { |
6.4 wait()和sleep()的区别?
wait()是Object类中的实例方法,而sleep()是Thread类中的静态方法
wait()会释放锁,而sleep()则不会释放锁资源
wait()如果不加时间限制,只能被notify()以及notifyAll()唤醒,不会自动唤醒;而sleep()则自动睡醒。
wait、notify、notify()只能用在synchronized代码块/方法中。否则会报错。而sleep则可以在任何时候调用。
1
Exception in thread "Thread-2" java.lang.IllegalMonitorStateException: current thread is not owner
另外,必须是synchronized中的锁对象调用上述三个方法;因为wait()会释放锁对象,所以必须是锁对象来调用。
上述三个方法主要是用于线程通信。
相同点:二者都可以使当前线程进入阻塞状态。