并发编程-深入理解JMM

97 阅读11分钟

并发和并行

并发

大家肯定在学习Java多线程时就接触到了并发和并行的概念。并发可以理解为单核CPU的执行方式,就是只有一个核的CPU,他要做很多事情,有些事情需要进行IO操作,大家都知道IO操作对于CPU来说是很慢的,所以CPU这段时间可不能单等着这个程序进行完IO在进行下一个程序的执行,CPU会执行下一个程序,这个进行IO的程序就先暂停,等你进行完对于时间相对于比较久的IO操作再进行你的执行。

值得一提的是,CPU怎么记住这个程序暂停之后接下来恢复会进行哪条指令呢?其实,这是因为每一个程序都有一个程序计数器(PC),他会记得这个程序接下来要进行的指令是哪一条。

image.png

并行

现在的CPU一般都是并行的方式,就如并发的所说,是单核CPU所进行的方式,随着现在科技发展基本上我们所用的CPU都是多核的,这也支持了并行方式。所谓并行,就是一个核的CPU进行一个程序,多个程序同时进行,就像和朋友散步,几个人并在一起,同时进行。这大大提升了CPU的运行效率。

image.png

并发三大特性

并发的三大特性分别是:可见性,有序性,原子性。这三个特性也都是并发编程BUG的源头。当然,现在相对成熟的Java并发编程的属性也对这些BUG提出了各种解决方案,有可以使用内存屏障的,有使用volatile关键字,有使用锁机制的等等,这些都是我们在后面需要学习的。

可见性

可见性就是指,两个线程在使用一个共享变量,一个线程修改了共享变量之后,另一个线程也要知道这个共享变量被修改了,就是说这个共享变量需要是可见的。在JMM中,通过变量修改后将新值同步回主存,在变量再被读取之前,线程从主存中刷新变量的值的方法来实现可见性的。 下面有几种方法保证可见性:

通过volatile 关键字保证可见性。

通过内存屏障保证可见性。

通过synchronized关键字保证可见性。

通过Lock保证可见性。

通过final关键字保证可见性

有序性

程序执行的顺序跟代码的先后顺序执行,因为在JVM中存在指令重排,所以存在有序性问题。 有下面几种方法保证有序性:

通过 volatile 关键字保证可见性。

通过内存屏障保证可见性。

通过synchronized关键字保证有序性。

通过 Lock保证有序性。

原子性

大家肯定知道事务吧,事务就是原子性的一个体现,对于一个事务,要么全部事情完成之后提交,如果有一件事情被破坏,那么整个事务都不会提交,原子性亦是如此,执行过程中要么全部不执行,要么不被任何因素打断。在Java中,对基本数据类型的变量读取和赋值操作是原子性操作。不采取任何原子性保障做事的自增操作不是原子性的。下面有几种保证原子性的方法:

通过 synchronized 关键字保证原子性。

通过 Lock保证原子性。

通过 CAS保证原子性。

可见性问题深入分析

我们用Java小程序来分析Java多线程可见性问题

import java.util.concurrent.TimeUnit;

public class VisibilityTest {

    private boolean flag = true;

    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag");
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + "开始执行.....");
        int i = 0;
        while (flag) {
            i++;
            //TODO 业务逻辑
        }
        System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();

        // 线程threadA模拟数据加载场景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 让threadA执行一会儿
        TimeUnit.SECONDS.sleep(1);

        // 线程threadB通过flag控制threadA的执行时间
        Thread threadB = new Thread(() -> test.refresh(), "threadB");
        threadB.start();

    }

    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

Java内存模型(JMM)

先问问大家一个问题,大家觉得这样的一段代码,threadA会跳出循环吗?在解决这个问题之前,我们还需要了解很多东西,接下来带着大家逐一了解。

什么是JMM

JMM(Java Memory Model),是Java虚拟机中的规范,他是一个虚拟的硬件,就是说他并不是真实存在的,是设计的人通过自己的逻辑和底层硬件一起实现的。JMM用于屏蔽掉各种硬件和操作系统的内存访问差异,做到Java程序在各种平台下都可以达到一致的并发效果。JMM规范了Java虚拟机和计算机内存是如何同步协作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。 JMM通过一组规则控制共享变量在共享数据区和私有数据区的访问方式。JMM是根据原子性,有序性,可见性展开的。

JMM与硬件内存架构的关系

JMM和内存架构一个本质区别就是JMM是逻辑上的,是虚构的,但是硬件内存结构是真实存在的,并且JMM是依赖在内存架构上的,只有有了内存架构才有JMM存在。JMM和计算机硬件内存架构有一个交叉关系:

image.png

JMM内存交互操作

1.lock(锁定):作用于主内存的变量,把一个共享变量设为一个线程的独占状态,可以理解为一个互斥锁,在解锁之前,其他线程无法访问该变量。

2.unlock(解锁):同样也是作用域主内存的变量,把一个上了锁的变量解锁,使得其他线程也可以使用该对象。

