1 多线程基础
1.1 进程和线程
1.1.1 什么是进程
进程(Process)是由操作系统执行的计算机程序的实例,是操作系统分配资源的基本单位。操作系统上运行的每一个应用程序都运行在一个进程中。比如计算机上的微信程序,也是运行在进程中。
1.1.2 什么是线程
线程(Thread)是进程内部按单一顺序执行的控制流,可以简化的理解为进程中的一项子任务。线程是CPU调度的基本单位。
例如,我们在使用音乐播放器时,音乐播放器可以一边播放音乐,一边显示歌词,同时还可以显示推送的信息,这些都可以看成是音乐播放器的子任务,每一项子任务可以看成是一个线程。
进程和线程的关系是:
1、一个进程可以包含一个或多个线程,但至少会有一个线程。
- 进程拥有的私有虚拟地址空间,仅能被它的线程访问。
2、一个线程一定属于某个进程。
- 线程只能访问该进程所拥有的资源。
1.1.3 单线程VS多线程
本小节以做家务为例介绍单线程和多线程的区别。在这个例子中,每个小组被分配到3项家务,分别是洗衣服、做饭、扫地。这里的小组表示进程,小组中的成员表示线程。
如果一个进程中仅存在一个线程,则该进程一般被称为单线程应用。这意味着,小组中仅包含1名成员,所有的家务都需要由这名成员完成。
在单线程的程序中,所有的任务按照顺序一个接一个地执行,如果某个任务阻塞(例如进行I/O操作、等待用户输入等),那么整个程序会被阻塞,直到该任务完成。这可能会导致程序的执行效率较低,特别是在处理耗时任务时,用户可能会感到程序响应缓慢。
如果一个进程中不止存在一个线程,则该进程称为多线程应用。这意味着,小组中将包含多名成员,所有的家务任务由这些成员共同完成。
多线程允许程序同时执行多个任务,每个任务由一个独立的线程负责执行。每个线程拥有自己的程序计数器、栈、寄存器和状态等,它们相互独立,互不干扰。这样,即使其中一个线程阻塞,其他线程仍然可以继续执行,从而提高程序的执行效率和响应性。
1.2 Java中的多线程
1.2.1 Java多线程概述
每一个Java程序实际上是一个JVM进程,JVM进程用一个线程来执行main方法,这个线程被称为主线程。
Java语言内置了对多线程的支持,开发者可以通过编码的方式,在主线程中启动多个线程,一般将主线程中启动的线程称为子线程。此外,JVM还会启动负责垃圾回收等其他工作的子线程。
多线程在Java程序中有着非常广泛的应用,例如网络通信、数据库访问、Web服务器和Web应用程序等。掌握Java多线程编程是后续深入学习其他内容的前提。
1.2.2 Thread类
Thread是Java中代表线程的类,位于java.lang包下。Thread类中包含封装线程具体信息的属性(如线程的名称、id和优先级等),以及对线程进行操作的常用方法(如线程休眠和唤醒等)。
在Java中,每个Thread类的对象代表一个具体的线程:
Thread 提供了获取线程信息的相关方法:
- long getId():返回该线程的标识符
- String getName():返回该线程的名称
- int getPriority():返回线程的优先级
- Thread.state getState():获取线程的状态
- boolean isAlive():测试线程是否处于活动状态
- boolean isDaemon():测试线程是否为守护线程
- boolean isInterrupted():测试线程是否已经中断
1.2.3 创建线程
Thread类常用的构造方法如下:
- public Thread():分配一个新的线程对象
- public Thread(String name):分配一个指定名称的新的线程对象
- public Thread(Runnable target):分配一个带指定目标的新的线程对象
Thread类其他常用的方法如下:
- public static Thread currentThread():返回对当前正在执行的线程对象的引用
- public String getName():获取当前线程名称
- public void start():此线程开始执行,Java虛拟机调用此线程的run()方法
1.2.4 【案例】使用Thread类创建线程
编写代码,使用Thread类创建并启动线程。代码示意如下:
public class ThreadDemo1 {public static void main(String[] args) {// 获取当前正在执行的线程对象 - 主线程Thread main = Thread.currentThread();// 输出线程名称System.out.println("name: " + main.getName());// 创建一个线程对象Thread t1 = new Thread("thread1");// 输出子线程名称System.out.println("name: " + t1.getName());// 启动子线程t1.start(); // 无运行效果}
}
1.2.5 指定线程的执行内容
在上一个案例中,我们创建并启动了子线程,但是没有看到任何子线程运行的效果,这是因为我们并没有为子线程提供要执行的内容。
Java通过Runnable接口的实现类来为线程提供执行的内容,该接口仅定义了一个抽象方法run():
开发者可以创建Runnable接口的实现类,并重写run方法,在run方法中定义线程要执行的代码。在创建线程对象时,传入Runnable接口实现类的对象,即可为该线程指定执行的代码。
Runnable接口的设计实现了线程运行状态管理和线程执行逻辑的分离,提供了更好的扩展性。
1.2.6 【案例】执行线程示例
编写代码,指定线程的执行内容并测试效果。代码示意如下:
public class ThreadDemo2 {public static void main(String[] args) {MyRun1 run1 = new MyRun1();Thread t1 = new Thread(run1, "thread1");Thread t2 = new Thread(run1, "thread2");// 启动子线程t1.start();t2.start();// 主线程输出信息for(int i = 0; i < 5; i++){// 线程输出信息System.out.println("main: " + i);}}
}
class MyRun1 implements Runnable{@Overridepublic void run() {// 获取当前线程对象Thread thread = Thread.currentThread();String threadName = thread.getName();for(int i = 0; i < 5; i++){// 线程输出信息System.out.println(threadName + ": " + i);}}
}
1.2.7 创建线程的另一种方法
细心的同学可能已经发现,Thread类也实现了Runnable接口,因此除了创建Runnable接口实现类的方法,开发者也可以通过继承Thread类并重写run方法的方式来创建线程。
优势:
- 编码更为简单,不需要单独声明Runnable接口的实现类
- 可以对Thread类进行扩展,开发更适用于特定场景的线程类
劣势:
- 占用了该类的继承窗口,无法继承其他类
- 不利于其他线程类重用run方法中的代码
- 大部分情况下,推荐使用创建Runnable接口实现类的方式来创建线程。
1.2.8 【案例】继承Thread类示例
编写代码,用继承Thread类的方式创建线程并测试效果。代码示意如下:
public class ThreadDemo3 {public static void main(String[] args) {MyThread t1 = new MyThread("thread1");MyThread t2 = new MyThread("thread2");t1.start();t2.start();}
}
class MyThread extends Thread {public MyThread(String name) {super(name);}@Overridepublic void run() {// 获取当前线程对象Thread thread = Thread.currentThread();String threadName = thread.getName();for(int i = 0; i < 5; i++){// 线程输出信息System.out.println(threadName + ": " + i);}}
}
2 线程的状态
2.1 线程状态概述
2.1.1 什么是线程的状态
线程是一个动态执行的过程,它也有一个从产生到结束的过程。线程从创建到执行完毕的整个过程称为线程的生命周期。
一个线程对象在其整个生命周期中可能处于5种状态:
1、新建状态(New):一个线程对象被创建出来时,该线程对象处于新建状态。
2、就绪状态(Runnable):当调用了一个线程对象的start()方法后,该线程对象处于就绪状态。
3、运行状态(Running):当CPU执行一个线程对象的run方法时,该线程对象处于运行状态。
4、阻塞状态(Blocked):当一个运行中的线程因为各种原因暂时停止运行时,该线程对象处于阻塞状态,阻塞状态可分为3种:等待阻塞、同步阻塞、其他阻塞。
5、终止状态(TERMINATED):当一个处于运行状态的线程完成任务或因其他原因终止时,该线程对象处于终止状态。
2.1.2 简单的线程运行状态
简单的线程运行状态是指在线程的整个运行中没有阻塞的情况发生,如下图所示:
线程调度程序会将CPU运行时间划分为若干个时间片段并分配给每个线程。拿到时间片的线程会被CPU执行,此时该线程处于运行状态。当时间片用完后,该线程重新回到就绪状态,一个线程在其生命周期中可能多次在就绪状态和运行状态之间切换。
操作系统中的时间片分配采用争抢机制,多次执行一个包含多线程的程序,其中各子线程的执行顺序可能完全不同。如下图所示:
2.1.3【案例】简单的线程运行状态示例
编写代码,测试线程的运行状态。代码示意如下:
public class ThreadStateDemo1 {public static void main(String[] args) {Thread thread1 = new Thread( () -> { // Lambda表达式定义线程执行体System.out.println("3: RUNNING");});System.out.println("1: "+thread1.getState());thread1.start();System.out.println("2: "+thread1.getState());for(int i = 0; i < 100000000; i++) {int j = i/10;}System.out.println("4: "+thread1.getState());}
}
2.1.4 线程的优先级
线程的优先级被线程调度器用来判定每个线程何时允许运行。线程的切换是由线程调度控制,无法通过代码来干涉,可以通过提高线程的优先级来最大程度的改善线程获取时间片的几率。
从理论上来说,优先级高的线程比优先级低的线程获得更多的CPU时间;但是实际上,线程获得的CPU时间通常由包括优先级在内的多个因素决定。这里,我们先讨论优先级的设置。
每个Java线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。优先级是一个整数,其取值范围是1(最低)~ 10(最高)。
线程提供了3个常量来表示最低,最高,以及默认优先级:
- Thread.MIN_PRIORITY,最低,值为1
- Thread.MAX_PRIORITY,最高,值为10
- Thread.NORM_PRIORITY,默认优先级,值为5
在默认情况下,每个线程都会分配一个优先级NORM_PRIORITY(5)。
Thread类提供了setPriority()方法,用于设置线程的优先级。具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,并且非常依赖底层平台。
2.1.5【案例】线程优先级示例
编写代码,测试线程的优先级设置及效果。代码示意如下:
public class PriorityDemo {public static void main(String[] args) {int num = 10000000;Thread min = new Thread(() -> {for (int i = 0; i <num; i++) {int j = i + 1;// 创造线程竞争机会Thread.yield();}System.out.println("Thread min stopped");});Thread max = new Thread(() -> {for (int i = 0; i <num; i++) {int j = i + 1;// 创造线程竞争机会Thread.yield();}System.out.println("Thread max stopped");});// 设置线程的优先级min.setPriority(Thread.MIN_PRIORITY);max.setPriority(Thread.MAX_PRIORITY);min.start();max.start();}
}
2.2 线程的状态操作
2.2.1 线程的状态操作概述
默认情况下,一个处于就绪状态的线程何时执行,由操作系统的调度器来决定。但是在很多实际的应用场景中,经常需要开发者人为的对线程的状态进行控制。例如,控制某个线程在其他线程执行完成后再执行,或者控制某个线程休眠一段时间等。
Java提供了多个可以操作线程状态的方法。例如:
- sleep():控制某个线程休眠一段时间
- join():控制某个线程等待另一个线程执行完成后再执行
- yield():控制一个线程从运行状态切换到就绪状态
- interrupt():向线程发送一个中断通知
2.2.2 sleep 方法
Thread 的静态方法 sleep 用于使当前线程进入阻塞状态,语法为:
static void sleep(long ms)
该方法会使当前线程进入阻塞状态指定毫秒,当阻塞指定毫秒后,当前线程会重新进入 Runnable 状态,等待分配时间片.
该方法声明会抛出一个InterruptException,因此在使用该方法时需要捕获这个异常。
2.2.3 【案例】sleep示例
编写代码,测试sleep方法,实现要求如下:
代码示意如下:
import java.time.LocalTime;
import java.util.concurrent.TimeUnit;
public class SleepDemo {public static void main(String[] args) {Thread t1 = new Thread( () -> {try{System.out.println("正在运行的线程名称:"+Thread.currentThread().getName()+" 开始时间 = " + LocalTime.now());// 延时2秒// Thread.sleep(2000);TimeUnit.SECONDS.sleep(2); // 1.5新语法 可读性更好System.out.println("正在运行的线程名称:"+Thread.currentThread().getName()+" 结束时间 = " + LocalTime.now());}catch(InterruptedException e){e.printStackTrace();}} );System.out.println("主线程开始时间 = " + LocalTime.now());t1.start();System.out.println("主线程结束时间 = " + LocalTime.now());}
}
2.2.4 join 方法
Thread 的 方法 join 用于等待当前线程结束,语法如下:
void join()
该方法声明抛出InterruptException。
2.2.5 【案例】join示例
import java.util.concurrent.TimeUnit;
public class JoinDemo {public static void main(String[] args) {Thread t1 = new Thread(new MyRun2(), "t1");Thread t2 = new Thread(new MyRun2(), "t2");Thread t3 = new Thread(new MyRun2(), "t3");t1.start();// 主线程等待t1线程死亡或等待2秒后向下执行启动t2线程try {// t1.join(2000);TimeUnit.SECONDS.timedJoin(t1, 2); // 1.5新语法} catch (InterruptedException e) {e.printStackTrace();}t2.start();// 主线程等待t1线程死亡后再向下执行启动t3线程的代码try {t1.join();} catch (InterruptedException e) {e.printStackTrace();}t3.start();// 主线程等待t1线程、t2线程和t3线程死亡后再向下执行try {t1.join();t2.join();t3.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("All threads are dead, exiting main thread");}
}
// 多个线程复用的场景,使用Runnable接口实现类
class MyRun2 implements Runnable{@Overridepublic void run() {System.out.println("Threadstarted:::"+Thread.currentThread().getName());try {TimeUnit.SECONDS.sleep(4);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Thread ended:::"+Thread.currentThread().getName());}
}
2.2.6 yield 方法
Thread 的静态方法 yield 语法如下:
static void yield()
该方法用于使当前线程主动让出当次CPU时间片回到Runnable状态,等待分配时间片。比如对正在运行中的线程1调用 yield 方法,则进入就绪状态:
2.2.7 【案例】yield示例
编写代码,测试yield方法,代码示意如下:
public class YieldDemo {public static void main(String[] args){Runnable run = () -> {String name = Thread.currentThread().getName();for (int i = 0;i<8;i++){System.out.println(name+":"+i);if(i == 5){// 主动释放CUP使用权Thread.yield();}}};Thread t1 = new Thread(run,"Thread1");t1.start();Thread t2 = new Thread(run,"Thread2");t2.start();}
}
2.2.8 interrupt方法
Thread 的静态方法 interrupt 用于设置线程的中断状态。该方法试图将目标线程的中断标志设置为true,最终结果取决于线程的当前状态。
对于当前处于运行状态的线程,调用interrupt方法后,线程的中断标识设置为true。如下图所示:
2.2.9 【案例】interrupt中断休眠示例
编写代码,测试使用interrupt方法中断休眠。代码示意如下:
import java.util.concurrent.TimeUnit;
public class InterruptDemo1 {public static void main(String args[]) {Thread thread = new Thread(() -> {System.out.println("线程启动了");try {TimeUnit.MINUTES.sleep(3);} catch (InterruptedException e) {System.out.println("抛出异常对象: "+e);}System.out.println("线程结束了");});thread.start(); // 启动子线程try {// 主线程休眠5秒TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}// 在线程阻塞时抛出一个中断信号// 这样线程就得以退出阻塞状态thread.interrupt();}
}
2.2.10 【案例】通过interrupted标识终止线程示例
可以通过循环确认某个线程的interrupted标识来控制一个线程的运行状态。
import java.util.concurrent.TimeUnit;
public class InterruptDemo2 {public static void main(String args[]) {Thread thread = new Thread(() -> {Thread cThread = Thread.currentThread();System.out.println("线程启动了");// while循环持续检测线程的中断标识while (!cThread.isInterrupted()){System.out.println("Thread say...");}System.out.println("线程结束了");});thread.start(); // 启动子线程try {// 主线程休眠1毫秒TimeUnit.MILLISECONDS.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}// 向运行中的子线程发送一个中断通知thread.interrupt();}
}
2.2.11 其他阻塞状态
线程的阻塞状态可细分为:等待阻塞、同步阻塞和其他阻塞。前两者和线程的同步相关,将在后续的课程中介绍。其他阻塞是指因调用线程的sleep方法或join方法而导致的线程阻塞,如下图所示:
输入/输出请求是指与其他程序或者与操作系统的输入/输出接口进行交互的场景,由于被访问的数据可能需要一定的准备工作,输入/输出操作是线程阻塞的常见原因。
2.3 后台线程
2.3.1 后台线程概述
一般来说,JAVA虚拟机中一般包括两种线程,分别是用户线程和后台线程。
后台线程指的是程序运行时在后台提供的一种通用服务的线程,也就是为其他线程提供服务的线程,也称为守护线程。
后台线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,也就是用户线程都结束时,程序也就终止了。同时,会终止进程中所有的后台线程。
反过来说,只要有任何非后台线程还在运行,程序就不会结束。例如,执行main()方法的就是一个非后台线程。
由于后台线程主要用于为系统中的其他对象和线程提供服务,因此它的优先级比较低。垃圾回收线程就是一个经典的守护线程:当程序中不再有任何运行的用户线程时,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以,当垃圾回收线程是Java虚拟机上仅剩的线程时,垃圾回收线程会自动退出。
通过setDaemon(true) 方法来设置线程为后台线程,可以将一个用户线程设置为后台线程。setDaemon()方法必须在start()方法之前设定,否则会抛出llegalThreadStateException异常。可以使用isDaemon()方法判断一个线程是前台线程还是后台线程。
2.3.2【案例】后台线程示例
编写代码,测试设置后台线程。代码示意如下:
public class DaemonDemo {public static void main(String[] args) {// 声明子线程执行逻辑并创建线程对象Thread daemonThread = new Thread(new Runnable() {public void run() {try {while (true) {System.out.println(Thread.currentThread().getName());try{Thread.sleep(1000);}catch (InterruptedException e){e.printStackTrace();}}}catch (Exception e) {System.out.println("Exception");}}});// 设置子线程为后台线程daemonThread.setDaemon(true);// 启动子线程daemonThread.start();// 主线程的输出信息for(int i = 0;i<2;i++){System.out.println(Thread.currentThread().getName());try{Thread.sleep(1000);}catch (InterruptedException e){e.printStackTrace();}}System.out.println("end main");}
}
3 线程并发安全
3.1 线程并发安全概述
3.1.1 什么是线程并发安全问题
一个进程下的多个线程共享该进程被分配到的内存空间,这意味着一个进程下的数据可能被该进程下的多个线程同时访问,这类数据称为共享资源(shared resources)或共享数据(shared data)。
常见的共享数据:对象的属性和类的静态变量。
常见的非共享数据:方法中声明的局部变量和方法形参。
多个线程对共享资源的并发访问可能会导致意外或错误的行为,称为线程并发安全问题,简称为线程安全(thread safety)问题。
下图表示一个具体的实例,来演示线程安全问题:
模拟场景为:一对夫妻使用相同的银行卡号去银行取钱,一人拿着存折,另一人拿着银行卡;假如卡中有2000元,丈夫取1500元,妻子在同一时间也取1500元。假设丈夫执行取钱操作时,程序刚刚判断了余额充足,还没有把钱从余额中扣除,妻子也执行取钱操作,此时妻子也可以从卡中取出1500元,即两个人从一张卡中取出3000元。这种失误,可以类比为线程安全问题。
3.1.2 【案例】线程安全问题示例
编写代码,模拟展示线程安全问题。代码示意如下:
public class ThreadSafeDemo1 {public static void main(String[] args) {Account account = new Account();account.withdraw();}
}
class Account{// 账户余额public static double balance = 2000;/*** 取款测试方法* 在该方法中,模拟丈夫和妻子同时取1500元*/public void withdraw(){MyThread t1 = new MyThread("丈夫",1500);MyThread t2 = new MyThread("妻子",1500);t1.start();t2.start();}/*** 封装取款操作的自定义线程类*/class MyThread extends Thread {private double money;private String name;public MyThread(String name, double money) {this.name = name;this.money = money;}public void run() {// 访问Account的静态属性balanceif(Account.balance>money) {try{Thread.sleep(1000);}catch (InterruptedException e) {e.printStackTrace();}Account.balance = Account.balance-money;System.out.println(name + "取钱成功! 余额:"+Account.balance);}}}
}
3.2 线程互斥
3.2.1 临界区
临界区是一个受保护的区域,限定一次不能有多个线程同时进入这个区域,仅在临界区中的线程可以对共享资源进行操作。当已经有一个线程进入临界区时,其他线程必须等待该线程离开临界区后,再进入临界区。
临界区是解决线程安全问题的一个常用方法。
3.2.2 线程互斥
线程互斥是指不同线程通过竞争进入临界区(共享的数据和硬件资源),将异步的操作变为同步操作。
异步操作是指,多线程并发的操作,相当于各自执行,可能存在并发冲突;同步操作是指,有先后顺序的操作,相当于你执行完我再执行,从而避免冲突。如下图所示:
可以限制某一时刻只有一个线程访问临界区,比如线程1在访问时,其他线程只能等待;当线程1离开临界区后,线程2、3、4均有可能进入临界区,但是无法确定具体是哪个线程进入。因为线程互斥无法限制访问者对资源的访问顺序,即访问是无序的。
3.2.3 synchronized关键字
Java中的synchronized关键字用于在程序中设置临界区,以实现线程的互斥,也称为“同步锁”。
synchronized关键字可以用来修饰一个代码块,称为“同步代码块”;也可以用来修饰一个方法,称为“同步方法”。同一时间,最多只能有1个线程执行同步代码块或同步方法中的代码。
synchronized代码块的语法如下:
synchronized (锁对象){// 需要同步访问控制的代码
}
3.2.4【案例】synchronized代码块示例
使用synchronized关键字改写上一个案例,实现线程安全。
代码示意如下:
public class SynchronizedDemo1{public static void main(String[] args) {Account2 account = new Account2();account.withdraw();}
}
class Account2{// 账户余额public static double balance = 2000;public static Object lock = new Object();/*** 取款测试方法* 在该方法中,模拟丈夫和妻子同时取1500元*/public void withdraw(){MyThread t1 = new MyThread("丈夫",1500);MyThread t2 = new MyThread("妻子",1500);t1.start();t2.start();}/*** 封装取款操作的自定义线程类*/class MyThread extends Thread {private double money;private String name;public MyThread(String name, double money) {this.name = name;this.money = money;}public void run() {synchronized (Account2.lock){// 访问Account的静态属性balanceif(Account2.balance>money) {try{Thread.sleep(1000);}catch (InterruptedException e) {e.printStackTrace();}Account2.balance = Account2.balance-money;System.out.println(name + "取钱成功! 余额:"+Account2.balance);}}}}
}
3.2.5 同步阻塞状态
当一个线程因synchronized关键字进入阻塞状态时,该线程处于同步阻塞状态,线程状态图如下所示。