Java内存模型的基础以及内存语义

Java中多个线程访问同一变量,对应到硬件上就是内存和CPU缓存,JVM是如何处理数据竞争的,必须了解Java的内存模型以及编译相关的JVM指令,同时还必须了解线程的实现方式。

Java语言通过编译器编译成JVM认识的class文件,JVM再把Class中的指令集转换成操作系统可识别二进制编码,操作系统再驱动硬件包括CPU、内存、磁盘等硬件设备。在这个过程中,数据是多分的,如何保证数据一致性很重要。数据库中经常提及的事务4个特征ACID,JVM操作内存数据涉及到原子性和一致性问题。

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

三大问题

原子性( Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、 load、assign、 use、 store和write,大致可以认为基本数据类型的访问读写是具备原子性的( 例外就是long和double的非原子性协定) 。

如果应用场景需要一个更大范围的原子性保证( 经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求, 尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作, 这两个字节码指令反映到Java代码中就是同步块——synchronized关键字, 因此在synchronized块之间的操作也具备原子性。

可见性( Visibility):可见性是指当一个线程修改了共享变量的值, 其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的, 无论是普通变量还是volatile变量都是如此, 普通变量与volatile变量的区别是, volatile的特殊规则保证了新值能立即同步到主内存, 以及每次使用前立即从主内存刷新。 因此, 可以说volatile保证了多线程操作时变量的可见性, 而普通变量则不能保证这一点。

有序性( Ordering):Java程序中天然的有序性可以总结为一句话: 如果在本线程内观察, 所有的操作都是有序的; 如果在一个线程中观察另一个线程, 所有的操作都是无序的。 前半句是指“线程内表现为串行的语义”( Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

硬件的效率

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

  • 重排序
  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

happens-before  先行发生原则:先行发生是Java内存模型中定义的两项操作之间的偏序关系, 如果说操作A先行发生于操作B, 其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、 发送了消息、 调用了方法等。

先行发生规则

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
     
  • 顺序一致性

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾, 但是也为计算机系统带来更高的复杂度, 因为它引入了一个新的问题: 缓存一致性( Cache Coherence) 。在多处理器系统中, 每个处理器都有自己的高速缓存, 而它们又共享同一主内存( Main Memory)。

除了增加高速缓存之外, 为了使得处理器内部的运算单元能尽量被充分利用, 处理器可能会对输入代码进行乱序执行( Out-Of-Order Execution) 优化, 处理器会在计算之后将乱序执行的结果重组, 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致, 因此, 如果存在一个计算任务依赖另外一个计算任务的中间结果, 那么其顺序性并不能靠代码的先后顺序来保证。 与处理器的乱序执行优化类似, Java虚拟机的即时编译器中也有类似的指令重排序( Instruction Reorder) 优化。

顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。

内存模型

  • 数据处理硬件模型

  • Java线程模型

指令工作区作用
lock(锁定)主内存把一个变量标识为一条线程独占的状态
unlock(解锁)主内存把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取)主内存把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入)工作内存把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用)工作内存把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
assign(赋值)工作内存把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储)工作内存把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
write(写入)主内存把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存复制到工作内存, 那就要顺序地执行read和load操作, 如果要把变量从工作内存同步回主内存, 就要顺序地执行store和write操作。 注意, Java内存模型只要求上述两个操作必须按顺序执行, 而没有保证是连续执行。 也就是说, read与load之间、 store与write之间是可插入其他指令的, 如对主内存中的变量a、 b进行访问时, 一种可能出现顺序是read a、 read b、 load b、 load a。 除此之外, Java内存模型还规定了在执行上述8种。

基本操作时必须满足如下规则:

  • 不允许read和load、 store和write操作之一单独出现, 即不允许一个变量从主内存读取了但工作内存不接受, 或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作, 即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地( 没有发生过任何assign操作) 把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”, 不允许在工作内存中直接使用一个未被初始化( load或assign) 的变量, 换句话说, 就是对一个变量实施use、 store操作之前, 必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作, 但lock操作可以被同一条线程重复执行多次, 多次执行lock后, 只有执行相同次数的unlock操作, 变量才会被解锁。
  • 如果对一个变量执行lock操作, 那将会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定, 那就不允许对它执行unlock操作, 也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前, 必须先把此变量同步回主内存中( 执行store、 write操作)。
     

线程实现方式

  • 使用内核线程实现,轻量级进程(Java实现方式)

首先, 由于是基于内核线程实现的, 所以各种线程操作, 如创建、 析构及同步, 都需要进行系统调用。 而系统调用的代价相对较高, 需要在用户态( User Mode) 和内核态( KernelMode) 中来回切换。 其次, 每个轻量级进程都需要有一个内核线程的支持, 因此轻量级进程要消耗一定的内核资源( 如内核线程的栈空间) , 因此一个系统支持轻量级进程的数量是有限的。

  • 使用用户线程实现(go实现方式)

使用用户线程的优势在于不需要系统内核支援, 劣势也在于没有系统内核的支援, 所有的线程操作都需要用户程序自己处理。 线程的创建、 切换和调度都是需要考虑的问题, 而且由于操作系统只把处理器资源分配到进程, 那诸如“阻塞如何处理”、 “多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至不可能完成。

  • 使用用户线程加轻量级进程混合实现

操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁, 这样可以使用内核提供的线程调度功能及处理器映射, 并且用户线程的系统调用要通过轻量级线程来完成, 大大降低了整个进程被完全阻塞的风险。

线程调度是指系统为线程分配处理器使用权的过程, 主要调度方式有两种, 分别是协同式线程调度( Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive  Threads Scheduling)。

线程间通信

  1. 使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
  2. 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
  3. notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。
  4. notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为BLOCKED。
  5. 从wait()方法返回的前提是获得了调用对象的锁。

等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感知到通知线程对变量做出的修改。

WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThread从WaitQueue移到SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后,WaitThread再次获取到锁并从wait()方法返回继续执行。

Java内存语义

  • volatile关键字

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

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

由于volatile变量只能保证可见性, 在不符合以下两条规则的运算场景中, 我们仍然要通过加锁( 使用synchronized或java.util.concurrent中的原子类) 来保证原子性

  • 运算结果并不依赖变量的当前值(volatile++会依赖当前变量), 或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。
  • final关键字

写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。

读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域
的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

初始化问题

memory = allocate();
ctorInstance(memory);
instance = memory;
// 1:分配对象的内存空间
// 2:初始化对象
// 3:设置instance指向刚分配的内存地址

重排

memory = allocate();
instance = memory;

 

ctorInstance(memory);

// 1:分配对象的内存空间
// 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
// 2:初始化对象

 JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
       return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
    }
}

问题

为什么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
        }
    }
}

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

参考:

《Java并发编程的艺术》

《深入理解Java虚拟机》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值