标签 虚拟机 下的文章 - 酷游博客
首页
关于
友链
Search
1
阿里的简历多久可以投递一次?次数多了有没有影响?可以同时进行吗?
45 阅读
2
Java中泛型的理解
40 阅读
3
Java 14 发布了,再也不怕 NullPointerException 了!
38 阅读
4
Java中的可变参数
37 阅读
5
该如何创建字符串,使用" "还是构造函数?
30 阅读
技术
登录
/
注册
找到
5
篇与
虚拟机
相关的结果
2025-01-22
CMS和G1改用三色标记法,可达性分析到底做错了什么?
我们都知道, 当JVM判断对象不再存活的时候,便会在下一次GC时候将该对象回收掉,为堆腾出空间,而JVM判断对象存活的算法大家比较熟知的有两种,分别是引用计数法和可达性分析算法 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 可达性分析算法: 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。 但是这两种算法其实并不完美,主要存在以下问题: 1、循环引用问题,如果两个对象互相引用,就形成了一个环形结构,如果采用引用计数法的话,那么这两个对象将用于无法被回收。 2、误标记的问题,在多线程环境下,如果一个线程正在遍历对象图,而另一个线程在同时修改对象图,就会导致遍历结果不准确。 3、STW时间长,可达性分析的整个过程都需要STW,以避免对象的状态发生改变,这就导致GC停顿时长很长,大大影响应用的整体性能。 为了解决上面这些问题,就引入了三色标记算法 什么是三色标记算法 三色标记法将对象分为三种状态:白色、灰色和黑色。 白色:该对象没有被标记过。 灰色:该对象已经被标记过了,但该对象的引用对象还没标记完。 黑色:该对象已经被标记过了,并且他的全部引用对象也都标记完了。 三色标记法的标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark)。 这三个阶段看上去是不是很熟悉,这不就是CMS的四个阶段中的前三个么?没错,就是他们。 ● 初始标记:遍历所有的根对象,将根对象和直接引用的对象标记为灰色。在这个阶段中,垃圾回收器只会扫描被直接或者间接引用的对象,而不会扫描整个堆。因此,初始标记阶段的时间比较短。(Stop The World) ● 并发标记:在这个过程中,垃圾回收器会从灰色对象开始遍历整个对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用写屏障(Write Barrier)技术来保证并发标记的正确性。(不需要STW) ● 重新标记:重新标记的主要作用是标记在并发标记阶段中被修改的对象以及未被遍历到的对象。这个过程中,垃圾回收器会从灰色对象重新开始遍历对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。(Stop The World) 在重新标记阶段结束之后,垃圾回收器会执行清除操作,将未被标记为可达对象的对象进行回收,从而释放内存空间。这个过程中,垃圾回收器会将所有未被标记的对象标记为白色(White)。 以上三个标记阶段中,初始标记和重新标记是需要STW的,而并发标记是不需要STW的。其中最耗时的其实就是并发标记的这个阶段,因为这个阶段需要遍历整个对象树,而三色标记把这个阶段做到了和应用线程并发执行,大大降低了GC的停顿时长。 写屏障 并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用写屏障(Write Barrier)技术来保证并发标记的正确性。 写屏障是一种在对象引用被修改时,将其新的引用信息记录在特殊数据结构中的机制。在三色标记法中,写屏障技术被用于记录对象的标记状态,并且只对未被标记过的对象进行标记。 当应用程序线程修改了一个对象的引用时,写屏障会记录该对象的新标记状态。如果该对象未被标记过,那么它会被标记为灰色,以便在垃圾回收器的下一次遍历中进行标记。如果该对象已经被标记为可达对象,那么写屏障不会对该对象进行任何操作。 通过使用写屏障技术,三色标记法能够确保垃圾回收器能够准确地标记所有可达对象,并且避免了多次标记同一对象的情况。同时,写屏障技术也会带来一定的性能开销,因为每次引用被修改时都需要记录新的标记状态。为了减少性能开销,垃圾回收器通常会使用基于插入式写屏障的优化技术,来降低写屏障的开销。 多标的问题 所谓多标,其实就是这个对象原本应该被回收掉的白色对象,但是被错误的标记成了黑色的存活对象。从而导致这个对象没有被GC回收掉。 这个一般发生在并发标记过程中,该对象还是有引用的,但是在过程中,应用程序执行过程中把他的引用关系删除了,导致他变成了一个垃圾对象。 多标的话,会产生浮动垃圾,这个问题一般都不太需要解决,因为这种垃圾一般都不会太多,另外在下一次GC的时候也都能被回收掉。 漏标的问题 所谓漏标,和多标刚好相反,就是说一个对象本来应该是黑色存活对象,但是没有被正确的标记上,导致被错误的垃圾回收掉了。 这种情况一旦发生是很危险的,一个正常使用的对象被垃圾回收掉了,这对系统来说是灾难性的问题,那么如何解决呢? 具体的解决方式,在CSM和G1中也不太一样。CMS采用的是增量更新方案,G1则采用的是原始快照的方案。 漏标的问题想要发生,需要同时满足两个充要条件: 1、至少有一个黑色对象在自己被标记之后指向了这个白色对象 2、所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用 那么,增量更新方案就是破坏了第一个条件,而原始快照方案就是破坏了第二个条件。 增量更新 “至少有一个黑色对象在自己被标记之后指向了这个白色对象”,这个条件如果被破坏了,那么就不会出现漏标的问题。所以: 如果有黑色对象在自己标记后,又重新指向了白色对象。那么我就把这个黑色对象的引用记录下来,在后续「重新标记」阶段再以这个黑色对象为根,对其引用进行重新扫描。通过这种方式,被黑色对象引用的白色对象就会变成灰色,从而变为存活状态。 这种方式有个缺点,就是会重新扫描新增的这部分黑色对象,会浪费多一些时间。但是其实这个浪费还好,因为本来这种漏标的情况就并不是特别常见,所以这部分需要重新扫描的黑色对象也并不多。 原始快照 “所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用”,这个条件如果被破坏了,那么就不会出现漏标的问题。所以: 如果灰色对象在扫描完成前删除了对白色对象的引用,那么我们就在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。 在后续「重新标记」阶段再以这些白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。 但是这种放回寺可能会把本来真的要取消引用的对象给错误的复活了,从而产生浮动垃圾。但是就像前面说的,多标的问题是可以忽略的。 总结 为了解决传统的引用计数法和可达性分析算法存在的循环引用问题、误标记问题以及STW时间长的问题,才CMS和G1中引入了三色标记算法,其实就是在标记过程中将对象的状态划分为白色、灰色、和黑色三种状态。 同时把一次标记拆分成初始标记、并发标记以及重新标记三个阶段。并且只有初始标记和重新标记是STW的,而耗时最长的重新标记不需要STW,可以和应用线程并行。 但是因为并发标记过程中是不需要STW的,这就需要采用写屏障的方式避免并发。但是还是会存在漏标的多标的问题。 多标的问题我们一般可以忽略,因为下次GC还是可以回收掉的。但是漏标的问题还是要解决的,避免对象使用过程中被回收了,为了解决这个问题,CMS和G1分别采用了增量更新和原始快照两种方案来实现的,都能帮助我们解决漏标的问题。
技术
# 虚拟机
酷游
1月22日
0
25
0
2025-01-22
万万没想到,JVM内存结构的面试题可以问的这么难?
在我的博客中,之前有很多文章介绍过JVM内存结构,相信很多看多我文章的朋友对这部分知识都有一定的了解了。 那么,请大家尝试着回答一下以下问题: 1、JVM管理的内存结构是怎样的?2、不同的虚拟机在实现运行时内存的时候有什么区别?3、运行时数据区中哪些区域是线程共享的?哪些是独享的?4、除了JVM运行时内存以外,还有什么区域可以用吗?5、堆和栈的区别是什么?6、Java中的数组是存储在堆上还是栈上的?7、Java中的对象创建有多少种方式?8、Java中对象创建的过程是怎么样的?9、Java中的对象一定在堆上分配内存吗?10、如何获取堆和栈的dump文件? 以上10道题,如果您可以全部准确无误的回答的话,那说明你真的很了解JVM的内存结构以及内存分配相关的知识了,如果有哪些知识点是不了解的,那么本文正好可以帮你答疑解惑。 JVM管理的内存结构是怎样的? Java虚拟机在执行Java程序的过程中会把他所管理的内存划分为若干个不同的数据区域。《Java虚拟机规范》中规定了JVM所管理的内存需要包括一下几个运行时区域:  主要包含了PC寄存器(程序计数器)、Java虚拟机栈、本地方法栈、Java堆、方法区以及运行时常量池。 各个区域有各自不同的作用,关于各个区域的作用就不在本文中相信介绍了。 但是,需要注意的是,上面的区域划分只是逻辑区域,对于有些区域的限制是比较松的,所以不同的虚拟机厂商在实现上,甚至是同一款虚拟机的不同版本也是不尽相同的。 不同的虚拟机在实现运行时内存的时候有什么区别? 前面提到过《Java虚拟机规范》定义的JVM运行时所需的内存区域,不同的虚拟机实现上有所不同,而在这么多区域中,规范对于方法区的管理是最宽松的,规范中关于这部分的描述如下: 方法区在虚拟机启动的时候创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集与压缩。本版本的规范也不限定实现方法区的内存位置和代码编译的管理策略。方法区的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多的空间时自行收缩。方法区在实际内存空间站可以是不连续的。 这一规定,可以说是给了虚拟机厂商很大的自由。 虚拟机规范对方法区实现的位置并没有明确要求,在最著名的HotSopt虚拟机实现中(在Java 8 之前),方法区仅是逻辑上的独立区域,在物理上并没有独立于堆而存在,而是位于永久代中。所以,这时候方法区也是可以被垃圾回收的。 实践证明,JVM中存在着大量的声明短暂的对象,还有一些生命周期比较长的对象。为了对他们采用不同的收集策略,采用了分代收集算法,所以HotSpot虚拟机把的根据对象的年龄不同,把堆分位新生代、老年代和永久代。 在Java 8中 ,HotSpot虚拟机移除了永久代,使用本地内存来存储类元数据信息并称之为:元空间(Metaspace)  运行时数据区中哪些区域是线程共享的?哪些是独享的? 在JVM运行时内存区域中,PC寄存器、虚拟机栈和本地方法栈是线程独享的。 而Java堆、方法区是线程共享的。但是值得注意的是,Java堆其实还未每一个线程单独分配了一块TLAB空间,这部分空间在分配时是线程独享的,在使用时是线程共享的。 除了JVM运行时内存以外,还有什么区域可以用吗? 除了我们前面介绍的虚拟机运行时数据区以外,还有一部分内存也被频繁使用,他不是运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,他就是——直接内存。 直接内存的分配不受Java堆大小的限制,但是他还是会收到服务器总内存的影响。 在JDK 1.4中引入的NIO中,引入了一种基于Channel和Buffer的I/O方式,他可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的应用进行操作。  堆和栈的区别是什么? 堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的,二者之间最大的区别就是存储的内容不同: 堆中主要存放对象实例。栈(局部变量表)中主要存放各种基本数据类型、对象的引用。 Java中的数组是存储在堆上还是栈上的? 在Java中,数组同样是一个对象,所以对象在内存中如何存放同样适用于数组; 所以,数组的实例是保存在堆中,而数组的引用是保存在栈上的。  Java中的对象创建有多少种方式? Java中有很多方式可以创建一个对象,最简单的方式就是使用new关键字。 User user = new User(); 除此以外,还可以使用反射机制创建对象: User user = User.class.newInstance(); 或者使用Constructor类的newInstance: Constructor constructor = User.class.getConstructor(); User user = constructor.newInstance(); 除此之外还可以使用clone方法和反序列化的方式,这两种方式不常用并且代码比较复杂,就不在这里展示了,感兴趣的可以自行了解下。 Java中对象创建的过程是怎么样的? 对于一个普通的Java对象的创建,大致过程如下: 1、虚拟机遇到new指令,到常量池定位到这个类的符号引用。2、检查符号引用代表的类是否被加载、解析、初始化过。3、虚拟机为对象分配内存。4、虚拟机将分配到的内存空间都初始化为零值。5、虚拟机对对象进行必要的设置。6、执行方法,成员变量进行初始化。 Java中的对象一定在堆上分配内存吗? 前面我们说过,Java堆中主要保存了对象实例,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。 其实,在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。 如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。  10、如何获取堆和栈的dump文件? Java Dump,Java虚拟机的运行时快照。将Java虚拟机运行时的状态和信息保存到文件。 可以使用在服务器上使用jmap命令来获取堆dump,使用jstack命令来获取线程的调用栈dump。
技术
# 虚拟机
酷游
1月22日
0
19
0
2025-01-22
牛逼了,教你用九种语言在JVM上输出HelloWorld
我们在《深入分析Java的编译原理》中提到过,为了让Java语言具有良好的跨平台能力,Java独具匠心的提供了一种可以在所有平台上都能使用的一种中间代码——字节码(ByteCode)。 有了字节码,无论是哪种平台(如Windows、Linux等),只要安装了虚拟机,都可以直接运行字节码。 同样,有了字节码,也解除了Java虚拟机和Java语言之间的耦合。这话可能很多人不理解,Java虚拟机不就是运行Java语言的么?这种解耦指的是什么? 其实,目前Java虚拟机已经可以支持很多除Java语言以外的语言了,如Kotlin、Groovy、JRuby、Jython、Scala等。之所以可以支持,就是因为这些语言也可以被编译成字节码。而虚拟机并不关心字节码是有哪种语言编译而来的。 经常使用IDE的开发者可能会发现,当我们在Intelij IDEA中,鼠标右键想要创建Java类的时候,IDE还会提示创建其他类型的文件,这就是IDE默认支持的一些可以运行在JVM上面的语言,没有提示的,可以通过插件来支持。 目前,可以直接在JVM上运行的语言有很多,今天介绍其中比较重要的九种。每种语言通过一段『HelloWorld』代码进行演示,看看不同语言的语法有何不同。 Kotlin Kotlin是一种在Java虚拟机上运行的静态类型编程语言,它也可以被编译成为JavaScript源代码。Kotlin的设计初衷就是用来生产高性能要求的程序的,所以运行起来和Java也是不相上下。Kotlin可以从 JetBrains InteilliJ Idea IDE这个开发工具以插件形式使用。 Hello World In Kotlin fun main(args: Array) { println("Hello, world!") } Groovy Apache的Groovy是Java平台上设计的面向对象编程语言。它的语法风格与Java很像,Java程序员能够很快的熟练使用 Groovy,实际上,Groovy编译器是可以接受完全纯粹的Java语法格式的。 使用Groovy的一个重要特点就是使用类型推断,即能够让编译器能够在程序员没有明确说明的时候推断出变量的类型。Groovy可以使用其他Java语言编写的库。Groovy的语法与Java非常相似,大多数Java代码也匹配Groovy的语法规则,尽管可能语义不同。 Hello World In Groovy static void main(String[] args) { println('Hello, world!'); } Scala Scala是一门多范式的编程语言,设计初衷是要集成面向对象编程和函数式编程的各种特性。 Scala经常被我们描述为多模式的编程语言,因为它混合了来自很多编程语言的元素的特征。但无论如何它本质上还是一个纯粹的面向对象语言。它相比传统编 程语言最大的优势就是提供了很好并行编程基础框架措施了。Scala代码能很好的被优化成字节码,运行起来和原生Java一样快。 Hello World In Scala object HelloWorld { def main(args: Array[String]) { System.out.println("Hello, world!"); } } Jruby JRuby是用来桥接Java与Ruby的,它是使用比Groovy更加简短的语法来编写代码,能够让每行代码执行更多的任务。就和Ruby一样,JRuby不仅仅只提供了高级的语法格式。它同样提供了纯粹的面向对象的实现,闭包等等,而且JRuby跟Ruby自身相比多了很多基于Java类库 可以调用,虽然Ruby也有很多类库,但是在数量以及广泛性上是无法跟Java标准类库相比的。 Hello World In Jruby "puts 'Hello, world!'" Jython Jython,是一个用Java语言写的Python解释器。Jython能够用Python语言来高效生成动态编译的Java字节码。 Hello World In Jython print "Hello, world!" Fantom Fantom是一种通用的面向对象编程语言,由Brian和Andy Frank创建,运行在Java Runtime Environment,JavaScript和.NET Common Language Runtime上。其主要设计目标是提供标准库API,以抽象出代码是否最终将在JRE或CLR上运行的问题。 Fantom是与Groovy以及JRuby差不多的一样面向对 象的编程语言,但是悲剧的是Fantom无法使用Java类库,而是使用它自己扩展的类库。 Hello World In Fantom class Hello { static Void main() { echo("Hello, world!") } } Clojure Clojure是Lisp编程语言在Java平台上的现代、函数式及动态方言。 与其他Lisp一样,Clojure视代码为数据且拥有一套Lisp宏系统。 虽然Clojure也能被直接编译成Java字节码,但是无法使用动态语言特性以及直 接调用Java类库。与其他的JVM脚本语言不一样,Clojure并不算是面向对象的。 Hello World In Clojure (defn -main [& args] (println "Hello, World!")) Rhino Rhino是一个完全以Java编写的JavaScript引擎,目前由Mozilla基金会所管理。 Rhino的特点是为JavaScript加了个壳,然后嵌入到Java中,这样能够让Java程序员直接使用。其中Rhino的JavaAdapters能够让JavaScript通过调用Java的类来实现特定的功能。 Hello World In Rhino print('Hello, world!') Ceylon Ceylon是一种面向对象,强烈静态类型的编程语言,强调不变性,由Red Hat创建。 Ceylon程序在Java虚拟机上运行,可以编译为JavaScript。 语言设计侧重于源代码可读性,可预测性,可扩展性,模块性和元编程性。 Hello World In Ceylon shared void run() { print("Hello, world!"); } 总结 好啦,以上就是目前主流的可以在JVM上面运行的9种语言。加上Java正好10种。如果你是一个Java开发,那么有必要掌握以上9中的一种,这样可以在一些有特殊需求的场景中有更多的选择。推荐在Groovy、Scala、Kotlin中选一个。
技术
# 虚拟机
酷游
1月22日
0
19
0
2025-01-22
我竟然被"双亲委派"给虐了!
最近一段时间,我在面试的过程中,很喜欢问双亲委派的一些问题,因为我发现这个问题真的可以帮助我全方位的了解一个候选人。 记得前几天一次面试过程中,我和一位候选人聊到了JVM的类加载机制的问题,他谈到了双亲委派,并且很自信的给我讲了一下他对于双亲委派的理解。 因为难得碰到一个对着块知识了解的比较多的候选人,于是我们展开了”300回合”的交锋,当问完这些问题的之后,大概半个小时已经过去了。 最后,这个后续人和我说:”我万万没想到,我一个工作7年的技术经理,竟然被双亲委派给虐了!!!“ 先来回顾下我都问了他哪些问题,看看你能回答上来多少个: 1、什么是双亲委派? 2、为什么需要双亲委派,不委派有什么问题? 3、”父加载器”和”子加载器”之间的关系是继承的吗? 4、双亲委派是怎么实现的? 5、我能不能主动破坏这种双亲委派机制?怎么破坏? 6、为什么重写loadClass方法可以破坏双亲委派,这个方法和findClass()、defineClass()区别是什么? 7、说一说你知道的双亲委派被破坏的例子吧 8、为什么JNDI、JDBC等需要破坏双亲委派? 9、为什么TOMCAT要破坏双亲委派? 10、谈谈你对模块化技术的理解吧! 以上,10个问题,从头开始答,你大概可以坚持到第几题? 什么是双亲委派机制 首先,我们知道,虚拟机在加载类的过程中需要使用类加载器进行加载,而在Java中,类加载器有很多,那么当JVM想要加载一个.class文件的时候,到底应该由哪个类加载器加载呢? 这就不得不提到”双亲委派机制”。 首先,我们需要知道的是,Java语言系统中支持以下4种类加载器: Bootstrap ClassLoader 启动类加载器 Extention ClassLoader 标准扩展类加载器 Application ClassLoader 应用类加载器 User ClassLoader 用户自定义类加载器 这四种类加载器之间,是存在着一种层次关系的,如下图  一般认为上一层加载器是下一层加载器的父加载器,那么,除了BootstrapClassLoader之外,所有的加载器都是有父加载器的。 那么,所谓的双亲委派机制,指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。 那么,什么情况下父加载器会无法加载某一个类呢? 其实,Java中提供的这四种类型的加载器,是有各自的职责的: Bootstrap ClassLoader ,主要负责加载Java核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。 Extention ClassLoader,主要负责加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。 Application ClassLoader ,主要负责加载当前应用的classpath下的所有类 User ClassLoader , 用户自定义的类加载器,可加载指定路径的class文件 那么也就是说,一个用户自定义的类,如com.hollis.ClassHollis 是无论如何也不会被Bootstrap和Extention加载器加载的。 为什么需要双亲委派? 如上面我们提到的,因为类加载器之间有严格的层次关系,那么也就使得Java类也随之具备了层次关系。 或者说这种层次关系是优先级。 比如一个定义在java.lang包下的类,因为它被存放在rt.jar之中,所以在被加载过程汇总,会被一直委托到Bootstrap ClassLoader,最终由Bootstrap ClassLoader所加载。 而一个用户自定义的com.hollis.ClassHollis类,他也会被一直委托到Bootstrap ClassLoader,但是因为Bootstrap ClassLoader不负责加载该类,那么会在由Extention ClassLoader尝试加载,而Extention ClassLoader也不负责这个类的加载,最终才会被Application ClassLoader加载。 这种机制有几个好处。 首先,通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。 另外,通过双亲委派的方式,还保证了安全性。因为Bootstrap ClassLoader在加载的时候,只会加载JAVA_HOME中的jar包里面的类,如java.lang.Integer,那么这个类是不会被随意替换的,除非有人跑到你的机器上, 破坏你的JDK。 那么,就可以避免有人自定义一个有破坏功能的java.lang.Integer被加载。这样可以有效的防止核心Java API被篡改。 “父子加载器”之间的关系是继承吗? 很多人看到父加载器、子加载器这样的名字,就会认为Java中的类加载器之间存在着继承关系。 甚至网上很多文章也会有类似的错误观点。 这里需要明确一下,双亲委派模型中,类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码的。 如下为ClassLoader中父加载器的定义: public abstract class ClassLoader { // The parent class loader for delegation private final ClassLoader parent; } 双亲委派是怎么实现的? 双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现并不复杂。 实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中: protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. 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; } } 代码不难理解,主要就是以下几个步骤: 1、先检查类是否已经被加载过 2、若没有加载则调用父加载器的loadClass()方法进行加载 3、若父加载器为空则默认使用启动类加载器作为父加载器。 4、如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。 如何主动破坏双亲委派机制? 知道了双亲委派模型的实现,那么想要破坏双亲委派机制就很简单了。 因为他的双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。 loadClass()、findClass()、defineClass()区别 ClassLoader中和类加载有关的方法有很多,前面提到了loadClass,除此之外,还有findClass和defineClass等,那么这几个方法有什么区别呢? loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。 findClass() 根据名称或位置加载.class字节码 definclass() 把字节码转化为Class 这里面需要展开讲一下loadClass和findClass,我们前面说过,当我们想要自定义一个类加载器的时候,并且像破坏双亲委派原则时,我们会重写loadClass方法。 那么,如果我们想定义一个类加载器,但是不想破坏双亲委派模型的时候呢? 这时候,就可以继承ClassLoader,并且重写findClass方法。findClass()方法是JDK1.2之后的ClassLoader新添加的一个方法。 /** * @since 1.2 */ protected Class findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } 这个方法只抛出了一个异常,没有默认实现。 JDK1.2之后已不再提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中。 因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载。 所以,如果你想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass中实现你自己的加载逻辑即可。 双亲委派被破坏的例子 双亲委派机制的破坏不是什么稀奇的事情,很多框架、容器等都会破坏这种机制来实现某些功能。 第一种被破坏的情况是在双亲委派出现之前。 由于双亲委派模型是在JDK1.2之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。 第二种,是JNDI、JDBC等需要加载SPI接口实现类的情况。 第三种是为了实现热插拔热部署工具。为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。 第四种时tomcat等web容器的出现。 第五种时OSGI、Jigsaw等模块化技术的应用。 为什么JNDI,JDBC等需要破坏双亲委派? 我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。 但是,调用方式除了API之外,还有一种SPI的方式。 如典型的JDBC服务,我们通常通过以下方式创建数据库连接: Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "1234"); 在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。 类加载时,会执行该类的静态方法。其中有一段关键的代码是: ServiceLoader loadedDrivers = ServiceLoader.load(Driver.class); 这段代码,会尝试加载classpath下面的所有实现了Driver接口的实现类。 那么,问题就来了。 DriverManager是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。 那么,怎么解决这个问题呢? 于是,就在JDBC中通过引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)的方式破坏了双亲委派原则。 我们深入到ServiceLoader.load方法就可以看到: public static ServiceLoader load(Class service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } 第一行,获取当前线程的线程上下⽂类加载器 AppClassLoader,⽤于加载 classpath 中的具体实现类。 为什么Tomcat要破坏双亲委派 我们知道,Tomcat是web容器,那么一个web容器可能需要部署多个应用程序。 不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。 如多个应用都要依赖hollis.jar,但是A应用需要依赖1.0.0版本,但是B应用需要依赖1.0.1版本。这两个版本中都有一个类是com.hollis.Test.class。 如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。 所以,Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器。 Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。 模块化技术与类加载机制 近几年模块化技术已经很成熟了,在JDK 9中已经应用了模块化的技术。 其实早在JDK 9之前,OSGI这种框架已经是模块化的了,而OSGI之所以能够实现模块热插拔和模块内部可见性的精准控制都归结于其特殊的类加载机制,加载器之间的关系不再是双亲委派模型的树状结构,而是发展成复杂的网状结构。  在JDK中,双亲委派也不是绝对的了。 在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。 这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿。 在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。 Class c = findLoadedClass(cn); if (c == null) { // 找到当前类属于哪个模块 LoadedModule loadedModule = findLoadedModule(cn); if (loadedModule != null) { //获取当前模块的类加载器 BuiltinClassLoader loader = loadedModule.loader(); //进行类加载 c = findClassInModuleOrNull(loadedModule, cn); } else { // 找不到模块信息才会进行双亲委派 if (parent != null) { c = parent.loadClassOrNull(cn); } } } 总结 以上,从什么是双亲委派,到如何实现与破坏双亲委派,又从破坏双亲委派的示例等多个方面全面介绍了关于双亲委派的知识。 相信通过学习本文,你一定对双亲委派机制有了更加深刻的了解。 阅读过本文之后,反手在简历上写下:熟悉Java的类加载机制,不服来问!
技术
# 虚拟机
酷游
1月22日
0
14
0
2025-01-22
到底谁才是垃圾?
作为 Java 程序员,我们是幸福的,因为我们不需要管理系统中的垃圾。我们只需要将重点放在业务中就可以了。至于垃圾什么的就交给天生的垃圾收集器就可以了。 那既然都这么说了,我们干嘛还要花心思来学习这些呢?我们学习这些肯定是为了更好的理解我们系统的底层运行原理啊,这样才能有针对性的写出 “更适合” JVM的代码。才能让我们的代码更健壮和安全,更关键的是能根据 JVM 的特性有针对性的进行调节和优化,最终写出执行效率高的代码。 到底谁是垃圾? 什么样的对象可以称为垃圾对象?换句话说:在垃圾收集器工作的时候,那些对象是可以被回收的,哪些对象是不可以被回收的?判断的标准是什么?系统中的对象千千万,怎么才能准确无误的找出来并“杀”掉就显得尤为重要。 为了解决上面的问题。JVM 专门设计一套判断对象是的是垃圾的算法——可达性分析。 可达性分析的原理是:根据每一个对象,一层一层的引用往上找,说白了就是看看那些地方在引用着这个对象。直到找到能被称之为GC Roots的对象在引用这个这个对象,那么这个时候 JVM 就认为这个对象是不是垃圾对象。 也就是在垃圾回收的时候是不会去回收这部分对象的。反之,这样的对象就可以被称为垃圾对象。也就意味着是会被在垃圾收集器工作的时候就会回收这部分对象。 GC Roots 说到这里,哪些是垃圾对象我们是可以判断了。那么刚刚提到的 GC Roots 又是什么鬼?简单的来讲,静态变量、局部变量、常量、本地方法栈中的对象都可以当做GC Roots。但是一般最常见的就是:静态变量、局部变量。 我们姑且先这个记住,也就是凡是被这些对象引用的对象,就是不能被回收的。换言之,系统是在某些地方还在使用这些对象,这些对象我们也称之为强引用。对应的还有软引用,弱引用和虚引用。 强引用(使用频率:) 我们平时开发时候通过 new 关键创建出来的对象就是强引用,这类对象在垃圾回收的时候只要是能找到 G CRoots,那么他们是不会被回收的。 软引用(使用频率:) 所谓软引用,就是表示该对象在垃圾回收期间,不软是否被其他对象引用,只要是内存空间不够了,那么该对象就会别垃圾收集器回收。(PS:这个也是大家很容易和弱引用搞混淆的一个术语。我相信你平时开发常用的一定是 SoftReference ,而很少使用 WeakReference 。也就是说,强引用下面的一个就是软引用。希望能帮助大家理解这两个之间的区别。) 弱引用(使用频率:) 这类引用存在的价值更容易被忽视,只要是在垃圾回收阶段,不管内存是否足够,该类型的对象都会被垃圾收集器回收。 虚引用(使用频率:程序员基本不会使用到) “虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用 JVM 内存结构 到此为止,我们已经知道了哪些对象是垃圾已经如何判断垃圾对象了。接下来就是要回收了。但是在学习回收之前,我们还需要知道JVM内存区域的划分。换句话说就是回收对象是在哪里进行的?我们先来看下 JVM 的内存结构(这种模型仅仅是人们为了更好的学习和理解 JVM 还虚拟出来的) 以上结构看起来并不复杂,主要由五大部分组成:方法区、堆内存、虚拟机栈(栈)、本地方法栈(一般不关注)、程序计数器。其中方法区和堆内存是线程共享的。其他三个是线程私有的。他们的主要作用如下: 方法区 被所有线程共享的区间,用来保存类信息、常量、静态变量、也就是被虚拟机编译后的代码。换句话说:静态变量、常量、类信息(版本信息、方法签名、属性等)和运行时常量池存在方法区中。其中常量池是方法区的一部分。 堆内存(垃圾回收的重中之重) 是 Java 虚拟机所管理的所有的内存区域中最大的一块内存区域。堆内存被所有线程的共享。 主要存放使用 new 关键字创建的对象。所有对象实例以及数组都要在堆上分配。 垃圾收集器就是根据GC算法,收集堆上对象所占用的内存空间(这也是垃圾回收的重点核心区域)。 虚拟机栈 虚拟栈,是由一个一个的栈帧(栈帧:理解为方法的标记即可),他是线程私有的,生命周期同线程一样。 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 局部变量表存放了编译期可知的各种基本数据类型(8个基本数据类型)、对象引用(地址指针)、returnAddress类型。局部变量表所需的内存空间在编译期间完成分配。在运行期间不会改变局部变量表的大小。 这个区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常。 程序计数器 程序计数器是线程私有的,里面记录的就是即将要执行的一条的CPU指令,因为在多线程环境中,必然会存在线程之之间的切换,这样JVM就需要有一套方案来记录某个线程在之前执行到了哪里。这就是程序计数器的作用 本地方法栈 记录的就是本地的通过C/C++ 写的一些程序(PS:这个空间中没有规定 OOM,也就是不会发生OOM的情况。因为程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存) 堆内存的详细结构 上面是说到了,堆内存是垃圾回收的重中之重,但是这并不意味这对象就是很笼统的在堆内存中的,他们也会被安排和分配到堆的不同的区域中。而堆内存主要是这么划分的,堆首先被划分成两大部分:年轻代(新生代)和老年代。年轻代又划分为:Eden、From Survivor、To Survivor。 其中年轻代和老年代所占的内存口空间比例为:1:2。年轻代中的Eden、From Survivor、To Survivor 占比为:8:1:1。画个图来帮助大家更形象的理解下: 大家不要急,一步一步来。 垃圾回收算法 从本小节开始,就是本文的重点了。JVM 在垃圾回收的时候: ① 到底使用了哪些垃圾回收算法? ② 分别在什么场景下使用? ③ 各自的优缺点? 下面就来正式的介绍下垃圾回收算法 标记-清除 标记清除是最简单和干脆的一种垃圾回收算法,他的执行流程是这样子的:当 JVM 标记出内存中的垃圾以后,直接将其清除,但是这样有一个很明显的缺点,就是会导致内存空间的不连续,也就是会产生很多的内存碎片。先画个图来看下 我们使用上图左边的图来表示垃圾回收之前的样子,黑色的区域表示可以被回收的垃圾对象。这些对象在内存空间中不是连续的。右侧这张图表示是垃圾回收过后的内存的样子。可以很明显的看到里面缠身了断断续续的 内存碎片。 那说半天垃圾不是已经被回收了吗?内存碎片就内存碎片呗。又能咋地? 好,我来这么告诉你,现在假设这些内存碎片所占用的口空间之和是1 M,现在新创建了一个对象大小就是 1 M,但是很遗憾的是,此时内存空间虽然加起来有 1 M,但是并不是连续的,所以也就无法存放这大对象。也就是说这样势必会造成内存空间的浪费,这就是内存碎片的危害。 这么一说标记-清除就没有优点了吗?优点还是有的:速度快 到此,我们来对标记-清除来做一个简单的优缺点小结: 优点 速度快,因为不需要移动和复制对象 缺点 会产生内存碎片,造成内存的浪费 标记-复制 上面的清除算法真的太差劲了。都不管后来人能不能存放的下,就直接啥也不管的去清除对象。所以升级后就来了复制算法。 复制算法的工作原理是这样子的:首先将内存划分成两个区域。新创建的对象都放在其中一块内存上面,当快满的时候,就将标记出来的存活的对象复制到另一块内存区域中(注意:这些对象在在复制的时候其内存空间上是严格排序且连续的),这样就腾出来一那一半就又变成了空闲空间了。依次循环运行。 在回收前将存活的对象复制到另一边去。然后再回收垃圾对象,回收完就类似下面的样子: 如果再来新对象被创建就会放在右边那块内存中,当内存满了,继续将存活对象复制到左边,然后清除掉垃圾对象。 标记-复制算法的明显的缺点就是:浪费了一半的内存,但是优点是不会产生内存碎片。所以我们再做技术的时候经常会走向一个矛盾点地方,那就是:一个新的技术的引入,必然会带来新的问题。 到这里我们来简单小结下标记-复制算法的优缺点: 优点 内存空间是连续的,不会产生内存碎片 缺点 1、浪费了一半的内存空间 2、复制对象会造成性能和时间上的消耗 说到底,似乎这两种垃圾回收回收算法都不是很好。而且在解决了原有的问题之后,所带来的新的问题也是无法接受的。所以又有了下面的垃圾回收算法。 标记-整理 标记-整理算法是结合了上面两者的特点进行演化而来的。具体的原理和执行流程是这样子的:我们将其分为三个阶段: 第一阶段为标记; 第二阶段为整理; 标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。 我们是画图说话,下面这张图是垃圾回收前的样子。 下图图表示的第一阶段:标记出存活对象和垃圾对象;并清除垃圾对象 白色空间表示被清理后的垃圾。 下面就开始进行整理: 可以看到,现在即没有内存碎片,也没有浪费内存空间。 但是这就完美了吗?他在标记和整理的时候会消耗大量的时间(微观上)。但是在大厂那种高并发的场景下,这似乎有点差强人意。 到此,我们将标记-整理的优缺点整理如下: 优点 1、不会产生内存碎片 2、不会浪费内存空间 缺点 太耗时间(性能低) 到此为止,我们已经了知道了标记-清除、标记-复制、标记-整理三大垃圾回收算法的优缺点。 单纯的从时间长短上面来看:标记-清除 < 标记-复制 < 标记-整理。 单纯从结果来看:标记-整理 > 标记-复制 >= 标记-清除 知道了垃圾回收算法,还有以下这些问题等着我们去分析: ① 垃圾收集器都有哪些呢? ② 年轻代和老年代又分别是哪些垃圾收集算法? ③ 不同的垃圾收集器对应哪些垃圾回收算法? ④ 年轻代和老年代分别使用哪些垃圾收集器? 带着这些问题,让我们继续往下看。 什么样的垃圾会进入到老年代 我们现在已经知道了什么是垃圾,那现在问题是:什么样的垃圾会进入到老年代?对象进入老年代的条件有三个,满足一个就会进入到老年代: 1、躲过15次GC。每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(jdk8默认的),也就是某个对象躲过了15次垃圾回收,那么JVM就认为这个是经常被使用的对象,就没必要再带着年轻代中了。具体的次数可以通过 -XX:MaxTenuringThreshold 来设置在躲过多少次垃圾收集后进去老年代。 2、动态对象年龄判断。规则:在某个 Survivor 中,如果有一批对象的大小总是大于该 Survivor 的 50%,那么此时大于等于该批对象年龄的对象机会会直接到老年代中。 3、大对象直接进入老年代。-XX:PretenureSizeThreshold 来设置大对象的临界值,大于该值的就被认为是大对象,就会直接进入老年代。 针对上面的三点来逐一分析。 躲过15次 GC 这个没啥好说的,最好理解,就是在执行了15次GC后,对象依旧存活,那么就将其移动到老年代中去,没执行一次垃圾回收,存活的对象的年龄就+1,具体的执行次数可以通过:-XX:PretenureSizeThreshold参数来设置。 动态对象年龄判断 这就有点难理解了,不过一定会给你讲清楚的 再来看下这个规则:在某个 Survivor 中,如果有一批对象的大小总是大于该 Survivor 的 50%,那么此时大于等于该批对象年龄的对象机会会直接到老年代中。 o(╥﹏╥)o 还是没理解。。。我们画图来理解试试 假设现在 To 里面的如图两个对象大小总和50 M,且都是3岁了,因为 To 是100 M,所以这个时候我们就说在某个 Survivor 中,如果有一批对象的大小总是大于该Survivor 的 50%。这个时候大于等于该批对象年龄的对象机会会直接到老年代中。 再还换句话说就是:当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivor 修可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了。 例如Survivor区域里现在有一比对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了的多个年龄对象总和超过了区域的50%,此时就会巴年龄n(含)以上的对象都放入老年代)。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 Minor GC 之后触发的。 大对象直接进入老年代 这个就简单了,-XX:PretenureSizeThreshold 来设置大对象的临界值。如 -XX:PretenureSizeThreshold=1024 * 1024。即对象超过1M直接进入老年代。其实大对象直接进入到老年代还包含这种情况:那就是当 Eden 中执行了 Minor GC 后,存活的对象的大小是 超过了100M了(上图 from 和 to 都是100M)此时这些存活的对象也是直接进入到老年代。 说了半天对象都跑到老年代去了,那既然老年代这个牛逼,干嘛还分年轻代和老年代?年轻人,你不要急。后文我会全部道来。我们下面先来看看老年代空间如果不够用怎么办? 老年代空间分配担保 上面说到了,对象在哪些情况下会进入到老年代,年轻代倒是省心了,你不够了就放到老年代,那如果老年代也不够了呢?那又是如何处理呢? 实际上是这样的。在年轻代执行 Minor GC 之前,首先会检查老年代的可用空间的大小是否是大于新生代所有对象的大小。为什么是所有对象,不应该是存活的对象吗? 你想啊,假如年轻代经过一次 Minor GC 后所有的对象都是存活的,这是不是就尴尬了(PS:所以这还是我们需要考虑的“临界情况”。不要觉得一般情况或者是泛泛的说法,程序的严谨性就是在临界情况下体现出来的) 现在假设在 Minor GC之前,检查发现老年代空间还真不够了,那么首先会去检查-XX:HandlerPromotionFailure的参数是否设置了,这个参数表示:是否设置空间分配担保。 * 是:就会判断老年代的剩余的空间的大小是否是大于之前的每一次 MinorGC 后进入老年代的对象的平均的大小 * 否:那么此时就会进行FULL GC来为老年代腾出一些空间 假设现在开启了空间分配担保,并且发现之前的每次 Minor GC 后的对象的平均大小(假设是10 M)是小于老年代可用空间的大小(假设现在是12 M)的,那么就会认为本次 Minor GC 后差不多也是10 M的对象进入到老年代。但是如果最终垃圾回收剩余存活对象大于13 M,那么就直接 OOM; 如果没有开启空间分配担保机制,那么就会触发一次 Full GC(老年代的垃圾回收称之为 Full GC),这样看看能不能在老年代中在腾出一些空间,因为一般老年代中的对象是长时间存活的,所以这招可能作用不是很大。 假设Full GC 结束了,再尝试进行 Minor GC ,此时又可能有好几种情况: 第一种情况:Minor GC 后,剩余的存活对象的大小是小于 from 区大小的,那么对象直接进入 from 区即可; 第二种情况:Minor GC 后,剩余的存活对象的大小是大于 from 区大小的,但是是小于老年区可用空间大小的,那么对象直接进入老年代; 第三种情况:Minor GC 后,剩余的存活的对象的大小是大于 from 区大小的,同时也大于老年区可用的空间的大小,这个时候就会根据XX:HandlerPromotionFailure的设置来触发一次 Full GC,如果此时 Full GC后老年代的空间还是不够存放 Minor GC 后剩下的对象。那么就 OOM。 上面说了这么多我们来画个图整理和理解下,以年轻代快满了为出发点(Minor GC前): 9、年轻代的垃圾回收算法 我们先来回头看下这张图(为了方便阅读,我直接复制下来) 对象在刚创建的时候(排除直接进如到老年代的情况)。我们认为都是被分配到年轻代的 Eden 中的,当 Eden 快满的时候,就会触发一次垃圾回收,称之为:Minor GC(一般情况下 Eden 中经过一次垃圾回收后存活的对象非常少,这就好像是一次请求创建了很多的临时变量和对象,请求结束这些基本就全是垃圾了,这就是为什么 from 和 to 比例这么小的原因) 将存活的对象移动到 from 区域,此时存活的对象的年龄就 +1 ,并且将 from 和 to 的指向交换位置。首先来看下刚刚回收完垃圾将对象转移到 from 的的图 然后我们强调了一个词,将 from 和 to 的指向交换位置: 这样子其实就是下面的样子: (PS:真正的内存空间的位置并没有变化,实际变化的是from 和 to 的指向,这样下次执行 Minor GC 的时候还是将存回的对象放在 from 区域,你懂了没?) 然后 Eden 区域继续存放新对象,当 Eden 再次快满的时候,又会技术出发 Yong GC(Minor GC 的另一个名字,为了让大家了解的更全面,故意都使用下),此时垃圾回收的是 Eden 和 to 区域中的垃圾,因为上一次存活了的对象到这一次不一定就存活了。然后将他们存活的对象在移动到 from 区域。然后交换 from 和 to 的位置指向。以此循环往复。 垃圾收集器 关于垃圾收集器其实现在更多关注的是 G1垃圾收集器,但是本文不会去介绍,这个会放在单独的一篇文章去介绍的。目前常见的垃圾收集器有: ①Serial 垃圾收集器 ②Serial Old 垃圾收集器 ③ParNew 垃圾收集器 ④CMS 垃圾收集器 ⑤Parallel Scavenge 垃圾收集器 ⑥Parallel Old 垃圾收集器 ⑦G1 垃圾收集器 他们具体工作在年轻代还是老年代我们来通过一张图说明: 箭头表示年轻代是 xxx 老年代可以是 xxx,表示一种对应关系。 通过java -XX:+PrintCommandLineFlags -version命令可以查看当前 JVM 使用的垃圾收集器 新生代的垃圾收集器 Serial(Serial/Serial Copying) 最古老,最稳定,简单高效,GC时候需要暂停用户线程,限定单核CPU环境,是Client模式下的默认新生代收集器(基本不再使用)。 对应的 JVM 参数为 -XX:UseSerialGC; 开启后,会使用Serial(Young区使用)+Serial Old(Old区使用)组合收集器。 新生代、老年代都会使用串行回收收集器,新生代使用【标记-复制算法】老年代使用【标记-整理算法】 ParNew(Parallel New Generation) 新生代是并行老年代是串行。 ParNew其实就是【Serial收集器新生代】的【并行多线程】版本。 它是很多java虚拟机在运行Server模式下的新生代的默认的垃圾收集器。 最常见的场景的是配合老年代的CMS GC工作,其余的行为和Serial收集器一样 ParNew工作的时候同样需要暂停其他的所有的线程 对应的JVM参数为 -XX:UserParNewGC 启用ParNew收集器,只作用于新生代,不影响老年代 开启后,会使用ParNew(Young区使用)+Serial Old的收集器组合,新生代使用【复制算法】老年代 使用【标记-整理算法】 并行回收GC(Parallel/ParallelScavenge)(默认收集器) 新生代和老年代都是并行。 Parallel Scavenge 收集器类似ParNew也是一个新生代的垃圾收集器,使用的【复制算法】,是一个并行多线程的垃圾收集器。俗称吞吐量优先的收集器。一句话:串行收集器在新生代和老年的并行化。 可控的吞吐量(运行用户的的代码时间/(运行用户的代码时间+垃圾收集时间))。也即运行100分 钟,垃圾收集时间为1分钟,那么吞吐量就是99%。高吞吐量意味着高效的CPU利用率 自适应调节策略也是Parallel Scavenge 和 ParNew 的一个重要的区别(虚拟机会根据当前的 系统的运行情况手机性能监控信息,动态的调整这些参数以提供最合适的停顿时间(- XX:MaxGCPauseMillis)或最大的吞吐量) 如果新生区激活-XX:+UseParallelGC(或者是-XX:UseParallelOldGC他们可以互相激活)老 年区就自动使用Parallel Old,使用Parallel Scavenge收集器 – -XX:ParallelGCThreads=N 表示启动多少个线程 cpu>8 N=5/8 cpu
技术
# 虚拟机
酷游
1月22日
0
6
0
易航博客