| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> Java知识库 -> 我把面试问烂了的?JVM?总结了一下(带答案,万字总结,精心打磨,建议收藏) -> 正文阅读 |
|
[Java知识库]我把面试问烂了的?JVM?总结了一下(带答案,万字总结,精心打磨,建议收藏) |
💂 个人主页: Java程序鱼 💬 如果文章对你有帮助,欢迎关注、点赞、收藏(一键三连)和订阅专栏 👤 微信号:hzy1014211086,想加入技术交流群的小伙伴可以加我好友,群里会分享学习资料、学习方法
京东自营购买链接: 当当自营购买链接:
前言目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了"自动化"时代,那么为什么我们还要去了解GC和内存分配呢? 一、虚拟机类加载机制Class文件中描述的各种信息,最终都是要加载到虚拟机中之后才能运行和使用。 JVM把Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。(例如Java多态、动态代理) Java虚拟机中的类加载(JVM把class文件加载到内存),按先后顺序需要经过加载、链接、初始化三个步骤。其中,链接过程中同样需要验证;而内存中的类没有经过初始化,同样不能使用。 ClassLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine决定。 1.虚拟机类加载过程加载阶段什么情况下需要开始类加载过程的加载阶段?这个「 Java虚拟机规范」中没有强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,「 Java虚拟机规范」则是严格规定了有且只有六种情况必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)
Java程序对类的使用方式分为:主动使用和被动使用。 加载、验证、准备、初始化、卸载这5个阶段顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。 加载阶段,Java虚拟机需要完成三件事:
连接阶段(1)验证 主要包括四种验证:文件格式验证、元数据验证、字节码验证、符合引用验证。 (2)准备
(3)解析 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。 初始化阶段进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 (1)
答案:1,为什么可以呢?在linking阶段的准备阶段,已经把i加载到内存,并且赋初始值(零值)了。
(2) 由于父类的
(3)Java虚拟机必须保证一个类的
结果: 线程2在初始化当前类时死循环了,会造成后面所有的线程全部阻塞。 2.类加载器(ClassLoader)Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader) 目前类加载器在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩。 类加载器:把我们硬盘上编译好的.Class文件,通过类装载器将字节码文件加载到内存中,生成一个Class对象。 这里的四者是包含关系,不是上下层,也不是子父类的继承关系 ClassLoader:是一个抽象类,我们可以继承它实现自定义加载器。 启动类加载器(Bootstrap)启动类加载器(Bootstrap):主要加载jre/lib/rt.jar(Java核心API ),getClassLoader为null。(C++实现的)
并不继承自java.lang.ClassLoader,没有父加载器 扩展类加载器(Extension)扩展类加载器(Extension):通过反射创建Class实例,而这个类在jre/lib/ext的jar包中,这时加载器就是Extension ClassLoader,加载jre/lib/ext里的类。 getClassLoader:sun.misc.Launcher$ExtensionLoader@HashCode 直接继承自URLClassLoader,间接继承ClassLoader 应用程序类加载器(APP)应用程序类加载器(APP):它负责加载用户类路径(ClassPath)上所有的类库。 getClassLoader:sun.misc.Launcher$AppLoader@HashCode 直接继承自URLClassLoader,间接继承ClassLoader 对于用户自定义的类,如果没有自定义过自己的类加载器,默认使用应用程序类加载器加载 可以通过ClassLoader.getSystemClassLoader();获取应用程序类加载器 自定义类加载器自定义类加载器的父类是应用程序类加载器 sun.misc.Launcher:它是一个Java虚拟机的入口应用 获取父类加载器:classLoader.getParent() 扩展类加载器和应用程序类加载器都继承了ClassLoader. 获取ClassLoader方法: 3.用户自定义加载器为什么要自定义类加载器?
用户自定义类加载器实现步骤: 举例:
(3)调用defineClass()把二进制流字节转化为Class
4.双亲委派机制双亲委派模型的工作原理:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。 为什么根类加载器为NULL?
举例:自定义一个包java.lang,自定义一个类String,然后里面声明main方法,运行时报错。(沙箱机制)
加载String时,使用的是BootstrapClassLoader,加载的是Java核心类库的String,并非我们自定义的String,核心类库的String类,没有main方法,因此报错。 沙箱机制:是由基于双亲委派机制上采取的一种JVM的自我保护机制,假设你要写一个java.lang.String 的类,由于双亲委派机制的原理,此请求会先交给Bootstrap试图进行加载,但是Bootstrap在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,因此就保证了java的运行机制不会被破坏.(安全特性,防止恶意代码对Java的破坏) 双亲委派优势:
二、Java运行时数据区JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。 Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。 JVM运行时数据区:Java代码运行的时候每个数据区的区块存的是什么用来干什么怎么存的 需提前理解的概念: Java 虚拟机运行时数据区:Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域有各自的用途,以及创建和消耗的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和消耗。 1.Program Counter Register(程序计数器)Program Counter Register:程序计数器 作用:程序计数器用来存储指向下一条指令的地址,也就是将要执行的指令代码。由执行引擎读取下一条指令。 它是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。 比如我A()方法调用了B()方法,执行完B之后怎么恢复,这时就需要程序计数器,字节码解释器就是通过改变计数器的值来选取下一条执行的字节码指令。 如果线程执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是native方法,这个计数器的值为undefined。 2.Java虚拟机栈栈是运行时的单位,而堆是存储的单位。 即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。 作用:主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。 它描述的是Java方法执行的线程内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
假如执行A方法创建一个A栈帧,A栈帧入栈,A方法调用B方法,需要为B方法创建一个B栈帧,然后入栈,B方法执行到方法出口之后,B栈帧出栈,然后A方法执行到方法出口之后,A栈帧出栈,这就是方法执行过程。 栈帧伴随着方法从创建到执行完成。 Java虚拟机规范允许Java栈的大小是动态或者是固定不变的。
设置栈内存大小 JVM直接对Java栈的操作只有两个,就是对栈桢的压栈和出栈,遵循先进后出原则。 在一条活动线程中,一个时间点上,只会有一个活动的栈桢,即只有当前正在执行的方法的栈桢(栈顶栈桢)是有效的,这个栈桢被称为当前栈桢,与当前栈桢对应的方法就是当前方法,定义这个方法的类就是当前类 Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令,另一种是抛出异常。不管使用哪种方式,都会导致栈桢被弹出. 栈桢内部结构: 局部变量表(Local Variables)局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。 局部变量所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。 其中64位长度的long和double类型的数据会占用2个Slot,其余的数据类型只占用1个Slot,局部变量所需的内存空间在编译期间分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 局部变量表最基本的存储单元是Slot(变量槽),32位一个变量槽 JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中的指定局部变量值。(如果占两个槽,使用起始索引) 补充:0槽位放this,所以从1开始 注意:局部变量必须显式赋值 1)boolean——1byte 0为false 非0为true 操作数栈(Operand Stack)在方法执行过程中,根据字节码指令,往栈中写入数据(ipush)或提取数据(iload),即入栈/出栈。 注意:这里栈不是指栈桢,指的是操作数栈 作用:用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。 某些字节码指令值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。 动态连接(Dynamic Linking)方法返回地址(Return Address)存储调用该方法的程序计数器的值 一些附加信息在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(死循环递归);如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可以动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
方法中定义局部变量是否线程安全?
3.本地方法栈(线程私有)本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构没有强制规定,因此具体的虚拟机可以自由实现它,甚至有的虚拟机(Sun公司的HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一,与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常 4.Java堆在虚拟机启动时创建,其空间大小也就确定了,是JVM管理的最大一块内存空间(堆内存的大小可调节)。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap),从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代,再细致一点的有Eden空间(伊甸园)、From Survivor(幸存者0区)空间、To Survivor(幸存者1区)空间等,从内存分配角度来看,线程共享的Java堆中可能划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),不过无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都仍然是对象实例,进一步划分目的是为了更好地回收内存,或者更快地分配内存。 根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上连续即可,就像我们的磁盘空间一样,在实现时,既可以实现成固定大小的,也可以是可扩展的,不过目前主流的虚拟机都是按照可扩展的来实现的,通过-Xmx和-Xms控制,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常(OutOfMemoryError: Java Heap space)。 -XX:SurvivorRatio,设置新生代中Eden和S0/S1空间的比例,默认-XX:SurvivorRatio=8,Eden:S0:S1=8:1:1,假设设置成-XX:SurvivorRatio=4,Eden:S0:S1=4:1:1 -XX:NewRatio,配置年轻代与老年代堆结构的占比,默认-XX:NewRatio=2新生代占1,老年代占2,年轻代占整个堆的1/3,假如-XX:NewRatio=4新生代占1,老年代占4,年轻代占整个堆的1/5 (1)new的对象先放伊甸园区。此区有大小限制 (5)如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区(form/to) (6)啥时候能去养老区呢?可以设置次数。默认是15次。
注意:Eden 区满时,会触发 Minor GC,此时会回收 Eden 区和幸存者区,但是幸存者区满了不会触发Minor GC,那怎么办? 当Survivor空间不足以容纳一次 Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代) 进行分配担保(Handle Promotion)。 (Serial、ParNew等新生代收集器均采用这种策略来设计新生代的内存布局)(来源JVM深入理解虚拟机) 内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了,内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。这对虚拟机来说就是安全的。 如果Survivor 区中相同年龄的存活对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据 为什么有TLAB(Thread Local Allocation Buffer)?
什么是TLAB(Thread Local Allocation Buffer)?
一个JVM实例只存在一个堆内存, 堆内存的大小是可以调节的。 类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。 堆: 栈:(线程私有)
5.方法区方法区(Method Area)是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non Heap),目的是与Java堆区分开来。 例如java核心java,会加载到方法区 (1)栈、堆、方法区关系 (2)方法区基本理解:
(3)Hotspot中方法区的演进 本质上,方法区和永久代并不等价,仅是对HotSpot而言。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。 现在看来,当年使用永久代,不是好的idea,导致Java程序更容易OOM(超过-XX:MaxPermSize上限) 到了JDK8时,HotSpot终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常(OutOfMemoryError: Metaspace)
(4)设置方法区大小 默认值依赖于平台。Windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace -XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类( 即这些类对应的类加载器不再存活) ,然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX :MetaspaceSize设置为一个相对较高的值。 JDK1.6及之前:有永久代,静态变量存放在永久代上 6.运行时常量池运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的需求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过,一般来说,除了保持Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。 既然运行时常量池也是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。 7.直接内存直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。 JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制,服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常 三、对象内存布局在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding) 1.对象头对象头,HotSpot虚拟机的对象头包括两部分信息。
另外,如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。 2.数据实例数据实例:是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类定义的,都需要记录,这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中的定义顺序的影响,HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从策略分配中可以看出,相同宽度的字段总是被分配到一起,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前,如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量可能会插入到父类的空隙之中。 3.对齐填充对齐填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。 四、对象访问定位JVM是如何通过栈桢中的对象引用访问到其内部的对象实例的呢? 建立对象是为了使用对象,我们的Java程序需要通过栈的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的,目前主流的访问方式有使用句柄和直接指针两种 对象访问的两种方式: 补充:使用句柄方式需要保存到对象实例数据的指针和到对象类型数据的指针。 (2)直接指针 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本,HotSpot使用的就是直接指针访问。 五、如何判定对象为垃圾对象?1.引用计数算法给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,当引用失效时,计数器值减1,任何时刻计数器为0的对象就是不可能再被使用的。 优点: 引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法 缺点:
举例:假设p.next = A, A.next = B, B.next=C,C.next = A,此时计数A是2,B是1,C是1,然后p.next=null,此时计数A是1,B是1,C是,此时会导致A、B、C不会被回收 垃圾回收详细日志信息:-XX:+PrintGCDetails
从上面运行结果可以看出,虚拟机并没有因为两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。 2.可达性分析算法根节点枚举:所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,即便号称停顿时间可控或几乎不会发生停顿的CMS、G1等收集器,在这一步骤也会暂停用户线程 执行效率比引用计算法低一点,但是可以解决循环引用问题 这个算法的基本思路就是通过一系列的称为‘GC Roots’的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
总结:基本思路就是通过一系列名为”GC Roots"的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被 Java中,可作为GC Roots的对象包括下面几种:
六、引用无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与‘引用’有关,在JDK1.2以前,Java 中的引用的定义很传统,如果reference类型的数据中存储数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用,这种定义很纯粹,但是太过狭隘,一个对象在这种定义下就只有被引用或者没有被引用两种状态了,对于如何描述一些‘食之无味,弃之可惜’的对象就显得无能为力。我们希望能描述这样一类对象:当存储空间还足够时,则能保留在内存之中;如果存储空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景。 在JDK1.2后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。
生存还是死亡:即使在可达性分析算法中不可达的对象,也并非是’非死不可’的,这时候它们暂时处于‘缓刑’阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为‘没有必要执行’。 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立、低优先级的Finalizer线程去执行它,这里所谓的’执行’是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救自己-------只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除‘即将回收’的集合,如果对象这时候还没有逃脱,那基本上它就真的被回收了。 总结:对象可以在被GC时自我拯救,但是这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次,不建议使用这种方法拯救对象,它的运行代价高昂,不确定性大,无法保证各个对象调用顺序。
执行结果: 七、如何回收垃圾对象?1.垃圾收集算法内存回收的方法论,垃圾收集器是方法论的落地实现 JVM中比较常见的三种垃圾收集算法: 标记-清除算法标记无用对象,然后进行清除回收。 标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。 优点:实现简单,不需要对象进行移动。 缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
标记-清除算法的执行过程如图所示:
复制算法为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。 复制算法的执行过程如图所示: 内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了,内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。这对虚拟机来说就是安全的。 总结:它将堆分为新生代和老年代,新生代又分为Eden空间和两块Survivor空间,它们的比例大概是8: 1: 1。 新生代中的对象大多存活率不高,所以我们一般采用复制算法。每次使用Eden 空间和其中的一块Survivor空间,当进行回收时, 将该两块空间中还存活的对象复制到另一块 Survivor空间中,每进行一次Minor GC对象的年龄就会加1, 默认达到15就可以进入老年代 (数值可以自己用调优参数设定)。Survivor区存放不下的对象,因为每次Minor GC的时候会将Eden区和一个from区的存存活对象放入to区,所以当to区装不下的对象时就会进入老年代 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。 缺点:
标记-整理算法在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。 优点:解决了标记-清理算法存在的内存碎片问题。 缺点:仍需要进行局部对象移动,一定程度上降低了效率。 “标记- 整理”算法的执行过程如下图所示: 2.垃圾收集算法Serial收集器在JDK1.3之前,它是虚拟机新生代收集的唯一选择,Serial 是一个单线程的收集器, 它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。 Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。 ParNew收集器ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样, ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。 ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。 ParNew 虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 Java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。 并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可 Parallel Scavenge收集器Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器, 它重点关注的是程序达到一个可控制的吞吐量(Thoughput, CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。 -XX:UseParallelGC、-XX:UseParallelOldGC可互相激活,不管配置哪个,两个都会开启,ParallelGC采用复制算法,ParallelOldGC采用标记-整理算法 JDK1.8 默认收集器: Parallel Scavenge (新生代) 和 Parallel Old (老年代) Serial Old收集器Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用’标记-整理’算法,这个收集器的主要意义也是在给Client模式下的虚拟机使用,如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用 Parallel Old收集器Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于’标记-整理’算法实现,这个收集器是在JDK1.6中才开始提供,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态,原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器外别无选择,由于老年代Serial Old收集器在服务端应用性能上的‘拖累’,使用了Parallel Scavenge收集器也未必在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件条件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS组合’给力’。 直到Parallel Old收集器出现后,’吞吐量优先’收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器,Parallel Old收集器的工作过程如图所示: CMS收集器CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。 从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括: 由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过图3-11可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。
优点:并发收集低停顿 CMS是一款优秀的收集器,它最主要优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector) 。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点: ②然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全 “Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX: CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需 ③还有最后一个缺点,在本节的开头曾提到,CMS是一款基于 “标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了解决这个问题,CMS收集器提供了一个XX: +UseCMSCompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数- XX: CMSFullGCsBeforeCompaction (此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。 适用场景:CMS非常适合堆内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器 开启该收集器的JVM参数:-XX:+UseConcMarkSweepGC开启该参数后会自动将 -XX:+UseParNewGC打开 开启该参数后,使用ParNew(Young区用) + CMS(Old区用) + Serial Old的收集器组合,Serial Old将作为CMS出错的后备收集器 G1收集器G1是一款主要面向服务端应用的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK 5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate) 的收集器。如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。 但作为一款曾被广泛运用过的收集器,经过多个版本的开发迭代后,CMS (以及之前几款收集器)的代码与HotSpot的内存管理、执行、编译、监控等子系统都有千丝万缕的联系,这是历史原因导致的,并不符合职责分离的设计原则。为此,规划JDK 10功能目标时,HotSpot虛拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离,CMS以及其他收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为CMS退出历史舞台铺下最后的道路了。 作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java (RTSJ) 的中软实时垃圾收集器特征了。 那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet) 进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。 G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region) ,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间, 或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。 核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置这些子区域的大小,在堆的使用上,G1并不要求对象的存储一定是物理上连续的只要逻辑上连续即可,每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。 大小范围在1MB~32MB,最多能设置2048个区域,也即能够支持的最大内存为: 32MB * 2048 = 65536MB = 64G内存 Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB ~ 32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,如图3-12所示。 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。 针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块,避免内存碎片 G1将堆内存“化整为零”的“解题思路”,看起来似乎没有太多令人惊讶之处,也完全不难理解,但其中的实现细节可是远远没有想象中那么简单,否则就不会从2004年Sun实验室发表第一篇关于G1的论文后一直拖到2012年4月JDK 7 Update4发布,用将近10年时间才倒腾出能够商用的G1收集器来。G1收集器至少有(不限于)以下这些关键的细节问题需要 ②譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误:CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS (Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中“Concurrent Mode Failure” 失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“StopThe World”。 ③譬如,怎样建立起可靠的停顿预测模型?用户通过-XX: MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢? G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。 如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤: 2)并发标记(Concurrent Marking) :从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。 3)最终标记(Final Marking) :对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。 4)筛选回收(Live Data Counting and Evacuation) :负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。 从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。 从Oracle官方透露出来的信息可获知,回收阶段(Evacuation) 其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region, 停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC) 中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过图3-13可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。 从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。 G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”在未来,G1收集器最终还 相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两 不过,G1相对 于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload) 都要比CMS要高。就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB) 算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。 以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。 -XX:+UseG1GC -XX:G1HeapRegionSize=n,设置的G1区域的大小,值是2的幂,范围是1MB到32MB。目标是根据最小的Java堆大小划分出约2048个区域 jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代) 参考文献: 京东自营购买链接: 当当自营购买链接:
|
|
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/23 18:42:11- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |