浅谈JVM原理

一、Java基本运行过程

       说到JVM,更容易想到的是Java,它可不仅仅是一门编程语言,它是一种技术,是由4个方面组成:Java编程语言、Java类文件格式、Java虚拟机(JVM)和Java应用程序接口(Java API)。它们的关系如下图所示:

                      浅谈JVM原理_第1张图片

                                                     Java内部组成关系图

       开发人员编写Java代码(.Java文件),然后通过Java编译器将之编译成字节码(.class文件),再然后字节码被装入内存,一旦字节码进入虚拟机,它就会被解释器解释执行,或者是被即时代码发生器有选择的转换成机器码执行。

      也就是如下:

      ①Java源文件—->编译器—->字节码文件

      ② 字节码文件—->JVM—->机器码

      那JVM是怎样的执行流程呢?

二、概念

       JVM是Java Virtual Machine(Java虚拟机)的缩写,它是Java的核心和基础,是Java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行Java的字节码程序。Java编译器只面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,即可通过特定平台运行。这也是为什么会有Java编译一次,到处运行的说法了。

三、三种主流分类

     ① Sun公司的HotSpot;

     ② BEA公司的JRockit;

     ③ IBM公司的J9 JVM;

     在JDK1.7及其以前我们所使用的都是Sun公司的HotSpot,但由于Sun公司和BEA公司都被oracle收购,jdk1.8将采用Sun公司的HotSpot和BEA公司的JRockit两个JVM中精华形成jdk1.8的JVM。

四、基本架构

                 浅谈JVM原理_第2张图片                                                                                                       

                                                  JVM基本架构

       JVM执行过程:

     (1)加载.class文件。

     (2)管理并分配内存。

     (3)执行垃圾收集。

      JVM由三个主要的子系统构成:类加载器子系统、运行时数据区(内存)和执行引擎。

     1、类加载器子系统

      类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称连接。类加载工作由ClassLoader及其子类负责。如下图:

                   浅谈JVM原理_第3张图片

                                                        Java类生命周期

       ①加载:加载阶段是“类加载机制”中的一个阶段,这个阶段通常也被称作“装载”,主要完成:

      (1)通过“类全名”来获取定义此类的二进制字节流。

      (2)将字节流所代表的静态存储结构转换为方法区的运行时数据结构。

      (3)在Java堆中生成一个代表这个类的Java.lang.Class对象,作为方法区这些数据的访问入口。

       相对于类加载过程的其他阶段,加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶 段,因为加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

       加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在Java堆中实例化一个Java.lang.Class类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。

       说到加载,不得不提到类加载器,下面就具体讲述下类加载器。

       JVM设计者把类加载阶段中的通过类全名来获取此类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序决定如何获取所需要的类。实现这个动作的代码模块成为“类加载器”。

       类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

       站在Java虚拟机的角度来讲,只存在两种不同的类加载器:

      (1)启动类加载器:它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分。

      (2)所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类Java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。

       站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

       启动类加载器:Bootstrap ClassLoader,跟上面相同。它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的Java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。

       扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由Java.ext.dirs系统变量指定的路径中的所有类库(如Javax.*开头的类),开发者可以直接使用扩展类加载器。

       应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

       应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的Java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

     (1)在执行非置信代码之前,自动验证数字签名。

     (2)动态地创建符合用户特定需要的定制化构建类。

     (3)从特定的场所取得Java class,例如数据库中和网络中。   

                                  浅谈JVM原理_第4张图片

                                                               类加载器体系

       上面的这种模型,就称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。子类加载器不是以继承的关系来实现,而是通过组合关系来复用父加载器的代码。

       双亲委派模型的工作过程为:如果一个类加载器收到了类请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,每一层都是如此,因此所有类加载的请求都会传到启动类加载器,只有当父加载器无法完成该请求时,子加载器才去自己加载。

       双亲委派模型的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如:Object,无论那个类加载器去加载该类,最终都是由启动类加载器进行加载的,因此Object类在程序的各种类加载环境中都是一个类。如果不用改模型,那么Java.lang.Object类存放在classpath中,那么系统中就会出现多个Object类,程序变得很混乱。

       ②验证:验证是连接阶段的第一步,这一步主要的目的是为了保证从加载阶段获取的字节流中包含的信息是符合虚拟机要求,并且对虚拟机来说是安全的。这个阶段主要完成4个方面的校验工作,包括文件格式验证、元数据验证、字节码验证和符号引用验证。

       文件格式验证主要是验证字节流是否符合Class文件格式规范,以及字节流能否被虚拟机处理,例如字节流文件是否以魔数0xCAFEBABE开头、主次版本号是否是虚拟机能处理的版本等。

       元数据验证主要是对字节码描述的信息进行语义分析,用来确保字节码描述信息符合Java语言规范,例如类的继承关系、类是否继承了不允许被继承的类、类实现了接口是否实现了接口中的所有方法等。

       字节码验证主要是通过数据流和控制流来分析和确定程序的语义是否合法、符合逻辑,例如验证操作数栈中数据的存取是否会出现类型不匹配、跳转指令是否会跳转到方法体以外的字节码指令上等。

       符号引用验证主要是确保解析动作能够正常执行,主要对常量池中的符号引用进行匹配性校验,例如符号引用中的类、属性和方法的访问性是否可以被当前类访问、能否通过字符串描述的全限定名来找到对应的类等。

       ③准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。假设一个类变量定义为:

       public static int value = 1024;

       那么变量value在准备阶段过后的初始值为0而不是1024,因为这时候尚未开始执行任何Java方法,而把value赋值为1024的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为1024的动作将在初始化阶段才会被执行。

       上面所说的通常情况下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,那么对于上面value,编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为1024。

       ④解析:解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。

       符号引用:符号引用是一组符号来描述所引用的对象,符号可以是任何形式的字面量,只要使用时能定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。

       直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄(一种特殊的智能指针)。直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定存在内存中。

       解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。对同一个符号引用进行多次解析请求时很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在.运行时常量池中记录直接引用,并把常标示为已解析状态),从而避免解析动作重复进行。

       解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。

      (1)类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

      (2)字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束(优先从接口来,然后是继承的父类.理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译)。

      (3)类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。 (4)接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

       ⑤初始化:初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码(初始化成为代码设定的默认值)。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器(clinit)方法的过程。在以下四种情况下初始化过程会被触发执行:

      (1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。

      (2)使用Java.lang.reflect包的方法对类进行反射调用的时候。

      (3)当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化。

      (4)jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类。

       ()方法的执行规则:

      (1)()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。

      (2)()方法与实例构造器()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此,在虚拟机中第一个被执行的()方法的类肯定是Java.lang.Object。

      (3)()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法。

      (4)接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成()方法。但是接口与类不同的是:执行接口的()方法不需要先执行父接口的()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。

      (5)虚拟机会保证一个类的()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

        举个例子:

        public class Person {

                public static int m = 1024;

                static {

                      m = 2048;

                }

       }

      ...

      public class Student extends Person {

               public static int n = m;

      }

      ...

      public class Demo {

              public static void main(String[] args) {

                     System.out.println("输出 = " + Student.n);

              }

       }

       执行上面的代码,会打印出2048,也就是说n的值被赋为了2048。我们来看得到该结果的步骤。

       首先在准备阶段为类变量分配内存并设置类变量初始值,这样m和n均被赋值为默认值0,而后再在调用()方法时给他们赋予程序中指定的值。当我们调用Student.n时,触发Student的()方法,根据规则2,在此之前,要先执行完其父类Person的()方法,又根据规则1,在执行()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Person的()方法时,会先将m赋值为1024,再执行static语句块中语句,将m赋值为2048,而后再执行Student类的()方法,这样便会将n的赋值为2048。

       如果我们颠倒一下Person类中“public static int m = 1024;”语句和“static语句块”的顺序,程序执行后,则会打印出1024。很明显是根据规则1,执行Person的()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int m = 1024;”语句。

       2、运行时数据区(内存)

       ①PC Register程序计数器:也有称作为PC寄存器。由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。

       在JVM规范中规定,如果线程执行的是非native(本地)方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。

       由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

       ②Java栈(Java虚拟机栈):栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。基本类型的变量和对象的引用变量都是在函数的栈内存中分配。

       栈帧中主要保存3类数据:

      (1)本地变量(Local Variables):输入参数和输出参数以及方法内的变量。

      (2)栈操作(Operand Stack):记录出栈、入栈的操作。

      (3)栈帧数据(Frame Data):包括类文件、方法等等。

       JVM对该区域规范了两种异常:1线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常。2若虚拟机栈可动态扩展,当无法申请到足够内存空间时将抛出OutOfMemoryError异常。通过jvm参数 –Xss指定栈空间,空间大小决定了函数调用的深度。线程独占。

       ③本地方法栈:JVM采用本地方法栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

       ④Java堆:Java中的堆是用来存储对象实例以及数组(当然,数组引用是存放在Java栈中的)。堆是被所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的。在JVM中只有一个堆。堆是Java垃圾收集器管理的主要区域,Java的垃圾回收机制会自动进行处理。堆内存分为三部分:

                           浅谈JVM原理_第5张图片

      (1)新生区

       新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分:伊甸区(Eden space)和幸存者区(Survivor pace),所有的类都是在伊甸区被new出来的。幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space)。伊甸区与幸存者区的空间大小比例默认为8:1:1。即当新生区的空间大小总数为20M 时,伊甸区的空间大小为16M,两块幸存者区则各分配2M。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园进行垃圾回收(Minor GC),将伊甸园中的剩余对象移动到幸存0区。若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1去也满了,再移动到老年区。若老年区也满了,那么这个时候将产生Major GC(FullGC),进行老年区的内存清理。若老年区执行Full GC 之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

       如果出现Java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:

       a)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

       b)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

     (2)老年区

      老年区用于保存从新生区筛选出来的 Java 对象,一般池对象都在这个区域活跃。

     (3)永久存储区

      永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。

      如果出现Java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。原因有二:

      a)程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。

      b) 大量动态反射生成的类不断被加载,最终导致Perm区被占满。

      说明:

      JDK1.6及之前:常量池分配在永久代 。

      JDK1.8及之后:永久代已经从Java堆中移除,String直接存放在堆中,类的元数据存储在meta space中,meta space占用外部内存,不占用堆内存。可以说,永久代已经更名为了元空间(meta space)。

      ⑤方法区(非堆):在方法区中,存储了每个类的信息(包括类的名称、修饰符、方法信息、字段信息)、类中静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息以及编译器编译后的代码等。当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,在这里进行的GC主要是方法区里的常量池和类型的卸载。当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

       在方法区中有一个非常重要的部分就是运行时常量池,用于存放静态编译产生的字面量和符号引用。运行时生成的常量也会存在这个常量池中,比如String的intern方法。它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。

       3、执行引擎

       执行引擎以指令为单位读取Java字节码。它就像一个CPU一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。

       不过Java字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被JVM执行的语言。字节码可以通过以下两种方式转换成合适的语言。

      (1)解释器

       一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。

      (2)即时(Just-In-Time)编译器

       即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。

      不过,用JIT编译器来编译代码所花的时间要比用解释器去一条条解释执行花的时间要多。因此,如果代码只被执行一次的话,那么最好还是解释执行而不是编译后再执行。因此,内置了JIT编译器的JVM都会检查方法的执行频率,如果一个方法的执行频率超过一个特定的值的话,那么这个方法就会被编译成本地代码。

      五、垃圾回收机制(GC)

      GC的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、老年代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停。

      1、哪些内存需要回收

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

      1.1、引用计数法

      引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

      优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

      缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

      1.2、可达性分析算法

      程序把所有的引用关系看作一张图,从一个节点GC ROOTS开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

                           浅谈JVM原理_第6张图片

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

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

   (2)方法区中类静态属性引用的对象。

   (3)方法区中常量引用的对象。

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

       不同的对象引用类型,GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:

     (1)强引用:

       在程序代码中普遍存在的,类似Object obj=new Object()这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

     (2)软引用:

      用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

     (3)弱引用:

      也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

     (4)虚引用:

      也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知(用来得知对象是否被GC)。

      无论引用计数算法还是可达性分析算法都是基于强引用而言的。即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

      第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。

      第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,在finalize()方法  中没有重新与引用链建立关联关系的,将被进行第二次标记。

      第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

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

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

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

    (2)加载该类的ClassLoader已经被回收。

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

      3、常用的垃圾回收算法

      ①标记-清除(Mark-Sweep)算法

      标记-清除算法是最基础的收集算法。算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(标记过程参照可达性分析)。

                 浅谈JVM原理_第7张图片

                                                        标记-清除算法

        主要缺点:

      (1)效率问题,标记和清除过程的效率都不高。

      (2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收

       ②复制(Copying)算法

       为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

                   浅谈JVM原理_第8张图片

                                                              复制算法

        主要缺点:

      (1)内存利用率问题,对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

      (2)效率问题,效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

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

       为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。

                 浅谈JVM原理_第9张图片

                                                        标记-整理算法

        ④分代收集(Generational Collection)算法

        分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。对不同生命周期的对象使用不同的算法进行回收。

       (1)新生代(Young Generation)的回收算法:

        新生代的特点是每次垃圾回收时都有大量的对象需要被回收,所以在新生代中JVM使用的是Mark-copy(标记-复制)算法。

        新生代将内存按照8:1:1分为一个Eden和so,s1三个区域;大部分对象都在Eden区域生成,在垃圾回收时,先将Eden存活的对象复制到s0区,然后清除Eden区,当这个s0区满了,则将Eden区和s0区的存活对象复制到s1,然后将Eden和s0区清空,此时s0是空的,然后交换s0和s1的角色(即下次回收会扫描eden和s1区),即保持s0为空,如此往复15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还存活,就存入老年代;如果当s1不足以存放Eden和s0存放的对象时,则将对象直接放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

       新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

      (2)老年代(Old Generation)的回收算法:

       老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact(标记-整理)算法。

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

       内存比新生代也大很多,大概是新生代的2倍,当老年代内存满时触发Major GC或Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

      (3)永久代(Permanent Generation)的回收算法:

       永久代(permanent generation)也称为“方法区(method area)”,他存储class对象和字符串常量。所以这块内存区域绝对不是永久的存放从老年代存活下来的对象的。在这块内存中有可能发生垃圾回收。发生在这里垃圾回收也被称为major GC。

       3、常见的垃圾收集器

                浅谈JVM原理_第10张图片

       ①Serial收集器(新生代)

       Serial收集器是最基本、历史最悠久的垃圾收集器了,在JDK1.3.1之前是新生代搜集器的唯一选择。通过名称就可以看出该垃圾收集器是一个单线程收集器它的单线程的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作, 更重要的是它在进行垃圾收集工作时候必须暂停其它所有的线程Stop the world, 直到它收集结束

       使用复制算法收集新生代垃圾。

              浅谈JVM原理_第11张图片

                                              Serial收集器运行示意图

        优点:简单高效、对于限定单个cpu的环境来说,serial收集器没有线程交互的开销,可专心做垃圾收集以获得最高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

       缺点:单线程收集器,在进行垃圾收集时,必须暂停其他所有的工作线程,直到它搜集结束。

       ②ParNew收集器(新生代)

       ParNew收集器其实是Serial收集器的多线程版本。除了多线程收集,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的收集器,其中的一个与性能无关的原因是,除了Serial收集器,目前只有它能和CMS收集器配合工作(CMS收集器是hotspot虚拟机中第一款真正意义上的并发收集器,简单来说就是你可以一边制造垃圾,收集器在不打断你的情况下进行收集)。ParNew单核状态下不如Serial,多核线程下才有优势。

       使用复制算法收集新生代垃圾。

                浅谈JVM原理_第12张图片

                                               ParNew收集器运行示意图

       ③Parallel Scavenge收集器(新生代)

       Parallel Scavenge 收集器类似于ParNew 收集器。它在回收期间不需要暂停其他用户线程。它追求高吞吐量(运行用户程序的时间 / (运行用户程序的时间 + 垃圾收集的时间)),高效利用CPU,主要是为了达到一个可控的吞吐量。Parallel Scavenge收集器是虚拟机运行在Server模式下的默认垃圾收集器。Parallel Scavenge收集器也被称为“吞吐量优先收集器”

       使用复制算法收集新生代垃圾。

       ④Serial Old 收集器(老年代)

       Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

       使用标记-整理算法收集老年代垃圾。

                浅谈JVM原理_第13张图片

                                              Serial Old收集器运行示意图

        ⑤Parallel Old 收集器(老年代)

        Parallel Scavenge 收集器的老年代版本。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

        使用标记-整理算法收集老年代垃圾。

               浅谈JVM原理_第14张图片

                                           Parallel Old收集器运行示意图

 

        ⑥CMS收集器(老年代)

        CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常重视服务的响应速度,以期给用户最好的体验。从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种“标记-清除”算法实现的。它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

      (1)初始标记,标记GCRoots能直接关联到的对象,时间很短。

      (2)并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长。

      (3)重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。

      (4)并发清除,回收内存空间,时间很长。

       其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。

        浅谈JVM原理_第15张图片

                                            Concurrent Mark Sweep收集器运行示意图

       优点:高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。

       但是它有明显的三个缺点:

      (1)对CPU资源非常敏感,可能会导致应用程序变慢,吞吐率下降。

      (2)无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾,同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。

      (3)由于采用的标记 - 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC。

       ⑦G1收集器(新生代+老年代)

       G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

使用复制 + 标记-整理算法收集新生代和老年代垃圾。

       G1收集器有以下特点:

     (1)并行和并发。使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。

     (2)分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。

     (3)空间整合。基于标记 - 整理算法,无内存碎片产生。

     (4)可预测的停顿。能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

      使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。

 

 

你可能感兴趣的:(jvm)