【JAVA CORE_API】Day19 多线程API(2)、多线程并发安全问题、同步
多线程API
进程和线程
-
进程:进程就像是一个程序在电脑里运行时的一个实例。你可以把它想象成一个独立的小工人,专门负责完成某项任务(比如打开浏览器、播放音乐)。每个进程都有自己独立的资源(比如内存)和工作空间,不会轻易打扰其他进程。多个进程可以同时运行,各自处理自己的任务。
-
线程:线程就像是一个进程中的小工人。一个进程可以有多个线程,每个线程负责进程中的某个具体工作,比如在一个浏览器里同时加载不同的网页。线程之间可以共享进程的资源(比如内存),所以它们可以更快地协同工作。但因为它们共享资源,也需要小心不要互相干扰。
进程和线程的关系
-
进程和线程的关系就像是公司和员工的关系。一个进程是公司,它可以有多个员工(线程)一起工作。每个员工都有自己的任务,但他们共享公司的资源(比如办公设备)。
-
进程切换是指CPU从一个公司(进程)跳到另一个公司,执行不同公司的任务。因为每个公司有自己的资源,所以这个切换需要花费时间去保存和加载这些资源。
-
线程切换是指CPU在同一个公司内换员工执行任务。因为员工共享公司的资源,切换时不需要太多额外操作,所以速度更快。
- CPU通过快速切换进程和线程来处理多任务,确保每个任务都能得到执行。
进程与线程的区别
-
资源占用:
-
进程:进程是独立的,拥有自己完整的资源集,包括内存、文件句柄等。每个进程有自己独立的地址空间,这使得进程之间相对隔离,互不干扰。
-
线程:线程是进程内的一个执行单元,多个线程共享同一个进程的资源(如内存、文件句柄)。因此,线程之间的资源共享更直接、效率更高,但也更容易引发资源争用的问题。
-
-
开销:
-
进程:进程的创建、销毁和切换都需要较大的开销。因为进程切换涉及到保存和恢复独立的资源(如内存状态、CPU寄存器等),所以会消耗更多的系统资源和时间。
-
线程:线程的创建、销毁和切换开销较小。因为线程共享进程的资源,切换时只需保存和恢复少量的线程上下文,所以切换速度更快,系统资源占用更少。
-
-
并发性:
-
进程:进程之间的并发性较弱,因为它们彼此独立,不容易直接共享数据。要实现进程间的通信(IPC),通常需要借助系统提供的机制(如管道、消息队列等),这会增加复杂性和开销。
-
线程:线程之间的并发性较强。由于线程共享进程的资源,多个线程可以更容易地协同工作、直接共享数据,从而提高并发执行的效率。但同时,这也容易引发线程间的同步问题,如数据竞争和死锁。
-
线程的生命周期
-
新建(New):线程对象被创建,但还没有开始执行。就像一个人刚出生,刚有了生命,但还没有真正开始行动。
-
就绪(Runnable):线程已经准备好执行,等待CPU调度。就像人长大到可以学习和工作的年龄,准备好去做事,等待机会开始行动。
-
运行(Running):线程获得CPU时间片,开始执行任务。就像当人获得工作机会或任务,开始真正行动,比如开始学习、工作。
-
阻塞(Blocked):线程因等待某些资源(如锁、IO)而暂停执行,直到资源可用。就像人遇到困难或等待某些条件(比如等材料、等别人回应),暂时不能继续工作,只能等待问题解决。
-
终止(Terminated):线程执行完毕或因异常退出,生命周期结束。就像人完成任务或退休,生命结束,所有活动停止。
线程优先级
-
线程优先级是一个数值,用来决定线程获取CPU时间的先后顺序。优先级越高,线程越有可能先被执行。
-
线程切换方式:操作系统根据线程优先级和调度算法,在多个线程间切换,分配CPU资源。
-
设置线程优先级:通过
setPriority(int newPriority)
方法设置,优先级范围从1(最低)到10(最高)。 -
默认优先级:所有线程默认优先级为5,即中等优先级。
-
-
优先级只是一个建议,具体调度由操作系统决定。
-
Java 中有三个线程优先级常量,用于方便地设置线程的优先级:
-
Thread.MIN_PRIORITY
:最小优先级,值为1。适用于不紧急的任务。 -
Thread.NORM_PRIORITY
:普通优先级,值为5。是默认优先级,适用于一般任务。 -
Thread.MAX_PRIORITY
:最大优先级,值为10。适用于需要优先处理的任务。
-
package day19;/*** 线程的优先级* 线程有10个优先级,分别用整数1-10表示,其中1是最低的,10是最高的,5是默认值*/public class PriorityDemo {public static void main(String[] args) {Thread thread01 = new Thread(){@Overridepublic void run() {for (int i = 0; i < 1000; i++) {System.out.println("Min" + i);}}};Thread thread02 = new Thread(){@Overridepublic void run() {for (int i = 0; i < 1000; i++) {System.out.println("Normal:" + i);}}};Thread thread03 = new Thread(){@Overridepublic void run() {for (int i = 0; i < 1000; i++) {System.out.println("Max:" + i);}}};thread01.setPriority(Thread.MIN_PRIORITY);thread02.setPriority(Thread.NORM_PRIORITY);thread03.setPriority(Thread.MAX_PRIORITY);thread01.start();thread02.start();thread03.start();}}
sleep阻塞
sleep
阻塞是指线程调用Thread.sleep(milliseconds)
方法后,进入阻塞状态,暂停执行指定的时间。
-
作用:
sleep
方法用于让当前线程暂停执行,释放CPU,让其他线程有机会运行。 -
特点:线程在
sleep
期间保持着它的资源(如锁),只是暂时停止运行。当时间到达后,线程自动恢复到就绪状态,等待CPU再次调度。 -
注意:
sleep
不会释放锁资源,也不会影响线程的优先级。它只是简单地让线程暂停一段时间。sleep
阻塞常用于控制线程执行的节奏,比如定时任务或轮询操作。package day19;/*** Thread提供了一个方法:static void sleep(long ms)* 暂停当前线程,让出CPU,让出时间片,等待ms毫秒* 超时后会自动回到Runnable状态再次开始并发*/public class SleepDemo {public static void main(String[] args) {System.out.println("程序开始...");try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("程序结束了...");}}
-
在实际使用情况下,sleep()有以下几种常见的用途:
-
控制任务执行频率:使用
sleep()
来暂停线程,使其每隔一定时间执行任务。例如,定时检查某个条件或定期更新状态。 -
模拟延迟:在开发和测试过程中,使用
sleep()
模拟网络延迟或处理时间,以测试系统对延迟的反应。 -
避免资源过度占用:在高频率的任务(如轮询)中,使用
sleep()
减少对CPU的过度占用,降低系统负载。 -
实现简单的等待机制:在多线程程序中,线程可能需要等待某些条件满足后才能继续执行。使用
sleep()
可以实现简单的等待或延时。 -
调试和测试:在调试和测试中,
sleep()
可用于创建特定的时间间隔,以便观察程序的行为或结果。
-
-
我们来写一个定时器Demo,需求:程序运行后在控制台上输入一个数字,然后从该数字开始每秒递减,到0时输出时见到。
package day19;import java.util.Scanner;public class ClockDemo {public static void main(String[] args) {System.out.println("请输入一个数字:");Scanner scanner = new Scanner(System.in);System.out.println("倒计时开始!");for (int i = scanner.nextInt(); i >= 0; i--) {System.out.println(i);try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("时间到!");}}
-
InterruptedExcption阻塞中断异常
-
InterruptedException
是 Java 中的一个异常,表示线程在阻塞状态(如sleep()
、wait()
或join()
)时被中断了。 -
在
sleep()
方法中的作用:-
当线程调用
sleep()
方法进入阻塞状态时,如果在等待期间有其他线程调用interrupt()
方法中断这个线程,sleep()
方法会抛出InterruptedException
。 -
这允许线程在被中断时可以立即恢复执行,并处理中断请求。
-
-
使用场景:
-
响应中断请求:在长时间运行的线程中,使用
sleep()
时可能会有其他线程请求中断。此时,InterruptedException
让线程能够响应中断请求,做出适当的处理(如清理资源、退出循环)。 -
中断支持的任务:在需要支持中断的任务中,比如一个线程正在等待或处理任务,但也需要能够中断以快速终止任务或恢复其他操作,使用
sleep()
时应考虑InterruptedException
。
-
-
实际开发中,处理
InterruptedException
是重要的,特别是在多线程程序中,确保线程能够响应中断请求并适当地退出或清理资源。-
示例:
package day19;/*** 线程阻塞打断异常*/public class InterruptedExceptionDemo {public static void main(String[] args) {Thread thread = new Thread("美女"){@Overridepublic void run() {try {System.out.println(getName() + ":刚美完容,睡一觉吧...zZ");Thread.sleep(100000000);} catch (InterruptedException e) {System.out.println(getName() + ":干嘛呢?干嘛呢!干嘛呢!!!");}}};Thread thread1 = new Thread("装修工") {@Overridepublic void run() {System.out.println(getName() + ":大锤80!小锤40!大哥你要几锤?");for (int i = 0; i < 5; i++) {System.out.println(getName() + ":80!");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("咣当!");System.out.println(getName() + ":大哥!搞定!!!");thread.interrupt();}};thread.start();thread1.start();}}
-
守护线程
-
什么是守护线程
守护线程(Daemon Thread)是指在后台运行的线程,它为其他线程提供服务,但不会阻止程序退出。守护线程通常用于执行不重要的任务,如垃圾回收、后台监控等。
-
守护线程和普通线程的区别
-
生命周期:
-
守护线程:当所有用户线程(非守护线程)全部结束时,守护线程才会结束。它不会阻止程序退出。
-
普通线程:程序的退出会等到所有普通线程都结束后,程序才会终止。
-
-
优先级:
-
守护线程:通常优先级较低,因为它的任务是辅助性质的。
-
普通线程:优先级可以根据任务的重要性设定。
-
-
-
如何使用守护线程
在创建线程时,可以通过
Thread
类的setDaemon(true)
方法将其设为守护线程。例如:void setDeamon(boolean) // 当参数为true时该线程为守护线程
-
守护线程的特点
-
后台服务:守护线程通常用于后台服务任务,如垃圾回收、日志记录、监控等。
-
自动退出:守护线程不会阻止程序终止,当所有非守护线程结束时,守护线程也会结束。
-
-
守护线程与垃圾回收(GC)
守护线程通常用于执行垃圾回收任务。Java 的垃圾回收线程就是一种守护线程,它在后台运行,清理不再使用的对象,而不会阻止程序的终止。
-
守护线程在什么情况使用
-
后台任务:适用于需要在后台持续运行的任务,而这些任务不应该阻止程序退出。例如,后台监控、定时任务、后台日志记录等。
-
辅助服务:适用于提供服务或支持其他用户线程的任务,比如线程池中的工作线程通常是守护线程。
-
package day19;public class DaemonThreadDemo {public static void main(String[] args) {// Rose线程Thread threadRose = new Thread("Rose"){@Overridepublic void run() {for (int i = 0; i < 5; i++) {System.out.println(getName() + ":Let me go!!!");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println(getName() + ":AAAAAAAAAA!!!!");System.out.println("噗通~");}};// Jack线程Thread threadJack = new Thread("Jack"){@Overridepublic void run() {while (true) {System.out.println(getName() + ":You jump! I jump!");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};// 线程控制threadRose.start();threadJack.setDaemon(true); // 将threadJack设置为守护线程threadJack.start();}}
多线程
多线程并发安全
-
生活中的例子
-
共享银行账户:
-
场景:假设有一个银行账户,两个不同的客户同时使用自动取款机(ATM)和柜台取款同一账户,每个客户就是一个线程,如果CPU执行客户1取款线程的一半时间片结束将线程分配给客户2,检查余额时恰巧同时进入检查余额是否足够的阶段,会不会出现都返回true的情况发生?两个人都成功取款?我们看下面的代码:
package day19;/*** 当多个线程并发操作同一临界资源,由于线程切换实际不确定,导致执行顺序出现混乱而产生不良后果,这就是线程并发安全问题** 临界资源:操作该资源的过程同时只能被单个线程进行*/public class SyncDemo01 {static boolean success1 = false; // 表示第一个人是否取款成功static boolean success2 = false; // 表示第二个人是否取款成功public static void main(String[] args) {int sum = 0; // 记录测试的次数Bank bank = new Bank();while (true) {sum++;Thread thread1 = new Thread() {@Overridepublic void run() {success1 = bank.getMoney(20000); // 取款}};Thread thread2 = new Thread() {@Overridepublic void run() {success2 = bank.getMoney(20000); // 取款}};thread1.start();thread2.start();try {Thread.sleep(10); // 阻塞主线程一段时间,用于让t1,t2线程执行完毕if (success1 && success2) {System.out.println("成功!两个人都取出20000块!");break;} else {System.out.println("失败!正常取款,只有一人取款成功!");success1 = false;success2 = false;bank.saveAccount(20000);}} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第" + sum + "次测试:");}}}class Bank {private int account = 20000; // 银行余额// 取钱方法,返回true说明允许出钞,返回false说明不允许出钞public boolean getMoney(int money) {int account = getAccount(); // 查询余额方法if (account >= money) { // 判断余额是否足够account = account - money; // 扣款Thread.yield(); // 主动放弃当前线程的时间片,模拟到这里CPU恰好分配的时间片结束saveAccount(account); // 保存新余额return true; // 允许出钞}return false; // 不允许出钞}public void saveAccount(int account){this.account = account;}public int getAccount() {return account;}}
-
并发安全:为了防止两个客户取款同时修改账户余额导致不一致,银行系统需要使用机制(如锁)来确保每次只有一个客户能够修改账户余额,从而保证账户余额的准确性。
-
-
-
计算机相关的例子
-
线程安全的计数器:
-
场景:多个线程同时增加一个全局计数器的值。如果没有适当的同步机制,多个线程同时对计数器进行操作可能会导致计数器值的丢失或重复增加。
-
并发安全:为了确保计数器的正确性,通常会使用线程安全的数据结构(如
AtomicInteger
)或在增加计数器时使用同步机制(如synchronized
关键字或Lock
)来保证每次操作的原子性,从而避免数据竞争。
-
-
如何解决并发问题
-
同步(Synchronization)
同步在多线程编程中是指协调多个线程对共享资源的访问,以防止并发操作导致数据的不一致性。由于多个线程可能同时访问和修改共享数据,如果没有同步机制,就会导致数据竞争和不可预测的结果。
-
synchronized
关键字Java 提供了
synchronized
关键字来实现同步,确保同一时刻只有一个线程可以执行被synchronized
修饰的代码部分,从而保护共享资源。 -
synchronized
的两种用法:-
同步方法:
-
当一个方法被
synchronized
修饰时,线程在调用这个方法之前需要获得方法所属对象的锁(如果是静态方法,则是类对象的锁)。 -
当一个线程持有对象锁时,其他线程必须等待,直到这个锁被释放后才能访问这个同步方法。
public synchronized void exampleMethod() {// 同步代码 }
-
-
同步代码块:
-
通过
synchronized
关键字可以对一个特定的对象加锁,通常是this
,以确保某一代码块在同一时间只能被一个线程执行。 -
这种方式比同步整个方法更灵活,只锁住关键的代码部分,可能减少不必要的性能损失。
public void exampleMethod() {synchronized(this) {// 同步代码块}}
-
-
-
优化刚才的代码:
package day19;/*** 当多个线程并发操作同一临界资源,由于线程切换实际不确定,导致执行顺序出现混乱而产生不良后果,这就是线程并发安全问题** 临界资源:操作该资源的过程同时只能被单个线程进行*/public class SyncDemo01 {static boolean success1 = false; // 表示第一个人是否取款成功static boolean success2 = false; // 表示第二个人是否取款成功public static void main(String[] args) {int sum = 0; // 记录测试的次数Bank bank = new Bank();while (true) {sum++;Thread thread1 = new Thread() {@Overridepublic void run() {success1 = bank.getMoney(20000); // 取款}};Thread thread2 = new Thread() {@Overridepublic void run() {success2 = bank.getMoney(20000); // 取款}};thread1.start();thread2.start();try {Thread.sleep(10); // 阻塞主线程一段时间,用于让t1,t2线程执行完毕if (success1 && success2) {System.out.println("成功!两个人都取出20000块!");break;} else {System.out.println("失败!正常取款,只有一人取款成功!");success1 = false;success2 = false;bank.saveAccount(20000);}} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第" + sum + "次测试:");}}}class Bank {private int account = 20000; // 银行余额// 取钱方法,返回true说明允许出钞,返回false说明不允许出钞public synchronized boolean getMoney(int money) {int account = getAccount(); // 查询余额方法if (account >= money) { // 判断余额是否足够account = account - money; // 扣款Thread.yield(); // 主动放弃当前线程的时间片,模拟到这里CPU恰好分配的时间片结束saveAccount(account); // 保存新余额return true; // 允许出钞}return false; // 不允许出钞}public void saveAccount(int account){this.account = account;}public int getAccount() {return account;}}
-
这段代码还存在性能问题:原因是把
synchronized
锁放在了整个getMoney()
方法上。虽然这样可以解决线程并发安全问题,但会导致性能下降,因为它限制了同一时间只有一个线程能够访问整个方法,这在多线程环境中会降低系统的吞吐量。 -
生活中的类比:去商场买衣服,想象你去商场买衣服的过程:
-
全程锁定:如果商场为了避免拥挤,每次只允许一个顾客进入并从头到尾完成所有购物步骤(挑选、试穿、付款等),这样可以确保秩序,但效率非常低,其他顾客必须等待很长时间才能轮到自己。
-
部分锁定:更合理的做法是只在关键步骤上(比如付款时)进行控制,同一时间只允许一个顾客在收银台结账,而其他顾客可以同时进行挑选和试穿。这样可以大大提高整体效率。
-
-
如何解决锁带来的性能问题呢?
同步块
-
同步块的组成和语法
同步块由
synchronized
关键字和一个同步监视器对象(锁)组成,确保在同一时间只有一个线程可以执行同步块内的代码。 -
语法:
java复制代码synchronized(锁对象) {// 需要同步的代码}
-
同步监视器对象的选取
-
锁对象是同步块的关键,它用于控制访问权限。
-
常见选择:
-
this
:当前对象实例,用于实例方法的同步。 -
类对象:
ClassName.class
,用于静态方法的同步。 -
任意其他对象:用于细粒度控制,避免锁定过多代码。
-
package day19;public class SyncDemo02 {public static void main(String[] args) {shop shop = new shop();Thread thread01 = new Thread("张禹垚一号") {@Overridepublic void run() {shop.buy();}};Thread thread02 = new Thread("张禹垚二号") {@Overridepublic void run() {shop.buy();}};thread01.start();thread02.start();}}class shop {public void buy(){try {Thread thread = Thread.currentThread(); // 获取当前线程对象System.out.println(thread.getName() + ":正在购买商品...");Thread.sleep(5000);synchronized (this) {System.out.println(thread.getName() + ":正在试衣服...");Thread.sleep(5000);}System.out.println(thread.getName() + ":购买成功!");} catch (InterruptedException e) {e.printStackTrace();}}}
-
-
选择合适的锁:
-
锁粒度:锁的范围应尽量小,以减少线程争用。选择只需要保护共享资源的关键部分作为锁对象。
-
实例锁:用
this
作为锁对象,适用于保护当前实例的共享资源,确保同一实例的同步方法或代码块同时只能被一个线程访问。 -
类锁:用
ClassName.class
作为锁对象,适用于保护静态变量或方法,确保同类的所有实例共享同一把锁。 -
自定义锁:用特定的对象作为锁,适用于需要更灵活的同步控制,避免过多的竞争。选择与共享资源最相关的对象。
-
一致性:确保所有相关代码段使用同一个锁对象,防止线程间产生不一致的锁定。
-