前言
在使用c++进行编程时,我们通过new创建的每一个对象都需要有对应的delete操作去释放对象所占用的内存,对内存的掌控度比较高,但是程序员需要知道对象什么时候不需要使用了,并需要手动释放内存,如果忘记了delete释放,很容易出现内存泄漏(申请内存后,没有释放,会一直占用着)和内存溢出(因为过多的内存泄漏导致无法申请足够的内存,即out of memory)的问题。
相比之下,java虚拟机提供了自动内存管理机制,java程序员可以解放双手,不再需要去写delete等手动释放内存的代码,虚拟机会自动将内存中无用的对象占用的内存释放。
了解jvm的必要
虽然有自动内存管理机制的存在,但是不代表写的每个java程序都不存在内存泄漏和内存溢出问题,我们需要对虚拟机有足够的了解,才能在发生内存泄露和内存溢出的时候有效地排查问题。
本文将对jvm虚拟机运行时内存进行一个基本的介绍,后续的文章也会讲解jvm其他知识,大部分都是自己的读书总结加上自己的理解。希望将自己的所学进行总结的同时能惠及他人,如果有什么地方讲的不对,希望各位同学能够指出。
内存划分
java虚拟机将其管理的内存划分为以下几块:
- 程序计数器 (PC Register)
- 虚拟机栈 (JVM Stack)
- 本地方法栈 (Native Method Stack)
- 堆 (Heap)
- 方法区 (Method Area)
各个区域都有其各自的特点和作用,以及不同的创建和销毁的时间
各个区域的介绍
程序计数器
- 描述
- 程序计数器是一个较小的内存区域
- 作用
- 记录着当前线程所执行的字节码行号。
- 字节码解释器在工作的时候,通过改变这个计数器的值来选取下一条要执行的字节码指令。
- 分支,循环,跳转,异常处理,线程恢复等功能都需要使用到这个程序计数器。
- 特点
- 线程私有--每个线程都有一个独立的程序计数器。
- 如果当前线程正在执行一个java方法,这程序计数器的值为虚拟机字节码指令的地址,如果执行的是一个
Native
方法,这个计数器的值则为空。 - 程序计数器是唯一没有规定OutOfMemoryError的内存区域。
- 创建时间
- 每个线程启动的时候会创建一个较小的内存区域作为线程的程序计数器
- 销毁时间
- 线程结束时会释放该内存区域
扩展问题1:为什么需要程序计数器?
java虚拟机的多线程是通过线程轮转,分配CPU时间片来执行java程序,当线程切换时,为了能够回到原来的字节码执行位置继续程序的执行,所以每个线程会有一个程序计数器。
扩展问题2:Native方法是什么?
java程序执行的时候调用的方法,有些是用java语言实现的,有些是用其他语言编写实现的,用其他语言实现的方法称为Native方法或本地方法,native方法会使用native
关键字进行标注,如Object
类的getClass()
方法:
public class Object {
public final native Class<?> getClass();
...
}
由于native方法不是java实现的,也就没有字节码行号之说,此时程序计数器的值应当为空(undefined)。
虚拟机栈
- 描述
- 虚拟机栈是描述java方法执行过程的一个内存模型。
- 具体描述:每个方法在执行的时候都会创建一个栈帧,栈帧中存储的是java方法的局部变量表、操作数栈、动态链接、方法出口等信息。java程序在执行的时候每调用一个java方法都会对应的创建栈帧并压入虚拟机栈中,当方法执行完毕,又会将栈帧从虚拟机栈中弹出。虚拟机栈就是栈帧存放的一个栈结构的内存区域。
- 作用
- 描述java方法执行的过程,保存栈帧。
- 特点
- 线程私有
- 此区域可能会有两种内存异常情况:
- 当栈的深度大于虚拟机所限制的最大深度,会抛出StackOverflowError异常。
- 如果虚拟机栈动态扩展无法申请到足够的内存,就会抛出OutOfMemoryError异常。
- 创建时间
- 线程启动的时候
- 销毁时间
- 线程结束的时候
扩展1:局部变量表
局部变量表用于存放编译期可知的各种基本数据类型、对象引用、returnAddress类型(一条字节码指令的地址)。
对于基本数据类型,存放的是变量的名和值;
对于引用类型,存放的是指向对象在堆中的起始地址。
ps: 对于64位的long或double类型的局部变量会占用两个局部变量表空间(Slot),其余的数据类型都是只占用一个局部变量表空间。
局部变量表所需要的空间在编译期间已经计算好了,在一个方法执行时,需要为栈帧分配多少局部变量表空间是完全确定的。
本地方法栈
本地方法栈的特性和虚拟机栈几乎一样。
- 本地方法栈与虚拟机栈的区别
- 本地方法栈为本地方法服务
- 本地方法栈可能出现的异常
- 同虚拟机栈一样可能抛出
StackOverflowError
和OutOfMemoryError
异常。
- 同虚拟机栈一样可能抛出
堆
- 描述
- 堆内存的唯一目的是存放对象实例。
- 堆内存是垃圾收集的主要区域,因此也叫GC堆
- 作用
- 存放对象实例
- 特点
- 虚拟机所管理的内存中最大的一块
- 几乎所有的对象都在堆区分配内存,当然也有例外,JIT编译器有可能会进行优化,直接在栈上分配,有关信息可以直接搜索“逃逸分析”了解,这不在本文的讨论范围内。
- 所有线程共享的一块内存区域
- 堆内存在物理上不一定是连续的,保证逻辑连续即可
- 堆内存区域无法满足分配对象实例所需内存,可能抛出OutOfMemoryError异常
- 堆内存设置固定大小也可以动态扩展,可在启动参数上指定最小大小及扩容的上限。
- 创建时间
- 虚拟机启动的时候就创建了堆内存
扩展1: 堆区细分
jvm为了垃圾回收的方便,将堆划分为新生代
和老年代
,新创建的对象基本上都放在新生代中,而存活比较久的对象则会移到老年代中。新生代和老年代采用不同的垃圾收集算法,可以更高效地回收内存。采用复制算法的新生代还可以细分为Eden
、From Survivor
和To Survivor
。具体的详情是怎样的,为了不偏离这篇文章的主旨,这里先打个问号,后序的文章将会详细介绍堆区的几个划分的用途。
堆区虽然是线程共享的,但是如果设定了启动参数-XX:+UseTLAB
,则开启了本地线程分配缓冲(Thread local Allocation Buffer, TLAB),会为每个线程单独在堆中划分出一个TLAB
,哪个线程需要分配内存,就先在该线程对应的TLAB中分配内存,当TLAB用完,才在堆区的Eden
中继续申请一块TLAB
。
方法区
方法区是用于存放虚拟机加载的类信息、常量、静态变量、编译后的代码等数据。
方法区特点:
- 线程共享
- 方法区大小可固定也可以动态扩展。
- 与堆区一样不需要连续的物理内存,但要求逻辑连续。
- 该区域的垃圾收集目标主要是针对运行时常量池的回收和对类进行卸载。
- 可能出现
OutOfMemoryError
异常。
扩展1:运行时常量池:
class文件中有个常量池,运行时常量池就是class文件中常量池经过类加载后存放的内存区域。
常量池主要存放两类常量:字面量和符号引用。
字面量指字符串,声明为final的常量值等;而符号引用是java编译后生成的各种常量,其包括:
- 类和接口的全限定名
- 成员变量的名称和描述符
- 方法的名称和描述符
jdk1.8
之前,方法区是用永久代
实现的,
在jdk1.7
以下的版本,运行时常量池是方法区的一部分,而jdk1.7
及之后的版本,运行时常量池中的字符串常量池已经不在方法区,而是在java堆中开辟了一块区域作为字符串常量池。
在jdk1.8开始,已经没有永久代的概念,譬如符号引用(Symbols)转移到了native 堆中的元空间;字面量也在 java heap;类的静态变量(class statics)转移到了java heap
扩展2:常量是否只能在编译期产生?
否,运行期也可能将新的常量放入运行时常量池中,比如String
的intern
方法。在jdk1.7的表现如下:
// 如果运行时常量池中,存在"10"这个字符串常量
// 则将常量池中的字符串对象返回,
// 如果不存在,则直接在运行时常量池中创建“10"这个字符串,并将其返回。
String s = String.valueOf(10).intern();
直接内存
前面讲的几块都属于虚拟机管理的运行时数据区域,java程序中也有可能会用到不是虚拟机运行时内存区域的一部分。这块内存我们通常称为直接内存
直接内存不受java堆大小的限制,但是受本机物理内存的限制。
直接内存也可能导致出现OutOfMemoryError异常。
直接内存的例子:
jdk 1.4 加入的NIO类,引入了一种基于通道Channel
和缓冲区Buffer
的IO方式。直接通过Native方法在java堆外的直接内存
中分配内存, 通过存储在java堆中的DirectByteBuffer对象作为这块直接内存的引用。操作DirectByteBuffer即可操作直接内存,这样做的好处是避免了要使用直接内存的时候需要先复制到java堆中。直接操作直接内存更加高效。
点赞是对我最大的鼓励