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,但是不包括对象头。
- 设置对象的对象头,包括指出这个对象是哪个类的实例,类型元数据的指针,哈希码等等。
- 根据实际的初始化参数调用()方法进行初始化实例。
- 🍉关于对象所需内存的申请方式:
第一种就是指针碰撞法,就是把所有堆区分成两个部分,左边是已使用,右边是未使用,每次分配仅需把指针向左移动所需大小的位置即可。使用这种方法要求垃圾回收器带有空间压缩功能。
第二种就是空闲列表法,使用一个列表维护整个堆哪些空间可用,哪些空间已用。
使用哪种方法取决于堆是否规整,而堆的规整有否取决于垃圾回收算法是否拥有空间压缩功能。
- 🍇内存分配时的并发问题: 如果对象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;但是直接法访问速度更快。