【并发编程】多线程安全问题,如何避免死锁

从今天开始阿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() 方法来中止线程。但是这并不意味着线程会立即停止执行,它只是设置了一个中断标志,线程可以通过检查这个标志来自行终止。 具体来说,当线程被中断时,可以通过以下方式来检查中断标志:

  1. 调用 Thread.currentThread().isInterrupted() 方法检查当前线程是否被中断。
  2. 调用 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 中保证线程安全的常用手段有以下三个:

  1. 使用锁机制:锁机制是一种用于控制多个线程对共享资源进行访问的机制。在 Java 中,锁机制主要有两种:synchronized 关键字和 Lock 接口。synchronized 关键字是 Java 中最基本的锁机制,它可以用来修饰方法或代码块,以实现对共享资源的互斥访问。而 Lock 接口是 Java5 中新增的一种锁机制,它提供了比 synchronized 更强大、更灵活的锁定机制,例如可重入锁、读写锁等;
  2. 使用线程安全的容器:如 ConcurrentHashMap、Hashtable、Vector。需要注意的是,线程安全的容器底层通常也是使用锁机制实现的;
  3. 使用本地变量:线程本地变量是一种特殊的变量,它只能被同一个线程访问。在 Java 中,线程本地变量可以通过 ThreadLocal 类来实现。每个 ThreadLocal 对象都可以存储一个线程本地变量,而且每个线程都有自己的一份线程本地变量副本,因此不同的线程之间互不干扰。

线程死锁

多个线程同时被阻塞,他们都在等待某个资源被释放,由于线程无限期的阻塞,导致程序不可能正常终止,我们把这种现象称为死锁。

如下图所示,线程1拥有资源A的锁A,想要获取资源B的锁B,但是此时资源B的锁B正被线程2拥有,而线程2却想要获取线程1拥有的锁A,所以俩线程会无限等待,造成死锁。

在这里插入图片描述

如何避免死锁?

产生死锁的四个必要条件

  • 互斥条件:该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何预防和避免线程死锁?

破坏死锁的产生的必要条件即可:

  • 破坏请求与保持条件 :一次性申请所有的资源。
  • 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

总结

本文我们从大家熟知的线程和进程入手,通过对比他俩的使用场景、代码使用来方便记忆。随后对线程的创建方式以及生命周期进行了详细的讲解。然后介绍了线程使用过程中的一些方法,方便大家更好的入手。最后对多线程的安全问题和死锁问题进行了总结,希望大家做到温故而知新,要不然很容易忘记概念性的东西。

  • 89
    点赞
  • 74
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 78
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 78
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿Q说代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值