5

面试官:这就是你理解的Java多线程基础? - 蓉城北斗君

 1 week ago
source link: https://www.cnblogs.com/edisonM79/p/18174331
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

现代的操作系统(Windows,Linux,Mac OS)等都可以同时打开多个软件(任务),这些软件在我们的感知上是同时运行的,例如我们可以一边浏览网页,一边听音乐。而CPU执行代码同一时间只能执行一条,但即使我们的电脑是单核CPU也可以同时运行多个任务,如下图所示,这是因为我们的 CPU 的运行的太快了,把时间分成一段一段的,通过时间片轮转分给多个任务交替执行。

1961099-20240506094122799-675123637.png

把CPU的时间切片,分给不同的任务执行,而且执行的非常快,看上去就像在同时运行一样。例如,网易云执行50ms,浏览器执行50ms,word 执行50ms,人的感官根本感知不到。现在多数的电脑都是多核(多个 CPU )多线程,例如4核8线程(可以近似的看成8个 CPU ),也是把每个核心运行时间切片分给不同的任务交替执行。

进程与线程

进程(Process)是操作系统对一个正在运行的程序的一种抽象,我们可以进程简单理解为操作系统中正在运行的一个软件,即把一个任务称之为一个进程,例如我们的网易云音乐就是一个进程,浏览器又是另外一个进程。

线程(Thread)线程是一个比进程更小的执行单位,进程是线程的容器,一个进程至少有一个线程而且可以产生多个线程,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据,多线程之间比多进程之间更容易共享数据,而且线程一般来说都比进程更加高效。

1961099-20240506094150748-1568437128.png

java语言内置了多线程支持:JVM 启动时会创建一个主线程,该主线程负责执行 main 方法,一个 Java 程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。

我们需要区分线程和线程体两个概念,线程可以驱动任务,因此需要一个描述任务的方式,这个方式就是线程体,而我们创建线程体有多种方式,而创建线程只有一种:将任务(线程体)显示的附着到线程上,调用 Thread 对象的 start()方法,执行线程的初始化操作,然后新线程调用 run() 方法启动任务。

创建线程体可以使用下面 3 种方式,然而这 3 种方式都是在创建线程体,直到调用 Thread 对象的 start() 方法时才请求 JVM 创建新的线程,具体什么时候运行有线程调度器 Scheduler 决定。

  1. 继承 Thread 类;
/**
 * 1、定义Thread类的子类
 */
public class MyThread extends Thread {
    //2、重写Thread类的run方法
    //run()方法体内的内容就是线程要执行的代码
    @Override
    public void run() {
        // ...
    }
}

public static void main(String[] args) {
    //3、创建线程对象
    MyThread mt = new MyThread();
    //4、启动线程
    mt.start();
    /**
     * 调用线程的start()方法来启动线程,启动线程的实质是请求JVM运行相应的线程,
     * 这个线程具体什么时候运行,由线程调度器(scheduler)决定
     * 注意:
     *   调用start()方法不代表线程能立马运行
     *   线程启动后会运行run()方法
     *   如果启动了多个线程,start()调用的顺序不一定就是线程启动的顺序
     */
}
  1. 实现 Runable 接口;
//1、实现Runnable接口
public class MyRunable implements Runnable{

    //2、实现run方法
    @Override
    public void run() {
        // ...
    }

