多线程和线程池

多线程

多线程基础

线程的几种状态

  • 新建状态(New): 新创建一个线程对象;
  • 就绪状态(Runnable):线程对象创建后,其它线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,只等待获取CPU的使用权,即在就绪状态的线程除CPU之外,其它的运行所需资源都已全部获得。
  • 运行状态(Running): 就绪状态的线程获取了CPU,执行程序代码。
  • 阻塞状态(Blocked): 阻塞状态是线程因为某种原因CPU使用权,暂时停止运行。直接线程进入就绪状态,才有机会转到运行状态。阻塞的情况分为三种:
    • 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入等待池中。进入这个状态后,是不能自动唤醒的必须依靠其他线程调用notify()notifyAll()方法才能被唤醒。
    • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
    • 其它阻塞:运行的线程执行sleep()join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时,或者I/O处理完毕时,线程重新转入就绪状态
  • 死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

结束线程的方法

  • 使用退出标志,终止线程,也就是当run()方法完成之后线程终止;

  • 使用interrupt()方法中断当前线程

    • 线程处于阻塞状态,如使用了sleep,同步锁的waitsocket中的receiver, accept等方法时,会使线程处于阻塞状态。当调用线程的interrupt()方法时,会抛出InterruptException异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后break跳出循环状态,从而让我们有机会结束这个线程的执行。通常很多人认为只要调用interrupt方法线程就会结束,实际上是错的, 一定要先捕获InterruptedException异常之后通过break来跳出循环,才能正常结束run方法。
    • 线程未处于阻塞状态,使用isInterrupted()判断线程的中断标志来退出循环。当使用interrupt()方法时,中断标志就会置true,和使用自定义的标志来控制循环是一样的道理。
    public class ThreadSafe extends Thread {
        public void run() { 
            while (!isInterrupted()) { //非阻塞过程中通过判断中断标志来退出
                try{
                    Thread.sleep(5*1000);//阻塞过程捕获中断异常来退出
                }catch(InterruptedException e) {
                    e.printStackTrace();
                    break;//捕获到异常之后,执行break跳出循环。
                }
            }
        } 
    }
    
  • 使用stop方法终止线程

    • 程序中可以直接使用thread.stop()强行终止线程,但是stop方法是很危险的,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,不安全主要是:thread.stop()调用之后,创建子线程的线程就会抛出ThreadDeatherror的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用stop方法来终止线程。

同步和互斥

  • 间接相互制约: 源于这种资源共享,即线程A在使用时,其它线程都要等待。
  • 直接相互制约: 线程A将计算结果提供给线程B作进一步处理,那么线程B在线程A将数据送达之前都将处于阻塞状态。
  • 间接相互制约可以称为互斥,直接相互制约可以称为同步。
  • 同步:是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。
  • 互斥:其实就是一种特殊的同步
  • 线程同步的主要任务是使并发执行的各线程之间能够有效的共享资源和相互合作。

多线程的好处

  • 使用多线程可以减少程序的响应时间
  • 与进程相比,线程的创建和切换开销小
  • 在多CPU计算机上使用多线程能提高CPU的利用率
  • 使用多线程能简化程序的结构,使程序便于理解和维护。

同步和异步

  • 当多个线程需要访问同一个资源时,它们需要以某种顺序来确保该资源在某一时刻只能被一个线程使用。否则,程序的运行结果将会是不可预料的,在这个情况下就必须对数据进行同步,同步的机制能够保证资源的安全
  • 要想实现同步操作,必须获得每一个线程对象的锁。获得它可以保证在同一时刻只有一个线程能够进入临界区(访问互斥资源的代码块),并且在这个锁被释放之前,其他线程就不能再进入这个临界区。如果还有其他线程想要获得该对象的锁,只能进入等待队列等待。只有当拥有该对象的锁的线程退出临界区时,锁才会被释放,其他等待线程谁获取了该锁,才能进入共享代码区。
  • 实现同步的方式有两种:一种是利用同步代码块来实现同步;另一种是利用同步方法来实现同步。
  • 异步: 在进行输输入输出处理时,不必关心其他线程的状态行为,也不必等到输入输出处理完毕才返回。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,异步能够提高程序的效率。

