转载:http://blog.csdn.net/weitry/article/details/53264262
系列文章规划:
- JVM基础(1)——内存模型
- JVM基础(2)——内存管理
- JVM基础(3)——编译机制
- JVM基础(4)——类加载机制
- JVM基础(5)——垃圾回收和调优
- JVM基础(6)——G1收集器及G1日志分析
- JVM基础(7)——jdk常用内置工具
1. 基本概念
1.1 顺序一致性
程序执行最简单的模型就是按照指令出现的顺序执行,这叫顺序一致性模型。是一个理想化的内存模型。有以下规则:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
1.2 重排序
但人为指定的顺序并不能总是保证符合CPU处理的特性,因此现代计算机体系和处理器架构都不保证顺序一致性。
Java规范规定JVM线程内部维持顺序化语义,也就是说只要程序的最终结果等同于它在严格顺序化环境下执行的结果,那么指令的执行顺序就可能与代码的顺序不一致,这个过程通过叫做指令的重排序。重排序存在的意义在于:JVM能够根据处理器的特性(CPU的多级缓存系统、多核处理器等)适当的重新排序机器指令,使机器指令更符合CPU的执行特点,最大限度的发挥机器的性能。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序需要遵守以下原则:
- 数据依赖性。不会改变存在数据依赖关系的两个操作的执行顺序。(注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)
- as-if-serial 语义。不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
源代码 -> 编译器优化重排序 -> 指令级并行重排序 -> 内存系统重排序 -> 最终执行的指令序列
- 1
- 2
- 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
多核处理器一般有一层或者多层的缓存,缓存加速了数据访问(因为数据距离处理器更近),降低了共享内存在总线上的通讯(因为本地缓存能够满足许多内存操作),最终提高CPU性能。缓存能够大大提升性能,但是它们也带来了许多挑战。例如,当两个CPU同时检查相同的内存地址时会发生什么?在什么样的条件下它们会看到相同的值?
在处理器层面上,内存模型定义了一个充要条件:
当前处理器可以看到其他处理器写入到内存的数据,并且其他处理器可以看到当前处理器写入到内存的数据。
有些处理器具有强内存模型(strong memory model),能够让所有的处理器在任意时间任意指定内存地址上看到完全相同的值。而另外一些处理器具有较弱内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。这些内存屏障通常在lock和unlock操作的时候完成。内存屏障在高级语言中对程序员是不可见的。
在强内存模型下,有时候编写程序可能会更容易,因为减少了对内存屏障的依赖。但是即使在一些最强的内存模型下,内存屏障仍然是必须的。设置内存屏障往往与我们的直觉并不一致。近来处理器设计的趋势更倾向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性。
1.3 happens-before法则
在JMM中,如果动作B要看到动作A的执行结果(无论A/B是否在同一个线程里面执行),那么A/B就需要满足happens-before关系。
happens-before规则如下:
- 程序顺序规则:线程中的每个操作happens before该线程中在程序顺序上后续的每个操作。
- 监视器锁规则:对一个监视器的解锁操作happens-before随后对该监视器的加锁操作。
- volatile变量规则:对一个volatile域的写操作happens-before任意后续对该volatile域的读操作。
- 线程启动原则:线程上调用start()方法happens before这个线程启动后的任何操作。
- 一个线程中所有的操作都happens before从这个线程join()方法成功返回的任何其他线程。(意思是其他线程等待一个线程的jion()方法完成,那么,这个线程中的所有操作happens before其他线程中的所有操作)
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
1.4 JMM
Java Memory Mode,即Java内存模型,是Java线程之间通信的控制机制。
当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:
- 在一个线程中写一个变量,
- 在另一个线程读同一个变量,
- 而且写和读没有通过同步来排序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性。即,程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
1.5 可见性
可见性一般用于指不同线程之间的数据是否可见。
在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享可见的。对于堆中的数据,在本地内存中会对应的创建该数据的副本(相当于缓冲);这些副本对于其它线程也是不可见的。
所有局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,对其它线程是不可见的。它们不会有内存可见性问题,也不受内存模型的影响。
1.6 原子性
是指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。
2. 同步机制
同步有几个方面的作用。最广为人知的就是互斥 ——一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。
但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。依据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。
2.1 lock
锁是 java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
锁的内存语义:
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
公平锁是通过“volatile”实现同步的。公平锁在释放锁的最后写volatile变量state;在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变的对获取锁的线程可见。
非公平锁通过CAS实现的,CAS就是compare and swap。CAS实际上调用的JNI函数,也就是CAS依赖于本地实现。以Intel来说,对于CAS的JNI实现函数,它保证:(1)禁止该CAS之前和之后的读和写指令重排序。(2)把写缓冲区中的所有数据刷新到内存中。
2.2 volatile
volatile的内存语义:
- volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
- volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
从内存语义的角度来说,volatile 与锁有相同的效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。
为了实现 volatile 内存语义,JMM 会分别限制两种重排序类型(编译器重排序和处理器重排序)。下面是 JMM 针对编译器制定的 volatile 重排序规则表:
是否能重排序 | (操作2)普通读/写 | (操作2)volatile读 | (操作2)volatile写 |
---|---|---|---|
(操作1)普通读/写 | NO | ||
(操作1)volatile读 | NO | NO | NO |
(操作1)volatile写 | NO | NO |
从上表我们可以看出:
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
2.3 final
基本类型final域,编译器和处理器要遵守两个重排序规则:
- final写:“构造函数内对一个final域的写入”,与“随后把这个被构造对象的引用赋值给一个引用变量”,这两个操作之间不能重排序。
- final读:“初次读一个包含final域的对象的引用”,与“随后初次读对象的final域”,这两个操作之间不能重排序。
引用类的final域,除上面两条之外,还有一条规则:
- final写:在“构造函数内对一个final引用的对象的成员域的写入”,与“随后在构造函数外把这个被构造对象的引用赋值给一个引用变量”,这两个操作之间不能重排序。
注意:写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。
JMM通过“内存屏障”实现final。在final域的写之后,构造函数return之前,插入一个StoreStore障屏。在读final域的操作前面插入一个LoadLoad屏障。
3. JMM总结
JMM保证:如果程序是正确同步的,程序的执行将具有顺序一致性 。
从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型(程序尽可能的顺序执行)来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(对程序重排序,做尽可能多的并发)来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM设计就需要在这两者之间作出协调。JMM对程序采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。