溪源的Java笔记—线程与线程池

前言

Java的进阶之路上不得不说的技术点就是——多线程,上期博客我们对JVM的知识进行了简单地整理,本期博客将针对线程与线程池后端知识点一一阐述。

JVM虚拟机 可参考我的博客:溪源的Java笔记—JVM
线程并发与线程安全可参考我的博客:溪源的Java笔记—线程并发与线程安全

正文

线程

线程CPU调度分派的基本单位。

线程的周期

  • 新建:当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时仅由JVM为其分配内存,并初始化其成员变量的值。
  • 就绪:当线程对象调用了start()方法之后,该线程处于就绪状态。Java 虚拟机会为其创建方法调用栈和程序计数器,等待调度运行。
  • 运行:如果处于就绪状态的线程获得了CPU,开始执行 run()方法的线程执行体,则该线程处于运行状态。
  • 阻塞: 是指线程因为某种原因放弃了CPU使用权,暂时停止运行。分为等待阻塞(wait)、同步阻塞(lock锁)、其他阻塞(sleep/join)三种情况。
  • 死亡: 结束后就是死亡状态,分正常死亡、异常结束、调用stop三种情况。

在这里插入图片描述

线程常见的方法:

  • void start():开启线程的方法
  • void run() :创建该类的子类时必须实现的方法
  • static void sleep(long t): 释放CPU的执行权,不释放锁 让优先级较低的线程有运行的机会, 是属于Thread类的方
  • final void wait():释放CPU的执行权,释放锁属于Object
  • final void notify(): 重新唤醒等待的线程。
  • static void yield():可以对当前线程进行临时暂停(让线程将资源释放出来 让同级的线程由运行的机会)
  • public final void join():让线程加入执行,执行某一线程join方法的线程会被冻结,等待某一线程执行结束,该线程才会恢复到可运行状态
  • suspend() :使线程进入阻塞。(不推荐使用)
  • resume() :使线程从阻塞状态中恢复。

终止线程的4种方式:

  • 正常运行结束: 程序运行结束,线程自动结束。
  • 使用退出标志退出线程:常有些线程是伺服线程。它们需要长时间的
    运行,只有在外部某些条件满足的情况下,才能关闭这些线程。这种结束类似自旋锁的实现。
  • Interrupt 方法结束线程a.当线程处于阻塞状态时:调用interrupt()方法时,会抛InterruptException异常。这个时候捕获异常,创造其实触发线程正常关闭的条件,就可关闭线程。实际上interrupt()不会直接关线程。 b.线程处于非阻塞状态时:使用 isInterrupted()判断线程的中断标志来退出循环。当使用interrupt()方法时,中断标志就会置 true
  • stop 方法终止线程:这种方式不是线程安全的,在调用 thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制)

线程中断

  • Java的中断是一种协作机制。也就是说调用线程对象的interrupt方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。
  • 设置线程中断不影响线程的继续执行,但是线程设置中断后,线程内调用了waitjionsleep方法中的一种, 立马抛出一个InterruptedException,且中断标志被清除,重新设置为false。

线程的实现方式:

  • 使用Thread,覆盖run()方法
  • 实现Runnable接口,重写run()方法
  • 实现Callable接口,相对于Runnable是有返回值,重写call()方法
  • 使用Executor框架来创建线程池

volatile

Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程,它具有两大特性:

  • 变量可见性:其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的 值对于其他线程是可以立即获取的。
  • 禁止重排序volatile 禁止了指令重排,是sychronized 更轻量级的同步锁,但是volatile变量的操作如果不是原子操作,不能保证线程同步。

原子操作:指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。

volatile变量可见性
造成多线程下内存不可见的原因:

  • Java内存模型(JMM)规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

当这个变量被标记成volatile时,这个变量就会直接从主内存获取更新,而不是从自己工作空间,从而解决可解决多线程下内存不可见的问题。

volatile禁止重排序
volatile可以通过插入内存屏障的方式,防止指令重排序。

