在虚拟机自动内存管理机制下,开发者不需要像 C/C++程序开发为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
JDK 1.8 和之前的版本略有不同,这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式,可分为两大部分:线程共享(方法区、堆、直接内存)、线程私有(虚拟机栈、本地方法栈和程序计数器)
1、程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,从而实现:分支、循环、跳转、异常处理、线程恢复等功能。另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储。
⭕️字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
⭕️在多线程的情况下,程序计数器记录当前线程执行的位置,从而当线程被切换恢复时能够知道该线程上次运行到哪儿了。
【注意】 程序计数器是唯一不会 OutOfMemoryError
的内存区域,其生命周期随着线程创建而创建,随着线程结束而死亡。
2、Java 虚拟机栈
与程序计数器一样,其生命周期和线程相同。栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些Native
方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
⭕️局部变量表 主要存放了编译期可知的8种基本数据类型和对象的引用(不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
⭕️操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
⭕️动态链接 主要服务一个方法需要调用其他方法的场景。Class
文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
【问题】 当栈空间溢出后,会抛出何种异常错误?
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。 栈空间虽不是无限的,但一般正常调用是不会有问题的。但若函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就会抛出错误。
根据是否可动态扩展栈空间来抛出两种错误,StackOverFlowError
错误:若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出该错误;栈还可能会出现OutOfMemoryError
错误:如果栈的内存大小可以动态扩展,如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出该错误。
【注意】 Hotspot
虚拟机栈容量不支持动态扩容,所以在线程成功申请到栈空间后,是不会抛出OutOfMemoryError
,否则仍会抛出。可以使用参数-Xss
选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
3、本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。【注意】 在 HotSpot 虚拟机中将其与虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
4、堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的内存区域,在虚拟机启动时创建。该区域主要目的是存放对象实例和数组,几乎所有的对象实例以及数组都在这里分配内存。且从JDK7
开始,字符串常量池和静态变量从方法区(永久代)移动了 Java 堆中。
运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。
【常见错误】 堆最容易出现OutOfMemoryError
错误,且该错误的表现形式有多种,比如:GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误;Java heap space
:假如在创建新的对象时,堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,若没有特别配置,将会使用默认值。
【问题】 为什么说几乎所有的对象实例以及数组都在这里分配内存?逃逸分析及优化?
Java 中“几乎”所有的对象都在堆中分配,但是,随着JIT
编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。JDK 1.7 便默认开启逃逸分析,若某方法中的对象引用未被返回或者未被外面使用(未逃逸出去),那么对象可以直接在栈上分配内存,从而避免堆上分配内存。
『逃逸分析』:Escape Analysis
是一种编译器优化技术,通过判断对象的作用域和存活时间,从而决定是否可将对象分配在栈上或者进行标量替换。对象的逃逸状态一般分为三种:全局逃逸(对象的引用逃出了方法或者线程)、参数逃逸(对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸)、没有逃逸。
『优化方法』:若未发生逃逸,或者只有参数逃逸,就可为对象采取不同程度的优化,如:栈上分配、标量替换、同步消除。
☁️栈上分配Stack Allocations
:将不会逃逸出方法和线程的对象直接分配在栈上,而不是在堆上。对象随着方法的结束而自动销毁,可以降低垃圾收集器运行的频率和压力,提高程序性能。HotSpot虚拟机目前还不支持完全的栈上分配优化,只实现了标量替换优化。
☁️标量替换Scalar Replacement
:标量Scalar
是指一个无法再分解成更小的数据的数据,基本数据类型和引用类型被称为标量。相对若数据可继续分解,就被称为聚合量Aggregate
,对象就是典型的聚合量。标量替换则是将未发生逃逸的对象拆分成若干个基本类型的变量,从而减少对象的创建和访问开销,提高程序的性能。标量替换可视作栈上分配的特例,对逃逸程度的要求更高,不允许对象发生逃逸。
☁️同步消除Synchronization Elimination
:线程同步本身是一个相对耗时的过程,若对象没有逃逸出线程,无法被其他线程访问,那该对象的读写就不会有竞争,对该对象实施的同步加锁操作也就可以安全地消除掉。
【问题】 JDK 1.7 为什么要将字符串常量池和静态变量(类对象)移动到堆中?
从JDK7
开始,字符串常量池和静态变量从方法区(永久代)移动了 Java 堆中,运行时常量池剩下的部分还在方法区中,也就是HotSpot虚拟机中的永久代。因为永久代(方法区实现)的回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。程序通常有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存,避免方法区的内存溢出,提高字符串的回收效率,以及优化String.intern()方法的性能。
具体来说,JDK1.7中字符串常量池不再存放字符串对象本身,而是存放字符串对象的引用,这些引用指向堆空间中的实际字符串对象。这样做的好处是,如果有多个相同的字符串对象被创建,它们可以共享同一个堆空间中的实例,节省内存空间。同时,由于堆空间比方法区更容易被垃圾回收器回收,因此可以提高字符串对象的回收效率。
另外,JDK1.7中String.intern()方法的实现也发生了变化。String.intern()方法是用来将一个字符串对象加入到字符串常量池中,并返回其引用。在JDK1.7之前,如果字符串常量池中已经存在相同内容的字符串对象,则直接返回其引用,不存在则在方法区中创建一个新的字符串对象,并返回其引用。问题是,如果调用String.intern()方法过多,可能会导致方法区内存溢出;在JDK1.7之后,若字符串常量池中已存在相同内容的字符串对象,则直接返回其引用;不存在则在堆空间中创建新的字符串对象,并将其引用加入到字符串常量池中,并返回其引用。🔥好处:避免在方法区中创建过多的字符串对象,减少内存溢出的风险。同时,由于堆空间中的字符串对象可以被垃圾回收器回收,因此可以提高String.intern()方法的性能。
至于静态变量,在JDK1.7之前,它们也是存放在方法区中的运行时常量池里。在JDK1.7之后,它们被移动到了堆空间中,并且与类对象一起存放在一块内存区域里。🔥好处:避免静态变量占用过多的方法区内存空间,导致内存溢出。同时,由于静态变量与类对象(java.lang.Class类的一个实例)一起存放在堆空间中,因此可以保证它们与类对象有相同的生命周期。
未完待续…