JUC复习篇(一)线程基础

前言

Java JUC章节的知识点繁多,为了更好的整理我掌握的知识点,故此通过书写博客的方式,其中从底层原理开始梳理,从而总结出自己对并发编程的理解。

什么是并发编程

原因还得从目前的计算机发展历史说起,根据摩尔定律,计算机CPU算力随着时间越来越高,但摩尔定律很快达到物理极限,单核心CPU算力遇到瓶颈,而人们为了追求更高的算力,使用了多核心CPU架构设计,多核心与超线程技术使得CPU算力大幅度提高。原理相当于有多个人同时执行不同的指令,整体效率当然比一个人执行得更高。(注意是整体效率更高,不意味着使用多线程就一定比单线程应用执行指令效率更高)

为了更好的运用多核心CPU的计算资源(压榨CPU),于是产生了多线程开发。虽然使用多线程在某些场景下确实提高了程序整体效率,但多线程也并不是银弹,使用多线程开发随之而来的也有诸多问题,例如:线程安全问题、锁机制、死锁、线程上下文切换带来的开销等问题。

那么说了这么多线程的描述,到底什么是线程呢?

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务,而一个进程中至少有一个线程执行。

通过JVM内存模型图,我们可以知晓线程 Thread 所处于整个JVM中具体位置。在VM Stack 虚拟机栈空间中,每一个线程都有自己的线程栈,线程栈中又存放着许多栈帧Stack Frame,栈帧中又存放着LVA,OS,FD等信息。

LVA:局部变量数组 (Local Variable Array)

  • 栈帧的局部变量部分是由为索引0开始的字节数组构成。
  • 它包含了所有的方法参数和局部变量。
  • 在数组中的每个插槽或条目大小是4个字节。
  • int,float和引用类型在数组中全部占用一个条目或插槽,例如,4字节大小。
  • double和long类型的数据在数组中占用2个连续条目,例如,总共8字节大小。
  • byte,short和char类型在存储之前会被转化为int类型并且占用1个插槽,例如,4字节大小。
  • 但是存储boolean类型值的方式在不同的JVM中有不同的实现。在大多数JVM的实现中boolean类型在局部变量数组中占用一个插槽。
  • 参数会首先被放置如局部变量数组中,顺序是它们在方法中声明的顺序。

OS:操作数栈 (Operand Stack)

  • JVM使用操作数栈作为运行的工作空间或者我们也可以说用来存储计算的中间结果。
  • 操作数栈也像局部变量数组一样组织为一个数组。但是它不是使用索引来访问的而是由一些指令来访问,这些指令可以把值推进栈中,也可以把值从栈中弹出并且做一些我们需要的操作。

FD:栈帧数据区(Frame Data)

  • 栈数据区包含了所有的符号引用(常量池解析)和普通方法的return位置,该return与特定的方法关联,用来跳转回去。
  • 它也包含了一个到异常表的引用,可以在异常发生的提供catch代码块的信息。

在JDK1.8中,默认的线程栈空间为1MB,但可通过-XX:ThreadStackSize=256k 调整线程栈内存空间大小,如果线程栈空间不足则会抛出异常 StackOverflowError。

线程栈

线程栈:每次线程执行函数时都会压入一个栈帧,线程栈遵循先进后出,先进在栈底,后进在栈顶,从栈顶往下执行。例如:A函数调用B函数,B函数又调用C函数,栈结构如下图:

栈结构简单,且不需要垃圾回收器,因为栈帧执行完成后即刻销毁内存。

JDK1.8中使用的一对一线程模型(及一个LWP对应一个KLT):

KLT:内核级线程(Kernel Level Thread)这种线程实现是指直接由操作系统内核来完成线程支持、线程切换和操纵调度器进行调度即线程映射到各个处理器。

LWP:轻量级进程 (Light Weight Process)程序一般不会直接去使用内核线程,而是通过去使用内核线程的一种高级接口叫LWP调度操作内核线程,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息这也是它之所以被称为轻量级的原因,并且它与KLT之间的关系是1:1的存在。

优点:

1、实现简单,可适用大部分多线程场景,目前主流的JVM都使用此方案。

缺点:

