JVM内存模型及垃圾回收

1. 什么是JVM

        JVM是我们运行java程序所必须的虚拟机环境,也是java实现跨平台原理的不可或缺的东西。“一次编译,到处运行”靠的就是JVM。

2. 为什么要JVM优化

        我们的代码都是在JVM中运行的,JVM有自己默认的配置,但是许多情况下采用默认配置并不是最好的选择,并且有可能导致运行效率更差。我们还是需要针对实际情况对其进行一定的优化使得程序运行的更加顺滑。

3. JVM组成

        主要有四部分组成:运行时数据区、类加载器子系统、本地方法库、执行引擎

3.1 类加载子系统

        用于类加载的。类加载的过程:

        1. 加载:将字节码文件加载到内存中。加载方式分为显示加载(new)以及隐式加载(反射)

        2. 验证:验证加载的文件是否为字节码文件,并且进行一系列的安全验证

        3. 准备:对静态变量分配内存空间并且初始化赋值(0或者null),如果想在这个阶段为其赋明确值则需要为变量添加final修饰

        4. 解析:将java符号引用替换为直接引用

        5. 初始化:为变量进行赋值

3.1.1 类与类加载器

        每一个类,都需要和它的类加载器一起确定其在JVM中的唯一性.换句话来说,不同类加载器加载的同一个字节码文件,得到的类都不相等.我们可以通过默认加载器去加载一个类,然后new一个对象,再通过自己定义的一个类加载器,去加载同一个字节码文件,拿前面得到的对象去instanceof,会得到的结果是false.

3.1.2 双亲委派机制

        在java中包含有四种类加载器:启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器,前三种是必然存在的

类加载器

        直接拿应用程序类加载器举例吧,它在要加载一个类的时候不会直接尝试去加载,而是会委派上级扩展类加载器加载,扩展类加载器也会继续委派上级启动类加载器加载。接着启动类加载器进行加载,如果启动类加载器在自己的搜索范围没有发现对应类则会让扩展类加载器加载,同样的扩展类加载器会在搜索范围内查找,如果没有则继续让应用程序类加载器加载,如果依旧没有,此时就会抛出异常。

        如上所说,每一个类加载器都有自己明确的加载范围,启动类加载器负责加载JAVA_HOME/lib下的类;扩展类加载器负责加载JAVA_HOME/lib/ext下的类;应用程序类加载器负责加载classpath下的类

        那么这么复杂一个流程意义何在呢?

        首先,可以明确的是进行这样的从下到上再从上到下的双亲委派是有意义的:

        这是为了安全性着想,举例来说:我们自定义一个Object类,放到自己的Classpath中,如果没有这种双亲委派机制则会直接通过应用程序类加载器将该类加载到内存中,势必会引发混乱的。但是如果有了双亲委派机制,则按照层级,在启动类加载器中将正确的Object类加载到内存,不会将我们自定义的Object加载,也就不会发生问题了

3.2 运行时数据区

运行时数据区组成

        其中堆和方法区是所有线程共享的,其他的都是线程私有的。程序计数器是JVM中唯一一块没有OOM的区域。

3.2.1 虚拟机栈

        是线程私有的,每一个方法就会创建一个栈桢,方法中的局部变量就存放在栈桢中,与它相关的错误有:Stack Overflow以及OOM前者是因为调用的方法过多并且没有弹出(例如递归调用方法并没有出口),后者是因为在创建对象申请新空间时不足或者循环递归调用过多对象。

        虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。

3.2.2 本地方法栈

        虚拟机栈执行的是java方法,本地方法栈执行的是本地(native)方法

3.2.3 方法区

        在java1.7及以前方法区(永久代)属于堆内存上的一块连续的区域。在jdk1.7之前用于存放类信息、常量池(包括字符常量池和class常量池)、编译后的代码、静态变量;从1.7开始就准备“去永久代”的规划了,jdk1.7将字符常量池以及静态变量放入到堆内存中。1.8就没有方法区了,取而代之的是在计算机内存上的元空间

方法区

        去永久代的原因?

        字符串容易导致永久代出现OOM

        类及方法难以确定具体大小,指定永久代大小是个问题

        永久代会为GC带来不必要的复杂度,且回收效率不高

3.2.4 堆内存

        堆内存是用于存放对象及数组的区域,是GC主要光顾的地方,是所有线程共享的。分为新生代、老年代。新生代主要用于存放刚创建的对象,是Mintor GC主要光顾的地方,由于采用分代GC算法,所以新生代又细分为Eden、S0、S1(标记复制算法),刚创建的对象会放在Eden中,当经历一次GC之后存活的对象会被放入到survivor区,默认经历15次GC之后会放入到老年代(当survivor区满了的时候会直接放入到老年代),老年代是fullGC光顾,间隔久,频率不高。

4. JVM垃圾回收

        进行垃圾回收需要做到的几点:

            如何判断垃圾对象?

            什么时候回收垃圾对象?

            怎么回收垃圾对象?

4.1 判断一个对象是否属于待回收状态有两种方法

4.1.1 引用计数法

        每个对象都会有一个引用计数器,当有外部引用或者其他对象引用该对象时,会将引用计数器+1,当失去引用时则-1,当对象引用计数器为0时则表示当前对象处于待回收状态。

        缺点:

        可能存在已没有外部引用(对象实际应处于待回收状态),但是有其他对象引用了该对象,并且该对象也引用了它(内部对象间互相引用),使得双方引用计数器不为0,导致不会被回收


引用计数1


引用计数2


引用计数3


引用计数4

        如图1所示,现在两个实例对象的引用计数都为1,属于非回收对象,当外部不在引用实例对象1时,其引用计数-1变为0,此时实例对象1处于待回收状态,如图2所示。

           但是,如果出现图3所示情况,即实例对象之间互相进行了引用,此时实例对象1和2的引用计数都是为2的。此时,外部不在引用这两个对象时,由于它们自身之间进行了互相引用,导致引用计数为1,所以不会被标记为待回收状态。

4.1.2 可达性分析法

        通过GCRoots实现,GCRoots是垃圾回收的起点,当一个对象到GCRoot没有任何引用链时(GCRoots到这个对象不可达),则当前对象处于待回收状态。

        进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除);如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做.


可达性1


可达性2

4.2 什么时候回收

        每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。这里涉及两个概念:安全点、安全区

4.2.1 安全点

        用白话解释,举例:妈妈正要进行扫地,但是小红此时告诉妈妈:“我正在吃苹果,还没有吃完呢,你等我吃完再扫”,妈妈就得等待小红吃完苹果再扫。那么此时小红吃完苹果时就是安全点,到那时妈妈就可以安心的进行扫地了。

        从线程角度看,safepoint可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停,比如发生GC时,需要暂停所有活动线程,但是该线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,然后才开始GC,该线程等待GC结束。参考原文链接:https://blog.csdn.net/u014590757/article/details/79855223

4.2.2 安全区

        同样的举例:妈妈扫地,此时小红告诉妈妈:“没事儿,妈妈你扫吧,我等十分钟再吃苹果”,那么妈妈就会进行扫地。此时这十分钟就属于安全区,在这期间妈妈不用担心扫地会影响到小红。

        安全区域是指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生gc都是安全的。当代码执行到安全区域时,首先标示自己已经进入了安全区域,那样如果在这段时间里jvm发起gc,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行gc,如果是那么就等到gc完成后再离开安全区域。

4.3 怎么回收垃圾

 4.3.1 常用回收算法

   1. 标记-清除

        首先标记出所有要回收的对象,标记完成之后统一回收。

        缺点:效率不高,并且会导致出现内存空间不连续,之后如要存放大对象则不好存放

    2. 标记-复制

        首先,将内存空间分为两部分,每次存储中其中一块,当一块进行GC时将存活对象复制到另一块中,同时把用过的那一块中对象清除,如此反复

        缺点:空间利用率不高

    3. 标记-整理

        边标记处理边整理使得空间连续

        缺点:效率比较低

    4. 分代回收算法

        将堆内存分为新生代和老年代,新生代又分为Eden、S0、S1,新生代对象一般存活时间较短,采用标记-复制算法。老年代对象一般存活时间较长,会比较少次数的进行回收,所以采用标记-清除或者标记-整理算法。

分代回收详解:

    ● 大多数情况下,新的对象都分配在Eden区,当Eden区没有空间进行分配时,将进行一次Minor GC,清理Eden区中的无用对象。清理后,Eden和From Survivor中的存活对象如果小于To Survivor的可用空间则进入To Survivor,否则直接进入老年代);Eden和From Survivor中还存活且能够进入To Survivor的对象年龄增加1岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1),当存活对象的年龄到达一定程度(默认15岁)后进入老年代,可以通过-XX:MaxTenuringThreshold来设置年龄的值。

    ●  当进行了Minor GC后,Eden还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。

    ●  占To Survivor空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如Survivor空间是10M,有几个年龄为4的对象占用总空间已经超过5M,则年龄大于等于4的对象都直接进入老年代,不需要等到MaxTenuringThreshold指定的岁数。

    ●  在进行Minor GC之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明Minor GC是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行Minor GC,否则执行Full GC。

    ●  当在java代码里直接调用System.gc()时,会建议JVM进行Full GC,但一般情况下都会触发Full GC,一般不建议使用,尽量让虚拟机自己管理GC的策略。

    ●  永久代(方法区)中用于存放类信息,jdk1.6及之前的版本永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发Full GC,如果经过Full GC还无法满足永久代存放新数据的需求,就会抛出永久代的内存溢出异常。

    ●  大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行Full GC。

4.3.2 Minor GC和Full GC

        在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。

如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。

        而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC一般是老年代的回收,并伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。

        所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。

4.3.3 常见垃圾回收器

现在常见的垃圾收集器有如下几种

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、CMS、Parallel Old

堆内存垃圾收集器:G1

    鄙人学识尚浅,本文中如有不妥之处,请见谅!也请指出!

你可能感兴趣的:(JVM内存模型及垃圾回收)