JVM的主要组成部分
- JVM包含两个两个子系统(类加载子系统和执行引擎)和两个组件(运行时数据区与和本地库接口)
- 类加载子系统:根据给定的全限定类名来装在class文件到运行时数据区域中的方法区。
- 执行引擎:执行classes中的指令。
- 本地接口:和本地方法库进行交互,是其他编程语言交互的接口。
- 运行时数据区域:JVM内存。
- 从JVM的角度看,一个程序的功能怎么实现?
- 首先通过编译器将编写的Java代码(.java)转换成字节码(.class)。
- 类加载器再把字节码加载到内存中,将其放在运行时数据区的方法区内。
- 而字节码文件只是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器执行引擎将字节码翻译成底层系统指令,交由CPU执行。
JVM运行时数据区
1.8以后
程序计数器(线程私有)
- 一块较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。
- 产生原因:由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间来实现的,一个处理器只会执行一条线程,为了线程切换后能够恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(互不影响、独立存储)。
- 线程在执行Java方法时,记录其正在执行的虚拟机字节码指令地址;线程在执行本地(Native)方法时,计数器记录为空。
- 作用:
- 通过改变程序计数器的值,字节码解释器来选取下一跳需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,线程切换后能够恢复到正确的执行位置。
- 线程私有的,所以生命周期与线程相同,JVM启动而生,JVM关闭而死。程序计数器是唯一在JVM中没有规定任何OutOfMemoryError情况的区域。
虚拟机栈(线程私有)
-
虚拟机栈描述的是Java方法执行的内存模型:**线程执行期间,每个方法被执行时,都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。**每个方法从被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 局部变量表:一组变量值的存储空间,用于存储方法参数和方法内部定义的局部变量。Java程序编译为.class文件时,方法表的Code属性的max_locals指定了该方法所需局部变量表的最大容量。局部变量表在编译期间分配内存空间。常说的栈内存就是局部变量表。
- 局部变量表的组成:
- 基本数据类型:byte、short、int、long、double、float、char、boolean
- 对象引用类型:reference,指向对象起始地址的引用指针。(不是对象本身)可能是32也可能是64。
- 返回地址类型:returnAddress,指向一条字节码指令的地址。
- 变量槽slot是局部变量表的最小单位,大小为32位。如果对于64位的数据类型,虚拟机以高位对其的方式为其分配两个连续的slot空间。
- 局部变量表的组成:
- 操作数栈:虚拟机栈的工作区,大多数指令都从这里弹出数据,执行运算,然后把结果压回操作数栈。
- 动态链接:每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接。
- 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(final、static域等)。
- 动态解析:另一部分将在每次的运行期间转化为直接引用,称为动态链接。
- 方法出口(返回地址)
- 当一个方法开始执行后,只有两种方法退出:
- 正常返回:遇到返回指令,将返回值传给上层方法的调用者,这种退出就是正常完成出口。一般来说调用者的PC计数器可以作为返回地址。
- 异常返回:当执行遇到异常,并且当前方法体内没有异常处理,就会导致方法退出,且没有返回值。这种退出叫做异常完成出口,该返回地址通过异常处理表来确定的。
- 当一个方法返回后可能执行下列三个操作:
- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 将PC计数器的值指向下一条方法指令位置。
- 当一个方法开始执行后,只有两种方法退出:
- 局部变量表:一组变量值的存储空间,用于存储方法参数和方法内部定义的局部变量。Java程序编译为.class文件时,方法表的Code属性的max_locals指定了该方法所需局部变量表的最大容量。局部变量表在编译期间分配内存空间。常说的栈内存就是局部变量表。
-
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入方法表的Code属性之中。因此栈帧的内存只取决于具体的虚拟机实现。
-
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与该栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
-
JVM对此区域规定了两个异常:StackOverflowEror和OutOfMemoryError
-
StackOverflowEror:如果当前线程请求栈的深度大于虚拟机栈所允许的深度,即某个线程的线程栈空间被耗尽,没有足够的资源分配去创建新的栈帧,就会抛出java.lang.StackOverflowError。
- 无限循环递归调用。
- 执行了大量的方法,导致线程空间耗尽。
- 方法里面声明了海量的局部变量。
- 本地代码中有栈上分配的逻辑,并且要求的内存很大。
-
OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出java.lang.OutOfMemoryError异常。
-
本地方法栈(线程私有)
- 与虚拟机栈作用类似,只不过Java虚拟机栈执行的是Java方法服务,而本地方法栈执行Native方法服务。
- 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
- 本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
堆(全局共享)
- Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存(不绝对,在虚拟机优化的策略下,也存在栈上分配、标量替换的情况)。
- 也就是说这个new出来的对象是存放在堆中的,而对象的引用是存放在栈中的。
- Java堆是GC回收的主要区域,很多时候也被称为GC堆。从内存回收的角度看,现在收集器基本上都采用分代收集算法,所以Java堆被划分成两个不同的区域:
- 新生代(Young Generation)
- Eden区
- From Survior区
- To Survior区
- 老年代(Old Generation)
- 分区并不影响存放内容,不管什么区都存放的是对象实例。进一步划分的原因是使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。
- 顾名思义,新的对象分配首先放在年轻代的Eden区,Survior区则是作为Old和Eden之间的缓冲,如果在Survior区中的对象经历若干次GC还是活的,就被转移到Old中。
- 新生代(Young Generation)
- 线程共享的Java堆可能划分出多个线程私有的分配缓冲区。
方法区(全局共享)
- 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也被称为永久代。
- 《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久带这一说法。
- 常用参数(1.8以前)
- -XX:PermSize=N 方法区(永久代)初始大小 。
- -XX:MaxPermSize=N 方法区(永久代)最大大小,超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError。
- 运行时的常量池(1.7以前方法区的一部分)
- Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。
- 作用:
- 除了保存编译期Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
- 具备动态性,存储运行时产生的常量(比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)
- 1.7后,将常量池放在了堆中。
- 1.8的时候,方法区(HotSpot的永久代)被彻底移除,取而代之的是元空间,元空间位于直接内存。
- 运行时的常量池和静态常量池存放在元空间中,而字符串常量池依然位于堆中。
- 常用参数:
- -XX:MetaspaceSize=N 设置元空间的初始(和最小大小)。
- -XX:MaxMetaspaceSize=N 设置元空间的最大大小。
- 面试题:为什么要用元空间取代方法区(永久代)的实现?
- 字符串存在方法区中,容易出现性能问题和内存溢出。
- 类和方法的信息比较难确定其大小,因此对于方法区的大小指定比较困难,太小容易出现永久代溢出,太大容易导致老年代溢出。
- 永久代为GC带来不必要的复杂度,且回收效率偏低。
- 将HptSpot和JRockit合二为一。
- 元空间并不在JVM内存中,而是使用本地内存,大小取决于系统内存。
- 该区域可以不选择进行内存回收,内存回收的主要目标是针对常量池的回收和类型的卸载。
直接内存
- 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。假如机器内存为4G,JVM占了1G,那么直接内存就还有3G。Java的NIO可以使用Native函数直接分配堆外内存。通常直接内存的速度会优于Java堆内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
- 这样能在一些场景中显著提高性能:读写频繁、性能要求高的场景可以考虑使用直接内存。这样避免了在Java堆和Native堆中来回复制数据。
- 直接内存不受Java堆大小的限制,但是受到本机的内存限制,所以也会出现OutOfMemoryError。
- 直接内存特点
- 内存的分配不受Java堆大小的限制,受本机总内存的限制。
- 可以由-XX:MaxDirectMemorySize指定。
- 直接内存申请空间耗费更高的性能。
- 直接内存IO读写的性能要优于普通的堆内存。
HotSpot虚拟机对象
-
从虚拟机的角度看创建对象:
- 虚拟机遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否被加载、解析和初始化过,如果没有先进行类加载。
- 类加载检查通过时,虚拟机为新生的对象分配内存,该内存大小在类加载完成后会确定。其实也就是把一块确定大小的内存从Java堆中划分出来。
- 对象内存分配办法:
- 指针碰撞:假设堆内存是绝对规整的,则所有用过的内存放一边,没用过的放一边,中间有个指针作为分界点,分配内存就是把这个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表:如果堆内存不是规整的,用过的和没用过的交错,则虚拟机就必须维护一个列表记录那些内存块是可用的,在分配的时候从列表中找一块足够大的空间划分给对象实例,并更新表上的记录。
- 对象的创建在虚拟机中是非常频繁的,在并发情况也不是线程安全的。例如堆正在给对象A分配内存,指针还没修改,对象B又使用了之前的指针来分配内存。内存分配的并发处理问题:
- 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS和失败重试的方式保证更新操作的原子性。
- 把内存分配动作按照线程划分在不同的空间中进行,即每个线程在堆中预先分配一小块内存(本地线程分配缓冲Thread Local Allocation Buffer, TLAB)。 哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完需要分配新的TLAB时,才需要同步锁定。
- 对象内存分配办法:
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,则该工作可以在TLAB分配时候进行,该操作保证了对象的实例字段在Java代码中不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 接下来,虚拟机对对象头进行必要的设置,例如该对象是哪个类的实例、如何找到类的元数据信息、对象的hashCode、对象的GC分代年龄等信息。这些信息存储在对象头中,根据虚拟机运行状态不同,例如是否启用偏向锁、锁状态,对象头就有不同的设置。执行到此,从虚拟机的角度来看,一个对象已经创建完成了,但是Java程序的角度看,创建对象才刚刚开始。
-
对象的内存布局:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)
- 对象头存储了两种类型的数据:
- 存储对象自身运行时的数据(Mark Word)。一个非固定的数据结构,以便于在极小的空间内存储尽量多的信息。
- hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID(锁升级)、偏向时间戳等。
- 类型指针(对象指向它的类元数据的指针)。
- 虚拟机通过该指针确定了这个对象是哪个类的实例。但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定经过对象本身。如果对象是一个Java数组,在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通Java对象来确定Java对象大小,但无法从数组的元数据中确认数组大小。
- 存储对象自身运行时的数据(Mark Word)。一个非固定的数据结构,以便于在极小的空间内存储尽量多的信息。
- 实例数据:存储对象真正的有效信息,即程序代码中所定义的各种类型字段内容,不管是父类继承还是子类定义。这部分存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
- 对齐填充:并不是必须存在,无特别含义,起一个占位符的作用。
- HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。即大小必须是8的整数倍。对象头刚好是8字节的整数倍,如果对象实例没有对齐,就需要对齐填充来补全。
- 对象头存储了两种类型的数据:
-
对象的访问定位:Java程序通过栈上的reference数据来操作堆上的具体对象。reference只是一个指向对象的引用。
-
句柄访问:Java堆中划分出一块内存来作为句柄池,reference中存储的就是Java对象的句柄地址。句柄中则包含了对象实例数据与类型数据各自的具体地址信息。速度快,节省了一次指针定位的时间开销。
-
直接指针:Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
-
对象的死亡判断以及垃圾回收
对象死亡判断
- 程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭,而栈中的栈帧分配多少内存基本上是在类结构确定下来就知道的,所以这三个区域内存分配和垃圾回收具有确定性。所以GC一般讨论的是Java堆和方法区。
- GC的第一步就是判断对象是否已经死了(不可能再被任何途径使用的对象)
-
引用计数器方法:给对象中添加一个引用计数器,每当有一个地方引用它时,就加1,引用失效就减1,任何时刻计数器都为0的对象就是不可能再被引用的。但该方法并不能解决对象之间互相循环引用的问题。
-
可达性分析:通过一系列的GC Roots对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达),证明该对象不可用。
- GC Roots对象一共有四种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(Native方法)引用的对象。
- GC Roots对象一共有四种:
-
- 对象宣告死亡的两个标记过程
- 对象在进行可达性分析后没有与GC Roots相连接的引用链。此时进行第一次标记并进行第一次筛选:是否有必要执行finalize()方法。
- 如果对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机认为没有必要对该对象执行finalize()。
- 如果该对象被判定需要执行,则该对象会被放进一个F-Queue的队列中,并且在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行。虚拟机触发该方法,但不会等它运行结束(如果一个对象在执行finalize()十分缓慢或者发生了死循环,则会导致F-Queue的对象一直等待,严重会导致整个GC系统崩坏)
- 如果该对象不想被回收,则在finalize()中重新与引用链上任何一个对象建立关联即可,例如用this把自己赋值给某个类变量或者对象的成员变量,进行自救。
- 第二次标记就是在筛选过后的finalize(),GC对F-Queue中的对象进行标记,如果说此时对象有了关联,则第二次标记把它从即将回收的集合中移除。如果依然没有关联,那么它就真的被回收了。一个对象的finalize()被执行,对象不一定就死了。
- finalize()的优先级很低,而且任何一个对象的finalize()方法只会被系统调用一次,一旦面临下一次回收,那么finalize()就不会被执行,对象的自救失败。
- 并不建议使用finalize()方法去拯救对象,因为运行代价很高,不确定性也大。基本上使用finalize()能做的事,使用try/finally就行。
- 对象在进行可达性分析后没有与GC Roots相连接的引用链。此时进行第一次标记并进行第一次筛选:是否有必要执行finalize()方法。
四种引用对比
- 强引用(StrongReference):程序代码中普遍存在的,例如Object o = new Object(); 只要强引用存在,GC永远不会回收这些对象。
- 软引用(SoftReference):一些还有用但非必须的对象,在系统将要发生内存溢出异常之前,才会把这些对象列进回收范围进行回收。如果回收后没有足够的内存就会发生OOM。
- 弱引用(WeakReference):同样描述非必须对象的,被弱引用关联的对象只能生存到下一次GC发生之前。GC时不论当前内存是否足够,弱引用关联的都会被回收。
- 虚引用(PhantomReference):最弱,一个对象是否有虚引用,对其生存时间完全没有影响,也无法通过虚引用来取得一个对象的实例。虚引用的唯一目的就是对象被GC时有个通知。
方法区的垃圾回收:效率较低
- 主要回收两部分内容:废弃常量和无用的类。和回收对象差不多,只要没有引用就直接被回收。
- 判断废弃常量就直接看引用就行,而无用的类则需要三个步骤:
- 该类的所有实例被回收,即Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射来访问该类的方法。
垃圾收集器算法(GC)
分代收集算法(大多数虚拟机采用)
根据对象存活周期将内存划分为新生代和老年代,然后根据每个代的不同特点去选择合适的收集算法。
- 新生代:每次垃圾收集都有大批对象死去,只有少量存活,此时选复制算法。
- 老年代:对象存活率高,必须使用标记-清除或者标记-整理算法。
标记-清除算法
标记处所有需要回收的对象,在标记完成后同一回收所有被标记的对象(前面说的两个标记)然后同一回收。
- 标记清除算法主要有两点不足:
- 效率问题(标记回收效率都不高)
- 空间问题(标记清除之后会产生大量不连续的内存碎片,这样导致以后在程序运行的时候需要分配较大对象时,无法找到足够连续内存)
复制算法
将可用内存按照容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行内存回收,而且把其他存活的对象都整个复制过去,不会考虑内存分配时的不连续的问题。只需要移动堆顶指针按顺序分配即可。
- 复制算法的最大不足是将内存缩小为原来的一半,有些浪费空间。
- 现在的虚拟机认为大多数的对象都是朝生夕死,所以并不需要一半的分,而是将内存分为较大的Eden和两块较小的Survivor。每次使用Eden和其中一块(From)Survivor。回收的时候将Eden和(From)Survivor中还存活的对象一并复制到另一个(To)Survivor中,然后清理掉Eden和使用的(From)Survivor。
- HotSpot默认Eden和(From)Survivor是8:1,即每次新生代中可用的空间为90%(80+10),但我们没有办法保证每次都没有大于10%的对象存活,当(To)Survivor空间不足时,就要向老年代进行分配担保。
标记-整理算法
一般多用于老年代,过程与标记-清除一样,只不过后续不是直接对可回收对象进行整理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
垃圾收集算法的实现
枚举根结点
- 可达性分析所带来的的性能问题:
- 可作为GC Roots的节点主要是在全局性的引用(常量或静态属性),与执行上下文(栈帧中的本地变量表)中,有时候方法区就数百兆,检查这里面的引用很费时间。
- 可达性分析时必须让整个执行系统“停止”,不能出现分析过程中对象引用关系依然不断变化的情况,这不满足就无法进行准确的可达性分析。这导致GC时必须停顿所有Java执行的线程(Stop The World)
- 解决方案(枚举根结点):当执行系统停顿后,HotSpot使用了一组OopMap的数据结构来得知那些地方存放着对象的引用(GC Roots)。 在类加载完成后,HotSpot就把对象内的偏移量的数据类型计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用,这样GC扫描就可以快速且准确得到这些信息了。
安全点
给特定位置上的指令生成对应的OopMap,暂停进行GC的位置也是安全点。
- OopMap会产生的问题:引用关系的变化(OopMap内容变化的指令很多),如果每一条指令都生成对应的OopMap,就会需要大量的额外空间,使得GC空间成本变得非常高。
- 程序在执行时只在安全点进行停下来GC,安全点的选定不能太少(让GC等待时间太长),也不能太多(增大运行时的负荷)。
- 安全点的选取就是以是否具有让程序长时间执行的特征为标准选取的。长时间执行最明显的特征就是指令序列复用,即方法调用,循环跳转,异常跳转等,这些功能指令才会产生安全点。
- 安全点有一个要解决的问题是:如何在GC发生时让所有线程都在最近的安全点停下来。
- 抢先式中断:不需要线程的执行代码主动去配合,GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让其执行到安全点上(不使用)
- 主动式中断:GC需要中断线程的时候,不直接对线程操作,而是设置一个标志。各个线程执行时主动去轮询该标志,发现是中断标志就把自己主动挂起。 轮询标志与安全点是一个地方,以及加上创建对象需要分配内存的地方。
安全区域
一段代码片段中,引用关系不会发生变化,在这个区域中的任何地方GC都是安全的,扩展的安全点。
- 线程执行到安全区域中的代码时,首先标识自己进入了安全区域,这样当在这段时间JVM进行GC时,就不用管标记的线程了。
- 线程离开安全区域时,要检查系统是否已经完成了根节点枚举(或者整个GC),如果完成了线程继续执行,没有就必须等待回收直到接收到可以安全离开安全区域的信号为止。
七种垃圾收集器
垃圾收集器有两个概念,并行和并发:
- 并行(Parallcl):多条垃圾收集线程并行工作,但用户线程依然处于等待状态。
- 并发(Concurrent):用户线程与垃圾收集线程同时执行(不一定并行,可能交替执行)
Serial收集器
最基本、历史最悠久的,jdk1.3.1之前是虚拟机新生代唯一的收集器。
- 单线程的,它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,且它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
- 新生代采用复制算法,老年代采用标记-整理算法。
- 优点:简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial没有线程交互的开销,只是做垃圾收集会获得最高的单线程收集效率。并且对于用户桌面(Client)应用场景中,分配给虚拟机管理的内存一般不大,停顿时间也不是很长,只要不是频繁的发生,这点停顿完全可以接受。
Serial Old收集器
- Serial收集器的老年代版本,与Serial基本一致。主要是给Client模式下的虚拟机使用。
- 在Server下还有两种用途
- jdk1.5之前配合使用Parallel Scavenge收集器搭配使用。
- 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用
ParNew收集器
- Serial收集器的多线程版本,是许多运行在Server模式下的虚拟机中首选的新生代收集器,除了Serial,只有它可以和CMS收集器配合。
- ParNew收集器在单CPU的环境中比Serial收集器差,甚至是给Serial加上了线程切换的开销,两个的线程情况下还不能保证比Serial收集器好。
Parallel Scavenge收集器
该垃圾收集器的关注点是达到一个可控制的吞吐量,又称吞吐量优先收集器。停顿时间越短适合于与用户交互多的程序,良好的响应速度提升了用户体验,高吞吐量则可以高效率的利用CPU时间,尽快的完成程序的运算任务。
- 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
- 重要参数:
- -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间的,为大于0的毫秒数,不能过小。小的话会直接导致垃圾收集发生的更频繁一些。
- -XX:GCTimeRatio:直接设置吞吐量大小,为大于0且小于100的整数,即垃圾收集时间占总时间的比率(吞吐量的倒数),默认为99。
- -XX:+UserAdaptiveSizePolicy:GC自适应调节策略的开关参数,开启后不需要手工指定新生代的大小、Eden与Survivor比例、晋升老年代对象年龄等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
- 使用Parallel Scavenge收集器可以配合GC自适应调节策略,只需要关注控制最大垃圾收集停顿时间和吞吐量大小即可,这也是与ParNew收集器的一个重要区别。
Parallel Old收集器
- Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法,jdk1.6以后提供。
- 没有它之前,Parallel Scavenge收集器很尴尬,如果新生代选择了它,老年代只能选择Serial Old(Parallel Scavenge收集器无法和CMS配合)。然而Serial Old在服务端性能很差(无法利用多CPU的处理能力),在老年代很大且硬件比较高级的时候还不如ParNew+CMS。
- 所以该收集器就是和Parallel Scavenge收集器进行配合:在注重吞吐量以及CPU资源敏感的场合进行高效的垃圾回收。
CMS(Concurrent Mark Sweep)收集器
获取最短回收停顿时间为目的的收集器,并发收集且低停顿。
- 基于标记-清除算法实现:
- 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快。需要Stop The World。
- 并发标记(CMS concurrent mark):进行GC Roots Tracing(判断对象是否仍在使用中)的过程。
- 重新标记(CMS remark):修正并发标记期间因用户程序继续运作而导致的标记产生变动的那一部分对象的标记记录,需要Stop The World。
- 并发清除(CMS concurrent sweep)。
- 整个过程中虽然并发标记和并发清除耗时最长,但这两个过程收集器线程可以与用户线程一起工作。总体上说CMS收集器内存回收过程是与用户线程一起并发执行的。
- CMS的缺点:
- 对CPU资源十分敏感。虽然它不会让用户线程停顿,可会因为占用了一部分的线程(CPU资源)导致应用程序变慢,总吞吐量降低。
- 无法处理浮动垃圾,可能导致Concurrent Mode Failure失败而导致Full GC(清理整个堆空间,包括新生代和老年代,时间比Minor GC慢十倍以上)的产生。
- 浮动垃圾:由于CMS并发清理阶段用户线程还在运行,CMS无法在当次收集中清理,伴随着程序的运行自然还有新的垃圾不断产生。每次产生的垃圾在本次无法清理,就得留到下次,以此类推。
- 由于用户线程还需要运行,则还要预留足够的内存空间去给用户线程使用。因此CMS不会像其他收集器一样等到老年代几乎被填满再进行收集,而是要预留一部分空间提供并发收集时程序的运作。如果CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败。此时虚拟机启动后备预案:临时启用Serial Old收集器对老年代的垃圾进行收集,这样导致停顿时间加长。
- -XX:CMSInitiatingOccupancyFraction来提高百分比。
- 基于标记清除算法,有大量的空间碎片产生。空间碎片过多的话,给大对象进行内存分配就有问题。往往就是老年代有很多空间,但不连续,没有足够大的空间来分配当前对象。不得不提前进行Full GC。
- -XX:UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片合并并整理。
- -XX:CMSFullGCsBeforeCompaction:设置执行多少次不压缩的Full GC时来一次带压缩的。
G1收集器
面向服务端的垃圾收集器,目标是代替CMS。
- 并行与并发:充分利用多CPU、多核环境下的硬件优势来缩短Stop The World停顿的时间。原本需要停顿Java线程执行GC的,G1收集器可以通过并发的方式让Java程序继续执行。
- 分代收集:采用不同的方式去处理创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
- 空间整合:整体上基于标记-整理算法实现,局部上是基于复制算法实现。这意味着G1在运作期间不会产生内存空间碎片,收集后提供规整可用的内存,来给大对象分配空间,避免了无法找到内存空间而GC。
- 可预测的停顿:让使用者明确指定在一个长度为M毫秒的时间片内,消耗在垃圾收集上的时间不得超过N毫秒。为了有计划的避免在整个Java堆中进行全区域的垃圾收集。
G1收集器把整个Java堆划分为多个大小相等的独立区域(Region),新生代老年代不再物理隔离。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间的大小以及所需要的时间),在后台维护一个优先列表,每次根据允许的时间,优先回收价值最大的Region。
- 存在问题:Region不可能是孤立的,一个对象在某个Region中,还可能与其他的Region中对象引用,甚至是和整个Java堆任意的对象发生引用关系。这就是说在做可达性判断的时候,得扫描整个堆。这个问题在G1中十分突出,以前的分代,新生代规模比老年代要小的多,收集也频繁,可拉到G1中就是新生代带着老生代一起扫描,效率下降。
- 解决方案:G1中的每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference对象是否处于不同的Region之中(分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是就把引用信息记录到被引用对象所属的Reginon的Remembered Set中。当进行内存回收的时候,在GC根结点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
G1收集器分为以下几个步骤收集:
- 初始标记:标记GC Roots能直接关联到的对象,且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。需要Stop The World。
- 并发标记:从GC Roots开始进行可达性分析,找出存活的对象。耗时较长,但可以与用户线程并发执行
- 最终标记:修正正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,虚拟机把这段时间对象变化记录在线程Remembered Set Logs里面,并且该阶段要把数据合并到Remembered Set中。需要Stop The World,但可并行执行。
- 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期待的停顿时间来进行回收计划。
内存的分配与回收策略
- 对象主要(优先)分配在新生代的Eden区上,当Eden区空间不足进行一次Minor GC(新生代垃圾清理,比较频繁,速度较快),大对象(需要大量连续内存)直接进入老年代。
- 写程序要避免短命大对象,大对象经常是内存还有不少空间就直接提前出发垃圾收集以获取足够的空间。
- 长期存活的对象将进入老年代。虚拟机给每个对象定义一个年龄计数器,对象在Eden出生并经过第一次Minor GC依然存活且能被Survivor收纳,就移动到Survivor中。年龄+1,每过一次Minor GC就加一岁,默认15岁进入老年代。这个阈值可以通过-XX:MaxTenuringThreshold来设置。
- 并不是非得达到MaxTenuringThreshold才到达:如果Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,则大于等于该年龄的对象直接进入老年代。
- 空间分配担保:主要是针对老年代进行的。
- Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间:
- 是则表明Minor GC是安全的。
- 否则虚拟机查看HandlePromotionFailure设置是否允许担保失败:
- 允许则继续检查老年代最大可用连续空间是否大于之前晋升到老年对象的平均大小:
- 大于则尝试进行一次Minor GC,尽管是有风险的。
- 小于则进行一次Full GC。
- 不允许则进行一次Full GC。
- 允许则继续检查老年代最大可用连续空间是否大于之前晋升到老年对象的平均大小:
- 为什么进行空间担保?:新生代采用复制算法,但为了内存利用率只使用一个Survivor空间作为轮换备份。如果出现大量对象在Minor GC后仍然存活(极端是都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代,前提是老年代本身要有容纳这些对象的剩余空间。一共有多少存活在实际完成内存回收之前是无法知道的,所以只能取之前的回收晋升到老年代的对象容量的平均大小来与老年代剩余空间比较决定是否Full GC。
- Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间:
Class文件组成详解(了解)
-
魔数:class的头四个字节,作用是确定该文件是否是能被虚拟机接受的class文件。
-
版本号:紧接着模式的四个字节,56是次版本号,78是主版本号。
-
常量池:存放字面量与符号引用,每个量都是一个表。
- 字面量:字符串、final常量等。
- 符号引用:
- 类和接口的全限定类名。
- 字段的名称和描述符。
- 方法的名称和描述符.
-
访问标志:识别类或者接口层次的访问信息。
- 类还是接口。
- 是否public。
- 是否abstract。
- 如果是类是否声明fianl等。
-
类索引、父索引、接口索引集合:确认类的继承关系。
- 类索引:确认全限定类名。
- 父索引:确定该类父索引全限定类名,只有一个。除了java.lang.Object,所有Java类都有父类,故父类索引都不为0。
- 接口索引集合:描述该类实现了那些接口(按照implement顺序)。如果该类本来就是接口(按照extends顺序),从左到右排列在接口索引集合中。没有实现任何则为0。
-
字段表集合:描述接口或者类中声明的变量,包括类级变量以及实例级变量,但不包括方法内部声明的局部变量。
- 包括访问标志、名称索引、描述符索引、属性表集合。
- 字段作用域(public、protected、private)、实例还是类变量(static)、可变性(final)、并发可见性(volatile)、是否可被序列化(transient)、数据类型(基本、对象、数组)、名称。
- 不列出父类或者父接口中继承而来的字段。
- 包括访问标志、名称索引、描述符索引、属性表集合。
-
方法表集合:描述方法,跟字段表集合差不多,没有volatile和transient。
- Java中要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须有不同的参数(返回值不同但参数相同不能重载)。
-
属性表集合:用于描述某些场景专有的信息,例如具体的字段,方法体。
- Code属性:方法体经过Javac编译器处理后最终变为字节码指令存储在Code属性内。接口或者抽象类的方法不存在Code属性(没有方法体)
- 重要结构:
- attribute_length:属性值的长度
- max_stack:操作数栈深度的最大值
- max_locals:局部变量表所需的存储空间,单位是slot(虚拟机为局部变量分配内存所使用的的最小单位)
- code_length和code:用于存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code用于存储字节码指令的一系列字节流。
- 重要结构:
- Exception属性:列举出方法中可能抛出的受查异常(Checked Exception),即方法描述时在throws关键字后面列举的异常
- LineNumberTable属性:描述Java源码行号与字节码行号(字节码偏移量)之间的对应关系
- LocalVariableTable属性:描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系
- SourceFile属性:记录生成这个Class文件的源码文件名称
- ConstantValue属性:通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用
- InnerClasses属性:记录内部类和宿主类之间的关联
- Deprecated和Synthetic属性:都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念
- Deprecated:某个类、字段或者方法被程序作者定为不推荐使用
- Synthetic:代表此字段或者方法并不是Java源码直接产生的,而是编译器自行添加的
- StackMapTable属性:jdk1.6后出现,在虚拟机类加载的字节码验证阶段被新类型检查验证器使用
- Signature属性:jdk1.5后出现,任何类、接口、初始化方法或者成员的泛型签名包含了类型变量或者参数化类型,则Singnature属性就记录泛型签名信息
- BootstrapMethods属性:jdk1.7后出现,用于保存invokedynamic指令引用的引导方法限定符
- Code属性:方法体经过Javac编译器处理后最终变为字节码指令存储在Code属性内。接口或者抽象类的方法不存在Code属性(没有方法体)
扩展:OutOfMemoryError具体分析
除了程序计数器,JVM的其他分区:方法区,虚拟机栈,本地方法栈,Java堆都有可能发生OOM。
Java堆溢出
- Java堆用于存储对象实例,只要不停的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。
- 解决方案:确认内存中的对象是否有必要,即分析是出现了内存泄漏还是内存溢出。
- 内存泄漏:分析为什么垃圾收集器无法自动回收一些对象。
- 内存溢出:堆中的对象都是必须存在的,那么就要检查虚拟机的堆参数与及其物理内存是否还可以调大。代码上检查是否某些对象生命周期、持有状态时间过长的情况。
虚拟机栈和本地方法栈溢出
- HotSpot虚拟机并不区分虚拟机栈跟本地方法栈,虽然有-Xoss参数(设置本地方法栈大小),但实际不起作用,栈容量只能由-Xss设置。
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError;如果虚拟机在扩展栈时无法申请足够的内存空间,抛出OutOfMemoryError。
- 如果不断的建立线程,会产生OOM。如果给每个线程分配的栈容量越大,越容易产生。不难理解,虚拟机提供参数区控制Java堆和方法区这两部分内存最大值。32位本机内存2GB,减去Xmx(最大堆容量)和MaxPermSize(最大方法区容量),忽略消耗内存小的程序计数器。那么剩下的内存就是虚拟机栈和本地方法栈瓜分了。给每个线程分配的栈容量越大,可以建立的线程数量就越小,不断地建立线程就会把剩下的内存耗尽,爆发OOM。
- 解决方案:一旦建立过多线程而导致内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。
方法区和运行时常量池溢出
- 运行时常量池前面也说了,属于方法区。方法区用于存放class的相关信息:类名、访问修饰符、常量池、字段描述、方法描述等。如果运行时产生大量的类,则方法区很容易被填满。如果大量的jsp也会导致PermGen space OOM(jsp第一次运行需要编译为Java类)
本机直接内存溢出
- 产生原因:一般是不断的去给其分配内存,各个内存区域的总和大于物理内存限制,导致内存耗尽。
扩展:StackOverflowError具体分析
public class SimpleExample {
public static void main(String args[]) {
a();
}
public static void a() {
int x = 0;
b();
}
public static void b() {
Y y = new Y();
c();
}
public static void c() {
float z = 0f;
}
}
当main()方法被调用后,执行线程按照代码执行顺序,将它正在执行的方法、基本数据类型、对象指针和返回值包装在栈帧中,逐一压入其私有的调用栈。则此时的栈应该是c()->b()->a()->main()。
-
程序启动后main方法入栈,然后a方法入栈,局部变量a被声明为int类型,且初始值为0,x和0都被包含在栈帧中。
-
然后b方法入栈,创建一个Y对象,并赋给变量y。实际的Y对象是在Java堆内存中创建的,不是线程栈,只是Y对象的引用以及变量y在栈帧里。
-
最后c方法入栈,变量z被声明为float类型,初始化为0f,z和0f都被包含在栈帧里。
-
当执行方法完成后,所有的线程栈帧按照LIFO的顺序出栈,直到栈空。
上述是正常的,我们改一下这个Example:
public class SimpleExample {
public static void main(String args[]) {
a();
}
public static void a() {
a();
}
}
这通过无限递归就发生了StackOverflowError,因为不停的把a往进压。
综上,JVM线程栈存储了方法的执行过程,基本数据类型,局部变量,对象指针和返回值等信息,这些都是要消耗内存,一旦线程栈的大小增长超过了允许的内存限制,就抛出了StackOverflowError。
使用-Xss参数减少栈的内存容量,以及定义大量的本地变量去增大某方法帧中本地变量表的长度,或者是不停的递归调用方法,均产生的是StackOverflowError。这表明在单个线程下,无论是栈帧太大还是虚拟机容量太小,当内存无法分配都产生StackOverflow。