20210714——Java并发编程的艺术 第二章 Java并发机制的底层实现原理

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令

volatile的使用

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量,Java语言提供了Volatile,在某些情况下比锁要更加方便,如果一字段被声明为volatile,java线程内存模型确保所有的线程看到这个变量的值是一致的。

cpu术语

内存屏障:memory barries 是一组处理器指令,用于实现对内存操作的顺序限制

缓冲行:cache line cpu高速缓存行中可以分配最小的存储单位,处理器填写缓存行时,会加载整个缓存行,现在cpu需要加载几百次cpu指令。

原子操作:atomic operations 不可中断的一个或者一系列操作

缓存行填充:cache line fill 当处理识别到从内存中读取操作数是可以缓存的,处理器读取整个告诉缓存行到适当的缓存

缓存命中:cache hti 如果进行告诉缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器会从缓存中读取操作数,而不是从内存中读取。

写命中:write hit 当处理器将操作数写回一个内存缓存的区域时,它会首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中

写缺失:write misses the cache 一个有效写的缓存行被写入到不存在的内存区域。

volatile保证可见性

有volatile变量修饰的共享变量进行写操作的时候会出现第二行汇编代码,lock前缀的指令在多核处理器下会发生两件事情:
1)将当前处理器缓存行的数据写回到系统内存
2)这个写会内存的操作会使其他cpu里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是将系统内存的数据读到内部缓存(L1,L2或者其他)进行操作,但是操作完不知道何时会写到内存,如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回系统内存,但是就算写回内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个护理期的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中 。

Synchronized的实现原理与应用

对于普通同步方法,锁是当前的实例对象
对于静态同步方法,锁是当前类的Class对象
对于同步方法块,锁是Synchronized括号里配置的对象

从JVM规范中可以看到synchronized在jvm里的实现原理,jvm基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的细节不一样。代码块同步是使用monitorenter和exit指令实现的。

monitorenter指令是编译后插入到同步代码块的开始位置,而monitorexit是插入到方法借书处和异常处,jvm要保证每个monitorenter必须有对应的monitorexit与之配对,任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象对应的monitor的所有权,即尝试获得对象的锁。

java对象头

synchronized用的锁是存在于java对象头离得,如果对象是数组类型,则虚拟机用三个字宽(word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头,在32位虚拟机中,1字宽等于4字节,即32位。

java对象头离得Mark word里默认存储对象的HashCOde、分代年龄和锁标志位。jvm的mark word的默认存储结构如下:

在这里插入图片描述

锁的升级与对比

jdk1.6为了减少获得锁和释放锁的带来的性能消耗,引入了偏向锁和轻量级锁。锁一共有4种状态,级别从高到低都是无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级为偏向锁,这种锁升级却不能降级的策略,目的是为了提供获得锁和释放锁的效率。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁,会在对象头和栈桢中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用了CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

竞争才会释放锁的机制,所以当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活,如果线程不处于活动状态,则将对象头设置为无锁装填,如果线程仍然活着,持有偏向锁的栈会执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁。

轻量级锁

轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方成为Displaced Mark Word,然后线程尝试使用CAS将对象头中Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。

轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生,如果事变,表示当前存在锁竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程是否锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

原子操作的实现原理

cpu术语定义

缓存行:缓存最小的操作单位

比较并交换:Compare and Swap ,cas操作需要输入两个数值,一个旧值(期望操作前的值)和一个心智,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

cpu流水线:cpu流水线的工作方式就像工业生产的装配流水线,在cpu中由5 6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5~6步后再由这些电路单元分别执行,这样就能实现一个CPU时钟周期完成一条指令,因此提高了CPU的运算速度。

内存顺序冲突:内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这种内存顺序冲突时,cpu必须清空流水线。

处理器如何实现原子性

第一个机制是通过总线锁保证原子性

如果多个处理器同时对共享变量进行读写改操作,那么共享变量就会被多个处理器同时进行操作,这样读写改操作就可能不是原子性的。原因主要是多个处理器同时从各自的缓存中读取变量i,分别进行+1操作,然后分别写入系统内存中,那么,想要保证读写改贡献该变量的操作是原子性的,即必须保证cpu1读写共享变量的时候,cpu2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁来解决这个问题的,所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

第二个机制是通过缓存锁来保证原子性

同一时刻,我们只需要保证对某个内存地址的操作是原子性即可,但是总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定进行优化。

频繁使用的内存会缓存在处理的L1、L2和L3高速缓存里,那么源自操作就可以直接在处理器内部缓存进行,并不需要声明总线锁,在Pentium6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在lock操作期间被锁定,那么它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。

CAS实现原子操作的三大问题

在java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法,CAS虽然很高效地解决了原子操作,但是CAS仍然存在三大问题

ABA问题

因为CAS需要在操作值的时候,检查值有没有变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,最后又变成了A,那么使用CAS进行检查的时候就会发现他的值没有变化,但是实际上却是变化了。ABA的问题解决思路就是使用版本号,在变量前面追加上版本号,每次变量更新的时候把版本号+1。从java1.5开始,jdk的atomic包里提供了一个类AtomicStampedReference来解决ABA问题,这个类的CompareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否扥与预期标志,如果全部相等,则以原子方式将该引用和标示的值设置为给定的更新值。

循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能够支持处理器提供的pause命令,那么效率会有一定的提升

只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值