当前位置: 首页 > news >正文

深入剖析Java线程:从基础到实战(上)

深入剖析Java线程:从基础到实战(上)

在这里插入图片描述

一、线程基础概念

在这里插入图片描述

1.1 进程与线程的区别

在操作系统的世界里,进程和线程是两个至关重要的概念,它们就像是计算机舞台上的两位主角,各自扮演着独特的角色。

定义:进程可以被看作是一个正在执行的程序的实例,它是操作系统进行资源分配和调度的基本单位 。每一个进程都有自己独立的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。打个比方,我们打开的每一个软件,比如浏览器、音乐播放器,它们在运行时都是一个独立的进程。而线程则是进程中的一个执行单元,是 CPU 调度和分派的基本单位,它比进程更轻量级 。一个进程可以包含多个线程,这些线程共享进程的资源,如内存、文件句柄等,但每个线程都有自己独立的栈空间和程序计数器,用于记录线程的执行状态和位置。可以将进程想象成一个工厂,而线程就是工厂里的工人,多个工人(线程)在同一个工厂(进程)里协作完成不同的任务。

资源占用:进程拥有独立的地址空间和丰富的系统资源,例如打开的文件、信号处理器状态等,这使得进程之间具有很强的隔离性。然而,这种隔离性也带来了较高的资源开销,每个进程都需要占用一定的内存和系统资源。相比之下,线程共享所属进程的资源,它们在同一个地址空间内运行,这大大减少了资源的占用和开销。线程之间可以方便地共享数据和通信,但也需要注意线程安全问题,避免多个线程同时访问和修改共享资源导致的数据不一致。

调度:进程作为调度的基本单位,进程之间的切换需要保存和恢复大量的上下文信息,包括地址空间、寄存器状态等,因此开销较大。而线程是 CPU 调度的基本单位,线程之间的切换只需要保存和恢复少量的寄存器状态和栈指针等信息,开销较小。这使得线程能够更加高效地利用 CPU 资源,实现更细粒度的并发控制。

为了更直观地理解进程和线程的区别,我们可以用一个生活中的例子来类比。假设你正在举办一场派对,派对场地就是一个进程,场地里的各种设施(如音响、灯光、桌椅等)就是进程所拥有的资源。而参加派对的每个人就是一个线程,大家在同一个场地里共享这些设施,进行交流、跳舞、吃东西等活动。如果要更换派对场地(切换进程),需要花费大量的时间和精力来搬运和重新布置设施;而如果只是在场地里的人之间进行活动的切换(切换线程),则相对简单快捷得多。

1.2 Java 线程的特点

在 Java 编程中,线程是实现并发编程的重要工具,它具有一些独特的特点,使得 Java 在处理多任务和并发场景时表现出色。

内存共享:Java 线程最大的特点之一就是能够共享所属进程的内存空间 。这意味着在同一个 Java 进程中的多个线程可以访问和修改相同的变量和对象,大大提高了数据的共享和传递效率。例如,我们可以创建一个共享的计数器对象,多个线程可以同时对这个计数器进行加一操作,实现对某些任务的计数统计。下面是一个简单的代码示例:

public class ThreadMemoryShareExample {// 共享变量private static int count = 0;public static void main(String[] args) {// 创建两个线程Thread thread1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {count++;}});Thread thread2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {count++;}});// 启动线程thread1.start();thread2.start();// 等待两个线程执行完毕try {thread1.join();thread2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("最终的计数值: " + count);}
}

在这个示例中,count变量是两个线程共享的,它们都可以对其进行操作。然而,由于多线程同时访问共享变量可能会导致线程安全问题,如数据竞争和不一致,因此在实际应用中需要采取一些同步机制来保证数据的正确性,这将在后面的章节中详细介绍。

并发执行:Java 线程能够实现并发执行,允许多个线程同时执行不同的任务,充分利用多核处理器的优势,提高程序的执行效率和响应速度。例如,在一个服务器应用中,可以为每个客户端请求分配一个独立的线程来处理,使得服务器能够同时响应多个客户端的请求,提供更好的用户体验。Java 的线程调度由操作系统负责,JVM 将线程的调度工作交给操作系统来管理,它会根据线程的优先级、CPU 的负载等因素来决定哪个线程获得 CPU 时间片执行。虽然我们可以通过设置线程的优先级来影响调度的顺序,但并不能完全保证线程的执行顺序,因为这还受到操作系统和其他因素的影响。

1.3 线程的生命周期

线程的生命周期就像是一个人的成长历程,从诞生到死亡,经历了多个不同的阶段。在 Java 中,线程的生命周期可以分为以下六种状态:

  1. 新建(NEW):当我们使用new关键字创建一个线程对象时,线程就处于新建状态 。此时,线程对象已经被创建,但还没有开始执行,就像一个刚出生的婴儿,还没有开始自己的 “人生旅程”。例如:
Thread thread = new Thread(() -> {// 线程执行的代码
});
  1. 就绪(RUNNABLE):当调用线程对象的start()方法后,线程进入就绪状态 。这意味着线程已经准备好运行,正在等待获取 CPU 时间片。就像一个运动员站在起跑线上,做好了起跑的准备,只等发令枪响就可以开始奔跑。在就绪状态下,线程可能会在就绪队列中等待,一旦获得 CPU 资源,就会进入运行状态。
