欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java 内存管理

程序员文章站 2022-07-13 14:11:12
...
说明:本篇文章是在阅读《深入理解Java虚拟机》过程中的一些笔记和分析,由于本人能力有限,如果有书写错误的地方,欢迎各位大佬批评指正!我们互相交流,学习,共同进步!

该项目的地址:https://github.com/xiaoheng1/jvm-read


Java 的内存结构分为运行时数据区+执行引擎+本地库接口+本地方法库

运行时数据区分为:堆、栈、本地方法栈、程序计数器、方法区组成.

(1)程序计数器为线程独有,它指示的是当前线程执行的字节码的行号指示器. 字节码就是通过修改程序计数器来实现分支、循环、跳转、异常处理、
线程恢复等基础功能,所以这小块内存区域必须是线程独有的(如果为共有的话,那岂不是乱套了,线程间相互影响). 还有一个值得说的是,不知道你们
注意没,有时候在 eclipse 中修改了代码后,行号指示器错行了,这个就是由于修改代码后,程序指示器没有更新的缘故.

(2)栈,也是线程独有的,虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的时候,Java 虚拟机会为其创建一个栈帧,然后将这个栈帧入栈,
栈帧中存放什么了?局部变量表、操作数栈、动态链接、方法出口等信息. 方法的调用对应着栈帧从入栈到出栈的过程. 由于物理硬件的限制,所以对于
栈的深度是有限制的,所以我们有时候在写代码的时候,递归调用层级过深的话,会抛出 *Error 异常.
在看 Netty 源码的时候,发现 Netty 也处理了虚拟机栈深度的问题,如果其超过了栈的深度,则启动另外的线程进行处理.

局部变量表用于存放方法参数和方法内定义的局部变量. 在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性中的 max_locals 数据项
中确定了该方法所需要分配的局部变量表的大小. 换句话说,局部变量表所占多大空间是预先确定好的,在运行期间是不会改变局部变量表的大小.
还有就是 long/double 占 2 个槽位,其他变量占用一个槽位.

一个局部变量可以存放 8 大基本数据类型,也可以存放 reference、returnAddress 等.

操作数栈:我们学习数据接口的时候,表达式求值就是使用这个栈来完成的.

动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接. 现在比如说我在一个
方法中调用了另一个方法,这时候就需要去常量池中找另一个方法(栈帧中是符号)的实际地址,这就是动态链接.
也可以这么理解,比如说,我们定义了 int a = 1; a 就是符号,但是这个 a 对应的实际地址是啥了?我们就要去查表确定.

方法的返回地址:当方法调用完后,要回到调用的地方,所以必须有一个东西存储方法出口.

说明:如果在方法中引用了类的成员变量,则该变量不算在栈帧的局部变量表中.


(3)本地方法栈,和栈类似,都是线程私有的,区别在于本地方法栈用于执行 native 方法.

虚拟机栈和本地方法栈都可以抛出 OutOfMemoryError 和 *Error 异常,它们的区别在于 *Error 是说所有栈帧大小
的总和 > -Xss 设置的值,换句话说,由于方法调用的层级过深,Jvm 为每个方法调用都产生了一个栈帧,导致栈空间满了.
OutOfMemoryError 是说没有足够的空间为线程分配栈空间(或者说无法扩展栈空间).


(4)堆,为线程共有,该内存存在的目的就是为了存放对象实例,几乎所有的对象都是在堆上分配空间(由于 JIT 技术和逃逸分析技术的发展,栈上分配,
标量替换优化技术导致一些微妙的变化发生,所有对象都在堆上分配也不再是那么绝对).
垃圾回收机制主要回收堆内存,现代垃圾回收器采用分代收集算法,Java 堆还可以进行细分:新生代、老年代、永久代(JDK7+不再是这样的),新生代
还可以进行细分为 Eden 区、From Survivor、To Survivor. 从内存分配的角度看,堆中可能划分出多个线程私有的分配缓冲区区(TLAB).
堆内存在逻辑上是连续的即可,并不要求在物理上也是连续的,如果对象无法在堆上完成分配,则抛出 OutOfMemoryError 异常.


(5)方法区,同样是所有线程共享,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据.

类信息:类的全限定名、类的直接超类的全限定名、类是 class 还是 interface、类的访问修饰符、类的常量池、域信息、方法信息、
静态变量等
总的来说:常量池,类信息,静态变量,方法信息、域信息、类加载器的引用、对 Class 对象(存放在堆中)的引用.


