Java基础之多线程

本文详细介绍了Java中多线程的创建方式,包括继承Thread类、实现Runnable接口、使用Callable接口以及线程池的使用。同时,文章讨论了线程池的工作原理,常见线程池类型以及线程的生命周期和状态转换。此外,还涵盖了线程的基本方法如start、yield、wait、sleep的区别,以及Java锁机制和volatile关键字的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

相对于传统的单线程编程,多线程可以在操作系统多核配置的基础上,更好地利用服务器的多个CPU资源,从而使程序更加高效地运行,比如本来一个任务一个线程执行需要100毫秒,现在10个线程执行就只需要10毫秒。下面总结下Java中多线程的知识点:
1、线程的创建方式
Java中多线程的意义在哪?如果直接调用run方法则当前线程一定是等待该方法结束才能调用下面的代码,但是现在是start方法,则此线程等待cpu调用,当此线程获取CPU资源就会变成当前线程,然后就会调用run方法,但其他线程可以不用等这个线程的run方法结束就可以调用下面的方法,显然这些效率大大提高。多线程的原理总结成一句话就是宏观上让线程一起执行靠的是分时利用cpu!(cpu相当于我们人,可以去开启烧水壶的开关后不用等水烧完就可以去开启其他事情的开关。)
四种方式:
(1)继承Thread类
这种方法比较简单,就是先创建一个继承Thread类的NewThread类,然后再new这个类的对象即线程,这个时候线程是新建状态,接着调用start方法来启动这个线程,这个时候线程是就绪状态(除了调用start方法会让线程处于等待状态,还可能是等待或者睡眠好了。),需要注意的是start方法是native方法,存在JVM的本地方法栈,不是用java写的,是操作系统层面的方法,表示在操作系统中开启一个线程,如果要运行这个线程,只需要调用run方法就会执行里面的业务逻辑(注意这个run方法不需要我们手动调用,而是cpu时间片轮到该线程,也就是获取到cpu资源后会自动调用run方法,如果自己手动调用,那就还是当前主线程中使用run这个普通方法,没有达到多线程的目的)。
(2)实现Runnable接口
有了继承Thread类这种方法,为什么还要实现Runnable接口这种方法?
因为java是单继承的,所以如果用继承Thread类来创建线程会出现一个问题:不能继承其他类,比较局限,此时可以采用实现Runnable接口创建线程,需要注意的是其实Thread也是Runnable接口的实现类,但Runnable接口没有start方法。
具体如何实现?
写个实现Runnable接口的NewThread类,再重写run方法,再new一个NewThread类的对象,然后start即可?不可以,因为Runnable接口没有start方法,只能借助Thread类的start方法,所以把这个对象作为Thread类的有参构造创建Thread类的对象(当然也可以像我一开始自己创建实现类,可以直接匿名内部类,省去重新创建一个Runnable实现类),再调用该对象的start方法,因为Thread的底层源码里run方法有个判断,如果目标方法不为空,则调用目标对象的run方法,即这个线程得到了cpu资源就会调用之前Runnable接口中重写的run方法。
(3)实现Callable接口
有了实现Runnable接口这种方法,为什么还要用Callable接口这种方法?
有个场景需要用到Callable接口:需要在一个主线程中开启多个线程并发执行一个任务,然后收集各个线程执行返回的结果并把最终结果汇总起来。
具体如何实现?
A、写一个类实现Callable接口,并重写call方法,在该方法里写计算逻辑并把计算结果返回;
B、创建一个线程池、一个用于接收返回结果的Future List和Callable线程实例;
C、使用线程池提交任务并把线程执行后的结果保存到Future中;
D、在线程执行结束后遍历Future List中的Future对象,然后在该对象上调用get方法就可以获取Callable线程任务返回的数据并汇总结果。
注:A、Runnable接口和Callable接口的区别?
首先是比较是否有返回值?Runnable接口中run方法没有返回值,而Callable中call方法通过泛型规定返回值类型;
其次是比较启动方式不同:二者都不能直接用start方法,因为没有,所以要么靠Thread类的start方法启动,要么靠线程池调用execute或者submit启动,Runnable接口是靠这两种都可以用启动;Callable接口是只能靠线程池的submit启动。
最后比较是否能抛出异常:Runnable接口不能抛出异常,所以不能使用全局容错机制;而Callable接口可以抛出异常,所以可以使用全局容错机制,比如Spring的异常通知。
B、Callable和Future是什么?
Callable和Future都是Executor框架下的接口,也都属于java.util.concurrent包,它们两个是一对组合,Callable用于产生结果,Future用于接收结果,当我们需要获取多线程执行结果的时候就要用到它们,Callable用call()方法执行任务,而且使用泛型定义它的返回值类型,而Future里的get方法获取Callable的执行结果。
C、FutureTask是什么?
FutureTask也可以获取Callable接口任务执行结果,但这个是异步获取,所谓异步是指如果线程执行任务要很久,主线程可以先执行自己的任务再去获取结果。
(4)使用线程池
因为线程是非常宝贵的计算资源,每次使用都创建一次并且在运行结束后都进行销毁很浪费资源,类比数据库的连接池,所以可以使用线程池创建对象实现线程的复用,线程复用的基本原理就是调用Thread类的start方法,该线程获取CPU资源后JVM就会调用其中的run方法,而如果是有参构造创建的Thread对象,那run方法其实调用的是Runnable对象的run方法,这个Runnable对象可以通过new Runnable这种匿名内部类形式实现;如果循环使用不同Runnable对象来有参构造创建Thread对象,那就不断调用Runnable对象中的run方法,如果把要执行的这些Runnable对象放到队列中,就可以控制正在执行的线程个数并使线程有序执行。

