深入理解Java虚拟机阅读心得(三)

时间:2023-01-01 23:54:22

Java中提倡的自动内存管理最终可以归结为自动化的解决两个问题:

  1. 给对象分配内存
  2. 回收分配给对象的内存

 

先说说回收这一方面的两个主要知识点

一。垃圾收集算法

1.标记-清理算法

  首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象(适用老年代)

  两个缺陷:(1)效率问题,标记和清除两个过程效率都不高

       (2)空间问题,标记清除算法会产生大量不连续的内存空间碎片,导致无法分配较大对象

2.复制算法

  将可用内存按容量划分为等大小的两块,每次只使用其中的一块。清理时将还存活着的对象复制到另一块中,然后把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行回收。商业虚拟机中常用复制算收集新生代对象,但这种算法的代价内存使用率过低,为此对算法进行改进。

  将内存空间划分为8:1:1的三个内存区间(Hotspot虚拟机中默认比例),其中占8比率的是Eden区,另外两个是Survior区,每次使用Eden区和其中一个Survior区;清理时,将Eden区和Survior1区中还存活着的对象复制到Survior2区中,然后清理掉Eden区和Survior1区。此外,这里在复制转移对象时,当Survior2区的空间不够用时,会需要依赖其他内存(老年代)进行分配担保。

3.标记-整理算法

  首先标记处所有需要回收的对象,然后让所有存活的对象向一端移动,最后直接清理掉端边界以外的内存。(针对老年代特点)

4.分代收集算法

     根据对象存活周期的不同将内存划分为几块。一般分为新生代(年轻代)和老年代(年老代),然后根据各个年代特点采用适当的收集算法。

 

二。垃圾收集器

  书中提到了总共7种不同的垃圾收集器,其中3种适用于年轻代,3种适用于年老代。此外,G1收集器为两者通用

  (一)年轻代中的3种垃圾收集器(都是使用复制算法?):

  1.Serial收集器

    单线程的收集器(单线程,且在垃圾收集时必须暂停其他所有的工作线程,即回收停顿)。Client 场景下的默认新生代收集器。

     这种收集器简单高效,无线程交互开销,因此拥有最高的单线程收集效率;可以配合CMS或Serial Old使用

  2.ParNew收集器

     Serial收集器的多线程版本。Server模式下的虚拟机中首选的新生代收集器(因为只有Serial和ParNew能与CMS收集器配合)

     默认开启的收集线程数与CPU的数量相同;可以配合CMS或Serial Old使用

  3.Parallel Scavenge收集器

     多线程收集器。与其他收集器目的不同,该收集器的目标是达到一个可控制的吞吐量(Throughput).

     吞吐量即CPU运行用户代码的时间和CPU总消耗时间的比值。可以配合Parallel Old或Serial Old使用

 

  (二)年老代中的三种垃圾收集器:

  1.Serial Old收集器

     Serial收集器的老年代版本,单线程收集器,使用标记-整理算法

  2.Parallel Old收集器

     Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法

  3.CMS(Concurrent Markup Sweap)收集器

    是一种以获取最短回收停顿时间为目标的收集器。是基于标记-清理算法的。

    运行分为四个步骤:

      1.初始标记:标记 GC Roots 能直接关联到的对象,需要回收停顿(即此时为单线程模式)

      2.并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿

      3.重新标记:需要回收停顿。此阶段是为了修正并发标记期间因用户程序继续运作而导致的标记变化部分

      4.并发清除

    CMS有三个明显的缺点:

      1.对CPU资源非常敏感。CMS默认启动的回收线程数量数为 (CPU数量+3)/4;即当CPU数量较少时,会分出较多比率的运算能力去执行多线程,即会导致吞吐量过低

      2.无法处理浮动垃圾。即并发清理过程中用户线程运作产生的垃圾。当超过启动阈值时(JDK1.6,阈值为92%),会出现"Concurrent Model Failure"而导致另一次Full GC产生。

      3.收集结束时会产生大量不连续的内存空间碎片。因为CMS是基于 标记-清除 算法的垃圾收集器。

      

          G1(Garbage-First)收集器

      G1 可以直接对新生代和老年代一起回收。G1收集器中,它将整个Java堆划分成多个大小相等的独立区域(Region),此时新生代和老年代不在是物理隔离的了,都是一部分Region的集合。

       通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。从而可以有计划的在Java堆中进行全区域的垃圾收集,进而能够建立可预测的停顿时间模型。G1通过跟踪各个Region里垃圾堆的价值大小,在后台维护一个优先列表,每次优先回收价值最大的Region。通过使用Region划分内存空间和优先级的区域回收方式,保证G1收集器在有效时间内获得尽可能高的收集效率。此外,G1中的每个Region都有一个与之对应的Remembered Set用于记录其他Region以及其他收集器中对该Region中对象的引用以避免虚拟机在做可达性分析的时避免全堆扫描

      不计算维护Remembered Set的操作,大致步骤如下:

      1.初始标记:需要停顿

      2.并发标记

      3.最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。需要停顿,但可以(多个最终标记线程)并发执行

      4.筛选标记:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。可与用户程序一起并发执行,但停顿用户线程将大幅度提高收集效率。

      G1收集器的四个特点

      1.并发与并行:通过利用多个CPU来缩短回收停顿时间,需要停顿的GC动作,也可通过并发的方式让Java程序继续执行

      2.分代收集:分代概念依然保留,无需其他收集器配合即可采用不同方式处理不同的生存周期对象

      3.空间整合整体上看是基于 标记-整理 算法,局部上(两个Region)看是基于 复制 算法实现的。不会产生不连续的内存空间碎片

      4.可预测的停顿:除追求低停顿外,能建立可预测的停顿时间模型,并能让使用者明确指定一个长度为M毫秒的时间片段。

 

