掌握Java的内存模型,你就是解决并发问题最靓的仔

2021年11月26日 阅读数:3
这篇文章主要向大家介绍掌握Java的内存模型,你就是解决并发问题最靓的仔,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。
摘要:若是编写的并发程序出现问题时,很难经过调试来解决相应的问题,此时,须要一行行的检查代码,这个时候,若是充分理解并掌握了Java的内存模型,你就可以很快分析并定位出问题所在。

本文分享自华为云社区《 【高并发】如何解决可见性和有序性问题?此次完全懂了!》,做者:冰 河 。html

今天,咱们先来看看在Java中是如何解决线程的可见性和有序性问题的,说到这,就不得不提一个Java的核心技术,那就是——Java的内存模型java

若是编写的并发程序出现问题时,很难经过调试来解决相应的问题,此时,须要一行行的检查代码,这个时候,若是充分理解并掌握了Java的内存模型,你就可以很快分析并定位出问题所在。程序员

什么是Java内存模型?

在内存里,Java内存模型规定了全部的变量都存储在主内存(物理内存)中,每条线程还有本身的工做内存,线程对变量的全部操做都必须在工做内存中进行。不一样的线程没法访问其余线程的工做内存里的内容。咱们可使用下图来表示在逻辑上 线程、主内存、工做内存的三者交互关系。缓存

如今,咱们都理解了缓存致使了可见性问题,编译优化致使了有序性问题。也就是说解决可见性和有序性问题的最直接的办法就是禁用缓存和编译优化。可是,若是只是简单的禁用了缓存和编译优化,那咱们写的所谓的高并发程序的性能也就高不到哪去了!甚至会和单线程程序的性能没什么两样!有时,因为竞争锁的存在,可能会比单线程程序的性能还要低。架构

那么,既然不能彻底禁用缓存和编译优化,那如何解决可见性和有序性的问题呢?其实,合理的方案应该是按照须要禁用缓存和编译优化。什么是按需禁用缓存和编译优化呢?简单点来讲,就是须要禁用的时候禁用,不须要禁用的时候就不由用。有些人可能会说,这不废话吗?其实否则,咱们继续向下看。并发

什么时候禁用和不由用缓存和编译优化,能够根据编写高并发程序的开发人员的要求来合理的肯定(这里须要重点理解)。因此,能够这么说,为了解决可见性和有序性问题,Java只须要提供给Java程序员按照须要禁用缓存和编译优化的方法便可。app

Java内存模型是一个很是复杂的规范,网上关于Java内存模型的文章不少,可是大多数说的都是理论,理论说多了就成了废话。这里,我不会太多的介绍Java内存模型那些晦涩难懂的理论知识。 其实,做为开发人员,咱们能够这样理解Java的内存模型:Java内存模型规范了Java虚拟机(JVM)如何提供按需禁用缓存和编译优化的方法ide

说的具体一些,这些方法包括:volatile、synchronized和final关键字,以及Java内存模型中的Happens-Before规则。函数

volatile为什么能保证线程间可见?

volatile关键字不是Java特有的,在C语言中也存在volatile关键字,这个关键字最原始的意义就是禁用CPU缓存。高并发

例如,咱们在程序中使用volatile关键字声明了一个变量,以下所示。

volatile int count = 0

此时,Java对这个变量的读写,不能使用CPU缓存,必须从内存中读取和写入。

蓝色的虚线箭头表明禁用了CPU缓存,黑色的实线箭头表明直接从主内存中读写数据。

接下来,咱们一块儿来看一个代码片断,以下所示。

【示例一】

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 1;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //x的值是多少呢?
    }
  }
}

以上示例来源于:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong

这里,假设线程A执行writer()方法,按照volatile会将v=true写入内存;线程B执行reader()方法,按照volatile,线程B会从内存中读取变量v,若是线程B读取到的变量v为true,那么,此时的变量x的值是多少呢??

这个示例程序给人的直觉就是x的值为1,其实,x的值具体是多少和JDK的版本有关,若是使用的JDK版本低于1.5,则x的值可能为1,也可能为0。若是使用1.5及1.5以上版本的JDK,则x的值就是1。

看到这个,就会有人提出问题了?这是为何呢?其实,答案就是在JDK1.5版本中的Java内存模型中引入了Happens-Before原则。

Happens-Before原则

咱们能够将Happens-Before原则总结成以下图所示。

接下来,咱们就结合案例程序来讲明Java内存模型中的Happens-Before原则。

【原则一】程序次序规则

在一个线程中,按照代码的顺序,前面的操做Happens-Before于后面的任意操做。

例如【示例一】中的程序x=1会在v=true以前执行。这个规则比较符合单线程的思惟:在同一个线程中,程序在前面对某个变量的修改必定是对后续操做可见的。

【原则二】volatile变量规则

对一个volatile变量的写操做,Happens-Before于后续对这个变量的读操做。

也就是说,对一个使用了volatile变量的写操做,先行发生于后面对这个变量的读操做。这个须要你们重点理解。

【原则三】传递规则

若是A Happens-Before B,而且B Happens-Before C,则A Happens-Before C。