1、用户线程的阻塞和唤醒直接映射到内核线程中,而且随着线程数量的增加,线程之间频繁切换导致CPU开销变大。当前JDK1.8中引入了CAS算法来避免线程之间的频繁切换与线程加锁,确实大幅度提高了JVM的并发性能。

2、操作系统内核可以创建的线程数有限,应用程序创建过多的线程,会大幅度降低系统的性能,CPU大部分时间片都会被过多的线程切换所消耗。

目前GO语言与JDK19版本所使用的多对多线程模型(及在LWP上构建虚拟用户线程UT):

UT:用户线程(User Thread)建立在用户空间,系统内核不能感知用户线程的存在,线程创建、销毁、切换开销小

多对多模型,又叫作两级线程模型,它充分吸收线程模型的优点且尽量规避它们的缺点。在此模型下用户线程与内核线程是多对多(M : N,通常M >= N)的映射模型。

优点:

1、应用程序自身去管理UT的相互切换,而底层CPU不必要频繁的切换线程,可以更连续的执行当前线程,从而提高程序效率。

2、UT的调度优先级可以通过程序自身把控。

3、极大提高并发量,可以通过此模型开启UT过万也可以很好的提供服务。此模型天生适合高并发场景。

缺点:

1、实现比较复杂,比如Go语言采用的GMP线程模型就是这种多对多的方式实现的,这也是为什么使用goroutine可以实现高并发的原因之一。目前Java的Loom项目也在这方面进行探索,在JDK19中已经实现,大家可以尝试使用。JEP 425:虚拟线程(预览版) (openjdk.org)

线程的5种状态

1、新建(New)

线程对象被创建后,就进入了新建状态

2、就绪(Runnable)

也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。但具体什么时候执行此线程由CPU自身决定。

3、运行(Running)

线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态

4、阻塞(Blocked)

  • (01) 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。可通过notify()方法唤醒该线程。唤醒之后线程进入就绪状态
  • (02) 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
  • (03) 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

5、死亡(Dead)

线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

Java线程池的应用

从上面的线程状态可以得知,不停的创建和销毁销毁线程,将会在新建与死亡两个状态中消耗更多的计算资源。为了更好的使用线程,于是便出现了管理线程资源的线程池。在线程池中我们会先创建好指定数量的线程,待需要使用的时候,从中取出线程运行,运行完对应任务后又扔回线程池中,这样就很好的避免了重复的创建和销毁步骤,从而提高程序的效率。

优点:

1、更好的管理应用程序中的线程数量、任务数量。

2、加快了任务执行速度,及缩短了请求响应时间。(没有创建和销毁步骤)

3、可使用多线程并行计算提高效率,以及其他的增强功能。

4、复用线程,降低了系统计算资源的开销。

线程池的创建方式

  • 通过 ThreadPoolExecutor 手动创建线程池。
  • 通过 Executors 执行器自动创建线程池。

7种创建线程池方式:

1、Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待。
2、Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。
3、Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序。
4、Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池。
5、Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池。
6、Executors.newWorkStealingPool:创建一个抢占式执行的线程池 (任务执行顺序不确定)
7、ThreadPoolExecutor:手动创建线程池的方式,它创建时最多可以设置 7 个参数。核心数,线程最大数,阻塞队列类型(有界,无界),存活时间,时间单位,线程工厂,拒绝策略

int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler

线程池的创建推荐使用最后一种 ThreadPoolExecutor 的方式来创建,因为使用它可以明确线程池的运行规则,规避资源耗尽的风险。

拒接策略

拒绝策略共有4种

1 ThreadPoolExecutor.AbortPolicy 默认拒绝策略,拒绝任务并抛出任务
2 ThreadPoolExecutor.CallerRunsPolicy 使用调用线程直接运行任务
3 ThreadPoolExecutor.DiscardPolicy 直接拒绝任务,不抛出错误
4 ThreadPoolExecutor.DiscardOldestPolicy 触发拒绝策略,只要还有任务新增,一直会丢弃阻塞队列的最老的任务,并将新的任务加入

线程最大执行任务数=线程数+队列长度。

如果线程池最大线程为10个线程,队列长度为20。则表明该线程池最大同时接受30个任务,超出的任务则会使用对应的拒绝策略。当然拒绝策略也可以自己去实现接口 RejectedExecutionHandler 处理拒绝流程。