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

目录


1、Java内存模型的基础
2、Java内存模型中的顺序一致性
3、Java内存模型中的happens-before
4、同步原语(volatile、synchronized、final)
5、双重检查锁定与延迟初始化
6、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++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有以下特性:
1、可见性:对一个volatile变量的读,总是能看到(任意线程)对这个bolatile变量最后的写入。
2、原子性:对任意单个volatile变量的读/写操作具有原子性,但类似于volatile++这种复合操作不具有原子性。

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

从jdk5开始,volatile变量的写-读可以实现线程之间的通信。

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

如下代码:

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包的源代码实现,会发现一个通用化的实现模式:
1、首先,声明共享变量为volatile;
2、然后,使用CAS的原子条件更新来实现线程之间的同步;
3、同时,配合以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变量具有原子性,复合操作不确保)

猜你喜欢

转载自juejin.im/post/5eb93dd5f265da7bc2405aa3