记录 学习《深入理解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 版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
JDK 8版本之后方法去(hotspot的永久代)被彻底移除了,取而代之的是元空间,元空间使用的是直接内存
上图所示的 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)呢?
- 整个永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存限制,虽然元空间仍旧可能出现溢出,但比原来出现的几率更小
当元空间溢出时会得到如下错误:
java.lang.OutOfMemoryError:MetaSpace
- 元空间里存放的是类的元数据,这样加载多少类的元数据就不由
MaxPermSize
控制了,而由系统的实际可用空间来控制,这样可加载更多类 - 在JDK8,合并HotSpot和JRockit的代码时,JRockit没有永久代整个概念,合并之后也就没有必要额外设置永久代。
运行时常量池
运行时常量池是方法区的一部分。Class文件中除了类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号的引用,这部分内容将在类加载后进入方法区的运行时常量池存放。
运行时常量池属于方法区一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError错误
- JDK1.7之前运行时常量池逻辑包含字符串常量池,存放在方法区,此时hotspot虚拟机对方法区的实现为永久代
- JDK1.7字符串常量池被从方法区拿到了堆中,字符串常量池被单独拿到堆,运行时常量池剩下的东西仍在方法区,也就是hotspot的永久代
- 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虚拟机对象探秘
- 本文链接:https://wentianhao.github.io/2021/06/18/java%E8%99%9A%E6%8B%9F%E6%9C%BA/
- 版权声明:本博客所有文章除特别声明外,均默认采用 许可协议。
若没有本文 Issue,您可以使用 Comment 模版新建。