摘要
多线程编程是现代软件开发中的一个重要概念,它允许程序同时执行多个任务,提高了程序的性能和响应性。本博客深入探讨了多线程编程的关键概念、原理和最佳实践。
线程、进程、多线程、并发、并行
- 进程
- 进程是计算机中运行的程序的实例。每次打开一个应用程序,操作系统都会为其分配内存空间并创建一个新的进程。
- 例如:QQ、微信等应用程序都是不同的进程。
- 线程
- 线程是进程内的执行单元,一个进程可以包含多个线程。线程共享进程的资源和内存,但有自己的执行上下文。
- 例如:在QQ中,同时进行文字聊天和视频通话可以看作是使用了多个线程来处理这两个功能。
- 多线程
- 多线程是指在同一时刻,一个进程内可以有多个线程并发执行。这允许应用程序同时处理多个任务。
- 例如:QQ可以同时打开多个聊天窗口,每个聊天窗口对应一个独立的线程,实现并发聊天功能;百度网盘可以同时下载多个文件,每个下载任务对应一个独立的线程,实现并发下载功能。
- 并发
- 单核CPU:同一时刻,多个任务交替执行,如下图所示:
- 并行
- 多核CPU:同一时刻,多个任务同时执行,如下图所示:
我们可以编写Java程序来查看自己电脑的CPU核数
Code:
java
Runtime runtime = Runtime.getRuntime();
//获取当前操作系统的cpu核数
int cpuNums = runtime.availableProcessors();
System.out.println("当前CPU核数=" +cpuNums);
输出: 当前CPU核数=6
线程7大状态
- New:线程对象已被创建,但尚未开始执行。此时线程尚未分配 CPU 时间片,处于等待状态。
- Runnable :线程可运行状态,表示线程已经准备好执行。在可运行状态下,线程可能处于以下两个子状态:
- Ready:就绪状态
- Running:运行状态
- TimedWaiting:线程因等待某个条件一段时间而进入等待状态。
- Waiting:线程因等待某个条件而进入等待状态。
- Blocked:线程被阻止等待某个锁资源。当线程试图访问一个已被其他线程占用的锁资源时,它会进入阻塞状态,直到锁资源可用。
- Terminated:线程已完成执行并终止。
创建线程的方式
在Java启动时,会默认创建两个线程:
main
线程:这是Java应用程序的主线程,是程序的入口点。main
线程执行main
方法中的代码,负责程序的初始化和执行。GC
线程:这是Java虚拟机(JVM)内部的垃圾回收线程。它负责在后台自动回收不再使用的内存,以确保程序的内存管理。
创建线程的方法主要有以下两种:
- 继承
Thread
类: 您可以创建一个自定义的类,继承自Thread
类,并重写run
方法来定义线程的执行逻辑。然后,通过创建该类的实例并调用start
方法来启动线程。 - 实现
Runnable
接口: 您可以创建一个实现了Runnable
接口的类,实现run
方法来定义线程的执行逻辑。然后,通过创建该类的实例,将其传递给Thread
类的构造函数,并调用start
方法来启动线程。
这两种方式都可以用于创建线程,但使用Runnable
接口通常更灵活,因为它允许多个线程共享相同的Runnable
实例,实现了解耦和代码复用。
以下是图示,展示了这两种线程创建方式的关系:
继承Thread类
Code:
java
public class MyThread extends Thread {
@Override
public void run() {
while(true){
System.out.println("喵喵,我是小猫咪" + (++times));
}
}
public static void main(String[] args) { //main线程
//创建一个线程对象
MyThread myThread = new MyThread();
myThread.start(); //开启新线程
//main线程业务代码
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
}
}
输出:
main 0
喵喵,我是小猫咪1
main 1
main 2
喵喵,我是小猫咪2
。。。
分析Code :
- 多核CPU:创建了
main
线程和myThread
线程,并且同时执行任务。 - 线程执行的顺序是不确定的,具体由CPU决定。
优点:
- 简单直观 :继承
Thread
类创建线程相对简单,只需创建一个继承自Thread
的子类并重写run
方法,然后实例化子类对象并调用start
方法即可启动线程。 - 易于理解:对于初学者来说,这种方式更容易理解和上手,因为它类似于面向对象编程的常规方式。
缺点:
- 单继承限制 :Java中不支持多重继承,因此如果您的线程已经继承自
Thread
类,就不能再继承其他类。这可能限制了您的代码组织和设计选择。 - 不利于共享资源 :继承
Thread
类的方式不太适合多个线程之间共享相同的资源,因为每个线程都是一个独立的对象,不容易在多个线程之间共享数据。 - 不利于线程池管理 :如果您使用线程池来管理线程,继承
Thread
类的方式可能不太适合,因为线程池更适合管理实现Runnable
接口的任务。
💡为什么使用start()方法启动线程,而不是直接调用run()方法?
myThread.start()
:创建一个新线程,实现多线程执行。myThread.run()
:相当于调用了一个方法,而没有真正的创建一个线程。
start()源码分析:
以下是start()
方法的底层源码,其中最核心的是start0()
方法。
java
public synchronized void start() {
。。。
。。。
start0(); //最核心的代码
}
private native void start0();
-
首先调用最核心的代码,即调用start0()方法;
-
由于start0()是本地方法,有JVM调用,底层是c/c++实现,无法查看;
-
所有线程并不会马上执行,只是将线程状态改为可运行状态,具体什么时候执行,取决于CPU。
实现Runnable接口(推荐使用)
💡为什么推荐通过实现Runnable来创建线程?
- 因为Java是单继承机制,在某些情况下已经继承了其他父类,这时在继承Thread类来创建线程显然不可能了。
- Java设计者就提供了另外一种方式创建线程,通过实现Runnable接口创建线程。
Code : 模拟抢票系统可以用多线程来模拟,每个线程代表一个用户尝试抢票。下面是一个简单的Java示例,使Runnable
接口来实现一个基本的抢票系统模拟:
java
public class TicketSystem implements Runnable {
private int totalTickets = 10; // 总票数
@Override
public void run() {
while (totalTickets > 0) {
if (totalTickets > 0) {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 抢到了第 " + totalTickets + " 张票");
totalTickets--;
} else {
System.out.println("票已售罄");
}
try {
Thread.sleep(100); // 模拟用户抢票间隔
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
TicketSystem ticketSystem = new TicketSystem();
Thread user1 = new Thread(ticketSystem, "小明");
Thread user2 = new Thread(ticketSystem, "张三");
Thread user3 = new Thread(ticketSystem, "李四");
user1.start();
user2.start();
user3.start();
}
}
输出:
李四抢到第10票
小明抢到第9票
。。。
张三抢到第2票
小明抢到第2票
李四抢到第1票
李四抢到第0票
小明抢到第-1票
分析Code :
从输出结果上可以看到有多线程的抢票系统存在以下几个问题:
- 张三抢到第2票和小明抢到第2票同时抢到了第2张票。
- 小明抢到第-1票
多个线程同一时刻访问了同一个资源,造成线程不安全
优点:
- 灵活性 :实现
Runnable
接口比继承Thread
类更灵活。一个类可以实现多个接口,因此您可以在一个类中实现Runnable
接口,同时还可以继承其他类或扩展其他功能。 - 避免单继承限制 :Java不支持多重继承,但可以实现多个接口。这使得通过实现
Runnable
接口创建线程更容易与其他类协作。 - 资源共享 :通过实现
Runnable
接口,多个线程可以共享相同的实例变量,这使得资源共享更容易。 - 线程池管理 :使用实现
Runnable
接口的线程更容易集成到线程池中,线程池可以更好地管理和重用线程。
缺点:
- 稍微复杂 :相对于继承
Thread
类,实现Runnable
接口的方式稍微复杂一些,需要在类中实现run
方法,并且需要创建Runnable
对象并传递给Thread
类的构造函数。 - 更多的代码 :需要编写更多的代码来创建和启动线程,因为您需要创建一个
Runnable
对象,然后将它传递给Thread
对象。
线程同步机制
💡为什么有线程同步机制?
- 为了确保多个线程可以安全地访问和操作共享的资源,以防止出现竞态条件(Race Condition)和数据不一致的情况。如上述的抢票系统存在的问题
线程同步的原理
如上图所示:
- 多个线程:现在有多个人同时要上厕所;
- 队列:当有多个线程同时访问同一个资源时,CPU会将多个线程进行排序争夺资源(锁),只有得到锁的线程才能访问资源;
- 锁:为了保证线程安全,每个线程在访问共享资源之前会尝试获取一个锁。锁充当门禁,只有持有锁的线程才能访问资源。
每个线程需要使用共享资源时,先尝试获取锁,如果锁被其他线程占用,则将该线程加入到队列中,等待获取锁,
使用完共享资源后,释放锁,线程重新排序队列,四个Person对象重新抢夺这个锁(已经使用的线程仍然可以继续排序争夺锁) 。
synchronized修饰同步方法/同步块实现同步机制
在Java中可以使用synchronized关键字创建同步方法 /同步块实现线程同步机制。
同步方法:
同步方法可以通过在方法声明中添加synchronized
关键字来实现。
这会使得该方法在被多个线程访问时,只有一个线程能够执行该方法,其他线程需要等待该线程执行完成后才能继续执行。
Code :
使用同步方法解决抢票问题
java
public class TicketSystem {
private int totalTickets = 10; // 总票数
// 使用同步方法确保线程安全
public synchronized void buyTicket(String threadName) {
if (totalTickets > 0) {
System.out.println(threadName + " 抢到了第 " + totalTickets + " 张票");
totalTickets--;
} else {
System.out.println("票已售罄");
}
}
public static void main(String[] args) {
TicketSystem ticketSystem = new TicketSystem();
Thread user1 = new Thread(() -> {
ticketSystem.buyTicket("张三");
});
Thread user2 = new Thread(() -> {
ticketSystem.buyTicket("小明");
});
Thread user3 = new Thread(() -> {
ticketSystem.buyTicket("李四");
});
user1.start();
user2.start();
user3.start();
}
}
在这个示例中,buyTicket
方法被定义为同步方法,确保了线程安全。多个用户线程(张三、小明、李四)同时尝试调用buyTicket
方法,但只有一个线程能够成功抢到票,其他线程会等待。
同步块:
同步块可以通过在代码块前添加**synchronized**
关键字来实现。
这会使得该代码块在被多个线程访问时,只有一个线程能够执行该代码块,其他线程需要等待该线程执行完成后才能继续执行。
Code :
使用同步块解决抢票问题
java
public class TicketSystem implements Runnable {
private int totalTickets; // 总票数
private int interval; // 抢票间隔(毫秒)
public TicketSystem(int totalTickets, int interval) {
this.totalTickets = totalTickets;
this.interval = interval;
}
@Override
public void run() {
while (true) {
synchronized (this) { // 使用同步块确保线程安全
if (totalTickets > 0) {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 抢到了第 " + totalTickets + " 张票");
totalTickets--;
} else {
System.out.println("票已售罄");
break; // 所有票已售完,退出循环
}
}
try {
Thread.sleep(interval); // 模拟用户抢票间隔
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
int totalTickets = 10;
int interval = 100;
TicketSystem ticketSystem = new TicketSystem(totalTickets, interval);
Thread user1 = new Thread(ticketSystem, "张三");
Thread user2 = new Thread(ticketSystem, "小明");
Thread user3 = new Thread(ticketSystem, "李四");
user1.start();
user2.start();
user3.start();
}
}
这个示例中,我将总票数和抢票间隔作为构造函数参数传递给 TicketSystem
类,使代码更具通用性。
死锁
多个线程各自占有一些共享的资源,并且相互等待其他线程占有的资源才能运行,从而导致两个线程都在等待对象释放资源。
- A线程:正在使用对象1,但是此时需要访问对象2 ;
- B线程:正在使用对象2 ,但是此时需要访问对象1;
- 两个线程都在等待对方释放资源,导致线程阻塞
- 若无外部干涉,它们都将无法继续执行下去,称为死锁。
Lock锁
在 Java 中,Lock
锁是一种用于多线程编程的机制,它提供了比传统的 synchronized
关键字更灵活和强大的线程同步和互斥控制方式。
Lock
接口定义了一套用于获取和释放锁的方法,可以手动开启和关闭锁。- 最常用的实现类是
ReentrantLock
。
Code:
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketSystem implements Runnable {
private int totalTickets = 10; // 总票数
private Lock lock = new ReentrantLock(); // 创建一个ReentrantLock锁
@Override
public void run() {
while (true) {
try {
lock.lock(); // 获取锁
if (totalTickets > 0) {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 抢到了第 " + totalTickets + " 张票");
totalTickets--;
} else {
System.out.println("票已售罄");
break; // 所有票已售完,退出循环
}
} finally {
lock.unlock(); // 释放锁
}
try {
Thread.sleep(100); // 模拟用户抢票间隔
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
TicketSystem ticketSystem = new TicketSystem();
Thread user1 = new Thread(ticketSystem, "张三");
Thread user2 = new Thread(ticketSystem, "小明");
Thread user3 = new Thread(ticketSystem, "李四");
user1.start();
user2.start();
user3.start();
}
}
在这个示例中,我们使用了 ReentrantLock
锁来控制对共享资源的访问。在 run
方法中,通过 lock.lock()
获取锁,在访问共享资源后使用 lock.unlock()
释放锁,以确保线程安全。
优点
- 使用
Lock
锁的优势在于它提供了更多的控制,例如可以设置锁的超时时间、使用条件变量等.
缺点
- 但需要注意,与
synchronized
关键字相比,使用Lock
锁需要手动释放锁,因此需要在finally
块中确保锁的释放,以防止出现死锁等问题。
线程常用方法
方法 | 说明 |
---|---|
setName() | 设置线程名称 |
getName() | 返回该线程名称 |
start() | 该线程开始执行,JVM调用start0()方法 |
run() | 调用线程对象run()方法 |
setPriority() | 更改线程的优先级 |
getpriority() | 获取线程的优先级 |
sleep() | 线程休眠 |
interrupt() | 中断线程 |
yield() | 线程礼让,但是不能保证线程礼让成功 |
join() | 线程插队 |
线程终止
- 当线程完成任务后,会自动退出;
- 可以通过使用变量来控制run()方法退出,终止线程(通知方式)。
- 不建议使用stop或者destory等过时的方法
Code:
java
public class ThreadStop implements Runnable {
private boolean isRunning = true; //线程停止标志位
@Override
public void run() {
int i = 0;
while (isRunning) {
System.out.println("正在执行..." + i++);
}
}
//暂停线程方法
public void stopThread() {
isRunning = false;
}
public static void main(String[] args) throws InterruptedException {
ThreadStop threadStop = new ThreadStop();
//创建线程,启动线程
new Thread(threadStop).start();
Thread.sleep(2000);
threadStop.stopThread();
}
}
线程在执行任务时会不断检查自己的终止状态,如果isRunning=false,则终止线程。
在main方法中,启动了一个线程,并在2秒后将isRunning=false,终止线程。
线程中断(interrupt)
线程中断是指一个线程向另一个线程发出信号,请求其停止正在执行的操作。
这个信号由一个布尔标志来表示,通常称为线程的中断状态(interrupt status)。
当线程的中断状态被设置为**true
**时,线程会收到一个中断请求。
Code:
java
class MyRunnable implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 线程执行任务
try {
Thread.sleep(1000); // 模拟工作
} catch (InterruptedException e) {
// 响应中断请求,可以进行清理工作
Thread.currentThread().interrupt(); // 重新设置中断状态
}
}
}
}
public class ThreadInterruptExample {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start(); //开启线程
// 在某个时刻中断线程
try {
Thread.sleep(5000);
thread.interrupt(); // 发送中断信号,设置为true
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
线程在执行任务时会不断的检查自己的中断状态,如果中断状态为true,则推出任务执行。
在"main"方法中,启动了一个线程,并且在5秒后发送中断请求。
线程插队(join)
线程一旦插队成功,则肯定先执行插入的线程所有的任务,再去执行其他线程的任务。
Code:
java
public class ThreadStop implements Runnable {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
System.out.println("vip线程来了," + i);
}
}
public static void main(String[] args) throws InterruptedException {
ThreadStop threadStop = new ThreadStop();
Thread thread = new Thread(threadStop, "A");
thread.start();
//main线程
for (int i = 0; i < 500; i++) {
if (i == 100) {
thread.join(); //线程插队
}
System.out.println("main线程," + i);
}
}
}
main线程在循环100次后,插入vip线程,等待vip线程执行完后,再继续执行main线程的任务。
礼让线程(yield)
Code:
java
public class ThreadStop implements Runnable {
@Override
public void run() {
System.out.println("正在执行" + Thread.currentThread().getName() + "线程");
Thread.yield(); //线程礼让
System.out.println("停止" + Thread.currentThread().getName() + "线程");
}
public static void main(String[] args) throws InterruptedException {
ThreadStop threadStop = new ThreadStop();
new Thread(threadStop, "A").start();
new Thread(threadStop, "B").start();
}
}
输出:
正在执行A线程
正在执行B线程
停止B线程
停止A线程
在main
方法中,我们创建了两个线程实例(线程"A"和线程"B"),它们都使用相同的ThreadStop
对象作为任务,并启动这两个线程。
由于两个线程共享ThreadStop
对象,它们运行相同的run()
方法。
由于Thread.yield()
方法的存在,这两个线程在执行过程中可能会主动让出CPU时间,以便其他线程有机会运行。
守护线程
- 线程可以分为:用户线程 、守护线程(上帝线程)
- JVM必须确保用户线程执行完成;
- JVM不需要等待守护线程执行完毕。
java
public class ThreadStop {
public static void main(String[] args) throws InterruptedException {
God god = new God();
Person person = new Person();
//创建上帝线程
Thread godThread = new Thread(god);
godThread.setDaemon(true); //设置为守护线程
godThread.start();
//创建用户线程
Thread personThread = new Thread(person);
personThread.start();
}
}
class God implements Runnable {
@Override
public void run() {
while (true) {
System.out.println("正在执行守护线程");
}
}
}
class Person implements Runnable {
@Override
public void run() {
for (int i = 0; i < 20; i++) {
System.out.println("用户岁数=" + i);
}
System.out.println("======say goodbye======");
}
}
输出:
正在执行守护线程
用户岁数=0
用户岁数=1
用户岁数=2
用户岁数=3
用户岁数=4
用户岁数=5
用户岁数=6
用户岁数=7
用户岁数=8
用户岁数=9
用户岁数=10
用户岁数=11
用户岁数=12
用户岁数=13
用户岁数=14
用户岁数=15
用户岁数=16
用户岁数=17
用户岁数=18
用户岁数=19
======say goodbye======
正在执行守护线程
正在执行守护线程
正在执行守护线程
正在执行守护线程
正在执行守护线程
正在执行守护线程
当用户线程执行完毕后,守护线程也会紧随其后。
结语
多线程编程是现代软件开发不可或缺的一部分,但也存在复杂性和挑战。
通过深入理解多线程的原理和最佳实践,开发人员可以更好地利用多核处理器,提高程序性能和响应性,同时避免潜在的线程安全问题。
本博客提供了一个较为基础的多线程编程指南,帮助开发人员入门这一重要领域的技能。