What's JVM——自动内存管理
2.1. 运行时数据区域
Java 所有程序都是运行在线程之上的,来看看 Java 的线程内存划分:
2.1.1. 程序计数器
🐶可以把程序计数器看成字节码行号指示器。
🐱因为 Java 为了保证多线程调度时更好地保存当前线程上下文信息。所以索性给每个线程设置了程序计数器,来保存每个线程的执行位置。
🐭因此程序计数器是线程私有的,这个没什么好说的。
🐰如果此时执行的是本地方法,那么程序计数器的值就是未定义的,本地方法有自己的程序计数器,Java 方法的程序计数器是给解释器等执行引擎看的。
2.1.2. Java 虚拟机栈
🦊它描述的是 Java 方法执行的线程内存模型。存放的是当前线程调用的 Java 方法的栈帧(这里说 Java 方法是为了区分本地方法),Java 方法的栈和计组原理里的方法栈一样的意思。
🐻它就是个堆栈结构,每次 Java 方法被调用,这个方法的栈帧进入当前线程的虚拟机栈,调用完毕,出栈。每个方法的调用->执行完毕对应着方法栈帧从入栈->出栈的过程。
🐼它保存着局部变量表(对应 C 的程序栈的局部变量),操作数栈(Java 是基于操作数栈的,C/C++是基于寄存器的),动态连接,方法出口。
局部变量表
🐮存放着编译期可知的各种 Java 基本数据类型和对象引用(类似 C 的指针)。
🐷变量的存放,放在局部变量槽中,JVM 规定 64 位的 long 和 double 使用两个槽,其余的使用一个,但是至于一个槽多大,并没有限制,可能是 1byte,也可能是 4byte。
这里提一下,HotSpot 虚拟机不支持虚拟机栈动态扩展,虚拟机栈大小在申请时就固定了。
2.1.3. 本地方法栈
🐒没什么好说的,就是留给本地方法使用的栈帧,比如当前线程调用了一个 C 方法,那么这个 C 方法的栈帧就会入当前的本地方法栈,调用完毕,出栈。
🦆顺带一提,JVM 本身是使用 C++写的,所以怎么设置本地方法栈取决于具体的虚拟机实现,HotSpot 为了图省事和简单,直接把本地方法栈加载虚拟机栈后面,当成一个普通 Java 栈帧处理。
2.1.4. Java 堆
🦅Java 堆是虚拟机管理的内存中最大的一块,因为 Java 对象的创建所需要的内存基本都从这里获取。此区域的唯一目的就是存放 Java 对象实例。
🐎它是被所有线程共享的一个区域,所以可能有并发问题(后面会提到,比如多线程环境下对象分配时的问题)。
🐄它也是垃圾回收器回收的区域,所以又称 GC 堆(不要翻译成垃圾堆了)。
🐂从内存分配角度来看,这个区域可能还有各个线程私有的 TLAB(分配缓冲区,后面会提及)。哪怕如此,它还是只能放置对象实例。
2.1.5. 方法区
🐖它也是各个线程共享的区域。用于存储被虚拟机加载的类型信息,常量,静态变量,JIT 编译后的代码缓存等数据。
🐑这个区域一般 GC 不参与回收,因为回收比较难且收益不高。就算是回收也只能是对常量池的回收和对类型的卸载(在框架中很重要,因为会不断生成动态类型)。
🐕方法区的数据一般比较固定,且一般不参与 GC,所以又称永生代,但是方法区大小是固定的,不可调整,考虑到性能和空间大小问题,JDK8 引入了元空间来替代方法区,元空间最大的不同在于它是分配在直接内存中的,且大小可变。
在 JDK7 以前,字符串常量池在方法区中;JDK7 之后,把字符串常量池挪到了堆中,因为这玩意太多了,还是动态改变的,但是常量池还是在方法区中;JDK8 之后,把方法区挪到了直接内存,并改名为元空间。
2.1.6. 运行时常量池
🍏这里不得不提一下在方法区的运行时常量池,class 文件中除了包含类的版本,字段,方法等,还会包含各种字面值常量和符号引用,它们会在类被加载后存放到运行时常量池中。
🍎另外,运行时常量池除了可以放置编译期产生的常量,也可以放置运行期间产生的常量。因此运行时常量池还具备动态性。也就是说,运行时常量池可以在运行时向里面添加元素。
这里小提一下运行时常量池,字符串常量池和 Class 常量池的区别。
Class 常量池属于每一个 class 文件,保存编译时产生的常量,符号引用等数据。在加载到内存中后就进入了运行时常量池中,不再存在。
字符串常量池存放字符串常量,用于编译期和运行时生成的字符串常量(不是字符串对象引用)的保存。
运行时常量池保存程序运行时的一些常量,比如 final static 这样的,以及 class 文件在被加载到内存之后,class 文件中的符号引用等常量。
2.1.7. 直接内存
🍐这个更像是本机内存,因为它不属于虚拟机运行时数据区,也不属于堆,而是存在于 JVM 管理之外的内存,可以通过 C 的 molloc 之类的函数访问到。
🍊直接内存通过向操作系统申请空间来实现,在这里需要说一下,Java 程序的内存有两个部分,一个是 JVM 堆,存放 Java 对象;一个是直接内存,由操作系统管理。引入直接内存是为了减少内存拷贝,我们以网络传输为例。当读取网卡数据时,传统方式是:网卡缓冲区=>内核空间=>用户空间=>JVM 堆=>程序;有了直接内存就是:网卡缓冲区=>内核空间=>用户空间=>程序。
🍋网络通信框架 Netty 通过在直接内存中操作实现了更高的 I/O 效率。
这里有一个更加清晰的文章,详细介绍了 Java 所谓的直接内存到底是什么?Netty对零拷贝(Zero Copy)三个层次的实现 - 王鸿飞的文章 - 知乎
2.2. Java 对象
2.2.1. 对象创建
🍌对象创建流程:
当虚拟机遇到一条 new 指令时,首先检查 new 后面的类能否在常量池中定位到且已经被加载,解析,初始化;如果无法定位到或未初始化,加载,则执行类加载。
类加载完成后,此类对象创建需要的内存便可确定。此时分配如是大小的空间(分配方法有两种,详见下面)。
空间分配完毕,将分配到的空间初始化为 0,但是不包括对象头。
设置对象的对象头,包括指出这个对象是哪个类的实例,类型元数据的指针,哈希码等等。
根据实际的初始化参数调用<init>()方法进行初始化实例。
🍉关于对象所需内存的申请方式:
第一种就是指针碰撞法,就是把所有堆区分成两个部分,左边是已使用,右边是未使用,每次分配仅需把指针向左移动所需大小的位置即可。使用这种方法要求垃圾回收器带有空间压缩功能。
第二种就是空闲列表法,使用一个列表维护整个堆哪些空间可用,哪些空间已用。
使用哪种方法取决于堆是否规整,而堆的规整有否取决于垃圾回收算法是否拥有空间压缩功能。
🍇内存分配时的并发问题:如果对象 A 在创建到移动空间指针来划分空间时被切换到了另一个线程,而此时线程 B 也在创建,那么就会造成空间指针的并发访问问题。解决方法有两个:
一是保证原子性+失败重试。另一种是使用 TLAB(分配缓冲区),即每个线程独有一个专门用来分配对象实例的区域。
这里有一篇详细的文章-并发下的对象创建问题讲述这个问题。
我在这里稍微简述一下这个问题的解决:如果可以使用**逃逸分析(比较复杂的一个前沿算法)**进行对象作用于分析得出对象不会逃出方法的话,可以使用栈上分配的方式;如果不可以,则使用上述两个方法之一。HotSpot 使用 TLAB 来解决,具体做法如下(假设新生代分配使用 2/8 分的 2Survivor+1Eden):
1️⃣每次在 Eden 上申请 1%的空间给每个线程,成为 TLAB(线程本地分配缓冲),每次线程创建对象,优先在 TLAB 上分配。
2️⃣设置一个阈值,如果新对象大小大于剩余空间,小于这个阈值,使用 CAS 重新划分 TLAB 并分配;否则在堆上分配。
TLAB 总空间 100KB,使用了 80KB,剩余 20KB,如果设置的 refill_waste 的值为 25KB,那么如果新对象的内存大于 25KB,则直接堆内存分配,如果小于 25KB,则会废弃掉之前的那个 TLAB,重新分配一个 TLAB 空间,给新对象分配内存。
此外,TLAB 仅作为分配空间用,对象的访问,GC,修改等不属于 TLAB 管理。
2.2.2. 对象实例的内存布局
🍓在 HotSpot 虚拟机中,对象实例的存储布局可以分为三个部分:对象头,实例数据,对齐填充。
对象头:此区域存储两类信息,一类是用于对象的运行时数据,比如哈希码,GC 分代年龄,线程持有的锁,偏向信息等;另一类是类型指针,指向类型元数据,Java 通过这个指针找到这个对象是哪个类的实例。
实例数据:存放着对象的实际数据,也就是对象的各种域。无论是继承来的还是自己定义的。
对齐填充:用来确保内存起始地址为 8 的倍数。
2.2.3. 对象的访问定位
🫐Java 程序通过栈上的 reference 类型的引用来找到堆上的具体对象。
🍈通过 reference 访问对象的方式有两种,一种是句柄,一种是直接指针。
句柄法:通过把堆划分成实例区和句柄池来实现,句柄池存放句柄,每个 reference 对象指向一个句柄,句柄包含两个指针:指向对象实例数据和指向对象类型的指针。
直接指针法:reference 直接指向对象,而对象中会留出一块区域用来保存类型指针。
二者优缺点很明显:句柄法在 GC 移动对象时,仅需修改实例数据指针即可,而直接法则需要修改 reference;但是直接法访问速度更快。
版权声明: 本文为 InfoQ 作者【CodeWithBuff】的原创文章。
原文链接:【http://xie.infoq.cn/article/b04e592e3ccd6e29738ebcc7a】。文章转载请联系作者。
评论