如何实现多线程

  • 继承Thread类,重写run()方法;
  • 实现Runnable接口,并实现该接口的run()方法;
    • 如果Thread类的run()方法没有被覆盖,并且为该Thread对象设置一个Runnable对象,该run()方法会调用Runnable对象的run()方法。
  • 实现Callable接口,重写call()方法。Callable接口实际是属于Executor框架中的功能类。
    • Callable可以在任务结束后提供一个返回值,Runnable无法提供该功能。
    • Callable中的call()方法可以抛出异常,而Runnablerun()方法不能抛出异常。
    • 运行Callable可以拿到一个Future对象,Future对象表示计算的结果,它提供了检查计算是否完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下,就可以使用Future来监视目标线程调用call()方法的情况,当调用Futureget()方法以获得结果时,当前线程就会阻塞,直到call()方法结束返回结果。

run和start方法

  • 系统通过调用线程类的start()方法来启动一个线程,此时该线程处于就绪状态,而非运行状态,也就意味着这个线程可以被JVM来调度执行。在调度过程中,JVM通过调用线程类的run()方法来完成实际操作,当run()方法结束后,此线程就会终止。
  • 如果直接调用线程类的run()方法,这会被当作一个普通的函数调用,程序中仍然只有主线程这一个线程,也就是说,start()方法能异步地调用run()方法,但是直接调用run()方法却是同步的,因此也就无法达到多线程的目的。
  • 只有通过调用线程类的start()方法才能真正达到多线程的目的。

多线程实现同步的方式

  • Java主要提供了3种实现同步机制的方法:
    • synchronized:每个对象都有一个对象锁与之相关联,该锁表明对象在任何时候只允许被一个线程所拥有,当一个线程调用对象的一段synchronized代码时,需要先获取这个锁,然后去执行相应的代码,执行结束后,释放锁。
    • wait()方法和notify()方法:在synchronized代码被执行期间,线程可以调用对象的wait()方法,释放对象锁,进入等待状态,并且可以调用notify()notifyAll()方法通知正在等待的其他线程。notify()方法仅唤醒一个线程(等待队列中的第一个线程)并允许它去获得锁,notifyAll()方法唤醒所有等待这个对象的线程并允许它们去获得锁。
    • Lock
      • lock():阻塞的方式获取锁,也就是说,如果获取到了锁,立即返回;如果别的线程持有锁,当前线程等待,直到获取锁后返回。
      • tryLock():非阻塞的方式获取锁。只是尝试性地去获取一下锁,如果获取的到锁,立即返回true,否则立即返回false
      • tryLock(long timeout, TimeUnit unit):如果获取到了锁,立即返回true,否则会等待参数给定的时间单元,在等待的过程中,如果获取到了锁,就返回true,如果等待超时,返回false
      • lockInterruptibly():如果获取到了锁,立即返回;如果没有获取到锁,当前线程处于休眠状态,直到获取锁,或者当前线程被别的线程中断(会收到InterruptedException异常)它与lock()方法最大的区别在于如果lock()方法获取不到锁,会一直处于阻塞状态且会忽略interrupt()方法。

sleep和wait方法

  • sleep()方法来自Thread类中的,而wait()方法,则来自于Object类的;
  • sleep()方法导致了程序暂停执行指定的时间,让出CPU给其它线程,但是它不会释放对象锁,当指定睡眠的时间到时,自动恢复到就绪状态或者运行状态;
  • wait()方法,线程会放弃锁对象,进入等待此对象锁的等待池中,只有持有此对象锁的线程调用notify()/ notifyAll()方法后,该线程才会进如锁池中,进入阻塞状态。如果获取到对象锁,从而进入运行状态
  • sleep()必须捕获异常,wait()不需要捕获异常。
  • 由于wait()方法的特殊意义,因此它必须放在同步控制方法或者同步代码块中使用,而sleep()方法则可以放在任何地方使用。

