写点什么

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

  • 2021 年 11 月 24 日
  • 本文字数:4156 字

    阅读完需:约 14 分钟

摘要:如果编写的并发程序出现问题时,很难通过调试来解决相应的问题,此时,需要一行行的检查代码,这个时候,如果充分理解并掌握了 Java 的内存模型,你就能够很快分析并定位出问题所在。


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


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


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

什么是 Java 内存模型?


在内存里,Java 内存模型规定了所有的变量都存储在主内存(物理内存)中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行。不同的线程无法访问其他线程的工作内存里的内容。我们可以使用下图来表示在逻辑上 线程、主内存、工作内存的三者交互关系。



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


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


何时禁用和不禁用缓存和编译优化,可以根据编写高并发程序的开发人员的要求来合理的确定(这里需要重点理解)。所以,可以这么说,为了解决可见性和有序性问题,Java 只需要提供给 Java 程序员按照需要禁用缓存和编译优化的方法即可。



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



说的具体一些,这些方法包括: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中初始化线程BThread threadB = new Thread(()->{    //此处的变量x的值是多少呢?答案是100});//线程A在启动线程B之前将共享变量x的值修改为100x = 100;//启动线程BthreadB.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中启动线程BthreadB.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 字段的读写操作前后各插入一些内存屏障。


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

发布于: 4 小时前阅读数: 5
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

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