Java内存模型中的同步原语(volatile、synchronized、final)

目录


一、Java内存模型的基础
二、Java内存模型中的顺序一致性
三、Java内存模型中的happens-before
四、同步原语(volatile、synchronized、final)
五、双重检查锁定与延迟初始化
六、Java内存模型综述
java

volatile的内存语义


volatile的特性

先看一下下面的例子:程序员

class VolatileExample{
    volatile long v1 = 0L;
    
    public void set(long l){
        v1 = l;
    }
    
    public void getAndIncrement(){
        v1++;
    }
    
    public long get(){
        return v1;
    }
}
复制代码

这段代码等价于下面的:安全

class VolatileExample{
    long v1 = 0L;
    
    public synchronized void set(long l){
        v1 = l;
    }
    
    public void getAndIncrement(){
        v1++;
    }
    
    public synchronized long get(){
        return v1;
    }
}
复制代码

如上面程序所示,一个volatile变量的单个读/写操做,与一个普通变量的读/写操做都是使用同一个锁来同步,他们之间的执行效果相同。数据结构

锁的happends-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,老是能看到(任意线程)对这个bolatile变量最后的写入。多线程

锁的语义决定了临界区代码的执行具备原子性。这意味着,即便是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具备原子性。若是是多个volatile操做或相似于volatile++这种复合操做,这些操做总体上不具备原子性。app

简而言之,volatile变量自身具备如下特性:
一、可见性:对一个volatile变量的读,老是能看到(任意线程)对这个bolatile变量最后的写入。
二、原子性:对任意单个volatile变量的读/写操做具备原子性,但相似于volatile++这种复合操做不具备原子性。框架

volatile写-读创建的happens-before关系

从jdk5开始,volatile变量的写-读能够实现线程之间的通讯。函数

从内存语义的角度来讲,volatile的写-读与锁的释放-获取有相同的效果:volatile写和锁的释放有相同的语义;volatile读与锁的获取有相同的语义。post

以下代码:性能

private  int  count;  //普通变量
private  volatile  boolean falg;  //volatile 修饰的变量
//写操做
public void writer(){
    count=1;   // 1
    falg=true;  //2
}
// 读操做
public void reader(){
    if(falg){                   //3
        int  sum=count+1;       // 4
    }
}
复制代码

假设有两个线程:线程A调用读方法, 线程B调用写方法。根据happens-before规则,这个过程的创建分为三类:
1)程序次序规则: 1 happens-before 2,3 happens-before 4
2)volatile规则:2 happens-before 3 。对一个volatile变量的写操做先行发生于后面对这个变量的读操做
3)传递规则: 1 happens-before 4 ;

转换为图形化的表现形式以下:

若是falg不是volatile修饰的,那么操做1和操做2之间没有数据依赖性,处理器可能会对这两个操做进行重排序,这时线程A正好执行先执行了操做2,而后这时线程B抢先执行了操做3, 发现为true就执行if语句里的代码, 获得值可能就是1,而不是咱们所预想的输出sum=2。

volatile写-读的内存语义

volatile写操做:当对一个volatile共享变量写操做时,JAVA内存模型会当前线程对应的更新的后的本地内存中的值强制刷新到主内存中。
volatile读操做:当读一个volatile共享变量时,JAVA内存模型会把当前线程对应的本地内存标记为无效,而后线程会从主内存中加载最新的值到工做内存中进行操做。

线程A写一个volatile变量,其实就是新城A向接下来要读取这个共享变量的某个线程,发送了一个信号,告诉它我已经修改了共享变量,你的工做内存的值要被标记无效。
线程B读一个volatile变量,其实就是接收了以前线程A发出的修改共享变量的信号。
对一个volatile变量的写操做,随后对这个变量的读操做,其实就是两个线程之间的进行了通信。

volatile内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JAVA内存模型内存屏障的插入策略:

在每一个volatile写以前插入一个StoreStore屏障
在每一个volatile写操做的后面插入一个StoreLoad屏障
在每一个volatile读操做的后面插入一个LoadLoad屏障
在每一个volatile读操做的后面插入一个LoadStore屏障

volatile写插入内存屏障后生成的指令序列示意图以下:

为何要加强volatile的内存语义

