JVM扫盲-3:虚拟机内存模型与高效并发

1,741 阅读26分钟

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。 关于定义的理解这是一个仁者见仁智者见智的事情。出现线程安全的问题一般是因为主内存和工作内存数据不一致性和重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解它们的核心在于理解 java 内存模型(JMM)。

1、虚拟机内存模型 JMM

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

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

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

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

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

虚拟机内存模型

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

内存间交互的操作

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

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

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 readload 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 storewrite 操作。Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是 readload 之间,storewrite 之间是可以插入其他指令的,如对主内存中的变量 ab 进行访问时,可能的顺序是 read aread bload bload a

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

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

此外,虚拟机还对 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 就可以对它们任意地排序。

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

  1. 程序次序规则 (Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操纵。准确的说是程序的控制流顺序,考虑分支和循环等;
  2. 管理锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间上的顺序)对同一个锁的 lock 操作;
  3. volatile 变量规则 (Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作;
  4. 线程启动规则 (Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作;
  5. 线程终止规则 (Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行;
  6. 线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted() 可以检测是否有中断发生;
  7. 对象终结规则 (Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 的开始;
  8. 传递性 (Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么可以得出 A 先行发生于操作 C。

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

如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况: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 的关系就称为一对一的线程模型。

Java线程模型

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

2.2 线程安全

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

互斥同步

在Java中最基本的使用同步方式是使用 sychronized 关键字,该关键字在被编译之后会在同步代码块前后形成 monitorentermonitorexit 字节码指令。这两个字节码都需要一个 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操作存在一些问题:

  1. 一个典型的 ABA 的问题,也就是说当内存中的值被一个线程修改了,又改了回去,此时当前线程看到的值与期望的一样,但实际上已经被其他线程修改过了。想要解决 ABA 的问题,则可以使用传统的互斥同步策略。
  2. CAS 还有一个问题就是可能会自旋时间过长。因为 CAS 是非阻塞同步的,虽然不会将线程挂起,但会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
  3. 根据上面的描述也可以看出,CAS 只能保证一个共享变量的原子性,当存在多个变量的时候就无法保证。一种解决的方案是将多个共享变量打包成一个,也就是将它们整体定义成一个对象,并用 CAS 保证这个整体的原子性,比如AtomicReference

无同步方案

所谓无同步方案就是不需要同步。

  1. 比如一些集合属于不可变集合,那么就没有必要对其进行同步。
  2. 有一些方法,它的作用就是一个函数,这在函数式编程思想里面比较常见,这种函数通过输入就可以预知输出,而且参与计算的变量都是局部变量等,所以也没必要进行同步。
  3. 还有一种就是线程局部变量,比如ThreadLocal等。

2.3 锁优化

自旋锁和自适应自旋

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

自旋锁在 JDK 1.4.2 中就已经引入,只不过默认是关闭的,可以使用 -XX:+UseSpinning 参数来开启,在 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 相关的那一部分。但真正去研究并发和并发包的内容,还有许多的源代码需要我们去阅读,仅仅一篇文章的篇幅显然无法全部覆盖。