阅读 189

【JVM】JVM知识点整理

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」

1.JVM

1.1 JVM概述

Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

2.JAVA内存区域

2.1运行时数据区

java虚拟机在执行Java程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK1.8和之前的版本略有不同 Jdk1.8之前为: image.png

Jdk1.8之后为: 取消了方法区,在直接内存中增加了元空间 未命名文件 (3).png

2.1.1 程序计数器

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,比如:顺序执行、选择、循环、异常处理
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切回来的时候能够知道线程上次运行到哪里了
  • 程序计数器是唯一一个不会出现OutOfMemoryError的内存区域。它的生命周期随着线程的创建而创建,随着线程的结束而死亡

2.1.2 虚拟机栈

虚拟机栈的生命周期与线程相同,描述的是java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。java内存可以粗糙的区分为堆内存和栈内存,其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。实际上,java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈,动态链接,方法出口信息等。如下图所示: 未命名文件 (5).png

局部变量表主要存放了编译期可知的各种数据类型(8种基本数据类型)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)

Java虚拟机栈会出现两种错误:

  • StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前java虚拟机栈的最大深度的时候,就抛出StackOverFlowError错误
  • OutOfMemoryError:若Java虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话,就会抛出OutOfMemoryError错误

2.1.3 本地方法栈

线程私有。和虚拟机栈所发挥的作用非常类似,区别是虚拟机栈为虚拟机执行java方法(字节码)服务,而本地方法栈是为虚拟机使用到的native方法服务。在HotSport虚拟机中和Java虚拟机合二为一。 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息等

在Java虚拟机规范中,对这个区域规定了两种异常StackOverFlowErrorOutOfMemoryError

2.1.4 java堆

Java堆(Java Head)是Java虚拟机所管理的内存中最大的一块,java堆是所有线程共享的一块区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java堆是垃圾回收器管理的主要区域,因此被称之为“GC堆(Garbage Collected Heap)”。从内存回收的角度来看,由于现在收集器基本都采用分代收集器算法,所以Java堆中还何以细分为:新生代和老年代;进一步可以细分为:Eden空间,From Survivor 空间。To Survivor空间等。这样划分的目的是为了更好的回收内存或者更快的分配内存。

在JDK7版本及JDK7版本之前,堆内存从GC的角度被通常分为下面三个部分:

  1. 新生代内存(Young Generation):约占整个空间的1/3
  2. 老生代(Old Generation):约占整个空间的2/3
  3. 永久代(Permanent Generation)

未命名文件.png

JDK8版本之后方法区(HotSpot的永久代)被彻底移除了,取而代之是元空间,元空间直接使用直接内存

  1. 一般情况下,新生代各空间初始值设置比例:Eden:from:to=8:1:1,即Eden:Survivor=8:2。配置参数为:-Xx:SurvivorRatio=8
  2. java堆是程序运行时动态申请某个大小的内存空间,是不连续的,逻辑上连续即可,通过-Xms与-Xmx来控制堆的大小
  3. 长期存活的对象将进入老年代:⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 (From Survivor)或者 s1(To Survivor),并且对象的年龄还加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到⼀定程度(默认为 15 岁),就会被晋升到⽼年代中。但如果在Survivor空间中相同年龄所有对象大小的综合大于Survivor空间的一半,年龄大于会等于该年龄的对象就可以直接进入老年代。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置
  4. 大对象直接进入老年代,所谓的大对象是指需要大量连续内存空间的java对象,比如:很长的字符串,数组等(写代码时尽量避免短命的大对象,因为经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集动作)
  5. 新生代只会进行MinorGc,老年代进行MajorGC。当发生MajorGC时,也会触发新生代MinorGC;FullGC清理整个堆空间,一般是JVM触发,不会人为进行。由于老年代空间比较大,MajorGC比较耗时,尽量减少发生MajorGC
  6. 一般对象在堆内存分配流程:

未命名文件 (1).png

堆这⾥最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有⼏ 种,⽐如:

  • OutOfMemoryError: GC Overhead Limit Exceeded :当JVM花太多时间执⾏垃圾回收并且只能回收很少的堆空间时,就会发⽣此错误。
  • java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不⾜以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存⽆关,和你配置的内存⼤⼩有关!)

2.1.5 方法区

2.1.5.1 方法区

