Java中的大魔王JVM -- 内存管理

上一篇文章跟大家聊过了JVM将class文件加载到内存的过程,那内存里面具体是怎么工作的,那今天继续了解下运行时数据区的原理。下图是我根据所了解到得知识绘制得JVM内存概况图:

由图可以看到JVM中运行时数据区主要用于存储在JVM运行过程中产生得数据,包括程序计数器、本地方法栈、虚拟机栈、堆、方法区。按照内存共享方式分的话,分为线程私有(程序计数器、本地方法栈、虚拟机栈)和内存共享(堆、方法区),此外其实还有一个是直接内存。那我们先大致认识下这几块的内容吧。

程序计数器

程序计数器的生命周期跟随线程,功能就是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果正在执行一个Java方法,计数器记录的就是正在执行的虚拟机字节码指令的地址,如果执行的是native方法,则计数器值为空(Undefined)。程序计数器是一块很小的内存,但却是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的区域(原因在于程序计数器存储的只是指令的地址,根本耗不了多少内存)。为什么要设置程序计数器呢?这个需要涉及到操作系统的内容了,CPU处理多个线程的时候,并不是一个一个处理,而是运用到一个时间片轮转机制,每个线程都会花一定时间去处理,然后轮询,有时候线程可能只是处理了一半,所以这时候就需要把处理到的位置(地址)保存起来,以供下次处理恢复使用)。

虚拟机栈