sleep和yield方法

  • sleep()方法会给其它线程运行机会时,不会考虑线程运行的优先级;
  • yield()只会给相同或更高优先级的线程运行机会。
  • sleep()方法调用后,线程进入阻塞状态。在指定的时间内,该线程肯定不会被执行;
  • yiedl()方法只是使当前线程重新回到可执行状态,所以执行yield()方法的线程有可能进入到可执行状态后马上又执行。
  • sleep()方法声明抛出InterruptedException,而yield()方法没有声明任何异常。

终止线程的方法

  • stop
    • 当用Thread.stop()来终止线程时,**它会释放已经锁定的所有监视资源。**如果当前任何一个受这些监视资源保护的对象处于一个不一致的状态,其他线程将会"看"到这个不一致的状态,这就可能会导致程序执行的不确定性,并且这种问题很难被定位。
  • suspend()
    • 调用suspend()方法容易发生死锁。因为调用suspend()方法不会释放锁,这就会导致一个问题:如果用一个suspend挂起一个有锁的线程,那么在锁恢复之前将不会被释放。
  • 鉴于以上两种方法的不安全性,上述两种方法都已经过时
  • 一般建议采用的方法是让线程自行结束进入Dead状态。一个线程进入Dead状态,即执行完run()方法,也就是说,如果想要停止一个线程的执行,就要提供某种方式让线程能够自动结束run()方法的执行。在实现时,可以通过设置flag标志来控制循环是否执行,通过这种方法来让线程离开run()方法从而终止线程。
  • 当线程处于阻塞状态时(当sleep()方法被调用或当wait()方法被调用或被IO阻塞时),上述方法就不可用了。此时可以使用interrupt()方法来打破阻塞,当interrupt()方法被调用时,会抛出InterruptedException异常,可以通过在run()方法中捕获这个异常来让线程安全退出

synchronizedlock

  • synchronized的缺陷:
    • 如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
      • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
      • 线程执行发生异常,此时JVM会让线程自动释放锁。
      • 那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,这就十分影响程序执行效率。
      • 因此就需要有一种机制可以不让等待的线程一直无期限地等待(如只等待一定的时间或者能够响应中断),通过Lock就可以办到
    • 当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:
      • 如果多个线程都是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。
      • 因此就需要一种机制来使得多个线程都是进行读操作时,线程之间不会发生冲突,通过Lock就可以。
      • 另外,通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。
  • synchronized使用了Object对象本身的notify、wait、notifyAll调度机制,而Lock则使用Condition进行线程之前的调度,完成synchronized实现的所有功能。
  • Lock不是Java语言内置的,synchronizedJava语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问。
  • 用法不一样
    • 在需要同步的对象中加入synchronized控制,synchronized既可以加在方法上,也可以加在特定的代码块中,括号中表示需要锁的对象。
    • Lock需要显式地指定起始位置和终止位置
  • 性能不一样
    • Lock不仅拥有和synchronized相同的并发性和内存语义,还多了锁投票、定时锁、等候和中断锁等
    • 资源竞争不是很激烈的情况下,synchronized的性能要优于ReentrantLock,但是在资源竞争很激烈的情况下,synchronized的性能会下降得非常快,而ReentrantLock的性能基本保持不变
  • 锁机制不一样
    • synchronized获得锁和释放的方式都是在块结构中,当获取多个锁时,必须以相反的顺序释放,并且是自动解锁不会因为出了异常而导致锁没有释放从而引发死锁
    • Lock则需要手动释放必须在finally块中释放,否则会引起死锁问题的发生。此外,Lock还提供了更强大的功能,它的**tryLock()方法可以采用非阻塞的方式去获取锁**。
  • 所以,最好不要同时使用这两种同步机制,因为ReentrantLock与synchronized所使用的机制不同。
类别 synchronized Lock
存在层次 Java的关键字,在jvm层面上 是一个类
锁的释放 1、以获取锁的线程执行完同步代码,释放锁; 2、线程执行发生异常,jvm会让线程释放锁 finally中必须释放锁,不然容易造成线程死锁
锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 分情况而定,Lock有多个锁获取的方式就可以尝试获得锁,线程可以不用一直等待
锁状态 无法判断 可以判断
锁类型 可重入 不可中断 非公平 可重入 可判断 可公平(两者皆可)
性能 少量同步 大量同步

