Java内存区域
Contents
1. 程序计数器
- 程序计数器是线程私有的并且是独立存储的。它的主要作用包含程序控制,就是顺序、分支、循环、异常处理、线程恢复等。其中线程恢复就是程序计数器中会存储当前线程执行的位置,在发生线程切换时,会将程序计算器中的信息存储起来,在线程恢复时继续执行。
- 程序计数器还记录了字节码指令,字节码解释器通过改变程序计数器来得到下一步进行的指令。
- 当程序计数器执行的时Native方法时,其值为Undefined
- 程序计数器所占的内存固定且十分小,因此是唯一不会发生 OutOfMemoryError 错误的内存区域
- 程序计数器的生命周期是随着线程的创建而创建,随着线程的销毁而销毁
2. Java 虚拟机栈
- 栈是服务于Java方法的
- 栈也是线程私有的,并且随着线程的创建而创建,随着线程的销毁而销毁。严格来说是随着方法的创建而创建,随着方法的结束而销毁。
- Java 虚拟机栈和数据结构中的栈具有相同的先进后出性质。
- Java 虚拟机栈由一个一个的栈帧组成,每一个栈帧中包含:局部变量表、操作数栈、动态链接、方法返回地址(返回值)。
- 局部变量表:存储的是程序在编译时就能预知到的一些变量类型(int、char、flaot、doble、bool等)和对象引用类型(不是对象,可能是指向对象实际地址的一个引用指针,也可能是指向一个代表对象的句柄)。对于基本类型,存储的就是直接是变量值,除了double和long占用两个slot、引用类型在32位机器占用1个slot,在64位机器占1个或2个slot,其余基本类型都占一个slot,一个slot占4个字节
- 操作数栈:存储一些中间的计算值或者临时变量
- 动态链接:在Java的class文件中,方法调用以符号引用的方式存储在运行时常量池中,此时只知道方法的一些基础的信息,例如方法名、返回值等,但是不知道方法的实际地址。需要完成方法调用就需要将符号引用转为直接引用(直接指向方法的地址),对于静态方法和私有方法,在类加载器执行时就会转为直接引用,但是对于一些虚方法,例如有多个实现类的接口类中的方法,由于不执行到那一步就不知道需要具体哪个实现,因此将转换过程移动到程序执行时由动态链接完成转换。
- 方法返回地址:Java程序有两种返回方式,一种是正常return,另一种是抛出异常。在栈帧中会出现两种内存异常,一是StackOverFlowError,在内存不允许动态分配时,栈帧的数量超过Java虚拟机的内存上限会抛出这个异常;二是OutOfMemeoryError,在内存允许动态分配时,虚拟机在动态拓展栈时无法申请到足够的内存时会抛出这个异常
3. 本地方法栈
- 作用和Java虚拟机栈相同,不同的是其服务与本地方法
4. 堆
- 整个JVM中内存最大的区域、在虚拟机启动时创建、主要就是存储对象实例,Java中几乎所有的对象实例都存储在堆中。同时也存储了静态变量和字符串常量池。
- 堆也是内存回收主要管理的区域,因此又称为GC堆,并且由于现在的回收机制大多都是分代回收机制,因此又可以细分为新生代、老年代、永久代(1.7及之前),其中新生代分为一个Eden区和两个S区。当一个对象在新生代的Eden区经历一次垃圾回收后依旧存在就会移动到S区,并且它的年龄会相应的增加。S区的对象会来回移动并增加年龄,当年龄超过一个值时会移动到老年代。
- 移动到老年代的年龄阈值最高不超过15,因为存储年龄阈值的地方在对象头中,此处占4个位(4bits),最大只能存储二进制的15.对象的存储结构为对象头、实例数据、对齐填充,其中对象头包含标记数据和类型指针,标记数据就是存储年龄阈值的地方。
- 年龄阈值的动态计算:虚拟机在遍历对象时会按照年龄从小到大累加对象的大小,当累加到某个年龄时总和大于S区的一半时会取当前年龄和设置的最大年龄阈值进行比较并且取最小值为当前的年龄阈值
- 在 JDK1.8, 将老年代移除变为元空间,并且元空间使用的是本地内存,不再受JVM的内存限制。
- 堆中最容易出现的就是OutOfMemoryError异常,分为两种,第一中是垃圾回收的时间过长并且只能回收很少的空间时会出现GC Overhead Limit Exceeded,另一种是在创建Java对象时内存不足会出现Java heap space
5. 方法区
- 方法区是虚拟机中的一种概念,并非实际存在的,而永久代和元空间就是它的具体实现,是虚拟机中真实存在的。
- Java中加载类时会从classes文件中加载元数据并存入方法区,也就是方法区存的是类的元数据(完整结构、类名、父类名、修饰符、实现的接口等);方法的完整字节码,也就是原始的指令序列;运行时常量池,是从class文件中的常量池转换而来。
- 1.8之后删除永久代并变为元空间的原因有:1.永久代受JVM本身设置的一个大小上限所限制,并且这个大小上限无法调整,而元空间使用的是本地内存,与本地机器相关,虽然也有内存溢出的风险,但是概率更低。2.元空间使用的是本地内存,作为方法区它就可以存储更多的类的元数据。3.永久代会给GC带来更多的复杂度,并且回收效率偏低
6. 运行时常量池
- 从class文件中的常量池表转换而来
- 主要存储了一些字面量和符号引用。字面量就是字母数字构成的字符串或者数值常量,例如 “HelloWord”和int a = 10中的10,符号引用就是用符号表示引用的目标,与虚拟机的内存布局无关,并且引用的目标不一定加载到内存中,而直接引用指向的目标是一定存在虚拟机内存中的
7. 字符串常量池
- 是JVM为了提高性能减少内存使用为字符串特地划分的一块区域,存储在堆中,主要是为了防止字符串的重复创建。
- 创建一个字符串对象时会先去字符串常量中查询该字符串是否已经存在,不存在则创建并加入字符串常量池,将该字符串的引用赋值给创建的对象。如果已经存在则直接将该字符串的引用赋值给创建的对象,无需重复创建。
- 字符串常量池的实现stringTable,其本质上是一个hashTable,存储key和字符串对象的引用,当创建多个相同的字符串时本质上是在hashTable中查找是否存在Key指向对应的字符串对象的引用,存在直接返回,不存在创建并插入新的key
8. 直接内存
- 是一种特殊的内存缓存区,不在堆或则方法区中分配,而是通过JNI的方法直接在本地内存上分配
- 类似的还有堆外内存
9. 对象的创建流程
对象的创建流程分为四步:
- 第一步类加载检查:在接收到一个new指令后会先去常量池中查找该类的符号引用,接着判断该类是否加载,加载过就直接复用,未加载则将其加载到内存中。
- 第二步内存分配:确认对象创建后就需要进行内存的分配。一般存在两个内存分配方法,一是指针碰撞,适用于内存规整的情况,将已使用的内存和空闲内存之间用一个指针区分,分配内存时将指针向空间内存的那一端移动即可,使用该方法的收集器有Serial,ParNew;二是空闲列表方法,适用于内存空间不规整的情况,维护一个列表记录哪些内存是空闲的,分配内存时查询一个足够大的空间分配并更新列表即可,使用该方法的收集器有CMS。在分配内存时会出现并发问题导致线程不安全,虚拟机中为保证线程安全会采用CAS乐观锁重试机制和TLAB机制解决这个问题,其中TLAB就是预先为每一个线程在Eden区分配一块内存,这块内存是该线程私有的,无需排队申请使用,当不够用时再采用CAS的方式去申请内存。
- 第三步初始化零值:为确保对象在Java中不赋值也能直接使用,将对应类型的字段全部初始化为零值。
- 第四步设置对象头:JVM将对象的大小,GC分代年龄,哈希码,锁状态标识,类元数据等存入对象头。
- 第五步执行Init方法:到第四步在Java中一个对象就已经产生了,但是在程序员角度该对象没有执行init方法,init主要是将对象按照程序员的规定初始化。
10. 对象内存布局
- 对象的内存布局分为三个部分:对象头、实例数据区、对齐填充区
- 对象头分为标记字段(Mark Word),存储GC分代年龄、哈希码等字段和类型指针(Klass Pointer),指向该对象类元素的指针,虚拟机用这个区分该对象来自哪个类
- 实例数据就是存储的对象真正数据,也就是各个字段的数据
- 对齐填充是为了满足对象大小是8字节的整数倍而设置的,在不是8字节的整数倍时进行填充,无其他特殊意义。8的倍数是因为虚拟机规定对象的起始地址一定要是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。
11. 对象访问定位
- 句柄:局部变量表中的引用指向堆中的句柄池,句柄中包含到对象实例数据的指针和到对象类型数据的指针,分别指向实例池中的对象实例数据和方法区的对象类型数据
- 直接指针:直接指向堆中的对象实例数据
