深入理解JVM

一、JVM 的组织结构

注意:方法区和堆是所有线程共享的内存区域,而 Java 栈、本地方法栈和程序计数器是运行时线程私有的内存区域。下面来看看各内存区域的作用:

  ① Java 堆(Heap)

对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放程序中new出来的所有对象实例,几乎所有的对象实例都在这里分配内存。 Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”。Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。堆内存由年轻代和老年代组成,而年轻代内存又被分成三部分,Eden 空间、From Survivor 空间、To Survivor 空间,默认情况下年轻代按照 8:1:1 的比例来分配。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

  ② 方法区(Method Area)

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 为了与 Java 堆区分开来,它有一个别名叫做 Non-Heap(非堆)。方法区也可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。垃圾收集行为在这个区域是比较少出现的,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载, 根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

  ③ 程序计数器(Program Counter Register)

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。简单点说,由于 Java 虚拟机的多线程中到底是哪个线程该时刻执行,最终是由每个线程通过抢占处理器CPU时间片来决定的,每个线程每次时间片用完,就需要记住该线程代码运行到哪一行的行号,因为多线程场景中势必会不停的进行切换,程序计数器就是记录对应执行的行号的。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

  ④ Java 虚拟机栈(JVM Stacks)

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。每个方法被执行的时候 都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在 Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常,当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。

  ⑤ 本地方法栈(Native Method Stacks)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是 Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

二、JVM 内存结构

JVM 内存结构主要有三大块:堆内存、方法区和栈

① 堆内存是 JVM 中最大的一块由年轻代和老年代组成,年轻代和老年代的比例为1:4(不同JVM版本比例可能不同),而年轻代内存又被分成三部分,Eden 空间、From Survivor 空间、To Survivor 空间,默认情况下年轻代按照 8:1:1 的比例来分配。

② 方法区存储类信息、常量、静态变量等数据,是线程共享的区域,为与 Java 堆区分,方法区还有一个别名 Non-Heap(非堆)

③ 栈又分为 Java 虚拟机栈和本地方法栈和程序计数器,主要用于方法的执行

  问题:JVM 新生代为何需要两个 Survivor 空间?

我们知道,目前主流的虚拟机实现都采用了分代收集的思想,把整个堆区划分为新生代和老年代;新生代又被划分成 Eden 空间、 From Survivor 和 To Survivor 三块区域。

看书的时候有个疑问,为什么非得是两个 Survivor 空间呢?要回答这个问题,其实等价于:为什么不是0个或1个 Survivor 空间?为什么2个 Survivor 空间可以达到要求?

  问题:为什么不是0个 Survivor 空间?

这个问题等价于:为什么需要 Survivor 空间。我们看看如果没有 Survivor 空间的话,垃圾收集将会怎样进行:一遍新生代 gc 过后,不管三七二十一,活着的对象全部进入老年代,即便它在接下来的几次 gc 过程中极有可能被回收掉。这样的话老年代很快被填满, Full GC 的频率大大增加。我们知道,老年代一般都会被规划成比新生代大很多,对它进行垃圾收集会消耗比较长的时间;如果收集的频率又很快的话,那就更糟糕了。基于这种考虑,虚拟机引进了“幸存区”的概念:如果对象在某次新生代 gc 之后任然存活,让它暂时进入幸存区;以后每熬过一次 gc ,让对象的年龄+1,直到其年龄达到某个设定的值(比如15岁), JVM 认为它很有可能是个“老不死的”对象,再呆在幸存区没有必要(而且老是在两个幸存区之间反复地复制也需要消耗资源),才会把它转移到老年代。

总之,设置 Survivor 空间的目的是让那些中等寿命的对象尽量在 Minor GC 时被干掉,最终在总体上减少虚拟机的垃圾收集过程对用户程序的影响。

  问题:为什么不是1个 Survivor 空间?

回答这个问题有一个前提,就是新生代一般都采用复制算法进行垃圾收集。原始的复制算法是把一块内存一分为二, gc 时把存活的对象从一块空间(From space)复制到另外一块空间(To space),再把原先的那块内存(From space)清理干净,最后调换 From space 和 To space 的逻辑角色(这样下一次 gc 的时候还可以按这样的方式进行)。

