Java并发编程基础知识点

目录

Java并发编程基础知识点

1、线程,进程概念及二者的关系

进程相关概念

定义:

进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个基本单位,包含了程序的代码、数据、文件描述符、内存空间和其他系统资源。

每一个进程都有它自己的内存空间和系统资源。

特性:

进程间相互独立,一个进程的崩溃不会影响其他进程。

进程之间的通信比较复杂,通常使用进程间通信机制(IPC)如管道、消息队列、共享内存等。

进程切换开销较大,因为涉及到内存页表的切换和其他资源的管理。

使用场景:

适用于需要高隔离性的任务,比如不同用户的程序,系统服务和用户应用的隔离等。

例如:生产环境Linux系统中运行了两个微服务,A和B, 那么这两个微服务就对应了两个不同的JVM进程。

线程相关概念

定义:

线程是进程中的一个执行单元,一个进程可以包含多个线程,它们共享进程的资源。

线程有自己的堆栈、程序计数器和局部变量,但共享进程的代码段、数据段和操作系统资源。

特性:

线程之间的切换开销较小,因为它们共享进程的资源,不涉及内存页表的切换。

线程间通信简单,因为它们共享相同的地址空间。

线程间相互影响较大,一个线程的异常可能导致整个进程崩溃。

使用场景:

适用于需要频繁进行任务切换且任务间需要共享大量数据的场景,如多线程服务器、并行计算等。

例如:我们在一个微服务应用中,new Thread新建了两个线程来处理集合中的数据。那么在Linux上运行这个微服务,就相当于启动了一个JVM进程,并且这个JVM进程包含了我们新建的线程。

进程与线程的关系

  • ①、包含关系:

    一个进程可以包含多个线程,但一个线程只能属于一个进程。

    进程是线程的容器,所有线程共享进程的资源。

  • ②、资源共享:

    同一进程内的线程共享进程的内存空间和资源。不同进程之间的资源是相互独立的。

  • ③、通信与同步:

    线程间通信更加高效,可以直接通过共享变量进行。

    进程间通信较复杂,需要使用操作系统提供的IPC机制。

  • ④、开销与性能:

    创建和销毁线程的开销比进程小,线程切换的开销也比进程切换小。

    多线程编程更容易实现数据共享和任务并发,但也更容易引起竞争条件和死锁等问题。

补充小知识点:

我们启动第一个最简单的JVM进程,比如只打印一个hello world 实际上这个进程中已经包含了多个线程。

我们可以运行一个简单的Java应用来看下一个基本的运行中JVM进程包含哪些线程:

java 复制代码
import java.util.concurrent.TimeUnit;

