Skip to content

3.线程池中提交一个任务的流程是怎样的?

  1. 在使用execute()方法提交一个Runnable对象时
  2. 会先判断当前线程池中的线程数是否小于corePoolSize
  3. 如果小于,则创建新线程并执行Runnable
  4. 如果大于等于,则尝试将Runnable加入到workQueue中
  5. 如果workQueue没满,则将Runnable正常入队,等待执行(关键点,核心线程数满了再来新的任务并不会立刻创建新的线程)
  6. 如果workQueue满了,则会入队失败,那么会尝试继续增加线程
  7. 如果当前线程池中的线程数是否小于maximumPoolSize
  8. 如果小于,则创建新线程并执行任务
  9. 如果大于等于,则执行拒绝策略,拒绝此Runnable

image.png 注意1:提交一个Runnable时,不管当前线程池中的线程是否空闲,只要数量小于核心线程数就会创建新线程。 注意2:ThreadPoolExecutor相当于是非公平的,比如队列满了之后提交的Runnable可能会比正在排队的Runnable先执行。 四种工作队列workQueue: 1、ArrayBlockingQueue:通过名字我们可以推测出,当前队列是基于Array数组实现的,数组的特性是初始化时需要指定数组的大小,也就是指定了存储的工作任务上限;ArrayBlockingQueue是一个基于数据的有界的阻塞队列,新加入的任务放到队列的队尾,等待被调度;如果队列已经满了,则会创建新线程;如果线程池数量也满了,则会执行拒绝策略; 2、LinkedBlockingQuene:通过名字我们可以推测出,当前队列是基于Linked链表来实现的,链表的特性是没有初始容量,也就意味着这个队列是无界的,最大容量可以达到Integer.MAX。也由于LinkedBlockingQuene的无界特性,当有新的任务进来,会一直存储在当前队列中,等待调度任务来进行调度;在此场景下,参数maximumPoolSize是无效的;LinkedBlockingQuene可能会带来资源耗尽的问题; 3、SynchronousQuene:同步队列,一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出任务,直接被调度任务调度执行当前任务;如果没有空闲的可用线程,则直接创建新的线程进行处理,当线程池数量达到maximumPoolSize时,则触发拒绝策略; 4、PriorityBlockingQueue:优先考虑无界阻塞队列,优先级可以通过Comparator来实现;

当工作任务队列达到最大值并且线程池的容量也达到了最大线程数时,当有新的任务进来时,则会触发拒绝策略;拒绝策略有四种: 1、CallerRunsPolicy 该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。 2、AbortPolicy 该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。 3、DiscardPolicy 该策略下,直接丢弃任务,什么都不做。 4、DiscardOldestPolicy 该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列

线程池有几种状态?分别是如何变化的?

线程池有五种状态,分别为:image.png

RUNNING接收新任务并且处理队列中的任务
SHUTDOWN不会接收新任务并且处理队列中的任务,任务处理完后会中断所有线程
STOP不会接收新任务并且不会处理队列中的任务,并且会直接中断所有线程
TIDYING所有线程都停止了之后,线程池的状态就会转为TIDYING,一旦达到此状态,就会调用线程池的terminated()
TERMINATEDterminated()执行完之后就会转变为TERMINATED

这五种状态并不能任意转换,只会有以下几种转换情况:

| 转变前

| 转变后

| 转变条件

| | --- | --- | --- | | RUNNING

| SHUTDOWN

| 手动调用shutdown()触发,或者线程池对象GC时会调用finalize()从而调用shutdown()

| | RUNNING

| STOP

| 手动调用**shutdownNow()**触发

| | SHUTDOWN

| STOP

| 手动先调用shutdown()紧着调用shutdownNow()触发

| | SHUTDOWN

| TIDYING

| 线程池所有线程都停止后自动触发

| | STOP

| TIDYING

| 线程池所有线程都停止后自动触发

| | TIDYING

| TERMINATED

| 线程池自动调用terminated()后触发

|

