程序员计算机杂谈JVM · Java虚拟机原理 · JVM上语言·框架· 生态系统

关于JVM的那点事

2017-11-24  本文已影响94人  数齐

虚拟机分为两种,第一种就是系统虚拟机,类似于vmware,virtualBox等连系统的硬件等一起模拟,比较重量级。另外一种就是程序虚拟机,典型的代表就是我们今天所要说的java虚拟机,他主要模拟的是运行时的环境,比较轻量级。比如java引用为好的功能就是“一次编译,到处运行”的基础设施就是JVM,他提供了一套统一的标准,不管是windows系统,还是mac还是Linux,只要安装了JVM,那么java程序就可以运行。下面我们看一下他的内容。

一. 组成部分

组成部分 是否线程私有 简介 特别说明
程序计数器 当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变。这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成,由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存 如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。
Java 虚拟机栈 它的生命周期与线程相同。虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame ①)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的Java 虚拟机都可动态扩展,只不过Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常
本地方法栈 本地方法栈则是为虚拟机使用到的Native 方法服务 本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常
Java 堆 Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。几乎所有的对象实例都在这里分配内存,包括Class对象 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常
方法区 在一个jvm实例的内部,类型信息被存储在一个称为方法区的内存逻辑区中。类型信息是由类加载器在类加载时从类文件中提取出来的。类(静态)变量也存储在方法区中。类型信息包括这个类型的完整有效名,这个类型直接父类的完整有效名(除非这个类型是interface或是 java.lang.Object,两种情况下都没有父类),这个类型的修饰符(public,abstract, final的某个子集) ,这个类型直接接口的一个有序列表 。除了以上的基本信息外,jvm还要为每个类型保存类型的常量池( constant pool) ,域(Field)信息 ,方法(Method)信息,除了常量外的所有静态(static)变量 。另外还有对类加载器的引用。jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。 还有对Class类的引用 ,jvm为每个加载的类型(译者:包括类和接口)都创建一个java.lang.Class的实例。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据联系起来 根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常

二. GC算法

谈JVM,免不了要聊他的GC,我们说java与c++等语言的区别之一就是java内存的释放是由虚拟机来控制,而不是像c++那样手动释放。有利有弊吧,好处是java程序员不需要关注垃圾回收,少了许多的样板代码,可以更多的专注于逻辑与业务的迭代。坏处同样明显就是这个特性成为了一种不可控的因素,往往成为了不可预计问题的根源,了解并掌握JVM的分析及调优成为了走向高阶程序员的必经之路。好了,扯到这里,我们继续吧。

算法名称 说明 优点 缺点
引用计数法 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;清除引用数为0的对象 判断效率也很高 对象之间相互循环引用未解决
标记-清除法 标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。 当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行 仅仅是可行 效率比较低(递归与全堆对象遍历)。空闲内存是不连续的,大对象放不下。stop the world
复制算法 复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。要想该算法效率高,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费 弥补了标记/清除算法中,内存布局混乱的缺点 它浪费了一半的内存。如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视
标记-压缩 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。其实就是将非GC的对象放到一边,需要被回收的对象放到另一边 标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价 标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法
分代算法 java堆分为新生代与老年代,新生代有分为eden区,s0,s1区。s0与s1是两块完全相等大小额内存区域。分代算法是针对对象的不同特性,而使用适合的算法,这里面并没有实际上的新算法产生。比如说新生代的对象,会大量创建,但是也会大量消亡,也就是说存活率低,这时候我们可以考虑使用复制算法。对于老年代的对象,通过了新生代的多次GC,晋升到了老年代,我们可以认为他们是相对稳定的,所以使用标记压缩会好一些。针对不同的代级(新生代与老年代),针对不同的对象特点,采用不同的GC算法就是我们说的分代算法。 针对不同的代级(新生代与老年代),针对不同的对象特点,采用不同的GC算法相对比较完善,也是现在采用的方案。

三. GC 垃圾收集器

GC算法是理论的基础,GC垃圾收集器是具体的产品,也是JVM的组成部分,下面我们也来说一下。

