JVM内存结构及垃圾回收

JVM内存结构:

        Java 虚拟机的内存结构并不是官方的说法,在《Java 虚拟机规范》中用的是「运行时数据区」这个术语。但很多时候这个名词并不是很形象,再加上日积月累的习惯,我们都习惯用虚拟机内存结构这个说法了。

         根据《Java 虚拟机规范》中的说法,Java 虚拟机的内存结构可以分为公有私有两部分。公有指的是所有线程都共享的部分,指的是 Java 堆、方法区、常量池私有指的是每个线程的私有数据,包括:PC寄存器、Java 虚拟机栈、本地方法栈

JVM的内存结构大概分为:

        堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。gc主要管理的对象。除了存放对象的数据部分外,还包括有对象的其他信息(GC标志、存储对象Hash码等)。

       参数控制:-Xms设置堆的最小空间大小。-Xmx设置堆的最大空间大小。-XX:NewSize设置新生代最小空间大小。-XX:MaxNewSize设置新生代最大空间大小。

       方法区(Method Area):线程共享存储虚拟机加载的类信息、常量、静态变量、即编译器编译后的代码

方法区、永久代(PermGen)与元空间(Metaspace)
  之前我也在纳闷,有时候喊方法区,有时候喊永久代。学习之后,发现其实永久代的概念只存在于HotSpot虚拟机(Java6及以前)上,因为HotSpot的设计团队是用的永久代的方式来实现的方法区。同时,他们把GC分代收集的范围扩展到了方法区,这样在管理堆的时顺带就管理了方法区(省去了专门为方法区编写内存管理代码的工作,有点像偷懒的自己,哈哈哈),其他如IBM J9则没有。Java7中,还有“残留”的永久代概念:这个时候,方法区中的部分内容开始转移到Java堆中。从Java8开始,永久代的概念已经被取消了,采用元空间的概念。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过JVM参数(-XX:MetaspaceSize等)来指定元空间的大小

       该区域的内存回收目标主要是针对常量池的回收和对类型的卸载,其中类型卸载的条件非常苛刻。

       运行时常量池:属于方法区的一部分,存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。

        字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。

        符号引用则属于编译原理方面的概念,包含了下面三类常量:

        类和接口的全限定名(Full Qualified Name),例如:com/example/demo/Demo.class
        字段的名称和描述符(Descriptor),例如:Field a:[Ljava/lang/String
        方法的名称和描述符,例如:Method java/lang/String."":(Ljava/lang/String;)V

       可能发生的异常:当方法区无法满足内存分配的时候,抛出OutOfMemoryError。

       虚拟机栈(VM Stack):线程私有每个方法在执行的时候也会创建一个栈帧,存储了局部变量,操作数,动态链接,方法返回地址。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。

可能发生的异常

      StackOverflowError:一般的深度递归容易导致栈溢出,也即线程请求的栈深度大于了虚拟机所允许的深度

      OutOfMemoryError:因为虚拟机栈是可以动态扩展的,当某一次扩展时无法申请到足够的内存,就会抛出OOM。

       本机方法栈(Native Method Stack):线程私有。为虚拟机使用到的Native 方法服务。如Java使用c或者c++编写的接口服务时,代码在此区运行。

可能发生的异常
  与Java虚拟机栈一样,有StackOverflowError和OutOfMemoryError

  

       程序计数器(Program Counter Register):线程私有。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令

总结:

其中,关于堆和方法区的演变历程如下:

内存分配大致流程:

       通过整理之后,现在可以得出,大体上内存分配可以视为如下的一个样式:因为操作系统分给每个进程的内存是有限制的,假设虚拟机进程被分配到了有2GB的内存,除开提前设定好的Java堆最大内存(Xmx),再减去方法区的最大容量(MaxPem),再减去程序计数器占据的大小(很小,几乎可以忽略),再减去虚拟机进程本身耗费的内存,剩下的就由虚拟机栈和本地方法栈瓜分了,该进程的多个线程就会在这里面先后分配属于自己栈容量。

OOM

Java堆溢出(Java heap space)

原因一:内存泄漏(Memory Leak)
  指程序在申请内存后,无法释放已申请的内存空间,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。换句话,就是说存在很多个对象在GC分析可达性后到GC Roots一直存在引用链,无法被回收器回收,可以理解为这些对象是“尸位素餐”。

原因二:内存溢出(Memory Overflow)
      最为常见的OOM原因,当无法申请到足够的内存时,抛出内存溢出。

虚拟机栈和本地方法栈溢出
  通过书中前面的内容,在栈里面会出现两种异常(StackOverflowError和OutOfMemoryError),分别对应着请求栈深度太大时的异常与虚拟机扩展栈时内存不够的异常。但是实际在测试过程中,发现后者很难复现。即使模拟出现后者,也不知道是因为此时内存不足是不是因为虚拟机栈扩展导致的。

解决方法:
  一般来说,采用虚拟机默认的栈深度(1000~2000)时,是没有问题,但是当在多线程条件下的时候,容易导致虚拟机栈出现OOM。这个时候,如果没法减少线程数,也没法升级硬件的时候,只有通过设定参数减小最大堆以及减小栈容量(个人理解的是每个方法压入栈的帧大小)来换取更多线程了。

方法区和运行时常量池溢出

  运行时常量池就在方法区中,所以一般两个的异常是一起的。比如程序产生了大量的无法被回收的类后,会造成方法区的溢出(比如cglib的动态反射技术如果不注意类回收的话就容易发生这种情况,又或者是同一个类被很多不同的类加载器加载后也会生成不同的类,再比如大量的jsp文件在第一次运行的时候被编译成了大量的java类)。

直接内存溢出

        默认与java堆大小一致,可以通过-XX:MaxDirectMemorySize设定。

特征
  直接内存导致的内存溢出一个明显的特征是在Heap Dump文件中不会看见明显的异常,且一般dump文件比较小。如果遇到这种情况,项目中又有使用了NIO的话,可以考虑是直接内存溢出了。

Java堆和方法区的回收

  因为程序计数器、Java虚拟机栈和本地方法栈这三个的生命周期是跟随线程的,而且栈中每一个栈帧分配多少内存是在类结构确定的时候就已经定下来了(不要忘了在运行期的时候JIT编译期会针对这个进行一些优化),所以以上三个区域的内存回收基本上是板上钉钉的事情,因为方法结束或者线程结束的时候,内存就被回收了。比较难的是Java堆和方法区的内存回收,这也是GC活动的主要重点区域。

垃圾判断算法:

引用计数法

      给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。缺点:无法解决循环引用的问题

        A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但是它们三个对象却从未被其他对象引用,只有它们自身互相引用。从垃圾的判断思想来看,它们三个确实是不被其他对象引用的,但是此时它们的引用计数却不为零。这就是引用计数法存在的循环引用问题。

可达性分析算法
      通过GC ROOT的对象作为搜索起始点,通过引用向下搜索,所走过的路径称为引用链。通过对象是否有到达引用链的路径来判断对象是否可被回收。

        可作为GC ROOT的对象:虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象)。简单地说,GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。那么通过这些引用延伸到的对象,自然也是存活的。

       通过可达性算法,成功解决了引用计数所无法解决的循环依赖问题,只要你无法与GC Root建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于GC Root
    参考文档:JVM垃圾回收机制 - 简书

