Java内存模型--Java并发编程的艺术读书笔记

1 Java内存模型的基础

  Java并发编程的两个关键问题是:线程之间二如何通信,线程之间如何同步。线程同步有两种:共享内存和消息传递。Java并发采用的是共享内存模型。
  Java线程间的通信通过Java内存模型(JMM)控制,JMM决定一个线程对变量的写入何时对其他线程可见。
在这里插入图片描述
如果线程A和线程B要通信的话,必须要经历两个步骤:
1、线程A把本地变量的值刷新到主内存的共享变量中;
2、线程B在主内存中去读刷新后的共享变量。

1.1 从源代码到指令序列的重排序

1、编译器优化的重排序:编译器会在不影响语义的情况下,对语句进行重排
2、指令级并行的重排序:现在操作系统采用了指令级并行技术将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序:处理器使用缓存和读写缓冲区(线程私有的),加载和存储操作看起来可能是乱序的。
下面举一个例子:处理器A和处理器B分别执行表格中的代码,
在这里插入图片描述
  在实际运行时,处理器A和处理器B将数据写入到写缓冲区,然后从内存中读取a,b的值,最后就会得到x=y=0的结果,这是因为处理器执行顺序是A1,A2,但是内存操作顺序是A2,A1。
在这里插入图片描述
  为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令,来禁止特定类型的处理器重排序。其中StoreLoad Barriers是一个全能型屏障,它具有其他三个屏障的效果,现代处理器大多支持该屏障,执行该屏障的开销会很昂贵,因为处理器需要把缓冲器的所有数据刷写到内存中。
在这里插入图片描述

1.1.1 happens-before

  JDK5开始,使用JSR-133内存模型,它happens-before概念来阐述操作之间的内存可见性。在JVM中。如果一个操作的结果要被另一个操作可见,它们之间必须有happens-before关系,这两个操作可以是同一个线程,也可以是不同线程。

  • 程序顺序规则:一个线程的每个操作,happens-before于该线程任意后续操作
  • 监视器锁规则:一个锁的加锁,happens-before于该锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于后续对该volatile域的读
  • 传递性:如果Ahappens-before于B,Bhappens-before于C,那么Ahappens-bofore于C

happens-before操作不是意味着前一个操作必须要在后一个操作之前执行,而是强调前一个操作的结果对后一个操作的可见性,且前一个操作按顺序排在后一个操作之前。

1.2 重排序

  重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序。

1.2.1 数据依赖性

  数据依赖性是指,两个操作访问同一个变量,至少有一个操作为写,则两个操作之间存在数据依赖性。对于这三种数据依赖性,只要修改操作顺序,结果就会不一样。但是,编译器和处理器会遵守数据依赖性,不会改变它们的执行顺序。这里说的考虑数据依赖性是针对单个处理器而言,对于多处理器,编译器和处理器不会考虑数据依赖性。
在这里插入图片描述

1.2.2 as-if-serial

  as-if-serial是指不管怎么重排序,单线程的执行结果不能被改变。编译器,run-time,处理器都必须遵守as-if-serial。为了遵守as0if-serial,编译器和处理器不会对有数据依赖性的操作进行重排序,而对没有数据依赖性的操作进行重排,以提高。这样编写单线程的时候我们就会感觉:单线程程序是按程序的顺序来执行的。

1.2.3 程序顺序规则

看这样一个计算圆面积的例子,存在三个happens-before规则:
1、A happens-before B
2、B happens-before C
3、A happens-before C
之前提到了,对于A happens-before B,B的操作却可以排在A前面,因为JVM要求的是前一个操作结果对后一个操作可见,且前一个操作按顺序排在后一个操作前面。这里操作A的执行结果不需要对操作B可见,且操作A和操作B重排序后结果并不会改变,因此JVM认为这种重排序不是非法的(not illegal),是允许的。
在这里插入图片描述
在这里插入图片描述

