探索 Java 内存管理机制

首图.jpg

目录

  1. 什么是内存?
  2. 什么是 Java 内存模型?
  3. 什么是 JVM?
  4. JVM 是怎么划分内存的?
  5. 栈帧中的数据有什么用?
  6. 什么是可达性算法?
  7. Java 中有哪几种引用?
  8. 什么是垃圾回收器?
  9. 参考文献

前言

这篇文章是我自己回顾和再学习 Java 内存管理相关知识的过程中整理出来的。

整理的目的是让我自己能对 Java 内存管理相关的知识的认识更全面一些,分享的目的是希望大家也能从这些知识中得到一些启发。

1. 什么是内存?

内存是计算机中重要的部件之一,是与 CPU 进行沟通的桥梁,是 CPU 能直接寻址的存储空间,由半导体器件制成。

如果说数据是商品,那硬盘就是商店的仓库,内存就是商店的货架,仓库里的商品你是不能直接买的,你只能买货架上的商品。

每一个程序中使用的内存区域相当于是不同的货架,当一个货架上需要摆放的商品超过这个货架所能容纳的最大值,就会出现放不下的情况,也就是内存溢出。

2. 什么是 JVM?

JVM(Java 虚拟机)是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。

JVM 有自己的硬件架构,如处理器、堆栈、寄存器等,还有对应分指令系统。

假如一个程序使用的内存区域是一个货架,那 JVM 就相当于是一个淘宝店铺,它不是真实存在的货架,但它和真实货架一样可以上架和下架商品,而且上架的商品数量也是有限的。

假如货架是在深圳,那 JVM 的平台无关性就相当于是客人可以在各个地方购买你在淘宝上发布的商品,不是只有在深圳才能购买货架上的商品。

3. 什么是 Java 内存模型?

Java 内存模型的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存,以及从内存中取出变量这样的底层细节。

下面我们就来看下 Java 内存模型的具体介绍。

3.1 主内存与工作内存

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程有自己的工作内存(Working Memory),线程的工作内存中保存了线程使用到的变量的内存副本。

线程对变量副本的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量。

不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递都要通过主内存来完成。

Java 内存模型.png

3.2 执行引擎

所谓执行引擎,就是一个运算器,能够识别输入的指令,并根据输入的指令执行一套特定的逻辑,最终输出特定的结果
执行引擎对于 JVM 的作用就像是 CPU 对于实体机器的作用,都可以识别指令,并且根据指令完成特定的运算。

3.3 主内存与工作内存的交互操作

Java 内存模型中定义了 8 种操作来完成主内存与工作内存之间具体的交互协议,虚拟机实现时必须保证每一种操作都是原子、不可再分的。

这 8 种操作又可分为作用于主内存的和作用于工作内存的操作。

3.3.1 作用于主内存的操作

  1. lock(锁定)

    作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  2. unlock(解锁)

    作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。

  3. read(读取)
    作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便 load 时使用。

  4. write(写入)

    作用于主内存的变量,它把 store 操作从工作内存中得到的变量值放入主内存的变量中。

3.3.2 作用于工作内存的操作

  1. load(载入)

    作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

  2. use(使用 )
    作用于工作内存的变量,它把一个工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用的变量的值的字节码执行时会执行这个操作。

  3. assign(赋值)
    作用于工作内存的变量,它把一个执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码执行时执行这个操作。

  4. store(存储)
    作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的 write 操作使用。

4. JVM 是怎么划分内存的?

JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个数据区域,而这些区域又可以分为线程私有的数据区域和线程共享的数据区域。

运行时数据区.png

4.1 线程私有的数据区域

4.1.1 程序计数器

程序计数器有下面两个特点。

  • 较小

    程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程执行的字节码的行号指示器。

  • 线程私有

    为了线程切换后能恢复到正确的执行位置,每条线程都有一个私有的程序计数器。

  • 无异常

    程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域。

4.1.2 虚拟机栈

虚拟机栈可以说是 Java 方法栈,它有下面三个特点。

  • 描述方法执行

    虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行时都会创建一个栈帧(Stack Frame),栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

    一个方法从调用到执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

    关于栈帧在第 5 大节会有一个更多的介绍。

  • 线程私有

    与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。

  • 异常

    在 Java 虚拟机规范中,对虚拟机栈规定了下面两种异常。

    1. StackOverflowError

      当执行 Java 方法时会进行压栈的操作,在栈中会保存局部变量、操作数栈和方法出口等信息。

      JVM 规定了栈的最大深度,如果线程请求执行方法时栈的深度大于规定的深度,就会抛出栈溢出异常 StackOverflowError。

    2. OutOfMemoryError

      如果虚拟机在扩展时无法申请到足够的内存,就会抛出内存溢出异常 OutOfMemoryError。

4.1.3 本地方法栈

本地方法栈(Native Method Stack)的作用与虚拟机栈非常相似,它有下面两个特点。

  • 为 Native 方法服务

    本地方法栈与虚拟机栈的区别是虚拟机栈为 Java 方法服务,而本地方法栈为 Native 方法服务。

  • 异常

    与虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

