前言
Java相较于其他编程语言更加容易学习,这其中很大一部分原因要归功于JVM的自动内存管理机制。
对于从事C语言的开发者来说,他们拥有每一个对象的「所有权」,更大的权力也意味着更多的职责,C开发者需要维护每一个对象「从生到死」的过程,当对象废弃不用时必须手动释放其内存,否则就会发生内存泄漏。而对于Java开发者来说,JVM的自动内存管理机制解决了这个让人头疼的问题,不容易出现内存泄漏和内存溢出的问题了,GC让开发者更加专注于程序本身,而不用去关心内存何时分配、何时回收、以及如何回收。
1. JVM运行时数据区
在聊GC前,有必要先了解一下JVM的内存模型,知道JVM是如何规划内存的,以及GC的主要作用区域。
如图所示,JVM运行时会将内存划分为五大块区域,其中「方法区」和「堆」随着JVM的启动而创建,是所有线程共享的内存区域。虚拟机栈、本地方法栈、程序计数器则是随着线程的创建被创建,线程运行结束后也就被销毁了。
1.1 程序计数器
程序计数器(Program Counter Register)是一块非常小的内存空间,几乎可以忽略不计。
它可以看作是线程所执行字节码的行号指数器,指向当前线程下一条应该执行的指令。对于:条件分支、循环、跳转、异常等基础功能都依赖于程序计数器。
对于CPU的一个核心来说,任意时刻只能跑一个线程。如果线程的CPU时间片用完就会被挂起,等待OS重新分配时间片再继续执行,那线程如何知道上次执行到哪里了呢?就是通过程序计数器来实现的,每个线程都需要维护一个私有的程序计数器。
如果线程在执行Java方法,计数器记录的是JVM字节码指令地址。如果执行的是Native方法,计数器值则为Undefined。
程序计数器是唯一一个没有规定任何OutOfMemoryError情况的内存区域,意味着在该区域不可能发生OOM异常,GC不会对该区域进行回收!
1.2 虚拟机栈
虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,生命周期和线程相同。
虚拟机栈描述的是Java方法执行的内存模型,JVM要执行一个方法时,首先会创建一个栈帧(Stack Frame)用于存放:局部变量表、操作数栈、动态链接、方法出口等信息。栈帧创建完毕后开始入栈执行,方法执行结束后即出栈。
方法执行的过程就是一个个栈帧从入栈到出栈的过程。
局部变量表主要用来存放编译器可知的各种基本数据类型、对象引用、returnAddress类型。局部变量表所需的内存空间在编译时就已经确认,运行期间不会修改局部变量表的大小。
在JVM规范中,虚拟机栈规定了两种异常:
- StackOverflowError
线程请求的栈深度大于JVM所允许的栈深度。
栈的容量是有限的,如果线程入栈的栈帧超过了限制就会抛出StackOverflowError异常,例如:方法递归。 - OutOfMemoryError
虚拟机栈是可以动态扩展的,如果扩展时无法申请到足够的内存,则会抛出OOM异常。
1.3 本地方法栈
本地方法栈(Native Method Stack)也是线程私有的,与虚拟机栈的作用非常类似。
区别是虚拟机栈是为执行Java方法服务的,而本地方法栈是为执行Native方法服务的。
与虚拟机栈一样,JVM规范中对本地方法栈也规定了StackOverflowError和OutOfMemoryError两种异常。
1.4 堆
Java堆(Java Heap)是线程共享的,一般来说也是JVM管理最大的一块内存区域,同时也是垃圾收集器GC的主要管理区域。
Java堆在JVM启动时创建,作用是:存放对象实例。
几乎所有的对象都在堆中创建,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得“所有对象都分配在堆上”不那么绝对了。
由于是GC主要管理的区域,所以也被称为:GC堆。
为了GC的高效回收,Java堆内部又做了如下划分:
1.5 方法区
方法区(Method Area)与Java堆一样,也是线程共享的一块内存区域。
它主要用来存储:被JVM加载的类信息,常量,静态变量,即时编译器产生的代码等数据。
也被称为:非堆(Non-Heap),目的是与Java堆区分开来。
JVM规范对方法区的限制比较宽松,JVM甚至可以不对方法区进行垃圾回收。这就导致在老版本的JDK中,方法区也别称为:永久代(PermGen)。
使用永久代来实现方法区不是个好主意,容易导致内存溢出,于是从JDK7开始有了“去永久代”行动,将原本放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎来元空间。
2.GC 概述
垃圾收集(Garbage Collection)简称为「GC」,它的历史远比Java语言本身久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
要想实现自动垃圾回收,首先需要思考三件事情:
前面介绍了JVM的五大内存区域,程序计数器占用内存极少,几乎可以忽略不计,而且永远不会内存溢出,GC不需要对其进行回收。虚拟机栈、本地方法栈随线程“同生共死”,栈中的栈帧随着方法的运行有条不紊的入栈、出栈,每个栈帧分配多少内存在编译期就已经基本确定,因此这两块区域内存的分配和回收都具备确定性,不太需要考虑如何回收的问题。
方法区就不一样了,一个接口到底有多少个实现类?每个类占用的内存是多少?你甚至可以在运行时动态的创建类,因此GC需要针对方法区进行回收。
Java堆也是如此,堆中存放着几乎所有的Java对象实例,一个类到底会创建多少个对象实例,只有在程序运行时才知道,这部分内存的分配和回收是动态的,GC需要重点关注。
2.1 哪些对象需要回收
实现自动垃圾回收的第一步,就是判断到底哪些对象是可以被回收的。一般来说有两种方式:引用计数算法和可达性分析算法,商用JVM几乎采用的都是后者。
2.1.1 引用计数算法
在对象中添加一个引用计数器,每引用一次计数器就加1,每取消一次引用计数器就减1,当计数器为0时表示对象不再被引用,此时就可以将对象回收了。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间,但是它原理简单,也很高效,在大多数情况下是一个不错的实现方案,但是它存在一个严重的弊端:无法解决循环引用。
例如一个链表,按理只要没有引用指向链表,链表就应该被回收,但是很遗憾,由于链表中所有的元素引用计数器都不为0,因此无法被回收,造成内存泄漏。
2.1.2 可达性分析算法
目前主流的商用JVM都是通过可达性分析来判断对象是否可以被回收的。
这个算法的基本思路是:
通过一系列被称为「GC Roots」的根对象作为起始节点集,从这些节点开始,通过引用关系向下搜寻,搜寻走过的路径称为「引用链」,如果某个对象到GC Roots没有任何引用链相连,就说明该对象不可达,即可以被回收。
对象可达指的就是:双方存在直接或间接的引用关系。
根可达或GC Roots可达就是指:对象到GC Roots存在直接或间接的引用关系。
可以作为GC Roots的对象有以下几类:
可达性分析就是JVM首先枚举根节点,找到一些为了保证程序能正常运行所必须要存活的对象,然后以这些对象为根,根据引用关系开始向下搜寻,存在直接或间接引用链的对象就存活,不存在引用链的对象就回收。
2.2 何时回收
JVM将内存划分为五大块区域,不同的GC会针对不同的区域进行垃圾回收,GC类型一般有以下几大类:
- Minor GC
也被称为“Young GC”、“轻GC”,只针对新生代进行的垃圾回收。 - Major GC
也被称为“Old GC”,只针对老年代进行的垃圾回收。 - Mixed GC
混合GC,针对新生代和部分老年代进行垃圾回收,部分垃圾收集器才支持。