再说说Java中如何自动化解决这两个问题

三。内存分配和回收策略

   1.内存分配

    (1)对象优先在Eden区分配

      大多数情况下,对象在新生代Eden区分配,当Eden区内存空间不够时,将触发Minor GC

    (2)大对象直接进入老年代

      大对象指需要连续内存空间的对象,如很长的字符串和数组;经常出现大对象会提前触发垃圾收集以获取足够长的连续内存空间放置大对象,因此可以通过设置参数来让超过一定长度的大对象直接进入老年代。

    (3)长期存活的对象进入老年代

      为每个对象定义一个单独的年龄计数器,每当该对象经过一次Minor GC之后存活,从Eden区(或survivor1区)复制到Survivor2区,则年龄+1;当对象的年龄增长到一定程度则进入老年代(默认年龄大于15的进入老年代)。

    (4)动态判断对象年龄,满足一定条件进入老年代

      JVM并不是一定要年龄到达一定程度才能进入老年代。当Survivor区中,某一年龄段的所有对象的大小和超过Survivor区的一半时,该年龄的所有对象都直接进入老年代。

 

  2.回收策略

    Minor GC:回收新生代。由于新生代对象存活时间短,因此Minor GC会频繁执行,执行效率一般较快。  

    Full GC:回收老年代和新生代。由于老年代存活时间较长,因此Full GC很少执行,执行效率也比较慢。

    空间分配担保:

      进行Minor GC前需要先检查老年代的最大可用空间是否大于新生代所有对象的总空间,如果满足,则认为Minor GC是安全的。

      如果不满足,则虚拟机会查看是否允许担保;如果允许,就会查看老年代最大可用连续内存空间是否大于以前每次晋升到老年代的对象的平均大小,大于,则开始Minor GC;如果不允许或小于,则触发一次Full GC

      以HotSpot虚拟机为例,在Minor GC进行时,会将Eden区和Survivor1区的对象复制到Survivor2区中,若此时Survivor2区中的内存空间不足以放下所有的对象,此时会将多余的对象暂时存放在老年代区域。如果此时老年代区域不足以放置剩余对象,则会发生错误,并提前触发Full GC。

 

四。类加载

  1.类加载的流程

    类的生命周期为以下7个阶段

      加载--->验证--->准备--->解析--->初始化--->使用--->卸载

    其中类加载过程为以下5个阶段

      加载--->验证--->准备--->解析--->初始化

    加载:加载主要分为三个小步骤:

      (1)根据类的全限定路径获取定义该类的二进制字节流

      (2)将字节流的静态存储结构转换为方法区的运行时存储结构

      (3)生成类的class对象,当作方法区中各种数据操作的入口

      此外,获取二进制字节流的方式共有四种。

      (1)Zip包中读取,如Jar包等

      (2)网络中获取,如Applet

      (3)运行时生成,如动态代理技术,在 java.lang.reflect.Proxy中

      (4)由其他文件生成,如Jsp文件生成对应的Class类

      验证:检验字节流是否符合虚拟机规范,且是否会危及虚拟机的安全问题

    准备:对类变量(static修饰的)进行初始赋值,使用的是方法区内存。但这里的初始赋值并不是根据代码进行初始化(除非变量使用final修饰).如 static int i = 23中的i会被初始化为0,但 static final int i = 23 会初始化为23

    解析:将常量池中的符号引用替换为直接引用。这一步也可以发生在初始化阶段之后

    初始化:执行Java代码,根据Java代码对类进行初始化。

        父类静态变量->父类静态代码块->子类静态变量->子类静态代码块->父类成员变量->父类成员代码块->父类构造函数->子类成员变量->子类成员代码块->子类构造函数

 

  2.类加载器

    启动类加载器,对应加载JRE_HOME目录中的文件

    扩展类加载器,对应于系统变量路径

    应用程序类加载器,对应于用户类路径

 

    双亲委派模型:要求除了启动类加载器之外,其他的加载器都要有自己的父类加载器。这里的父子关系通过组合方式获得,而不是继承方式。

           启动类加载器 --- 扩展类加载器 --- 应用程序类加载器 --- 用户自定义类记载器

    工作流程:双亲委派模型中,子类加载器获得请求后,会将请求转发给其父类加载器(一层一层往上传,直到启动类加载器),只有当父类加载器无法加载完成时,子类加载器才会尝试加载

    优点:使得Java类随着它的类加载器一起具有一种带有优先级的关系,从而使得基础类得到统一。