什么是虚拟机?
系统虚拟机
和程序虚拟机
。什么是 Java 虚拟机?
执行 Java 字节码的虚拟计算机
,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。跨平台性
、优秀的垃圾回收器
,以及可靠的即时编译器
。Java 技术的核心就是 Java 虚拟机
(JVM,Java Virtual Machine),因为所有的 Java 程序都运行在 Java 虚拟机内部。JVM 的架构模型
Java 编译器输入的指令流基本上是一种基于栈的指令集架构
,另外一种指令集架构则是基于寄存器的指令集机构
。
基于栈式架构的特点
可移植性
更好,更好实现跨平台
。基于寄存器架构的特点
由于跨平台的设计,Java 的指令都是根据栈来设计的。
JVM 的生命周期
虚拟机的启动:
Java 虚拟的启动是通过引导类加载器
(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。虚拟机的执行:
执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机等待进程。虚拟机的退出:
程序正常执行结束、程序在执行过程中遇到异常或错误而终止、由于操作系统出现错误而导致Java虚拟机进程终止。某线程调用 Runtime 类 halt 方法或 System 类的 exit 方法,并且 Java 安全管理器也允许这样的操作。作用:
加载 Class 文件
, class 文件在文件开头有特定的文件标识。是否可以运行,则由 Execution Engine 决定
。加载的类信息存放于一块称为方法区的内存空间
。除了类的信息外,方法区中还会存放运行时常量池信息
,可能还包括字符串字面量
和数字常量
(这部分常量信息是Class 文件中常量池部分的内存映射)。加载
:通过一个类的全限定名获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的 java.lang.Class 对象
,作为方法区这个类的各种数据的访问入口。
链接
:
确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性
,不会危害虚拟机自身安全。主要包含四种验证:文件格式验证
,元数据验证
,字节码验证
,符号引用验证
。为类变量分配内存并且设置该变量的默认初始值
,即零值。这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化。同时这里也不会为实例变量分配初始化
,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java 堆中。将常量池内的符号引用转换为直接引用的过程
。事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。初始化
:
执行类构造器方法 ()
的过程。类变量的赋值动作
和静态代码
中的语句合并而来。JVM 会保证子类的 () 执行前,父类的() 已经执行完毕
。一个类的() 方法在多线程下被同步枷锁
。JVM 支持俩种类型的类加载器,分别为:
引导类加载器(Bootstrap ClassLoader)(或者叫启动类加载器)
。
自定义加载器(User-Defined ClassLoader)
。
自定义加载器又可分为:
扩展类加载器
:
应用程序类加载器
(系统加载器,ApplicassLoader):我们自己代码写的类就是该加载器加载的。
该类加载是程序中默认的类加载器
,一般来说,Java 应用的类都是由他来完成加载的。除此之外用户还可以自定义类加载器,来定制类的加载方式,好处是:
自定义类加载器实现步骤:
获取 ClassLoader 的途径:
clazz.getClassLoader()
Thread.currentThread().getContextClassLoader()
ClassLoader.getSystemClassLoader()
DriverManager.getCallerClassLoader()
Java 虚拟机对 class 文件采用的是按需加载
的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生产 class 对象。而且加载某个类的 class 文件时
,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,他是一种任务委派模式
。
工作原理:
优势:
在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:
类名必须一致,包括包名
。类的 Classloader (指 ClassLoader 实例对象)必须相同
。Java 虚拟机定义了若干中程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
灰色的为单独线程私有的,红色的为多个线程共享的。即:
线程是一个程序里的运行单元。JVM 允许一个应用有多个线程并行的执行。当一个Java 线程准备好执行以后,此时一个操作系统的本地线程也同时创建
。Java 线程执行中止后,本地线程也会中止。
后台线程在 Hotspot JVM 里主要是以下几个:
虚拟机线程:
周期任务线程:
GC线程:
编译线程:
信号调度线程。
什么是 PC 寄存器?
很小的内存空间
,几乎可以忽略不计。也是运行速度最快
的存储区域。线程私有的
,生命周期与线程的生命周期一致
。存储当前线程正在执行的 Java 方法的 JVM 指令地址
;或者,如果在执行 native 方法,则是未指定值(undefined)
。程序控制流的指示器
,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
。唯一一个
在Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域
。使用 PC 寄存器存储字节码指令地址有什么用?
因为 CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM 字节码解释器就需要通过改变 PC 寄存器的值来`明确下一条应该执行什么样的字节码指令。
PC 寄存器为什么会被设定为线程私有?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器。
Java 虚拟机栈
(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈
,其内部保存一个个的栈帧
,对应这一次次的 Java方法调用
。生命周期和线程一致
。主管 Java 程序的运行,它保存方法的局部变量
(8中基本数据类型、对象的引用地址)、部分结果
,并参与方法的调用和返回
。
栈是运行时的单位,而堆是存储的单位。
特点:
速有效的分配存储方式
,访问速度仅次于程序计数器。进栈
(入栈、压栈);执行结束后的出栈
工作。不存在垃圾回收问题
。设置栈的大小:
-Xss
选线来设线程的最大栈空间(后面可接单位 k,m)、栈的大小直接决定了函数调用的最大可达深度。栈中存储了什么?
每个线程都有自己的栈
,栈中的数据都是以栈帧
(Stack Frame) 的格式存在。每个方法都各自对应一个栈帧
。内存空间
,是一个数据集
,维系着方法执行过程中
的各种数据信息
。栈运行原理:
压栈
和出栈
,遵循 先进后出,后进先出 的原则。当前栈帧
,与当前栈帧相对于的方法就是当前方法
,定义这个方法的类就是当前类
。栈帧
进行操作。栈的顶端
,成为新的当前栈
。每个栈帧中存储着:
局部变量表(Local Variables)
操作数栈(Operand Stack
)(或表达式栈)动态连接(Dynamic Linking)
(或指向运行时常量池的方法应用)方法返回地址(Return Address)
(或方法正常退出或者异常退出的定义)一些附加信息
1. 局部变量表:
局部变量表
也被称为局部变量数组
或本地变量表
。定义为一个数组,主要用于存储方法参数和定义在方法体内的局部变量,
这些数据包括各类基本数据类型、对象引用、以及 returnAddress 类型。线程的私有数据
,因此不存在数据安全问题
。局部变量表所需的容量大小是在编译确定下来的
,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
方法嵌套调用的次数由栈的大小决定的
。一般来说,栈越大,方法嵌套调用次数越多
。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。局部变量表中的变量只在当前方法调用中有效
。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随着销毁
。关于 Solt 的理解:
局部变量表,最基本的存储单元是 Solt (变量槽)
。32位以内的类型只占用一个 solt
(包括 returnAddress 类型),64位的类型(long,double)占用俩个solt。
如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的solt处,其余的参数按照参数表顺序继续排列。
需要注意的是:栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用于,那么在其作用域后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。如下图所示:变量 c 就重复利用了 变量 b 的槽位。
静态变量和局部变量的对比:
局部变量则必须认为的初始化
,否则无法使用。在局部变量表中的变量也是重要的垃圾回收跟节点,只要被局部变量表中直接或间接引用的对象都不会被回收
。2. 操作数栈:
每一个独立的栈帧中除了包含局部变量表以外,还包含一个先进后出的操作数栈,也可以称之为表达式栈。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据
,即入栈(push)/出栈(pop)。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中
,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最搭深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
栈中的任何一个元素都是可以任意的 Java 数据类型。
操作数栈并非采用访问索引的方式来进行数据访问
的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
栈顶缓存技术:
将栈顶元素全部缓存在物理CPU的寄存器,以此降低对内存的读/写次数,提升执行引擎的执行效率。
3. 动态链接:
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如 invokedynamic 指令。
在Java源文件被编译到字节码文件中,所有的变量的方法引用都作为符号引用(Symbolic Reference) 保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中执行方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法的调用:
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
静态链接:
当一个字节码文件被装载 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。动态链接:
如果调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。所对应的方法绑定机制为:早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次
。
早期绑定:
早期绑定就是被指调用的目标方法如果在编译期可知,且运行期保持不变
时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就是可以使用静态链接的方法将符号引用转换为直接引用。晚期绑定:
如果被调用的方法在编译期无法确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,
这种绑定方式也就被称之为晚期绑定。非虚方法:
编译期就确定了具体的调用版本,
这个版本在运行时是不可变的,
这样的方法成为非虚方法。
静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。
虚方法表:
为了提高性能,JVM 采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
那么虚方法表什么时候被创建?
链接阶段被创建并初始化
,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。4. 方法返回地址:
调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
而通过异常通过退出的,返回地址是要通过异常 表来确定的
,栈帧中一般不会保存这部分信息。正常完成出口和异常完成出口的区别在于:通常异常完成出口退出的不会给他的上层调用者产生任何的返回值。
5. 一些附加信息:(了解即可)
一个Native Method
就是一个Java 调用的非 Java 代码的接口,在定义一个Native method时,并不提供实现体(有些像定义一个 Java interface),因为其体现是由非 Java 语言在外面实现的。
本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。表示符 native 可以与所有其他的 Java 标识符连用,但是 abstract 除外。有时 Java 应用需要与 Java 外面的环境交互,这是本地方法存在的主要原因。
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)。
本地方法是使用C语言实现的。
它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不受虚拟机限制的世界。它和虚拟机拥有相同的权限。
虚拟机内部的运行时数据区。
并不是所有的虚拟机都支持本地方法。因为Java虚拟机规范并没有明确的要求本地方法栈的使用语言、具体实现方式、数据结构等
。如果 JVM 产品不打算支持 native 方法,也可以无需是实现本地方法栈。
在 HotSpot JVM 中,直接将本地方法栈和虚拟机栈合二为一。
堆的核心概述:
一个进程对应一个 JVM 实例,一个 JVM 实例只存在一个堆内存,
堆也是 Java 内存管理的核心区域。Java 堆区在 JVM 启动的时候即被创建,其空间大小也就是确定了。
是 JVM 管理的最大一块内存空间。堆内存的大小是可以调节的。
物理机上
不连续的内存空间中,但在逻辑上
它应该被视为连续的。所有的线程共享Java堆(一个进程内或一个JVM实例内),在这里还可以划分线程私有的缓冲区(TLAB)。
现代垃圾收集器大部分都是基于分代收集理论设计,堆空间细分为:
Java 7 及之前
堆内存逻辑上
分为三部分:新生代 + 养老代 + 永久区
Java 8 及以后
堆内存逻辑上
分为三部分:新生代 + 养老代 + 元空间
设置堆内存大小与OOM
Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项"“-Xmx” 和 "-Xms"来进行设置。
"-Xms" 用于表示堆区的起始内存,
等价于 -XX:InitialHeapSize“-Xmx” 用于表示堆区的最大内存,
等价于 -XX:MaxHeapSize一旦堆区中的内存大小超过了 “-Xmx” 所指定的最大内存时,将会抛出 OutOfMemoryError异常。
通常会将 -Xms 和 -Xmx 俩个参数配置相同的值,其目的是为了能够在Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下,初始内存大小:物理电脑内存大小 / 64,最大内存大小:物料电脑内存大小 / 4。
老年代与年轻代:
存储在 JVM 中的 Java 对象可以被划分为俩类:
生命周期较短
的瞬间对象,这类对象的创建和消亡都非常迅速。生命周期却非常长
,在某些极端的情况下还能够与JVM 的生命周期保持一致。Java 堆区进一步细分的话,可以划分为年轻代和老年代
其中年轻代又可以划分 Eden 空间、Survivor0 空间和 Surviror1 空间(有时也叫做 from 区、to区)。
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1
,可以通过选项 " - XX:SurvivorRatio"调整这个空间比例。比如 -XX:SurvivorRatio=8
几乎
所有的Java对象都是在Eden区被new出来的。
绝大部分的Java对象的销毁都在新生代进行了。
可以使用选项 “-Xmn” 设置新生代最大内存大小。
对象分配过程:
为新对象分配内存是一件非常严谨和复杂的任务。JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
new 的对象先放伊甸园区
。此区有大小限制。
当伊甸园的空间填满后,
程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC)
,将伊甸园区中的不在被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
如果再次出发垃圾回收
,此时上次幸存下来的放在幸存者0区的
,如果没有回收,就会放到幸存者1区。
如果再次经理垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
在养老区,相对悠闲。当养老区内存不足时,再次出发 GC:Major GC,进行养老区的内存清理。
若养老区执行了 Majro GC 之后发现依然无法进行对象的保存,就会产生OOM异常。
(java.lang.OutOfMemoryError):Java heap space。
总结:
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
关于垃圾回收:频繁在新生区收集,很少在养老区收费,几乎不在永久区/原空间收集。
流程图:
Minor GC、Major GC 与 Full GC
JVM 在进行 GC 时,并非每次都对上面三个内存(新生代,老年代,方法区)区域一起回收的,大部分时间回收的都是新生代。
针对 HotSpot JVM 的实现,它里面的GC 按照回收区域又分为两大类型:部分收集(Partial GC)和 整堆收集(Full GC)。
部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
只是新生代(Eden、s0、s1)的垃圾收集。
只是老年代的垃圾收集。
目前,只有 CMS GC 会有单独收集老年代的行为。注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。整堆收集(Full GC):收集整个 Java 堆和方法区
的垃圾收集。
年轻代GC(Minor GC)触发机制:
当年轻代空间不足时,就会触发 Minor GC
,这里的年轻代满指的是 Eden 区满,Survivor 满不会引发 GC。
(每次 Minor GC 会清理年轻代的内存)。非常频繁
,一般回收速度也比较快。
STM,暂停其他用户的线程
,等垃圾回收结束,用户线程才恢复运行。老年代GC(Major GC/Full GC) 触发机制:
也就是在 老年代空间不足时,会先尝试触发 Minor GC。如果之后空间还不足,则触发 Major GC。
比 Minor GC 慢10倍以上,STM 的时间更长。
Full GC 触发机制:(后面细讲)
为什么需要把Java堆分代?不分代就不能正常工作了吗?
分代的唯一理由就是优化GC 性能。
如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是“朝生夕死”的,如果分代的话,吧新创建的对象放到某一个地方,当GC的时候先把这块存储 "朝生夕死"对象的区域进行回收,这样就会腾出很大的空间出来。内存分配策略:
针对不同年龄段的对象分配原则如下所示:
优先分配到 Eden。
大对象直接分配到老年代,
尽量避免程序中出现过多的大对象。长期存活的对象分配到老年代。
动态对象年龄判断。
如果 survivor 区中相同年龄的所有对象大小的总和大于 survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到年龄达到阈值。空间分配担保。
:-xx:HandlePromotionFailure。就是当servivor 区满了,需要分担给老年代区。内存分配过程:TLAB
什么是 TLAB?
堆 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题
,同时还能提升内存空间的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。不是所有的对象实例都能够在TLAB中成功分配内存
,但JVM确实是将TLAB作为内存分配的首选。-XX:UseTLAB
” 设置是否开启TLAB空间。-XX:TLABWasteTargetPercent
”设置TLAB空间所占用Eden空间的百分比大小。TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
为什么有 TLAB(Thread Local Allocation Buffer)?
并发环境下从堆区中划分内存空间是线程不安全的。
避免多个线程同时操作同一个地址,需要使用枷锁等机制,进而影响分配速度。
堆是分配对象的唯一选择吗?
不是。
有一种特殊情况,那就是经过逃逸分析(Escape Analysis)(其实就是局部变量,不会被外部方法使用到)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。
这样就无需再堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。
栈上分配。
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
同步策略。
如果一个对象被发现中能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
分离队形或标量替换。
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
在《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上属于堆的一部分
,但一些简单的实现可能不会选择区进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区看作是一个独立于Java堆的内存空间。
是各个线程共享的内存区域。
方法区在JVM启动的时候被创建
,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
可以选择固定大小或者扩展。
方法区的大小决定了系统可以保存多少个类,
如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。java.lang.OutOfMemoryError:PermGen space(JDK 7) 或者 java.lang.OutOfMemoryError:Metaspace(JDK8)。关闭JVM就会释放这个区域的内存。
设置方法区内存的大小:
-XX:MetaspaceSize和-XX:MaxMetaspaceSize
指定,替代上述原有的俩个参数。默认情况下,虚拟机会耗尽所有的可用系统内存,如果元数据区发生溢出,虚拟机一样会抛出异常OutOMemoryError:Metaspace。
永久代则使用的是JVM的内存。-XX:MetaspaceSize:设置初始的原空间大小。
对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触发这个水位线,Full GC 将会触发并卸载没用的类(即这些类对应的类加载器不在存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的内存不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。为了避免频繁的GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
方法区(Method Area)存储什么?
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息:
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
完整有效名称
(全名=包名.类名)。直接父类的完整有效有效名
(对于interface或是java.lang.Object,都没有父类)。修饰符(public,abstract,final的某个子集)。
直接接口的一个有序列表
。JVM 必须在方法中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)。JVM必须保存所有方法的一些信息,同域信息一样包括声明顺序:
方法名称
方法的返回类型(或 void)
方法参数的数量和类型(按顺序)
方法的修饰符(public,private,protected,static,fianl,synchronized,natice,abstract的一个子集)
方法的字节码(bytecodes)、操作数栈,局部变量表的大小(bastract和natice方法除外)。
异常表(abstract和natice方法除外)
,每个异常处理的开始位置,结束位置,代码处理和程序计数器中的偏移地址、被捕获的异常类的常量池索引。运行时常量池:
方法区,内部包含了运行时常量池。
字节码文件,内部包含了常量池。
为什么需要常量池?
数据会很大以至于不能直接存放到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。
在动态链接的时候会用到运行时常量池,比如如下的代码:public class SimpleClass{
public void sayHello(){
System.out.pritln("hello");
}
}
虽然只有194字节,但是里面却使用了String,System,printStream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!
这里就需要常量池了!常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池的理解:
运行时常量池是方法区的一部分。
常量池(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生产的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护了一个常量池。池中的数据项向数组项一样,是通过索引访问的。
包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真是地址。
具备动态性。
相对于静态常量池,运行时常量池具有动态性,在程序运行的时候可能将新的常量放入运行时常量池中,比如使用String类的intern方法。构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutofMemoryError异常。
StringTable 为什么要调整?
判断存储位置案例:
三个 new ObjectHolder();都是存储在堆中的。instanceObje 成员变量,随着对象一起存放到堆中的。localObjet 是存放到栈的局部变量表中的,statieObj在jdk1.7之前是存放在永久代中的,JDK1.7之后是存放在堆中了。
方法区的垃圾回收:
可以不要求虚拟机在方法区实现垃圾回收。
较难令人满意,尤其是类型的卸载
,条件相当苛刻。但是这部分区域的回收有时有确实是必要的。常量池中废弃的常量和不再使用的类型。
new
class的newInstance():反射的方式
,只能调用空参的构造器,权限必须是public
Constructor的newInstance(xxx):反射的方式
,可以调用空参、带参的构造器,权限没有要求
使用clone():
不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
使用反序列化:
从文件中、网络中获取一个对象的二进制流
第三方库objenesis
判断对象对应的类是否加载、链接、初始化。
虚拟机遇到一条 new 指令,首先区检查这个指令的参数能否在 Metaspacce 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 Key 进行查找对应的 class 文件。如果没有找到文件,则抛出 ClassNotFoundException 异常,如果找到,则进行类加载,并生产对应的 Class 类对象。
为对象分配内存。
首先计算对象占用空间的大小,接着在堆中划分一块内存给对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即四个字节。
空闲列表法来为对象分配内存。
意思是虚拟机维护了一个列表,记录那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式称为空闲列表处理并发安全问题(防止多线程挣抢内存)。
采用cas失败重试,区域加锁保证更新的原子性。每个线程分配一块tlab,通过-XX:+/-UseTLAB参数来设置,jdk8默认是开启的。
初始化分配到的空间(零值初始化)。
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。
设置对象的对象头。
将对象的所属类(即类的元数据信息)、对象的hashcode和对象的gc信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于jvm实现
执行init方法进行初始化。
在java程序的视角来看,初始化才正式开始,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。显示赋值 代码块赋值 构造器中赋值都是在这里进行的
对象头(Header)
。对象头包含俩部分:
实例数据(Instance Data)
:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)。规则是:相同宽度的字段总是被分配到一起、父类中定义的变量会出现在子类之前、如果 CompacFields 参数为 true(默认为true):子类的窄变量可能插入都父类变量的空隙。
对齐填充(Padding)
:不是必须的,也没特别含义,仅仅起到占位符的作用。
举例子说明:
public class CustomerTest{
public static void main(String[] args){
Customer cust = new Customer();
}
}
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
}
class Account{
}
上述代码的内存结构分布图如下:
句柄访问。
直接指针(HotSpot采用)。
缺点:
虚拟机的执行引擎则是有软件自行实现的,
因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
将字节码指令解释/编译为对应平台上的本地机器指令才可以。
简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。执行引擎的工作过程:
执行什么样的字节码指令完全依赖于PC寄存器。
更新下一条需要被执行的指令地址。
执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中对象实例信息,以及通过对象头中元数据指针定位到目标对象的类型信息。
输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤。
什么是解释器(Interpreter),什么是JIT编译器?
将每条字节码
文件中的内容"翻译"为
对应平台的本地机器指令执行。
虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
为什么说Java是半编译半解释型语言?
将解释执行与编译执行二者结合起来进行。
Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:
编译器和解释器之间的区别:
注:这里的程序指字节码
看到这里可能会有疑问,编译器到底编译的是源程序,还是字节码?
前端编译器
(其实叫 “编译器的前端” 更准确一些) 把 .java 文件转变成 .class 文件的过程。是 Sun 的 Javac 、Eclipse JDT 中的增量式编译器(ECJ)。后端运行期编译器
(JIT 编译器
,Just In Time Compiler)把字节码转变成机器码的过程。HotSpot VM 的 C1、C2
的编译器。静态提前编译器
(AOT 编译器
,Ahead Of Time Compiler,JDK9 引入):所谓AOT 编译,是与即时编译相对立的一个概念。即时编译器指的是程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码。而AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。HotSpot JVM 的执行方式:
热点探测功能
,将有价值的字节码编译为本地机器指令,以换取更高的执行效率。热点代码及探测方式:
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为 "热点代码",
因此都可以通过JIT编译器翻译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR(On Stack Replacement)翻译。
一个方法究竟要`被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT 编译器才会将这些 "热点代码"编译为本地机器指定执行。这里主要依靠热点探测功能。
目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,
HotSpot VM 将会为每一个方法建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
调用次数。
它的默认阈值在 Client 模式下是 1500次,在 Server 模式下是10000次。超过这个阈值就会触发JIT编译器。具体过程为:当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译后的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和
是否超过方法嗲用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。循环次数。
方法调用计数器的热度衰减:
一段时间之内方法被调用的次数。
当超过一定的时间限制,
如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,
这个过程成为方法调用计数器热度的衰减,
而这段时间就称之为此方法统计的半衰周期。
-XX:-UseCounterDecay 来关闭热度衰减,
让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。 -XX:CounterHalfLifeTime 参数设置半衰期的时间,单位是秒。
HotSpot VM 可以设置程序执行方式:
-Xint
:完全采用解释器模式执行程序。-Xcomp
:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。-Xmixed
:采用解释器+即时编译器的混合模式共同执行程序。HotSpot VM 中的 JIT 分类:
在HotSpot VM 中内嵌有两个JIT编译器,分别为 Client Comiler
和 Server Compile
r,但大多数情况下我们简称为 C1 编译器和 C2 编译器
。开发人员可以通过如下命令显式指定 Java 虚拟机在运行时到底使用哪一种即时编译器,如下所示:
-client
:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器;C1 编译器会对字节码进行简单和可靠的优化,耗时短。已达到更快的编译速度。-server
:指定Java 虚拟机运行在 Server 模式下,并使用 C2 编译器。C2 进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。C1 和 C2 编译器不同的优化策略:
在不同的编译器上有不同的优化策略,C1 编译器上主要有方法内联,去虚拟化、冗余消除。
方法内联
:将引用的函数代码编译到引用点出,这样可以减少栈帧的生成,减少参数传递以及跳转过程。去虚拟化
:对唯一的实现类进行内联。冗余消除
:在运行期间吧一些不会执行的代码折叠掉。C2 的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在 C2 上有如下几种优化:
标量替换
:用标量值代替聚合对象的属性值。栈上分配
:对于未逃逸的对象分配对象的栈而不是堆。同步消除
:清楚同步操作,通常指 synchronized。分层编译(Tiered Compilation)策略
:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。不过在 Java 7 版本之后,一旦开发人员在程序中显示指定命令 “-server” 时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。
什么是字符串常量池,和运行时常量池有什么关系?
String Pool
。在HotSpot VM里实现的string pool功能的是一个StringTable类
,它是一个哈希表
。 在工作中,String类是我们使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建
,其维护了一块特殊的内存空间,这就是我们今天要讨论的核心:字符串常量池。字符串常量池由String类私有的维护。堆里边的字符串常量池存放的是字符串的引用或者字符串
(两者都有)。String 的基本特性:
String 声明为final的,不可被继承
。
Sting 实现了 Serializable接口:表示字符串是支持序列化
的。实现了Comparable接口:表示String可以比较大小。
String在jdk及以前内部定义了finalchar[]
value 用于存储字符串数据。jdk9 时改为byte[]
。
代表不可变的字符序列。简称:不可变性。
当字符串重新赋值
时,需要重写指定内存区域
赋值,不能使用原有的value进行赋值。
当对现有的字符串进行连续操作
时,也需要重新指定内存区域
赋值,不能使用原有的value进行赋值。
当调用String的replace()
方法修改指定字符或字符串时,也需要重新指定内存区域
赋值,不能使用原有的value进行赋值。
通过字面量的方式(双引号赋值)
(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池中是不会存储相同内容
的字符串的。
String 的String Pool 是一个固定大小的HashTable
,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后会直接造成的影响就是当调用String.intern(判断当前字符串是在在常量池中,没有则创建,其实就是放入stringtable 中)时性能会大幅下降。
使用 -XX:StringTableSize 可设置StringTable 的长度。
在 jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求。
在jdk7中,StringTable的长度默认值是60013,StringTableSize设置没有要求。
Jdk8开始,设置StringTable的长度的话,1009是可设置的最小值。
在Java语言中有8中基本数据类型和一种比较特殊的类型String。这些类型为了使他们在运行过程中速度更快、更节省内存,都提供一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8中基本数据结构类型的常量池都是系统协调的。String类型的常量池比较特殊。它的作用使用方法有两种。
使用双引号声明出来的String对象会直接存储在常量池中
。比如String info = “abc”;String 提供的intern() 方法
。Java 6 及以前,字符串常量池存放在永久代。
Java 7 中 Oracle 的工程师堆字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到了Java堆内。
Java 8 元空间替代永久代,字符串常量还是在堆。
字符串拼接操作:
intern() 的使用:
在 jdk1.6中,将这个字符串对象尝试放入串池,如果串池中有,则并不会放入,返回已有的串池中的对象的地址。如果没有,会把次对象复制一份
,放入池中,并返回串池中的对象地址。
在 jdk1.7期,将这个字符串对象尝试放入串池,如果串池中有,则并不会放入,返回已有的串池中的对象的地址。如果没有,会把次对象的引用地址复制一份
,放入池中,并返回串池中的对象地址。
如果不是用双引号声明的String 对象,可以使用String 提供的intern方法,intern方法会从字符串常量池中查询当前字符串是否存在。若不存在就会将当前字符串放入常量池中。比如:
String myinfo = new String("I love abc").intern();
也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同,因此,下列表达式的值必定是true;
("a" + "b" + "c").intern() == "abc";
通俗点讲,Interned String 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意:这个值会被存放在字符串内部池(String Intern Pool);
如何保证变量 S 指向的是字符串常量池中的数据呢?
方式一:
String s = "abc";
方式二:
String s = new String("abc").intern();
String s = new StringBuilder("abc").toString().intern();
new String(“ab”) 会创建几个对象呢?(俩个)
new String(“a”) + new String(“b”) 创建了几个对象呢?6 个
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s1 = new String("1") + new String("1");
System.out.println(s1.intern() == s1);
JDK6中,常量池在永久代中,s1.intern()去常量池中查找"11",发现没有该常量,则在常量池中开辟空间存储"11",返回常量池中的值,s1指向堆空间地址,所以二者不相等。
JDK7中,常量池在堆空间,s1.intern()去常量池中查找"11",发现没有该常量,则在字符串常量池中开辟空间,指向堆空间地址,则返回字符串常量池指向的堆空间地址,s1也是堆空间地址,所以二者相等。
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
G1的String 去重操作:
背景:对许多Java应用(有大的也有小的) 做的测试得出一下结论:
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合超不多25%是String对象。
更进一步,这里面差不多一般String对象时重复的,重复的意识是说:String1.equals(String2) = true。堆上存在重复的String对象必然是一种内存的浪费。
这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。
实现:
对每一个访问的对象都会检查是否是候选的要去重的String对象。
命令行选项:
默认是不开启的,需要手动开启。
什么是垃圾?
运行程序中没有任何指针指向的对象
,这个对象就是需要被回收的垃圾。为什么需要GC?
内存迟早都会被消耗完,
因为不断地分配内存空间而不进行回收,就好像不停生产生活垃圾而从来不进行打扫一样。JVM将整理出的内存分配给新的对象。
没有GC就不能保证应用程序的 正常进行。
而经常造成STW的GC又更不上实际的需求,所以才会不断地尝试对GC进行优化。Java 垃圾回收机制
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。
如果没有垃圾回收器,Java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发。
垃圾回收器可以堆年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。其中,Java堆是垃圾收集器的工作重点。
从次数上来说
垃圾标记阶段:对象存活判断
需要区分内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,
只有被标记已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称之为垃圾标记阶段。
引用计数算法
和可达性分析算法。
方式一:引用计数算法
引用计数器属性。用于记录对象被引用的情况。
实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
增加了存储空间的开销。
每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
引用计数器有一个严重的问题,即无法处理循环引用的情况。
这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
方式二:可达性分析(或根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同时具备实现简单
和执行高效等
特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
相较于引用计数算法,这里的可达性分析就是Java、C#选择的。
这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage)。
所谓 “GC Roots” 跟集合就是一组必须活跃的引用。
基本思路:
搜索被根对象集合所连接的目标对象是否可达。
引用链(Reference Chain)。
在Java语言中,GC Root是包括以下几类元素:
虚拟机栈中引用的对象
:各个线程被调用的方法中使用的参数、局部变量等。
本地方法栈内JNI(通常说的本地方法)引用对象。
方法区中类静态属性引用的对象。
比如,Java类的引用类型静态变量。
方法区中常量引用的对象。
比如,字符串常量池(StringTable)里的引用。
所有被同步锁synchronized持有的对象。
Java虚拟机内部的引用。
基本数据类型对象的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Root集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同、还可以有其他对象"临时性"地加入,共同构成完整GC Roots 集合。
比如:分代集合和局部回收(Partial GC)。
小技巧:由于 Root 采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那就是一个Root。
使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。
这点不满足的话分析结果的准确性就无法保证。这点也是导致GC进行时必须""
Stop The World 的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收次对象之前,总会先调用这个对象的 finalize() 方法。
finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放,
通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据连接等。
永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。理由包括下面三点:
从功能上来说,finalize()方法与 C++ 中的析构函数比较相似,但是Java采用的是基于垃圾回收期的自动内存管理机制,所以 finalize() 方法在本质上不同于 C++ 的析构函数。
由于 finalize() 方法的存在,虚拟机中的对象一般处于三种可能的状态。
如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,次对象需要被回收。但事实上,也并非是"非死不可"的,这时候他们暂时处于"缓刑" 的阶段。一个无法触及的对象有可能在某一个条件下"复活"自己,
如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象的三种状态。如下:
可触及的:
从跟节点开始,可以到达这个对象。可复活的:
对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。不可触及的:
对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize() 只会被调用一次。以上三种状态中,是由于finalize() 方法的存在,进行的分区。只有在对象不可触及时才可以被回收。
判定对象ObjA是否可回收,至少要经历两次标记过程:
如果对象objA到 GC Roots 没有引用链,则进行第一次标记。
进行筛选、判断此对象是否有必要执行 finalize() 方法。
finalize()方法是对象逃脱死亡的最后机会,
稍后GC会对F-Queue队列中的对象进行第二次标记时,如果objA在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移除 "即将回收"集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次。标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。
标记-清除(Mark-Sweep)算法:
背景:标记-清除算法(Mark Sweep)是一种非常基础和常见的垃圾收集算反,该算法被 J.McCarthy等人在1960年提出并应用于Lisp语言。
执行过程:当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记:
Collector 从引用跟节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。清除:
Collector 对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。缺点:
需要注意的是,这里的清除是指什么呢?
并不是真的置空,而是吧需要清除的对象地址保存在空闲的地址列表里。
下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。复制(Copying)算法:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
所以现在的商业虚拟机都是用这种垃圾算法回收新生代。
标记-压缩(或标记-整理、Mark-Compact)算法。
第一阶段:和标记-清除算法一样,从跟节点开始标记所有被引用对象。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,在进行一次内存碎片整理,因此,也可以把它称之为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象时一项优缺点并存在的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址一次排序,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
缺点:
三种算法对比:
Mark-Sweep | Mark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的两倍大小(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
分代收集算法:
不同生命周期的对象可以采用不用的收集方式,以便调高回收效率。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期较长。
但是还有一些对象,主要是程序运行过程中生产的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次就可回收。
目前几乎所有的GC都是采用分代收集(Generational Collection)算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点:年轻代中:
区域相对老年代较小,对象生命周期短、存活率低、回收频繁。这种情况复制算法的回收整理,速度是最快的。
复制算法的效率之和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过HotSpot中的两个survivor的设计得到缓解。老年代中:
区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
增量收集算法:
将严重影响用户体验或者系统的稳定性。
为了解决这个问题,即堆实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecion)算法的诞生。基本思想就是:
如果一次间所有的垃圾进行收集处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只手机一下片区域的内存空间,接着切换到应用程序线程。依次反复,知道垃圾收集完成。
增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分段的方式完成标记、清理或复制工作。
垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法:
将一块大的内存区域分隔成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
这种算法的好处是可以控制一次回收多少个小区间。
会显式触发Full GC
,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。无需手动触发,否则就太过于麻烦了。
在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序奔溃的罪魁祸首之一。
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不会太容易出现OOM。
大多数情况下,GC会进行各种年龄段的垃圾回收,是在不行了就放大招,来一次独占的Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。
javado中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
没有空闲内存的情况,说明Java虚拟机的堆内存不够。原因有二:
Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较客观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数 -Xms、-Xmx来调整。代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。
对于老版本的Oracle JDK ,因为永久代的大小是有限的,并且JVM 堆永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字段串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:"java.lang.OutOfMemoryError:PermGen space"。
随着元数据的引入,方法区内存已经不在那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:"java.lang.OutOfMerryError:Metaspace"。
直接内存不足,也会导致OOM。
这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,进其所能去清理出空间。
回收软引用指向的对象等。
当然,也不是任何情况下垃圾收集器都会被触发的。
严格来说,只有对象不会被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
宽泛意义上的"内存泄"。
单例模式:
单例的生命周期和应用程序是一样长的,所有单例程序中,如果持有对象外部对象的引用的话,那么这个外部队形是不能被回收的,则会导致内存泄漏的产生。`一些提供了close的资源未关闭导致内存泄漏。
数据库连接(dataSource.getConnection()),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。停顿产生时整个应用程序都会被暂停,没有任何响应,
有点像卡死的感觉,整个停顿称为STW。
如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
后台自动发起和自动完成的。
在用户不可见的情况下,把用户正常的工作线程全部停掉。强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)
4种,这4中引用强度依次逐渐减弱。
无论何种情况下,只要强调引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
吞入量:运行用户代码的时间占总运行时间的比例。
总运行时间:程序的运行时间+内存回收的时间。暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
内存占用:Java 堆区所占的内存大小。
在最大吞吐量优先的情况下,降低停顿时间。
新生代收集器:
Serial 收集器采用复制算法、串行回收和“Stop-The-World”机制的方式执行内存回收。
Serial Old收集器同样也采用了串行回收和 "Stop-The-World" 机制,只不过内存回收算法使用的是标记-压缩算法。
Serial Old 是运行在 Client 模式下默认的老年代的垃圾收集器。在Server模式下主要有两个用途:与新生代的Parallel Scavenge 配合使用 和作为老年代CMS收集器的后背垃圾收集方案。只会使用一个CPU或一条收集线程去完成垃圾收集工作,
最重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。
-XX:+UseSerialGC
参数可以指定年轻代和老年代都使用串行收集器。即 新生代用 Serial GC ,且老年代用 Serial Old GC。也是采用赋值算法、"Stop-The-World" 机制。
-XX:+UseParNewGC
"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。-XX:parallelGCThreads
限制线程数量,默认开启和CPU数据相同的线程数。Parallel Scavenge:吞吐量偶先。
采用了复制算法、并行回收和 "Stop The World" 机制。
可控制的吞吐量(Throughput),
它也被称为吞吐量优先的垃圾收集器。自适应调节策略
也是Parallel Scavenge 与 ParNew 一个重要区别。适合在后台运算而不需要太多交互的任务。
因此,常见的服务器环境中使用。例如,哪些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-The-World" 机制。
-XX:+UseParallelGC:
手动指定年轻代使用Parallel并行收集器执行内存回收任务。-XX:+UseParallelOldGC:
手动指定老年代都是使用并行回收回收器。和上面的参数,默认开启一个,另一个也会被开启(互相激活)。-XX:ParallelGCThreads:
设置年轻代并行收集器的线程数。一般地,最好和CPU数量相等,以避免过多的线程数影响垃圾收集性能。-XX:MaxGCPauseMillis:
设置垃圾收集器最大停顿时间(STW的时间),单位是毫秒。-XX:GCTimeRatio:
垃圾收集时间占总时间的比例(=1 / (N+1))。用于衡量吞吐量的大小。-XX:+UseAdaptiveSizePolicy:
设置Parallel Scavenge 收集器具有自适应调节策略。老年代收集器:
Seral Old:
Parallel Old:
这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集器线程与用户线程同时工作。
目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,
以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。采用标记-清除算法,并且也会"Stop-The-World"。
四个阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
初始标记(Initial-Mark)阶段:
在这个阶段中,程序中所有的工作线程都将会因为"Stop The World" 机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能关联到的对象。
一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
并发标记(Concurrent-Mark)阶段:
从 GC Roots 的直接关联对象开始遍历整个对象图的过程,
这个过程耗时比较长
但是不需要停顿用户线程,
可以与垃圾收集线程一起并发运行。
重新标记(Remark)阶段:
由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
,这个阶段的停顿时间通常会比初始标记解阶段稍长一些,但也远比并发标记阶段的时间短。并发清除(Concurrent-Sweep)阶段:
此阶段清理删除掉标记杰顿的已经四万的对象,释放内存空间。
由于不需要移动存活对象,所以这个阶段也是可以与用户线程并发的。初始化标记和再次标记这两个阶段中仍然需要执行"Stop-The-World" 机制暂停程序中的工作线程,
不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要 “Stop-The-World”,只是尽可能地缩短暂停时间。由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收时低停顿的。
CMS回收过程中,还应该确保应用程序用户线程有足够的内存空间。
因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了在进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,
以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败
,这时虚拟机将启动后备方案:临时启用Serial Old 收集器来重新进行老年代的垃圾收集,
这样停顿的时间就很长了。标记-清除算法,
这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一块内存块,不可避免地将会产生一些内存碎片
。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。保证用户线程能继续执行,前提是它运行的资源不受影响。
Mark Compact 更适合 “Stop The World” 这种场景下使用。并发收集与低延迟。
会产生内存碎片,
导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。CMS 收集器堆CPU资源非常敏感。
在并发解读那,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。CMS收集器无法处理浮动垃圾。
可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记解读那如果产生新的垃圾对象,CMS将无法堆这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
-XX:CMSlinitiatingOccupanyFraction
:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。-XX:+uSERcmsCompactAtFullCollection
:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX:CMSFullGCsBeforeCompaction
:设置在执行多少次Full GC后堆内存空间进行压缩整理。-XX:ParallelCMSThreads
:设置CMS的线程数量。ParallelCMSThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,收到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。整堆收集:
业务越来越庞大、复杂、用户越来越多,
没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update 4之后引入的一个新的垃圾收集器,是当今收集器技术发展的最前沿成果之一。不断扩大的内存和不断增加的处理器数量,
进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起"全功能收集器"的重任与期望。
主要针对配备多核CPU及大容量内存的机器,
以提高概率满足GC停顿时间的同时,还兼备高吞吐量的性能特征。是JDK 9 以后默认的垃圾回收器,
取代了CMS回收器以及Parallel + Parallel Old 组合,被官方称为 “全功能的垃圾收集器
”。-XX:UseG1GC
来启用。并行与并发。
分代收集。
G1依然属于分代型垃圾回收器
,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
空间整合。
Region之间是赋值算法、
但整体上实际可看做是标记-压缩算法
,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到联系内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
可预测的停顿时间模型(即:软实时Soft real-time)
。这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗垃圾收集上的时间不得超过N毫秒。
每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限时间内可以获取尽可能高的收集效率。
-XX:+UserG1GC。
手动指定使用G1收集器执行内存回收任务。-XX:G1HeapRegionSize。
设置每个Region(介绍看下文)的大小。值时2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。-XX:MaxGCPauseMillis。
设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到哦)。默认值是200ms。-XX:ParallelGCThread 。
设置STW时GC线程数的值。最多设置为8。-XX:ConcGCThreads 。
设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。-XX:InitiatingHeapOccupancyPercent。
设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。垃圾收集器的组合关系:
-XX:+PrintCommandLineFlags:
查看命令行相关参数(包含使用的垃圾收集器)。jinfo -flag 相关垃圾收集器参数 进程ID
所有的Region大小相同,且在JVM生命周期内不会被改变。
如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。
为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。G1回收器垃圾回收过程:
当年轻代的Eden区用尽时开始年轻代回收过程:
G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
G1的老年代回收期不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。
同时,整个老年代Region是和年轻代一起被回收的。G1回收器垃圾回收过程:Remembered Set
解决办法:
每个Region都有一个对应的Remembered Set。
GC日志分析参数:
XX:+PrintGC
: 输出GC日志。类似 -verbnose:gc。-XX:+PrintGCDetails
: 输出GC的详细日志。-XX:+PrintGCTimeStamps
:输出GC的时间戳(以基准时间的形式)。-XX:+PrintGCDateSXtamps
:输出GC的时间戳(以日期的形式,如2013-05-14T21:53:59.234+0800)。-XX:+PrintHeapAtGC
:在日志GC的前台打印出堆的信息。-Xloggc:../logs/gc.log
:日志文件的输出路径。