JVM学习-运行时内存篇
前言
视频教程:https://www.bilibili.com/video/BV1Dz4y1A7FB?p=2&vd_source=85ac5ee1b07df12a44b648a8751d30f6
相关知识点
HotSpotVM内存结构
或者和第一篇-字节码篇的HotSpotVM图一样即可。
哪些内存结构和线程一一对应
和线程一一对应即线程私有:程序计数器、本地方法栈、虚拟机栈都是线程私有。
程序计数器
作用
程序计数器用来存储指向下一条指令的地址
。CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
指令地址即PC程序计数器存放的地址。执行Native方法时程序计数器为undefined,因为native方法为C语言实现,没有被编译成字节码指令。
基本特征
- 很小的一块内存空间,几乎可以忽略不计,也是运行速度最快的存储区域,大小不会随着程序的运行而变化。
- 线程私有,生命周期和线程的生命周期相同。
- 唯一一个在JVM规范中没有规定任何OutOfMemoryError场景的区域。
虚拟机栈
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。
- 线程私有
- 生命周期和线程生命周期相同
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
- 栈中不存在GC,但存在OOM
- Java栈的大小是动态的或者是固定不变的(抛出的异常也不同)
栈和堆:
栈管运行:保存局部变量(8中基本数据类型、对象的引用地址)、部分结果、方法调用和返回
堆管存储:保存成员变量、属性、引用类型变量(类、数组、接口)
可能抛出的异常
StackOverFlowError:如果采用固定大小的Java虚拟机栈,超过最大容量,将会抛出一个StackOverflowError异常
OutOfMemoryError:如果Java虚拟机栈可以动态扩展,在尝试扩展或者创建新的线程时,没有足够的内存去创建对应的虚拟机栈,将会抛出—个OutOfMemoryError异常。
原因:1.局部变量表占用大
2.栈调用次数过多
如何设置栈大小
1 | -Xss size (即:-XX:ThreadStackSize) |
jdk5.0之前,默认栈大小:256k
jdk5.0之后,默认栈大小:1024k
栈大小不能设置过大,否则会导致系统用于创建线程的数量减少。(栈大,导致线程变大)
栈帧
即栈的单位。每个栈帧对应代码中的一个方法。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
内部结构
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 附加信息
局部变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量
- 局部变量表所需的容量大小是在编译期确定下来的,在方法运行期间是不会改变局部变量表的大小的
- 方法嵌套调用的次数由栈的大小决定
- 局部变量表中的变量只在当前方法(栈帧)调用中有效
是否会有线程安全问题
基本数据类型(栈私有)不会有线程安全问题,引用数据类型(局部变量表存放的地址)实际在堆中,会有线程安全问题
局部变量表的Slot
- 参数值的存放总是在局部变量数组的index为0开始,到数组长度-1的索引结束。
- 局部变量表,最基本的存储单元是Slot(变量槽)
- 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
- 如果当前帧是由
构造方法
或者实例方法
创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。 - 栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
局部变量和静态变量的区别
系统会给静态变量初始化初值,但不会给局部变量初始化初值,使用时必须初始化。
和GC Roots的关系
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了
- 栈中的任何一个元素都是可以任意的Java数据类型,32bit的类型占用一个栈单位深度,64bit的类型占用两个栈单位深度
- 在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据来完成一次数据访问。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈和局部变量表对比
局部变量首先从内存放入操作数栈,然后再存入局部变量表
1 | public void testAddOperation(){ |
字节码分析:
栈顶缓存技术
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接
动态链接即指向运行时常量池的方法引用。
- 描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。比如:invokedynamic指令。
静态链接、动态链接、早期绑定、晚期绑定
- 静态连接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在
编译期可知
,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。- 动态链接:如果被调用的方法在
编译期无法被确定下来
,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
绑定:链接对应的绑定机制。是一个字段、方法或者类在符号引用被替换为直接引用的
过程
,仅仅发生一次。
早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
非虚方法、虚方法
如果一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
- 非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。比如:静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
- 虚方法:运行时确定调用版本,除非虚以外的其他方法称为虚方法。
方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
- 如果在过程结束;类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过,则返回 java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常。
虚方法表
- 在面向对象的编程中,会很频繁的使用到动态分派。为了提高性能,JVM采用在类的
方法区
建立一个虚方法表
- 非虚方法不会出现在表中来实现。使用
索引表
来代替查找。- 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
- 虚方法表会在类加载的
链接阶段
被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
- 在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
- 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
- 通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
本地方法栈
本地方法
一个Java调用非Java代码的接口
- 通过native关键字修饰
- System.currentTimeMillis()方法
- Thread类的start()内部
- 标识符native可以与所有其它的java标识符连用,但是abstract除外。
本地方法栈
- 线程私有
- 大小可配置
堆
- 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
- Java 堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
- 堆内存的大小是可以调节的。
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 堆,是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆的内部结构
JDK7及之前:新生代、老年代、永久代
JDK8及之后:新生代、老年代、元空间
新生代和老年代
-
新生代:生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
- 新生代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)
- 几乎所有的Java对象都是在Eden区被new出来的
- 绝大部分的Java对象的销毁都在新生代进行了。
-
老年代:生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
堆的参数设置
设置堆内存大小
- "-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
- "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
- 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError:heap异常。
- 通常会将 -Xms 和 -Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
- 堆默认最大值计算方式:如果物理内存少于192M,那么heap最大值为物理内存的一半。如果物理内存大于等于1G,那么heap的最大值为物理内存的1/4。
- 堆默认最小值计算方式:最少不得少于8M,如果物理内存大于等于1G,那么默认值为物理内存的1/64,即1024/64=16M。最小堆内存在jvm启动的时候就会被初始化。
设置新生代和老年代的比例
-XX:NewRatio=参数
默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
可以使用选项”-Xmn”设置新生代最大内存大小。这个参数一般使用默认值就可以了。
设置Eden、幸存者区比例
-XX:SurvivorRatio=参数
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1。可以通过选项“-XX:SurvivorRatio”调整这个空间比例。比如-XX:SurvivorRatio=8
设置新生代垃圾的最大年龄
-XX:MaxTenuringThreshold=参数
设置空间分配担保(已失效)
-XX:HandlePromotionFailure=参数
空间分配担保
幸存者s0,s1区:复制之后有交换,谁空谁是to
垃圾回收:频繁在新生区收集;很少在养老区收集;几乎不在永久区/元空间收集
1 | 1.new的对象先放伊甸园区。此区有大小限制。 |
内存分配策略
如果对象在Eden 出生并经过第一次MinorGC 后仍然存活,并且能被Survivor 容纳的话,将被移动到Survivor 空间中,并将对象年龄设为1 。对象在Survivor 区中每熬过一次MinorGC , 年龄就增加1岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。
内存分配原则
- 优先分配到Eden
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
- 空间分配担保
-XX:HandlePromotionFailure
MinorGC、MajorGC、FullGC
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型一种是部分收集(Partial GC),一种是整堆收集(Full GC)
- 部分收集:
- 新生代收集(Minor GC / Young GC):只是新生代(Eden\S0,S1)的垃圾收集
- 老年代收集(Major GC / Old GC):只是老年代的垃圾收集
- 目前,只有CMS GC会有单独收集老年代的行为
- 很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集
MinorGC触发机制
- 当年轻代空间不足时,就会触发Minor GC。这里的年轻代满指的是Eden区满,Survivor满不会引发GC。(每次 Minor GC 会清理年轻代的内存。)
- 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代GC(Major GC/Full GC)触发机制
- 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了。
- 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
- 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
- Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
- 如果Major GC 后,内存还不足,就报OOM了。
FullGC触发机制
- 调用System.gc()时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。
堆中出现OOM如何解决
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
为什么需要把Java堆分代?
优化GC性能。
TLAB
为什么需要TLAB
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
- 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
- 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
方法区
存放内容:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存
- 存放类型信息。① 这个类型的完整有效名称(全名=包名.类名)② 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类) ③ 这个类型的修饰符(public,abstract, final的某个子集) ④ 这个类型直接接口的一个有序列表
- 域信息。域名称、域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)
- 方法信息。名称、返回类型、参数数量和类型、修饰符、字节码、异常表
- 运行时常量池。用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。【运行时常量池:数量值、字符串值、类引用、字段引用、方法引用】
方法区看作是一块独立于堆的内存空间,逻辑上是堆的一部分。
- 方法区与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类
- 方法区也会报OOM。通常因为创建的类过多且没有销毁;加载大量第三方jar包。
- 关闭JVM就会释放这个区域的内存
堆、栈、方法区的关系
元空间和方法代的区别
元空间不在虚拟机设置的内存中,而是使用本地内存。
方法区参数设置
元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定
为什么永久代要被元空间替换
- 为永久代设置空间大小是很难确定的。
- 对永久代进行调优是很困难的。
为什么StringTable要调整
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
方法区是否存在GC
主要回收两部分内容:常量池中废弃的常量和不再使用的类型。只要常量池中的常量/类型没有被任何地方引用,就可以被回收。
- 判断常量和判断Java堆中的对象类似
- 判断类比较麻烦
- 该类所有的实例(父子类)都已经被回收
- 加载该类的类加载器已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
有以上三种条件是回收的充分条件,不一定都满足就一定被回收。
直接内存
直接内存是在Java堆外的、直接向系统申请的内存区间
特点:
- 来源于
NIO
,通过存在堆中的DirectByteBuffer操作Native内存- Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
- 访问直接内存的速度会优于Java堆。即读写性能高
- 出于性能考虑,读写频繁的场合可能会考虑使用直接内存
- 直接内存大小可以通过MaxDirectMemorySize设置
- 如果不指定,默认与堆的最大值-Xmx参数值一致
缺点:
- 分配回收成本较高
- 不受JVM内存回收管理
- 也可能导致OutOfMemoryError异常
面试题
程序计数器
程序计数器如何计数
1 | ``` |
角度一:GC;OOM
角度二:栈、堆执行效率
角度三:内存大小;数据结构
角度四:栈管运行;堆管存储。
1 |
|
虚拟机栈-可能抛出的异常。
场景:
一、局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。
二、递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
1 |
|
堆的内部结构、栈帧的内部结构
1 |
|
堆的内部结构
1 |
|
堆的参数设置
1 |
|
空间分配担保
1 |
|
MinorGC、MajorGC、FullGC
1 |
|
永久代
1 |
|
- Java 6及以前,字符串常量池存放在永久代。
- Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
- Java 8 中,字符串常量仍然在堆。
1 |
|
综上