在JDK5以前的旧的内存模型中,虽然不容许volatile变量之间重排序,但旧的java内存模型容许volatile变量与普通变量重排序。示意图以下:

在上图上,1和2之间没有数据依赖关系时,1和2就可能会被重排序。其结果就是:读线程B执行4时,不必定能看到写线程A在执行1操做对共享变量的修改。

所以,为了提供一种比锁更轻量级的线程之间通讯的机制,JDK5以后就加强了volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具备相同的内存语义。

因为volatile仅仅保证单个volatile变量的读/写具备原子性,而锁的互斥执行的特性能够确保对整个临界区代码的执行具备原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更具备优点。

锁(synchronized)的内存语义


锁的释放-获取创建的happens-before关系

到这里就记得一句话:锁除了让临界区代码互斥执行,还可让释放锁的线程向同一个获取锁的线程发送消息

线程A获取了锁,执行完相应代码,而后线程B才能去获取到锁,在线程B获取到锁的时候,线程A释放锁以前全部可见的共享变量都马上对线程B可见。

锁的释放和获取的内存语义

当线程释放锁时,JAVA内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中,当另外一个线程获取锁的时候,JAVA内存模型会将该线程对应的本地内存设置为无效,因此该线程必须从主内存中读取共享变量,这就使得前一个线程在释放锁以后共享变量必然对另外一个线程可见。以下图:

总结: 1)线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了消息。
2)线程B获取一个锁,实质上是线程B接收到了以前某个线程发出的消息。
3)线程A释放锁,随后线程B获取这个锁,这个过程本质上是线程A经过主内存向线程B发送消息。

锁内存语义的实现

在分析synchronized内存语义实现以前,先来看下可重入锁(Reentrantlock)的实现例子:

加锁和释放锁的方法以下:

public void lock() {
    sync.lock();
}
 
// sync.lock()实现
final void lock() {
    acquire(1);
}
 
public void unlock() {
    sync.release(1);
}
复制代码

lock方法和unlock方法的具体实现都代理给了sync对象,来看一下sync对象的定义:

abstract static class Sync extends AbstractQueuedSynchronizer {...}
复制代码

这里能够看到,Reentrantlock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,后面会有代码。

static final class FairSync extends Sync public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
复制代码

FairSync():公平锁
NonfairSync():非公平锁

公平锁和非公平锁的区别就是获取锁的规则不一样,好比早晨起来买早餐,公平锁就是你们都一个个排队等待买,非公平锁就是你来的时候正好前一我的买完走了,而你去插队购买了。

先来看下公平锁:

上面lock方法和unlock方法的具体实现都是由acquire和release方法完成的,而FairSync类中并无定义acquire方法和release方法,这两个方法都是在Sync的父类AQS类中实现的。

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
     public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
复制代码

咱们能够看出,获取锁和释放锁的具体操做是在tryAcquire和tryRelease中实现的,而tryAcquire和tryRelease在父类AQS中只是接口,具体实现留给子类Sync。也是真正的加锁,释放的逻辑。

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); //获取锁的真正开始,首先读取volatile变量state
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc); //写state
                return true;
            }
            return false;
        }
复制代码
protected final boolean tryRelease(int releases) {
            int c = getState() - releases; //读取state
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);  //释放锁的最后,写volatile变量state
            return free;
        }
复制代码

公平锁在释放锁的最后写volatile变量state;在获取锁时首先读这个volatile变量。根据volatile的happens-before规则(一个volatile变量的写操做发生在这个volatile变量随后的读操做以前),释放锁的线程在写volatile变量以前可见的共享变量,在获取锁的线程读取同一个volatile变量后将当即变的对获取锁的线程可见。

非公平锁的释放和公平锁彻底同样,因此这里仅仅分析非公平锁的获取。

final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
      protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
复制代码

CAS:若是当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操做具备volatile读和写的内存语义。

这里咱们分别从编译器和处理器的角度来分析,CAS如何同时具备volatile读和volatile写的内存语义。前文咱们提到过,编译器不会对volatile读与volatile读后面的任意内存操做重排序;编译器不会对volatile写与volatile写前面的任意内存操做重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操做重排序。