守护线程

  • 守护线程又称为服务线程、精灵线程或后台线程,是指在程序运行时在后台提供了一种通用服务的线程,这种线程并不属于程序中不可或缺的部分。通俗的说,任何一个守护线程都是整个JVM中所有非守护线程的保姆
  • 当用户线程已经全部退出运行,只剩下守护线程存在了,JVM也就退出了,此时守护线程就像被杀死一样。也就是说,只要有任何非守护线程还在运行,程序就不会终止。
  • 守护线程一般具有较低的优先级,它并非只由JVM内部提供,用户在编写程序也可以自己设置守护线程。但是必须在该线程启动之前(调用start()方法之前)调用对象的setDaemon(true)方法,若将上述参数设为false,则表示为用户进程模式。
  • 当在一个守护线程中产生了其他线程,那么这些新产生的线程默认还是守护线程,用户线程也是如此。
  • 守护线程的一个典型例子就是垃圾回收器。

join方法的作用

  • join()方法的作用是让调用该方法的线程在执行完run()方法后,再执行join()方法后面的代码。简单的说,就是将两个线程合并,用于实现同步功能
  • 如果在join()方法中传入参数,则表示当前线程等待合并的时间,单位毫秒。

FutureTask

  • 实现CallableRunnable接口。相较于实现Runnable接口的方式,方法可以有返回值,并且可以抛出异常
  • 执行Callable方式,需要Future实现类的支持,用于接收运算结果。FutureTaskFuture接口的实现类
public class TestCallable {
    
    public static void main(String[] args) {
        Callable<Integer> callable = () -> {
            int sum = 0;
            for (int i = 0; i < 100000; i++) {
                sum += i;
            }
            return sum;
        };
        // 执行Callable方式,需要FutureTask实现类的支持,用于接收运算结果。
        FutureTask<Integer> result = new FutureTask<>(callable);
        new Thread(result).start();
        // 接收线程运算后的结果
        try {
            Integer integer = result.get();// 这里线程会阻塞,知道接收到返回值。
            System.out.println(integer);
        } catch (Expection e) {
            e.prntStackTrace();
        }
    }
}

线程池

线程池的优势

  • 降低资源消耗。 通过复用已存在的线程和降低线程关闭的次数来尽可能降低系统性能损耗;
  • 提升系统响应速度。通过复用线程,省去创建线程的过程,因此整体上提升了系统的响应速度。
  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,因此,需要使用线程池来管理线程。
  • 可以根据系统的承受能力,调整线程池中工作线程的数量,防止因为消耗过多内存导致服务器崩溃

线程池工作原理

在这里插入图片描述

  • 先判断线程池中核心线程池中所有的线程是否都在执行任务。如果不是,则新创建一个线程执行刚提交的任务。如果都在执行任务,则进入阻塞队列。
  • 判断当前阻塞队列是否已满,如果未满,则将提交的任务放置在阻塞队列中;否则,进入下一步。
  • 判断线程池中所有的线程是否都在执行任务。如果没有,则创建一个新的线程来执行任务;否则,交给饱和策略进行处理。

线程池的创建