    public static void main(String[] args) {
        //3、将实现了Runnable接口的对象传入Thread的构造方法中
        Thread thread = new Thread(new MyRunable());
        //4、启动线程
        thread.start();
    }
}
  1. 实现 callable 接口
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableExample {
    public static void main(String[] args) {
        // 1、实现Callable接口的匿名内部类
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                System.out.println("Callable task is running");
                return 42;
            }
        };
        // 2、将Callable包装在RunnableFuture实现类中
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        // 3、将FutureTask实例传递给Thread类来执行
        Thread thread = new Thread(futureTask);
        thread.start();

        try {
            Integer result = futureTask.get();
            System.out.println("Result: " + result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在日常使用中,建议能用接口实现就不要用继承 Thread 的方式来创建线程,原因如下:

  1. 避免单继承的限制:Java是单继承的语言,如果一个类继承Thread类,就无法再继承其他类。而实现Runnable接口则不会有这种限制,避免了单继承的局限性。
  2. 更好的适配性:实现Runnable接口可以更好地支持类似线程池的机制,让线程的执行和任务的分离更清晰。传递Runnable对象给线程池执行任务十分方便,而且可以重复使用。
  3. 更好的面向对象设计:继承Thread类是一种功能导向的设计,而实现Runnable接口更倾向于面向对象的设计,符合面向对象的编程思想。

线程的状态

一个线程对象只能调用一次 start() 方法启动新线程,并在新线程中执行 run() 方法,一旦 run() 方法 执行完毕,线程就终止死亡了。我们通过 Thread 类中的枚举类 State 来看一下 Java 线程有哪些状态:

    public enum State {
        
        /**
         * 新建状态
         * 还没有执行start()方法的线程状态
         */
        NEW,

        /**
         * 可运行状态
         * 在Java虚拟机中运行处于可运行状态的线程,可能正在等待其他资源,例如处理器
         */
        RUNNABLE,
        /**
         * 阻塞状态
         * 处于阻塞状态的线程正在等待监视器锁,以进入同步代码块或在调用wait()后重新进入
         */
        BLOCKED,
        
        /**
         * 无限期等待状态
         * 线程因调用一下方法之一而处于无限期等待状态:
         * Object.wait with no timeout
         * Thread.join with no timeout
         * LockSupport.park
         * 处于等待状态的线程正在等待另一个线程执行特定操作
         */
        WAITING,
    
        /**
         * 具有指定等待时间的等待线程的线程状态
         * 线程处于定时等待状态的原因是调用了以下方法之一,并指定了正等待时间:
         * Thread.sleep
         * Object.wait with timeout
         * Thread.join with timeout
         * LockSupport.parkNanos
         * LockSupport.parkUntil
         */
        TIMED_WAITING,

        /**
         * 已终止线程的线程状态.
         * 线程已执行完毕.
         */
        TERMINATED;
    }

由源码可知,Java 的线程状态有 6 种:

  1. NEW:新创建的线程,还未执行;
  2. RUNNABLE:正在运行中线程或正在等待资源分配的准备运行的线程;
  3. BLOCKED:等待获取监视器锁的线程;
  4. WAITING:等待另外一个线程执行特定操作,没有时间限制;
  5. TIMED_WAITING:等待某个特定线程在制定时间段内执行特定操作;
  6. TERMINATED:线程执行完毕

线程状态的转换可以参考下图:

1961099-20240506094236283-1064328979.png
  • NEW 状态

创建线程后未启动线程状态为 NEW,在该线程调用 start() 方法以前会一直保持这种状态。此时,JVM 会为该线程分配内存并初始化其成员变量的值,但是该线程并没有表现出任何线程的动态特征,程序也不会执行线程的执行体,即 run() 方法的部分。

下面的代码,我们可以调用 Thread.getState() 方法来获取线程的状态,可以看出打印出来的状态为 NEW。

public void ThreadTest() {
    Thread t = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("ThreadTest");
        }
    });
    System.out.println(t.getState()); // NEW
}
  • RUNNABLE

当在Java的Thread对象上调用start()方法后,以下过程将会发生:

  1. 线程状态变化:线程对象的状态会从NEW(新建)状态转变为RUNNABLE(可运行)状态,表明线程已经准备好运行,但尚未分配到CPU执行。
  2. 系统资源分配:线程调度器会为该线程分配系统资源,例如CPU时间。然而,并不保证立即执行,具体执行时机还取决于线程调度器的调度算法和其他运行中的线程。
  3. 执行run()方法:当该线程被线程调度器选中并分配到CPU时间时,线程的run()方法会被调用,线程开始执行具体的任务逻辑。

