JVM初识

作者: ClawHub的技术分享 | 来源:发表于2018-12-07 18:00 被阅读0次

    今天对JVM进行了简单的学习,做了一个总结,如果有不对的地方还请大家指正。

    一.java代码如何运行

    编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。程序执行效率高,依赖编译器,跨平台性差些。如C、C++、Delphi等。

    解释型语言:程序不需要编译,程序在运行时才翻译成机器语言,每执 行一次都要翻译一次。因此效率比较低。比如Basic语言,专门有一个解释器能够直接执行Basic程 序,每个语句都是执行的时候才翻译。(在运行程序的时候才翻译,专门有一个解释器去进行翻译,每个语句都是执行的时候才翻译。效率比较低,依赖解释器,跨 平台性好.)

    Java程序从源文件创建到程序运行要经过两大步骤:1、源文件由编译器编译成字节码(ByteCode)  2、字节码由java虚拟机解释成机器语言运行。因为java程序既要编译同时也要经过JVM的解释运行,所以说Java被称为半解释语言( "semi-interpreted" language)。

    ① 编译过程:创建完源文件之后,程序会先被编译为.class文件。Java编译一个类时,如果这个类所依赖的类还没有被编译,编译器就会先编译这个被依赖的类,然后引用,否则直接引用,这个有点象make。如果java编译器在指定目录下找不到该类所其依赖的类的.class文件或者.java源文件的话,编译器话报“cant find symbol”的错误。

    编译后的字节码文件格式主要分为两部分:常量池方法字节码。常量池记录的是代码出现过的所有token(类名,成员变量名等等)以及符号引用(方法引用,成员变量引用等等)

    ②运行:java类运行的过程大概可分为两个过程:1、类的加载  2、类的执行。需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。

    下面通过以下这个java程序,来说明java程序从编译到最后运行的整个流程。代码如下:

    //MainApp.java  

    publicclass MainApp {  

    publicstaticvoid main(String[] args) {  

    Animal animal =new Animal("Puppy");  

            animal.printName();  

        }  

    }  

    //Animal.java  

    publicclass Animal {  

    public String name;  

    public Animal(String name) {  

    this.name = name;  

        }  

    publicvoid printName() {  

    System.out.println("Animal ["+name+"]");  

        }  

    }

    1.在编译好java程序得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。

    2.然后JVM找到AppMain的主函数入口,开始执行main函数。

    3.main函数的第一条命令是Animal  animal = new Animal("Puppy");就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。

    4.加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存, 然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。

    5.当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。

    6.开始运行printName()函数。

    二.JVM执行过程

    JVM启动后,对操作系统来说,JVM是一个的进程,这个进程的基本结构如上图所示。它包括:类加载器子系统、运行时数据区、执行引擎和本地方法接口。

    运行时数据区是JVM从操作系统申请来的堆空间和操作系统给JVM分配的栈空间的总称。JVM为了运行Java程序,又进一步对运行时数据区进行了划分,划分为Java方法区、Java堆、Java栈、PC寄存器、本地方法栈等,这里JVM从操作系统申请来的堆空间被划分为方法区和Java堆,操作系统给JVM分配的栈空间构成Java栈。

    三.JVM总体结构

    1)类加载子系统与方法区:类加载子系统负责从文件系统或者网络中加载Classs信息,加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中可能还会存放运行时常量池信息,包括字符串字面量和数字常量(这部分常量信息是Classs文件中常量池部分的内存映射)。classloader就是类加载子系统的一个组件,但是类加载子系统不仅仅有classloader,还有文件校验等功能。这一部分负责的内容总体来说,就是负责把我们的java文件加载到虚拟机里面。

    2) java 堆: java 堆在虚拟机启动的时候建立,它是java程序最主要的内存工作区域。几乎所有的java对象实例都存放在java 堆中。堆空间是所有线程共享的,这是-块与java应用密切相关的内存空间。这里是重点,稍后会详细就说。

    3)直接内存: java 的NIO库允许java程序使用直接内存。直接内存是在java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。直接映射到物理内存,大小取决于物理内存,不受JVM分配给堆的大小限制。

    4)垃圾回收系统:垃圾回收系统是java虚拟机的重要组成部分,垃圾回收器可以对方法区、java堆和直接内存进行回收。其中,java 堆是垃圾收集器的工作重点。和C/C++不同,java中所有的对象空间释放都是隐式的,也就是说,java 中没有类似free(咸者delete()这样的函数释放指定的内存区域。对于不再使用的垃圾对象,垃圾回收系统会在后台黑默工作,默黑默查找、标识并释放垃圾对象,完成包括java堆、方法区和直接内存中的全自动化管理。

    5) java栈:每一个java虚拟机线程都有一一个私有的java栈,一个线程的java栈在线程创建的时候被创建,java 栈中保存着帧信息,java 栈中保存着局部变量(也称为栈变量)、方法参数,同时和java方法的调用、返回密切相关。

    6)本地方法栈:本地方法栈和java栈非常类似,最大的不同在于java 栈用于方法的调用,而本地方法栈则用于本地方法(nativemothod,也就是操作系统的api,所以为了)的调用,作为对java虚拟机的重要扩展, java 虚拟机允许java直接调用本地方法(通常使用C编写)

    7) PC ( Program Counter): PC寄存器也是每一一个线程私有的空间,java 虚拟机会为每一-个java线程创建PC寄存器。在任意时刻,一个java线程总是在执行一个方法, 这个正在被执行的方法称为当前方法。如果当前方法不是本地方法,PC 寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined

    8)执行引擎:执行引擎是java虚拟机的最核心组件之- - ,它负责执行虚拟机的字节码,现代虚拟机为了提高执行效率,会使用即时编译技术将方法编译成机器码后再执行。

    Java HotSpot Client VM(-lient),为在客户端环境中减少启动时间而优化;

    Java Hotspot server VM(-server),为在服务器环境中最大化程序执行速度而设计。比较占内存

    Java HotSpot Client模式和Server模式的区别

    当虚拟机运行在-client模式的时候,使用的是一-个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级代号为C2的编译器. C2比C1编译器编译的相对彻底服务起来之后,性能更高

          重点:在部分JDK1.6版本和后续的JDK版本(64位系统)中,-client 参数已经不起作用了,Server 模式成为唯一,但是32位还可以。具体如何切换可以参考这篇文章

    https://blog.csdn.net/jacksonary/article/details/80334040

    四.Java堆结构与内存分代

    ①JVM的内存分代策略

             Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对HotSpot虚拟机而言) ,这就是JVM的内存分代策略。

     ②为什么要分代?

          堆内存是虚拟机管理的内存中最大的-块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想-下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。

          有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差, 一般不进行垃圾回收,还可以根据不同年代的特点,采用不同的垃圾收集算法。分代垃圾收集大大提升了垃圾收集效率,这些都是JVM分代的好处。

    ③内存分代划分

    Java虚拟机将堆内存划分为新生代、老年战和永久代,永久代是HotSpaot 虚拟机特有的概念,它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且HotSpot也有取消永久代的趋势,在JDK 1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据(移植到方法区),与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。内存分代示意图如下:

    新生代(Young)

     新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集-般可以回收70% ~ 95%的空间,回收效率很高。

    老年代(Old Generationn)

            在新生代中经历了多次(具体看虚拟机配置的阀值) GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

    永久代(Permanent Generationn)

            永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

    五.哪些垃圾需要被回收

            大家都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

     垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法。
    ①引用计数( Reference Counting ):

            比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。看下面的例子。

    public classReferenceFindTest{

        publicstaticvoidmain(String[] args){

            MyObject object1 = new MyObject();

            MyObject object2 = new MyObject();

            object1.object = object2;

            object2.object = object1;

            object1 = null;

            object2 = null;

        }

    }

    这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

    ②可达性分析算法

            可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

    在Java语言中,可作为GC Roots的对象包括下面几种:

      a) 虚拟机栈中引用的对象(栈帧中的本地变量表);

      b) 方法区中类静态属性引用的对象;

      c) 方法区中常量引用的对象;

      d) 本地方法栈中JNI(Native方法)引用的对象。

    ③方法区如何判断是否需要回收

            方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

    该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;

    加载该类的ClassLoader已经被回收;

    该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

    关于类加载的原理,也是阿里面试的主角,面试官也问过我比如:能否自己定义String,答案是不行,因为jvm在加载类的时候会执行双亲委派,

    原理请参考:http://www.cnblogs.com/aspirant/p/7200523.html

    五.常用的垃圾收集算法

    ①复制算法( Copying ):

            此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。这个就是用在堆分代回收那里的算法。

    ②标记-清除法(Mark-Sweep)

            是JVM垃圾回收算法中最古老的一个,该算法共分成两个阶段,第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,清除未被标记的对象。该算法的缺点是需要暂停整个应用,并且会产生内存碎片。

    我们可以看到在回收以后未使用的空间是不连续的,不连续的空间也就是内存碎片,这会影响到存储。

    ③标记-整理法(Mark-Compact)

    此算法结合了标记-清楚算法和复制算法的优点,也分为两个阶段,第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题,按顺序排放,同时解决了复制算法所需内存空间过大的问题。

    ④分代收集算法

    分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

    a.年轻代回收算法(核心其实就是复制算法)

     HotSpot将新生代划分为三块,-块较大的Eden空间和两块较小的Survivor空间,默认比例为8: 1: 1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代) ,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在FromSurvivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15 ,新生代中的对象每熬过一轮垃圾回收年龄值就加1 ,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的FromSurvivor区,新的From Survivor区就是.上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的(其实这就是分代收集算法中的年轻代回收算法,稍后我们会看到)。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

    b.老年代回收算法(回收主要以Mark-Compact为主)

    1.在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

    2.内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

    c. 持久代(Permanent Generation)的回收算法

            用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体回收可见上文关于方法区的介绍。

    六.常见垃圾收集器

    首先我们先明确ScavengeGC (次收集)和Full GC(全收集)的区别

           新生代GC (Scavenge GC) :

             Scavenge GC指发生在新生代的GC,因为新生代的Java对象大多都是朝生夕死,所以Scavenge GC非常频繁,般回收速度也比较快。当Eden空间不足以为对象分配内存时,会触发Scavenge GC。

          一般情况下 ,当新对象生成并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC是非常频繁的。

            老年代GC (Full GC/MajorGC) :

              Full GC指发生在老年代的GC,出现了Full GC一般会伴随着至少一次的Minor GC (就是次收集搞不定的时候,才全收集),比如:分配担保失败。Full GC的速度-般会比MinorGC慢10倍以上。当老年代内存不足或者显式调用Systemgc()方法时,会触发Full GC。

    下面正式看oracle公司提供的hotspot中7中垃圾收集器(有箭头指向的,是可以结合使用的)

    新生代收集器

    ①Serial收集器(也叫串行收集器)

            在JDK1.3之前唯一一个次收集器,单线程收集器,标记和清理都是单线程,优点是简单高效,缺点是在进行垃圾收集时必须暂停所有其他线程(“stop the world”,下图的安全点就是来触发暂停的)。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

    ②ParNew(也叫并行收集器)

            ParNew收集器其实是前面Serial 的多线程版本,除使多条线程进行GC外,包括Serial可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与Serial完全一样(当老年代启用CMS收集器XX: +UseConcMarkSweepG时,ParNew是默认新生代收集器).ParNew缩短了在垃圾回收时其他线程的暂停时间,但不能完全消除。

            由于存在线程切换的开销,ParNew在单CPU的环境中比不上Serial,且在通过超线程技术实现的两个CPU的环境中也不能100%保证能超越Serial.但随着可用的CPU数量的增加,收集效率肯定也会大大增加。

    ③Parall Scavenge

            与ParNew类似Parall Scavenge也是使用复制算法,也是并行多线程收集器.但与其他收集器关注尽可能缩短垃圾收集时间不同,Parallel Scavenge更关注系统吞吐量:系统吞吐量=运行用户代码时间运行用户代码时间+垃圾收集时间),停顿时间越短就越适用于用户交互的程序良好的响应速度能提升用户的体验;而高吞吐量则适用于后台运算而不需要太多交互的任务可以最高效率地利用CPU时间尽快地完成程序国的运算任务. Parall Scavenge提供了如下参数设置系统吞吐量:

    老年代收集器

    ①Serial Old收集器

    老年代单线程收集器,Serial收集器的老年代版本,使用的是标记-整理算法。

    ②Parallel Old收集器

    Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先,使用停止-复制算法。

    ③CMS收集器

    这里我先插一嘴,并行和并发都是啥,并行是几个人同一时间干一件事,比如上面的Parallel Old收集器在垃圾回收时是几个线程一起做的。并发是几个人在同一时间能做不同的事,也就是说前面几个垃圾收集器中,用户线程可以不暂停,系统一边回收垃圾,一边执行用户线程。

    CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器,一款真正意义上的并发收集器,虽然现在已经有了理论意义上表现更好的G1收集器,但现在主流互联网企业线上选用的仍是CMS(如Taobag、微店)。如果老年代使用CMS垃圾回收器,需要添加虚拟机参数-"XX:+UseConcMarkSweepGC"。

    CMS是一-种以获取最短回收停顿时间为目标的收集器(CMS又称多并发低暂停的收集器,基于”标记清除”算法实现,整个GC过程分为以下4个步骤:

    1.初始标记(CMS initial mark)

    2.并发标记(CMS concurrent mark: GC Roots Tracing过程)

    3.重新标记(CMS remark)

    4.并发清除(CMS concurrent sweep:已死象将会就地释放,注意:此处没有压缩

    其中1和3两个步骤(初始标记、重新标记仍需STW但初始标记仅只标记一下GC Roots能直接关联到的对象,速度很快;而重新标记则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那-部分对象的标记记录,虽然般比初始标记阶段稍长,但要远小于并发标记时间)

    触发条件

    1.如果没有设置 UseCMSInitiatingOccupancyOnly,虚拟机会根据收集的数据决定是否触发(线上环境建议带上这个参数,不然会加大问题排查的难度)

    2.老年代使用率达到阈值 CMSInitiatingOccupancyFraction,默认92%

    3.永久代的使用率达到阈值 CMSInitiatingPermOccupancyFraction,默认92%,前提是开启 CMSClassUnloadingEnabled

    4.新生代的晋升担保失败

    内容太多感兴趣的可以看看大神的分析:http://www.cnblogs.com/aspirant/p/8663911.html

    ④G1收集器

    内容实在太多了,而且我没完全理解,感兴趣的可以看看大神讲解

    连接:http://www.cnblogs.com/aspirant/p/8663872.html

    七.JVM调优

    划重点了哈,这是我知道的为数不多的重点0.0

    相关文章

      网友评论

          本文标题:JVM初识

          本文链接:https://www.haomeiwen.com/subject/hoothqtx.html