public class TestA {
    public static void main(String[] args) {
        
        // 打印 hello world
        System.out.println("hello world");

        // 让主线程休眠5分钟  方面我们去观察线程
        try {
            TimeUnit.MINUTES.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

我们 javac TestA .java编译好后,再java TestA 运行。

利用jps工具 查看正在运行的java应用进程号。

再用 jstack 工具,查看上面进程下的线程情况。

java 复制代码
C:\Users\73158\Desktop\TestJavaSe\out\production\TestJavaSe>javac TestA.java
C:\Users\73158\Desktop\TestJavaSe\out\production\TestJavaSe>java TestA
hello world

C:\Users\73158>jps
17512 TestA

C:\Users\73158>jstack 17512
2024-06-29 09:55:19
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.202-b08 mixed mode):

"Service Thread" #18 daemon prio=9 os_prio=0 tid=0x000000001ec3a000 nid=0x413c runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread11" #17 daemon prio=9 os_prio=2 tid=0x000000001eb78000 nid=0x1ce4 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread10" #16 daemon prio=9 os_prio=2 tid=0x000000001eb6a000 nid=0x5540 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread9" #15 daemon prio=9 os_prio=2 tid=0x000000001eb69800 nid=0x5158 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C1 CompilerThread8" #14 daemon prio=9 os_prio=2 tid=0x000000001eb68800 nid=0x2e90 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread7" #13 daemon prio=9 os_prio=2 tid=0x000000001eb66000 nid=0x12d0 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread6" #12 daemon prio=9 os_prio=2 tid=0x000000001eb61000 nid=0x4ff8 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread5" #11 daemon prio=9 os_prio=2 tid=0x000000001eb5e800 nid=0x4364 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread4" #10 daemon prio=9 os_prio=2 tid=0x000000001eb5d000 nid=0x4fac waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread3" #9 daemon prio=9 os_prio=2 tid=0x000000001eb5c800 nid=0x4d44 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread2" #8 daemon prio=9 os_prio=2 tid=0x000000001eb59800 nid=0x5b38 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread1" #7 daemon prio=9 os_prio=2 tid=0x000000001eb59000 nid=0x3b64 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread0" #6 daemon prio=9 os_prio=2 tid=0x000000001eb56800 nid=0x51f4 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Attach Listener" #5 daemon prio=5 os_prio=2 tid=0x000000001eb03000 nid=0x278c waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Signal Dispatcher" #4 daemon prio=9 os_prio=2 tid=0x000000001eb02000 nid=0x3464 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Finalizer" #3 daemon prio=8 os_prio=1 tid=0x000000001eae6800 nid=0x3bb4 in Object.wait() [0x000000002045f000]
   java.lang.Thread.State: WAITING (on object monitor)
        at java.lang.Object.wait(Native Method)
        - waiting on <0x000000076eb08ed0> (a java.lang.ref.ReferenceQueue$Lock)
        at java.lang.ref.ReferenceQueue.remove(Unknown Source)
        - locked <0x000000076eb08ed0> (a java.lang.ref.ReferenceQueue$Lock)
        at java.lang.ref.ReferenceQueue.remove(Unknown Source)
        at java.lang.ref.Finalizer$FinalizerThread.run(Unknown Source)

"Reference Handler" #2 daemon prio=10 os_prio=2 tid=0x000000001d05c800 nid=0x3d74 in Object.wait() [0x000000002035e000]
   java.lang.Thread.State: WAITING (on object monitor)
        at java.lang.Object.wait(Native Method)
        - waiting on <0x000000076eb06bf8> (a java.lang.ref.Reference$Lock)
        at java.lang.Object.wait(Unknown Source)
        at java.lang.ref.Reference.tryHandlePending(Unknown Source)
        - locked <0x000000076eb06bf8> (a java.lang.ref.Reference$Lock)
        at java.lang.ref.Reference$ReferenceHandler.run(Unknown Source)

"main" #1 prio=5 os_prio=0 tid=0x0000000003642800 nid=0x2040 waiting on condition [0x000000000363f000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at java.lang.Thread.sleep(Unknown Source)
        at java.util.concurrent.TimeUnit.sleep(Unknown Source)
        at TestA.main(TestA.java:9)

"VM Thread" os_prio=2 tid=0x000000001eac2000 nid=0x41b4 runnable

"GC task thread#0 (ParallelGC)" os_prio=0 tid=0x0000000003657000 nid=0x1ad0 runnable

"GC task thread#1 (ParallelGC)" os_prio=0 tid=0x0000000003658800 nid=0x51d0 runnable

"GC task thread#2 (ParallelGC)" os_prio=0 tid=0x000000000365a000 nid=0x43c8 runnable

"GC task thread#3 (ParallelGC)" os_prio=0 tid=0x000000000365c000 nid=0xe80 runnable

"GC task thread#4 (ParallelGC)" os_prio=0 tid=0x000000000365e000 nid=0x17dc runnable

"GC task thread#5 (ParallelGC)" os_prio=0 tid=0x000000000365f000 nid=0x5bc8 runnable

"GC task thread#6 (ParallelGC)" os_prio=0 tid=0x0000000003662800 nid=0x2674 runnable

"GC task thread#7 (ParallelGC)" os_prio=0 tid=0x0000000003663800 nid=0x37dc runnable

"GC task thread#8 (ParallelGC)" os_prio=0 tid=0x0000000003665800 nid=0x25bc runnable

"GC task thread#9 (ParallelGC)" os_prio=0 tid=0x0000000003667000 nid=0x1a64 runnable

"GC task thread#10 (ParallelGC)" os_prio=0 tid=0x0000000003668000 nid=0x5040 runnable

"GC task thread#11 (ParallelGC)" os_prio=0 tid=0x000000000366b000 nid=0x3b90 runnable

"GC task thread#12 (ParallelGC)" os_prio=0 tid=0x000000000366c800 nid=0x3688 runnable

"VM Periodic Task Thread" os_prio=2 tid=0x000000001eca7000 nid=0x2664 waiting on condition

JNI global references: 5

可以看到Java虚拟机(JVM)当前的所有线程状态,包括守护线程、用户线程以及与垃圾回收相关的线程等。
其中主要信息如下:

  • ①、主线程(main):

    状态:TIMED_WAITING (sleeping),意味着主线程正在等待特定时间后继续执行,通常是因为调用了 Thread.sleep() 方法。

    堆栈跟踪指出,TestA.java:9 行调用了线程睡眠方法。这可能是程序设计的一部分,用于模拟延时或控制执行节奏。

  • ②、编译器线程(C1 CompilerThread, C2 CompilerThread):

    这些线程负责即时编译(JIT),将字节码转换为本地机器码以提高性能。

    它们的状态大部分为 RUNNABLE 或 WAITING on condition,表明它们要么正在执行编译任务,要么在等待新的编译任务。

  • ③、垃圾回收相关线程:

    如 Service Thread, Finalizer, Reference Handler 等,分别负责不同的垃圾回收辅助工作,如处理终结对象、引用队列等。

    这些线程的状态表明它们正按预期运行。

  • ④、守护线程:

    包括 Signal Dispatcher, Attach Listener 等,这些线程负责处理信号、虚拟机附件请求等后台任务,对JVM的正常运行至关重要。

  • ⑤、GC Task Threads (ParallelGC):

    这些是执行并行垃圾回收工作的线程。它们的存在表明您的JVM配置使用了并行垃圾回收器,并且它们处于可运行状态,准备进行垃圾回收工作。

所以能够得出结论,一个运行状态的最基本的JVM进程内部都是以多线程方式在运行。

2、线程的状态

参考机械工业出版社 《Java核心技术卷 Ⅰ》第11版

Java线程的状态:

状态名称 说明
NEW 新建(初始)状态,线程被构建,但是还没有调用 start() 方法
RUNNABLE 运行状态,Java 线程将操作系统的就绪和运行两种状态笼统地称作"运行中"
BLOCKED 阻塞状态,表示线程阻塞于锁
WAITING 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态,该状态不同于 WAITING,它可以在指定的时间自动返回到RUNNABLE 状态
TERMINATED 终止状态,表示当前线程已经执行完毕

Java线程不同状态之间的切换图示

总结:

Java线程创建之后它将处于 NEW(新建) 状态。

调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。

可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。

当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。

而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。

当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。

当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。

线程在执行 Runnable 的run()方法完毕之后将会进入到 TERMINATED(终止) 状态。

操作系统层面的线程状态:

状态名称 说明
新建 新建状态,线程被创建,但尚未开始执行。此时,线程尚未被调度到CPU上。
就绪 就绪状态,线程已准备好运行,即它可以被CPU调度执行,但它可能正在等待CPU分配时间片。
阻塞 阻塞状态,线程暂时不能执行,可能是因为等待某个事件(如I/O完成、锁的释放或其他同步条件满足)。它不消耗CPU时间。包含等待和超时等待。
运行 运行状态,线程正被CPU执行。
终结 终结状态,线程已完成执行,无论是正常结束还是因异常终止。

图示:

3、Java程序中如何创建线程?

①、继承Thread类

通过继承Thread类并重写run()方法来创建线程。
示例:

java 复制代码
public class MyThread extends Thread {
    @Override
    public void run() {
        // 线程执行的代码
        System.out.println("Thread is running");
    }

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();  // 启动线程
    }
}

②、实现Runnable接口

通过实现Runnable接口并将其传递给Thread对象来创建线程。

java 复制代码
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
        System.out.println("Runnable is running");
    }

    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();  // 启动线程
    }
}

