《Java并发编程的艺术》第二章——Java并发机制的底层实现

来源:互联网 发布:贫贱夫妻百事哀 知乎 编辑:程序博客网 时间:2024/06/06 02:07
Java并发机制的底层实现原理


知识点:
  1. volatile的应用
  2. synchronized的实现原理及应用
  3. 原子操作的实现原理
1.volatile的应用
Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致的更新,线程应该确保通过排他锁单独获取这个变量。
也就是说,使用volatile修饰的变量,可以保证其“可见性”。
  • 何为“可见性”?
就是一个线程的修改,可以让另一个线程”感知“到。
  • 为什么需要“可见性”?
所谓“共享变量”就是所有线程都可以使用的变量,存储于内存中,每个使用到该变量的线程,都要保存一份变量的副本到自己对应的CPU高速缓存中,每个CPU高速缓存都是相互独立的,当CPU修改共享变量后(其实只是修改的共享变量的副本),需要写回到内存中,而写回到内存中这个操作,时机是不确定的,所以就可能造成该共享变量已经修改(但并未写回内存),但其他高速缓存中仍然保存着旧值的副本的情况。
  • volatile 的作用及原理?
volatile在此刻隆重登场,使用volatile修饰的共享变量,会在编译时增加一个“Lock”的前缀指令,该指令会引发两件事情:
1)将当前处理器缓存行的数据立即写回系统内存。
2)这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。
为了提高运行速度,CPU是不和内存直接通信的,而是把系统内存的数据缓存到内部缓存再进行操作,操作后,并不确定何时写回内存,而使用volatile修饰的变量会让CPU将当前缓存行立即写回内存。但即使在写回内存后,其他CPU里缓存的数据仍然可能是旧值,所以,在多处理器下,就会实现缓存一致性协议来避免这个问题。每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前缓存行设置为无效状态,当处理器需要对这个数据进行操作时,再重新从内存中读取。
  • volatile的使用优化
追加字节优化性能:JDK7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。原理是:若队列的头节点和尾节点都不足64字节时,头节点和尾节点会被读取到同一个缓存行中,当一个处理器试图修改头节点时,需要锁定整个缓存行,那么在缓存一致性协议下,会导致其他处理器不能访问自己缓存中的尾节点(因为他的缓存已经无效,需要重新从内存中读取),而出队入队操作会不停的修改头节点和尾节点,会严重影响性能。所以采用追加到64字节来避免该问题。
【备注】虽然追加字节的方式可以提高性能,但并不是所有场景都适用,如:缓存行非64字节宽的处理器,共享变量不会被频繁的写。
2.synchronized的实现原理及应用
synchronized是多线程并发编程中的元老,亦可称为”重量级锁“。
以下例子展示synchronized的用法:
实例一:
package com.lipeng.second;import java.util.concurrent.TimeUnit;/** * 使用Synchronized修饰方法,同一时间只能有一个线程访问被同步的代码 * SyncDemo1-Thread-1、SyncDemo1-Thread-2访问同步代码 * SyncDemo1-Thread-3、SyncDemo1-Thread-4访问非同步代码 * @author promi * */public class SyncDemo1 {static Sync1 sync=new Sync1();public static void main(String[] args) {Thread thread1=new Thread(new Runnable() {@Overridepublic void run() {sync.syncAction();}},"SyncDemo1-Thread-1");Thread thread2=new Thread(new Runnable() {@Overridepublic void run() {sync.syncAction();}},"SyncDemo1-Thread-2");Thread thread3=new Thread(new Runnable() {@Overridepublic void run() {sync.noSyncAction();}},"SyncDemo1-Thread-3");Thread thread4=new Thread(new Runnable() {@Overridepublic void run() {sync.noSyncAction();}},"SyncDemo1-Thread-4");thread1.start();thread2.start();thread3.start();thread4.start();}}class Sync1{public synchronized void syncAction(){System.out.println(Thread.currentThread().getName()+" 执行syncAction方法 ,TimeStrap:"+System.currentTimeMillis());try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}}public void noSyncAction(){System.out.println(Thread.currentThread().getName()+"执行noSyncAction方法,TimeStrap:"+System.currentTimeMillis());try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}}}



运行结果:


通过结果可以看到线程1和2在执行同步代码时,线程1先获取到锁,直到3秒后释放锁,线程2才可以获取到锁并执行。
实例二:
package com.lipeng.second;import java.util.concurrent.TimeUnit;/** * 使用synchronized封装代码块获取"对象锁" * SyncDemo1-Thread-1、SyncDemo1-Thread-2访问同步代码 * SyncDemo1-Thread-3、SyncDemo1-Thread-4访问非同步代码 * @author promi * */public class SyncDemo2 {static Sync2 sync=new Sync2();public static void main(String[] args) {Thread thread1=new Thread(new Runnable() {@Overridepublic void run() {sync.syncAction();}},"SyncDemo2-Thread-1");Thread thread2=new Thread(new Runnable() {@Overridepublic void run() {sync.syncAction();}},"SyncDemo2-Thread-2");Thread thread3=new Thread(new Runnable() {@Overridepublic void run() {sync.noSyncAction();}},"SyncDemo2-Thread-3");Thread thread4=new Thread(new Runnable() {@Overridepublic void run() {sync.noSyncAction();}},"SyncDemo2-Thread-4");thread1.start();thread2.start();thread3.start();thread4.start();}}class Sync2{public void syncAction(){synchronized(this){System.out.println(Thread.currentThread().getName()+" 执行syncAction方法 ,TimeStrap:"+System.currentTimeMillis());try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}}}public void noSyncAction(){System.out.println(Thread.currentThread().getName()+" 执行noSyncAction方法 ,TimeStrap:"+System.currentTimeMillis());}}

运行结果:


Synchronized(this)表示在执行该代码块之前需要获取到对象锁。在线程1获取到对象锁后,其他线程仍然可访问其他未同步的代码。
实例三:
package com.lipeng.second;import java.util.concurrent.TimeUnit;/** * 使用synchronized封装代码块获取"类锁" * SyncDemo1-Thread-1、SyncDemo1-Thread-2访问同步代码 * SyncDemo1-Thread-3、SyncDemo1-Thread-4访问非同步代码 * @author promi * */public class SyncDemo3 {public static void main(String[] args) {Thread thread1=new Thread(new Runnable() {@Overridepublic void run() {Sync3 sync=new Sync3();sync.syncAction();}},"SyncDemo3-Thread-1");Thread thread2=new Thread(new Runnable() {@Overridepublic void run() {Sync3 sync=new Sync3();sync.syncAction();}},"SyncDemo3-Thread-2");Thread thread3=new Thread(new Runnable() {@Overridepublic void run() {Sync3 sync=new Sync3();sync.noSyncAction();}},"SyncDemo3-Thread-3");Thread thread4=new Thread(new Runnable() {@Overridepublic void run() {Sync3 sync=new Sync3();sync.noSyncAction();}},"SyncDemo3-Thread-4");thread1.start();thread2.start();thread3.start();thread4.start();}}class Sync3{public void syncAction(){synchronized(Sync3.class){System.out.println(Thread.currentThread().getName()+" 执行syncAction方法 ,TimeStrap:"+System.currentTimeMillis());try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}}}public void noSyncAction(){System.out.println(Thread.currentThread().getName()+" 执行noSyncAction方法 ,TimeStrap:"+System.currentTimeMillis());}}

运行结果:

实例一和二的锁,都是对象锁,即每个线程中使用的都是同一个Sync对象,若在每个线程中声明不同的Sync对象,则不会出现线程阻塞等待锁的情况,因为每个线程获取到及需要获取的对象锁并不是同一个。但实例三种同步代码块中需要获取“类锁”,即使在每个线程中声明不同的Sync对象,也避免不了锁的等待。因为该锁是“属于类的”,是同一个。
总结synchronized使用形式:
a),对于普通方法,锁是当前对象。
b),对于静态同步方法,锁是当前类的Class对象。
c),对于同步方法块,锁是synchronized括号里配置的对象。
  • 那么synchronized在JVM里是怎么实现的呢?