处于RUNNABLE 状态的线程要么正在运行中,要么已经准备好运行但正在等待系统分配 CPU 资源

在Java虚拟机(JVM)中,JVM 自带的线程调度器负责决定Java线程的执行顺序。它会根据线程的优先级和调度算法来确定哪个线程可以获得 CPU 时间。通常情况下,程序员可以通过设置线程的优先级来影响线程调度器的决策,但实际线程的调度仍由 JVM 负责。

  • BLOCKED

当线程尝试访问某个由其他线程锁定的代码块时,该线程会因为需要等待获取监视器锁进入 BLOCKED 状态,线程获取锁后就会结束此状态。

  • WAITING

线程正在等待另一个线程执行特定操作时处于 WAITING 等待状态,例如当线程调用以下方法时会进入 WAITING 等待状态:

Object.wait()

Object.notify() / Object.notifyAll()

Thread.join()

被调用的线程(Thread)执行完毕

LockSupport.park()

上述方法中的 wait() 和 join() 没有传入超时时间 timeout 参数,线程只能等待其他线程显示的唤醒或执行完毕,否则不会被分配 CPU 时间片。

  • TIMED_WAITING

线程在这种状态下属于期限等待,无需其他线程显示的唤醒当前线程,在一定时间内被系统自动唤醒。

阻塞和等待的区别在于:阻塞是被动的,等待是主动的。阻塞是在等待获取锁,而等待是在等待一定的条件发生。

Thread.sleep()

设置了 Timeout 参数的 Object.wait() 方法

时间结束 / Object.notify() / Object.notifyAll()

设置了 Timeout 参数的 Thread.join() 方法

时间结束 / 被调用的线程执行完毕

LockSupport.parkNanos() 方法

LockSupport.parkUntil() 方法

  • TERMINATED

线程执行完毕或者产生异常而结束会进入 TERMINATED 状态,进入该状态的线程已经死亡。

并发问题产生的原因是:多个线程同时对一个共享资源进行非原子性操作,这里面包含了三个产生并发问题的三个条件:多个线程同时,共享资源,非原子性操作,解决线程安全问题的本质就是要破坏这三个条件,因此可以把多线程的并行执行,修改为单线程的串行执行,即同一时刻只让一个线程执行,这种解决方式就叫做互斥锁。

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock,从而达到保护共享资源的目的,当多条线程执行到被保护的区域时,都需要先去获取到锁,这时候只能有一条线程获取到锁,执行被保护区域的代码,其他线程在保护区外部等待获取锁,直到当前线程执行完毕释放资源后,其他线程才有执行的机会。

synchronized 和 ReentrantLock 可以保证可见性、原子性和有序性,另外一个 Java 的关键字 Volatile 也可以保证可见性,另外后者还可以禁止指令重排序。

Synchronized

在 Java 中每个对象都可以作为锁,Synchronized 也是依赖 Java 的对象来实现锁,一共有三种类型的锁:

  1. 当前实例锁:锁定的是实例对象,即为 this 锁;
  2. 类对象锁:锁定的是类对象,即为 Class 对象锁;
  3. 对象实例锁:锁定的给定的对象实例,即位 Object 锁;

在使用 Synchronize 时也有三种不同的方式:

  1. 修饰普通方法:使用 this 锁,在执行该方法前必须先获取当前实例对象的锁资源;
  2. 修饰静态方法:使用 class 锁,在执行该方法前必须先获取当前类对象的锁资源;
  3. 修饰代码块:使用 Object 锁,在执行该方法前必须先获取给定对象的锁资源;
public class A {

    String lockObject = new String();
    
    // 锁定当前的实例,this锁,每个实例拥有一个锁
    public synchronized void a() {};
    // 修饰的是静态方法,使用的 class 锁,多个对象共享 class 锁
    public static synchronized void b() {}
    
    public void c() {
        // 修饰的是代码块,使用的 lockObject 对象的锁,也是实例锁
        synchronized(lockObject) {
            // do something 
        }
        // 修饰代码块,使用的 B.class 类对象锁
        synchronized(B.class) {
            
        }    
    }

}

