【Java 多线程基础 - 上篇】
Java 多线程基础 - 上篇
多线程编程是现代软件开发中不可或缺的一部分,尤其是在需要处理大量数据或进行 I/O 密集型操作时,合理使用多线程可以显著提高程序的效率和响应性。本文将深入解析 Java 中的多线程概念、创建线程的方法、以及多线程的优势。
1. 认识线程(Thread)
1.1 线程的概念
线程是程序执行的最小单位。一个线程就是一个"执行流",程序中的每一条执行路径都可以看作一个线程。多个线程可以在同一时刻并发执行各自的任务,从而提高程序的效率和响应性。
为了帮助理解线程,我们可以用一个银行业务办理的例子来说明。假设某公司需要去银行办理业务,包括财务转账、福利发放和社保缴纳等。若只有一个会计(张三)来处理这些业务,那么他需要排队等待,并且需要花费大量时间完成所有任务。为了提高效率,张三可以找两位同事(李四和王五)一起来帮忙。每个人负责一项任务,最终三个人共同完成任务,这样就大大减少了等待时间。
这里的张三、李四、王五就可以看作是多个线程。一个大的任务被分解成了多个小任务,每个线程分别负责执行其中的一部分任务。这种方式被称为多线程。
1) 为什么要使用线程
- 并发编程的需求:多核 CPU 的普及使得并发编程成为了提高性能的必然需求。多个线程可以同时在不同的 CPU 核心上执行任务,提升整体计算能力。
- 提高资源利用率:有些任务,如等待文件 I/O 或数据库操作,可能会导致 CPU 处于空闲状态。通过使用线程,可以在等待期间执行其他任务,从而提升资源利用率。
- 线程比进程更轻量:线程创建和销毁的开销远小于进程。线程之间的上下文切换也更高效。
2) 进程与线程的区别
- 进程:进程是操作系统分配资源的最小单位,每个进程有独立的内存空间。进程之间是相互隔离的。
- 线程:线程是操作系统调度的最小单位。一个进程内的多个线程共享内存空间。线程之间的通信和共享数据比进程更高效。
线程和进程的主要区别在于,进程之间不共享内存空间,而线程共享内存空间。
3) Java中的线程与操作系统线程的关系
在 Java 中,线程的实现是通过对操作系统线程的封装来完成的。Java 提供了 Thread
类,它封装了操作系统提供的线程 API,使得我们可以通过该类来创建、管理和控制线程。每个 Java 线程对应操作系统中的一个线程,JVM 会管理这些线程的调度。
1.2 创建线程
Java 中有多种方法来创建线程,常见的两种方式是:继承 Thread
类和实现 Runnable
接口。
1) 继承 Thread
类
通过继承 Thread
类,我们可以重写其 run()
方法,并调用 start()
启动线程。
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程正在执行");
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start(); // 启动线程
}
}
2) 实现 Runnable
接口
另一种创建线程的方式是实现 Runnable
接口。实现 Runnable
接口后,我们可以将其传递给 Thread
构造函数。
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程正在执行");
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread t = new Thread(myRunnable);
t.start(); // 启动线程
}
}
3) 匿名内部类与 Lambda 表达式
Java 还允许使用匿名内部类和 Lambda 表达式来创建线程。下面是两种方式的示例:
// 使用匿名内部类
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("使用匿名类创建线程");
}
};
// 使用 Lambda 表达式
Thread t2 = new Thread(() -> System.out.println("使用 Lambda 表达式创建线程"));
t1.start();
t2.start();
1.3 多线程的优势
多线程的最大优势之一是能够在多个 CPU 核心上并行执行任务,从而加快计算速度。下面我们通过一个简单的例子来对比并行执行与串行执行的性能。
public class ThreadAdvantage {
private static final long count = 10_0000_0000;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long begin = System.nanoTime();
Thread thread = new Thread(() -> {
int a = 0;
for (long i = 0; i < count; i++) {
a--;
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
thread.join(); // 等待线程执行完毕
long end = System.nanoTime();
double ms = (end - begin) / 1000.0 / 1000.0;
System.out.printf("并发: %f 毫秒%n", ms);
}
private static void serial() {
long begin = System.nanoTime();
int a = 0;
for (long i = 0; i < count; i++) {
a--;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long end = System.nanoTime();
double ms = (end - begin) / 1000.0 / 1000.0;
System.out.printf("串行: %f 毫秒%n", ms);
}
}
在该示例中,concurrency()
使用了多线程执行,而 serial()
在主线程中串行执行。通过对比执行时间,我们可以看到,使用多线程能够有效地减少任务执行时间。
1.4 线程的生命周期
线程在执行过程中会经历不同的状态,包括新建、就绪、运行、阻塞和死亡状态。我们可以通过以下状态图来理解线程的生命周期:
2. Thread 类及常见方法
在 Java 中,Thread
类是一个非常重要的类,它用于管理和调度线程。每个线程都有一个唯一的 Thread
对象与之关联,而 JVM 会用这些 Thread
对象来管理所有的线程调度和状态。通过了解 Thread
类,我们可以更好地控制线程的生命周期、状态、以及与其他线程的交互。
2.1 Thread 的常见构造方法
Thread
类有多个构造方法,我们可以通过这些方法来创建线程对象并指定线程执行的任务。下面是几种常见的线程构造方法:
Thread t1 = new Thread(); // 默认构造方法
Thread t2 = new Thread(new MyRunnable()); // 通过传入Runnable对象创建线程
Thread t3 = new Thread("自定义线程名称"); // 使用自定义名称创建线程
Thread t4 = new Thread(new MyRunnable(), "自定义线程名称"); // 同时传入Runnable和名称
这几种构造方法可以创建不同的线程,并为线程提供任务执行内容以及线程名称。使用线程时,线程并不会立即启动,必须通过调用 start()
方法才能真正开始执行。
2.2 Thread 的几个常见属性
Thread
类有一些常见的属性和方法,可以帮助我们获取和控制线程的状态:
- ID:线程的唯一标识,通过
getId()
方法获取。 - 名称:线程的名称,用于调试时识别线程,通过
getName()
获取。 - 状态:线程当前的状态(如新建、就绪、运行、阻塞等),通过
getState()
获取。 - 优先级:线程的优先级,影响线程的调度优先级,通过
getPriority()
获取。 - 是否后台线程:通过
isDaemon()
方法判断线程是否为后台线程。后台线程在所有前台线程结束后会自动退出。 - 是否存活:判断线程是否还在运行,通过
isAlive()
获取。 - 是否被中断:通过
isInterrupted()
判断线程是否被中断。
示例:
public class ThreadDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
System.out.println(Thread.currentThread().getName() + ": 我还活着");
Thread.sleep(1000); // 让线程休眠1秒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 我即将死去");
});
System.out.println(Thread.currentThread().getName() + ": ID: " + thread.getId());
System.out.println(Thread.currentThread().getName() + ": 名称: " + thread.getName());
System.out.println(Thread.currentThread().getName() + ": 状态: " + thread.getState());
System.out.println(Thread.currentThread().getName() + ": 优先级: " + thread.getPriority());
thread.start(); // 启动线程
while (thread.isAlive()) {} // 等待线程结束
System.out.println(Thread.currentThread().getName() + ": 状态: " + thread.getState());
}
}
2.3 启动一个线程 - start()
在我们创建了一个 Thread
对象后,线程并不会自动执行。为了启动线程,我们需要调用 start()
方法。start()
方法会让线程进入就绪状态,并且 JVM 会为其分配时间片,在合适的时机执行线程中的 run()
方法。
示例:
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + ": 正在工作...");
}
});
thread.start(); // 启动线程
调用 start()
方法后,线程会在后台执行,且其执行逻辑由 run()
方法定义。值得注意的是,不能直接调用 run()
方法,否则它将作为普通的方法被执行,而不会开启新的线程。
2.4 中断一个线程
有时我们需要中断一个正在执行的线程。线程的中断是一个标记位,它并不强制线程立即停止,而是告知线程它应该停止。
我们可以通过以下两种方式来中断线程:
- 使用共享标志位:通过设置一个标志位来控制线程的停止。
- 调用
interrupt()
方法:调用interrupt()
方法通知线程中断,线程会抛出InterruptedException
,然后可以根据需要决定是否终止执行。
示例 1:使用共享标志位:
public class ThreadDemo {
private static class MyRunnable implements Runnable {
public volatile boolean isQuit = false; // 共享标志位
@Override
public void run() {
while (!isQuit) {
System.out.println(Thread.currentThread().getName() + ": 正在工作...");
try {
Thread.sleep(1000); // 模拟线程工作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 任务已终止");
}
}
public static void main(String[] args) throws InterruptedException {
MyRunnable target = new MyRunnable();
Thread thread = new Thread(target, "线程A");
thread.start(); // 启动线程
Thread.sleep(5000); // 模拟5秒后中断线程
System.out.println("通知线程停止工作");
target.isQuit = true; // 设置标志位
}
}
示例 2:使用 interrupt()
方法:
public class ThreadDemo {
private static class MyRunnable implements Runnable {
@Override
public void run() {
while (!Thread.interrupted()) {
System.out.println(Thread.currentThread().getName() + ": 正在工作...");
try {
Thread.sleep(1000); // 模拟线程工作
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + ": 被中断了!");
break; // 线程中断后跳出循环
}
}
System.out.println(Thread.currentThread().getName() + ": 任务已终止");
}
}
public static void main(String[] args) throws InterruptedException {
MyRunnable target = new MyRunnable();
Thread thread = new Thread(target, "线程B");
thread.start(); // 启动线程
Thread.sleep(5000); // 模拟5秒后中断线程
System.out.println("通知线程停止工作");
thread.interrupt(); // 中断线程
}
}
2.5 等待一个线程 - join()
有时我们需要等待一个线程执行完毕后再继续执行当前线程的操作。此时可以使用 join()
方法。join()
方法会使当前线程等待,直到调用该方法的线程执行完毕。
join()
方法有几个重载版本,支持设置最大等待时间:
public void join() throws InterruptedException; // 无限期等待
public void join(long millis) throws InterruptedException; // 等待指定时间(毫秒)
public void join(long millis, int nanos) throws InterruptedException; // 高精度等待
示例:
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Runnable target = () -> {
for (int i = 0; i < 10; i++) {
try {
System.out.println(Thread.currentThread().getName() + ": 我还在工作!");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ": 我结束了!");
};
Thread thread1 = new Thread(target, "线程A");
Thread thread2 = new Thread(target, "线程B");
thread1.start();
thread1.join(); // 等待线程A完成
thread2.start();
thread2.join(); // 等待线程B完成
System.out.println("所有线程完成,主线程继续执行");
}
}
2.6 获取当前线程引用 - currentThread()
Thread
类提供了 currentThread()
方法来获取当前正在执行的线程对象引用。通过这个方法,我们可以获取当前线程的信息。
Thread current = Thread.currentThread();
System.out.println("当前线程名称:" + current.getName());
2.7 休眠当前线程 - sleep()
sleep()
方法让当前线程进入休眠状态,休眠时间结束后,线程会自动重新进入就绪状态。需要注意,线程的调度是不可控的,因此实际休眠的时间可能大于等于我们设置的时间。
Thread.sleep(1000); // 使当前线程休眠1000毫秒
通过合理使用 Thread
类的各种方法
,我们可以有效地管理线程的执行,确保多线程程序的高效和稳定运行。
2.8 线程的状态及线程安全详解
在 Java 中,线程的生命周期受线程状态的影响。线程的状态是由 Thread.State
枚举类定义的,并且线程的状态会随着线程的执行过程而发生变化。了解这些状态及其转移是多线程编程中的重要基础,同时,掌握如何处理线程安全问题也至关重要。在这篇文章中,我们将深入探讨线程的各种状态及其转移,并解决线程不安全的问题。
3. 线程的状态
线程在生命周期中会经历多个不同的状态。Thread.State
枚举类为我们提供了以下几种状态来描述线程的运行状态:
3.1 线程的所有状态
Thread.State
枚举包含以下几种状态:
- NEW:线程已被创建,但尚未开始执行。此时,线程的
start()
方法还未被调用。 - RUNNABLE:线程已开始执行,且处于可以被操作系统调度的状态。请注意,这个状态不一定表示线程正在运行,而是表示线程可以被调度运行。
- BLOCKED:线程正在等待获取锁。通常发生在多个线程竞争同一资源时,线程会被阻塞,直到能够获取到锁。
- WAITING:线程正在等待其他线程的通知或者事件的发生。线程在此状态下不会占用 CPU 时间,直到条件满足为止。
- TIMED_WAITING:线程在等待某个事件的发生,但它的等待是有时间限制的。线程通过
Thread.sleep(time)
或Object.wait(time)
等方法进入该状态。 - TERMINATED:线程执行完成或由于异常终止时进入该状态。线程的
run()
方法执行完毕或者线程被强制终止后,线程进入此状态。
3.2 线程状态与状态转移
线程的状态是多线程程序中不可忽视的一部分,它描述了线程生命周期中的不同阶段,并且线程会根据不同的条件在这些状态之间进行转移。
线程状态的转移通常会发生在以下几种情况:
- NEW → RUNNABLE:线程调用了
start()
方法后,线程进入 RUNNABLE 状态,表示线程已准备好执行。 - RUNNABLE → BLOCKED:线程尝试获取已经被锁定的资源时,会进入 BLOCKED 状态。
- RUNNABLE → WAITING/TIMED_WAITING:线程调用了
wait()
或join()
等方法后,会进入 WAITING 或 TIMED_WAITING 状态。 - RUNNABLE → TERMINATED:线程的
run()
方法执行完毕,线程进入 TERMINATED 状态,表示线程已经结束。
线程的状态转移有助于我们理解程序在多线程环境下的行为。
3.3 观察线程的状态与转移
我们可以通过代码监控线程的状态变化,理解不同状态之间的转移。
观察 1: 关注 NEW、RUNNABLE、TERMINATED 状态的转换
可以使用 Thread.isAlive()
方法来判断一个线程是否处于非 NEW 或 TERMINATED 状态。
示例代码:
public class ThreadStateTransfer {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
// 模拟任务
}
}, "李四");
System.out.println(t.getName() + ": " + t.getState()); // 输出 NEW
t.start();
while (t.isAlive()) {
System.out.println(t.getName() + ": " + t.getState()); // 输出 RUNNABLE 状态
}
System.out.println(t.getName() + ": " + t.getState()); // 输出 TERMINATED
}
}
观察 2: 关注 WAITING、BLOCKED、TIMED_WAITING 状态的转换
这些状态通常与线程的锁和等待机制有关。我们可以通过 wait()
或 sleep()
方法进入 WAITING 或 TIMED_WAITING 状态,且 BLOCKED 状态通常发生在多个线程竞争同一个资源时。
示例代码:
public class ThreadStateTransfer {
public static void main(String[] args) throws InterruptedException {
final Object object = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (object) {
try {
Thread.sleep(1000); // 模拟线程在等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (object) {
System.out.println("t2 is working");
}
}
}, "t2");
t1.start();
t2.start();
}
}
在上面的例子中,t1
进入 TIMED_WAITING 状态,而 t2
因为 t1
持有锁,进入 BLOCKED 状态。
观察 3: yield()
方法
yield()
方法会让当前线程放弃 CPU 使用权,并使当前线程重新进入就绪队列。yield()
不会改变线程的状态,只是影响线程的调度顺序。
示例代码:
public class YieldExample {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
System.out.println("张三");
Thread.yield(); // 让出 CPU
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
System.out.println("李四");
}
}, "t2");
t1.start();
t2.start();
}
}
通过使用 yield()
,可以观察到张三和李四的输出顺序发生变化,说明线程的调度顺序可能会有所调整。
4. 多线程带来的风险——线程安全
4.1 观察线程不安全
多线程环境下,多个线程同时操作共享资源时,往往会出现线程安全问题。以计数器为例,当多个线程同时操作同一个共享变量时,如果没有同步机制,结果可能不一致。
示例代码:
static class Counter {
public int count = 0;
void increase() {
count++; // 非原子操作,可能会引发线程安全问题
}
}
public class ThreadUnsafeExample {
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count); // 可能不是预期的 100000
}
}
由于没有同步机制,count++
操作实际上是非原子的,可能导致多个线程同时修改 count
,最终结果并不符合预期。
4.2 线程安全的概念
线程安全的程序能够在多线程环境下按预期工作,不会产生不一致的结果。简单来说,如果在单线程环境下代码能够正确运行,那么在多线程环境下代码也应当能够正确运行。
4.3 线程不安全的原因
线程不安全通常发生在共享数据被多个线程同时修改时,造成了数据竞争。具体原因包括:
- 原子性:操作不是原子性的,比如
count++
,它实际上是由多个步骤组成的。 - 可见性:线程对共享数据的修改可能没有及时更新到其他线程的工作内存中。
- 指令重排序:编译器或 CPU 可能会对指令进行重排序,导致线程执行的顺序与预期不符。
4.4 解决线程不安全问题
可以通过使用 synchronized
来保证线程安全,确保共享资源的操作在同一时刻只有一个线程能够执行。
示例代码:
static class Counter {
public int count = 0;
synchronized void increase() { // 使用 synchronized 保证线程安全
count++;
}
}
public class ThreadSafeExample {
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count); // 现在的结果是预期的 100000
}
}
通过给 increase()
方法加上 synchronized
关键字,我们确保了每次只有一个线程能够访问该方法,从而避免了线程安全问题。这样就保证了共享变量 count
的原子性操作,最终输出的结果是预期的 100000
。