并发编程的一些总结
并发编程的目的是为了充分利用资源让程度运行的更快,原则是,将代码中串行执行的部分变成并发执行。
并发:指多线程交替执行 / 并行:指同时执行
1、多线程面临的挑战
并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行。在使用多线程时需面临以下的几个挑战:
1)上下文切换
上下文切换是一个过程,任务从被CPU保存到再加载的过程就是一次上下文切换。上下文的切换会影响多线程的执行速度。
并不是启动更多的线程就能让程序最大限度的并发执行,多线程会面临创建线程和上下文的切换的开销,死锁问题。
CPU通过给每个线程分配时间片,即线程的执行时间,通常非常短,一般是几十毫秒,因此CPU通过不停的切换线程执行。在切换前会保留上一个任务的状态(保存在进程控制块(PCB,CPU的内存中)),以便下次切换回在这个任务时可以加载这个任务的状态。
(上下文切换像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码(书签))
如何减少上下文切换?
减少上下文的切换有无锁并发编程、CAS算法、使用最小线程和使用协程。
① 无锁并发编程:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以使用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
② CAS:如Java Atomic包下的API都是根据CAS来更新修改数据,不需要加锁
③ 使用最小线程:避免创建不需要的线程,任务很少却创建很多线程会造成大量线程出于等待状态
④ 协程:在单线程上实现多任务的调度,并在单线程里维持多个任务间的切换
2)死锁
什么是死锁?
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源(相互请求锁定对方已经锁定占用的资源),从而导致恶性循环等待的现象
如:两个线程都在锁内等待获取对方持有的锁。
如何避免死锁?
1)避免一个线程同时获取多个锁
2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
3)尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
4)对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况
3)资源限制
什么是资源限制?
如硬件相关的带宽的上传/下载速度、硬盘读写速度、CPU处理速度,还有软件相关的数据库的连接数和socket的连接数等。
这些资源限制不会因为创建多线程而打破,如下载某个资源的速度是1M/s,启动10个线程下载也不会变成10M/s。
如果因为受限于资源,多线程在串行执行,那么程序反而比单线程串行执行还要慢。因为增加了上下文切换和资源调度的时间。
如何解决资源限制的问题?
根据不同的资源限制调整程序的并发度
对于硬件资源限制,可以考虑使用集群并行执行程序
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和socket连接复用
2、Java并发机制底层实现
Java代码在编译后生成字节码,字节码被类加载器加载到JVM中,JVM执行字节码最终转化为汇编指令在CPU上执行。Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
1)volatile
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见性”。volatile使用恰当的话比synchronized的使用和执行成本低,因为它的使用不会引起线程上下文的切换和调度。
2)synchronized
JDK 1.6对synchronized进行了大量优化,使其不那么重了。如为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
Java中的每一个对象都可以作为锁,有以下三种形式:
(1):对于普通方法,锁是当前实例对象
(2):对于静态同步方法,锁是当前类的Class对象
(3):对于同步代码块,锁是synchronized括号里的对象
当一个线程试图访问同步代码块时,它必须先得到锁,退出或抛出异常时必须释放锁。那么锁存放在哪里呢?锁里面会存储什么信息呢?
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,代码块同步是通过monitorenter和monitorexit指令实现的。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法的结束处和异常处,JVM要求每个monitorenter必须有对应的monitorexit与之配对。
每个对象都有一个monitor与之关联,是每个对象与生俱来的隐藏字段,线程获取锁时,会根据锁对象头中的monitor状态进行加锁判断,如果monitor为0,就可以加锁持有锁,并将monitor置为1,如果当前线程已经持有了monitor,那么monitor继续加1(可重入),线程执行完毕释放锁则将monitor减1,直至monitor为0,则锁释放成功;如果monitor为1,表示锁已经被其他线程持有,则线程将处于BLOCKED阻塞状态。
3)Java对象头
synchronized锁是存在Java对象头中的,如果对象是数组类型,JVM使用3个Word(自宽)存储对象头,如果对象是非数组类型,则用2 Word存储对象头。
在32位虚拟机中,1 Word等于4字节,即32bit。JVM中对象头的布局分为两部分信息:
(1)第一部分用来存储对象自身的运行时数据,如哈希码(hashCode)、GC分代年龄、锁标志位等。官方称为Mark Word,它是实现轻量级锁和偏向锁的关键。
(2)另外一部分用于存储指向方法区对象类型的指针,如果是数组的话,还会有一个额外的部分用于存储数组的长度。
32位JVM中的Mark Word默认存储结构和不同锁下的状态变化为:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁,0否1是 | 2bit锁标志位 | |
23bit | 2bit | ||||
无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量的指针 | 10 | |||
GC标志 | 空 | 11 |
4)锁优化
JDK1.6中锁一共有4种状态,级别从低到高依次是无锁、偏向锁、轻量级锁、重量级锁;锁的状态会随着竞争逐渐升级,而不能降级。
(1)偏向锁:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程获得,为了让线程获得锁的代价更低所以引入了偏向锁。
偏向锁的“偏”,就是偏心的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取的时候,虚拟机会把锁对象头中的标志为设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当有另外一个线程去尝试获取这个锁时,偏向模式宣告结束。
根据锁对象目前是否出于被锁定的状态,撤销偏向后恢复到无锁状态(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就按轻量级锁的过程来执行。
偏向锁可以提高带有同步但无竞争的程序性能。但是它并不一定总是对程序有利,如果程序中大多数的锁总是被多个不同的线程访问,那么偏向模式就是多余的。可通过JVM参数 -XX:UseBiasedLocking=false 来关闭偏向锁(默认是开启的)
(2)轻量级锁
加锁:线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。
解锁:使用原子的CAS操作将Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
三种锁对比:
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 |
加锁和解锁不需要额外的消耗,和执行 非同步方法相比仅存在纳秒级的差距 |
如果线程间存在锁竞争,会带来 额外的锁撤销的消耗 |
只有一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 |
如果始终得不到锁竞争的线程, 使用自旋会消耗CPU |
追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
5)原子操作实现原理
原子(atomic)本意是“不能被分割的最小粒子”。原子操作意为:不可被中断的一个或一系列操作。
Java中通过锁和自旋CAS的方式来实现原子操作。
(1):使用自旋CAS实现原子操作
JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。
(2):CAS实现原子操作的三大问题
1、ABA问题,解决思路就是使用版本号,在变量前面追加版本号,每次更新的时候把版本号加1,那么ABA就会变成1A2B3A。JDK的Atomic包里提供了AtomicStampedReference
来解决ABA问题,它的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志(版本号),如果全部相等,则以原子方式将
该引用和该标志的值设置为给定的更新值。
2、循环时间长开销大,如果自旋CAS长时间不成功,会给CPU带来非常大的执行开销。
3、一次只能保证一个共享变量的原子操作,当对一个共享变量执行操作时,我们可以使用自旋CAS的方式来保证原子操作,但是对多个共享变量操作时,自旋CAS就无法保证操作
的原子性,这个时候可以用锁。JDK1.5提供了AtomicReference来保证引用对象之间的原子性,我们可以将多个变量放在一个对象里来进行CAS操作。
(3):使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域,因此不会被打断,实现原子操作。
END.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· AI与.NET技术实操系列(六):基于图像分类模型对图像进行分类