并发01--并发存在的问题及底层实现原理
一、并发编程的挑战
并发编程的挑战---多线程程序不一定运行的比单线程快:
(1)上下文切换
任务从保存到再加载的过程就是一次上下文切换;
减少上下文切换的方法:无锁并发编程、CAS算法、使用最少线程和使用协程。
a、无锁并发编程:多线程会竞争锁会引起上下文切换,因此使用无锁并发编程,可以避免上下文切换(例如:使用hash取模的方式固定线程处理固定的数据)
b、CAS算法:使用CAS自旋的方式避免使用锁
c、使用最少线程:尽量少的创建线程(这里的少指的是满足多线程要求的情况下尽量少的创建)
d、协程:在单线程里实现多任务调度,并在单线程里维持多个任务间的切换
(2)死锁问题
避免死锁的几个常见方法
a、避免一个线程获取多个锁
b、避免一个线程在锁内同时占用多个资源,尽量保证一个锁只占用一个资源
c、使用定时锁(lock.tryLock(timeOut))来代替使用内部锁机制
d、对于数据库,加锁和解锁必须在同一个数据库连接里,否则会出此案解锁失败的情况
(3)资源的限制
资源的限制有:带宽的上传/下载速度、硬盘读写速度、CPU处理速度
如果资源有限,就需要针对不同的情况做并发编程了,如果设置不当,会导致程序更慢。
二、并发编程底层实现原理
1、volatile
volatile使用恰当的话,它比synchronized是使用和执行成本更低,因为他不会引起线程上下文的切换和调度。
有volatile修饰的共享变量在写操作时,会多出一条有 lock 修饰的语句,lock修饰的汇编代码有两个作用:
(1)将当前缓存行的数据回写到内存中
lock指令确保处理器独占共享资源,这里有的处理器是使用锁定总线的方式来保证(开销大),有的处理器使用的是锁定缓存来保证
(2)这个回写内存的操作会让其他CPU中缓存了该内存地址的数据无效
每个处理器使用嗅探在总线上传播的数据来检查自己的缓存值是不是过期了
对于volatile使用中的优化:
可以使用追加字节到64位的方式来做性能优化:例如LinkedTransFerQueue
原因:大多数处理器的L1、L2、L3缓存的高速缓存行都是64个字节宽,如果一个队列的头节点和尾节点不足64位,则会导致同一个头节点/尾节点会落在不同的缓存行上,那么每一次出入队列都需要锁定多个缓存行,导致性能降低;如果将头节点和尾节点追加字节到64个,则可以保证头节点和尾节点只落在一个缓存行上,每一次操作头节点/尾节点时只需要锁定一个缓存行即可。
不应该追加到64字节的情况:
(1)如果缓存行非64个字节宽,则不能追加到64字节,例如P6系列和奔腾处理器,则是32个字节,这时可以追加到32个字节
(2)队列不会被频繁的写:只要不会被频繁的写,缓存行就不会被频繁的锁定,同事追加到64需要处理器读取更多的内容到高速缓存区,这本身就会造成性能的损耗。
2、synchronized
java中的每个对象都可以作为锁,具体如下:
a、对于普通同步方法,锁是当前实例
b、对于静态同步方法,锁是当前类的Class对象
c、对于同步方法块,锁是Synchronized括号内的对象
对于同步代码块,JVM使用的是Monitor对象(monitorenter和monitorexit)来实现的,monitorenter插入在同步代码块开始点,标记该monitor被持有,处于锁定状态;线程执行到monitorenter时,尝试获取对象monitor的所有权,即尝试获取对象的锁,执行到monitoerexit时,释放对象的所有权,即释放锁。
对于同步方法,则是在方法上生成一个ACC_SYNCHRONIZED修饰符来完成同步
(1)java头对象
synchronized锁是存在于对象头,如果对象是数组,则使用3个字宽存储头对象,如果是非数组,则使用2个字宽存储头对象,对于32位的虚拟机,存储如下:
长度 | 内容 | 说明 |
32bit | Mark Word | 存储对象的hashCode和锁等内容 |
32bit | Class Metadata Address | 存储到对象类型的指针 |
32bit | Array length | 数组的长度(如果非数组,没有该字宽内容) |
由上述表格可见,锁是存储与对象头的Mark Word里面的,那么 Mark Word里面存储的是什么内容呢?
锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标志位 |
无锁状态 | 对象的hashCode | 对象的分代年龄 | 0 | 01 |
但是在运行时期,如果锁标志位变化,则mark word的存储内容也会随之变化
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否是偏向锁 | 锁标志位 | ||
无锁状态 | 对象的hashCode | 对象的分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 |
对于64位的虚拟机,相比于32位虚拟机,主要是新增了25bit的填充,以及新增了1bit的cms_free,同时hashCode新增了6bit,详情如下:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
cms_free | 分代年龄 | 偏向锁 | 锁标志位 | |||
无锁 | unused | hashCode | 0 | 01 | ||
偏向锁 | 线程ID(54bit)Epoch(2bit) | 1 | 01 |
(2)锁升级与对比
以上内容说了java头对象中markword会随着锁状态的变化而变化,这是为了减少使用锁时,每次都需要获得锁、释放锁带来的性能损耗,因此引入了偏向锁和轻量级锁;
如上表中所示,锁的级别从低到高依次是:无锁状态<偏向锁<轻量级锁<重量级锁;锁可以随着竞争情况逐渐升级,而不能降级。
a、偏向锁
偏向锁的流程如上图所示:
a1、T1线程执行同步代码块时,检查对象头中是否存在T1线程,由于此使处于无锁状态,因此不存在偏向线程,则将持有偏向锁的相称为T1线程(此使可以执行成功),直接执行同步代码块
a2、T1线程再次执行同步代码块时,此使对象头中存在T1线程,直接执行同步代码块
a3、T2线程访问同步代码块,检查对象头中存在的是T1线程,则使用CAS将头对象中的持有线程设置为T2线程,如果设置成功,则执行同步代码块;如果设置失败,则撤销偏向锁
a4、偏向锁只有等到锁竞争的时候,持有锁的线程才会释放锁,并且需要等到全局安全点时,先暂停持有偏向锁的线程,然后检查偏向锁持有线程是否存活,如果存活,则将锁升级为轻量级锁;如果没有存活,则将头对象设置为无锁状态,然后T2线程通过CAS将头对象的持有线程设置为T2线程,然后执行同步代码块
以上流程设计几个点
a1、只有头对象是无锁状态,才能CAS更新持有线程
a2、CAS更新失败,则升级锁
了解了这两点,就比较好理解生个流程图(我当时看的时候,就是这两点没搞明白,一直对流程图似懂非懂)
在开发中,偏向锁优化
关闭延迟激活:-XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking=false
b、轻量级锁
轻量级锁的流程如上图所示:
b1、线程1访问同步代码块,在栈中分配存放锁的空间,并且将markword复制到该空间中
b2、CAS替换头对象中markword为上一步创建的空间地址
b3、设置成功后,将markword设置为轻量级锁,并执行同步代码块
b4、如果此时有线程2执行同步代码块,前期的分配空间并辅助markword到栈的操作正常执行
b5、CAS修改markword为T2线程新创建的空间,由于T1线程已经做了修改,因此T2线程将修改失败
b6、由于T2线程CAS修改markword失败,因此锁膨胀为重量级锁,并且修改markword为执行重量级锁的指针;同时T2线程阻塞,等待通知
b7、此时T1线程执行完同步代码块后,需要将T1线程中开辟空间中存放的markword内容复原回对象头中的markword,但是此时对象头中的markword已经被T2线程修改为重量级锁,因此CAS替换失败
b8、由于上一步CAS失败,因此T1线程直接释放锁,并唤醒在阻塞的T2线程
b9、T2线程被唤醒,重新政所锁访问同步代码块
c、锁的优点对比
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的开销,和执行非同步代码块相比仅存在纳秒级的差距 | 如果线程间存在竞争,则会带来额外的锁撤销带来的消耗 | 适用于只有一个线程访问同步代码块的场景 |
轻量级锁 | 竞争线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋回消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 竞争不使用自旋,不消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
(3)原子操作的实现
简单的说,处理器是通过总线锁定和缓存锁定两个机制来保证原子操作的
总线锁定:使用处理器提供的一个LOCK信号,当一个处理器在总线上输出此信号时,其他处理器的请求被阻塞,那么该处理器则可以独占共享内存
缺点:开销较大,因为锁定总线后,其他处理器就不能处理其他内存地址的数据
缓存锁定:内存区域如果被缓存在处理器的缓存行中,并且在lock操作期间被锁定,那么当他执行锁操作回写到内存时,处理器不在总线上声言LOCK信号,而是修改内部的内存地址,并允许他的缓存一致性来保证操作的原子性。
缓存一致性会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行数据时,会使缓存行无效。
有两种情况不会使用锁定缓存:
a、当操作的数据不能被缓存到处理器内部(缓存行)时,或操作的数据跨多个缓存行时
b、有些处理器不支持缓存锁定
JAVA中实现原子操作是通过锁和循环CAS的方式来实现的
CAS实现:利用了处理器的CMPXCHG指令实现的。基本思路就是循环进行CAS直到成功。
使用锁:即使用锁保证只有一个线程可以操作锁定的内存。这里需要特殊说明一下,在JVM内部,除了偏向锁外,其余的锁都用到了CAS。
最后说一下使用CAS存在的问题及解决方案:
a、ABA问题:
说明,原来原始值为A,变成了B,然后又变成了A,表面看值仍然是A,没有变化,实际上已经被修改过
解决:增加版本号,每次修改都要更新版本号,版本号一致再对比值,如果两者均一致,则认为没有被修改过
java的解决方案:JDK的Atomic包中提供了类AtomicStampedReference来解决ABA问题,他检查当前引用&预期引用、当前标志&预期标志来判断是否被修改过
b、循环时间长的问题
说明:自旋CAS如果长时间不成功,则会给CPU带来非常大的执行开销
解决:JVM如果可以支持处理器的pause命令,效率会有提升。
c、只能保证一个共享变量的原子操作
说明:cas只能保证一个共享变量的原子操作
解决:可以将多个变量合并成一个共享变量来操作,例如 i=2,j=3可以使用 ij = 23 来表示。
java解决方案:JDK提供了AtomicReference类来保证对象之间的原子性,可以将多个共享变量放在一个对象内来进行CAS操作。
-----------------------------------------------------------
---------------------------------------------
朦胧的夜 留笔~~