As-If-Serial原则: 不管怎么进行指令重排序,单线程内程序的执行结果不能被改变。

Happens-Before原则: 用来指定两个操作之间的执行顺序,由于这个两个操作可以在一个线程之内,也可以在不同线程之间,通过这个规则可以保证跨线程的内存可见性。
具体规则如下:

  1. 程序次序规则:一个线程内,按照代码书写顺序,书写在前面的操作先发生于书写在后面的操作.
  2. 锁定规则:一个UNLOCK操作先行发生于后面对同一个锁的UNLOCK操作.
  3. volatile变量规则:对volatile修饰的变量的写操作 先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则Thread对象的start()方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

线程共享数据
Java 里面进行多线程通信的主要方式就是共享内存的方式,核心做法就是将共享数据封装在Runnable对象中,具体实现分一下两种情况:

  • 多线程行为一致,共同操作一个数据源:使用同一个Runnable对象(实现Runable接口)。共享数据作为这个类的成员变量,例如电影买票系统。
  • 多线程行为不一致,共同操作一个数据源:使用一个或多个Runnable,使用set()get()方法进行数据源在Runnable之间传递。例如为了保证安全,银行系统分步骤进行操作。

ThreadLocal
用来采用副本变量的方式来,实现每一个线程都有一个自己的变量(内部实现时ThreadLocalMap)。这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或 者组件之间一些公共变量的传递的复杂度。

常见的应用:

  • 用来解决数据库连接,存放connection对象,不同线程存放各自session
  • 解决simpleDateFormat线程安全问题;
  • 在使用结束时,调用ThreadLocal.remove来释放其value的引用,可以防止造成内存泄漏。

关于线程池

线程是处理器调度的基本单位。我们会为每一个请求都独立创建一个线程,而操作系统创建线程、切换线程状态、结束线程都要使用CPU进行调度。

Java当中主要有两类线程池:

  • Executor线程池Executor是个简单的接口,它提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnable来表示任务。Executor基于"生产者-消费者"模式,提交任务的操作相当于生产者,执行任务的则相当于消费者。
  • ForkjoinPool线程池:它非常适合执行可以分解子任务的任务,比如树的遍历,归并排序,或者其他一些递归场景。

Executor线程池

Executor线程池的逻辑结构

在这里插入图片描述
创建线程池的参数

public ThreadPoolExecutor(
      int corePoolSize,       #核心线程数
      int maxinmumPoolSize,   #线程总数  非核心数=总数-核心数
      long keepAliveTime,     #当前线程数大于核心线程数时  线程的等待新任务的等待时间(核心线程也会面临死亡)
      TimeUnit unit,          #时间单位
      BlockingQueue<Runnable> workQueue`在这里插入代码片` #任务队列
      RejectedExecutionHandler #(选填) 拒绝处理器
)

ThreadPoolExecutor线程池处理线程的过程:

  1. 当前运行线程数 小于corePoolSize 任务直接交给核心线程进行执行
  2. 当前运行线程数 大于或等于 corePoolSize 任务且满足队列未满,那么
    任务将进入任务队列进行等待,并且任务队列都具有阻塞性,所以只有当核心线程数的任务执行完了,才会从任务队列中获取任务。
  3. 当前运行线程数 大于或等于 corePoolSize 任务且队列已满,那么 任务进入非核心线程。
  4. 当核心线程、等待队列、非核心线程都被占用的时候线程会被拒绝器处理。

阻塞特性:当队列满了,便会阻塞等待,直到有元素出队,后续的元素才可以被加入队列。