虚拟机栈的生命周期跟随线程,虚拟机栈是用来描述Java方法执行的内存模型。每个方法执行时都会创建一个栈帧,里面存储了局部变量表、操作数栈、动态链接、返回地址等信息。我们平时线程运行至少要执行一个main方法,因为main方法是所有程序运行的入口,那么虚拟机栈中至少会有一个栈帧。其中,局部变量表存放了编译器可知的各种基本数据类型(包括boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(执行一条字节码指令的地址)。而且这局部变量表所需的内存空间在编译期间就完成了分配,当执行到某个方法时,这个方法需要在帧中分配多大的局部变量空间是确定的不会在运行期间再改变。系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。操作数栈在一个方法刚执行的时候是空的,在方法执行过程中会不断地有各种字节码指令入栈和出栈。动态链接的场景是:在一个类中一个方法要调用其他方法,需要将该方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中。每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态链接。一个方法执行完成后有两种方式可以退出这个方法:正常退出或者异常退出,无论采用何种方式,都需要在退出后返回当调用该方法的位置,然后继续执行,所以栈帧中也保留了方法执行的返回地址。虚拟机栈可以设置大小的(-Xss  1024k) ,但是如果一个线程请求的栈深度超过了虚拟机栈所允许的深度(即执行的虚拟机栈深度大于虚拟机栈的深度),就会抛出StackOverFlowError(举个例子,递归调用里面没有设置结束条件)。而虚拟机栈动态扩展的时候如果无法申请到足够的内存,就会抛出OutOfMemoryError。

本地方法栈

本地方法栈大致的功能都是和虚拟机栈一样的,不同的地方在于,虚拟机栈是用于服务虚拟机执行Java方法,本地方法栈顾名思义就是服务于本地方法(Native)的。虚拟机中对本地方法栈的规范没有强制的规定,HotSpot虚拟机中把本地方法栈和虚拟机栈合二为一,当然,本地方法栈和虚拟机栈一样也会出现StackOverFlowError,OutOfMemoryError。

Java堆在虚拟机启动的时候就创建了,属于线程共享内存,是内存最大的一个区域。Java的主要功能是用来存放对象实例。(所有的对象实例以及数组都要在堆上分配)。既然内存大,那就需要认真的管理了。所以会涉及到很多的分代管理及回收以及空间划分等操作。这也是本文要重点展开的点,详细内容看下文。

方法区

属于线程共享的内存区域,用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,HotSpot虚拟机用永久代来实现方法区。这样JVM的垃圾回收器可以像管理Java堆一样来管理方法区内存。永久代的内存回收主要是针对常量池的回收和类的卸载。

对以上几个概念有所了解了之后,就开始今天的对JMM的探究之旅。

JMM(JVM Memory Model--JVM内存模型)

JVM运行时内存,一般主要就是指Java堆内存,堆内的内存分配大致如下图

给大家解释一下:堆内存主要分为新生代和老年代,其中新生代占总内存的1/3,老年代占总内存的2/3,而永久代只有很小的一块空间,永久代在Java8之后,就改称元数据了。新生代中又分为Eden区(内存占8/10)、SurvivorFrom区(内存占1/10)、SurvivorTo区(内存占1/10)。那么JVM内存是怎么工作的呢?当我们创建新的对象(不是大对象)的时候,会在Eden区分配内存,为什么内存是8/10大小呢,因为90%的新对象会在GC中被回收,那么还剩10%需要空间来储存,那为啥是8:1:1而不是9:1呢,后面给详解哈。刚刚说到新创建的对象都是放在Eden区,经过第一次GC(新生代中使用的是Minor GC)后,还存活的对象就被存到了SurvivorFrom区,这样一来,这些存活的对象他们的年龄就是1,当第二次GC来的时候,SurvivorFrom区内存活的对象就放到了SurvivorTo区,此时他们的年龄就变成了2,然后SurvivorTo区被标记成SurvivorFrom区,而原先的SurvivorFrom区被清空后,被标记成SurvivorTo区。(因为JVM会频繁创建对象,所以新生代中会频繁触发GC)这样往复,等到SurvivorTo区内的对象年龄达到了15,就被存到老年代中,老年代中GC的频率没那么高,所以对象存活的时间相对比较久。那这里刚刚提到了GC,是不是有必要对GC作一些了解?

GC,Garbage Collection 垃圾回收。这里的垃圾是指那些无用对象。那JVM怎么知道哪些对象有用哪些对象无用呢?有比较出名的几种算法可以判断:

一、引用计数法,给对象添加一个引用计数器,每当有一个地方引用该对象时,其引用计数器加1,失去引用时就减1,然后GC的时候判断引用计数是否为0,0的话就是无用对象,对其回收。这种算法效率很高,但是会有一个循环引用的问题,比如对象A持有B的引用,B又持有A的引用,这样子就没办法归零从而回收内存了。

二、可达性分析,通过一系列成为“GC Roots”的对象作为起始点,从这些节点开始(采用根搜索算法 GC Roots Tracing)向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,证明此对象时不可用的。(一般被标记2次以上不可用,就会被回收内存)。在 Java 中,有以下几种对象可以作为 GC Root:Java 虚拟机栈(局部变量表)中的引用的对象;方法区中静态引用指向的对象;仍处于存活状态中的线程对象;Native 方法中 JNI 引用的对象。那啥时候回收呢?这个根据不同的虚拟机而定,大部分情况下,堆中内存不够分配了或者开发者主动调用API都会触发垃圾回收。

无用对象找到了,那采用什么样的方法回收呢?

标记清除算法

第一个要聊的就是标记清除算法,标记回收算法总共分为两个阶段:Mark标记阶段,从GC Roots开始,找到所有不可达对象(前面有谈到)根据可达性分析标记对象是否要回收;Sweep清除阶段,将所有标记为无用的对象进行清除。该算法存在两个问题:效率较低且空间碎片化问题,因为如果被回收的对象在内存上并不是连续的内存空间,那么就会导致回收后的内存零碎不连续,不利于后续分配新的较大内存。

复制算法

复制算法将内存分为相等的两块区域(我们姑且称为A区和B区),就像前面聊到的新生代中的SurvivorFrom区和SurvivorTo区,每次只用其中的一块比如对象都储存在A区,然后GC的时候将可用对象都复制到B区,复制完了之后将A区内存全部清空。这样的话,效率相对高一些,也解决了内存碎片化的问题,但是当频繁创建对象的时候,肯定会频繁触发GC,少了一半的内存,运行起来相对比较吃力。所以后面HotSpot JVM采用的是新生代划分为Eden区、SurvivorFrom区、SurvivorTo区,按照8:1:1分配内存。虽然大部分数据表明,新生代中的对象有百分九十多都是朝生夕死,但是也不能完全确保每次GC只有不多于10%的对象存活,所以当Survivor区域的内存不够时,就需要借助其他内存(老年代)进行空间担保。这样子的话,当新生代中创建的对象现有的内存不够支持的话(大对象),就通过担保分配机制,直接存到老年代中去了。

标记整理算法

标记清除算法效率,而且存在内存碎片的问题,复制算法需要大的内存空间作担保,而且对象存活率高的时候,清除效率就不会很高,然后就出来了一个新的处理方式,标记整理算法总结了两者的优点,第一阶段在内存中的对象进行标记,然后第二阶段,将所有有用的对象都压缩到内存的一端(也有翻译成标记压缩算法),然后清理掉这一端边界以外的其他内存。

分代收集算法

复制算法和标记整理算法都有其合适的应用场景,所以JVM中将两者充分利用:新生代对象存活率不高,就采用复制算法,老年代生命周期较长,而且也时常会有大对象,故采用标记整理算法。或许有朋友会问,老年代内存那么大,对象那么多,如果有个老年代对象引用了新生代中的对象,那每次执行垃圾回收的时候都要查一遍老年代的对象,这效率会不会很低。为了解决这问题,JVM在老年代中设计了一个512byte的card table,保存了所有老年代引用新生代对象的信息,所以后面只要查表就行了。

除了新生代和老年代,在JVM中还有一个区域--永久代(JDK1.8之后称为元数据),永久代用来存储Class类、常量、方法描述等,永久代中主要需要回收的就是废弃的常量和无用的类。(当然了,永久代里面也会有垃圾回收)

分区收集算法

垃圾回收会造成系统系统一定时间范围内的停顿,如果一次性对整个堆内存进行检测回收,会造成系统长时间卡顿。为了缩短垃圾回收时的停顿时间,JVM采用了分区收集算法,将整个堆空间划分为连续的大小不同的小区域,然后对每个小区域单独进行内存使用和垃圾回收。每次都回收小区域的内存,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。

既然不同的内存区域设置了不同的垃圾回收算法,那么也会安排不同的垃圾收集器进行回收。JVM针对新生代和老年代提供了不同的垃圾收集器,其中新生代使用的垃圾收集器有Serial(单线程,复制算法)、ParNew(多线程,复制算法)、Parallel Scavenge(多线程,复制算法,提高了吞吐量);老年代使用的垃圾收集器有Serial Old(单线程,标记整理算法)、Parallel Old(多线程,标记整理算法)、CMS(多线程并发,标记清除算法),然后还有针对不同区域的G1垃圾收集器。

关于垃圾收集器,JVM的发展过程中,收集器也经过了不同时期的迭代更新,内容还是比较多的,这里就介绍一下最原始的收集器以及最新的收集器:

Serial收集器,历史最悠久的垃圾收集器,最大的特点就是“Stop the world”,Serial收集器是单线程工作的收集器,而且在回收垃圾的时候,所有工作线程都必须停止。虽然这个问题很令人头疼,但是它也有着优于其他收集器的地方,它相对于其他收集器的单线程相比,简单而高效,因为它没有线程交互的开销,专注于做垃圾收集从而获得最高的单线程收集效率,它目前依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。

Garbege First(G1)收集器

G1收集器采用了分区收集的算法,这里只是简单概括,其实内部运行原理远比这个复杂的多。其运作过程大致划分为四个步骤:

初始标记,标记下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时能正确在可用的Region中分配新对象;

并发标记,从GC Root开始堆堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象(耗时,并发执行),然后重新处理SATB记录下的在并发时有引用变动的对象;

最终标记,处理并发标记阶段结束后仍遗留下来的SATB记录;

筛选回收,更新Region统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可有选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。

到这里,JVM的内存管理差不多了解完了,其实这里很多地方都可以扩展出详细的知识点,笔者这里只是简单概括,更多详细内容可以查阅书籍和网上资料进行补充哈。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值