一文弄懂JVM内存结构,垃圾回收器和垃圾回收算法

声明:本文从知乎上部分热门文章做二次整理,希望可以帮助更多的人,如有侵权,请联系删除。

jvm

概述:

jvm: java virtual machine, 用于把我们写的那些不能直接被程序识别的java代码,翻译给操作系统,告诉他我们要做的是什么操作。

 

生命周期:

java程序开始执行的时候运行,程序结束后停止

机器上运行几个java程序,就会相应的有几个jvm进程

jvm中的线程分为两种: 守护线程和普通线程。守护线程是jvm自己使用的线程,比如垃圾回收。 普通线程一般指的就是java程序的线程,只要jvm中有普通线程在执行一般情况jvm就不会停止,除非强制调用exit();方法种植程序。

 

启动过程:

根据本地配置的环境变量找到jvm, java.exe 通过LoadJavaVm 来装入jvm文件,LoadLibrary装载jvm动态连接库,然后把JVM中的到处函数JNI_CreateJavaVM和JNI_GetDefaultJavaVMInitArgs 挂接到InvocationFunction 变量的CreateJavaVM和GetDafaultJavaVMInitArgs 函数指针变量上。JVM的装载工作完成。

运行java程序: jvm运行java程序的方式有两种,jar包和Class

运行jar的时候:java.exe调用GetMainClassName函数,该函数先活的JNIEnv实例然后调用JarFileJNIEnv类中的getMainfest(),从期返回的Mainifest对象中getArribittes("Main-Class")的值,即jar包中文件:META-INF/MANIFEST.MF指向的Main-Class的朱雷明作为运行的主类。之后main函数会调用java.c中LoadClass方法状态该主类(使用JNIEvn实例的FindClass)

 

运行Class的时候,main函数直接接调用Java.c中LoadClass方法装在该类

 

类加载器:

