前言
现代CPU运算速度以百亿计,家用计算机和操作系统也是数十进程,数百线程,程序相应也需要采用多线程和并发的技术
并发和并行:并发是指某个时间段,多任务处理;并行是指同时处理多任务的能力;这两个概念极易混淆,区分点就在于进程是否同时执行
比如一个医生一会去看病,一会化验,一会开药,属于并发
比如几个医生同时看病,属于并行
在并发状态下,程序封闭性被打破,有三个问题
一、并发程序之间互相制约
二、并发程序执行断断续续
三、当并发数设置合理并且CPU 拥有足够的处理能力时,并发会提高程序的运行效率。
线程安全
多线程可以更好利用cpu,但是太多也会加大理解难度,程序可理解性低下
比如搬砖,10个人搬比1个人搬快,但加大到1000个人,就会出现拥堵,成本也过高,所以就需要设置合适的线程参数
如图,PID是程序的标号,Threads是这个进程下的线程数量
线程拥有操作栈、程序计数器、局部变量表等资源,生命周期有NEW (新建状态)、RUNNABLE (就绪状态)、RUNNING (运行状态)、BLOCKED (阻塞状态)、DEAD (终止状态)五种状态。
(1)NEW,新建,是线程创建但未启动,创建线程有三种方式:第一种是继承自Thread 类,第二种是实现Runnable 接口,第三种是实现Callable 接口
第二种相比第一种,暴露细节更少,更加符合里氏替换原则,而第三种源码如下图
可以看到第三种和第二种有两个不同
1.可以通过call()方法获取返回值,前两种则不能直接获取,需要借助共享变量获取
2.call()可以抛出异常,而Runnable 只有通过setDefaultUncaughtExceptionHandler() 的方式才能在主线程中捕捉到子线程异常。
(2)Runnable,即就绪状态,是调用start()之后而在运行之前的状态
start() 不能被多次调用,否则会抛出Illega!StateException 异常。
(3)RUNNING 运行状态,是run正在执行时线程的状态。
线程可能会由于某些因素而退出RUNNING ,如时间、异常、锁、调度等。
(4)BOLCKED状态,阻塞,有三种情况
1.同步阻塞,锁被其他线程占用
2.主动阻塞:调用了sleep,join这些方法,让出cpu
3.等待阻塞:执行wait
(5)DEAD,终止
run方法执行结束,或因异常退出,不可逆
线程安全,用那个医生的例子,就是如果对一个病人的诊断,却对另外一个病人开了药,就会出现问题,线程中就是A线程查询数据,B线程抢占cpu修改了数据,A恢复后一查已经是错误数据了,通常使用同步机制来协调线程执行
通常单线程串行没有这些问题,多线程下考虑四个因素
1.数据单线程可见,将各个线程隔离开,也就不会互相影响,例如ThreadLocal
2.只读对象,例如String,Integer,用final修饰,可以复制,不可写入
3.线程安全类,例如StringBuffer,有sycronized修饰,是线程安全
4.同步与锁机制,这里主要是开发者在代码里实现的安全同步机制,很复杂,容易出问题
以上线程安全的核心理念就是只读或者加锁,这些操作大多是JUC(java.util.concurrent)包里实现的,这个并发包里主要有几种类
1.线程同步类
CountDownLatch 、Semaphore 、CyclicBarrier这些类,有更丰富的线程协调场景,逐步淘汰了object的wait,notify方法
2.并发集合类
ConcurrentHashMap比较典型,主要是执行速度快,提取数据准,以前是分段锁,现在是CAS,ConcurrentSkipListMap 、CopyOnWriteArrayList、BlockingQueue 等。
3.线程管理类
线程池,创建可以用Exec utors 静态工厂或者使用ThreadPoolExecutor 等,,通过ScheduledExecutorService 来执行定时任务。
4.锁相关类
锁主要是Lock接口,但相关概念被弱化,因为很多类库已经封装了
线程安全意识一定要有,初创公司业务流量比较小,初级程序员缺乏安全意识,并发问题复现追踪困难,往往除了问题不了了之,但后期业务扩展重构就会付出很大代价
锁
锁,顾名思义就是锁住资源仅供自己使用,在早期有悲观锁,后来有乐观锁,偏向锁,分段锁等,锁主要提供可见性和互斥性,主要讲解了一些JUC包中的锁,主要有两种实现方式
一、并发包中的锁
二、 利用同步代码块
信号量同步
信号量同步指在不同线程之间,通过一个信号量值对线程排序,比如CountDownLatch 、Semaphore,分别基于时间和信号维度
以一个翻译软件返回多语言翻译结果代码为例
代码在1处抛出异常,但没有被catch到,之后执行彻底无法定位到异常,这里扩展:子线程异常可以通过setUncaughtExceptionHandler捕获
CountDownLatch是基于时间的同步类,以海关为例,就是有多个窗口相当于处理逻辑,多个人相当于线程,有空闲窗口时就指示线程去执行
Semaphore 的信号同步类,只有在调用Semaphore 对象的acquire()成功后,才可以往下执行,完成后执
行release() 释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行。
如上,一个线程需要进一步确认,不影响别的线程执行,如果Semaphore值为1,就变成互斥锁了
此外还有CyclicBarrier是同步达到某点的信号量触发机制,意思就是把海关的旅客分为一批一批的,上一批安检完上下一批,
较为低效,但在一些特殊场景下比较合适
最后,为了性能和安全,建议使用这些并发包里的信号同步类,避免使用wait和notify这些方法进行同步
线程池
线程的使用和销毁需要对程序计数器,虚拟机栈,本地方法栈进行频繁创建和销毁,所以需要复用线程,减少性能消耗,使用线程池主要有四个好处:
1、实现复用,控制最大并发数
2、实现对任务线程的缓存与拒绝
3、可以实现时间功能如定时执行,周期执行
4、隔离环境,可以把不同功能限制在不同的线程池,避免互相影响
这里的讲解顺序是:首先从ThreadPoolExecutor 构造方法讲起, 学习如何自定义ThreadFactory 和RejectedExecutionHandler , 并编写一个最简单的线程池示例。然后,通过分析ThreadPoo!Executor 的execute 和addWorker 两个核心方法,学习如何把任务线程加入
到线程池中运行
如上图是ThreadPoolExecutor的执行代码
7个参数就是线程池的核心参数
第1个参数: corePoolSize 表示常驻核心线程数。大于0时不会销毁,等于0时销毁,设置过大浪费空间,设置过小会导致频繁创建销毁
第2 个参数。maximumPoolSize 表示线程池能够容纳同时执行的最大线程数。必须大于1.当线程超出最大size时,需要借助缓存队列,等于核心线程数就代表这是固定大小线程池
第3 个参数: keepAliveTime 表示线程池中的线程空闲时间,当空闲时间达
到keepAliveTime 值时,线程会被销毁,直到只剩下corePoolSize 个线程为止,避
免浪费内存和旬柄资源。在默认情况下,当线程池的线程数大于corePoo!Size 时,
keepAliveTime 才会起作用。但是当ThreadPoo!Executor 的allowCoreThreadTimeOut
变量设置为true 时, 核心线程超时后也会被回收。
第4 个参数TimeUnit 表示时间单位。keepAliveTime 的时间单位通常是
TimeUnit.SECONDS 。
第5 个参数, workQueue 表示缓存队列。当请求的线程数大于maximumPoo!Size
时, 线程进入BlockingQueue 阻塞队列。后续示例代码申使用的LinkedBlockingQueue
是单向链表,使用锁来控制入队和出队的原子性,两个锁分另lj控制元素的添加和获取,
是一个生产消费模型队列。
第6 个参数threadFactory 表示线程工厂。它用来生产一组相同任务的结程。线
程池的命名是通过给这个factory 增加组名前缀来实现的。在虚拟机分析时,就可
以知道线程任务是由哪个线程工厂产生的。
第7 个参数handler 表示执行拒绝策晤的对象。当超过第5 个参数workQueue
的任务缓存区上限的时候,就可以通过该策略处理请求,这是种简单的限流保护。
像某年双十没有处理好访问流量过载时的拒绝策略,导致内部测试页面被展示出来,
使用户手足无措。友好的拒绝策略可以是如下三种
1.保存到数据库,缓冲峰谷,队列空闲后再读取执行
2.转向提示页面
3.打印日志
现实中很少手动实例化队列、线程工厂、拒绝处理服务,而是通过Exectuor,Exceutors 与ThreadPool Executor 是什么关系?下图可以看到
ExecutorService 接口继承了Executor 接口,定义了管理线程任务的方法。
A bstractExecutorService 提供了submit() 、invokeAll() 等部分方法的实现
通过Executors 的静态工厂方法可以创建三个结程池的包装对象。ForkJoinPool 、ThreadPooIExecutor 、
ScheduledThreadPoolExecutor
Executors 核心的方法有五个:
NewWorkStealingPool:JDK8引入,创建有足够线程数的线程池支持并发,并使用多个队列减少竞争
newCachedThreadPool:最大线程数可达Integer的上限,伸缩性极大,存在OOM 风险,KeepAlice默认60s,工作线程空闲则回收,若有新需求则新建线程
newScheduledThreadPool:最大线程数可达Integer的上限,如上,支持定时及周期执行,相比Timer更加安全强大,相比CachedPool,不回收工作现场
newSingl eThreadExecutor:单线程线程池,串行顺序执行所有线程
newFixedThreadPool:固定线程池,核心数等于最大,KeepAlive=0,如下图
这个队列没有初始化容量
可见,无界队列有OOM风险,除workSteal线程池,其他都有资源耗尽风险
- Executors中的线程工厂都比较简单,不太好用,线程工厂应该可以标识线程序号,拒绝策略则提供提示和跳转
通过newThread 方法快速、统一地创建线程任务,出错时方便回溯
如上图,绿色部分明显比蓝色部分更加友好,容易定位信息
上图是拒绝策略的实现,可以打印出线程池状态
内部提供了四个公开的内部静态类
AbortPolicy (默认):丢弃任务并抛出RejectedExecutionException 异常。
DiscardPolicy : 丢弃任务,但是不抛出异常, 这是不推荐的做法。
DiscardOldestPolicy : 抛弃队列中等待最久的任务, 然后把当前任务加入队列中。
CallerRunsPolicy :调用任务的run ()方法绕过线程池直接执行。
上面是根据改进的方法实现的线程池,任务被拒绝后,拒绝策略打印出了当前线程池已经达到maxsize=2,且缓存队列已满,完成任务数提示已经有1个
线程池源码
线程池源码里有位移运算,包括左右移动,来高效改变值
·高三位代表线程池状态,包括符号位,五种状态按十进制大小排序:
RUNNING < SHUTDOWN < STOP < TIDYING <TERMINATED ,这样只需比较值就可以判定线程池状态
下图为execute方法,这是Exectuor接口的唯一方法,参数传入代表执行线程
第1 处: execute 方法在不同的阶段有三次addWorker 。
第2处,发生拒绝的理由有两个:线程池非Running状态,等待队列已满
下图为addworker代码
这段代码长而难懂,有些地方实现方法别出心裁
第一处是标号退出循环的用法
第二处是不太好阅读,如下比较清晰
第三处是一个原子性操作,退出到1处标号
第四处,如果为真则跳到1处标号,为假则继续在for中循环
第五处,这里方法执行失败几率很低,失败后再执行成功几率很高,类似自旋锁,处理逻辑里是先加1,创建失败再减一,相比创建成功加1,失败后再销毁线程,要节约性能
第六次worker代码
使用线程池要注意:
1、合理设置各类参数,根据实际业务设置合适的工作线程数
2、一切线程由线程池提供,不能自行创建线程
3、创建线程指定有意义的名称,方便出错时回溯
- 线程池不允许使用Executors ,而是通过ThreadPoolExecutor 的方式创建,这样的处理方式能更加明确线程池的运行规则,规避资源耗尽的风险。
Thread Local
ThreadLocal设计初衷是为了解决并发时的共享变量问题,但由于弱引用和哈希碰撞不易理解,导致使用难,故障频发
首先介绍引用类型
对象在堆上创建后所持有的引用是一种变量类型,引用之间可以构成一条链,从GCRoots判断引用是否可达,可达性用来判断对象是否可回收
强引用:Object object = new Object即为引用,只要持有强引用,且GC roots可达,就不会被回收
软引用:OOM触发FGC,会回收以获取更多空间,主要用来缓存一些中间数据,用户操作
弱引用:下一次YGC回收,但YGC时间不确定导致弱引用消失也不确定,由于引用不劫持对象,get 弱引用可能空指针
虚引用:通常只是为了在回收时获取一个通知,比如与引用队列联合使用
图例以房子买卖进行解释
某个卖家有一套房,卖出后为空,4个买家用四种引用关系指向这套房子
buyer1是强引用,seller赋值给他就永久有效,系统不会由于seller为空就回收它
buyer2是软引用,只要不OOM,就可以通过get获取房子,就像租房
buyer3是弱引用,过户后,seller为空,几秒内就买家会失去房子
buyer4是虚引用,定义后无法访问到房子对象,卖家虚构了房源,类似诈骗
强引用最常用,软弱引用次之,虚引用几乎不用
下图代码,首先设置jvm 参数-Xms20m -Xmx 20m , 即只有20MB 的堆内存空间。
不断添加对象,每个对象有2000个成员变量,为了尽快消耗完内存
new house()匿名对象,赋值给软引用,运行一会达到了耗尽的状态
之后软引用特性生效,开始释放内存,如图,释放到了百数量级
- 软引用SoftReference 的父类Reference 的属性: private T referent ,它指向new
House ()对象,而SoftReference 的get (),也是调用了super.get()来访问父类这个私有
属性。大量的House 在内存即将耗尽前,成功地次又一次被清理掉
buyer2是引用类型,但本身占用一些内存,由于被ArrayList强引用劫持,在循环大量add方法后,产生了OOM
强软弱虚都有带队列的构造方法
这个队列用来检查软引用的对象是否被回收,进而清理失去house的软引用对象 - 之后做一个对比试验
把1,2处代码取消注释,把3处代码注释掉,再次运行,在没有软引用下,i=1024就触发OOM - 软引用在内存紧张时能释放许多内存,一般用于同一服务器内缓存中间结果,命中缓存就提取缓存结果,否则重计算或者获取
- 软引用不能缓存高频数据,否则一旦服务器重启或者大规模回收软引用,就会导致无法命中,压力全部给了数据库
- 下图代码实验软引用在除了OOM外是否会被回收
gc方法是建议回收,但执行取决于JVM判断
runFinalization方法是强制调用已经失去引用对象的finalize方法
这两个方法用于更快进行垃圾回收
一直输出still there,说明buyer2一直持有有效引用,这里如果要灵敏感知对方为null,就需要换成弱引用,这样就可以及时回收
在JVM 启动参数加- XX : +PrintGCDetails (或高版本JDK 使用-Xlog:gc )采观察GC 的触发情况
上图代码,YGC下明显回收了弱引用指向的new house对象
1处这里如果是hashmap,就是强引用,seller为空,也不会影响key变为null,hashmap的size为2,而weakhashmap就是1,回收了seller1指向的引用;weakhashmap使用场景是一些不敏感的临时信息,例如用户登陆系统里的访问路径,关闭浏览器后可以自动清空
弱引用的这种特性也用在了ThreadLocal上,为了在TL对象消失后,持有它的线程对象及时回收,避免内存泄露,但实际使用场景上,却导致了理解难度加大
实际使用中,应该把强引用置为null,避免强引用劫持
ThreadLocal价值
文章举例用真人CS举例演示了ThreadLocal的思想
游戏开始时,每个人能够领到一把电子枪,枪把上有三个数字:子弹数、杀敌数、自己的命数,为其设置的初始值分别为1500 、0 、10 。
如果多个人每个人是一个线程,三个初始值写在哪?如果是各自写死,那么统一修改就很麻烦
如果共享,各个线程并发修改就会造成数据问题
这时ThreadLocal出现了,它是各个线程共享的,但每个线程获取的副本是互相独立的,注意不能将其翻译为线程本地化或本地结程,英语恰当的名称应该叫作CopyValuelntoEveryThread
如上图,这里没有进行set操作,初始值是通过重写的intialvalue方法实现的,这个方法不是在加载静态变量时执行,而是在各个线程执行在ThreadLocal. get()时,get源码如下
每个线程都有一个ThreadLocalMap,为空就会执行setlnitia!Value();map创建后就代表ThreadLocals属性初始化完成
如果e==null , 依然会执行到setlnitia!Value() ,如下为setlnitia!Value的源码
之前cs游戏的1处使用了ThreadLocalRandom,同样是各自独立生成随机数,避免共享random导致性能下降的问题
这里已经知道ThreadLocal是每个线程独有的,ThreadLocal对象通常是private static修饰,static是因为需要复制进入本地线程,但解决不了共享变量的更新问题,下图为实例
这里输出结果仍然是乱序的,不可控制,可见在利用引用操作共享对象时,还需要线程同步
如上图,ThreadLocal 有个静态内部类叫ThreadLocalMap ,它还有个静态内部类叫Entry,在Thread 中的ThreadLoca!Map 属性的赋值是在ThreadLocal 类中的createMap()中进行的。ThreadLocal 与ThreadLocalMap 有三组对应的方法get()、set()和remove(), 在ThreadLocal 中对它们只做校验和判断,最终的实现会落在ThreadLoca!Map 上。Entry 继承自WeakReference ,没有方法,只有一个value 成员变量,它的key 是ThreadLocal 对象
如上图
• 1 个Thread 有且仅有1 个ThreadLoca!Map 对象,
• 1 个Entry 对象的Key 弱可|用指向1 个ThreadLocal 对象;
• 1 个ThreadLoca\Map 对象存储多个Entry 对象,
• 1 个ThreadLocal 对象可以被多个结程所共享i
• ThreadLocal 对象不持有Value, Value 由线程的Entry 对象持有。
图中红色虚线解释如下entry源码
Entry对象均被Threadlocals持有,线程对象执行完毕后就会回收,而这里的红字是弱引用,即使线程在执行中,只要ThreadLocal对象为空了,Entry的key在下一次ygc时也会回收,而ThreadLocal在下一次set和get时,又会把key为空的value也变为空,value也被回收,就避免内存泄露,但实际上如同源码注解一样,
私有静态变量,其生命周期不会随着线程结束而结束
- ThreadLocal有三个重要方法
( l ) set() : 如果没有set 操作的Thread Local , 容易引起脏数据问题。
( 2 ) get() ,始终没有get 操作的Thread Local 对象是没有意义的。
( 3 ) remove() :如果没有rcmove 操作,容易引起内存泄露。
对于非静态使用,属于ThreadLocal的实例内部类,就失去了线程间共享的本质属性,这时它的作用就在于在线程之中,跨类,跨方法传递数据,这样防止了用参数和返回值传递造成过度耦合。通过将Thread 构造方法的最后一个参数设置为true ,可以把当前线程的变量继续往下传递给它创建的子线程;
下方代码中的parent 是它的父线程
createlnheritedMap () 其实就是调用ThreadLocalMap 的私有构造方法来产生一个实例对象, 把父线程的不为null 的线程变量都拷贝过来
这里有一个案例,淘宝很多场景是通过ThreadLocal穿透上下文,比如TL获取了某个traceid,但是子线程里这个id为空,就需要
InheritableThreadLocal 来解决父子线程之间共享线程变量的问题,使整个连接过程中的traceId 一致
使用Threa dLocal 和InheritableThreadLocal 透传上下文时,需要注意线程间切换、
异常传输时的处理,避免在传输过程中因处理不当而导致的上下文丢失。
- SimpleDateFormat 是线程不安全的类,定义为static 对象,会有数据同步
风险。通过源码可以看出, SimpleDateFormat 内部有一个Calendar 对象,在日期转字
符串或字符串转曰期的过程中,多线程共享时有非常高的概率产生错误, 推荐的方式
之一就是使用ThreadLocal ,让每个线程单独拥有这个对象。示例代码如下
Thread Local 副作用
ThreadLocal使用时也有一些问题,比如脏数据和内存泄露,这两个问题通常是在线程池的线程中使用ThreadLocal 引发的,因为线程池有线程复用和内存常驻两个特点。
1、 脏数据
线程池会复用线程,由于ThreadLocal与线程复用,也会被重用,如果run方法中不显式地remove之前的ThreadLocal信息,且下一个线程不set重设值,就会get到之前的线程信息,拿到脏数据
脏数据问题在实际故障中十分常见。比如, 用户A 下单后没有看到订单记录,而用户B 却看到了用户A 的订单记录。通过排查发现是由于session 优化引发的。在原来的请求过程中,用户每次请求Server , 都需要通过sessionld 去缓存里查询用户的session 信息,这样做无疑增加了一次调用。因此,开发工程师决定采用某框架来缓存每个用户对应的SecurityContext , 它封装了session 相关信息。优化后虽然会为每个用户新建一个session 相关的上下文,但是由于Threadlocal 没有在线程处理结束时及时进行remove() 清理操作, 在高并发场景下,线程池中的线程可能会读取到上一个线程缓存的用户信息。如下代码
2、内存泄露
使用static 关键字来修饰ThreadLocal。在此场景下,寄希望于ThreadLocal 对象失去引用后, 触发弱引用机制来回收Entry 的Value 就不现实了。在上例中,如果不进行remove() 操作, 那么这个线程执行完成后,通过ThreadLocal 对象持有的String 对象是不会被释放的。
以上两个问题的解决办法很简单,就是在每次用完ThreadLocal 时, 必须要及时调用remove()方法清理。