面试官:这就是你理解的 Java 多线程基础?
引言
现代的操作系统(Windows,Linux,Mac OS)等都可以同时打开多个软件(任务),这些软件在我们的感知上是同时运行的,例如我们可以一边浏览网页,一边听音乐。而 CPU 执行代码同一时间只能执行一条,但即使我们的电脑是单核 CPU 也可以同时运行多个任务,如下图所示,这是因为我们的 CPU 的运行的太快了,把时间分成一段一段的,通过时间片轮转分给多个任务交替执行。
把 CPU 的时间切片,分给不同的任务执行,而且执行的非常快,看上去就像在同时运行一样。例如,网易云执行 50ms,浏览器执行 50ms,word 执行 50ms,人的感官根本感知不到。现在多数的电脑都是多核(多个 CPU )多线程,例如 4 核 8 线程(可以近似的看成 8 个 CPU ),也是把每个核心运行时间切片分给不同的任务交替执行。
进程与线程
进程(Process)是操作系统对一个正在运行的程序的一种抽象,我们可以进程简单理解为操作系统中正在运行的一个软件,即把一个任务称之为一个进程,例如我们的网易云音乐就是一个进程,浏览器又是另外一个进程。
线程(Thread)线程是一个比进程更小的执行单位,进程是线程的容器,一个进程至少有一个线程而且可以产生多个线程,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据,多线程之间比多进程之间更容易共享数据,而且线程一般来说都比进程更加高效。
java 语言内置了多线程支持:JVM 启动时会创建一个主线程,该主线程负责执行 main 方法,一个 Java 程序实际上是一个 JVM 进程,JVM 进程用一个主线程来执行 main()方法内部,我们又可以启动多个线程。此外,JVM 还有负责垃圾回收的其他工作线程等。
创建线程
我们需要区分线程和线程体两个概念,线程可以驱动任务,因此需要一个描述任务的方式,这个方式就是线程体,而我们创建线程体有多种方式,而创建线程只有一种:将任务(线程体)显示的附着到线程上,调用 Thread 对象的 start()方法,执行线程的初始化操作,然后新线程调用 run() 方法启动任务。
创建线程体可以使用下面 3 种方式,然而这 3 种方式都是在创建线程体,直到调用 Thread 对象的 start() 方法时才请求 JVM 创建新的线程,具体什么时候运行有线程调度器 Scheduler 决定。
继承 Thread 类;
实现 Runable 接口;
实现 callable 接口
在日常使用中,建议能用接口实现就不要用继承 Thread 的方式来创建线程,原因如下:
避免单继承的限制:Java 是单继承的语言,如果一个类继承 Thread 类,就无法再继承其他类。而实现 Runnable 接口则不会有这种限制,避免了单继承的局限性。
更好的适配性:实现 Runnable 接口可以更好地支持类似线程池的机制,让线程的执行和任务的分离更清晰。传递 Runnable 对象给线程池执行任务十分方便,而且可以重复使用。
更好的面向对象设计:继承 Thread 类是一种功能导向的设计,而实现 Runnable 接口更倾向于面向对象的设计,符合面向对象的编程思想。
线程的状态
一个线程对象只能调用一次 start() 方法启动新线程,并在新线程中执行 run() 方法,一旦 run() 方法 执行完毕,线程就终止死亡了。我们通过 Thread 类中的枚举类 State 来看一下 Java 线程有哪些状态:
由源码可知,Java 的线程状态有 6 种:
NEW:新创建的线程,还未执行;
RUNNABLE:正在运行中线程或正在等待资源分配的准备运行的线程;
BLOCKED:等待获取监视器锁的线程;
WAITING:等待另外一个线程执行特定操作,没有时间限制;
TIMED_WAITING:等待某个特定线程在制定时间段内执行特定操作;
TERMINATED:线程执行完毕
线程状态的转换可以参考下图:
NEW 状态
创建线程后未启动线程状态为 NEW,在该线程调用 start() 方法以前会一直保持这种状态。此时,JVM 会为该线程分配内存并初始化其成员变量的值,但是该线程并没有表现出任何线程的动态特征,程序也不会执行线程的执行体,即 run() 方法的部分。
下面的代码,我们可以调用 Thread.getState() 方法来获取线程的状态,可以看出打印出来的状态为 NEW。
RUNNABLE
当在 Java 的 Thread 对象上调用 start()方法后,以下过程将会发生:
线程状态变化:线程对象的状态会从 NEW(新建)状态转变为 RUNNABLE(可运行)状态,表明线程已经准备好运行,但尚未分配到 CPU 执行。
系统资源分配:线程调度器会为该线程分配系统资源,例如 CPU 时间。然而,并不保证立即执行,具体执行时机还取决于线程调度器的调度算法和其他运行中的线程。
执行 run()方法:当该线程被线程调度器选中并分配到 CPU 时间时,线程的 run()方法会被调用,线程开始执行具体的任务逻辑。
处于 RUNNABLE 状态的线程要么正在运行中,要么已经准备好运行但正在等待系统分配 CPU 资源。
在 Java 虚拟机(JVM)中,JVM 自带的线程调度器负责决定 Java 线程的执行顺序。它会根据线程的优先级和调度算法来确定哪个线程可以获得 CPU 时间。通常情况下,程序员可以通过设置线程的优先级来影响线程调度器的决策,但实际线程的调度仍由 JVM 负责。
BLOCKED
当线程尝试访问某个由其他线程锁定的代码块时,该线程会因为需要等待获取监视器锁进入 BLOCKED 状态,线程获取锁后就会结束此状态。
WAITING
线程正在等待另一个线程执行特定操作时处于 WAITING 等待状态,例如当线程调用以下方法时会进入 WAITING 等待状态:
上述方法中的 wait() 和 join() 没有传入超时时间 timeout 参数,线程只能等待其他线程显示的唤醒或执行完毕,否则不会被分配 CPU 时间片。
TIMED_WAITING
线程在这种状态下属于期限等待,无需其他线程显示的唤醒当前线程,在一定时间内被系统自动唤醒。
阻塞和等待的区别在于:阻塞是被动的,等待是主动的。阻塞是在等待获取锁,而等待是在等待一定的条件发生。
TERMINATED
线程执行完毕或者产生异常而结束会进入 TERMINATED 状态,进入该状态的线程已经死亡。
线程同步
并发问题产生的原因是:多个线程同时对一个共享资源进行非原子性操作,这里面包含了三个产生并发问题的三个条件:多个线程同时,共享资源,非原子性操作,解决线程安全问题的本质就是要破坏这三个条件,因此可以把多线程的并行执行,修改为单线程的串行执行,即同一时刻只让一个线程执行,这种解决方式就叫做互斥锁。
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock,从而达到保护共享资源的目的,当多条线程执行到被保护的区域时,都需要先去获取到锁,这时候只能有一条线程获取到锁,执行被保护区域的代码,其他线程在保护区外部等待获取锁,直到当前线程执行完毕释放资源后,其他线程才有执行的机会。
synchronized 和 ReentrantLock 可以保证可见性、原子性和有序性,另外一个 Java 的关键字 Volatile 也可以保证可见性,另外后者还可以禁止指令重排序。
Synchronized
在 Java 中每个对象都可以作为锁,Synchronized 也是依赖 Java 的对象来实现锁,一共有三种类型的锁:
当前实例锁:锁定的是实例对象,即为 this 锁;
类对象锁:锁定的是类对象,即为 Class 对象锁;
对象实例锁:锁定的给定的对象实例,即位 Object 锁;
在使用 Synchronize 时也有三种不同的方式:
修饰普通方法:使用 this 锁,在执行该方法前必须先获取当前实例对象的锁资源;
修饰静态方法:使用 class 锁,在执行该方法前必须先获取当前类对象的锁资源;
修饰代码块:使用 Object 锁,在执行该方法前必须先获取给定对象的锁资源;
三种不同的使用方式有不同的应用场景,我们在使用的过程中一定要注意加锁的对象是谁,否则可能会产生意想不到的结果。在加锁时,尽量减少加锁的区域,例如能够在方法体中对代码块加锁,就不要在方法上面加锁,加锁的区域越短越好。
ReentrantLock
ReentrantLock 是 Java.util.concurrent(J.U.C)包中的锁,该锁由 JDK 实现,而 synchronized 是由 JVM 实现的。
上面的代码演示了 ReentrantLock 的使用方法,显示的调用 lock()方法加锁,在 finally 中显示的释放锁。
锁比较
在需要使用锁时,除非需要使用 reentrantLock 的高级功能,否则优先使用 synchronized 关键字加锁,这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生支持它,而 ReentrantLock 不是所有的 JDK 版本都支持,并且使用 syschronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保释放锁。
线程池
线程池可以管理一系列线程,当有任务需要处理时,直接从线程池里面获取线程来处理,当线程处理完任务时再放回到线程池中等待下一个任务,这样可以减少每次创建线程的开销,提升资源的利用率。线程池提供了一种限制和管理资源的方式,每个线程池还维护了一些基本的统计信息,例如 已完成任务的数量等。在《Java 并发编程的艺术》一书中提到使用线程有三点好处:
降低资源的消耗率:通过重复利用已创建的线程,降低线程创建和销毁造成的开销;
提高响应速度:当任务到达时,任务不需要等待线程创建结束即可执行;
提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控;
创建线程池
可以使用内置的线程池,通过 Executor 框架的工具类 Executors 来创建预先定义好的线程池。
Executors
Executors 工具类提供的创建线程池的方法如下图所示:
从上图中可以看出,Executors 工具类可以创建多种类型的线程池,包括:
FixedThreadPool:固定线程数量的线程池,在创建该线程池时,需要传入一个线程池中线程个数的 int 参数,当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有空闲线程,则新的任务会被暂存在一个任务队列中,待有线程空闲时处理。
SingleThreadPool:单线程线程池,在该线程池中只有一个线程,若超过一个线程提交到该线程池,任务会被保存到任务队列中,等到该线程空闲时,按照先入先出的顺序执行队列中的任务。
CachedThreadPool:可缓存线程的线程池,该线程池的线程数量不确定,在优先使用空闲线程的条件下,遇到新的任务提交时,会创建一个新的线程来处理任务,任务处理完毕后回到线程池等待复用。
ScheduledExecutorPool:给定的延迟后运行的任务或定期执行任务的线程池。
自定义创建
如下图,可以通过 ThreadPoolExecutor 构造函数来创建线程池(推荐)。
优先推荐使用 ThreadPoolExecutor 来创建线程池,在《阿里巴巴 Java 开发手册》中指出线程资源必须使用线程池来提供,不允许在应用中自行显示创建线程,也强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式来创建线程池。
使用内置的线程池有以下缺点:
newFixedThreadPool 和 SingleThreadPool 使用的是无界队列 LinkedBlockingQueue,任务队列最大成都为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;
CachedThreadPool:使用的是同步队列 SyschronousQueue,允许创建的线程数量为 Integer.MAX_VALUE, 如果任务执行较慢,可能会创建大量的线程,从而导致 OOM。
ScheduledExecutorPool:使用的无界的延迟阻塞队列 DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;
实际上内置的线程池也是调用 ThreadPoolExecutor 来创建的线程池:
线程池参数
我们来看一下自定义创建线程池的参数有哪些?
如上,ThreadPoolExecutor 中有三个很重要的参数
corePoolSize:任务队列未达到队列容量时,最大可以同时运行的线程数量。
maxinumPoolSize:任务队列中存放的任务达到队列容量时,当前可以同时运行的线程数量变为最大线程数。
workQueue:新任务来时会先判断当前运行的线程数量是否达到核心线程数,若达到核心线程数,新任务会被存放在队列中。
ThreadPoolExecutor 其他常见参数:
keepAliveTime:线程池中的线程数量超过 corePoolSize 时,如果这个时候没有新的任务提交,核心线程以外的线程不会立即销毁,会等到 keepAliveTime 的时间,然后才会销毁超出部分的线程。
unit:keepAliveTime 参数的时间单位。
threadFactory:executor 创建新线程时用到。
handler:拒绝策略
下面这张图可以看出,核心线程数量为 4,最大线程数量为 8。新任务提交到线程池时,首先判断是否有线程或者线程数量是否小于核心线程数,若满足则首先创建新的线程执行任务,当核心线程数到达 corePoolSize 时,将任务缓存到任务队列中,当任务队列存放的任务到达队列容量时,再创建新的线程,直到达到 maxnumPoolSize 的线程数量,后续再根据拒绝策略返回。
拒绝策略
如果当前线程池同时运行的线程数量达到了最大线程数并且队列也已经放满了任务时,ThreadPoolExecutor 再接收到新的线程时,会执行一些预定义的拒绝策略,例如:
ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException 来拒绝新任务的处理。
ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用 execute 方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。
ThreadPoolExecutor 默认执行的是 AbortPolicy,抛出 RejectedExecutionException 来拒绝新任务。如果不想丢弃任务,也可以使用 CallerRunsPolicy 将任务回退给调用者,使用调用者线程来执行任务。
线程池任务处理流程
当新任务被提交到线程池时,首先判断核心线程数量是否达到 corePoolSize,若未达到则创建新的线程,直到线程池中的线程数量到达 corePoolSize 的大小。
当核心线程数量达到 corePoolSize 的数量时,将新达到的任务缓存在阻塞队列中,直到任务队列容量用完,无法存放新的任务。
任务队列无法存放新任务后,若线程池中的线程数量小于 maxnumPoolSize,则创建新的线程来执行任务,直到线程数量达到 maxnumPoolSize 的数量。
根据创建线程池时设置的拒绝策略来处理新提交的任务。
后记
本文从进程与线程、创建线程、线程状态、线程同步和线程池等多个方面讲述了线程基础知识,希望大家对线程和线程池有了一个基础的了解。后面我们将继续深入多线程的其他知识,例如 Sychronized 的原理分析,JUC 工具类和 ThreadLocal 本地变量等知识,尽请关注。
文章转载自:蓉城北斗君
评论