java内存模型和并发

java内存模型和并发

java内存模型和并发

皮皮虾我们走

C++ rookie

34 人赞了该文章

前言

为了更好的理解java并发编程,特地补充一篇关于java内存模型也即JMM的分享。

PS:近期个人撸了一个微服务框架,欢迎想了解微服务实现的同学指正。

kingtang/gamma

随着单个处理器频率的提升越来越困难,人们转向了多核理器,同时这么多年来致力于提高程序的运行效率,然而面向多核处理器的并发编程却并不轻松,java在语言级别提供的多线程并发能力为我们编写并发的程序提供了不少便利。本文并不是讲述如何编写多线程程序的文章,而是尝试从另一个角度去理解一下java并发和多线程的基础,理解其中的内容自然而然的能够帮助我们更好的使用java的并发库。

本文所涉及的有些内容可能和我们之前的认知有些许不同,但这正是JMM存在的意义。同时本文理解起来也比较抽象,需要有一定的背景知识才不会感觉云里雾里。上面我们提到一个词JMM,也即java memory model,这个词在JSL中出现过,但是对其更详细的解释是在JSR133中。下面的内容有一部分来自JSR133,如果想详细了解其中的内容可以参考

http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html

1、什么是内存模型?

在多处理器系统中,每个处理器通常都有一层或多层缓存比如CPU的L1、L2,缓存的作用不必多说,但是缓存在提高性能的同时却带来了另外一个问题,也就是数据一致性。假如两个处理器同时操作同一块内存,由于缓存的存在,那么在什么条件下两个处理器可以看到一致的值?

在处理器级别,内存模型定义了何时一个处理器能够看到另一个处理器写入的值或者一个处理器写入的值何时能够被其他的处理器看到。一些系统中能够表现出很强的一致性模型,在任意时刻所有的处理器看到的数据都是一样的。但是在另外一些系统中却表现出另外一种相对弱的一致性模型,也就是说有一些被称为内存屏障的特殊指令来控制内存的可见性,在高级语言这个层面,这些指令通常伴随着锁来生效,通常我们不需要关心。通常大多数处理器架构实现的都是弱一致性模型,这是因为它能够带来更好的扩展性和性能。由于java跨平台的特性,因此java尝试提供一个在不同平台上统一的内存模型,而这种模型在很大程度上和硬件层面的内存模型类似,可以说非常相似,至于为什么提供这种模型,前面已经提到过,是为了定义多线程*享变量的可见性和保证程序能够对外(多个CPU)提供统一的视图。

 

JSL中提到过JVM中“存在”一个主内存,所有的线程共享主内存,而每个线程有自己独立的工作内存,工作内存相互不可见,因此java中线程之间的通信实际上使用的是共享内存,另外一种常用的通信方式是消息,目前已经有很多语言提供了基于消息的并发系统,比如scala的akka。下面是从网上找来的一种图,展示的是java的内存模型大概是个什么样子?

java内存模型和并发

jvm对变量的操作是在自己的工作内存中,之后再刷新到主内存,这样其他线程才有机会看到前面线程的操作。基于这种事实,在并发中会出现一些难以预测的行为,尤其是当碰上指令重排序,情况更加复杂。

2、指令重排序

由于编译器、runtime、硬件指令重排序的存在,使得多线程中内存的可见性变得更加难以理解。在不改变程序语义的前提下,编译器可能会为了提高程序的执行效率而进行指令重排序。具体来说一个对内存的写入指令可能会被“提前”执行,这种指令重排序在编译器、运行时和硬件上都有可能发生,只要是内存模型允许的指令重排序都是合法的,但这其中也有一个限制,必须遵循 “as-if-serial”的原则,也就是不管如何重排序程序串行的执行结果不会改变。关于指令重排序可以用下面一个简单的例子来解释:

 

 /* 指令重排序
 * @author Administrator
 *
 */
public class Reordering
{
    
    private int a, b;
    
    public void write()
    {
        a = 1;
        b = 2;
    }
    
    public void read()
    {
        int r1 = b;
        int r2 = a;
    }
}

我们假设上面的代码在两个线程之间并发的执行,由于这里涉及到对类的成员变量并发操作,因此这不是一个被正确同步过的代码,而代码的执行顺序和结果也不固定,有可能得到以下几种执行路径:

java内存模型和并发

java内存模型和并发

以上三种情况都是我们可以预料到的,但是由于指令重排序的存在还可能出现一种看起来有违常理的结果,也就是r1=2,r2=0,如果r1=2则说明b=2已经执行,按常理讲a=1也已经执行,无论如何r2也不可能为0,但是从单线程角度看,由于b和a两个数据不存在依赖关系,因此将b=2操作排在a=1前面执行也是合理的,因此可能会出现下面一种执行路径:

java内存模型和并发

的确上面的结果有些出乎意料,但是未正确同步的代码确实可能出现这种诡异的现象,这对程序员来说有点儿不能接受。

3synchronized

同步大概会涉及到几个方面,最容易理解的是互斥,在同一时间只能有一个线程持有锁,值得一提的是在jvm层面synchronized关键字是利用monitor来实现的,同一个线程可以多次进入monitor。然而同步不仅仅包括互斥,同样重要的还有可见性,同步块能够保证被之前线程写过的内存对后面进入同步块的线程可见。当退出同步块的时候伴随着将缓存刷新到主内存的动作,因此此线程的写入可以被后面的线程看见。