4.2 所有线程共享的数据区域

4.2.1 Java 堆

Java 堆(Java Heap)也就是实例堆,它用于存放我们创建的对象实例,它有下面几个特点。

  • 最大

    对于大多数应用来说,Java 堆是 JVM 管理的内存中最大的一块内存区域。

  • 线程共享

    Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。

  • 存放实例

    堆的唯一作用就是存放对象实例,几乎所有的对象实例都是在这里分配内存。

  • GC

    堆是垃圾收集器管理的主要区域,所以有时也叫 GC 堆。

4.2.2 方法区

方法区(Method Area)存储的是已被虚拟机加载的数据,它有下面几个特点。

  • 线程共享

    方法区和堆一样,是所有线程共享的内存区域。

  • 存储的数据类型

    • 类信息
    • 常量
    • 静态变量
    • 即时编译器编译后的代码
  • 异常

    方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出异常 OutOfMemoryError。

方法区又可分为运行时常量池和直接内存两部分。

  1. 运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分。

    Class 文件中除了有类的版本、字段、方法和接口等描述信息,还有一项信息就是常量池(Constant Pool Table)。

    常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

    运行时常量池受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

  2. 直接内存

    直接内存(Direct Memory)有下面几个特点。

    • 在虚拟机数据区外

      直接内存不是从虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

    • 直接分配

      在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道与缓冲区的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,这样能避免在 Java 堆和 Native 堆中来回复制数据。

    • 受设备内存大小限制

      直接内存的分配不会受到 Java 堆大小的限制,但是会受到设备总内存(RAM 以及 SWAP 区)大小以及处理器寻址空间的限制。

    • 异常

      直接内存的容量默认与 Java 堆的最大值一样,如果超额申请内存,也有可能导致 OOM 异常出现。

5. 栈帧中的数据有什么用?

当 Java 程序出现异常时,程序会打印出对应的异常堆栈,通过这个堆栈我们可以知道方法的调用链路,而这个调用链路就是由一个个 Java 方法栈帧组成的。

我们来看下栈帧中包含的局部变量表、操作数栈、动态连接和返回地址分别有着什么作用。

栈帧.png

5.1 局部变量表

局部变量表(Local Variable Table)中的变量只在当前函数调用中有效,当函数调用结束后,随着函数栈帧的销毁,局部变量表也会随之销毁。

局部变量表中存放的编译期可知的各种数据有如下三种。

  1. 基本数据类型

    如 boolean、char、int 等

  2. 对象引用

    reference 类型,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置

  3. returnAddress 类型

    指向了一条字节码指令的地址。

5.2 操作数栈

操作数栈(Operand Stack)也叫操作栈,它主要用于保存计算过程的中间结果,同时作为计算过程中临时变量的存储空间。

操作数栈也是一个先进后出的数据结构,只支持入栈和出栈两种操作。

当一个方法刚开始执行时,操作数栈是空的,在方法执行的过程中,会有各种字节码执行往操作数栈中写入和提取内容,也就是出栈/入栈操作。

比如下面的这张图中,当调用了虚拟机的 iadd 指令后,它就会在操作数栈中弹出两个整数并进行加法计算,并将计算结果入栈。

操作数栈.png

5.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

5.4 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法,一种是正常完成出口,另一种是异常完成出口。

  1. 正常完成出口

    执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。

    是否有返回值和返回值的类型将根据遇到哪种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

  2. 异常完成出口

    在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。

    一个方法使用异常完成出口的方式退出,任何值都不会返回给它的调用者。

无论采用哪种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行。

6. 什么是可达性算法?

