【深入理解java虚拟机】-- 自动内存管理机制

本系列内容,大量引用自《深入理解java虚拟机》,说是照抄一遍也不为过。不过作者自己也加入了一些图文用来帮助理解。

java内存区域与内存溢出异常

运行时数据区域

​ java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机的进程的启动而存在,有的预取则依赖用户线程的启动和结束而销毁。

​ Java虚拟机所管理的内存包括以下几个运行时数据区域:(下图中图形大小,不代表实际大小比例,只表示相对关系)

​ 从上面的图可以清晰的看出,jdk1.8之后取消了方法区,本地内存中增加了一个元空间。jdk1.8中方法区由元空间实现,现在的元空间可以看作之前的方法区(也称为永久代)。

程序计数器

程序计数器是一块较小的内存空间。它可以看作是当前线程锁执行字节码的行号指示器。在虚拟机的概念中,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。

​ 由于java虚拟机中多线程的实现,因此每一个线程都有自己独立的程序处理器,保证各线程之间计数器不受影响。

如果线程执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行的是native方法,这个计数器则为空(null)。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemotyError情况的区域。

java虚拟机栈

与程序计数器一样,java虚拟机栈也是线程私有的。他的生命周期与线程相同,虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,可以这么理解栈帧,虚拟机栈包含N个栈帧每个栈帧包含局部变量表,操作数栈,动态链接,方法出口等信息)。每个方法从调用到执行完成这个过程,就对应这一个栈帧在虚拟机栈中的入栈到出栈的过程。

局部变量表

局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

其中64位长度的long和double类型会占用2个局部变量空间,其余的数据类型只会占用1个局部变量空间。局部变量表所需的内存空间在编译期间完成内存分配,当进入一个方法时,这个方法需要在帧中分配多大的内存空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在java虚拟机规范中,对这个区域规定了两种异常状态:如果线程请求的栈的深度大于虚拟机允许的深度,将抛出StackOverFlowError异常(栈溢出),如果虚拟机栈可以动态扩展(现在大部分java虚拟机都可以动态扩展,只不过java虚拟机规范中也允许固定长度的java虚拟机栈),如果扩展时无法申请到足够的内存空间,就会抛出OutOfmMemoryError异常(没有足够的内存)

操作数栈、动态链接,方法出口后面的章节会讲到。

本地方法栈

​ 本地方法栈与虚拟机栈所发挥的作用是相似的。区别在于虚拟机栈是为java方法服务,而本地方法栈则为虚拟机使用的native方法服务。HotSpot虚拟机中将本地方法栈和虚拟机栈合二为一。与虚拟机栈一样本地方法栈也会抛出StackOverFlowErrorOutOfmMemoryError异常。

java堆

​ 堆是被所有线程共享的一块内存区域,也是java虚拟机中管理的最大一块区域。几乎所有的对象实例都在这里分配,java虚拟机中虽然规定,所有的对象实例以及数组都需要在堆上分配,但是随着JIT编译器的发展与逃逸技术逐渐成熟,栈上分配、标量替换技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

​ java栈与堆相比,java栈存储速度比较块,但由于存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。

​ 堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。

方法区

​ 方法区也被称作为”永久代“,与堆一样方法区也是线程共享的内存区域。用来存储类的信息,例如:方法,方法名,返回值,常量。当它无法满足内存分配需求时,方法区会抛出OutOfMemoryError。

​ jdk1.7以后取消了方法区,用元空间替代。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。这样的好处就是元空间不会受到堆内存大小的限制,只跟本地内存有关,所以不会出现永久代存在时的内存溢出问题。但也不能滥用,不然会耗尽本地内存。

运行时常量池

常量池:class文件常量池,是class文件的一部分,用于保存编译时确定的数据。

运行时常量池

  Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。类加载后,常量池中的数据会在运行时常量池中存放

字符串常量池

​ HotSpot VM里,记录interned string的一个全局表叫做StringTable,它本质上就是个HashSet<String>。注意它只存储对java.lang.String实例的引用,而不存储String对象的内容

jdk 1.7后,移除了方法区,字符串常量池依旧保持在堆中,常量池移动到元空间中了

元空间

​ Meta Space是JDK1.8引入的,在JDK1.8使用的是方法区,永久代(Permnament Generation)。
元空间存储的是元信息,使用的是操作系统的本地内存(Metaspace与PermGen之间最大的区别),可以是不连续的,由元空间虚拟机进行管理。可以产生OutOfMemoryError。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

hospot虚拟机对象探秘

对象的创建

new指令:虚拟机遇到一条new的指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号代表的类是否已被加载、解析和初始化过。如果没有,那么先执行相对于的类加载过程。类加载检查后,接下来虚拟机将对新生对象分配内存。