前面我们讨论重排序都是基于多处理器或者多线程的场景,但是实际上在单处理器或者单线程上也存在重排序,因此java为了保证能够让正确的同步不会被重排序所影响,描述了一个被称为“happens-before”的原则,如果一个操作happens-before另外一个操作,则JMM可以保证第一个操作对第二个操作可见,这些规则大致有以下几种:

  • 某个线程中的每个动作都 happens-before 该线程中该动作后面的动作。
  • 某个管程上的 unlock 动作 happens-before 同一个管程上后续的 lock 动作。
  • 对某个 volatile 字段的写操作 happens-before 每个后续对该 volatile 字段的读操作。
  • 在某个线程对象上调用 start()方法 happens-before 该启动了的线程中的任意动作。
  • 某个线程中的所有动作 happens-before 任意其它线程成功从该线程对象上的join()中返回。
  • 如果某个动作 a happens-before 动作 b,且 b happens-before 动作 c,则有 a happens-before c。

 

这些都是JMM为我们提供的一种保证,因此针对以上场景的代码编写不需要显示的进行同步,比如对一个线程的start,然后在run方法中执行一些操作,JMM保证执行run方法的时候线程肯定已经启动了,happens-before不会受到任何级别的指令重排序影响,也就是说针对以上或者能够用以上原则推到出来的happens-before关系,JVM通过插入正确的内存屏障指令可以保证程序的正确语义。由于synchronized关键字是通过对monitor的lock和unlock实现的因此上面的原则也包含了synchronized,值得一提的是final关键字的语义则稍有不同。

4final

语法层面上我们都知道被final修饰的变量都是不可变的,这也意味着一旦final字段被第一次初始化,后面都不会再出现对它的写操作,因此被final修饰的字段天然是线程安全的。在JSR133之前final语义是不完备的,甚至和普通的字段并没有区别,这导致某些场景下final所表现出的行为违背了原本所规定的不可变性质。比如下面一段简单的代码:

 

public class FinalFieldExample
{
    final int a;
    
    static FinalFieldExample obj;
    
    public FinalFieldExample()
    
    {
        a = 4;
    }
    
    public static void writer()//线程A执行
    {
        obj = new FinalFieldExample();
    }
    
    public static void reader()//线程B执行
    {
        if (null != obj)
        
        {
            int r1 = obj.a;
        }
    }
}

在多线程的场景下final字段a可能为0也可能为4,为什么会出现如此奇怪的现象。这需要将上面的代码拆开来看,writer方法中的obj = new FinalFieldExample();这段代码实际上是由很多指令组成的,从逻辑上讲如果按照粗粒度来划分至少也有对象初始化和引用赋值两步,假设对象引用的赋值操作先行发生,那么对于线程B来说看到的是一个不完整的对象,这里的不完整也就是说对象的属性还未完全初始化好,因为对象的初始化并不是一个原子的操作,因此a可能为0,这种不确定性并不是final关键字想要的结果。在JSR-133出现之前如果想保证前面的代码执行正确,需要对读写方法加锁。但是从另外一个角度去想,final表示的是只读,那就不会产生并发的问题,也就不应该用锁,于是JSR-133对final的语义做了增强,因此上面的代码在JSR-133之后不会有任何歧义产生,final的值在任何时刻都是4。一句题外话final关键字对于JVM的优化是很友好的,这有助于编译器(前端、后端)对代码进行内联,内联的好处不必多讲,因此能够使用final修饰的尽可能使用final。上面的代码还可以引出一个非常有名的问题“Double-Checked Locking”,记得之前我们写懒加载通常会这么写:

 

class Foo
{
    
    private Helper helper = null;
    
    public Helper getHelper()
    {
        if (helper == null)
            synchronized (this)
            {
                if (helper == null)
                    helper = new Helper();
            }
        
        return helper;
    }
    
    // other functions and members...
}

实际上上面的代码行为类似前面讲到的内容,会有Helper未完全初始化的问题,因此有人建议将helper字段设置为volatile,但是在JSR-133之前加volatile也是没有用的,后面JSR-133增强了volatile的语义,使得加volatile的写法是可行的,至于为什么之前不行,之后又可行了,需要去了解volatile语义前后的差别,其实主要是重排序规则的变化,后面的volatile语义更接近同步块。实际上《java并发编程实践》一书中给出了一个更优雅的实现方式:

 

public class ResourceFactory
{
    private static class ResourceHolder
    {
        public static Resource resource = new Resource();
    }
    
    public static Resource getResource()
    {
        return ResourceHolder.resource;
    }
}

利用jvm自身的初始化加锁机制很好的解决了懒加载的问题。

5volatile

volatile保证了内存的可见性,但是正如上一节所讲的双检查锁的问题,在JSR-133之前volatile的语义允许volatile变量和非volatile变量之间的重排序,这就导致了一个问题,假设线程A进入同步块,并且构造Helper,此时对Helper的赋值操作很肯能会和Helper实例变量的初始化(此处假设Helper有成员变量,这很合理)操作重排序,这会导致线程B看到一个未被完全初始化的Helper对象。JSR-133增强了volatile的语义,使得volatile变量和普通的变量的操作也不允许重排序出现,这就使得线程B只会看到一个完全或者压根没有初始化的对象,不会产生歧义。

程序每次读取到的volatile变量都是其他线程写入的最新值,每次对volatile变量的写入也都会触发缓存刷新的动作。不依赖当前状态的变量通常可以使用volatile来避免锁的竞争,比如标记某次初始化是否进行过的变量。 private volatile boolean isInitialized; 至于volatile的实现原理,鉴于篇幅的原因,不再详细介绍,有兴趣的可以自google。

结语:本文主要介绍了JMM模型、指令重排序以及happens-before原则,并对java中几个常见的并发api做了简单的介绍,如果对并发方面的内容感兴趣可以看下官方对JMM的介绍,并且强烈推荐《java并发编程实践》以及Doug Lea的《Concurrent Programming in Java》两本书。

发布于 2017-06-14