方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆(Non-Heap),目的是与Java堆区分开来。HotSpot虚拟机把GC分代收集扩展至方法区,即使用Java堆的永久代来实现方法区,这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存,而不必为方法区开发专门的内存管理器

方法区也被叫做永久代。在《Java虚拟机规范》中只规范了有方法区这个概念和它的作用,并没有规定如何去实现它。那么在不同的JVM上方法区的实现是不相同的。方法区和永久代的关系很像Java中的接口和类,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。也就是说,永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这个说法

2.1.5.2 运行时常量区

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool table),用于存放编译期生成的各种字面量和符号应用,这部分内容将在类加载后存放到方法区的运行时常量池中。Java虚拟机堆class文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范的要求,这样才会被虚拟机认可、装载和执行。 运行常量池即为方法区的一部分,自然也受到方法区的内存的限制,当常量池无法申请到内存时会抛出OutOfMemoryError异常。

2.1.6 直接内存

直接内存并不是虚拟机运行数据区的一部分,也不是java虚拟机规范中定义的内存区域。但是这部分频繁使用也会导致OutOfMemoryError异常出现。

JDK1.4 中新加⼊的 NIO(New Input/Output) 类,引⼊了⼀种基于通道(Channel) 与缓存区(Buffer) 的 I/O ⽅式,它可以直接使⽤ Native 函数库直接分配堆外内存,然后通过⼀个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引⽤进⾏操作。这样就能在⼀些场景中显著提⾼性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存⼤⼩以及处理器寻址空间的限制。

2.1.7 Java8的元空间

在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

2.2 虚拟机对象

2.2.1 对象的创建

step1.类的加载

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

step2.分配内存

类的加载检查通过后,接下来是为新生对象分配内存。但类加载完成后所需的内存大小就已经完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。分配内存有两种方式:

  • 指针碰撞(Bump the Pointer):假设java堆中内存是绝对规整的,所有用过得内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪到一段与对象大小相等的距离
  • 空闲列表(Free List):如果java堆中的内存并不是完整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

选择哪种分配方式有java堆是否完整决定,而java堆是否规整又由所采用的垃圾收集器是否带有压缩整理(标记-整理)功能决定。因此,在使用Serial、ParNew等带有Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

还有一个问题需要考虑,在虚拟机中对象频繁的创建(即使是修改一个指针所指的位置),在并发情况下会带来线程安全的问题。作为虚拟机来说,必须保证线程安全,所有虚拟机采用两种方式保证线程安全:

  • CAS+失败重试:CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):为每一个线程预先在Eden区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中剩余内存或TLAB的内存已用尽时,在采用上述的CAS进行内存分配
step3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。

step4.设置对象头

初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

step5.执行Init方法

在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从 Java 程序的视⻆来看,对象创建才刚开始, <init> ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ <init>⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来

2.2.2 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分成3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

2.2.2.1 对象头

对象头包括两部分信息,分别是Mark Word、类型指针:

  • Mark Word:是用于存储对象自身的运行时数据,如哈希码,GC分代年龄、锁状态标志,线程持有的锁、偏向线程ID、偏向时间戳等
  • 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2.2.2.2 实例数据

实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容,无论是父类继承下来的还是自定义的都需要记录下来。HotSpot虚拟机默认分配策略为long/double、int、short/char、byte、boolean,从分配策略中可以看出,相同宽度的字段总是被分配到一起。

2.2.2.3 对齐填充

不是必然存在的,也没用特别的含义,它仅仅起着占位符的作用。

2.2.3 对象的访问

建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式有虚拟机实现⽽定,⽬前主流的访问⽅式有使⽤句柄和直接指针两种:

  1. 句柄: 如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;

image1.png

2.直接指针: 如果使⽤直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。

image2.png

这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。

3.垃圾回收器与算法

3.1 如何确定垃圾

堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。

3.1.1 java中的引用类型

从JDK1.2开始,Java对引用的概念进行了扩充,将引用分为了:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这4种引用的强度依次减弱。

  • 强引用(Strong Reference):它是java中默认的引用类型,如果不特意使用java.lang.ref下的类,那么程序中的所有引用都是强引用。有强引用存在的对象永远不会被gc收集,所以在内存不够用时,JVM宁愿抛出OutOfMemoryError这样的错误,也不愿意将强引用对象回收
  • 软引用(Soft Reference):软引用是用来描述一些非必需但是仍有用的对象,它尽最大可能的保证对象不被回收。当内存足够时对象不会被回收,内存不足时,对象就会被回收。对象被回收后内存任然不够用,才会抛出异常。这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等等
  • 弱引用(Weak Reference):无论内存是否足够,只要jvm开始进行垃圾回收,那些被弱引用关联的对象都会被回收
  • 虚引用(Phantom Reference):是最弱的一种引用关系,随时都可能被回收。Jdk1.2之后用PhantomReference类来表示。通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

3.1.2 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能在被使用的。 一些主流的虚拟机里面没有选用引用计数法来管理内存,有个主要的原因是它很难解决对象之间相互循环引用的问题

3.1.3 可达性分析算法

这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。

image3.png

在Java语言中可以作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(一般说的Native方法)引用的对象

3.1.4回收方法区

  1. 永久代的垃圾收集器主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象类似。首先要判断一个常量是废弃常量,即当前没有任何对象引用该常量,如果这是发生内存回收的话而且有必要的话,这个常量就会被系统清理出常量池。常量池中的接口、方法、字段的符号引用都是如此

  2. 判断一个类是否是“无用的类”的条件相对比较苛刻,需要同时满足下面3个条件才能算是“无用的类”

    • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过发射访问该类的方法
    • 虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是可以,而不是和对象一样不使用了就会必然回收。

3.2 垃圾收集算法

3.2.1 标记-清除算法

该算法分为标记和清除两个阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

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

标记-清除算法执行过程:

image.png

3.2.2 复制算法

为了解决效率问题,一种称为“复制(Copying)”的收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存分为大小相同两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

image.png

3.2.3 标记-整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-整理算法”一样,但后续步骤不是直接对可收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存

3.2.4 分代收集算法

当前虚拟机的垃圾收集都采取分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

  • 比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集

3.2.5 分区收集算法

分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。

3.3 垃圾收集器

Java 堆内存被划分为新生代和老年代两部分,新生代主要使用复制和标记-清除垃圾回收算法;年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器

3.3.1 Serial垃圾收集器

Serial(串⾏)收集器是最基本、历史最悠久的垃圾收集器了。⼤家看名字就知道这个收集器是⼀个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使⽤⼀条垃圾收集线程去完成垃圾收集⼯作,更重要的是它在进⾏垃圾收集⼯作的时候必须暂停其他所有的⼯作线程("Stop The World" ),直到它收集结束。

虚拟机的设计者们当然知道 Stop The World 带来的不良⽤户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。 但是 Serial 收集器有没有优于其他垃圾收集器的地⽅呢?当然有,它简单⽽⾼效(与其他收集器的单线程相⽐)。Serial 收集器由于没有线程交互的开销,⾃然可以获得很⾼的单线程收集效率。Serial 收集器对于运⾏在 Client 模式下的虚拟机来说是个不错的选择。

3.3.2 ParNew垃圾收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多线程进⾏垃圾收集外,其余⾏为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全⼀样。

它是许多运⾏在 Server 模式下的虚拟机的⾸要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后⾯会介绍到)配合⼯作。

3.3.3 Parallel Scavenge收集器

Parallel Scavenge 收集器也是使⽤复制算法的多线程收集器,它看上去⼏乎和 ParNew 都⼀样。 那么它有什么特别之处呢?

-XX:+UseParallelGC  使用Parallel 收集器+老年代串行
-XX:+UseParallelOldGC 使用Parallel收集器+老年代并行
复制代码

Parallel Scavenge 收集器关注点是吞吐量(⾼效率的利⽤ CPU)。CMS 等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验)。所谓吞吐量就是 CPU 中⽤于运⾏⽤户代码的时间与 CPU 总消耗时间的⽐值。 Parallel Scavenge 收集器提供了很多参数供⽤户找到最合适的停顿时间或最⼤吞吐量,如果对于收集器运作不太了解,⼿⼯优化存在困难的时候,使⽤Parallel Scavenge 收集器配合⾃适应调节策略,把内存管理优化交给虚拟机去完成也是⼀个不错的选择。

这是 JDK1.8 默认收集器 使⽤ java -XX:+PrintCommandLineFlags -version 命令查看

image1.png

JDK1.8 默认使⽤的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使⽤-XX:-UseParallelOldGC 来禁⽤该功能

3.3.4 Serial Old收集器

Serial 收集器的⽼年代版本,它同样是⼀个单线程收集器。它主要有两⼤⽤途:⼀种⽤途是在JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使⽤,另⼀种⽤途是作为 CMS 收集器的后备⽅案。

