JVM
[TOC]
JVM
JRE JVM JRE
JRE是Java应用程序的运行时环境,它由jvm和java类库组成。JRE提供了Java应用程序执行所需要的所有必要组件,包括JVM、类加载器、字节码验证器、垃圾回收器等。
JVM是java平台的核心组件之一。JVM是一个虚拟的计算机,可以在不同的操作系统上运行Java的class字节码文件,并将其转换为机器码执行。JVM提供了内存管理、垃圾回收、安全性和其他关键功能。
JDK是Java应用程序开发所需的工具包,他包括JRE、编译器、调试器,JavaDoc等一些工具。
其中,JVM并不是只有一种,只要满足JVM规范,每个公司组织或者个人都可以开发自己专属的JVM,也就是说我们平时接触的HotSpot只是JVM规范的一种实现

类加载器
启动类加载器(BootstrapClassLoader (JVM中))
加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等
扩展类加载器(ExtClassloader extends URLCLassLoader->...->ClassLoader)
加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包
应用程序类加载器(APPClassLoader extends URLCLassLoader->...->ClassLoader)
加截ClassPath.路径下的类包,主要就是加截你自己写的那些类
自定义加载器(ClassLoader)
加载用户自定义路径下的类包
ExtClassloader、APPClassLoader并不是父子关系,而是类中有一个parent属性用于存储父的类加载器,类似于链表结构。( 注:这是JDK8之前的类加载器结构)
双亲委派机制

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的C1αss),子类加载器才会尝试自己去加载。
优点:
沙箱安全机制(安全性):比如自己写的String.class类不会被加载,这样可以防止核心库被随意篡改
避免类的重复加载(唯一性):当父ClassLoader已经加载了该类的时候,就不需要子ClassLoader再加载一次,
打破双亲委派机制
同样的类在不同的类加载器Classloader实例中加载会被视作不同的类,所以在Web应用程序中,由于存在多个Web应用程序共享同一个Tomcat容器的情况(Tomcat的webapp下多个war包),而且每个Web应用程序都有自己的类加载器,这就可能导致类加载冲突的问题。
Tomcat的做法给每个Web应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找。这种做法可以确保每个Web应用程序都有自己的类加载器,从而避免了类加载冲突的问题。
并不是Web应用程序下的所有依赖都需要隔离的,比如Redis就可以Web应用程序之间共享(如果有需要的话),因为如果版本相同,没必要每个Wb应用程序都独自加载一份。
Tomcat就在WebAppClassLoader上加了个父类加载器(SharedClassLoader),如果WebAppClassLoader自身没有加载到某个类,那就委托 SharedClassLoader去加载
为了隔绝Web应用程序与Tomcat本身的类,又有类加载器(CatalinaClassLoader)来装载 Tomcat本身的依赖。如果Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享。各个类加载器的加载目录可以到tomcat的catalina.propertiesi配置文件上查看

内存结构
取自JVM 内存结构
JVM的内存结构,指的就是JVM定义的运行时数据区域,分为五大块,两大类:
线程私有:
程序计数器:用于记录各个线程执行的字节码的地址(分支、循环、跳转、异常、线程恢复等都依赖于计数器)
虚拟机栈:线程创建的时候会创建一个单独的虚拟机站,在调用一个方法的时候就会创建一个栈帧(包含局部变量表、操作数栈、动态链接、返回地址...)
本地方法栈:调用native方法
线程共有:
堆内存:分配所有对象实例,垃圾回收主要区域
元数据区(方法区):虚拟机加载的类信息(类的版本、字段、方法、接口、和父类等信息),常量池(静态常量池、运行时常量池(存储类加载时生成的直接用)),静态变量
直接内存:非JVM内存的对外内存,NIO操作的内存,效率高
注:这是JVM「规范」的分区概念,到具体的实现落地,不同的厂商实现可能是有所区别的

虚拟机栈

堆内存
新生代(1/3)
Eden(伊甸园区)0.8
Survivor(存活者区)
From Survivor 0.1
To Survivor 0.1
老年代(2/3)

垃圾回收
使用Java的时候我们并不需要关心内存的释放,这是因为JVM帮我们做了垃圾回收。
首先只要对象不再被引用了,那么该对象就会被视作垃圾,需要对其进行回收
引用计数法
计数器在对象被引用时+1,但对象引用失败或不在引用-1。当计数器为0时,说明对象不再被引用,可以被可回收。
**缺点:**如果对象存在循环依赖,那么就无法判断该对象是否应该被回收
可达性分析法
从「GCRoots」开始向下搜索,从「GCRoots」出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象。当对象到「GCRoots」之间都没有任何引用相连时,说明对象是不可用的,可以被回收。
「GCRoots.」是一组必须「活跃」的引用:类的静态变量引用、native所引用的对象、虚拟机栈栈顶指向堆的对象引用
垃圾回收算法
标记清除
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。
在标记阶段首先通过根节点(GC Roots),标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。
适用场合:
存活对象较多的情况下比较高效
适用于老年代
缺点:
容易产生内存碎片,再来一个比较大的对象时(典型情况:该对象的大小大于空闲表中的每一块大小但是小于其中每一块的和),会提前触发垃圾回收。
扫描了整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)
复制
从「GCRoots」集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一块儿新的内存(To Survivor区)上去,之后将原来的那一块儿内存(From Survivor区)全部回收掉
适用场合:
存活对象较少的情况下比较高效
扫描了整个空间一次(标记存活对象并复制移动)
**适用于新生代:**基本上98%的对象是”朝生夕死”的,存活下来的会很少。这也是为什么新生代有两个From和To区
缺点:
需要一块儿空的内存空间
需要复制移动对象
标记整理
首先从「GCRoots」节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
分代收集算法
分代收集算法就是目前虚拟机使用的回收算法,它解决了标记整理不适用于老年代的问题,将内存分为各个年代。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。
在不同年代使用不同的算法,从而使用最合适的算法,新生代存活率低,可以使用复制算法。而老年代对象存活率搞,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。
垃圾回收机制
新产生的对象优先分配在Eden区(除非配置了-XX:PretenureSizeThreshold,大于该值的对象会直接进入老年代);
当Eden区满了或放不下了,这时候其中存活的对象会复制到from区。
之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,这时候将会把Eden区和from区存活下来的对象复制到to区(同理,如果存活下来的对象to区都放不下,则这些存活下来的对象全部进入老年代),之后回收掉Eden区和from区的所有内存。
To区进行垃圾回收,存活对象复制至From区,如此,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1),默认情况下,当对象被复制了15次(这个次数可以通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。
当老年代满了或者存放不下将要进入老年代的存活对象的时候,就会发生一次Full GC(这个是我们最需要减少的,因为耗时很严重)。
1.Minor GC(复制算法)
对新生代进行回收,不会影响到老年代。因为新生代的 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收能尽快完成。
可达性分析法是如何区分老年代和新生代的对象呢?
HotSpot虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上
当做Minor GC的时候,从GC Roots出发,如果发现「老年代」的对象,那就不往下走了
但是肯定会存在老年代对象引用年轻代对象,所以不扫描老年代也是不行的
那么JVM是如何避免Minor GC时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性HotSpot JVM引入了卡表(card table)来实现这一目的。如下图所示:
「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」。如3所示
每次Minor GC的时候只需要去「卡表」找到「脏页」,找到后加入至GC Root,而不用去遍历整个「老年代」的对象了
2.Full GC(标记整理)
也叫Major GC,对整个堆进行回收,包括新生代和老年代。由于Full GC需要对整个堆进行回收,所以比MinorGC要慢,因此应该尽可能减少Full GC的次数,导致Full GC的原因包括:老年代被写满、永久代(Permenant)被写满和System.gc()被显式调用等。
总结:
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC) 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
老年代收集(Major GC/o1dGc):只是老年代的垃圾收集。
//目前,只有CMS GC会有单独收集老年代的行为。
//注意,很多时候Major GC会和Fu11GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
//目前,只有G1 GC会有这种行为
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
垃圾收集器
「年轻代」:Serial(单线程)、Parallel Scavenge(多线程)、ParNew
「老年代」:Serial Old(单线程)、Parallel Old(多线程)、CMS
CMS(Concurrent Mark Sweep)
相较于其他垃圾收集器,其他垃圾收集器:在回收垃圾时,会进行STW(Stop The World)停止用户线程,等待GC完成,恢复用户线程。
而CMS则是GC和STW并发交替进行。
初始标记:
标记GCRoots直接关联的对象以及年轻代指向老年代的对象(只是标记一下GC Roots能直接关联到的对象,速度很快)