手动调用**shutdownNow()**触发,可以从RUNNING-》STOP 为什么先该状态再关闭线程? 先修改状态,防止新的线程加入进来,然后再去关闭线程池中的线程

java
 public List<Runnable> shutdownNow() {
        List<Runnable> tasks;
        final ReentrantLock mainLock = this.mainLock;
        mainLock.lock();
        try {
            checkShutdownAccess();
            //先修改状态
            advanceRunState(STOP);
            //再去关闭线程
            interruptWorkers();
            tasks = drainQueue();
        } finally {
            mainLock.unlock();
        }
         //线程池中的线程都关闭后,自动触发进入TIDYING状态
        tryTerminate();
        return tasks;
    }


final void tryTerminate() {
        for (;;) {
            ···

            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                //设置线程池的状态为TIDYING
                if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
                    try {,
                        //这是一个空方法,用于重写扩展,可以在线程池进入TIDYING状态时执行或许操作
                        terminated();
                    } finally {
                        //接着会自动触发进入TERMINATED状态,此时线程池真正的关闭了
                        ctl.set(ctlOf(TERMINATED, 0));
                        termination.signalAll();
                    }
                    return;
                }
            } finally {
                mainLock.unlock();
            }
            // else retry on failed CAS
        }
    }

如何优雅的停止一个线程?

Thread类中有两个方法:

  • start():开启一个线程
  • stop():停止一个线程

但是stop()方法是不建议使用,并且是有可能在未来版本中删除掉的: image.png 因为stop()方法太粗暴了,一旦调用了stop(),就会直接停掉线程,这样就可能造成严重的问题,比如任务执行到哪一步了?该释放的锁释放了没有?都存在疑问。 这里强调一点,stop()会释放线程占用的synchronized锁,而不会自动释放ReentrantLock锁 我们建议通过中断来停止线程:(为什么呢?因为我们希望可控的停止线程,让线程在满足指定的条件之后再停止)

java
/**
 * 其他线程调用thread.interrupt();后,工作线程的isInterrupted()属性更改为true,并结合我们的其他逻辑,实现线程的可控停止
*/
public class ThreadTest {

    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(() -> {

            for (int i = 0; i < 1000000; i++) {
                if (Thread.currentThread().isInterrupted() && i > 500000) {
                    break;
                }

                System.out.println(i);

            }

        });
        thread.start();

        Thread.sleep(1000);

        thread.interrupt();
    }
}

以上代码,我们可以控制:变量i只有在大于500000时才会停止,不然就算中断了也不会停止。

另外,线程池中也是通过interrupt()来停止线程的,比如shutdownNow()方法中会调用:

java
void interruptIfStarted() {
    Thread t;
    if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) {
        try {
            t.interrupt();
        } catch (SecurityException ignore) {
        }
    }
}

线程池的核心线程数、最大线程数该如何设置?

我们都知道,线程池中有两个非常重要的参数:

  1. corePoolSize:核心线程数,表示线程池中的常驻线程的个数
  2. maximumPoolSize:最大线程数,表示线程池中能开辟的最大线程个数

那这两个参数该如何设置呢?

我们对线程池负责执行的任务分为三种情况:

  1. CPU密集型任务,比如找出1-1000000中的素数
  2. IO密集型任务,比如文件IO、网络IO
  3. 混合型任务

CPU密集型任务

CPU密集型任务的特点时,线程在执行任务时会一直利用CPU,所以对于这种情况,就尽可能避免发生线程上下文切换。 比如,现在我的电脑只有一个CPU,如果有两个线程在同时执行找素数的任务,那么这个CPU就需要额外的进行线程上下文切换,从而达到线程并行的效果,此时执行这两个任务的总时间为:任务执行时间*2+线程上下文切换的时间 而如果只有一个线程,这个线程来执行两个任务,那么时间为:任务执行时间*2 所以对于CPU密集型任务,线程数最好就等于CPU核心数,可以通过以下API拿到你电脑的核心数:

java
Runtime.getRuntime().availableProcessors()

只不过,为了应对线程执行过程发生缺页中断或其他异常导致线程阻塞的请求,我们可以额外在多设置一个线程,这样当某个线程暂时不需要CPU时,可以有替补线程来继续利用CPU。 所以,对于CPU密集型任务,我们可以设置线程数为:CPU核心数+1

IO密集型任务

我们在来看IO型任务,线程在执行IO型任务时,可能大部分时间都阻塞在IO上,假如现在有10个CPU,如果我们只设置了10个线程来执行IO型任务,那么很有可能这10个线程都阻塞在了IO上,这样这10个CPU就都没活干了,所以,对于IO型任务,我们通常会设置线程数为:2*CPU核心数

不过,就算是设置为了2*CPU核心数,也不一定是最佳的,比如,有10个CPU,线程数为20,那么也有可能这20个线程同时阻塞在了IO上,所以可以再增加线程,从而去压榨CPU的利用率。 通常,如果IO型任务执行的时间越长,那么同时阻塞在IO上的线程就可能越多,我们就可以设置更多的线程,但是,线程肯定不是越多越好,我们可以通过以下这个公式来进行计算:*线程数 = CPU核心数 ( 1 + 线程等待时间 / 线程运行总时间 )

  • 线程等待时间:指的就是线程没有使用CPU的时间,比如阻塞在了IO
  • 线程运行总时间:指的是线程执行完某个任务的总时间

图中表示,在刚刚这次抽样过程中,run()总共的执行时间为538948ms,利用了CPU的时间为86873ms,所以没有利用CPU的时间为538948ms-86873ms。

所以我们可以计算出: 线程等待时间 = 538948ms-86873ms 线程运行总时间 = 538948ms

所以:线程数 = 8 *( 1 + (538948ms-86873ms) / 538948ms )= 14.xxx

所以根据公式算出来的线程为14、15个线程左右。

按上述公式,如果我们执行的任务IO密集型任务,那么:线程等待时间 = 线程运行总时间,所以: 线程数 = CPU核心数 *( 1 + 线程等待时间 / 线程运行总时间 ) = CPU核心数 *( 1 + 1 ) = CPU核心数 * 2

以上只是理论,实际工作中情况会更复杂,比如一个应用中,可能有多个线程池,除开线程池中的线程可能还有很多其他线程,或者除开这个应用还是一些其他应用也在运行,所以实际工作中如果要确定线程数,最好是压测。

总结,我们再工作中,对于:

  1. CPU密集型任务:CPU核心数+1,这样既能充分利用CPU,也不至于有太多的上下文切换成本
  2. IO型任务:建议压测,或者先用公式计算出一个理论值(理论值通常都比较小)
  3. 对于核心业务(访问频率高),可以把核心线程数设置为我们压测出来的结果,最大线程数可以等于核心线程数,或者大一点点,比如我们压测时可能会发现500个线程最佳,但是600个线程时也还行,此时600就可以为最大线程数
  4. 对于非核心业务(访问频率不高),核心线程数可以比较小,避免操作系统去维护不必要的线程,最大线程数可以设置为我们计算或压测出来的结果。

如何理解Java并发中的可见性?

Java并发可见性指的是多线程并发访问共享变量时,对变量的更改能够被其他线程及时感知,即在一个线程修改变量后,其他线程能够立即看到这个变量的修改结果。 image.png 当线程A读取变量i的值时,会从内存中读取数据,并缓存一份在CPU1内部的高速缓存中,然后线程1修改i,改为i=2,但是还没有回写到内存,此时线程B也来读取i,那么也会从内存读取,读到的i仍然为1,此时就出现了可见性问题。 在Java中,可以volatile关键字来保证变量的可见性,对于加了volatile的变量,线程在读取该变量时会直接从内存中读取,再修改该变量时会同时修改CPU高速缓存和内存中的值。

如何理解Java并发中的原子性?

Java并发原子性指的是在多线程并发的情况下,一段代码或操作要么完全执行成功,要么完全不执行,不出现执行一半被其他线程打断或干扰的情况。换句话说,就是对同一个变量的多个操作能够像原子操作一样,保证多线程环境下的数据一致性,避免出现数据竞争和脏数据等问题。