3.read(读操作):作用与于主内存的变量,将变量值从主内存导入到线程的工作内存中,以便后续的load操作。

4.load(载入):read操作之后的操作,作用于工作内存。变量值被read操作到工作内存中后,线程会使用load指令加载该变量,以便进行对变量的各种操作。操作的是主内存拷贝的变量副本。

5.use(使用):作用于工作内存中的变量,是对变量的使用。

6.assign(赋值):与use指令相似,但是这个指令是对变量的修改。

7.store(存储):作用于工作内存中的变量,把工作内存中的变量传到主内存当中去,以便进行write操作。

8.write(写操作):最后的操作,对工作内存传入主内存中改变的变量进行写入。

image.png

JMM的内存可见性保证

程序有三种,分别是:

1.单线程程序

单线程程序不会产生内存可见性问题,因为在执行过程中CPU总是执行完一个程序之后会进行主内存中变量的检查,所以对其他程序来说共享变量的改变一直是可见的。

2.正确同步的多线程程序:

正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

3.未正确同步的多线程程序:

JMM为它们提供了最小安全性保障:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值。未同步程序在JMM中执行时,整体上是无序 的,其执行结果无法预知。 JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行 结果一致。

接下来我们详细介绍volatile关键字

volatile在内存中的语义

volatile特性

1.可见性:对于volatile修饰的变量,对于其他线程该共享变量总是可见的。

2.原子性:在volatile执行的变量中要么执行要么不执行,但是类似于volatile++这种复合操作不具有原子性。

3.有序性:就在前面讲的,对于指令JVM对进行指令重排,但是被volatile修饰的关键字执行指令时并不会进行指令重排。原理是通过内存屏障实现的。

volatile写和读操作

:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

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

volatile可见性实现原理

volatile修饰的变量必须read,load,use,assign,store,write操作必须是连续的。 volatile在硬件层面中会锁定变量缓存行区域并且写回主内存,缓存一致性会保证其他程序正常执行时可以拿到这个被修改的变量。

volatile在hotspot中实现

image.png

大家可以看到最后一行代码,是c++代码调用了storeload()方法,这其实是一个内存屏障。在文末会对内存屏障进行一个简单的介绍。

lock前缀指令的作用

1.lock是汇编语言面向操作系统的指令,它可以保证后续指令执行的原子性,使得其他线程无法访问这个被锁住的变量,做到保证其他指令执行的原子性。当然这个开销很大。

2.lock有内存屏障的功能,它可以使得指令禁止重排序。

3.lock指令会等待他之前的指令执行完,并且会将所有的缓存的写操作写回内存。

在汇编层面volatile的实现

介绍了lock指令,那volatile和lock肯定脱不了干系。 其实volatile关键字就是在底层调用了lock指令,使得这个线程在写回主存时,其他线程对他是无法操作的,并且等写操作写回到主存时,缓存一致性会使要使用该共享变量的线程对该进程进行更新。下面是JVM的可见汇编指令:

image.png

举个例子,线程A和线程B要同时使用共享变量a,那么如果不使用volatile或者synchronized等关键字,若A对a进行的修改,那如果B要操作a,必然会发生线程不安全。如果使用了volatile关键字加在a上,那么如果A对a修改了,volatile会在底层调用内存屏障storeload(这其实是级别最高的内存屏障),使得其他线程不能访问他(原理就是对这个变量加了锁)。等到A修改完a之后,a会因为读写一致性从A的工作内存被写回到主内存,那么如果B要继续进行的话,就要对a进行值更新。做到同步。

有序性问题深入分析

大家思考下下面程序中x,y最后的值是什么

public class ReOrderTest {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread thread1 = new Thread(() -> {
                shortWait(20000);
                a = 1;
                x = b;
            });
            Thread thread2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 && y == 0) {
                break;
            }
        }
    }

    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

对于这个程序,其实就是输出xy的值,也可以探讨有序性问题,大家可以看到,x和b有关系,y和a有关系。 所以语句:

    a = 1;
    x = b;

这两条语句没有任何关联性,所以底层可以对他们进行指令重排。谁的先后顺序都不在乎,因为互不联系,所以顺序不一样的结果都一样。对于y的表达式亦是如此。

所以大家应该就知道了,输出结果可以是01,10,00。那大家就会问为什么会是00呢,就是两个线程都是先执行的对xy变量的赋值,而他们刚开始就是0,所以也会产生00的结果。

指令重排序

对于上面的有序性分析,大家肯定对指令重排很关心。其实他就是一个JVM对语句的优化机制,很好理解。目的就是为了程序执行的更快,内存占用更少。

指令重排的意义是:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重 排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

volatile重排序规则

image.png

小结

这篇博客花了我四天的时间,当然时间也是挤出来的,里面有我对多线程的见解和对volatile的解释,有涉及底层也有涉及代码。能写完这些我感觉很开心,也希望看到我这篇文章的读者们可以更好的理解多线程!