2、线程池的工作原理
上面提到了线程池可以创建对象,那它具体是怎么创建的?
需要注意的是线程池刚被创建的时候,是没有线程的,只有过来一次请求即调用execute方法后才会创建一个核心线程去处理这个请求,和以前线程处理完就会销毁不同的是这里不会销毁,而是等待下一个请求,如果这个核心线程在执行的过程中来了其他请求怎么办?线程池会创建新的核心线程去执行,那如果来了n个请求就创建n个核心线程吗?不是的,如果创建的核心线程数达到指定的数量,那就会把请求放到工作队列中,如果请求太多,工作队列也满了怎么办?那就把请求交给临时线程处理,临时线程处理完这些请求后不会立马被销毁,等待一定时间内新来的请求,如果没有请求那就会被销毁,需要注意的是工作队列里的请求是给核心线程用的,所以哪怕核心线程很忙而临时线程很空,但还是不会让临时线程去执行这些请求,如果请求太多让临时线程也都很忙怎么办?(临时线程数=最大线程数-核心线程数)那只能拒绝。JDK内置的拒绝策略有四种,分别是直接丢弃、抛异常、调用者在调用者的线程执行和丢弃阻塞队列中等待时间最长的一个线程,当然也可以自定义拒绝策略。
通过上面线程池的创建流程可知,线程池由4个核心组件构成:创建并管理线程池的线程池管理器、线程池中执行具体任务的工作线程、定义工作线程调度和执行策略的任务接口(工作线程只有实现这个接口才能被线程池调度)、存放待执行任务的任务队列。
注:
A、如果你提交任务时,线程池队列已满,这时会发生什么?

这个要按线程池队列是有界队列还是无界队列这两种情况分析:
有界队列以ArrayBlockingQueue为例,如果ArrayBlockingQueue满了则会采用拒绝策略处理满了的任务;无界队列以LinkedBlockingQueue为例,因为LinkedBlockingQueue近乎于无穷大的队列,所以可以继续存放任务。
B、那阻塞队列是什么?
JDK7中提供了7种阻塞队列,比如数组结构或者链表结构组成的有界阻塞队列、链表结构组成的无界或者双向阻塞队列等。阻塞队列和一般的队列区别在于多了两个附加操作:
A、支持阻塞的插入方法:当队列满的时候,队列会阻塞插入元素的线程,直到队列不满;
B、支持阻塞的移除方法:当队列空的时候,获取元素的队列会等待队列变成非空。

