最强的Java并发模型

434次阅读  |  发布于3年以前

大家好, 今天给大家带来了一篇Java并发模型文章,长文预警,文章有点长,但是干货非常多。

我相信在工作中,大家多多少少都会有使用到一些并发的工具类(java.util.concurrent),比如:ReentrantLock

他们的出现就是为了解决Java并发出现的并发三大问题:重排序、内存的可见性、原子性,保证多线程条件下Java的语义能够正确的执行,得到预期的结果。

下面我们就围绕着这三大问题详细聊一聊Java的并发模型。

并发编程模型

在并发编程中,主要解决的问题就是线程之间的如何实现通信,通信的机制主要有两种:共享内存通信消息传递Java的并发编程模型中使用的是共享内存的方式进行通信(JMM)

在理解JMM之前,大家先在自己的电脑上运行一下下面的这段程序:

public class JMMExample {

 int a = 0;
 boolean flag = false;
 public void writer() {
  a = 1; // 步骤1
  flag = true; // 步骤2
 }

 public void reader() {
  System.err.println(flag+"===="+a);
  if (flag) { // 步骤3
   System.err.println(flag+"===="+a);
   int i = a * a; // 步骤4
  }
 }

 public static void main(String[] args) throws InterruptedException {
  for (;;){
   Thread.sleep(300);
   JMMExample jmmExample = new JMMExample();
   Thread a = new Thread(new Runnable() {
    @Override
    public void run() {
     jmmExample.reader();
    }
   });

   Thread b= new Thread(new Runnable() {
    @Override
    public void run() {
     jmmExample.writer();
    }
   });

   a.start();
   b.start();
  }
 }
}

这段程序很简单,可能出现的结果有多种:

false====0
true====1
false====0
true====1
false====1
true====1
false====0
true====1
false====0

false====1、false====0、true====1、true====0,这四种情况都有可能,但是有两种出现的概率比较小false====1、true====0。

先来详细了解一下Java多线程的通信机制(JMM模型),还是使用上面的代码案例两个线程a和线程b,假如线程a要与线程b通信:

首先线程a会把主内存的共享变量a加载到自己的工作内存中,然后把a修改为1,再通过步骤2将共享变量刷到主内存中,接着线程b从主内存中通过步骤3将共享变量加载到自己的工作内存中,最后线程b就可以实现对变量a的自由操作。

但是在工作内存中是仅仅对当前线程可见,并且存储的是共享变量的副本,之所以加入这个线程工作内存是有原因的。

工作内存是抽象出来的概念,为了解决CPU与主内存之间运行速率的差距,引入了线程的工作内存,实际不存在,它主要涉及到缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

上面就是完整的JMM模型,至于为什么会出现false====1、true====0的结果,在下面的重排序部分,我们会进行详细的分析。

并发三剑客

JMM属于较弱的内存模型,为了保证性能JMM并没有强制禁止大量处理器和编译器的优化行为,为性能打开了一扇大门,但是凡事有利也有弊。

性能得到提升的同时,也会出现并发编程中的三大问题:重排序、内存可见性、原子性问题。

重排序

上面的歹说到会出现四种情况,其中之所以会出现了false====1、true====0是因为出现了重排序,原本指令执行的顺序并不是按照程序的顺序执行。

在没有正确使用一些同步机制、顺序机制、禁止指令重排机制的情况下,都会出现指令的重排。

下面我们通过图解来深入重排序,还是沿用上面的代码:

根据上面的执行顺序当线程a在进入if(flag)之前,线程b就已经将a修改为1,但是还未来得及修改flag,此时就会出现false====1的情况。

假如另一种情况线程b先执行flag = true,为什么线程b能够先执行flag = true的赋值呢?

因为在JMM内存模型中,对于没有数据依赖的两个步骤可以实现重排序,那么此时按照上面的时序图,在步骤4后输出flag和a的值,就有可能出现true====0

因为在输出flag和a的值时线程b还没有执行a=1的赋值操作,a还是初始值为0。

