2018年第51周-JAVA虚拟机内存模型及垃圾回收机制(概要)

2次阅读

共计 7432 个字符,预计需要花费 19 分钟才能阅读完成。

JAVA 内存区域
运行时数据区域
根据《Java 虚拟机规范(Java SE 7 版)》的规定,Java 虚拟机管理的内存将会包括以下运行时数据区域:
1. 程序计数器 2.Java 虚拟机栈(在 HotSpot 虚拟机中本地方法栈和虚拟机栈合二为一)3.Java 堆 4. 方法区(包括运行时常量池),存放的是 Class 的相关信息和常量,又称“永生代”5. 直接内存(NIO 使用的)
程序计数器
程序计数器(Program Counter Register)是当前线程所执行的字节码的行号指示器。各个线程之间的计数器互不影响,所以这块内存是线程私有的内存。
Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有,生命周期与线程相同。是 Java 方法执行的内存模型,每个方法执行的同时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出等信息。每个方法从调用执行到执行完毕的过程,就是对应这栈帧的一次入栈和出栈过程。
方法区
方法区(Method Area)是线程共享的内存区域,用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 堆
这就是重头戏了,是 Java 虚拟机所管理的内存中最大的一块,也是所有线程共享的一块内存。此内存存放对象实例,所有也是垃圾回收的主要区域。采用分代收集算法,简单说就是分为新生代和老年代。
新生代又根据对象朝生暮死的特性,分为 Eden 空间、两块 Survivor 空间(From 空间和 To 空间)。From 空间和 To 空间是相对的,两者是两快大小相同的内存。新生代垃圾回收器采用复制算法(Copying)来回收新生代,新生的对象都会分配到 Eden 空间,当回收时,会将 Eden 空间和一块 Survivor 空间的存活对象,复制到一块 Survivor 空间(假设是 To 空间),此时 Eden 空间和 From 空间就会被直接清空,如果 To 空间不够分配内存给存活的空间,则存活的对象会直接进入老年代的内存空间。新生代的回收频率高,但每次回收的耗时短,这个回收过程称为 Minor GC。HotSpot 虚拟机默认 Eden 空间和一块 Survivor 空间是 8:1
老年代,Java 虚拟机管理的内存空间,除去新生代、虚拟机栈、方法区等内存,就是属于老年代。老年代其特点就是对象的存活率很高,甚至 100%,所以使用复制算法,会严重浪费内存。所以根据老年代的特点,首先标志所有需要回收的对象,在标记完成后,将所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。这就是标志 - 整理(Mark-Compact)算法,也有称为标志压缩法,其实是一个东西。老年代的回收频率低,但每次回收的耗时长,这个回收过程称为 Major GC,也粗略认为是 Full GC。
直接内存
直接内存(Direct Memory)并不是 Java 虚拟机运行时数据区的一部分。在 JDK1.4 引入了 NIO,引入了基于通道(Channel)和缓冲区(Buffer)的 I / O 方式,它可以使用 Native 函数直接分配堆外内存。其 GC 回收也是通过 Full GC 进行回收。
各区域的内存溢出情况
Java 堆的异常是:
// 可通过配置参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=../logs/dump.hprof 把虚拟机在出现内存溢出时 Dump 当前的内存堆转储存快照
java.lang.OutOfMemoryError: Java heap space
Java 虚拟机栈有两种异常分别是:StackOverflowError 异常
// 虚拟机配置 -Xss128k 来设置虚拟机栈内存容量
//stack 深度长度为:1000
Exception in thread “main” java.lang.StackOverflowError
at com.jc.vm.StackOverflowDemo.stackLeek(StackOverflowDemo.java:7)

OutOfMemoryError 异常
java.lang.OutOfMemoryError: unable to create new native thread
虚拟栈 OutOfMemoryError 异常情况需说明下:虚拟机提供了参数控制 Java 堆和方法区的这两部分的内存的最大值。而操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows 限制是 2GB,则 2GB 减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略。如果虚拟机进程本身耗费的内存不计算在内,则剩下的内存就是由 Java 虚拟机栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数就自然减少,建立线程时容就越容易把剩下的内存耗尽。如果是建立过多的线程导致的内存溢出,在不减少线程数或更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
方法区的异常是:能导致内存溢出,也就是常量变多或 Class 变多,可以使用 CGLib 动态创建类来造成方法区内存溢出
// 在 JDK7 及以下版本,我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制方法区大小
// 注:Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=10M; support was removed in 8.0
//Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=10M; support was removed in 8.0
//JDK8 已将参数 -XX:PermSize 和 -XX:MaxPermSize 移除了
Exception in thread “main” java.lang.OutOfMemoryError: PermGen space