③、使用Lambda表达式

使用Lambda表达式来创建Runnable对象,使代码更加简洁。本质上和第二种一样。

java 复制代码
public class LambdaThread {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            // 线程执行的代码
            System.out.println("Lambda Runnable is running");
        });
        thread.start();  // 启动线程
    }
}

④、使用Callable和Future

通过实现Callable接口,并使用FutureTask来包装Callable对象,然后将其传递给Thread对象来创建线程。
这种方式可以返回线程执行的结果,并且可以抛出异常。

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 线程执行的代码
        return "Callable result";
    }

    public static void main(String[] args) {
        MyCallable myCallable = new MyCallable();
        FutureTask<String> futureTask = new FutureTask<>(myCallable);
        Thread thread = new Thread(futureTask);
        thread.start();  // 启动线程

        try {
            // 获取线程执行结果
            String result = futureTask.get();
            System.out.println(result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
        
    }
}

⑤、使用线程池(Executor框架)

通过Java提供的Executor框架来管理线程池,从而创建和管理多个线程。

这种方式是生产环境使用最多的方式。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                // 线程执行的代码
                System.out.println("Thread pool is running");
            });
        }

        // 关闭线程池
        executorService.shutdown();
    }
}

生产一般使用下面这个构造函数自己构造合适的线程池。

