JVM内存分配与管理详解

2022-09-05 10:39:52 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

概述

了解C 的程序员都知道,在内存管理领域,都是由程序员维护与管理,程序员用于最高的管理权限,但对于java程序员来说,在内存管理领域,程序员不必去关心内存的分配以及回收,在jvm自动内存管理机制的帮助下,不需要想C 一样为每一个new操作去编写delete/free代码,这一切交给jvm,但正是这一切都交给了jvm,一旦出现内存泄漏与溢出,如果不了jvm,那么对于程序的编写与调试将会非常困难,因此了解jvm时怎样分配内存管理是非常关键的,下面我们来介绍一下Jvm内存区域的分配以及常见的内存溢出错误。

一、运行时数据区域

1.程序计数器

程序计数器是一块较小的内存空间,它可以看做当前线程所执行的字节码的行号指示器,在jvm中,虚拟机通过改变程序计数器的值来选取下一条需要执行的字节码的指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖它。 在java多线程中四通过线程轮流切片并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程的指令,每一条线程都需要一个独立的程序计数器,各个计数器之间不相互影响,独立存储,因为这类内存区域称为“线程私有”的内存。此内存区域是唯一一个在jvm中没有规定任何OutOfMemoryError情况的区域。

2.java虚拟机栈

与程序计数器一样,java虚拟机栈也是线程私有的,它的生命周期与线程相同,虚拟机栈描述的是java方法执行的内存模型:每一个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储用于存储局部变量表,操作数栈,动态链接,方法出口等信息,每一个方法从调用到执行 完成的过程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。局部变量表中存放着编译时期的各种基本数据类型(long/double,int,boolean,byte,char,short,float),对象引用(reference类型,它不等于对象本身,可能是指向对象的一个指针,也可能是一个指向对象的一个句柄或者其他与位置有关的信息)和returnAddress类型(指向了一条字节码指令的地址)。其中long和double类型的数据会占用两个局部变量空间(slot),其余数据类型只占用一个局部变量空间,局部变量表在编译时期完成分配,在方法运行期间不会改变局部变量表的空间大小。 在java虚拟机栈中,规定了两种异常状况: StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,将抛出该异常。 OutOfMemoryError:如果虚拟机栈可以动态扩展,当无法申请过到足够的内存,就会抛出该异常。

3.本地方法栈

本地方法栈与虚拟机栈的作用相似,它们之间的区别在于java虚拟机栈为虚拟机执行java方法(字节码)服务,而本地方法栈为虚拟机栈使用到的Native方法服务(在SUN HotSpot虚拟机中将虚拟机栈和本地方法栈合二为一)。

4.Java堆 在Java虚拟机中,java堆是在这部分内存中最大的一块,java堆是被所有线程共享的一块内存区域,这部分内存用于存放对象的实例,几乎所有对象的实例都在这里分配内存。java堆同时也是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆(Garbage Collected Heap)”,从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以java堆还细分为新生代和年老代,再细致点就是Eden空间,From Survivor空间,To Survivor空间等,从内存分配的角度来看,java堆有时还回创建一个多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),但是无论如何分配,都与存放内容无关,都存放的是对象实例,只是为了更有利回收和分配内存,对java堆的内存,可以是一块连续的内存,也可以是不连续的内存,只要逻辑上连续即可,在实际中,这部分内存既可以是固定的内存,也可以是可扩展的(可以通过-Xmx和-Xms控制),如果在堆中没有内存完成实例的分配,并且堆也无法扩展,将会抛出OutOfMemoryError异常。

5.方法区

方法区与java堆一样,都是线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。尽管有时候jvm将方法区描述为堆的一个逻辑部分,但是它有一个别名Non-Heap(非堆),用来与堆进行区分。对于方法区,将它划分为“永久代”,因此这部分可以不实现垃圾收集,但并非这部分区域数据永久存在,这区域主要针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求的时候,将抛出OutOfMemoryError异常。

6.运行时常量池

运行时常量池是方法区的一部分,在class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。常量池的另外一个重要的特性就是具有动态性,java语言并不要求常量一定只有在编译期间才能产生,也就是说并非只有在class文件中常量池中的内容才能放入运行时常量池中,也可以在程序运行期间将新的常量放入池中。当常量池中无法再申请到内存时会抛出OutOfMemoryError异常。

二、对象的创建

1.对象的创建

java作为一种面相对象的编程语言,在程序运行期间无时无刻不在创建着对象的实例,通过new关键字来创建对象的实例,那么这个过程是怎样的呢?下面来让我们一起探讨一下。

在jvm遇到new关键字后,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且去检查这个符号引用代表的类是否已被加载,解析和初始化过,如果没有就必须先执行类加载的全过程。在类加载检查后,jvm将会为新生对象在java堆中分配内存,对象所需要的内存大小在类加载的过程中即可完全确定。 在堆中对内存的分配可以分为两种方式,如果堆中的内存时连续的规整的,那么所有使用的内存放在一边,没有使用的内存放在另一边,中间放着一个指针作为分界的指示器,当为对象分配内存的时候,只需将指针移动,划分出一块没有使用的内存即可,这种分配方式成为“指针碰撞”。另一种方式是堆中的内存并不规整,所有的空闲内存都存储在一块空闲列表中,当为对象分配内存时只需更新该列表即可,这种分配方式成为“空闲列表”。选择哪种分配方式由堆的内存是否规整来决定。 在这里还有一点需要注意的是对象的创建时一个非常频繁的操作,当程序处于高并发的状况下时就不能保证线程安全了,例如当为对象A分配内存,当指针还没有来得及修改时,对象B又同时使用了原来的指针来分配内存,当发生这种情况时,我们有两种解决方案: 1)对分配的内存进行同步处理来保证操作的原子性。 2)把内存分配的动作按照线程划分在不同的空间中,即每一个线程在java堆上预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程需要分配内存就现在这个线程的TLAB上分配。只有TLAB用完并且要分配新的TLAB时才需要同步锁定。jvm是否使用TLAB可以通过-XX: /-UserTLAB参数来设定。

2.对象的内存布局

对象在内存中存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

1)对象头

对象头包括两部分信息: 一部分是用于存储对象自身的运行数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪一个类的实例。当对象是一个java数组的时候,那么对象头还必须有一块用于记录数组长度的数据,因此虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中无法确定数组的大小。

2)实例数据中存储的是对象真正有效的信息。

3)对齐填充这部分并不是必须要存在的,没有特别的含义,在jvm中对象的大小必须是8字节的整数倍,而对象头也是8字节的倍数,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

三、对象的访问定位

对于对象的访问方式有使用句柄和直接指针两种。 1.使用句柄:

如果使用句柄,那么reference中存储的是对象句柄地址,java堆划分一部分内存作为句柄池,句柄中包括了对象实例数据与类型数据各自的具体地址信息。

2.直接指针

直接使用指针,reference存放的就是对象地址,在java堆中就必须考虑如何放置访问实例的类型数据的信息。

使用这两种访问方式,各有优劣,使用句柄最大的好处就是当对象被移动的时候只需要改变句柄的地址,无需修改reference。直接指针访问方式速度快,节省了很多开销对于频繁需要访问的对象。

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/137470.html原文链接:https://javaforall.cn

0 人点赞