直接内存的异常:直接内存是不受 Java 堆大小的限制,不过可以 JDK 参数 -XX: MaxDirectMemorySize(默认与 -Xmx 大小一样),如果,大于次参数的值,则会抛出 java.langOfMemoryError,实际没有真正向操作系统申请分配内存,而是通过计算得出内存无法分配。
对象的内存分配算法
指针碰撞(Bump the Pointer):这种分配算法指 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的放在一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是那个指针向空闲空间那边挪动一段与对象大小想等的距离。缺点是处理并发不好。空闲列表(Bump the Pointer):这种分配算法指虚拟机维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。缺点是造成碎片比较多
选择哪种分配算法,是根据 Java 堆是否规整来决定,所以不同垃圾收集器使用不同的分配算法:Serial、ParNew 等带 Compact 过程的收集器,采用的分配算法就是指针碰撞。CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。
对象访问方式
对象的访问方式分为使用句柄和直接指针(HotSpot 使用直接指针)。两个访问方式各有好处,使用句柄访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象移动时(垃圾回收)只需改变句柄中的数据指针即可。而直接指针访问方式的好处就是速度快,reference 直接指向数据,比使用句柄,节省了一次指针定位时间,由于对象的访问是非常频繁,所以能节省很多时间。
对象是否存活判断算法
引用计数(Reference Counting),此算法实现简单,判定效率高。如微软公司的 COM(Component Object Model)技术、使用 AcionScript3 的 FlashPlayer、Python 语言和 Squirrel 都使用引用计数算法进行内存管理。缺点,很难解决对象之间相互循环引用的问题。
可达性分析,此算法基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些检点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话:从 GC Roots 到这个对象不可达)时,则证明对象是不可用的。在 Java 语言中,可作为 GC Roots 的对象包括下面几种: 虚拟机栈中引用的对象。方法区中类静态属性引用的对象。方法区中常量引用的对象。本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
垃圾收集算法
标记 - 清除 (Mark-Sweep) 算法,此算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。缺点有两个:一是效率问题;另一个是空间问题,标记清楚后会产生大量不连续的内存碎片。复制 (Copy) 算法,是当前主流垃圾回收的思想(针对新生代的内存区域),此算法就将可用的内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存在的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。缺点是代价内存耗费比较多。但专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要 1:1 的比例来划分内存,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 默认 Eden 和 Survivor 的大小比例是 8:1,也就是新生代中可用内存空间为整个新生代容量的 90%,只有 10% 的内存会被“浪费”。当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion),所以这种垃圾收集算法如果不想浪费 50% 空间,就需要有额外的空间进行分配担保以应对所有对象都 100% 存活的极端情况,所以老年代一般不能直接选用这种算法。标记 - 整理 (Mark-Compact) 算法,这是根据老年代的特点提出一种算法,标记过程仍与“标记 - 清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法,此算法是根据对象存活周期的不同将内存划分为几块。一般的把 Java 堆分为新生代和老年代。在新生代中,每次垃圾收集时都发现大批对象死去,只有少量存活,那就是选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记 - 清理”或者“标记整理”算法进行回收。
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾回收器就是内存回收的具体实现。