public class B {
    
}

三种不同的使用方式有不同的应用场景,我们在使用的过程中一定要注意加锁的对象是谁,否则可能会产生意想不到的结果。在加锁时,尽量减少加锁的区域,例如能够在方法体中对代码块加锁,就不要在方法上面加锁,加锁的区域越短越好。

ReentrantLock

ReentrantLock 是 Java.util.concurrent(J.U.C)包中的锁,该锁由 JDK 实现,而 synchronized 是由 JVM 实现的。

public class ReentrantLockDemo{
    private Lock lock = new ReentrantLock();

    private void func() {
        lock.lock(); // 加锁
        try {
            for (int i = 0; i < 10; i++) {
                system.out.prrint(i)
            }
        } finally {
            lock.unlock(); // 确保释放锁
        }
    } 
}

public static void main(Stirng[] args) {
    ReentrantLockDemo reentrantLockDemo = new ReentrantLockDemo();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}

上面的代码演示了ReentrantLock 的使用方法,显示的调用 lock()方法加锁,在 finally 中显示的释放锁。

synchronized

reentrantLock

新版本 Java 对 synchronized 进行了大量的优化,大致相同

等待可中断

默认非公平,支持公平锁

绑定多个条件

帮点多个 Condition 对象

在需要使用锁时,除非需要使用 reentrantLock 的高级功能,否则优先使用 synchronized 关键字加锁,这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生支持它,而 ReentrantLock 不是所有的 JDK 版本都支持,并且使用 syschronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保释放锁。

线程池可以管理一系列线程,当有任务需要处理时,直接从线程池里面获取线程来处理,当线程处理完任务时再放回到线程池中等待下一个任务,这样可以减少每次创建线程的开销,提升资源的利用率。线程池提供了一种限制和管理资源的方式,每个线程池还维护了一些基本的统计信息,例如 已完成任务的数量等。在《Java 并发编程的艺术》一书中提到使用线程有三点好处:

  1. 降低资源的消耗率:通过重复利用已创建的线程,降低线程创建和销毁造成的开销;
  2. 提高响应速度:当任务到达时,任务不需要等待线程创建结束即可执行;
  3. 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控;

创建线程池

可以使用内置的线程池,通过 Executor 框架的工具类 Executors 来创建预先定义好的线程池。

Executors

Executors 工具类提供的创建线程池的方法如下图所示:

1961099-20240506094306469-793065660.png

从上图中可以看出,Executors 工具类可以创建多种类型的线程池,包括:

  1. FixedThreadPool:固定线程数量的线程池,在创建该线程池时,需要传入一个线程池中线程个数的 int 参数,当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有空闲线程,则新的任务会被暂存在一个任务队列中,待有线程空闲时处理。
  2. SingleThreadPool:单线程线程池,在该线程池中只有一个线程,若超过一个线程提交到该线程池,任务会被保存到任务队列中,等到该线程空闲时,按照先入先出的顺序执行队列中的任务。
  3. CachedThreadPool:可缓存线程的线程池,该线程池的线程数量不确定,在优先使用空闲线程的条件下,遇到新的任务提交时,会创建一个新的线程来处理任务,任务处理完毕后回到线程池等待复用。
  4. ScheduledExecutorPool:给定的延迟后运行的任务或定期执行任务的线程池。

自定义创建

如下图,可以通过 ThreadPoolExecutor 构造函数来创建线程池(推荐)。

1961099-20240506094325762-1714141352.png

优先推荐使用 ThreadPoolExecutor 来创建线程池,在《阿里巴巴 Java 开发手册》中指出线程资源必须使用线程池来提供,不允许在应用中自行显示创建线程,也强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式来创建线程池。

使用内置的线程池有以下缺点:

  • newFixedThreadPool 和 SingleThreadPool使用的是无界队列 LinkedBlockingQueue,任务队列最大成都为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;
  • CachedThreadPool:使用的是同步队列 SyschronousQueue,允许创建的线程数量为 Integer.MAX_VALUE, 如果任务执行较慢,可能会创建大量的线程,从而导致 OOM。
  • ScheduledExecutorPool:使用的无界的延迟阻塞队列 DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;

