Java 与 C、C++ 最大的区别在于内存管理方面。

对于 C、C++来说, 在内存管理方面,既拥有每个对象的“所有权”,又担负着每个对象生命从开始到终结的维护责任。 而对 Java 来说,在虚拟机自动内存管理机制的帮助下,不再需要为每个 new 操作去写配对的 delete / free 操作,所以不容易出现自动内存泄露和内存溢出等问题。

内存泄露与内存溢出的区别

内存泄露(OutOfMemory):申请的内存空间,用完后没有被清除,导致内存越来越小。(Java 中使用 强引用导致大对象无法被 GC 回收,最后造成内存泄露)

内存溢出(stackOverFlow):内存越界,有栈溢出和缓冲区溢出

Java 内存布局

JVM 在执行 Java 程序时,会将它所管理的内存划分为若干个不同的数据区域。这些区域有着各自的用途,以及创建和销毁时间。JVM 所管理的内存划分如下:


java 堆栈溢出需要重启吗 java栈溢出和堆溢出的区别_常量池


如图所示,有些区域是所有线程共享的,有些是线程私有的。

程序计数器

程序计数器是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码的指令。

每一个线程都需要一个独立的程序计数器,在任何一个确定的时刻,一个处理器都只会执行一个线程中的指令。

如果当前线程正在执行的是一个 Java 方法,计数器里面记录的值是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个 native 方法,这个计数器里面的值为空

此内存区域是唯一一个没有 OOM 情况的区域。ps:OOM(OutOfMemoryError)

作用:记录当前线程执行的字节码行号。

Java 虚拟机栈

虚拟机栈描述的是 Java 方法执行的线程的内存模型:每一个方法被执行的时候,JVM 都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

每一个方法被调用直到执行完毕的过程,就对应着在一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存储了编译器可知的各种 Java 虚拟机基本数据类型、对象引用。 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot*)** 来表示, 其中 64 位长度的 long 和 double 类型的数据会占用两个变量槽, 其余的数据类型只占用一个。 局部变量表所需的内存空间在 译期间完成分配, 当进入一个方法时, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定 的, 在方法运行期间不会改变局部变量表的大小。

Java 虚拟机栈是线程私有的,生命周期与线程类似。

该内存区域会出现的两种异常情况

  • StackOverflowError 异常:线程请求的栈深度大于虚拟机所允许的深度。
  • OutOfMemoryError 异常:如果 Java 虚拟机栈容量支持动态扩容(有些虚拟机不支持,HotSpot),当栈扩容时无法申请到足够的内存就会抛出此异常。

作用:JVM 调用一个方法就同步创建一个栈帧,用于存储局部变量、操作数栈、方法出口等信息。局部变量存储了基本数据类型和对象引用。

本地方法栈

本地方法栈与虚拟机栈功能类似,区别在于虚拟机栈为虚拟机执行 Java 方法(字节码)服务,而本地方法栈则是为虚拟机执行 native 方法服务。

在 HotSpot 虚拟机中,本地方法栈和虚拟机栈合二为一。

Java 堆

Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java 堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。

作用:存储对象实例,几乎所有的对象创建都在这里分配内存。

如果从分配内存的角度看, 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer, TLAB) , 以提升对象分配时的效率。

Java 堆可以处于物理上不连续的内存空间中, 但在逻辑上它应该被视为连续的, 这点就像我们用磁盘空间去存储文件一样, 并不要求每个文件都连续存放。 但对于大对象(典型的如数组对象) , 多数虚拟机实现出于实现简单、 存储高效的考虑, 很可能会要求连续的内存空间。

抛出的异常

  • OOM:Java 堆没有内存完成实例分配,并且堆也无法再进行扩容时抛出,大多数虚拟机实现的堆都是可以扩容的。

方法区

方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。

类型信息:类名、 访问修饰符、 常量池、 字段描述、 方法描述等。

JDK6 之前,方法区被称为“永久代”,存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。

在 JDK 7,将“永久代”中的字符串常量池、静态变量等移到堆内存中

JDK8,废弃了 “永久代”,使用元空间实现方法区,并将“永久代”中的类型信息移动到元空间

运行时常量池