并发标记
此阶段,CMS GC遍历所有的对象,标记存活的对象,从前一阶段“初始标记”找到的根元素开始算起。“并发标记”阶段就是与应用程序同时运行,不用暂停的阶段。此阶段由于与用户线程并发执行,对象的状态可能会发生变化,如下:
年轻代的对象从年轻代晋升到老年代
有些对象被直接分配到老年代
老年代和年轻代的对象引用关系变化
JVM会通过Card(卡片)的方式将发生改变的老年代区域标记为“脏”区,这就是所谓的卡片标记(Card Marking)

在上边的图中,“当前处理的对象”的一个引用就被应用线程给断开了,即这个部分的对象关系发生了变化
并发预清理
也是用于标记老年代存活的对象,此阶段仍然是与应用线程并发执行的,不需要停止应用线程。 目的: 让下一阶段重新标记的STW时间尽可能短 标记目标:
老年代中在并发标记中被标记为“dirty”的card
幸存区(from和to)中引用的老年代对象
关闭参数:-XX:-CMSPrecleaningEnabled,默认开启

如果预清理之后,当满足:
Eden的使用空间大于**-XX:CMSScheduleRemarkEdenSizeThreshold**(默认2M);
Eden的使用率达到**-XX:CMSScheduleRemarkEdenPenetration**(默认50%);
设置了CMSMaxAbortablePrecleanLoops循环次数(默认为0),并且执行的次数大于或者等于这个值时(延长进入下一个重新标记的时间);
CMSMaxAbortablePrecleanTime(默认5000毫秒),执行可中断预清理的时间超过了这个值(延长进入下一个重新标记的时间);
则会触发MinorGC(回收年轻代的对象,此过程有STW,可以减少下一阶段重新标记时遍历新生代的对象)
应用程序线程可能会持续地创建和销毁对象,这些对象被称为浮动垃圾(Floating Garbage)。如果不及时清理这些浮动垃圾(大部分是新生代区对象),它们可能会影响到最终标记(重新标记)的准确性和效率。
下一个重新标记阶段会进行全局标记,如果有太多的垃圾对象需要回收,就会导致标记时间过长,从而导致连续停顿(concurrent mode failure)。超过了CMS预留的时间,这时CMS垃圾回收器就会放弃增量标记的方式,转而使用全局标记的方式,停止应用程序线程,进行垃圾回收,从而导致连续停顿。
重新标记
重新标记是此阶段GC事件中的第二次(也是最后一次)STW停顿。 目标: 重新扫描堆中的对象,因为之前的预清理阶段是并发执行的,有可能GC线程跟不上应用程序的修改速度。 扫描范围: 新生代对象+GC Roots+被标记为“脏”区的对象。如果预清理阶段没有做好,这一步扫描新生代的时候就会花很多时间。
并发清除
此阶段与应用程序并发执行,不需要STW停顿。JVM在此阶段删除不再使用的对象,并回收他们占用的内存空间。因为前面阶段已经把所有还在使用的对象进行了标记,因此此阶段可以与应用线程并发的执行。

最后会重置CMS算法相关的内部数据,为下一次GC循环做准备。
G1(Garbage First)
能够支持指定所在一个长度为毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标(可以根据指定时间进行垃圾收集)
**设计理念:**G1面向所有的堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块区域中存放的垃圾数量最多,回收效益最大。所以G1对堆的划分不再是物理形式(内存),而是以逻辑形式(地址)划分。

