JVM内存区域
JVM运行时数据区
定义: JVM在执行JAVA程序的时候会把它所管理的区域划分为若干个不同的虚拟区域进行管理.
JAVA引以为豪的就是他的自动化内存管理机制. 相比于C++的手动管理以及难以理解的指针来说, JAVA程序写起来就方便很多. 所以要深入理解JVM就要先深入理解内存虚拟化的概念.
在JVM中, 内存主要划分为堆, 栈, 方法区.
同时以线程的角度来划分也可以划分为线程私有区与线程共享区.
线程私有区: 单独的一个线程对应单独的一片区域. 线程与线程之间互不打扰.
线程共享区: 被所有线程共享, 且共享区唯一.
次数还设计一个直接内存的概念. 这个内存不属于JVM的运行时数据区, 但也会频繁被使用. 假设 计算机的内存有8个G, 虚拟机划分走了5个G, 那么直接内存就生下了3个G, JVM此时就可以借助一些工具来使用直接内存
JAVA的方法运行与虚拟机栈
线程私有
虚拟机栈
栈的数据结构: 先进后出(FILO)的数据结构
虚拟机栈的作用: 在JVM执行的过程中, 储存了当前线程所运行的方法, 以及方法内的数据, 指令, 返回地址.
虚拟机栈是基于线程的: 哪怕单个线程中只有一个main方法(),也是以线程的方式运行的. 在线程的生命周期中, 所有参与计算的数据会频繁的出入栈. 因此, 虚拟机栈的生命周期和线程是一样的..
虚拟机栈的大小:JVM会为每个线程的虚拟机栈分配固定大小的内存, 一般是1M(-Xss参数). 所以虚拟机栈所能容纳的栈帧一定是有限的的. 若栈帧不断地进栈而不出栈, 最终会导致当前线程虚拟机栈的内存空间耗尽. 典型的入伍结束条件的递归数调用.
栈帧及其四大区域
在每个方法被调用的时候都会生成一个栈帧, 并且入栈. 一旦方法调用完毕就出栈, 并且释放内存.
栈帧用于储存局部变量表, 操作数栈, 动态链接, 方法出口等信息. 所谓的栈内存先进后出, 值得就是栈帧压栈.
局部变量表
顾名思义,存放我们的局部变量(方法中的变量)的. 它的长度时32位, 主要存放我们JAVA的八大基础类型. 一般32位的都可以犯下. 64位使用高低字节, 占两个也可以存放下. 一般的方法创建出对象, 我们再次只需要创建一个引用地址即可.
当进入一个方法时, 这个方法需要栈帧中分配多大的局部变量空间是完全确定的. 在方法运行运行期间不会改变局部变量表的大小. 注意:此处说的大小是指槽的数量.
操作数栈
存放JAVA执行操作数的, 它也是一个先进后出的栈. 操作数栈就是用来操作数的. 操作的元素可以使任意JAVA数据类型. 所以当方法刚开始的时候, 操作数栈是空的.
操作数栈我把他理解为JVm执行引擎的一个工作区. 也就是方法在执行, 才会对操作数栈进行操作,如果代码不执行, 操作数栈就是空的.(个人理解: 对于每个独立的栈帧, 操作数栈就像我们的程序一样负责处理逻辑, 局部变量表就像数据库一样储存数据.)
动态连接
JAVA语言特性多台(后续结合class与执行引擎以及方法调用的动静态分派章节一起会详细记录,此处先大概叙述一下.)
首先, 看到了动态链接,那么是不是会猜一下是不是有静态连接呢? 没错, 所谓"连接", 简单来说指的就是方法的调用. 那么为什么我们的战争中只有动态连接, 没有静态连接? 因为静态连接时再类加载阶段就确定不变的,运行期一定不会变. 而动态连接是类加载期间不确定, 而运行时才确定下来. 这里又牵扯到虚, 非虚方法. 动, 静态分派的概念. 之后的文章中会详细讲解. 注: 这里的确定下来指符号引用转换为直接引用, 也就是后续运行时不需要做任何处理, 直接就可以获取地址.
完成出口(返回地址)
当一个方法开始执行时, 可能有两种方式退出该方法:
1. 正常完成出口
2. 一场完成出口
正常完成出口是指方法正常完成并退吹, 没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常.) 如果当前方法正常完成, 则根据当前方法返回的字节码指令, 这时有可能会有返回值传递给方法调用者(调用它的方法),或者不返回值. 具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定.
异常完成出口时指方法执行过程中遇到异常, 并且这个异常在方法体内部没有的到处理, 导致方法退出. 以下代码为例:
很明显, 当程序发生异常后, 会继续执行catch中的内容. 而方法之后的内容将被忽略.
无论时Java虚拟机抛出的异常还是代码中使用throw指令产生的异常, 只要在本方法的异常表中没有搜索到响应的异常处理器, 就会导致方法退出.
无论方法采用和中方式退吹, 在方法退出后都需要返回到方法被调用的位置, 程序还能继续执行, 方法返回时可能需要在当前栈帧中保存一些信息, 用来帮它回复它的上层方法(调用层)执行状态.
方法退出过程实际上就等同于把当前线帧出栈, 因此退出可以执行的操作有: 回复上层方法的局部变量表和操作数栈, 把返回值(如果有的话)压入调用者的操作数栈中, 调整PC计数器的值以指向方法调用指令后的下一条指令.
一般来说,方法正常退出时, 调用者的PC计数值可以作为返回地址, 栈帧中可能保存此计数值. 而方法异常退出时, 返回地址是通过异常处理器确定的, 栈帧中一般不会保存此部分信息.
程序正常
(调用程序计数器的地址回归用户线程)
三部曲:
1.恢复上层方法的局部变量表和操作数栈
2. 把返回值(如果有的话)压入调用者栈帧的操作数栈中.
3. 调整程序计数器的值指向方法调用执行后的一条执行.
程序异常
(通过异常处理表中<非栈帧中的>)来处理
出入栈栈帧变化
JAVA虚拟机栈存放的有局部变量表, 操作数栈, 动态链接以及完成出口, 其中动态连接与完成出口搭配栈帧中的程序计算器, 记录CPU的执行情况. 因为CPU在镀锡爱你成环境下时高速切换的状态, 程序计数器会记录本线程本次执行到的为止, 然后完成出口切入,等下次线程抢到执行权时, 从程序计数的位置继续执行线程. 此处的重点是局部变量表与操作数栈的协作.
此处代码在执行的过程中,以栈帧的角度分来分解程序的执行流程。入栈,出栈示意图。
程序计数器
只占用较小的内存空间, 记录线程当前执行的行号, 各线程之间相互独立, 互不影响.
程序计数器只占用很小的内存空间, 主要单独记录所属线程的所执行到的字节码地址. 例如:分支, 循环, 转跳, 异常, 数据恢复等都依赖于程序计数器. 由于JAVA时多线程语言, 当CPU的线程数据超过核数时, 线程之间会根据时间片轮循来争夺CPU资源. 如果一个线程的时间片试用完了, 或者因为其他原因资源被提前抢走了, 那么这个线程的程序计数器就需要记录下一条运行的指令. 程序计数器时JVM中唯一不会OOM的地方.
本地方法栈
由于JVM时JAVA虚拟机, 所以内部有一套完整的指令执行流程. 所以在运行JAVA方法的时候需要用到程序计数器.
当是本地方法栈(native修饰符)的方法时, 这个方法不是JVM来执行, 所以不需要程序计数器来执行. 这时因为错做系统也有一个程序计数器, 这个会记录本地方法区的代码的执行地址. 所以如果执行到本地方法栈的方法时, 虚拟机栈中的程序计数器会显示(Undefined).
栈帧执行对内存的影响
反编译流程
对class进行反汇编 javap -c XXXX.class
字节码查看网址:[三] java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符 - 云+社区 - 腾讯云
反编译工具的使用
1. 找到程序预编译后的class文件
2. 按住Shift+右键, 打开DOS命令.
3. 使用javap -c xxxx.class命令对class文件进行反变回, 执行结果如下:
流程:
0: x = 1 进入操作数栈
1: x = 1 进入局部变量表
2: y = 2 进入操作数栈
3: y = 2 进入局部变量吃
4: Z = 1+2 进入操作数栈(这里没有多的指令执行1+2)
5: z = 3 进入局部变量表
6: x = 1 从局部变量表进入操作数栈
7: y = 2 从局部变量表进入操作数栈
8: 执行x + y (此操作数栈会把值交给执行引擎执行, 并把执行结果再返回操作数栈)
9: z = 3 从局部变量表进入操作数栈
10: 执行(x+y)*z的结果(此操作数栈会把值交给执行引擎执行, 并把执行结果再返回操作数栈)
11: 计算结果h进入局部变量表
13: h = 9 从局部变量表进入操作数栈(此处不连接可能上面结果比较大, 用两个位置存放)
15: h = 9从操作数栈返回给调用的栈帧
在JVM中, 如果提到给予解释执行的方法时给予栈的执行引擎. 基于栈的引擎说的就是操作数栈.
本地方法栈的意义
本地方法栈与虚拟机的功能类似. JAVA虚拟机用于管理JAVA函数的调用. 本地方法栈用于管理本地方法的函数调用. 但本地方法不是用JAVA写的. 例如Object.hashcode()方法.
本地方法栈专门服务的是native修饰符修饰的方法. 甚至可以把本地方法栈与虚拟机栈合二为一, 虚拟机规范中没有强制规定, 个版本虚拟机自由实现. HotSpot就将两个区域合二为一.
线程共享
方法区
方法区与堆空间类似, 都是共享内存去. 所以方法区时线程共享的. 加入一个类在加载进JVM之前. 两个线程都想访问方法区中的同一个信息. 此时就只允许一个线程区加载它, 另外的线程必须等待(JVM自己实现了锁功能, 在未来学习的单例模式之延迟占位类模式就利用了这一点). 而我们经常提到的永久代和元空间, 指的都是方法区的实现. 永久代的出现, HotSpot实现着想把它做的类似于堆内存, 这样垃圾回收器可以像管理堆内存一样管理方法去. 然而永久代的大小时有上限的, 导致方法区更容易出现OOM问题. 在JDK6以后, 开发者花了大心思, 舍弃了永久代转而使用元空间. 对元空间的实现, 虚拟机要求十分宽松.不强制要求设置内存上线, 甚至可以不实现垃圾回收. 避免了此区域不用对象未完全回收而导致的内存现楼(不处理! 不作为!).
符号引用
关于符号引用和直接引用, 很多博客与书记中都讲的比较抽象. 下面说一下我的理解:
首先class常量池并不是我们的运行时常量池. class常量池时存在class文件中的一片内存, 它的作用就是需要在类加载的过程中, 把累的相关信息转移到我们的方法区中. 我们在动态连接过程中, 由栈帧中的直接引用访问到具体方法去中的方法. 但是栈帧的方法能能访问class常量池的类信息么?
肯定不能, 因为此时数据压根还没进入到JVM运行时数据区. 那么当class常量池中的数据加载到我们的方法区时, 方法区也是有一个引用去专门识别class常量池中的方法的. 当通过符号引用把class常量池中的数据加载入方法区后, 符号引用就转化为直接引用, 也就是实际分配了一个运行时数据区的地址, 供我们运行时数据区中的引用直接访问. (符号引用可以使任意类型的字面量, 但具体明明规则不需要我们知道. 只要知道符号引用可以帮我们准确的从class常量池把类数据加载进运行时数据区就行).
即兴发挥一下: class常量池就像人口贩子. 不同的类信息就像它们抓住的小孩儿. 人口贩子哪有心情一个以记住小孩儿笑什么, 索性就把这群孩子叫成1号, 2号, 3号(符号引用) ......., 当哟润来买孩子时, 人口贩子说: "哎! 那个2号, 你过来!" . 卖家看到孩子很喜欢, 于是买下孩子. 但是孩子是自己人了总不能还叫2号吧, 于是就起了个名叫张三. 以后家里人叫孩子就直接叫张三了(直接引用).
直接引用
直接引用就是一个可以访问到真是地址的引用.(张三, 张三喊你呢!!)
字面量
所谓的字面量即变量的值. 字面量只能以"="右值出现. "="左值叫做常量或变量
int i=0;// i 是变量, 0是字面量
final int a = 10;// a是常量,10是字面量
string str = "hellow world";//str是变量,hello world是字面量
常量池与运行时常量池
当类加载到内存的时候, JVM会将class文件常量池(这个文件常量池是在class文件中的, 还没有到方法区)中的内容加载到运行时常量池中. 在解析阶段, JVM会把符号引用解析成直接引用(对象的索引值).
例如:类中的一个字符串量在class文件中时, 存放在class文件常量池中; 在JVM加载完类之后, JVM会将这个常量从class文件常量池加载到JVM常量池中. 并在解析过程中, 制定该字符串对象的索引值. 运行时常量池也是共享的, 多个类公用一个运行时常量池. class文件常量池中所存放的多个相同字面量的字符串在运行时常量池中只会存一份.
常量池有许多概念.例如class常量池,字符串常量池,运行时常量池.
虚拟机规范以上定义只属于方法区, 并没有规定虚拟机厂商的实现.
严格来说, Java中的常量池, 实际上分为两种形态: 静态常量池和运行时常量池.
1) 所谓静态常量池, 即*.class文件中的常量池, class文件中的常量池载入到内存中, 并保存在方法区中, 我们常说的常量池, 就是指方法区中的运行时常量池.
2) 而运行时常量池, 则是jvm虚拟机在完成类装载操作, 将class文件中的常量池载入到内存中, 并保存在方法区中, 我们常说的常量池, 就是指方法区中的运行时常量池.
运行时常量池在jdk1.7之后被移动到堆内存中, 这里的移动指的是物理空间, 但逻辑上还是方法区.(方法区是逻辑分区)
永久代与元空间
JDK1.7之前用的是永久代. 把字符串常量池的数据移动到了堆内存中实现. 但是这样做容易造成性能问题以及内存溢出. 永久代的大小时可以制定的, 但是因为永久代依然占据着运行时数据区的内存, 所以内存大小设置笑了容易造成永久代移除, 如果设置大了容易造成老年代溢出, 从而使垃圾回收变频繁导致效率降低. 因此在之后的元空间把永久代的理论干掉了. 元空间使用的是本地内存(直接内存), 所以元空间理论上只受本地内存的大小影响.
扩展: 当Oracle公司收购BEA获得了JRockit虚拟所有权, 准备把JRockit中优秀的功能一直到HotSpot中来. 但JRockit没有永久代, 因此给合并带来了很大的苦难. 在JDK6之后, HotSpot开发团队决定下功夫把永久代的概念取出, 逐渐改用为元空间.
元空间不再占用我们的运行时数据区的内存, 而是放到堆外内存中. 也就是说只要机器的总内存勾搭, JVM就不会出现方法区的内存溢出. 当然, 无限制的使用依旧会造成操作系统的死亡.
简单来说, 永久代和元空间指的都是方法区.
堆(heap)
堆占用了JVM中内存空间最大的一部分, 我们几乎申请的所有对象都是在堆内存中存储的. 我们常说的垃圾回收, 操作的对象就是堆.
堆空间一般是程序启动时就申请了, 但是不一定会全部用完. 堆一般设置成可伸缩的. (正常的堆随着程序启动一般只有很小. 随着对象的不断创建而不断增大扩容).
对着对象的频发创建, 堆空间占用的越来越多, 就需要不定期的堆不再使用的对象进行回收. 这个在Java中就叫做GC(Garbage Collection).
那么对一个对象的在创建的时候到底是分配在堆还是栈上呢?
对于基本类型来说: 如果是方法体内声明了数据类型的对象(局部变量), 它就会分配在栈上. 如果是其他情况(例如成员变量), 就会分配在堆中.
对于引用类型来说: new 出来的对象会创建在队中, 而引起会保存在虚拟机栈中局部变量表里.
扩展: 那么new出来的对象一定在堆中吗?
《Java虚拟机规范》中的描述是: "所有对象的示例以及数组都应当在堆上分配". 而随着即时编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上分配, 标量替换优化手段也已经导致这个说法不那么绝对了.
堆外内存(直接内存)
当JVM运行时会想操作系统申请大量内存进行数据存储. 例如虚拟机栈, 本地方法区, 程序计数器. 这块被称为栈区. 操作系统剩余的内存也就是堆外内存.
他不是虚拟机运行时数据区的一部分, 但受本机总内存限制. 所以也会产生OOM
小结:
1: 直接内存主要用DirectByteBuffer申请的内存, 可以使用参数"MaxDirectMemorySize"来限制大小(否则这个直到windows卡死也停不下来)
2: 堆外内存可以通过Unsafe或者其他JNI手段直接申请. 堆外内存泄漏是很严重的. 排查难度高, 影响大, 甚至造成主机死亡.