运行时常量池(Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池表(Constant Pool Table) , 用于存放编译期生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性, Java语言并不要求常量 一定只有编译期才能产生, 也就是说, 并非预置入 Class 文件中常量池的内容才能进入方法区运行时常 量池, 运行期间也可以将新的常量放入池中。

直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分, 也不是《Java虚拟机规范》 中 定义的内存区域。

在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区 (Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了 在 Java 堆和 Native 堆中来回复制数据。

直接内存的大小可以通过 -XX:MaxDirectMemorySize 参数设置,默认大小与 Java 堆最大值 (-Xmx 指定)一样

HotSpot 虚拟机对象探究

HotSpot 在 Java 堆中中对象的分配、布局和访问。

对象的创建(分配)

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

2、在类加载检查通过后, 接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定。 为对象分配空间的任务实际上等同于把一块确定大小的内存块从 Java 堆中划分出来。

分配内存的方式有两种:

  • 指针碰撞:如果 Java 堆中内存是绝对规整的, 所有被使用过的内存都被放在一边, 空闲的内存被放在另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
  • 空闲列表:如果 Java 堆中的内存不是规整的, 已被使用的内存和空闲的内存相互交错在一起, 那 就没有办法简单地进行指针碰撞了, 虚拟机就必须维护一个列表, 记录上哪些内存块是可用的, 在分 配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录 。

选择哪种分配方式由 Java 堆中是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力所决定的。

除了如何划分可用空间外,还需要考虑线程安全的问题。 修改一个指针所指向的位置,在并发场景下并非是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

两种方式解决并发场景下的问题

  • 对分配内存空间的动作进行同步处理:使用 CAS + 自旋(失败重试),保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间之中进行: 即每个线程在 Java 堆中预先分配一小块内存, 称为本地线程分配缓冲(Thread Local AllocationBuffer, TLAB) , 哪个线程要分配内存, 就在哪个线程的本地缓冲区中分配, 只有本地缓冲区用完了, 分配新的缓存区时才需要同步锁定。 通过参数 -XX: +/-UseTLAB 来设置虚拟机是否使用本地缓冲区。

3、内存分配完成之后, 虚拟机必须将分配到的内存空间(但不包括对象头) 都初始化为零值。

4、Java 虚拟机对对象进行必要的设置。

上面4个步骤执行完毕后,在虚拟机看来,一个对象就产生了。但在 Java 程序看来,对象的创建才刚刚开始,构造函数还没有执行。

对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

对象头部分包括两类信息

  • 用于存储对象自身的运行时数据:哈希码,GC 分代年龄、锁状态标志、线程持有的锁、 偏向线程ID、 偏向时间戳等 ,“Make Word”。
  • 类型指针:对象指向它的类型元数据的指针。

实例数据部分

对象真正存储的有效信息, 即我们在程序代码里面所定义的各种类型的字段内容, 无论是从父类继承下来的, 还是在子类中定义的字段都必须记录起来。

这部分的存储顺序会受到虚拟机分配策略参数(-XX: FieldsAllocationStyle参数) 和字段在 Java 源码中定义顺序的影响。

HotSpot虚拟机默认的分配顺序为longs/doubles、 ints、 shorts/chars、 bytes/booleans、 oops(OrdinaryObject Pointers, OOPs) 。 相同宽度的字段总是被分配到一起存放,在满足这个条件后,父类中定义的变量会出现在子类之前。

如果 HotSpot 虚拟机的 +XX: CompactFields 参数值为 true(默认就为true) , 那子类之中较窄的变量也允许插入父类变量的空隙之中, 以节省出一点点空间。

对齐填充

仅仅起到占位符的作用。HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8字节的整数倍, 即任何对象的大小都必须是 8 字节的整数倍。如果没有对齐的话,就使用对齐填充来补全

对象的访问定位

Java 程序通过栈上的 reference 来操作堆上的对象。而 reference 通过什么方式定位、访问堆中对象的具体问题,不同的虚拟机有不用的实现方式。

主流的访问方式有两种:

  • 句柄:Java堆中将可能会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址, 而句柄中包含了对象实例数据与类型数据各自具体的地址信息 。
  • 直接访问Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址 。

HotSpot 主要采用的是直接访问方式。