jvm默认提供了三个类加载器:

  1. Bootstrap classLoader: 称之为启动类加载器,是最顶层的类加载器,负责加载JDK中的核心类库,jdk/lib目录下的jar. 如rt.jar,resource.jar, charset.jar等

  2. Extension ClassLoader: 称之为扩展类加载器,负责加载java的扩展类库,默认加载$JAVA_HOME中jre/lib/*.jar, 或-Djava.ext.dirs指定目录下的jar包

  3. APP ClassLoader:称之为系统类加载器,负责加载应用程序classPath目录下的所有jar和class文件。

    除了java默认的三个类加载之外,我们还可以根据自身需要自定义ClassLoader, 自定义的类加载器必须继承ClassLoader类,除了Bootstrap ClassLoader,不是一个普通的java类,底层使用c++语言编写的,已经嵌入到jvm内核中,当jvm启动后,BootstrapClassLoader也随之启动,负责加载完核心类库后,并构造ExtensionClassLoader和 App CLassLoader

    类加载的机制包括加载,连接(验证,准备,解析),初始化

方法区:

在jvm中,类型信息和类静态变量都保存在方法区中,类型信息是由类加载器在类加载的过程中从类文件中提取出来的,需要注意一点的是,常量池也存放于方法区中。

程序中所有的线程共享一个方法区,所以访问方法区的信息必须确保线程是安全的。如果有两个线程同时去加载一个类,那么只能有一个线程被允许去加载这个类,另一个必须等待。

方法区也是可以被垃圾回收,但是条件肺炎严苛,必须在该类没有任何引用的情况下。

类型信息包括:类型全名,类型的父类型全名,接口还是类,类型修饰符,父接口全名列表,类型的字段信息,类型的方法信息,所有的静态变量,指向类加载器的引用,指向Class的引用,基本类型常量池

堆:

当java创建一个类的对象或者数组时,都在堆中为新的对象分配内存,虚拟机中只有一个堆,程序中所有的线程都共享它。堆占用的控件是最多的,堆的存取类型为管道类型,先进先出。在程序运行中,可以动态分配堆内存的大小。

 

栈:

java栈中只保存基本数据类型和自定义对象的引用,注意只是对象的引用,而不是对象本身,对象本身保存在堆中。像String,Integer,等包装类是存放于堆中的。栈是先进后出类型的,栈内的数据在超出其作用域后,会被自动释放掉,不由JVM GC管理。每一个线程都包含一个栈区,每个栈中的数据都是私有的,其他栈不能访问。每个线程都会创建一个操作栈,每个栈又包含了若干个栈帧,每个栈帧对应着每个方法的每次调用,每个栈帧包含了三个部分:

局部变量区(方法内基本类型变量、变量对象指针),操作数栈区(存放方法执行过程中产生的中间结果),运行环境区(动态连接、正确的方法返回相关信息、异常捕捉)

 

本地方法栈:

本地方法栈的功能和jvm栈得非常类似,用于存储本地方法的局部变量表,本地方法的操作数栈等信息。本地方法栈是在程序调用或jvm调用本地方法接口(native)时候启用。本地方法都不是使用java语言编写的,比如使用C语言编写的本地方法,本地方法也不由jvm去运行,所有本地方法的运行不受jvm的管理。hotspot vm将本地方法栈和jvm栈合并了。

 

程序计数器:

在jvm概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成。jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了各条线程之间的切换后计数器能恢复到正确的执行位置,所以每条线程都有一个独立的程序计数器。程序计数器只占很小的一块内存空间。当线程正在执行一个java方法,程序计数器记录的是正在执行的jvm字节码指令的地址,如果正在执行的是一个native方法,那么程序计数器的值则为空(undefined). 程序计数器是唯一一个在jvm规范中没有规定任何oom的区域。

 

JVM执行引擎:

Java虚拟机相当于一台虚拟的“物理机”,这两种机器都有代码执行能力,其区别主要是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的。而JVM的执行引擎是自己实现的,因此程序员可以自行制定指令集和执行引擎的结构体系,因此能够执行那些不被硬件直接支持的指令集格式。

在JVM规范中制定了虚拟机字节码执行引擎的概念模型,这个模型称之为JVM执行引擎的统一外观。JVM实现中,可能会有两种的执行方式:解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码)。有些虚拟机只采用一种执行方式,有些则可能同时采用两种,甚至有可能包含几个不同级别的编译器执行引擎。

输入的是字节码文件、处理过程是等效字节码解析过程、输出的是执行结果。在这三点上每个JVM执行引擎都是一致的。

 

本地方法接口(JNI)

JAVA NATIVE INTERFACE: 提供了若干api实现java和其他语言的通信(主要是C和C++)

适用场景: 当我们有一些旧的库,已经使用C语言编写好了,如果要移植到java上来,非常浪费时间,而jni可以支持java程序与C语言编写的库进行交互,这要就不必要进行移植了。或者是与硬件,操作系统进行交互,提高程序的性能等,都可以使用JNI,需要注意的一点是需要保证本地代码能工作在任何java虚拟机环境。副作用:一旦使用JNI,java将失去两个优点,一个是不在跨平台,一个是程序不在绝对安全。

 

JAVA 常量池

jvm常量池也称之为运行时常量池,他是方法区的一部分,用于存放编译期间生成的各种字面量和符号引用。运行时常量池不要求一定只有在编译器产生的才能进入,运行期间也可以将常量放入池中,这种特性被开发人员利用的比较多的是String.intern()方法。

由“用于存放编译期间生成的各种字面量和符号引用” 这句话可见,常量池中存储的是对象的引用而不是对象的本身。

常量池的好处: 为了避免频繁的创建和销毁对象而影响系统性能,他也实现了对象的共享。

例如字符串常量池:在编译阶段就把所有的字符串文字放到一个常量池中。节省内存空间,节省运行时间。

 

GC-垃圾回收:

stop-the-world(stw): 他会在任何一种GC算法中发生。stw意味着jvm因为需要执行GC而停止了应用程序的执行。当stw发生时,出GC所需的线程外,所有的线程都进入等待状态,直到GC任务完成。GC优化的很多时候,就是减少stw的发生。

需要注意的是,jvm gc只回收堆和方法区内的对象,而栈区的数据,在超出作用域后会被jvm自动释放掉,所有其不再jvm gc的管理范围内。

jvm -gc 如何判断对象可以被回收了?

  • 对象没有应用

  • 作用域发生未捕获异常

  • 程序在作用域正常执行完毕

  • 程序执行了System.exit();

  • 程序发生意外终止(被杀线程等)

在java程序中不能显示的分配和注销缓存,因为这些事情jvm都帮我们做了,那就是GC.有些时候我们可以将相关对象设置成null来试图显示的清楚缓存,但是并不是设置成null就会一定被标记为可回收,有可能会发生逃逸。将对象设置成null至少没有什么坏处,但是使用System.gc()便不可取了,使用System.gc()的时候并不是马上执行GC操作,而是会等待一段时间,甚至不执行,而且System.gc()如果别执行,会出发Full GC,这费城影响性能。

 

GC什么时候执行:

eden区空间不够存放新对象的时候,执行minor gc。 升到年老代的对象大于老年代的剩余空间时执行full gc,或者小于的时候,被 HandlePromotionFailure 参数强制Full GC。 调优主要是减少Full GC 的触发次数,可以通过NewRatio 控制新生代转老年代的比例,通过MaxTurningThreshold 设置对象进入老年代的年龄阀值。

 

按代的垃圾回收机制:

新生代(Young generation):绝大多数的最新被创建的对象都会被分配到这里,由于大部分在创建后很快变得不可达,很多对象别创建在新生代,然后消失。对象从这个区域消失的过程,我们称之为 Minor GC

老年代(old generation): 对象没有变得不可达,并且从新生代周期中存活了下来,会被拷贝到这里。其区域分配的空间要比新生代多。也正是由于其相对较大的空间,发生在老年代的GC次数要比新生代少得多。对象从老年代消失的过程称之为: Major GC, 或者 Full GC.

持久代(Permanent generation):也称之为方法区,用于保存类常量以及字符串常量,注意,这个区域不是用于存储那些从老年代存活下来的对象,这个区域也可能发生GC, 发生在这个区域的GC事件也被算作Major GC,只不过在这个区域发生GC 的条件非诚严苛,必须符合以下三种条件:

  1. 所有实例被回收

  2. 加载该类的ClassLoader被回收

  3. Class 对象无法通过任何途径访问(包括反射)

     

    如果老年代要引用新生代的对象,会发生什么呢?

    为了解决这个问题,老年代中存在一个 card table ,它是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询 card table 来决定是否可以被回收,而不用查询整个老年代。这个 card table 由一个write barrier 来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。

默认的新生代和老年代所占空间的比例为1:2

 

新生代空间的扣成和逻辑:

分为三个部分: 一个伊甸园空间(eden), 两个幸存者空间)(From Survivor, To Survivor)默认比例: Eden:From:to = 8:1:1

每个空间执行顺序:

  1. 绝大多数刚刚被创建的对象会存放在伊甸园EDEN空间

  2. 在eden空间执行第一次gc(minor gc)后,存活的对象被移动到其中的一个幸存者区

  3. 此后,每次Eden空间执行gc后,存活的对象都会被堆积在同一个幸存者空间。

  4. 当一个幸存者空间饱和,还存在存活的对象会被移动到另一个幸存者空间,然后会清空已经饱和的那个幸存者空间

  5. 在以上步骤中重复N次(N=MAXTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会别移动到老年代

从上面的步骤可以发现,两个幸存者空间,必须有一个是保持空的,如果两个幸存者空间都有数据,或者两个都是空的,那一定是你的系统出现了某种错误。

我们需要重点记住的是,对象在刚刚被创建之后,是保存在Eden区的,哪些长期存活的对象会经由幸存者空间转到老年代空间。也有例外的情况,对于一些比较大的对象(需要分配连续比较大的空间)则直接进入到老年代,一般在幸存者空间不足的情况下发生。

 

老年代空间的构成与逻辑:

老年代空间的构成其实很简单,他不像新生代那样划分为几个区域,他只有一个区域,里面存储的对象并不像新生代空间绝大部分都是朝闻道,夕死矣。这里的对象几乎都是从Survivor空间中熬过来的,他们绝不会轻易狗带。因此FULL GC 发生的次数不会有minor gc那么频繁,并且做一次full gc的时间比minor gc要更长(约10倍)

 

GC算法:

 1. 根搜索算法(可达性分析): 从GCROOT开始,寻找对应的引用节点,找到这个节点后,继续寻找这个节点的引用节点。当所有的引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,及无用的节点。目前java中可以作为GCroot的对象有: 虚拟机栈中引用的对象(本地变量表),方法区中静态属性引用的对象,方法区中常量引用的对象,本地方法栈中引用的对象(native)

2. 标记-清除算法:

标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,在扫描整个空间中未标记的对象进行直接回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活的对象比较多的情况下极为高效,但是由于标记-清除算法直接回收不存活的对象,并没有对存活的对象进行整理,因此会导致内存碎片。

3. 复制算法:

复制算法将内存划分为两个区间,使用此算法时,所有的动态分配的对象都只能分配在其中一个区间,而另一个区间是闲置的。复制算法采用从根集合扫描,将存活对象复制到空闲区间,当扫描完毕活动区间后,会将活动区间一次性全部回收,此时原本的空闲区间变成了活动区间,下次gc的时候会重复刚才的操作,以此循环。复制算法在存活对象较少的时候,极为高效,但是带来的成本是牺牲一半的内存空间用于对象的移动,所以复制算法使用的场景,必须是对象的存活率非常低才行。

4. 标记-整理算法:

标记-整理算法采用和标记-清除算法一样的方式进行对象的标记,清除,但是在回收不存活对象占用的空间后,会见给所有的存活的对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题,

JVM 为了优化内存得回收,是用来分代回收的方式,对于新生代的内存回收,主要采用复制算法,而对于老年代的回收,大多采用标记整理算法。

 

垃圾回收器

需要注意的是,每一个回收器都存在stw的问题,只不过各个回收器在stw时间优化程度、算法的不同,可根据自身需求选择适合的回收器。

1.Serial(-XX: + UseSerialGC)

从名字可以看出,这是一个串行的垃圾回收器,这也是java虚拟机中最基本,历史最悠久的收集器,在jdk1.3之前是java虚拟机新生代收集器的唯一选择,目前也是ClientVM 下ServerVM4核4gb以下机器的默认垃圾回收器,Serial收集器并不是只能使用一个CPU进行收集,而是当jvm需要进行垃圾回收的时候,需暂停所有的用户线程,直到回收结束。

使用算法: 复制算法。

Serial收集器虽然是最老的,但是它对于限定单个CPU的环境来说,由于没有线程交互的开销,专心做垃圾收集,所以它在这种情况下是相对于其他收集器中最高效的。

2. SerialOld(-XX: + UseSerialGC)

SerialOld是Serial收集器的老年代收集器版本,它同样是一个单线程收集器,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

使用算法:标记 - 整理算法

3. ParNew(-XX: +UseParNewGC)

ParNew其实就是Serial收集器的多线程版本。除了Serial收集器外,只有它能与CMS收集器配合工作。

使用算法: 复制算法

ParNew 是许多运行在Server模式下的JVM的首选的新生代收集器,但是在单cpu的情况下,他的效率远远低于Serial收集器,所以一定要注意使用场景。

 

4. ParallelScavenge(-XX:+UseParallelGC)

ParallelScavenge又被称为吞吐量优先收集器,和ParNew 收集器类似,是一个新生代收集器

使用算法: 复制算法

ParallelScavenge收集器的目的是打到一个可控的吞吐量,所谓吞吐量就是cpu用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。如果虚拟机总共运行了100分钟,其中垃圾回收用了1分钟,那么吞吐量就是99%

所以这个收集器适合在后台运算而不需要很多交互的任务。接下来看看两个用于准备控制吞吐量的参数 1,-XX:MaxGCPauseMills(控制最大垃圾收集的时间) 设置一个大于0的毫秒数,收集器尽可能地保证内存回收不超过设定值。但是并不是设置地越小就越快。GC停顿时间缩短是以缩短吞吐量和新生代空间来换取的。 2,-XX:GCTimeRatio(设置吞吐量大小) 设置一个0-100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

 

5. ParallelOld(-XX: + UseParallelOldGC)

ParallelOld 是并行收集器,和SerialOld 一样,是一个老年代收集器,是老年代吞吐量优先的一个收集器,这个收集器在JDK1.6之后才开始提供的,再次之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge的整体速度,而ParallelOld出现了之后,吞吐量有限收集器才名副其实

使用算法: 标记-整理算法

在注重吞吐量与CPU数量大于1 的情况下,都可以优先考虑ParallelScavenge + ParallelOld收集器

6. CMS(-XX:UseConcMarkSweepGC)

CMS是一个老年代收集器,全称Concurrent Low Pause Collector, 是JDK1.4以后开始引用的心GC收集器,在jdk5,jdk6中得到了进一步的改进。他是对于响应时间的重要性需求大于吞吐量要求的收集器,对于要求服务器响应速度高的情况下,使用CMS非常合适。

CMS的一大特点,就是用两次短暂的暂定来代替串行或者并行标记整理算法时候的长暂停

使用算法:标记-清理

执行过程如下:

初始标记(STW initial mark):在这个阶段,需要虚拟机停顿在正在执行的应用线程,官方叫法叫做STW,这个过程从根对象扫描直接关联的对象,并做标记,这个过程会很快完成。

并发标记(Concurrent marking) :这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记,注意这里是并发标记,标识用户线程可以和GC线程一起并发执行,这个阶段不会暂停用户线程

并发预清理(Concurrent precleaning):这个阶段仍然是并发的,jvm查找正在执行并发标记阶段时候进入老年代的对象(可能这是会有对象从新生代晋升到老年代,或被分配到老年代)通过重新扫描,减少在一个阶段重新标记的工作,因为下一个阶段会stw

重新标记(stw remark): 这个阶段会再次暂停正在执行的应用线程,重新从根对象开始查找并标记并发阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致)并处理对象关联,这一次耗时回避“初始标记”更长,并且这个阶段可以并行标记。

并发清理(Concurrent sweeping): 这个阶段是并发的,应用程序和GC清理线程可以一起并发执行

并发重置(Concurrent reset):这个阶段仍然是并发的,重置CMS收集器的数据结构,等待下一次垃圾回收

CMS:缺点:

  1. 内存碎片;由于使用了标记-清理算法,导致内存空间中会产生内存碎片,不过CMS收集器做了一些小的优化,就是把未分配的空间汇总成一个列表,当有JVM需要分配内存空间的时候,会搜索这个列表找到符合条件的空间来存储这个对象,但是内存碎片的问题仍然存在,如果一个对象需要三块连续的空间来存储,因为内存碎片的问题,找不到这样的空间,就会导致full gc.

  2. 需要更多的CPU资源:由于使用了并发处理,很多情况下都是GC线程和用户线程并发执行的,这样就需要占用更多的CPU资源,也是牺牲了一定吞吐量的原因。

  3. 需要更大的堆空间:因为CMS标记阶段用用程序的线程还是执行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间,cms默认在老年代空间使用68%的时候启动垃圾回收,可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

 

7. garbageFirst(G1)

G1收集器是jdk1.7提供的一个新的收集器,是当今收集器技术发展的最前沿成果之一。G1是一款面向服务端应用的垃圾收集器,Hotspot开发团队赋予它的使命是未来可以替换掉cms.

G1具备以下特点:

  1. 并行与并发: G1能充分利用多CPU,多核心环境下的硬件优势,使用多个CPU来缩短STW停顿的时间,部分其他收集器原本需要停顿java线程执行的G1动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

  2. 分代收集:与其他收集器一样,分代概念在G1中仍然得以保留,虽然G1可以不需要其他收集器配合就能单独管理整个GC堆,但他能够采取不同的方式去处理新创建的对象和已经存活了一段时间。熬过多个gc的旧对象已获得更好的收集效果

  3. 空间整合:与CMS的标记-清除算法不同,G1收集器从整体上看是基于标记-整理算法实现的,从局部(两个region)上看是基于复制算法实现的,但无论如何,两种算法都意味着g1运行期间不会产生内存空间碎片,收集后能够提供规整的可用内存。这种特性有利于程序的长时间运行, 分配大对象时不会因为无法找到连续的内存空间而提前触发下一次GC

  4. 可预测的停顿:这是G1相比cms的另一大优势,降低停顿时间是G1和cms的共同关注点,但是G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时java(RTSJ)的垃圾收集器的特征了。

 

整理一下新生代和老年代的收集器。

新生代收集器:

Serial (-XX:+UseSerialGC)

ParNew(-XX:+UseParNewGC)

ParallelScavenge(-XX:+UseParallelGC)

G1 收集器

老年代收集器:

SerialOld(-XX:+UseSerialOldGC)

ParallelOld(-XX:+UseParallelOldGC)

CMS(-XX:+UseConcMarkSweepGC)

G1 收集器

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章