目录
多线程
相关名词解释
程序、进程与线程
并行与并发
单核与多核
Java多线程概述
@Test测试框架测试多线程的注意事项
主线程和子线程的概念
@Test测试类需要使用.join()方法来确保子线程执行完毕
线程调度(目前仅了解)
守护线程(Daemon Thread)和普通线程(用户线程)
守护线程(Daemon Thread)
普通线程(用户线程)
创建线程:Thread类和Runnable接口对比
Thread类
Thread类构造函数参数解析
Runnable接口
对比
线程生命周期
JDK1.5及之后:6种状态
线程安全问题以及解决方案
线程同步
概述
同步机制解决线程安全问题的原理
同步代码块和同步方法
同步锁机制
synchronized的锁是什么
同步操作的思考顺序
总结
synchronized与Lock的对比
死锁
线程间通信
概述
基础
等待唤醒机制
调用wait和notify需注意的细节
同步监视器的释放时机
释放锁的操作
不会释放锁的操作
线程池
概述
线程池相关API·常用
拓展
Callable接口
Future接口
多线程
相关名词解释
程序、进程与线程
-
程序(program):为完成特定任务,用某种语言编写的
一组指令的集合
。即指一段静态的代码
,静态对象。 -
进程(process):程序的一次执行过程,或是正在内存中运行的应用程序。如:运行中的QQ,运行中的网易音乐播放器。
-
每个进程都有一个独立的内存空间,系统运行一个程序即是一个进程从创建、运行到消亡的过程。(生命周期)
-
程序是静态的,进程是动态的
-
进程作为
操作系统调度和分配资源的最小单位
(亦是系统运行程序的基本单位),系统在运行时会为每个进程分配不同的内存区域。 -
现代的操作系统,大都是支持多进程的,支持同时运行多个程序。比如:现在我们上课一边使用编辑器,一边使用录屏软件,同时还开着画图板,dos窗口等软件。
-
-
线程(thread):进程可进一步细化为线程,是程序内部的
一条执行路径
。一个进程中至少有一个线程。-
一个进程同一时间若
并行
执行多个线程,就是支持多线程的。 -
线程作为
CPU调度和执行的最小单位
。 -
一个进程中的多个线程共享相同的内存单元,它们从同一个堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来
安全的隐患
。 -
下图中,红框的蓝色区域为线程独享,黄色区域为线程共享。
-
-
进程与线程的区别:
-
进程是一个独立的执行环境,拥有自己的地址空间和系统资源,进程间通信的开销较大。
-
线程是进程内的执行单元,共享进程的资源,线程间通信相对容易且开销较小。
-
进程之间是相互独立的,一个进程的崩溃不会影响其他进程。
-
线程之间共享同一进程的上下文,一个线程的错误可能会影响整个进程的稳定性。
-
-
多线程的优势和应用:
-
多线程可以提高程序的并发性,充分利用多核处理器的能力,提高程序的执行效率。
-
多线程适用于需要同时处理多个任务或需要实时响应的应用,如并发服务器、多媒体处理和游戏等。
-
注意:
不同的进程之间是不共享内存的。
进程之间的数据交换和通信的成本很高。
并行与并发
并行和并发是两个与多任务处理相关的概念
并行是真正同时执行多个任务的情况,需要具备多个物理执行单元;而并发是多个任务在时间上重叠执行的情况,可以在单个处理器上通过快速切换来实现。并行和并发在多任务处理中具有不同的应用场景和特点。
-
并行(Parallel):
-
并行是指同时执行多个任务或操作,每个任务都在不同的处理器核心上独立执行。
-
并行可以显著提高任务的执行速度,特别是在具有多个处理器核心或多台机器的系统中。
-
并行需要具备物理上的多个执行单元,如多核处理器、多机集群等。
-
-
并发(Concurrent):
-
并发是指同时处理多个任务或操作,这些任务在时间上是重叠的,但不一定是同时执行的。
-
在单个处理器上,通过快速的切换和调度,使得多个任务交替执行,给人一种同时执行的感觉。
-
并发可以提高系统的资源利用率和响应能力,充分利用等待时间来处理其他任务。
-
并发通常使用多线程或多进程来实现。
-
-
并行与并发的区别:
-
并行是真正同时执行多个任务或操作,每个任务独立在不同的处理器核心上执行。
-
并发是指多个任务在时间上重叠执行,通过快速切换来实现多任务的交替执行。
-
并行需要具备物理上的多个执行单元,如多核处理器或多机集群。
-
并发可以在单个处理器上实现,通过时间片轮转等方式实现任务的交替执行。
-
-
并行与并发的应用场景:
-
并行适用于需要同时处理大量独立任务的场景,如科学计算、图像处理等。
-
并发适用于需要同时处理多个交互任务或同时接收多个请求的场景,如服务器、多媒体应用等。
-
-
并行与并发的关系:
-
并行是并发的一种特殊形式,可以看作是并发的极端情况,即真正同时执行多个任务。
-
并发可以通过并行来实现,但并行不一定需要并发。在单核处理器上,多个任务通过快速切换来实现并发;在多核处理器上,可以同时执行多个任务来实现并行和并发。
-
单核与多核
总结起来,单核处理器只有一个物理处理核心,一次只能执行一个任务,通过任务切换来实现多任务的并发执行;而多核处理器拥有多个物理处理核心,可以同时执行多个任务,通过并行执行来提高处理能力。多核处理器具有更好的性能和并行能力,适用于需要处理大量并发任务的应用。随着技术的进步,多核处理器成为了主流,核心数量和性能不断增加。
下面是关于单核和多核的详细解释:
-
单核处理器(Single-core Processor):
-
单核处理器是一种只拥有一个物理处理核心的中央处理器(CPU)。
-
单核处理器只能同时执行一个指令流,即一次只能处理一个任务。
-
单核处理器通过快速地在不同任务之间进行切换来实现多任务的并发执行。
-
单核处理器的性能主要受到单个核心的时钟频率、架构和缓存等因素的影响。
-
-
多核处理器(Multi-core Processor):
-
多核处理器是一种拥有多个物理处理核心的中央处理器(CPU)。
-
多核处理器可以同时执行多个指令流,即一次可以处理多个任务。
-
多核处理器的每个核心都可以独立地执行指令,具有独立的寄存器和缓存。
-
多核处理器通过多个核心的并行执行来实现更高的处理能力和性能。
-
-
单核与多核的区别:
-
单核处理器只有一个物理处理核心,一次只能执行一个任务,无法实现真正的并行处理。
-
多核处理器拥有多个物理处理核心,可以同时执行多个任务,实现并行处理。
-
单核处理器通过任务切换来实现多任务的并发执行,而多核处理器通过并行执行来提高任务的处理能力。
-
多核处理器在相同时钟频率下可以比单核处理器更高效地执行多任务。
-
-
单核与多核的应用和优势:
-
单核处理器适用于简单的任务和较低要求的应用,如一般办公、网页浏览等。
-
多核处理器适用于需要处理大量并发任务的应用,如高性能计算、服务器、多媒体处理等。
-
多核处理器具有更好的性能和并行能力,可以提高系统的响应速度和任务处理效率。
-
-
单核与多核的发展趋势:
-
随着技术的进步,单核处理器的性能提升已经遇到了瓶颈,因此多核处理器成为了主流。
-
多核处理器的核心数量和性能不断增加,以满足日益增长的计算需求。
-
同时,软件开发人员需要针对多核处理器进行并行编程,以充分利用多核处理器的潜力。
-
Java多线程概述
多线程是指在一个程序中同时执行多个线程,每个线程都有自己的执行路径。Java提供了内置的多线程支持,通过使用java.lang.Thread
类来创建和管理线程。
在实际应用中,还有许多高级的多线程概念和技术,例如线程池的配置、线程间的通信方式、线程的优先级、线程的中断和终止等。
以下是Java多线程的详细细节:
-
线程调度:
-
线程调度由操作系统负责,Java提供了一些工具和方法来影响线程调度。
-
线程调度策略可以是抢占式或协同式。在抢占式调度中,操作系统决定在何时暂停当前线程并执行其他线程。在协同式调度中,线程必须自行释放CPU控制权。
-
-
创建线程:
-
继承Thread类:定义一个继承自
Thread
类的子类,并重写run()
方法,该方法包含线程的实际执行逻辑。 -
实现Runnable接口:定义一个实现了
Runnable
接口的类,并实现run()
方法。 -
使用Lambda表达式:通过Lambda表达式创建线程。
-
-
启动线程:
-
调用线程对象的
start()
方法来启动线程。这会在新的线程中调用run()
方法。
-
-
线程生命周期:
-
新建状态(New):线程对象被创建,但还没有调用
start()
方法。 -
运行状态(Runnable):线程正在执行或者等待CPU调度执行。
-
阻塞状态(Blocked):线程等待某个条件的发生,比如等待I/O操作完成或者等待锁的释放。
-
等待状态(Waiting):线程等待其他线程的通知,进入等待队列。
-
计时等待状态(Timed Waiting):线程等待一段指定的时间,超时后自动恢复到Runnable状态。
-
终止状态(Terminated):线程执行完毕或者因异常退出。
-
-
线程安全:
-
线程安全是指多个线程同时访问共享资源时,不会出现数据不一致或者其他异常情况。
-
需要保证线程安全时,可以使用同步机制或者使用线程安全的数据结构(如
ConcurrentHashMap
、AtomicInteger
等)。
-
-
线程同步:
-
多个线程可以同时访问和修改共享数据,这可能导致数据不一致和竞态条件。使用同步机制可以避免这些问题。
-
synchronized
关键字:可以修饰方法或代码块,确保同一时间只有一个线程可以访问被修饰的代码。 -
volatile
关键字:用于确保变量的可见性,保证多个线程对变量的修改能够正确地被其他线程读取。 -
锁(Lock):Java提供了
java.util.concurrent.locks
包中的锁机制,如ReentrantLock
、ReadWriteLock
等。
-
-
线程间通信:
-
线程间通信是指多个线程在执行过程中进行信息交换。
-
wait()
、notify()
和notifyAll()
:这些方法是Object
类中的方法,用于线程间的等待和唤醒。 -
join()
:一个线程可以调用另一个线程的join()
方法,等待另一个线程执行完毕后再继续执行。
-
-
线程池:
-
线程池是一种管理和复用线程的机制,可以减少线程创建和销毁的开销。
-
java.util.concurrent.Executors
类提供了创建线程池的方法,如newFixedThreadPool()
、newCachedThreadPool()
等。
-
@Test测试框架测试多线程的注意事项
主线程和子线程的概念
JVM启动时会创建一个主线程,该主线程负责执行Main方法,它是程序的入口点。主线程负责执行程序的主要逻辑,并且是程序中第一个被执行的线程。
子线程(Child Thread)是由主线程或其他线程创建的额外线程,用于并发执行任务。
子线程和主线程是相对的概念,主线程创建子线程后,可以继续执行其他代码,而子线程则在独立的执行路径上执行自己的任务。子线程可以同时与主线程并发执行,实现多任务处理。
@Test
测试类需要使用.join()
方法来确保子线程执行完毕
.join()
方法会阻塞当前线程(通常是主线程),直到调用.join()
方法的线程(子线程)执行完毕。
-
线程启动和加入:使用
Thread
类的.start()
方法来启动线程的执行,并使用.join()
方法来等待线程执行完毕。这样可以确保主线程在所有线程执行完毕后再继续执行。 -
异常处理:在多线程测试中,需要适当地处理线程抛出的异常。可以使用
try-catch
块来捕获异常,并在测试中适当地处理或报告异常情况。 -
并发断言:如果你的测试涉及到并发操作,可能需要使用适当的断言机制来验证并发结果的正确性。例如,使用
CountDownLatch
、CyclicBarrier
等同步工具来控制线程的执行顺序和同步点,并在合适的时机进行断言验证。
线程调度(目前仅了解)
-
调度策略:操作系统通常采用不同的调度策略来确定线程的执行顺序。常见的调度策略包括先来先服务(FCFS)、轮转调度(Round Robin)、优先级调度(Priority Scheduling)等。
-
线程优先级:每个线程都可以分配一个优先级,用于指示线程执行的相对重要性。优先级通常由整数表示,范围从最低优先级(通常为1)到最高优先级(通常为10)。调度器根据线程的优先级来决定应该给予哪个线程更多的CPU时间。
-
抢占式调度:在抢占式调度中,较高优先级的线程有权力剥夺较低优先级线程的CPU执行时间。这意味着,当高优先级线程准备就绪时,它可以中断正在执行的低优先级线程并立即执行。抢占式调度可以提高对紧急任务的响应能力。
-
协同式调度:在协同式调度中,线程自愿放弃CPU控制权,并将控制权交给其他线程。只有当线程主动释放CPU时,其他线程才能获得执行的机会。协同式调度需要线程显式地合作,以避免某个线程占用CPU时间过长而导致其他线程无法执行。
-
上下文切换:上下文切换是指从一个线程切换到另一个线程时,保存当前线程的上下文(如寄存器状态、程序计数器等),并加载下一个线程的上下文,以便恢复其执行状态。上下文切换是一项开销较大的操作,因为需要保存和恢复大量的线程上下文信息。
-
时间片:时间片是指操作系统分配给每个线程的最大连续执行时间。在轮转调度中,每个线程按顺序执行一个时间片,然后切换到下一个线程。如果线程在时间片结束之前没有完成,它将被暂停并等待下一个时间片。
守护线程(Daemon Thread)和普通线程(用户线程)
守护线程(Daemon Thread)
总结起来,守护线程是一种特殊类型的线程,其存在不会阻止程序的结束。它们常用于执行后台任务和支持功能,但在设计和使用守护线程时需要注意线程的可靠性和行为的不确定性。
守护线程(Daemon Thread)是在Java中的一种特殊类型的线程。与普通线程(用户线程)不同,守护线程的存在不会阻止程序的结束。在理解守护线程的全部详细细节时,以下是需要了解的重要概念和行为:
-
守护线程定义:通过将线程对象的
setDaemon(true)
方法设置为true
,可以将线程设置为守护线程。默认情况下,线程是非守护线程(用户线程)。 -
程序结束条件:当所有非守护线程(用户线程)执行完毕且没有活动非守护线程时,Java程序将自动退出。守护线程的结束并不会阻止程序的退出。
-
生命周期管理:守护线程的生命周期与程序的生命周期相互关联。当所有非守护线程结束时,Java虚拟机(JVM)会检查是否还有活动的守护线程。如果没有,JVM会自动退出。
-
资源回收:守护线程通常用于执行一些后台任务,如垃圾回收(Garbage Collection)等。当所有非守护线程结束时,JVM会自动停止守护线程并释放相关资源。
-
不可靠性:守护线程可能会在任何时候被中断,甚至在执行过程中被强制终止。这是因为守护线程的执行时间不受程序的控制,而是由JVM自主决定。
-
父线程的特性继承:守护线程的创建是在其父线程中进行的。因此,父线程结束后,守护线程会继承父线程的特性,包括线程优先级、线程组等。
需要注意的是,守护线程适用于执行一些后台任务或提供支持功能的线程,而不应该用于执行关键任务或涉及重要数据的线程。由于守护线程的不可靠性和无法控制的特性,它们可能无法完成预期工作。
普通线程(用户线程)
总结起来,普通线程是在Java中常见的线程类型,具有自己的生命周期、并发执行、同步机制和线程间通信等特性。了解普通线程的详细细节可以帮助你编写正确、可靠的多线程程序。
普通线程(用户线程)是在Java中最常见的线程类型。与守护线程不同,普通线程的存在会阻止程序的结束。以下是关于普通线程的全部详细细节:
-
线程创建和启动:使用Thread类或其子类创建线程对象,并调用
start()
方法来启动线程的执行。每个线程都有一个对应的线程控制块(Thread Control Block,TCB),包含线程的状态、优先级、栈等信息。 -
生命周期管理:线程的生命周期包括新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和终止(Terminated)等状态。线程通过状态的转换来管理其生命周期。
-
并发执行:多个普通线程可以并发执行,共享CPU时间片并交替执行。线程调度器负责根据调度算法决定线程的执行顺序和时间片分配。
-
线程同步:线程同步是确保多个线程之间按照预期的顺序和方式访问共享资源的机制。常用的线程同步机制包括互斥锁(Mutex)、信号量(Semaphore)、条件变量(Condition Variable)等。
-
线程间通信:线程间通信是指不同线程之间交换信息或协调行动的机制。常用的线程间通信机制包括共享内存、管道、消息队列、信号量等。
-
异常处理:线程可能抛出异常,因此需要适当地处理线程抛出的异常。可以使用
try-catch
块来捕获并处理线程中的异常。 -
线程优先级:线程可以设置优先级来指示其相对重要性和调度顺序。优先级较高的线程在竞争CPU时间时具有较高的概率被调度执行。
-
线程安全性:线程安全性涉及保护共享资源免受并发访问的影响。通过使用同步机制、原子操作、线程安全的数据结构等,可以确保多线程环境下的数据一致性和正确性。
需要注意的是,普通线程的执行顺序和时间片分配是由线程调度器决定的,因此无法精确控制线程的执行顺序。并且,在多线程编程中需要小心处理共享资源的并发访问,以避免竞态条件和数据不一致性的问题。
创建线程:Thread类和Runnable接口对比
Thread类
-
线程创建和启动:
-
可以通过继承Thread类或构造Thread类的实例来创建线程对象。
-
继承Thread类的方式,需要重写
run()
方法来定义线程的执行逻辑。 -
调用线程对象的
start()
方法来启动线程的执行。start()
方法会在新的线程中调用run()
方法。 -
每个线程对象对应一个独立的执行线程,具有自己的线程控制块(TCB)、堆栈和执行状态。
-
-
生命周期管理:
-
线程的生命周期包括新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和终止(Terminated)等状态。
-
线程通过状态的转换来管理其生命周期。例如,调用
start()
方法后,线程从新建状态转换为就绪状态,然后由线程调度器决定何时转换为运行状态。
-
-
并发执行:
-
多个Thread对象可以并发执行,共享CPU时间片并交替执行。
-
线程调度器负责根据调度算法决定线程的执行顺序和时间片分配。
-
可以通过设置线程的优先级来影响线程的调度顺序,但不能确保绝对的执行顺序。
-
-
线程同步:
-
线程同步是确保多个线程之间按照预期的顺序和方式访问共享资源的机制。
-
常用的线程同步机制包括互斥锁(Mutex)、信号量(Semaphore)、条件变量(Condition Variable)等。
-
通过同步机制,可以避免竞态条件和数据不一致性的问题。
-
Thread类构造函数参数解析
-
Thread(Runnable target)
-
参数:target - 实现了Runnable接口的对象,用于定义线程的执行逻辑。
-
作用:创建一个新的线程对象,并将Runnable对象作为线程的执行目标。
-
默认线程名称:系统自动生成一个唯一的线程名称,以"Thread-"为前缀,后跟一个数字。
-
-
Thread(Runnable target, String name)
-
参数:target - 实现了Runnable接口的对象,用于定义线程的执行逻辑。 name - 线程的名称。
-
作用:创建一个新的线程对象,并将Runnable对象作为线程的执行目标,同时指定线程的名称。
-
-
Thread(ThreadGroup group, Runnable target)
-
参数:group - 线程组对象,用于将线程归类和管理。 target - 实现了Runnable接口的对象,用于定义线程的执行逻辑。
-
作用:创建一个新的线程对象,并将Runnable对象作为线程的执行目标,同时将线程归属到指定的线程组。
-
-
Thread(ThreadGroup group, Runnable target, String name)
-
参数:group - 线程组对象,用于将线程归类和管理。 target - 实现了Runnable接口的对象,用于定义线程的执行逻辑。 name - 线程的名称。
-
作用:创建一个新的线程对象,并将Runnable对象作为线程的执行目标,同时指定线程的名称,并将线程归属到指定的线程组。
-
-
Thread(ThreadGroup group, Runnable target, String name, long stackSize)
-
参数:group - 线程组对象,用于将线程归类和管理。 target - 实现了Runnable接口的对象,用于定义线程的执行逻辑。 name - 线程的名称。 stackSize - 线程的堆栈大小,以字节为单位,0表示使用默认值。
-
作用:创建一个新的线程对象,并将Runnable对象作为线程的执行目标,同时指定线程的名称和堆栈大小,并将线程归属到指定的线程组。
-
Runnable接口
-
接口定义:
-
Runnable接口是一个函数式接口,定义了一个抽象方法
run()
,用于定义线程的执行逻辑。 -
run()
方法没有参数和返回值,需要在实现类中重写该方法,并在其中编写线程的具体执行逻辑。
-
-
创建线程对象:
-
创建线程对象时,可以将实现了Runnable接口的对象作为参数传递给Thread类的构造函数,或者使用线程池等方式。
-
通过将Runnable对象传递给Thread类,可以将线程逻辑与线程类解耦,实现更好的代码组织和复用。
-
-
启动线程执行:
-
使用Thread类的
start()
方法来启动使用Runnable实现的线程执行。 -
调用
start()
方法后,会创建一个新的线程,并自动调用Runnable对象的run()
方法来执行线程的逻辑。 -
注意不要直接调用Runnable对象的
run()
方法,否则只会在当前线程中以普通方法的方式执行,而不会启动新的线程。
-
-
并发执行:
-
多个使用Runnable实现的线程可以并发执行,共享CPU时间片并交替执行。
-
线程调度器负责根据调度算法决定线程的执行顺序和时间片分配。
-
-
线程同步与共享数据:
-
使用Runnable实现的线程可能需要访问共享资源,需要考虑线程同步的问题。
-
可以使用同步机制(如synchronized关键字、Lock接口)、原子操作、线程安全的数据结构等来保护共享数据的一致性。
-
合理地控制对共享资源的访问,可以避免竞态条件和数据不一致性的问题。
-
-
结束线程执行:
-
Runnable接口本身没有提供方法来结束线程执行。
-
线程的执行可以通过让
run()
方法正常返回来结束,或者通过调用Thread类的interrupt()
方法来中断线程的执行。
-
-
线程池中的应用:
-
Runnable接口广泛用于线程池中,通过将实现Runnable接口的任务提交给线程池来执行。
-
线程池会根据需要创建、管理和复用线程,提高线程的效率和性能。
-
对比
-
Thread类:
-
Thread类是Java提供的一个类,用于创建和管理线程。
-
通过继承Thread类,可以创建自定义的线程类,并重写其
run()
方法来定义线程的执行逻辑。 -
使用
start()
方法启动线程的执行。调用start()
方法后,会自动调用线程的run()
方法。 -
每个线程类都是一个独立的实体,具有自己的线程控制块(TCB)、堆栈和执行状态。
-
Thread类提供了一些方法来操作线程,如
sleep()
、join()
、interrupt()
等。
-
-
Runnable接口:
-
Runnable接口是一个函数式接口,定义了一个单一的抽象方法
run()
,用于定义线程的执行逻辑。 -
通过实现Runnable接口,可以将自定义的线程逻辑与线程类分离,实现更好的代码组织和复用。
-
创建线程对象时,可以将实现了Runnable接口的对象作为参数传递给Thread类的构造函数。
-
使用线程对象的
start()
方法启动线程的执行,该线程会自动调用Runnable对象的run()
方法。 -
实现Runnable接口的类可以同时作为其他类的父类或实现其他接口,提供更大的灵活性。
-
-
区别和选择:
-
通过继承Thread类创建线程,线程类直接拥有了线程的特性,但继承关系在Java中是单继承的,因此可能限制了类的继承关系。
-
实现Runnable接口则将线程的逻辑与线程类解耦,可同时实现其他接口或继承其他类,提供更多的灵活性和可扩展性。
-
一般而言,推荐使用实现Runnable接口的方式创建线程,因为它更灵活,并且可以避免因为继承关系而受限。
-
线程生命周期
线程的生命周期有五种状态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)。CPU需要在多条线程之间切换,于是线程状态会多次在运行、阻塞、就绪之间切换。
1. 新建(New)
当声明并创建一个Thread类或其子类的对象时,新生的线程对象处于新建状态。在这个阶段,JVM为该线程对象分配内存,并初始化实例变量的值。与其他Java对象一样,线程对象在新建状态下没有任何线程的动态特征,程序也不会执行它的线程体run()。
2. 就绪(Runnable)
但是,当线程对象调用了start()方法之后,线程的状态发生变化,从新建状态转为就绪状态。JVM会为该线程创建方法调用栈和程序计数器。然而,处于就绪状态的线程并没有开始运行,它只是表示已具备了运行的条件,并随时可以被调度。线程的具体调度时机取决于JVM中的线程调度器。
注意:
程序只能在新建状态的线程上调用start()方法,并且只能调用一次。如果对非新建状态的线程(如已启动的线程或已死亡的线程)调用start()方法,将会引发IllegalThreadStateException异常。
3. 运行(Running)
一旦就绪状态的线程获得CPU资源,开始执行其线程体run()中的代码,该线程就进入运行状态。在单核处理器的计算机上,任何时刻只能有一个线程处于运行状态。然而,在多核处理器上,多个线程可以并行执行。
然而,运行状态是短暂的,因为CPU需要公平地分配资源。对于采用抢占式调度策略的系统来说,系统会为每个可执行的线程分配一个小时间段来处理任务。当该时间段用完时,系统会剥夺该线程所占用的资源,使其返回到就绪状态,等待下一次被调度。此时,其他线程将获得执行的机会。在选择下一个线程时,系统会适当考虑线程的优先级。
4. 阻塞(Blocked)
当正在运行的线程遇到以下情况时,它会让出CPU并暂时中止执行,进入阻塞状态:
-
线程调用了sleep()方法,主动放弃所占用的CPU资源;
-
线程试图获取一个已被其他线程持有的同步监视器;
-
线程执行过程中,同步监视器调用了wait()方法,使其等待某个通知(notify);
-
线程执行过程中,同步监视器调用了wait(time)方法;
-
线程执行过程中遇到了其他线程对象的加塞(join);
-
线程被调用suspend方法挂起(已过时,因为容易导致死锁)。
当正在执行的线程被阻塞后,其他线程将有机会执行。对于上述情况,当发生以下情况时,线程将解除阻塞,重新进入就绪状态,等待线程调度器再次调度:
-
线程的sleep()时间到期;
-
线程成功获取了同步监视器;
-
线程收到了通知(notify);
-
线程等待的时间到期;
-
加塞的线程结束了;
-
被挂起的线程又被调用了resume恢复方法(已过时,因为容易导致死锁)。
5. 死亡(Dead)
线程以以下三种方式之一结束,结束后进入死亡状态:
-
run()方法的执行完成,线程正常结束;
-
线程执行过程中抛出了未捕获的异常(Exception)或错误(Error);
-
直接调用该线程的stop方法来结束该线程(已过时)。
JDK1.5及之后:6种状态
在Java的java.lang.Thread.State
枚举类中,定义了以下状态:
-
NEW(新建)
:表示线程刚被创建但尚未启动,即还未调用start
方法。 -
RUNNABLE(可运行)
:此状态没有区分就绪和运行状态。对于Java对象而言,只能标记为可运行。具体的运行时间由操作系统进行调度,因此对于Java对象的状态来说,无法区分它们在什么时候运行。 -
TERMINATED(被终止)
:表示线程已经结束其生命周期,不再运行。
需要重点说明的是,根据Thread.State
的定义,阻塞状态分为三种:BLOCKED
、WAITING
和TIMED_WAITING
。
-
BLOCKED(锁阻塞)
:该状态在API中的介绍为:线程正在等待获取一个监视器锁(锁对象)。只有获得锁对象的线程才能继续执行。-
例如,如果线程A和线程B使用同一个锁对象,当线程A获取到锁时,线程A进入
RUNNABLE
状态,而线程B则进入BLOCKED
状态。
-
-
TIMED_WAITING(计时等待)
:该状态在API中的介绍为:线程正在等待另一个线程执行某个(唤醒)动作,但设置了一个限定时间。-
当线程在执行过程中遇到
Thread
类的sleep
或join
方法、Object
类的wait
方法,或LockSupport
类的park
方法,并且在调用这些方法时设置了时间限制,线程将进入TIMED_WAITING
状态,直到指定的时间到达或被中断。
-
-
WAITING(无限等待)
:该状态在API中的介绍为:线程正在无限期地等待另一个线程执行某个特殊的(唤醒)动作。-
当线程在执行过程中遇到
Object
类的wait
方法、Thread
类的join
方法,或LockSupport
类的park
方法,并且在调用这些方法时未指定等待时间,线程将进入WAITING
状态,直到被唤醒。-
使用
Object
类的wait
方法进入WAITING
状态时,需要使用Object
的notify
或notifyAll
方法进行唤醒。 -
使用
Condition
的await
方法进入WAITING
状态时,需要使用Condition
的signal
方法进行唤醒。 -
使用
LockSupport
类的park
方法进入WAITING
状态时,需要使用LockSupport
类的unpark
方法进行唤醒。 -
使用
Thread
类的join
方法进入WAITING
状态时,只有当调用join
方法的线程对象结束时,当前线程才能恢复。
-
-
需要注意的是,当从WAITING
或TIMED_WAITING
状态恢复到RUNNABLE
状态时,如果发现当前线程未获取到监视器锁,则立即转入BLOCKED
状态。
在仔细研究API文档时,我们可以明显观察到Timed Waiting(计时等待)和Waiting(无限等待)状态之间存在密切联系。
在Waiting状态中,wait方法是不带参数的。这种情况下,线程陷入了无限期等待状态,类似于我们日常生活中设定的一个没有具体时间限制的闹钟。只有在某个特定条件满足时,通过调用相应的唤醒方法,才能使线程从这种状态中恢复。这样的设计方案旨在确保线程在等待期间不会被不必要地唤醒,以提高系统的效率。
而在Timed Waiting状态中,wait方法是带有参数的。这种情况下,我们可以将其类比为设置了倒计时的闹钟。我们设定了一个特定的时间,在时间到达时,线程会自动被唤醒。然而,如果在线程等待期间提前收到唤醒通知,那么预先设定的倒计时就变得多余了,因为此时不再需要等待预定的时间。因此,这种设计方案可以同时满足两种需求:如果未收到唤醒通知,线程将保持在Timed Waiting状态,直到倒计时结束自动唤醒;如果在倒计时期间收到唤醒通知,线程将立即从Timed Waiting状态被唤醒。
此设计方案的优势在于根据具体需求灵活地控制线程的等待时间,并在必要时及时唤醒线程,以提高程序的效率。
线程安全问题以及解决方案
-
数据不一致:
-
如果多个线程同时读写共享数据,可能会导致数据不一致的问题。例如,一个线程正在修改数据,而另一个线程正在读取同一份数据。
-
解决方法:
-
使用同步机制(如
synchronized
关键字或ReentrantLock
类)来保证同一时间只有一个线程可以修改共享数据。 -
使用
volatile
关键字来确保数据的可见性,即一个线程对数据的修改对其他线程是可见的。 -
使用
Atomic
类提供的原子操作,如AtomicInteger
、AtomicLong
等,来保证对数据的原子性操作。
-
-
-
竞态条件:
-
竞态条件指的是多线程环境下,线程的执行顺序和时序对最终结果产生影响的情况。这可能导致不确定的结果。
-
解决方法:
-
使用同步机制来保证关键操作的原子性,避免竞态条件的发生。
-
使用线程安全的数据结构,如
ConcurrentHashMap
、ConcurrentLinkedQueue
等,来避免竞态条件。
-
-
-
死锁:
-
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程无法继续执行的情况。
-
解决方法:
-
避免策略性地使用多个锁,并确保线程在获取锁时的顺序是一致的,以避免死锁的发生。
-
使用
ReentrantLock
类提供的tryLock()
方法来尝试获取锁,并设置超时时间,避免线程长时间等待。
-
-
-
线程间通信:
-
在多线程编程中,线程之间需要进行协调和通信,以避免数据错乱和线程间的竞争。
-
解决方法:
-
使用
wait()
、notify()
和notifyAll()
等方法进行线程的等待和唤醒操作。 -
使用
Lock
接口提供的条件变量(Condition
)进行线程间的通信。 -
使用并发集合类(如
BlockingQueue
、CountDownLatch
、CyclicBarrier
等)来实现线程间的同步和通信。
-
-
-
不可变对象:
-
不可变对象是指一旦创建就不能被修改的对象。在多线程环境下,使用不可变对象可以避免许多线程安全问题。
-
解决方法:
-
将共享数据设计成不可变对象,确保它们的状态不会被修改。
-
如果需要修改数据,创建一个新的对象,而不是直接修改原有的共享对象。
-
-
线程同步
概述
线程同步是一种机制,用于解决多线程并发访问共享资源时可能引发的线程安全问题。当多个线程同时访问共享资源时,如果没有适当的同步机制,可能会导致数据不一致、竞态条件和其他并发问题。
在线程同步中,常用的同步机制包括锁、互斥量、信号量和条件变量等。这些机制都提供了一种方式,让线程能够互斥地访问共享资源,即一次只允许一个线程访问资源,其他线程需要等待。
同步机制的核心思想是通过引入临界区(Critical Section)来保护共享资源。临界区是指一段代码,只有一个线程可以进入执行,其他线程必须等待。通过对临界区的控制,可以确保共享资源在任意时刻只被一个线程访问,从而避免并发访问引发的问题。
在实现线程同步时,需要注意以下几点:
-
确定共享资源:明确哪些数据或对象是多个线程共享的。
-
选择合适的同步机制:根据具体情况选择适当的同步机制,如锁、互斥量等。
-
定义临界区:确定哪些代码段需要保护起来,确保在同一时间只有一个线程可以执行。
-
确保同步操作的顺序和逻辑正确:在临界区内对共享资源进行操作时,确保操作的顺序和逻辑是正确的,避免出现数据不一致的情况。
-
避免死锁和饥饿:设计同步机制时,需要注意避免死锁(Deadlock)和饥饿(Starvation)的情况发生。
同步机制解决线程安全问题的原理
同步机制的原理实际上是给某段代码加上了"锁",任何线程在执行这段代码之前都必须先获取这个"锁",我们称之为同步锁。Java对象在堆中的数据分为对象头、实例变量和空白填充。其中,对象头包含以下内容:
-
标记字(Mark Word):记录与当前对象相关的GC、锁标记等信息。
-
类指针:每个对象需要记录它是由哪个类创建的。
-
数组长度(仅数组对象具有)
当某个线程获取了同步锁对象后,同步锁对象会记录该线程的ID,这样其他线程就只能等待。除非该线程释放了锁对象,其他线程才能重新获取/占用同步锁对象。
同步代码块和同步方法
同步代码块:synchronized关键字可以用于某个代码块之前,表示对该代码块的资源进行互斥访问。 格式:
synchronized(同步锁){ 需要同步操作的代码 }
同步方法:synchronized关键字直接修饰方法,表示在同一时刻只能有一个线程进入该方法,其他线程在外部等待。
public synchronized void method(){ 可能会产生线程安全问题的代码 }
同步锁机制
在《Thinking in Java》中,对并发工作的解释如下:在处理并发任务时,你需要一种方法来防止两个任务访问相同的资源(即共享资源竞争)。防止此类冲突的方法是在任务使用资源时对其进行加锁。第一个访问某个资源的任务必须锁定该资源,使其他任务无法在解锁之前访问它,而在解锁时,另一个任务可以锁定并使用该资源。
synchronized的锁是什么
同步锁对象可以是任意类型,但必须确保多个线程竞争同一个共享资源时使用相同的同步锁对象。
对于同步代码块而言,同步锁对象由程序员手动指定(通常为this或类名.class),但对于同步方法而言,同步锁对象只能是默认的:
-
静态方法:当前类的Class对象(类名.class)
-
非静态方法:this
同步操作的思考顺序
-
如何发现问题,即代码是否存在线程安全问题(非常重要): (1)明确哪些代码是多线程运行的代码。 (2)明确多个线程是否共享数据。 (3)明确多线程运行代码中是否包含多条语句操作共享数据。
-
如何解决问题(非常重要): 对于多条操作共享数据的语句,只能让一个线程执行完它们,在执行过程中,其他线程不能参与执行。 换句话说,所有操作共享数据的语句都必须放在同步范围内。
-
切记: 范围太小:不能解决安全问题。 范围太大:因为一旦某个线程获取到锁,其他线程就只能等待,所以范围太大会降低效率,无法合理利用CPU资源。
总结
线程同步是一种机制,用于在多线程环境中协调线程的执行顺序,以避免数据竞争和不一致的问题。
-
synchronized关键字:
-
synchronized关键字是Java中最常用的实现线程同步的机制。
-
使用synchronized关键字可以修饰方法或代码块,将其标记为同步代码区域。
-
同步方法:将关键字修饰在方法声明上,表示整个方法是同步的。
-
同步代码块:将关键字修饰在代码块的开始和结束处,指定需要同步的代码范围。
-
使用synchronized关键字时,每个对象都有一个内置的锁(也称为监视器锁或互斥锁),线程需要获取该锁才能执行同步代码区域。
-
-
对象锁:
-
使用synchronized关键字时,锁是以对象为单位的。每个对象都有一个内置的锁。
-
当线程执行同步方法或同步代码块时,它会尝试获取对象的锁。
-
如果锁没有被其他线程占用,线程将获得锁并执行同步代码。如果锁被占用,线程将进入阻塞状态,直到锁被释放。
-
-
类锁:
-
类锁是指对类的静态成员(包括静态方法和静态代码块)进行同步。
-
使用synchronized关键字修饰静态方法或静态代码块时,获取的是类锁。
-
类锁是针对整个类的,而不是针对类的实例。
-
-
实例锁与对象锁:
-
实例锁是指对对象的实例成员进行同步。
-
使用synchronized关键字修饰非静态方法或非静态代码块时,获取的是实例锁。
-
每个实例都有自己的实例锁,不同实例之间互不影响。
-
-
内置锁和重入性:
-
内置锁是指由synchronized关键字提供的锁机制。
-
内置锁是可重入的,即线程可以多次获取同一个锁而不会造成死锁。
-
当一个线程持有锁时,它可以进入由同一个锁保护的其他同步代码区域,而不会被阻塞。
-
-
锁的释放:
-
当线程执行完同步代码区域或遇到return语句、异常等情况时,会释放持有的锁。
-
也可以使用synchronized关键字修饰的wait()方法来释放锁,并使线程进入等待状态,直到被唤醒。
-
-
volatile关键字:
-
volatile关键字可以保证被修饰的变量对所有线程的可见性,但不能解决线程安全问题。
-
使用volatile关键字修饰变量时,线程在每次访问变量时都会从主内存中读取最新的值,而不是使用线程本地的缓存值。
-
-
Lock接口:
-
Java提供了Lock接口及其实现类(例如ReentrantLock)来实现显式锁机制。
-
Lock接口提供了更灵活的锁定和解锁操作,可以实现更复杂的同步需求。
-
synchronized与Lock的对比
synchronized和Lock是Java中用于实现线程同步的两种机制,它们在实现方式、功能和使用方面有一些区别。
需要根据具体的需求和场景选择使用synchronized还是Lock。synchronized是Java内置的同步机制,使用简单,适用于大多数情况下的线程同步;而Lock提供了更高级的功能和灵活性,适用于复杂的同步场景。
说明:开发建议中处理线程安全问题优先使用顺序为:
• Lock ----> 同步代码块 ----> 同步方法
下面是它们的详细对比:
1. 实现方式:
-
synchronized:synchronized是Java内置的关键字,通过在方法或代码块上添加synchronized关键字来实现同步。它依赖于对象的内部锁(也称为监视器锁)。
-
Lock:Lock是一个接口,在java.util.concurrent.locks包中定义,通过实例化Lock的具体实现类来获得锁。常用的实现类有ReentrantLock、ReentrantReadWriteLock.ReadLock和ReentrantReadWriteLock.WriteLock。
2. 锁的获取与释放:
-
synchronized:在进入synchronized代码块或方法时,线程会自动获取锁,执行完代码块或方法后会自动释放锁。如果线程无法获取锁,它将被阻塞,直到锁可用。
-
Lock:使用Lock时,线程需要手动获取锁和释放锁。通过调用Lock接口的lock()方法获取锁,使用完后调用unlock()方法释放锁。Lock提供了更灵活的锁获取和释放方式,可以在代码中更精确地控制锁的获取和释放。
3. 锁的灵活性:
-
synchronized:synchronized的灵活性较低,它只能实现基本的互斥同步。无法中断一个正在尝试获取synchronized锁的线程,也无法设定获取锁的超时时间。
-
Lock:Lock接口提供了更高级的功能,例如可中断的获取锁(tryLock()方法)、可限时的获取锁(tryLock(long timeout, TimeUnit unit)方法)等。这使得Lock在某些特定场景下更加灵活和可控。
4. 锁的公平性:
-
synchronized:synchronized是非公平锁,无法指定线程获取锁的顺序。
-
Lock:Lock提供了公平锁和非公平锁的选择。公平锁会按照线程的请求顺序来获取锁,而非公平锁则允许线程插队获取锁。
5. 可重入性:
-
synchronized:synchronized是可重入锁,同一个线程可以多次获取同一个锁。
-
Lock:Lock也是可重入锁,允许同一个线程多次获取同一个锁。
6. 性能:
-
synchronized:synchronized在获取和释放锁时会涉及到操作系统层面的线程阻塞和唤醒,可能会有一定的性能开销。
-
Lock:Lock的性能通常比synchronized更好,尤其是在高并发的情况下,因为它提供了更细粒度的控制和更灵活的线程调度。
7. 锁的可用性:
-
synchronized:无法监控和管理锁的状态,无法获取等待获取锁的线程数量。
-
Lock:Lock接口提供了方法用于监控和管理锁的状态,例如判断锁是否被占用、获取等待获取锁的线程数量等。
8. 适用场景:
-
synchronized:适用于简单的同步需求,易于使用和理解。
-
Lock:适用于复杂的同步需求,需要更灵活的控制和功能,例如可中断获取锁、可限时获取锁等。
特点 | synchronized | Lock |
---|---|---|
实现方式 | 关键字 | 接口 |
锁的获取与释放 | 自动获取和释放锁 | 手动获取和释放锁 |
锁的灵活性 | 低 | 高 |
中断获取锁的线程 | 不支持 | 支持 |
设置获取锁的超时时间 | 不支持 | 支持 |
锁的公平性 | 非公平锁(默认) | 可以选择公平锁或非公平锁 |
可重入性 | 支持 | 支持 |
性能 | 相对较低,因为可能存在竞争和上下文切换开销 | 相对较高,因为允许更细粒度的控制和更灵活的线程调度 |
锁的可用性 | 无法监控和管理锁的状态 | 可以监控和管理锁的状态,例如判断锁是否被占用、获取等待线程数量 |
适用场景 | 简单的同步需求,易于使用和理解 | 复杂的同步需求,需要更灵活的控制和功能 |
死锁
死锁是多线程编程中的一种常见并发问题,指的是两个或多个线程互相持有对方所需的资源,并且由于无法获取到对方所持有的资源而陷入无限等待的状态。
-
死锁条件:
-
互斥条件(Mutual Exclusion):资源只能被一个线程占用,其他线程必须等待释放。
-
请求与保持条件(Hold and Wait):线程持有至少一个资源,并且在等待其他线程所持有的资源。
-
不可剥夺条件(No Preemption):线程已获得的资源不能被剥夺,只能在使用完后自行释放。
-
循环等待条件(Circular Wait):存在一个线程资源的循环链,每个线程都在等待下一个线程所持有的资源。
-
-
死锁示例:
-
假设有两个线程A和B,以及两个共享资源X和Y。
-
线程A首先获取资源X,然后请求资源Y。
-
同时,线程B已经获取资源Y,然后请求资源X。
-
由于互斥条件和不可剥夺条件,线程A无法获取到资源Y,线程B也无法获取到资源X。
-
此时,线程A和线程B都在等待对方释放所需的资源,形成循环等待条件,导致死锁。
-
-
死锁的影响:
-
死锁会导致系统进入无响应状态,无法继续正常执行。
-
死锁会浪费系统资源,因为被死锁线程所持有的资源无法被其他线程使用。
-
死锁的发生可能需要人工干预才能解决,增加了系统的管理和维护成本。
-
-
预防和避免死锁:
-
破坏死锁条件:通过设计避免互斥、请求与保持、不可剥夺和循环等待条件,可以有效避免死锁的发生。
-
使用资源有序性:对资源进行编号或排序,要求线程按照相同的顺序获取资源,避免循环等待。
-
加强资源分配策略:采用资源分配算法,如银行家算法,确保资源分配是安全的,不会导致死锁。
-
设置超时机制:在获取资源时设置超时机制,避免长时间等待而无法完成任务。
-
避免嵌套锁:尽量避免在持有一个锁的情况下请求另一个锁,减少死锁的可能性。
-
-
检测和恢复死锁:
-
死锁检测:通过算法检测系统是否处于死锁状态,常见的算法有资源分配图算法和银行家算法等。
-
死锁恢复:一旦检测到死锁,可以采取一些措施进行恢复,如终止部分线程、回滚操作或释放资源等。
-
-
解决死锁:
一旦死锁发生,往往难以通过人为干预来解决,因此我们只能尽量规避死锁的产生。为了打破诱发死锁的条件,我们可以考虑以下方法:
针对条件1 互斥条件(Mutual Exclusion):互斥条件基本上无法被破坏,因为线程需要通过互斥来解决资源的安全性问题。
针对条件2 请求与保持条件(Hold and Wait)(占用且等待):可以考虑一次性申请所有所需的资源,这样就消除了等待的问题,避免了死锁的发生。
针对条件3 不可剥夺条件(No Preemption):当线程在占用部分资源后进一步申请其他资源时,若无法获取所需资源,应主动释放已经占用的资源。
针对条件4 循环等待条件(Circular Wait):可以将资源按线性顺序进行分配。在申请资源时,优先申请序号较小的资源,以避免循环等待的情况。
线程间通信
概述
线程间通信是多线程编程中的重要概念,用于不同线程之间的数据交换和协调。
-
共享内存:
-
多个线程可以通过共享内存的方式进行通信。
-
共享内存是指多个线程可以访问和修改同一个变量或数据结构。
-
线程间通信通过读写共享内存的方式实现数据的交换和共享。
-
-
线程间通信的方法:
-
等待/通知机制:通过wait()、notify()和notifyAll()方法实现。
-
阻塞队列:通过阻塞队列(如java.util.concurrent.BlockingQueue)实现线程间的数据交换。
-
信号量:通过信号量(如java.util.concurrent.Semaphore)控制并发线程的数量。
-
栅栏:通过栅栏(如java.util.concurrent.CyclicBarrier)实现多个线程的同步点。
-
计数器:通过计数器(如java.util.concurrent.CountDownLatch)实现线程的等待和唤醒。
-
条件变量:通过条件变量(如java.util.concurrent.locks.Condition)实现线程的等待和唤醒。
-
-
等待/通知机制:
-
等待/通知机制是一种基于对象监视器的线程间通信方式。
-
对象监视器是指每个Java对象都关联着一个锁,可以用来实现同步和线程间通信。
-
wait()方法使线程进入等待状态,释放对象的锁,并等待其他线程的通知。
-
notify()方法唤醒一个等待的线程,使其从等待状态变为可运行状态。
-
notifyAll()方法唤醒所有等待的线程,使它们从等待状态变为可运行状态。
-
-
阻塞队列:
-
阻塞队列是一种支持阻塞操作的线程安全队列。
-
线程可以通过阻塞队列的put()方法将数据放入队列,如果队列已满,则线程会阻塞等待。
-
线程可以通过阻塞队列的take()方法从队列中取出数据,如果队列为空,则线程会阻塞等待。
-
-
信号量:
-
信号量是一种计数器,用于控制并发线程的数量。
-
线程可以通过信号量的acquire()方法获取许可证,如果许可证数量为0,则线程会阻塞等待。
-
线程可以通过信号量的release()方法释放许可证,使其他等待的线程获取许可证。
-
-
栅栏:
-
栅栏是一种同步辅助工具,用于等待多个线程到达一个同步点后再同时执行。
-
栅栏的构造函数指定了需要等待的线程数,当所有线程都到达栅栏时,栅栏打开,所有线程同时执行。
-
栅栏可以循环使用,即当所有线程到达栅栏后,栅栏会自动重置,可以进行下一轮的等待和执行。
-
-
计数器:
-
计数器是一种同步辅助工具,用于线程的等待和唤醒。
-
计数器的初始值设定了需要等待的线程数量,每个线程完成一定操作后,计数器的值减少。
-
当计数器的值变为0时,等待的线程会被唤醒继续执行。
-
-
条件变量:
-
条件变量是一种用于线程间通信的高级同步工具。
-
条件变量是与锁关联的,可以通过await()方法使线程等待条件满足,通过signal()方法唤醒等待的线程。
-
一个锁可以关联多个条件变量,每个条件变量可以有自己的等待集合和通知机制。
-
基础
为何需要处理线程间通信:
在需要多个线程共同完成任务且希望它们按照一定规律执行的情况下,多线程之间需要一些通信机制来协调它们的工作,以实现多线程对共享数据的协同操作。
例如,假设线程A负责生产包子,线程B负责消费包子,这里的包子可以视为共享资源。线程A和线程B执行的动作分别是生产和消费,但线程B必须等待线程A完成生产后才能执行。因此,线程A和线程B之间需要线程通信机制,即等待唤醒机制。
等待唤醒机制
等待唤醒机制是多个线程之间的一种协作机制。我们通常提到线程时常将其与竞争(race)联系在一起,例如争夺锁,但这并不是线程故事的全部,线程之间也存在协作机制。
在某个线程满足特定条件时,它会进入等待状态(wait() / wait(time)),等待其他线程执行完特定代码后将其唤醒(notify())。还可以指定等待的时间,时间到达后自动唤醒。当有多个线程进行等待时,可以使用notifyAll()来唤醒所有等待的线程。wait/notify就是线程之间的一种协作机制。
-
wait:线程停止活动,不再参与调度,进入等待集合(wait set),因此不会浪费CPU资源,也不会竞争锁。此时线程的状态为WAITING或TIMED_WAITING。它还会等待其他线程执行特定动作,即“通知(notify)”或等待时间到达。等待在该对象上的线程会从等待集合中释放出来,重新进入调度队列(ready queue)中。
-
notify:选择通知对象的等待集合中的一个线程进行释放。
-
notifyAll:释放通知对象的等待集合中的所有线程。
注意:
被通知的线程被唤醒后,不一定能立即恢复执行,因为它之前中断的地方是在同步块内,而此时它已经不持有锁。因此,它需要再次尝试获取锁(可能会面临其他线程的竞争)。只有成功获取锁后,才能在之前调用wait方法的地方继续执行。
总结如下:
如果能获取锁,线程从WAITING状态变为RUNNABLE(可运行)状态。
否则,线程从WAITING状态又变为BLOCKED(等待锁)状态。
调用wait和notify需注意的细节
-
wait()
方法与notify()
方法必须要由同一个锁对象调用。这是因为只有通过相同的锁对象调用notify()
方法,才能唤醒使用相同锁对象调用wait()
方法后的线程。这种同步保证了线程之间的正确通信和协作。 -
wait()
方法与notify()
方法是属于Object
类的方法。这是因为锁对象可以是任意对象,而任意对象的所属类都是继承了Object
类的。因此,所有的对象都可以调用wait()
和notify()
方法来实现线程间的等待和唤醒操作。 -
wait()
方法与notify()
方法必须要在同步代码块或者是同步函数中使用。这是因为在调用这两个方法时,必须通过锁对象来进行调用。否则,将会抛出java.lang.IllegalMonitorStateException
异常。通过在同步代码块或同步函数中使用wait()
和notify()
方法,我们可以保证线程在正确的同步环境下进行等待和唤醒操作,从而避免出现并发访问数据的问题。 -
当使用
wait()
方法时,需要在while
循环中检查条件。这是为了防止虚假唤醒(spurious wakeup)的情况发生,在线程被唤醒时重新检查条件是否满足。 -
在Java中,等待唤醒机制只适用于在同一个对象上等待的线程之间的通信。如果需要跨多个对象进行线程通信,可以考虑使用
Lock
和Condition
接口提供的等待唤醒机制。
同步监视器的释放时机
任何线程在进入同步代码块或同步方法之前,必须先获取对同步监视器的锁定。那么,什么时候会释放对同步监视器的锁定呢?
释放锁的操作
以下情况下,当前线程会释放对同步监视器的锁定:
-
当前线程的同步方法或同步代码块执行结束。
-
当前线程在同步代码块或同步方法中遇到break或return语句,终止了该代码块或方法的继续执行。
-
当前线程在同步代码块或同步方法中出现了未处理的错误或异常,导致当前线程异常结束。
-
当前线程在同步代码块或同步方法中执行了锁对象的wait()方法,导致当前线程被挂起,并释放锁。
不会释放锁的操作
以下情况下,线程执行同步代码块或同步方法时,不会释放锁(同步监视器):
-
程序调用Thread.sleep()或Thread.yield()方法暂停当前线程的执行。
-
其他线程调用了该线程的suspend()方法将该线程挂起。
请注意,应尽量避免使用过时的suspend()和resume()方法来控制线程。
线程池
概述
线程池是一种用于管理和重用线程的机制,它可以提高线程的创建和销毁效率,并有效控制并发线程的数量。
这些是线程池的一些关键细节和常见配置,实际使用线程池时,可以根据具体需求来选择适当的线程池实现和配置参数。
-
线程池组成:线程池由以下组件组成:
-
任务队列(Task Queue):用于存储等待执行的任务。当线程池中的线程空闲时,它们会从任务队列中获取任务并执行。
-
工作线程(Worker Threads):线程池中的实际线程。它们负责执行任务并处理任务队列中的任务。
-
线程管理器(Thread Manager):负责创建、销毁和管理线程池中的线程。
-
-
线程池工作原理:
-
当任务到达时,线程池会按照一定的调度算法从任务队列中选择一个任务分配给空闲的工作线程。
-
工作线程从任务队列中获取任务并执行。
-
执行完任务后,工作线程可以继续从任务队列中获取下一个任务,或者等待新任务的到来。
-
如果线程池中没有空闲线程,新任务可以等待,直到有线程空闲为止。
-
线程池中的线程可以重复使用,避免了创建和销毁线程的开销。
-
-
线程池的优势:
-
减少线程创建和销毁的开销:线程池可以重复利用已经创建的线程,避免频繁创建和销毁线程所带来的开销,提高系统性能。
-
控制并发线程数量:线程池可以限制并发线程的数量,防止线程过多导致系统资源耗尽或性能下降。
-
提供任务排队和调度机制:线程池使用任务队列来存储待执行的任务,并按照一定的调度算法选择任务分配给空闲线程,提供了任务的排队和调度机制。
-
-
线程池的参数和配置:
-
核心线程数(Core Pool Size):线程池中保持的最小线程数,即使线程处于空闲状态,也不会被销毁。
-
最大线程数(Maximum Pool Size):线程池中允许创建的最大线程数,包括核心线程数和临时线程数。
-
任务队列(Task Queue):用于存储等待执行的任务的数据结构,可以是有界队列或无界队列。
-
线程存活时间(Thread Keep Alive Time):当线程池中线程数量超过核心线程数时,空闲线程的最大存活时间。超过该时间后,空闲线程将被销毁,直到线程数回到核心线程数为止。
-
拒绝策略(Rejected Execution Policy):当任务无法被线程池执行时的处理策略,例如,抛出异常、丢弃任务或调用者运行等。
-
-
常见的线程池实现:
-
FixedThreadPool:固定大小的线程池,核心线程数和最大线程数相等,任务队列为无界队列。
-
CachedThreadPool:可缓存的线程池,核心线程数为0,最大线程数为Integer.MAX_VALUE,适用于执行大量短期任务的场景。
-
ScheduledThreadPool:定时任务的线程池,可以按照一定的时间间隔执行任务。
-
SingleThreadExecutor:单线程的线程池,核心线程数为1,适用于需要保证任务按顺序执行的场景。
-
线程池相关API·常用
在JDK5.0之前,我们必须手动自定义线程池。然而,从JDK5.0开始,Java提供了内置的线程池相关API,位于java.util.concurrent
包下。这些API包括ExecutorService
和Executors
。
ExecutorService
是真正的线程池接口,其常见子类为ThreadPoolExecutor。它提供以下方法:
-
void execute(Runnable command)
: 用于执行任务或命令,没有返回值。一般用于执行实现了Runnable接口的任务。 -
<T> Future<T> submit(Callable<T> task)
: 用于执行任务,具有返回值。通常用于执行实现了Callable接口的任务。 -
void shutdown()
: 用于关闭线程池,释放相关资源。
Executors
是一个线程池的工厂类,通过其静态工厂方法可以创建多种类型的线程池对象。以下是其常用方法:
-
Executors.newCachedThreadPool()
: 创建一个可根据需要创建新线程的线程池,适用于执行大量短期任务的场景。 -
Executors.newFixedThreadPool(int nThreads)
: 创建一个可重用固定线程数的线程池,适用于控制并发线程数量的场景。 -
Executors.newSingleThreadExecutor()
: 创建一个只有一个线程的线程池,适用于需要保证任务按顺序执行的场景。 -
Executors.newScheduledThreadPool(int corePoolSize)
: 创建一个线程池,它可以安排在给定延迟后运行命令或定期执行任务的场景。
拓展
Callable接口
Callable接口是Java中用于表示可返回结果并可能抛出异常的任务的接口。它是Java并发编程中的一部分,位于java.util.concurrent
包下。下面是Callable接口的全部详细细节:
-
接口定义:Callable接口是一个泛型接口,定义如下:
public interface Callable<V> { V call() throws Exception; }
其中,V
表示任务执行完毕后的返回值类型。
-
方法说明:
-
call()
方法:Callable接口中唯一的方法,用于执行具体的任务逻辑。该方法可以抛出Exception
,允许任务在执行过程中抛出异常。
-
返回值:
-
call()
方法返回一个泛型类型的结果,即V
类型的值。 -
可以使用
Future<V>
对象来获取任务的执行结果。
-
使用场景:
-
Callable接口通常与线程池(如ExecutorService)结合使用,可以通过线程池提交Callable任务进行执行。
-
由于Callable任务具有返回值,可以通过Future对象获取任务的执行结果,可以用于获取任务执行的状态、取消任务的执行、等待任务执行完成等操作。
-
与Runnable接口的对比:
-
Callable接口与Runnable接口类似,都用于表示任务,但Callable接口可以返回结果,而Runnable接口没有返回值。
-
Callable接口的
call()
方法可以抛出异常,而Runnable接口的run()
方法不能抛出受检查异常。 -
Callable任务必须通过ExecutorService的
submit()
方法提交给线程池执行,而Runnable任务可以使用execute()
方法或submit()
方法提交给线程池执行。
通过实现Callable接口,可以定义具有返回值的任务,并利用线程池进行执行和管理。这使得并发编程更加灵活,可以获取任务的执行结果,并进行后续处理。
Future接口
Future接口是Java中用于表示异步计算结果的接口,它是Java并发编程中的一部分,位于java.util.concurrent
包下。下面是Future接口的全部详细细节:
-
接口定义:Future接口是一个泛型接口,定义如下:
public interface Future<V> { boolean cancel(boolean mayInterruptIfRunning); boolean isCancelled(); boolean isDone(); V get() throws InterruptedException, ExecutionException; V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException; }
其中,V
表示异步计算的结果类型。
-
方法说明:
-
cancel(boolean mayInterruptIfRunning)
方法:尝试取消任务的执行。mayInterruptIfRunning
参数表示是否允许中断正在执行的任务。 -
isCancelled()
方法:判断任务是否被取消。 -
isDone()
方法:判断任务是否已完成(无论是正常完成、被取消还是异常完成)。 -
get()
方法:获取异步计算的结果,如果任务还未完成,则会阻塞当前线程直到任务完成并返回结果。该方法可能抛出InterruptedException
和ExecutionException
异常。 -
get(long timeout, TimeUnit unit)
方法:在指定的时间内获取异步计算的结果,如果任务还未完成,则会阻塞当前线程直到任务完成并返回结果,或者超过指定的时间。该方法可能抛出InterruptedException
、ExecutionException
和TimeoutException
异常。
-
返回值:
-
cancel()
方法返回一个boolean值,表示是否成功取消任务。 -
isCancelled()
方法返回一个boolean值,表示任务是否被取消。 -
isDone()
方法返回一个boolean值,表示任务是否已完成。 -
get()
方法返回异步计算的结果,类型为V
。
-
使用场景:
-
Future接口通常与ExecutorService结合使用,用于提交异步任务并获取任务的执行结果。
-
通过Future对象可以对任务进行管理,如取消任务的执行、判断任务是否完成、等待任务执行完成等操作。
通过使用Future接口,可以异步执行计算任务,并在需要时获取计算结果。这在并发编程中非常有用,可以提高程序的性能和响应性。