likes
comments
collection
share

Java5、Java9内存模型

作者站长头像
站长
· 阅读数 22

为了充分利用CPU多核心,我们要编写多线程的程序。大部分场景下,线程间交互会造成很大的开销,充分理解内存模型有利于编写最高性能的多线程程序。

Java5的内存模型资料网上很多,而Java9的修订就很少有资料。此外,很多资料的描述非常晦涩难懂,我想用自己的方式重新描述一下。

问题列表

  1. 如果A线程传递一个对象给B线程(比如上面的User对象),这个对象内部的每个字段都要标记为volatile或者访问方法加synchronized吗?

Java5的内存模型

本文会从另一个角度来描述Java5的内存模型,不需要了解晦涩的happens before原则,只需要知道一点简单的概念,就能开发在任何情况下都正确的程序。其实对于有一点基础的开发者来说,只要理解了volatile和final的含义即可(其实也就是JSR-133进行的最重要的两个修订),下面就详细说明。

首先我们假设一个简单的硬件结构,每个CPU核心有自己的Cache,然后共享主内存(实际结构比这要复杂的多)。此外,程序指令会乱序执行,这个乱序可能是编译器(包括javac编译器和JIT编译器),也可能是硬件导致的。本文举的例子实际并不严谨,但足够应用开发人员以此开发出正确的程序。

volatile写保证会刷到主内存中去,而volatile读一定会从主内存读取,这是volatile最原始的语义,它提供一种比加锁更加轻量的线程数据同步方式。

我们先看下面这个程序:

private User user;

public synchronized void setUser(User user) { 
    this.user = user;
}
public User getUser() {   
    return user;
}

写方法是同步的,而读方法不是,那么在多线程场景下,线程A调用了写方法以后,线程B调用getUser()仍然可能会返回null。解决办法有两个,第一是把getUser()方法也标记为同步,第二是将user字段标记为volatile

这个时候突然有一个可怕的推论,按上面这个例子,如果A线程传递一个对象给B线程(比如上面的User对象),这个对象内部的每个字段都要标记为volatile或者访问方法加synchronized吗?

我们知道实际情况是不需要,这得益于JSR-133对volatile的修订。假设线程A有写入了10个字段要传递给线程B,前面9个字段都可以是普通变量,只要最后一个字段是volatile就可以,volatile写入的时候,除了保证这个字段自己会写入主内存,它会让前面的9个字段也被刷到主内存,同时它会禁用重排序,也就是前面9个字段的写在执行过程中不会排到volatile写后面去。线程B读的时候要先读第10个字段,volatile读会让CPU Cache(按前面我们假设的模型)失效,所以它就能读到线程A写入的前面9个字段,volatile读也会禁用重排序,这是为了保证后面9个字段的读不会重排到volatile读前面去。这是不是有点难懂,读的时候顺序怎么还要反过来呢?实际上我们日常就是这么做的:

private volatile User user;

public void setUser(User user) {  
    this.user = user;
}
public User getUser() {   
    return user;
}

上面代码假设User类里面有9个普通字段,线程A先设置了这9个字段,然后调用setUser(volatile写);线程B调用getUser是先做volatile读,然后才能访问User类里面的9个字段。synchronized(或Lock)也有类似这样的效果,进入synchronized代码块(加锁)相当于volatile读,失效Cache,这样同步代码块里面能读到最新的数据,离开synchronized代码块(解锁)相当于volatile写,把Cache中的数据都刷到主内存中去。

线程之间还有别的通信方式,比如通过一个并发Queue来传递数据,写入Queue的操作,不管具体实现是加锁还是不加锁(synchronized、CAS、volatile、Lock等),一定具有volatile写的内存语义,放入队列的数据,会被刷进主内存;同样,读取Queue的操作一定具有volatile读的内存语义,失效Cache,从主内存中读。

上面这些内容的严谨表述就是happens before原则,它定义的更加晦涩一些,也更加全面。比如happens before会保证,新启动的线程一定能看见启动它的线程,在启动它之前写入的变量,这和我们的常识一致,所以本文就没有必要一一列出了。

需要注意的是,上面的这些因果顺序保证,要求的是读写同一个volatile变量,或者访问同一个锁。如果锁里套锁,并且还有很复杂的因果关系的话。。。你最好不要这样做。

到目前为止,我们了解了一些知识,但好像又没有什么用途,因为即使不知道上面这些知识,我们日常开发的程序仍然是有效的。

接下来我们看下关于JSR-133对于final的修订,这个知识就有点冷。下面的这个程序是有问题的:

public class User {   
    private long bornTime;    
    private static User instance;
    private User(long bornTime) {   
        this.bornTime = bornTime; 
    }
    public static User getInstance() {     
        if (instance != null) {        
            return instance;      
        }
        synchronized (User.class) {    
            if (instance != null) {      
                return instance;       
             }
             instance = new User(System.currentTimeMillis());       
             return instance;      
         } 
    }
    public long getBornTime() {     
        return bornTime;   
    }
}

instance只需要初始化一次,这个程序本意是希望除了第一次,每次调用getInstance的时候都不要加锁,由于对instance字段的写是一个原子操作,因此它期望别的线程看到instance字段的值不为null以后,直接返回,进而可以访问instance.getBornTime()。遗憾的是,由于重排序(构造函数内对bornTime的赋值被重排序到构造函数之外),一个线程可能会拿到一个bornTime还未赋值的User对象。

一个解决办法是将bornTime标记为volatile,上面我们讲过volatile能禁止重排序。

除此之外更加轻量的方法是将bornTime标记为final,它能阻止构造函数中对final变量的赋值被重排序到构造函数之外。使用final需要注意两点:

  • 只能保证被final标记的字段不被重排序,如果构造的时候还写了其它没有final标记的字段,其它这些字段没有保证
  • 在构造函数中不要将this外溢并且让其它线程看到,否则其它线程看到一个没有构造完成的对象,就没有保证了(当然你还可以用别的方式保证比如volatile)

Java5之后 Java9之前

前面的内容对于开发普通的程序已经足够了,Java9关于内存模型的修订只是补充,前面的知识仍然都是适用的。但对于dongting这样的项目开发来说有更高的要求,所以我们还要继续探讨。

Java5的内存模型看起来已经没有什么问题了,还想进一步改进的动力在于,对一个高性能程序来说volatile的读写开销太大了。比如现在有一个volatile变量,对它进行读操作必然伴随失效缓存和禁用重排序等开销,而在某些要求不严格的场景下,我就想看看它大致的值,但是却没有办法像读普通变量一样去读它。

重大的修订发生在Java9,不过我们先看看这之前有什么改变,毕竟还有很多项目仍然用Java8。

Java6给AtomicInteger等引入了lazySet方法,这名字起的好奇怪,文档也很含糊,大概的意思是最终会设置上,我们先按下,等下文还会提到它。

Java8给Unsafe增加了loadFence / storeFence / fullFence方法,因为前面我们没有提到内存屏障(基于标准的Java5内存模型开发,并不需要手工使用屏障),再加上它又在Unsafe里面,同样先按下。

总的来说,Java6、7、8在这方面改动极小。

Java9的修订

受到Java5内存模型的启发,C++11开始也定义了统一的、更强大的内存模型,这样,使用C/C++开发的高性能并发程序,也能够跨平台了。2017年发布的Java9修订了内存模型,又基本上对齐了C++。

Java9内存模型相关的修改,核心的类是VarHandle,不过这个类的使用比较复杂。这里还是用比较简单的AtomicInteger举例。它增加了以下方法:getPlain()、setPlain(int)、getOpaque()、setOpaque(int)、getAcquire()、setRelease(int)等。

AtomicInteger内部的value是个volatile int,所以它的get()、set(int)方法具有volatile读写的内存语义。而getPlain()、setPlain(int),顾名思义,我们可以像读写普通变量一样读写一个volatile了!注意并不需要将volatile int声明为AtomicInteger才能实现这一点,VarHandle类可以实现对volatile int进行任何方式的读写,具体的使用限于篇幅本文就不描述了,我们还是用AtomicInteger来举例。

getOpaque()、setOpaque(int)提供的是opaque模式的读写。这种模式很少用,它只能提供非常弱的保证。它只保证对以opaque模式访问的这个变量,操作是部分有序的,多次写会以顺序的方式被看见。对opaque变量的写最终会对其它线程可见(plain写不能保证最终可见,如果一个线程循环访问一个plain stop变量决定是否停止,理论上可能永远不会停止)。最后opaque读写具有原子性,一个64bit的变量不会被拆成两个。总的来说,opaque模式不是特别有用。(TODO 这里我没有完全理解Per-variable antecedence acyclicity和Coherence的区别)

getAcquire()、setRelease()是一对,一个用于读,一个用于写,名称和功能和C++的memory_order_acquire/memory_order_release一致。在release写操作之前的读写操作一定不会被重排到release写操作的后面去,而在acquire读之后的读写操作不会被重排到acquire读之前去,这可以用来建立因果关系:

private final AtomicReference<User> userRef = new AtomicReference<>();
public void setUser(User user) {    userRef.setRelease(user);}
public User getUser() {    return userRef.getAcquire();}

还是类似上面的例子,假设User类有9个字段,线程A(生产者)先创建User类设置9个字段(使用plain模式设置),然后调用setUser方法(release写),如果线程B调用getUser方法(acquire读)发现返回的值不为null,那么它一定能看见线程A设置在User类上面的9个字段。

setRelease和前面讲的Java6引入的lazySet具有相同的效果,但是Java6里面没有办法进行acquire读。

Release/Acquire提供的一致性保证比volatile读写要弱。见下面的例子:

private AtomicInteger int x = new AtomicInteger(0);
private AtomicInteger int y = new AtomicInteger(0);
public int invokeByThread1() {  
    x.setRelease(1);  
    return y.getAcquire();
}
public void invokeByThread2() { 
    y.setRelease(1);   
    return x.getAcquire();
}

上面代码两个线程可能会都读到0。但是如果访问模式都是volatile,至少有一个会返回1。有点难理解?我们先看看下一部分:内存屏障。

关于内存屏障

不同的CPU体系架构下有不同的屏障实现,它们可能非常不同。

Doug Lea在“The JSR-133 Cookbook for Compiler Writers”一文中提出了4种抽象的内存屏障(实现的时候再映射到具体CPU的屏障):

  • LoadLoad屏障。假设3条指令分别是Load1、LoadLoad屏障、Load2,那么Load1一定不会排到Load2后面去,也就是说Load1会先执行。
  • StoreStore屏障。以此类推,Store1、StoreStore、Store2。Store1会先于Store2完成,而且会保证Store1先写到主内存里面去。
  • LoadStore屏障。以此类推,Load1、LoadStore、Store2。Load1会先于Store2完成。
  • StoreLoad屏障。以此类推,Store1、StoreLoad、Load2。保证Store1先于Load2完成,并且Store1会先写到主内存里面去。这是开销最昂贵的一个屏障。

到具体硬件架构上实现这4种屏障的时候,有的屏障的实现可能是no-op,比如x86是强内存模型,就不需要实现部分屏障。

Java9的VarHandle类上提供了全面的设置屏障的静态方法(终于不是在Unsafe里面了):

屏障方法别名效果
acquireFenceloadFenceLoadLoad + LoadStore
releaseFencestoreFenceStoreStore + LoadStore
fullFenceloadFence() + storeFence()+ StoreLoad
loadLoadFence当前实现等于loadFence()
storeStoreFence当前实现等于storeFence()

Release/Acquire和Volatile的内存语义,都可以用普通读写(64bit变量注意使用opaque保证原子性)加上内存屏障来实现(这可能不是最高效的实现,实际实现未必是这样):

操作等效实现
release writereleaseFence(); plain write
acquire readplain read; acquireFence() 
volatile writereleaseFence(); plain write; fullFence() 
volatile readplain read; fullFence() 

这样我们再回过来看刚才那个代码例子,如果使用Release/Acquire模式,它的等效实现是这样的:

private int x;private int y;
public int invokeByThread1() {  
    VarHandle.releaseFence();  
    x = 1; 
    int ry = y; 
    VarHandle.acquireFence();   
    return ry;
}
public void invokeByThread2() {   
    VarHandle.releaseFence();   
    y = 1;    
    int rx = x; 
    VarHandle.acquireFence(); 
    return rx;
}

注意到没有,写操作和读操作之间是没有屏障的,所以可能会被调换顺序,导致两个线程都返回0的问题。换成volatile的等效实现看一下:

private int x;
private int y;
public int invokeByThread1() { 
    VarHandle.releaseFence();
    x = 1;  
    VarHandle.fullFence();   
    int ry = y;   
    VarHandle.fullFence();   
    return ry;
}
public void invokeByThread2() { 
    VarHandle.releaseFence();
    y = 1;   
    VarHandle.fullFence();  
    int rx = x;   
    VarHandle.fullFence();  
    return rx;
}

显然不会有问题了。

建议

  • 封装在一个类中,不要外溢
  • 尽量不要涉及多个变量以不同的混合方式访问
  • 反复review,如果有复杂的因果关系,从理论上推导一下正确性
  • 用单元测试尽可能的模拟一下各种场景

内容来源

原文章链接 (尊重大佬的原创) github.com/dtprj/dongt…

后续

会开一个专栏,下一节会对 java的并发知识 结合 文章内容进行细致说明,不一定都对,如有错误可以指出,我会修订。

但是因为 我有2个 方面(算上这个) 同时在进行,估计不会很频繁的更新