1.2.4 重排序对多线程的影响

class ReorderExample {
	int a = 0;
	boolean flag = false;
	public void writer(){
		a = 1;
		flag = true;
	}
	
	public void reader(){
		if(flag){
			int i = a*a;
		}
	}
}

  flag是一个标记,用来标识变量a是否被写入,这里假设线程A执行writer方法,线程B执行reader方法,那么线程B在执行操作4时,能否看到线程A对共享变量a的写入呢?答案是不一定。因为编译器和处理器会对操作1和操作2进行重排序,同样的,操作3和操作4没有数据依赖关系,也可以重排序。
  在程序中,操作3和操作4存在控制依赖关系,会应先该序列执行的并行度,因此编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存一个名为**重排序缓冲(ROB)**的硬件缓存中,当判断为真时,就把计算结果写入变量i。
在这里插入图片描述

  在单线程中,对存在控制依赖单位操作进行重排序,不会改变执行结果,但是多线程程序中,对存在控制以来的操作进行重排序,可能会改变程序的执行结果。

1.3 顺序一致性

  顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参考。

1.3.1 数据竞争

  Java内存模型规范对数据竞争的定义为:在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。如果程序是正确同步的(synchronized,volatile,final),程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

1.3.2 顺序一致性内存模型

  顺序一致性内存模型是理论参考模型,有两大特性:
1、一个线程中的所有操作必须按照程序的顺序来执行
2、(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性模型中,每个操作都必须原子执行且立刻对所有线程可见
  在概念上,程序一致性模型有一个单一的全局内存,这个内存有左右摆动的开关连接到任意一个线程,每一个线程按照程序的顺序来执行内存的读写操作。在这种情况下,所有线程的所有内存读写操作都是串行化的。
在这里插入图片描述
下面是同步后的顺序一致性:
在这里插入图片描述
下面是未同步的顺序一致性:
在这里插入图片描述

1.3.3 同步程序的顺序一致性

class SynchronizedExample{
	int a = 0;
	boolean flag = false;
	public synchronized void writer(){
		a = 1;
		flag = true;
	}
	
	public synchronized  void reader(){
		if(flag){
			int i = a*a;
		}
	}
}

  根据JMM规范,该程序的执行结果将于该程序在顺序一致性模型中的执行机如果相同,下图为对比。在JMM中,临界区内的代码可以重排序,虽然线程A在临界区内做了重排序,但是由于监视器的互斥执行的特性,线程B根本无法观察到线程A在临界区内的重排序。这种重排序即提高了执行效率,又没有改变程序的执行结果。这里可以看到JMM具体实现的基本方针为:在不改变(正确同步)程序执行结果的前提下,尽可能为编译器和处理器的优化打开方便之门。
在这里插入图片描述

1.3.4 未同步程序的执行特性

  对于未同步或者未正确同步的程序,JMM只提供最小安全性:即线程执行时读取到的值,要么时之前某个线程已经写入的值,要么时默认值(0,null,false),保证不会无中生有(Out Of Thin Air)。因此在已清零的内存空间分配对象时,默认初始化已经完成了。未同步程序在JMM中和顺序一致性模型中的差别:
1、JMM不保证单线程中的操作按程序的顺序执行,但是顺序一致性模型保证单线程内的操作按程序的顺序执行
2、JMM不保证所有线程能看到一直的操作顺序,顺序一致性模型能保证所有线程只能看到一致的操作执行顺序
3、JMM不保证对64位的long型和double型的写操作具有原子性,但是顺序一致性模型保证对所有内存的读写操作都具有原子性
  总线的工作机制保证所有的处理器对内存的访问都以串行化的方式来执行,在一些32位处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。此时可能会把一个64位的long/double型变量的写操作拆分成两个32位的写操作来执行,此时的写操作将不再具有原子性。
在这里插入图片描述
注意,JDK5以前,一个64位的long/double型变量的读写操作都不具有原子性,JDK5以后,一个64位的long/double型变量的读操作具有原子性,写操作没有原子性。

1.4 volatile的内存语义

1.4.1 volatile的特性

一个volatile变量的单个读写操作,和一个普通变量的读写操作使用同一个锁来同步,它们之间的执行效果相同。特性:

  • 可见性:对一个volatile变量的读,总是能看到其他线程对这个volatile变量最后的写入
  • 原子性:对单个volatile变量的读写具有原子性,但类似于volatile++这种复合操作将不具有原子性

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

volatile的写和锁的释放具有相同的内存语义,volatile的读和锁的获取具有相同的内存语义。

1.4.3 volatile写-读的内存语义

volatile写内存的语义:当写一个volatile变量时,程序会把本地缓存中写完的变量值刷新到主内存中。
volatile读内存的语义:当读一个volatile变量时,JMM将会把本地内存中该变量的值置为无效,接下来从主内存中读取共享变量。
总结:线程A写一个volatile变量,实际上是对接下来要读这个volatile变量的线程B发出了共享变量修改的信息;线程B读一个volatile变量,实际上是接收了线程A的信息。
在这里插入图片描述

1.4.4 volatile内存语义的实现

  为了实现volatile语义,JMM会限制编译器重排序和处理器重排序。实际上,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器排序。JMM基于保守策略实现下列内存屏障插入策略,保证在任意处理器平台,任意的程序都能得到正确的volatile内存语义:
在这里插入图片描述
  对volatile写后面的内存屏障,作用是避免该volatile写操作和后面有可能的volatile读写操作重排序。实际上可以在每个volatile写后面插入StoreLoad屏障,或者在每个volatile操作前面插入StoreLoad屏障。JMM考虑到常见情况是一个线程写volatile变量,多个线程读同一个volatile变量,因此选择在volatile写操作之后插入StroeLoad屏障可以带来效率的提升。

1.5 锁的内存语义

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

  当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁,JMM会把该线程对应的本地内存置为无效,临界区代码必须从主内存中读取共享变量。

1.5.2 锁内存语义的实现

  借助ReentrantLock源码来分析锁内存语义的具体实现机制。

class ReentrantLockExample {
	int a = 0;
	ReentrantLock lock = new ReentrantLock();
	public void writer(){
		lock.lock();
		try{
			a++;
		}finally{
			lock.unlock();
		}
	}
	public void reader(){
		lock.lock();
		try{
			int i = a;
		}finally{
			lock.unlock();
		}
	}
}

  在ReentrantLock中,调用lock方法来获取锁,调用unlock方法来释放锁。ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。AQS使用一个整形的volatile变量(state)来维护同步状态。他是ReentrantLock内存语义实现的关键。
在这里插入图片描述
  ReentrantLock分为公平锁和非公平锁,在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile变量的happens-before规则,释放锁的线程在写volatile变量之前,可见的共享变量,在获取锁的线程读取同一个volatile变量后,立即变得对获取锁的线程可见。
  非公平锁使用原子操作的方式更新state变量,即compareAndSet方法,CAS。如果当前状态值等于预期值,则以原子方式将同步状态设置位给定的更新值,此操作具有volatile读和写的内存语义。编译器不会对CAS与CAS前面和后面的任意内存操作重排序。
  因此锁的获取和释放具有两种方式:
1、利用volatile变量的写-读具有的内存语义
2、利用CAS所附带的volatile读和volatile写的内存语义

1.5.3 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变量
  concurrent包通用化的实现模式为:首先声明共享变量为volatile,然后使用CAS的原子条件更新来实现线程间的同步,同时,配合volatile的读写和CAS具有的volatile读和写的内存于一来实现线程之间的通信。一些基础类包括AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包下的类)都是使用这种模式来实现的,concuerrent包的高层类有时依赖于这些基础类来实现的。
在这里插入图片描述

