读书笔记之Java并发原理与JMM

来源 :博客园 | 作者 : 到点|链接:阅读原文

备注:本文是《Java并发编程的艺术》一书的读书笔记。

1
Java并发机制的底层原理实现

volatile的应用

  volatile是轻量级的synchronized,他在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。


synchronized的实现原理与应用

  synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3中形势* 

 1.对于普通同步方法,锁是当前实例对象。
 2.对于静态同步方法,锁是当前类的Class对象。
 3.对于同步方法块,锁是Synchronized括号里配置的对象。
 当一个线程试图访问同步代码块时,它首先必须得到锁,推出或抛出异常时必须释放锁。  

偏向锁

  当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。  

轻量级锁

  线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦确认升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。  

锁的优缺点对比

                                                                                                                                                                       

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了线程的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间
同步块执行速度非常快
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量
同步块执行速度较长

原子操作的实现原理

 

原子(atomic)本意是“不能被进一步分割的最小粒子“,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作“。  

术语定义

                                                                                                                                                               

术语名称英文解释
缓存行Cache line缓存的最小操作单位
比较并交换Compare and SwapCAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换
CPU流水线CPU pipelineCPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再由这点电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度
内存顺序冲突Memory order violation内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线
处理器如何实现原子操作
  处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

(1)使用总线锁保证原子性:第一机制是通过总线锁保证原子性。保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独享共享内存。
(2)使用缓存锁保证原子性:第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其它内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场景下使用缓存锁定代替总线锁定来进行优化。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当其他执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,到其他处理器回写已被锁定的缓存行数据时,会使缓存行无效。
但是有两种情况下处理器不回使用缓存锁定:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。当有些处理器不支持缓存锁定。

2
Java如何实现原子操作

(1)使用循环CAS实现原子操作:JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。从Java1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean。
(2)CAS实现原子操作的三大问题ABA问题,因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有变化,但是实际上却变化了。ABA问题的解决思路是使用版本号,在变量前追加版本号,每次变量更新的时候把版本号加1,那么A-B-A就会变成了1A-2B-3A。从JAVA1.5开始,JDK的Atomic包里提供了一个类AtomicStampedreference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标识的值设置为给定的更新值。循环时间长开销大,自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟的时间取决于具体的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的执行效率。只能保证一个共享变量的原子性,当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。Java1.5以后JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
(3)使用锁机制实现原子操作:锁机制保证了只有获得锁的线程才能狗操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

3
Java内存模型

Java内存模型的基础

  在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通行是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种: 内存共享 消息传递
   

同步 是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显性进行的。程序员需要显性执行某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接受之前,因此同步是隐形进行的。

Java并发采用的是共享内存模型。

JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性。

从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

happens-before简介
  JSR-133(JDK1.5开始Java使用的新的内存模型)使用 happens-before 的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程内,也可以是在不同线程之间。
   

程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
 volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个 volatile域的读。
 传递性:如果A heppens-before于B,且B happnes-before C, 那么A happens-before C。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行,happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

重排序

  重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
as-if-serial语义
  as-if-serial的语义是:不管怎么重排序,单线程程序执行的结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

volatile的内存语义

  • 可见性。对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入。

  • 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。


volatile写-读的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:但读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

锁的内存语义

锁的释放和获取的内存语义
  • 当线程释放锁时:JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。  

  • 当线程获取锁时:JMM会把该线程对应的本地变量置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。


CAS具有volatile读和volatile写的内存语义

公平锁与非公平锁的内存语义(ReentrantLock为例):

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state。  

  • 公平锁获取时,首先会去读volatile变量。  

  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。


从对ReentrantLock的分析可以看出,释放锁-获取锁的内存语义的实现至少有下面两种方式:

  • 利用volatile变量的写-读所具有的内存语义。

  • 利用CAS所附带的volatile读和volatile写的内存语义。  


final域的内存语义

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。


  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。


JSR-133为什么要增强final的语义?
在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变,比如,一个线程当前看到一个证书final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final域的值时,却发现值变成了1(被某个线程初始化之后的值)。

happens-before

happens-before定义:  

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(是JMM对程序员的承诺)

  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系执行的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一直,那么这种重排序是合法的。(是JMM对编译器和处理器重排序的约束原则)


  as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。 

 as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程的程序员创造了一个幻境:正确同步的多线程程程是按happens-before指定的顺序来执行的。

happens-before规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  • 传递性:如果A happens-before B, 且B happens-before C, 那么A happens-before C。

  • start()规则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。


双重检查锁定与延迟初始化

public class DoubleCheckedLocking {
   private static Instance instance;

   public static Instance getInsatnce() {
       if (instance == null) {
           synchronized(DoubleCheckedLocking.class) {
               if (instance == null) {
                   instance = new DoubleCheckedLocking();
               }
           }
       }
   }
}
 
 
  • 双重检查是比较常见的一种延迟初始化方案,但是还是会存在一些问题:

  
  
memory = allocate();    
ctorInstance(memory);  
instance = memory;


上面伪代码中2和3可能会被重排序,这种情况下返回的instance引用可能还没有初始化完成。这个时候我们要从两个方面解决问题:
1.不允许2和3重排序。
2.允许2和3重排序,但不允许其他线程“看到”这个重排序。

基于volatile的解决方案
public class DoubleCheckedLocking {
   private volatile static Instance instance;

   public static Instance getInsatnce() {
       if (instance == null) {
           synchronized(DoubleCheckedLocking.class) {
               if (instance == null) {
                   instance = new DoubleCheckedLocking();
                   
               }
           }
       }
   }
}
 
 
  • 基于类初始化的解决方案

  
  
public class DoubleCheckedLocking {
   private static class InstanceHolder {
       public static Instance instance = new Instance();
   }

   public static Instance getInsatnce() {
       return InstanceHolder.instance;
   }
}
推荐阅读

20届大厂收割之路

Java中class的初始化顺序

Java多线程系列-之生产消费者问题

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值