3、说下5种常用的线程池
Java中的线程池是通过Executor框架实现的,这个框架里用到了Callable、Future、Executor、ExecutorService这几个接口(都在java.util.concurrent包下,想搞懂,看源码,可以参考下面的扩展内容),其中ExecuterService继承Executor,还有ThreadPoolExecutor、Executors这两个类,ThreadPoolExecutor类继承AbstractExecutorService类,而AbstractExecutorService是ExecutorService的实现类,Executors类虽然和它们没有继承或者实现关系,但该类里的所有静态方法返回值类型是ExecutorService;方法体里return的是ThreadPoolExecutor对象,ThreadPoolExecutor类用于创建线程池,主要用到的是上面提到的4个组件;静态方法名就是下面5种常用的线程池名称:
1)newCachedThreadPool:可缓存的线程池(重用线程),在创建新线程的时候判断是否有可重用的线程,如果有则重新使用,如果没有则创建;所以这个场景适用于任务执行时间短线程,从而达到高效复用。
2)newFixedThreadPool:固定大小的线程池(上面提到的线程池工作原理主要是这个),该线程池用于控制线程最大并发数,
3)newScheduledThreadPool:可做任务调度的线程池(定时)
4)newSingleThreadExecutor:单个线程的线程池(单例)
5)newWorkStealingPool:足够大小的线程池(JDK1.8新增)
自己一直有个问题:既然ThreadPoolExecutor类就可以创建线程池,为什么还要特意弄个Executors类?这就是工厂模式的好处,因为单独用ThreadPoolExecutor创建各种线程需要传入很多参数比较麻烦,所以用Executors这个工厂来生产线程池直接用比较方便。
注:
A、高并发、任务执行时间短的业务怎么使用线程池?

线程池线程数可以设置为CPU核数+1,并减少多线程上下文切换;
B、高并发、任务执行时间长的业务怎么使用线程池?
解决这种类型的业务关键不是线程池,而是整体架构的设计,首先是考虑这些业务里的某些数据是否可以做缓存,其次是考虑是否能增加服务器,最后考虑能否使用中间件对任务进行拆分和解耦;
C、并发不高、任务执行时间长的业务怎么使用线程池?
这个要看任务执行时间长是因为集中执行IO操作还是计算操作,如果是集中执行IO操作,因为IO操作不占用CPU,所以不需要让CPU闲下来,可以加大线程池中线程的数量,让CPU执行更多任务;如果是集中执行计算操作,那就把线程池中线程的数量弄的少一点,一般是CPU核数+1即可,并减少多线程上下文切换。
D、上面提到减少多线程上下文切换,那什么是多线程上下文切换?
首先明确多线程指的是从软件或者硬件上实现多个线程的并发技术;
多线程的好处是让图片、视频下载这种执行时间比较长的任务放到后台执行,从而提升用户体验。
多线程的坏处是需要更多的内存空间,即空间换时间;代码中有大量的线程会降低代码的可读性;多线程会因为争抢同一个资源导致线程安全问题。
多线程上下文切换是指线程把当前任务的状态保存然后执行下一个任务,为什么会有线程的上下文切换?因为CPU利用时间片轮询来为每个任务都服务一定时间;当然不止这个原因,还可能是因为在高并发情况下多个任务争抢锁资源,但当前任务没有抢到所以被CPU挂起执行下一个任务。

4、线程的生命周期
线程有五个状态:新建、就绪、运行、阻塞和死亡。
正确情况下应该是新建到就绪到运行再到死亡。
新建状态比较简单,new一个线程,该线程就是新建状态;
就绪状态也比较简单,调用Thread的start方法,则该线程就是就绪状态;
死亡状态比上面两种状态复杂点,除了运行完run方法或者call方法后线程就会被正常销毁(线程池的核心线程除外),还有两种情况,一种是调用线程对象的stop方法,这种方式很危险,类比电脑突然断电肯定会有危害;另一种是线程调用Interrupt方法,然后出现InterruptException这个异常并捕获后break当前方法就可以提前结束run方法从而使线程被销毁。
运行状态比较复杂,因为线程在运行的过程中可能会出现意外:
第一种意外:线程从就绪到运行靠的是得到CPU的资源,那如果运行过程中失去了CPU资源就会重回就绪状态,还有一种方法会重回就绪状态:线程调用yield方法,这个方法是让当前线程让出CPU使用权。
第二种意外:从运行状态变成阻塞状态,阻塞的方法有很多,主要分为等待阻塞、同步阻塞和其他阻塞这三类,等待阻塞是指正在运行的线程调用Object的wait方法,此时线程放弃当前锁对象,处于等待阻塞状态;同步阻塞是指正在运行的线程尝试获取正在被其他线程占用的对象同步锁时(比如想要运行Synchronized修饰的代码块),JVM会把该线程放到锁池(Local Pool)中,此时线程处于同步阻塞状态;其他阻塞是指正在运行的线程执行Thread类的sleep方法或者join方法或者发出I/O请求(具体点就是调用socket的receiver、accept等方法),此时该线程也会变成阻塞状态。
那阻塞状态的线程还能变回运行状态吗?不行,只能变成就绪状态然后等待机会得到CPU使用权,那什么时候会阻塞结束?那要看具体是哪种阻塞?如果是等待阻塞,只有等Object的notify或者notifyAll方法才能被唤醒然后进入锁池,拿到锁标识后才会回到就绪状态;如果是同步阻塞,和等待阻塞一样需要拿到锁标识后才能回到就绪状态(和等待阻塞相比少一个notify和notifyAll);如果是其他阻塞,那就要看具体哪种情况,具体问题具体分析。
线程生命周期图如下所示:
在这里插入图片描述
5、线程的基本方法
为什么要聊下线程的基本方法?因为这些方法会影响线程状态的变化,比如start方法会让新建状态的线程状态变成就绪状态;yield方法会让线程让步,即让出cpu执行权,从而变回就绪状态;wait、sleep方法会导致线程阻塞;而interrupt、stop方法会导致线程死亡。
由于篇幅关系,这里不一一介绍这些方法,而是说下两个注意点:
(1)start方法和run方法的区别
线程调用start方法会进入就绪状态,此时需要等到cpu调度才能执行里面的run方法,因为是在后台执行的,所以不用等run方法执行完毕就可以执行下面的代码,但如果直接调用run方法,那下面的代码必须等待run方法执行完才能执行,所以只有调用线程的start方法才算真正实现多线程运行。
(2)wait方法和sleep方法的区别
虽然这两种方法都会导致正在运行的线程进入阻塞状态,但二者还是有很大的区别:
首先这两种方法所属的类不同:wait方法属于顶级父类Object类,所以所有对象都可以调用这个方法;sleep方法属于Thread类;
其次是调用方法的过程中有无释放对象锁:有无释放锁为什么重要?因为这决定线程是从阻塞到运行还是到就绪。wait方法在执行过程中,线程会释放CPU执行权和对象锁,然后进入等待此对象的等待锁池,只有针对该对象调用Object的notify方法才会让线程进入就绪状态;而sleep方法在执行过程中,线程只会释放cpu执行权,但不会释放对象锁,等睡眠时间到了就会自动恢复运行状态。

