简单的介绍一下JVM(Java Virtual Machine)吧,它也叫Java虚拟机。虽然它叫虚拟机,可是实际上不是咱们所理解的虚拟机,它更像操做系统中的一个进程。JVM屏蔽了各个操做系统底层的相关的东西,Java程序只须要生成对应的字节码文件,而后由JVM来负责解释运行。html
介绍几个容易混淆的概念,JDK(Java Development Kit) 能够算是整个Java的核心,其中有编译、调试的工具包和基础类库,它也包含了JRE。git
JRE(Java Runtime Environment),包含了JVM和基础类库。而JVM就是咱们今天要聊的主角,开篇聊到,JVM负责解释运行,它会将本身的指令映射到当前不一样设备的CPU指令集上,因此只须要在不一样的操做系统上装不一样版本的虚拟机便可。这也给了Java跨平台的能力。github
就跟咱们用三方库同样,一样的功能有不一样的实现。JVM也是同样的,第一款JVM是Sun公司的Classic VM,JDK1.2以前JVM都是采用的Classic VM,而以后,逐渐被咱们都知道的HotSpot给替代,直到JDK1.4,Classic VM才彻底被弃用。算法
HotSpot应该是目前使用最普遍的虚拟机(自信点,把应该去掉),也是OpenJDK中所带的虚拟机。可是你可能不知道,HotSpot最开始并非由Sun公司开发,而是由一家小公司设计并实现的,并且最初也不是为Java语言设计的。Sun公司看到了这个虚拟机在JIT上的优点,因而就收购了这家公司,从而得到了HotSpot VM。安全
可能你经历过被灵魂拷问是什么滋味,若是线上发生了OOM(Out Of Memory),该怎么排查?若是要你来对一个JVM的运行参数进行调优,你该怎么作?多线程
不像C++能够本身来主宰内存,同时扮演上帝和最底层劳工的角色,Java里咱们把内存管理交给了JVM,若是咱们不能了解其中具体的运行时内存分布以及垃圾回收的原理,那等到问题真正出现了,极可能就无从查起。这也是要深刻的了解JVM的必要性。并发
Java在运行时会将内存分红以下几个区域进行管理,堆、方法区、虚拟机栈、本地方法栈和程序计数器。jvm
堆
-函数
堆(Java Heap)是JVM所管理的内存中最大的一块了。咱们日常开发中使用new
关键字来进行实例化的对象几乎都会在堆中分配内存,全部线程均可以共享被分配在堆上的对象。工具
堆也是JVM垃圾回收的主要区域,正由于垃圾回收的分代机制,其实堆中还能够分为更细的新生代、老年代。GC这块后面会细讲。
那为何是几乎呢?在JVM自己的规范中是规定了全部的对象都会在堆上分配内存的,可是随着JIT(Just In Time)编译器和逃逸分析技术的成熟,全部对象都在堆上分配内存就变得没有那么绝对了。
不知道你有没有据说过,二八定律在咱们的程序中也一样适用,那就是20%的代码占用了系统运行中80%的资源。在咱们写的代码中,就可能会存在一些热点代码,频繁的被调用。除了被频繁的调用的代码,还有被执行屡次的循环体也算热点代码。
那此时JIT编译器就会对这部分的代码进行优化,将它们编译成Machine Code,并作一些对应的优化。不熟悉的同窗可能会说,咱们的代码不都已经被编译成了字节码了吗?怎么又被编译成了Machine Code?
由于字节码只是一个中间状态,真正的运行是JVM在运行的时候,就跟解释型语言同样将字节码逐条的翻译成了Machine Code,这个Machine Code才是操做系统可以识别直接运行的指令。而JIT就会把编译好的热点代码所对应的Machine Code保存下来, 下载再调用时就省去了从字节码编译到Machine Code的过程,效率天然也就提升了。
咱们刚刚提到过,Java中几乎全部的对象都在堆上分配空间,堆中的内存空间是全部线程共享的,因此在多线程下才须要去考虑同步的相关问题。那若是这个变量是个局部变量,只会在某个函数中被访问到呢?
这种局部变量就是未逃逸的变量,而这个变量若是在其余的地方也能被访问到呢?这说明这个变量逃逸出了当前的做用域。经过逃逸分析咱们能够知道哪些变量没有逃逸出当前做用域,那这个对象内存就能够在栈中分配,随着调用的结束,随着线程的继续执行完成,栈空间被回收,这个局部变量分配的内存也会一块儿被回收。
方法区存放了被加载的Class信息、常量、静态变量和JIT编译以后的结果等数据,与堆同样,方法区也是被全部线程共享的内存区域。但与堆不一样,相对于堆的GC力度,这块的垃圾回收力度能够说是小了很是多,可是仍然有针对常量的GC。
虚拟机栈是线程私有的,因此在多线程下不须要作同步的操做,是线程安全的。当每一个方法执行时,就会在当前线程中虚拟机栈中建立一个栈帧,每一个方法从调用到结束的过程,就对应了栈帧在虚拟机栈中的入栈、出栈的过程。那天然而然,栈帧中应该存放的就是方法的局部变量、操做数栈、动态连接和对应的返回信息。
不知道你遇到过在方法内写递归时,因为退出条件一直没有达到,致使程序陷入了无限循环,而后就会看到程序抛出了一个StackOverflow
的错误。其所对应的栈就是上面提到的操做数栈。
固然这是在内存足够的状况下,若是内存不够,则会直接抛出OutOfMemory
,也就是常说的OOM。
本地方法栈的功能与虚拟机栈相似,区别在于虚拟机栈是服务于JVM中的Java方法,而本地方法栈则服务于Native的方法。
其实堆中的区域还能够划分为新生代和老年代,再分割的细一点,能够到Eden、From Survivor、To Survivor。首先分配的对象实例会到Eden区,在新生代这块区域通常是最大的,与From Survivor的比例是8:1,固然这个比例能够经过JVM参数来改变。并且当分配的对象实体很大的时候将会直接进入到老年代。
为何要对堆进行更加细致的内存区域划分,实际上是为了让垃圾回收更加的高效。
那JVM是如何判断哪些对象是“垃圾”须要被回收呢?咱们就须要来了解一下JVM是如何来判断哪些内存须要进行回收的。
实现的思路是,给每一个对象添加一个引用计数器,每当有其余的对象引用了这个对象,就把引用计数器的值+1,若是一个对象的引用计数为0则说明没有对象引用它。
乍一看是没有问题的,那为何Java并无采起这种呢?
想象一下这个场景,一个函数中定义了两个对象O1和O2,而后O1引用了O2,O1又引用了O1,这样一来,两个对象的引用计数器都不为0,可是实际上这两个对象不再会被访问到了。
因此咱们须要另一种方案来解决这个问题。
可达性分析能够理解为一棵树的遍历,根节点是一个对象,而其子节点是引用了当前对象的对象。从根节点开始作遍历,若是发现从全部根节点出发的遍历都已经完成了,可是仍然有对象没有被访问到,那么说明这些对象是不可用的,须要将内存回收掉。
这些根节点有个名字叫作GC Roots,哪些资源能够被看成GC Roots呢?
咱们刚刚聊过,在引用计数中,若是其引用计数器的值为0,则占用的内存会被回收掉。而在可达性分析中,若是没有某个对象没有任何引用,它也不必定会被回收掉。
聊完了JVM如何判断一个对象是否须要回收,接下来咱们再聊一下JVM是如何进行回收的。
顾名思义,其过程分为两个阶段,分别是标记和清除。首先标记出全部须要回收的对象,而后统一对标记的对象进行回收。这个算法的十分的局限,首先标记和清除的两个过程效率都不高,并且这样的清理方式会产生大量的内存碎片,什么意思呢?
就是虽然整体看起来还有足够的剩余内存空间,可是他们都是以一块很小的内存分散在各个地方。若是此时须要为一个大对象申请空间,即便整体上的内存空间足够,可是JVM没法找到一块这么大的连续内存空间,就会致使触发一次GC。
其大体的思路是,将现有的内存空间分为两半A和B,全部的新对象的内存都在A中分配,而后当A用完了以后,就开始对象存活判断,将A中还存活的对象复制到B去,而后一次性将A中的内存空间回收掉。
这样一来就不会出现使用标记-清除所形成的内存碎片的问题了。可是,它仍然有本身的不足。那就是之内存空间缩小了一半为代价,而在某些状况下,这种代价实际上是很高的。
堆中新生代就是采用的复制算法。刚刚提到过,新生代被分为了Eden、From Survivor、To Survivor,因为几乎全部的新对象都会在这里分配内存,因此Eden区比Survivor区要大不少。所以Eden区和Survivor区就不须要按照复制算法默认的1:1的来分配内存。
在HotSpot中Eden和Survivor的比例默认是8:1,也就意味着只有10%的空间会被浪费掉。
看到这你可能会发现一个问题。
既然你的Eden区要比Survivor区大这么多,要是一次GC以后的存活对象的大小 大于Survivor区的总大小该怎么处理?
的确,在新生代GC时,最坏的状况就是Eden区的全部对象都是存活的,那这个JVM会怎么处理呢?这里须要引入一个概念叫作内存分配担保。
当发生了上面这种状况,新生代须要老年代的内存空间来作担保,把Survivor存放不下的对象直接存进老年代中。
标记-整理其GC的过程与标记-清楚是同样的,只不过会让全部的存活对象往同一边移动,这样一来就不会像标记-整理那样留下大量的内存碎片。
这也是当前主流虚拟机所采用的算法,其实就是针对不一样的内存区域的特性,使用上面提到过的不一样的算法。
例如新生代的特性是大部分的对象都是须要被回收掉的,只有少许对象会存活下来。因此新生代通常都是采用复制算法。
而老年代属于对象存活率都很高的内存空间,则采用标记-清除和标记-整理算法来进行垃圾回收。
聊完了垃圾回收的算法,咱们须要再了解一下GC具体是经过什么落地的, 也就是上面的算法的实际应用。
Serial采用的是复制算法的垃圾收集器,并且是单线程运做的。也就是说,当Serial进行垃圾收集时,必需要暂停其余全部线程的工做,直到垃圾收集完成,这个动做叫STW(Stop The World) 。Golang中的GC也会存在STW,在其标记阶段的准备过程当中会暂停掉全部正在运行的Goroutine。
并且这个暂停动做对用户来讲是不可见的,用户可能只会知道某个请求执行了好久,没有经验的话是很难跟GC挂上钩的。
可是从某些方面来看,若是你的系统就只有单核,那么Serial就不会存在线程之间的交互的开销,能够提升GC的效率。这也是为何Serial仍然是Client模式下的默认新生代收集器。
ParNew与Serial只有一个区别,那就是ParNew是多线程的,而Serial是单线程的。除此以外,其使用的垃圾收集算法和收集行为彻底同样。
该收集器若是在单核的环境下,其性能可能会比Serial更差一些,由于单核没法发挥多线程的优点。在多核环境下,其默认的线程与CPU数量相同。
Parallel Scavenge是一个多线程的收集器,也是在server模式下的默认垃圾收集器。上面的两种收集器关注的重点是如何减小STW的时间,而Parallel Scavenge则更加关注于系统的吞吐量。
例如JVM已经运行了100分钟,而GC了1分钟,那么此时系统的吞吐量为(100 - 1)/100 = 99%
。
吞吐量和短停顿时间其侧重的点不同,须要根据本身的实际状况来判断。
GC的总时间越短,系统的吞吐量则越高。换句话说,高吞吐量则意味着,STW的时间可能会比正常的时间多一点,也就更加适合那种不存在太多交互的后台的系统,由于对实时性的要求不是很高,就能够高效率的完成任务。
STW的时间短,则说明对系统的响应速度要求很高,由于要跟用户频繁的交互。由于低响应时间会带来较高的用户体验。
Serial Old是Serial的老年代版本,使用的标记-整理算法, 其实从这看出来,新生代和老年代收集器的一个差异。
新生代:大部分的资源都是 须要被回收老年代:大部分的资源都不须要被回收
因此,新生代收集器基本都是用的复制算法,老年代收集器基本都是用的标记-整理算法。
Serial Old也是给Client模式下JVM使用的。
Parallel Old是Parallel Scavenge的老年代版本,也是一个多线程的、采用标记-整理算法的收集器,刚刚讨论过了系统吞吐量,那么在对CPU的资源十分敏感的状况下, 能够考虑Parallel Scavenge和Parallel Old这个新生代-老年代的垃圾收集器组合。
CMS全称(Concurrent Mark Sweep),使用的是标记-清除的收集算法。重点关注于最低的STW时间的收集器,若是你的应用很是注重与响应时间,那么就能够考虑使用CMS。
从图中能够看出其核心的步骤:
- 首先会进行初始标记,标记从GCRoots出发可以关联到的全部对象,此时须要STW,可是不须要不少时间
- 而后会进行并发标记,多线程对全部对象经过GC Roots Tracing进行可达性分析,这个过程较为耗时
- 完成以后会从新标记,因为在并发标记的过程当中,程序还在正常运行,此时有些对象的状态可能已经发生了变化,因此须要STW,来进行从新标记,所用的时间大小关系为
初始标记 < 从新标记 < 并发标记
。- 标记阶段完成以后,开始执行并发清楚。
CMS是一个优势很明显的的垃圾收集器,例如能够多线程的进行GC,且拥有较低的STW的时间。可是一样的,CMS也有不少缺点。
咱们开篇也提到过,使用标记-清除算法会形成不连续的内存空间,也就是内存碎片。若是此时须要给较大的对象分配空间,会发现内存不足,从新触发一次Full GC。
其次,因为CMS可能会比注重吞吐量的收集器占用更多的CPU资源,可是若是应用程序自己就已经对CPU资源很敏感了,就会致使GC时的可用CPU资源变少,GC的整个时间就会变长,那么就会致使系统的吞吐量下降。
G1全称Garbage First,业界目前对其评价很高,JDK9中甚至提议将其设置为默认的垃圾收集器。咱们前面讲过,Parallel Scavenge更加关注于吞吐量,而CMS更加关注于更短的STW时间,那么G1就是在实现高吞吐的同时,尽量的减小STW的时间。
咱们知道,上面聊过的垃圾收集器都会把连续的堆内存空间分为新生代、老年代,新生代则被划分的更加的细,有Eden和两个较小的Survivor空间,并且都是连续的内存空间。而G1则不同凡响,它引入了新的概念,叫Region。
Region是一堆大小相等可是不连续的内存空间,一样是采用了分代的思想,可是不存在其余的收集器的物理隔离,属于新生代和老年代的region分布在堆的各个地方。
上面H则表明大对象,也叫Humongous Object。为了防止大对象的频繁拷贝,会直接的将其放入老年代。G1相比于其余的垃圾收集器有什么特色呢?
从宏观上来看,其采用的是标记-整理算法, 而从region到region来看,其采用的是复制算法的,因此G1在运行期间不会像CMS同样产生内存碎片。
除此以外,G1还能够经过多个CPU,来缩短STW的时间,与用户线程并发的执行。而且能够创建可预测的停顿时间模型,让使用者知道在某个时间片内,消耗在GC上的时间不得超过多少毫秒。之因此G1可以作到这点,是由于没像其他的收集器同样收集整个新生代和老年代,而是在有计划的避免对整个堆进行全区域的垃圾收集。
这个图来自于参考中的博客,总结的很到位。