JVM—虚拟机内存模型与高效并发

Java内存模型,即Java Memory Model,简称 JMM ,它是一种抽象的概念,或者是一种协议,用来解决在并发编程过程当中内存访问的问题,同时又能够兼容不一样的硬件和操做系统,JMM的原理与硬件一致性的原理相似。在硬件一致性的实现中,每一个CPU会存在一个高速缓存,而且各个CPU经过与本身的高速缓存交互来向共享内存中读写数据。spring

以下图所示,在Java内存模型中,全部的变量都存储在主内存。每一个Java线程都存在着本身的工做内存,工做内存中保存了该线程用获得的变量的副本,线程对变量的读写都在工做内存中完成,没法直接操做主内存,也没法直接访问其余线程的工做内存。当一个线程之间的变量的值的传递必须通过主内存。编程

当两个线程A和线程B之间要完成通讯的话,要经历以下两步:缓存

  1. 线程A从主内存中将共享变量读入线程A的工做内存后并进行操做,以后将数据从新写回到主内存中;
  2. 线程B从主存中读取最新的共享变量

volatile关键字使得每次volatile变量都可以强制刷新到主存,从而对每一个线程都是可见的。安全

须要注意的是,JMM与Java内存区域的划分是不一样的概念层次,更恰当说JMM描述的是一组规则,经过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式。在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工做内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。性能优化

内存间交互的操做

上面介绍了JMM中主内存和工做内存交互以及线程之间通讯的原理,可是具体到各个内存之间如何进行变量的传递,JMM定义了8种操做,用来实现主内存与工做内存之间的具体交互协议:微信

lock
unlock
read
load
use
assign
store
write

若是要把一个变量从主内存中复制到工做内存,就须要按顺寻地执行 read 和 load 操做,若是把变量从工做内存中同步回主内存中,就要按顺序地执行 store 和 writ e操做。Java内存模型只要求上述两个操做必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间, store 和 write 之间是能够插入其余指令的,如对主内存中的变量 a 、 b 进行访问时,可能的顺序是 read a , read b , load b , load a 。多线程

Java内存模型还规定了在执行上述八种基本操做时,必须知足以下规则:架构

  1. 不容许 read 和 load 、 store 和 write 操做之一单独出现;
  2. 不容许一个线程丢弃它的最近 assign 的操做,即变量在工做内存中改变了以后必须同步到主内存中;
  3. 不容许一个线程无缘由地(没有发生过任何 assign 操做)把数据从工做内存同步回主内存中;
  4. 一个新的变量只能在主内存中诞生,不容许在工做内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施 use 和 store 操做以前,必须先执行过了 assign 和 load 操做;
  5. 一个变量在同一时刻只容许一条线程对其进行 lock 操做, lock 和 unlock 必须成对出现;
  6. 若是对一个变量执行 lock 操做,将会清空工做内存中此变量的值,在执行引擎使用这个变量前须要从新执行 load 或 assign 操做初始化变量的值;
  7. 若是一个变量事先没有被 lock 操做锁定,则不容许对它执行 unlock 操做,也不容许去unlock一个被其余线程锁定的变量;
  8. 对一个变量执行 unlock 操做以前,必须先把此变量同步到主内存中(执行 store 和 write操做)。

此外,虚拟机还对voliate关键字和long及double作了一些特殊的规定。并发

voliate关键字的两个做用

  1. 保证变量的可见性:当一个被voliate关键字修饰的变量被一个线程修改的时候,其余线程能够马上获得修改以后的结果。当一个线程向被voliate关键字修饰的变量写入数据的时候,虚拟机会强制它被值刷新到主内存中。当一个线程用到被voliate关键字修饰的值的时候,虚拟机会强制要求它从主内存中读取。
  2. 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果时正确的,可是没法保证程序的操做顺序与代码顺序一致。这在单线程中不会构成问题,可是在多线程中就会出现问题。很是经典的例子是在单例方法中同时对字段加入voliate,就是为了防止指令重排序。为了说明这一点,能够看下面的例子。

咱们如下面的程序为例来讲明voliate是如何防止指令重排序:app

public class Singleton {
    private volatile static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) { // 1
            sychronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton(); // 2
                }
            }
        }
        return singleton;
    }
} 
复制代码

实际上当程序执行到2处的时候,若是咱们没有使用voliate关键字修饰变量singleton,就可能会形成错误。这是由于使用 new 关键字初始化一个对象的过程并非一个原子的操做,它分红下面三个步骤进行:

  1. 给 singleton 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将 singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)

若是虚拟机存在指令重排序优化,则步骤2和3的顺序是没法肯定的。若是A线程率先进入同步代码块并先执行了3而没有执行2,此时由于singleton已经非null。这时候线程B到了1处,判断singleton非null并将其返回使用,由于此时Singleton实际上还未初始化,天然就会出错。

可是特别注意在jdk 1.5之前的版本使用了volatile的双检锁仍是有问题的。其缘由是Java 5之前的JMM(Java 内存模型)是存在缺陷的,即时将变量声明成volatile也不能彻底避免重排序,主要是volatile变量先后的代码仍然存在重排序问题。这个volatile屏蔽重排序的问题在jdk 1.5 (JSR-133)中才得以修复,这时候jdk对volatile加强了语义,对volatile对象都会加入读写的内存屏障,以此来保证可见性,这时候2-3就变成了代码序而不会被CPU重排,因此在这以后才能够放心使用volatile。

对long及double的特殊规定

虚拟机除了对voliate关键字作了特殊规定,还对long及double作了一些特殊的规定:容许没有被volatile修饰的long和double类型的变量读写操做分红两个32位操做。也就是说,对long和double的读写是非原子的,它是分红两个步骤来进行的。可是,你能够经过将它们声明为voliate的来保证对它们的读写的原子性。

先行发生原则(happens-before) & as-if-serial

Java内存模型是经过各类操做定义的,JMM为程序中全部的操做定义了一个偏序关系,就是先行发生原则(Happens-before)。它是判断数据是否存在竞争、线程是否安全的主要依据。想要保证执行操做B的线程看到操做A的结果,那么在A和B之间必须知足Happens-before关系,不然JVM就能够对它们任意地排序。

先行发生原则主要包括下面几项,当两个变量之间知足如下关系中的任意一个的时候,咱们就能够判断它们之间的是存在前后顺序的,串行执行的。

程序次序规则(Program Order Rule)
管理锁定规则(Monitor Lock Rule)
volatile变量规则(Volatile Variable Rule)
线程启动规则(Thread Start Rule)
线程终止规则(Thread Termination Rule)
线程中断规则(Thread Interruption Rule)
对象终结规则(Finilizer Rule)
传递性(Transitivity)

不一样操做时间前后顺序与先行发生原则之间没有关系,两者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以先行发生原则为准。

若是两个操做访问同一个变量,且这两个操做有一个为写操做,此时这两个操做就存在数据依赖性这里就存在三种状况:1).读后写;2).写后写;3). 写后读,三种操做都是存在数据依赖性的,若是重排序会对最终执行结果会存在影响。编译器和处理器在重排序时,会遵照数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操做的执行顺序。

还有就是 as-if-serial 语义:无论怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵照as-if-serial语义。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

先行发生原则(happens-before)和as-if-serial语义是虚拟机为了保证执行结果不变的状况下提供程序的并行度优化所遵循的原则,前者适用于多线程的情形,后者适用于单线程的环境。

二、Java线程

2.1 Java线程的实现

在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是经过语言级别层面程序去间接调用系统内核的线程模型,即咱们在使用Java线程时,Java虚拟机内部是转而调用当前操做系统的内核线程来完成当前任务。这里须要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操做系统内核(Kernel)支持的线程,这种线程是由操做系统内核来完成线程切换,内核经过操做调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每一个内核线程能够视为内核的一个分身,这也就是操做系统能够同时处理多任务的缘由。因为咱们编写的多线程程序属于语言层面的,程序通常不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是一般意义上的线程,因为每一个轻量级进程都会映射到一个内核线程,所以咱们能够经过轻量级进程调用内核线程,进而由操做系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。

如图所示,每一个线程最终都会映射到CPU中进行处理,若是CPU存在多核,那么一个CPU将能够并行执行多个线程任务。

2.2 线程安全

Java中可使用三种方式来保障程序的线程安全:1).互斥同步;2).非阻塞同步;3).无同步。

互斥同步

在Java中最基本的使用同步方式是使用 sychronized 关键字,该关键字在被编译以后会在同步代码块先后造成 monitorenter 和 monitorexit 字节码指令。这两个字节码都须要一个reference类型的参数来指明要锁定和解锁的对象。若是在Java程序中明确指定了对象参数,就会使用该对象,不然就会根据sychronized修饰的是实例方法仍是类方法,去去对象实例或者Class对象做为加锁对象。

synchronized先天具备 重入性 :根据虚拟机的要求,在执行sychronized指令时,首先要尝试获取对象的锁。若是这个对象没有被锁定,或者当前线程已经拥有了该对象的锁,就把锁的计数器加1,相应地执行 monitorexit 指令时会将锁的计数器减1,当计数器为0时就释放锁。弱获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

除了使用sychronized,咱们还可使用JUC中的ReentrantLock来实现同步,它与sychronized相似,区别主要表如今如下3个方面:

  1. 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程能够选择放弃等待;
  2. 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次得到锁;而非公平锁没法保证,当锁被释放时任何在等待的线程均可以得到锁。sychronized自己时非公平锁,而ReentrantLock默认是非公平的,能够经过构造函数要求其为公平的。
  3. 锁能够绑定多个条件:ReentrantLock能够绑定多个Condition对象,而sychronized要与多个条件关联就不得不加一个锁,ReentrantLock只要屡次调用newCondition便可。

在JDK1.5以前,sychronized在多线程环境下比ReentrantLock要差一些,可是在JDK1.6以上,虚拟机对sychronized的性能进行了优化,性能再也不是使用ReentrantLock替代sychronized的主要因素。

非阻塞同步

所谓非阻塞同步就是在实现同步的过程当中无需将线程挂起,它是相对于互斥同步而言的。互斥同步本质上是一种悲观的并发策略,而非阻塞同步是一种乐观的并发策略。在JUC中的许多并发组建都是基于CAS原理实现的,所谓CAS就是Compare-And-Swape,相似于乐观加锁。但与咱们熟知的乐观锁不一样的是,它在判断的时候会涉及到3个值:“新值”、“旧值”和“内存中的值”,在实现的时候会使用一个无限循环,每次拿“旧值”与“内存中的值”进行比较,若是两个值同样就说明“内存中的值”没有被其余线程修改过,不然就被修改过,须要从新读取内存中的值为“旧值”,再拿“旧值”与“内存中的值”进行判断。直到“旧值”与“内存中的值”同样,就把“新值”更新到内存当中。

这里要注意上面的CAS操做是分3个步骤的,可是这3个步骤必须一次性完成,由于否则的话,当判断“内存中的值”与“旧值”相等以后,向内存写入“新值”之间被其余线程修改就可能会获得错误的结果。JDK中的 sun.misc.Unsafe 中的 compareAndSwapInt 等一系列方法Native就是用来完成这种操做的。另外还要注意,上面的CAS操做存在一些问题:

AtomicReference

无同步方案

所谓无同步方案就是不须要同步,好比一些集合属于不可变集合,那么就没有必要对其进行同步。有一些方法,它的做用就是一个函数,这在函数式编程思想里面比较常见,这种函数经过输入就能够预知输出,并且参与计算的变量都是局部变量等,因此也不必进行同步。还有一种就是线程局部变量,好比ThreadLocal等。

2.3 锁优化

自旋锁和自适应自旋

自旋锁用来解决互斥同步过程当中线程切换的问题,由于线程切换自己是存在必定的开销的。若是物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,咱们就可让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,咱们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可使用 -XX:+UseSpinnin g参数来开启,在JDK 1.6中就已经改成默认开启了。自旋等待自己虽然避免了线程切换的开销,但它是要占用处理器时间的, 因此若是锁被占用的时间很短,自旋等待的效果就会很是好,反之若是锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会作任何有用的工做, 反而会带来性能的浪费。

咱们能够经过参数 -XX:PreBlockSpin 来指定自旋的次数,默认值是10次。在JDK 1.6中引入了 自适应的自旋锁 。自适应意味着自旋的时间再也不固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。若是在同一个锁对象上,自旋等待刚刚成功得到过锁,而且持有锁的线程正在运行中,那么虚拟机就会认为此次自旋也颇有可能再次成功,进而它将容许自旋等待持续相对更长的时间, 好比100个循环。另外一方面,若是对于某个锁,自旋不多成功得到过,那在之后要获取这个锁时将可能省略掉自旋过程,以免浪费处理器资源。

下面是自旋锁的一种实现的例子:

public class SpinLock {
    private AtomicReference<Thread> sign = new AtomicReference<>();

    public void lock() {
        Thread current = Thread.currentThread();
        while(!sign.compareAndSet(null, current)) ;
    }

    public void unlock() {
        Thread current = Thread.currentThread();
        sign.compareAndSet(current, null);
    }
}
复制代码

从上面的例子咱们能够看出,自旋锁是经过CAS操做,经过比较期值是否符合预期来加锁和释放锁的。在lock方法中若是sign中的值是null,也就代标锁被释放了,不然锁被其余线程占用,须要经过循环来等待。在unlock方法中,经过将sign中的值设置为null来通知正在等待的线程锁已经被释放。

锁粗化

锁粗化的概念应该比较好理解,就是将屡次链接在一块儿的加锁、解锁操做合并为一次,将多个连续的锁扩展成一个范围更大的锁。

public class StringBufferTest {
    StringBuffer sb = new StringBuffer();

    public void append(){
        sb.append("a");
        sb.append("b");
        sb.append("c");
    }
}
复制代码

这里每次调用 sb.append() 方法都须要加锁和解锁,若是虚拟机检测到有一系列连串的对同一个对象加锁和解锁操做,就会将其合并成一次范围更大的加锁和解锁操做,即在第一次 append()方法时进行加锁,最后一次 append() 方法结束后进行解锁。