thread.start();
  1. 运行(RUNNING):当线程从就绪状态获得了 CPU 时间片后,它进入运行状态,开始执行线程体中的代码 。此时,线程正在执行run()方法中的逻辑,就像运动员在赛道上全力奔跑一样。在运行过程中,线程可能会因为各种原因(如时间片用完、调用了yield()方法等)重新回到就绪状态。

  2. 阻塞(BLOCKED):在运行过程中,如果线程尝试获取已被其他线程锁定的对象锁,或者等待 I/O 操作完成等情况下,线程会进入阻塞状态 。一旦阻塞条件解除,线程返回到就绪状态。例如,当一个线程试图进入一个被synchronized关键字修饰的代码块,而该代码块已经被其他线程占用时,这个线程就会进入阻塞状态,等待锁的释放。可以将阻塞状态想象成运动员在比赛中遇到了障碍物,需要停下来等待障碍物清除后才能继续前进。

  3. 等待(WAITING):调用wait()join()LockSupport.park()等方法会使线程进入等待状态 。在这个状态下,线程不会自动恢复,需要其他线程显式地唤醒。例如,当一个线程调用了Object类的wait()方法后,它会释放持有的锁,并进入等待状态,直到其他线程调用notify()notifyAll()方法唤醒它。等待状态就像是运动员在比赛中主动停下来休息,等待教练的指令再继续比赛。

  4. 计时等待(TIMED_WAITING):如果线程调用了带有超时参数的方法,如sleep(long millis)wait(long timeout)join(long millis)或者LockSupport.parkNanos()等,它会进入计时等待状态 。到达指定的时间后,线程会自动恢复到就绪状态。例如,线程调用sleep(1000)方法,会使线程暂停执行 1000 毫秒,在这期间线程处于计时等待状态,时间一到就会自动醒来,进入就绪状态。计时等待状态可以看作是运动员在比赛中给自己设定了一个休息时间,休息结束后就继续比赛。

  5. 终止(TERMINATED):当线程完成了所有任务或因为异常而停止运行时,它进入终止状态 。线程一旦终止,就不能再回到任何其他状态,就像一个人完成了自己的人生使命,生命结束。例如,当线程的run()方法执行完毕,或者在执行过程中抛出了未捕获的异常,线程就会进入终止状态。

为了更清晰地展示线程生命周期的状态转换,我们可以用下面的状态图来表示:

在这里插入图片描述

下面通过一个代码示例来展示线程生命周期的状态转换过程:

public class ThreadLifecycleExample {public static void main(String[] args) throws InterruptedException {// 创建一个新的线程Thread thread = new Thread(() -> {System.out.println(Thread.currentThread().getName() + " 正在运行...");try {// 线程进入计时等待状态,休眠2秒Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + " 执行完毕!");}, "MyThread");// 打印线程的初始状态(NEW)System.out.println("线程状态: " + thread.getState());// 启动线程,进入RUNNABLE状态thread.start();// 打印线程启动后的状态(RUNNABLE)System.out.println("线程状态: " + thread.getState());// 主线程休眠500毫秒,确保子线程进入TIMED_WAITING状态Thread.sleep(500);// 打印此时线程的状态(TIMED_WAITING)System.out.println("线程状态: " + thread.getState());// 主线程等待子线程执行完毕thread.join();// 打印线程执行完毕后的状态(TERMINATED)System.out.println("线程状态: " + thread.getState());}
}

在这个示例中,我们创建了一个线程,并依次打印了线程在不同阶段的状态,通过运行这个程序,可以直观地看到线程生命周期的状态转换过程。

二、Java 线程的创建与启动

在 Java 中,线程的创建与启动是并发编程的基础操作,Java 提供了多种方式来实现这一过程,每种方式都有其独特的特点和适用场景。下面我们将详细介绍 Java 线程创建与启动的常见方式。

2.1 继承 Thread 类

继承Thread类是创建线程最直接的方式之一。通过继承Thread类并重写其run方法,我们可以定义线程的执行逻辑。run方法中包含了线程要执行的任务代码,当线程启动后,run方法中的代码将在新的线程中执行。

以下是一个通过继承Thread类创建线程的示例代码:

// 自定义线程类,继承自Thread类
class MyThread extends Thread {// 重写run方法,定义线程执行的任务@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + " 正在执行: " + i);}}
}public class ThreadCreationByInheritance {public static void main(String[] args) {// 创建自定义线程类的实例MyThread myThread = new MyThread();// 设置线程名称(可选)myThread.setName("MyCustomThread");// 启动线程myThread.start();// 主线程继续执行自己的任务for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + " 正在执行: " + i);}}
}

在上述代码中:

  • MyThread类继承自Thread类,并重写了run方法,在run方法中使用循环打印线程的执行信息。

  • main方法中,创建了MyThread类的实例myThread,并调用start方法启动线程。调用start方法后,Java 虚拟机将为该线程分配资源,并将其置于就绪状态,等待 CPU 调度执行。此时,run方法中的代码将在新的线程中执行,而main方法中的代码也会继续在主线程中执行,实现了多线程并发执行的效果。

需要注意的是,直接调用run方法并不会启动新的线程,而是在当前线程中直接执行run方法中的代码,这与调用普通方法没有区别。只有调用start方法才能真正启动一个新的线程。

2.2 实现 Runnable 接口

实现Runnable接口是另一种常见的创建线程的方式。这种方式相比继承Thread类具有更高的灵活性,因为 Java 不支持多重继承,而一个类可以实现多个接口。通过实现Runnable接口,我们可以将线程的任务逻辑与线程对象分离,提高代码的可维护性和复用性。

实现Runnable接口的步骤如下:

  1. 创建一个类,实现Runnable接口。

  2. 在实现类中重写run方法,将线程要执行的任务代码放入run方法中。

  3. 创建实现类的实例,并将其作为参数传递给Thread类的构造函数,创建Thread对象。

  4. 调用Thread对象的start方法启动线程。

下面是一个实现Runnable接口创建线程的示例代码:

// 实现Runnable接口的类
class MyRunnable implements Runnable {// 重写run方法,定义线程执行的任务@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + " 正在执行: " + i);}}
}public class ThreadCreationByRunnable {public static void main(String[] args) {// 创建实现Runnable接口的类的实例MyRunnable myRunnable = new MyRunnable();// 使用MyRunnable实例创建Thread对象Thread thread = new Thread(myRunnable, "MyRunnableThread");// 启动线程thread.start();// 主线程继续执行自己的任务for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + " 正在执行: " + i);}}
}

在这个示例中:

  • MyRunnable类实现了Runnable接口,并在run方法中定义了线程的执行逻辑。

  • main方法中,首先创建了MyRunnable类的实例myRunnable,然后将其作为参数传递给Thread类的构造函数,创建了一个Thread对象thread。最后调用threadstart方法启动线程。

实现Runnable接口的方式更适合多个线程共享同一任务逻辑的场景。例如,在一个多线程的服务器应用中,每个客户端请求都可以由同一个实现Runnable接口的任务来处理,通过创建多个Thread对象并传入相同的Runnable实例,就可以实现多个线程并发处理不同的客户端请求,提高服务器的并发处理能力。同时,由于Runnable只是一个接口,实现它的类还可以继承其他类,这大大增强了代码的灵活性和扩展性。

2.3 实现 Callable 接口

Callable接口是 Java 5.0 引入的,它类似于Runnable接口,但提供了更强大的功能。与Runnable接口不同的是,Callable接口的call方法可以返回执行结果,并且可以抛出异常。这使得Callable接口非常适合用于需要获取线程执行结果的场景,比如异步计算、数据查询等。

使用Callable接口创建线程通常需要结合FutureFutureTask类来获取线程的执行结果。Future接口代表异步计算的结果,它提供了方法来检查计算是否完成、等待计算完成以及获取计算结果。FutureTask类则是Future接口的一个实现,同时它还实现了Runnable接口,因此可以作为Thread的构造参数,用于启动线程。

以下是一个实现Callable接口创建线程并获取结果的示例代码:

import java.util.concurrent.*;// 实现Callable接口的类
class MyCallable implements Callable<Integer> {// 重写call方法,定义线程执行的任务并返回结果@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 1; i <= 100; i++) {sum += i;}return sum;}
}public class ThreadCreationByCallable {public static void main(String[] args) throws ExecutionException, InterruptedException {// 创建实现Callable接口的类的实例MyCallable myCallable = new MyCallable();// 使用MyCallable实例创建FutureTask对象FutureTask<Integer> futureTask = new FutureTask<>(myCallable);// 使用FutureTask对象创建Thread对象Thread thread = new Thread(futureTask);// 启动线程thread.start();// 获取线程执行结果(此方法会阻塞,直到线程执行完成并返回结果)Integer result = futureTask.get();System.out.println("线程执行结果: " + result);}
}

在上述代码中:

  • MyCallable类实现了Callable接口,并在call方法中实现了计算 1 到 100 的整数和的逻辑,最后返回计算结果。

  • main方法中,创建了MyCallable类的实例myCallable,并将其传递给FutureTask的构造函数创建futureTask对象。futureTask既实现了Future接口,又实现了Runnable接口。然后将futureTask作为参数传递给Thread类的构造函数创建thread对象,并调用start方法启动线程。最后,通过调用futureTask.get()方法获取线程的执行结果,该方法会阻塞当前线程,直到futureTask代表的异步任务执行完成并返回结果。

2.4 三种方式的对比与选择建议

在 Java 中,创建线程的四种常见方式(继承Thread类、实现Runnable接口、实现Callable接口以及使用线程池,线程池部分将在后续章节介绍)各有优缺点,在实际应用中需要根据具体的需求和场景来选择合适的方式。下面从代码结构、功能特性、适用场景等方面对这四种方式进行对比,并给出选择建议。

代码结构

  • 继承Thread:代码结构简单直观,直接继承Thread类并重写run方法即可定义线程的执行逻辑。但由于 Java 单继承的限制,继承了Thread类后,该类无法再继承其他类,这在一定程度上限制了代码的扩展性。

  • 实现Runnable接口:将线程的任务逻辑与线程对象分离,代码结构更加清晰,符合面向对象的设计原则。实现Runnable接口的类还可以继承其他类,提高了代码的灵活性和复用性。

  • 实现Callable接口:与实现Runnable接口类似,但Callable接口的call方法可以返回结果和抛出异常,功能更加强大。然而,由于需要结合FutureFutureTask类来获取结果,代码结构相对复杂一些。

功能特性

  • 继承Thread:不支持返回线程执行结果,run方法不能抛出受检异常。适用于简单的线程任务,不需要获取线程执行结果和处理异常的场景。

  • 实现Runnable接口:同样不支持返回线程执行结果,run方法也不能抛出受检异常。适用于多个线程共享同一任务逻辑,不需要返回结果的场景,如打印日志、简单的任务执行等。

  • 实现Callable接口:支持返回线程执行结果,并且call方法可以抛出异常。适用于需要获取线程执行结果或处理异常的场景,如异步计算、数据查询等。

适用场景

  • 继承Thread:适合初学者学习线程的基本概念和使用方法,以及一些简单的、独立的线程任务,这些任务不需要与其他类有继承关系,并且不需要返回执行结果。例如,一个简单的定时任务,每隔一段时间执行一次特定的操作。

  • 实现Runnable接口:是最常用的创建线程方式之一,适用于大多数多线程应用场景。特别是当一个类已经继承了其他类,或者需要多个线程共享同一任务逻辑时,实现Runnable接口是更好的选择。比如在一个多线程的文件处理程序中,多个线程可以共享同一个实现Runnable接口的文件处理任务,提高代码的复用性。

  • 实现Callable接口:当线程任务需要返回执行结果,或者在执行过程中可能抛出异常并需要进行处理时,应该使用Callable接口。例如,在分布式计算中,一个线程负责执行复杂的计算任务,完成后需要返回计算结果供其他线程或模块使用。

综上所述,在选择创建线程的方式时,需要综合考虑代码的结构、功能需求以及应用场景等因素。对于简单的任务,继承Thread类或实现Runnable接口可能就足够了;而对于复杂的任务,特别是需要返回结果或处理异常的情况,应该选择实现Callable接口。通过合理选择创建线程的方式,可以编写出高效、健壮的多线程程序。
其实还有一种叫线程池的方式,后面会对线程池进行介绍。

三、线程的基本操作

在 Java 多线程编程中,除了创建和启动线程外,还需要掌握一些线程的基本操作,这些操作可以帮助我们更好地控制线程的执行流程,实现复杂的多线程应用场景。下面将介绍线程的睡眠(sleep)、让步(yield)、加入(join)以及线程优先级的设置与影响。

3.1 线程的睡眠(sleep)

Thread.sleep是 Java 中用于让当前线程进入休眠状态一段时间的方法。当一个线程调用Thread.sleep方法后,它会进入 “计时等待” 状态,直到指定的时间过去,然后自动被唤醒并进入就绪状态,等待 CPU 调度执行。例如,Thread.sleep(1000)表示让当前线程暂停执行 1000 毫秒(1 秒)。

Thread.sleep方法有两个重载版本:

  • public static native void sleep(long millis) throws InterruptedException:使当前线程睡眠指定的毫秒数。

  • public static void sleep(long millis, int nanos) throws InterruptedException:使当前线程睡眠指定的毫秒数和纳秒数。

Thread.sleep方法的唤醒主要依赖于以下两个条件:

  • 时间到达:当指定的睡眠时间过去后,线程会自动从 “计时等待” 状态中被唤醒,进入就绪状态,等待 CPU 调度继续执行后续的代码。

  • 中断:如果在一个线程调用Thread.sleep时,另一个线程对其调用了interrupt()方法,那么这个休眠的线程会被唤醒,并抛出一个InterruptedException异常。这个异常可以被捕获,从而终止或调整线程的行为。

下面通过一个代码示例来演示Thread.sleep方法的使用:

public class SleepExample {public static void main(String[] args) {Thread thread = new Thread(() -> {System.out.println("子线程开始,休眠3秒");try {Thread.sleep(3000); // 子线程休眠3秒} catch (InterruptedException e) {System.out.println("子线程被唤醒");}System.out.println("子线程结束");});thread.start();System.out.println("主线程休眠1秒");try {Thread.sleep(1000); // 主线程休眠1秒} catch (InterruptedException e) {e.printStackTrace();}System.out.println("唤醒子线程");thread.interrupt(); // 中断子线程,尝试唤醒它}
}

在上述示例中:

  • 首先创建了一个子线程thread,在子线程的run方法中,输出 “子线程开始,休眠 3 秒”,然后调用Thread.sleep(3000)使子线程休眠 3 秒。

  • 在主线程中,输出 “主线程休眠 1 秒”,然后调用Thread.sleep(1000)使主线程休眠 1 秒。

  • 主线程休眠 1 秒后,调用thread.interrupt()中断子线程。此时,如果子线程正在睡眠,它会被唤醒并抛出InterruptedException异常,在catch块中捕获异常并输出 “子线程被唤醒”。

  • 最后,子线程无论是否被中断,都会继续执行catch块后面的代码,输出 “子线程结束”。

3.2 线程的让步(yield)

Thread.yield是 Java 中一个用于线程调度的方法,它的作用是暂停当前正在执行的线程对象,并使该线程进入就绪状态,以便让具有相同优先级的其他线程有机会执行。简单来说,yield方法可以让当前线程主动让出 CPU 资源,给其他线程一个执行的机会。

yield方法是Thread类的一个静态方法,其定义如下:public static native void yield();。当一个线程调用yield方法时,它会从运行状态回到就绪状态,而不是阻塞状态。这意味着该线程仍然可以被调度器选中执行,只是它不再是当前正在执行的线程。调度器会根据线程的优先级和其他因素来决定下一个要执行的线程。

需要注意的是,yield方法只是一个提示性的方法,它并不能保证当前线程一定会让出 CPU 资源,也不能保证其他线程一定会被选中执行。调度器仍然可以根据自己的算法和策略来决定线程的执行顺序。在大多数情况下,yield方法将导致线程从运行状态转到就绪状态,但有可能没有效果。

下面通过一个示例代码来展示yield方法的使用:

public class YieldExample {public static void main(String[] args) {Thread thread1 = new Thread(() -> {for (int i = 0; i < 10; i++) {System.out.println("Thread 1: " + i);if (i % 3 == 0) {Thread.yield(); // 当i能被3整除时,调用yield方法}}});Thread thread2 = new Thread(() -> {for (int i = 0; i < 10; i++) {System.out.println("Thread 2: " + i);}});thread1.start();thread2.start();}
}

在这个示例中,创建了两个线程thread1thread2thread1在每次循环中,当i能被 3 整除时,调用Thread.yield()方法,让出 CPU 资源,使thread2有机会执行。由于yield方法的不确定性,实际运行结果可能会有所不同,但在理想情况下,两个线程会交替执行,输出的结果可能会是Thread 1: 0Thread 2: 0Thread 1: 1Thread 2: 1等等。

3.3 线程的加入(join)

在 Java 多线程编程中,join方法是一个非常有用的方法,它主要用于控制线程的执行顺序。当在一个线程中调用另一个线程的join方法时,当前线程会暂停执行,直到被调用join方法的线程执行完毕,当前线程才会继续执行。

join方法是Thread类的一个实例方法,它有三个重载版本:

  • public final void join() throws InterruptedException:等待被调用join方法的线程执行完毕。

  • public final synchronized void join(long millis) throws InterruptedException:等待被调用join方法的线程执行完毕,最多等待指定的毫秒数。如果在指定的时间内线程执行完毕,或者等待时间超时,当前线程都会继续执行。

  • public final synchronized void join(long millis, int nanos) throws InterruptedException:等待被调用join方法的线程执行完毕,最多等待指定的毫秒数和纳秒数。

join方法的实现原理是通过调用线程的wait方法来达到同步的目的。例如,在 A 线程中调用了 B 线程的join方法,则相当于 A 线程调用了 B 线程的wait方法,在调用了 B 线程的wait方法后,A 线程就会进入阻塞状态,当 B 线程执行完(或者到达等待时间),B 线程会自动调用自身的notifyAll方法唤醒 A 线程,从而达到同步的目的。

下面通过一个代码示例来演示join方法的使用:

public class JoinExample {public static void main(String[] args) throws InterruptedException {System.out.println("主线程开始执行");Thread thread = new Thread(() -> {System.out.println("子线程开始执行");try {Thread.sleep(2000); // 让子线程休眠2秒,模拟子线程执行任务} catch (InterruptedException e) {e.printStackTrace();}System.out.println("子线程执行完毕");});thread.start(); // 启动子线程thread.join(); // 主线程等待子线程结束System.out.println("主线程确认子线程已执行完毕,继续执行");}
}

在上述示例中:

  • 首先在main方法中输出 “主线程开始执行”。

  • 然后创建一个子线程thread,在子线程的run方法中,输出 “子线程开始执行”,接着调用Thread.sleep(2000)使子线程休眠 2 秒,模拟子线程执行任务,最后输出 “子线程执行完毕”。

  • 在主线程中启动子线程后,调用thread.join()方法,这会使主线程阻塞,直到子线程执行完毕。

  • 当子线程执行完毕后,主线程被唤醒,继续执行后面的代码,输出 “主线程确认子线程已执行完毕,继续执行”。

3.4 线程优先级的设置与影响

在 Java 中,每个线程都有一个优先级,优先级高的线程在竞争 CPU 资源时更有优势,更有可能被线程调度器选中执行。线程优先级的范围是 1 到 10,分别用Thread.MIN_PRIORITY(值为 1)、Thread.NORM_PRIORITY(值为 5)和Thread.MAX_PRIORITY(值为 10)来表示最低优先级、普通优先级和最高优先级。

可以通过setPriority方法来设置线程的优先级,通过getPriority方法来获取线程的优先级。例如:

Thread thread = new Thread(() -> {// 线程执行的任务
});
thread.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级为最高
int priority = thread.getPriority(); // 获取线程的优先级

需要注意的是,虽然线程优先级可以影响线程的调度顺序,但它并不能完全保证线程的执行顺序。这是因为线程的调度是由操作系统来完成的,不同的操作系统对于线程调度的策略是不同的。在某些操作系统中,线程的优先级可能并不起作用,线程的切换是完全随机的。

下面通过一个示例代码来展示线程优先级对线程调度的影响:

public class PriorityExample {public static void main(String[] args) {Thread highPriorityThread = new Thread(() -> {for (int i = 0; i < 10; i++) {System.out.println("高优先级线程: " + i);}});Thread lowPriorityThread = new Thread(() -> {for (int i = 0; i < 10; i++) {System.out.println("低优先级线程: " + i);}});highPriorityThread.setPriority(Thread.MAX_PRIORITY);lowPriorityThread.setPriority(Thread.MIN_PRIORITY);highPriorityThread.start();lowPriorityThread.start();}
}

在这个示例中,创建了两个线程highPriorityThreadlowPriorityThread,分别设置它们的优先级为最高和最低。然后启动这两个线程,观察它们的执行顺序。在理想情况下,高优先级线程会优先获得 CPU 资源,更多地被执行,但实际运行结果可能会因为操作系统的不同而有所差异。在某些系统中,可能会看到高优先级线程几乎一直执行,直到完成任务;而在另一些系统中,可能仍然会出现低优先级线程也能得到执行的情况,因为线程优先级只是一个提示,操作系统不一定会完全按照优先级来调度线程。

四、线程同步与锁机制

在多线程编程中,线程同步与锁机制是至关重要的概念,它们用于解决多线程访问共享资源时可能出现的线程安全问题,确保程序在并发环境下的正确性和稳定性。

4.1 线程安全问题的产生

当多个线程同时访问和修改共享资源时,就可能会出现线程安全问题。这是因为线程的执行是由操作系统调度的,具有不确定性,多个线程可能会在同一时间对共享资源进行读写操作,从而导致数据不一致或其他不可预测的结果。

为了更直观地理解线程安全问题的产生,我们来看一个简单的代码示例:

public class UnsafeCounter {private int count = 0;public void increment() {count++;}public int getCount() {return count;}
}public class ThreadSafetyExample {public static void main(String[] args) throws InterruptedException {UnsafeCounter counter = new UnsafeCounter();Thread thread1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {counter.increment();}});Thread thread2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {counter.increment();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("预期结果应该是2000,实际结果: " + counter.getCount());}
}

在上述代码中,UnsafeCounter类有一个count变量和一个increment方法,increment方法用于对count进行加 1 操作。在ThreadSafetyExample类的main方法中,创建了两个线程thread1thread2,它们都对counter进行 1000 次的increment操作。理论上,最终的count值应该是 2000,但在实际运行中,由于count++操作不是原子性的,它包含了读取、修改和写入三个步骤,在多线程环境下,这三个步骤可能会被其他线程打断,从而导致数据不一致。多次运行上述代码,可能会得到不同的结果,且结果往往小于 2000。

4.2 synchronized 关键字

为了解决线程安全问题,Java 提供了synchronized关键字,它可以用于修饰方法或代码块,确保同一时间只有一个线程能够执行被synchronized修饰的代码,从而实现线程同步。

修饰方法

synchronized修饰实例方法时,锁是当前实例对象。这意味着,同一时刻只有一个线程能够进入该方法,其他试图进入的线程将会被阻塞,直到当前线程执行完毕。例如:

public class SynchronizedMethodExample {private int count = 0;public synchronized void increment() {count++;}public int getCount() {return count;}
}

在上述代码中,increment方法被synchronized修饰,当一个线程调用increment方法时,它会自动获取当前对象的锁,其他线程如果想要调用该方法,必须等待当前线程释放锁。

synchronized修饰静态方法时,锁是当前类的Class实例。这意味着,无论通过哪个实例对象去调用该静态方法,同一时刻都只有一个线程能够执行该方法。例如:

public class SynchronizedStaticMethodExample {private static int count = 0;public static synchronized void increment() {count++;}public static int getCount() {return count;}
}

在这个例子中,increment是一个静态方法,被synchronized修饰后,锁是SynchronizedStaticMethodExample.class,所有对该静态方法的调用都需要获取这个类锁。

修饰代码块

synchronized还可以用于修饰代码块,此时需要指定一个对象作为锁。同一时刻只有一个线程能够进入被该对象锁定的代码块。使用代码块可以精确控制同步的范围,从而提高性能。例如:

public class SynchronizedBlockExample {private int count = 0;private final Object lock = new Object();public void increment() {synchronized (lock) {count++;}}public int getCount() {return count;}
}

在上述代码中,定义了一个lock对象,increment方法中的synchronized代码块使用lock作为锁。当一个线程进入这个代码块时,它会获取lock对象的锁,其他线程如果想要进入该代码块,必须等待lock对象的锁被释放。

原理分析

synchronized的实现原理主要依赖于 JVM 的内部机制,包括对象头、Monitor(监视器锁)等概念。Java 对象在内存中的布局包括对象头、实例变量和填充数据。对象头中存储了关于对象的元数据信息、哈希码、GC 分代年龄以及锁状态等信息。synchronized关键字就是通过对对象头的操作来实现锁定的。每个对象都有一个与之关联的监视器锁(Monitor)。当线程试图执行synchronized修饰的代码块或方法时,它必须先获取该对象的监视器锁。如果锁已经被其他线程持有,则当前线程将被阻塞,直到锁被释放。在 JDK 1.6 之后,synchronized的锁进行了优化,主要包括偏向锁、轻量级锁和重量级锁三种状态。偏向锁是为了减少无竞争情况下的同步开销,轻量级锁则是为了减少线程挂起和唤醒的开销,而重量级锁则是通过操作系统的互斥量(Mutex)来实现的,性能开销相对较大。锁的升级过程是根据竞争情况逐步升级的,以提高性能。

4.3 Lock 接口与 ReentrantLock 类

除了synchronized关键字,Java 还提供了Lock接口及其实现类来实现更灵活的线程同步控制。Lock接口定义了一套用于控制对共享资源访问的方法,它提供了比synchronized关键字更细粒度的控制、可中断的锁获取操作、超时获取锁等功能。

Lock接口包含以下主要方法:

  • void lock():获取锁。如果锁不可用,当前线程将被阻塞,直到锁可用。

  • void lockInterruptibly() throws InterruptedException:获取锁,同时允许线程在等待锁的过程中被中断。

  • boolean tryLock():尝试获取锁。如果锁可用,立即返回true并获取锁;否则,立即返回false

  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException:在指定的等待时间内尝试获取锁,如果在等待时间内获取到锁,则返回true

  • void unlock():释放锁。

  • Condition newCondition():返回一个与该锁关联的Condition对象,用于实现线程间的通信。

ReentrantLockLock接口的一个常用实现类,它是一个可重入的互斥锁,具有与使用synchronized加锁一样的特性,并且功能更加强大。以下是一个使用ReentrantLock的示例:

import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockExample {private int count = 0;private final ReentrantLock lock = new ReentrantLock();public void increment() {lock.lock();try {count++;} finally {lock.unlock();}}public int getCount() {return count;}
}

在上述代码中,创建了一个ReentrantLock对象lock,在increment方法中,首先调用lock.lock()获取锁,然后执行对count的加 1 操作,最后在finally块中调用lock.unlock()释放锁,以确保无论是否发生异常,锁都能被正确释放。

与 synchronized 的对比

  • 锁获取与释放方式synchronized是由 JVM 自动管理锁的获取和释放,当线程进入同步代码块或方法时自动获取锁,退出时自动释放锁;而ReentrantLock需要手动调用lock()方法获取锁,调用unlock()方法释放锁,并且为了确保锁能被正确释放,通常将unlock()方法放在finally块中。

  • 可中断性synchronized关键字获取锁时,如果锁被其他线程持有,当前线程会一直阻塞,无法被中断;而ReentrantLock提供了lockInterruptibly()方法,允许线程在等待锁的过程中被中断。

  • 公平性synchronized关键字是非公平锁,它不保证线程获取锁的顺序;ReentrantLock默认也是非公平锁,但可以通过构造函数new ReentrantLock(true)创建公平锁,公平锁会按照线程请求锁的顺序来分配锁。

  • 功能特性ReentrantLock还提供了更多的功能,如tryLock()方法可以尝试获取锁而不阻塞,tryLock(long time, TimeUnit unit)方法可以在指定时间内尝试获取锁,newCondition()方法可以创建多个条件变量,实现更灵活的线程间通信。

4.4 线程安全的集合类

在多线程编程中,使用线程安全的集合类可以避免由于多线程访问共享集合而导致的数据不一致和其他线程安全问题。Java 提供了一些线程安全的集合类,以下是一些常见的线程安全集合类及其特点:

ConcurrentHashMapConcurrentHashMap是线程安全且高效的并发集合,它在多线程环境下被广泛使用。在 JDK 1.7 中,ConcurrentHashMap使用了分段锁(Segment)的设计,将数据划分为多个段,每个段对应一个锁,不同线程访问不同段的数据时可以同时进行而不互相阻塞,从而提高了并发性能。在 JDK 1.8 中,ConcurrentHashMap摒弃了 Segment 锁机制,采用了数组 + 链表 + 红黑树的组合数据结构,并且使用 CAS(Compare And Swap)和synchronized关键字来实现线程安全。在进行增删改查时,只需要锁住当前操作的链表头部节点即可,大大降低了锁的粒度,进一步提升了并发效率。例如:

import java.util.concurrent.ConcurrentHashMap;public class ConcurrentHashMapExample {public static void main(String[] args) {ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();map.put("key1", 1);map.put("key2", 2);Integer value = map.get("key1");System.out.println(value);}
}

CopyOnWriteArrayList 和 CopyOnWriteArraySetCopyOnWriteArrayListCopyOnWriteArraySet适用于读多写少的场景。它们在修改时会创建新的底层数组,避免了修改时的锁定,从而提高了读取性能。当有线程对集合进行写操作(如添加、删除元素)时,会先复制一份当前的数组,在新数组上进行修改,修改完成后再将原数组的引用指向新数组。而读操作始终是在原数组上进行,不会受到写操作的影响。例如:

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;public class CopyOnWriteArrayListExample {public static void main(String[] args) {List<String> list = new CopyOnWriteArrayList<>();list.add("element1");list.add("element2");for (String element : list) {System.out.println(element);}}
}

ConcurrentLinkedQueueConcurrentLinkedQueue是一个线程安全的无界队列,它采用链表结构实现,适用于高并发的队列操作场景。它的插入和删除操作都是线程安全的,并且性能较高。ConcurrentLinkedQueue使用 CAS 操作来实现无锁的并发控制,避免了传统锁机制带来的性能开销。例如:

import java.util.concurrent.ConcurrentLinkedQueue;public class ConcurrentLinkedQueueExample {public static void main(String[] args) {ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();queue.add("element1");queue.add("element2");String element = queue.poll();System.out.println(element);}
}

LinkedBlockingQueueLinkedBlockingQueue是线程安全的阻塞队列,它常用于生产者 - 消费者模型中。它具有固定的容量(也可以创建无界的队列),当队列满时,生产者线程会被阻塞,直到有空间可用;当队列空时,消费者线程会被阻塞,直到有元素可用。LinkedBlockingQueue提供了puttake方法用于插入和获取元素,这两个方法在队列满或空时会阻塞线程,从而实现了线程间的同步和协作。例如:

import java.util.concurrent.LinkedBlockingQueue;public class LinkedBlockingQueueExample {public static void main(String[] args) throws InterruptedException {LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>(2);queue.put("element1");queue.put("element2");String element = queue.take();System.out.println(element);}
}

在上述示例中,创建了一个容量为 2 的LinkedBlockingQueue,当向队列中添加第三个元素时,put方法会阻塞,直到有元素被取出,队列中有空间。

通过合理使用这些线程安全的集合类,可以有效地避免多线程环境下集合操作的线程安全问题,提高程序的并发性能和稳定性。

五、线程间通信

在多线程编程中,线程间通信是一个非常重要的概念,它允许不同线程之间进行协作和信息共享,从而实现更复杂的功能。例如,在生产者 - 消费者模型中,生产者线程和消费者线程需要通过通信来协调数据的生产和消费;在多线程的任务处理中,一个线程可能需要等待另一个线程完成某个任务后才能继续执行。Java 提供了多种机制来实现线程间通信,下面将详细介绍wait()notify()/notifyAll()方法以及Condition接口。

5.1 wait () 和 notify ()/notifyAll () 方法

wait()notify()notifyAll()Object类中的方法,用于实现线程间的通信。这些方法必须在同步代码块或同步方法中使用,因为它们操作的是对象的监视器(锁)。

wait () 方法:当线程调用一个对象的wait()方法时,该线程会释放该对象的锁,并进入等待状态(WAITING),直到其他线程调用同一个对象的notify()notifyAll()方法,或者被中断。在等待期间,线程会从运行状态变为等待状态,并且不会占用 CPU 资源,直到被唤醒。

notify () 方法:唤醒在此对象监视器上等待的单个线程。如果有多个线程都在等待,则选择其中一个唤醒(具体哪个取决于线程调度器)。被唤醒的线程将尝试重新获取对象的锁,然后从wait()方法返回继续执行。

notifyAll () 方法:唤醒在此对象监视器上等待的所有线程。这些线程将竞争获取对象的锁,然后从wait()方法返回继续执行。

为了更好地理解这些方法的使用,我们来看一个经典的生产者 - 消费者模型的示例:

import java.util.LinkedList;
import java.util.Queue;public class ProducerConsumerExample {private static final int MAX_SIZE = 5;private final Queue<Integer> buffer = new LinkedList<>();private final Object lock = new Object();// 生产者public void produce() throws InterruptedException {int value = 0;while (true) {synchronized (lock) {// 缓冲区满时等待while (buffer.size() == MAX_SIZE) {System.out.println("缓冲区满,生产者等待...");lock.wait();}System.out.println("生产: " + value);buffer.offer(value++);lock.notifyAll();}Thread.sleep(500); // 模拟生产耗时}}// 消费者public void consume() throws InterruptedException {while (true) {synchronized (lock) {// 缓冲区空时等待while (buffer.isEmpty()) {System.out.println("缓冲区空,消费者等待...");lock.wait();}int value = buffer.poll();System.out.println("消费: " + value);lock.notifyAll();}Thread.sleep(1000); // 模拟消费耗时}}public static void main(String[] args) {ProducerConsumerExample demo = new ProducerConsumerExample();// 启动生产者线程new Thread(() -> {try {demo.produce();} catch (InterruptedException e) {e.printStackTrace();}}).start();// 启动消费者线程new Thread(() -> {try {demo.consume();} catch (InterruptedException e) {e.printStackTrace();}}).start();}
}

在这个示例中:

  • buffer是一个共享的队列,作为生产者和消费者之间的缓冲区,MAX_SIZE定义了缓冲区的最大容量。

  • lock是一个用于同步的对象,生产者和消费者通过lock来获取对象的锁,以保证对buffer的操作是线程安全的。

  • produce方法中,生产者线程首先获取lock的锁,然后检查buffer是否已满。如果已满,调用lock.wait()方法释放锁并进入等待状态,直到被消费者线程唤醒。当缓冲区有空间时,生产者将数据放入buffer,然后调用lock.notifyAll()方法唤醒所有等待的线程(这里主要是消费者线程)。

  • consume方法中,消费者线程获取lock的锁后,检查buffer是否为空。如果为空,调用lock.wait()方法释放锁并进入等待状态,直到被生产者线程唤醒。当缓冲区有数据时,消费者从buffer中取出数据,然后调用lock.notifyAll()方法唤醒所有等待的线程(这里主要是生产者线程)。

5.2 Condition 接口

Condition接口是在 Java 1.5 中引入的,它提供了比wait()notify()/notifyAll()更灵活和强大的线程间通信机制。Condition接口定义了等待 / 通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition是依赖Lock对象的。

Condition接口的主要方法如下:

  • await():使当前线程进入等待状态,直到被通知(signal())或中断。当前线程进入等待状态时,会释放关联的锁,并且在从await()方法返回前会重新获取锁。

  • awaitUninterruptibly():使当前线程进入等待状态,对中断不响应。与await()方法不同,该方法在等待过程中不会因为中断而抛出InterruptedException

  • awaitNanos(long nanosTimeout):使当前线程进入等待状态,直到被通知、中断或超时。返回值表示剩余时间,如果在nanosTimeout纳秒之前被唤醒,那么返回值就是nanosTimeout减去实际耗时;返回值小于等于 0 说明超时。

  • await(long time, TimeUnit unit):使当前线程进入等待状态,直到被通知、中断或超时。如果没有到指定时间被通知返回true,否则返回false

  • awaitUntil(Date deadline):使当前线程进入等待状态,直到被通知、中断或到达指定的截止时间。

  • signal():唤醒一个等待在Condition上的线程。该线程从等待方法返回之前必须获得与Condition相关联的锁。

  • signalAll():唤醒所有等待在Condition上的线程。这些线程在从等待方法返回之前必须竞争获取与Condition相关联的锁。

下面通过一个有界队列的示例来展示Condition接口的使用:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;class BoundedQueue<T> {private Object[] items;private int addIndex, removeIndex, count;private Lock lock = new ReentrantLock();private Condition notEmpty = lock.newCondition();private Condition notFull = lock.newCondition();public BoundedQueue(int size) {items = new Object[size];}// 添加一个元素,如果数组满,则添加线程进入等待状态,直到有"空位"public void add(T t) throws InterruptedException {lock.lock();try {while (count == items.length) {notFull.await();}items[addIndex] = t;if (++addIndex == items.length) addIndex = 0;++count;notEmpty.signal();} finally {lock.unlock();}}// 由头部删除一个元素,如果数组空,则删除线程进入等待状态,直到有新添加元素@SuppressWarnings("unchecked")public T remove() throws InterruptedException {lock.lock();try {while (count == 0) {notEmpty.await();}Object x = items[removeIndex];if (++removeIndex == items.length) removeIndex = 0;--count;notFull.signal();return (T) x;} finally {lock.unlock();}}
}

在这个示例中:

  • BoundedQueue类实现了一个有界队列,使用Object数组items来存储元素,addIndexremoveIndex分别表示添加和删除元素的索引,count表示队列中元素的数量。

  • lock是一个ReentrantLock对象,用于实现线程同步。notEmptynotFull是两个Condition对象,分别用于表示队列不为空和队列不满的条件。

  • add方法中,当队列满时(count == items.length),调用notFull.await()方法使当前线程等待,直到其他线程调用notFull.signal()方法唤醒它。当有空间时,将元素添加到队列中,并调用notEmpty.signal()方法唤醒等待在notEmpty条件上的线程(即等待获取元素的线程)。

  • remove方法中,当队列空时(count == 0),调用notEmpty.await()方法使当前线程等待,直到其他线程调用notEmpty.signal()方法唤醒它。当有元素时,从队列中取出元素,并调用notFull.signal()方法唤醒等待在notFull条件上的线程(即等待添加元素的线程)。

通过使用Condition接口,我们可以实现更细粒度的线程间通信和同步控制,尤其是在需要多个条件变量的场景下,Condition接口比wait()notify()/notifyAll()方法更加灵活和高效。

下一篇我们继续介绍线程池、现成的高级特性以及在实际项目中的应用。深入剖析Java线程:从基础到实战(下)

http://www.dtcms.com/a/318885.html

相关文章:

  • ubuntu cloud init 20.04LTS升级到22.04LTS
  • vue3接收SSE流数据进行实时渲染日志
  • Web开发模式 前端渲染 后端渲染 身份认证
  • 第三章:【springboot】框架介绍MyBatis
  • Spring AOP动态代理核心原理深度解析 - 图解+实战揭秘Java代理设计模式
  • 前端百分比展示导致后端 BigDecimal 转换异常的排查与解决
  • 多账号管理方案:解析一款免Root的App分身工具
  • 【RabbitMQ面试精讲 Day 13】HAProxy与负载均衡配置
  • HTTP 协议升级(HTTP Upgrade)机制
  • winform中的listbox实现拖拽功能
  • 基于ubuntu搭建gitlab
  • KDE Connect
  • 一篇文章入门TCP与UDP(保姆级别)
  • 02电气设计-安全继电器电路设计(让电路等级达到P4的安全等级)
  • C语言strncmp函数详解:安全比较字符串的实用工具
  • 合约收款方式,转账与问题安全
  • 怎么进行专项分析项目?
  • 上证50期权持仓明细在哪里查询?
  • C语言(08)——整数浮点数在内存中的存储
  • LINUX-批量文件管理及vim文件编辑器
  • 浅析 Berachain v2 ,对原有 PoL 机制进行了哪些升级?
  • AutoMQ-Kafka的替代方案实战
  • JAVA第六学:数组的使用
  • 【C++】哈希表原理与实现详解
  • 基于langchain的两个实际应用:[MCP多服务器聊天系统]和[解析PDF文档的RAG问答]
  • 智能制造的中枢神经工控机在自动化产线中的关键角色
  • 行业应用案例:MCP在不同垂直领域的落地实践
  • 二叉树算法之【中序遍历】
  • OpenAI重磅发布:GPT最新开源大模型gpt-oss系列全面解析
  • SpringBoot请求重定向目标地址不正确问题分析排查