6、Java中的锁
Java中为什么用锁?因为多线程编程会出现数据不一致情况,所以加锁来保证数据一致性。
那在什么地方加锁?线程一般操作的是对象或者方法,所以在这两个前面加锁从而保证每次该对象或者该方法只能有一个线程使用,如果其他线程想用必须等待即变成阻塞状态。
锁有哪几种?锁按不同的分类方法可以分为不同的锁:
如果按照乐观和悲观角度分,可以分为乐观锁和悲观锁;
如果按获取资源的公平性角度分,可以分为公平锁和非公平锁;
如果按是否共享资源的角度分,可以分为共享锁和独占锁;
如果按锁的状态分,可以分为偏向锁、轻量级锁和重量级锁。
还有一种锁是JVM为了更快地使用CPU资源设计的,名字是自旋锁,适用的场景是持有锁的线程会在很短时间内释放锁资源,从而让等待竞争该锁的线程不需要进行内核态和用户态之间转换,即不需要进入阻塞状态,而是等一等就好,从而减少CPU的上下文切换,但缺点是如果持有锁的线程没有很短时间内释放锁或者锁竞争很激烈,那会引起CPU的浪费,所以如果系统需要依赖复杂锁的时候不能用自旋锁。
(1)乐观锁和悲观锁
乐观锁顾名思义就是线程在使用对象或者方法里的数据时比较乐观,操作数据分为读和写,读的时候以为没有其他线程修改数据,所以不会加锁(类似不关门);但写的时候会判断别人有没有修改过该数据,怎么判断?靠CAS(compare And Swap,比较和交换)实现,具体是指线程在准备修改某个值的时候,会先去查询原值,比较是否发生了变化,如果没有则交换,如果有变化则不执行写操作,并返回失败状态;但CAS只是比较原值是否发生变化,那就会有一个ABA问题,比如线程1和线程2同时从内存中取出V位置的值A,线程1把这个值进行比较,此时线程2把值从A改成B,然后线程3把B改成A,则线程1没发觉A期间发生了变化,在某些场合可能会出现数据不一致现象,怎么办?多比较一个东西:版本号或者时间戳。CAS有个点需要注意:java.util.concurrent这个并发包是建立在CAS之上的,所以相对于synchronized阻塞算法,并发包在性能上有很大提升,这也是并发情况下HashMap采用ConcurrentHashMap而不用HashTable的原因。但还需要注意的是CAS只适用于线程冲突较少的情况使用,不然程序性能会大幅度降低。
悲观的思想和乐观是相反的,所以悲观锁和乐观锁也是的相反,从上面对于乐观锁的描述可以知道乐观锁实际上并没有加锁,那悲观锁肯定要加锁;问题是悲观锁靠什么实现?大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器,对应的类是AbstractQueuedSynchronizer)架构来实现,AQS是一个用于构建锁和同步容器的框架,上面提到的并发包里的许多类都是基于AQS构建,比如FutureTask、ReentrantLock等,当然我们常用的synchronized也是基于AQS构建(这个是关键字,不需要任何包)。
上面提到同步容器,那什么是同步容器?
主要代表是List接口实现类Vector和Map接口实现类HashTable,以及解决集合线程安全的工具类Collections.synchronizedXxx方法等,其实就是内部采用synchronized关键字锁住当前对象的容器就是同步容器,从读写层面理解就是读和写使用同一个锁,需要等待。
那和同步容器对应的并发容器是什么?
从读写层面理解就是和同步容器相反,即读和写可以采用不同锁,主要代表是采用分段锁技术实现的ConcurrentHashMap,ConcurrentHashMap把HashMap底层的数组分成若干段,每段都有一个锁,从而达到高效的并发访问效果。
(2)synchronized 关键字
通过悲观锁引申出synchronized,那这个是干嘛的?
首先从乐观和悲观角度看,synchronized肯定是悲观锁;
从是否共享资源来看,synchronized属于独占锁;
从锁的状态来看,synchronized属于重量级锁。
可以修饰在哪?
上面提到锁用于在对象或者方法前面,但对象也分在哪里,而方法也分静态方法和普通方法,所以细分为以下三个部分:
A、synchronized修饰成员变量和普通方法时
锁住的是对象的实例,即this对象,所有如果多个线程都调用这个成员变量或者普通方法需要一个个来。
B、synchronized修饰静态方法时
锁住的是class实例,因为静态方法属于class即类级别,所以虽然多个线程调用同一个类里不同的静态方法,但还是要一个个来(类比虽然你们住一个套间里不同房间,但大门锁住还是要一个个进)。
C、synchronized修饰一个代码块时
锁住的是所有代码块中配置的对象,一般会一开始就定义这个对象,比如定义一个String对象:String lockA=”lockA”,然后在多个代码块前面写上synchronized (lockA)锁住代码块,因为大家都要得到lockA这个对象才能执行代码块里的业务逻辑,所以需要一个个来。
需要注意的是如果我们定义两个String对象lockA和lockB,然后在synchronized(lockA)代码块里执行synchronized(lockB)代码块,同时在synchronized(lockB)代码块里执行synchronized(lockA)代码块,那就会相互握着各自需要的资源导致死锁。
synchronized是元老级锁,是重量级锁,JDK1.6对synchronized做了很多优化,比如为了减少锁的获取和释放带来的性能消耗而引入偏向锁和轻量级锁,所以synchronized获取锁的方法从原来直接重量级锁变成先偏向锁获取,如果获取失败再升级为轻量级锁,轻量级锁也获取失败则先短暂自旋防止被挂起然后升级为重量级锁获取。注意锁只能升级,不能降级。
注:synchronized的底层实现原理有空再总结,这里简单说下synchronized代码块的原理:
synchronized代码块是由一对monitorenter/monitorexit指令实现的Monitor对象是同步的基本实现单元,可以理解为在指令中插入了监视器来监视对象。
(3)ReentrantLock类(java.util.concurrent.locks包下)
ReentrantLock继承了Lock接口,可以实现和synchronized一样的功能,但功能比synchronized更强大,主要体现在一些细节控制上,比如实现了公平锁和非公平锁,还有为避免多线程死锁提供了定时锁等方法,这里讲下ReentrantLock如何实现?ReentrantLock类里有个内部类Sync,这个类继承AQS,即AbstractQueuedSynchronizer类,而且公平锁类和非公平锁类继承这个类,需要注意的是ReentrantLock默认非公平锁,如果想要切换成公平锁需要调用公平锁类的有参构造实现。
注:同样是锁,ReentrantLock和synchronized区别?
首先从二者本质上分析:ReentrantLock是类,属于API级别;而synchronized是关键字,属于JVM级别。
其次从二者功能上分析:ReentrantLock的功能比synchronized强大,提供了一些避免死锁的方法和公平锁,而且可以分别定义读写锁从而提高多个线程读操作的效率。
最后从二者使用上分析:ReentrantLock是显式获取和释放锁,而synchronized是隐式获取和释放锁,显式可以理解为手动,所以ReentrantLock需要手动释放锁,那如果程序出现异常不能手动释放锁怎么办?在finally块定义解锁操作。
(4)volatile 关键字
多线程的通信是通过共享内存实现的,想要内存共享需要解决三个问题:可见性、有序性和原子性,前面两个问题是由Java内存模型即JVM解决,而原子性是由锁解决。
而JVM级别的关键字中有个volatile就是解决线程可见性的问题,即所有线程读到它修饰的变量,则这个变量的值都是最新的;它还有一个作用是禁止指令重排。
注:上面提到多线程通信,那多线程之间如何共享数据?
简单来说就是多线程之间通过共享对象从而共享数据,具体来说有两种方法:
A、把数据抽象成一个类,然后把对这个数据的操作封装到类的方法中;
B、把Runnable对象作为一个类的内部类,并将共享数据作为这个类的成员变量。

扩展:
(1)Executor接口的源码
public interface Executor {
void execute(Runnable command);
}
里面就execute()方法,并且方法参数是Runnable对象,Runnable接口里就一个run方法。
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
其中@FunctionalInterface是用于定义函数式接口,说明java.lang.Runnable是函数式接口,函数式接口的特点是只有一个抽象方法(可以有其他方法)。
(2)ExecutorService接口继承Executor接口
public interface ExecutorService extends Executor {
很多方法;
主要是Submit()和invokeAll()方法,但有不同的重载方式,其中方法参数类型分别是Runnable接口和Callable接口。
}
(3)AbstractExecutorService类实现ExecutorService接口
public abstract class AbstractExecutorService implements ExecutorService {
重写上面的所有submit()和invokeAll方法。
}
(4)ThreadPoolExecutor类继承AbstractExecutorService类
public class ThreadPoolExecutor extends AbstractExecutorService {
创建线程池四大组件:线程池管理器、工作线程、任务接口和任务队列
}
(5)Executors类和前面的类或者接口没有任何继承、实现关系,完全单独存在,但里面静态方法和它们有关,其中方法返回值类型是ExecutorService,方法里return的是ThreadPoolExecutor对象。
public class Executors {
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),
threadFactory);
}
还有其他静态方法,方法名都是各个线程池名称(包括重载方法)。
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值