关于作者
程序猿周周
⌨️ 短视频小厂BUG攻城狮
如果文章对你有帮助,记得关注、点赞、收藏,一键三连哦,你的支持将成为我最大的动力
本文是《后端面试小册子》系列的第 1️⃣0️⃣ 篇文章,该系列将整理和梳理笔者作为 Java 后端程序猿在日常工作以及面试中遇到的实际问题,通过这些问题的系统学习,也帮助笔者顺利拿到阿里、字节、华为、快手等多个大厂 Offer,也祝愿大家能够早日斩获自己心仪的 Offer。
PS:《后端面试小册子》已整理成册,目前共十三章节,总计约二十万字,欢迎关注公众号【程序猿周周】获取电子版和更多学习资料(最新系列文章也会在此陆续更新)。公众号后台可以回复关键词「电⼦书」可获得这份面试小册子。文中所有内容都会在 Github 开源,项目地址 csnotes,如文中存在错误,欢迎指出。如果觉得文章还对你有所帮助,赶紧点个免费的 star 支持一下吧!
标题 | 地址 |
---|---|
MySQL数据库面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122910606 |
Redis面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122934938 |
计算机网络面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122973684 |
操作系统面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994599 |
Linux面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994862 |
Spring面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123016872 |
Java基础面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123080189 |
Java集合面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123171501 |
Java并发面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123266624 |
Java虚拟机面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123412605 |
Java异常面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123462676 |
设计模式面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123490442 |
Dubbo面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123538243 |
Netty面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123564362 |
JVM(Java virtual machine)是 Java 程序的运行环境,它同时也是一个操作系统的一个应用程序,因此 JVM 也有他自己的运行生命周期,也有自己的代码和数据空间。
JVM 主要由两个子系统以及两个组件r 组成:
JVM 在执行 Java 程序时,会把其所管理的内存划分成多个区域,每个区域都有不同用途,每个区域的创建和销毁时间也不同。
由于各大厂家虚拟机的实现各个区域划分可能有所不同,但 JVM 规范中规定的区域可以分为以下几个部分:
是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。在虚拟机概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此未来线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,故程序计数器是线程私有的。
如果线程正在执行的是一个 Java 方法,这个计数器记录的则是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器则为空(undefined)。
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
和程序计数器一样,虚拟机栈也是线程私有的,且其生命周期与线程相同。
在栈中,每执行一个方法前都会创建一个栈帧,来储存局部变量表、操作数栈、动态链接以及方法出口等信息。每个方法从调用到完成,就对应着栈中一个栈帧的压栈到弹出的过程。
同时一个方法对应的栈帧中局部变量表所需内存空间早已在编译期间就分配完成,当进入一个方法,虚拟机栈对应压入一个栈帧,次栈帧局部变量表空间大小完全确定。
在 Java 虚拟机规范中,对此区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出 Stack OverflowError 异常;如果虚拟机栈可以动态扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的。
但在我们最常用的 HotSpot 虚拟机中,本地方法栈与 Java 虚拟机栈合二为一
Java 堆是一块线程共享的内存区域,它在虚拟机启动时分配创建,它是 JVM 内存里最大的一块内存,但是它的目的只有一个:存放对象实例。
但是随着 JIT 编译器的发展和逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也就变得不那么绝对了。
又被称非堆(Non-Heap),JVM 虚拟机规范中把方法区描述为堆的一个逻辑部分,它与 Java 堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
但这部分区域又比较特殊,且虚拟机如何实现方法区不受虚拟机规范约束。比如 HotSpot 中,JDK 1.7 之前方法区为永久代,其垃圾回收受 Java 堆 GC 分代收集算法管理,1.7 将 String 常量池移入堆中,1.8 中永久代被完全移除,将类加载信息放入一个线程共享的元空间内。
直接内存并不是虚拟机运行时数据区的一部分,也不是 JVM 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 出现。
如 NIO 使用 Native 函数库直接分配堆外内存,然后通过一个 DirectByteBuffer 对象作为这块内存的引用,可以大幅度减轻堆的负担,提高虚拟机性能。
=============
总结一下:
1)JVM 的内存模型中一共有两个“栈”,分别是:虚拟机栈和本地方法栈。两个“栈”的功能类似,都是方法运行过程的内存模型。并且两个“栈”内部构造相同,都是线程私有。只不过虚拟机栈描述的是 Java 方法运行过程的内存模型;而本地方法栈是描述 Java 本地(Native)方法运行过程的内存模型。
2)JVM 的内存模型中一共有两个“堆”,一个是原本的堆,一个是方法区。方法区本质上是属于堆的一个逻辑部分。堆中存放对象,方法区中存放类信息、常量、静态变量、即时编译器编译的代码。
3)堆是 JVM 中最大的一块内存区域,也是垃圾收集器主要的工作区域。
4)程序计数器、虚拟机栈、本地方法栈是线程私有的。并且它们的生命周期和所属的线程一样。而堆、方法区是线程共享的,在 JVM 中只有一个堆、一个方法栈。并且在 JVM 启动的时候就创建,JVM 停止才销毁。
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译 Class 文件时就在方法的 code 属性 max_locals 中确定了该方法所需要分配的局部变量表的最大容量。
也常称为操作栈,它是一个后入先出(Last In First out,LIFO)栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
ref
JDK 1.8 以前的 HotSpot 有个叫方法区的内存区域,也叫永久代(permanent generation)。而从 JDK 1.7 开始,方法区的部分数据就被移除:符号引用(Symbols)移至 Native heap,字面量(interned strings)和静态变量(class statics)移至 Java heap。
至于为什么要用**元空间(Metaspace)**替代方法区,这是因为随着动态类加载的情况越来越多,这块内存变得不太可控,如果设置小了,系统运行过程中就容易出现内存溢出,设置大了又浪费内存。
1)类加载
当 JVM 遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析以及初始化。如果没有,则先执行类加载过程。
(此过程详见类加载章节)
2)内存分配
类加载检查通过后,对象所需的内存空间大小在类加载完成之后便可确定,JVM 会根据垃圾回收期选取内存分配算法:
serial,ParNew 等带有压缩功能的回收器,内存是连续的,内存指针移动基于对象大小移动。
CMS,通过维护一个空间内存列表,存放对象、分配内存还需考虑并发申请内存问题(CAS、TLAB本地线程缓冲)。
堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错。虚拟机必须维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找到一块足够大的内存划分给对象实例,并更新列表上的记录。
3)初始化
内存分配完成之后,则需要初始化对象的信息,主要涉及属性默认值、对象头信息以及执行构造函数。
JVM 就需要将分配的内存空间都初始化为零(不包括对象头),如果在 TLAB 上分配内存,此过程可提前至 TLAB 分配时进行。这一步保证了对象的实例字段可以不赋初值也可以直接使用。
设置对象头信息,这些信息包括该对象是那个类的实例,如何才能找到该类的元数据信息,对象的哈希码,对象的 GC 分代信息等。
执行完以上步骤之后,对于 JVM 来说新的对象已经创建完成,但对于 Java 程序来说,对象创建才刚开始,因为构造函数还没有执行,所以要执行方法进行自定义初始化。
由于 JVM 中创建对象的行为非常频繁,因此需要考虑内存分配的并发问题解决方案:
1)对分配内存空间的动作进行同步,即用CAS失败重试的方式;
2)把内存分配的动作按照线程划分在不同的空间中进行,每个线程在 Java 堆中预先分配一小块内存,即本地线程分配缓冲 TLAB(Thread Local Allocation Buffer),各线程首先在 TLAB 上分配内存,TLAB 使用完之后,分配新的 TLAB 时才需要同步锁定。JVM 是否使用 TLAB 可以通过 -XX:+/-UseTLAB
参数指定。
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 JVM 规范中只规定了一个指向对象的引用,并未定义这个引用如何定位和访问具体位置,所以对象访问方式由具体虚拟机实现而定。目前主流的访问方式有句柄和直接指针两种。
ava堆对象的布局中就必须考虑如何放置访问方法区中类型数据的相关信息。
Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例和类型数据各自的具体地址信息。
二者各有优势,使用句柄访问这样做的好处是栈中 reference 存储的句柄地址较为稳定,因为在 Java 堆中进行了垃圾回收,对象的地址发生了改变的时候,只需要修改句柄的对象实例数据指针就行。而使用直接指针的最大好处就是速度更快。
对象在堆内存的内存布局主要有三部分,即对象头、实例数据以及对其填充。
1)对象头(Header)
对象头主要包含两部分的内容,一个叫做运行时元数据(MarkWord),一个叫做类型指针(Class Metadata Address)。
类型指针指向元数据区代表当前类的 class 对象,确定该对象所属的类型,而运行时元数据又包含了:
2)实例数据(Instance Data)
它是对象真正存储的有效信息,包括程序代码中定义的各种字段类型,当然也包含从父类继承下来的字段。注意这里有一些规则:相同宽度的字段总是被分配在一起,父类中定义的变量会出现在子类之前,因为父类的加载是优先于子类加载的。
3)对齐填充
没有特殊含义,仅仅起到占位符的作用。
程序在运行过程中,会产生大量的内存垃圾(一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,对程序而言它们已经死亡),为了确保程序运行时的性能,Java 虚拟机在程序运行的过程中不断地进行自动的垃圾回收(GC)。
JVM 的垃圾回收器都不需要我们手动处理无引用的对象了,这个就是最大的优点。而缺点也显而易见,Java 并未提供显式的内存管理操作,仅有的 System.gc()
方法也只是通知 JVM 需要进行 GC 操作,但是否执行仍需 JVM 决定。
程序计数器、虚拟机栈、本地方法栈三个区域随线程而生随线程而灭。虚拟机栈中的栈帧随着方法的开始和结束对应着入栈和出栈。每一个栈帧需分配内存的大小在类结构确定下来时就已知了。因此这几个区域的内存分配和回收都具有确定性。方法结束时或是线程结束时内存会随之回收。
在 JVM 的堆和方法区中,一个接口的实现类所需的内存可能不同,一个方法的不同分支所需内存也可能不同。只有在程序运行期才能知道要创建多少对象,这部分的内存分配和回收具有动态性。
Java 在垃圾回收之前,需要判断对象是否存活,只有死亡的对象才能被 GC 回收。常用的两种方式是:引用计数法和可达性分析。
给对象添加一个引用计数器,每当有一个对象引用时 +1,当引用失效时 -1,任何时刻计数器为 0 的对象就是可以被回收的对象。
引用计数发实现虽然简单,且很高效,但很难解决对象之间循环引用的问题。
通过一系列称为 GC Roots 的对象作为起始点,从这些点向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则认为对象不可达。
即使在可达性分析中不可达的对象,也并非是非死不可,只是处于缓刑阶段,要真正死亡至少要经历两次标记,这跟 finalize 有关。
1)虚拟机栈中引用的对象;
2)方法区中(1.8称为元空间)的类静态属性引用的对象;
3)方法区中的常量引用的对象;
4)本地方法栈中的JNI(native方法)引用的对象。
JVM 在对 Java 对象回收阶段进行两次标记,一次筛选。
1)如果可达性分析算法发现对象没有在 GC ROOT 引用链中,进行第一次标记并筛选是否需要调用重写的 finalize() 方法。
2)筛选的依据是被回收对象是否重写过 finalize() 方法,且该重写方法并未被虚拟机调用过,不满足直接进行回收。
3)如果该对象有必要执行 finalize() 方法,该对象就会被放置在一个叫 F-Queue 的队列之中 ,并在稍后会由虚拟机自动创建一个低优先级的线程 Finalizer 线程去执行它。这个线程只会触发这个方法,不一定会等它结束,原因是防止一个 finalize() 方法在虚拟机中执行缓慢或死循环,导致 F-Queue 队列中其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。
finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将会对 F-Queue 队列中的对象进行第二次小规模的标记,如果对象在 finalize() 方法中重新与引用链上的任何一个对象建立联系,就可以拯救自己。
有些人认为方法区(如 HotSpot 中的元空间或者永久代)是没有垃圾收集行为的,《Java虚 拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11 时期的 ZGC 收集器就不支持类卸载),方法区垃圾收集的性价比通常也是比较低的,在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70-99% 的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分,废弃的常量和不再使用的类型。
JVM 被允许对满足上述三个条件的无用类进行回收,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 还提供了 -Xnoclassgc
参数进行控制。
由于 Java 虚拟机规范中并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。
常见的算法有新生代的复制算法,以及老年代的标记清除和标记整理算法。
这是最基础的垃圾回收算法,因为它最容易实现,思想也是最简单的。标记-清除算法分为标记和清除两个阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
为了解决标记清除算法的缺陷,复制算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。同时回收效率也和存活对象数量有着极大关系。
为了解决复制算法的缺陷,充分利用内存空间,提出了标记整理算法,也称压缩算法。该算法标记阶段和标记清除一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取复制算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照 1:1 的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中的一块 Survivor 空间,当进行回收时,将 Eden 和 Survivor 中还存活的对象复制到另一块 Survivor 空间中,然后清理掉 Eden 和刚才使用过的 Survivor 空间。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法(压缩法)。
通常将新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,其比例是 8:1:1。这是一个根据统计学得出的数据,新生代对象生存时间比较短,大约 80% 对象被回收。
图中展示了 7 种作用于不同分代的收集器,分别是新生代收集器 Serial、ParNew、Parallel Scavenge,老年代收集器 CMS、Serial Old、Parallel Old 以及整堆收集器 G1。如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
单线程、简单高效,对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程回收效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
ParNew 收集器其实就是Serial收集器的多线程版本。除了使用多线程外其余行为均和 Serial 收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等),同样存在 STW 问题。
可以使用 -XX:ParallelGCThreads
参数来设置垃圾收集的线程数。许多运行在 Server 模式下的虚拟机中首选的新生代收集器,因为它是除了 Serial 外,唯一能与 CMS 收集器配合工作的。
这是一个可以控制吞吐量的多线程收集器,故也称为吞吐量优先收集器。相比 ParNew 收集器,Parallel Scavenge 可以使用 XX:MaxGCPauseMillis
控制最大的垃圾收集停顿时间以及 XX:GCRatio
直接设置吞吐量的大小。
同时支持 GC 自适应调节策略。即 Parallel Scavenge 收集器可设置 -XX:+UseAdptiveSizePolicy
参数。当打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等信息,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量。
Serial 收集器的老年代版本,同样是单线程收集器,采用标记-整理算法。
是 Parallel Scavenge收集器的老年代版本,采用标记-整理算法。适用于注重高吞吐量以及 CPU 资源敏感的场合。
一种以获取最短回收停顿时间为目标的收集器。也是基于标记-清除算法的并发收集器。此处并发同步于前面提及的几种多线程并行收集器,是指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。
非常适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景。
一款面向服务端应用的垃圾收集器。
CMS 收集器的运行过程分为下列 4 步:
1)初始标记阶段,标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题。
2)并发标记阶段,进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
3)重新标记阶段,为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,也存在 STW 问题。
4)并发清除阶段,对标记的对象进行清除回收。
CMS 收集器的缺点:
如果不计算维护 Remembered Set 的操作,G1 收集器大致可分为如下步骤:
1)初始标记:仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。(需要线程停顿,但耗时很短。)
2)并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)
3)最终标记:为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)
4)筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)
先来了解一下什么是 MinorGC(Young GC) 和 Major GC/FullGC。
1)MinorGC
从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC,也叫Young GC。因为 Java 对象大多具备朝生夕死的特征,所以MinorGC非常频繁,一般回收速度也比较快。一般采用复制算法。
MinorGC 触发条件也很简单:当年轻代空间不足时,就会触发 MinorGC,注意这里的年轻代满指的是 Eden 区,Survivor 满不会引发 MinorGC。
同时 Minor GC 会引发 STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
2)Major GC
CMS 收集器中,当老年代满时会触发 Major GC。且目前只有 CMS 收集器会有单独收集老年代的行为。
3)Full GC
Full GC 对收集整堆(新生代、老年代)和方法区的垃圾收集。
当年老代满时会引发 Full GC,将会同时回收新生代、年老代 ;当永久代满时也会引发 Full GC,会导致 Class、Method 元信息的卸载。
在垃圾回收算法中,标记总是必可少的一步。要找出存活对象,根据可达性分析,从 GC Roots 开始进行遍历访问,同时我们把遍历对象图过程中遇到的对象,按是否访问过这个条件标记成以下三种颜色:
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
1)初始时,所有对象都在白色集合中;
2)将 GC Roots 直接引用到的对象挪到灰色集合中;
3)从灰色集合中获取对象:将本对象引用到的其他对象全部挪到灰色集合中,最后将本对象本身挪到黑色集合里面;
重复步骤 3),直至灰色集合为空时结束。结束后,仍在白色集合的对象即为 GC Roots 不可达,可以进行回收。
当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
当某个对象标记成灰色时,不被引用对象继续引用,本该被回收却被当作存活对象继续遍历下去,产生浮动垃圾。这并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先将 E 与 G 的引用关系断开,再让 D 引用到 G。切回GC线程继续执行回收算法时,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。最后导致 G 被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
从代码角度看,漏标只有同时满足以下逻辑时才会发生:
var G = objE.fieldG; // 1. 读
objE.fieldG = null; // 2. 写
objD.fieldG = G; // 3. 写
1)读取灰色对象的成员变量属性值 ref;
2)将灰色对象对应属性设置为 null;
3)将属性 ref 赋值给黑色对象。
不难看出,只要在上面这三步中的任意一步中做一些手脚,通过读写屏障将引用对象记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),再对该集合的对象遍历即可(重新标记)。
重新标记通常是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。
在现代垃圾回收器中,不同的收集器对漏标的处理方案有所不同:
当原来成员变量的引用发生变化之前,记录下原来的引用对象,即原始快照(Snapshot At The Beginning,SATB)。
当有新引用插入进来时,记录下新的引用对象,这种做法的思路是不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。
读取成员变量时一律记录下来,这种做法是保守的,但也是安全的。
ref
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
在 Java 中,一个类从定义到使用可以分为加载、验证、准备、解析、初始化、使用以及卸载几个步骤。其中验证、准备和解析又可以统称为连接。
1)通过全限定类名来获取定义此类的二进制字节流;
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
1)文件格式验证:此阶段保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。如是否以魔数 0xCAFEBABE 开头、主、次版本号是否在当前虚拟机处理范围之内、常量合理性验证等。
2)元数据验证:此阶段保证不存在不符合 Java 语言规范的元数据信息。如是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等。
3)字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如保证跳转指令不会跳转到方法体以外的字节码指令上。
4)符号引用验证:在解析阶段中发生,保证可以将符号引用转化为直接引用。
可以考虑使用 -Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。
虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。
同时为支持运行时绑定,解析过程在某些情况下可在初始化之后再开始。
到初始化阶段,才真正开始执行类中定义的 Java 程序代码,此阶段是执行
方法的过程。该方法是由编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。
此过程不包括构造器中的语句。构造器是初始化对象的,类加载完成后,创建对象时候将调用的
方法来初始化对象。
1)父类静态变量/静态初始化块 - 子类静态变量/静态初始化块;
2)父类变量/初始化块 - 父类构造器;
3)子类变量/初始化块 - 子类构造器。
对于加载,Java 虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则严格规定了以下几种情况必须立即对类进行初始化,如果类没有进行过初始化,则需要先触发其初始化。
1)遇到 new(用 new 实例对象),getStatic(读取一个静态字段),putstatic(设置一个静态字段),invokeStatic(调用一个类的静态方法)这四条指令字节码命令时;
2)使用 Java.lang.reflect 反射包的方法对类进行反射调用时,如果此时类没有进行 init,会先 init;
3)当初始化一个类时,如果其父类没有进行初始化,先初始化父类;
4)JVM 启动时,用户需要指定一个执行的主类(包含 main 的类),虚拟机会先执行这个类;
5)当使用 JDK 1.7 的动态语言支持的时候,当 java.lang.invoke.MethodHandler 实例后的结果是 REF-getStatic/REF_putstatic/REF_invokeStatic 的句柄,并且这些句柄对应的类没初始化的话应该首先初始。
以上这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用,如:
1)通过子类引用父类的静态字段,不会导致子类初始化。
2)通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass[10]
;
3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
类加载器是负责将可能是网络上、也可能是磁盘上的 class 文件加载到内存中,并为其生成对应的 java.lang.class 对象。一旦一个类被载入 JVM 了,同一个类就不会被再次加载。
在 JAVA 中一个类用其全限定类名(包名和类名)作为其唯一标识,但是在 JVM 中,一个类用其全限定类名和其类加载器作为其唯一标识。即在 JAVA 中的同一个类,如果用不同的类加载器加载,则生成的 class 对象认为是不同的。
当 JVM 启动时,会形成由三个类加载器组成的初始类加载器层次结构:
是嵌在 JVM 内核中的加载器,该加载器是用 C++ 语言编写,主要负载加载 JAVA_HOME/lib
下的类库,启动类加载器无法被应用程序直接使用。
该加载器器是用 JAVA 编写,且它的父类加载器是 Bootstrap。主要加载 JAVA_HOME/lib/ext
目录中的类库。也可通过 -Djava.ext.dirs=
参数设置加载路径。
系统类加载器,也称为应用程序类加载器,负责加载应用程序 classpath 目录下的所有 jar 和 class 文件。它的父加载器为 ExtClassLoader。
此外还有:
实现自定义类加载器分为两步:一是继承 java.lang.ClassLoader
;二是重写父类的 findClass()
方法。
为解决基础类无法调用类加载器加载用户提供代码的问题,Java 引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器默认就是 Application 类加载器,并且可以通过 java.lang.Thread.setContextClassLoaser() 方法进行设置。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型对于保证 Java 程序的稳定运作很重要,例如 java.lang.Object
这个类,它存放在 rt.jar
中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载的时候
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
双亲委派机制原则在 loadClass() 方法中,只需要绕开该方法中即可。
使用自定义类加载器,重写 loadClass()
方法。(注意不是 findClass()
方法)
给当前线程设定关联类加载器(线程上下文类加载器),使用 SPI(Service Provider Interface ) 机制绕开 loadclass()
方法。
第二种方法已被广泛应用,如 JDBC、Dubbo 等。
首先,JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole
和 jvisualvm
这两款视图监控工具。
1)jps:与 Linux 上的 ps 类似,用于查看有权访问的虚拟机的进程,并显示他们的进程号。当未指定 hostid 时,默认查看本机 JVM 进程。
2)jinfo:可以输出并修改运行时的 Java 进程的一些参数。
3)jstat:可以用来监视 JVM 内存内的各种堆和非堆的大小及其内存使用量。
4)jstack:堆栈跟踪工具,一般用于查看某个进程包含线程的情况。
5)jmap:打印出某个 JVM 进程内存内的所有对象的情况,一般用于查看内存占用情况。
6)jconsole:一个 GUI 监视工具,可以以图表化的形式显示各种数据,并支持远程连接。
是一款内存分析工具,利用 dump 分享内存泄漏。
ref
1)-Xms
:初始堆大小,JVM 启动的时候,给定堆空间大小。
2)-Xmx
:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。
3)-Xmn
:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小。
4)-XX:NewSize=n
:设置年轻代初始化大小大小。
5)-XX:MaxNewSize=n
:设置年轻代最大值。
6)-XX:NewRatio=n
:设置年轻代和年老代的比值。如 n = 3 时表示年轻代与年老代比值为 1:3,年轻代占整个年轻代+年老代和的 1/4。
7)-XX:SurvivorRatio=n
年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8。
8)-Xss
:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。
9)-XX:ThreadStackSize=n
:线程堆栈大小。
10)-XX:PermSize=n
:设置持久代初始值。
11)-XX:MaxPermSize=n
:设置持久代大小。
12)-XX:MaxTenuringThreshold=n
设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。
1)-XX:LargePageSizeInBytes=n
:设置堆内存的内存页大小。
2)-XX:+UseFastAccessorMethods
:优化原始类型的 getter 方法性能。
3)-XX:+DisableExplicitGC
:禁止在运行期显式地调用S ystem.gc(),默认启用。
4)-XX:+AggressiveOpts
:是否启用 JVM 开发团队最新的调优成果。如编译优化,偏向锁,并行年老代收集等,JDK 6 之后默认启动。
5)-XX:+UseBiasedLocking
:是否启用偏向锁,JDK 6 默认启用。
6)-Xnoclassgc
:是否禁用垃圾回收。
7)-XX:+UseThreadPriorities
:使用本地线程的优先级,默认启用。
1)-XX:+UseSerialGC
:设置串行收集器,年轻带收集器。
2)-XX:+UseParNewGC
:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK 5 以上 JVM 会根据系统配置自行设置,所以无需再设置此值。
3)-XX:+UseParallelGC
:设置并行收集器,目标是目标是达到可控制的吞吐量。
4)-XX:+UseParallelOldGC
:设置并行年老代收集器,JDK 6 支持对年老代并行收集。
5)-XX:+UseConcMarkSweepGC
:设置年老代并发收集器。
6)-XX:+UseG1GC
:设置 G1 收集器,JDK 9 默认垃圾收集器