首页 > 基础资料 博客日记
【并发编程】多线程安全问题,如何避免死锁
2023-07-24 16:11:20基础资料围观310次
文章目录
从今天开始阿Q将陆续更新java并发编程专栏,期待您的订阅。
在系统学习线程之前,我们先来了解一下它的概念,与经常提到的进程做个对比,方便记忆。
概念
线程和进程是操作系统中的两个重要概念,它们都代表了程序运行时的执行单位,它们的出现是为了更好地管理计算机资源和提高系统的运行效率,使用它们可以实现多任务同时运行,从而提高系统资源的利用率。
进程
进程是程序的一次执行过程,系统运行的每一个程序都是一个进程,它是操作系统资源分配的最小单位,也是系统运行程序的基本单位。
在同一个操作系统中,多个进程可以并发执行,每个进程都拥有各自独立的内存空间,相互之间不会产生影响。如下图,windows 系统的任务管理器页面运行的进程就对应着一个一个的应用。
在 Java 中,启动 main 函数就是启动了一个 JVM 进程,而 main 函数所在的线程就是进程中的一个线程,也称主线程。
线程
线程是比进程更小的执行单位,一个进程中包含多个线程,通过 JMX 来看看一个普通的 Java 程序有哪些线程:
public class MultiThread {
public static void main(String[] args) {
// 获取 Java 线程管理 MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程 ID 和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
}
}
}
/**
执行结果:
[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口
*/
线程共享所属进程的堆和方法区,独有程序计数器、虚拟机栈、本地方法栈等资源。如下图:红色为线程共享区域,蓝色为线程独占区域。
线程的创建、销毁、切换的开销比进程小,能够更快地完成任务,并且可以充分利用多核处理器的优势,所以又被称为轻量级进程。同一进程的线程间可能会进行资源的交互,而进程间是不存在的。
对比
总的来说,进程与线程的区别主要有以下几点:
- 进程是操作系统分配资源的最小单位,而线程是CPU调度的最小单位。
- 进程之间相互独立,拥有各自独立的内存空间,线程之间共享进程的内存空间。
- 进程切换开销大,线程切换开销小。
- 进程间通信复杂,线程间通信简单。
代码使用
进程
在 java 代码中通过使用 ProcessBuilder 类来创建一个名为 notepad.exe 的进程,即打开记事本应用程序。
public class CreateProcessTest {
public static void main(String[] args) {
try {
ProcessBuilder processBuilder = new ProcessBuilder("notepad.exe");
Process process = processBuilder.start();
int exitCode = process.waitFor();
System.out.println("进程已结束,退出码为:" + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
然后调用 waitFor() 方法等待进程结束,并获取进程的退出码。
最后运行这个程序,会看到一个新的记事本窗口弹出来,这就是我们创建的进程。
当关闭记事本窗口时,程序会继续执行,输出进程的退出码。
线程
使用 Thread 类来创建一个新的线程:在 Thread 构造函数中,传入一个 Runnable 接口的实现,该实现定义了线程的任务。
这里只是简单地输出一些信息并模拟一个耗时任务。
public class CreateThreadTest {
public static void main(String[] args) {
// 创建一个新的线程
Thread thread = new Thread(() -> {
System.out.println("线程开始执行");
try {
Thread.sleep(10000); // 模拟线程执行耗时任务
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程执行完成");
});
// 启动线程
thread.start();
System.out.println("主线程继续执行");
}
}
然后调用 start() 方法来启动线程,当线程启动后,它会执行 run() 方法中定义的任务,同时主线程会继续执行。
执行结果如下:
主线程继续执行
线程开始执行
线程执行完成
线程创建方式
①. 继承Thread类创建线程类
-
定义 Thread 类的子类,并重写该类的 run() 方法,该 run() 方法的方法体就代表了线程要完成的任务。因此把 run() 方法称为执行体。
-
创建 Thread 子类的实例,即创建了线程对象。
-
调用线程对象的 start() 方法来启动该线程。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("1111111");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
②. 通过 Runnable 接口创建线程类
-
定义 Runnable 接口的实现类,并重写该接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体。
-
创建 Runnable 实现类的实例,并依此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
-
调用线程对象的 start() 方法来启动该线程。
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println("1111111");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
Thread threadNew = new Thread(thread);
threadNew.start();
}
}
③. 通过 Callable 和 Future 创建线程
-
创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法将作为线程执行体,并且有返回值。
-
创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值。
-
使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
-
调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值。
public class MyThread implements Callable {
@Override
public Object call() throws Exception {
return 1+1;
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
FutureTask futureTask = new FutureTask<>(myThread);
new Thread(futureTask).start();
Object o = null;
try {
o = futureTask.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println(o.toString());
}
}
线程的生命周期和状态
- NEW: 初始状态,线程被创建出来但没有被调用 start() 。
- RUNNABLE: 运行状态,线程被调用了 start() 等待运行的状态。
- BLOCKED :阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕。
线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
操作系统层面,线程有 READTY 和 RUNNING 两种状态,而在 JVM 层面,只有 RUNNABLE 一种状态,因为每个线程在获取到 CPU 时间片时只会运行 0.01秒左右就会被切换执行其他的线程,线程切换速度之快,就没必要区分这两种状态了。
停止线程
在 Java 中,可以通过调用线程的 interrupt() 方法来中止线程。但是这并不意味着线程会立即停止执行,它只是设置了一个中断标志,线程可以通过检查这个标志来自行终止。 具体来说,当线程被中断时,可以通过以下方式来检查中断标志:
- 调用 Thread.currentThread().isInterrupted() 方法检查当前线程是否被中断。
- 调用 Thread.interrupted() 方法检查当前线程是否被中断,并清除中断状态。
在实际应用中,如果需要中止一个线程,可以在执行任务的循环中检查中断标志,如果中断标志被设置,则退出循环,从而中止线程的执行。
方法介绍
- Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入阻塞,但不释放对象锁,millis 后线程自动苏醒进入可运行状态。作用:给其它线程执行机会的最佳方式。
- Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的 cpu 时间片,由运行状态变会可运行状态,让 OS 再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证 yield() 达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield() 不会导致阻塞。
- obj.wait(),当前线程调用对象的 wait() 方法,当前线程释放对象锁,进入等待队列。依靠
notify()/notifyAll()
唤醒或者wait(long timeout)
timeout 时间到自动唤醒。 - t.join()/t.join(long millis),当前线程里调用其它线程1的 join 方法,当前线程阻塞,但不释放对象锁,直到线程1执行完毕或者 millis 时间到,当前线程进入可运行状态。
- obj.notify() 唤醒在此对象监视器上等待的单个线程,选择是任意性的。
- notifyAll() 唤醒在此对象监视器上等待的所有线程。
sleep() / wait()
共同点 :两者都可以暂停线程的执行。
区别 :
- sleep() 方法不释放锁, wait() 方法释放锁;
- wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
- wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒,或者也可以使用
wait(long timeout)
超时后线程会自动苏醒。 - sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
为什么 wait() 不被定义在 Thread 中?sleep() 定义在 Thread 中?
wait 是想让获得对象锁的线程暂停执行,会自动释放当前线程占有的对象锁。每个对象都有对象锁,既然要释放对象锁,就得对对象进行操作。
因为 sleep() 是让当前线程暂停执行,不需要获得对象锁,所以不涉及到对象类。
run()/start()
- 通过调用 Thread 类的 start() 方法来启动一个线程,真正实现了多线程运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码; 这时此线程是处于就绪状态, 并没有运行。 然后通过此 Thread.start() 调用方法 run() 来完成其运行状态, 这里方法 run() 称为线程体,它包含了要执行的这个线程的内容, run() 方法运行结束, 此线程终止。然后CPU再调度其它线程。
- run() 方法是在本线程里的,只是线程里的一个函数, 而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接调用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。
为什么使用多线程?
- 单个线程被IO阻塞,其它线程还可以获取 cpu,提高系统资源的利用率。
- 线程被称为轻量级的进程,它的切换与调度成本远远小于进程。
- 利用好多线程机制可以大大提高系统整体的并发能力以及性能。
- 现在到了多核的时代,多个线程可以并行执行,减少了线程上下文切换的开销。
CPU 通过给每个线程分配 CPU 时间片 来实现线程切换,在进行线程切换时需要保存当前线程正在执行任务的状态(也就是我们所说的上下文),以便下次切回到这个任务时,还可以继续执行该任务,即任务从保存到再加载的过程就是一次上下文切换。
多线程的缺点:多线程并发执行可能会导致内存泄漏、死锁、线程不安全等问题。
线程安全问题
在 Java 中,多线程并发操作同一个共享变量时,就可能会发生线程安全问题。 在 Java 中保证线程安全的常用手段有以下三个:
- 使用锁机制:锁机制是一种用于控制多个线程对共享资源进行访问的机制。在 Java 中,锁机制主要有两种:synchronized 关键字和 Lock 接口。synchronized 关键字是 Java 中最基本的锁机制,它可以用来修饰方法或代码块,以实现对共享资源的互斥访问。而 Lock 接口是 Java5 中新增的一种锁机制,它提供了比 synchronized 更强大、更灵活的锁定机制,例如可重入锁、读写锁等;
- 使用线程安全的容器:如 ConcurrentHashMap、Hashtable、Vector。需要注意的是,线程安全的容器底层通常也是使用锁机制实现的;
- 使用本地变量:线程本地变量是一种特殊的变量,它只能被同一个线程访问。在 Java 中,线程本地变量可以通过 ThreadLocal 类来实现。每个 ThreadLocal 对象都可以存储一个线程本地变量,而且每个线程都有自己的一份线程本地变量副本,因此不同的线程之间互不干扰。
线程死锁
多个线程同时被阻塞,他们都在等待某个资源被释放,由于线程无限期的阻塞,导致程序不可能正常终止,我们把这种现象称为死锁。
如下图所示,线程1拥有资源A的锁A,想要获取资源B的锁B,但是此时资源B的锁B正被线程2拥有,而线程2却想要获取线程1拥有的锁A,所以俩线程会无限等待,造成死锁。
如何避免死锁?
产生死锁的四个必要条件
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何预防和避免线程死锁?
破坏死锁的产生的必要条件即可:
- 破坏请求与保持条件 :一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
总结
本文我们从大家熟知的线程和进程入手,通过对比他俩的使用场景、代码使用来方便记忆。随后对线程的创建方式以及生命周期进行了详细的讲解。然后介绍了线程使用过程中的一些方法,方便大家更好的入手。最后对多线程的安全问题和死锁问题进行了总结,希望大家做到温故而知新,要不然很容易忘记概念性的东西。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签: