JVM
1.基本概念:
JVM 是可运行 Java 代码的假想计算机 ,包括一套字节码指令集、一组寄存器、一个栈、
一个垃圾回收,堆 和 一个存储方法域。JVM 是运行在操作系统之上的,它与硬件没有直接
的交互。
2.运行过程:
我们都知道 Java 源文件,通过编译器,能够生产相应的.Class 文件,也就是字节码文件,
而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码 。
也就是如下:
1 Java 源文件—->编译器—->字节码文件
2 字节码文件—->JVM—->机器码
每一种平台的解释器是不同的,但是实现的虚拟机是相同的,这也就是 Java 为什么能够
跨平台的原因了 ,当一个程序从开始运行,这时虚拟机就开始实例化了,多个程序启动就会
存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不
能共享。
线程
这里所说的线程指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。 Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓 冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。 Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可 用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时会释放原生线程和 Java 线程的所有资源。
Hotspot JVM 后台运行的系统线程主要有下面几个:
| 虚拟机线程
(VM thread)
| 这个线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当
堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有:stop-the-
world 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。 |
| —- | —- |
| 周期性任务线程 | 这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。
|
| GC 线程 | 这些线程支持 JVM 中不同的垃圾回收活动。 | | 编译器线程 | 这些线程在运行时将字节码动态编译成本地平台相关的机器码。 | | 信号分发线程 | 这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。 |
JVM 内存区域
JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区
域【JAVA 堆、方法区】、直接内存。
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 Hotspot
VM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的
生/死对应)。
线程共享区域随虚拟机的启动/关闭而创建/销毁。
直接内存并不是 JVM 运行时数据区的一部分, 但也会被频繁的使用: 在 JDK 1.4 引入的 NIO 提
供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用
DirectByteBuffer 对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在 Java
堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。
程序计数器(线程私有)
一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。
正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如
果还是 Native 方法,则为空。
这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈(线程私有)
是描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)
用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成
的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接
(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异
常)都算作方法结束。
1.局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。
在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量)
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。
虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。
2. 操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。
操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。
当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。
3.动态连接
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
4.方法返回
当一个方法开始执行时,可能有两种方式退出该方法:
- 正常完成出口
- 异常完成出口
正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。
异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。
无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。
本地方法栈(线程私有)
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
堆(Heap-线程共享)-运行时数据区
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行 垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以 细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
方法区/永久代(线程共享)
即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静 态变量、即时编译器编译后的代码等数据. HotSpot VM 把 GC 分代收集扩展至方法区, 即使用 Java 堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型 的卸载, 因此收益一般很小)。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版 本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加 载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量 池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会 被虚拟机认可、装载和执行。
JVM 运行时内存
Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年
代。
新生代
是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
Eden 区
Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老 年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行 一次垃圾回收。
ServivorFrom
上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
ServivorTo
MinorGC 的过程(复制->清空->互换)
MinorGC 采用复制算法。
1:eden、servicorFrom 复制到 ServicorTo,年龄+1
首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年
龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不
够位置了就放到老年区);
2:清空 eden、servicorFrom
然后,清空 Eden 和 ServicorFrom 中的对象;
3:ServicorTo 和 ServicorFrom 互换
最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom
区。
老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行
了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足
够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没
有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减
少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的
时候,就会抛出 OOM(Out of Memory)异常。
永久代
指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被 放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这 也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
JAVA8 与元数据
在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间 的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用 本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
堆内存分配方式
堆内存
堆内存分配方式分为“指针碰撞”与“空闲列表”两种,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
指针碰撞(Bump the Pointer)
如果堆内存是完全工整的,用过的内存和没用的内存各在一边每次分配的时候只需要将指针向空闲内存一方移动一段和内存大小相等区域即可。比如ParNew、Serial、G1这种带整理的垃圾回收器,由它们负责回收的区域就采用“指针碰撞”方式来进行内存分配。
空闲列表(free list)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。像CMS垃圾回收器回收后并不对内存进行整理,因此老年代会存在较多的碎片,这样当需要在老年代进行内存分配是,采用的是“空闲列表”的方式。
堆内存分配并发问题
由于堆内存是多线程共享的,因此不管哪种堆分配方式,都不可避免的存在并发问题。JVM主要采用两种方案来解决并发问题:
同步处理
JVM采用CAS(Compare and Swap)乐观锁 + 重试机制 来保证多线程更新free list的原子性。
TLAB(Thread Local Allocation Buffer)
JVM运行中,内存分配是一个比较频繁的动作,同步处理带来的失败重试会对分配性能有较大影响,因此,从java 1.6开始引入了TLAB技术,可以通过-XX:+/-UseTLAB来开启或者关闭(默认是开启的)。TLAB线程的一块私有内存,在线程初始化时,从堆中申请一块指定大小的内存。当前线程需要申请内存时,先从自己的TLAB中分配,容量不够时再通过同步处理从Eden区申请。这样从TLAB中分配时没有并发问题,能大幅提升分配效率。
TLAB上分配过程
TLAB的管理是依靠三个指针:start、end、top。start与end标记了Eden中被该TLAB管理的区域,该区域不会被其他线程分配内存所使用,top是分配指针,开始时指向start的位置,随着内存分配的进行,慢慢向end靠近,当撞上end时触发TLAB refill。每次
内存中Eden的结构大体为:
TLAB中分配规则(开启UseTLAB情况下):
- TLAB剩余空间大于待分配对象大小,直接在TLAB上分配返回。
- 如果TLAB剩余空间放不下对象,同时TLAB剩余空间大小 大于允许的浪费阈值(refill_waste),那么本次放弃在TLAB上分配,直接在eden区进行慢分配,TLAB剩余空间留着下次继续使用。
- 如果TLAB剩余空间放不下对象,同时TLAB剩余空间大小 小于允许的浪费阈值,那么进入到“慢分配”过程:
a. 丢弃当前TLAB。
b. 从eden区裸空间重新分配一个新TLAB,然后对象再在新TLAB上分配。(这里从eden区新申请TLAB时,如果eden区空间不够,会触发YGC。)
几点需要注意的地方:
- 开启TLAB情况下,TLAB占用eden区空间比较小,默认只有Eden空间的1%,可以通过 -XX:TLABWasteTargetPercent来调整。
- 默认情况下,TLAB大小和refill_waste阈值都是由JVM在运行时根据“历史统计信息”动态计算出来的,比如一个线程内存分配申请很频繁,可能该线程的TLAB就会更大。如果不想让系统自动调整,可以通过JVM参数来控制:
- -XX:-ResizeTLAB (禁用TLAB size的动态调整策略)
- -XX:TLABSize (指定固定的TLAB size)
- TLABRefillWasteFraction (可浪费阈值,默认是64:可浪费1/64的TLAB空间)
栈上分配
以上可知,一般情况下JVM中对象都是在堆中进行内存分配,但是在堆中分配内存虽然有TLAB类似的技术能降低同步带来的开销,但还是存在较多在TLAB上分配不了的情况,另外,堆中分配的对象在生命周期结束后需要专门的垃圾回收器来清理,和栈中内存“方法退出即可销毁”的分配回收相比效率较低。
在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段编译是把.class转换成机器指令的过程。为提升第二阶段的执行速度,引入了JIT技术(Just-in-time Compilation),JIT其中主要的工作包括:
- “热点代码检测”;
- 热点代码编译优化(逃逸分析、锁消除、锁膨胀、方法内联、空值检查消除等);
- 缓存热点代码编译后的机器指令;
其中编译优化中采用的一种重要技术“逃逸分析”就是优化部分堆上分配为“栈上分配”的基础优化。
逃逸分析
逃逸分析(Escape Analysis)是一种代码分析,通过动态分析对象的作用域,可以分析出某个对象是否永远只在某个方法、线程的范围内,并没有“逃逸”出这个范围,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。
- 方法逃逸 方法中定义的对象被外部方法引用(如:作为调用参数或者返回值被其他方法引用到);
- 线程逃逸 对象可以被其他线程访问到(如:实例变量、类变量等)
通过 -XX:+DoEscapeAnalysis 参数来控制“逃逸分析”是否开启,jdk 1.8下默认开启。
对于“无法逃逸”的对象,JIT编译器可以对代码进行优化,主要有以下几种优化方式:
同步消除
线程同步的消耗较大,通过“逃逸分析”,如果确定一个对象不会被其他线程访问到,那对这个对象的读写就不会存在并发问题,因此可以清除该对象的同步锁。
如下示例:该对象的同步锁在编译优化阶段去掉了,提升执行性能。
public void sample() {
Object lock = new Object();
synchronized(lock) {
System.out.println(lock);
}
}
public void sample() {
Object lock = new Object();
System.out.println(lock);
}
通过 -XX:+EliminateLocks 参数来控制“同步消除”是否开启,jdk 1.8下默认开启。
标量替换
JIT经过“逃逸分析”发现一个对象只在方法内部使用不会被外界访问时,会在编译优化过程中把这个对象的成员变量拆解成若干个原始数据类型的标量来进行替代,这个过程就是“标量替换”。通过这种方式就能间接实现堆上对象不用在堆上分配而是通过替代的标量在栈上分配了。
如下示例:经过标量替换后,User对象被替换成两个标量了,从而避免在堆上进行分配。
public void sayHi() {
User user = new User(1, 14100L);
System.out.println("Say Hi to:" + user.uid + "," + user.staffNum);
}
class User {
private int uid;
private long staffNum;
}
public void sayHi() {
int uid = 1;
long staffNum = 14100L;
System.out.println("Say Hi to:" + uid + "," + staffNum);
}
通过 -XX:+EliminateAllocations 参数来控制“标量替换”是否开启,jdk 1.8下默认开启。
栈上分配
如果确定一个对象不会逃逸出方法之外,那么让对象在栈上分配内存,对象所占用的内存空间就可以随着栈帧的出栈而销毁。目前JVM没有实现真正的“栈上分配”,而是通过“标量替换”来间接实现的。
总结
由以上整理可知,虚拟机对象分配流程大概如下:首先如果开启栈上分配,JVM会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行TLAB分配,如果TLAB分配不成功,再尝试在eden区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。在eden区和老年代分配主要通过“指针碰撞”和“空闲列表”两种方式实现,通过CAS解决堆上“非TLAB方式分配”的并发问题。
Java 代码编译和执行的整个过程
Java 代码编译是由 Java 源码编译器来完成,流程图如下所示:
Javac是一种编译器,能将一种语言规范转化成另外一种语言规范,通常编译器都是将便于人理解的语言规范转化成机器容易理解的语言规范,如C/C++或者汇编语言都是将源代码直接编译成目标机器码,这个目标机器代码是CPU直接执行的指令集合。这些指令集合也就是底层的一种语言规范。
Javac的编译器也是将Java这种对人非常友好的编程语言编译成对对所有机器都非常友好的一种语言。这种语言不是针对某种机器或某个平台。怎么消除不同种类,不同平台之间的差异这个任务就有JVM来完成,而Javac的任务就是将Java源代码语言转化为JVM能够识别的一种语言,然后由JVM将JVM语言再转化成当前这个机器能够识别的机器语言。
Javac的任务就是将Java源代码编译成Java字节码,也就是JVM能够识别的二进制代码,从表面看是将.java文件转化为.class文件。而实际上是将Java源代码转化成一连串二进制数字,这些二进制数字是有格式的,只有JVM能够真确的识别他们到底代表什么意思。
编译器把一种语言规范转化为另一种语言规范的这个过程需要哪些步骤?回答这个问题需要参照《编译原理》,总结过程如下:
1)词法分析:读取源代码,一个字节一个字节的读进来,找出这些词法中我们定义的语言关键词如:if、else、while等,识别哪些if是合法的哪些是不合法的。这个步骤就是词法分析过程。
词法分析的结果:就是从源代码中找出了一些规范化的token流,就像人类语言中,给你一句话你要分辨出哪些是一个词语,哪些是标点符号,哪些是动词,哪些是名词。
2)语法分析:就是对词法分析中得到的token流进行语法分析,这一步就是检查这些关键词组合在一起是不是符合Java语言规范。如if的后面是不是紧跟着一个布尔型判断表达式。
语法分析的结果:就是形成一个符合Java语言规定的抽象语法树,抽象语法树是一个结构化的语法表达形式,它的作用是把语言的主要词法用一个结构化的形式组织在一起。这棵语法树可以被后面按照新的规则再重新组织。
3)语义分析:语法分析完成之后也就不存在语法问题了,语义分析的主要工作就是把一些难懂的,复杂的语法转化成更简单的语法。就如难懂的文言文转化为大家都懂的百话文,或者是注释一下一些不懂的成语。
语义分析结果:就是将复杂的语法转化为简单的语法,对应到Java就是将foreach转化为for循环,还有一些注释等。最后生成一棵抽象的语法树,这棵语法树也就更接近目标语言的语法规则。
4)字节码生成:将会根据经过注释的抽象语法树生成字节码,也就是将一个数据结构转化为另外一个数据结构。就像将所有的中文词语翻译成英文单词后按照英文语法组装文英文语句。代码生成器的结果就是生成符合java虚拟机规范的字节码。这个过程中的需要的组件如下图2所示:
图2
从上面的描述中我们知道编译就是将一种语言通过分析分解,再按照一定的方式先形成一个简单的框架(将Java源文件的字节流转化为对应的token流)然后在通过详细的分析按照一定的规定在这个框架里添加东西使这个token流形成更加结构化的语法树(就是将前面生成的token流中的一个个单词组装成一句话),但是这棵树离我们的目标—Java字节码还有点差距,所以再进行语义分析使那颗粗糙的树更加完整完善(给类添加默认的构造函数,检查变量在使用前有没有初始化,检查操作变量类型是否匹配),然后javac编译器调用com.sun.tools.javac.jvm.Gen类遍历这棵语法树将java方法中的代码块转换成符合JVM语法的命令形式的二进制数据。按照JVM的文件组织格式将字节码输出到以class为扩展名的文件中,也就是生成最终的java字节码。词法分析就是将关键词组织成token流即检查源码中的的关键词是否真确并组织成token流,而语法分析就是检查源码是否符合java语法规范并将词组成语句。语义分析就是简化复杂的添加缺少的,检查变量类型是否合法。代码生成器就是遍历这棵树生成符合JVM规范的代码。
Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示:
Java 代码编译和执行的整个过程包含了以下三个重要的机制:
- Java 源码编译机制
- 类加载机制
- 类执行机制
Java 源码编译机制
Java 源码编译由以下三个过程组成:
- 分析和输入到符号表
- 注解处理
- 语义分析和生成 class 文件
流程图如下所示:
最后生成的 class 文件由以下部分组成:
- 结构信息。包括 class 文件格式版本号及各部分的数量与大小的信息。
- 元数据。对应于 Java 源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池。
方法信息。对应 Java 源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。
类加载机制
JVM 的类加载是通过 ClassLoader 及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
1)Bootstrap ClassLoader
负责加载$JAVA_HOME中jre/lib/rt.jar
里所有的 class,由 C++ 实现,不是 ClassLoader 子类。
2)Extension ClassLoader
负责加载Java平台中扩展功能的一些 jar 包,包括$JAVA_HOME中jre/lib/*.jar
或-Djava.ext.dirs
指定目录下的 jar 包。
3)App ClassLoader
负责记载 classpath 中指定的 jar 包及目录中 class。
4)Custom ClassLoader
属于应用程序根据自身需要自定义的 ClassLoader,如 Tomcat、jboss 都会根据 J2EE 规范自行实现 ClassLoader。
加载过程中会先检查类是否被已加载,检查顺序是自底向上,从 Custom ClassLoader 到 BootStrap ClassLoader 逐层检查,只要某个 Classloader 已加载就视为已加载此类,保证此类只所有 ClassLoade r加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。类执行机制
JVM 是基于栈的体系结构来执行 class 字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。栈的结构如下图所示:
类的生命周期
上图展示的是类生命周期流向;在本文里,我只打算谈谈类的”初始化”以及”对象实例化”两个阶段。
类初始化
类”初始化”阶段,它是一个类或接口被首次使用的前阶段中的最后一项工作,本阶段负责为类变量赋予正确的初始值。
Java 编译器把所有的类变量初始化语句和类型的静态初始化器通通收集到方法内,该方法只能被 Jvm 调用,专门承担初始化工作。
除接口以外,初始化一个类之前必须保证其直接超类已被初始化,并且该初始化过程是由 Jvm 保证线程安全的。另外,并非所有的类都会拥有一个() 方法,在以下条件中该类不会拥有 () 方法: 该类既没有声明任何类变量,也没有静态初始化语句;
- 该类声明了类变量,但没有明确使用类变量初始化语句或静态初始化语句初始化;
该类仅包含静态 final 变量的类变量初始化语句,并且类变量初始化语句是编译时常量表达式。
对象初始化
在类被装载、连接和初始化,这个类就随时都可能使用了。对象实例化和初始化是就是对象生命的起始阶段的活动,在这里我们主要讨论对象的初始化工作的相关特点。
Java 编译器在编译每个类时都会为该类至少生成一个实例初始化方法—即 “()” 方法。此方法与源代码中的每个构造方法相对应,如果类没有明确地声明任何构造方法,编译器则为该类生成一个默认的无参构造方法,这个默认的构造器仅仅调用父类的无参构造器,与此同时也会生成一个与默认构造方法对应的 “ ()” 方法.
通常来说,() 方法内包括的代码内容大概为:调用另一个 () 方法;对实例变量初始化;与其对应的构造方法内的代码。
如果构造方法是明确地从调用同一个类中的另一个构造方法开始,那它对应的() 方法体内包括的内容为:一个对本类的 () 方法的调用;对应用构造方法内的所有字节码。
如果构造方法不是通过调用自身类的其它构造方法开始,并且该对象不是 Object 对象,那() 法内则包括的内容为:一个对父类 () 方法的调用;对实例变量初始化方法的字节码;最后是对应构造子的方法体字节码。
如果这个类是 Object,那么它的() 方法则不包括对父类 () 方法的调用。 类的初始化时机
本文到目前为止,我们已经大概有了解到了类生命周期中都经历了哪些阶段,但这个类的生命周期的开始阶段—类装载又是在什么时候被触发呢?类又是何时被初始化的呢?让我们带着这三个疑问继续去寻找答案。
Java 虚拟机规范为类的初始化时机做了严格定义:”initialize on first active use”—“ 在首次主动使用时初始化”。这个规则直接影响着类装载、连接和初始化类的机制—因为在类型被初始化之前它必须已经被连接,然而在连接之前又必须保证它已经被装载了。
在与初始化时机相关的类装载时机问题上,Java 虚拟机规范并没有对其做严格的定义,这就使得 JVM 在实现上可以根据自己的特点提供采用不同的装载策略。我们可以思考一下 Jboss AOP 框架的实现原理,它就是在对你的 class 文件装载环节做了手脚—插入了 AOP 的相关拦截字节码,这使得它可以对程序员做到完全透明化,哪怕你用 new 操作符创建出的对象实例也一样能被 AOP 框架拦截—与之相对应的 Spring AOP,你必须通过他的 BeanFactory 获得被 AOP 代理过的受管对象,当然 Jboss AOP 的缺点也很明显—他是和 JBOSS 服务器绑定很紧密的,你不能很轻松的移植到其它服务器上。嗯~……,说到这里有些跑题了,要知道 AOP 实现策略足可以写一本厚厚的书了,嘿嘿,就此打住。
说了这么多,类的初始化时机就是在”在首次主动使用时”,那么,哪些情形下才符合首次主动使用的要求呢?
首次主动使用的情形:创建某个类的新实例时—new、反射、克隆或反序列化;
- 调用某个类的静态方法时;
- 使用某个类或接口的静态字段或对该字段赋值时(final字段除外);
- 调用Java的某些反射方法时
- 初始化某个类的子类时
- 在虚拟机启动时某个含有main()方法的那个启动类。
除了以上几种情形以外,所有其它使用JAVA类型的方式都是被动使用的,他们不会导致类的初始化。
垃圾回收与算法
如何确定垃圾
引用计数法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单 的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关 联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收 对象。
可达性分析
为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”
对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记
过程。两次标记后仍然是可回收对象,则将面临回收。
标记清除算法(Mark-Sweep)
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清
除阶段回收被标记的对象所占用的空间。如图
从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可
利用空间的问题。
复制算法(copying)
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小
的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用
的内存清掉,如图:
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原
本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
标记整理算法(Mark-Compact)
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清
理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:
分代收集算法
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存 划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃 圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
新生代与复制算法
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要 回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代 划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用
Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另 一块 Survivor 空间中。
<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/470251/1581155953757-9e662b19-0d06-48b4-94ad-c7ae8d870a39.png#align=left&display=inline&height=135&name=image.png&originHeight=270&originWidth=1486&size=137302&status=done&style=none&width=743)
老年代与标记整理算法
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
- JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,
常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。 - 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目
前存放对象的那一块),少数情况会直接分配到老生代。 - 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
- 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
- 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
- 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。
JAVA 四中引用类型
强引用
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引 用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即 使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之 一。
软引用
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
弱引用
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
虚引用
虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
GC 垃圾收集器
JAVA堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,JDK1.6 中 Sun HotSpot 虚拟机的垃圾收集器如下:
Serial 垃圾收集器(单线程、复制算法)
Serial(英文连续)是最基本垃圾收集器,使用复制算法,曾经是 JDK1.3.1 之前新生代唯一的垃圾 收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工 作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。<br />Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限 定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial
垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
ParNew垃圾收集器(Serial+多线程)
ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃
圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也
要暂停所有其他的工作线程。
ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限
制垃圾收集器的线程数。【Parallel:平行的】
ParNew 虽然是除了多线程外和 Serial 收集器几乎完全一样,但是 ParNew 垃圾收集器是很多 java
虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
Parallel Scavenge 收集器(多线程复制算法、高效)
Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃 圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码 的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)), 高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而
不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个 重要区别。
SerialOld收集器(单线程标记整理算法)
Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法, 这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。
在 Server 模式下,主要有两个用途:
- 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。
- 作为年老代中使用 CMS 收集器的后备垃圾收集方案。
新生代 Serial 与年老代 Serial Old 搭配垃圾收集过程图:
新生代 Parallel Scavenge 收集器与 ParNew 收集器工作原理类似,都是多线程的收集器,都使 用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。新生代 Parallel Scavenge/ParNew 与年老代 Serial Old 搭配垃圾收集过程图:
新生代 Parallel Scavenge 收集器与 ParNew 收集器工作原理类似,都是多线程的收集器,都使
用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。新生代 Parallel
Scavenge/ParNew 与年老代 Serial Old 搭配垃圾收集过程图:
CMS收集器(多线程标记清除算法)
最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。<br />CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
初始标记
只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
并发标记
进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
重新标记
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记 记录,仍然需要暂停所有的工作线程。
并发清除
清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并
发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看
CMS 收集器的内存回收和用户线程是一起并发地执行。
CMS 收集器工作过程:
G1收集器
Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收 集器两个最突出的改进是:
- 基于标记-整理算法,不产生内存碎片。
- 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域 的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾 最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收 集效率。
HotSpot是什么?
1. HotSpot历史
SUN的JDK版本从1.3.1开始运用HotSpot虚拟机, 2006年底开源,主要使用C++实现,JNI接口部分用C实现。<br /> HotSpot是较新的Java虚拟机,用来代替JIT(Just in Time),可以大大提高Java运行的性能。 <br /> Java原先是把源代码编译为字节码在虚拟机执行,这样执行速度较慢。而HotSpot将常用的部分代码编译为本地(原生,native)代码,这样显着提高了性能。 <br /> HotSpot JVM 参数可以分为规则参数(standard options)和非规则参数(non-standard options)。 <br /> 规则参数相对稳定,在JDK未来的版本里不会有太大的改动。 <br /> 非规则参数则有因升级JDK而改动的可能。<br /> 规则和非规则参数这里不做介绍了,网上资料很多。
2.HotSpot基础知识
HotSpot包括一个解释器和两个编译器(client 和 server,二选一的),解释与编译混合执行模式,默认启动解释执行。<br /> 编译器:java源代码被编译器编译成class文件(字节码),java字节码在运行时可以被动态编译(JIT)成本地代码(前提是解释与编译混合执行模式且虚拟机不是刚启动时)。<br /> 解释器: 解释器用来解释class文件(字节码),java是解释语言(书上这么说的)。<br /> server启动慢,占用内存多,执行效率高,适用于服务器端应用;<br /> client启动快,占用内存小,执行效率没有server快,默认情况下不进行动态编译,适用于桌面应用程序。<br /> 由-XX:+RewriteFrequentPairs参数控制 client模式默认关闭,server模式默认开启<br /> 在jre安装目录下的lib/i386/jvm.cfg 文件下。<br /> java -version<br /> Java HotSpot(TM) Client VM (build 14.3-b01, mixed mode, sharing)<br /> mixed mode 解释与编译 混合的执行模式 默认使用这种模式<br /> java -Xint -version<br /> Java HotSpot(TM) Client VM (build 14.3-b01, interpreted mode, sharing)<br /> interpreted 纯解释模式 禁用JIT编译<br /> java -Xcomp -version<br /> Java HotSpot(TM) Client VM (build 14.3-b01, compiled mode, sharing)<br /> compiled 纯编译模式(如果方法无法编译,则回退到解释模式执行无法编译的方法)
3.动态编译
动态编译(compile during run-time),英文称Dynamic compilation;Just In Time也是这个意思。<br /> HotSpot对bytecode的编译不是在程序运行前编译的,而是在程序运行过程中编译的。<br /> HotSpot里运行着一个监视器(Profile Monitor),用来监视程序的运行状况。<br /> java字节码(class文件)是以解释的方式被加载到虚拟机中(默认启动时解释执行)。 程序运行过程中,那一部分运用频率大,那些对程序的性能影响重要。对程序运行效率影响大的代码,称为热点(hotspot),HotSpot会把这些热点动态地编译成机器码(native code),同时对机器码进行优化,从而提高运行效率。对那些较少运行的代码,HotSpot就不会把他们编译。<br /> HotSpot对字节码有三层处理:不编译(字节码加载到虚拟机中时的状态。也就是当虚拟机执行的时候再编译),编译(把字节码编译成本地代码。虚拟机执行的时候已经编译好了,不要再编译了),编译并优化(不但把字节码编译成本地代码,而且还进行了优化)。<br /> 至于那些程序那些不编译,那些编译,那些优化,则是由监视器(Profile Monitor)决定。
4.为什么不静态编译那?
为什么字节码在装载到虚拟机之前就编译成本地代码那? <br /> 动态编译器也在许多方面比静态编译器优越。静态编译器通常很难准确预知程序运行过程中究竟什么部分最需要优化。<br /> 函数调用都是很浪费系统时间的,因为有许多进栈出栈操作。因此有一种优化办法,就是把原来的函数调用,通过编译器的编译,改成非函数调用,把函数代码直接嵌到调用出,变成顺序执行。<br /> 面向对象的语言支持多态,静态编译无效确定程序调用哪个方法,因为多态是在程序运行中确定调用哪个方法。
Java内存模型
最近,面试过很多Java中高级开发,问过很多次关于Java内存模型的知识,问完之后,很多人上来就开始回答:
Java内存模型由几部分组成,堆、本地方法栈、虚拟机栈、方法区…
每一次我不想打断他们的话,虽然我知道这又是一个误会了我的问题的朋友。
其实,我想问的Java内存模型,是和并发编程有关的。而候选人给我回答的那叫JVM内存结构,完全是两回事。
很多时候,在我没有打断他们的情况下,一部分人慢慢的讲到了GC相关的知识。这种情况下,我只能硬着头皮继续问一些和JVM有关的知识。
但是,我的本意其实是想看一下他对Java并发有多少了解啊。
经常,我都在继续追问了一些他们回答的”Java内存模型”相关的知识后,友善的提醒一句,其实我想问的Java内存模型并不是他回答的这个…
有的时候,我会进一步提醒一句:是和并发编程有关的,是和主内存以及线程工作内存有关的。。。
那么,本文就来简单说一说,关于Java内存模型,到底应该如何回答这个面试题。
1
为什么会误解
首先,我们先来分析一下问什么很多人,甚至是大多数人会答非所问呢?
我觉得主要有几个原因:
1、Java内存模型,这个词听着太像是关于内存分布的知识了。听上去和并发编程没有半毛钱关系。
2、网上很多资料都是错的。不信你去网上搜索一下”Java内存模型”,你会发现,很多人打着内存模型的标题,介绍了JVM内存结构的知识。
这里提一句,我尝试着Google搜索了一下搜索”Java内存模型”,首页展示结果如下:

首页排名靠前的5篇文章中,有1篇是错的,介绍了JVM内存结构。
3、还存在一种情况,虽然不多见,但是也有。那就是很多面试官自己也以为内存模型就是要介绍堆、栈、方法区这些知识。就导致有时候面试者不知道自己到底应该如何回答。
那么,到底什么是Java内存模型?关于这道面试题应该如何回答呢?
2
什么是内存模型
Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。
Java内存模型的相关知识在 JSR-133: Java Memory Model and Thread Specification 中描述的。JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
那么,我们这里就先来说说什么是所谓的内存模型规范、这里提到的原子性、可见性以及有序性又是什么东西?
原子性
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。
缓存一致性
在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
有序性
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。
多CPU多级缓存导致的一致性问题、CPU时间片机制导致的原子性问题、以及处理器优化和指令重排导致的有序性问题等,都硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?
最简单直接的做法就是废除处理器和处理器的优化技术、废除CPU缓存,让CPU直接和主存交互。但是,这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。
所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
针对上面的这些问题,不同的操作系统都有不同的解决方案,而Java语言为了屏蔽掉底层的差异,定义了一套属于Java语言的内存模型规范,即Java内存模型。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

3
Java内存模型的实现
了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
本文并不准备把所有的关键字逐一介绍其用法,因为关于各个关键字的用法,网上有很多资料。读者可以自行学习。本文还有一个重点要介绍的就是,我们前面提到,并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized。
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:
volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。
但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。
4_
面试如何回答
前面我介绍完了一些和Java内存模型有关的基础知识,只是基础,并不是全部,因为随便一个知识点还是都可以展开的,如volatile是如何实现可见性的?synchronized是如何实现有序性的?
但是,当面试官问你:能简单介绍下你理解的内存模型吗?
首先,先和面试官确认一下:您说的内存模型指的是JMM,也就是和并发编程有关的那一个吧?
在得到肯定答复后,再开始介绍(如果不是,那可能就要回答堆、栈、方法区哪些了….囧…):
Java内存模型,其实是保证了Java程序在各种平台下对内存的访问都能够得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
除此之外,Java内存模型还提供了一系列原语,封装了底层实现后,供开发者直接使用。如我们常用的一些关键字:synchronized、volatile以及并发包等。
回答到这里就可以了,然后面试官可能会继续追问,然后根据他的追问再继续往下回答即可。
所以,当有人再问你Java内存模型的时候,不要一张嘴就直接回答堆栈、方法区甚至GC了,那样显得很不专业!
原文地址:https://www.yuque.com/lobotomy/java/zu4d2l