图展示了 7 种作用不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。其中?是 G1 收集器
Serial 收集器(新生代)
这个收集器时一个单线程,独占式的收集器,它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。这是 JDK1.3 时之前新生代唯一的选择。参数 -XX:UseSerialGC,新生代使用 Serial 收集器,老年代使用 Serial Old 收集器。虚拟机在 Client 模式下,默认使用该收集器作为新生代收集器。使用复制算法。
ParNew 收集器(新生代)
ParNew 收集器其实就是 Serial 收集器的多线程版本。由于 CMS 作为老年代的收集器,无法与新生代的 PS 收集器配合工作。参数 -XX:+UseConcMarkSweepGC,新生代就是默认使用 ParNew 收集器,老年代使用 CMS 收集器。参数 -XX:+UseParNewGC,新生代使用 ParNew 收集器,老年代使用 Serial Old 收集器。需考虑 -XX:ParallelGCThreads 来控制垃圾收集的线程数。使用复制算法。
Parallel Scavenge 收集器(新生代)
PS 收集器跟 ParNew 收集器一样,都是多线程,独占式的收集器这个收集器与其他收集器关注点不同,CMS 等收集器关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 PS 收集器的目标则是达到一个可控制的吞吐量(Throughput)。吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。关注吞吐量的收集器适合后台运算。可控制吞吐量参数 -XX:+UseParallelGC,新生代使用 PS 收集器,老年代使用 Serial Old 收集器。参数 -XX:+UseParallelOldGC,新生代使用 PS 收集器,老年代使用 Parallel Old 收集器。使用复制算法。
Serial Old 收集器(老年代)
单线程,独占式收集器。在 Server 模式下,主要有两大用途:一种用途是 JDK5 以及之前的版本中于 PS 收集器搭配使用,另一种用途是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。虚拟机在 Client 模式下,默认使用该收集器作为老生代收集器。参数 -XX:UseSerialGC,新生代使用 Serial 收集器,老年代使用 Serial Old 收集器。参数 -XX:+UseParNewGC,新生代使用 ParNew 收集器,老年代使用 Serial Old 收集器。参数 -XX:+UseParallelGC,新生代使用 PS 收集器,老年代使用 Serial Old 收集器。使用标志压缩算法。
Parallel Old 收集器(老年代)
多线程,独占式收集器 JDK6 才提供的,在此之前 PS 收集器只能于 Serial Old 收集器搭配,效果并不明显,且无法和 CMS 搭配,所以才诞生 Parallel Old 收集器。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。参数 -XX:+UseParallelOldGC,新生代使用 PS 收集器,老年代使用 Parallel Old 收集器。使用标志压缩算法。
CMS 收集器(老年代)
多线程,整体上不是独占式收集器,只有初始标识和重新标志阶段是独占系统资源的。CMS(Concurrent Mark Sweep)收集器时一种以获取最短回收停顿时间为目标的收集器。简单的说,此收集器关注点是低停顿时间。是只有在初始标识和重新标志阶段是 STW,其他阶段是可以与应用线程一起执行的。从名字包含“Mark Sweep”就知道使用的收集算法是“标记——清除”算法。四步骤:1. 初始标记(CMS initial mark),需要 STW2. 并发标记(CMS concurrent mark)3. 重新标记(CMS remark),需要 STW4. 并发清除(CMS concurrent sweep)优点:并发收集、低停顿
目前很大一部分的 Java 应用集中在互联网站或者 B / S 系统的服务端上,这类应用尤其重视服务的响应时间,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常适合。如我所在过的一家互联网公司在 64 位的 jdk 情况下使用 CMS 收集器
缺点:1.CMS 收集器对 CPU 资源敏感 2.CMS 收集器无法处理浮动垃圾(Floating Garbage,指的是 CMS 在整个过程基本都不 STW,所以应用程序还在不断产生垃圾,这些垃圾称为浮动垃圾),则需通过 -XX:CMSInitiatingOccupancyFraction 来配置老年代内存达到多少时触发 CMS 收集。JDK5 默认收 68%,JDK6 默认是 92%。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集。所以 -XX:CMSInitiatingOccupancyFraction 配置越高,就很容易导致大量的“Concurrent Mode Failure”失败,性能反而降低。如我所在过的一家互联网公司在 64 位的 jdk 情况下使用 -XX:CMSInitiatingOccupancyFraction=70 3.CMS 是基于“标记——清除”算法实现的收集器,则会产生大量空间碎片。则需配置个参数 -XX:+UserCMSCompactAtFullCollection 来开启碎片整理。但依然会造成停顿时间太长,所以虚拟机设计者提供了 -XX:CMSFullGCsBeforeCompaction,来控制执行多少次不压缩的 Full GC 后进行带压缩的 Full GC,默认值为 0(表示每次进入 Full GC 时都进行碎片压缩)
参数 -XX:+UseConcMarkSweepGC,新生代就是默认使用 ParNew 收集器,老年代使用 CMS 收集器。使用标志清除算法。
G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一。葱 JDK 6u14 开始就有 Early Access 版本的 G1 收集器供开发人员实验、试用,由此开始 G1 收集器的“Experimental”状态持续了数年时间,直至 JDK 7u4,Sun 公司才认为它达到足够成熟的商用程度,移除了“Experimental”的标识。未来可以替换 JDK 1.5 中发布的 CMS 收集器。G1 具备一下特点:1. 并行并发 2. 分代收集 3. 空间整合, 分 Region 去处理,类似于并发的 map 4. 可预测的停顿
G1 收集器运作大致可分为以下步骤:1. 初始标记(Initial Marking)2. 并发标记(Concurrent Marking)3. 最终标记(Final Marking)4. 筛选标记(Live Data Counting and Evacuation)
附录
并行(Parallel): 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。并发(Concurrent): 指用户线程与垃圾收集线程同事执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
参考:https://bugs.java.com/bugdata…《深入理解 Java 虚拟机》《实战 Java 虚拟机》

正文完
 0