虚拟机运行机制
JVM运行在操作系统上,不与硬件设备直接交互。
JAVA程序执行流程:Java源代码文件( Hello·java)被编译器编译成字节码文件( Hello·class),字节码文件被JVM中的解释器编译成机器码在不同操作系统上运行。
Java程序具体运行过程:
Java跨平台的原因:每种操作系统的解释器都是不同的,但基于解释器实现的虚拟机是相同的。
虚拟机实例生命周期描述:在一个Java进程开始运行后,虚拟机就开始实例化,有多个进程启动就会实例化多个虚拟机实例。进程退出或关闭,则虚拟机实例销毁,在多个虚拟机实例之间不能共享数据。
虚拟机内部结构
JVM包括一个类加载器子系统(Class Loader Subsystem)、运行时数据区(Runtime Data Area)、执行引擎(Engine)和本地接口库(Nateive Interface Library)。
本地接口库调用本地方法库(Native Method Library)与OS交互。
JVM内部结构图如下:
其中:
虚拟机与多线程
在多核操作系统上,JVM允许在一个进程内同时并发执行多个线程。
JVM中的线程与操作系统中的线程是相互对应的。
JVM线程的调度是交给操作系统负责的。
有些程序语言有协程的概念,如Golang的并发,协程可以粗略地看成是轻量级的线程,一个协程并非对应一个操作系统线程,而是多个协程对应一个操作系统线程,协程之间通过调度器协调。
这种设计有以下的好处:
轻量级,资源占用较少。
调度是基于语言层面,减少操作系统线程调度的开销。
从JVM角度看,Java线程的执行流程:
在JVM后台运行的线程主要有:
虚拟机内存区域
JVM的内存区域分为:
线程私有区域的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。
在JVM中,每个Java线程与操作系统本地线程直接映射,因此这部分内存区域的存在与否和本地线程的启动和销毁对应。
线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁。
直接内存又称为对外内存,直接内存不是JVM运行时数据区的一部分,但在并发编程中被频繁调用。
JDK的NIO模块提供的基于 Channel与 Buffer的I/O操作就是基于堆外内存实现的,NIO模块通过调用Native Method Library直接在操作系统上分配堆外内存,然后使用 DirectByteBuffer对象作为这块内存的引用对内存进行操作,Java进程可以通过堆外内存技术避免在Java堆和Native堆中来回复制数据带来的资源占用和性能消耗,因此堆外内存在高并发应用场景下被广泛使用.NETty、Flink、HBase、Hadoop都有用到堆外存内存)。
程序计数器
程序计数器(PC)属于线程私有区域,程序计数器是唯一无内存溢出(Out of Memory)问题的区域。
程序计数器是一块很小的内存空间,用于存储当前运行的线程所执行的字节码的行号指示器。
每个运行中的线程都有一个独立的程序计数器,在方法正在执行时,该方法的程序计数器记录的是实时虚拟机字节码指令的地址。
注:如果该方法执行的是 native方法,则程序计数器的值为空(Undefined)。
虚拟机栈
虚拟机栈(JVM Stack)属于线程私有区域,描述Java方法的执行过程。
虚拟机栈是描述Java方法的执行过程的内存模型,它在当前栈帧(Stack Frame)中主要存储了以下信息:
同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接(Dynamic Linking)方法的返回值和异常分派(Dispatch Exception)。
栈帧:栈帧用来记录方法的执行过程。
线程运行及栈帧的变化过程如下:
线程1在CPU1上运行,线程2在CPU2上运行,在CPU资源不够时其他线程将处于等待状态(图中的线程N),等待获取CPU时间片。而在线程内部,每个方法的执行和返回都对应一个栈帧的入栈和出栈,每个运行中的线程当前只有一个栈帧处于活动状态。
本地方法区
本地方法区(Native Method Area)和虚拟机栈的作用类似,区别是虚拟机栈是为执行Java方法服务的,本地方法区是为Native方法服务的。
虚拟机堆
虚拟机堆(JVM Heap),也称为运行时数据区,虚拟机堆是线程共享的。
在JVM运行过程中创建的对象和产生的数据都被存储在堆中,堆是被线程共享的内存区域,也是垃圾回收器进行垃圾回收的最主要的内存区域。
由于现代JVM采用分代收集算法,因此Java堆从GC(Garbage Collection,垃圾回收)的角度还可以细分为:新生代、老年代和永久代。
方法区
方法区(Method Area),也被称为永久代,用于存储常量、静态变量、类信息、JIT编译后的机器码、运行时常量池等数据。
JVM把GC分代收集扩展至方法区,即使用Java堆的永久分代来实现方法区,这样JVM的垃圾收集器就可以像管理Java堆一样管理这部分内存。永久带的内存回收主要针对常量池的回收和类的卸载,因此可回收的对象很少。
常量被存储在运行时常量池(Runtime Constant Pool)中,是方法区的一部分。静态变量也属于方法区的一部分。在类信息(Class文件)中不但保存了类的版本、字段、方法、接口等描述信息,还保存了常量信息。
在即时编译后,代码的内容将在执行阶段(类加载完成后)被保存在方法区的运行时常量池中。Java虚拟机对Class文件每一部分的格式都有明确的规定,只有符合JVM规范的Class文件才能通过虚拟机的检查,然后被装载、执行。
虚拟机运行时内存
JVM的运行时内存也叫做JVM堆,从GC的角度可以将JVM堆分为新生代、老年代和永久代。
其中,新生代默认占1/3堆空间,老年代默认占2/3堆空间。
新生代又分为Eden区、ServivorFrom和ServivorTo区。
JVM堆分代分区的结构如下:
JVM新创建的对象(除了大对象)都会被存放在新生代,默认占1/3堆内存空间。
由于JVM会频繁创建对象,所以新生代会频繁出发MinorGC进行垃圾回收。
新生代
新生代分为Eden区(8/10新生代空间)、ServivorFrom区(1/10新生代空间)、ServivorTo区(1/10新生代空间)。
Eden区:Java新创建的对象首先会被存放在Eden区,如果新创建的对象属于大对象,则直接将其分配到老年代。
ServivorTo区:保留上一次MinorGC时的幸存者。
ServivorFrom区:将上一次MinorGC时的幸存者作为这一次MinorGC的被扫描者。
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:
老年代
老年代主要存放长生命周期对象和大对象。
老年代的GC过程叫做MajorGC,在老年代,对象比较稳定,MajorGC不会被频繁触发。
在进行MajorGC之前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到足够大的连续内存空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收活动,释放JVM的内存空间。
MajorGC采用标记清除算法,该算法首先会扫描所以对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。
因为要先扫描老年代的所有对象再回收,所以MajorGC的耗时比较长,MajorGC的标记清除算法容易产生内存碎片。
在老年代没有内存空间可分配时,会抛出OOM异常。
永久代
永久代指内存的永久保存区域,主要存放 Class和 Meta(元数据)的信息。
Class在类加载时被放入永久代码。
永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会抛出Out OfMemory异常,比如Tomcat引用Jar文件过多导致JVM内存不足而无法启动。
需要注意的是,在Java 8中永久代已经被元数据区(也叫作元空间)取代。
元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。
因此,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
在Java 8中,JVM将类的元数据放入本地内存(Native Memory)中,将常量池和类的静态变量放入Java堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存(MaxPermSize)空间决定,而由操作系统的实际可用内存空间决定。
垃圾回收与算法
确定垃圾
Java采用引用计数法和可达性分析来确定对象是否需要被回收。
根搜索算法以一系列GC Roots的点作为起点向下搜索,在一个对象到任何 GC Roots都没有引用链相连时,说明其已死亡。
引用计数法
在Java中如果要操作对象,就必须先获取该对象的引用,因此可以通过引用计数法来判断一个对象是否可以被回收。
在为对象添加一个引用时,引用计数加1;在为对象删除一个引用时,引用计数减1;如果一个对象的引用计数为0,则表示此刻该对象没有被引用,可以被回收。
引用计数法容易产生循环引用问题。
循环引用指两个对象相互引用,导致它们的引用一直存在,而不能被回收。
可达性分析
为了解决引用计数法的循环引用问题,Java还采用了可达性分析来判断对象是否可以被回收。
可达性分析的过程:
常用垃圾回收算法
Java中常用的垃圾回收算法有:
标记清除算法
标记清除算法是基础的垃圾回收算法,其过程分为标记和清除阶段。
在标记阶段标记所以需要回收的对象,在清除阶段清除可回收的对象并释放其所占用的内存空间。
由于标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间,因此如果内存中可被回收的小对象居多,则会引起内存碎片化的问题,继而引起大对象无法获得连续可用空间的问题。
复制算法
复制算法是为了解决标记清除算法内存碎片化的问题而设计的。
复制算法的基本原理:
复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用,即可用的内存空间被压缩到原来的一半,因此存在大量的内存浪费。
同时,在系统中有大量长时间存活的对象时,这些对象将在内存区域1和内存区域2之间来回复制而影响系统的运行效率。
因此,该算法只在对象为“朝生夕死”状态时运行效率较高。
标记整理算法
标记整理算法结合了标记清除算法和复制算法的优点,其标记阶段和标记清除算法的标记阶段相同,在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存。
分代收集算法
无论是标记清除算法、复制算法还是标记整理算法,都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象都进行垃圾回收。
因此,针对不同的对象类型,JVM采用了不同的垃圾回收算法,该算法被称为分代收集算法。
分代收集算法根据对象的不同类型将内存划分为不同的区域,JVM将堆划分为新生代和老年代。
新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,在每次进行垃圾回收时都有大量的对象被回收。
老年袋主要存放大对象和生命周期长的对象,因此可回收的对象相对较少。
因此,JVM根据不同的区域对象的特点选择了不同的算法。
目前,大部分JVM在新生代都采用了复制算法,因为在新生代中每次进行垃圾回收时都有大量的对象被回收,需要复制的对象(存活的对象)较少,不存在大量的对象在内存中被来回复制的问题,因此采用复制算法能安全、高效地回收新生代大量的短生命周期的对象并释放内存。
JVM将新生代进一步划分为一块较大的Eden区和两块较小的Servivor区,Servivor区又分为ServivorFrom区和ServivorTo区。
JVM在运行过程中主要使用Eden区和ServivorFrom区,进行垃圾回收时会将在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,然后清理Eden区和ServivorFrom区的内存空间。
老年代主要存放生命周期较长的对象和大对象,因而每次只有少量非存活的对象被回收,因而在老年代采用标记清除算法。
在JVM中还有一个区域,即方法区的永久代,永久代用来存储Class类、常量、方法描述等。
在永久代主要回收废弃的常量和无用的类。
JVM内存中的对象主要被分配到新生代的Eden区和ServivorFrom区,在少数情况下会被直接分配到老年代。
在新生代的Eden区和ServivorFrom区的内存空间不足时会触发一次GC,该过程被称为MinorGC。
在MinorGC后,在Eden区和ServivorFrom区中存活的对象会被复制到ServivorTo区,然后Eden区和ServivorFrom区被清理。
如果此时在ServivorTo区无法找到连续的内存空间存储某个对象,则将这个对象直接存储到老年代。
若Servivor区的对象经过一次GC后仍然存活,则其年龄加1。
在默认情况下,对象在年龄达到15岁时,将被移到老年代。
引用类型
在Java中,一切皆对象,对象的操作是通过该对象的引用(Reference)实现的。
在Java中,引用类型有4种:
强引用
在Java中最常见的就是强引用。
强引用:在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。
有强引用的对象一定为可达性状态,所以不会被垃圾回收机制回收。
因此,强引用是造成Java内存泄漏(Memory Link)的主要原因。
软引用
软引用:软引用通过 SoftReference类实现。
如果一个对象只有软引用,则在系统内存空间不足时该对象将被回收。
弱引用
弱引用:弱引用通过 WeakReference类实现。
如果一个对象只有弱引用,则在垃圾回收过程中一定会被回收。
虚引用
虚引用:虚引用通过 PhantomReference类实现。
虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态。
分代收集算法
JVM根据对象存活周期的不同将内存划分为新生代、老年代和永久代,并根据各年代的特点分别采用不同的GC算法。
新生代与复制算法
新生代主要存储短生命周期的对象,因此在垃圾回收的标记阶段会标记大量已死亡的对象及少量存活的对象,因此只需要选用复制算法将少量存活的对象复制到内存的另一端并清理原区域的内存即可。
老年代与标记整理算法
老年代主要存放长生命周期的对象和大对象,可回收的对象一般较少,因此JVM采用标记整理算法进行垃圾回收,直接释放死亡状态的对象所占用的内存空间即可。
分区收集算法
分区算法将整个堆空间划分为连续的大小不同的小区域,对每个小区都单独进行内存使用和垃圾回收,这样做的好处是可以根据每个小区域内存的大小灵活使用和释放内存。
分区收集算法可以根据系统可接受的停顿时间,每个都快速回收若干个小区域的内存,以缩短垃圾回收系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。
如果垃圾回收机制一次回收整个堆内存,则需要更长的系统停顿时间,长时间的系统停顿将影响系统运行的稳定性。
垃圾收集器
Java堆内存分为新生代和老年代。
新生代主要存储短生命周期的对象,适合使用复制算法进行垃圾回收。
老年袋主要存储长生命周期对象和大对象,适合使用标记整理算法进行垃圾回收。
JVM针对新生代和老年代分别提供了多种不同的垃圾收集器,针对新生代提供的垃圾收集器有 SerialOld、 ParallelOld、 CMS,还有针对不同区域的 G1分区收集算法。
Serial
Serial:单线程、复制算法。
Serial垃圾收集器基于复制算法实现,它是一个单线程收集器,在它正在进行垃圾收集时,必须暂停其他工作线程,直到垃圾收集结束。
Serial垃圾收集器采用了复制算法,简单、高效,对于单CPU运行环境来说,没有线程交互开销,可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器是JVM运行在Client模式下的新生代的默认垃圾收集器。
ParNew
ParNew:多线程、复制算法。
ParNew垃圾收集器是Serial垃圾收集器的多线程实现,同样采用了复制算法,它采用多线程模式工作,除此之外和Serial收集器几乎一样。
ParNew垃圾收集器在垃圾收集过程中会暂停所有其他工作线程,是Java虚拟机运行在Server模式下的新生代的默认垃圾收集器。
ParNew垃圾收集器默认开启与CPU同等数量的线程进行垃圾回收,在Java应用启动时可通过 -XX:ParallelGCThreads参数调节ParNew垃圾收集器的工作线程数。
Parallel Scavenge
Parallel Scavenge:多线程、复制算法。
Parallel Scavenge收集器是为提高新生代垃圾收集效率而设计的垃圾收集器,基于多线程复制算法实现,在系统吞吐量上有很大的优化,可以更高效地利用CPU尽快完成垃圾回收任务。
Parallel Scavenge通过自适应调节策略提高系统吞吐量,提供了三个参数用于调节、控制垃圾回收的停顿时间及吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数,控制吞吐量大小的 -XX:GCTimeRatio参数和控制自适应调节策略开启与否的 UseAdaptiveSizePolicy参数。
Serial Old
Serial Old:单线程、标记整理算法。
Serial Old垃圾收集器是Serial垃圾收集器的老年代实现,同Serial一样采用单线程执行,不同的是,Serial Old针对老年代长生命周期的特点基于标记整理算法实现。
Serial Old垃圾收集器是JVM运行在Client模式下的老年代的默认垃圾收集器。
新生代的Serial垃圾收集器和老年代的Serial Old垃圾收集器可搭配使用,分别针对JVM的新生代和老年代进行垃圾回收,其垃圾收集过程如图所示。
在新生代采用Serial垃圾收集器基于复制算法进行垃圾回收,未被其回收的对象在老年代被Serial Old垃圾收集器基于标记整理算法进行垃圾回收。
Parallel Old
Parallel Old:多线程、标记整理算法。
Parallel Old垃圾收集器采用多线程并发进行垃圾回收,它根据老年代长生命周期的特点,基于多线程的标记整理算法实现。
Parallel Old垃圾收集器在设计上优先考虑系统吞吐量,其次考虑停顿时间等因素,如果系统对吞吐量的要求较高,则可以优先考虑新生代的Parallel Scavenge垃圾收集器和老年代的Parallel Old垃圾收集器的配合使用。
新生代的Parallel Scavenge垃圾收集器和老年代的Parallel Old垃圾收集器的搭配运行过程如图。
新生代基于Parallel Scavenge垃圾收集器的复制算法进行垃圾回收,老年代基于Parallel Old垃圾收集器的标记整理算法进行垃圾回收。
CMS
CMS(Concurrent Mark Sweep)垃圾收集器是为老年代设计的垃圾收集器,其主要目的是达到最短的垃圾回收停顿时间,基于线程的标记清除算法实现,以便在多线程并发环境下以最短的垃圾收集停顿时间提高系统的稳定性。
CMS的工作机制相对复杂,垃圾回收过程包含如下4个步骤。
CMS垃圾收集器在和用户线程一起工作时(并发标记和并发清除)不需要暂停用户线程,有效缩短了垃圾回收时系统的停顿时间,同时由于CMS垃圾收集器和用户线程一起工作,因此其并行度和效率也有很大提升。
G1
G1(Garbage First)垃圾收集器为了避免全区域垃圾收集引起的系统停顿,将堆内存划分为大小固定的几个独立区域,独立使用这些区域的内存资源并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,在垃圾回收过程中根据系统允许的最长垃圾收集时间,优先回收垃圾最多的区域。
G1垃圾收集器通过内存区域独立划分使用和根据不同优先级回收各区域垃圾的机制,确保了G1垃圾收集器在有限时间内获得最高的垃圾收集效率。
相对于CMS收集器,G1垃圾收集器有两个突出的改进。
类加载机制
JVM的类加载阶段
JVM的类加载分为5个阶段:加载、验证、准备、解析、初始化。
在类初始化完成后就可以使用该类的信息,在一个类不再被需要时可以从JVM中卸载。
加载
加载:加载是指JVM读取 Class文件,并且根据 Class文件描述创建 java.lang.Class对象的过程。
类加载过程主要包含将 Class文件读取到运行时区域的方法区内,在堆中创建 java.lang.Class对象,并封装类在方法区的数据结构的过程,在读取 Class文件时既可以通过文件的形式读取,也可以通过 JAR包、 WAR包读取,还可以通过代理自动生成 Class或其他方式读取。
验证
验证:验证主要用于确保 Class文件符合当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的 Class文件才能被JVM加载。
准备
准备:准备主要工作是在方法区中为类变量分配内存空间并设置类中变量的初始值。
初始值指不同数据类型的默认值,这里需要注意 final类型的变量和非 final类型的变量在准备阶段的数据初始化过程不同。
栗如,一个成员变量的定义如下:
public static long value = 1000;
在以上代码中,静态变量value在准备阶段的初始值是0,将value设置为1000的动作是在对象初始化时完成的,因为JVM在编译阶段会将静态变量的初始化操作定义在构造器中。但是,如果将变量value声明为final类型:
public static final int value = 1000;
则JVM在编译阶段后会为final类型的变量value生成其对应的ConstantValue属性,虚拟机在准备阶段会根据ConstantValue属性将value赋值为1000。
解析
解析:解析是指JVM会将常量池中的符号引用替换为直接引用。
初始化
初始化:初始化主要通过执行类构造器的
方法为类进行初始化。
方法是在编译阶段由编译器自动收集类中静态语句块和变量的赋值操作组成的。
JVM规定,只有在父类的
方法都执行成功后,子类中的
方法才可以被执行。
在一个类中既没有静态变量赋值操作也没有静态语句块时,编译器不会为该类生成
方法。
在发生以下几种情况时,JVM不会执行类的初始化流程。
类加载器
JVM提供了3种类加载器,分别是启动类加载器、扩展类加载器和应用程序类加载器。
类加载器分为:启动类加载器、扩展类加载器、应用程序类加载器、自定义加载器。
双亲委派机制
JVM通过双亲委派机制对类进行加载。
双亲委派机制指一个类在收到类加载请求后不会尝试自己加载这个类,而是把该类加载请求向上委派给其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。
若父类加载器在接收到类加载请求后发现自己也无法加载该类(通常原因是该类的 Class文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委派子类加载器加载该类,直到该类被成功加载,若找不到该类,则JVM会抛出 ClassNotFoud异常。
双亲委派类加载机制的类加载流程如下:
双亲委派机制的核心是保障类的唯一性和安全性。
例如在加载 rt.jar包中的 java.lang.Object类时,无论是哪个类加载器加载这个类,最终都将类加载请求委托给启动类加载器加载,这样就保证了类加载的唯一性。
如果在JVM中存在包名和类名相同的两个类,则该类将无法被加载,JVM也无法完成类加载流程。
OSGI
OSGI(Open Service Gateway Initiative)是Java动态化模块化系统的一系列规范,旨在为实现Java程序的模块化编程提供基础条件。
基于OSGI的程序可以实现模块级的热插拔功能,在程序升级更新时,可以只针对需要更新的程序进行停用和重新安装,极大提高了系统升级的安全性和便捷性。
OSGI提供了一种面向服务的架构,该架构为组件提供了动态发现其他组件的功能,这样无论是加入组件还是卸载组件,都能被系统的其他组件感知,以便各个组件之间能更好地协调工作。
OSGI不但定义了模块化开发的规范,还定义了实现这些规范所依赖的服务与架构,市场上也有成熟的框架对其进行实现和应用,但只有部分应用适合采用OSGI方式,因为它为了实现动态模块,不再遵循JVM类加载双亲委派机制和其他JVM规范,在安全性上有所牺牲。