收集器名称 代别 特点 设置参数
新生代串行回收器 串行回收器 它仅仅使用单线程进行垃圾回收,它是独占式的垃圾回收进行垃圾回收时,Java应用程序中的线程都需要暂停使用复制算法适合CPU等硬件不是很好的场合 -XX:+UseSerialGC 指定新生使用新生代串行收集器和老年代串行收集器, 当以client模式运行时, 它是默认的垃圾收集器
老年代串行回收器 串行回收器 同新生代串行回收器一样, 单线程, 独占式的垃圾回收器通常老年代垃圾回收比新生代回收要更长时间, 所以可能会使应用程序停顿较长时间 -XX:+UseSerialGC 新生代,老年代都使用串行回收器.-XX:+UseParNeGC新生代使用ParNew回收器, 老年代使用串行回收器.-XX:+UseParallelGC 新生代使用ParallelGC回收器, 老年代使用串行回收器
新生代ParNew回收器 并行回收器 将串行回收多线程化.使用复制算法.垃圾回收时, 应用程序仍会暂停, 只不过由于是多线程回收, 在多核CPU上,回收效率会高于串行 -XX:+UseParNewGC 新生代使用ParNew回收器, 老年代使用串行回收器.-XX:+UseConcMarkSweepGC 新生代使用ParNew回收器, 老年代使用CMS回收器.-XX:ParallelGCThreads=n 指回ParNew回收器工作时的线程数量, cpu核数小时8时, 其值等于cpu数量, 高于8时,可以使用公式(3+((5*CPU_count)/8))
新生代ParallelGC回收器 并行回收器 同ParNew回收器一样, 不同的地方在于,它非常关注系统的吞吐量(通过参数控制) ,使用复制算法,支持自适应的GC调节策略 -XX:+UseParallelGC新生代用ParallelGC回收器, 老年代使用串行回收器.-XX:+UseParallelOldGC  新生代用ParallelGC回收器, 老年代使用ParallelOldGC回收器系统吞吐量的控制:-XX:MaxGCPauseMillis=n(单位ms)设置垃圾回收的最大停顿时间,-XX:GCTimeRatio=n(n在0-100之间)  设置吞吐量的大小, 假设值为n, 那系统将花费不超过1/(n+1)的时间用于垃圾回收.-XX:+UseAdaptiveSizePolicy 打开自适应GC策略, 在这种模式下, 新生代的大小, eden,survivior的比例, 晋升老年代的对象年龄等参数会被自动调整,以达到堆大小, 吞吐量, 停顿时间之间的平衡点
老年代ParallelOldGC回收器 并行回收器 同新生代的ParallelGC回收器一样, 是属于老年代的关注吞吐量的多线程并发回收器,使用标记压缩算法 -XX:+UseParallelOldGC新生代用ParallelGC回收器, 老年代使用ParallelOldGC回收器, 是非常关注系统吞吐量的回收器组合, 适合用于对吞吐量要求较高的系统.-XX:ParallelGCThreads=n指回ParNew回收器工作时的线程数量, cpu核数小时8时, 其值等于cpu数量, 高于8时, 可以使用公式(3+((5*CPU_count)/8))
老年代的并发回收器 CMS回收器(Concurrent Mark Sweep,并发标记清除) 是并发回收, 非独占式的回收器, 大部分时候应用程序不会停止运行针对年老代的回收器使用并发标记清除算法, 因此回收后会有内存碎片, 可以使参数设置进行内存碎片的压缩整理与ParallelGC和ParallelOldGC不同, CMS主要关注系统停顿时间初始标记->并发标记->预清理->重新标记->并发清理->并发重置.初始标记与理新标记是独占系统资源的,不能与用户线程一起执行,而其它阶段则可以与用户线程一起执行 -XX:CMSPrecleaningEnabled关闭预清理, 不进行预清理, 默认在并发标记后, 会有一个预清理的操作,可减少停顿时间.-XX:+UseConcMarkSweepGC老年代使用CMS回收器, 新生代使用ParNew回收器.-XX:ConcGCThreads=n设置并发线程数量,-XX:ParallelCMSThreads=n  同上, 设置并发线程数量, -XX:CMSInitiatingOccupancyFraction=n指定老年代回收阀值, 即当老年代内存使用率达到这个值时, 会执行一次CMS回收,默认值为68, 设置技巧: (Xmx-Xmn)*(100-CMSInitiatingOccupancyFraction)/100)>=Xmn,-XX:+UseCMSCompactAtFullCollection  开启内存碎片的整理, 即当CMS垃圾回收完成后, 进行一次内存碎片整理, 要注意内存碎片的整理并不是并发进行的, 因此可能会引起程序停顿.-XX:CMSFullGCsBeforeCompation=n  用于指定进行多少次CMS回收后, 再进行一次内存压缩.-XX:+CMSParallelRemarkEnabled在使用UseParNewGC的情况下, 尽量减少 mark 的时间-XX:+UseCMSInitiatingOccupancyOnly  表示只有达到阀值时才进行CMS回收.Class的回收(永久区的回收).-XX:+CMSClassUnloadingEnabled开启回收Perm区的内存, 默认情况下, 是需要触发一次FullGC.-XX:CMSInitiatingPermOccupancyFraction=n当永久区占用率达到这个n值时,启动CMS回收, 需上一个参数开启的情况下使用
G1回收器(jdk1.7后全新的回收器, 用于取代CMS) 独特的垃圾回收策略, 属于分代垃圾回收器,使用分区算法, 不要求eden, 年轻代或老年代的空间都连续.并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源.分代GC: 分代收集器, 同时兼顾年轻代和老年代.空间整理: 回收过程中, 会进行适当对象移动, 减少空间碎片.可预见性: G1可选取部分区域进行回收, 可以缩小回收范围, 减少全局停顿 -XX:+UseG1GC打开G1收集器开关,-XX:MaxGCPauseMillis=n  指定目标的最大停顿时间,任何一次停顿时间超过这个值, G1就会尝试调整新生代和老年代的比例, 调整堆大小, 调整晋升年龄.-XX:ParallelGCThreads=n  用于设置并行回收时, GC的工作线程数量-XX:InitiatingHeapOccpancyPercent=n指定整个堆的使用率达到多少时, 执行一次并发标记周期, 默认45, 过大会导致并发标记周期迟迟不能启动, 增加FullGC的可能, 过小会导致GC频繁, 会导致应用程序性能有所下降

四. 类加载的过程

在Class文件中描述的各种信息,最终都需要加载到虚拟机中才能运行和使用,JVM把描述类数据的字节码.Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

过程 所属阶段 说明 备注
加载 通过“类全名”来获取定义此类的二进制字节流将字节流所代表的静态存储结构转换为方法区的运行时数据结构在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口
链接 验证 文件格式验证:验证class文件格式规范,例如: class文件是否已魔术0xCAFEBABE开头 , 主、次版本号是否在当前虚拟机处理范围之内等元数据验证:这个阶段是对字节码描述的信息进行语义分析,以保证起描述的信息符合java语言规范要求。验证点可能包括:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、这个类是否继承了不允许被继承的类(被final修饰的)、如果这个类的父类是抽象类,是否实现了起父类或接口中要求实现的所有方法。字节码验证:进行数据流和控制流分析,这个阶段对类的方法体进行校验分析,这个阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如:保证访法体中的类型转换有效,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但不能把一个父类对象赋值给子类数据类型、保证跳转命令不会跳转到方法体以外的字节码命令上。符号引用验证:符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用类中的类,字段和方法的访问性(private、protected、public、default)是否可被当前类访问 这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证
链接 准备 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量定义为:public static int value = 12;那么变量value在准备阶段过后的初始值为0而不是12,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为12的动作将在初始化阶段才会被执行。上面所说的“通常情况”下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,建设上面类变量value定义为:public static final int value = 123;编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为123
链接 解析 解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。虚拟机规范并没有规定解析阶段发生的具体时间,只要求了在执行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析,所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。解析的动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。分别对应编译后常量池内的CONSTANT_Class_Info(类、接口的解析)、CONSTANT_Fieldref_Info(字段解析)、CONSTANT_Methodef_Info(类方法解析)、CONSTANT_InterfaceMethoder_Info(接口方法解析)四种常量类型
初始化 类的初始化阶段是类加载过程的最后一步,在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。在以下四种情况下初始化过程会被触发执行:遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。使用java.lang.reflect包的方法对类进行反射调用的时候当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类。在上面准备阶段 public static int value = 12; 在准备阶段完成后 value的值为0,而在初始化阶调用了类构造器<clinit>()方法,这个阶段完成后value的值为12。a. 类构造器<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句快可以赋值,但是不能访问。b. 类构造器<clinit>()方法与类的构造函数(实例构造函数<init>()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中的第一个执行的<clinit>()方法的类肯定是java.lang.Object。
使用
卸载 该类所有的实例已经被回收该类对应的java.lang.Class对象没有任何对方被引用加载该类的ClassLoder已经被回收 通过上面的加载的流程我们可以看出,Class对象可以看做是模板,这个模板里面定义了生产产品所需要的原料(方法,属性等,关联着方法区的这些信息)。而实例对象就是按照模板生产的具体额产品。那么下面我们就来讨论一下怎么废弃这个模板。第一个,实例对象都已经回收,说明没有人用到这个模板了(object.getClass会将对象实例与Class实例相关联)。没有对象用了,我们再看一下还有没有其他的地方用到这个模板了,这就是第二条说的:该类对应的java.lang.Class对象没有任何对方被引用。前面两条都是说的是确认没有用Class这个对象了。然后就看谁创建的这个Class,当然就是classloader,class.getClassLoader()会关联加载这个类的类加载器,同时类加载器

参考文献:
JVM(三):类加载机制(类加载过程和类加载器)
Java方法区
深入java虚拟机

上一篇下一篇

猜你喜欢

热点阅读