多线程
@TOC
<hr style=" border:solid; width:100px; height:1px;" color=#000000 size=1">
一、线程简介
在了解线程之前先了解一下什么是进程!
1、进程(process)
1.1、程序是指令和数据的有序集合,是静态的概念。
1.2、进程是执行程序的一次执行过程,是动态的概念。是系统资源分配的单位。
2、一个进程中至少有一个线程。
线程是CPU调度和执行的单位。
3、线程
3.1 线程是独立的执行路径。
3.2 main()称之为主线程,为系统的入口,用于执行整个程序。
3.3 线程的运行由调度器安排调度,不能人为干预其执行的先后顺序。
3.4 线程会带来额外的开销。如CPU调度时间,并发控制开销。
二、线程实现
1、线程创建
只有主线程一条执行路径,当有多条执行路径时,主线程和子线程交替执行
创建一个类继承自Thread类,要对其run()方法进行重写,编写线程执行体
在主线程中通过创建一个线程对象,进而调用start()方法开启线程,启动线程,程序代码如下:
```java
public class TestThread extends Thread {@Override public void run() { for (int i = 0; i < 20; i++) System.out.println("我是run" + i); }
public static void main(String[] args) {
TestThread testThread = new TestThread();
testThread.start();
for (int i = 0; i < 1000; i++) {
System.out.println("我是start" + i);
}
}
}
## 2、网图下载【强化多线程的概念】
前提:需要导入commors-i6-2.6.jarr包,考入lib目录下,并将该jar包添加到library中
思路:多线程下载,需写一个下载器,下载器中写一个方法
## 3、线程创建的第二种方法
**方法:**实现Runnable接口,重写run方法,执行线程需丢入runnable接口实现类
**步骤:**具体步骤如下:
```java
package lesson.lessonThread;
// 1、实现runnable接口
public class TestThread2 implements Runnable{
// 2、重写run方法
@Override
public void run() {
for (int i = 0; i < 200; i++) {
System.out.println("run"+i);
}
}
public static void main(String[] args) {
// 3、创建runnable接口实现类对象
TestThread2 testThread2 = new TestThread2();
// 4、创建线程对象,通过线程对象来开启我们的线程(代理)
new Thread(testThread2).start();
for (int i = 0; i < 1000; i++) {
System.out.println("start"+i);
}
}
}
小结
继承Thread类 | 实现Runnable接口 |
---|---|
子类继承Thread类具备多线程能力 | 实现接口Runnable具有多线程能力 |
启动线程:子类对象.start() | 启动线程:传入目标对象+Thread类.start() |
不建议使用:避免oop单继承局限性 | 推荐使用:避免单继承局限性,灵活方便, 方便同一个对象被多个线程使用 |
4、延时 Thread.sleep()
4.1、单位是ms
4.2、给线程起名字:Thread.currentThread().getName()获得
4.3、案例:龟兔赛跑——Race
题目要求:
首先有赛道距离,然后离终点越来越近
判断比赛是否结束
打印出胜利者
龟兔赛跑开始
模拟兔子睡着了
终于,乌龟赢得了比赛
代码:
```java
package lesson.lessonThread;public class Race implements Runnable {
// 胜利者private static String winner; @Override public void run() { for (int i = 0; i < 101; i++) { //模拟兔子休息 if (Thread.currentThread().getName().equals("兔子")&&i%10==0){ try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } }
// 判断比赛是否结束
boolean flag = gameOver(i);
// 当比赛结束,停止程序
if(flag){
break;
}
System.out.println(Thread.currentThread().getName()+"-->跑了"+i+"步");
}
}
//判断是否完成比赛
private boolean gameOver(int steps) {
if (winner != null) {//已经存在胜利者,比赛结束
return true;
}
{
if (steps >= 100) {
winner=Thread.currentThread().getName();
System.out.println("Winner is " + winner);
return true;
}
}
return false;
}
public static void main(String[] args) {
Race race = new Race();
new Thread(race, "兔子").start();
new Thread(race, "乌龟").start();
}
}
## 5、静态代理
**实现静态代理对比Thread**
静态代理模式总结
* 真实对象和代理对象都要实现同一个类
* 真是对象和代理对象都要实现同一个接口
* 好处就是:代理对象可以做很多真实对象做不了的事情
* 真实对象专注做自己的事情
```java
public class StaticProxy {
public static void main(String[] args) {
// 真实对象
You you = new You();
new Thread(()-> System.out.println("我爱你")).start();
WeddingCompany weddingCompany = new WeddingCompany(you);
weddingCompany.HappyMarry();
}
}
interface Marry{
//人生的四大喜事
void HappyMarry();
}
//真实角色,你去结婚
class You implements Marry{
@Override
public void HappyMarry() {
System.out.println("你要结婚了,超开心!!!");
}
}
//代理角色,帮助你结婚
class WeddingCompany implements Marry{
//代理角色,真实目标角色
private Marry target;
public WeddingCompany(Marry target) {
this.target=target;
}
@Override
public void HappyMarry() {
before();
this.target.HappyMarry(); //这就是真实对象
after();
}
private void before() {
System.out.println("结婚之前要做的事");
}
private void after() {
System.out.println("结婚之后要做到事");
}
}
6、Lambda表达式
作用:
可以让你的代码看起来很简洁
去掉了一堆没有意义的代码,只留下核心的逻辑
其实质属于函数式编程的概念
lambda表达式的推导
```java
package lesson.lessonThread;
/*- 推导lambda表达式
- */
public class TestLambda {
//3、静态内部类 static class Like2 implements ILike{ @Override public void lambda() { System.out.println("I like lambda02"); } } public static void main(String[] args) { ILike like = new Like(); like.lambda(); like = new Like2(); like.lambda(); //4、局部内部类 class Like3 implements ILike{ @Override public void lambda() { System.out.println("I like lambda03"); } } like = new Like3(); like.lambda(); //5、匿名内部类,没有类的名称,必须借助接口或者父类 like = new ILike() { @Override public void lambda() { System.out.println("I like lambda04"); } }; like.lambda(); //6、用lambda简化,类跟方法都可以不要,直接上方法体 like = ()->{ System.out.println("I like lambda05"); }; like.lambda(); }
}
//1、定义一个函数式接口
interface ILike{
void lambda();
}
//2、实现类
class Like implements ILike{
@Override
public void lambda() {
System.out.println("I like lambda");
}
}
**lambda表达式的简化**
```java
ILike like = null;
//1、可以直接简化参数类型
like = (a)->{
System.out.println("I like lambda");
};
//2、简化括号
like = a->{
System.out.println("I like lambda");
};
//3、简化花括号
like = a->System.out.println("I like lambda");
简化总结:
- lambda表达式只能有一行代码的情况下才能简化成一行,如果有多行就要用代码块包裹
- 前提是接口为函数式接口(接口中只有一个方法)
- 多个参数也可以去掉参数类型,但是要去掉都得去掉,必须加上括号
- 代码里有多行时,第三种简化
三、线程状态
Java官方API将线程的整个生命周期分为六个状态,分别是NEW(新建状态)、RUNNABLE(可运行状态)、BLOCKED(阻塞状态)、WAITING(等待状态)、TIMED_WAITING(定时等待状态)和TERMINATED(终止状态)。线程的不同状态表明了线程当前正在进行的活动,在程序中,通过一些操作,可以使线程在不同状态之间转换,如图1所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CwiLh3vu-1624255925446)(https://book.itheima.net/uploads/course/images/java/1.10/image-20200615183133721.png)]
图1 线程状态转换图
图1中展示了线程各种状态的转换关系,箭头方向表示可转换的方向。接下来,针对线程生命周期中的六种状态分别进行详细讲解,具体如下:
1.NEW(新建状态)
创建一个线程对象后,该线程对象就处于新建状态,此时它不能运行,和其他Java对象一样,仅仅由JVM为其分配了内存,没有表现出任何线程的动态特征。
2.RUNNABLE(可运行状态)
当新建状态下的线程对象调用了start()方法,此时就会从新建状态进入可运行状态。在RUNNABLE状态内部又可细分成两种状态:READY(就绪状态)和RUNNING(运行状态),并且线程可以在这两个状态之间相互转换。
● 就绪状态:线程对象调用start()方法之后,等待JVM的调度,此时线程并没有运行;
● 运行状态:线程对象获得JVM调度,如果存在多个CPU,那么允许多个线程并行运行。
3.BLOCKED(阻塞状态)
处于运行状态的线程可能会因为某些原因失去CPU的执行权,暂时停止运行进入阻塞状态。此时,JVM不会给线程分配CPU,直到线程重新进入就绪状态,才有机会转换到运行状态。阻塞状态的线程只能先进入就绪状态,不能直接进入运行状态。
线程一般会在以下两种情况下进入阻塞状态:
● 当线程A运行过程中,试图获取同步锁时,却被线程B获取,此时JVM把当前线程A存到对象的锁池中,线程A就会进入阻塞状态;
● 当线程运行过程中,发出IO请求时,此时该线程也会进入阻塞状态。
4.WAITING(等待状态)
当处于运行状态的线程调用了无时间参数限制的方法后,如wait()、join()等方法,就会将当前运行中的线程转换为等待状态。
处于等待状态中的线程不能立即争夺CPU使用权,必须等待其他线程执行特定的操作后,才有机会再次争夺CPU使用权,将等待状态的线程转换为运行状态。例如,调用wait()方法而处于等待状态中的线程,必须等待其他线程调用notify()或者notifyAll()方法唤醒当前等待中的线程;调用join()方法而处于等待状态中的线程,必须等待其他加入的线程终止。
5.TIMED_WAITING(定时等待状态)
将运行状态中的线程转换为定时等待状态中的线程与转换为等待状态中的线程操作类似,只是运行线程调用了有时间参数限制的方法,如sleep(long millis)、wait(long timeout)、join(long millis)等方法。
处于定时等待状态中的线程也不能立即争夺CPU使用权,必须等待其他相关线程执行完特定的操作或者限时时间结束后,才有机会再次争夺CPU使用权,将定时等待状态的线程转换为运行状态。例如,调用了wait(long timeout) 方法而处于等待状态中的线程,需要通过其他线程调用notify()或者notifyAll()方法唤醒当前等待中的线程,或者等待限时时间结束后也可以进行状态转换。
6.TERMINATED(终止状态)
线程的run()方法、call()方法正常执行完毕或者线程抛出一个未捕获的异常(Exception)、错误(Error),线程就进入终止状态。一旦进入终止状态,线程将不再拥有运行的资格,也不能再转换到其他状态,生命周期结束。
四、线程调度
1、线程的优先级
在应用程序中,如果要对线程进行调度,最直接的方式就是设置线程的优先级。优先级越高的线程获得CPU执行的机会越大,而优先级越低的线程获得CPU执行的机会越小。线程的优先级用1~10之间的整数来表示,数字越大优先级越高。除了可以直接使用数字表示线程的优先级,还可以使用Thread类中提供的三个静态常量表示线程的优先级,如表1所示。
表1 Thread类的优先级常量
Thread**类的静态常量** | 功能描述 |
---|---|
static int MAX_PRIORITY | 表示线程的最高优先级,相当于值10 |
static int MIN_PRIORITY | 表示线程的最低优先级,相当于值1 |
static int NORM_PRIORIY | 表示线程的普通优先级,相当于值5 |
程序在运行期间,处于就绪状态的每个线程都有自己的优先级,例如main线程具有普通优先级。然而线程优先级不是固定不变的,可以通过Thread类的setPriority(int newPriority)方法对其进行设置,该方法中的参数newPriority接收的是1~10之间的整数或者Thread类的三个静态常量。接下来通过一个案例来演示不同优先级的两个线程在程序中的运行情况,如文件1所示。
文件1 Example07.java
1 public class Example07 {
2 public static void main(String[] args) {
3 // 分别创建两个Thread线程对象
4 Thread thread1 = new Thread(() -> {
5 for (int i = 0; i < 10; i++) {
6 System.out.println(Thread.currentThread().getName()
7 + "正在输出i:" + i);
8 }
9 },"优先级较低的线程");
10
11 Thread thread2 = new Thread(() -> {
12 for (int j = 0; j < 10; j++) {
13 System.out.println(Thread.currentThread().getName()
14 + "正在输出j:" + j);
15 }
16 },"优先级较高的线程");
17 // 分别设置线程的优先级
18 thread1.setPriority(Thread.MIN_PRIORITY);
19 thread2.setPriority(10);
20 // 开启两个线程
21 thread1.start();
22 thread2.start();
23 }
24 }
运行结果如图1所示。
图1 运行结果
文件1中,创建了两个线程thread1和thread2,分别将线程的优先级设置为1和10,从图1可以看出,优先级较高的thread2线程会获得更多的机会优先执行。
需要注意的是,虽然Java中提供了10个线程优先级,但是这些优先级需要操作系统的支持,不同的操作系统对优先级的支持是不一样的,不能很好的和Java中线程优先级一一对应,因此,在设计多线程应用程序时,其功能的实现一定不能依赖于线程的优先级,而只能把线程优先级作为一种提高程序效率的手段。
2、线程休眠
在前面过线程的优先级,优先级高的线程有更大的概率优先执行,而优先级低的线程可能会后执行。如果想要人为地控制线程执行顺序,使正在执行的线程暂停,将CPU使用权让给其他线程,这时可以使用静态方法sleep(long millis),该方法可以让当前正在执行的线程暂停一段时间,进入休眠等待状态,这样其他的线程就可以得到执行的机会。sleep(long millis)方法会声明抛出InterruptedException异常,因此在调用该方法时应该捕获异常,或者声明抛出该异常。
接下来通过案例演示一下sleep()方法在程序中的使用,如文件1所示。
文件1 Example08.java
1 public class Example08 {
2 public static void main(String[] args) {
3 // 分别定义两个Thread线程对象
4 Thread thread1 = new Thread(() -> {
5 for (int i = 0; i < 10; i++) {
6 System.out.println(Thread.currentThread().getName()
7 + "正在输出i:" + i);
8 if(i ==2){
9 try {
10 // 在该线程执行过程中进入睡眠状态,让其他线程先执行
11 Thread.sleep(500);
12 } catch (InterruptedException e) {
13 e.printStackTrace();
14 }
15 }
16 }
17 });
18 Thread thread2 = new Thread(() -> {
19 for (int j = 0; j < 10; j++) {
20 System.out.println(Thread.currentThread().getName()
21 + "正在输出j:" + j);
22 }
23 });
24 // 开启两个线程
25 thread1.start();
26 thread2.start();
27 }
28 }
运行结果如图1所示。
图1 运行结果
文件1中开启了两个线程,同时在thread1线程执行过程中调用了Thread的sleep(500)方法,目的是让一个线程在执行的某一时刻休眠500毫秒,从而使另一个线程获得执行的机会。正常情况下,这两个线程会争相获取CUP执行权并交互打印输出信息。
从图1可以看出,当thread1执行到i==2时,就会进入休眠状态,此时可以看到thread2线程一直会获得CUP使用权,直到thread1线程休眠时间消耗完成才有机会获得CUP使用权。
需要注意的是,线程类Thread提供了两个线程休眠方法:sleep(long millis)和sleep(long millis, int nanos),这两个方法都带有休眠时间参数,当其他线程都终止后并不代表当前休眠的线程会立即执行,而是必须当休眠时间结束后,线程才会转换到就绪状态。
3、线程让步
在校园中,经常会看到很多同学一起打篮球,当某个同学抢到篮球后就可以拍一会之后传递给其他人,大家重新开始抢篮球,这个过程就相当于程序中的线程让步。线程让步可以通过yield()方法来实现,该方法和sleep(long millis)方法有点类似,都可以让当前正在运行的线程暂停,区别在于yield()方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次。当某个线程调用yield()方法之后,与当前线程优先级相同或者更高的线程可以获得执行的机会。接下来通过一个案例来演示一下yield()方法的使用,如文件1所示。
文件1 Example09.java
1 // 定义YieldThread类继承Thread类
2 class YieldThread extends Thread {
3 // 定义一个有参的构造方法
4 public YieldThread(String name) {
5 super(name); // 调用父类的构造方法
6 }
7 public void run() {
8 for (int i = 0; i < 5; i++) {
9 System.out.println(Thread.currentThread().getName()+"---"+i);
10 if (i == 2) {
11 System.out.print("线程让步:");
12 Thread.yield(); // 线程运行到此,作出让步
13 }
14 }
15 }
16 }
17 public class Example09 {
18 public static void main(String[] args) {
19 // 创建两个线程
20 Thread thread1 = new YieldThread("thread1");
21 Thread thread2 = new YieldThread("thread2");
22 // 开启两个线程
23 thread1.start();
24 thread2.start();
25 }
26 }
运行结果如图1所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7PECumlS-1624255925452)(https://book.itheima.net/uploads/course/images/java/1.10/image-20200615183813176.png)]
图1 运行结果
文件1中创建了两个线程thread1和thread2,它们的优先级相同。两个线程在循环变量i等于2时,都会调用Thread的yield()方法,使当前线程暂停,让两个线程再次争夺CUP使用权,从运行结果可以看出,当线程thread1输出2以后,会做出让步,线程thread2获得执行权,同样,线程thread2输出2后,也会做出让步,线程thread1获得执行权。
小提示:
通过yield()方法可以实现线程让步,让当前正在运行的线程失去CPU使用权,让系统的调度器重新调度一次,由于Java虚拟机默认采用抢占式调度模型,所有线程都会再次抢占CUP资源使用权,所以在执行线程让步后并不能保证立即执行其他线程。
4、线程插队
现实生活中经常能碰到“插队”的情况,同样,在Thread类中也提供了一个join()方法来实现这个“功能”。当在某个线程中调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()方法加入的线程执行完成后它才会继续运行。接下来通过一个案例来演示一下join()方法的使用,如文件1所示。
文件1 Example10.java
1 class EmergencyThread implements Runnable {
2 public void run() {
3 for (int i = 1; i < 6; i++) {
4 System.out.println(Thread.currentThread().getName()
5 +"输入:"+i);
6 }
7 }
8 }
9 public class Example10 {
10 public static void main(String[] args) throws InterruptedException {
11 // 创建线程
12 Thread thread1 = new Thread(new EmergencyThread(),"thread1");
13 thread1.start(); // 开启线程
14 for (int i = 1; i < 6; i++) {
15 System.out.println(Thread.currentThread().getName()
16 +"输入:"+i);
17 if (i == 2) {
18 thread1.join(); // 调用join()方法
19 }
20 }
21 }
22 }
运行结果如图1所示。
图1 运行结果
文件1中,在main线程中开启了一个线程thread1,这两个线程会相互争夺CUP使用权输出语句。当main线程中的循环变量为2时,调用thread1线程的join()方法,这时,thread1线程就会“插队”优先执行,并且整个程序执行完毕后才会执行其他线程。从运行结果可以看出,当main线程输出2以后,thread1线程就开始执行,直到执行完毕,main线程才继续执行。
Thread类中除了提供一个无参数的线程插队join()方法外,还提供了带有时间参数的线程插队方法join(long millis)。当执行带有时间参数的join(long millis)进行线程插队时,必须等待插入的线程指定时间过后才会继续执行其他线程。
五、线程同步
1、线程安全
上一节的售票案例,极有可能碰到“意外”情况,如一张票被打印多次,或者打印出的票号为0甚至负数。这些“意外”都是由多线程操作共享资源tickets所导致的线程安全问题,接下来对案例进行修改,模拟四个窗口出售10张票,并在售票的代码中每次售票时线程休眠100毫秒,如文件1所示。
文件1 Example11.java
1 // 定义SaleThread类实现Runnable接口
2 class SaleThread implements Runnable {
3 private int tickets = 10; // 10张票
4 public void run() {
5 while (true) {
6 if (tickets > 0) {
7 try {
8 Thread.sleep(100); // 模拟售票耗时过程
9 } catch (InterruptedException e) {
10 e.printStackTrace();
11 }
12 System.out.println(Thread.currentThread().getName()
13 + " 正在发售第 " + tickets-- + " 张票 ");
14 }
15 }
16 }
17 }
18 public class Example11 {
19 public static void main(String[] args) {
20 SaleThread saleThread = new SaleThread();
21 // 创建并开启四个线程,模拟4个售票窗口
22 new Thread(saleThread, "窗口1").start();
23 new Thread(saleThread, "窗口2").start();
24 new Thread(saleThread, "窗口3").start();
25 new Thread(saleThread, "窗口4").start();
26 }
27 }
运行结果如图1所示。
图1 运行结果
图1中,最后几行打印售出的票为0和负数,这种现象是不应该出现的,因为在售票程序中做了判断只有当票号大于0时才会进行售票。运行结果中之所以出现了负数的票号是因为多线程在售票时出现了安全问题。
在售票程序的while循环中添加了sleep()方法,这样就模拟了售票过程中线程的延迟。由于线程有延迟,当票号减为1时,假设窗口2线程此时出售1号票,对票号进行判断后,进入while循环,在售票之前通过sleep()方法模拟售票时耗时操作,这时窗口1线程会进行售票,由于此时票号仍为1,因此窗口1线程也会进入循环,同理,四个线程都会进入while循环,休眠结束后,四个线程都会进行售票,这样就相当于将票号减了四次,结果中出现了1、0、-1、-2这样的票号。
2、同步代码块
通过前面小节的学习,了解到线程安全问题其实就是由多个线程同时处理共享资源所导致的。要想解决线程安全问题,必须得保证处理共享资源的代码在任意时刻只能有一个线程访问。为此,Java中提供了线程同步机制。当多个线程使用同一个共享资源时,可以将处理共享资源的代码放置在一个使用synchronized关键字来修饰的代码块中,这段代码块被称作同步代码块,其语法格式如下:
synchronized(lock){
// 操作共享资源代码块
...
}
上述代码中,lock是一个锁对象,它是同步代码块的关键。当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1,此时线程会执行同步代码块,同时将锁对象的标志位置为0。当一个新的线程执行到这段同步代码块时,由于锁对象的标志位为0,新线程会发生阻塞,等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进入同步代码块执行其中的代码,这样循环往复,直到共享资源被处理完为止。这个过程就好比一个公用电话亭,只有前一个人打完电话出来后,后面的人才可以打。
接下来将售票的代码放到synchronized区域中进行修改,如文件1所示。
文件1 Example12.java
1 // 定义SaleThread2类实现Runnable接口
2 class SaleThread2 implements Runnable {
3 private int tickets = 10; // 10张票
4 Object lock = new Object(); // 定义任意一个对象,用作同步代码块的锁
5 public void run() {
6 while (true) {
7 synchronized (lock) {
// 定义同步代码块
8 if (tickets > 0) {
9 try {
10 Thread.sleep(100); // 模拟售票耗时过程
11 } catch (InterruptedException e) {
12 e.printStackTrace();
13 }
14 System.out.println(Thread.currentThread().getName()
15 + " 正在发售第 " + tickets-- + " 张票 ");
16 }
17 }
18 }
19 }
20 }
21 public class Example12 {
22 public static void main(String[] args) {
23 SaleThread2 saleThread = new SaleThread2();
24 // 创建并开启四个线程,模拟4个售票窗口
25 new Thread(saleThread, "窗口1").start();
26 new Thread(saleThread, "窗口2").start();
27 new Thread(saleThread, "窗口3").start();
28 new Thread(saleThread, "窗口4").start();
29 }
30 }
运行结果如图1所示。
图1 运行结果
文件1中,将有关tickets变量的操作全部都放到同步代码块中synchronized (lock) {},从图10-16可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。
注意
同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是相同的。“任意”说的是共享锁对象的类型。所以,锁对象的创建代码不能放到run()方法中,否则每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位,线程之间便不能产生同步的效果。
3、同步方法
通过前面小节的学习,了解到同步代码块可以有效解决线程的安全问题,当把共享资源的操作放在synchronized定义的区域内时,便为这些操作加了同步锁。同样,在方法前面也可以使用synchronized关键字来修饰,被修饰的方法为同步方法,它能实现和同步代码块同样的功能,具体语法格式如下:
[修饰符] synchronized 返回值类型 方法名([参数1,……]){
}
被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行。
接下来使用同步方法模拟售票系统,如文件1所示。
文件1 Example13.java
1 // 定义SaleThread3类实现Runnable接口
2 class SaleThread3 implements Runnable {
3 private int tickets = 10;
4 public void run() {
5 while (true) {
6 saleTicket(); // 调用售票方法
7 }
8 }
9 // 定义一个同步方法saleTicket()
10 private synchronized void saleTicket() {
11 if (tickets > 0) {
12 try {
13 Thread.sleep(100); // 模拟售票耗时过程
14 } catch (InterruptedException e) {
15 e.printStackTrace();
16 }
17 System.out.println(Thread.currentThread().getName()
18 + " 正在发售第 " + tickets-- + " 张票 ");
19 }
20 }
21 }
22 public class Example13 {
23 public static void main(String[] args) {
24 SaleThread3 saleThread = new SaleThread3();
25 // 创建并开启四个线程,模拟4个售票窗口
26 new Thread(saleThread, "窗口1").start();
27 new Thread(saleThread, "窗口2").start();
28 new Thread(saleThread, "窗口3").start();
29 new Thread(saleThread, "窗口4").start();
30 }
31 }
运行结果如图1所示。
图1 运行结果
文件1中,将售票代码抽取为售票方法saleTicket(),并用synchronized关键字把saleTicket()修饰为同步方法,然后在run()方法中调用该方法。从图1可以看出,同样没有出现0号和负数号的票,说明同步方法实现了和同步代码块一样的效果。
思考:
大家可能会有这样的疑问:同步代码块的锁是自己定义的任意类型的对象,那么同步方法是否也存在锁?如果有,它的锁是什么呢?答案是肯定的,同步方法也有锁,它的锁就是当前调用该方法的对象,也就是this指向的对象。这样做的好处是,同步方法被所有线程所共享,方法所在的对象相对于所有线程来说是唯一的,从而保证了锁的唯一性。当一个线程执行该方法时,其他的线程就不能进入该方法中,直到这个线程执行完该方法为止,从而达到了线程同步的效果。
有时候需要同步的方法是静态方法,静态方法不需要创建对象就可以直接用“类名.方法名()”的方式调用。这时候我们就会有一个疑问,如果不创建对象,静态同步方法的锁就不会是this,那么静态同步方法的锁是什么?Java中静态方法的锁是该方法所在类的class对象,该对象可以直接类名.class的方式获取。
同步代码块和同步方法解决多线程问题有好处也有弊端。同步解决了多个线程同时访问共享数据时的线程安全问题,只要加上同一个锁,在同一时间内只能有一条线程执行,但是线程在执行同步代码时每次都会判断锁的状态,非常消耗资源,效率较低。
4、同步锁
synchronized同步代码块和同步方法使用一种封闭式的锁机制,使用起来非常简单,也能够解决线程同步过程中出现的线程安全问题,但也有一些限制,例如它无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。
从JDK 5开始,Java增加了一个功能更强大的Lock锁。Lock锁与synchronized隐式锁在功能上基本相同,其最大的优势在于Lock锁可以让某个线程在持续获取同步锁失败后返回,不再继续等待,另外Lock锁在使用时也更加灵活。
接下来将售票案例改为使用Lock锁进行演示,如文件1所示。
文件1 Example14.java
1 import java.util.concurrent.locks.*;
2 // 定义LockThread类实现Runnable接口
3 class LockThread implements Runnable {
4 private int tickets = 10; // 10张票
5 // 定义一个Lock锁对象
6 private final Lock lock = new ReentrantLock();
7 public void run() {
8 while (true) {
9 lock.lock(); // 对代码块进行加锁
10 if (tickets > 0) {
11 try {
12 Thread.sleep(100); // 模拟售票耗时过程
13 System.out.println(Thread.currentThread().getName()
14 + " 正在发售第 " + tickets-- + " 张票 ");
15 } catch (InterruptedException e) {
16 e.printStackTrace();
17 }finally{
18 lock.unlock(); // 执行完代码块后释放锁
19 }
20 }
21 }
22 }
23 }
24 public class Example14 {
25 public static void main(String[] args) {
26 LockThread lockThread = new LockThread();
27 // 创建并开启四个线程,模拟4个售票窗口
28 new Thread(lockThread, "窗口1").start();
29 new Thread(lockThread, "窗口2").start();
30 new Thread(lockThread, "窗口3").start();
31 new Thread(lockThread, "窗口4").start();
32 }
33 }
运行结果如图1所示。
图1 运行结果
文件1中,通过Lock接口的实现类ReentrantLock来创建一个Lock锁对象,并通过Lock锁对象的lock()方法和unlock()方法对核心代码块进行了上锁和解锁。从图1可以看出,使用Lock同步锁也可以实现正常售票,解决线程同步过程中的安全问题。
需要注意的是,ReentrantLock类是Lock锁接口的实现类,也是常用的同步锁,在该同步锁中除了lock()方法和unlock()方法外,还提供了一些其他同步锁操作的方法,例如tryLock()方法可以判断某个线程锁是否可用。另外,在使用Lock同步锁时,可以根据需要在不同代码位置灵活的上锁和解锁,为了保证所有情况下都能正常解锁以确保其他线程可以执行,通常情况下会在finally{}代码块中调用unlock()方法来解锁。
5、死锁问题
有这样一个场景:一个中国人和一个美国人在一起吃饭,美国人拿了中国人的筷子,中国人拿了美国人的刀叉,两个人开始争执不休:
中国人:“你先给我筷子,我再给你刀叉!”
美国人:“你先给我刀叉,我再给你筷子!”
…………
上面场景的结果可想而知,两个人都吃不到饭。这个例子中的中国人和美国人相当于不同的线程,筷子和刀叉就相当于锁。两个线程在运行时都在等待对方的锁,这样便造成了程序的停滞,这种现象称为死锁。接下来通过中国人和美国人吃饭的案例来模拟死锁问题,如文件1所示。
文件1 Example15.java
1 class DeadLockThread implements Runnable {
2 // 定义两个不同的锁对象
3 static Object chopsticks = new Object();
4 static Object knifeAndFork = new Object();
5 private boolean flag;
6 DeadLockThread(boolean flag) {
7 this.flag = flag;
8 }
9 public void run() {
10 if (flag) {
11 while (true) {
12 // chopsticks锁对象上的同步代码块
13 synchronized (chopsticks) {
14 System.out.println(Thread.currentThread().getName()
15 + "---if---chopsticks");
16 // knifeAndFork锁对象上的同步代码块
17 synchronized (knifeAndFork) {
18 System.out.println(Thread.currentThread().getName()
19 + "---if---knifeAndFork");
20 }
21 }
22 }
23 } else {
24 while (true) {
25 // knifeAndFork锁对象上的同步代码块
26 synchronized (knifeAndFork) {
27 System.out.println(Thread.currentThread().getName()
28 + "---else---knifeAndFork");
29 // chopsticks锁对象上的同步代码块
30 synchronized (chopsticks) {
31 System.out.println(Thread.currentThread().getName()
32 + "---else---chopsticks");
33 }
34 }
35 }
36 }
37 }
38 }
39 public class Example15 {
40 public static void main(String[] args) {
41 // 创建两个DeadLockThread对象
42 DeadLockThread thread1 = new DeadLockThread(true);
43 DeadLockThread thread2 = new DeadLockThread(false);
44 // 创建并开启两个线程
45 new Thread(thread1, "Chinese").start();
46 new Thread(thread2, "American").start();
47 }
48 }
运行结果如图1所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cul0NffF-1624255925457)(https://book.itheima.net/uploads/course/images/java/1.10/image-20200615184832303.png)]
图1 运行结果
文件1中,创建了两个名为Chinese和American的线程,分别执行run()方法中if和else代码块中的同步代码块。Chinese线程中拥有chopsticks锁,只有获得knifeAndFork锁才能执行完毕,而American线程拥有knifeAndFork锁,只有获得chopsticks锁才能执行完毕,两个线程都需要对方所占用的锁,但是都无法释放自己所拥有的锁,于是这两个线程都处于了挂起状态,从而造成了如图1所示的死锁。
六、线程通信问题
1、问题引出
为了更好地理解线程间的通信,可以模拟现实生活中常见的生产者消费者场景,假设有两个线程生产者线程和消费者线程,同时去操作同一种商品,其中生产者线程负责生产商品,消费者线程负责消费商品。接下来通过一个案例来实现上述情况,具体实现如文件1所示。
文件1 Example16.java
1 import java.util.*;
2 public class Example16 {
3 public static void main(String[] args) {
4 // 定义一个集合类,模拟存储生产的商品
5 List<Object> goods = new ArrayList<>();
6 // 记录线程执行前统一的起始时间start
7 long start = System.currentTimeMillis();
8 // 创建一个生产者线程,用于生产商品并存入商品集合
9 Thread thread1 = new Thread(() -> {
10 int num = 0;
11 while (System.currentTimeMillis()-start<=100) {
12 goods.add("商品" + ++num);
13 System.out.println("生产商品" + num);
14 }
15 }, "生产者");
16 // 创建一个消费线程,用于消费商品并将商品从集合删除
17 Thread thread2 = new Thread(() -> {
18 int num = 0;
19 while (System.currentTimeMillis()-start<=100) {
20 goods.remove("商品" + ++num);
21 System.out.println("消费商品" + num);
22 }
23 }, "消费者");
24 // 同时启动生产者和消费者两个线程,并统一执行100毫秒的时间
25 thread1.start();
26 thread2.start();
27 }
28 }
运行结果如图1所示。
图1 运行结果
在文件1中,简单模拟了生产者线程和消费者线程,生产者线程thread1用来生产商品并存入商品集合goods中,而消费者线程thread2用来消费商品并删除集合中的该商品,同时为了保证执行数据容易查看,控制了生产者线程和消费者线程任务的共同执行时间为100毫秒,通过在该任务执行时间内来演示多线程执行过程中出现的问题。
从图1可以看到,在两个线程任务执行起初阶段还比较正常,生产者线程一边生成商品,消费者线程一边消费商品,但是执行到后面生产者线程和消费者线程的供需节奏不一致,消费者线程一直在消耗产品而生产者线程不再生成产品,出现这种情况显然是不正确的。
2、问题如何解决
如果想解决上述问题,就需要控制多个线程按照一定的顺序轮流执行,此时就需要让线程间进行通信,保证线程任务的协调进行。为此,Java在Object类中提供了wait()、notify()、notifyAll()等方法用于解决线程间的通信问题,因为Java中所有类都是Object类的子类或间接子类,因此任何类的实例对象都可以直接使用这些方法。接下来针对这几个方法进行简要说明,如表1所示。
表1 线程通信的常用方法
方法声明 | 功能描述 |
---|---|
void wait() | 使当前线程放弃同步锁并进入等待,直到其他线程进入此同步锁,并调用notify()或notifyAll()方法唤醒该线程为止 |
void notify() | 唤醒此同步锁上等待的第一个调用wait()方法的线程 |
void notifyAll() | 唤醒此同步锁上调用wait()方法的所有线程 |
表1中,列出了3个与线程通信相关的方法,其中wait()方法用于使当前线程进入等待状态,notify()和notifyAll()方法用于唤醒当前处于等待状态的线程。需要注意的是,wait()、notify()和notifyAll()这三个方法的调用者都应该是同步锁对象,如果这三个方法的调用者不是同步锁对象,Java虚拟机就会抛出IllegalMonitorStateException异常。
接下来通过使用wait()和notify()方法,实现线程间的通信,如文件1所示。
文件1 Example17.java
1 import java.util.*;
2 public class Example17 {
3 public static void main(String[] args) {
4 // 定义一个集合类,模拟存储生产的商品
5 List<Object> goods = new ArrayList<>();
6 // 记录线程执行前统一的起始时间start
7 long start = System.currentTimeMillis();
8 // 创建一个生产者线程,用于生产商品并存入商品集合
9 Thread thread1 = new Thread(() -> {
10 int num = 0;
11 while (System.currentTimeMillis()-start<=100) {
12 // 使用synchronized关键字同步商品生产和消费
13 synchronized (goods) {
14 // 有商品就让生产者进入等待状态
15 if(goods.size() >0){
16 try {
17 goods.wait();
18 } catch (InterruptedException e) {
19 e.printStackTrace();
20 }
21 }else{
22 // 生产者继续生产商品
23 goods.add("商品" + ++num);
24 System.out.println("生产商品" + num);
25 }
26 }
27 }
28 }, "生产者");
29 // 创建一个消费线程,用于消费商品并将商品从集合删除
30 Thread thread2 = new Thread(() -> {
31 int num = 0;
32 while (System.currentTimeMillis()-start<=100) {
33 // 使用synchronized关键字同步商品消费和消费
34 synchronized (goods) {
35 // 商品不足就唤醒生产者进行生产
36 if(goods.size()<= 0){
37 goods.notify();
38 }else{
39 // 继续消费商品
40 goods.remove("商品" + ++num);
41 System.out.println("消费商品" + num);
42 }
43 }
44 }
45 }, "消费者");
46 // 同时启动生产者和消费者两个线程,并统一执行100毫秒的时间
47 thread1.start();
48 thread2.start();
49 }
50 }
文件1中,在生产者和消费者线程的两个执行任务中同时使用synchronized关键字同步商品生产和消费,之后每生产出商品,便调用wait()方法将当前线程置于等待状态,等待消费者线程进行消费,当消费者线程执行任务发现没有商品时便调用notify()方法唤醒对应同步锁上等待的生成者线程,让生产者线程继续生产,从而持续达到供需有序、均衡。从图1可以看出,生产者线程和消费者线程按照先生产后消费的顺序轮流执行,不再出现供需节奏不一致的问题。
32 while (System.currentTimeMillis()-start<=100) {
33 // 使用synchronized关键字同步商品消费和消费
34 synchronized (goods) {
35 // 商品不足就唤醒生产者进行生产
36 if(goods.size()<= 0){
37 goods.notify();
38 }else{
39 // 继续消费商品
40 goods.remove("商品" + ++num);
41 System.out.println("消费商品" + num);
42 }
43 }
44 }
45 }, "消费者");
46 // 同时启动生产者和消费者两个线程,并统一执行100毫秒的时间
47 thread1.start();
48 thread2.start();
49 }
50 }
文件1中,在生产者和消费者线程的两个执行任务中同时使用synchronized关键字同步商品生产和消费,之后每生产出商品,便调用wait()方法将当前线程置于等待状态,等待消费者线程进行消费,当消费者线程执行任务发现没有商品时便调用notify()方法唤醒对应同步锁上等待的生成者线程,让生产者线程继续生产,从而持续达到供需有序、均衡。从图1可以看出,生产者线程和消费者线程按照先生产后消费的顺序轮流执行,不再出现供需节奏不一致的问题。
需要说明的是,Java为线程等待方法wait()提供了多个重载方法,包括无参wait()方法、有等待时间的wait(long timeout)方法和wait(long timeout, int nanos)方法。其中,带有等待时间参数的wait()方法,除了会在其他线程对象调用notify()和notifyAll()方法来唤醒当前处于等待状态的线程,还会在等待时间过后自动唤醒处于等待状态的线程。