垃圾收集算法:

      标记-清除算法(Mark-Sweep)

       标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉,标记和清除的效率不高。

       标记和清除后产生大量不连续内存碎片,需要维护一个空闲列表。这样导致如果后续需要分配大内存时候找不到足够的连续内存会再次触发另外一次垃圾收集。

      在进行GC的时候,需要停止整个应用程序,导致用户体验差。

复制算法(Copying):

       核心思想: 将内存空间一分为二,每次只是用其中一块,在垃圾回收时,将还存活的对象复制到另一块内存空间,原来的内存空间全部清除。

  然后交换两块内存的角色。

       复制算法暴露了另一个问题,例如硬盘本来有500G,但却只能用200G,代价实在太高。

标记-整理(Mark-Compact):

执行过程:

  1,第一阶段和标记-清除算法一样,从根节点标记所有引用的对象,

  2,第二阶段将所有存活的对象压缩到内存的一侧,按顺序排放(使用指针碰撞为新对象分配内存空间)

  3,清理边界外的所有空间

  优缺点:

    优点:

      跟标记清除算法比: 没有内存碎片,新对象分配内存使用指针碰撞,不需要维护一个空闲列表

      跟复制算法比: 内存空间利用效率高

    缺点:

      效率上说,低于复制算法

      移动对象时,如果对象被其他对象引用,还需要调整引用的地址

      移动时必须停止所有用户线程(stop the world)

