(一)JVM之内存分区
(一)深入理解JVM之内存分区
JVM将执行Java程序的内存划分为不同的数据区域
介绍各分区作用
1.线程私有区域
1.1 程序计数器
程序计数器是内存区域中一块比较小的内存空间。它的作用就像是一个指向正在运行的字节码行号的指针。改变计数器的值来指向当前要运行的字节码指令。
并且由于Java多线程也是分配时间片的模式来实现的,在单核处理器过程中,线程的切换后为了指向正确的执行位置,也需要程序计数器。
同时,为了各线程的执行进度互不影响,所以将其独立存储,是线程的私有空间。
若当前执行的是native方法,计数器则为空。
1.1.1 Error
程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError异常的区域。
1.2 虚拟机栈
由于线程私有,因此其生命周期与线程相同,同生共死。
虚拟机栈是Java方法执行的内存模型。每个方法在执行时,都会创建一个栈帧。该栈帧会存储一些信息(局部变量表、操作数栈、动态链接、方法出口等),然后被压入栈中,待该方法执行完后再弹出。
(注:方法出口就是调用该方法的字节码指令地址,方法运行完后要回到方法调用处)
(注:局部变量表所需的内存在编译期间就已经完成分配,进入方法是,栈帧需要分配多大的局部变量表是完全确定的,在运行期间不会改变)
(注:
编译期间完成分配: 是指,在编译时期,在程序字节码内生成一些指令,由这些指令控制程序在运行时分配好内存。
运行期分配:是指,运行时才确定分配的大小,存储位置也是在运行时才知道。
)
1.2.1 Error
该区域存在两种异常
StackOverflowError 由栈的深度固定造成。在线程请求栈深度大于虚拟机栈允许深度时抛出。
OutOfMemoryError 由栈的动态深度造成。 当栈无法申请到足够的内存时抛出。
1.3 本地方法栈
生命周期与线程相同。
其作用与虚拟机栈非常相似,区别在于虚拟机栈为Java方法服务,而本地方法栈,顾名思义,是为虚拟机使用到的Native方法服务。
虚拟机规范中对本地方法栈的实现方式没有强制规定,如何实现全看虚拟机的设计者,有些虚拟机甚至会将虚拟机栈和本地方法栈合二为一。
1.2.1 Error
与虚拟机栈一样。
2.线程共享区域
2.1 Java堆
JVM所管理的内存中最大的一块。被所有线程共享。在虚拟机启动时创建(生命周期区别于上述三个)。
Java堆的唯一目的就是存放对象实例(几乎所有的对象实例和数组都要在Java堆上分配)。
Java堆是垃圾收集管理的主要区域,有时也被称为“GC堆”。并且由于所有收集器都采用分代算法,Java堆中还可细分为:新生代和老年代。再细还可分为 Eden、From Survivor、To Survivor空间等。尽管Java堆是共享的,但还是有可能划分出多个线程私有的分配缓冲区。
Java堆不要求物理空间连续,只要逻辑连续即可。
2.1.1 Error
若堆中再无空间可以完成实例分配,将会抛出OOM异常。
2.2 方法区
线程共享。
用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。方法区存储的数据和方法的执行没有关系,而和类加载有关。
方法区可以选择不实现垃圾收集。但相对而言,实现了垃圾收集的虚拟机在这个区域的垃圾收集行为也是比较少的,方法区内存回收主要是针对常量池的回收和对类型的卸载。
2.2.1 Error
当方法区无法满足内存分配需求时,抛出OOM异常。
2.2.2 运行时常量池
运行时常量池时方法区的一部分。
常量池,用于存储编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。
(类加载后,运行时常量池会保存class文件常量池;类解析后,运行时常量池会保存符号引用对应的直接引用)
(当然常量并非只有编译器能够产生,运行期间也能将新的常量放入常量池中)
作用:常量池为了避免频繁的创建和销毁对象而影响系统性能,实现了对象的共享。
例如字符串常量池,在编译阶段九八所有的字符串文字放到一个常量池中。
(1)节省内存空间。常量池中所有相同的字符串常量被合并,只占用一个空间。
(2)节省运行时间:比较字符串时,比equals()快。对于两个引用变量,只用判断是否相等,也就可以判断实际值是否相等。
Error
常量池无法再申请到内存时会抛出OutOfMemory异常。
3.直接内存
并非虚拟机内存的部分,也不是虚拟机规范中定义的内存区域。
JDK1.4中新加入的NIO类和通道与缓冲区的I/O方式,使得可以使用Native函数库直接分配堆外内存,然后通过存储在Java堆内的DirectByteBuffer对象作为这块内存的引用进行操作。
该部分虽然不受制于JVM内存的限制,但依然会受到本机的内存(RAM、Swap区)大小限制。因此当各个区域内存总和大于物理内存限制时将会抛出OOM异常。
对象
对象创建流程
上面所提到的代表一个类的符号引用,在字节码加载阶段,就会被加载到方法区中的常量池中。
对象内存分配(堆中)
对象所需大小在类加载时就已经确定大小。这里主要介绍两种JVM内存分配的方式。
1.指针碰撞 Bump the Pointer
这种情况是假设堆中内存绝对规整,即一边为用过,一边为空闲内存,每次分配空间只需要将指向边界的内存右移即可。
2.空闲列表 Free List
若Java堆中内存不够规整,就不能进行简单的指针碰撞,而是用维护一个列表的方式。
列表中记录了那些内存块是可用的,在分配时从列表中找到一块足够大的空间分给对象实例。
上述两种分配方式由Java堆内存分配是否规整决定,而Java堆内存的是否规整又由垃圾收集器采用的垃圾收集算法是否带有压缩整理功能决定。因此不同的收集器会采用不同的分配方法。
对象分配的线程安全问题
造成线程安全问题的原因是,Java对象的创建及内存分配在JVM中是一件非常频繁的事情,我们很可能会遇多个线程抢占时间片后都为对象分配内存的现象,而内存分配的行为本质上来说就是修改指针的值(指针指向的地址)。
很有可能出现,A对象已经分配好内存,准备做出修改,此时发生了中断,而切换到其他线程为B对象分配内存。等待A对象的线程再来完成刚刚的分配修改动作时,内存已经被占用,造成了线程不安全。
解决方案1:基于CAS
基于CAS对分配内存空间的动作保证原子性,再配上失败重试。
解决方案2:本地线程分配缓冲 TLAB (Thread Local Allocation Buffer)
预先在Java堆中为每个线程分配一小块内存,称为TLAB,只有在TLAB用完并重新分配TLAB时,才需要同步锁定。
内存分配完毕后,JVM将分配的空间都初始化为对应数据类型的零值,若使用TLAB,这步可以提前至TLAB分配前进行。(保证了Java代码不赋初始值就可以运行)
对象内存布局
在hotSpot虚拟机中,对象在内存中的布局可以分为3块区域:对象头 、 实例数据 和 对齐填充。
对象头 Header
存储两部分信息
第一部分 存储对象自身运行时的数据 Mark Word
该部分包括,哈希码,GC分代年龄,锁状态标志,线程持有锁、偏向线程ID、偏向时间戳等。
第二部分 存储类型指针(对象指向类元数据的指针)
JVM通过该指针来确定这个对象是哪个类创建的。
若对象是一个Java数组,则对象头中还必须包含数组长度的数据。
实例数据Instance Data
该部分是对象真正存储的有效信息,是程序代码中所定义的各种类型的字段内容。
无论从父类继承下来,还是在子类中定义的,都需要记录起来。。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律