vlambda博客
学习文章列表

笔记:“深入理解Java虚拟机” (一)

本文共计4036字,读完大约需要15分钟

这是《深入理解Java虚拟机 2》读书笔记的第一篇,主要记录JVM的数据区域划分、垃圾回收算法和主流垃圾回收器的特点。

内存管理机制

Java虚拟机在执行Java程序时,将其所管理的内存划分为几个不同的数据区域,每个区域有不同的用途,如下图:

程序计数器:

占用一块较小的内存区域,作用类似于CPU中的寄存器。

同理,在Java虚拟机中,字节码解释器工作时从程序计数器中获取需要执行的字节码命令,程序的分支、循环、跳转均依赖这个过程。

在Java虚拟机中,多线程是通过各个线程轮流切换、获取CPU资源来实现的。因此,在某个确定的时间,一个CPU内核只会执行一个线程中的指令。为了不同的线程不断切换之后,仍然能回到正确的执行位置,需要每个线程有自己独立的程序计数器进行标记。这类内存区域也称为“线程私有”内存。生命周期和线程同步。

程序计数器中存储的数据所占空间大小,不随着程序执行而变化,因此该内存区域不会发生溢出(OutOfMemoryError)。


虚拟机栈:

线程私有内存,声明周期和线程同步,是Java方法执行的内存模型。

因此,线程当前执行的方法对应的栈帧必位于栈的顶端;在线程请求中,如递归调用,若请求的栈深度大于虚拟机所允许的深度,就会出现栈溢出错误StackOverFlowError;如果虚拟机栈在进行动态拓展时,无法申请到足够的内存,就会出现OutOfMemoryError错误。

栈帧中存储的各部分名称 栈帧存储的各部分作用
局部变量表 存储局部变量(包括方法中声明的非静态变量和函数形参);对于基本数据类型变量,直接存储其值;对于引用类型变量,存储指向对象的引用(地址)。在编译期间,局部变量表所需内存空间已完成分配,当程序执行时,其内存空间不变
操作数栈 操作数栈在建立时为空,在执行方法时存放由局部变量表或全局变量产生的数据,以及运算结果。由于局部变量表内存空间在编译器已确定,则操作数栈空间也随之确定
方法返回地址 一个方法调用完成后,需要返回到初始调用它的地方,这需要在栈帧中保存方法返回地址
指向运行时常量池的引用 指向运行时常量


本地方法栈:

本地方法栈作用和虚拟机栈作用类似,也会抛出StackOverFlowErrorOutOfMemoryError 异常,区别是:虚拟机栈执行Java方法(字节码)服务,本地方法栈执行Native方法服务。

由于《Java虚拟机规范》中未对本地方法栈中的方法使用语言、方式进行强制规定,故不同的虚拟机对本地方法栈的实现方式各不相同,甚至可以将本地方法栈和虚拟机栈合并(如 Hot-Spot VM)。


堆:

堆内存区域被所有线程共享,在虚拟机启动时创建。作用是:存放对象实例,为几乎所有的对象实例分配内存。

Java堆是垃圾收集器管理的主要区域,也称“GC堆”;收集器通常采用分代收集算法,因此堆可以分为:新生代和老年代。其中新生代约占1/3的堆空间,老年代约占2/3的堆空间。

新生代是机会所有Java对象产生的地方,为了标记方便,新生代还可以分为EdenFrom SurvivorTo Survivor区。两个Survivor区大小相同,便于进行复制算法,其中一块Survivor区(假定为From Survivor区)和Eden区一起作为活动区,经过一次Minor GC后,将尚存活的对象复制到To Survior区,年龄标记为1,并清除两个活动区。之后,每发生一次Minor GC,To Survivor区的对象还存活,就将年龄+1。当到达设定的某个值之后(默认是15),对象就会进入老年代。

Java中的大对象(需要大量连续内存空间的对象),如长字符串、长数组,往往会直接放入老年代,避免在新生代的Eden区和两个Survivor区之间发生大量内存复制,造成资源消耗


方法区:

方法区内存区域被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译器编译后的代码。

由于HotSpot虚拟机将GC分代收集拓展至方法区,故该虚拟机设计中方法区也称为“永久代”。

  • 运行时常量池:当类和接口被加载到虚拟机中,对应的运行时常量池就会创建,用于存放编译期间生成的各种字面量符号引用;运行期间也可将新的常量放入池中

    • 字面量:文本字符串、八种基本类型的值以及被声明为final的常量,比如int a = 1;String b = s;中的as

    • 符号引用:类和方法的全限定名,字段的名称和描述符,方法的名称和描述符

即便被称为永久代,在方法区也会发生内存回收,回收内容包括废弃常量和无用的类。

  • 废弃常量:如字符串abc在常量池中,但没有任何一个String对象引用该常量,则其就为废弃常量

  • 无用的类:Java堆中不存在该类的任何实例(即实例都被回收),加载该类的类加载器也被回收,该类对应的java.lang.class也被回收,即无法通过反射获得该类;满足这三个条件,才是无用类


直接内存:

直接内存不是虚拟机运行时数据区的一部分,Java虚拟机规范也未作出定义,但该部分内存会在程序运行时多次使用到。

JDK1.4之后引入了NIO类,提供了DirectBuffer类,继承自ByteBuffer类。不同的是,普通的ByteBuffer需要通过JVM堆分配内存,内存大小收到最大堆内存容量的限制;DirectBuffer直接分配在物理内存空间中,不经过JVM,内存大小受系统内存容量限制。

也正是基于此,在具体服务器上配置JVM参数时,和内存相关的-Xmx等参数,要为直接内存预留一部分空间。


垃圾收集机制

引用:

1.2之后,Java将引用分为了强引用、软引用、弱引用、虚引用:

  • 强引用:最普遍,如Object o=new Object();,只要强引用还在,垃圾收集器就一直不会回收被引用的对象,哪怕会由此导致内存溢出OutOfMemoryError

  • 软引用:用于描述有用但并非必须的对象,对于软引用的对象,在即将发生内存溢出之前,JVM会对它们进行第二次回收

  • 弱引用:较软引用更弱,其关联的对象只能存活到第二次回收之前

  • 虚引用:最弱的一种,其关联的对象生存时间不会受虚引用自身影响


判断对象是否需要回收

引用计数算法:

引用计数算法是一种经常使用的垃圾收集算法,但由于难以解决对象之间的循环引用问题,故主流的Java虚拟机并未采用该算法。(Python、Squirrel等脚本语言使用该法,至于它们如何解决循环引用,此处略)

实现原理:为每个对象分配一个计数器,当有一个地方引用它时,就+1,引用失效时就-1;则计数器为0的对象就是无效对象,可以回收


可达性分析算法:

主流的商用语言,如Java,C#常用此法判断对象是否存活。

实现原理:一个类似于树的结构,以一个GC Roots对象为根节点,向下搜索,其所经过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连接,就可以被回收。

在Java中可以被回收的对象有:

  • 虚拟机栈中引用的对象

  • 方法区中类静态属性引用的变量

  • 方法去中常量引用的对象

  • 本地方法栈中 Native方法引用的对象


垃圾收集算法

标记-清除算法:

标记出需要回收的对象,然后统一回收所有被标记的对象。

不足:

  • 标记-清楚之后会产生大量的不连续的内存碎片,过于碎片化会导致之后程序在运行时无法为大对象分配连续的内存空间

  • 效率问题


复制算法:

将内存按容量大小分相等的两块,一次先紧一块用,该块用完时,将尚存活的对象复制到另一块,然后在再清除前面的那块;解决了内存碎片问题

不足:使得可使用内存缩小到了1/2


标记-整理算法:

从标记-清除算法进化而来,在后续步骤中,先让所有存活对象都移动到内存一端,然后清空该边界以外的空间


分代收集算法:

将Java堆内存划分为新生代和老年代,对于每次会“死亡”大量对象的新生代,采用复制算法;对于“存活率”高的老年代,使用标记-整理算法


垃圾收集器

JDK1.7之后的Hot-Spot虚拟机正式提供了G1收集器,而在JDK11之后又新加入了Epsilon GC和ZGC两个收集器

垃圾收集的并发和并行:

并行:多条垃圾收集线程同时工作,用户线程处于等待状态

并发:用户线程和垃圾收集线程同时执行(交替或并行),用户程序继续运行,垃圾收集程序运行在另一CPU

吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间)

Minor GC 和 Full GC / Major GC

前者指发生在新生代的垃圾收集动作,使用更频繁,回收速度较快

后者指发生在老年代的垃圾收集动作,出现一次Full GC,至少会发生一次Minor GC,一般速度较慢


Serial收集器:

Jvm client模式下默认的新生代收集器,单线程执行,使用复制算法,它在进行垃圾收集时,必须暂停其他所有的工作线程(用户线程),适用于用户的桌面应用场景(Client)

ParNew收集器:

可视为Serial收集器的多线程版本,除多线程外,其控制参数、收集算法、回收策略均和前者相同,是服务器模式下首选的新生代收集器;在多核CPU的情况下拥有更好的效果

Parallel Scavenge收集器:

并行多线程的新生代收集器,使用复制算法,和ParNew的不同在于,其更关注吞吐量(其他收集器更关注垃圾收集时用户线程的停顿时间)。停顿时间越短用户体验越好,吞吐量越高CPU的利用效率越高,程序运算越快

Serial Old收集器:

Serial收集器的老年代版本,单线程执行,使用标记-整理算法,主要适用于Client模式

Parallel Old收集器:

Parallel Scavenge的老年代版本,多线程执行,使用标记-整理算法;在注重吞吐量的场景中,常使用Parallel Scavenge + Parallel Old的搭配

CMS收集器:

针对老年代,使用标记-清除算法,和用户线程并发执行,更关注用户线程的停顿时间,用户体验较好

由于并发对CPU 资源的消耗,CMS收集器对CPU资源敏感

G1收集器:

有分代收集的概念,但可以独立完成整个GC回收,整体上使用标记-整理算法,适用于服务器应用


总结

  1. JVM的运行时数据区域分为:线程共享的堆和方法区,线程隔离的虚拟机栈、本地方法栈和程序计数器。重点关注堆和虚拟机栈,前者为对象分配内存,后者主管方法执行

  2. 主要垃圾回收算法有:标记-清除算法、标记-整理算法,复制算法和分代回收算法。针对内存区域中的不同划分,不同的垃圾回收器会采用不同的回收算法。各回收算法之间可以相互协同。

  3. 垃圾回收器的比较总结见下表:

收集器名称 执行方式 负责分代 主要算法 关注点 备注
Serial 串行 新生代 复制算法 响应速度 单线程
Serial Old 串行 老年代 标记-整理 响应速度 serial的老年代版本
ParNew 并行 新生代 复制算法 响应速度 serial的多线程版本
Parallel Scavenge 并行 新生代 复制算法 吞吐量 适用后台运算
Parallel Old 并行 老年代 标记-整理 吞吐量 常搭配parallel scavenge使用
CMS 并发 老年代 标记-清除 响应速度 对CPU敏感
G1 并发 整体 标记-整理为主,复制算法为辅 响应速度 整体回收


参考链接

  1. 《深入理解Java虚拟机:JVM高级特性和最佳实践》(第2版)·周志明