一般把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

        

        试想一下,如果我们单独采用任何一种算法,那么最终的垃圾回收效率都不会很好。其实 JVM 虚拟机的建造者们也是这么想的,因此在实际的垃圾回收算法中采用了分代算法。

分代收集算法:

       分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据对象存活周期的不同将内存划分为几块。

       在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

       在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或者标记-整理算法来进行回收。

        

        在这里我们再深入地聊一聊新生代里采取的垃圾回收算法。如我们上面所说,新生代的特点是存活对象少,适合采用复制算法。而复制算法的一种最简单实现便是折半内存使用,另一半备用。但实际上我们知道,在实际的 JVM 新生代划分中,却不是采用等分为两块内存的形式。而是分为:Eden 区域、from 区域、to 区域 这三个区域。那么为什么 JVM 最终要采用这种形式,而不用 50% 等分为两个内存块的方式?

        要解答这个问题,我们就需要先深入了解新生代对象的特点。根据IBM公司的研究表明,在新生代中的对象 98% 是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间。所以在HotSpot虚拟机中,JVM 将内存划分为一块较大的Eden空间和两块较小的Survivor空间,其大小占比是8:1:1。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Eden空间。

        通过这种方式,内存的空间利用率达到了90%,只有10%的空间是浪费掉了。而如果通过均分为两块内存,则其内存利用率只有 50%,两者利用率相差了将近一倍。


内存区域与回收策略:

      对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中(大对象直接分到老年代),分配的规则并不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象优先在Eden分配

       大多数情况下,对象会在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC。Minor GC相比Major GC更频繁,回收速度也更快。通过Minor GC之后,Eden区中绝大部分对象会被回收,而那些存活对象,将会送到Survivor的From区若From区空间不够,则直接进入Old区)。

Survivor

      Survivor区相当于是Eden区和Old区的一个缓冲,类似于我们交通灯中的黄灯。Survivor又分为2个区,一个是From区,一个是To区每次执行Minor GC,会将Eden区中存活的对象放到Survivor的From区,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。(From SurvivorTo Survivor的逻辑关系会发生颠倒: From变To , To变From,目的是保证有连续的空间存放对方,避免碎片化的发生)

Survivor区存在的意义

      如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次Minor GC没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor的存在意义就是减少被送到老年代的对象,进而减少Major GC的发生Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代

大对象直接进入老年代

       所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。

       虚拟机提供了一个XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用的是复制算法)。

长期存活的对象将进入老年代

       虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中(正常情况下对象会不断的在Survivor的From与To区之间移动),并且对象年龄设为1。对象在Survivor区中每经历一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 XX:MaxPretenuringThreshold 设置。

动态对象年龄判定

     为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到 MaxPretenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxPretenuringThreshold中要求的年龄。

内存回收策略参考文档:JVM垃圾回收机制 - 简书

        我们经常会听到许多垃圾回收的术语,例如:Minor GC、Major GC、Young GC、Old GC、Full GC、Stop-The-World 等。但这些 GC 术语到底指的是什么,它们之间的区别到底是什么?

Minor GC

        从年轻代空间回收内存被称为 Minor GC,有时候也称之为 Young GC。对于 Minor GC,你需要知道的一些点:

  • 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以 Eden 区越小,越频繁执行 Minor GC。
  • 当年轻代中的 Eden 区分配满的时候,年轻代中的部分对象会晋升到老年代,所以 Minor GC 后老年代的占用量通常会有所升高。
  • 质疑常规的认知,所有的 Minor GC 都会触发 Stop-The-World,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的,因为大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果情况相反,即 Eden 区大部分新生对象不符合 GC 条件(即他们不被垃圾回收器收集),那么 Minor GC 执行时暂停的时间将会长很多(因为他们要JVM要将他们复制到 Survivor 区或老年代)。

Major GC

        从老年代空间回收内存被称为 Major GC,有时候也称之为 Old GC

        许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。

        Minor GC 作用于年轻代,Major GC 作用于老年代。 分配对象内存时发现内存不够,触发 Minor GC。Minor GC 会将对象移到老年代中,如果此时老年代空间不够,那么触发 Major GC。因此才会说,许多 Major GC 是由 Minor GC 引起的。

Full GC

        Full GC 是清理整个堆空间 —— 包括年轻代、老年代和永久代(如果有的话)。因此 Full GC 可以说是 Minor GC 和 Major GC 的结合

Full GC触发条件:

        1、System.gc()方法的调用

        在代码中调用System.gc()方法会建议JVM进行Full GC,但是注意这只是建议,JVM执行不执行是另外一回事儿,不过在大多数情况下会增加Full GC的次数,导致系统性能下降,一般建议不要手动进行此方法的调用,可以通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc。

        2、老年代(Tenured Gen)空间不足

        在Survivor区域的对象满足晋升到老年代的条件时,晋升进入老年代的对象大小大于老年代的可用内存,这个时候会触发Full GC。

        3、Metaspace区内存达到阈值

       从JDK8开始,永久代(PermGen)的概念被废弃掉了,取而代之的是一个称为Metaspace的存储空间。Metaspace使用的是本地内存,而不是堆内存,也就是说在默认情况下Metaspace的大小只与本地内存大小有关。-XX:MetaspaceSize=21810376B(约为20.8MB)超过这个值就会引发Full GC,这个值不是固定的,是会随着JVM的运行进行动态调整的,与此相关的参数还有多个,详细情况请参考这篇文章jdk8 Metaspace 调优。

        4、堆中产生大对象超过阈值

        PretenureSizeThreshold进行设定,大对象或者长期存活的对象进入老年代,典型的大对象就是很长的字符串或者数组,它们在被创建后会直接进入老年代,虽然可能新生代中的Eden区域可以放置这个对象,在要放置的时候JVM如果发现老年代的空间不足时,会触发GC

        5、老年代连续空间不足

        JVM如果判断老年代没有做足够的连续空间来放置大对象,那么就会引起Full GC,例如老年代可用空间大小为200K,但不是连续的,连续内存只要100K,而晋升到老年代的对象大小为120K,由于120>100的连续空间,所以就会触发Full GC。

Stop-The-World

        Stop-The-World,中文一般翻译为全世界暂停,是指在进行垃圾回收时因为标记或清理的需要,必须让所有执行任务的线程停止执行任务,从而让垃圾回收线程回收垃圾的时间间隔。

        在 Stop-The-World 这段时间里,所有非垃圾回收线程都无法工作,都暂停下来。只有等到垃圾回收线程工作完成才可以继续工作。可以看出,Stop-The-World 时间的长短将关系到应用程序的响应时间,因此在 GC 过程中,Stop-The-World 的时间是一个非常重要的指标。

JVM垃圾回收器

        

参考博客:

Java编译原理--类加载过程_天道酬勤 地道酬善 人道酬诚-CSDN博客_类加载的过程

细嚼慢咽JVM-03 - 三斤的博客 | ThreeJin Blog

JVM基础系列第6讲:Java 虚拟机内存结构 - 陈树义 - 博客园

JVM基础系列第8讲:JVM 垃圾回收机制 - 陈树义 - 博客园

JVM基础系列第10讲:垃圾回收的几种类型 - 陈树义 - 博客园

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值