实际上内置的线程池也是调用 ThreadPoolExecutor 来创建的线程池:

// 无界队列 LinkedBlockingQueue
public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
}

// 无界队列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
}

// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE`
public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
}

// DelayedWorkQueue(延迟阻塞队列)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

线程池参数

我们来看一下自定义创建线程池的参数有哪些?

    public ThreadPoolExecutor(int corePoolSize, // 核心线程数
                              int maximumPoolSize, // 最大线程数
                              long keepAliveTime, // 当线程数大于核心线程数时,
                                                  // 多余的空闲线程存活时间
                              TimeUnit unit, // 时间单位
                              BlockingQueue<Runnable> workQueue, // 任务队列
                              ThreadFactory threadFactory, // 线程工厂,用于创建线程,一般默认
                              RejectedExecutionHandler handler) { // 拒绝策略
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

如上,ThreadPoolExecutor 中有三个很重要的参数

  1. corePoolSize:任务队列未达到队列容量时,最大可以同时运行的线程数量。
  2. maxinumPoolSize:任务队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数。
  3. workQueue:新任务来时会先判断当前运行的线程数量是否达到核心线程数,若达到核心线程数,新任务会被存放在队列中。

ThreadPoolExecutor 其他常见参数:

  • keepAliveTime:线程池中的线程数量超过 corePoolSize 时,如果这个时候没有新的任务提交,核心线程以外的线程不会立即销毁,会等到 keepAliveTime 的时间,然后才会销毁超出部分的线程。
  • unit:keepAliveTime 参数的时间单位。
  • threadFactory:executor 创建新线程时用到。
  • handler:拒绝策略

下面这张图可以看出,核心线程数量为 4,最大线程数量为 8。新任务提交到线程池时,首先判断是否有线程或者线程数量是否小于核心线程数,若满足则首先创建新的线程执行任务,当核心线程数到达 corePoolSize 时,将任务缓存到任务队列中,当任务队列存放的任务到达队列容量时,再创建新的线程,直到达到 maxnumPoolSize 的线程数量,后续再根据拒绝策略返回。

1961099-20240506094355744-519938207.png

如果当前线程池同时运行的线程数量达到了最大线程数并且队列也已经放满了任务时,ThreadPoolExecutor 再接收到新的线程时,会执行一些预定义的拒绝策略,例如:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

ThreadPoolExecutor 默认执行的是 AbortPolicy,抛出 RejectedExecutionException 来拒绝新任务。如果不想丢弃任务,也可以使用 CallerRunsPolicy 将任务回退给调用者,使用调用者线程来执行任务。

public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                // 直接主线程执行,而不是线程池中的线程执行
                r.run();
            }
        }
    }

线程池任务处理流程

1961099-20240506094410256-1201249518.png
  1. 当新任务被提交到线程池时,首先判断核心线程数量是否达到 corePoolSize,若未达到则创建新的线程,直到线程池中的线程数量到达 corePoolSize 的大小。
  2. 当核心线程数量达到 corePoolSize 的数量时,将新达到的任务缓存在阻塞队列中,直到任务队列容量用完,无法存放新的任务。
  3. 任务队列无法存放新任务后,若线程池中的线程数量小于 maxnumPoolSize,则创建新的线程来执行任务,直到线程数量达到 maxnumPoolSize 的数量。
  4. 根据创建线程池时设置的拒绝策略来处理新提交的任务。

本文从进程与线程、创建线程、线程状态、线程同步和线程池等多个方面讲述了线程基础知识,希望大家对线程和线程池有了一个基础的了解。后面我们将继续深入多线程的其他知识,例如 Sychronized 的原理分析,JUC 工具类和 ThreadLocal 本地变量等知识,尽请关注。

因本人技术有限,如出现内容错误,请评论区纠正。码字不易,点个关注再走吧~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK