JVM详记()

  本篇文章为你整理了JVM详记()的详细内容,包含有 JVM详记,希望能帮助你了解 JVM详记。

  1 运行时数据区域

  从概念上Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
 

  
 

  在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

  方法区Java8之后的变化:

  移除了永久代(PermGen),替换为元空间(Metaspace)

  永久代中的class metadata(类元信息)转移到了native memory(本地内存,而不是虚拟机)

  永久代中的interned Strings(字符串常量池) 和 class static variables(类静态变量)转移到了Java heap

  永久代参数(PermSize、MaxPermSize)- 元空间参数(MetaspaceSize、MaxMetaspaceSize)

  Java8为什么要将永久代替换成Metaspace?

  字符串存在永久代中,容易出现性能问题和内存溢出。

  类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

  永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

  Oracle 可能会将HotSpot 与 JRockit 合二为一,JRockit没有所谓的永久代。

  1.1 程序计数器

  程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器, 这类内存区域称为“线程私有”的内存。

  如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。 此内存区域是唯一在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

  1.2 Java虚拟机栈

  Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同 。 虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表[1]、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  -Xss 为jvm启动的每个线程分配的内存大小,默认JDK1.4中是256K,JDK1.5+中是1M
 

  运行时栈帧结构

  Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

  在编译Java程序源码的时候,栈帧中局部变量表大小及操作数栈深度,就已经被分析计算出来,并且写入到方法表的Code属性之中。

  对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作。

  局部变量表

  局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

  局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference[2]或returnAddress[3]类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储,在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。Java语言中明确的64位的数据类型只有long和double两种。由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。

  Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位 数据类型的变量,则说明会同时使用第N和N+1两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。

  当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

  变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。这样的设计可以节省栈帧空间,但在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。

  操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

  随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者 返回给方法调用者,也就是出栈/入栈操作。

  大多虚拟机的实现里都会令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。

  每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

  方法返回地址

  在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。 一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这 个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

  当一个方法开始执行后,只有两种方式退出这个方法。

  执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,称为“正常调用完成”(Normal Method Invocation Completion)。

  方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,称为“异常调用完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

  在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

  1.3 本地方法栈

  本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

  1.4 Java堆

  Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建, 唯一目的就是存放对象实例。 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

  设置堆空间大小: 初始分配-Xms指定,默认是物理内存的1/64;最大分配由-Xmx指定,默认是物理内存的1/4

  从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代(青年代)和老年代。

  针对具备“朝生夕灭”特点的对象,把新生代分为一块较大的Eden空间和两块较小的 Survivor空间(From Survivor空间、To Survivor空间),每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空 间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当Survivor空间不足以容纳一次新生代Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

  在Java7 Hotspot虚拟机中将Java堆内存分为3个部分: 青年代Young Generation、老年代Old Generation、永久代Permanent Generation。
 

  在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了。

  配置新生代和老年代堆结构占比:

  默认 -XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3。修改占比 -XX:NewPatio=4 , 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5

  默认 -XX:SurvivorRatio=8,标识Eden空间和另外两个Survivor空间占比分别为8:1:1

  分配对象策略

  
对象优先在Eden分配

  大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。参数-XX:+UseAdaptiveSizePolicy( Parallel Scavenge收集器,默认开启),会导致Eden与Survivor区默认8:1:1比例自动变化。

  
大对象直接进入老年代

  大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。如果对象超过 -XX:PretenureSizeThreshold 设置的大小,会直接进入老年代,这个参数只在 Serial 和ParNew两个收集器下有效。

  
长期存活的对象将进入老年代

  对象的GC年龄增加到一定程度 (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同)就会被晋升到老年代中。可以通过参数 -XX:MaxTenuringThreshold 来设置。

  
动态对象年龄判定

  当前放对象的Survivor区域里,一批对象的总大小大于这块Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

  
空间分配担保机制

  年轻代每次minor gc之前,JVM都会检查下老年代最大可用的连续空间是否大于年轻代里现有的所有对象大小之和(包括垃圾对象) 。如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure),如果允许:那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于,那就要改为进行一次Full GC。

  
 

  1.5 方法区

  方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  方法区结构

  类型信息 :对每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信 息:

  这个类型的完整有效名称(全名 = 包名.类名)

  这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类)

  这个类型的修饰符( public, abstract,final的某个子集)

  这个类型直接接口的一个有序列表 域信息

  
域信息:即为类的属性,成员变量。 JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序。 域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient的某个子集)

  方法信息:JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  方法名称方法的返回类型(或void)

  方法参数的数量和类型(按顺序)

  方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集

  方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)

  异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

  
方法区设置:

  jdk7及以前通过-xx:Permsize来设置永久代初始分配空间,默认值是20.75M ;-XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M,当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。

  JDK8以后元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定 默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。

  在JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移至Java堆中,而到了JDK 8,完全废弃了永久代的概念,改用在本地内存中实现的元空间(Meta-space)来代替,把JDK7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

  如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError异常。

  运行时常量池

  常量池:存放编译期间生成的各种字面量与符号引用

  运行时常量池:常量池表在运行时的表现形式

  运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量[4](Literal)与符号引用[5](Symbolic References),这部分内容将在类加载后存放到方法区的运行时常量池中。这些常量池现在是静态信息,只有到运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也 就是我们说的动态链接了。例如,compute()这个符号引用在运行时就会被转变为compute()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。

  但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

  运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性利用得比较多的便是String类的 intern()方法。

  字符串常量池

  字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,会极大程度地影响程序的性能。

  JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化:

  为字符串开辟一个字符串常量池,类似于缓存区

  创建字符串常量时,首先查询字符串常量池是否存在该字符串

  存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

  三种字符串操作(Jdk1.7 及以上版本):

  
String s = "z"; // s指向常量池中的引用

  这种方式创建的字符串对象,只会在常量池中。 因为有"z"这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象 如果有,则直接返回该对象在常量池中的引用; 如果没有,则会在常量池中创建一个新对象,再返回引用。

  
String s1 = new String("z"); // s1指向内存中的对象引用

  这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。 步骤大致如下: 因为有"z"这个字面量,所以会先检查字符串常量池中是否存在字符串"z" 不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象"z"; 存在的话,就直接去堆内存中创建一个字符串对象"z"; 最后,将内存中的引用返回。

  
System.out.println(s1 == s2); //false

  String中的intern方法是一个 native 的方法,当调用 intern方法时,如果池已经包含一个等于此String对象的字符串 (用equals(oject)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将 s1 复制到字符串常量池里)。

  
Jdk1.6及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池

  Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里

  Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里

  2 类加载机制

  Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略为Java应用提供了极高的扩展性和灵活性,Java动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

  类被加载到方法区中后主要包含运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。

  2.1类加载的时机

  一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

  加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

  有且只有六种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):

  new对象;读取或设置静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外);调用静态方法。

  对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

  当初始化类的时候,其父类还没有进行过初始化,需要先触发父类的初始化。

  当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

  接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

  这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

  接口与类真正有区别的是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

  2.2 类加载的过程

  2.2.1 加载

  在加载阶段,Java虚拟机需要完成以下三件事情:

  通过一个类的全限定名来获取定义此类的二进制字节流。

  将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  2.2.2 验证

  验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

  验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证[6]、元数据验证[7]、字节码验证[8]、符号引用验证[9]。

  可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

  2.2.3 准备

  准备阶段正式为类变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值(数据类型的默认零值,如果类字段的字段属性表中存在ConstantValue属性(final),就会初始化final所指定的值。),在JDK 7及之前,HotSpot使用永久代来实现方法区时,这些变量所使用的内存都应当在方法区中进行分配。JDK7及之后,类变量则会随着Class对象一起存放在Java堆中。

  这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着一起分配在Java堆中。

  2.2.4 解析

  解析阶段是Java虚拟机将常量池内的符号引用[10](Symbolic References)替换为直接引用[11](Direct References)的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。在解析阶段也对方法或者字段的可访问性(public、protected、private、<package>)进行检查。

  在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,在JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError异常。

  2.2.5 初始化

  在初始化阶段会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()[12]方法的过程。<clinit>()是Javac编译器的自动生成物。

  2.3 类加载器

  类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作在Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

  类与类加载器

  类加载器只用于实现类的加载动作,对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每 一个类加载器,都拥有一个独立的类名称空间。

  比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance() 方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

  站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

  JDK 8及之前版本的三层类加载器:

  
启动类加载器(Bootstrap Class Loader):负责加载存放在\lib(JAVA_HOME/jre/lib/rt.jar、 resource.jar或sun.boot.class.path路径下的内容)目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。

  启动类加载器无法被Java程序直接引用, 如果需要把加载请求委派给启动类加载器去处理,那直接使用null代替即可。

  
扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

  
应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  
JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。

  当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,否则该类所依赖及引用的类也由这个ClassLoder载入。

  双亲委派模型

  类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

  双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

  为什么要设计双亲委派机制?

  沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心 API库被随意篡改

  避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一 次,保证被加载类的唯一性

  如果自定义类加载器,就需要继承java.lang.ClassLoader,并重写findClass(),如果想不遵循双亲委派的类加载顺序,还需要重写loadClass()。

  线程上下文类加载器 (Thread Context ClassLoader):这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器。

  Tomcat打破双亲委派机制

  从图中的委派关系中可以看出: CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用, 从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则 与对方相互隔离。 WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader 实例之间相互隔离。 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的 就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例, 并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。

  3 HotSpot虚拟机对象

  3.1 对象的内存布局

  在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  对象头(Header)

  HotSpot虚拟机对象的对象头部分包括两类信息。

  第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它 为“Mark Word”。 Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间;

  对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

  jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩,启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­UseCompressedOops

  为什么要进行指针压缩?
 

  1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。
 

  2.为了减少64位平台下内存的消耗,启用指针压缩功能。
 

  3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm 只用32位地址就可以支持更大的内存配置(小于等于32G)。
 

  4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间。
 

  5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内 存不要大于32G为好

  实例数据(Instance Data)

  实例数据部分是对象真正存储的有效信息,即程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。 HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存 放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的 +XX:CompactFields参数值为true(默认为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

  对齐填充(Padding)

  对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。 HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

  3.2 对象的创建

  
类加载检查

  当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到 一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  
分配内存

  接下来从Java堆中划分出来一块确定大小的内存,使用指针碰撞[13](Bump The Pointer)或空闲列表[14](Free List)的方式分配给新生对象(类加载完成后可完全确定)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

  
初始化

  存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。 这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  
设置对象头

  接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到 类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

  
构造函数

  执行方法 <clinit>()执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

  
解决并发问题的方法:

  对象创建在并发情况下并不是线程安全的,解决这个问题有两种可选方案:

  对分配内存空间的动作进行同步处理:实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。

  把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

  3.3 对象的访问定位

  Java程序会通过栈上的reference数据来操作堆上的具体对象,主流的访问方式主要有使用句柄[15]和直接指针[16]两种,HotSpot主要使用第二种方式进行对象访问。
 

  句柄:
 

  
 

  直接指针:
 

  4 垃圾收集器

  程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,因此这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

  判断对象存活算法

  引用计数算法

  在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的。

  但是单纯的引用计数就很难解决对象之间相互循环引用的问题。它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

  可达性分析算法

  当前主流的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

  在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。

  在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  所有被同步锁(synchronized关键字)持有的对象。

  在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

  Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

  除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

  具备了局部回收特征的垃圾收集器,可以避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

  在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强 度依次逐渐减弱。

  强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

  弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

  虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供 了PhantomReference类来实现虚引用。

  对象死亡过程

  真正宣告一个对象死亡,最多会经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法(不推荐使用的语法)。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

  如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

  回收方法区

  方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

  常量池中的常量已经没有任何对象引用,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

  判定一个类型是否属于“不再被使用的类”的条件比较苛刻,需要同时满足下面三个条件:

  
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则通常是很难达成的。

  
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  关于是否要对类型进行回收,HotSpot虚拟机提供了Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX: +TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在 Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

  
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

  垃圾收集算法

  标记-清除算法

  最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来。

  主要缺点有两个:

  第一个是执行效率不稳定,如果需要标记的对象太多,效率不高。

  第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

  标记-复制算法

  标记-复制算法解决了标记-清除算法面对大量可回收对象时执行效率低的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,对于多数对象都是可回收的情况,复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,可用内存缩小为了原来的一半。

  标记-整理算法

  针对老年代对象的存亡特征,有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

  HotSpot的算法细节实现

  根节点枚举

  收集器在根节点枚举这一步骤时都是必须暂停用户线程的,必须在一个能保障一致性的快照中才得以进行。

  当用户线程停顿下来之后,HotSpot 使用一组称为OopMap(Ordinary Object Pointer Map,普通对象指针地图),的数据结构直接得到哪些地方存放着对象引用。在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

  导致OopMap内容变化的指令非常多,HotSpot没有为每条指令都生成OopMap,只是在“特定的位置”记录 了这些信息,这些位置被称为安全点(Safepoint)。

  安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

  垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。有两种方案可供选择:

  
抢先式中断 (Preemptive Suspension):抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

  
主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

  由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。当需要暂停用户线程时,虚拟机把对应地址内存页设置为不可读,那线程执行到相应的指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。

  
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

  安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。可以把安全区域看作被扩展拉伸了的安全点。

  当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

  记忆集与卡表

  记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,为解决对象跨代引用所带来的问题。

  在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,所以实现记忆集的时候,便可以选择粗犷的记录粒度来节省记忆集的存储和维护成本:

  字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

  对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

  卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

  第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。

  CARD_TABLE [this address 9] = 1;

  字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。

  一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

  有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态。

  写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用。

  卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。

  并发的可达性分析

  堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间就更长。如果用户线程与收集器是并发工作:收集器在对象图结构上标记,同时用户线程在修改引用关系——即修改对象图的结构,可能出现把原本存活的对象错误标记为已消亡。

  当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:

  赋值器插入了一条或多条从黑色[17]对象到白色[18]对象的新引用;

  赋值器删除了全部从灰色[19]对象到该白色对象的直接或间接引用;

  要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。

  增量更新:破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。

  原始快照:破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。目的就是让这种对象在本轮GC清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾

  HotSpot并发标记时对漏标的处理方案如下: CMS:写屏障 + 增量更新 ;G1,Shenandoah:写屏障 + SATB

  为什么G1用SATB?CMS用增量更新?

  SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC 再深度扫描。

  垃圾收集器

  JDK8中默认使用组合是: Parallel Scavenge 、Parallel Old

  Serial

  Serial收集器是一个单线程工作的收集器,但它的“单线 程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

  HotSpot虚拟机运行在客户端模式下的默认新生代收集器,优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比)它是所有收集器里额外内存消耗(Memory Footprint)最小、单线程收集效率最高的。

  Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

  使用方式:-XX:+UseSerialGC -XX:+UseSerialOldGC

  ParNew

  ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

  它默认开启的收集线程数与处理器核心数量相同,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

  ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。

  Parallel

  Parallel Scavenge收集器是基于标记-复制算法实现的,也是能够并行收集的多线程收集器。Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间 )

  高吞吐量可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

  Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis[20]参数以及吞吐量大小的-XX:GCTimeRatio[21]参数。

  Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。

  Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现。
 

  CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现,它的运作过程分为四个步骤,包括:

  初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;

  并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,CMS收集器采用增量更新算法实现收集线程与用户线程互不干扰地运行;

  重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短;

  并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

  初始标记、重新标记这两个步骤仍然需要“Stop The World”。

  CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计 算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不少于25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。

  CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在JDK5的默认设置下,当老年代使用了68%的空间后CMS收集器就会被激活,JDK6时提升至92%。CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集。可以通过参数-XX:CMSInitiatingOccupancyFraction设置。

  CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启, JDK 9废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(JDK9废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表 示每次进入Full GC时都进行碎片整理)。

  CMS的相关核心参数 :

  -XX:+UseConcMarkSweepGC:启用cms

  -XX:ConcGCThreads:并发的GC线程数

  -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC

  -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)

  -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一 次

  -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整

  -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段

  -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW

  -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

  Garbage First

  Garbage First(简称G1)开创了收集器面向局部收集的设计思路和基于Region的内存布局形。

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: