JVM(1)— JVM基础

94 阅读21分钟

一、JAVA垃圾收集

任何语言在运行过程中都会创建对象,也就意味着需要在内存中为这些对象在内存中分配空间,在这些对象失去使用的意义的时候,需要释放掉这些内容,保证内存能够提供给新的对象使用。对于对象内存的释放就是垃圾回收机制,也叫做gc(Garbage Collection),对于java开发者来说gc是一个双刃剑。

C和JAVA的垃圾收集

c的垃圾回收是人工的,工作量大,但是可控性高。java是自动化的,但是可控性很差,甚至有时会出现内存溢出的情况

c.gif java.gif

提到java的垃圾回收机制就不得不提一个方法:

 System.gc()用于调用垃圾收集器,在调用时,垃圾收集器将运行以回收未使用的内存空间。它将尝试释放被丢弃对象占用的内存。

 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。

 所以System.gc()并不能说是完美主动进行了垃圾回收。

  • 内存泄漏:memory leak,资源使用完后没有及时释放,内存泄漏次数多了会导致内存溢出。

  • 内存溢出:out of memory,系统无法分配你申请的空间。

学习JAVA垃圾收集需要弄懂的问题:

  1. jvm怎么确定哪些对象需要回收

  2. jvm在时候时候进行垃圾回收

  3. jvm是怎么清除垃圾的

二、JAVA垃圾收集基础知识

详细知识可学习《深入理解Java虚拟机》

Java语言最重要的特点就是跨平台运行。使用jvm就是为了支持与操作系统无关,实现跨平台。

jvm屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。jvm在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

1、JVM模型

JDK1.8 jvm运行时数据区域:

1.8同1.7比,最大的差别就是元数据区取代了永久代。元数据区的本质和永久代类似,都是对JVM规范中方法区的实现。不过元数据区与永久代之间最大的区别在于元数据空间并不在虚拟机中,而是使用本地内存

jdk1.7.png jdk1.8.jpg

JDK1.7 JDK1.8

程序计数器:一块较小的内存空间,当前线程所执行的字节码的行号指示器。每条线程都需要一个独立的程序计数器,互不影响,独立存储。主要用于多线程执行中,当某个线程的时间片耗尽时,记录该线程当前执行的位置,使得线程下次可以继续执行。此区域是唯一一个在java比较中不会有OutOfMemoryError异常的区域。

Java虚拟机栈:线程私有,生命周期与线程相同。我们常说的“堆内存、栈内存”中的“栈内存”指的便是虚拟机栈,确切地说,指的是虚拟机栈的栈帧中的局部变量表,因为这里存放了一个方法的所有局部变量。局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,long,double),对象引用(reference类型,他不同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnaddress类型(指向了一条自己吗指令的地址)。

  • 虚拟机栈的StackOverflowError:若单个线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError(栈溢出错误)。-Xss参数指定栈的大小。例如:只入栈不出栈会导致此异常。例子JavaVMStackSOF.java

  • 虚拟机栈的OutOfMemoryError:当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时抛出的异常。JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存大致上等于“JVM进程能占用的最大内存(依赖于具体操作系统) - 最大堆内存 - 最大方法区内存 - 程序计数器内存(可以忽略不计) - JVM进程本身消耗内存”。例子:JavaVMStackOOM.java (会造成机器卡死)

    设置单个线程虚拟机栈的占用内存为2m并不断生成新的线程,最终虚拟机栈无法申请到新的内存,抛出异常:

    Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

本地方法栈:本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowError和OutOfMemoryError异常。不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。

Java堆:Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,存放对象实例。Java堆在内存中可以处于不连续的区域,只要逻辑上连续即可。当堆无法分配对象内存且无法再扩展时,会抛出OutOfMemoryError异常。例子:HeapOOM.java

方法区:也称非堆(Non-Heap),永久代。又是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field等元数据对象、static-final常量、static变量、jit编译器编译后的代码等数据。1.8版本中移除了方法区并使用metaspace(元数据空间)作为替代实现。metaspace占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。

matespace:存放类加载的信息,在类卸载时释放空间,该区域上限是系统内存。

2、哪些对象需要回收(判断对象生死的方法)

垃圾回收针对java堆中的对象,在垃圾回收之前,需要判断对象是否存活。

1》引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它,计数器值就加1,引用失效时,计数器值减1。任何时刻计数器为0的对象就是不可能再使用的。

  • 优点:实现简单,判定效率高

  • 缺点:存在对象之间的循环引用问题

    public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
    	//step1
    	ReferenceCountingGC objA = new ReferenceCountingGC();
    	//step2
    	ReferenceCountingGC objB = new ReferenceCountingGC();
    
    	//相互引用
    	//step3
    	objA.instance = objB;
    	//step4
    	objB.instance = objA;
    
    	//step5
    	objA = null;
    	//step6
    	objB = null;
    
    	//显式GC
    	System.gc();
    }
    
    public static void main(String[] args) {
    	testGC();
    }
    

    }

step1:objA的引用+1 =1

step2:objB的引用+1 =1

step3:objB的引用+1 =2

step4:objA的引用+1 =2

step5:objA的引用-1 =1

step6:objB的引用-1 =1

很明显,到最后两个实例都不再用了(都等于null了),但是GC却无法回收,因为引用数不是0,而是1,这就造成了内存泄漏。也很明显,现在虚拟机都不采用此方式。

2》可达性分析算法:

在主流的商用语言中,都通过可达性分析算法来判断对象是否存活。

通过一系列的GC Roots的对象作为起始点,从这些根节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

GCroots.jpg

Java中,可作为GC Roots的对象(一组必须活跃的引用):

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

  • 方法区中类静态属性引用的对象

  • 方法区中常量引用的对象

  • 本地方法栈中JNI(即一般说的native方法)中引用的对象

可达性分析算法流程图

GC.jpg

  • 任何一个对象的finalize()方法都只会被系统调用一次

  • 不推荐使用finalize()方法,其调用时机不确定,且只保证方法会调用,不保证会执行完。若用来进行释放资源,可能会造成资源无法及时回收。

  • finalize:www.liangzl.com/get-article…

3、垃圾收集算法

1》标记-清除算法

GC标记清除.png

最基础的算法,有以下不足:

1、效率问题,标记和清除效率都不高;

2、空间问题:清除后产生大量不连续的内存碎片,导致以后分配大对象时没有连续的内存空间,不得不提前再触发一次垃圾收集动作。

2》复制算法

将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。图是算法具体的一次执行过程后的结果对比。

GC复制.png

此算法提高内存分配效率,只需移动顶端指针,按顺序分配即可。解决了内存碎片问题,但也有以下不足。

1、可用内存缩小为原来的一半。

2、当存活的对象数量很多时,复制的效率很慢。

3》标记-整理算法

标记过程还是和标记 - 清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

GC标记整理.png

4》分代收集算法

  • Minor GC/Young GC 年轻代GC

  • Major GC 老年代GC

  • Full GC (Minor + Major)清理年轻代和老年代

把堆分为新生代和老年代,然后根据各年代的特点选择最合适的回收算法。

  • 新生代基本上都是朝生暮死的,生存时间很短暂,因此可以采拥标记 - 复制算法,只需要复制少量的对象就可以完成收集。

  • 老年代中的对象存活率高,也没有额外的空间进行分配担保,因此必须使用标记 - 整理或者标记 - 清除算法进行回收。

新生代内存划分:

HotSpot JVM把新生代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to),默认比例为8:1

1、在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的

2、进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到老年代中,没有达到阈值的对象会被复制到“To”区域。

3、经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,保证名为To的Survivor区域是空的。

young_gc.png

4、垃圾收集器

垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。

HotSpot虚拟机的垃圾收集器

垃圾收集器.png

1》Serial收集器

  • 最基本,最久远的收集器

  • 单线程收集器:进行垃圾收集时,必须暂停其他所有线程,直到它收集结束

  • 新生代收集器,采用复制算法

  • 简单高效,适用于运行在Client模式下的虚拟机,对于单个CPU环境而言,Serial收集器由于没有线程交互开销,可以获取最高的单线程收集效率。

2》ParNew收集器

  • Serial的多线程版本

  • 新生代收集器,采用复制算法

  • 和老年代收集器CMS配合使用

3》Parallel Scavenge收集器

  • 新生代收集器,采用复制算法

  • 多线程

  • 目的是达到可控的吞吐量:吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

  • 适合后台运算不需要太多交互的任务

4》Serial Old收集器

  • Serial收集器的老年代版本

  • 单线程,采用标记-整理算法

  • CMS发生Concurrent Mode Failure时的后备预案

5》Parallel Old收集器

  • Parallel Scavenge收集器的老年代版本

  • 多线程,采用标记-整理算法

  • 适用于注重吞吐量和CPU资源敏感的场合

6》CMS(Concurrent Mark Sweep)收集器

  • 以获取最短停顿时间为目标

  • 基于标记-清除算法

分为以下4个阶段

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

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

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

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

初始标记和重新标记需要“Stop The World”,而耗时较多的并发标记和并发清除过程可以和用户线程一起执行。

CMS优点:并发收集、低停顿

CMS收集器3个缺点:

1. CMS对CPU资源敏感,CMS默认回收线程数是(CPU数量+3)/4,CPU数量大于4时,并发回收时垃圾收集线程占用不少于25%的资源;CPU不足4个时,CMS对用户线程影响较大。

2. CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现Concurrent Mode Failure导致Full GC的产生。因为并发清除阶段用户线程还在运行,那么必选要预留足够的空间给用户线程使用,

预留比例通过-XX:CMSInitiatingOccupancyFractio参数设置。当CMS预留的内存无法满足需要时,就会导致Concurrent Mode Failure失败。

3. CMS基于标记-清除算法,容易产生大量的内存空间碎片。导致无法为对象分配连续的内存空间,不得不触发Full GC。可以通过参数设置,使CMS进行内存整理,但会增加回收时间。

7》G1(Garbage-First)收集器

  • jdk9中默认的垃圾收集器

  • 并发收集器

  • 分代收集

  • 从整体看是基于标记-整理算法,从局部看基于复制算法

  • 可预测的停顿时间

G1的设计原则就是简单可行的性能调优,开发人员仅仅需要声明以下参数即可:-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。其次,G1将新生代,老年代的物理空间划分取消了。这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

使用G1收集器时,Java堆会被划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但两者已经不是物理隔离了,都是一部分Region(不需要连续)的集合。

Region的大小可以通过G1HeapRegionSize参数进行设置,其必须是2的幂,范围允许为1Mb到32Mb。 JVM的会基于堆内存的初始值和最大值的平均数计算分区的尺寸,平均的堆尺寸会分出约2000个Region。分区大小一旦设置,则启动之后不会再变化。

G1.png

  1. Eden regions(年轻代-Eden区)

  2. Survivor regions(年轻代-Survivor区)

  3. Old regions(老年代)

  4. Humongous regions(巨型对象区域)

  5. Free regions(未分配区域,也会叫做可用分区)-上图中空白的区域

G1的垃圾回收分为以下4个阶段:

1. 初始标记(Initial Marking):标记GC Roots能直接关联到的对象

2. 并发标记(Concurrent Marking):从GC Roots出发,进行可达性分析

3. 最终标记(Final Marking):修正并发标记阶段因用户程序继续运行导致标记产生变化的那部分记录

4. 筛选回收(Live Data Counting and Evacuation):对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间制定回收计划