在Java中重排序主要分为编译器重排序处理器重排序

  1. 编译器重排序:在不改变单线程程序语义的条件下,可以重新安排语句的执行顺序。
  2. 指令级并行重排序:如果上下文不存在数据以来,那么处理器可以改变对应机器指令的执行顺序。
  3. 内存系统重排序:内存系统并不会出现重排序,只是因为存在读/写缓冲区,内存中可能会出现乱序的现象。

对于重排序的具体实现,比如volatile 关键字,是在代码生成对应的指令序列的时候,在特定的位置拆入特定类型的内存屏障指令 ,通过内存屏障指令能够禁止特定类型的处理器重排序。

对于这个,这里只做一个简单的介绍,后面介绍volatile 关键字的时候,会详细的深入。

内存可见性

内存的可见性问题是因为线程的工作内存引起的,因为要提高CPU的使用效率,就加入工作内存,但是工作内存由仅仅对当前线程可见,所以也就存在主内存与工作内存的数据不一致性(内存的可见性)。

线程a从主内存读取初始的变量a = 0,线程b也从主内存读取初始的变量a = 0,当线程a修改变量a=1,此时的变量a=1对于线程b是不可见的,也就是对于线程b来说相当于a变量没有变化,线程a的修改没有生效。

假如,线程b也将变量a修改为1,本来按理来说线程a和线程b都使得变量a+1,那么变量a本应该变为2,此时线程a将自己的变量刷到主内存,线程b也将变量a刷到主内存。

此时得到的变量a的值就不知道我们所要的,为了保证线程a和线程b的正确操作,那么不管是线程a还是线程b修改a变量后都应该对另一个线程立即可见,修改后立即刷新到主内存。

当另一个线程要使用变量a的时候,只能自己工作内存的对应的缓存行失效,只能从主内存中从新获取,这一样就能保证数据的正确性,内存的可见性。

这个也是volatile 的关键字保证内存可见性的原理,在后面说到的时候,会进行详细的深入。

原子性

原子性是指一个或者多个操作是原子性的,不能够被打断,只要开始执行就是执行完毕,其中不会被打断。

原子性和重排序都是为了保证可见性服务的,只有在多线程中实现内存的可见性,才能保证数据的正确性。

Java的java.util.concurrent包下提供了许多锁来保证原子性,通常有以下三种手段:

  1. synchronized同步代码块
  2. cas原子类工具(无锁机制)
  3. lock锁机制

而volatile 是不能够保证原子性的,但是他对于单个属性来说能够及时保证内存的可见性。

happens-before原则

happens-before是一种规则,它提供了跨线程的内存可见性的保证,假如写操作a happens-before于读操作b,那么不管操作a与操作b是否在同一个线程,只要在happens-before关系的保障下,操作a都能够及时对于操作b及时可见。

happens-before的具体内容,主要有以下六大规则,如下:

  1. 程序顺序规则:一个线程中的每一个操作happens-before与后续中该线程的任意操作。
  2. 监视器规则:一个锁的解锁happens-before于后续对于这个锁的加锁操作。
  3. volatile规则:一个volatile域的写happens-before与后续对于这个域的读操作。
  4. 传递性规则:A happens-before于B,B happens-before于C,那么A happens-before于C。
  5. start()规则:线程A中调用ThreadB.start()操作happens-before与线程B中的任意操作。
  6. join()规则:线程A中调用ThreadB.join()操作,那么线程B中的任意操作happens-before于ThreadB.join()的成功返回。

注意happens-before原则中,两个操作符合该原则,并不意味着程序代码的顺序与happens-before原则顺序一致。

因为JMM模型中,他希望编译器和处理器实现一个弱的内存模型,对于编译器的束缚的越少,优化的性能就能越提高,而对于程序员实现内存的可见性,Java提供一系列的类库来帮助实现。

Java程序员只需要按照happens-before原则编码实现,就能保证内存的可见性。

synchronized

