本文分为两部分,一部分对JVM进行大致总结,第二部分为对周志明的JVM进行梳理
一:大致总结
首先利用上面这张图来大致说一下JVM:首先把一个.java文件编译为一个.class文件(我们可以认为.java是用java写的,.class是用汇编语言写的),然后通过类加载子系统把程序运行需要的内容放在内存中对应的区域(比如方法区,堆。关于这些这一部分下面会稍微展开一点。我们可以大致理解为方法区放类,堆放对象实例和数组,本地方法栈放C等语言写的方法,java方法栈放java的方法,程序计数器告诉CPU现在程序执行到哪个方法了)。由解释器从内存中读取内容执行。JIT编译器把经常被使用的代码提前编译好,方便程序运行。
在一个类被加载的过程中(从.class文件到真正可以用的内存中),可以分为加载,链接,初始化三个阶段。第一个阶段加载就是先在内存中开辟一个区域,作为这个类对应的区域。然后是链接:验证步判断文件是否正确(被篡改等等),防止在后续加载过程中崩溃;准备步为静态变量赋0值,int就是0,boolean就是false等等;解析就是把符号引用转为直接引用,即把人能看懂的一个类名(符号引用)转化为内存中这个类的地址(直接引用)。然后进行初始化,之前的静态变量被真正赋值,静态方法被执行。到这里一个类才算真正被加载好。
java的外部库有很多,在运行时没必要全部加载,只需要加载我们要用的库(类)就行了。类加载器就是用来干这个的。首先JVM加载BootStrapClassLoader,这个加载器是用C/C++写的,它加载一些必要的库,即lang包util包(此时ClassLoader类就被加载了)。然后继承ClassLoader的类来对别的包进行加载,ExtClassLoader加载一个外部包,AppClassLoader则可以指定加载哪些包
通过阅读源码我们可以发现,AppClassLoader有一个parent属性,这个属性是一个ExtClassLoader。当AppClassLoader需要加载一个类时,会首先判断这个类有没有被ExtClassLoader加载,如果被加载了,那就就返回ExtClassLoader加载的内容。如果没有被加载,那么会判断这个类是否应该由BootStrapClassLoader加载,如果也不应该,那么再由AppClassLoader加载。
展示这张图的主要目的是为了说明一个问题:蓝色区域(方法区和堆)是所有线程共享的,而绿色区域(方法栈和计数器)则是每个线程私有的。也就是说,在一个虚拟机中,会有很多个方法栈、很多歌程序计数器,而方法区和堆则只有一个
虚拟机处理的是字节码指令(.class文件中的语句),一行一行执行的时候,需要为线程标识下一个应该执行哪一句。程序计数器就是干这个的。
用来存储当前线程正在执行且未执行完的方法。当一个线程的全部方法执行完毕但并未被销毁的时候,他的虚拟机栈是依然存在的(即使这个时候栈中可能并没有方法),因此虚拟机栈不需要垃圾回收。
在主流的虚拟机中,栈所使用的内存是连续的。如果是固定大小的虚拟机栈,那么比较好理解,如果大小是可以动态调整的,那么当虚拟机栈需要扩大时,会向内存中申请一个更大的连续区域,并把当前栈中的内容复制到新申请的区域中。
二:周志明总结
本文为JVM的入门篇,主要介绍JVM的常见内容
总述
我们知道java本身不直接操作内存,那么我们就需要一个工具来操作内存,这个工具就是虚拟机
首先我们要明确一个事情:java不是Oracle公司独有的。我们常说的java可能指的是Oracle公司推出的不同的java版本,比如java8,java17等等。但也有一些其他公司也推出了java语言。他们都是java,只是具体实现有所不同。
java的虚拟机依照功能不同,可以大致分为五个部分:方法区、堆、java虚拟机栈,本地方法栈和程序计数器。需要指出的是,这些区域是逻辑上的区域,他们可以有很多各自不同的实现。不同的JVM就会有不同的实现细节,我们这里借用Oracle公司使用的HotSpot虚拟机来介绍。
我们会首先介绍这五部分存放什么内容,然后再具体介绍基于虚拟机的操作。
虚拟机组成及其中内容
一、方法区
方法区中主要储存类的一些加载信息、运行时常量池、静态变量、编译后代码。
类的加载信息比如说字段(field,即类中定义的变量),方法,接口
运行时常量池参考文章关于常量池 (属性)
静态变量:被static修饰的变量
编译后的代码(即class文件中的内容,是二进制数据)
在java1.8之前,方法区采用永久代(Permanent Generation)的形式出现,而1.8及其之后为元空间(Meta Space)。永久代是虚拟机内存(或者说是堆内存)的一部分。他的大小在早期常用虚拟机中是固定的。1.8之后,将方法区的实现方法由永久代改为元空间,此时方法区为本地内存的一部分,并且大小不再有上限。方法区中的字符串常量池也被转移到堆中。
这里插一句,字符串常量池只包含内些字面量和intern()的字符串。intern方法是一个native方法。如果一个字符串对象没有在常量池中出现过,那么这个对象的地址会被储存在常量池中,并返回对应的地址(指向原地址的变量也会重新指向这个常量池中的字符串)。如果已经出现过,那么这个字符串对象不动,返回常量池中字符串的地址。(7+之后)
这种转变的主要原因就是永久代的内存管理不灵活(大小固定)。因此换为了更灵活的元空间管理方式。避免大小固定的限制!!
二、堆
堆中存放的是对象的实例和数组的具体内容
三、虚拟机栈
每个线程都会有一个虚拟机栈,用来存放他要执行的方法。
四、本地方法栈
每个线程都会有一个本地方法栈,用来存放本地方法的地方
五、程序计数器
每个线程都会有一个程序计数器,标识当前线程运行到了方法的哪一行
垃圾收集(GC)
标记计数与可达性分析
在垃圾回收之前,首先要确定如何发现垃圾,或者说一个对象在什么时候应该被认为是垃圾。
有两种方法,即标记计数与可达性分析。
标记计数就是为对象维护一个引用头,被引用一次就加1,引用失效时就减一。这种方法很好理解,但在java中不常用。这种方法有一个很明显的问题就是不能处理循环引用。即a引用了b,b引用了a,除此之外没有任何引用。那么此时a和b显然是垃圾,却不能被标记计数法成功发现。
可达性分析需要维护一个“GC roots”集合,通过这个集合中的元素能引用到的对象就是有用的,否则就是没用的(即使他可能被其他对象引用)。关于这个集合中具体有哪些元素,不同的虚拟机实现有不同的策略(这个很好理解,不同的虚拟机有不同的回收策略,那么当然需要不同的标记策略)。
现在的GCroots大多以卡表的形式被实现,但具体细节还没看懂。比如具体如何创建,什么时候更新,把哪些量定义为roots(书上说的很含糊,等要用到了再说)
常见收集算法
1.标记清除
先对所有的垃圾对象标记,然后他们清除(或者叫清空),不被清除的不动
这样做的好处在于思路很简单,缺点在于会产生很多碎片空间而且效率一般
2.标记整理
先对所有的垃圾对象标记,然后把非垃圾对象依次移动到内存的一端,并且把移动之后剩下的空间全部清除。相当于标记一遍,内存整理一遍。
这样的好处在于不会再产生碎片空间,缺点在于速度变慢,而且由于移动了对象,需要修改对象的引用和对象头
3.复制
复制算法需要将内存空间划分成两部分,From空间和To空间(逻辑意义上的)。每次只在一部分的空间里存放对象。需要垃圾收集的时候就标记这一部分空间中的垃圾,然后把非垃圾对象(可达对象)复制到另一个空间中。
复制算法有两个版本,老版本是先全部标记垃圾,然后再遍历一遍复制过去。新版本叫cheney算法。发现一个可达对象就复制一个。现在常用的大多是cheney算法。
优点在于直接复制,不用修改对象头、很快,而且不会产生碎片空间。缺点在于需要两倍的空间开销,而且也需要修改对象的引用,并且如果垃圾少的话需要复制的就很多。
常见垃圾收集器
CMS和G1是最经常问到的,这里先总结。这两个总结完之后会写其他的
CMS收集器
全称Concurrent Mark Swap收集器。顾名思义,就是实现了一个并行的,基于标记清除算法的垃圾收集器。具体来说分为初始标记、并发标记、重新标记和并发清除四个部分。CMS收集器主要用于老年代的收集,经常和ParNew收集器(新生代)组合使用。
对于CMS的运行过程,下面的图十分形象。
初始阶段只找到每个GC roots的直接关联的对象,这一步需要STW操作(Stop the world),但耗时很短。然后是并发标记,这一步可以和用户线程并发进行(一般用标记用到的线程数是(CPU核心线程数+3)/4),这一步虽然耗时长,但是由于是并发处理,所以也无所谓了。由于是并发进行标记,所以标记过程中有的对象的引用关系会改变,这时就需要重新标记,这一步也要STW。最后进行并发清理。
上面提到了STW的概念,这是指的在此时暂停所有用户线程,那么从用户的角度来讲此时就像世界被暂停了一样。这个操作是通过安全点完成的。每个用户线程会每隔一段时间都会检查一个内存点的值。当虚拟机希望暂停用户线程时(暂停世界),就会修改这个点的值来通知用户线程暂停,所有线程都暂停后虚拟机进行操作。
CMS的的优点、或者说主要目的就是为了实现低暂停的并发收集。低暂停在很多应用需要与客户端交流的场景下都很重要。但CMS的缺点在于:1、需要一个多线程的CPU,如果线程数太少的话并发的收集处理并没有优势。2、无法收集浮动垃圾。这里的浮动垃圾指的就是并发清理过程中产生的垃圾,这些垃圾只能等到下次收集才能被处理。这会导致需要为垃圾收集预留空间,如果空间太小的话会导致并发失败Concurrent Mode Failure,强制进行一次STW的Serial Old对老年代进行收集。3、会产生碎片空间,需要在几次CMS之后对空间进行整理。
G1收集器
G1收集器是垃圾收集器的一次大跨越。个人认为主要贡献在于不再划分物理上的新生代和老年代,使得整个堆区域可以被一起收集。并且为之后的垃圾收集器指明了一个努力的方向:之前的要么关注低延迟,要么关注高吞吐量(吞吐量的定义是用户代码运行时间/(用户代码运行时间+垃圾收集时间))。而G1则提供了一个新思路:允许设定垃圾收集的时间,这样就可以让垃圾收集时间和垃圾产生时间实现一个平衡。
G1收集器将整个堆区域划分为很多个region,每个region储存新生代或老年代的对象。通过这种操作,G1不再划分物理上的新生代与老年代,只保留概念上的新生代老年代。每次垃圾收集时,把每个region看做一个整体的区域,要么全收集,要么全不懂。根据region中需要被收集的对象的大小和个数对每个region进行排序。对排序靠前的region进行收集(用户设定的收集时间长,就可以多收集几个,时间短就少收集几个)。收集时采用复制算法,把整个region复制到空白区域,然后把原region清空。需要指出的是,G1的收集算法在整体上可以看做是标记整理,但在局部(每个region)上看其实是标记复制算法。
通过这个图可以看到,与CMS的并发程度不同,G1只有并发标记步是并发,其余都需要STW。(垃圾收集阶段,即筛选回收阶段,由于需要移动对象,因此不得不暂停用户线程)
G1的优势十分明显:1、用户可以选择垃圾收集所需的时间,2、由于是复制算法,因此不会产生碎片空间;3、此外,可以指定最大停顿时间、分Region的内存布局、按收益动 态确定回收集这些创新性设计带来的红利(书上原话,但具体怎么个红利的形式没说)。
但G1同时也存在一些问题:1、由于划分了许多region,因此需要更大的卡表(GC roots记忆集)来标记代间(region间)的引用关系,进而需要更大的内存空间。2、在更新卡表时,CMS和G1的操作也并不一样(这里是为了说明他们负载执行不同)。CMS使用写后屏障来实现卡表更新,G1则使用原始快照搜索(SATB)算法和写前屏障。
我们很难直接断定G1和CMS的优劣关系。但通常情况下,在大内存情况下G1要好一点,小内存时CMS好一点。
需要指出的是,G1作为一个划时代的产品,还是有很多改进空间的,比如ZGC和Shenandoah收集器(这俩还没看,被问到了再说)
Serial收集器
这是最老的收集器,单线程收集,新生代老年代分开收集,新生代标记复制算法(eden区,s1区和s0区),老年代标记整理算法。可以看到这种算法在整个收集过程中都需要STW。但需要提一点:单线程并不一定比多线程差(因为少了线程间沟通的开销)。并且这种算法现在也并不是完全不用了。比如在CMS或G1内存不足时,会触发STW并执行full GC。此时会用Serial Old收集器对整个堆区域进行标记整理
ParNew收集器
ParNew可以理解为并行版本的Serial New。只能处理新生代。它本身其实优势并不多,只是它可以和CMS一起用,所以才有了一定的发展。但现在随着G1的兴起,ParNew已经在退出历史舞台了。
Parallel Scavenge收集器
和前面的传统收集器不同,这个收集器关于如何获得最大的吞吐量(用户运行时间/总时间,前面有写)(调整新生代的大小就可以实现这种目的)。用于处理新生代、标记复制算法
Parallel Old收集器
和Parallel Scavenge搭配使用,在线程资源极为稀缺或关注吞吐量的场合使用。
Shenandoah收集器
回来再说
ZGC
回来再说