创建线程池主要是 ThreadPoolExecutor 类来完成,ThreadPoolExecutor的有许多重载的构造方法,通过参数最多的构造方法来理解创建线程池有哪些需要配置的参数。ThreadPoolExecutor的构造方法为:

ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
  • corePoolSize:核心线程池的大小。当提交一个任务时,如果当前核心线程池的线程数没有达到corePoolSize,则会创建新的线程来执行所提交的任务,**即使当前核心线程池中有空闲的线程。**如果当前核心线程池的线程个数已经达到了corePoolSize,则不再重新创建线程。如果调用了prestartCoreThread()或者 prestartAllCoreThreads(),线程池创建的时候所有的核心线程都会被创建并且启动
  • maximumPoolSize:表示线程池能创建线程的最大个数。如果当阻塞队列已满时,并且当前线程池线程个数没有超过maximumPoolSize的话,就会创建新的线程来执行任务。
  • keepAliveTime:空闲线程存活时间。 如果当前线程池的线程个数已经超过了corePoolSize,并且线程空闲时间超过了keepAliveTime的话,就会将这些空闲线程销毁,这样可以尽可能降低系统资源的消耗。
  • unit:时间单位。keepAliveTime指定时间单位。
  • workQueue:阻塞队列。 用于保存任务的阻塞队列,阻塞队列后面详述。
  • threadFactory:创建线程的工厂类。 可以通过指定线程工厂为每个创建出来的线程设置更有意义的名字,如果出现并发问题,也方便查找问题原因。
  • handler:饱和策略。 当线程池的阻塞队列已满和指定的线程都已经开启,说明当前线程池已经处于饱和状态,那么就需要采用一种策略来处理这种情况。采用的策略如下:
    • AbortPolicy: 直接拒绝所提交的任务,并抛出RejectdExecutionException异常;
    • CallerRunsPolicy: 只用调用者所在的线程来执行任务;
    • DiscardPolicy: 不处理直接丢弃掉任务;
    • DiscardOldestPolicy: 丢弃掉阻塞队列中存放时间最久的任务,执行当前任务。

线程池执行逻辑

在这里插入图片描述

一个任务通过execute(Runnable)方法被添加到线程池,任务必须是Runnable类型的对象,任务的执行方法就是调用Runnable类型对象的run()方法。当一个任务通过execute(Runnable)方法欲添加到线程池时,会做如下几步:

  • 如果此时线程池中线程数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
  • 如果此时线程池中线程数量大于等于corePoolSize,但是阻塞队列workQueue未饱和,那么任务进入阻塞队列。
  • 如果此时线程池中线程数量大于等于corePoolSize,阻塞队列workQueue也满了,但线程池中线程数量小于maximumPoolSize,那么会建立新的线程来处理添加的任务;
  • 如果此时线程池中线程数量大于等于corePoolSize,阻塞队列workQueue也满了,而线程池中线程数量等于maximumPoolSize,那么通过handler所指定的策略来处理此任务。
  • 综上,处理任务的优先级为:核心线程corePoolSize、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler饱和策略处理任务。
  • 当线程池中的线程数量大于corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数。

线程池的关闭

关闭线程池,可以通过shutdownshutdownNow这两个方法。它们的原理都是遍历线程池中所有的线程,然后依次中断线程。shutdownshutdownNow还是有不一样的地方:

  • shutdownNow:首先将线程池的状态设置为STOP,然后尝试停止所有的正在执行和未执行任务的线程,并返回等待执行任务的列表;
  • shutdown:只是将线程池的状态设置为SHUTDOWN状态,然后中断所有未正在执行的线程。

可以看出shutdown方法会将正在执行的任务继续执行完,而shutdownNow会直接中断正在执行的任务。调用了这两个方法的任意一个,isShutdown方法都会返回true,当所有的线程都关闭成功,才表示线程池成功关闭,这时调用isTerminated方法才会返回true

合理配置线程池的参数

首先分析任务特性,可以从以下几个角度来进行分析:

  • 任务的性质:CPU密集型任务,IO密集型任务和混合型任务;
  • 任务的优先级: 高、中和低;
  • 任务的执行时间: 长、中和短;
  • 任务的依赖性: 是否依赖其它系统资源,如数据库连接。

任务性质不同的任务可以同不同规模的线程分开处理。

  • 任务的性质
    • CPU密集型任务配置尽可能少的线程数量,如配置 Ncpu+1 个线程的线程池。
    • IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程,如 2xNcpu
    • 混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
  • 任务的优先级
    • 优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。
  • 任务的执行时间
    • 执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
  • 任务的依赖性
    • 依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU

并且,阻塞队列最好是使用有界队列,如果采用无界队列的话,一旦任务积压在阻塞队列中的话就会占用过多的内存资源,甚至会使得系统崩溃。

