深入理解Java多线程核心知识
多线程相对于其他 Java 知识点来讲,有⼀定的学习门槛,并且了解起来⽐较费劲。在平时⼯作中如若使⽤不当会出现数据错乱、执⾏效率低(还不如单线程去运⾏)或者死锁程序挂掉等等问题,所以掌握了解多线程⾄关重要。本⽂从基础概念开始到最后的并发模型由浅⼊深,讲解下线程⽅⾯的知识。
概念梳理
本节我将带⼤家了解多线程中⼏⼤基础概念。
并发与并⾏
并⾏,表⽰两个线程同时做事情。
并发,表⽰⼀会做这个事情,⼀会做另⼀个事情,存在着调度。单核 CPU 不可能存在并⾏(微观上)。
临界区
临界区⽤来表⽰⼀种公共资源或者说是共享数据,可以被多个线程使⽤。但是每⼀次,只能有⼀个线程使⽤它,⼀旦临界区资源被占⽤,其他线程要想使⽤这个资源,就必须等待。
阻塞与⾮阻塞
阻塞和⾮阻塞通常⽤来形容多线程间的相互影响。⽐如⼀个线程占⽤了临界区资源,那么其它所有需要这个资源的线程就必须在这个临界区中进⾏等待,等待会导致线程挂起。这种情况就是阻塞。
此时,如果占⽤资源的线程⼀直不愿意释放资源,那么其它所有阻塞在这个临界区上的线程都不能⼯作。阻塞是指线程在操作系统层⾯被挂起。阻塞⼀般性能不好,需⼤约8万个时钟周期来做调度。⾮阻塞则允许多个线程同时进⼊临界区。
死锁
死锁是进程死锁的简称,是指多个进程循环等待他⽅占有的资源⽽⽆限的僵持下去的局⾯。
活锁
假设有两个线程1、2,它们都需要资源 A/B,假设1号线程占有了 A 资源,2号线程占有了 B 资源;由于两个线程都需要同时拥有这两个资源才可以⼯作,为了避免死锁,1号线程释放了 A 资源占有锁,2号线程释放了 B 资源占有锁;此时 AB 空闲,两个线程⼜同时抢锁,再次出现上述情况,此时发⽣了活锁。
简单类⽐,电梯遇到⼈,⼀个进的⼀个出的,对⾯占路,两个⼈同时往⼀个⽅向让路,来回重复,还是堵着路。如果线上应⽤遇到了活锁问题,恭喜你中奖了,这类问题⽐较难排查。
饥饿
饥饿是指某⼀个或者多个线程因为种种原因⽆法获得所需要的资源,导致⼀直⽆法执⾏。
线程的⽣命周期
在线程的⽣命周期中,它要经历创建、可运⾏、不可运⾏⼏种状态。
创建状态
当⽤ new 操作符创建⼀个新的线程对象时,该线程处于创建状态。处于创建状态的线程只是⼀个空的线程对象,系统不为它分配资源。
可运⾏状态
执⾏线程的 start() ⽅法将为线程分配必须的系统资源,安排其运⾏,并调⽤线程体——run()⽅法,这样就使得该线程处于可运⾏状态(Runnable)。这⼀状态并不是运⾏中状态(Running),因为线程也许实际上并未真正运⾏。
不可运⾏状态
当发⽣下列事件时,处于运⾏状态的线程会转⼊到不可运⾏状态:
调⽤了 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 如下所⽰:
/**
* public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime, * TimeUnit unit,BlockingQueue * keepAliveTime和TimeUnit指定线程空闲后的最⼤存活时间 * workQueue则是线程池的缓冲队列,还未执⾏的线程会在队列中等待 * 监控队列长度,确保队列有界 * 不当的线程池⼤⼩会使得处理速度变慢,稳定性下降,并且导致内存泄露。如果配置的线程过少,则队列会持续变⼤,消耗过多内存。 * ⽽过多的线程⼜会 由于频繁的上下⽂切换导致整个系统的速度变缓——殊途⽽同归。队列的长度⾄关重要,它必须得是有界的,这样如果线程池不堪重负了它可以暂时拒绝掉新的请求。 * ExecutorService 默认的实现是⼀个⽆界的 LinkedBlockingQueue。 */ private ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, corePoolSize+1, 10l, TimeUnit.SECONDS, new LinkedBlockingQueue 先来解释下其中的参数含义(如果看的⽐较模糊可以⼤致有个印象,后⾯的图是关键)。 corePoolSize核⼼池的⼤⼩。 在创建了线程池后,默认情况下,线程池中并没有任何线程,⽽是等待有任务到来才创建线程去执⾏任务,除⾮调⽤了 prestartAllCoreThreads() 或者 prestartCoreThread() ⽅法,从这两个⽅法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建 corePoolSize 个线程或者⼀个线程。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建⼀个线程去执⾏任务,当线程池中的线程数⽬达到 corePoolSize后,就会把到达的任务放到缓存队列当中。 maximumPoolSize 线程池最⼤线程数,这个参数也是⼀个⾮常重要的参数,它表⽰在线程池中最多能创建多少个线程。 keepAliveTime 表⽰线程没有任务执⾏时最多保持多久时间会终⽌。默认情况下,只有当线程池中的线程数⼤于 corePoolSize 时,keepAliveTime 才会起作⽤,直到线程池中的线程数不⼤于 corePoolSize,即当线程池中的线程数⼤于 corePoolSize 时,如果⼀个线程空闲的时间达到 keepAliveTime,则会终⽌,直到线程池中的线程数不超过 corePoolSize。 但是如果调⽤了 allowCoreThreadTimeOut(boolean) ⽅法,在线程池中的线程数不⼤于 corePoolSize 时,keepAliveTime 参数也会起作⽤,直到线程池中的线程数为0。 unit 参数 keepAliveTime 的时间单位。 workQueue ⼀个阻塞队列,⽤来存储等待执⾏的任务,这个参数的选择也很重要,会对线程池的运⾏过程产⽣重⼤影响,⼀般来说,这⾥的阻塞队列有以下这⼏种选择:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。 threadFactory 线程⼯⼚,主要⽤来创建线程。 handler 表⽰当拒绝处理任务时的策略,有以下四种取值: 1. ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常;2. ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常; 3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前⾯的任务,然后重新尝试执⾏任务(重复此过程);4. ThreadPoolExecutor.CallerRunsPolicy:由调⽤线程处理该任务。上⾯这些参数是如何配合⼯作的呢?请看下图:注意图上⾯的序号。 简单总结下线程池之间的参数协作分为以下⼏步: 1. 线程优先向 CorePool 中提交; 2. 在 Corepool 满了之后,线程被提交到任务队列,等待线程池空闲; 3. 在任务队列满了之后 corePool 还没有空闲,那么任务将被提交到 maxPool 中,如果 MaxPool 满了之后执⾏ task 拒绝策略。流程图如下: 以上就是原⽣线程池创建的核⼼原理。除了原⽣线程池之外并发包还提供了简单的创建⽅式,上⾯也说了它们是对原⽣线程池的⼀种包装,可以让开发者简单快捷的创建所需要的线程池。 Executors newSingleThreadExecutor 创建⼀个线程的线程池,在这个线程池中始终只有⼀个线程存在。如果线程池中的线程因为异常问题退出,那么会有⼀个新的线程来替代它。此线程池保证所有任务的执⾏顺序按照任务的提交顺序执⾏。 newFixedThreadPool 创建固定⼤⼩的线程池。每次提交⼀个任务就创建⼀个线程,直到线程达到线程池的最⼤⼤⼩。线程池的⼤⼩⼀旦达到最⼤值就会保持不变,如果某个线程因为执⾏异常⽽结束,那么线程池会补充⼀个新线程。 newCachedThreadPool 可根据实际情况,调整线程数量的线程池,线程池中的线程数量不确定,如果有空闲线程会优先选择空闲线程,如果没有空闲线程并且此时有任务提交会创建新的线程。在正常开发中并不推荐这个线程池,因为在极端情况下,会因为 newCachedThreadPool 创建过多线程⽽耗尽 CPU 和内存资源。 newScheduledThreadPool 此线程池可以指定固定数量的线程来周期性的去执⾏。⽐如通过 scheduleAtFixedRate 或者 scheduleWithFixedDelay 来指定周期时间。PS:另外在写定时任务时(如果不⽤ Quartz 框架),最好采⽤这种线程池来做,因为它可以保证⾥⾯始终是存在活的线程的。 推荐使⽤ ThreadPoolExecutor ⽅式 在阿⾥的 Java 开发⼿册时有⼀条是不推荐使⽤ Executors 去创建,⽽是推荐去使⽤ ThreadPoolExecutor 来创建线程池。 这样做的⽬的主要原因是:使⽤ Executors 创建线程池不会传⼊核⼼参数,⽽是采⽤的默认值,这样的话我们往往会忽略掉⾥⾯参数的含义,如果业务场景要求⽐较苛刻的话,存在资源耗尽的风险;另外采⽤ ThreadPoolExecutor 的⽅式可以让我们更加清楚地了解线程池的运⾏规则,不管是⾯试还是对技术成长都有莫⼤的好处。 改了变量,其他线程可以⽴即知道。保证可见性的⽅法有以下⼏种: volatile 加⼊ volatile 关键字的变量在进⾏汇编时会多出⼀个 lock 前缀指令,这个前缀指令相当于⼀个内存屏障,内存屏障可以保证内存操作的顺序。当声明为volatile 的变量进⾏写操作时,那么这个变量需要将数据写到主内存中。 由于处理器会实现缓存⼀致性协议,所以写到主内存后会导致其他处理器的缓存⽆效,也就是线程⼯作内存⽆效,需要从主内存中重新刷新数据。 因篇幅问题不能全部显示,请点此查看更多更全内容