3.3.5 Parallel Old收集器

Parallel Scavenge 收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。在注重吞吐量以及CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

3.3.6 CMS收集器

CMS(Concurrent Mark Sweep) 收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⾮常符合在注重⽤户体验的应⽤上使⽤。

CMS(Concurrent Mark Sweep)收集器 是 HotSpot 虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。从名字中的Mark Sweep这两个词可以看出,CMS 收集器是⼀种 “标记-清除”算法实现的,它的运作过程相⽐于前⾯⼏种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以 GC 线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为⽤户程序继续运⾏⽽导致标记产⽣变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短
  • 并发清除: 开启⽤户线程,同时 GC 线程开始对未标记的区域做清扫。

image2.png

从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点:

  • 对 CPU 资源敏感;
  • ⽆法处理浮动垃圾;
  • 它使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。

3.3.7 G1收集器

G1(Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配置多颗处理器及大容量内存的机器,以及高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。

G1收集器被视为jdk1.7中HotSpot虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念
  • 空间整合:与CMS的标记-整理算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内

G1收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region(这也就是它的名字 Garbage-First 的由来)。这种使⽤ Region 划分内存空间以及有优先级的区域回收⽅式,保证了 G1 收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

4.类的加载

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载,验证,准备,解析,初始化,使用,卸载7个阶段。其中验证,准备,解析3个部分统称为连接。如下图:

imag1e.png

4.1 类的加载时机

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    • 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

4.2 类的加载过程

4.2.1 加载

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。这个阶段会在内存中生产一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口

4.2.2 验证

验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成4个阶段的检验动作:

  1. 文件格式验证

    • 是否以魔数0xCAFEBABE开头
    • 主、次版本号是否在当前虚拟机处理范围之内
    • 常量池的常量中是否有不被支持的常量类型
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • ……
  2. 元数据验证

    • 主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范:
    • 当前类是否有父类(除了java.lang.Object之外,所有的类都应有父类)
    • 当前类的父类是否继承了不允许被继承的类(被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • ……
  3. 字节码验证

    主要目的是通过数据流和控制流分析,确定程序语义是合法的、逻辑正确的

  4. 符号引用验证

    这个步骤发生在虚拟机将符号引用转化为直接引用的时候,它的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出如IllegalAccessError,NoSuchFieldError,NoSuchMethodError等异常

4.2.3 准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:

public static int num = 1234;
复制代码

实际上变量 v 在准备阶段过后的初始值为 0 而不是 1234,将 num 赋值为1234put static 指令是程序被编译后,存放于类构造器<client>方法之中。

但是注意如果声明为:

public static final int num = 1234;
复制代码

在编译阶段会为 num 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 num 赋值为 1234

4.2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用:直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

4.2.5 初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

4.2.6 类构造器<clinit>

初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子<client>方法执行之前,父类的<client>方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。 注意以下几种情况不会执行类初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initializefalse 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

4.3 类的加载器

4.3.1 类与类的加载器

虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM 提供了 3 种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。
  2. 扩展类加载器(Extension ClassLoader):负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。
  3. 应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader实现自定义的类加载器。

im1age.png

4.3.2 双亲委派模型

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。

image21.png

4.3.3 OSGI(动态模型系统)

OSGi(Open Service Gateway Initiative),是面向 Java 的动态模型系统,是 Java 动态化模块化系统的一系列规范。 动态改变构造:OSGi 服务平台提供在多种网络设备上无需重启的动态改变构造的功能。为了最小化耦合度和促使这些耦合度可管理,OSGi 技术提供一种面向服务的架构,它能使这些组件动态地发现对方。 模块化编程与热插拔:OSGi 旨在为实现 Java 程序的模块化编程提供基础条件,基于 OSGi 的程序很可能可以实现模块级的热插拔功能,当程序升级更新时,可以只停用、重新安装然后启动程序的其中一部分,这对企业级程序开发来说是非常具有诱惑力的特性。

OSGi 描绘了一个很美好的模块化开发目标,而且定义了实现这个目标的所需要服务与架构,同时也有成熟的框架进行实现支持。但并非所有的应用都适合采用

OSGi 作为基础架构,它在提供强大功能同时,也引入了额外的复杂度,因为它不遵守了类加载的双亲委托模型。

文章分类
后端
文章标签