每个对象都有一个monitor对象与之关联,JVM基于进入和退出Monitor对象来实现方法同步和代码同步。但两者实现细节不同。代码同步是使用monitorenter,monitorexit(这两个指令必须成对出现)指令实现。方法同步具体细节在JVM规范里并未说明,但通过这两个指令同样可以实现。
  • synchronized用的锁是存在哪里的?
synchronized用到的锁信息是存在Java对象头里的。
  • 对象头的结构?
Java对象头在32位/64位系统中,分别使用8个字节及16个字节来表示。其中Mark Word占用4个字节(8个字节),Class Metadata Address占用4个字节(8个字节)。
Mark Word主要存储对象的hashCode、分代年龄、锁信息等。
Class Metadata Address主要存储指向对象类型信息的指针。
以32位系统为例,Java对象头的存储结构如下:

但对于不同的锁及其状态而言,4个字节不足以表示其信息,所以按照锁标志位的不同,来存储不同的信息。

  • 偏向锁
在Java SE1.6中,为了减少获取锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6中,所共有4中状态,从低到高为:无锁状态,偏向锁,轻量级锁,重量级锁。
a),CAS:compare and swap:
存在3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
b),偏向锁加锁过程:
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录存储偏向的线程ID,以后该线程再进入和退出同步块时只需要检测对象头中Mark Word中是否存储当前线程的ID即可,而不需要进行CAS操作来进行加锁和解锁操作。如果测试成功,则获得锁,若测试失败,则检测Mark Word中锁标志位是否设置为01,如果为01,则进行CAS操作将对象头偏向锁存储的线程ID指向当前线程。若不为01,则使用CAS竞争锁。
c),偏向锁解锁过程:
偏向锁使用一种等到竞争才会释放的机制。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。
d),关闭偏向锁
偏向所在Java6和Java7里是默认开启的,但是它在应用程序启动几秒钟后才激活,如果必要刻意使用JVM参数来关闭延迟:-XX:BiasedLockingStartUpDelay=0.或直接关闭偏向锁:-XX:-UseBiasedLocking=false。
偏向锁的初始化流程图如下:   

  • 轻量级锁

a),轻量级锁加锁:
线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(DIsplaced Mark Word)。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,则获得锁。如果失败,则表示有其他线程竞争锁,当前线程尝试使用自旋来获取锁。
b),轻量级锁解锁:
在解锁时,会使用原子的CAS操作将DIsplaced Mark Word替换回对象头,如果成功,则表示没有竞争发生。如果失败,则表示当前所存在竞争,锁会膨胀为重量级锁。竞争该锁的线程全部会阻塞,知道当前线程释放该锁。
锁膨胀为重量级锁流程图如下:


c),锁的优缺点对比



3.原子操作的实现原理
原子本意是“不能被进一步分割的最小粒子”,而原子操作意为“不可被中断的一个或一个系列操作”。
  • 处理器如何实现原子操作?
a),使用总线锁保证原子性:当一个处理器使用总线时,在总线上输出LOCK#信号,其他处理器使用总线操作该共享内存的请求就会被阻塞,那么该处理器可以独占共享内存,从而保证操作的原子性。
b),使用缓存锁保证原子性:当一个处理器对缓存行中的共享变量进行操作时,通过缓存一致性协议,让其他处理器中缓存中的该共享变量无效,从而保证操作的原子性。
  • Java中如何实现原子操作?
a),通过循环CAS:循环进行CAS操作直到成功为止。<br>
b),通过锁机制保证只有获得锁的线程才能操作锁定的内存区域。<br>
【备注】:CAS实现原子操作带来的问题:
ABA问题:若共享变量修改过程为A->B->A,进行CAS操作时,虽然值发生过更改,但又变成了预期值。解决方法是在变量前追加版本号。1A->2B->3A。
循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来很大的开销。
只能保证一个共享变量的原子操作:CAS无法保证对多个共享变量的原子操作。


【备注】:本文图片均摘自《Java并发编程的艺术》·方腾飞,若本文有错或不恰当的描述,请各位不吝斧正。谢谢!

阅读全文
0 0
原创粉丝点击