当前位置: 首页 > 科技观察

深入理解Java多线程核心知识:跳槽面试必备技能

时间:2023-03-18 02:47:27 科技观察

多线程相对于其他Java知识点有一定的学习门槛,理解起来难度更大。在日常工作中,如果使用不当,会出现数据混乱、执行效率低(不如单线程运行)或死锁程序挂起等问题,因此掌握和理解多线程非常重要。本文从基本概念入手,由浅入深,讲解最新的并发模型,讲解线程的相关知识。概念化在这一节中,我将带您了解多线程中的几个基本概念。并发和并行意味着两个线程同时做事。并发就是一次做一件事,同时做另一件事,有调度。单核CPU不可能实现并行(微观上)。临界区临界区用于表示可被多个线程使用的公共资源或共享数据。但是每次,只有一个线程可以使用它。一旦临界区资源被占用,其他线程要想使用这个资源就必须等待。阻塞和非阻塞阻塞和非阻塞通常用来描述多线程之间的交互。例如,如果一个线程占用了一个临界区资源,那么所有其他需要这个资源的线程都必须在这个临界区等待,而等待会导致该线程挂起。这种情况称为阻塞。这时候,如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界区的线程都无法工作。阻塞意味着线程在操作系统级别被挂起。Blocking一般性能较差,调度需要80000个时钟周期左右。非阻塞允许多个线程同时进入临界区。死锁死锁是进程死锁的缩写,指的是多个进程在循环中等待被别人占用的资源而死锁的情况。Livelock假设有两个线程1和2,都需要资源A/B,假设线程1占用资源A,线程2占用资源B;由于两个线程都需要同时拥有这两个资源,它们才能工作,为了避免死锁,1号线程释放了A的资源占有锁,2号线程释放了B的资源占有锁;此时AB处于空闲状态,两个线程同时抢到锁,又出现了上面的情况。锁。简单打个比方,电梯遇人,一进一出,对面占道,两个人同时向一个方向让路,如此反复来回,还是堵死了路。如果在线申请遇到活锁问题,恭喜你中奖了。此类问题更难排除故障。Hunger饥饿是指一个或多个线程由于各种原因得不到需要的资源,导致无法执行。线程的生命周期在线程的生命周期中,会经历几种状态:创建、可运行和不可运行。创建状态当使用new操作符创建一个新的线程对象时,线程处于创建状态。处于创建状态的线程只是一个空的线程对象,系统不会为其分配资源。可运行态执行线程的start()方法会为该线程分配必要的系统资源,安排其运行,并调用线程体-run()方法,使该线程处于可运行态(Runnable)。这个状态是没有运行,因为线程可能实际上并没有运行。不可运行状态当以下事件发生时,处于运行状态的线程将进入不可运行状态:sleep()方法被调用;线程调用wait()方法等待特定条件的满足;线程输入/输出被阻塞;返回为可运行状态;处于睡眠状态的线程经过指定时间后;如果线程正在等待某个条件,则另一个对象必须通过notify()或notifyAll()方法将条件变化通知等待线程;如果线程因为输入输出被阻塞,等待输入输出完成。线程优先级线程优先级和设置线程优先级是为了方便系统在多线程环境下对线程进行调度,优先级高的线程会先执行。线程的优先级设置遵循以下原则:线程创建时,子线程继承父线程的优先级;线程创建后,可以通过调用setPriority()方法改变优先级;线程的优先级是1-10之间的正整数。线程调度策略线程调度器选择具有最高优先级的线程运行。但是,如果出现以下情况,线程的运行将被终止:在线程体内调用yield()方法,放弃占用CPU的权利;在线程体中调用sleep()方法,使线程进入休眠状态;由于I/O操作而阻塞的线程;另一个更高优先级的线程出现;在支持时间片的系统上,该线程的时间片用完了。单线程创建方式单线程创建方式比较简单,一般只有两种方式:继承Thread类,实现Runnable接口;这两个方法比较常用,Demo中没有,但是对于新手来说,需要注意的问题是:是否继承Thread类还是实现Runable接口,业务逻辑写在run方法,线程启动时执行start()方法;开启新线程不影响主线程的代码执行顺序,不会阻塞主线程的执行;新线程无法保证与主线程的代码执行顺序;对于多线程程序,从微观上看,一次只有一个线程在工作,多线程的目的是让CPU保持忙碌;查看Thread的源码可以看到,Thread类实现了Runnable接口,所以两者本质上是一个;PS:工作中也可以借鉴这种代码结构,为上层调用提供更多的选择,作为服务商的核心业务为什么要使用线程池进行常态化维护通过上面的介绍,可以开发多线程程序,何必引入线程池。主要原因是上述单线程方式存在以下问题:线程工作周期:线程创建所需时间为T1,线程执行所需时间为T2,线程销毁所需时间为T3,往往是T1+T3大于T2,所以频繁创建线程会额外消耗太多时间;如果有任务过来,再创建线程效率会很低。如果可以直接从池中获取可用线程,效率会提高。因此,线程池省去了执行任务前创建线程的过程,节省了时间,提高了效率;线程池可以管理和控制线程,因为线程是稀缺资源。会消耗系统资源,降低系统的稳定性。线程池可用于统一分配、调优和监控;线程池提供了一个队列来存储和缓冲等待执行的任务。以上原因大致总结一下,所以可以得出结论,在日常工作中,如果要开发多线程程序,尽量使用线程池来创建和管理线程。从调用API的角度来看,通过线程池创建的线程有两种。一种是原生的线程池,另一种是通过Java提供的并发包创建的。后者比较简单,后者其实就是一个原生的线程池。线程池的创建方法进行了简化和封装,方便调用者使用,但道理是一样的。所以理解原生线程池的原理是非常重要的。ThreadPoolExecutor通过ThreadPoolExecutor创建线程池。API如下:publicThreadPoolExecutor(intcorePoolSize,intmaximumPoolSize,longkeepAliveTime,TimeUnitunit,BlockingQueueworkQueue);我有印象,下面这张图是重点)。corePoolSize核心池的大小。线程池创建后,默认情况下,线程池中没有线程,而是等待任务到来,然后再创建线程执行任务,除非调用了prestartAllCoreThreads()或prestartCoreThread()方法,从这些两种方法看名字就知道是pre-creatingthreads的意思,也就是在没有任务到来之前创建corePoolSize个线程或者一个线程。默认情况下,创建线程池后,线程池中的线程数为0。当有任务到来时,会创建一个线程来执行该任务。当线程池中的线程数达到corePoolSize时,将到达的任务放入缓存队列中。maximumPoolSize线程池最大线程数,这个参数也是一个很重要的参数,它表示线程池最多可以创建多少个线程。keepAliveTime表示当没有任务执行时线程将被终止的时间。默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize,即当线程池中的线程数大于corePoolSize时大于corePoolSize,如果一个线程空闲时间达到keepAliveTime,就会被终止,直到线程池中的线程数不超过corePoolSize。但是如果调用了allowCoreThreadTimeOut(boolean)方法,当线程池线程数不大于corePoolSize时,keepAliveTime参数也会生效,直到线程池线程数为0。单元参数keepAliveTime。workQueue是一个阻塞队列,用于存放等待执行的任务。这个参数的选择也很重要,会对线程池的运行过程产生重大影响。一般来说,这里的阻塞队列有以下几种选择:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。threadFactory线程工厂,主要用来创建线程。handler表示任务被拒绝时的策略,有以下四个值:ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException;ThreadPoolExecutor.DiscardPolicy:同样丢弃任务,但不抛出异常;ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列中之前的任务,然后重新尝试执行该任务(重复该过程);ThreadPoolExecutor.CallerRunsPolicy:任务由调用线程处理。上述参数如何协同工作?请看下图:注意图中的序号。线程池之间的参数配合简单总结分为以下几个步骤:线程先提交到CorePool;CorePool满后,将线程提交到任务队列,等待线程池空闲;任务队列满后,corePool还没有空闲,就会把任务提交给maxPool,如果MaxPool满了,就会执行任务拒绝策略。流程图如下:以上就是创建原生线程池的核心原理。除了原生的线程池,并发包还提供了简单的创建方法。上面说了,它们是对原生线程池的一种封装,可以让开发者快速方便的创建自己需要的线程池。ExecutorsnewSingleThreadExecutor创建一个线程池,其中始终只存在一个线程。如果线程池中的一个线程因为异常退出,一个新的线程将取代它。这个线程池保证所有任务的执行顺序按照任务提交的顺序执行。newFixedThreadPool创建一个固定大小的线程池。每次提交任务时都会创建一个线程,直到线程达到线程池的最大大小。一旦线程池的大小达到最大值,它将保持不变。如果一个线程因为异常执行而结束,线程池会添加一个新的线程。newCachedThreadPool可以根据实际情况调整线程池的线程数。线程池中的线程数是不确定的。如果有空闲线程,则优先选择空闲线程。如果没有空闲线程,此时有任务提交,就会创建新的线程。一般开发不推荐使用该线程池,因为在极端情况下会因为newCachedThreadPool创建的线程过多而耗尽CPU和内存资源。newScheduledThreadPool这个线程池可以指定固定数量的线程周期性的执行。例如,通过scheduleAtFixedRate或scheduleWithFixedDelay指定周期时间。PS:另外,在写定时任务的时候(如果没有使用Quartz框架),最好使用这个线程池,因为这样可以保证里面一直有活动的线程。推荐使用ThreadPoolExecutor方法。在阿里的Java开发手册中,有一篇文章不推荐使用Executors来创建,而是推荐使用ThreadPoolExecutor来创建线程池。这样做的主要原因是:使用Executors创建线程池不会传入核心参数,而是默认值。在这种情况下,我们往往会忽略里面参数的含义。如果业务场景要求比较苛刻,就会有资源消耗。另外,ThreadPoolExecutor的使用,可以让我们更清晰的了解线程池的运行规则,无论是对面试还是技术成长都有很大的帮助。当变量改变时,其他线程可以立即知道。有几种方法可以确保可见性:volatile具有volatile关键字的变量在汇编期间将有一个额外的锁前缀指令。这个前缀指令相当于一个内存屏障,内存屏障可以保证内存操作的顺序。当一个声明为volatile的变量被写入时,该变量需要向主存写入数据。由于处理器会实现缓存一致性协议,写入主存会使其他处理器的缓存失效,即线程工作内存失效,需要从主存中刷新数据。