对于java程序员小白来说(没错,是我),jvm总是笼罩着一层神秘的面纱的,java是如何分配内存的,又是如何回收内存的呢?有人说内存管理是一道墙,墙里面的人想出去,墙外面的人想进去。而我们java程序员,就是硬着头皮进去的那群人...
学习的目的很简单
-----知道jvm是什么东西,它是干什么的,以及它是怎么干的
-----知其为何,何其为之
-----夸夸其谈
-----装逼
把书读薄,把知识丰满。
本文旨在记录虚拟机学习过程中的一些理解和知识点,如果有幸能帮助到你,或许可以给你提供帮助,乐意之至。
由java虚拟机规范规定,将java虚拟机管理的内存分为以下几个运行时的数据区域(这只是规范上面的分区,具体不同的虚拟机会有不同的实现)
其中方法区和堆(黄色块)是 所有线程共享 的区域,虚拟机栈,本地方法栈和程序计数器这三块是 线程私有的区域,各个线程之间互不影响,独立存储。
堆是内存池中最大的一块区域,也是jvm垃圾回收最主要的区域,那么我们就来梳理一下堆的知识。
对象的创建过程:前面讲到,堆是用来存放对象实例的,那么一个对象到底是如何创建又是如何放入堆中的呢?
a). java中通常使用一个new关键字来创建对象,当虚拟机接收到new指令时,需要检查这个类是否已经被加载、解析和初始化,如果没有的话,需要进行相应的类加载过程。类加载的过程是分为多个阶段的,其中 初始化阶段 ,虚拟机严格规定了有且只有5种情况,属于对一个类进行 主动引用 ,必须立即堆类进行“初始化”,除此之外的所有引用都是被动引动,不会触发初始化操作。
b).虚拟机为对象分配内存,如果堆里面的内存是规整的,已经使用的内存放一边,空闲的内存放另一边,这种分配方式称为“ 指针碰撞 ”;如果内存不是规整的,那么虚拟机就要维护一个列表,记录哪些内存块是可用的,分配的时候需要从列表里面找到一块足够大的内存空间划分给对象,称为“ 空闲列表 ”
c).内存分配之后,将分配到的内存空间初始化为零值
d).此时在虚拟机看来,一个新对象已经创建完成了,java执行对应的init方法,按照程序进行初始化,得到一个真正可用的对象
对象的内存布局:对象可以分为3块区域,对象头,示例数据和对齐填充,对象头又由 Mark Word 和 类型指针组成
a).Mark Word 用于存储对象自身运行时数据(HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等)
b). 类型指针 即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
对象的定位:前面提到,java方法中对象的引用是存放在栈上的,而对象的实例是存放在堆上的。目前主流的访问方式有使用 句柄 和 直接指针 两种
a).句柄:java堆中划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,句柄中含有两个地址,一个指向堆中这个对象的实例的地址,一个指向方法区中这个对象类型信息的地址
b).直接指针:reference中直接存储对象的实例地址,然后通过取到对象头中的类型指针来确定方法区中这个对象类型信息的地址
c).比较:采用句柄的最大好处就是对象的实例地址在发生改变的时候,只需要更新句柄中指向对象实例的数据指针,不需要修改reference本身,但是可以看到相对比直接指针访问,句柄多了一次指针定位,这使得它的速度更慢
java堆溢出:常见的就是OOM异常,导致堆溢出的原因通常有两个, 内存泄露 和 内存溢出
a).内存泄漏:对象已经可以回收,但是却没有被回收。 那么问题来了,怎么判断一个对象是不是可以被回收或者说应该被回收呢?通常虚拟机是采用 可达性算法 来判定的,这个后面我会继续展开一下。
b).内存溢出:内存中的对象确实都是必须存活的,换而言之就是堆的容量已经成为了程序的瓶颈了,一方面我们可以尝试调大堆的内存,另一方面可以优化我们的代码,检查是否真正需要这么多对象,是否存在对象生命周期过长、持有状态时间过长的情况;总结起来就是“开源节流”。
堆内存的分配和回收:我们着重讲一下虚拟机在堆上的垃圾收集,在线程私有的内存分区中,内存会随着方法结束或者线程的结束而回收,所以这部分没有太多的操作空间,而堆和方法区是被所有线程所共享的一块区域,也是我们有必要深入了解的区域。
a).判断对象的存活:在内存泄漏的知识点中提到过 可达性算法 这一概念,在可达性算法中,有一个 GC Roots 的概念,这个算法的基本思想就是如果一个对象到 GC Roots没有任何引用链相连(也就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,他们会被判定为是可回收的对象;
除此之外,还有另一个更简单的方法,给每个对象添加一个引用计数器,如果有一个地方引用它,计数器加1,引用失效的时候,计数器减1,计数器的值为0时这个对象就是可回收的,这是 引用计数算法 的基本思想,但是引用计数算法无法解决循环引用的问题,可以看到如下图的4、5、6三个对象,存在相互循环的引用,导致这三个对象的引用计数器不为0,但是这三个对象的的确确是属于需要回收的范畴的,这也是很多主流虚拟机放弃使用引用计数算法的原因。
在java中,可以作为GC Roots的对象有4种:
1).虚拟机栈(栈帧中的本地变量表)中引用的对象 2).方法区中类静态属性引用的对象 3).方法区中常量引用的对象 4).本地方法栈中JNI(即一般说的Native方法)引用的对象 复制代码
b).对象引用:java中定义了四种引用,引用强度从强到弱依次是 强引用 、 软引用 、 弱引用 和 虚引用 。
c).回收对象:如果一个对象是GC Roots不可达的,也 不一定 会被回收,如果这个对象 覆盖 了finalize()方法并且这个finalize()方法是 第一次 被虚拟机调用,那么此时会执行对象的finalize()方法,如果在方法中,它重新与引用链中的任意一个对象建立了 关联 ,那么它就可以逃过被回收的命运。
上面介绍了堆上对象从创建到回收的过程,那么下面我们就来了解一下虚拟机到底是用什么样的方式来回收对象。
标记-清除算法:将回收过程分为“标记”和“清除”两个阶段,首先标记出需要回收的对象, 然后标记完成以后统一回收所有被标记的对象,这是最基础的收集算法,主要有两个比较大的缺陷, 一是效率低;二是产生大量不连续的内存碎片,这些空间无法被较大对象利用起来 复制算法:将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这块的内存用完了, 就将存活的对象复制到另外一块上,然后将这块的内存全部清理掉; 这样复制到另一块上的已使用内存是规整的,再分配时就可以使用前面提到过的“指针碰撞法” 但是我们可以发现这种做法每次只能使用一半的内存,付出的代价未免太大。 标记-整理算法:标记的过程与“标记-清除”算法一样,后续将所有存活的对象向一端移动,然后清理掉边界外的内存 分代收集算法:准确来说这不是一种算法,而是根据虚拟机中不同对象的存活周期不同,将内存进行分代, 一般是分为新生代(Young)和老年代(Old),新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低, 比较适合用复制算法,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。 在新生代中经历了多次(具体看虚拟机配置的阀值,默认15次)GC后仍然存活下来的对象会进入老年代中。 老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢, 老年代没有额外的空间进行分配担保,所以比较适合用“标记-清理”或者“标记-整理”算法进行回收 复制代码
上面说到,新生代是使用复制算法来回收内存的,复制算法最致命的缺陷就是会浪费一半的内存,由于新生代中对象的特点就是“朝生夕死”,所以并不需要将按照1:1的比例来划分。 HosSpot虚拟机将新生代分为Eden区和Survivor区,默认为8:1,同时survivor有两个,所以整体的比例应该是8:1:1,也就是说新生代中的可用空间是90%。但我们无法保证每次回收都只有不多于10%的对象存活下来,那么当survior区的空间不足时,会依赖老年代来进行分配担保,直白的讲就是把survivor区中放不下的对象放到老年代中。
那么为什么要设置两个survivor呢?如果只有一个可以嘛?(化身为面试官了嗷) 答案肯定是不可以嘛。。结合复制算法的思想,我们可以想到一块survior区必然是要保持为空的,以便我们将存活的对象复制过去。假设只有一块空白surviorA,当eden区满了的时候,触发第一次minorGC,我们将eden区中存活到达一定时间的对象复制到surviorA中,很快第二次触发minorGC,eden区有部分对象要进入survivorA区中,而surviorA区本身也有一部分对象要被回收,此时就会在survior中产生 内存碎片 ,根据复制算法的思想,我们希望得到的是规整的内存空间;如果是两个survior区的话,此时就可以将eden区和存放有对象的surviorA区中存活的对象都复制到空白surviorB区中,然后清空前面提到的eden区和surviorA区,此时原先存放有对象的surviorA变成了空白survior区,等待下一次minorGC存放对象。
我们经常会听到MinorGC和FullGC或者说MajorGC这种说法,那么它们具体代表的含义你真的清楚嘛?
MinorGC:也叫做新生代GC,顾名思义就是发生在新生代的垃圾收集动作。MinorGC非常频繁,同时回收的速度也很快 FullGC/MajorGC:老年代GC,指的是发生在老年代的GC,MajorGC经常会伴随着至少一次的MinorGC,同时MajorGC的速度一般要比MionrGC慢10倍以上。 复制代码
以上内容梗概基本来源于《深入理解java虚拟机》这本书的前三章,也是笔者重点阅读的章节,属于比较基础和理论的部分,其中结合了笔者自身的理解和粗浅认识,如果有偏颇之处,望读者不吝指出。(太长的篇幅容易产生阅读抵触~ 哈哈哈)后面有机会的话,会填坑一下虚拟机的类加载机制和java内存模型和线程部分。 ps:大家可以猜一下哪张图片是笔者盗来的,哈哈哈,会不会太简单了(总有一些图片是你看见了就不忍心自己画的)