在主流的商用程序语言(Java、C# 和 Lisp 等)的主流实现中,都是通过可达性分析(Reachability Analysis)判定对象是否存活的。

这个算法的基本思路就是通过一系列“GC Roots”对象作为起始点,从这些节点开始向下搜索,搜索走过的路径就叫引用链。

当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

比如下图中的 object5、object6、object7,虽然它们互有关联,但是它们到 GC Roots 是不可达的,所以它们会被判定为可回收对象。

引用链

在 Java 中,不同内存区域中可作为 GC Roots 的对象包括下面几种。

  1. 虚拟机栈

    虚拟机栈的栈帧中的局部变量表中引用的对象,比如某个方法正在使用的类字段。

  2. 方法区

    1. 类静态属性引用的对象
    2. 常量引用的对象
  3. 本地方法栈

    本地方法栈中 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 虚引用

虚引用是最弱的一种引用关系,它有以下几个特点。

  • 无法获取

    一个对象是否有虚引用的存在,都不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。

  • 收到通知

    为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

  • PhatomReference

    在 JDK 1.2 后,提供了 PhantomReference 类来实现虚引用。

8. 什么是垃圾回收器?

地上有脏东西是不可避免的,但是天天都要扫地又太麻烦了,有没有什么办法可以让我们不用扫地呢?

扫地机器人就可以帮我们做这件事,而垃圾回收器 GC(Garbage Collector)就相当于是扫地机器人。

我们 Java 开发者不用像 C++ 开发者那样关心内存释放的问题,但是我们也不能挡着扫地机器人的路。

当我们操作不当导致某块内存泄漏时,GC 就不能对这块内存进行回收。

GC 可不是个好伺候的主,如果你让“GC 很忙”,那它就会让你“应用很卡”。

拿 Android 来说,进行 GC 时,所有线程都要暂停,包括主线程,16ms 是 Android 要求的每帧绘制时间,而当 GC 的时间超过 16ms,就会造成丢帧的情况,也就是界面卡顿。

垃圾回收器回收资源的方式就是垃圾回收算法,下面我们来看下四个主要的垃圾回收算法。

8.1 标记-清除算法

标记-清除算法(Mark-Sweep)相当于是先把货架上有人买的、没人买的、空着的商品和位置都记录下来,然后再把没人买的商品统一进行下架。

标记-清除算法.png
  • 工作原理

    • 第一步:标记所有需要回收的对象

    • 第二步:标记完成后,统一回收所有被标记的对象

  • 缺点

    • 效率低

      标记和清除的效率都不高

    • 内存碎片

      标记清除后会产生大量不连续的内存碎片,内存碎片太多会导致当程序需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发 GC

8.2 复制算法

为了解决效率问题,复制(Copying)收集算法出现了。

复制算法.png
  • 工作原理
复制算法把可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

当使用中的这块内存用完了,就把存活的对象复制到另一块内存上,然后把已使用的空间一次清理掉。

这样每次都是对半个内存区域进行回收,内存分配时也不用考虑内存碎片等复杂问题。
  • 优点

    复制算法的优点是每次只对半个内存区域进行内存回收,分配内存时也不用考虑内存碎片等复杂情况,只要一动堆顶指针,按顺序分配内存即可。

  • 缺点

    • 浪费空间

      把内存缩小一半来使用太浪费空间。

    • 有时效率较低

      在对象存活率高时,要进行较多的复制操作,这时效率就变低了

8.3 标记-整理算法

在复制算法中,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用内存中所有对象都存活的低端情况,所以养老区不能用这种算法。

根据养老区的特点,有人提出了一种标记-整理(Mark-Compact)算法。

标记-整理算法.png
  • 工作原理

    标记-整理算法的标记过程与标记-清除算法一样,但后续步骤是让所有存活的对象向一端移动,然后直接清理掉边界外的内存。

8.4 分代收集算法

现代商业虚拟机的垃圾回收都采用分代收集(Generational Collection)算法,这种算法会根据对象存活周期的不同将内存划分为几块,这样就可以根据各个区域的特点采用最适当的收集算法。

在新生区,每次垃圾收集都有大批对象死去,只有少量存活,所以可以用复制算法。

养老区中因为对象存活率高、没有额外空间对它进行担保,就必须使用标记-清理或标记-整理算法进行回收。

堆内存可分为新生区、养老区和永久存储区三个区域。

堆内存区域.png
  1. 新生区

    新生区(Young Generation Space)是类的诞生、成长和消亡的区域。

    新生区又分为伊甸区(Eden space)、幸存者区(Survivor space)两部分。

    • 伊甸区

      大多数情况下,对象都是在伊甸区中分配的,当伊甸区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。

      Minor GC 是指发生在新生区的垃圾收集动作,Minor GC 非常频繁,回收速度也比较快。

      当伊甸区的空间用完时,GC 会对伊甸区进行垃圾回收,然后把伊甸区剩下的对象移动到幸存 0 区。

    • 幸存 0 区

      如果幸存 0 区满了,GC 会对该区域进行垃圾回收,然后再把该区剩下的对象移动到幸存 1 区。

    • 幸存 1 区

      如果幸存 1 区满了,GC 会对该区域进行垃圾回收,然后把幸存 1 区中的对象移动到养老区。

  1. 养老区

    养老区(Tenure Generation Space)用于保存从新生区筛选出来的 Java 对象。

    当幸存 1 区移动尝试对象到养老区,但是发现空间不足时,虚拟机会发起一次 Major GC。

    Major GC 的速度一般比 Minor GC 慢 10 倍以上。

    大对象会直接进入养老区,比如很大的数字和很长的字符串。

  2. 永久存储区

    永久存储区(Permanent Space)是一个常驻内存区域,用于存放 JDK 自身携带的 Class Interface 元数据。

    永久存储区存储的是运行环境必需的类信息,被装载进该区域的数据是不会被垃圾回收器回收掉的,只有 JVM 关闭时才会释放此区域的内存。

参考文献

  1. 视频

    Top团队大牛带你玩转Android性能分析与优化

  2. 书籍

    《深入理解Java虚拟机(第2版)》

    《实战Java虚拟机》

    《揭秘 Java 虚拟机》

你可能感兴趣的:(探索 Java 内存管理机制)