Java 多线程 知识点汇总【完善中】

进程与线程:

进程:可以简单理解为运行中的程序。
    进程特点:【待完善】
线程:进程中的顺序执行流。   
    进程特点:【待完善】


线程六大状态:
NEW、RUNNABLE、BLOCK、WAITING、TIMED_WAITING、TERMINATED


线程状态间的关系:
Java 多线程 知识点汇总【完善中】_第1张图片

  1. 当使用extend Thread、implement Runnable、implement Callable 等方式new一个新的线程类时,该线程类对应的线程状态即为NEW。
  2. 当执行run()方法时,线程状态从NEW变为RUNNABLE
  3. 如果执行了wait() join()等方法主动暂停了线程,线程进入WAITING状态,进入该状态不释放占用的锁。
  4. 如果执行了带超时时间的waitsleepjoin方法主动暂停了线程,线程进入TIMED_WAITING状态,进入该状态同样不释放占用的锁。
  5. 如果线程运行到synchronized修饰的代码段或者使用Lock类进行加锁的代码时,如果无法获取到锁,即进入BLOCK状态
  6. 如果线程在BLOCK状态下获取到锁、如果线程在WAITING状态下被主动唤醒、如果线程在TIMED_WAITING状态下被主动唤醒或者超时时间到,即进入RUNNABLE状态。
  7. 当线程运行完毕或者遇到意外退出时,状态变为TERMINATED
  8. 学习操作系统时,会有READY和RUNNING这两个概念,其实应该是针对进程的说法,因为线程内部每次运行时间都特别短,READY与RUNNING切换特别频繁,没有必要严格继承操作系统那一套状态转移,所以Thread类源码中对此的定义也说明了:A thread can be in only one state at a given point in time.These states are virtual machine states which do not reflect any operating system thread states.意思是线程状态其实是虚拟机定义的状态,与操作系统thread的状态无关。(参考自 https://www.zhihu.com/question/56494969
      • *

    Java多线程回调:
    这一块我现在也没弄明白,之后再补充吧。


    启动线程的方法:

    1. 继承Thread类 最简单的方法
    public class Task extends Thread{
        private String tName;
     @Override
     public void run(){
            try{
                System.out.println("线程" + tName + Thread.currentThread() + "正在运行!");
     Thread.sleep(3000);
     }
            catch (Exception e){
                e.getMessage();
     e.printStackTrace();
     }
        }
        public Task(String tName){
            this.tName = tName;
     }
        public static void main(String[] args) {
            Thread task1 = new Task("任务1");
     Thread task2 = new Task("任务2");
     Thread task3 = new Task("任务3");
     task1.start();
     task2.start();
     task3.start();
     }
    }
    1. 实现Runnable接口 需要作为Thread的target传入,仍然是以Thread类运行
    public class Task2 implements Runnable{
        private String tName;
     @Override
     public void run(){
            try{
                System.out.println("线程" + tName + Thread.currentThread() + "正在运行!");
     Thread.sleep(3000);
     }
            catch (Exception e){
                e.getMessage();
     e.printStackTrace();
     }
        }
        public Task2(String tName){
            this.tName = tName;
     }
        public static void main(String[] args) {
            Thread task1 = new Thread(new Task2("任务1"));
     Thread task2 = new Thread(new Task2("任务2"));
     Thread task3 = new Thread(new Task2("任务3"));
     task1.start();
     task2.start();
     task3.start();
     }
    }
    1. 实现Callable接口,配合FutureTask使用 具有返回值,可使用返回值进行交互等操作。
      public class Task3 implements Callable {
          private String tName;
       @Override
       public Integer call(){
              try{
                  System.out.println("线程" + tName + Thread.currentThread() + "正在运行!");
       Thread.sleep(3000);
       }
              catch (Exception e){
                  e.getMessage();
       e.printStackTrace();
       }
              return 0;
       }
          public Task3(String tName){
              this.tName = tName;
       }
          public static void main(String[] args) {
              try{
                  FutureTask futureTask1 = new FutureTask<>(new Task3("任务1"));
       //第一种运行方法
       futureTask1.run();
       //第二种运行方法
       new Thread(futureTask1).start();
       System.out.println("线程执行结束返回值为:" + futureTask1.get());
       }catch (Exception e){
                  e.printStackTrace();
       }
          }
      }

      三种启动线程方法的对比:
      由于Java具有单继承,多实现的特性,一般常用实现Runnable或实现Callable的方式使用多线程,毕竟每个类只有一个继承的机会,如果给了Thread,灵活性就大打折扣。如果不关心线程的运行结果,使用继承Runnable即可,如果需要监控线程运行状态以便进行后续处理的话,只能使用实现Callable方法,传入对象作为进程间通信的公共访问区,可以灵活使用。


      多线程并发就得防止同时读写造成的冲突,也就需要锁机制:

      一般来说锁有两种类型:
      独占锁:占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和 JUC(java.util.concurrent 并发工具包的缩写)中Lock的实现类就是独占锁。
      共享锁:共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。 独享锁与共享锁也是通过AQS(AbstractQueuedSynchronizer 后面会有详解)来实现的,通过实现不同的方法,来实现独享或者共享。

      Java中常用的的独占锁有两种实现,synchronized关键字Lock接口
      synchronized:
      这是一个Java内置关键字,也可以java内置的一个特性。当一个线程访问一个被synchronized修饰的代码块,会自动获取对应的一个锁,并在执行该代码块时,其他线程想访问这个代码块,会一直处于等待状态,只有等该线程释放锁后,其他线程进行资源竞争,竞争获取到锁的线程才能访问该代码块。
      线程释放synchronized修饰的代码块锁的方式有两种:

      1. 该线程执行完对应代码块,自动释放锁。(正常释放)
      2. 在执行该代码块是发生了异常,JVM会自动释放锁。(异常释放)

      Lock:
      Lock是一个接口,方法定义如下:

      void lock() // 如果锁可用就获得锁,如果锁不可用就阻塞直到锁释放
      void lockInterruptibly() // 和 lock()方法相似, 但阻塞的线程可中断,抛出 java.lang.InterruptedException异常
      boolean tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回true
      boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁方法
      void unlock() // 释放锁

      实现Lock接口的类有很多,以下为几个常见的锁实现

      • ReentrantLock:表示重入锁,它是唯一一个直接实现了Lock接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
      • ReentrantReadWriteLock:重入读写锁,它实现了ReadWriteLock接口,在这个类中维护了两个锁,一个是ReadLock,一个是WriteLock,他们都分别实现了Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
      • StampedLock: stampedLock是JDK8引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。相比于普通的ReentranReadWriteLock主要多了一种乐观读的功能。(该锁的详细说明可以看这篇帖子https://www.cnblogs.com/myworld7/p/12332911.html,之后我可能会整合到这篇文章中)

      下面是两种实现互斥锁的方法的对比表格:

      维度 synchronized Lock
      存在层次 关键字,在JVM层 是一个类,在应用层
      获取锁 自动获取,获取不到则一直等待 手动获取,可灵活处理获取不到的情况
      释放锁 (1. 执行完释放 2.抛出异常释放)有且仅有这两种自动释放 手动释放,支持超时释放
      锁状态 无法判断 可以判断
      锁类型 可重入,不可中断,非公平 可重入,可中断,可公平
      场景 适合少量代码实现同步 适合大量代码实现同步

      什么是AQS
      AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。
      AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包内。是实现Lock家族的核心类,基础类。

      AQS的核心思想
      如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
      ——CLH(Craig,Landin,and Hagersten)队列,一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
      (CLH详解 可以参考该文章 https://blog.csdn.net/firebolt100/article/details/82662102

      AQS详解可以看这个文章:https://blog.csdn.net/GV7lZB0y87u7C/article/details/92260574


      说了这么多线程的事情,可是实际开发场景中,大家都喜欢用线程池,那么下面来说说线程池相关的事情:

      什么是池?
      “池”是一种思想策略,池化策略的核心本质是复用
      许多开发场景中,比如使用线程和数据库连接时,创建、销毁一个线程或数据库连接所需要的开销较大,这种情况下,池化策略就应运而生。这种策略可以最大限度地避免进行创建和销毁,将已经创建的线程或数据库连接保留在“池”这个容器中,随用随取,用完放回,支持复用。这样就省去了创建的代价,但是多出了维护“池”的代价,也就是复用的代价。
      当经过评估,发现“复用的代价”远远小于“创建的代价”,则应该考虑使用池化策略。有些场景下 “创建的代价”远远小于“复用的代价”,此时就不必使用池化策略了。

      线程池的创建:
      支持手动和自动创建。
      手动创建:new ThreadPoolExecutor 自定义各项参数
      自动创建:Executor.function() 通过简单参数创建指定类型线程池

      线程池核心类:ThreadPoolExecutor
      我们先看该类的构造方法:

      public ThreadPoolExecutor(int corePoolSize,
       int maximumPoolSize,
       long keepAliveTime,
       TimeUnit unit,
       BlockingQueue workQueue) {
          this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
       Executors.defaultThreadFactory(), defaultHandler);
      }
      public ThreadPoolExecutor(int corePoolSize,
       int maximumPoolSize,
       long keepAliveTime,
       TimeUnit unit,
       BlockingQueue workQueue,
       ThreadFactory threadFactory) {
          this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
       threadFactory, defaultHandler);
      }
      public ThreadPoolExecutor(int corePoolSize,
       int maximumPoolSize,
       long keepAliveTime,
       TimeUnit unit,
       BlockingQueue workQueue,
       RejectedExecutionHandler handler) {
          this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
       Executors.defaultThreadFactory(), handler);
      }
      public ThreadPoolExecutor(int corePoolSize,
       int maximumPoolSize,
       long keepAliveTime,
       TimeUnit unit,
       BlockingQueue workQueue,
       ThreadFactory threadFactory,
       RejectedExecutionHandler handler) {
          //略
      }

      ThreadPoolExecutor类中一共提供了四个构造方法,其中前三个内部都是调用了最后一个构造方法,所以我们可以理解为,给出了三种常用参数搭配的构造方法以及一个根本的构造方法。

      ThreadPoolExecutor构造函数重要入参解析:
      在这里给出定义,后面看到线程池工作流程图时就会更加清晰。

      • int corePoolSize:核心线程数,也是线程池中常驻的线程数,线程池初始化时默认是没有线程的,当任务来临时才开始创建线程去执行任务
      • int maximumPoolSize:最大线程数,在核心线程数的基础上可能会额外增加一些非核心线程,需要注意的是只有当workQueue队列填满时才会创建多于corePoolSize的线程(线程池总线程数不超过maxPoolSize)
      • long keepAliveTime:非核心线程的空闲时间超过keepAliveTime就会被自动终止回收掉,注意当corePoolSize=maxPoolSize时,keepAliveTime参数也就不起作用了(因为不存在非核心线程,所以说这个参数只针对非核心线程管用);
      • TimeUnit unit:keepAliveTime的时间单位
      • BlockingQueue workQueue:用于保存任务的队列,可以为无界、有界、同步移交三种队列类型之一,当池子里的工作线程数大于corePoolSize时,这时新进来的任务会被放到队列中(后面会详细解释每种队列)
      • ThreadFactory threadFactory:创建线程的工厂类,默认使用Executors.defaultThreadFactory(),也可以使用guava库的ThreadFactoryBuilder来创建
      • RejectedExecutionHandler handler:线程池无法继续接收任务(队列已满且线程数达到maximunPoolSize)时的饱和策略,取值有AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy (后面会详细解释每种策略)

      线程池创建线程流程图:
      Java 多线程 知识点汇总【完善中】_第2张图片
      对这张图,我刚开始还真有个地方没想明白,那就是提交任务后的第一个判断。假设线程池是刚创建好的,核心线程数3,任务队列长度5,最大线程数10(额外线程数为10-3=7),初始化状态下核心线程池为空。此时提交任务,一定会在核心线程池中创建新的线程,然后把任务交给该线程去执行。假设第一个任务执行完毕,此时核心线程池里有一个空闲进程,此时又来一个新任务,该任务不会交给核心线程池里的那一个空闲进程,而是新创建一个线程,交给它去做。重复该逻辑直到核心线程池中线程数达到3。当核心线程池中线程数达到上限时,此时来一个新任务,线程池会把这个任务放到任务队列中去,而不是直接交给核心线程池的一个空闲线程来做

      workQueue队列
      有下面几种常见的队列:
      (1)ArrayBlockingQueue:规定大小的BlockingQueue,其构造必须指定大小。其所含的对象是FIFO顺序排序的。

      (2)LinkedBlockingQueue:大小不固定的BlockingQueue,若其构造时可以指定队列长度,最大值为Integer.MAX_VALUE,如果不指定长度,默认为最大值。当不指定队列长度时,可视为无界队列。所含的对象是FIFO顺序排序的。

      (3)PriorityBlockingQueue:类似于LinkedBlockingQueue,但是其所含对象的排序不是FIFO,而是依据对象的自然顺序或者构造函数的Comparator决定,也就是支持自定义优先级比较策略。默认队列大小是11。

      (4)SynchronizedQueue:与无界情况下的LinkedBlockingQueue正好相反,没有任务队列,接收到任务直接转发给空闲线程或执行拒绝策略,也就是上文说到的同步提交。
      (5)DelayedWorkQueue:ScheduledThreadPoolExecutor 内部的一个基于时间的任务队列,按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,方便任务的执行。

      Executors工具类中为我们提供了几种线程池:
      自动创建线程池的几种方式都封装在Executors工具类中:

      • newFixedThreadPool:使用的构造方式为
      new ThreadPoolExecutor(var0, var0, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())

      ,设置了corePoolSize=maxPoolSize,keepAliveTime=0(此时该参数没作用),无界队列,任务可以无限放入,当请求过多时(任务处理速度跟不上任务提交速度造成请求堆积)可能导致占用过多内存或直接导致OOM异常

      • newSingleThreadExector:使用的构造方式为
      new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(), var0)

      ,基本同newFixedThreadPool,但是将线程数设置为了1,单线程,弊端和newFixedThreadPool一致

      • newCachedThreadPool:使用的构造方式为
      new ThreadPoolExecutor(0, 2147483647, 60L, TimeUnit.SECONDS, new SynchronousQueue())

      ,corePoolSize=0,maxPoolSize为很大的数,同步移交队列,也就是说不维护常驻线程(核心线程),每次来请求直接创建新线程来处理任务,也不使用队列缓冲,会自动回收多余线程,由于将maxPoolSize设置成Integer.MAX_VALUE,当请求很多时就可能创建过多的线程,导致资源耗尽OOM

      • newScheduledThreadPool:使用的构造方式为
      new ThreadPoolExecutor(var1, 2147483647, 0L, TimeUnit.NANOSECONDS, new ScheduledThreadPoolExecutor.DelayedWorkQueue())

      ,支持定时周期性执行,注意一下使用的是延迟队列,弊端同newCachedThreadPool一致

      所以根据上面分析我们可以看到,FixedThreadPool和SigleThreadExecutor中之所以用LinkedBlockingQueue无界队列,是因为设置了corePoolSize=maxPoolSize,线程数无法动态扩展,于是就设置了无界阻塞队列来应对不可知的任务量;而CachedThreadPool则使用的是SynchronousQueue同步移交队列,因为CachedThreadPool设置了corePoolSize=0,maxPoolSize=Integer.MAX_VALUE,用无限的备用线程数来执行任务,就用不到队列来存储任务;
      SchduledThreadPool用的是延迟队列DelayedWorkQueue 一般来说这个线程池就与这个队列关联。
      在实际项目开发中也是推荐使用手动创建线程池的方式,而不用默认方式,关于这点在《阿里巴巴开发规范》中是这样描述的:
      Java 多线程 知识点汇总【完善中】_第3张图片
      从这些默认的线程池的参数我们也能看出来,这种默认给最大长度队列以及最大限度线程上限的做法很极端,容易出现OOM,所以开发中最好自己手动创建,根据需求制定最合适且性能最好的线程池是最合适的做法。
      所以后续就会有线程池参数调优这个操作,找到适合当前业务场景最合适的线程池属性。

      handler拒绝策略

      • AbortPolicy:中断抛出异常(问题报告给老板,让老板处理)
      • DiscardPolicy:默默丢弃任务,不进行任何通知(悄悄丢弃问题,不告诉老板)
      • DiscardOldestPolicy:丢弃掉在队列中存在时间最久的任务(把最早出现的问题扔掉,该问题放入待解决列表)
      • CallerRunsPolicy:让提交任务的线程去执行任务(让老板去解决问题)

      用完了线程池,如何关闭?

      • shutdownNow():立即关闭线程池(暴力),正在执行中的及队列中的任务会被中断,同时该方法会返回被中断的队列中的任务列表(长按电源键-关机)
      • shutdown():平滑关闭线程池,正在执行中的及队列中的任务能执行完成,后续进来的任务会被执行拒绝策略(开始-关机界面-确定关机-关机动画-关机)

      简单描述是这样,但是还有很多的细节,比如调用shutdown方法后,正在执行任务的线程做出什么反应?正在等待任务的线程又做出什么反应?线程在什么情况下才会彻底退出。如果不了解这些细节,在关闭线程池时就难免遇到,像线程池关闭不了,关闭线程池出现报错等情况。

      再说这些关闭线程池细节之前,需要强调一点的是,调用完shutdownNow和shuwdown方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination方法进行同步等待。

      如何合理的配置Java线程池
      线程池究竟设置多大要看你的线程池执行的什么任务了,CPU密集型、IO密集型、混合型,任务类型不同,设置的方式也不一样

      任务一般分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池

      1. CPU密集型(频繁计算)
        尽量使用较小的线程池,一般CPU核心数+1
        因为CPU密集型任务CPU的使用率很高,若开过多的线程,只能增加线程上下文的切换次数,带来额外的开销
        问:上文解释只说明了为啥这种情况下线程数不能太大,但是为啥一般情况下设定为CPU核心数+1?
        答:

        • 通俗解释:对于计算密集型的程序,线程数应当等于核心数,但是再怎么计算密集,总有一些IO等能够将CPU让出来的操作吧,所以再加一个线程来把等待IO的CPU时间利用起来
        • 严谨解释:对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。)
      2. IO密集型(频繁读写)
        方法一:可以使用较大的线程池,一般CPU核心数 * 2
        IO密集型CPU使用率不高,可以让CPU等待IO的时候处理别的任务,充分利用cpu时间
        方法二:线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
        下面举个例子:

      比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。这个公式进一步转化为:
      最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目
      3、混合型
      可以将任务分为CPU密集型和IO密集型,然后分别使用不同的线程池去处理,按情况而定

      你可能感兴趣的:(java,多线程)