深入剖析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 中,线程的生命周期可以分为以下六种状态:
- 新建(NEW):当我们使用
new
关键字创建一个线程对象时,线程就处于新建状态 。此时,线程对象已经被创建,但还没有开始执行,就像一个刚出生的婴儿,还没有开始自己的 “人生旅程”。例如:
Thread thread = new Thread(() -> {
// 线程执行的代码
});
- 就绪(RUNNABLE):当调用线程对象的
start()
方法后,线程进入就绪状态 。这意味着线程已经准备好运行,正在等待获取 CPU 时间片。就像一个运动员站在起跑线上,做好了起跑的准备,只等发令枪响就可以开始奔跑。在就绪状态下,线程可能会在就绪队列中等待,一旦获得 CPU 资源,就会进入运行状态。
thread.start();
运行(RUNNING):当线程从就绪状态获得了 CPU 时间片后,它进入运行状态,开始执行线程体中的代码 。此时,线程正在执行
run()
方法中的逻辑,就像运动员在赛道上全力奔跑一样。在运行过程中,线程可能会因为各种原因(如时间片用完、调用了yield()
方法等)重新回到就绪状态。阻塞(BLOCKED):在运行过程中,如果线程尝试获取已被其他线程锁定的对象锁,或者等待 I/O 操作完成等情况下,线程会进入阻塞状态 。一旦阻塞条件解除,线程返回到就绪状态。例如,当一个线程试图进入一个被
synchronized
关键字修饰的代码块,而该代码块已经被其他线程占用时,这个线程就会进入阻塞状态,等待锁的释放。可以将阻塞状态想象成运动员在比赛中遇到了障碍物,需要停下来等待障碍物清除后才能继续前进。等待(WAITING):调用
wait()
、join()
或LockSupport.park()
等方法会使线程进入等待状态 。在这个状态下,线程不会自动恢复,需要其他线程显式地唤醒。例如,当一个线程调用了Object
类的wait()
方法后,它会释放持有的锁,并进入等待状态,直到其他线程调用notify()
或notifyAll()
方法唤醒它。等待状态就像是运动员在比赛中主动停下来休息,等待教练的指令再继续比赛。计时等待(TIMED_WAITING):如果线程调用了带有超时参数的方法,如
sleep(long millis)
、wait(long timeout)
、join(long millis)
或者LockSupport.parkNanos()
等,它会进入计时等待状态 。到达指定的时间后,线程会自动恢复到就绪状态。例如,线程调用sleep(1000)
方法,会使线程暂停执行 1000 毫秒,在这期间线程处于计时等待状态,时间一到就会自动醒来,进入就绪状态。计时等待状态可以看作是运动员在比赛中给自己设定了一个休息时间,休息结束后就继续比赛。终止(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方法,定义线程执行的任务
@Override
public 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
接口的步骤如下:
创建一个类,实现
Runnable
接口。在实现类中重写
run
方法,将线程要执行的任务代码放入run
方法中。创建实现类的实例,并将其作为参数传递给
Thread
类的构造函数,创建Thread
对象。调用
Thread
对象的start
方法启动线程。
下面是一个实现Runnable
接口创建线程的示例代码:
// 实现Runnable接口的类
class MyRunnable implements Runnable {
// 重写run方法,定义线程执行的任务
@Override
public 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
。最后调用thread
的start
方法启动线程。
实现Runnable
接口的方式更适合多个线程共享同一任务逻辑的场景。例如,在一个多线程的服务器应用中,每个客户端请求都可以由同一个实现Runnable
接口的任务来处理,通过创建多个Thread
对象并传入相同的Runnable
实例,就可以实现多个线程并发处理不同的客户端请求,提高服务器的并发处理能力。同时,由于Runnable
只是一个接口,实现它的类还可以继承其他类,这大大增强了代码的灵活性和扩展性。
2.3 实现 Callable 接口
Callable
接口是 Java 5.0 引入的,它类似于Runnable
接口,但提供了更强大的功能。与Runnable
接口不同的是,Callable
接口的call
方法可以返回执行结果,并且可以抛出异常。这使得Callable
接口非常适合用于需要获取线程执行结果的场景,比如异步计算、数据查询等。
使用Callable
接口创建线程通常需要结合Future
或FutureTask
类来获取线程的执行结果。Future
接口代表异步计算的结果,它提供了方法来检查计算是否完成、等待计算完成以及获取计算结果。FutureTask
类则是Future
接口的一个实现,同时它还实现了Runnable
接口,因此可以作为Thread
的构造参数,用于启动线程。
以下是一个实现Callable
接口创建线程并获取结果的示例代码:
import java.util.concurrent.*;
// 实现Callable接口的类
class MyCallable implements Callable<Integer> {
// 重写call方法,定义线程执行的任务并返回结果
@Override
public 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
方法可以返回结果和抛出异常,功能更加强大。然而,由于需要结合Future
或FutureTask
类来获取结果,代码结构相对复杂一些。
功能特性:
继承
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();
}
}
在这个示例中,创建了两个线程thread1
和thread2
。thread1
在每次循环中,当i
能被 3 整除时,调用Thread.yield()
方法,让出 CPU 资源,使thread2
有机会执行。由于yield
方法的不确定性,实际运行结果可能会有所不同,但在理想情况下,两个线程会交替执行,输出的结果可能会是Thread 1: 0
、Thread 2: 0
、Thread 1: 1
、Thread 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();
}
}
在这个示例中,创建了两个线程highPriorityThread
和lowPriorityThread
,分别设置它们的优先级为最高和最低。然后启动这两个线程,观察它们的执行顺序。在理想情况下,高优先级线程会优先获得 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
方法中,创建了两个线程thread1
和thread2
,它们都对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()
throwsInterruptedException
:获取锁,同时允许线程在等待锁的过程中被中断。boolean tryLock()
:尝试获取锁。如果锁可用,立即返回true
并获取锁;否则,立即返回false
。boolean tryLock(long time, TimeUnit unit)
throwsInterruptedException
:在指定的等待时间内尝试获取锁,如果在等待时间内获取到锁,则返回true
。void unlock()
:释放锁。Condition newCondition()
:返回一个与该锁关联的Condition
对象,用于实现线程间的通信。
ReentrantLock
是Lock
接口的一个常用实现类,它是一个可重入的互斥锁,具有与使用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 提供了一些线程安全的集合类,以下是一些常见的线程安全集合类及其特点:
ConcurrentHashMap:ConcurrentHashMap
是线程安全且高效的并发集合,它在多线程环境下被广泛使用。在 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 和 CopyOnWriteArraySet:CopyOnWriteArrayList
和CopyOnWriteArraySet
适用于读多写少的场景。它们在修改时会创建新的底层数组,避免了修改时的锁定,从而提高了读取性能。当有线程对集合进行写操作(如添加、删除元素)时,会先复制一份当前的数组,在新数组上进行修改,修改完成后再将原数组的引用指向新数组。而读操作始终是在原数组上进行,不会受到写操作的影响。例如:
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);
}
}
}
ConcurrentLinkedQueue:ConcurrentLinkedQueue
是一个线程安全的无界队列,它采用链表结构实现,适用于高并发的队列操作场景。它的插入和删除操作都是线程安全的,并且性能较高。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);
}
}
LinkedBlockingQueue:LinkedBlockingQueue
是线程安全的阻塞队列,它常用于生产者 - 消费者模型中。它具有固定的容量(也可以创建无界的队列),当队列满时,生产者线程会被阻塞,直到有空间可用;当队列空时,消费者线程会被阻塞,直到有元素可用。LinkedBlockingQueue
提供了put
和take
方法用于插入和获取元素,这两个方法在队列满或空时会阻塞线程,从而实现了线程间的同步和协作。例如:
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
来存储元素,addIndex
和removeIndex
分别表示添加和删除元素的索引,count
表示队列中元素的数量。lock
是一个ReentrantLock
对象,用于实现线程同步。notEmpty
和notFull
是两个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线程:从基础到实战(下)