任务队列
线程池中的任务队列,实现BlockingQueue接口,即阻塞队列接口 Queue本身也是先进先出的数据结构,它常见的有实现类有:

  • SyschronousQueue:在某次添加元素后必须等待其他线程取走后才能继续添加。所以一次只能有一个任务,它可以保证线程安全,但使用通常业务会要求非核心线程无限大。
  • ArrayBlockingQueue:数组的方式,大小创建后不能改变大小,具有阻塞特性。
  • LinkedBlockingQueue:无限容量基于链表的形式。
  • PriorityBlockingQueue:按照优先级进行内部元素排序的无限队列。
  • DelayQueue:一个带有延迟时间的无界阻塞队列。
  • LinkedTransferQueue:无限队列,先进先出,具有阻塞特性。
  • LinkedBlockingDeque :无限双端链表队列,可以从两头进行元素的读/取操作。

关于拒绝处理器
适用:那些既不能进入核心线程、等待队列,也无法使用非核心线程来处理,或者线程异常的线程:

  • CallerRunsPolicy:直接运行该任务的run方法,但不是在线程池内部,适合处理业务比较重要且数量不多的场景。
  • AbortPolicy:RejectedExecutionException异常抛出。适用对业务非常重要的完全不能不执行的场景。(默认)
  • DiscardPolicy:不会做任何处理。适合处理丢失对业务影响不大的场景。
  • DiscardOldestPolicy:检查等待队列 强行取出队列头部任务(并抛弃该头部任务)后再进行执行该任务。适合新数据比旧数据重要的场景。

Java Executor预定义的4种线程池
1.CachedThreadPool
CachedThreadPool,可缓存的线程池:

  • 该线程池中没有核心线程,非核心线程的数量为Integer.max_value( 最大值 2 的 31 次方 - 1)

  • 调用 execute 将重用以前构造 的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并
    从缓存中移除那些已有 60 秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资 源。

  • 采用SynchronousQueue任务队列,适用于耗时少,任务量大的情况。

2.ScheduledThreadPool
ScheduledThreadPool,周期性执行任务的线程池:

  • 它可安排在给定延迟后运行命令或者定期地执行。
  • 有核心线程,也有非核心线程,非核心线程数:Integer.max_value -核心线程数。
  • 采用DelayedWorkQueue队列,适用延迟执行、定时执行的情况。

3.SingleThreadPool
SingleThreadPool,只有一条线程来执行任务的线程池:

  • 只有一个核心线程,这个线程 池可以在线程死后(或发生异常时)重新启动一个线程来替代原来的线程继续执行下去
  • 采用LinkedBlockingQueue队列,适合适用于有顺序的任务的情况。

4.FixedThreadPool
FixedThreadPool,定长的线程池:

  • 有核心线程,核心线程的即为最大的线程数量,没有非核心线程。
  • FixedThreadPool能保证每一个Runnable对象都不会丢失,线程过小会大量进入拒绝策略中,线程数过大,但是一定程度上可能会造成OOM。在真实场景采用设置线程总数设为Integer.max_value
  • 采用LinkedBlockingQueue队列,适合能够避免频繁回收线程和创建线程,适合长期任务的情况。

特定场景如何去设置线程池
线程池的关键点是:

  • 1.尽量减少线程切换和管理的开支;
  • 2.最大化利用CPU

对于1,要求线程数尽量少,这样可以减少线程切换和管理的开支;
对于2,要求尽量多的线程,以保证CPU资源最大化的利用。

根据场景给与的建议:

  • 高并发,低耗时的情况:建议少线程,只要满足并发即可;例如并发100,线程池可能设置为10就可以
  • 低并发,高耗时的情况:建议多线程,保证有空闲线程,接受新的任务;例如并发10,线程池可能就要设置为20;
  • 高并发高耗时的情况:1要分析任务类型,2增加排队,3、加大线程数

ForkJoin Pool

ForkJoinPool 线程池在 JDK 8 加入,主要用法和之前的线程池是相同的,也是把任务交给线程池去执行,线程池中也有任务队列来存放任务,和之前的五种线程池不同的是,它非常适合执行可以分解子任务的任务,比如树的遍历,归并排序,或者其他一些递归场景。

在这里插入图片描述

在这里插入图片描述

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