JVM虚拟机学习(一)JVM的内存划分

 

1. 运行时数据区域

在执行java程序的过程中,jvm划分了多个区域,每个区域各自实现着自己的作用,有些区域是在jvm执行的整个过程中存在,有些则伴随着代码属性或方法的创建而创建、销毁而销毁,总的来说,jvm的运行时区域可以分为以下几个区域:

(本图为引用)

下面就分别来介绍这几个区域的作用。

1.1 程序计数器

可以将它看做是当前线程所执行的字节码的指示器,提示了当前线程应该去执行哪条指令,通过改变计数器的值来选取下一条需要执行的字节码指令。

每个线程拥有一个独立的程序计数器,互不影响。如果执行的是一个java方法,则计数器指向的是正在执行字节码的地址;如果执行的是一个Native方法,则计数器为空。

这里简单说一下Native方法,native是指以Java代码调用执行非java代码的方法,运行的时候用java进行调用,实际执行的是不用语言实现的程序,出现这个naïve方法主要是以下三种情况:

Java程序与其他语言的交互;

JVM与操作系统的交互中可能会用到;

JVM部分的模块是以其他语言编写,如用C语言实现,所以也会用到native方法;

1.2 虚拟机栈

也是线程私有的,每个线程有一个虚拟机栈。主要描述的是方法执行的内存模型。线程每进入一个方法,执行每个方法的时候,就会向虚拟机栈中压入一个栈帧,这个栈帧中存放当前方法的局部变量表、操作数栈、方法出入口信息等、供方法执行时的获取和调用。

(图画的有点拙劣,将就一下看)

这里解释一下局部变量表,主要存放了三类信息:

  1. 编译期可知的基本数据类型(byte/short/int/long/char/Boolean/float/double),其中64位长度的long和doouble会占两个局部变量空间。
  2. 对象引用,存放非基本类型的对象引用,可能是指向对象存放地址的指针,或代表对象的一个句柄。(注意不是存放对象本身)
  3. Return Address:方法结束后返回地址,指向了一条字节码指令地址,供程序计数器使用,指示到返回地址进行执行。

对于局部变量表,每个方法所需的局部变量表都在编译期分配完成,所以在线程进入方法的时候,这个方法需要压入虚拟机栈中的栈帧大小已经确定,并且在执行过程中不会改变局部变量表的大小。

在局部变量表这个区域,通常会出现两种异常状况:

  1. 如果线程在进入方法时请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常;
  2. 虚拟机栈是可以动态扩展的(也可以设置固定大小的虚拟机栈),如果在扩展时无法申请到足够的内存,则会出现OutOfMemoryError异常

1.3 本地方法栈

本地方法栈发挥的作用与虚拟机栈相同,只是虚拟机栈是用于执行java方法,而本地方法栈是用于执行 Native方法。两者原理相同,所以也会抛出StackOverflowError和OutOfMemoryError异常

1.4 Java堆

Java堆得作用在于存放对象实例,几乎所有的对象实例都是存放在堆中(不绝对,部分对象也会分配在栈上,以后细说)。Java堆是被所有线共享的内存区域。

Java堆可与处于物理不连续的内存空间中,只要逻辑上是连续的就可以,伴随着JVM的启动而创建,可以是固定大小,也可以动态扩展(他通过-Xmx和-Xms控制),在进行动态扩展时,如果申请不到足够的空间,同样会抛出OutOfMemoryError异常。

此外,java堆是垃圾收集器管理的主要区域,关于垃圾回收器的管理,放到后面说。

1.5 方法区

每个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码数据。

这里简单说一下常量池,也是属于方法区的一部分,用于存放编译期生成的各种字符常量和符号引用,在类加载完毕后放入方法区的常量池存放。除此之外,运行期间也能将新的常量放入池中,比如String类的intern()方法。

当调用 intern 方法时,如果池中已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。

1.6 直接内存

直接内存不是虚拟机运行时数据区的一部分,但是这部分内存也会经常使用到,所以这里说一下;

对于NIO类,引入了Channel和Buffer,他可以使用Naïve函数库来分配堆外的内存,然后通过存储在堆中的DirectByteBuffer对象作为这块堆外内存的引用进行操作,这块堆外内存就是直接内存,避免了堆内堆外来回复制数据。就像下面这个样子:

 

(找个时间还是好好练习一下画图吧。。。。)

2. 对象的创建

   在执行一个new语句时,首先是会检查是否能在常量池中定位到一个类的符号引用,并检查这个类是否已被加载、解析和初始化,如果没有,则先进行类的加载过程(类的加载过程放到后面说)

在类加载后,要为新生的对象分配内存,为对象在堆中分配一块内存出来,一般有两种方法:

  1. 指针碰撞法:假设所有用到的内存放到堆中的一边,没用到的内存放到另一边,中间存放一个指针进行隔离,在进行内存分配的时候,就将指针向空间内存区域移动相应大小的空间。

  1. 空闲列表法:已使用的内存和未使用的内存是交错的,虚拟机会维护一个空闲列表,记录未被使用的空间,在进行分配时,根据需要分配的空间,在空闲列表中找到一个足够大的空间分配给对象。

内存空间的分配在于堆内存是否规整,内存是否规整又取决于垃圾回收算法的压缩整理功能,所以基于不同的垃圾回收算法选择不同的分配机制。

此外,还有一个问题在于对象的创建是频繁的,在并发情况下必须考虑到线程的安全问题,一般有两种方法来实现内存分配的线程安全性:

  1. 对分配空间的动作进行同步处理,保证原子性
  2. 把内存分配的动作按线程分在不同的空间中进行,预先分配一小块内存,成为本地线程分配一个缓冲的TLAB空间,哪一个线程需要分配内存,就在对应线程的TLAB空间中进行分配。

