Java并发2:JMM,volatile,synchronized,final

并发编程的两个关键问题

并发编程须要处理两个关键问题:线程之间如何通讯以及线程之间如何同步java

通讯是指线程之间以何种机制来交换信息。线程之间的通讯机制有两种:共享内存和消息传递。git

共享内存模型中,线程之间共享程序的公共状态,经过读-写内存中的公共状态进行隐式通讯。多条线程共享一片内存,发送者将消息写入内存,接收者从内存中读取消息,从而实现了消息的传递。程序员

消息传递模型中,线程之间经过发送消息来进行显式通讯。github

同步是指程序中用于控制不一样线程间操做发生相对顺序的机制。在共享内存模型中,须要进行显式的同步,程序员必须显式指定某段代码须要在线程之间互斥执行;在消息传递模型中,消息发送必须在消息接收以前,所以同步是隐式进行的。编程

Java采用的是共享内存模型。数组

Java内存模型

在 Java 中,全部实例域、静态域和数组元素存放在堆内存,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享。缓存

Java 线程之间的通讯由 Java 内存模型控制,JMM 决定一个线程对共享变量的写入什么时候对另外一个线程可见。线程之间的共享变量存储在主内存中,每一个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

当线程A与线程B之间要通讯的话,首先线程A将本地内存中更新过的共享变量刷新到主内存;而后线程B到主内存去读取线程A以前已经更新过的共享变量。

Java 内存模型和硬件的内存架构不一致,是交叉关系。不管是堆仍是栈,大部分数据都会存储到内存中,一部分栈和堆的数据也有可能存到CPU寄存器中。Java内存模型试图屏蔽各类硬件和操做系统的内存访问差别,以实现让Java程序在各类平台下都能达到一致的内存访问效果。安全

Java 内存模型的三大特性:原子性、可见性和顺序性多线程

原子性

原子性就是指一个操做中要么所有执行成功,不然失败。Java内存模型容许虚拟机将没有被volatile修饰的64位数据(long,double)的读写操做划分为两次32位操做进行。架构

i++这样的操做,实际上是分为获取i,i自增以及赋值给i三步的,若是要实现这样的原子操做就须要使用原子类实现,或者也可使用synchronized互斥锁来保证操做的原子性。

CAS

CAS 也就是 CompareAndSet, 在Java中能够经过循环CAS来实现原子操做。在JVM内部,除了偏向锁,JVM实现锁的方式都是用了CAS,也就是当一个线程想进入同步块的时候使用CAS获取锁,退出时使用CAS释放锁。

可见性

可见性指的是当一个线程修改了共享变量的值,其余线程可以当即得知这个修改。Java内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

重排序

执行程序时,为了提升性能,编译器和处理器经常会对指令进行重排序。

  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,从新安排语句执行顺序
  • 指令级并行重排序:处理器采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应及其的执行顺序。
  • 内存系统的重排序:处理器使用缓存和读/写缓冲区,使得加载和存储操做看上去多是乱序执行。

重排序可能致使多线程程序出现内存可见性问题。JMM 经过插入特定类型的内存屏障指令来禁止特定类型的处理器重排序,确保了不一样的编译器和处理器平台上,能提供一致的内存可见性保证。

数据依赖性

若是两个操做访问同一个变量,且这两个操做中有一个是写操做,这两个操做之间就存在数据依赖性。在重排序时,会遵照数据依赖性,不会改变存在数据依赖关系的两个操做的执行顺序,也就是不会重排序。可是,这是针对单个处理器或单个线程而言的,多线程或多处理器之间的数据依赖性不被考虑在内。

as-if-serial

无论怎么重排序,单线程程序的执行结果不能被改变。as-if-serial 语义使得单线程程序员无需担忧重排序的干扰。

重排序可能会改变多线程程序的执行结果,以下图所示

happens-before

JMM 一方面要为程序员提供足够强的内存可见性保证;另外一方面,对编译器和处理器的限制要尽量放松。

JMM 对不一样性质的重排序,采起了不一样的策略:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM 不作要求,容许重排序。 也就是说,JMM 遵循的基本原则是:只要不改变程序的执行结果,编译器和处理器怎么优化都行。

JSR-133 中对 happens-before 关系定义以下:

  1. 若是一个操做 happens-before 另外一个操做,那么第一个操做的执行结果将对第二个操做可见,且第一个操做的执行顺序排在第二个操做以前。
  2. 两个操做中间存在 happens-before 关系,若是重排序以后的执行结果与按照 happends-before 执行结果一致,JMM 容许这种重排序。

