前言
在内存管理方面,Java程序员要比C/C++程序员幸福多了,因为Java虚拟机会帮我们进行内存管理,所以我们不用手动的去进行 delete/free 操作来释放对象的内存占用,虽然说内存管理的事情交给Java虚拟机了,但也不是绝对安全的,总会有出错的情况,如果我们不熟悉Java虚拟机是如何管理内存的,那么排查问题将会是一个非常困难的过程。
Java运行时内存区域
Java程序在运行的时候,虚拟机会把这个进程所占用的内存空间进行逻辑划分,划分出不同的区域,JDK1.8相比较于之前的版本,稍微有些改动。
JDK1.8 之前
JDK1.8 之后
JDK1.8 最大的变化就是方法区从Java程序的进程空间分离出去,改名为元空间,并且在本地内存中占用一块儿地方,作为元空间的内存空间。而类常量池和字符串常量池被转移到了堆中。
方法区是Java虚拟机中的一个规范,永久代和元空间是不同的实现,可以理解为方法区是一个接口,永久代和元空间是实现类。
从整体上来看,Java运行时内存空间可以分为两种,线程私有和线程共享
线程私有:
- 虚拟机栈
- 本地方法栈
- 程序计数器
线程共享:
- 堆
- 方法区(1.8之后叫元空间)
- 直接内存(在Java运行时内存区域之外)
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
从上面的介绍中我们知道了程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
⚠️ 注意 :程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈
Java虚拟机栈是每个线程私有的,它的声明周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。线程每调用一次方法就会创建一个栈帧,并且把栈帧入栈,一旦方法调用完成,则对应这个方法的栈帧就会出栈。 每个栈帧中都保存着方法的局部变量信表、操作数栈、动态连接、方法返回地址。
局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
动态链接 主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
栈空间不是无限的,正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
简单总结一下程序运行中栈可能会出现两种错误:
StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError错误。OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError 和 OutOfMemoryError两种错误。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap) 。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
在JDK1.8之后 PermGen(永久代)已经被 Metaspace(元空间)所替代,元空间使用的是直接内存。
创建对象时,该对象首先会在Eden区域分配空间,在一次新生代的垃圾回收之后,如果对象还存活,则会进入Survivor区的S0或者S1,并且这个对象的年龄会加1(在对象头中记录对象的年龄),当在Survivor区中的对象年龄增加到一定程度后(默认是15岁),就会被转移到老年代中。对象转移到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold来设置。
不过在之后Hotspot VM对一个对象转移到老年代的年龄阈值做了一些优化,主要目的是可以动态设置一个合适的晋升老年代的年龄阈值,以降低出现OutOfMemoryError的几率。
方法区
在开头的部分我们也说过了,方法区是《Java虚拟机规范》中一个规范,规定了方法区的概念和他的作用,具体的实现就是虚拟机要考虑的事情了,在Hotspot虚拟机中,具体的实现有永久代和元空间,JDK1.8之前的实现叫做永久代,1.8开始就改名为元空间了。不管叫什么名字,这块儿区域是线程共享的一块内存区域。
方法区的作用是保存一个类的信息,当我们需要使用一个类的时候,需要先加载并解析这个类的Class文件,获取类的信息,其中类的信息包括字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量和符号引用的 常量池表(Constant Pool Table) 。
字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
字符串常量池
字符串常量池是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
JDK1.7之前,字符串常量池是存放在永久代的。JDK1.8开始,字符串长连吃和静态变量从永久代移动到了Java堆中,开头的图中有展示。
为什么要将字符串常量池移动到堆中呢?
主要是因为永久代的GC回收效率太低了(只有在Full GC的时候才会进行垃圾回收处理),而Java程序中通常会有大量被创建的字符串等待回收,将字符串常量池放到堆中,能够更加高效及时的回收字符串所占用的内存。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。