1. 内存模型
JDK 8之前
JDK 8
线程私有 | 线程共享 |
---|---|
程序计数器,虚拟机栈,本地方法栈 | 堆,方法区,元空间,直接内存(非运行时数据区的一部分) |
1.1 程序计数器
程序计数器一块较小的内存空间,并且是唯一一个不会出现 OutOfMemoryError 的内存区域。
作用
- 帮助字节码解释器实现代码的流程控制。
程序计数器可看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。 - 记录当前线程执行的位置。
帮助线程在上下文切换后能恢复到正确的执行位置。每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,所以程序计数器是线程私有的。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2 虚拟机栈
虚拟机栈描述的是 Java 方法执行的内存模型。虚拟机栈由一个个栈帧组成,每个栈帧中由局部变量表、操作数栈、动态链接、方法返回地址和一些额外附加信息组成。
方法调用的数据都是通过栈传递的。每次方法调用都会有一个对应的栈帧被压入Java栈,每一个函数调用结束后,都会有一个栈帧被弹出。虚拟机栈也是线程私有的,每个线程都有各自的虚拟机栈。它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
局部变量表
存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
操作数栈
方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程的动态链接。
运行时常量池存中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。
静态解析:一部分在类加载阶段或第一次使用的时候转化为直接引用。
动态链接:一部分在每一次的运行期间转化为直接引用。
方法出口信息
方法有正常和异常两个退出方式。无论何种退出都需要返回方法调用的位置,方法返回是可能需要在栈帧中保存一些信息,用来帮助他恢复它的上层方法的执行状态。
正常退出,调用者的程序计数器的值就可以作为返回地址,栈帧可能保存该计数器值;异常退出,返回地址通过异常处理表确定,栈帧一般不存。
附加信息
添加一些不在规范中的信息保存到栈帧中。
异常
- StackOverFlowError:若虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。
- OutOfMemoryError:若虚拟机栈的内存大小允许动态扩展,当线程请求栈时内存用完了,无法再动态扩展了,就抛出 OutOfMemoryError 异常。
1.3 本地方法栈
本地方法栈和虚拟机栈所发挥的作用非常相似,区别在于虚拟机栈为虚拟机执行 Java 方法 (字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
1.4 堆
堆是 JVM 所管理的内存中最大的一块,是所有线程共享的一块内存区域,它在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以堆可以分为:新生代和老年代。新生代还可以再细分为:Eden空间、From Survivor和To Survivor空间。进一步划分空间的目的是更好的回收内存和更快的分配内存。
1.5 方法区(元空间)
方法区与堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译器编译后的代码等数据。
运行时常量池
运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息。运行时常量池是方法区的一部分,所以受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
变化过程
- JDK 7 之前:运行时常量池(包含字符串常量池)存放在方法区,此时hotspot虚拟机对方法区的实现为永久代。
- JDK 7:字符串常量池从方法区移到堆中,运行时常量池还在方法区。
- JDK 8:方法区被去掉,在直接内存中开辟了一个内存空间为元空间,其功能和方法区一样。字符串常量池还在堆中。
方法区替换为元空间的目的
为了使内存不受限。永久代(方法区)在 JVM 本身需要设置固定大小后才能上线,无法进行调整。而元空间使用的是直接内存,受本机可用内存的限制。参数 -XX:MaxMetaspaceSize
可设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。
1.6 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。本机直接内存的分配不会受到堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
2. 类加载器
JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader。
类加载器 | 介绍 |
---|---|
BootstrapClassLoader(启动类加载器) | 最顶层的加载类,由C++实现。负责加载 %JAVA_HOME%/lib 目录下的jar包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。 |
ExtensionClassLoader(扩展类加载器) | 主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。 |
AppClassLoader(应用程序类加载器) | 面向用户的加载器,负责加载当前应用 classpath 下的所有jar包和类。 |
双亲委派模型
每一个类都有一个对应它的类加载器,系统中的类加载器在协同工作的时候会默认使用双亲委派模型 。双亲委派模型指自底向上检查类是否被加载,再自顶向下尝试加载类。
在类加载的时候,系统会先判断当前类是否被加载过,已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
好处
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。
- 避免类的重复加载。相同的类文件被不同的类加载器加载产生的是两个不同的类。
- 保证 Java 的核心 API 不被篡改。如果没有使用双亲委派模型的话,当我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。
破坏双亲委派模型
自己定义一个类加载器(继承 java.lang.ClassLoader),然后重载 loadClass() 即可。
类隔离
因为在Java中不同类加载器加载的类在JVM看来是两个不同的类,在JVM中一个类的唯一标识是类加载器+类名。所以让每个模块使用独立的类加载器加载,这样不同模块之间的依赖就不会互相影响。
类加载传导规则:JVM 会选择当前类的类加载器来加载所有该类的引用的类。通过这种方式,只要让模块的 main 方法类使用不同的类加载器加载,那么每个模块的都会使用 main 方法类的类加载器加载的,这样就能让多个模块分别使用不同类加载器。
JVM 在触发类加载时调用的是 ClassLoader.loadClass 方法。通过自定义类加载器破坏双亲委派机制,然后利用类加载传导规则实现了不同模块的类隔离。
3. 类加载过程
加载
加载主要做三件事:
- 通过全类名获取定义此类的二进制字节流;
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构;
- 在内存中创建一个代表该类的 Class 对象,作为方法区这些数据的访问入口。
虚拟机规范并不具体,是很非常灵活的。“通过全类名获取定义此类的二进制字节流” 并没有指明具体从哪里获取、怎样获取。比较常见的就是从 ZIP 包中读取(日后出现的JAR、EAR、WAR格式的基础)和其他文件生成(典型应用就是JSP)等等。
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。
加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
验证
验证是检查类的二进制表示形式并验证生成的.class文件是否有效,确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
准备是为类或接口级别的静态变量分配内存和默认值。
解析
解析是将符号引用与方法区中的原始内存引用进行更改的过程,将常量池内的符号引用替换为直接引用。
符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量(如全类名),只要能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果是直接引用,那引用的目标必定已经在内存中存在。
初始化
初始化是类加载的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。在该阶段中,所有静态变量都被分配了原始值,并且静态块从父类执行到子类。到了初始化阶段,才真正执行类中的定义的Java程序代码(或者说是字节码)。
顺序:基类静态成员,基类静态块,派生类静态成员,派生类静态块。
4. 对象的创建过程
类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。
分配方式有指针碰撞和空闲列表两种,选择哪种分配方式由堆是否规整决定。而堆内存是否规整,取决于 GC 收集器的算法是标记-清除还是标记-整理,复制算法的内存也是规整的。
内存分配并发问题
在创建对象的时候也需要保证线程安全。因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的。虚拟机采用两种方式来保证线程安全:
1.CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁并假设没有冲突的去完成某项操作,如果冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
2.TLAB: 为每一个线程预先在堆的Eden区分配一块内存。JVM 在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用CAS+失败重试进行内存分配。
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码和对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始。
Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到 init 方法中,收敛顺序为:
父类成员变量
父类构造块
父类构造函数
子类成员变量
子类构造块
子类构造函数
初始化顺序:
基类静态成员,基类静态块,派生类静态成员,派生类静态块;(类加载过程中完成)
基类成员变量,基本构造块,基类构造函数,派生类成员变量,派生类构造块,派生类构造函数。(执行 init 方法中完成)
5. 对象访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种。
句柄
在堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了到对象实例数据的指针和到对象类型数据的指针。
直接指针
堆中保存对象实例数据的时候,还会保存到对象类型数据的指针。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
6. 对象存活
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
6.1 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1。任何时候计数器为0的对象就是不可能再被使用的。
Java没有采用引用计数算法来管理内存,原因是它很难解决对象之间相互循环引用的问题。
6.2 可达性分析算法
基本思想是通过一系列的称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
通过可达性算法,成功解决了引用计数所无法解决的“循环依赖”的问题,只要你无法与 GC Root 建立直接或间接的连接,系统就会判定你为可回收对象。
在 Java 语言中,可作为 GC Root 的对象包括以下4种:
- 虚拟机栈的栈帧中的局部变量表中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Java Native Interface) 引用的对象。
6.3 强引用,软引用,弱引用,虚引用
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
JDK1.2之前,Java中引用的定义很传统:如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。JDK1.2以后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种(引用强度逐渐减弱)。
强引用 StrongReference
大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那么垃圾回收器绝不会回收它。当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象。
软引用 SoftReference
一个对象具有软引用,如果内存空间足够,垃圾回收器就不会回收它。如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中。
弱引用 WeakReference
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,JVM 就会把这个弱引用加入到与之关联的引用队列中。
虚引用 PhantomReference
虚引用与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生。
7. 垃圾收集
堆是垃圾收集器管理的主要区域,但是方法区也会进行垃圾收集,主要回收两部分内容:废弃常量,无用的类。
废弃常量
假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量。如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池。
无用的类
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收。这里说的仅仅是可以,而并不是和对象一样不使用了就会必然被回收。
7.1 垃圾收集算法
7.1.1 标记-清除算法
标记-清除(Mark-Sweep)是最基础的一种垃圾回收算法,算法分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,回收时会把对象作为分块,连接到空闲链表中。新对象的分配通过空闲链表实现。
标记-清除存在效率和空间(标记清除后会产生大量不连续的碎片)上的问题。
- 内存碎片。大量的内存碎片会导致大对象分配时可能失败,从而提前触发了另一次垃圾回收动作。
- 访问局部性差。具有引用关系的对象可能会被分配在堆中较远的位置,这会增加程序访问所需的时间。
标记阶段有两种方式:
1.对象头标记:标记对象头,需要遍历整个堆来扫描对象。
2.位图标记:根据对象所在的地址和堆的起始地址就可以算出对象是在第几块上,然后在位图中其置为 1 ,表明这块地址上的对象被标记了。位图标记可以快速遍历清除对象,并且与写时复制技术(copy-on-write)相兼容。
7.1.2 复制算法
复制(Copying)是在标记-清除上演化而来,解决标记-清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。新对象的分配通过指针碰撞实现。
复制算法保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效。但是内存利用率只有一半,当存活的对象很多时,复制的压力还是很大的,会比较慢。
7.1.3 标记-整理算法
标记-整理(Mark-Compact)算法中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。新对象的分配通过指针碰撞实现。
标记-整理算法作为在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。但是它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
7.1.4 分代收集算法
分代收集(Generational Collection)算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合算法。对象存活周期的不同将内存划分为几块,一般是把堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,且没有额外空间对它进行分配担保,就使用标记-清理或者标记-整理算法来进行回收。
分代收集存在这堆空间如何划分和跨代引用的问题。
堆空间划分
CMS将堆空间分为Eden区、From/To Survivor区和Old区;G1将堆空间划分成多个大小相等独立区域(Region),每一个Region 都可以根据需要,作为Eden区、Survivor区和Old区。
跨代引用
在回收新生代的时候,有可能有老年代的对象引用了新生代对象。使用记录集(Remembered Set)记录了从老年代对象到新生代对象的引用,新生代 GC 时将会把记录集视为 GC Roots 的一部分,而避免扫描整体非收集区域。
解决:引入写屏障,如果一个老年代的引用指向了一个新生代的对象,就会触发写屏障。
写屏障判断:
- 发出引用的对象是不是老年代对象;
- 目标引用标对象是不是新生代对象;
- 发出引用的对象是否还没有加入记录集。
如果满足以上三点,则本次新建的引用关系中,老年代的对象会被加入到记录集。
上述过程可能会带来浮动垃圾,原因是所有由老年代->新生代的引用都会被加入记录集,但老年代内对象的存活性,只有在下一次老年代GC 时才知道。
分代算法的优点在于减小了 GC 的作用范围后带来的高吞吐,但如果在某些应用中对象会活得很久,如果在这样的场景下使用分代算法,老年代的 GC 就会很频繁,反而降低了 GC 的吞吐。此外,由于在记录跨代引用时引入了写屏障,这也会带来一定的性能开销。
7.2 堆中内存模型
堆(Heap)是 JVM 所管理的内存中最大的一块,又是垃圾收集器管理的主要区域。
堆主要分为2个区域:新生代与老年代,其中新生代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
- 部分收集(Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集。发生新生代的GC并复制存活对象,并且很频繁。单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集。
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集(Full GC):收集整个 Java 堆和方法区。
7.2.1 Eden 区
IBM 公司的专业研究表明,有将近98%的对象是朝生夕死。所以针对这一现状,大多数情况下对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。这就是分配担保机制。
7.2.2 Survivor 区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲。Survivor 区又分为 From 区和 To 区。
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而很多对象虽然一次 Minor GC 没有消灭,但其实也并不会存活多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor 区的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
Survivor 区为什么是两个?
两个 Survivor 区最大的好处就是解决内存碎片化。
如果 Survivor 如果只有一个区域,那么 Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区。而之前 Survivor 区中的对象,可能也有一些是需要被清除的。在这种场景下就只能进行标记-清除,而标记-清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记-清除必然会让内存产生严重的碎片化。
因为 Survivor 有2个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。
这种机制最大的好处就是,整个过程中,永远有一个 Survivor 区是空的,另一个非空的 Survivor 区是无碎片的。那么,Survivor 区为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。
7.2.3 Old 区
老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法。
除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代。比如大对象,长期存活对象和动态对象年龄。
大对象
大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及2个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,就需要注意了。
长期存活对象
虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加1岁。当年龄增加到15岁时,这时候就会被转移到老年代。这里的15,JVM 也支持进行特殊设置。
动态对象年龄
虚拟机并不重视要求对象年龄必须到15岁,才会放入老年区。如果 Survivor 区中相同年龄所有对象大小的总合大于 Survivor 区的一半,那么年龄大于等于该年龄的对象就可以直接进去老年区,无需等到15岁。
7.3 垃圾收集器
衡量GC性能的标准:吞吐量、停顿时间和垃圾回收频率。
- 吞吐量:系统应用程序花费的时间和系统运行总时长的比值,即GC的吞吐量=GC耗时/系统总运行时间,GC的吞吐量一般不低于95%。
- 停顿时间:停顿时间是垃圾收集器在工作的时候,应用程序暂停的时间。一般串行收集器的停顿时间较长,并发收集器的停顿时间因为收集器和应用程序交替运行,所以停顿时间会比较短,但是效率不如串行,系统吞吐量会有所下降。
- 垃圾回收频率:垃圾回收频率时间和停顿时间是互相影响的,可以通过增大内存的方式来降低垃圾回收发生的频率,但是内存增大后,堆积的对象就更多,当垃圾回收时,停顿的时间就会增加。
各种垃圾回收器和垃圾回收算法间的关系如下:
- Serial:复制
- ParNew:复制
- Parallel Scavenge:复制
- Serial Old:标记-整理
- Parallel Old:标记-整理
- CMS(Concurrent-Mark-Sweep):(并发)标记-清除
- G1(Garbage-First):并发标记 + 并行复制
- ZGC/C4:并发标记 + 并发复制
- Shenandoah GC:并发标记 + 并发复制
Serial
Serial(串行)是一个单线程收集器。单线程意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,在它进行垃圾收集工作的时候必须暂停其他所有的工作线程,直到它收集结束。由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择。
ParNew
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样。ParNew收集器是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器)配合工作。
Parallel Scavenge
Parallel Scavenge 收集器类似于ParNew 收集器,但是它更关注吞吐量,即更高效率的利用CPU。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。
Serial Old
Serial Old 收集器是 Serial 收集器的老年代版本,它同样是一个单线程收集器。主要用于在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用和作为CMS收集器的后备方案。
Parallel Old
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和标记-整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge 收集器和Parallel Old收集器。
7.3.1 CMS
CMS(Concurrent Mark Sweep)收集器由标记-清除算法实现,是一种以获取最短回收停顿时间为目标的收集器,符合在注重用户体验的应用上使用。它是 HotSpot 虚拟机中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程基本上同时工作。
过程
- 初始标记(STW)。很短,暂停所有的其他线程,仅标记下GC Root能直接关联的对象。
- 并发标记。时间很长,并发执行GC和用户线程,进行GC Root跟踪,标记所有关联的对象。在该阶段结束时,并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用,所以GC线程无法保证可达性分析的实时性。对于漏标的对象,通过下一步增量更新解决。
- 重新标记(STW)。短暂,增量更新。修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
- 并发清除。开启用户线程,同时GC线程开始对为标记的区域做清除。
优缺点
- 优点:并发收集、低停顿。
- 缺点:对CPU资源敏感、无法处理浮动垃圾和会产生大量内存碎片。
7.3.2 G1
G1(Garbage-First)收集器是面向服务器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征,被视为 JDK 7 中HotSpot虚拟机的一个重要进化特征。
G1为了实现STW的时间可预测,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 根据需要扮演新生代的Eden区、Survivor区和Old区。不同角色的 Region 采用不同的策略去处理,对新旧对象进行更好的收集。另外 Region 中还有一类特殊的 Humongous 区,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous 区中,G1 的进行回收大多数情况下都把 Humongous 区作为老年代的一部分来进行看待。
参数 -XX:+UseG1GC
开启G1。
参数 -XX:G1HeapRegionSize
限制每个Region的大小,取值范围为1MB至32MB,且应为2的N次幂。一般建议逐渐增大该值,随着 size 增加,垃圾的存活时间更长,GC 间隔更长,每次 GC 的时间也会更长。
参数 -XX:MaxGCPauseMillis
为最大GC暂停时间 设置最大GC暂停时间的目标(单位毫秒),这是个软目标,JVM会尽最大可能实现它。
过程
- 初始标记(STW)。短,借用进行Minor GC的时候同步完成,仅标记GC Roots能直接关联到的对象,并且修改 TAMS 指针的值。要达到GC与用户线程并发运行,必须要解决回收过程中新对象的分配。G1为每一个Region区域添加了两个TAMS(Top at Mark Start)的指针,从 Region 中划出一部分空间用于记录并发回收过程中的新对象,这样的对象认为它们是存活的,不纳入垃圾回收范围。通过修改指针,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。
- 并发标记。时间很长。并发执行GC和用户线程,进行GC Root跟踪,标记所有关联的对象。在该阶段结束时,并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用,所以GC线程无法保证可达性分析的实时性。对于漏标的对象,通过下一步SATB(snapshot at the beginning)算法解决。
- 最终标记(STW)。短暂,SATB。处理并发阶段遗留下来的少量 SATB 记录(漏标对象)。
- 筛选回收(STW)。更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,优先选择回收价值最大的Region。可以选择任意多个Region构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的Region中,再清理掉整个旧 Region 的全部空间。
优点
- 并行与并发。G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
- 分代收集。分代概念在 G1 中依然得以保留,但是G1独立管理整个堆,通过将堆分Region,每个Region扮演不同的分代角色。
- 空间整合。G1从整体上看是基于标记-整理的算法实现,从局部(两个 Region 之间)上来看是基于复制算法实现的。这两种算法在 G1 运作期间不会产生内存空间碎片,提供规整的可用内存,有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
- 可预测的停顿。
-XX:MaxGCPauseMillis
指定目标的最大停顿时间,G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。
缺点
- 堆利用率不高:记录集占用内存空间较大,一般会达到1%~20%;
- 暂停时间较长:通常 G1 的 STW 时间要达到几十到几百毫秒,还不够低。
7.3.3 三色标记法
CMS和G1垃圾收集器都是通过可达性分析,并发标识所有关联的对象。可达性分析类 GC 都属于「搜索型算法」(标记阶段经常用到深度优先搜索),这一类算法的过程可以用 Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking)来进行抽象。三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个GC。
三色标记算法背后的首要原则就是把堆中的对象根据它们的颜色分到不同集合里面:
- 白色:未被标记对象。如果标记完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
- 灰色:自身已经被标记,但是还没标记完它的所有子对象。
- 黑色:根对象或者该对象和它的所有子对象都被标记。
流程
- 刚开始所有对象都是白色;
- 从根节点遍历,将GC Roots直接引用的对象添加到灰色集合;
- 从灰色集合中取出对象,将该对象所有的引用都加入到灰色集合,自己加入到黑色集合;
- 循环第三步,直到灰色集合为空,意味着可达性分析结束。仍在白色集合中的对象即为不可达,可以进行回收。
下面是第一轮标记结束后,各个对象的颜色分布。
在标记对象是否存活的过程中,对象间的引用关系是不能改变的,这对于串行 GC 来说是可行的,因为此时应用程序处于 STW 状态。对于并发 GC 来说,在分析对象引用关系期间,对象间引用关系的建立和销毁是肯定存在的,如果没有其他补偿手段,并发标记期间就可能出现对象多标和漏标的情况。
多标
A(黑色)->C(灰色)->E(白色)
在进行下面的标记之前,A和C之间的引用关系被解除了,那么C和E都应该是垃圾,但是C不会在本轮GC中回收,需要等GC。
这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾。
漏标
如下图,在进行下面的标记之前,A创建了和E之间的引用,C断开了和E之间的引用。
在进行后面的标记时,因为C没有对E的引用,所以E不会被放到灰色集合,虽然A重新引用了E,但是A已经是黑色了,不会再返回重新深度遍历。最后导致E一直停留在白色集合中,被当做垃圾回收。事实上E是活动对象。
多标不会影响程序的正确性,只会推迟垃圾回收的时机,漏标会影响程序的正确性。
漏标条件
- 插入了一个从黑色对象(A)到白色对象(E)的新引用。
- 删除了从灰色对象(C)到白色对象(E)的直接或者间接引用。
解决方法
要避免对象的漏标,只需要打破上述两个条件中的任何一个即可。
- 增量更新(Increment Update):新增引用关系后,当发出引用的是黑色或白色对象会被标记为灰色,或者将被引用的对象标记为灰色。当检测到应用即将访问白色对象时,将其置为灰色。
- STAB(Snapshot At The Begining)。删除引用关系前,将所有即将被删除的引用关系记录下来,最后以这些引用为根重新扫描。
CMS使用增量更新,关注引用的增加。在重新标记时让垃圾回收器重新扫描。
G1使用STAB,关注引用的删除。在最终标记时短暂暂停用户线程,处理遗留下来的少量的 SATB 记录。
7.3.4 GC调优
核心思路就是尽可能的使对象在新生代被回收,减少对象进入老年代。
降低 Minor GC 频率
- 增大新生代空间:增大内存会增加回收时的卡顿时间。Minor GC 也会导致应用程序的卡顿,只是时间非常短暂,那么扩大Eden区会不会导致Minor GC的时间增长,还得深入看一下一次Minor GC发生了什么。
降低 Full GC 频率
- 减少创建大对象:由于新生代的空间一般很小,大对象会被直接创建在老年代。
- 增大内存空间:堆内存不足就直接增大堆内存的空间,把初始化内存空间就设置成最大堆内存空间,可以显著降低Full GC频率。
- 选择合适的GC。
具体调优还是得看场景根据 GC 日志具体分析,常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率 、老年代内存占用量等等。
比如发现频繁会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Ful GC。所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor 。或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏、或者有第三方类库调用了 System.gc 等。
8. 调优
流程
JVM 调优根据内存占用,延迟和吞吐量来评估。调优是从满足程序的内存使用需求开始的,之后是时间延迟的要求,最后才是吞吐量的要求。这三个属性任何一个性能提升,都是以其他一个或两个属性性能损失为代价。
原则
- 每次 Minor GC 都要尽可能多的收集垃圾对象,以减少发生 Full GC 的频率。
- 处理吞吐量和延迟问题时候,垃圾处理器能使用的内存越大,垃圾收集的效果越好,应用程序也会越来越流畅。
- 在性能属性里面,吞吐量、延迟、内存占用,我们只能选择其中两个进行调优,不可三者兼得。
8.1 参数
参数 | 描述 |
---|---|
-Xms | 初始堆大小 |
-Xmx | 最大堆大小 |
-Xmn | 新生代空间大小(eden+2 survivor space) |
-Xss | 线程的栈最大深度大小 |
-XX:MetaspaceSize | 元空间默认大小 |
-XX:MaxMetaspaceSize | 元空间最大大小 |
-XX:+PrintGCDetails | 打印详细的GC日志 |
-XX:+UseConcMarkSweepGC | 指定使用的垃圾收集器,这里使用CMS收集器 |
-XX:+PrintGC | 输出GC日志 |
-XX:+PrintGCDetails | 输出GC的详细日志 |
-XX:+PrintGCTimeStamps | 输出GC的时间戳(以基准时间的形式) |
-XX:+PrintGCDateStamps | 输出GC的时间戳(以日期的形式) |
-XX:+PrintHeapAtGC | 在进行GC的前后打印出堆的信息 |
-Xloggc:…/logs/gc.log | 日志文件的输出路径 |
-XX:+TraceClassLoading | 打印类加载顺序 |
8.2 工具
- jps:查看本机java进程信息
- jstack:打印线程的栈信息,制作线程dump文件
- jmap:打印内存映射,制作堆dump文件
- jstat:性能监控工具
- jhat:内存分析工具
- jvisualvm:查看堆内存
- GCViewer/GCeasy:可视化查看GC日志
8.3 应用
8.3.1 CPU 飙升
找到最耗CPU的进程,获取pid
top
找到该进程下最耗费cpu的线程,获取threadPid
top -Hp pid
转换进制,将十进制threadPid转成十六进制threadPid16
printf "%x\n" threadPid
打印堆栈信息
jstack -l pid > ./pid.stack
过滤指定线程
cat pid.stack | grep threadPid16 -C 8
8.3.2 线程死锁
查看sleep的进程,获取pid
top
打印堆栈信息,可从输出信息看到死锁
jstack -l pid
8.3.3 内存溢出(OOM)
查看堆内存信息
jmap -heap pid
查看gc,每秒输出一次gc的分代内存分配情况,以及gc时间
jstat -gcutil pid 1000
查找最费内存的对象
会强制Full GC
jmap -histo:live pid | more
dump内存快照
jmap -dump:format=b,file=/tmp/dump.dat pid
参考:
JVM
咱们从头到尾说一次 Java 垃圾回收
JVM体系结构:JVM类加载器和运行时数据区
深入理解java虚拟机(全章节完整)
深入JVM类加载机制
如何实现Java类隔离加载?
垃圾回收
JVM垃圾回收器及算法原理
垃圾回收底层原理
底层原理:垃圾回收算法是如何设计的?
前沿实践:垃圾回收器是如何演进的?
JVM垃圾回收入门知识