对于从事 C/C++ 程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的帝皇,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到终结的维护责任。
对于 Java 程序员来说,在虚拟机的自动内存管理机制的帮助下,不在需要为每一个 new 操作去写配对的 delete/free 代码,而且不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过也正是因为 Java 程序员把内存控制权交给了 Java 虚拟机,一旦出现内存泄漏和溢出的问题,如果不了解虚拟机怎样使用内存的,那排查错误将会成为一项异常艰难的工作。
1. 什么是 JVM?
JVM(Java 虚拟机)是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。
JVM 有自己的硬件架构,如处理器、堆栈、寄存器等,还有对应分指令系统。
假如一个程序使用的内存区域是一个货架,那 JVM 就相当于是一个淘宝店铺,它不是真实存在的货架,但它和真实货架一样可以上架和下架商品,而且上架的商品数量也是有限的。
假如货架是在深圳,那 JVM 的平台无关性就相当于是客人可以在各个地方购买你在淘宝上发布的商品,不是只有在深圳才能购买货架上的商品。
2. 什么是 Java 内存模型?
Java 内存模型的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存,以及从内存中取出变量这样的底层细节。
下面我们就来看下 Java 内存模型的具体介绍。
2.1 主内存与工作内存
Java 内存模型规定了所有的变量都存储阿紫主内存(Main Memory)中,每条线程有自己的工作内存(Working Memory),线程的工作内存中保存了该线程所使用到的变量的内存副本。
不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量的传递都要通过主内存来完成(如下图 2-1所有)。
图2-1 Java 内存模型
2.2 执行引擎
所谓执行引擎,就是一个运算器,能够识别输入的指令,并根据输入的指令执行一套特定的逻辑,最终输入特定的结果执行引擎对于 JVM 的作用就像是 CPU 对于实体机器的作用,都可以识别指令,并且根据指令完成特定的运算。
2.3 主内存与工作内存的交互模型
Java 内存模型中定义了 8 种操作完成主内存与工作内存之间具体的交互协议,虚拟机实现时必须保证 每一种操作都是原子级、不可再分的。
这 8 种操作又可分为作用于主内存和作用于工作内存的操作。
2.3.1 作用于主内存的操作
① lock(锁定)
作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
② unlock(解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。
③ read(读取)
作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便 laod 时使用。
④ write(写入)
作用于主内存的变量,它把 store 操作从工作内存中得到的变量值存入到主内存变量中。
2.3.2 作用于工作内存的操作
① load(载入)
作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
② use(使用)
作用于工作内存的变量,它把一个工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用的变量的值的字节码执行时会执行这个操作。
③ assign(赋值)
作用于工作内存的变量,它把一个执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个变量赋值的字节码执行时执行这个操作。
④ store(存储)
作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的 write 操作。
3. JVM 是怎么划分内存的?
Java 虚拟机在执行 Java 程序过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是以来用户线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存将会包括以下几个运行区域,如图4-1所示。
图4-1 Java 虚拟机运行时数据区
3.1 线程私有的数据区
3.1.1 程序计数器
程序计数器有以下 三个特点:
- 较小
程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所指向的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要指向字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 线程私有
由于 Java 虚拟机的多线程是通过线程轮流 切换并分配处理器指向时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- 无异常
如果线程正在执行的是一个 Java 的方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有任何 OutOfMemoryError 情况的区域。
3.1.2 虚拟机栈
- 线程私有
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。
- 描述 Java 方法执行的内存模型
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame,栈帧是方法运行期的基础数据结构)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈栈从入栈到出栈的过程。
- 异常
在 Java 虚拟中,对虚拟机规定了下面两种异常:
① StockOverflowError
当执行 Java 方法是会进行压栈的操作,在栈栈会保存局部变量、操作栈和方法出口等信息。
JVM 规定了栈的最大胜读,如果线程请求执行方法时栈的深度大于规定的深度,就会抛出栈溢出异常 StockOverflowError。
② OutOfMemoryError
如果虚拟机在扩展时无法申请到足够的内存,就会抛出内存溢出异常 OutOfMemoryError。
3.1.3 本地方法栈
本地方法栈的作用于虚拟机非常相似,它有下面两个特点。
① 为 native 服务
本地方法栈与虚拟机栈的区别是虚拟机栈为 Java 服务,而本地方法栈为 native 方法服务。
② 异常
与虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
3.2 所有线程共享的数据区域
3.2.1 Java 堆
Java 堆(Java Heap)也就是实例堆,它有以下四个特点:
① 最大
对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。
② 线程共享
Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
③ 存放实例
此内存区域的唯一目的就是存放对象的实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发送,所有的对象都分配在堆上也渐渐变得不那么“绝对”了 。
④ GC
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”(Carbage Collected Heap)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法(详见XXX),所以 Java 堆中还可以细分为:新生代和老年代。如果从内存分配的角度看,线程共享的 Java 堆中可能划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都依然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
3.2.2 方法区
方法区存储的是已经被虚拟机加载的数据,它有以下三个特点:
① 线程共享
方法区域 Java 堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的等数据。
② 存储的数据类型
◇ 类的信息;
◇ 常量;
◇ 静态变量;
◇ 即时编译器编译后的代码,等。
③ 异常
方法区的大小决定履历系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样抛出内存溢出异常 OutOfMemoryError。
方法区又可以分为运行时常量池和直接内存两部分。
① 运行常量池
运行时常量池(Running Constant Pool)是方法区的一部分。
Class 文件中处了有类的 版本、字段、方法和接口等描述信息,还有一项信息就是常量池(Constant Pool Table)。
常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池受到方法区内存的限制,当常量池无法再申请到内存时就会抛出 OutOfMemoryError 异常。
② 直接内存
直接内存(Direct Memory)有以下四个特点:
a)在虚拟机数据区外
直接内存不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
b)直接分配
在 JDL1.4 中新加入的 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用操作,这样能避免在 Java 堆和 native 堆中来回复制数据。
c)受设备内存大小限制
直接内存的分配不会受到 Java 堆大小的限制,但是会受到设备总内存(RAM 以及 SWAP 区)大小以及处理器寻址空间的限制。
d)异常
直接内存的容量默认与 Java 对的最大值一样,如果超额申请内存,也可能导致 OOM 异常出现。
4. 对象访问
介绍完 Java 虚拟机的运行时数据区之后,我们可以探讨一个问题:在 Java 语言中,对象访问是如何进行的?对象访问在 Java 中中悟出不在,是最普通的程序行为,但即使是最简单的访问,也会涉及 Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面这段代码:
Object obj = new Object();
假如这句代码出现在方法体中,那“Object obj”这部分的寓意兼顾会反映到 Java 栈的本地变量表中,作为一个 reference 类型数据出现。而“new Object()”这部分的语义将会反映 到 Java 堆中,形成一块存储了 Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在 Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
由于 reference 类型在 Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪些方法区定位,以及访问到 Java 堆中的对象的具体位置,因此不同虚拟机的对访问方式会有所不同,主流的访问方法有两种:使用句柄和直接指针。
- 如果使用句柄访问方式,Java 堆中将会划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如图4-1所示。
图4-1 通过句柄访问对象
- 如果使用直接指针访问方式,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中直接存储的就是对象地址,如图4-2所示。
图4-2 通过直接指针获取对象
两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多也是一项非常可观的执行成本。
5. 栈帧中的数据有什么用?
当 Java 程序出现异常时,程序会打印出对于的异常堆栈,通过这个堆栈我们可以知道方法的调用链路,而这个调用链路就是有一个个 Java 方法栈帧组成。
下面来看下栈帧包含的局部变量表、操作数栈、动态连接和返回地址分别有着什么作用:
图5-1 栈帧
5.1 局部变量表
局部变量表(Local Variable Table)中的变量只在当前函数调用中有效,当函数调用结束后,随着函数栈帧的销毁,局部变量表也会随之销毁。
局部变量表中存放着编译期可知的各种数据,如以下三种:
① 基本数据类型
如 boolean、char、int等。
② 对象引用
reference 类型,可能是一个执行对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。
③ returnAddress 类型
指向了一条字节码指令的地址。
5.2 操作数栈
操作数栈(Operand Stack)也叫操作栈,它主要用于保存计算过程的中间结果,同时作为计算过程中临时变量的存储空间。
操作数栈也是一个先进后出的数据结构,只支持入栈和出栈两种操作。
当一个方法刚开执行时,操作数是空的,在方法执行的过程中,会有各种字节码执行往操作数栈中写入和提取内容,也就是出栈/入栈操作。
如下面的这张图(如图5-2所示)中,当调用了虚拟机额 iadd 指令后,它就会在操作数栈弹出两个整数并进行加法计算,并将计算结果入栈。
图5-2 iadd 指令与操作数栈的变化
5.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属非法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
5.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法,一种是正常调用完成,另一种是异常调用完成。
① 正常调用完成
执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法调用者。
是否有返回值和返回值的类型将根据遇到哪种方法返回指令来决定,这种退出方法的方式成为正常调用完成(Normal Method Invocation Completion)。
② 异常调用完成
在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,就会导致方法退出,这种退出方式成为异常调用完成(Abrupt Method Invocation Completion)
一个方法使用异常调用完成 的方法退出,任何值都不会返回给它的调用者。
无论采用哪种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行。
6. 什么是可达性算法?
在主流的商用程序语言(Java、C#等)的主流实现中,都是通过可达性分析(Reachability Analysis)判定对象是否存活。
这个算法的基本思路就是通过一系列“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索走过的路径就叫引用链。
当一个对象到 GC Roots 没有与任何引用链相连时,则证明此对象是无用的。
比如,下图(图6-1所示)中的 object6, object7, object8, 虽然它们相互关联,但是衙门到 GC Roots 是不可达的所以它们会被判定为可回收对象。
图6-1 可达性算法判断对象是否可回收
在 Java 中,不同内存区域可作为 GC Roots 的对象的包括以下三种:
① 虚拟机栈
虚拟机栈的栈帧中的局部变量表中引用的对象,比如某个方法正在使用的类字段。
② 方法区
◆ 类静态属性引用的对象;
◆ 常量引用的对象。
③ 本地方法栈
本地方法栈栈 native 方法引用的对象。
7. Java 中有哪几种引用?
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与引用有关。
在 JDK 1.2 之后,Java 对引用的改良进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次减弱。
7.1 强引用
强引用有以下四个特点:
① 普遍存在
强引用是指代码中普遍存在的,比如“Object obj = new Object()”这类引用。
② 直接引用
强引用可以直接访问目标对象。
③ 不会回收
强引用指向的对象在任何时候都不会被系统回收,虚拟机及时抛出 OOM 异常,也不会回收强引用指向的对象。
使用 obj=null 不会触发 GC,但是在下次GC的时候,这个强引用对象就可以被回收了。
④ OOM 隐患
强引用可能导致内存泄漏。
7.2 软引用
软引用有以下四个特点:
① 有用但非必需
软引用用于描述一些还有用但非必需的对象。
② 二次回收
对于软引用关联的对象,在系统即将发生内存溢出前,会把这些对象列入回收范围中进行二次回收。
③ OOM 隐患
如果二次回收后还没有足够的内存,就会抛出内存溢出异常。
④ SoftReference
在 JDK 1.2 后,Java 提供了 SoftReference 类来实现软引用。
7.3 弱引用
软引用有以下四个特点
① 比软引用更弱
弱引用的强度比软引用一些,被弱引用关联的对象只能生存到下一次 GC 前。
② 发现即回收
在 GC 时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。
③ 可有可无
软引用、弱引用适合保存可有可无的缓存数据。
④ WeakReference
JDK 1.2 后,提供了 WeakReference 类来实现弱引用。
7.4 虚引用
虚引用是最弱的一种引用关系,它有以下三个特点:
① 无法获取
一个对象无论是否有虚引用的存在,都不会对其生成时间构成影响,也无法通过虚引用取得一个对象实例。
② 收到通知
为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾回收器回收时收到一个系统通知。
③ PhantomReference
在 JDK 1.2 后,提供了 PhantomReference 类来实现虚引用。
8. 什么是垃圾回收器?
如果说收集算法是内存回收的方法论,那么垃圾回收器就是内存回收的具体实现。我们 Java 开发者不用像 C++ 开发者那样关心内存释放的问题,但我们也不能胡乱操作。当我们操作不当导致某块内存泄漏时,GC 就不能对这块内存进行回收。
GC 可不是好伺候的主,如果你让“GC 很忙”,那它就会让你“应用很卡”。
拿 Android 来说,进行 GC 时,所有线程都要暂停,包括主线程,16ms 是Android 要求的每帧绘制时间,而当 GC 的时间超过 16ms,就会造成丢帧的情况,也就是界面卡顿。
垃圾回收器回收资源的方法就是垃圾回收算法,下面我们来看下四个主要的垃圾回收算法。
8.1 标记-清除算法
标记-清除算法(Mark-Sweep)相当于是先把货架上有人买的、没人买的、空着的商品和位置都记录下来,然后再把没人买的商品统一进行下架。
图8-1 标记-清除算法
- 工作原理
◇ 第一步:标记所有需要回收的对象;
◇ 第二步:标记完成后,统一回收所有被标记的对象。
- 缺点
◇ 效率低
标记和清除的效率都不高。
◇ 内存碎片
标记清除后会产生大量不连续的内存碎片,内存碎片大多会导致当程序需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发 GC。
8.2 复制算法
为了解决效率问题,复制(Copying)收集算法出现了。
图8-2 复制算法
- 工作原理
复制算法把可用内存按容量划分为大小相同的两块,每次只使用其中的一块。
当使用中的这块内存用完了,就把存活的对象复制到另一块内存上,然后把已使用的控件一次清理掉。
这样每次都是对半个内存区域进行回收,内存分配时也不用考虑内存碎片等复杂问题。
- 优点
复制算法的优点是每次只对半个内存区域进行内存回收,分配内存时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。
- 缺点
◇ 浪费空间
把内存缩小一半用太浪费空间。
◇ 有时效率低
在对象存活率高时,要进行较多的复制操作,这时效率就低了。
8.3 标记-整理算法
在复制算法中,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用内存中所有对象都存活的低端情况所以养老区不能用这种算法。
根据养老区的特点,有人提出了一种标记-整理(Mark-Compact)算法。
图8-3 标记-整理算法
- 工作原理
标记-整理算法的标记过程与标记-清除算法一样,但后续步骤是让所有存活的对象向一端移动,然后直接清除掉边界外的内存。
8.4 分代收集算法
现代商业虚拟机的垃圾回收机制都是采用分代收集算法(Generational Collection)算法,这种算法会根据对象存活周期的不同将内存划分为几块,这样就可以根据各个区域的特点采用最适当的收集算法。
在新生区,每次垃圾收集都有大批对象死去,只有少了存活,所以可以用复制算法。
养老区中因为对象存活率高、没有额外空间对它进行担保,就必须使用标记-清除或标记-整理算法进行回收。
对内存可分为新生区、养老区永久存储区三个区域。
图8-4 堆存储的三个区域
8.4.1 新生区(Young Generation Space)
新生区是类的诞生、成长、消亡的区域。
新生区有分为伊甸区、幸存者区两部分。
- 伊甸区(Eden Space)
大多数情况下,对象都是在伊甸区中分配的,当伊甸区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。
Minor GC 是指发生在新生区的垃圾回收动作,Minor GC 非常繁忙,回收速度也比较快。
当伊甸区的空间用完时,GC 会对伊甸区进行垃圾回收,然后把伊甸区剩下的对象移动到幸存0区。
- 幸存0区(Survivor 0 Space)
如果幸存0区满了,GC 会对该区域进行垃圾回收,然后把该 区域剩下的对象移动到幸存1区。
- 幸存1区(Survivor 1 Space)
如果幸存1区满了,GC 会对该区域进行垃圾回收,然后把幸存1区中的对象移动到养老区。
8.4.2 养老区(Tenure Generation Space)
养老区用于保存从新生区筛选出来的 Java 对象。
当幸存1区尝试移动对象到养老区,但是发现空间不足时,虚拟机会发起一次 Major GC。
Major GC 的速度一般比 Minor GC 慢 10 倍以上。
大对象直接进入养老区,比如很大的数字和很长的字符串。
8.4.3 永久存储区(Permanent Space)
永久存储区是一个常驻内存区域,用于存放 JDK 自身携带的 Class Interface 元数据。
永久存储区存储的是运行环境必需的类信息,被装载进该区域的数据是不会被垃圾回收器回收掉的,只有 JVM 关闭时才会释放此区域的内存。