技术转载——JVM 工作原理和工作流程简述
JAVA之所以跨平台,是因为有JVM这么一个编译和运行机器,它令对于系统的操作对于用户而言是黑盒的,使得开发人员更快速和更注重软件功能的实现。然而,也因为jvm是黑盒,所以内部和底层具有不确定性,如果用状态机来表示jvm,那么jvm就是一种现役复制不确定的状态机,因为它的状态和表现跟系统、底层、硬件等等都有关系,从而状态是不确定,如果在分布式应用中,jvm一直以来兼容性都不是很好,这就是主要原因。尽管如此,就单一的系统而言,弄清楚jvm运行的来龙去脉,对于系统的运行至关重要。
理解jvm的运行原理具有以下几点充分作用:
1、针对系统进行内存和垃圾回收监控
2、解决因内存溢出和泄露的问题
3、对系统进行优化
4、提升jvm和系统性能
jvm的运行原理有主要有三方面,其实这也是jvm的主要工作:
1、内存管理
2、执行流程
3、垃圾回收
在开始之前,有一些知识需要直到,广义来讲,jvm并不是指sun的hotspot,而是一个规范,因此不同厂商会根据规范实现不同的jvm,因此这些jvm的表现都不是一致甚至相差甚远。在jvm规范中,通常我们所能接触的就是命令行参数了。
命令行参数
命令行参数分为三种,标准、非标准、非稳定
标准命令行参数会在jvm规范中明确列出,强制实现的选项,并且具有版本控制废弃的管理通知。非标准的命令行参数不是规范强制并且可能没有对应的通知,非稳定参数是特定调校的选项,同时也是非标准的。标准的选项可通过help命令查看,非标准的选项通过-X为前缀访问,非稳定的前缀是-XX,通常对于布尔类型的选项,用+或-来设置true或者false,如-XX:+UseTLAB开启线程内存缓冲分配。
内存划分
jvm是具有内存自动分配和管理的架构,而内存管理自动化是解放劳动力的重要工具,可以对比C/C++,开发人员不需要管理内存,开发效率会比较高。
在jvm中,使用的内存分为两类,线程共享内存和线程私有内存。
结合我们平时的代码可以看出,线程共享的内容包括方法、实例对象、常量,分别对应共享内存中的方法区、堆区、常量池。
堆区
堆区通常是共享内存中最大的一块,因此它也是GC重点关注区域。堆区可能是连续的也可能是不连续的,以及堆区的大小都会对GC造成相应的影响。-Xms和-Xmx设置堆的最小和最大值,如果堆内存大小超过最大值,则抛出OutOfMemoryError异常。
方法区
方法区存储的是方法、类的结构信息,而常量池也包含在内,除了我们代码中所看到的静态常量,这些常量还包括一些字节码内容和类初始化所需的特殊内容。通常情况下,jvm不会对方法区GC直到方法区大小不够,即使GC也只是针对常量池和类型,所以也被称为永久区Permanent Generation,除了可以设置大小以外,还可以设置是否进行GC,如果超过大小,抛出OutOfMemoryError异常。
常量池
这里说的常量池是运行时的,通常是字节码中的类的版本、描述,以及常量池表,这个表是一种符号表,在运行的时候将这些符号的引用变为直接符号。由此可以看出,加载类会使用常量池和方法区,如果类过多或常量过多,也会抛出OutOfMemoryError异常。
线程私有内存区
线程私有内存是被某一独立线程独占的,包括PC寄存器、java栈、本地方法栈
PC寄存器
这个寄存器是jvm内部的,而非物理寄存器,因此也可以看出,jvm的指令执行是基于栈架构的,所有的操作都是经过入栈出栈完成,为了确保线程安全,它被设定为线程私有的。通常,栈中存储字节码指令地址,如果调用的是本地方法,即native方法,则是空值。会不会抛出OutOfMemoryError异常,jvm目前没有明确规定。
java栈
java栈的颗粒度比PC寄存器大,存储方法的局部变量、操作计数、方法返回\方法出口等信息。局部变量除了我们代码所接触的类型,还包括一种叫做returnAddress返回地址类型,也是一种jvm规范的原始类型,但是开发人员并不能使用,实际上这种类型标识一条字节码指令的操作吗。java栈也会OutOfMemoryError异常,不过他也是可以动态扩展的。
本地方法栈
用于支持本地方法调用时使用,但是jvm没有强制实现,和java栈类似。
执行流程
我们的代码在IDE中或者通过CMD来执行即可看到执行结果,而实际上每次执行都会启动和关闭jvm,这个过程是相当复杂的,下面罗列一下主要步骤。
对于sun的hotspot,launcher负责维护jvm的生命周期,包括启动和结束关闭。就是我们在java目录下看到的java.exe和javaw.exe,一个有控制台输出,另一个没有,用于执行GUI程序。
jvm的启动初始化
1、解析命令行参数,设置内存大小和JIT编译器,并且加载系统环境变量。
2、查找主类,并且调用本地方法JNI_CreateJavaVM创建jvm主线程。
3、当jvm初始化完成,就会加载主类,如果加载成功,则调用本地方法传入参数,然后开始执行java的程序了。
其中调用本地方法JNI_CreateJavaVM创建jvm主线程,是jvm的启动过程,实际上启动器并非直接调用该本地方法,而是先用main()函数创建主线程,然后通过主线程调用javamain()函数调用该JNI_CreateJavaVM方法创建子线程来完成初始化并执行java程序。因为创建的主线程是操作系统分配的初始线程,为了更好的定制线程,通过在该线程上创建再初始线程来初始化jvm。
进一步细化JNI_CreateJavaVM函数的执行内容,主要流程如下:
1、检查是否线程安全,也就是是否只有一个线程调用此方法,一个线程只能创建一个jvm实例。
2、初始化各个模块,如日志、计数器、内存页等。
3、加载核心库并初始化线程库
4、初始化全局数据,这步完成后就可以创建java子线程了
5、初始化类加载器、解析器、编译器、GC等模块。其中重要一点就是初始化universe类型,这种类型是java中一切类型的类型,是一种数据结构,所有java的存储对象都用该类型类存储。
6、加载并初始化基础类库,如Lang、System、reflect等包。
7、返回给调用者。
通过上面的步骤,可以发现基础类库是在初始化阶段完成加载的,这跟开发人员编写的类库加载顺序是不同的。
jvm的关闭
当java程序结束,jvm会先检查有无未处理的异常以及清理这些异常,然后调用本地方法断开主线程跟本地方法接口的连接,如果可以断开,说明已经没有线程在运行了,则可以安全的关闭jvm。
和JNI_CreateJavaVM方法对应的是DestroyJavaVM方法,当jvm在启动和运行时发生错误,根据严重程度会调用该方法关闭jvm,而在理想状态下,即正常运行直到退出,也是调用DestroyJavaVM方法关闭并销毁jvm。停止jvm按照以下主要步骤进行:
1、守护线程一致等待,直到只有一个非守护线程执行。
2、停止监控、计数器等线程。
3、移除当前线程,释放保护页。
4、释放所有资源,返回到调用者。
我们可以看出,当需要关闭jvm时,如果jvm中仍有线程在运行,是无法强制关闭的,这就是为什么我们很多代码的运行出现异常后,重复的调试导致有多个后台jvm在运行却不能自动结束而要手动关闭。
类加载机制
在前面说到,开发人员使用的类和基础类库并非同一时间加载的,这是有原因的。类的加载由类加载器来完成,包括加载、连接、初始化三个阶段。完成加载后就可以通过new来创建类的实例对象了。类的加载可以理解为根据类的字节码文件全路径名读取后转换为与目标类型一致的Class类型,并且是可以动态加载的。
加载类由类加载器完成,加载器分为两种,一种是Bootstrap Classloader引导加载器,另一种是User-defined Classloader用户自定义加载器,用户自定义加载器默认又分为ExtClassloader和AppClassloader。
引导加载器是C++编写的,负责完成lib目录里的类加载,也就是前面所说的基础类库,而ExtClassloader和AppClassloader是java编写的,分别负责加载lib/ext目录和ClassPath系统路径中的类型。他们都是Classloader的子类,我们也可以通过继承父类来实现自己的类加载器。
父类委托模式
通过查阅类关系树可以发现,AppClassloader是ExtClassloader的子类,而ExtClassloader则是Classloader的子类,java规范要求自定义的类加载器都派生与父类,并且在进行类加载的时候,都要委托给直接上级父类执行加载,这就是父类委托模式(parents delegation model),国内很多翻译为双亲委托模式,但是你会发现是多亲模式,所以我认为父类委托更为合适。
父类委托模式在执行时,子类始终会委托父类加载,一级一级的向上请求,知道最后唯一的超类来进行加载,如果父类无法加载,再一级一级的退回到子类进行加载,这样就不会重复加载相同的类了。
为什么要使用父类委托模式?因为类的加载必须是一次性不可重复的,试想一下,如果基础类库中的类可以重复加另一个类来替换原来的类,那是多么严重的安全隐患,为了避免这一点,基础类库都是由C++编写的启动加载器来加载,但是为了兼顾扩展性,所以除了基础类库,其他的类都可以通过用户加载器来加载,那么为了避免但不强制要求避免重复加载的情况发生,java规范就采取并建议我们按照父类委托的方式实现类加载器。
类的加载过程
前面说到,类先经过类加载器将字节码文件转换为Class对象,但是这个时候并不能使用它,此时的类结构信息存储在方法区内,还需要对其进行验证,结构信息是否有效合法,一旦通过验证,就会为类中的静态变量分配内存空间并初始化值,这些准备工作完成后,还需将类结构中的符号和常量表的符号进行解析转为直接引用,这时候的类才具有执行能力。最后的工作就是初始化了,也就是我们代码中在new一个对象之前会执行的static代码块。
垃圾回收机制
jvm的垃圾回收包括内存动态分配和内存回收两大块。内存的分配和垃圾回收是息息相关的,内存分配的方式一定程度上决定采取何种垃圾收集器和收集算法。
前面说到,堆内存可以是连续也可以是不连续的,也是GC的重点区域,但正由于这种分布的不确定性,该GC带来很大麻烦。首先针对连续的情况。
指针碰撞
通过前面讲述的jvm启动过程,我们知道创建对象就需要在堆内存中划分出一部分来存储对象,如果此时的内存是规整的,那么将空闲的和已使用的各放置一边,两部分的边界处用一个指针标记,当新增对象内存分配,就将指针偏移相应的位置,下一次分配内存只需要知道最后指针偏移的位置开始分配内存并更新指针偏移量即可,这种方式就是指针碰撞(bump the pointer)。
空闲列表
然而,需要面临的一个问题首先不是规整问题,而是线程安全,如果对指针的操作加锁,必然会降低性能。并且如果堆不是连续的,指针碰撞就变得很棘手,此时还有一种解决办法,就是通过一张表记录下所有空闲的内存,每当分配内存就更新表上的记录,这种方式就是空闲列表(free list)。
不管哪种方式,都必须解决线程安全,对于指针碰撞,为了满足规整的先决条件,这就要求GC收集器具有压缩规整功能,如serial、par等收集器,而采用mark-sweep的cms这种收集器则不支持规整,因为他就是通过空闲列表方式来整理的内存的。分配内存就需要对内存指针进行操作,如何确保指针的使用是线程安全的?一种做法是用过CAS原子操作来实现,也就是所谓的失败重试保证更新原子性。还有一种做法就是TLAB(本地线程缓冲),即在堆内存中事先划分一块线程独占的私有内存,这样线程就可以互不干涉的创建对象了,如果TLAB不够用,再已加锁的方式分配TLAB,并且对象的初始化还可以提前进行。
分代划分收集机制
目前大部分的GC都是采用分代收集算法的,换而言之,也就是内存是分代划分的。这当中的设计有很多复杂和严格的要求,首先对算法绝对精确,不能造成误删和误读,还要保证没用的对象及时回收,以及如何处理产生的碎片和系统停顿开销等。涉及的指标和算法,就在另一篇中单独阐述了。