Java内存

JVM在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域,这些不同的数据区域共同构成Java的内存结构。Java内存结构内存模型(JMM)是两个不同的概念,本文讨论的是Java的内存结构

一、运行时数据区域

1.程序计数器

程序计数器是当前线程所执行的字节码的行号指示器。每条线程都要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,属于线程私有的内存。程序计数器是唯一一个在Java虚拟机规范里没有规定任何OutOfMemoryError情况的区域,因为它只保存一个固定大小的地址。

2.Java虚拟机栈

虚拟机栈也是线程私有的,每个线程都有独立的栈,栈的生命周期与线程相同。虚拟机栈是描述Java方法执行的内存模型:Java方法在开始执行时都会创建一个栈桢,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个Java方法从调用到执行完成的过程,就是对应栈桢从该线程栈中从入栈到出栈的过程

局部变量表存放编译期可知的8种基本数据类型对象引用reference,其所需内存空间在编译期就完成分配,当程序运行时进入一个方法时,这个方法需要在栈桢中分配多大的局部变量表空间时完全确定的,方法运行期间不会改变局部变量表的大小。

3.本地方法栈

线程私有,虚拟机栈为Java方法服务,本地方法栈为Native方法服务。

4.Java堆

Java堆是所有线程共享的区域,唯一目的就是存放对象实例。从垃圾回收的角度看,Java堆可以细分为新生代和老年代,从内存模型(JMM)的角度看,可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)

5.方法区

方法区也是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。根据HotSpot规范,从垃圾回收的角度,方法区被划为永久代,现在已经改成了元空间(在本地内存Native Memory)。方法区是JVM的规范,永久代/元空间是HotSpot实现方法区的技术细节。

运行时常量池是方法区的一部分。注意Class文件中也有常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。在类加载的链接-解析阶段,会将符号引用替换为直接引用句柄直接指针)。

运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,即常量并不一定只有在编译期(Class文件生成的时期)产生,也就是并非预置入Class文件常量池中的内容才能进入运行时常量池运行时也能将新的常量放入池中。比如String的intern()方法:

  • JDK1.6中,intern()将首次遇到的String实例复制到字符串常量池,然后返回这个复制实例的引用。返回的是指向字符串常量池的引用。
  • JDK1.7后,intern()将首次遇到的String实例的引用保存到字符串常量池中,并返回这个引用。返回的是指向Java堆的引用。

字符串常量池原本作为运行时常量池的一部分,存在于方法区,在JDK1.7之后被剥离出来,移动到Java堆中

二、对象探秘

1.对象创建

通过new关键字来创建对象,JVM遇到new指令时,先去检查对象所属类是否已经加载(包含加载、链接和初始化三个阶段),如果没有,那么需要先执行类加载过程。

类加载完毕后将在Java堆中为对象分配内存,对象所需内存大小在类加载完成后就完全确定了,因此只需要在堆中划分出一块确定大小的空闲内存分配给对象。

内存分配有指针碰撞空闲列表两种方式:

  • 当java堆中内存绝对规整,已占用内存在一边,空闲内存在另一边,采用指针碰撞方法(指针顺移,直到碰撞到已占用内存块)。
  • 当Java堆内存不规整,已使用和未使用的内存块交错排布,采用空闲列表方法(维护一个列表,记录哪些内存块可用,为对象分配一个足够大的内存块)。

Java堆是否规整取决于JVM采用的GC算法

  • 如果采用标记-清理算法,堆内存是不规整的,采用空闲列表方法。
  • 如果采用的是复制算法、标记-整理算法,堆内存规整,采用指针碰撞方法。

内存分配可能存在线程安全问题,解决方案:

  • 对每次分配内存空间(移动指针)的动作进行同步,保证更新操作的原子性。
  • 为每个线程,在Java堆中分配一块本地线程分配缓冲TLAB(线程工作内存),TLAB用完后再同步到主存。相比于第一种方法的优点是效率更高,因为不用频繁的锁,缺点是占用空间,而且不保证即时可见性

内存分配完成后,执行默认初始化,将对象的内存空间初始化为0值。

然后设置对象的对象头(Object Header),保存对象是哪个类的实例、如何找到类的元数据信息、对象的HashCode对象的分代年龄等信息。对象的内存布局可以分为对象头实例数据对齐填充三部分。

最后进行对象的指定初始化,即按照程序猿的意愿(code),初始化对象的各属性值。

2.对象的访问定位

对象的访问通过引用reference,引用方式包括句柄直接指针两种:

  • 使用句柄访问,Java堆中会划分出一块内存作为句柄池,reference中存储句柄地址,句柄中包含了对象地址类型信息的地址。因此访问对象和类型信息都需要两次指针定位

  • 使用直接指针访问,reference中直接存储对象地址,通过对象头中的类型信息指针访问类型信息。因此访问对象只需要一次指针定位,访问类型信息需要两次定位

句柄访问的优势是reference中存储稳定的句柄地址,当对象被移动(GC时)时只需要改变句柄中的对象地址,而reference本身不需要更改(对象的引用可能很多,而对象的句柄只有一个,因此只需要改一次

直接指针访问的优势是访问对象的速度更快,因为节省了一次指针定位的时间开销。

三、内存泄露异常(OutOfMemoryError,OOM)

JVM的内存区域中,除了程序计数器之外,其他几个内存区域都可能会发生OOM异常,虽然叫异常,但是英文全称是OutOfMemoryError属于Java异常体系中的错误Error

1.Java堆溢出

堆溢出包括内存泄漏(Memory Leak)内存溢出(Memory Overflow)两种情况:

  • 内存泄漏该回收的对象没有被回收查看泄漏对象到GC Roots的引用链,定位泄漏代码,找到泄漏对象是通过怎样的路径关联到GC Roots,导致垃圾回收器无法自动回收它们的。
  • 内存溢出内存中的对象还确实都应该活着。检查是否存在某些对象生命周期过长、持有状态时间过长的情况,或者考虑调大堆内存

2.虚拟机栈和本地方法栈溢出

栈内存包括两种异常StackOverflowErrorOutOfMemoryError(OOM)

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的最大深度(往死里递归)。
  • OutOfMemoryError:虚拟机扩展栈时无法申请到足够的内存空间。

单线程情况下,一般都是抛出StackOverflowError多线程的情况下,不断建立新的线程,从而分配新的栈,最终导致没有足够的内存去分配新的栈,导致OutOfMemoryError

如果是因为建立多线程导致的OOM,可以考虑减少堆容量(这样栈空间就更大了)或者减少栈容量来换取更多的线程(栈空间一定,就能分配数量更多的栈了)。精髓是通过“减少内存”的手段来解决内存溢出问题。

3.方法区溢出

JDK8以前,永久代是HotSpot对方法区的具体实现。方法区的溢出有以下可能原因:

  • JDK7之前,频繁使用String.intern()方法(会将String实例从堆中复制到字符串常量池)。
  • 运行期间生成了大量代理类,导致方法区被撑爆,无法卸载。
  • 应用长时间运行,没有重启。

JDK8之后,元空间替换了永久代实现,元空间使用本地内存字符串常量池也从方法区移到了堆中,都能帮助避免方法区泄漏。

文章作者: Moon Lou
文章链接: https://loumoon.github.io/2021/03/17/Java内存/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Moon's Blog