happens-before 与 as-if-serial 相比,后者保证了单线程内程序的执行结果不被改变;前者保证正确同步的多线程程序的执行结果不被改变。

JSR-133中定义了以下的 happens-before 规则:

  • 单一线程原则:在一个线程内,程序前面的操做先于后面的操做。
  • 监视器锁规则:一个unlock操做先于后面对同一个锁的lock操做发生。
  • volatile变量规则:对一个 volatile 变量的写操做先行发生于后面对这个变量的读操做,也就是说读取的值确定是最新的。
  • 线程启动规则:Thread对象的start()方法调用先行发生于此线程的每个动做。
  • 线程加入规则:Thread 对象的结束先行发生于 join() 方法返回。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,能够经过 interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  • 传递性:若是操做 A 先行发生于操做 B,操做 B 先行发生于操做 C,那么操做 A 先行发生于操做 C。

可见性实现

可见性有三种实现方式:

  • volatile
  • synchronized 对一个变量执行 unlock 操做以前,必须把变量值同步回主内存
  • final 被 final关键字修饰的字段在构造器中一旦初始化完成,而且没有发生 this 逃逸(其它线程经过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

顺序性

数据竞争

在一个线程中写一个变量,在另外一个线程中读一个变量,并且写和读没有经过同步来排序。

JMM 中的顺序性

在理想化的顺序一致性内存模型中,有两大特性:

  • 一个线程中的全部操做必须按照程序的顺序来执行
  • 全部线程都只能看到一个单一的操做执行顺序。

JMM对正确同步的多线程程序的内存一致性作了以下保证:若是程序是正确同步的,程序的执行将具备顺序一致性,也即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

JMM 的实现方针为:在不改变正确同步的程序执行结果的前提下,尽量为优化提供方便。所以,JMM 与上述理想化的顺序一致性内存模型有以下差别:

  • 顺序一致性模型保证单线程操做按照顺序执行;JMM 不保证这一点(临界区内能够重排序)
  • JMM 不保证全部线程看到一致的操做执行顺序
  • JMM 不保证对64位的 long 和 double 类型变量的写操做具备原子性。

Java中可使用volatile关键字来保证顺序性,还能够用synchronized和lock来保证。

  • volatile 关键字经过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障以前。
  • 经过 synchronized 和 lock 来保证有序性,它保证每一个时刻只有一个线程执行同步代码,至关因而让线程顺序执行同步代码。

volatile

volatile 关键字解决的是内存可见性的问题,会使得全部对 volatile 变量的读写都会直接刷新到主存,保证了变量的可见性。

要注意的是,使用 volatile 关键字仅能实现对原始变量操做的原子性(boolean,int,long等),不能保证符合操做的原子性(如i++)。

一个 volatile 变量的单个读/写操做,和使用同一个锁对普通变量的读/写操做进行同步,执行的效果是相同的。锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总能看到对这个变量最后的写入,从而实现了可见性。须要注意的是,对任意单个 volatile 变量的读/写具备原子性,可是相似于i++这种复合操做不具备原子性。

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到内存。 当读一个 volatile 变量时,JMM 会把线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

具体来讲,线程A写一个 volatile 变量,实质上是线程A向接下来将要读这个 volatile 变量的线程发出了它修改的信息;线程B读一个 volatile 变量,实质上是线程B接收了以前某个线程发出的修改信息。

synchronized

JVM 是经过进入和退出对象监视器来实现同步的。Java 中的每个对象均可以做为锁。

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

synchronized使用

  • https://juejin.im/post/5c0b6a5e51882521c8116c3c
  • https://juejin.im/post/5c0b9dc4e51d45022a15db8a

锁优化

JDK 1.6 中对 synchronized 进行了优化,为了减小获取和释放锁带来的消耗引入了偏向所和轻量锁。也就是说锁一共有四种状态,级别从低到高分别是:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。锁能够升级可是不能降级。

Java头

synchronized 使用的锁是存放在 Java 对象头中的。若是对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,若是对象是非数组类型,则用2字宽存储对象头。

Java 头中包含了Mark Word,用来存储对象的 hashCode 或者锁信息,在运行期间其中存储的数据会随着锁的标志位的变化而变化。

偏向锁

大多数状况下,锁不只不存在多线程竞争,并且老是由统一线程屡次得到,为了让线程获取锁的代价更低而引入了偏向锁。

它的核心思想是:若是一个线程得到了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再作任何同步操做。这样就节省了大量有关锁申请的操做,从而提升了程序性能。所以,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,由于连续屡次极有多是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。

释放锁:当有另一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来断定将对象头中的 Mark Word 设置为无锁或者是轻量锁状态。

轻量级锁

加锁: 当代码进入同步块时,若是同步对象为无锁状态时,当前线程会在栈帧中建立一个锁记录(Lock Record)区域,同时将锁对象的对象头中 Mark Word 拷贝到锁记录中,再尝试使用 CAS 将 Mark Word 更新为指向锁记录的指针。若是更新成功,当前线程就得到了锁。若是更新失败 JVM 会先检查锁对象的 Mark Word 是否指向当前线程的锁记录。若是是则说明当前线程拥有锁对象的锁,能够直接进入同步块。不是则说明有其余线程抢占了锁,尝试使用自旋锁来获取锁。

**解锁:**轻量锁的解锁过程也是利用 CAS 来实现的,会尝试锁记录替换回锁对象的 Mark Word 。若是替换成功则说明整个同步操做完成,失败则说明有其余线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁)

