记录 学习《深入理解Java虚拟机》以及 JavaGuide

在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。

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

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域。

线程私有的:

  • 程序计数器
  • 本地方法栈
  • 虚拟机栈

线程共享的:

  • 方法区
  • 直接内存(非运行时数据区的一部分)

程序计数器

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

为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,称这类内存区域为“线程私有”的内存

程序计数器主要有两个作用

  • 字节码解释器通过改变计数器的值来依次读取指令,从而实现代码的流程控制
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来时能知道该线程上次运行的位置

如果线程正在执行一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址

如果正在执行的是一个native方法,计数器的值为空(Undefined)。

程序计数器是唯一一个在Java虚拟机规范中不会出现$\color{Red}{OutOfMemoryError}$的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java virtual machine stacks)也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。生命周期与线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的

Java内存区粗糙的分为**堆内存(Heap)栈内存(Stack)**,栈就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。

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

64位长度的 long 和 double类型的数据占用2个局部变量空间(Slot),其余数据类型只占1个。

Java虚拟机栈会出现两种错误:$\color{Red}{StackOverFlowError}$ 和$\color{Red}{OutOfMemoryError}$

  • $\color{Red}{StackOverFlowError}$:若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出$\color{Red}{StackOverFlowError}$错误
  • $\color{Red}{OutOfMemoryError}$: Java虚拟机栈的内存大小可以动态扩展,如果虚拟机在动态扩展时无法申请到足够的内存空间,则抛出$\color{Red}{OutOfMemoryError}$异常

栈帧是方法运行期很重要的基础数据结构

HotSpot虚拟机的栈容量是不可动态扩展的,Classic虚拟机可以动态扩展,所以在HotSpot虚拟机上不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常

扩展:方法/函数如何调用?
Java栈可用类比数据结构中栈,Java栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出

Java方法有两种返回方式:

  • return语句
  • 抛出异常

不管哪种方式都会导致栈帧被弹出

本地方法栈

和虚拟机栈发挥的作用相似,区别:虚拟机栈为虚拟机执行Java方法(也就是为字节码)服务,而本地方法栈则为虚拟机执行Native方法服务。在hotspot虚拟机中,直接将本地方法栈和虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈中也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧会出栈并释放内存空间,也会出现$\color{Red}{StackOverFlowError}$ 和$\color{Red}{OutOfMemoryError}$两种错误

Java堆

Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域唯一的目的就是存放对象实例,几乎所有对象实例以及数组都在这里分配内存,随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术,将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从JDK1.7开始默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还细分为 新生代 和 老年代。再细致一点有:Eden空间、From Survivor空间、To Survivor空间等。进一步划分的目的是为了更好的回收内存,或者更快的分配内存

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

JDK堆内存结构-JDK7

JDK 8版本之后方法去(hotspot的永久代)被彻底移除了,取而代之的是元空间,元空间使用的是直接内存
JDK堆内存结构-JDK8

上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  • $\color{Red}{OutOfMemoryError: GC Overhead Limit Exceeded}$:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  • $\color{Red}{java.lang.OutOfMemoryError: Java heap space}$ :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发j$\color{Red}{java.lang.OutOfMemoryError: Java heap space}$ 错误。(和本机物理内存无关,和你配置的内存大小有关!)

方法区

方法区和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

方法区也被称为永久代

方法区和永久代的关系

《Java虚拟机规范》只是规定了方法区的概念和作用,并没有规定如何实现,在不同的Jvm上方法区的实现肯定不同。方法区和永久代的关系很像Java的接口和类的关系,类实现接口,而永久代就是hotspot虚拟机对虚拟机规范中方法区的一种实现方式。永久代是hotspot的概念,方法区是Java虚拟机规范中的定义,是一种规范,永久代是一种实现,一个是标准一个是实现,其他虚拟机实现并没有永久代这一说法。

JDK1.8之前,永久代还没彻底被移除,通常通过下面这些参数调节方法区大小

-XX:PermSize=N  //方法区 (永久代)初始大小
-XX:MaxPermSize=N //方法区(永久代)最大大小,超过这个值将抛出OutOfMemoryError异常

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进行方法区后就“永久存在”了

JDK1.8,方法区(hotspot的永久代)被彻底移除了,取而代之是元空间,元空间使用的是直接内存

-XX:MetaspaceSize=N //设置 Metaspace的初始大小
-XX:MaxMetaspaceSize=N //设置最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?

  1. 整个永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存限制,虽然元空间仍旧可能出现溢出,但比原来出现的几率更小

    当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError:MetaSpace

  2. 元空间里存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了,而由系统的实际可用空间来控制,这样可加载更多类
  3. 在JDK8,合并HotSpot和JRockit的代码时,JRockit没有永久代整个概念,合并之后也就没有必要额外设置永久代。

运行时常量池

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

运行时常量池属于方法区一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError错误

  1. JDK1.7之前运行时常量池逻辑包含字符串常量池,存放在方法区,此时hotspot虚拟机对方法区的实现为永久代
  2. JDK1.7字符串常量池被从方法区拿到了堆中,字符串常量池被单独拿到堆,运行时常量池剩下的东西仍在方法区,也就是hotspot的永久代
  3. JDK1.8 hotspot移除了永久代用元空间(MetaSpace)取而代之,字符串常量池还在堆中,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(MetaSpace)

相关问题:JVM常量池中存储的是对象还是引用?
https://www.zhihu.com/question/57109429/answer/151717241 by RednaxelaFX

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存区域被频繁使用,也可能导致OutOfMemoryError异常。

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

本机直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存(包括RAM以及SWAP区或分页文件)大小以及处理器寻址空间的限制

HotSpot虚拟机对象探秘