java内存区域及对象的创建过程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/m0_38001814/article/details/88775403

前言

对于我们java程序员来说,在虚拟机自动内存管理机制的帮助下,我们并不需要关心对对象的内存分配与回收,不太容易出现内存泄漏和内存溢出问题,但是我们还是有必要去了解虚拟机这块的内存区域知识。

运行时数据区域

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途以及创建和销毁的时间。下面先列出两张图以便能有个大概的认识先:

接下来就对这四大区域作一个简单的介绍

程序计数器:是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成,例如通过改变这个计数器的值来选取下一条需要执行的字节码指令。在多线程环境下,每个线程都会有个独立的程序计数器,他们之间互不影响,独立存储。同时该内存区域也是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError(OOM)情况的区域。

栈:一般我们所说的栈内存往往就是虚拟机栈,而上面列出的大纲中所指的本地方法栈其实与虚拟机栈所发挥的作用是非常相似的,唯一的区别不过是虚拟机栈是为虚拟机执行java方法(也就是字节码)服务,而本地方法栈则为虚拟机所使用的native方法服务。下面重点介绍下虚拟机栈,本地方法栈可类推:

虚拟机栈:与程序计数器一样,同样是线程私有的,生命周期与线程相同。如上文所说,它主要描述的是java方法所执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表(8大基本数据类型+reference<对象引用类型>+returnAddress<指向了一条字节码指令的地址>)、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。当线程所请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError即栈内存溢出,如不合理的递归方法。

堆:是java虚拟机管理中内存区域最大的一块,是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。java堆是垃圾收集器管理的主要区域,从内存回收的角度来看,堆还可以分为新生代和老年代,新生代中还可分为Eden空间、两个Survivor空间(From和To),下次写垃圾收集时会重点讲这些空间的作用。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区:与java堆一样,也是各个线程所共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。关于常量池需要注意,在class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息即是常量池,用于存放编译器生成的各种字面量和符号引号,这部分内容将在类加载后进入方法区的运行时常量池中存放。同时java语言并不要求常量一定只有编译器才能产生,运行期间也可能将新的常量放入池中,如String类的intern()方法。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载,当方法区无法满足内存分配需求时,将抛出OOM异常。

OutOfMemoryError异常

即上面一直所说的OOM异常,也叫堆内存溢出,我们知道java堆用于存储对象实例,只要不断的创建对象并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制来清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生堆内存溢出。

对象的创建

java是一门面向对象的编程语言,在程序运行过程中无时无刻都有对象被创建出来,而我们通常仅仅只需要一个new关键字,对象就能被成功创建,那么在虚拟机中,对象的创建过程到底是怎么样的呢?简单来说分为四个步骤:类加载、分配内存、初始化、必要的设置,下面详细讲解下

一、当虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。

二、在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,因此为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。接下来便是选择哪种分配方式为对象分配内存了,这里可能初读会有点不太理解,建议了解完垃圾收集算法之后再看会懂的快很多。

内存分配方式:所采用的垃圾收集器是否带有压缩整理功能  =>  java堆是否规整(越少的内存碎片即越规整) => 分配方式

因此分配方式大致有两种,"指针碰撞"(Bump the Pointer)与"空闲列表"(Free List)

指针碰撞:假设java堆中内存是绝对规整的,所有用过的内存都放在一遍,空闲的内存放在另一边(这种情况通常是由带有标记-整理算法的收集器所产生的),中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪到一段与对象大小相等的距离,这种分配方式就称为"指针碰撞"

空闲列表:如果java堆中的内存并不是规整的,即已使用的内存和空闲的内存相互交错,那就没有办法进行简单的指针碰撞了,虚拟机就必须维护一个列表,用于记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式就称为"空闲列表"

分配内存并发问题:

由于对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案有两种:

     a、对分配内存空间的动作进行同步处理:实际上虚拟机就是采用CAS算法配上失败重试的方式保证更新操作的原子性

     b、把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

三、内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在java代码中可以不赋初始值就能直接使用,程序能访问到这些字段的数据类型所对应的零值

四、最后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息都存放在对象的对象头中。

写在最后

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。如果对这3块区域感兴趣的话可自行百度。

对象的访问定位

建立对象是为了使用对象,我们的java程序需要通过栈上的reference数据来操作堆上的具体对象,由于reference类型在java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象的访问方式也是取决于虚拟机实现而定,主流的访问方式分为使用句柄和直接指针两种:

a、使用句柄访问的话,那么java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。

b、使用直接指针访问,reference中存储的直接就是对象地址,与上面相比节省了一次指针定位的时间开销成本。

针对HotSpot虚拟机来说,是使用第二种方式来进行对象访问的。

猜你喜欢

转载自blog.csdn.net/m0_38001814/article/details/88775403