1、JVM编译运行过程
通常把Java分为编译期和运行时。这里说的Java的编译和C/C++是有着不同的意义的,javac的编译,编译Java源码生成“.class”文件里面实际是字节码,而不是可以直接执行的机器码。在运行时,JVM会通过类加载器加载字节码,解释或者编译执行:(1).class文件经过JVM内嵌的解释器解释执行;(2)存在JIT编译器(Just In Time Compiler 即时编译器)把经常运行的代码作为“热点代码”编译与本地平台相关的机器码,并进行各种层次的优化;(3)AOT编译器:Java 9提供的直接将所有代码编译成机器码执行。
在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文统称JIT编译器)。
即时编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。
为什么HotSpot虚拟机要使用解释器与编译器并存的架构?
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。
JVM启动时,可以指定不同的参数对运行模式进行选择。比如,指定“-Xint”就是告诉JVM只进行解释执行,不对代码进行编译,这种模式抛弃了JIT可能带来的性能优势。与其相对应的,还有一个“-Xcomp”参数,这是告诉JVM关闭解释器,不要进行解释执行,或者叫做最大优化级别。
除了日常最常见的Java使用模式,其实还有一种新的编译方式,即所谓的AOT(ahead-of-time compilation),直接将字节码编译成机器代码,这样就避免了JIT预热等各方面的开销,比如Oracle JDK9就引入了实验性的AOT特性。
编译的时间开销
解释器的执行,抽象的看是这样的:
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
而要JIT编译然后再执行的话,抽象的看则是:
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果
说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。
JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。
所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。
怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
1、只被调用一次,例如类的构造器(class initializer,
2、没有循环
对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
只有对频繁执行的代码,JIT编译才能保证有正面的收益。
为何HotSpot虚拟机要实现两个不同的即时编译器?
HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。
用Client Complier获取更高的编译速度,用Server Complier 来获取更好的编译质量。为什么提供多个即时编译器与为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。
哪些程序代码会被编译为本地代码?如何编译为本地代码?
程序中的代码只有是热点代码时,才会编译为本地代码,那么什么是热点代码呢?
运行过程中会被即时编译器编译的“热点代码”有两类:
1、被多次调用的方法。
2、被多次执行的循环体。
两种情况,编译器都是以整个方法作为编译对象。 这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了。
如何判断方法或一段代码或是不是热点代码呢?
要知道方法或一段代码是不是热点代码,是不是需要触发即时编译,需要进行Hot Spot Detection(热点探测)。
目前主要的热点探测方式有以下两种:
(1)基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。
(2)基于计数器的热点探测
采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。
HotSpot虚拟机中使用的是哪种热点检测方式呢?
在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
2、Java内存区域与Java内存模型
1)Java内存区域
Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。
方法区(Method Area,又称永久代):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。由于早期的Hotspot JVM实现,很多人习惯于将方法区称为永久代。可以通过-XX:PermSize -XX:MaxPermSize 等参数调整其大小。Java8中将永久代移除,同时增加了元数据区(Metaspace)。
运行时常量池(Run-Time Constant Pool):
这是方法区的一部分。常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。
从jdk1.7已经开始准备“去永久代”的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中(常量池除字符串常量池还有class常量池等,这里只是把字符串常量池移到堆内存中);在jdk1.8中,方法区已不存在,原方法区中存储的类信息、编译后的代码数据等已经移到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。Jdk1.3~1.6、jdk1.7、jdk1.8中方法区的变迁如下图:
去永久代的原因有:
(1)字符串存在永久代中,容易出现性能问题和内存溢出;
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出;
(3)永久代会为GC带来不必要的复杂度,并且回收效率偏低。
元空间(MetaSpace)
Jdk1.8中,已经不存在永久代(方法区),替代它的是一块空间叫做“元空间”,和永久代相似,都是JVM规范对方法区的实现,但是元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。元空间的大小仅受本地内存限制,但可以通过-XX: MetaSpaceSize和-XX: MaxMetaSpace来指定元空间的大小。
方法区替代为元空间的内存区域如下图所示:
JVM堆(Java Heap)内存:
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例和数组,几乎所有的对象实例都在这里分配内存。注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。jdk1.8后,字符串常量池从永久代中剥离出来,存放在其中(如jdk1.8的内存区域图)。
在虚拟机启动时,我们指定的“Xmx”之类参数就是用来指定最大堆空间等指标。
堆空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
新生代主要存放新创建的对象与存活时长小的对象,新生代=Eden(Eden用来存放JVM刚分配的对象)+S1(From Survivor)+S2(To Survivor);两个Survivor区的大小是相同的,默认情况下HotSpot对三个区的空间大小比例为Eden(8):S1(1):S2(1),可以通过-XX:SurvivorRatio参数调整这个比例,两个Survivor空间一样大,两个Survivor区在运行过程中会被分配为一个From空间和一个To空间的角色,新分配的对象默认都是在新生代的Eden区+From空间(对于较大的对象也会直接分配在老生代),To空间则一直为空。也就是说新生代最多可以有90%的空间供对象内存分配用。当新生代内存满时(无法满足新对象分配),则会启动新生代的垃圾收集。垃圾收集的过程中,会将不再被引用的对象占用的空间释放,仍然存活的对象会被复制到To空间,同时这些存活对象的年龄加1(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1)。垃圾收集完成后,Eden区和From空间都为空,只有To空间存放仍然存活的对象,此时From空间和To空间的角色互换。(例如:原来S1为From,S2为To,经历一次垃圾收集后S2变为From,S1变为To)。
-Xmn用来设置堆内新生代的大小。通过这个值我们也可以得到老生代的大小:-Xmx减去-Xmn
老年代:
正常情况,对于新生代的对象,如果坚强的活到一定岁数,在进行新生代垃圾回收时,这部分对象就会被提升到老生代。当然,礼法不外乎人情,总是会存在一些特殊的情况,导致一些对象可以跨越年龄的障碍出现在老生代:
1)分配大对象时,会直接在老年代分配,默认如果对象的大小大于一个survivor区的大小,就会直接在老生代分配,该大小可以通过-XX:PretenureSizeThreshold参数调整。
2)新生代垃圾回收时,如果仍然存活的对象无法全部放入To空间,则多余的对象也会直接进入老生代。
-XX:MaxTenuringThreshold 设置转入老生代的存活次数。如果是0,则直接跳过新生代进入老生代。
堆内存会从JVM启动参数(最小值-Xms:1G,最大值-Xmx:3G)指定的内存中分配。
年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。
-XX:NewRatio=n设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足以存放新对象则抛出OutOfMemoryError异常。常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。一个栈桢就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈桢已经进入虚拟机栈并且处于栈顶的位置,每个方法从调用到结束对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下:
-Xss256k: 设置每个线程的堆栈大小。在jdk1.5之前-Xss默认是256k,jdk1.5之后默认是1M。
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。一般情况下,我们无需关心此区域。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
直接内存(Direct Memory):
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
NIO的Buffer提供一个可以直接访问系统物理内存的类——DirectBuffer。DirectBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同。普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制。而DirectBuffer直接分配在物理内存中,并不占用堆空间。在访问普通的ByteBuffer时,系统总是会使用一个“内核缓冲区”进行操作。而DirectBuffer所处的位置,就相当于这个“内核缓冲区”。因此,使用DirectBuffer是一种更加接近内存底层的方法,所以它的速度比普通的ByteBuffer更快。
DirectBuffer并没有真正向OS申请分配内存,其最终还是通过调用Unsafe的allocateMemory
()来进行内存分配。不过JVM对DirectMemory可申请的大小也有限制,可用-XX:MaxDirect
MemorySize设置,这部分内存不受JVM垃圾回收管理。maxCachedBufferSize:每个NIO线程使用的缓存大小,在jdk1.8.101前无限制能力。
Code cache内存:
JIT Compiler在运行时对热点方法进行编译,就会将编译后的方法存储在Code Cache里面。
-XX:ReservedCodeCacheSize=32m 保留代码占用的内存容量,默认值32m
物理内存占用计算:
一般来说,物理内存占用=Xmx + XX:MaxMetaspaceSize + XX:ReservedCodeCacheSize +XX:MaxDirectMemorySize + maxCachedBufferSize * m + Xss * n + jvm_internal
说明:
Xmx: 堆内存大小
MaxMetaspaceSize: JVM元空间大小
ReservedCodeCacheSize: 缓存JIT编译后的code chche,默认值240M
MaxDirectMemorySize:NIO直接分配的DirectBuffer大小,默认值比堆内存大小略小;
maxCachedBufferSize: 每个NIO线程使用的缓存大小,在jdk1.8.101前无限制能力(配
置参数为-Djdk.nio.maxCachedBufferSize)
Xss: 线程栈大小,此参数与线程数有关。
jvm_internal: 为虚拟机内部使用的,通过经验值观察大概在50M左右。
2)Java内存模型
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
关于JMM中的主内存和工作内存说明如下:
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
内存间交互操作
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。
read:把一个变量的值从主内存传输到工作内存中
load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
use:把工作内存中一个变量的值传递给执行引擎
assign:把一个从执行引擎接收到的值赋给工作内存的变量
store:把工作内存的一个变量的值传送到主内存中
write:在 store 之后执行,把 store 得到的值放入主内存的变量中
lock:作用于主内存的变量
unlock
谈谈Java内存模型存在的必要性。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。
内存模型三大特性
为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的。
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
可见性
理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
主要有三种实现可见性的方式:
volatile
synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
JMM提供的解决方案
在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。
下面就来具体介绍下happens-before原则(先行发生原则):
单一线程规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
管程锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程加入规则:Thread 对象的结束先行发生于 join() 方法返回
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始