分配内存:
  • 指针碰撞:假设java堆中内存时绝对规整的,所有用过的内存放在一边,空闲的放到另外一边,中间放一个指针做为分界点的指示器,那么分配内存仅仅只需要将指针往空闲空间的那边移动与对象大小相等的距离。

  • 空闲列表:如果java堆中内存并不是规整的,已使用和未使用的互相交错,那么就没办法使用指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,再分配时找到一块足够大的空间,分给对象实例并记录为已使用。

使用哪种方式是由堆是否规整来决定的,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

使用指针碰撞来分配从内存的收集器:

​ Serial、ParNew等带Compact过程的收集器

使用空闲列表来分配从内存的收集器:

​ CMS这种基于Mark-Sweep算法(标记-清除算法)的收集器

​ 除了如何划分可用空间外,对象的创建是否频繁也是考虑一个点。即使是修改指针位置,在并发情况下也会出现,A在计算完需要分配的内存还未移动后,B又使用原来的指针分配内存。而解决这种情况有两种方法:

  1. 采用cas+失败重试机制,保证更新的原子性(跟java中,AQS中获取锁方式一样,通过cas修改状态值,失败后,循环重试,直到成功或抛出异常为止)
  2. 把内存分配的动作按照线程划分到不同的空间中进行,即每个线程在java堆中预分配一块较小的区域,成为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程需要分配内存,就在那个线程的TLAB上分配,只有TLAB用完了并分配新的TLAB时,才需要锁定。是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设置。

初始化:内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为0值(不包括对象头)。如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步保证了对象的实例字段在java代码中不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

对象的初始设置:接下来虚拟机要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄。这些信息存放在对象头中(Object Header)。根据当前虚拟机运行状态不同是否需要启用偏向锁等,对象头会有不同的设置。

init方法:上面的工作都完成之后,从虚拟机的角度看,一个新的对象已经产生了,但是从java的角度来看,对象的创建才刚刚开始,init方法还没有执行,所有的字段都还为零,所以一般来说执行new指令之后会接着执行init方法,这时候才算真正完成对象的创建。

对象的内存布局

​ 在Hotspot虚拟机中,对象在内存中储存的布局可以分配三块区域:对象头(Object Header)、实例数据(Instance Data)和对其填充(Padding)。

​ Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程id、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。

以下描述,引用自https://www.cnblogs.com/duanxz/p/4967042.html

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否偏向锁 锁标志位
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC标记 11
偏向锁 线程ID Epoch 对象分代年龄 1 01

HotSpot虚拟机对象头Mark Word

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

​ 对象头的另一部分是类型指针,即元素指向它的类元数据的指针,虚拟机通过这个指针来确定是哪个对象的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话来说,查找对象的元数据信息并不一定要经过对象本身,另外如果对象是一个java数组,那么对象头还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据信息确定java对象的大小,但是从数据的元数据中却无法确认数组的大小。

  • Mark Word(标记字段):对象的Mark Word部分占4个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。
  • Klass Pointer(Class对象指针):Class对象指针的大小也是4个字节,其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址
  • 对象实际数据:这里面包括了对象的所有成员变量(无论是从父类继承下来的,还是子类中定义的),其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节。
  • 对齐:最后一部分是对齐填充的字节,按8个字节填充。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

Mrak Word 三大作用:

  • 记录锁信息,通过更改锁标志加锁
  • 记录GC信息,由于Mrak Word中只为对象分代年龄分配了4bit,所以记录的最大数也就是1111,转换为十进制也就是15,所以锁年龄最大为15。
  • 记录hashcode(identifyHashCode)

System类提供一个identifyHashCode(Object o)的方法,该方法返回指定对象的精确hashCode值,也是根据该对象的地址计算得到的HashCode值。当某个类的hashCode()方法被重写之后,该类实例的hashCode()的方法就不能唯一标识该对象。但是通过identifyHashCode()方法返回的依然是hashCode()值,依然是根据该对象的地址计算得到 hashCode值。所以两个对象的identifyHashCode()值相同,该对象就绝对是一个对象

对象的访问定位

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

  1. 句柄:java堆中划分一块内存出来锁为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的地址信息。使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

  2. 直接指针:java堆对象布局中就必须考虑如何放置类型数据的相关信息,而reference中存储的直接就是对象地址。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot使用的是直接指针这种方式访问对象的。

    下图为jdk 1.7中,两种访问方式定位的内存结构图,为了方便描述,将reference一分为二,实际中只会有一个reference。

放一张自己翻阅资料后,画的一张JVM内存结构图。

如果有错误,还请在评论区中指出哦。

# ,

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×