JVM—运行时数据区
运行时数据区概述
JVM运行时数据区如下图:
整个JVM构成里面,主要由三部分组成:类加载系统、运行时数据区、执行引擎。
按照线程使用情况和职责分成两大类:
- 线程独享(程序执行区域)
- 虚拟机栈、本地方法栈、程序计数器
- 不需要垃圾回收
- 线程共享(数据存储区域)
- 堆和方法区
- 存储类的静态数据和对象数据
- 需要垃圾回收
堆
Java堆在JVM启动时创建内存区域去实现对象、数组与运行时常量的内存分配,它是虚拟机管理最大的区域,也是垃圾回收的主要内存区域。
内存划分:
核心逻辑就是三大假说,基于程序运行情况进行不断地优化设计。
堆内存为什么会存在新生代和老年代?
分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说 (Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
- 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;
- 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域。
这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
内存模型变迁
JDK 1.7内存模型如下图:
- Young 年轻区︰主要保存年轻对象,分为三部分,Eden区、两个Survivor区。
- Tenured年老区︰主要保存年长对象,当对象在Young复制转移一定的次数后,对象就会被转移到Tenured区。
- Perm永久区︰主要保存class、method、filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到OOM:PermGen space的错误。
- Virtual区:最大内存和初始内存的差值,就是Virtual区。
JDK 1.8内存模型如下图:
- 由2部分组成,新生代(Eden+ 2*Survivor ) +年老代(OldGen )
- JDK1.8中变化最大的是Perm永久区用Metaspace进行了替换
- 注意:区别于JDK1.7,Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。
JDK 1.9内存模型如下图:
- 取消新生代、老年代的物理划分
- 将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代、老年代区域
虚拟机栈
栈帧是什么
栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执行的时候都会创建一个栈帧(Stack Frame)。栈内存大小决定了方法调用的深度,核内存过小则会导致方法调用的深度较小,如递归调用的次数较少。
虚拟机栈的构成如下:
当前栈帧
一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。
什么时候创建栈帧
调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。
栈异常的两种情况
- 如果线程请求的栈深度大于虚拟机所允许的深度(Xss默认1m),会抛出StackOverflowError异常
- 如果在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError异常【不一定会复现,不同机器不同现象】
本地方法栈
本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。
简单地讲,一个Native Method就是一个Java调用非Java代码的接口。
方法区
方法区(Method Area)是可供各个线程共享的运行时内存区域,方法区本质上是Java语言编译后代码存储区域,它存储每一个类的结构信息,例如:运行时常量池、成员变量、方法数据、构造方法和普通方法的字节码指令等内容。很多语言都有类似区域。
方法区的具体实现有两种:永久代(PermGen)、元空间(Metaspace)。
方法区存储什么数据
方法区构成如下:
主要有如下三种类型:
- Class:
- 1.类型信息,比如Class
- 2.方法信息:比如Method(方法名称、方法参数列表、方法返回值信息)
- 3.字段信息:比如Field(字段类型、字段名称需要特殊设置才能保存的住)
- 4.类变量(静态变量):JDK1.7之后,转移到堆中存储
- 5.方法表(方法调用的时候):调用某个类的方法时,根据某个类的方法表去查找合适的方法进行调用的。
- 运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后,转移到堆中存储
- 字面量类型
- 引用类型:内存地址
- JIT编译器编译之后的代码缓存
如果需要方法方法区中类的其他信息,都必须先获得Class对象,才能去方法该Class对象关联的方法信息或者字段信息。
永久代和元空间的区别是什么
- JDK1.8之前使用的方法区实现是永久代,JDK1.8及以后使用的实现是元空间;
- 存储位置不同:
- 永久代所使用的内存区域是JVM进程所使用的区域,它的大小受整个JVM的大小所限制;
- 元空间所使用的内存区域是物理内存区域,元空间的使用大小只会受物理内存大小的限制。
- 存储内容不同:
- 永久代存储的信息基本上就是上面方法区存储内容中的数据;
- 元空间只存储类的元信息,而静态变量和运行时常量池都挪到了堆中。
为什么要使用元空间来替代永久代
- 字符串存在永久代中,容易出现性能问题和永久代内存溢出;
- 类及方法的信息等比较难确定大小,所以对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出;
- 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
方法区实现变迁历史如下图:
字符串常量池
三种常量池的比较
- class常量池:一个class文件只有一个class常量池
- 字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
- 符号引用:Class、Method、Field等
- 运行时常量池:一个class对象有一个运行时常量池
- 字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
- 符号引用:Class、Method、Field等
- 字符串常量池:全局只有一个字符串常量池·双引号引起来的字符串值
- 双引号引起来的字符串值
字符串的存储位置
-
单独使用""引号创建的字符串都是常量,编译期就已经确定存储到Sstring Pool中。
-
使用new String("")创建的对象会存储到heap中,是运行期新创建的。
-
使用只包含常量的字符串连接符如"aa"+"bb"创建的也是常量,编译期就能确定已经存储到StringPool中。
-
使用包含变量的字符串连接如"aa"+s创建的对象是运行期才创建的,存储到heap中。
-
运行期调用String的intern()方法可以向String Pool中动态添加对象。
程序计数器
程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。
为什么需要程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换(系统上下文切换)后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。
存储的什么数据?
如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器的值则为空。
异常:此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
直接内存(堆外内存)与堆内存比较:
- 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显;
- 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显。
评论区