咱们结合【原则一】、【原则二】和【原则三】再来看【示例一】程序,此时,咱们能够得出以下结论:

(1)x = 1 Happens-Before 写变量v = true,符合【原则一】程序次序规则。

(2)写变量v = true Happens-Before 读变量v = true,符合【原则二】volatile变量规则。

再根据【原则三】传递规则,咱们能够得出结论:x = 1 Happens-Before 读变量v=true。

也就是说,若是线程B读取到了v=true,那么,线程A设置的x = 1对线程B就是可见的。换句话说,就是此时的线程B可以访问到x=1。

其实,Java 1.5版本的 java.util.concurrent并发工具就是靠volatile语义来实现可见性的。

【原则四】锁定规则

对一个锁的解锁操做 Happens-Before于后续对这个锁的加锁操做。

例如,下面的代码,在进入synchronized代码块以前,会自动加锁,在代码块执行完毕后,会自动释放锁。

【示例二】

public class Test{
    private int x = 0;
    public void initX{
        synchronized(this){ //自动加锁
            if(this.x < 10){
                this.x = 10;
            }
        } //自动释放锁
    }
}

咱们能够这样理解这段程序:假设变量x的值为10,线程A执行完synchronized代码块以后将x变量的值修改成10,并释放synchronized锁。当线程B进入synchronized代码块时,可以获取到线程A对x变量的写操做,也就是说,线程B访问到的x变量的值为10。

【原则五】线程启动规则

若是线程A调用线程B的start()方法来启动线程B,则start()操做Happens-Before于线程B中的任意操做。

咱们也能够这样理解线程启动规则:线程A启动线程B以后,线程B可以看到线程A在启动线程B以前的操做。

咱们来看下面的代码。

【示例三】

//在线程A中初始化线程B
Thread threadB = new Thread(()->{
    //此处的变量x的值是多少呢?答案是100
});
//线程A在启动线程B以前将共享变量x的值修改成100
x = 100;
//启动线程B
threadB.start();

上述代码是在线程A中执行的一个代码片断,根据【原则五】线程的启动规则,线程A启动线程B以后,线程B可以看到线程A在启动线程B以前的操做,在线程B中访问到的x变量的值为100。

【原则六】线程终结规则

线程A等待线程B完成(在线程A中调用线程B的join()方法实现),当线程B完成后(线程A调用线程B的join()方法返回),则线程A可以访问到线程B对共享变量的操做。

例如,在线程A中进行的以下操做。

【示例四】

Thread threadB = new Thread(()-{
    //在线程B中,将共享变量x的值修改成100
    x = 100;
});
//在线程A中启动线程B
threadB.start();
//在线程A中等待线程B执行完成
threadB.join();
//此处访问共享变量x的值为100

【原则七】线程中断规则

对线程interrupt()方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生。

例如,下面的程序代码。在线程A中中断线程B以前,将共享变量x的值修改成100,则当线程B检测到中断事件时,访问到的x变量的值为100。

【示例五】

    //在线程A中将x变量的值初始化为0
    private int x = 0;

    public void execute(){
        //在线程A中初始化线程B
        Thread threadB = new Thread(()->{
            //线程B检测本身是否被中断
            if (Thread.currentThread().isInterrupted()){
                //若是线程B被中断,则此时X的值为100
                System.out.println(x);
            }
        });
        //在线程A中启动线程B
        threadB.start();
        //在线程A中将共享变量X的值修改成100
        x = 100;
        //在线程A中中断线程B
        threadB.interrupt();
    }

【原则八】对象终结原则

一个对象的初始化完成Happens-Before于它的finalize()方法的开始。

例如,下面的程序代码。

【示例六】

public class TestThread {

   public TestThread(){
       System.out.println("构造方法");
   }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象销毁");
    }

    public static void main(String[] args){
        new TestThread();
        System.gc();
    }
}

运行结果以下所示。

构造方法
对象销毁

再说final关键字

使用final关键字修饰的变量,是不会被改变的。可是在Java 1.5以前的版本中,使用final修饰的变量也会出现错误的状况,在Java 1.5版本以后,Java内存模型对使用final关键字修饰的变量的重排序进行了必定的约束。只要咱们可以提供正确的构造函数就不会出现问题。

例如,下面的程序代码,在构造函数中将this赋值给了全局变量global.obj,此时对象初始化尚未完成,此时对象初始化尚未完成,此时对象初始化尚未完成,重要的事情说三遍!!线程经过global.obj读取的x值可能为0。

【示例七】

final x = 0;
public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  global.obj = this;
}

以上示例来源于:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong

Java内存模式的底层实现

主要是经过内存屏障(memory barrier)禁止重排序的, 即时编译器根据具体的底层体系架构, 将这些内存屏障替换成具体的 CPU 指令。 对于编译器而言,内存屏障将限制它所能作的重排序优化。 而对于处理器而言, 内存屏障将会致使缓存的刷新操做。 好比, 对于volatile, 编译器将在volatile字段的读写操做先后各插入一些内存屏障。

 

点击关注,第一时间了解华为云新鲜技术~