在 Java 中,实现线程主要有四种方法,这些方法本质上都依赖于 Thread
类来进行线程控制。下面将详细介绍这四种方法及其原理。
Thread
类通过创建一个继承自 Thread
类的子类,并重写其 run()
方法,将线程要执行的任务放在 run()
方法中。然后创建该子类的实例,调用 start()
方法启动线程。
优点:实现简单,直接继承 Thread
类并重写 run()
方法即可。
缺点:Java 是单继承的,继承了 Thread
类后就不能再继承其他类,灵活性较差。
Runnable
接口定义一个实现 Runnable
接口的类,实现其 run()
方法,将线程要执行的任务放在 run()
方法中。然后创建该类的实例,并将其作为参数传递给 Thread
类的构造函数,最后调用 Thread
实例的 start()
方法启动线程。Thread
类在执行时会调用 Runnable
接口的 run()
函数。
优点:将线程和需要执行的内容分离,提高了代码的可维护性和可扩展性。一个类可以同时实现 Runnable
接口和继承其他类,避免了单继承的限制。
缺点:代码相对复杂一些,需要创建 Runnable
实现类和 Thread
实例。
Callable
接口并使用 FutureTask
定义一个实现 Callable
接口的类,实现其 call()
方法,该方法可以有返回值并能抛出异常。创建该类的实例,将其作为参数传递给 FutureTask
类的构造函数,FutureTask
会将 Callable
接口适配为 Runnable
接口,因为 FutureTask
本身也实现了 Runnable
接口。然后将 FutureTask
实例作为参数传递给 Thread
类的构造函数,调用 Thread
实例的 start()
方法启动线程。FutureTask
在 run()
方法执行时,会将 Callable
接口返回的结果存储在类的实例属性中。用户可以调用 FutureTask
的 get()
方法获取线程执行的结果。
优点:可以获取线程执行的返回结果,并且可以处理异常。结合了 Runnable
接口的灵活性和返回结果的能力。
缺点:代码复杂度较高,涉及 Callable
、FutureTask
和 Thread
多个类的使用。
Callable
接口并使用线程池定义一个实现 Callable
接口的类,实现其 call()
方法。创建一个线程池,将 Callable
实例提交给线程池,线程池会返回一个 Future
对象。通过 Future
对象的 get()
方法可以获取线程执行的结果。
优点:可以复用线程,减少线程创建和销毁的开销,提高系统性能。可以方便地管理和控制线程的执行。
缺点:需要了解线程池的相关知识,使用不当可能会导致资源浪费或系统性能下降。
在多线程编程中,线程池是一个非常重要的概念,它具有诸多显著的优势,能帮助开发者更高效地管理和利用线程资源。
1.资源控制
当存在多个任务需要并发执行时,线程池可以对这些任务进行有效的管理和调度。通过线程池,我们能够精确控制所需的线程资源,避免因创建过多线程而导致系统资源耗尽,进而引发性能下降甚至系统崩溃的问题。例如,在一个 Web 服务器中,如果每个请求都创建一个新线程来处理,当请求数量急剧增加时,服务器可能会因为资源不足而无法正常工作。而使用线程池,我们可以根据服务器的硬件资源和性能需求,合理设置线程池的大小,确保系统能够稳定运行。
2.减少线程创建和销毁的消耗
线程的创建和销毁是一个相对开销较大的操作,会消耗大量的系统资源和时间。线程池通过复用线程的方式,避免了频繁创建和销毁线程所带来的消耗。当一个线程完成当前任务后,它不会被销毁,而是继续等待下一个任务的到来,从而大大提高了系统的性能和效率。
3.减少上下文切换
上下文切换是指操作系统在不同线程之间切换执行时,需要保存和恢复线程的执行状态,这也会消耗一定的系统资源和时间。由于线程池复用线程,减少了线程的创建和销毁次数,也就相应地减少了上下文切换的次数,进一步提高了系统的性能。
线程池的核心机制在于能够复用线程,这主要依赖于其内部的工作线程和任务队列。
1.工作线程的创建
线程池在初始化时会创建一定数量的工作线程。这些工作线程一旦启动,通常不会停止,而是进入一个循环状态,持续等待新任务的到来。
2.任务队列的作用
线程池内部维护了一个任务队列,用于存储待执行的任务。当有新任务提交到线程池时,这些任务会被放入任务队列中。
3.线程的复用过程
工作线程的主要工作就是从任务队列中获取需要执行的任务。具体过程如下:
如果任务队列中有任务,工作线程会从队列中取出一个任务,并对其进行调度执行。执行完当前任务后,线程不会终止,而是继续从任务队列中获取下一个任务,实现线程的复用。
如果任务队列为空,工作线程会进入等待状态,在队列中循环等待新任务的加入。一旦有新任务被提交到队列中,等待的线程会立即获取该任务并执行。
通过这种方式,线程池实现了线程的复用,避免了频繁创建和销毁线程所带来的性能开销,提高了系统的整体性能和稳定性。
综上所述,线程池在多线程编程中具有不可忽视的重要性,合理使用线程池可以有效地提高系统的性能和资源利用率。
Thread
类是 Java 实现多线程最重要的类之一,它的部分方法会和操作系统进行交互以实现线程的创建、调度和控制,但它的一些方法主要在 JVM 层面操作;同时 Java 与系统交互还有其他多种途径和类库。
Thread()
:无参构造方法,用于创建一个新的线程对象,但不会启动线程。
Thread(Runnable target)
:传入一个实现了 Runnable
接口的对象,创建一个新的线程对象。
start()
:启动线程,使线程进入就绪状态,等待 CPU 调度执行。该方法会调用线程的 run()
方法。
run()
:线程的执行体,当线程启动后,start()
方法会调用该方法。通常需要重写该方法来定义线程要执行的任务。
sleep(long millis)
:使当前线程暂停执行指定的毫秒数,线程进入阻塞状态。该方法是静态方法,可直接通过 Thread
类调用。
join()
:等待该线程终止。当在一个线程中调用另一个线程的 join()
方法时,当前线程会阻塞,直到被调用的线程执行完毕。
yield()
:暂停当前正在执行的线程对象,并执行其他线程。该方法会使当前线程从运行状态变为就绪状态,让 CPU 去调度其他线程,但并不能保证一定会让出 CPU。
interrupt()
:中断线程。调用该方法会给线程设置一个中断标志,但并不会直接终止线程,需要在线程内部检查中断标志并做相应处理。
getName()
:返回线程的名称。
setName(String name)
:设置线程的名称。
getPriority()
:返回线程的优先级。线程优先级范围是 1 - 10,默认优先级是 5。
setPriority(int newPriority)
:设置线程的优先级。
isAlive()
:测试线程是否处于活动状态。如果线程已经启动且尚未终止,则返回 true
。
currentThread()
:返回对当前正在执行的线程对象的引用。
getThreadGroup()
:返回该线程所属的线程组。
这些方法涵盖了线程的创建、启动、状态控制、信息获取等方面,是 Java 多线程编程中常用的工具。
在多线程环境中,多个线程可能会同时访问和修改共享资源。如果没有适当的同步机制,就可能会出现数据不一致、竞态条件等问题。多线程同步的目的就是确保在同一时刻只有一个或一组线程可以访问共享资源,从而保证数据的完整性和一致性。
synchronized
关键字与 wait()
、notify()
机制)Java 每个对象都关联着一个监视器(内置锁)。借助 synchronized
关键字,线程可以获取对象的监视器锁来访问被保护的代码块或方法,从而保证同一时刻只有一个线程能操作共享资源。而 wait()
、notify()
和 notifyAll()
方法则用于线程间的协作与通信。
获取锁:线程进入 synchronized
修饰的代码块或方法时,会自动获取对象的监视器锁。
释放锁并等待:在同步代码块中,线程可以调用对象的 wait()
方法,这会使该线程释放持有的锁,并进入对象的等待队列中等待。
唤醒线程:其他线程在获取到同一对象的锁后,可调用该对象的 notify()
方法随机唤醒一个等待的线程,或调用 notifyAll()
方法唤醒所有等待的线程。被唤醒的线程会重新竞争对象的锁,获取锁后继续执行后续代码。
wait()
、notify()
和 notifyAll()
方法必须在同步代码块或同步方法中调用,因为这些方法依赖于对象的监视器锁机制。若不在同步环境下调用,会抛出 IllegalMonitorStateException
异常。
当一个线程调用 a
对象的 wait()
方法释放 a
对象的锁,另一个线程拿到 a
对象的锁并执行 notify()
后,第一个线程不能直接开始执行,而是要等第二个线程的同步代码块执行完成释放锁之后才可以,下面详细解释其原因和过程:
wait()
方法当第一个线程在同步代码块中调用 a.wait()
方法时,它会做两件重要的事情:
释放当前持有的 a
对象的锁,这样其他线程就有机会获取 a
对象的锁。
该线程进入 a
对象的等待队列,处于等待状态,暂停执行后续代码。
notify()
方法另一个线程在第一个线程释放 a
对象的锁后,有机会获取到 a
对象的锁并进入同步代码块。当它在同步代码块中调用 a.notify()
方法时:
notify()
方法会从 a
对象的等待队列中随机选择一个处于等待状态的线程(这里就是第一个线程)进行唤醒。
但被唤醒的第一个线程不会立即恢复执行,因为第二个线程此时仍然持有 a
对象的锁。
第二个线程继续执行同步代码块中的剩余代码,直到同步代码块执行完毕。当同步代码块执行完成后,第二个线程会自动释放 a
对象的锁。
在第二个线程释放 a
对象的锁后,之前被唤醒的第一个线程会和其他可能正在竞争 a
对象锁的线程一起竞争该锁。如果第一个线程成功获取到 a
对象的锁,它会从调用 wait()
方法的地方继续执行后续代码。
wait()
方法的情况当一个线程调用对象的 wait()
方法时,会经历以下步骤:
进入等待队列:该线程会加入到所调用对象的等待队列中。每个 Java 对象都有一个与之关联的等待队列,用于存放调用该对象 wait()
方法而进入等待状态的线程。
进入阻塞状态:线程释放持有的对象锁,并进入阻塞状态,暂停执行后续代码。此时,其他线程可以竞争并获取该对象的锁。
被 notify()
或 notifyAll()
唤醒:当另一个线程在获取该对象的锁后调用 notify()
方法(随机唤醒一个等待线程)或 notifyAll()
方法(唤醒所有等待线程)时,调用 wait()
的线程会从阻塞状态中被唤醒。
进入就绪状态并竞争锁:被唤醒的线程不会立即继续执行,而是进入就绪状态,开始竞争该对象的锁。只有当该线程成功获取到对象的锁后,才会从调用 wait()
方法的位置继续执行后续代码。
wait(long timeout)
方法的情况wait(long timeout)
方法允许线程在指定的时间内等待,若超过该时间仍未被唤醒,则自动唤醒。具体步骤如下:
进入等待队列与阻塞状态:和调用 wait()
方法一样,线程会进入对象的等待队列,并释放对象锁,进入阻塞状态。
等待指定时间:线程在阻塞状态下等待指定的 timeout
毫秒数。
自动唤醒或被提前唤醒:
自动唤醒:如果在 timeout
毫秒内没有其他线程调用该对象的 notify()
或 notifyAll()
方法,线程会在 timeout
时间到达后自动从阻塞状态中唤醒。
提前唤醒:若在 timeout
时间内有其他线程调用了该对象的 notify()
或 notifyAll()
方法,线程会被提前唤醒。
进入就绪状态并竞争锁:无论是自动唤醒还是提前唤醒,线程都会进入就绪状态,开始竞争对象的锁。获取到锁后,从调用 wait(long timeout)
方法的位置继续执行后续代码。
ReentrantLock
等外部锁)显式锁的实现基于 Java 的并发框架,其内部会维护一个等待队列,用于存放那些因无法获取锁而被阻塞的线程。当持有锁的线程释放锁时,锁的实现会从等待队列中选择合适的线程进行唤醒,让这些线程重新竞争锁。
以 ReentrantLock
为例,它内部依赖于 AbstractQueuedSynchronizer
(AQS)来实现锁的同步机制。AQS 是 Java 并发包中很多同步组件的基础框架,它维护了一个双向链表作为等待队列。
线程阻塞:当一个线程调用 ReentrantLock
的 lock()
方法且锁已被其他线程持有时,该线程会被封装成一个节点加入到 AQS 的等待队列中,并进入阻塞状态。
线程唤醒:当持有锁的线程调用 unlock()
方法释放锁时,AQS 会从等待队列的头部取出一个节点(线程),并唤醒该线程,让其重新竞争锁。
创建锁对象:在类中创建 ReentrantLock
等锁的实例。
获取锁:线程通过调用锁对象的 lock()
方法来获取锁。
执行操作:获取到锁后,线程执行需要同步的代码。
释放锁:无论操作是否成功,都需要在 finally
块中调用锁对象的 unlock()
方法释放锁,以确保锁能被正确释放。
显式锁提供了更多高级特性,如可中断锁(lockInterruptibly()
)、公平锁(在创建锁时可指定公平性)、尝试获取锁(tryLock()
)等,能更好地满足复杂场景下的同步需求。
Atomic
系列类)java.util.concurrent.atomic
包提供了一系列原子类,如 AtomicInteger
、AtomicLong
等。这些类利用 CAS(Compare - And - Swap)算法,在硬件层面保证操作的原子性,避免了使用锁带来的性能开销。
直接调用原子类提供的原子方法进行操作,这些方法在执行时不会被其他线程中断,从而保证操作的原子性。
适用于对简单数据类型的简单操作,如递增、递减等。由于避免了锁的使用,在高并发场景下能获得较好的性能。
在操作系统层面,线程一般有新建态、就绪态、运行态、阻塞态和结束态这五种典型状态:
新建态:线程刚被创建,系统为其分配必要资源和数据结构,但尚未进入调度队列。
就绪态:线程已获得除 CPU 之外的所有所需资源,在就绪队列中等待系统调度以获取 CPU 时间片。
运行态:线程正在 CPU 上执行指令。
阻塞态:线程因等待某个事件(如 I/O 完成、锁释放等)而暂停执行,放弃 CPU 资源。
结束态:线程执行完毕或因异常退出,系统回收其占用的资源。
Java 虚拟机中线程有六种状态,定义在 Thread.State
枚举中,分别是 NEW
(新建态)、RUNNABLE
(可运行态)、BLOCKED
(阻塞态)、WAITING
(等待态)、TIMED_WAITING
(定时等待态)和 TERMINATED
(终止态)。
Java 是跨平台的编程语言,其设计目标是提供统一的编程接口,屏蔽不同操作系统底层线程管理的差异。JVM 无法精确控制线程何时能获得 CPU 时间片并真正开始执行,这是由操作系统的调度器决定的。所以 Java 把线程是否正在 CPU 上实际执行和是否具备执行条件这两种情况统一抽象为 RUNNABLE
(可运行态)。
当线程处于 RUNNABLE
状态时,意味着它已经做好了执行准备,要么正在被 CPU 执行,要么在就绪队列中等待被调度执行。Java 不区分这两种情况,是为了提供一个跨平台的统一线程状态模型,方便开发者进行多线程编程。
WAITING
(等待态):线程调用 Object.wait()
(无超时)、Thread.join()
(无超时)、LockSupport.park()
等方法后进入该状态,等待特定条件满足或其他线程唤醒。
TIMED_WAITING
(定时等待态):线程调用 Thread.sleep()
、Object.wait(long timeout)
、Thread.join(long timeout)
、LockSupport.parkNanos()
、LockSupport.parkUntil()
等带超时参数的方法后进入该状态,在指定时间内等待,超时后自动恢复。
等待态:线程进入等待态时会释放持有的锁(如果有的话),这样其他线程就可以获取该锁并执行相应的同步代码块。当线程被唤醒后,需要重新竞争锁才能继续执行。
阻塞态:线程处于阻塞态是因为无法获取锁,它本身并不持有锁。只有当持有锁的线程释放锁后,阻塞的线程才有机会竞争获取锁,进入可运行状态。
等待态:处于等待态的线程需要其他线程调用相应的唤醒方法才能被唤醒,如 Object.notify()
、Object.notifyAll()
或者 LockSupport.unpark()
。
阻塞态:处于阻塞态的线程在持有锁的线程释放锁后,会自动参与锁的竞争,一旦获取到锁就会进入可运行状态,不需要其他额外的唤醒操作。
Java 虚拟机的线程状态是对操作系统线程状态的一种更高层次的抽象和封装,它们之间存在一定的对应关系:
Java 的 NEW
态对应操作系统的新建态。
Java 的 RUNNABLE
态涵盖了操作系统的就绪态和运行态。
Java 的 BLOCKED
态、WAITING
态和 TIMED_WAITING
态大致对应操作系统的阻塞态,只是在 JVM 中根据等待原因和是否有超时做了更细致的划分。
Java 的 TERMINATED
态对应操作系统的结束态。
综上所述,Java 虚拟机基于跨平台的设计理念,采用 RUNNABLE
态来统一表示线程可执行的状态,从而简化了线程状态模型,避免了不同操作系统调度细节带来的差异。