(6)运行时常量池,是方法区的一部分. Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期
生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放.
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性. 也就是说常量并不需要一开始就要在类中进行定义,可以在运行时动态的加入
到常量池中.

Class 文件常量池是 *.java 文件被编译为 *.class 文件后的东西,位于本地,而运行时常量池存储在方法区中.

(7)直接内存
直接内存不属于 Java 虚拟机规范中定义的内存区域,这部分内存的好处在于在某些场景下能够显著提高性能. 在 Netty 中就使用到了这部分内存.
需要注意的是,使用这部分内存确实很快,但是容易造成内存泄露(须谨慎使用).


1.对象的创建

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

类加载完后,虚拟机为对象分配内存. 假设 Java 堆中的内存是绝对规整的,所有用过的内存都放在一边,没用过的内存放在另一边,中间放一个指针
做为分界点的指示器,那分配内存就是将指针向空闲那边移动一段与对象大小相等的距离,这种分配方式成为 '指针碰撞'. 如果 Java 堆中的内存并
不是规整的,那虚拟机就必须维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的
记录,这种分配方式成为 '空闲列表'(这块和计算机的内存管理有点像). 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由
所采用的垃圾收集器是否带有压缩整理功能决定. 因此,在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用指针碰撞,而使用
CMS这种基于 Mark-Sweep 算法的收集器,则采用空闲列表.

值得注意的是,在堆上分配空间是很频繁的,所以在多线程情况下容易出现问题,JVM 是如何做的了?采用 CAS + 失败重试的方式保证更新操作的
原子性. 另一种是把内存分配的工作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,即 TLAB. 那个线程需要分配
内存,就在那个线程的 TLAB 上进行分配,只有当 TLAB 用完后并分配新的 TLAB 时,才需要同步.

2.初始化零值(这是最基本的保证).
内存分配完毕后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证了对象的实例字段在 Java 代码中可以不赋值初始化就直接使用.

3.必要设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例,如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息.
这些信息存放在对象的对象头中.

在上面工作都完成后,从虚拟机的角度来说,一个新对象已经产生,但是从 Java 程序的角度来说,对象创建才刚刚开始——<init>方法还没有执行,所有
的字段都还是零.

<init> 方法是指收集类中的所有实例变量的赋值动作、实例代码块以及构造函数合并产生的,所以它的作用很清晰明了,就是为了进行初始化.

对象的内存布局

对象在内存中存储的布局可以分为 3 块区域:对象头、实例数据和对齐填充

对象头又分两部分,一部分存储对象自身的运行时数据,例如哈希值、GC分代年龄、线程持有的锁等,这部分成为 Mark Word. 对象头的另一部分是
类型指针,即对象指向它的类元数据的指针.

实例数据就是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容.

对齐填充,由于 HotSpot VM 的自动内存管理要求对象的起始地址必须是 8 字节的整数倍,所以当实例数据部分不够 8 字节的整数倍时进行填充.


对象的访问

我们知道,在栈上通过 reference 来访问堆上的对象. 这是如何实现的了?

目前有两种实现方式,一种是直接访问,一种是基于句柄访问.

直接访问就是 reference 存放对象的地址.

基于句柄访问就是在堆中划分出一块内存,用于存放句柄信息,句柄中一个指针指向对象实例数据,另一个指针指向类元数据信息.

看到这里,有小伙伴有疑问吗?这里到底改变了对象头的结构没?为啥在句柄池中需要两个指针?我认为一个指针就可以实现了,一个指针指向对象,
在使用对象中的指针指向类元数据信息.


OutOfMemoryError

1.堆溢出

-Xms 设置堆的最小内存
-Xmx 设置堆的最大内存
-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常时 Dump 出当前堆内存快照

2.虚拟机栈和本地方法栈

-Xss 设置栈的容量

当在多线程环境中出现栈内存溢出的话,那么要么更新到 64 位虚拟机,或者通过减少最大堆内存,或者减少栈容量来换取更多的线程.

3.方法区内存
-PermSize 设置方法区的内存
-MaxPermSize 设置方法区最大内存

4.直接内存
-XX: MaxDirectMemorySize 如果不指定,则默认和 Java 堆最大值(-Xmx) 一样.