java 复制代码
public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
                              // ...
                              }

4、简单介绍线程状态切换相关的方法

start方法

作用:启动一个新线程。

细节:当调用start()方法时,线程会进入就绪(Ready)状态,等待JVM线程调度器为其分配CPU时间。当获得CPU时间后,线程会进入运行(Running)状态,并执行其 run()方法。

notify方法

作用:唤醒在该对象监视器上等待的单个线程。

细节:调用notify()方法会将等待池中的一个线程(具体哪个线程被唤醒是不可预知的)移动到锁池中,等待获得对象的锁。通常与wait()方法结合使用。

wait方法

作用:让当前线程进入等待(Waiting)状态,直到其他线程调用该对象的notify()或notifyAll()方法。

细节:调用wait()方法时,当前线程会释放锁并进入等待池,直到被唤醒。

Thread类的sleep方法

作用:使当前线程暂停执行一段时间,进入阻塞(Blocked)状态。

细节:sleep()方法接受一个时间参数(以毫秒为单位),在这段时间内,线程不会获得CPU时间。

TimeUnit类的sleep方法

作用:类似于Thread类的sleep()方法,但可以指定时间单位。

细节:提供了一种更加直观的方式来暂停线程,如秒、毫秒、微秒等。

join方法

作用:等待该线程终止。

细节:调用join()方法的线程会进入等待(Waiting)状态,直到被调用线程完成执行。

yield方法

作用:提示线程调度器当前线程愿意放弃CPU时间片。

细节:调用yield()方法会使当前线程从运行(Running)状态变为就绪(Ready)状态,允许其他具有相同优先级的线程获得执行机会。

interrupt方法

作用:中断线程,使其从阻塞状态或等待状态中恢复过来。

细节:调用interrupt()方法会设置线程的中断状态。如果线程正处于sleep()、wait()或join()状态,会抛出InterruptedException异常。

对比sleepwait

比较点 wait() / wait(long) sleep(long)
共同点 让当前线程暂时放弃CPU的使用权,进入阻塞状态 让当前线程暂时放弃CPU的使用权,进入阻塞状态
方法归属不同 属于 Object 的成员方法,每个对象都有 属于 Thread 的静态方法
唤醒时机不同 1. wait(long)wait() 可以被 notify 唤醒,wait() 如果不唤醒就一直等待下去 2. 可以被打断唤醒 1. 等待相应毫秒后唤醒 2. 可以被打断唤醒
锁特性不同 1. 调用前必须先获取 wait 对象的锁 2. 执行后会释放对象锁,允许其他线程获取该对象锁 1. 无此限制 2. 在 synchronized 代码块中执行,不会释放对象锁

5、使用多线程的好处

列举一些常见的好处:

  • ①、 提高CPU利用率

    CPU通常具有多个核心(多核处理器)。每个核心可以独立执行线程。多线程程序可以将任务分配给不同的核心,从而并行处理多个任务,充分利用多核处理器的能力。

  • ②、提高程序吞吐量
    并行处理

    多线程允许将大任务分解为多个子任务,并行执行,从而加快任务的完成速度。例如,在Web服务器中,可以为每个请求分配一个线程,允许服务器同时处理多个请求,显著提高吞吐量。
    资源等待

    线程在等待某些资源(如磁盘IO、网络IO)时,可以释放CPU给其他线程使用,避免CPU空闲等待,提高整体系统的吞吐量。

  • ③、提高资源利用率

    I/O操作与计算操作并行

    多线程允许I/O操作和计算操作并行进行。例如,一个线程可以负责读取文件或从网络接收数据,而另一个线程可以处理这些数据。这样可以充分利用系统的I/O带宽和计算能力。

  • ④、异步编程模型

    多线程支持异步编程模型,可以更方便地处理异步事件(如用户输入、网络请求),提高代码的可读性和可维护性。

