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

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

如下图所示,在Java内存模型中,所有的变量都存储在主内存。每个Java线程都存在着自己的工作内存,工作内存中保存了该线程用得到的变量的副本,线程对变量的读写都在工作内存中完成,无法直接操作主内存,也无法直接访问其他线程的工作内存。当一个线程之间的变量的值的传递必须经过主内存。

当两个线程A和线程B之间要完成通信的话,要经历如下两步:

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

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

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

需要注意的是,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是如何防止指令重排序:

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语义是虚拟机为了保证执行结果不变的情况下提供程序的并行度优化所遵循的原则,前者适用于多线程的情形,后者适用于单线程的环境。

2、Java线程

2.1 Java线程的实现

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

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

如图所示,每个线程最终都会映射到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相关的那一部分。但真正去研究并发和并发包的内容,还有许多的源代码需要我们去阅读,仅仅一篇文章的篇幅显然无法全部覆盖。

在此我向大家推荐一个Java高级群 :725633148 里面会分享一些资深架构师录制的视频录像:(有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构)等这些成为架构师必备的知识体系 进群马上免费领取,目前受益良多!

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/gupao123456/article/details/81210920
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