常用的线程池

  • newSingleThreadExecutor
    • 创建一个单线程化的Executor,即只创建唯一的工作线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO,优先级)执行。
    • 如果这个线程异常结束,会有另一个取代它,保证顺序执行。
    • 最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。
  • newFixedThreadPool
    • 创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大值。
    • 如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。
    • 具有线程池提高程序效率和节省创建线程时所耗的开销的优点。
    • 在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
  • newCacheThreadPool
    • 创建一个可缓存的线程池,此线程不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者JVM)能够创建的最大线程大小。
    • 如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    • 它的特点如下:
      • 工作线程的创建数量几乎没有限制(其实也有限制的,数目为Integer. MAX_VALUE),这样可灵活地往线程池中添加线程。
      • 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程
      • 在使用newCacheThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪。
  • newSecheduledThreadPool
    • 创建一个定长的线程池,而且支持定时的以及周期性的任务执行。

阻塞队列

概述

  • 在实际编程中,会经常使用到JDKCollection集合框架中的各种容器类如实现List, Map, Queue接口的容器类,但是这些容器类基本上不是线程安全的,除了使用Collections可以将其转换为线程安全的容器,Doug Lea大师为我们都准备了对应的线程安全的容器,如实现List接口的CopyOnWriteArrayList,实现Map接口的ConcurrentHashMap,实现Queue接口的ConcurrentLinkedQueue
  • 阻塞队列(BlockingQueue):被广泛使用在生产者-消费者问题中,其原因是BlockingQueue提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
  • 阻塞队列最核心的功能就是,能够可阻塞式的插入和删除队列元素

基本操作

BlockingQueue继承于Queue接口,因此,对数据元素的基本操作有:

  • 插入元素
    • add(E e): 队列插入数据,当队列满时,插入元素时会抛出IllegalStateException异常;
    • offer(E e): 当往队列插入数据时,插入成功返回true,否则返回false。当队列满时不会抛出异常;
  • 删除元素
    • remove(Object o): 从队列中删除数据,成功则返回true,否则为false
    • poll: 删除数据,当队列为空时,返回null
  • 查看元素
    • element: 获取队头元素,如果队列为空时则抛出NoSuchElementException异常;
    • peek: 获取队头元素,如果队列为空则抛出NoSuchElementException异常。

BlockingQueue具有的特殊操作:

  • 插入元素
    • put(E e): 当阻塞队列容量已经满时,往阻塞队列插入数据的线程会被阻塞,直至阻塞队列已经有空余的容量可供使用;
    • offer(E e, long timeout, TimeUnit unit): 若阻塞队列已经满时,同样会阻塞插入数据的线程,直至阻塞队列已经有空余的地方,与put方法不同的是,该方法会有一个超时时间,若超过当前给定的超时时间,插入数据的线程会退出;
  • 删除数据
    • take(): 当阻塞队列为空时,获取队头数据的线程会被阻塞;
    • poll(long timeout, TimeUnit unit): 当阻塞队列为空时,获取数据的线程会被阻塞,另外,如果被阻塞的线程超过了给定的时长,该线程会退出。

ArrayBlockingQueue

  • ArrayBlockingQueue是由数组实现的有界阻塞队列。该队列命令元素FIFO(先进先出)。因此,队头元素时队列中存在时间最长的数据元素,而队尾数据则是当前队列最新的数据元素ArrayBlockingQueue可作为有界数据缓冲区,生产者插入数据到队列容器中,并由消费者提取。ArrayBlockingQueue一旦创建,容量不能改变
  • 当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
  • ArrayBlockingQueue默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到ArrayBlockingQueue。而非公平性则是指访问ArrayBlockingQueue的顺序不是遵守严格的时间顺序,有可能存在,一旦ArrayBlockingQueue可以被访问时,长时间阻塞的线程依然无法访问到ArrayBlockingQueue
  • 要想获取公平性的ArrayBlockingQueue,需要在创建时通过构造函数传入true
  • ArrayBlockingQueue不允许元素为nullArrayBlockingQueue在队列已满时将会调用notFullawait()方法释放锁并处于阻塞状态;一旦ArrayBlockingQueue不为满的状态,就将元素入队。
  • ArrayBlockingQueue的并发阻塞是通过ReentrantLockCondition来实现的。ArrayBlockingQueue内部只有一把锁,意味着同一时刻只有一个线程能进行入队或者出队的操作。