用户界面线程

在GUI应用程序中,用户界面线程负责处理用户输入和更新界面。如果这个线程被阻塞,用户界面就会变得无响应。通过将耗时的任务(如文件IO、网络请求)放到后台线程中,可以保持用户界面的流畅性和响应性。

6、使用多线程可能带来的问题

列举一些常见的问题:

  • ①、线程同步问题
    竞态条件(Race Conditions)

    当多个线程同时访问和修改共享资源时,如果不正确同步,可能会导致竞态条件。 这种情况下,程序的行为依赖于线程执行的顺序,导致不可预测的错误。
    死锁(Deadlocks)

    当两个或多个线程互相等待对方释放资源时,会发生死锁,导致线程无法继续执行。
    数据一致性

    多个线程同时访问和修改共享数据时,如果不正确同步,可能会导致数据的不一致,导致程序在不同的执行时刻呈现不同的状态。

  • ②、性能问题
    上下文切换(Context Switching)

    线程切换会产生上下文切换开销。上下文切换是操作系统保存和恢复线程状态的过程,如果切换频繁,会导致性能下降。
    过度创建线程

    创建过多的线程会导致CPU频繁地在这些线程之间切换,导致系统性能下降。每个线程都有内存开销,如果创建过多的线程,内存使用会显著增加,可能导致内存不足。

  • ③、内存可见性问题

    线程间共享变量的修改在另一个线程中可能不可见。如果不正确同步,可能会出现内存一致性错误。

  • ④、难以调试和测试

    多线程程序的非确定性和复杂性使得调试和测试变得更加困难。重现多线程问题通常很困难,因为这些问题往往依赖于特定的时间序列和线程调度。

什么是线程死锁?如何避免死锁?

当两个或多个线程互相等待对方释放资源时,就会发生死锁。

例如:有两只狗秀逗和四眼,秀逗和它的好兄弟四眼都着急上厕所,假设上厕所需要拿到厕所钥匙和手纸,此时秀逗抢到了钥匙,四眼抢到了手纸。它俩谁也不让谁,秀逗拿着钥匙等待四眼给它手纸,四眼拿着手纸等秀逗给它钥匙。等着等着,两只狗都崩裤兜里了。。。

这就是出现了线程死锁,反应到计算机上,可能的问题就是导致线程无法继续往下运行,还会导致计算机资源无法释放,系统可用资源逐渐减少,最终可能导致系统资源耗尽。参与死锁的线程无法继续执行,造成这些线程的永久阻塞。随着死锁线程的增加,可用线程数减少,系统整体处理能力下降,导致性能下降。

产生死锁必须具备以下四个条件:

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

为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。

现在我们来挨个分析一下:

破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件 :一次性申请所有的资源。

破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

那么如何解决秀逗和四眼拉裤兜的问题呢?

我们可以破坏请求与保持条件,把厕所钥匙和手纸放在一个盒子里,秀逗和四眼抢盒子,谁抢到了盒子谁就能获得上厕所必备的钥匙和手纸。

还可以破坏不可剥夺条件,秀逗或者四眼如果没有抢到两样东西,可以选择主动放弃手里的东西让给对方,让对方先用,这样也可以解决问题。

最后还可以破坏循环等待条件,规定只能先抢钥匙,抢到钥匙的才能去拿手纸,用完之后先放回手纸再放回钥匙。

如何知道Java程序产生了死锁?

检测死锁方式比较多,主要分为两类:

①、手动编程实现

以上面秀逗、四眼的例子写个示例:

java 复制代码
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Timer;
import java.util.TimerTask;

public class TestA {
    // 用于监控和管理Java虚拟机中的线程行为
    private static final ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();

