JVM 内存区域
Posted Joey Liao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM 内存区域相关的知识,希望对你有一定的参考价值。
文章目录
1 运行时数据区
JDK 1.8 之前 :
JDK 1.8 之后 :
1.1 线程私有:
- 程序计数器
- 虚拟机栈
- 本地方法栈
1.1.1 程序计数器
程序计数器是一块较小的内存空间,存储着当前线程下一步要执行的字节码的内存地址。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
1.1.2 java虚拟机栈
与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
- 局部变量表: 存放了编译期可知的各种数据类型 (boolean、byte、char、short、int、float、long、double)和对象引用
- 操作数栈: 用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
- 动态链接: 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError
错误。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
简单总结一下程序运行中栈可能会出现两种错误:
- StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出
StackOverFlowError
错误。 - OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出
OutOfMemoryError
异常。
1.1.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
1.2 线程共享
- 堆
- 方法区
- 直接内存(非运行数据区的一部分)
1.2.1 堆
是Java虚拟机管理的内存中最大的一块,所有线程共享堆。堆的唯一目的就是存储对象实例,几乎所有对象的实例以及数组都是在这里分配内存
从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存
大部分情况下,对象会初始化在Eden区,在一次新生代垃圾回收后,若对象还存活的话就会将该对象的年龄+1,并进入S0或S1。在servivor区中,“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。
堆这里最容易出现的就是 OutOfMemoryError
错误,并且出现这种错误之后的表现形式还会有几种,比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded :
当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space :
假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。
2.2 方法区
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
Java虚拟机内存模型中的方法区(Method Area)主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。具体包含以下内容:
- 类信息:包括类的完整结构、父类、接口、字段、方法等信息。
- 运行时常量池:用于存储编译期生成的字面量(如字符串、数字等)和符号引用(如类和方法的引用等)。
- 静态变量:存储类级别的变量,即所有实例共享的变量。
- 即时编译器编译后的代码:在某些情况下,虚拟机会将部分代码编译成本地机器代码,以提高程序的执行效率。这些代码通常被存储在方法区中。
- 常量:如枚举、final常量等。
方法区和永久代以及元空间是什么关系呢?
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
2.2.1 运行时常量池
运行时常量池(Runtime Constant Pool)是Java虚拟机在加载类时为每个类分配的一块内存区域,用于存储该类中的常量池信息,包括编译时常量和运行时生成的常量。运行时常量池中包含以下信息:
- 字面量:包括字符串字面量、数字字面量、布尔型字面量等。
int num = 10; // 整数字面量
double pi = 3.14; // 浮点数字面量
char ch = 'A'; // 字符字面量
String str = "Hello, world!"; // 字符串字面量
- 符号引用:包括类和接口的全限定名、字段和方法的名称和描述符。
java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder;
在这个符号引用中,java/lang/StringBuilder
是被引用的类的全限定名,append
是被引用的方法的名称,(Ljava/lang/String;)
和(Ljava/lang/StringBuilder;)
是被引用方法的参数类型和返回值类型的描述符。
- 运行时常量:包括运行时生成的常量,例如通过String类的intern()方法生成的字符串常量。
- 方法和字段的引用:包括类的方法和字段的引用。
常量池表会在类加载后存放到方法区的运行时常量池中。
2.3 字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp
,StringTable
本质上就是一个HashSet<String>
,容量为 StringTableSize
(可以通过 -XX:StringTableSize 参数来设置)。
StringTable
中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池从永久代移动了 Java 堆中。
2.4 直接内存
直接内存(Direct Memory)是Java NIO(New I/O)中的一种高效的I/O处理方式,它是一种使用Native Memory(本地内存)的内存分配方式,与Java堆内存分配方式不同。直接内存分配的是本地内存,而不是Java堆内存,它不会受到Java堆内存大小的限制,因此可以分配更大的内存空间,并且可以提高I/O操作的效率。
直接内存通过使用Java NIO中的ByteBuffer
类进行分配和操作。在使用ByteBuffer
分配直接内存时,它会在Java堆内存中创建一个DirectByteBuffer
对象,但是真正的内存空间是在本地内存中分配的。当调用ByteBuffer
的get()
和put()
等方法时,它会直接读写本地内存中的数据,而不需要进行额外的数据拷贝操作,因此可以提高I/O操作的效率。另外,直接内存也可以通过调用Unsafe
类提供的allocateMemory()
方法进行分配,但是这种方式不够安全,不建议使用。
需要注意的是,直接内存虽然能够提高I/O操作的效率,但是它也存在一些问题。例如,直接内存的分配和释放比Java堆内存更加复杂,需要调用Native方法来实现。此外,直接内存不受Java虚拟机的内存管理机制控制,因此需要开发人员手动释放直接内存,否则可能会导致内存泄漏问题。
3 HotSpot虚拟机对象探秘
3.1 对象的创建
-
类加载检查:new 指令-》是否能在常量池中定位到这个类的符号引用-》检查这个符号引用代表的类是否已被加载过、解析和初始化过-》如果没有,那必须先执行相应的类加载过程
-
分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来:
- 指针碰撞(Bump the Pointer):指针碰撞是一种常用的堆内存分配方法,它将堆内存分为两个部分,一部分是已分配的对象区域,另一部分是未分配的空闲区域。当分配对象时,Java虚拟机会将指向空闲区域的指针向前移动,直到找到一个足够大的空间来分配对象。(堆内存规整(即没有内存碎片)的情况下。)
- 空闲列表:是一种常用的堆内存分配方法,它将堆内存划分为一系列大小不等的空间块,并使用一个列表来维护已分配和未分配的内存块。当分配对象时,Java虚拟机会在空闲列表中寻找一个足够大的空间块,并将其分配给新对象 (堆内存不规整的情况下。)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
内存分配并发问题:虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
-
初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
-
设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,对象头包含:
- 对象的哈希码
- 对象的GC信息:包括对象的分代信息、是否可回收标识
- 对象的锁信息:包括对象的锁状态、持有该对象锁的线程ID等
- 类型指针:指向对象所属类的Class对象
- 对象的数组长度:只有数组对象才会包含该信息。
-
执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, init方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
- 对象头上文以及阐述
- 实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
- 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
3.3 对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,reference
中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
JVM内存区域参数配置
转自:https://www.jianshu.com/p/5946c0a414b5
需要提前了解的知识点:
下图是JVM内存区域划分的逻辑图
从图中我们大概了解JVM相关的内存区域。
JVM内存包括区域
- Heap(堆区)
- New Generation(新生代)
- Eden
- Survivor From
- Survivor To
- Old Generation(老年代)
- 方法区
- Permanent Generation(持久代)
- Stack(栈区)
- Metaspace(元空间)
- Direct ByteBuffer(直接内存)
下面我们就通过一些JVM启动参数来配置以上内存空间
Heap(堆)内存大小设置
-Xms512m
设置JVM堆初始内存为512M
-Xmx1g
设置JVM堆最大可用内存为1G
New Generation(新生代)内存大小设置
-Xmn256m
设置JVM的新生代内存大小(-Xmn 是将NewSize与MaxNewSize设为一致。256m),同下面两个参数
-XX:NewSize=256m
-XX:MaxNewSize=256m
还可以通过新生代和老年代内存的比值来设置新生代大小
-XX:NewRatio=3
设置新生代(包括Eden和两个Survivor区)与老年代的比值(除去持久代)。设置为3,则新生代与老年代所占比值为1:3,新生代占整个堆栈的1/4
Survivor内存大小设置
-XX:SurvivorRatio=8
设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个新生代的1/10
Eden内存大小设置
新生代减去2*Survivor的内存大小就是Eden的大小。
Old Generation(老年的)的内存大小设置
堆内存减去新生代内存
如上面设置的参数举例如下:
老年代初始内存为:512M-256M=256M
老年代最大内存为:1G-256M=768M
Stack(栈)内存大小设置
-Xss1m
每个线程都会产生一个栈。在相同物理内存下,减小这个值能生成更多的线程。如果这个值太小会影响方法调用的深度。
Permanent Generation(持久代)内存大小设置
方法区内存分配(JDK8以前的版本使用,JDK8以后没有持久代了,使用的MetaSpace)
-XX: PermSize=128m 设置持久代初始内存大小128M
-XX:MaxPermSize=512m 设置持久代最大内存大小512M
Metaspace(元空间)内存大小设置
元空间(Metaspace)(JDK8)
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m(JDK8),JDK8的持久代几乎可用完机器的所有内存,同样设一个128M的初始值,512M的最大值保护一下。
- 默认情况下,类元数据分配受到可用的本机内存容量的限制(容量依然取决于你使用32位JVM还是64位操作系统的虚拟内存的可用性)。
- 一个新的参数 (MaxMetaspaceSize)可以使用。允许你来限制用于类元数据的本地内存。如果没有特别指定,元空间将会根据应用程序在运行时的需求动态设置大小。
Direct ByteBuffer(直接内存)内存大小设置
-XX:MaxDirectMemorySize
此参数的含义是当Direct ByteBuffer分配的堆外内存到达指定大小后,即触发Full GC。注意该值是有上限的,默认是64M,最大为sun.misc.VM.maxDirectMemory(),在程序中中可以获得-XX:MaxDirectMemorySize的设置的值。
使用NIO可以api可以使用直接内存。
设置新生代代对象进入老年代的年龄
-XX:MaxTenuringThreshold=15
设置垃圾最大年龄。如果设置为0的话,则新生代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则新生代对象会在Survivor区进行多次复制,这样可以增加对象再新生代的存活时间,增加在新生代即被回收的概论。
他最大值为15岁,因为对象头中用了4位进行存储垃圾年龄 【1111(二进制)=15(十进制)】。
不常用的参数:
-XX:MaxHeapFreeRatio=70
GC后java堆中空闲量占的最大比例,大于该值,则堆内存会减少
-XX:MinHeapFreeRatio=40
GC后java堆中空闲量占的最小比例,小于该值,则堆内存会增加
-XX:PretenureSizeThreshold=1024
(单位字节)对象大小大于1024字节的直接在老年代分配对象
-XX:TLABWasteTargetPercent =1
TLAB占eden区的百分比 默认1%
以上是关于JVM 内存区域的主要内容,如果未能解决你的问题,请参考以下文章
JVM 内存区域总结:方法区+堆内存+本地方法栈+元空间——JVM系列
JVM 内存区域总结:方法区+堆内存+本地方法栈+元空间——JVM系列