Java基础系列(9)- 多线程
1|0基础概念:程序、进程、线程
1|1程序 program
是为完成特定任务、用某种语言编写的一组指令的集合。即指一段静态的代码
,静态对象
1|2进程 process
是程序的一次执行过程,或是正在运行的一个程序
。是一个动态的过程:有它自身的产生、存在和消亡的过程——生命周期
- 程序是静态的,进程是动态的
进程作为资源分配的单位
,系统在运行时会为每个进程分配不同的内存区域
1|3线程 thread
进程可进一步细化为线程,是一个程序内部的一条执行路径。
- 若一个进程同一时间
并行
执行多个线程,就是支持多线程的 线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc)
,线程切换的开销小- 一个进程中的多个线程共享相同的内存单元/内存地址空间,它们从同一堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来
安全的隐患
一个java应用程序 java.exe,其实至少有三个线程:main()主线程,gc() 垃圾回收线程,异常处理线程
1|4并发与并行
并行
:多个cpu同时执行多个任务。比如:多个人同时做不同的事并发
:一个CPU(采用时间片)同时执行多个任务。比如:秒杀、多个人做同一件事
2|0线程的创建和使用
java语言的jvm允许程序运行多个线程,它通过 java.lang.Thread
类来体现
Thread类的特性:
- 每个线程都是通过某个特定Thread对象的run()方法来完成操作的,经常把run()方法的主体称为
线程体
- 通过该Thread对象的start()方法来启动这个线程,而非直接调用run()
2|1创建多线程的方式一:继承于Thread
- 创建一个继承于Thread类的子类
- 重写Thread类的run() --> 将此线程执行的操作声明在run()中
- 创建Thread类的子类的对象
- 通过此对象调用start()
例子:遍历100以内的所有的偶数
练习:创建两个分线程,其中一个线程遍历100以内的偶数,另一个线程遍历100以内的奇数
创建Thread类的匿名子类的方式
2|2线程的常用方法
start()
:启动线程,并执行对象的run()方法
run()
:通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中
currentThread()
:静态方法,返回当前线程。在Thread子类中就是this,通常用于主线程和Runnable实现类
getName
:返回线程的名称
setName(String name)
:设置该线程名称
yield()
:释放当前cpu的执行权
- 暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程
- 若队列中没有同优先级的线程,忽略此方法
join()
:在线程a中调用线程b的join(),此时线程a就进入阻塞状态,直到线程b完全执行完以后,线程a才结束阻塞状态
- 低优先级的线程也可以获得执行
sleep(long millis)
:静态方法,让当前线程”睡眠“指定的millitime毫秒。在指定的millitime毫秒时间内,当前线程是阻塞状态
- 令当前活动线程在指定时间段内放弃对CPU控制,使其他线程有机会被执行,时间到后重排队
- 抛出InterruptedException异常
stop()
:强制线程生命期结束,不推荐使用
isAlive()
:返回boolean,判断线程是否还活着
通过带参构造器重命名创建的线程
yeild
join
sleep
isAlive
2|3线程的优先级
Java的调度方法:
- 同优先级线程组成先进先出队列(先到先服务),使用时间片策略
- 对高优先级,使用优先调度的抢占式策略
线程的优先级等级:
- MAX_PRIORITY:10
- MIN_PRIORITY:1
- NORM_PRIORITY:5 (默认)
涉及的方法:
- getPriority():返回线程优先级
- setPriority(int newPriority):改变线程的优先级
说明
- 线程创建时继承父线程的优先级
- 低优先级只是获得调度的概率低,并非一定是在高优先级线程之后才被调用
2|4创建多线程的方式二:实现Runnable接口
- 创建一个实现了Runnable接口的类
- 实现类去实现Runnable中的抽象方法:run()
- 创建实现类的对象
- 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
- 通过Thread类的对象调用start():①启动线程 ②调用当前线程的run() -->调用了Runnable类型的target()的run()
2|5比较创建线程的两种方式
开发中:优先选择,实现Runnable接口的方式
原因:
- 实现的方式没有类的单继承性的局限性
- 实现的方式更适合来处理多个线程有共享数据的情况
联系:public calss Thread implements Runnable
相同点:两种方式都需要重写run(),将线程要执行的逻辑声明在run()中
3|0线程的生命周期
要想实现多线程,必须在主线程中创建新的线程对象。java语言使用Thread类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态:
新建
:当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态就绪
:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源运行
:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run()方法定义了线程的操作和功能阻塞
:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态死亡
:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束
4|0线程的同步
4|1解决线程安全问题的方式一:同步代码块
说明:
-
操作共享数据的代码,即为需要被同步的代码
-
共享数据:多个线程共同操作的变量。
-
同步监视器:俗称,锁。任何一个类的对象,都可以充当锁。
要求:多个线程必须要共用同一把锁
同步代码块处理实现Runnable的线程安全问题
同步代码块处理继承Thread类的线程安全问题
注意要static 创建obj
补充(this,类.class)
在实现Runnable接口创建多线程的方式中,我们可以考虑使用this充当同步监视器。(只创建了一个对象)
在继承Thread类创建多线程的方式中,慎用this充当同步监视器,考虑使用当前类充当同步监视器
4|2解决线程安全问题的方式二:同步方法
如果操作共享数据的代码完整的声明在一个方法中,我们不妨将此方法声明同步的.
同步方法处理实现Runnable的线程安全问题
同步方法处理继承Thread类的方式中的线程安全问题
关于同步方法的总结
- 同步方法仍然涉及到同步监视器,只是不需要我们显式的声明
- 非静态的同步方法,同步监视器是:this
- 静态的同步方法,同步监视器是:当前类本身
4|3死锁
死锁的理解:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁
说明:
- 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续
- 我们使用同步时,要避免出现死锁
演示线程的死锁问题
4|4解决线程安全问题的方式三:Lock锁 - JDK5.0新增
- Lock 是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized是隐式锁,出了作用域自动释放
- Lock只有代码块锁,synchronized有代码块锁和方法锁
- 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
面试题:synchronized 与 Lock 的异同?
相同:二者都可以解决线程安全问题
不同:synchronized 机制在执行完相应的同步代码以后,自动的释放同步监视器。Lock需要手动的启动同步(Lock()),同时结束同步也需要手动的实现(unlock())
优先使用顺序:
- Lock
- 同步代码块(已经进入了方法体,分配了相应资源)
- 同步方法(在方法体之外)
练习题:银行有一个账户,有两个储户分别向同一个账户存3000元,每次存1000,存3次,每次存完打印余额
5|0线程的通信
wait()
:一旦执行此方法,当前线程就进入阻塞状态,并释放同步监视器notify()
:一旦执行此方法,就会唤醒被wait的一个线程。如果有多个线程被wait,就唤醒优先级高的那个notifyAll()
:一旦执行此方法,就会唤醒所有被wait的线程
说明:
- wait() 、notify()、notifyAll() 三个方法
必须使用在同步代码块或者同步方法
中。(Lock不行) - wait() 、notify()、notifyAll() 三个方法的
调用者必须是同步代码块或同步方法中的同步监视器
。否则,会出现IllegalMonitorStateException
异常 - wait() 、notify()、notifyAll() 三个方法是定义在
java.lang.Object
类中
面试题:sleep() 和 wait() 的异同?
相同点:一旦执行方法,都可以使得当前的线程进入阻塞状态
不同点:
- 两个方法声明的位置不同:Thread 类中声明sleep(),Object类中声明wait()
- 调用的要求不同:sleep()可以在任何需要的场景下调用。wait() 必须使用在同步代码块或同步方法中
- 关于是否释放同步监视器:如果两个方法都使用在同步代码块或同步方法中,sleep() 不会释放锁,wait() 会释放锁
生产者消费则模型:等着捞笔记
6|0JDK5.0新增线程创建方式
6|1解决线程安全问题的方式三:实现Callable接口
与使用Runnable相比,Callable功能更强大
- 相比run()方法,可以有返回值
- 方法可以抛出异常
- 支持泛型的返回值
- 需要借助FutureTask类,比如获取返回结果
Future接口
- 可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等
- FutrueTask是Futrue接口的唯一实现类
- FutrueTask 同时实现了Runnable、Future接口。它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值
6|2解决线程安全问题的方式四:使用线程池
线程池的基本概念
线程池,本质上是一种对象池,用于管理线程资源。
在任务执行前,需要从线程池中拿出线程来执行。
在任务执行完成之后,需要把线程放回线程池。
通过线程的这种反复利用机制,可以有效地避免直接创建线程所带来的坏处。
线程池的优缺点
优点
- 降低资源的消耗。线程本身是一种资源,创建和销毁线程会有CPU开销;创建的线程也会占用一定的内存。
- 提高任务执行的响应速度。任务执行时,可以不必等到线程创建完之后再执行。
- 提高线程的可管理性。线程不能无限制地创建,需要进行统一的分配、调优和监控。
缺点
- 频繁的线程创建和销毁会占用更多的CPU和内存
- 频繁的线程创建和销毁会对GC产生比较大的压力
- 线程太多,线程切换带来的开销将不可忽视
- 线程太少,多核CPU得不到充分利用,是一种浪费
线程池创建流程
通过上图,我们看到了线程池的主要处理流程。我们的关注点在于,任务提交之后是怎么执行的。大致如下:
- 判断核心线程池是否已满,如果不是,则创建线程执行任务
- 如果核心线程池满了,判断队列是否满了,如果队列没满,将任务放在队列中
- 如果队列满了,则判断线程池是否已满,如果没满,创建线程执行任务
- 如果线程池也满了,则按照拒绝策略对任务进行处理
在jdk
里面,我们可以将处理流程描述得更清楚一点。来看看ThreadPoolExecutor
的处理流程。
我们将概念做一下映射。
corePool
-> 核心线程池maximumPool
-> 线程池BlockQueue
-> 队列RejectedExecutionHandler
-> 拒绝策略
入门级例子
为了更直观地理解线程池,我们通过一个例子来宏观地了解一下线程池用法。
在这个例子中,首先创建了一个固定长度为5的线程池。然后使用循环的方式往线程池中提交了10个任务,每个任务休眠1秒。在任务休眠之前,将任务所在的线程id进行打印输出。
所以,理论上只会打印5个不同的线程id,且每个线程id会被打印2次。
Executors
Executors
是一个线程池工厂,提供了很多的工厂方法,我们来看看它大概能创建哪些线程池。
1. 创建单一线程的线程池
顾名思义,这个线程池只有一个线程。若多个任务被提交到此线程池,那么会被缓存到队列(队列长度为Integer.MAX_VALUE
)。当线程空闲的时候,按照FIFO的方式进行处理。
2. 创建固定数量的线程池
和创建单一线程的线程池
类似,只是这儿可以并行处理任务的线程数更多一些罢了。若多个任务被提交到此线程池,会有下面的处理过程。
- 如果线程的数量未达到指定数量,则创建线程来执行任务
- 如果线程池的数量达到了指定数量,并且有线程是空闲的,则取出空闲线程执行任务
- 如果没有线程是空闲的,则将任务缓存到队列(队列长度为
Integer.MAX_VALUE
)。当线程空闲的时候,按照FIFO的方式进行处理
3. 创建带缓存的线程池
这种方式创建的线程池,核心线程池的长度为0,线程池最大长度为Integer.MAX_VALUE
。由于本身使用SynchronousQueue
作为等待队列的缘故,导致往队列里面每插入一个元素,必须等待另一个线程从这个队列删除一个元素。
4. 创建定时调度的线程池
和上面3个工厂方法返回的线程池类型有所不同,它返回的是ScheduledThreadPoolExecutor
类型的线程池。平时我们实现定时调度功能的时候,可能更多的是使用第三方类库,比如:quartz等。但是对于更底层的功能,我们仍然需要了解。
我们写一个例子来看看如何使用。
-
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
,定时调度,每个调度任务会至少等待period
的时间,如果任务执行的时间超过period
,则等待的时间为任务执行的时间 -
scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
,定时调度,第二个任务执行的时间 = 第一个任务执行时间 +delay
-
schedule(Runnable command, long delay, TimeUnit unit)
,定时调度,延迟delay
后执行,且只执行一次
手动创建线程池
理论上,我们可以通过Executors
来创建线程池,这种方式非常简单。但正是因为简单,所以限制了线程池的功能。比如:无长度限制的队列,可能因为任务堆积导致OOM,这是非常严重的bug,应尽可能地避免。怎么避免?归根结底,还是需要我们通过更底层的方式来创建线程池。
抛开定时调度的线程池不管,我们看看ThreadPoolExecutor
。它提供了好几个构造方法,但是最底层的构造方法却只有一个。那么,我们就从这个构造方法着手分析。
这个构造方法有7个参数,我们逐一来进行分析。
corePoolSize
,线程池中的核心线程数,也是线程池中常
驻的线程数,线程池初始化时默认是没有线程的,当任务来临时才开始创建线程去执行任务maximumPoolSize
,线程池中的最大线程数,在核心线程数的基础上可能会额外增加一些非核心线程,需要注意的是只有当workQueue
队列填满时才会创建多于corePoolSize
的线程(线程池总线程数不超过maxPoolSize
)keepAliveTime
,非核心线程的空闲时间超过keepAliveTime
就会被自动终止回收掉,注意当corePoolSize=maxPoolSize
时,keepAliveTime
参数也就不起作用了(因为不存在非核心线程)unit
,keepAliveTime
的时间单位,可以是毫秒、秒、分钟、小时和天,等等workQueue
,等待队列,线程池中的线程数超过核心线程数corePoolSize
时,任务将放在等待队列,它是一个BlockingQueue
类型的对象threadFactory
,创建线程的工厂类,默认使用Executors.defaultThreadFactory()
,也可以使用guava
库的ThreadFactoryBuilder
来创建handler
,拒绝策略,当线程池和等待队列(队列已满且线程数达到maximunPoolSize)
都满了之后,需要通过该对象的回调函数进行回调处理
这些参数里面,基本类型的参数都比较简单,我们不做进一步的分析。我们更关心的是workQueue
、threadFactory
和handler
,接下来我们将进一步分析。
1. 等待队列-workQueue
等待队列是BlockingQueue
类型的,理论上只要是它的子类,我们都可以用来作为等待队列。
同时,jdk内部自带一些阻塞队列,我们来看看大概有哪些。
ArrayBlockingQueue
,(有界队列):队列长度受限,当队列满了就需要创建多余的线程来执行任务LinkedBlockingQueue
,队列可以有界,也可以无界。基于链表实现的阻塞队列。当请求越来越多时(任务处理速度跟不上任务提交速度造成请求堆积)可能导致内存占用过多或OOMSynchronousQueue
,不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作将一直处于阻塞状态。该队列也是Executors.newCachedThreadPool()
的默认队列。可以简单理解为队列长度为零PriorityBlockingQueue
,带优先级的无界阻塞队列
通常情况下,我们需要指定阻塞队列的上界(比如1024)。另外,如果执行的任务很多,我们可能需要将任务进行分类,然后将不同分类的任务放到不同的线程池中执行。
2. 线程工厂-threadFactory
ThreadFactory
是一个接口,只有一个方法。既然是线程工厂,那么我们就可以用它生产一个线程对象。来看看这个接口的定义。
Executors
的实现使用了默认的线程工厂-DefaultThreadFactory
。它的实现主要用于创建一个线程,线程的名字为pool-{poolNum}-thread-{threadNum}
很多时候,我们需要自定义线程名字。我们只需要自己实现ThreadFactory
,用于创建特定场景的线程即可。
3. 拒绝策略-handler
所谓拒绝策略,就是当线程池满了、队列也满了的时候,我们对任务采取的措施。或者丢弃、或者执行、或者其他...
jdk自带4种拒绝策略
CallerRunsPolicy
// 在调用者线程执行,即让提交任务的线程去执行任务(对比前三种比较友好一丢丢)AbortPolicy
// 直接抛出RejectedExecutionException
异常DiscardPolicy
// 默默丢弃任务,不进行任何通知DiscardOldestPolicy
// 丢弃队列里最旧的那个任务,再尝试执行当前任务
这四种策略各有优劣,比较常用的是DiscardPolicy
,但是这种策略有一个弊端就是任务执行的轨迹不会被记录下来。所以,我们往往需要实现自定义的拒绝策略, 通过实现RejectedExecutionHandler
接口的方式。
提交任务的几种方式
往线程池中提交任务,主要有两种方法,execute()
和submit()
。
execute()
用于提交不需要返回结果的任务,我们看一个例子。
submit()
用于提交一个需要返回果的任务。该方法返回一个Future
对象,通过调用这个对象的get()
方法,我们就能获得返回结果。get()
方法会一直阻塞,直到返回结果返回。另外,我们也可以使用它的重载方法get(long timeout, TimeUnit unit)
,这个方法也会阻塞,但是在超时时间内仍然没有返回结果时,将抛出异常TimeoutException
。
关闭线程池
在线程池使用完成之后,我们需要对线程池中的资源进行释放操作,这就涉及到关闭功能。我们可以调用线程池对象的shutdown()
和shutdownNow()
方法来关闭线程池。
这两个方法都是关闭操作,又有什么不同呢?
shutdown()
会将线程池状态置为SHUTDOWN
,不再接受新的任务,同时会等待线程池中已有的任务执行完成再结束。shutdownNow()
会将线程池状态置为SHUTDOWN
,对所有线程执行interrupt()
操作,清空队列,并将队列中的任务返回回来。
另外,关闭线程池涉及到两个返回boolean
的方法,isShutdown()
和isTerminated
,分别表示是否关闭和是否终止。
如何正确配置线程池的参数
前面我们讲到了手动创建线程池涉及到的几个参数,那么我们要如何设置这些参数才算是正确的应用呢?实际上,需要根据任务的特性来分析。
- 任务的性质:CPU密集型、IO密集型和混杂型
- 任务的优先级:高中低
- 任务执行的时间:长中短
- 任务的依赖性:是否依赖数据库或者其他系统资源
不同的性质的任务,我们采取的配置将有所不同。在《Java并发编程实践》中有相应的计算公式。
通常来说,如果任务属于CPU密集型,那么我们可以将线程池数量设置成CPU的个数,以减少线程切换带来的开销。如果任务属于IO密集型,我们可以将线程池数量设置得更多一些,比如CPU个数*2。
PS
:我们可以通过Runtime.getRuntime().availableProcessors()
来获取CPU的个数。
线程池监控
如果系统中大量用到了线程池,那么我们有必要对线程池进行监控。利用监控,我们能在问题出现前提前感知到,也可以根据监控信息来定位可能出现的问题。
那么我们可以监控哪些信息?又有哪些方法可用于我们的扩展支持呢?
首先,ThreadPoolExecutor
自带了一些方法。
long getTaskCount()
,获取已经执行或正在执行的任务数long getCompletedTaskCount()
,获取已经执行的任务数int getLargestPoolSize()
,获取线程池曾经创建过的最大线程数,根据这个参数,我们可以知道线程池是否满过int getPoolSize()
,获取线程池线程数int getActiveCount()
,获取活跃线程数(正在执行任务的线程数)
其次,ThreadPoolExecutor
留给我们自行处理的方法有3个,它在ThreadPoolExecutor
中为空实现(也就是什么都不做)。
protected void beforeExecute(Thread t, Runnable r)
// 任务执行前被调用protected void afterExecute(Runnable r, Throwable t)
// 任务执行后被调用protected void terminated()
// 线程池结束后被调用
针对这3个方法,我们写一个例子。
输出结果如下:
一个特殊的问题
任何代码在使用的时候都可能遇到问题,线程池也不例外。楼主在现实的系统中就遇到过很奇葩的问题。我们来看一个例子。
我们循环了5次,理论上应该有5个结果被输出。可是最终的执行结果却很让人很意外--只有4次输出。我们进一步分析发现,当第一次循环,除数为0时,理论上应该抛出异常才对,但是这儿却没有,异常被莫名其妙地吞掉了!
这又是为什么呢?
我们进一步看看submit()
方法,这个方法是一个非阻塞方法,有一个返回对象,返回的是Future
对象。那么我们就猜测,会不会是因为没有对Future
对象做处理导致的。
我们将代码微调一下,重新运行,异常信息终于打印出来了。
PS
:在使用submit()
的时候一定要注意它的返回对象Future
,为了避免任务执行异常被吞掉的问题,我们需要调用Future.get()
方法。另外,使用execute()
将不会出现这种问题。
__EOF__

本文链接:https://www.cnblogs.com/dongye95/p/15755415.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
2019-01-01 正则表达式