我们知道,在 HotSpot 虚拟机里, Eden 空间和 Survivor 空间默认的比例是 8:1 。我们来看看在只有一个 Survivor 空间的情况下,这个 8:1 会有什么问题。此处为了方便说明,我们假设新生代一共为 9 MB 。对象优先在 Eden 区分配,当 Eden 空间满 8 MB 时,触发第一次 Minor GC 。比如说有 0.5 MB 的对象存活,那这 0.5 MB 的对象将由 Eden 区向 Survivor 区复制。这次 Minor GC 过后, Eden 区被清理干净, Survivor 区被占用了 0.5 MB ,还剩 0.5 MB 。到这里一切都很美好,但问题马上就来了:从现在开始所有对象将会在这剩下的 0.5 MB 的空间上被分配,很快就会发现空间不足,于是只好触发下一次 Minor GC 。可以看出在这种情况下,当 Survivor 空间作为对象“出生地”的时候,很容易触发 Minor GC ,这种 8:1 的不对称分配不但没能在总体上降低 Minor GC 的频率,还会把 gc 的时间间隔搞得很不平均。把 Eden : Survivor 设成 1 : 1 也一样,每当对象总大小满 5 MB 的时候都必须触发一次 Minor GC ,唯一的变化是 gc 的时间间隔相对平均了。

上面的论述都是以“新生代使用复制算法”这个既定事实作为前提来讨论的。如果不是这样,比如说新生代采用“标记-清除”或者“标记-整理”算法来实现幸存对象的移动,好像确实是只需要一个 Survivor 就够了。至于主流的虚拟机实现为什么不考虑采用这种方式,我也不是很清楚,或许有实现难度、内存碎片或者执行效率方面的考虑吧。

  问题:为什么2个 Survivor 空间可以达到要求?

问题很清楚了,无论 Eden 和 Survivor 的比例怎么设置,在只有一个 Survivor 的情况下,总体上看在新生代空间满一半的时候就会触发一次 Minor GC 。那有没有提升的空间呢?比如说永远在新生代空间满 80% 的时候才触发 Minor GC ?

事实上是可以做到的:我们可以设两个 Survivor 空间( From Survivor 和 To Survivor )。比如,我们把 Eden : From Survivor : To Survivor 空间大小设成 8 : 1 : 1 ,对象总是在 Eden 区出生, From Survivor 保存当前的幸存对象, To Survivor 为空。一次 gc 发生后:

1)Eden 区活着的对象 + From Survivor 存储的对象被复制到 To Survivor ;

2)清空 Eden 和 From Survivor ;

3)颠倒 From Survivor 和 To Survivor 的逻辑关系: From 变 To , To 变 From 。

可以看出,只有在 Eden 空间快满的时候才会触发 Minor GC 。而 Eden 空间占新生代的绝大部分,所以 Minor GC 的频率得以降低。当然,使用两个 Survivor 这种方式我们也付出了一定的代价,如 10% 的空间浪费、复制对象的开销等。

三、如何通过参数来控制各区域的内存大小

-Xms 设置堆的最小空间大小 

-Xmx 设置堆的最大空间大小 

-XX:NewSize 设置新生代最小空间大小 

-XX:MaxNewSize 设置新生代最大空间大小 

-XX:PermSize 设置永久代最小空间大小 

-XX:MaxPermSize 设置永久代最大空间大小 

-Xss 设置每个线程的堆栈大小 

没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制,老年代空间大小=堆空间大小-年轻代空间大小。

四、JVM 垃圾回收

  ① 垃圾收集器的由来

垃圾收集 Garbage Collection 通常被称为“GC”,它诞生于 1960 年 MIT 的 Lisp 语言。JVM 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理。因此,我们的内存垃圾回收主要集中于 Java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。

  ② 垃圾收集器是什么?

GC 其实是一种自动的内存管理工具,其行为主要包括以下两部分:

    第一:在 Java 堆中,为新创建的对象分配空间 

    第二:在 Java 堆中,回收没用的对象占用的空间

  ③ 对象存活的判断

判断对象是否存活一般有两种方式:

(1)引用计数:每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法简单,缺点是无法解决对象相互循环引用的问题。

(2)可达性分析(Reachability Analysis):从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可达对象,此时判定为垃圾对象。

在 Java 语言中,GC Roots 包括: 

    1、虚拟机栈中引用的对象 

    2、方法区中类静态属性实体引用的对象 

    3、方法区中常量引用的对象 

    4、本地方法栈中 JNI 引用的对象

使用场景:由于循环引用的问题,一般采用跟踪(可达性分析)方法。

  ④ 垃圾回收算法

