JVM它是Java Virtual Machine 的缩写,主要是通过在实际计算机模仿各种计算机功能来实现的,组成部分包括堆、方法区、栈、本地方法栈、程序计算器等部分组成的,其中方法回收堆和方法区是共享区,也就是谁都可以使用,而栈和程序计算器、本地方法栈区是归JVM的。Java能够被称为“一次编译,到处运行”的原因就是Java屏蔽了很多的操作系统平台相关信息,使得Java只需要生成在JVM虚拟机运行的目标代码也就是所说的字节码,就可以在多种平台运行。
Java 虚拟机(JVM)是运⾏ Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),⽬的是使⽤相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语⾔“⼀次编译,随处可以运⾏”的关键所在.
什么是字节码?采⽤字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做 字节码 (即扩展名为 .class 的⽂件),它不⾯向任
何特定的处理器,只⾯向虚拟机。Java 语⾔通过字节码的⽅式,在⼀定程度上解决了传统解释型语
⾔执⾏效率低的问题,同时⼜保留了解释型语⾔可移植的特点。所以 Java 程序运⾏时⽐⾼效,
⽽且,由于字节码并不针对⼀种特定的机器,因此,Java 程序⽆须重新编译便可在多种不同操作系
统的计算机上运⾏。
JDK 是 Java Development Kit,它是功能⻬全的 Java SDK。它拥有 JRE 所拥有的⼀切,还有编译器(javac)和⼯具(如 javadoc 和 jdb)。它能够创建和编译程序。
JRE 是 Java 运⾏时环境。它是运⾏已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的⼀些基础构件。但是,它不能⽤于创建新程序。
粗略分来,JVM的内部体系结构分为三部分,分别是:类装载器(ClassLoader)子系统,运行时数据区,和执行引擎。
每一个Java虚拟机都由一个类加载子系统(class loader subsystem)负责加载程序中的类型(类和接口),并赋予唯一的名字。每一个 Java 虚拟机都有一个执行引擎(execution engine)负责执行被加载类中包含的指令。 JVM 的两种类装载器包括:启动类装载器和用户自定义类装载器,启动类装载器是 JVM 实现的一部分,用户自定义装载器则是Java程序的一部分,必须是 ClassLoader 类的子类
主要的执行技术有:解释,即时编译,自适应优化、芯片级直接执行其中解释属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式 。
自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。
主要包括:方法区,堆,Java栈,PC寄存器,本地方法栈
堆:存放所有程序在运行时创建的对象
方法区:当JVM的类装载器加载.class文件,并进行解析,把解析的类型信息放入方法区。
Java栈和PC寄存器由线程独享
JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址
本地方法栈:存储本地方法调用的状态
Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK. 1.8 和
之前的版本略有不同,下⾯会介绍到。
线程隔离的:
程序计数器
虚拟机栈
本地方法栈
线程共享:
堆
方法区(元数据区)
直接内存(非运行时数据区的一部分)
JVM1.8同1.7比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。
程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器。
Java 虚拟机的多线程是通过线程轮流切换,分配处理器时间的方式来实现的,所以在任何一个确定的时刻,一个处理器(即多处理器的一个内核)都只会执行一条线程中的指令。因此,为了线程切换后,能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各个线程之间不影响,独立存储,我们称这类内存区域为“线程私有”。
如果线程执行的是Java方法,则记录的是正在执行的虚拟机字节码指令的地址。如果是Native 本地方法,计数器值为空(Undefined)
从上面的介绍中我们知道程序计数器主要有两个作用:
注意:程序计数器是唯⼀⼀个不会出现 OutOfMemoryError 的内存区域,它的⽣命周期随着线程的创建
⽽创建,随着线程的结束⽽死亡。是唯一一个在《java虚拟机规范》中没有规定任何 OOM 情况的区域。
线程私有,每个线程对应一个Java虚拟机栈,其生命周期与线程同进同退。每个Java方法(也就是字节码)在被调用的时候都会创建一个栈帧,并入栈。一旦完成调用,则出栈。所有的的栈帧都出栈后,线程也就完成了使命。
Java虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于保存 局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完毕的过程,对应了栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向一条字节码指令的地址)。
Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。
Java 方法有两种返回方式:(1)return 语句;(2)抛出异常,不管哪种返回方式都会导致栈帧被弹出。
功能与Java虚拟机栈十分相同。区别在于,虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服
务,而本地方法栈为虚拟机使用到的native方法服务。
Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创
建。此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的
⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代:再
细致⼀点有:Eden空间、From Survivor、To Survivor空间等。进⼀步划分的⽬的是更好地回收内存,
或者更快地分配内存。
堆空间内存分配(默认情况下)
命令行上执行如下命令,查看所有默认的jvm参数
java -XX:+PrintFlagsFinal -version
输出有大几百行,这里只取其中的两个有关联的参数
[Global flags]
uintx InitialSurvivorRatio = 8 {product}
uintx NewRatio = 2 {product}
... ...
java version "1.8.0_152"
Java(TM) SE Runtime Environment (build 1.8.0_152-b16)
Java HotSpot(TM) 64-Bit Server VM (build 25.152-b16, mixed mode)
参数 | 作用 |
---|---|
-XX:InitialSurvivorRatio | 新生代Eden/survivor空间的初始比例 |
-XX:NewRatio | Old区和Yong区的内存比例 |
一道推算题
默认参数下,如果仅给出eden区40M,求堆空间总大小
答:根据比例可以推算出,两个survivor区各5M,年轻代50M。老年代是年轻代的两倍,即100M。那么堆总大小就是150M。
⽅法区与 Java 堆⼀样,是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常量、静
态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把⽅法区描述为堆的⼀个逻辑部分,但
是它却有⼀个别名叫做 Non-Heap(⾮堆),⽬的应该是与 Java 堆区分开来。
⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。
⽅法区和永久代的关系
《Java虚拟机规范》只是规定了有⽅法区这么个概念和它的作⽤,并没有规定如何去实现它。那
么,在不同的 JVM 上⽅法区的实现肯定是不同的了。 ⽅法区和永久代的关系很像Java中接⼝和类
的关系,类实现了接⼝,⽽永久代就是HotSpot虚拟机对虚拟机规范中⽅法区的⼀种实现⽅式。 也
就是说,永久代是HotSpot的概念,⽅法区是Java虚拟机规范中的定义,是⼀种规范,⽽永久代是⼀
种实现,⼀个是标准⼀个是实现,其他的虚拟机实现并没有永久带这⼀说法。
(1)JDK 1.8 之前永久代还没被彻底移除的时候通常通过下⾯这些参数来调节⽅法区⼤⼩
-XX:PermSize=N // ⽅法区(永久代)初始⼤⼩
-XX:MaxPermSize=N // ⽅法区(永久代)最⼤⼤⼩,超过这个值将会抛出
(2)JDK 1.8 的时候,⽅法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取⽽代之是元空间,元空间使⽤的是直接内存。
-XX:MetaspaceSize=N //设置Metaspace的初始(和最⼩⼤⼩)
-XX:MaxMetaspaceSize=N //设置Metaspace的最⼤⼤⼩
与永久代很⼤的不同就是,如果不指定⼤⼩的话,随着更多类的创建,虚拟机会耗尽所有可⽤的系统内存。
运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还
有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)
既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛
出 OutOfMemoryError 异常。
直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
(1)内存分配的两种方式
选择以上两种⽅式中的哪⼀种,取决于 Java 堆内存是否规整。⽽ Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
(2)内存分配并发问题
在创建对象的时候有⼀个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采⽤两种⽅式来保证线程安全:
CAS+失败重试: CAS 是乐观锁的⼀种实现⽅式。所谓乐观锁就是,每次不加锁⽽是假设没有冲突⽽去完成某项操作,如果因为冲突失败就重试,直到成功为⽌。虚拟机采⽤ CAS 配上失败重试的⽅式保证更新操作的原⼦性。
TLAB: 为每⼀个线程预先在Eden区分配⼀块⼉内存,JVM在给线程中的对象分配内存时,⾸先在TLAB分配,当对象⼤于TLAB中的剩余内存或TLAB的内存已⽤尽时,再采⽤上述的CAS进⾏内存分配
建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式有虚拟机实现⽽定,⽬前主流的访问⽅式有 ①使⽤句柄 和 ②直接指针 两种:
如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;
如果使⽤直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。
这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。
堆空间的基本结构:
上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
⼤多数情况下,对象在新⽣代中 eden 区分配。当 eden 区没有⾜够空间进⾏分配时,虚拟机将发起⼀
次 Minor GC。
堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。
给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引何时候计数器为0的对象就是不可能再被使⽤的。
这个算法的基本思想就是通过⼀系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所⾛过的路径称为引⽤链,当⼀个对象到 GC Roots 没有任何引⽤链相连的话,则证明此对象是不可⽤的。
⽆论是通过引⽤计数法判断对象引⽤数量,还是通过可达性分析法判断对象的引⽤链是否可达,判定对象的存活都与“引⽤”有关。
JDK1.2之前,Java中引⽤的定义很传统:如果reference类型的数据存储的数值代表的是另⼀块内存的起始地址,就称这块内存代表⼀个引⽤。
JDK1.2以后,Java对引⽤的概念进⾏了扩充,将引⽤分为强引⽤、软引⽤、弱引⽤、虚引⽤四种(引⽤强度逐渐减弱)
强引⽤(StrongReference)
以前我们使⽤的⼤部分引⽤实际上都是强引⽤,这是使⽤最普遍的引⽤。如果⼀个对象具有强引⽤,那就类似于必不可少的⽣活⽤品,垃圾回收器绝不会回收它。当内存空 间不⾜,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。
软引⽤(SoftReference)
如果⼀个对象只具有软引⽤,那就类似于可有可⽆的⽣活⽤品。如果内存空间⾜够,垃圾回收器就不会回收它,如果内存空间不⾜了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使⽤。软引⽤可⽤来实现内存敏感的⾼速缓存。
软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。
弱引⽤(WeakReference)
如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。
弱引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。
虚引⽤(PhantomReference)
"虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被垃圾回收。
虚引⽤主要⽤来跟踪对象被垃圾回收的活动。
虚引⽤与软引⽤和弱引⽤的⼀个区别在于: 虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃 圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是 否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。
特别注意,在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出(OutOfMemory)等问题的产⽣。
运⾏时常量池主要回收的是废弃的常量。那么,我们如何判断⼀个常量是废弃常量呢?
假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引⽤该字符串常量的话,就说明常量
“abc” 就是废弃常量,如果这时发⽣内存回收的话⽽且有必要的话,“abc” 就会被系统清理出常量池。
⽅法区主要回收的是⽆⽤的类,那么如何判断⼀个类是⽆⽤的类的呢?
判定⼀个常量是否是“废弃常量”比较简单,⽽要判定⼀个类是否是“⽆⽤的类”的条件则相对苛刻许多。类需要同时满⾜下⾯3个条件才能算是 “⽆⽤的类” :
虚拟机可以对满⾜上述3个条件的⽆⽤类进⾏回收,这⾥说的仅仅是“可以”,⽽并不是和对象⼀样不使⽤了就会必然被回收。
算法分为 “标记” 和 “清除” 阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不⾜进⾏改进得到。这种垃圾收集算法会带来两个明显的问题:
为了解决效率问题,“复制”收集算法出现了。它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收,这种垃圾收集算法会带来两个明显的问题:
根据⽼年代的特点特出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。
当前虚拟机的垃圾收集都采⽤分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为⼏块。⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。⽽⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。
主要是为了提升GC效率。上⾯提到的分代收集算法已经很好的解释了这个问题。
如果说收集算法是内存回收的⽅法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并⾮要挑选出⼀个最好的收集器。因为知道现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应⽤场景选择适合⾃⼰的垃圾收集器。试想⼀下:如果有⼀种四海之内、任何场景下都适⽤的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。⼤家看名字就知道这个收集器是⼀个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使⽤⼀条垃圾收集线程去完成垃圾收集⼯作,更重要的是它在进⾏垃圾收集⼯作的时候必须暂停其他所有的⼯作线程( “Stop The World” ),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法。
虚拟机的设计者们当然知道Stop The World带来的不良⽤户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是Serial收集器有没有优于其他垃圾收集器的地⽅呢?当然有,它简单⽽⾼效(与其他收集器的单线程相⽐)。Serial收集器由于没有线程交互的开销,⾃然可以获得很⾼的单线程收集效率。Serial收集器对于运⾏在Client模式下的虚拟机来说是个不错的选择。
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多线程进⾏垃圾收集外,其余⾏为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全⼀样。
新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。
它是许多运⾏在Server模式下的虚拟机的⾸要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后⾯会介绍到)配合⼯作。
并⾏和并发概念补充:
Parallel Scavenge 收集器类似于ParNew 收集器。 那么它有什么特别之处呢?
-XX:+UseParallelGC
使⽤Parallel收集器+ ⽼年代串⾏
-XX:+UseParallelOldGC
使⽤Parallel收集器+ ⽼年代并⾏
Parallel Scavenge收集器关注点是吞吐量(⾼效率的利⽤CPU)。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验)。所谓吞吐量就是CPU中⽤于运⾏⽤户代码的时间与CPU总消耗时间的⽐值。 Parallel Scavenge收集器提供了很多参数供⽤户找到最合适的停顿时间或最⼤吞吐量,如果对于收集器运作不太了解的话,⼿⼯优化存在的话可以选择把内存管理优化交给虚拟机去完成也是⼀个不错的选择。
新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。
Serial收集器的⽼年代版本,它同样是⼀个单线程收集器。它主要有两⼤⽤途:⼀种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使⽤,另⼀种用途是作为CMS收集器的后备⽅案。
Parallel Scavenge收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。
CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它而非常符合在注重⽤户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是HotSpot虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。
从名字中的Mark Sweep这两个词可以看出,CMS收集器是⼀种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:
从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点:
G1 (Garbage-First)是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征。
被视为JDK1.7中HotSpot虚拟机的⼀个重要进化特征。它具备⼀下特点:
G1收集器的运作⼤致分为以下⼏个步骤:
G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。这种使⽤Region划分内存空间以及有优先级的区域回收⽅式,保证了GF收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。
根据 Java 虚拟机规范,类⽂件由单个 ClassFile 结构组成:
ClassFile {
u4 magic; //Class ⽂件的标志
u2 minor_version; //Class 的⼩版本号
u2 major_version; //Class 的⼤版本号
u2 constant_pool_count; //常量池的数量
cp_info constant_pool[constant_pool_count-1]; //常量池
u2 access_flags; //Class 的访问标记
u2 this_class; //当前类
u2 super_class; //⽗类
u2 interfaces_count; //接⼝
u2 interfaces[interfaces_count]; //⼀个类可以实现多个接⼝
u2 fields_count; //Class ⽂件的字段属性
field_info fields[fields_count]; //⼀个类会可以有个字段
u2 methods_count; //Class ⽂件的⽅法数量
method_info methods[methods_count]; //⼀个类可以有个多个⽅法
u2 attributes_count; //此类的属性表中的属性数
attribute_info attributes[attributes_count]; //属性表集合
}
Class ⽂件字节码结构组织示意图
下⾯会按照上图结构按顺序详细介绍⼀下 Class ⽂件结构涉及到的⼀些组件。
加载
它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,比如jar 文件,class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
连接
这是核心的步骤,简单说是把原始的类定义信息平滑地转入 JVM 运行的过程中。这里可进一步细分成三个步骤:
初始化
这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。再来谈谈双亲委派模型,简单说就是当加载器(Class-Loader)试图加载某个类型的时候,除非父类加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。
JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃ java.lang.ClassLoader :
public class ClassLoaderDemo {
public static void main(String[] args) {
System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
}
}
Output
ClassLodarDemo's ClassLoader is
sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is
sun.misc.Launcher$ExtClassLoader@1b6d3586
The GrandParent of ClassLodarDemo's ClassLoader is null
AppClassLoader 的⽗类加载器为 ExtClassLoader
ExtClassLoader 的⽗类加载器为null,null并不代表 ExtClassLoader 没有⽗类加载器,而是 Bootstrap ClassLoader 。
private final ClassLoader parent;
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求的类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//父加载器不为空,调用父加载器 loadClass() ⽅法处理
c = parent.loadClass(name, false);
} else {
//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//抛出异常说明⽗类加载器⽆法完成加载请求
}
if (c == null) {
long t1 = System.nanoTime();
//自己尝试加载
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
双亲委派模型的好处
双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的⽅式不仅仅根据类名,相同的类⽂件被不同的类加载器加载产⽣的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现⼀些问题,比如我们编写⼀个称为 java.lang.Object 类的话,那么程序运⾏的时候,系统就会出现多个不同的Object 类
如何避免双亲委托机制
为了避免双亲委托机制,我们可以⾃⼰定义⼀个类加载器,然后重载 loadClass() 即可。
如何⾃定义类加载器
除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃java.lang.ClassLoader 。如果我们要⾃定义⾃⼰的类加载器,很明显需要继承 ClassLoader 。
参考: 傑0327、bruce128、温驭臣、JavaGuide面试突击