关于分区有几个重要的概念:

  • G1还是采用分代回收,但是不同的分代之间内存不一定是连续的,不同分代的Region的占用数也不一定是固定的(不建议通过相关选项显式设置年轻代大小。会覆盖暂停时间目标。)。年轻代的Eden,Survivor数量会随着每一次GC发生相应的改变。

  • 分区是不固定属于哪个分代的,所以比如一次ygc过后,原来的Eden的分区就会变成空闲的可用分区,随后也可能被用作分配巨型对象,成为H区等。

  • G1中的巨型对象是指,占用了Region容量的50%以上的一个对象。Humongous区,就专门用来存储巨型对象。如果一个H区装不下一个巨型对象,则会通过连续的若干H分区来存储。因为巨型对象的转移会影响GC效率,所以并发标记阶段发现巨型对象不再存活时,会将其直接回收。ygc也会在某些情况下对巨型对象进行回收。

  • 通过上图可以看出,分区可以有效利用内存空间,因为收集整体是使用“标记-整理”,Region之间基于“复制”算法,GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片

  • G1类似CMS,也会在比如一次fullgc中基于堆尺寸的计算重新调整(增加)堆的空间。但是相较于执行fullgc,G1 GC会在无法分配对象或者巨型对象无法获得连续分区来分配空间时,优先尝试扩展堆空间来获得更多的可用分区。原则上就是G1会计算执行GC的时间,并且极力减少花在GC上的时间,如果可能,会通过不断扩展堆空间来满足对象分配、转移的需要

  • 因为G1提供了“可预测的暂停时间”,也是基于G1的启发式算法,所以G1会估算年轻代需要多少分区,以及还有多少分区要被回收。ygc触发的契机就是在Eden分区数量达到上限时。一次ygc会回收所有的Eden和survivor区。其中存活的对象会被转移到另一个新的survivor区或者old区,如果转移的目标分区满了,会再将可用区标记成S或者O区。

G1的另一个显著特点他能够让用户设置应用的暂停时间,为什么G1能做到这一点呢?也许你已经注意到了,G1回收的第4步,它是“选择一些内存块”,而不是整代内存来回收,这是G1跟其它GC非常不同的一点,其它GC每次回收都会回收整个Generation的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而G1每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。

由于内存被分成了很多小块,又带来了另外的好处,由于内存块比较小,进行内存压缩整理的代价都比较小,相比其它GC算法,可以有效的规避内存碎片的问题。

G1的缺点是,如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。因此G1比较适合内存稍大一点的应用(一般来说至少4G以上),小内存的应用还是用传统的垃圾回收器比如CMS比较合适。

G1的垃圾收集种类

G1中提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。

1》young GC

发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

2》mixed GC

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。

在cms中,当老年代的使用率达到80%时,就会触发一次cms gc。相对的,mixed gc中也有一个阈值参数 -XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc.

3》full GC

如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc.(G1收集器是JAVA 9正式引入的。JDK10之前FullGC处理会交给单线程的Serial Old垃圾收集器。JAVA 10发布之后,对G1收集器的Full GC做了优化,优化点就是把单线程的Full GC变成了多线程并行Full GC,提供了并发标记的Full GC方案Parallelize Mark-Sweep-Compact

5、内存分配与回收策略

对象的内存分配规则不是百分百固定的,其细节取决于使用哪种垃圾回收器组合,还有虚拟机中与内存相关的参数设置。但有以下几种普遍的规则。

1》对象优先分配在Eden区,Eden区没有足够空间时,虚拟机发起一次Minor GC

2》大对象直接进入老年代:大对象指需要连续内存的java对象,比如很长的字符串和数组。通过设置参数-XX:PretenureSizeThreshold,令大于这个值的对象直接在老年代分配。

3》长期存活的对象进入老年代:虚拟机给每个对象定义了年龄计数器,对象在Survivor中每熬过一次Minor GC,年龄增加1,到一定程度(默认15)就会晋升到老年代。年龄可通过参数-XX:MaxTenuringThreshold设置。

4》动态对象年龄判定:为适应不同程序的内存情况,虚拟机并不是永远要求对象的年龄达到阈值才晋升老年代。如果在Survivor中空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代。

5》空间分配担保:在发生Minor GC时,虚拟机会检查老年代连续的空闲区域是否大于新生代所有对象的总和,若成立,则说明Minor GC是安全的,否则,虚拟机需要查看HandlePromotionFailure的值,看是否运行担保失败,若允许,则虚拟机继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于,将尝试进行一次Minor GC;若小于或者HandlePromotionFailure设置不运行冒险,那么此时将改成一次Full GC,以上是JDK Update 24之前的策略,之后的策略改变了,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

1.png 2.png