1. 定义

JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。
image.png
这个内存模型和虚拟机的堆栈等概念是不一样的,如果非要联系在一起,主内存可以对应堆内存保存对象实例的部分,工作内存可以对应栈区;而从硬件层面来说,主内存就是物理内存,工作内存就是CPU的寄存器和高速缓存。

2. 内存交互标准

JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量实际上是从主内存拷贝的副本,线程对变量的读取和写入必须在工作内存中进行,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

2.1 八大交互指令

JMM定义了8种内存交互操作,JVM必须保证每一个操作都是原子的、不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外):

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

    2.2 交互规则

    JMM对这八种指令的使用,制定了如下规则:

  • 不允许read&load、store&write操作之一单独出现,即这两组命令必须成对使用

  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主内存
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。即对变量实施use、store操作之前,必须经过assign和load操作(有这两个操作说明变量是从主内存加载过来的)
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

    3. 模型特征

  • 原子性**:**例如上面八项操作,在操作系统里面是不可分割的单元,必须一次性执行完,不可打断。在Java中,被synchronized关键字或其他锁包裹起来的操作也可以认为是原子的。从一个线程观察另外一个线程的时候,看到的都是一个个原子性的操作。

  • 可见性**:**每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。volatile关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存进行读取,因此volatile可以保证可见性。除了volatile以外,synchronized和final也能实现可见性。synchronized保证unlock之前必须先把变量刷新回主内存。final修饰的字段在构造器中一旦完成初始化,并且构造器没有this逸出,那么其他线程就能看到final字段的值。
  • 有序性**:**java的有序性跟线程相关。如果在线程内部观察,会发现当前线程的一切操作都是有序的。如果在线程的外部来观察的话,会发现线程的所有操作都是无序的。因为JMM的工作内存和主内存之间存在延迟,而且java会对一些指令进行重新排序。volatile和synchronized可以保证程序的有序性,很多程序员只理解这两个关键字的执行互斥,而没有很好的理解到volatile和synchronized也能保证指令不进行重排序。

    3.1 this逸出

    所谓this逸出,就是在不该发布的时候发布了一个引用。通常发生在内部类中,例如A类有一个内部类B,而A的构造方法中初始化了B类,B的构造方法中又引用了A类的成员(方法或属性)。那么当new A()时,就会存在A还没构造完毕,B就已经在引用A的成员(针对B来说,就是this.xxxx,this指向外部类)的情况,此时就发生了this逸出,因为A还没初始化,B的this指针无法指向任何实例对象。

    4. volatile关键字的内存语义

    volatile在java语言中是一个关键字,用于修饰变量。被volatile修饰的变量后,表示这个变量在不同线程中是共享,编译时与运行时都会注意到这个变量是共享的,因此不会对该变量进行重排序。

JVM对代码进行编译的时候会进行指令优化,调整互不关联的代码的执行顺序,在单线程的时候,指令优化会保证优化后的结果不会出错。但是在多线程的时候,可能发生代码顺序颠倒导致某些变量在还没初始化时便被引用的问题,但如果将该变量用volatile修饰,就不会有问题。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令生成一个内存屏障。保证重排序后的指令不会越过内存屏障,即volatile之前的代码只会在volatile之前执行,volaiter之后的代码只会在volatile之后执行。

4.1 使用场景

volatile关键字一般用于标记变量的修饰,但volatile只保证有序性和可见性,而synchronized加锁机制可以同时确保可见性、原子性、有序性。当且仅当满足以下条件下,才能够使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值,或者确保只有单个线程变更变量的值。
  2. 该变量不会与其他变量一起纳入不变性条件中(即针对变量的判断语句不能同时依赖两个不同的变量)。
    1. 例如,a>1,这是一个单一变量不变性条件,但如果是a>b,那就出现了两个变量组合的复杂不变性条件
    2. 在复杂不变性条件中,一旦出现多个不同线程去操作这些不同的变量,那就会引发线程安全
  3. 在访问变量的时候不需要加锁(通常这是一个只读变量,不存在写入操作)。

理解:

  • 第一条:volatile不能作为多线程中的计数器,计数器的count++操作,分为三步,第一步先读取count的数值,第二步count+1,第三步将count+1的结果写入count。volatile不能保证操作的原子性。上述的三步操作中,如果有其他线程对count进行操作,就可能导致脏数据。
  • 第二条:若有连个变量lower初始为0,upper初始为5,并且upper和lower都用volatile修饰。我们期望不管怎么修改upper或者lower,都能保证upper>lower恒成立。然而如果同时有两个线程,t1操作upper,t2操作lower,两线程同时执行的时候就有可能会产生upper<lower这种不期望的结果。
  • 第三条:当访问一个变量需要加锁时,一般认为这个变量需要保证原子性和可见性,而volatile关键字只能保证变量的可见性,无法保证原子性,因此不具备锁的特性,不能用作锁。

    5. Final域的内存语义

    被final修饰的变量,相比普通变量,内存语义有一些不同。具体如下:

  • JMM禁止把Final域的变量重排序到构造器的外部。

  • 在一个线程中初次读该对象和该对象下的Final域时,JMM禁止处理器重排序这两个操作。

    6. Happen-Before(先行发生规则)

    分析一个并发程序是否安全,其实都依赖Happen-Before原则进行分析。Happen-Before被翻译成先行发生原则,意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

Happen-Before的规则有以下几条

  • 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
  • 管程锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。
  • volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必行晚于线程中所有操作
  • 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生
  • 对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法
  • 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C

以上就是Happen-Before中的规则。通过这些条件的判定,仍然很难判断一个线程是否能安全执行,毕竟在我们的时候线程安全多数依赖于工具类的安全性来保证。想提高自己对线程是否安全的判断能力,必然需要理解所使用的框架或者工具的实现,并积累线程安全的经验。

6.1 as-if-serial规则

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果都不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

7. synchronized关键字

线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。那么共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。

volatile关键字虽然可以保证可见性,但无法保证原子性,因此不可以作为锁来使用,Java使用synchronized关键字来声明锁,具有使每个线程依次排队操作共享变量的功能。当然了,这种同步机制效率很低,因为synchronized实际上是一个独占锁,即悲观锁。

7.1 实现原理

synchronized可使用在代码块和方法中,根据synchronized使用的位置,通常会有这些使用场景:
JMM内存模型 - 图2
PS:类对象指的是Class字节码,即类定义好后的元信息,只会有一个,而实例对象是类的副本,可以有多个。类对象如果成员被修改,会引发所有的实例对象成员一起被修改,而实例对象由于修改的是副本,所以不会影响到类对象。

如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法时分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里的需要注意的是:如果被锁的是类对象,尽管可以new多个实例对象,但他们仍然是属于同一个类,所以这些new出来的实例对象会全部被锁住

7.2 对象锁(monitor)机制

先写一个简单的demo:

  1. public class SynchronizedDemo {
  2. public static void main(String[] args) {
  3. synchronized (SynchronizedDemo.class) {}
  4. method();
  5. }
  6. private static synchronized void method() {}
  7. }

上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。编译之后,切换到SynchronizedDemo.class的同级目录之后用javap -v SynchronizedDemo.class查看字节码文件:
JMM内存模型 - 图3

如图,上面用黄色高亮的部分就是添加Synchronized关键字之后独有的,执行同步代码块后首先要先执行monitorenter指令,退出的时候执行monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是获取对象的监视器monitor,当线程获取监视器后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到监视器。

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁住的对象依然是类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
JMM内存模型 - 图4
该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步队列进行等待,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

7.3 happens-before分析

  1. public class MonitorDemo {
  2. private int a = 0;
  3. public synchronized void writer() { // 1
  4. a++; // 2
  5. } // 3
  6. public synchronized void reader() { // 4
  7. int i = a; // 5
  8. } // 6
  9. }

happens-before规则可以一定程度地评估代码是否是线程安全的,现在来分析上方代码中的Synchronized关键字是否满足这个规则:
JMM内存模型 - 图5

在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?

根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。因此线程A先对共享变量a进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见,那么线程B所读取到的a的值应该为1,而不是0。

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

接下来看看基于java内存抽象模型的Synchronized的内存语义:

JMM内存模型 - 图6

从上图可以看出,线程A会首先会从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加一操作后,再将该值刷新到主内存,整个过程即为线程A 加锁—>执行临界区代码—>释放锁相对应的内存语义。

JMM内存模型 - 图7

线程B获取锁的时候同样会从主内存中共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。从整体上来看,线程A的执行结果(a=1)对线程B是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。另外也验证了2 happens-before 5,2的执行结果对5是可见的。从横向来看,这就像线程A通过主内存中的共享变量和线程B进行通信,A 告诉 B 我们俩的共享数据现在为1啦,这种线程间的通信机制正好吻合java的内存模型,也符合共享内存的并发模型结构。

8. synchronized优化

现在我们对Synchronized应该有所印象了,它最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。但这种方式肯定效率低下,每次只能通过一个线程,因而在此前提下,我们能不能让每次通过的速度变快一点呢?

8.1 CAS操作

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
PS:理论上所有的乐观锁都是基于比对新老数据本身或数据版本号来实现的

8.1.1 原理和实现

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。

CAS的实现需要硬件指令集的支撑,JDK1.5后的虚拟机正式使用处理器提供的CMPXCHG指令实现。元老级的Synchronized(未优化前)最主要的问题是:在多个线程竞争的情况下会出现线程阻塞和唤醒带来的性能问题,因为这是一种互斥同步锁(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒操作,因此也叫做非阻塞同步,这是两者主要的区别。在JUC包中利用CAS实现的类有很多,可以说是支撑起整个并发包的实现,在Lock实现中会用CAS改变state变量,在atomic包中的类也几乎也都是用CAS实现的。

8.1.2 存在的问题

  • ABA问题:因为CAS会检查旧值有没有变化,这里就会存在一个有意思的问题,比如一个旧值A变成了B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。Java在1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是靠版本号。
  • 自旋时间过长:CAS属于非阻塞同步,也就是说不会将线程挂起,会自旋(也就是死循环)并进行无数次重试,一旦自旋时间过长对计算机来说会带来很大的资源消耗。当然,如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
  • 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时CAS能保证其原子性,但如果同时对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象来整合多个共享变量,然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

    8.2 Java对象头

    在同步的时候需要获取对象的monitor,即获取到对象的锁。而对象的锁实际上就是一个标志,即存放在Java对象里的对象头。Java对象头里的Mark Word部分默认的存放对象的Hashcode(直接将对象toString()时,往往会得到一个@xxxx字符串,这个就是对象的哈希值)、分代年龄、锁标记位。在32位JVM中,Mark Word默认存储结构为:
    JMM内存模型 - 图8

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:
JMM内存模型 - 图9

8.3 偏向锁

著名的虚拟机HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,便引入了偏向锁:

  • 获取锁:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不再需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
  • 撤销锁:偏向锁使用了一种等到竞争出现才释放锁的机制,所以只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,否则将一直持有。

JMM内存模型 - 图10
如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的线程栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁,要么标记对象不适合作为偏向锁,最后唤醒暂停的线程。

8.4 轻量级锁

  • 加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来持续获取锁。
  • 解锁:轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

JMM内存模型 - 图11

因为自旋会消耗CPU资源,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

8.5 各类锁的对比

JMM内存模型 - 图12