转载

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

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

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

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

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

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

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

Java内存模型

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

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

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

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

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

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

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

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 语义使得单线程程序员无需担心重排序的干扰。

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

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

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 不保证对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使用

  • juejin.im/post/5c0b6a…
  • juejin.im/post/5c0b9d…

锁优化

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

Java头

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

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

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

偏向锁

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

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

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

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

轻量级锁

加锁:当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(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 标记的变量可以被编译器优化

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/5c219a895188257800086554
正文到此结束
Loading...