今天闲来无事,有空闲的时间,所以想坐下来聊一聊Java的GC以及Java对象在内存中的分配。
和标题一样,本篇绝对是用最直接最通俗易懂的大白话来聊
文章中基本不会有听起来很高大上的专业术语,也不会有太多概念性的描述,本着一看就懂的原则来写。
因为之前看很多文章都是概念性的东西太多,让人越看越迷糊,越看越觉得有距离感,不接地气,看完之后跟没看一样。我最不希望的就是这个,所以我写的东西都是尽量用最通俗易懂、最接地气的大白话来描述,我写所有博客的愿景是让看文章的人看完后觉得有所收获,希望对看到文章的小可爱们有所帮助。当然,写得如有不对或不妥之处,还请海涵,欢迎下方留言指正,共同进步
一、Java的垃圾回收GC先来聊GC,是因为这个过程中会涉及到JVM对堆内存的分代管理,以及eden区等等这些概念,有了这些概念后,再去聊Java对象在内存中的分配会好理解很多,所以先来聊一聊GC。
1、确定垃圾对象
GC(Garbage Collection)顾名思义就是垃圾回收的意思
那既然要回收垃圾对象,首先得知道哪些对象是垃圾吧,怎么判定哪些是垃圾对象呢?
有两个办法:
1)第一种是引用标记法,简单来说就是这个对象被一个地方引用了,这个计数的标记就加一,有地方释放了这个对象的引用,这个标记就减一。这个算法认为当一个对象的引用数量为零,那就意味着没有地方引用这个对象了,那么此时这个对象就是垃圾对象。但是引用标记法有个致命的问题,那就是解决不了循环引用的问题,所以Java并没有采用引用标记法。具体什么是循环引用,并不是这里的重点,可以自行查一下
(2)第二种是可达性分析法,简单说,就是从一个根对象出发,到某个对象如果有可达的路径,就认为它不是垃圾对象,否则认为是垃圾对象。对象也被称为GC Root,那有个关键的问题:哪些对象可以被视为根对象呢?
在《深入理解JVM虚拟机》中有这样的描述,以下三种可以作为跟踪对象
1.虚拟机栈:栈帧中的局部变量表引用的对象
2.方法区中的静态变量和常量引用的对象
3.native方法引用的对象
确定了根对象后,通过这个算法就可以定位到哪些对象是垃圾对象了
2、回收垃圾对象通过上边的方法知道了哪些对象是垃圾对象后,就可以回收垃圾了,回收垃圾同样也有几种不同的方法
常见的有三种
(1)标记-清除法:简单的说就是把那些已经标记为垃圾的对象进行清除,这种算法有一个缺点就是清理完成后会产生大量内存碎片(因为这些垃圾对象在内存中很多都是分散分布的,不可能总是连续的在一起的,所以清理完会导致内存不连续)
(2)标记-压缩法:简单来说就是把那些已经标记为垃圾的对象进行清除后,再把分散开的内存碎片进行整理
(3)复制法:简单说就是在要回收的内存区域之外,再另准备一块空白的内存,把不是垃圾的对象直接复制到这个空白内存区域里,然后就可以简单粗暴的把要回收的那个内存区域全部清空。
不同的算法有不同的特点,不同的算法适用于不同的场景:
少量对象存活,适合使用复制算法
大量对象存活,适合使用标记清理或者标记压缩法
所以JVM把堆内存进行了分代来管理,分为年轻代、老年代和永久代。不同的代,适用不同的回收算法
由于年轻代的对象大部分是朝生夕死,只有少部分对象存活,所以很适合用复制算法
但是复制算法有一个很大的缺点,就是需要两块一样大小的内存来进行轮换,这就导致了会浪费一半的空间。但是经过研究统计发现,年轻代每次只有大概10%的对象存活,所以就又把年轻代分为了eden区、servivor from区、servivor to区,他们的比例是8:1:1,也就是eden区占80%,servivor from和servivor to 它俩作为轮换区域,分别占10%(也就是用尽量少的内存资源来实现复制算法)。这个8:1:1面试的时候可能会被问到,需要注意一下
但是每次大概只有10%对象存活,这是个统计的概率事件,实际中并不一定每次都只有10%或者少于10%的对象存活
所以那要万一有时候存活的对象大于10%呢,那准备的servivor区的空间不就不够复制算法运行了吗?
这怎么办?所以这时候需要担保,具体的说就是用老年代来作为担保,每次年轻代GC(minor GC)的时候都会去检查老年代最大连续可用空间是否大于年轻代中所有对象总和的大小,如果大于则认为这个担保是没有风险的,就进行正常的minor GC;
但是如果发现小于,那就会继续判断你是否设置了允许担保失败,如果你设置的是不允许担保失败,那这次minor GC就要改为一次Full GC;如果你设置的是允许担保失败,那它会继续去判断老年代最大连续可用空间是否大于历次晋升到老年代的对象大小,如果大于,就尝试着去minor GC一次,尽管这次GC是有风险的,如果尝试后失败了,那就Full GC;如果小于的话,那这次minor GC也要改为一次Full GC。
可以发现如果开启了允许担保失败的话,有可能会出现绕了一大圈最后发现还是失败了的情况,最后还是得去Full GC
虽然会出现这种情况,但是还是建议设置开启这个允许担保失败,因为开启了允许担保失败后,会在一定程度上减少Full GC的次数,要知道一次Full GC的时间是minor GC的几倍甚至几十倍,所以要尽量避免Full GC
画一下上述空间担保分配的简要流程图
以上只是对垃圾回收的大体总结,并不涉及具体的细节
有了以上大体了解后,建议拜读《深入理解JVM虚拟机》一书,写得确实很好的一本书,相信读后定会收获颇丰。
讲垃圾回收一定绕不开那七种垃圾回收器:Serial(年轻代)、ParNew(年轻代)、Paralle Scavenge(年轻代)、Serial Old(年老代)、Parallel Old(年老代)、CMS(Concurrent Mark Sweep年老代)、G1
不同垃圾回收器使用的算法不同(但都是基于标记清除或标记整理或复制算法这三种的),用的场景也不同
关于这七种垃圾回收器,本篇并不展开详述,只是抛砖引玉,如有需要,也可自行参考《深入理解JVM虚拟机》
二、Java对象在内存中的那些事下面来聊一聊Java对象在内存中的那些事。
我总结了大概这几方面:对象在内存中的创建、对象在内存中的布局、对象在内存中的访问定位
下面分别展开详述
1、对象在内存中的创建
对象在内存中创建到底是怎么样一个过程呢?大致有以下几个步骤:
(1)虚拟机遇到一条new指令时,首先检查这个对应的类能否在常量池中定位到一个类的符号引用
(2)判断这个类是否已被加载、解析和初始化
如果没有,则必须进行相应的加载过程
(3)为新生对象在Java堆中分配内存空间
分配内存有两种方式:指针碰撞和空闲列表
指针碰撞是指:假设Java堆中内存绝对规整,所有已使用的内存都放在一边,空闲的内存都放在另一边,中间放着一个指针作为分界点,那这时候分配内存就仅仅是把这个指针向空闲的那边挪动一段(挪动的大小就是需要分配的对象的大小),这种分配方式就称为“指针碰撞”。
空闲列表是指:如果Java堆内存并不是规整的,已经使用的内存和空闲的内存相互交错,那肯定就没办法使用指针碰撞的方式进行内存分配了,这时候虚拟机就必须维护一个列表来记录哪些内存块儿是可用的,然后在需要进行内存分配的时候就从列表中找到一块儿足够大的内存划分给对象,并且更新列表上的记录,这种分配方式称之为“空闲列表”。
到底用哪种方式进行分配是由堆内存是否规整决定的,而堆内存是否规整又是由你具体使用的哪种垃圾回收器决定的,
如果你使用的是“标记-清除”这种类型的垃圾回收器,那么会导致堆内存不规则产生内存碎片,适合使用空闲列表的方式;
如果你使用的是“标记-整理(压缩)”这种类型的垃圾回收器,适合使用指针碰撞的方式。
除了讨论使用哪种方式进行内存分配外,还有一个问题需要考虑:对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针指向的位置(指针碰撞的方式)或者找到空闲空间给对象分配,并更新列表(空闲列表的方式),在并发情况下也并不是安全的,因为上述的操作并不能保证其原子性,很可能出现不同的对象申请到同一块内存的情况
解决这个问题有几种方案:基于硬件指令的CAS方式来保证操作的原子性或者使用TLAB的方式,再或者可以使用栈上分配
栈上分配和TLAB的具体内容本篇不展开讨论,具体内容可参考我之前写的另一篇:用大白话来聊一聊Java对象的栈上分配和TLAB
(4)内存分配完之后,虚拟机需要将分配到的内存空间都初始化为零值
比如int 型的零值是0,布尔的零值是false,引用数据类型零值是null等等
(5)设置对象头相关数据:GC分代年龄、对象的哈希码 hashCode、元数据信息等等
(6)执行<init>方法
执行<init>方法包括但不仅限于:构造代码块儿、构造函数,具体步骤如下
1)父类静态变量,父类静态代码块执行初始化
2)子类静态变量,子类静态代码块执行初始化
3)父类全局变量,父类构造代码块执行初始化
4)父类构造函数执行
5)子类全局变量,子类构造代码块执行初始化
6)子类构造函数执行
上述就是针对对象创建底层步骤的一些总结
对象在内存里创建出来后,那创建完的对象在内存中是什么样的?对象里都包括哪些内容?
2、对象在内存中的布局
在虚拟机中,对象在内存中的存储布局可分为三块:对象头、实例数据和对齐填充
(1)对象头:对象头用于存储对象的元数据信息
对象头又可以分为两块内容:第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别位32bit和64bit,官方称它为 Mark Word。对象头的另一部分是类型指针,指向它的类元数据的指针,用于判断对象属于哪个类的实例,另外,如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
!!!注意:对象头这一块很重要,它是实现synchronized锁的基础,也是后续偏向锁,轻量级锁,自旋锁等锁优化,锁升级的基础
(2)实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。父类定义的变量会出现在子类定义的变量的前面。各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。
(3)对齐填充
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。为什么需要有对齐填充呢?由于hotspot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
再给个图帮助理解记忆
以上就是对象在内存中布局的一些总结
对象创建好了,也知道创建的对象在内存中存储的布局结构了
咱们创建对象肯定是为了访问它,使用它,那怎么访问它呢?
3、对象在内存中的访问定位建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位访问堆中的对象的具体位置,所以对象的访问方式取决于具体的虚拟机实现而定。目前主流的访问方式有使用句柄和直接指针两种。
(1)句柄的访问方式
如图所示,如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,句柄池中放的是一个一个的句柄,句柄中存的是对象实例数据与对象类型数据的指针引用。栈中局部变量里reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针
(2)直接指针的访问方式
如图所示,直接指针访问对象,栈中局部变量里reference中存储的就是对象地址,相当于一级指针。
(3)对比
这两种对象访问方式各有利弊,使用句柄访问的最大好处就是在移动对象时(如垃圾回收的标记整理算法在回收完垃圾对象后需要把剩下存活的对象进行整理移动,以减少内存碎片),reference中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;但是如果使用直接指针方式的话,在对象被移动的时候需要修改reference中存储的地址。从效率方面比较的话,直接指针的效率要高于句柄,因为直接指针的方式只进行了一次指针定位,节省了时间开销,HotSpot采用的直接指针的实现方式。
上述就是对Java对象的访问定位的理解与总结
咱们说完了对象在内存中分配的以及访问的具体细节后,下面从宏观上来说一下对象分配的几个特点
4、对象分配的几个特点对象的分配有以下几个特点
(1)对象优先分配在eden区
(2)大对象直接进去老年代,这个阈值可以自定义
(3)年长的对象进去老年代,默认的年长值是15
(4)动态对象年龄判断
以上几点的具体内容也可参考《深入理解JVM虚拟机》一书
OK,今天就先聊到这里