书上还对X86处理器就为cmpxchg指令加上lock前缀(lock cmpxchg).lock说明以下
1)确保对内存的读-改-写操做原子执行。
2)禁止该指令与以前和以后的读和写指令重排序。
3)把写缓冲区中的全部数据刷新到内存中。

如今对公平锁和非公平锁的内存语义作个总结:
1)公平锁和非公平锁释放时,最后都要写一个volatile变量state。
2)公平锁获取时,首先会去读这个volatile变量。
3)非公平锁获取时,首先会用CAS更新这个volatile变量,这个操做同时具备volatile读和volatile写的内存语义。

下图为我理解的图:

从本文对ReentrantLock的分析能够看出,锁释放-获取的内存语义的实现至少有下面两种方式:
1)利用volatile变量的写-读所具备的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。

不管是公平仍是非公平性,这也解释了Lock接口的实现类能实现和synchronized内置锁同样的内存数据可见性。

concurrent包的实现

因为java的CAS同时具备 volatile 读和volatile写的内存语义,所以Java线程之间的通讯如今有了下面四种方式:
1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操做,这是在多处理器中实现同步的关键。同时,volatile变量的读/写和CAS能够实现线程之间的通讯。把这些特性整合在一块儿,就造成了整个concurrent包得以实现的基石。若是咱们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
一、首先,声明共享变量为volatile;
二、而后,使用CAS的原子条件更新来实现线程之间的同步;
三、同时,配合以volatile的读/写和CAS所具备的volatile读和写的内存语义来实现线程之间的通讯。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从总体来看,concurrent包的实现示意图以下:

final的内存语义


final域的重排序规则

对于final域,编译器和处理器要遵照两个重排序规则

1> 在构造函数内对一个final域的写入,与随后把这个构造函数的引用赋值给一个引用变量,两个操做不能重排序

2> 初次读一个包含final域对象的引用,和随后初次读这个final域,这两个操做不能重排序

class FinalExample{
	int i;//普通变量
	final int j;//final变量
	static FinalExample obj;
	public FinalExample(){//构造函数
		i = 1;//写普通域
		j = 2;//写final域
	}
	public static void writer(){//线程A写执行
		obj = new FinalExample();
	}
	public static void read(){//线程B读执行
		FinalExample fe = obj;//读取包含final域对象的引用
		int a = fe.i;//读取普通变量
		int b = fe.j;//读取final变量
	}
}
复制代码

写final域的重排序规则

写final域的操做不能重排序到构造函数以外,包含两个方面

1> JMM禁止编译器将写final域的操做重排序到构造函数外

2> 编译器会在final域的写入以后,构造函数return前,插入一个StoreStore屏障,这个屏障禁止处理器把final域的写重排序到构造函数以外

writer方法的调用,首先会构造一个实例,在将这个实例赋给一个引用,假设线程B读没有重排序的话

线程A中发生 写普通域的操做重排序到构造函数外面,读线程读取构造函数的引用,并去读普通域的值,就会读取到普通域的初值,而final域因为它的重排序特性,对final域的写入并不会重排序到构造函数外,这样读线程读取构造函数的引用是,就能正确读取到final域初始化后的值。

结论就是: 在一个对象的引用对一个线程可见前,能保证final变量被正确初始化,而普通域不具备这个特性,由于普通域的写入可能会重排序到构造函数外.也就是在多线程环境下,拿到一个对象的引用后,可能会出现它的普通属性的变量尚未被正确初始化的状况。

读final域的重排序规则

读取一个final域的引用和随后读取这个final域,不能重排序

在多线程环境下,线程A执行writer方法中,final的写重排序规则,保证final域被其余线程初始化时候必定是正确初始化的,线程B执行reader方法,若是读取final域的操做重排序到读取包含final域的对象的引用以前,final变量都尚未被初始化,这是一个错误的读取操做,显然,当final引用读取以后,若是这个引用不为空,可以保证final变量被初始化过,这个读取就没有问题

结论:多线程环境下,final域的读取操做会重排序读取在包含final域的引用以后,可是普通域的读取操做可能排在,引用的前面。

final域为引用类型

当final域是引用类型时,写final域的重排序规则对编译器和处理器增长下面约束:在构造函数内对一个final引用的对象的成员域的写入,和随后把这个构造函数的引用赋给一个引用变量,这二者之间不能重排序。