synchronized 是Java语言的一个关键字,它本身的意思为同步,是用来保证线程安全的,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。

synchronized一句话来解释其作用就是:能够保证同一时刻最多只有一个线程执行该段代码,以达到并发安全的效果。synchronized就犹如一把锁,当一个线程获取到该锁,别的线程只能等待其执行完才能执行。

synchronized可以说是Java中元老级的关键字了,也是面试的高频的问点,在jdk1.6之前它是一把重量级锁,性能不被大家看好,在次之后对它做了很多优化,性能也大大提升。

那么synchronized的实现的底层原理是什么,jdk1.6之后又对它做了哪些优化呢?接下来我们一步一步的分析。

synchronized的特性

synchronized能够保证在多线程的情况下线程安全,直接可以它的特性进行总结原因,synchronized有以下四个特性:

  1. 原子性:保证被synchronized修饰的一个或者多个操作,在执行的过程中不会被任何的因素打断,即所谓的原子操作,直到锁被释放。
  2. 可见性:保证持有锁的当前线程在释放锁之前,对共享变量的修改会刷新到主存中,并对其它线程可见。
  3. 有序性:保证多线程时刻中只有一个线程执行,线程执行的顺序都是有序的。
  4. 可重入性:保证在多线程中,有其他的线程试图竞争持有锁的临界资源时,其它的线程会处于等待状态,而当前持有锁的线程可以重复的申请自己持有锁的临界资源。

上面的也是粗略的进行概括,接下来就一步一步的进行深入的分析synchronized的这四个特性的底层原理。

原子性

上面介绍了原子性就是一个或者多个操作,在执行的过程中不会被任何的因素打断,这里的任何因素打断具体一点主要是指cpu的线程调度。

在Java语言中对基本数据类型读取和赋值才是原子操作,这些操作在执行的过程不会被中断。而像a++或者a+=1类似的操作,都并非是原子性操作。

因为这些操作底层执行的流程分为这三步:读取值、计算值、赋值。才算完成上面的操作,在多线程的时候就会存在线程安全的问题,产生脏数据,导致最后的结果并非预期的结果。

在面试的过程中也会有很多面试官常常拿volatile和synchronized做比较,在原子性方面区别就是volatile没有办法保证原子性,而synchronized可以实现原子性。

那么synchronized的底层又是怎么实现原子性的呢?这里又要从synchronized的字节码说起,在idea中写了一段简单的代码如下所示:

public class TestSynchronized implements Runnable {

    @Override
    public void run() {
        synchronized (this) {
            System.out.println("同步代码块");
        }
    }

    public static void main(String[] args) {
        TestSynchronized sync = new TestSynchronized();
        Thread t = new Thread(sync);
        t.start();
    }
}

代码很简单,通过字节码进行分析,执行的字节码如下图所示,在字节码中可以看出在执行代码块中的代码之前有一个monitorenter,后面的是离开monitorexit。

不难猜测执行同步代码块中的代码时,首先要获取对象锁,对应使用monitorenter指令 ,在执行完代码块之后,就要释放锁,所对应的指令就是monitorexit。

在这里又会有一个面试考点就是:什么会出现两次的monitorexit呢?这是因为一个线程对一个对象上锁了,后续就一定要解锁,第二个monitorexit是为了保证在线程异常时,也能正常解锁,避免造成死锁。

可见性

synchronized实现可见性就是在解锁之前,必须将工作内存中的数据同步到主内存,其它线程操作该变量时每次都可以看到被修改后的值。

说到工作内存和主内存这个要从JMM说起,主存是放共享变量的地方,而工作内存是线程私有的,存放的是主存的变量的副本,线程不会对主存的变量直接操作。这里画了一张图给大家理解:

有序性

synchronized在实现有序性时,多线程并发访问只有一个线程执行,从而保证线程执行的顺序都是有序的。

synchronized为了实现有序性,通过阻塞其它线程的方式,来达到线程的有序执行,接下来看一个简单的代码:

public class TestSynchronized implements Runnable {
    Object o= new Object();
    public static void main(String[] args) throws InterruptedException {
        TestSynchronized sync = new TestSynchronized ();
        Thread t1 = new Thread(sync);
        Thread t2 = new Thread(sync);
        t1.start();
        t2.start();
    }
    @Override
    public void run() {
        synchronized (o) {
            try {
                System.out.println(Thread.currentThread().getName() + "线程开始执行");
                Thread.sleep(5000);
                System.out.println(Thread.currentThread().getName() + "线程等待5秒后执行完毕");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这个毋庸置疑,当你加了synchronized代码块的时候,这两个线程执行必须是有序的,同一个线程前后的输出一定会在一起,执行的结果如图所示:

假如注释掉synchronized的代码块,两个线程的执行就不再是有序的执行,就会出现如图所示的情况:

可重入性

synchronized的可重入性就是当一个线程已经持有锁对象的临界资源,当该线程再次请求对象的临界资源,可以请求成功,这种情况属于重入锁。

实现的底层原理就是synchronized底层维护一个计数器,当线程获取该锁时,计数器+1,再次获取锁时继续+1,释放锁时,计数器-1,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

synchronized基本用法

前面详细的介绍了synchronized的基本特性,接下来详细的介绍synchronized的基本用法,我们基本都知道大部分是时候只会用到同步方法上,但是它的用法有下面三种:

在同步普通方法中锁对象就是this,也就是当前对象,哪个对象调用的同步方法,锁对象就是就是它。

当然同步普通方法只能作用在单例上,若不是单例,同步方法就会失效,原因很简单,多例中锁对象不一样,没办法生效。

在同步静态方法中的锁对象是当前类的class对象,这个相信大家都能想到。

在同步代码块中,可以有很多的玩法,因为锁对象是任意的,由程序员自己操作指定。

synchronized的优化

在JVM的书籍中介绍到,synchronized在jdk6之前一直使用的是重量级锁,在jdk6之后便对其进行了优化,新增了偏向锁、轻量级锁(自旋锁),并通过锁消除、锁粗化、自旋锁、自适应自旋等方法使用于各种场景,大大提升了synchronized的性能。

下面就来详细的介绍synchronized被优化的过程以及原理,对synchronized优化的实现的具体的原理图如下所示:

在synchronized优化的最重要的就是锁升级的优化过程,也是大厂面试的必问的锁知识点,接下来我们就详细的了解这个过程。

锁升级

在讲解锁升级的过程,先了解对象的在内存中的布局情况,为什么呢?因为锁的信息是存储在对象的markword中,只有了解了对象的布局,对深入的了解锁升级会更有帮助。

在我们创建一个对象后,大部分时候,对象都是分配在堆中,因为还有可能对象在栈上分配,所以这里用大部分情况。

对于一个对象创建完之后,在内存中的布局情况,我之前也写过一篇文章,详细可以参考这一篇[],这里做一个大概的回顾,一个对象在内存中的布局图如下所示。

对象在内存布局中主要分为以下三个部分:对象头(markword、class pointer)、示例数据(instance data)、对齐(可有可无)

其中对象头中,若是对象为数组则还包含数据的长度,其中markword中主要包含信息有:GC年龄信息、锁对象信息、hashCode信息

class pointer是类型指针,指向当前对象class文件,实例数据若是一个对象有属性private int n=1,这是n=1即使存储在示例数据中。

最后的填充可有可无,这个取决于对象的大小,所示对象大小能被8字节整除,则该部分没有,不能被整除,就会填充对象大小到能够被8字节整除。

在对象的内存布局中,最值得我们关注的就是markword,因为markword是存储锁信息的,接下来的实验中,就是要观察markword包含的位里面的大小的变化。

要在实际中观察到对象的内存布局情况,可以借助JOL依赖库,全程是JAVA Objct Layout,即是Java对象布局,只需要在你的maven工程里面引入如下maven坐标:

<dependency>
 <groupId>org.openjdk.jol</groupId>
 <artifactId>jol-core</artifactId>
 <version>0.9</version>
</dependency>

然后创建一个SpringBoot项目,加入上面Maven依赖,接着创建Java类JaveObjectLayout,代码如下:

public class JaveObjectLayout {
 public static void main(String[] args) {
  Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(s);
 }
}

执行代码后输出的结果如下图所示:

有人问这是啥?不慌,且听我慢慢道来,这个就是Java在内存中的布局数据,前八个字节表示的是markword,其中OFFSET表示起始位,SIZE表示偏移位。

比如第一行0 4,表示第0个字节开始算4个字节,然后第二行4 4表示第4个字节开始算4个字节,这样就一共8个字节,表示完整的markword信息。

其中后面的VAlUE数据表示的是对应的这4个字节上的具体位的数据,1字节=8位,这个也刚好对应。

在能看懂这个之前必须要了解各种锁对应的位数上的是0还是1,才能够知道上面输出的表示是什么信息,看一张各种锁表示的信息图:

其中倒数第三位为偏向位(1:表示偏向,0:无锁),最后两位就是锁标识位了;无锁状态位001,偏向锁为101,轻量级锁为00,而重量级锁为10,最后11表示GC信息。

接下来我们来聊聊详细的锁升级的过程,当初始化完对象后,对象处于无锁状态,在只有一个线程第一次使用该对象,不存在锁竞争时,我们便会认为该线程偏向于它。

偏向锁的实质就是将线程的ThreadID存储于markword中,表明该线程偏向于它,偏向锁的目的就是为了降低性能消耗,当再次同一个线程进来的时候,发现偏向于自己,连CAS也不用做了,就可以获取锁对象资源。

若是某一时刻又来了线程二、线程三也想竞争这把锁,此时是轻度的竞争,便升级为轻量级锁,于是这三个线程就开始竞争了,他们就会去判断锁是否由释放,若是没有释放,没有获得锁的线程就会自旋,这就是自旋锁。

在自旋的过程,也会尝试的去获取锁,直到获取锁成功。在jdk1.6之后又出现了自适应自旋,就是jdk根据运行的情况和每个线程运行的情况决定要不要升级。

自适应自旋是对自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

这个竞争的过程的实质就是看谁能把自己的ThreadID贴在对象的markword中,而这个过程就是CAS操作,原子操作。

倘若此时又来了线程四、线程5.....线程n,都想获取该锁,竞争越来越激烈了,此时就会升级为重量级锁。

所谓的重量级锁,为什么叫做重量级呢?因为重量级锁要通过操作系统,由用户态切换到内核态的过程,这个切换的过程是非常消耗资源的,并且经过系统调用。

那么为啥重量级锁那么消耗资源?还要它,要它有何用?是这样的,假如没有重量级锁,不管有多少个线程都是自旋,那么当线程是大了,等待的线程永远在自旋。

自旋是要消耗cpu资源的,这样cpu就撑不住了,反而性能会大大下降,在经过反复的测试后,肯定是有一个临界值,当超过这个临界值时,反而使用重量级锁性能更加高效。

因为重量级锁不需要消耗cpu的资源,都把等待的线程放在了一个等待的队列中,需要的时候在唤醒他们。

在jdk1.6之前当线程的自选次数超过10次或者等待的自旋的线程数超过了CPU核数的二分之一,就会升级为重量级锁。

当然也有情况就是偏向锁一开始就重度竞争,这是就直接升级为重量级锁,这个在互联网项目中也是很常见的。

经过上面的详细讲解于是就出现了下面的锁升级图,在不同的条件就会升级为不同的锁:

锁消除、锁粗化

锁消除是另一种锁的优化措施,在编译期间,会对上下文进行扫描,去除掉不可能存在竞争的锁,这样就不必执行没有必要的上锁和解锁操作消耗性能。

锁粗化就是扩大所得范围,避免反复执行加锁和释放锁,避免不必要的性能消耗。

锁的内存语义

当一个线程释放锁的时候,JMM会把该线程内共享变量及时的刷新到主内存中,及时的对其他线程可见。

当一个线程获取锁的时候,JMM会把该线程共享变量对应的缓存行失效,当该线程操作该副本的时候,在被监视器保护的临界区代码必须从主内存中重新获取。

双重检查锁定

双重检查锁定是个非常有意思的东西,而且在面试的时候也是经常会被问到的(单例模式)。今天我们就来研究一下:

我们知道到单例模式分为饿汉式懒汉式,为了减少性能消耗,可能都会选择饿汉式,延迟加载对象,代码实现如下:

public class DoubleCheckExample {
 private static Example example;
 public static Example getExample () {
  if (example== null){ // 步骤1
   example= new Example (); //步骤2
  }
  return example;
 }
}

此时,在多线程环境下,没有其他机制保证线程安全下,就会出现数据安全的问题,当线程1执行完步骤1,还么有执行步骤2,线程2又进来了又判断了if (instance == null){是否成立,此时new Instance()就会被执行两次。

那么此时有人提出了,使用锁机制来保证,实现的代码如下:

public class DoubleCheckExample {
 private static Example example;
 public synchronized static Example getExample() {
  if (example== null){ // 步骤1
   example= new Example(); // 步骤2
  }
  return example;
 }
}

仔细上看上面的代码似乎完美,但是细细斟酌,这个代码还是有优化的空间,假如调用getExample()方法非常频繁,那么每一调用竞争都会加锁和解锁操作,实际中只需要第一次初始化的时候加锁即可,所以代码可以做如下的优化:

public class DoubleCheckExample  {
 private static Example example; 
 public static Example getExample() { 
  if (example == null) { // 第一次检查
   synchronized (DoubleCheckExample.class) { // 加锁
    if (example == null) {// 第二次检查
     example = new Example(); // 问题的根源出在这里
    }
   } 
  } 
  return example; 
 } 
}

上面的代码相比以前的直接在方法层级加synchronized ,进入方法会先判断if (example == null) 是否为null,若是不为null,直接返回对象,这里有好处就是当并发非常大的时候,就不用进入加锁和解锁操作,能够提升不少的性能功能消耗。

网友直呼完美,但是这样的代码,还是有问题的,因为一个new Example()的操作涉及三个步骤:

memory = allocate();    // 1:分配内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory;     // 3 :将instance引用指向刚分配的内存地址

其中第二步和第三步因为不存在数据的依赖性,所以再JMM的内存模型中,是可以出现重排序的,有可能出现第三步先于第二步执行。

假如,此时执行了instance = memory; 操作,但是还没有执行ctorInstance(memory);操作,第二个线程执行getExample()方法判断if (example == null)。

因为instance = memory; 已经执行,所以不为null,直接返回一个还没有完全初始化的对象,在使用的时候,就会出现问题,当然,这种几率很小,因为初始化对象的速度很快,时间很短,但是大并发的时候存在这样的可能性。

解决的方法就是给Example 加上volatile 关键字,禁止步骤2余步骤3的重排序,最后的实现代码如下:

public class DoubleCheckExample  {
 private static volatile Example example; 
 public static Example getExample() { 
  if (example == null) { // 第一次检查
   synchronized (DoubleCheckExample.class) { // 加锁
    if (example == null) {// 第二次检查
     example = new Example(); // 问题的根源出在这里
    }
   } 
  } 
  return example; 
 } 
}

我们来分析一波,加入第一个线程也是进入getExample()方法,执行了new Example();操作,并且Example 被volatile 修饰,那么ctorInstance(memory);和instance = memory; 就不会出现重排序,只有执行完instance = memory; 此时对象才不为null,并且已经初始化完。

这样就即提高了性能,也解决了数据的安全性问题,网友又直呼完美。

volatile

volatile是java中热门关键字,也是面试中的高频问点,今天就来深入的从各种volatile面试题中剖析它的底层原理实现,并通过简单的代码去证明。

在深入volatile之前,我们先从原理入手,然后层层深入,逐步剖析它的底层原理,使用过volatile关键字的程序员都知道,在多线程并发场景中volitile能够保障共享变量的可见性和禁止重排序。

volatile的内存可见性

对于内存的可见性,上面我已经详细的解说了,主内存与线程的工作内存之间的关系。

volatile修饰的变量保证可见性与锁机制保证可见性的内存语义是一致的,当一个共享变量被volatile修饰,volatile域的写操作JMM会把当前线程的工作内存的副本刷新到主内存中,保证对于其后的读操作及时可见。

而volatile域的读操作JMM会使该线程缓存该共享变量的缓存行失效,该线程只能再次去主内存读取,获取到最新值。

如此一来保证了volatile域的可见性。

volatile禁止重排序

前面重排序说到了编译器重排序与处理器重排序,而volatile在处理器层面,JMM对于volatile的重排序做了如下的规定:

来源于并发编程艺术

从表中可以看出只要第一个操作为volatile读不管第二个操作是普通读写/volatile读写或者第二个操作volatile不管第一个操作是普通读写/volatile读写都是禁止进行重排序的。

为了实现volatile的禁止重排序规则,编译器会在需要进行禁止重排序的代码序列,在生成字节码序列的时候,在特定的指令序列插入内存屏障来禁止重排序。具体规则如下:

具体的作用参考《并发编程艺术》中的图片,如下所示:

再继续深入一下,通过代码的指令序列来研究:

public class TestVolatile extends Thread {
    private static volatile  boolean flag = false;

    public void run() {
        while (!flag) ;
        System.out.println("run方法退出了")
    }

    public static void main(String[] args) throws Exception {
        new TestVolatile().start();
        Thread.sleep(5000);
        flag = true;
    }
}

让我们具体从idea的输出的汇编指令中可以看出,我们看到红色线框里面的那行指令:putstatic flag ,将静态变量flag入栈,注意观察add指令前面有一个lock前缀指令。

注意:让idea输出程序的汇编指令,在启动程序的时候,可以加上 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly作为启动参数,就可以查看汇编指令。

简单的说被volatile修饰的共享变量,在lock指令后是一个原子操作,该原子操作不会被其它线程的调度机制打断,该原子操作一旦执行就会运行到结束,中间不会切换到任意一个线程。

当使用lock前缀的机器指令,它会向cpu发送一个LOCK#信号,这样能保证在多核多线程的情况下互斥的使用该共享变量的内存地址。直到执行完毕,该锁定才会消失。

volatile总结

volatile和锁都能保证可见性,先把volatile与锁做一个比较,volatile是不能保证原子性而锁机制是可以保证原子性。

所以再灵活性上锁机制是优于volatile关键字的,但是性能上volatile是优于锁机制。

volatile只能作用于属性,保证共享属性的可见性,在happens-before原则中volatile的写操作happens-before于volatile的读操作。

volatile能够禁止指令重排序,而锁机制在临界区内是可以允许指令重排,锁机制保证的是临界区内与临界区外之间的代码禁止指令重排。

所以具体是选择volatile还是选择锁机制,还是要看业务场景,结合具体的场景而定,你知道的两者时间的区别越多,原理越深,对于你选择就越游刃有余。

final域的重排序

final可以用来修饰方法,修饰属性,以及修饰类,修饰方法表示方法不能够被重写,修饰属性表示一旦被初始化就不能够被修改,修饰类表示不能够被继承,但是这些与我们下面聊的禁止重排序没有关系,只不过给大家复习一下,哈哈哈哈。

final域是很多博文很少提及的,上面说到在解决双重检查锁定给对象加上volatile是可以禁止指令重排的,final域也是可以做到的,当所有属性都加上final关键字的时候也可以达到相同的目的。

JMM冲final域的重排序主要体现在:

好了这一期的文章就到这里了,我们下一期间,加入文章对你有帮助的 。

参考

《Java并发编程艺术》

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8