Region区域
定义: 将Java堆划分为多个大小相等的Region,每个Region都可以是新生代、老年代。G1收集器根据角色的不同采用不同的策略去处理 大小: 2 的N次幂,1MB~32MB
垃圾收集
从宏观看,G1的Collector一侧就是两个大部分,并且这两个部分可以相对独立执行。
全局并发标记(global concurrent marking)
拷贝存活对象(evacuation)
全局并发标记(global concurrent marking)
基于SATB(Snapshot At The Begining)形式的并发标记。
1、初始标记(STW)
G1对根进行标记。扫描根集合,标记所有从根集合可直接到达的对象(CMS的初始标记也是类似)并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,而不是用对象头的mark word里的mark bit。在分代式G1模式中,初始标记阶段借用 young GC的暂停,因而没有额外的、单独的暂停阶段。
为什么初始标记阶段是借用Young GC的暂停做的?
从逻辑上将“全局并发标记”和“拷贝存活对象”是相对独立的,但是“全局并发标记”阶段的“初始标记”阶段又和Young GC要做的事情有重叠---遍历根集合,所以在实现上把他们安排在一起做,Young GC期间可以顺带做,也可以不做。
Young GC
首先,扫描GC Roots(年轻代以及RSet中的老年代对象)找出存活对象。
其次,将dirty card中的card更新到RSet中(当存在跨代引用时,并不能及时更新RSet,因为RSet的处理需要线程同步,开销大,所以先使用dirty card简单记录那些进行跨代引用的对象,等扫描的时候在去进行进一步的RSet的记录)
然后,处理RSet,简而言之就是,RSet中的对象所指的对象就是存活对象。
接着,就是对之前找出的存活对象进行复制,复制到Survivor区(或者晋升到老年区)
最后,把Eden区清空(剩下的都是垃圾对象或已经被复制了的对象)
2、并发标记
G1在整个堆中查找可以访问的(存活的)对象,递归扫描整个堆里的对象图。每扫描到一个对象就对其进行标记,并压入扫描栈中。重复扫描过程直到扫描栈清空。过程中还会扫描SATB 写屏障(write barrier)所记录下的引用。
3、最终/重新标记(STW)
处理在并发标记阶段剩余未处理的SATB写屏障的记录。同时此阶段也进行弱引用处理(reference proccessing),**这个暂停与CMS的remark有一个本质的区别,这个暂停只需要扫描SATB buffer(将这些旧引用作为根重新扫描一遍,避免漏标),而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,**而此时整个Young 区不管对象死活都会被当做根集合的一部分,因而CMS remark有可能会非常慢。
4、清理(cleanup)(STW)
清点和重置标记状态,与mark-sweep中的sweep阶段类似,但不是在堆上sweep实际对象,而是在marking bitmap里统计每个Region被标记为活的对象有多少。这个阶段如果发现完全没有活对象的Region,就会将其整体回收到可分配Region列表中(空闲列表)。
拷贝存活对象 (Evacuation)(STW)
也叫筛选回收/清理(STW),负责把一部分Region里的活对象拷贝到空Region里去,然后回收原本的Region的空间。 此阶段可以自由选择任意多个Region来独立收集构成收集集合(Collection Set,CSet),靠每个Region的RSet实现。这是Regional garbage collector的特点。 确定完CSet后肯定就要复制了,其实就和ParallelScavenge的Young GC算法类似,采用并行复制算法把CSet里每个Region里的活对象拷贝到新的Region里,整个过程完全暂停。 “Garbage-First Garbage Collection”论文中讲到,CSet的选定完全靠统计模型找出收益最高、开销不超过用户指定的上限的若干个Region,由于每个Region都有RSet覆盖,要单独evacuate任意一个或者多个Region都没问题。
分代式G1模式下有两种选定CSet的子模式:
Young GC:选定所有Young区里的Region。通过控制Young区的Region个数来控制GC的开销。
Mixex GC:选定所有Young Gen里的Region,外加根据global concurrent marking统计得出收益最高的若干old区的Region。在用户指定的开销目标范围内尽可能选择收益高的old区Region
可以看到Young区Region总是在CSet内,因此分代式G1不维护从Young区Region出发的引用设计的RSet更新。
分代式G1的正常工作流程就是在Young GC与Mixed GC之间视情况进行切换,背后定期做全局并发标记。
初始化标记默认搭在YongGC上执行;
当全局并发标记正在工作时,G1不会选择做Mixed GC,反之MixedGC正在进行中G1也不会启动初始化标记。
在正常的工作流程中没有Full GC的概念,Old区的收集完全靠MixedGC来完成。
什么场景适合使用G1
50%以上的堆被存活对象占用
对象分配和晋升的速度变化非常大
垃圾回收时间特别长,0.5~1秒甚至以上
8GB以上的堆内存(建议值)
要求的停顿时间是500ms以内
如何选择垃圾收集器
优先调整堆的大小让服务器自己来选择
如果内存小于100M,使用串行收集器
如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
如果允许停顿时间超过1秒,选择并行或者JVM自己选
如果响应时间最重要,并且不能超过1秒,使用并发收集器
4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的gion,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以 G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
每秒几十万并发的系统如何优化JVM?
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafkai这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc-卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置-X:MaxGCPauseMills为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。 G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
逃逸分析
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,对象只在方法内部使用,则测认为没有发生逃逸。
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
Last updated