1.6 final域的内存语义

1.6.1 final域的重排序规则

  编译器和处理器要遵循两个final域的重排序规则:
1、构造器内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,它们之间的操作不能重排序。因此在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,但是普通域可能还没有被初始化完成。
2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。这保证了在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

1.6.2 final域为引用类型

  上面讨论的final域是基础数据类型,如果final域是引用类型,加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用复制给一个引用变量,这两个操作之间不能重排序。

1.7 happens-before

  happens-before用来指定两个操作之间的执行顺序,它们可以在一个线程,也可以在不同线程。
1、如果操作Ahappens-before操作B,那么操作A的结果将对操作B可见,而且操作A的执行顺序在操作B之前
2、如果重排序后的执行结果与按happens-before关系来执行的结果一致,那么这种重排序并不非法

1.8 双重检查锁定与延迟初始化

  双重检查锁定长被用来延迟初始化,以降低初始化类和创建对象的开销,但它是一个错误的做法。

1.8.1 双重检查锁定

  Java程序中有可能会延迟一些高开销的对象初始化操作,只有使用这些对象是才进行初始化(懒汉式)。

class DoubleCheckedLocking {
    private static Instance instance;
    public static Instance getInstance(){
        if(instance==null){
            synchronized (DoubleCheckedLocking.class){
                if(instance==null){
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}

  上面是双重检查锁定,如果第一次检查instance不为null,那么就不执行下面的加锁和初始化操作,也难辞可以大幅降低synchronized带来的性能开销。上面代码表面看起来两全其美,一、多个线程试图同一时间创建对象,会通过加锁来保证只有一个线程能创建对象;二、在对象创建号后,执行getInstance方法不需要获得锁,直接返回已创建好的对象。
  但是存在一种情况,代码执行到第四行是,代码读取到instance不为null,但是instance引用的对象可能还没有完成初始化。

1.8.2 问题的根源

上面代码的第七行可以分解为三行伪代码:分配内存空间,初始化对象,将内存空间给instance

memory = allocate();//1
ctorInstance(memory);//2
instance = memory;//3

  第二和第三行伪代码可能会被重排序,因为Java语言规范保证重排序不会改变单线程内的程序执行结果,因此并没有违反Java语言规范。此时线程B可能访问到还未初始化的对象。

memory = allocate();
instance = memory;
ctorInstance(memory);

  可以有两个方法实现线程安全的延迟初始化:
1、不允许2和3重排
2、允许2和3重排,但不允许其他线程看到这个重排

1.8.3 volatile的解决方案

  将instance声明为volatile型,就可以实现线程安全的延迟初始化。此时伪代码中的2和3将在多线程环境中禁止重排。

1.8.4 基于类初始化的解决方案

  JVM在类的初始化阶段,会获取一个锁,这个锁可以同步多个线程对同一个类的初始化(这样保证初始化的类只有一个)。因此可以利用这个特性,实现基于类的初始化的解决方案:

class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance(){
        return InstanceHolder.instance;
    }
}

  通过对比两种方案,可以发现基于类初始化的方案代码更简洁,但基于volatile的方案有一个优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。因此,需要对静态字段使用线程安全的延迟初始化,就用基于类的初始化的代码;如果要对实例字段使用线程安全的延迟初始化,就使用基于volatile的延迟初始化。

1.9 Java内存模型总结

1.9.1 处理器的内存模型

1、放松程序中写-读操作的顺序,由此产生了 Total Store Ordering 内存模型(TSO)
2、在上面的基础上,继续放松程序中写-写操作的顺序,产生了Partial Store Order内存模型(PSO)
3、在前两条的基础上,继续放松程序中读-写和读-读操作的顺序吗,由此产生了Relaxed Memory Order内存模型(RMO)和PowerPC内存模型。
注意:这里的处理器对读/写操作放松,是指两个操作之间不存在数据依赖性为前提。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值