LinkedBlockingQueue

  • LinkedBlockingQueue是用链表实现可选容量有界阻塞队列,且满足FIFO的特性,与ArrayBlockingQueue相比起来具有更高的吞吐量。
  • 为了防止LinkedBlockingQueue容量迅速增,损耗大量内存。通常在创建LinkedBlockingQueue对象时,会指定其大小,如果未指定,容量等于Integer.MAX_VALUE
  • LinkedBlockingQueue插入数据和删除数据时分别是由两个不同的locktakeLockputLock)来控制线程安全的,因此,也由这两个lock生成了两个对应的conditionnotEmptynotFull)来实现可阻塞的插入和删除数据。
  • LinkedBlockingQueue不允许元素为null
  • 同一时刻,只能有一个线程执行入队操作,因为putLock在将元素插入到队列尾部时加锁了。
  • 如果队列满了,那么将会调用notFullawait()方法将该线程加入到Condition等待队列中。
  • 一旦一个出队线程取走了一个元素,并通知了入队等待队列可以释放线程了,那么第一个加入到Condition队列中的将会被释放,那么该线程将会重新获得put锁,继而执行enqueue()方法,将节点插入到队列的尾部。
  • LinkedBlockingQueue允许两个线程同时在两端进行入队或出队的操作的,但一端同时只能有一个线程进行操作,这是通过两把锁来区分的;
  • 为了维持底部数据的统一,引入了AtomicInteger的一个count变量,表示队列中元素的个数。count只能在两个地方变化,一个是入队的方法(可以+1),另一个是出队的方法(可以-1),而AtomicInteger是原子安全的,所以也就确保了底层队列的数据同步。

两个队列的对比

  • 都不允许元素为null,且都是线程安全的队列。
  • ArrayBlockingQueue底层基于定长的数组,所以容量限制了;LinkedBlockingQueue底层基于链表实现队列,所以容量可选,如果不设置,那么容量是int的最大值 。
  • ArrayBlockingQueue内部维持一把锁和两个条件,同一时刻只能有一个线程队列的一端操作;
  • LinkedBlockingQueue内部维持两把锁和两个条件,同一时刻可以有两个线程在队列的两端操作,但同一时刻只能有一个线程在一端操作。
  • LinkedBlockingQueueremove()时,由于需要对整个队列链表实现遍历,所以需要获取两把锁,对两端加锁。

PriorityBlockingQueue

  • PriorityBlockingQueue带优先级的无界阻塞队列,每次出队都返回优先级最高的元素,这类队列必须是实现Comparable接口,队列通过这个接口的compare方法确定对象的priority。当前和其他对象比较,如果compare方法返回负数,那么在队列里面的优先级就比较高
    • 比较规则:当前对象和其他对象做比较,当前优先级大就返回-1,优先级小就返回1
    • 优先级队列不允许null值,不允许未实现Comparable接口的对象。
  • PriorityBlockingQueue类似于ArrayBlockingQueue内部使用一个独占锁来控制同时只有一个线程可以进行入队和出队,另外前者只使用了一个notEmpty条件变量而没有notFull这是因为前者是无界队列,当put时候永远不会处于await,所以也不需要被唤醒。
  • PriorityBlockingQueue始终保证出队的元素是优先级最高的元素,且可以定制优先级的规则,内部通过使用一个二叉树最小堆算法来维护内部数组,这个数组是可扩容的,如果当前元素个数最大容量时候会通过算法扩容
  • 为了避免在扩容操作时候其他线程不能进行出队操作,实现上使用了先释放锁,然后通过CAS算法保证同时只有一个线程可以扩容成功。

猜你喜欢

转载自blog.csdn.net/lingboo111/article/details/88573455