轻量级锁

轻量级锁是用来解决重量级锁在互斥过程当中的性能消耗问题的,所谓的重量级锁就是 sychronized 关键字实现的锁。 synchronized 是经过对象内部的一个叫作监视器锁(monitor)来实现的。可是监视器锁本质又依赖于底层的操做系统的 Mutex Lock 来实现的。而操做系统实现线程之间的切换就须要从用户态转换到核心态,这个成本很是高,状态之间的转换须要相对比较长的时间。

首先,对象的对象头中存在一个部分叫作 Mark word ,其中存储了对象的运行时数据,如哈希码、GC年龄等,其中有2bit用于存储锁标志位。

在代码进入同步块的时候,若是对象锁状态为无锁状态(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中创建一个名为 锁记录 ( Lock Record )的空间,用于存储锁对象目前的 Mark Word 的拷贝。拷贝成功后,虚拟机将使用CAS操做尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对的 Mark word 。而且将对象的 Mark Word 的锁标志位变为"00",表示该对象处于锁定状态。更新操做失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,若是是就说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行。不然说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的变为“10”, Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了避免让线程阻塞,而采用循环去获取锁的过程。

从上面咱们能够看出,实际上当一个线程获取了一个对象的轻量级锁以后,对象的 Mark Word会指向线程的栈帧中的 Lock Record ,而栈帧中的 Lock Record 也会指向对象的 Mark Word 。 栈帧中的 Lock Record 用于判断当前线程已经持有了哪些对象的锁,而对象的 Mark Word 用来判断哪一个线程持有了当前对象的锁。 当一个线程尝试去获取一个对象的锁的时候,会先经过锁标志位判断当前对象是否被加锁,而后经过CAS操做来判断当前获取该对象锁的线程是不是当前线程。

轻量级锁不是设计用来取代重量级锁的,由于它除了加锁以外还增长了额外的CAS操做,所以在竞争激烈的状况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它如今认为只可能有一个线程来访问它,因此当第一个线程来访问它的时候,它会偏向这个线程。此时,对象持有偏向锁,偏向第一个线程。这个线程在修改对象头成为偏向锁的时候使用CAS操做,并将对象头中的ThreadID改为本身的ID,以后再次访问这个对象时,只须要对比ID,不须要再使用CAS在进行操做。

一旦有第二个线程访问这个对象,由于偏向锁不会主动释放,因此第二个线程能够看到对象时偏向状态,这时代表在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,若是挂了,则能够将对象变为无锁状态,而后从新偏向新的线程,若是原来的线程依然存活,则立刻执行那个线程的操做栈,检查该对象的使用状况,若是仍然须要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。若是不存在使用了,则能够将对象回复成无锁状态,而后从新偏向。

轻量级锁认为竞争存在,可是竞争的程度很轻,通常两个线程对于同一个锁的操做都会错开,或者说稍微等待一下(自旋),另外一个线程就会释放锁。 可是当自旋超过必定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程之外的线程都阻塞,防止CPU空转。

若是大多数状况下锁老是被多个不一样的线程访问,那么偏向模式就是多余的,能够经过 -XX:-UserBiaseLocking 禁止偏向锁优化。

轻量级锁和偏向锁的提出是基于一个事实,就是大部分状况下获取一个对象锁的线程都是同一个线程,它在这种情形下的效率会比重量级锁高,当锁老是被多个不一样的线程访问它们的效率就不必定比重量级锁高。 所以,它们的提出不是用来取代重量级锁的,但在一些场景中会比重量级锁效率高,所以咱们能够根据本身应用的场景经过虚拟机参数来设置是否启用它们。

总结

JMM是Java实现并发的理论基础,JMM种规定了8种操做与8种规则,并对voliate、long和double类型作了特别的规定。

JVM会对咱们的代码进行重排序以优化性能,对于重排序,JMM又提出了先行发生原则(happens-before)和as-if-serial语义,以保证程序的最终结果不会由于重排序而改变。

Java的线程是经过一种轻量级进行映射到内核线程实现的。咱们可使用互斥同步、非阻塞同步和无同步三种方式来保证多线程状况下的线程安全。此外,Java还提供了多种锁优化的策咯来提高多线程状况下的代码性能。

这里主要介绍JMM的内容,因此介绍的并发相关内容也仅介绍了与JMM相关的那一部分。但真正去研究并发和并发包的内容,还有许多的源代码须要咱们去阅读,仅仅一篇文章的篇幅显然没法所有覆盖。

注:关注做者微信公众号,了解更多分布式架构、微服务、netty、MySQL、spring、、性能优化、等知识点。

公众号:《 Java大蜗牛 

相关文章
相关标签/搜索