    // 厕所钥匙
    private static final Object toiletKey = new Object();
    // 厕纸
    private static final Object toiletPaper = new Object();


    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            deadThreadCheck();
        }, "死锁检测线程");
        t.start();


        Thread xiudou = new Thread(() -> {
            synchronized (toiletKey) {
                System.out.println("秀逗抢到了厕所钥匙");
                System.out.println("秀逗开始抢厕纸");
                synchronized (toiletPaper) {
                    System.out.println("秀逗抢到了厕纸");
                }
            }
        }, "xiudou");


        Thread siyan = new Thread(() -> {
            synchronized (toiletPaper) {
                System.out.println("四眼抢到了厕纸");
                System.out.println("四眼开始抢厕所钥匙");
                synchronized (toiletKey) {
                    System.out.println("四眼抢到了厕所钥匙");
                }
            }
        }, "siyan");


        xiudou.start();
        siyan.start();

    }


    // 定时检测线程死锁
    public static void deadThreadCheck() {
        Timer timer = new Timer(true);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
                if (deadlockedThreads != null && deadlockedThreads.length > 0) {
                    System.out.println("发现死锁线程!");
                    ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(deadlockedThreads);
                    for (ThreadInfo threadInfo : threadInfos) {
                        System.out.println(threadInfo);
                    }
                }
            }
        }, 0, 5000); // 每5秒检测一次
    }

}

运行结果:

shell 复制代码
秀逗抢到了厕所钥匙
秀逗开始抢厕纸
四眼抢到了厕纸
四眼开始抢厕所钥匙

发现死锁线程!
"siyan" Id=22 BLOCKED on java.lang.Object@1e8278f owned by "xiudou" Id=21

"xiudou" Id=21 BLOCKED on java.lang.Object@f2fd9c2 owned by "siyan" Id=22

可以看到代码利用ThreadMXBean接口提供的findDeadlockedThreads方法,检测出了死锁线程,并打印了出来。

线程信息包括,线程名称,id,被什么锁阻塞了,谁持有锁,锁持有者的线程名称,id。

②、借助工具

JDK本身提供了许多工具,用于检测JVM的运行状态。

比如: jconsole、jvisualvm。

如果你下载的是完整JDK,下面这两个工具就在JDK安装的bin目录中。

jvisualvm检测死锁

JVM启动参数添加以下参数:

java 复制代码
-Dcom.sun.management.jmxremote=true
# 启用JMX远程监控,使得JMX(Java Management Extensions)可以通过网络进行远程连接。

-Dcom.sun.management.jmxremote.port=9999
# 指定JMX远程监控的端口号。这里设置为9999,JMX客户端将通过这个端口连接到JVM进行监控和管理。

-Dcom.sun.management.jmxremote.ssl=false
# 禁用SSL(Secure Sockets Layer)加密。默认情况下,JMX远程监控使用SSL加密通信,设置为false后,将使用未加密的通信,这在开发和测试环境中可能会使用,但在生产环境中应慎用以确保安全。

-Dcom.sun.management.jmxremote.authenticate=false
# 禁用JMX远程监控的身份验证。默认情况下,JMX远程监控要求身份验证,设置为false后,任何人都可以连接并监控JVM,这同样在生产环境中应慎用以确保安全。

-Djava.rmi.server.hostname=localhost
# 设置Java RMI(Remote Method Invocation)服务器的主机名。这里设置为localhost,表示只能从本地连接到JMX服务。如果希望远程连接,可以设置为服务器的实际主机名或IP地址。

添加IDEA的JVM启动参数,运行上面代码。

打开jconsole程序,添加远程连接:

点击线程,检测死锁按钮。

就能看到死锁线程的详细信息:

jvisualvm检测死锁

和jconsole类似也可以添加JMX远程连接。

线程的上下文切换详解

线程的上下文切换是操作系统在多任务处理中,为了实现线程调度,在不同线程之间切换CPU执行权的过程。这个过程涉及保存当前线程的状态并恢复另一个线程的状态,使得多个线程能够共享CPU资源进行并发执行。

这里要注意一个概念的区分,上面说了并发执行,并发不是并行,需要注意区分。

并发(Concurrency)

并发指的是在同一时间段内,多个任务交替进行。并发任务并不一定要同时运行,而是快速切换执行,使得在宏观上看起来它们是同时进行的。并发的关键在于任务之间的切换和共享资源的协调。多线程、协程和多任务调度都属于并发处理。

并发的特点:

任务交替执行:通过时间片轮转等调度机制,任务交替执行。

共享资源:并发任务通常会共享系统资源(如CPU、内存)。

线程切换:通过线程上下文切换来实现任务间的切换。

并行(Parallelism)

并行指的是在同一时刻 ,多个任务同时进行。并行处理依赖于多核处理器或多台处理器,使得多个任务真正同时运行。并行的关键在于硬件支持多个处理单元同时工作。

并行的特点:

任务同时执行:多个任务在同一时刻同时执行。

多核处理器:依赖于多核CPU或多处理器系统。

独立资源:每个任务通常有独立的资源(如独立的CPU核心)。

举个例子:

下图中的CPU为8核,那么运行在这个CPU上的程序,最多只能并行运行8个线程。但是可以并发运行大于8个线程。

下面继续介绍线程的上下文切换相关概念:

  • ①、 什么是线程的上下文

    线程的上下文包含了线程执行所需的所有状态信息,包括:

    程序计数器(Program Counter,PC):当前正在执行的指令的地址。

    寄存器(Registers):CPU寄存器的内容,如通用寄存器、浮点寄存器等。

    线程栈(Stack):存储局部变量、函数调用信息等。

    内存管理信息:页表、段表等信息,用于地址空间转换。

    处理器状态字(Processor Status Word,PSW):包含标志位和控制信息。

    其它资源状态:如文件描述符等。

  • ②、上下文切换的过程

    线程上下文切换的大致过程如下:
    保存当前线程的状态:

    将当前线程的寄存器内容、程序计数器、处理器状态字等信息保存到该线程的线程控制块(Thread Control Block,TCB)中。
    选择新的线程:

    操作系统的调度器根据调度算法选择一个新的线程来运行。这个过程涉及从就绪队列中选择合适的线程。
    恢复新线程的状态:

    从新线程的TCB中恢复其寄存器内容、程序计数器、处理器状态字等信息。
    更新内存管理信息:

    更新页表、段表等内存管理信息,以便新的线程可以正确访问其内存空间。
    切换完成,执行新线程:

    CPU开始执行新线程的指令。

  • ③、上下文切换的开销

    上下文切换是有开销的,因为它涉及多个步骤和对硬件资源的操作。主要的开销包括:

    CPU开销:保存和恢复寄存器、程序计数器等操作需要CPU周期。

    内存开销:需要保存和恢复内存管理信息。

    缓存开销:上下文切换会导致缓存失效,增加缓存命中率的降低。

    调度开销:操作系统调度器选择新线程的过程也会产生开销。

    高频率的上下文切换会导致系统性能下降,因此需要合理控制线程数量和切换频率。

  • ④、上下文切换的原因

    上下文切换的主要原因包括:

    时间片到期:操作系统采用时间片轮转调度,每个线程只能占用CPU一个时间片,到期后进行上下文切换。

    线程阻塞:线程因为I/O操作或等待资源而阻塞,操作系统会切换到其他就绪线程。

    优先级调度:高优先级线程进入就绪状态,操作系统进行优先级调度,切换到高优先级线程。

    多处理器环境:在多处理器系统中,负载均衡和任务分配也会导致上下文切换。

既然上下文切换会产生性上开销,那么就要想办法减少上下文切换,来降低切换带来的性能开销。

减少上下文切换的策略:

为了减少上下文切换带来的性能开销,可以采取以下策略:

减少线程数量:合理控制线程数量,避免线程过多导致频繁的上下文切换。

使用线程池:使用线程池可以重用线程,减少线程的创建和销毁,降低上下文切换的频率。

提高线程的工作负载:尽量增加每个线程的工作时间,减少线程切换的频率。

优化调度算法:采用合适的调度算法,减少不必要的上下文切换。

7、如何保证线程安全

线程安全的含义

线程安全是指在多线程环境下,多个线程同时访问共享资源 时,不会出现数据不一致、数据损坏或程序崩溃的现象。

一个线程安全的程序保证了每个线程对共享资源的操作是原子的,不会被其他线程的操作打断,从而确保了数据的正确性和一致性。

注意上面说的是共享资源,所以多线程只有在访问共享资源的情况下,才需要处理线程安全问题。假如处理的是自身线程的私有资源是不涉及线程安全问题的。

还需要注意,多个线程同时访问且修改共享资源 时才会涉及线程安全问题,如果多个线程只是读取共享资源,也不会涉及线程安全问题。

共享资源与线程安全

共享资源是指多个线程可以访问和修改的资源,包括:

全局变量

静态变量

对象实例的成员变量(在多个线程中共享同一个对象实例时)

当多个线程同时访问这些共享资源时,可能会出现竞态条件、数据不一致、死锁等问题。因此,需要采取同步措施来确保线程安全。

私有资源与线程安全

私有资源是指每个线程独立拥有和操作的资源,包括:

方法的局部变量

每个线程独立创建的对象实例

ThreadLocal变量(为每个线程提供独立的变量副本,后面会详细讲解)

由于私有资源只被单个线程访问和修改,不存在多个线程竞争访问的问题,因此不涉及线程安全问题。

保证线程安全的本质

保证线程安全的本质是对共享资源的访问进行适当的同步,确保每次只有一个线程能够访问或修改共享资源。

学习并发编程需要理解几个非常重要的概念: 原子性、可见性、有序性。

  • ①、原子性

    原子性指的是一个操作或一组操作要么全部执行并且中间不被打断,要么全部不执行。在多线程环境中,原子操作是不可分割的,即在一个操作完成之前,其他线程不能访问和修改相同的资源。

  • ②、可见性

    可见性指的是一个线程对共享变量的修改能够及时对其他线程可见。在多线程环境中,线程对变量的修改可能不会立即被其他线程看到,导致线程之间的数据不一致。

  • ③、有序性

    有序性指的是程序执行的顺序与代码的顺序一致。在多线程环境中,编译器和处理器可能会对指令进行重排序,以提高性能,但这种重排序不能改变单线程程序的语义。

我们保证线程安全的手段基本上都是围绕 原子性、可见性、有序性展开的。

保证线程安全的前提下提高性能

细粒度锁定(Fine-Grained Locking):

尽量缩小锁的范围,减少锁的持有时间,以提高并发性。

比如之前我们说过的JDK8对于ConcurrentHashMap的优化,比如LinkedBlockingQueue 采用的读写锁分离,都缩小了锁的范围。

读写锁(ReadWriteLock):

使用读写锁ReentrantReadWriteLock,允许多个读操作同时进行,但写操作是独占的,从而提高读操作的并发性。

无锁算法和数据结构:

使用无锁算法和数据结构,避免锁的使用,如Atomic原子类和ConcurrentLinkedQueue、CAS操作等。

对并发编程有整体了解后再去看ConcurrentLinkedQueue详解一定会有新的收获。
可以参考我的文章ConcurrentLinkedQueue详解。

线程本地存储(ThreadLocal):

使用ThreadLocal为每个线程提供独立的变量副本,避免共享变量的竞争。

后面文章会详细介绍,Java保证线程安全的技术,以及提高并发编程性能的相关知识点。

相关推荐
skywalk81638 分钟前
Clojure学习:运行在 Java 虚拟机(JVM)上的动态Lisp编程语言
java·lisp·clojure
一碗谦谦粉12 分钟前
Spring之spring的单例bean是线程安全的吗
java·spring
liuliuliuliuyujie13 分钟前
学生管理系统
java
ItKevin爱java16 分钟前
java八股文面试题
java·开发语言
喜欢猪猪28 分钟前
两个线程之间是如何通信的呢?
java·开发语言
co松柏32 分钟前
在非 antd pro 项目中使用 umi OpenAPI
java·前端
java66666888833 分钟前
Spring Boot中的数据加密与解密
java·spring boot·后端
NightHacker44 分钟前
什么是AOP思想?
java·开发语言
番茄炒西红柿炒蛋1 小时前
秋招Java后端开发冲刺——非关系型数据库篇(MongoDB)
java·mongodb·nosql
虫小宝1 小时前
高可用电商返利APP架构设计与实现分享
java·开发语言