内存分配完成后,虚拟机就会将分配好的内存空间都初始化为零,保证对象的实例在java代码中可以不赋初始值就直接使用。

紧接着,会对对象进行一些必要的设置,比如设置对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等,这些信息统一存放到对象头中(Object Header),关于对象头后续再说。

到此,在虚拟机层面,一个新的对象已经产生,但从java代码的层面上卡,还需要对新的对象执行初始化方法init,把对象按照程序员的意愿进行初始化。自此,一个真正可用的对象才算创建完毕。

3. 对象的内存布局

 在堆中存储的对象,中主要分为三个部分:对象头、示例数据和对齐填充。

  1. 对象头:一部分主要存放一些自身运行时的数据,例如,哈希码、GC分代年龄、锁状态标志,线程持有的锁、偏向时间戳等(关于提到这些信息,可以先不作了解,在后续深入学习的时候回一一接触到)。另一部是指针类型,即指向它自身的类型的类元数据指针,来确定这个对象是哪个类的实例。
  2. 示例数据:这部分存放对象的有效信息,也是程序代码中所定义的内容,包含从父类继承的和自己新建的对象和变量。 
  3. 对齐填充:无特别含义,主要是因为对象的大小规定必须是8个字节的整数倍。因为对象头为8个字节,所以主要用于示例数据区域如果没有满足8个字节整数倍,则使用过对齐填充。

4. 对象的访问定位

    之前说到过,虚拟机栈的每个栈帧中,包含了基本数据类型、对象引用(Reference)、返回的程序计数器地址。栈中对存储在堆中对象的引用就是通过Reference实现的。主要有两种实现方式:

  1. 使用句柄访问,java堆中会专门开辟一个空间来存放句柄,叫句柄池,Reference存放的是句柄的地址,句柄中包含了需要访问对象的地址信息。优势在于在对象被移动时,改变的只是句柄中的对象地址信息,而不需要Reference中的句柄信息。
  2. 使用指针访问,Reference中存放的就是对象在java堆中的地址信息。优势在于速度更快,因为减少了一次指针定位的时间开销。

4. OutOfMemoryErorr异常简述

4.1 Java堆溢出

前面提到了,Java堆用于存储对象实例,如果不断的新建对象,并且保持GC Roots到对象之间有可达路径不被垃圾回收器回收,如果新建对象的数量达到了最大堆的容积,就会产生内存溢出。

这里讲一下内存泄漏和内存溢出的而区别。

内存泄漏说的是程序在申请内存后,使用之后不归还,无法释放已申请的空间。一次内存泄漏不会有什么影响,但是时间久了,内存就会因为泄漏可利用空间越来越少,到最后,就没有可用的空间了。

内存溢出:上面说到的最后,申请新的对象没有足够的空间了,这个问题就是内存溢出。所以内存泄漏是造成内存溢出的原因之一。

对于内存泄漏,需要通过工具检查泄漏的对象到GC ROOT的引用链,检查是怎样的路径导致垃圾收集器无法回收,掌握泄漏对象的类信息,定位到泄漏代码的位置。

而对于内存溢出,则可以调节虚拟机的参数,设置内存大小,或者查看的代码中对象的时间是否持有过长、生命周期是否过长,减少运行时期的内存消耗。

4.2 虚拟机栈和本地方法栈溢出

    因为针对于HotSpot虚拟机,并不区分虚拟机栈和本地方法栈,所以这里放到一起说。

    这个区域主要会出现两个异常,一个是请求深度大于虚拟机规定的最大深度,造成StackOverFlowError。另一个是扩展栈时无法申请到足够的空间,造成OutOfMemoryErorr。

    这里有一个问题,当栈空间无法分配的时候,到底是因为内存太小,还是已使用的栈空间太大,实际上是对同一种情况的不同描述。

    针对于单线程来说,不管是减少栈空间,还是定义大量局部变量,增大局部变量表,都是造成StackOverFlowError异常。

    对于多线程,如果给每个线程分配的栈空间越大,那么到最后可分配的剩余空间就越小,最后剩余的空间不够分配线程的栈帧,就会造成OutOfMemoryErorr异常。所以针对于多线程的这种异常,解决办法是减少给线程分配的栈内存。(同理,堆中也是这样)

4.3 方法区溢出

    方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码数据、还有就是常量池。所以这个区域的溢出一般是产生了大量的类,导致溢出。此外,在常见的开发中,很多主流框架,会对类进行增强,使用CGlib这样的代理功能动态代理类,增强类,增强的类越多,就需要越大的方法区存储动态生成的class,动态生成的越多,代表着方法区就会分出更多的空间去存储。

4.4本机直接内存溢出

     由直接内存导致内存溢出,一般不会再Heap Dump文件中出现明显的异常,如果在出现OOM异常之后,dump文件很小,而程序中应用到了NIO,就可以查看一下是不是内存溢出这原因。

   关于Heap Dump这个文件,放到后续再讲。

本篇文章质检单的讲了一下JVM的内存划分,及对象的简单创建。其实也可以看出,文中多次提到了关于jvm的垃圾管理,对于JVM来说,垃圾回收管理是不可分裂来说的,每一个对象从建立、使用、销毁的整个过程都有它的参与,所以下一篇会学习一下关于JVM的垃圾收集器及运作方法。

   由于本人也是刚开始学习JVM,所以文中如果出现了不对的地方,欢迎大家指正。还有我所学习的可能有时候会很片面,也请大家进行提醒,交流分享,谢谢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值