《Java并发编程》学习2——底层实现原理

1.volatile

volatile关键字作用Java内存模型确保了所有线程看到的被声明为volatile的字段的修改是一致的。

JVM层面:如果对volatile变量进行写操作,JVM会向处理发送一条Lock前缀的指令,使得这个变量所在的cache行的数据写回到内存中。

Lock前缀指令的执行会声言处理器的LOCK#信号,该信号确保在声言期间,处理器可以独占任何共享内存(锁住总线)。

计算机层面:根据cache一致性协议,在多处理器中,每个处理器会通过嗅探在总线上传播的数据来检测自己的cache中缓存的值是否过期;当处理器发现cache行对应的内存地址的内容发生了修改,就会将其设置为无效,此时只能重新从内存中把数据读取到自己的cache中。

2.锁机制

synchronized

synchronized关键字作用:用于加锁。

实现基础:Java中的每一个对象都可以作为锁,具体表现为以下3种形式。

①对于普通同步方法→锁为当前实现对象

②对于静态同步方法→锁为当前类的Class对象

③对于同步方法块→锁为synchronized括号里配置的对象 [synchronized(a lock object)]

JVM层面:基于进入和退出Monitor对象来实现同步的,使用一对monitorenter和monitorexit指令。monitorenter指令在编译后被插入在同步代码块开始位置,monitorexit被插入到方法结束处或异常处,每个monitorenter必须和与之配对的monitorexit成对使用。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时会尝试获取对象对应的monitor的所有权。

Java对象头

Java对象头存储了对象锁类型和状态。Java对象头可以包含3个部分,对于普通对象,其对象头只包含前两部分。

2.1 Java对象头字段
长度 内容 说明
32/64bit Mark Word 存储对象的HashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/32bit Array Length(仅当对象是数组类型时需要) 数组的长度

在运行期间,Mark Word会随对象锁状态而变化。

2.2 Mark Word的状态变化(32bit)
锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否是偏向锁 锁标志位
无锁 对象的HashCode 对象分代年龄 0 01
偏向锁 线程ID Epoch 对象分代年龄 1 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC标记 11

Java锁状态与锁升级

4种锁状态,从低级到高级依次是无锁、偏向锁、轻量级锁和重量级锁,锁只能升级不能降级。

①偏向锁

锁的获取:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。以后该线程进入或退出同步块,只需简单地测试一下对象头的Mark Word中是否存储了指向当前线程的偏向锁:如果测试成功,表明该线程已获得锁;如果测试失败,则需检测Mark Word中的偏向锁标识是否为1:如果不是,则使用CAS竞争锁;如果是,则尝试使用CAS将对象头的偏向锁指向当前进程。

锁的释放:偏向锁使用的是等到竞争出现才释放锁的机制,其释放前需要等待全局安全点(没有正在执行的字节码)。释放偏向锁,首先会暂停拥有偏向锁的进程,然后判断该进程是否处于活动状态:如果不是,则将对象头设置为无锁状态;如果是,则拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(需要锁升级),最后唤醒被暂停的线程。

锁的升级:当一个线程已经获取了一个对象的偏向锁时,一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,操作系统检查原来持有该对象锁的线程是否依然存活:如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程;如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况:如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁;如果不存在使用了,则可以将对象恢复成无锁状态,然后需要时再重新偏向。

图2.1 偏向锁的初始化流程图

关闭偏向锁的默认使用:-XX:-UseBiasedLocking=false,程序默认会进入轻量级锁状态。

②轻量级锁

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

自旋:实际上就是线程在等待锁释放,会消耗CPU资源。

锁的释放:解锁时,线程会使用CAS操作将Displaced Mark Word替换回对象头,如果成功,表明没有竞争发生;如果失败,表明当前锁存在竞争,锁就好膨胀为重量级锁。

图2.2 争夺锁导致锁膨胀流程图

③重量级锁

当锁处于该状态下,其他线程试图获取锁(竞争锁)时都会被阻塞,当持有锁的线程释放锁之后会唤醒这些线程。

2.3 锁的比较
优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在所竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步块的情况
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果竞争的线程始终得不到锁,使用自旋会消耗大量CPU资源

追求响应时间

同步块执行时间很短的情况

重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间慢

追求吞吐量

同步块执行时间较长

3.原子操作

(1)处理器实现原子操作

机制1——通过锁总线保证原子性

使用处理器提供的LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器对总线的请求将被阻塞,此时该处理器就可以独占共享内存。

机制2——通过缓存锁定来保证原子性

锁总线的开销大,因为其他处理器无法访问共享内存的其他地址的数据。

当数据存储在缓存cache中时,可以不用声言总线锁,而是利用缓存锁定缓存一致性协议来保证原子操作。

(这一部分没看懂,修改内部的内存地址是什么操作???)

不能使用缓存锁定的两种情况:①操作的数据不能缓存或者跨多个缓存行,这时只能调用总线锁定;②处理器不支持。

(2)Java实现原子操作

使用循环CAS实现原子操作

CAS的实现:JVM中的CAS操作是利用处理器提供的CMPXCHG(比较交换)指令实现的,自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

存在的问题:①ABA问题:要比较的值原本为A变成了B又变成了A(并发导致的问题)。解决方法是每一次值都追加上一个版本号,即上面的变化改为1A变成了2B再变成了3A。②循环时间开销大:循环CAS操作会一直占用CPU的开销。③只能保证一个共享变量的原子操作,多个共享变量只能利用锁或者将它们合并成一个共享变量。

使用锁机制实现原子操作

锁机制保证了只有获取锁的线程才能够操作锁定的内存区域。JVM内部实现锁的方式,除了偏向锁,都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获得锁,退出同步块的时候使用循环CAS释放锁。

Java中的atomic包

从JVM和处理器层面学习Java并发用到的底层原理,对volatile、锁和原子操作的深入理解,对于并发编程时会有更好的理解以及对具备分析和解决遇到的问题的能力。

猜你喜欢

转载自blog.csdn.net/hyhy12580/article/details/99494006