Java内存区域
一 介绍
Java内存区域不同于Java内存模型(JMM),
Java内存区域是指 JVM运行时将数据分区域存储 ,简单的说就是不同的数据放在不同的地方。通常又叫 运行时数据区域。
Java内存模型(JMM)定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。而Java 程序员把内存控制权利交给 JVM,一旦出现内存泄漏和溢出等问题,若不了解虚拟机是怎样使用内存的,那么排查错误将十分困难。
二 Java运行时数据区
JDK1.8之前:
![运行时数据区JDK1.8-](https://img-blog.csdnimg.cn/20210624131001698.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzE4MTE1NTAx,size_16,color_FFFFFF,t_70#pic_center)
JDK1.8之后:
![运行时数据区JDK1.8+](https://img-blog.csdnimg.cn/20210624131327694.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzE4MTE1NTAx,size_16,color_FFFFFF,t_70#pic_center)
两者区别在于JDK1.8之前方法区的实现是永久代;
JDK1.8之后方法区的实现是元空间。
- 元空间使用的是直接内存,只受本机内存限制,而永久代有一个 JVM 本身设置的固定大小上限,无法进行调整;
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它有两个作用:
- 由于JVM可以并发执行线程,因此会存在线程之间的切换,而这个时候就程序计数器会记录下当前程序执行到的位置,以便在其他线程执行完毕后,恢复现场继续执行;
- 另一个作用就是字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
JVM会为每个线程分配一个程序计数器,与线程的生命周期相同。
如果线程正在执行的是应该Java方法,这个计数器记录的是正在执行虚拟机字节码指令的地址;
如果正在执行的是Native方法,计数器的值则为空(undefined)。
程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
2.2 虚拟机栈
虚拟机栈 描述的是 Java 方法执行的内存模型。
每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
![虚拟机栈](https://img-blog.csdnimg.cn/20210624140945776.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzE4MTE1NTAx,size_16,color_FFFFFF,t_70#pic_center)
栈帧结构如下:
![栈帧结构](https://img-blog.csdnimg.cn/20210624141345789.png#pic_center)
- 局部变量表
主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
- 操作数栈
在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
- 动态链接
指向运行时常量池的方法引用。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里,程序运行时将其加载进方法区的运行时常量池中。
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
符号引用即用用字符串符号的形式来表示引用,其实被引用的类、方法或者变量还没有被加载到内存中;
直接引用则是有具体引用地址的指针,被引用的类、方法或者变量已经被加载到内存中。
- 方法出口
Java 方法有两种返回方式:return 语句和抛出异常。不管哪种返回方式都会导致栈帧被弹出。
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。
- StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
2.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
2.4 堆
堆是垃圾收集器管理的主要区域,又称为“GC堆”,是Java虚拟机管理的内存中最大的一块。Java堆是线程共享的区域。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
现在的虚拟机(包括HotSpot VM)都是采用分代回收算法。在分代回收的思想中,
把堆分为:新生代+老年代+永久代(1.8没有了);
新生代 又分为 Eden + From Survivor + To Survivor区。
JDK1.8之前:
![JDK1.8-堆内存](https://img-blog.csdnimg.cn/20210624145020620.png#pic_center)
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
![JDK1.8+堆内存](https://img-blog.csdnimg.cn/20210624145153675.png#pic_center)
2.5 方法区
方法区与 Java 堆一样,是所有线程共享的内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-
运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
-
常用参数:
//JDK1.8之前
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
//JDK1.8之后
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
三 HotSpot 虚拟机对象探秘
3.1 对象的创建
Java 中提供的几种对象创建方式:
- 使用new关键字;
- 使用Class类的newInstance方法;
- 使用Constructor类的newInstance方法;
- 使用Clone的方法;
- 使用反序列化。
创建流程:
![Java创建对象过程](https://img-blog.csdnimg.cn/20210624153736177.png#pic_center)
3.1.1 类加载检查
虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。
3.1.2 分配内存
若Java堆中内存是绝对规整的,使用指针碰撞方式分配内存;如果不是规整的,就用空闲列表法。
-
指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
-
空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
划分内存时还需要考虑一个问题-并发,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题.也有两种方式:
-
CAS同步处理:对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);
-
本地线程分配缓冲(Thread Local Allocation Buffer, TLAB):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
然后内存空间初始化操作。
3.1.3 初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
3.1.4 设置对象头
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
3.1.5 执行Lnit()方法
执行 new 指令之后会接着执行 init()方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3.2 对象的内存布局
Java的实例对象、数组对象在内存中的组成包括如下三部分:对象头Hearder、实例数据、内存填充。如下:
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
3.3 对象的访问定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。
- 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
- 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
四 内存溢出异常
内存溢出就是你要求分配的内存超出了系统能给你的,系统不能满足需求,于是产生溢出。 内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。
理论上来说,Java是有GC垃圾回收机制的,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。
但长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就导致java中内存泄露。内存泄漏有可能会导致内存溢出。
4.1 栈溢出
- 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误;
- 若 本地方法栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误
4.2 堆溢出
- OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误;
- java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
4.3 方法区溢出
- JDK1.8之前,永久代也是java堆内存的一部分,主要用来存放Class的相关信息,如类名,访问修饰符等等。一般永久代溢出的原因是动态加载大量的Class并且没有及时被GC回收。只能通过调整永久代内存参数的方式解决;
- JDK1.8之后,方法区的实为元空间,元空间使用的是直接内存,受本机可用内存的限制,元空间仍旧可能溢出,但比原来出现的几率会更小。当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace。
4.4 其他情况
操作系统对每个进程的内存都是有一定限制的,当堆内存和非堆内存分配过大时,剩余的内存不足以创建足够的线程栈,就会产生OutOfMemoryError。因此我们可以增大进程占用的总内存或减小堆内存等来解决问题。