class FinalReferenceExample{
	final int[] intArray;//为引用类型的final
	static FinalReferenceExample obj;
	public FinalReferenceExample(){//构造函数
		intArray = new int[1];//1
		intArray[0] = 1;//2
	}
	public static void writerOne(){//写线程A执行
		obj = new FinalReferenceExample();//3
	}
	public static void writerTwo(){//写线程B执行
		obj.intArray[0]=2;//4
	}
	public static void reader(){//读线程C执行
		if(obj!=null){//5
			int temp = obj.intArray[0];//6
		}
}
复制代码

如今假设一种可能,写线程A执行完毕,写线程B执行,读线程C执行 写线程A执行,根据前面final域的重排序规则,操做1对final域的写入和操做2对final域的写入,不会重排序到操做3对象的引用赋给一个引用变量后面,也就是读线程C至少能够看到 intArray[0]为1,

而线程B的写入和线程C存在数据竞争,读线程C可能看不到线程B对intArray的写入,若是想要看到,须要同步来保证内存可见性。

final引用为什么不能从构造函数内“溢出”

写final域的重排序规则保证,在引用变量为任意线程可见以前,final域已经被正确初始化了,而且还要保证: 在构造函数内部,不能让这个对象的引用对其余线程可见,也就是对象引用不能在构造函数内溢出。

public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;

public FinalReferenceEscapeExample () {
    i = 1;                              //1写final域
    obj = this;                          //2 this引用在此“逸出”
}

public static void writer() {
    new FinalReferenceEscapeExample ();
}

public static void reader {
    if (obj != null) {                     //3
        int temp = obj.i;                 //4
    }
}
}
复制代码

假设一个线程A执行writer()方法,另外一个线程B执行reader()方法。这里的操做2使得对象还未完成构造前就为线程B可见。即便这里的操做2是构造函数的最后一步,且即便在程序中操做2排在操做1后面,执行read()方法的线程仍然可能没法看到final域被初始化后的值,由于这里的操做1和操做2之间可能被重排序。以下图:

从上图咱们能够看出:在构造函数返回前,被构造对象的引用不能为其余线程可见,由于此时的final域可能尚未被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化以后的值。

final语义在处理器中的实现

如今咱们以x86处理器为例,说明final语义在处理器中的具体实现。

上面咱们提到,写final域的重排序规则会要求译编器在final域的写以后,构造函数return以前,插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操做前面插入一个LoadLoad屏障。

因为x86处理器不会对写-写操做作重排序,因此在x86处理器中,写final域须要的StoreStore障屏会被省略掉。一样,因为x86处理器不会对存在间接依赖关系的操做作重排序,因此在x86处理器中,读final域须要的LoadLoad屏障也会被省略掉。也就是说在x86处理器中,final域的读/写不会插入任何内存屏障!

为何要加强final的语义

在旧的Java内存模型中 ,最严重的一个缺陷就是线程可能看到final域的值会改变。好比,一个线程当前看到一个整形final域的值为0(还未初始化以前的默认值),过一段时间以后这个线程再去读这个final域的值时,却发现值变为了1(被某个线程初始化以后的值)。最多见的例子就是在旧的Java内存模型中,String的值可能会改变(参考文献2中有一个具体的例子,感兴趣的读者能够自行参考,这里就不赘述了)。

为了修补这个漏洞,JSR-133专家组加强了final的语义。经过为final域增长写和读重排序规则,能够为java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不须要使用同步(指lock和volatile的使用),就能够保证任意线程都能看到这个final域在构造函数中被初始化以后的值。

总结


保证代码顺序执行是靠插入内存屏障,禁止重排序从而保证程序代码是按顺序执行的(临界区的代码也不能重排序),由于JAVA内存模型是共享内存模型,在一个线程释放完锁以前,共享变量的值已经刷新在主内存中了,而上一个线程通讯下一个线程获取锁的时候,主内存中的共享变量已是最新的了,下一个线程会将本地内存设置为无效,而后从新去主内存读值,这样保证了线程之间的可见性和原子性。(volatile只能保证单个volatile变量具备原子性,复合操做不确保)

相关文章
相关标签/搜索