(1)标记-清除算法

    首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。 它的主要缺点有两个: 

     第一:标记和清除过程两个过程都需要对堆进行扫描,导致效率都不高 

     第二:另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

(2)复制算法

堆内存分为新生代和老年代,而新生代又分为Eden(新生区)、From Survivor 和 To Survivor 三部分,并且这三部分的默认比例为8:1:1,在程序运行过程中所有new创建的对象都是存储在新生代中的Eden新生区中,经谷歌测试数据统计,在Eden中产生的对象有 98% 以上的对象都只是被使用一次。当Eden新生区存储的对象数据达到80%时,就会触发一次 Minor GC垃圾回收,复制算法指的是将本次GC过滤出来幸存的对象,复制到From Survivor幸存区中,然后将Eden新生区清空,当下一次Eden新生区中再次达到80%时,就会再次进行 Minor GC垃圾回收,这次不仅对Eden新生区进行过滤出幸存的对象,还会对第一次结果幸存者放置的From Survivor幸存区进行过滤,然后将这两部分过滤出来的幸存对象复制到To Survivor幸存区中,然后将Eden新生区和From Survivor幸存区进行清空,然后颠倒 From Survivor 和 To Survivor 的逻辑关系,即 From 变 To , To 变 From 。

总结: 只有在 Eden 空间快满的时候才会触发 Minor GC ,而 Eden 空间占新生代的绝大部分,所以 Minor GC 的频率得以降低,并且不会产生大量的内存碎片。缺点:造成内存 10% 的空间浪费、复制对象的开销等。

(3)标记-整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低,对于老年代一般不能直接选用这种算法。对于老年代,提出了一个类似第一种标记-清除的方案,该方案在标记后,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉另一端边界以外的内存。

(4)分代收集算法

GC 分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。谷歌的测试表明: 98%以上的对象都只是被使用一次。分代收集(Generational Collection)算法,把 Java 堆分为新生代和老年代,这样就可以 根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,并且没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

五、垃圾收集器

  ① Serial 收集器 

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收,新生代复制算法、老年代标记-压缩,垃圾收集的过程中会 Stop The World(服务暂停)。使用参数:-XX:+UseSerialGC 来开启。

  ② ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本。新生代复制算法、老年代标记-压缩,参数控制:-XX:+UseParNewGC, -XX:ParallelGCThreads 限制线程数量

  ③ Parallel 收集器

Parallel Scavenge 收集器类似 ParNew 收集器,Parallel 收集器更关注系统的吞吐量。可以通过参数控制 GC 的时间不大于多少毫秒或者比例,新生代复制算法、老年代标记-压缩。参数控制:-XX:+UseParallelGC。 

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分 钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

  ④ CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用都集中在互联网站或 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器是基于“标记-清除”算法实现的,CMS 收集器的内存回收过程是与用户线程一起并发地执行。

优点:并发收集、低停顿 

缺点:产生大量空间碎片、并发阶段会降低吞吐量

参数控制: 

  -XX:+UseConcMarkSweepGC 使用 CMS 收集器 

  -XX:+ UseCMSCompactAtFullCollection Full GC 后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长 

  -XX:+CMSFullGCsBeforeCompaction 设置进行几次 Full GC 后,进行一次碎片整理 

  -XX:ParallelCMSThreads 设定 CMS 的线程数量(一般情况约等于可用 CPU 数量)

  ⑤ G1 收集器

G1 是目前技术发展的最前沿成果之一,HotSpot 开发团队赋予它的使命是未来可以替换掉 JDK1.5 中发布的 CMS 收集器。它将整个 Java 堆划分为多个大 小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region 的集合。G1 的新生代收集跟 ParNew 类似,当新生代占用达到一定比例的时候,开始出发收集。和 CMS 类似,G1 收集器收集老年代对象会有短暂停顿。

G1 与 CMS 对比有一下不同: 

1、分代: CMS 中,堆被分为 PermGen,YoungGen,OldGen;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域(region),在每个区域中,虽然也保留了新老代的概 念,但是收集器是以整个区域为单位收集的。 

2、算法: 相对于 CMS 的“标记-清理”算法,G1 会使用压缩算法,保证不产生多余的 碎片。收集阶段,G1 会将某个区域存活的对象拷贝的其他区域,然后将整个区域整个回收。 

3、停顿时间可控: 为了缩短停顿时间,G1 建立可预存停顿模型,这样在用户设置的停顿 时间范围内,G1 会选择适当的区域进行收集,确保停顿时间不超过用户指定时间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值