《码出高效:java开发手册》七 - 并发与多线程

文章探讨了现代计算机中的并发和并行概念,强调了线程安全的重要性,介绍了线程的状态和创建方式,以及多线程可能导致的问题。线程池的概念和优势被阐述,包括ExecutorService、线程工厂和拒绝策略。此外,文章还讨论了ThreadLocal的作用和避免内存泄露的方法,以及信号量同步机制如CountDownLatch和Semaphore。最后提到了线程池的配置和线程安全的实践注意事项。
摘要由CSDN通过智能技术生成

前言

现代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()方法清理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值