三种锁的对比:

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

volatile和synchronized比较

  • volatile 本质是告诉jvm当前变量在工做内存中的值是不肯定的,须要从主存读取;synchronized 是锁定当前变量,只有当前线程能够访问该变量,其余线程被阻塞
  • volatile 只能使用在变量级别;synchronized 可使用在变量、方法和类级别
  • volatile 仅能实现变量可见性,不能保证原子性;synchronized 能够保证变量的可见性和原子性
  • volatile 不会形成线程阻塞;synchronized 可能会形成线程的阻塞
  • volatile 标记的变量不会被编译器优化,synchronized 标记的变量能够被编译器优化

锁内存语义

释放锁与volatile写有相同的内存语义,线程A释放锁,是A向要获取锁的线程发出A对共享变量修改的消息。 获取锁与volatile读有相同的内存语义,是线程B接收了以前线程发出的堆共享变量作的修改的消息。

从 ReentrantLock 中能够看到:

  • 公平锁和非公平锁的释放,都须要写一个volatile变量 state
  • 公平锁的获取,首先要读 volatile 变量
  • 非公平锁的获取,用CAS更新volatile变量,同时有volatile读、写的内存语义

在juc包中源代码实现,能够发现Java线程之间通讯的通用化实现模式:

  1. 首先声明共享变量为 volatile
  2. 使用CAS的原子条件更新来实现线程之间同步
  3. 配合以 volatile 的读/写和CAS所具备的读和写的内存语义来实现线程间通讯。

final域

重排序规则

对于 final 域,遵循两个重排序规则:

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操做不能重排序
  2. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操做之间不能重排序。
public class FinalExample{
    int i;
    final int j;
    static FinalExample obj;
    
    public FinalExample(){
        i=1;
        j=2;
    }
    
    public static void writer(){
        obj=new FinalExample();
    }
    
    public static void reader(){
        FinalExample object=obj;
        int a=object.i;
        int b=object.j;
    }
}
复制代码

假设线程A执行 writer() 方法,线程B执行 reader() 方法。

写final域的重排序规则 写 final 域的重排序规则禁止把 final 域的写重排序到构造函数以外。从而确保了在对象引用被任意线程可见以前,对象的final域已经被正确的初始化过了。在上述的代码中,线程B得到的对象,final域必定被正确初始化,普通域i却不必定。

读final域的重排序规则 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序该操做。从而确保在读一个对象的final域以前,必定会先读包含这个final域的对象的引用

final域为引用类型 在构造函数内对一个final引用的对象的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,不能重排序。

可是,要获得上述的效果,须要保证在构造函数内部,不能让这个被构造对象的引用被其余线程所见,也就是不能有this逸出。

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

https://juejin.im/post/5c122d00e51d4541284cc592


参考资料

  • Java并发编程的艺术
  • Java多线程编程的艺术
  • https://blog.csdn.net/suifeng3051/article/details/52611310
  • https://github.com/CyC2018/CS-Notes/blob/master/docs/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E5%8D%81java-%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B
  • https://blog.csdn.net/u010425776/article/details/54290526
相关文章
相关标签/搜索