由于CPU、内存、IO(磁盘、网络)之间的性能差距,为了能充分利用CPU,当线程执行IO操作时, 线程会让出CPU,使得CPU去执行其他线程的指令,并且本身来说,为了达到线程并发执行的效果,CPU也会按固定时间片来切换执行不同线程。

当我们执行i++这行代码时,底层其实对应的是三条指令:

  1. 从内存中读取i的值
  2. 对i+1
  3. 写回i的值到CPU高速缓存

但是有可能执行线程A执行了第1条指令后,就发生了线程切换,线程A相当于暂停执行,此时如果有另外一个线程B也在执行i++,并且把3条执行都执行完了,那么线程B得到的结果是i=2,然后线程A又切换回来继续执行,最终导致线程A得到的i也为2,正常来说i应该等于3的,这就是原子性问题。 image.png Java中我们需要通过各种锁机制来保证原子性。

如何理解Java并发中的有序性?

Java并发有序性指的是多个线程执行的指令和操作,按照开发者编写程序的顺序或者预定的顺序进行执行。多线程并发执行时,可能会发生指令的重排,导致程序的执行顺序与预期不一致,从而出现数据竞争和线程安全问题。

编译器有时为了进行编译优化,会进行指令重排序,比如:

java
new Person();

这行代码会分为三步:

  1. 申请内存空间
  2. 在内存空间初始化Person对象相关的内容
  3. 返回内存空间地址

但是编译有可能会优化为:

  1. 申请内存空间
  2. 返回内存空间地址
  3. 在内存空间初始化Person对象相关的内容

所以对于我们的单例模式实现:

java

public class Person {
  static Person instance;
    
  static Person getInstance(){
    if (instance == null) {
      synchronized(Person.class) {
        if (instance == null)
          instance = new Person();
        }
    }
    return instance;
  }
}

就算了用DCL可能也会有问题,比如线程A拿到锁后,在new Person()时,第二步就返回了内存地址并赋值给了instance变量,此时线程B来执行getInstance(),直接就判断出了instance不为空,但是instance对于的对象其实是还没有初始化的,里面的成员变量可能为null。 我们可以通过锁机制或者volatile来保证有序性。 关于synchronized的讲解可以查看Java多线程之详解synchronized关键字

Java中如何避免死锁?

造成死锁的几个原因:

  1. 一个资源每次只能被一个线程使用
  2. 一个线程在阻塞等待某个资源时,不释放已占有资源
  3. 一个线程已经获得的资源,在未使用完之前,不能被强行剥夺
  4. 若干线程形成头尾相接的循环等待资源关系

这是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破第4个条件,不出现循环等待锁的关系。

在开发过程中:

  1. 要注意加锁顺序,保证每个线程按同样的顺序进行加锁
  2. 要注意加锁时限,可以针对所设置一个超时时间
  3. 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决

ReentrantLock中tryLock()和lock()方法的区别

  1. tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false
  2. lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值

ReentrantLock中的公平锁和非公平锁的底层实现

首先不管是公平锁和非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于:线程在使用lock()方法加锁时,如果是公平锁,会先检查AQS队列中是否存在线程在排队,如果有线程在排队,则当前线程也进行排队,如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。

不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段。

另外,ReentrantLock是可重入锁,不管是公平锁还是非公平锁都是可重入的。

Sychronized的偏向锁、轻量级锁、重量级锁

Sychronized和ReentrantLock的区别

  1. sychronized是一个关键字,ReentrantLock是一个类
  2. sychronized会自动的加锁与释放锁,ReentrantLock需要程序员手动加锁与释放锁
  3. sychronized的底层是JVM层面的锁,ReentrantLock是API层面的锁
  4. sychronized是非公平锁,ReentrantLock可以选择公平锁或非公平锁
  5. sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识来标识锁的状态
  6. sychronized底层有一个锁升级的

如何理解ThreadLocal以及它的底层执行原理

  1. ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据
  2. ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值
  3. 如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清楚Entry对象
  4. ThreadLocal经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接)