🍊 内存模型
🎉 JDK1.6、JDK1.7、JDK1.8 内存模型演变
JDK 1.6:有永久代,静态变量存放在永久代上。
JDK 1.7:有永久代,但已经把字符串常量池、静态变量,存放在堆上。逐渐的减少永久代的使用。
JDK 1.8:无永久代,运行时常量池、类常量池,都保存在元数据区,也就是常说的元空间。但字符串常量池仍然存放在堆上。
🎉 Java虚拟机栈
每一个方法在执行的同时,都会创建出一个栈帧,用于存放局部变量表、操作数栈、动态链接、方法出口、线程等信息。方法从调用到执行完成,都对应着栈帧从虚拟机中入栈和出栈的过程。最终,栈帧会随着方法的创建到结束而销毁。
🎉 堆和元空间
在 JDK 1.8 之后就不在堆上分配方法区了,元空间从虚拟机Java堆中转移到本地内存,默认情况下,元空间的大小仅受本地内存的限制,说白了也就是以后不会因为永久代空间不够而抛出OOM异常出现了。jdk1.8以前版本的 class和JAR包数据存储在 PermGen下面 ,PermGen 大小是固定的,而且项目之间无法共用,公有的 class,所以比较容易出现OOM异常。
🍊 内存屏障
什么是内存屏障:内存屏障是一条指令,该指令可以对编译器和处理器的指令重排做出一定的限制,比如,一条内存屏障指令可以禁止编译器和处理器将其后面的指令移到内存屏障指令之前。
为什么需要内存屏障:编译器和处理器指令重排只能保证在单线程执行下逻辑正确,在多个线程同时读写多个变量的情况下,如果不对指令重排作出一定限制,代码的执行结果会根据指令重排后的顺序产生不同的结果。指令重排后的顺序每次执行时都可能不一样,显然我们希望我们的代码执行结果与代码顺序是逻辑一致的,所以我们需要内存屏障。
🍊 Class文件结构
字节码结构有:魔数,副版本号,主版本号,常量池容量计数器,访问标志,类索引,父类索引,接口索引集合,字段表,方法表,属性表等。
拿魔数来说,它是用来区分文件类型的一种标志,会占用开头的4个字节,之所以需要魔数来区分文件类型,是因为文件名后缀容易被修改,所以为了保证文件的安全性,将文件类型写在文件内部可以保证不被篡改。
魔数后面的4位就是版本号,也是4个字节,前2个字节表示次版本号,后2个字节表示主版本号,这二个版本号是为了标注jdk的一个版本,起到一个jdk版本兼容性的一个作用,比如说高版本的jdk代码不能使用低版本的jdk运行,这个时候主次版本号就起到这个作用。
版本号后二个字节就是常量池容量计数器,写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了,这是为了满足不引用任何一个常量池的项目,比如说匿名内部类,它没有类名,但是它的类名也需要存储到常量池里面,那只能指向常量池的第0号位置,又比如说Object类,它是所有类的父类,那它的父类指向的是常量池中的0的位置。
常量池后面就是访问标志,用两个字节来表示,其标识了类或者接口的访问信息,比如:这个.Class文件是类还是接口,是不是被定义成public,是不是abstract,如果是类,是不是被声明成final等。
访问标志后的两个字节就是类索引,通过类索引我们可以确定到类的全限定名。类索引后的两个字节就是父类索引,通过父类索引可以确定到父类的全限定名,通过这二个全限定名可以获取到类路径。
父类索引后的两个字节是接口索引计数器,接口索引计数器表示接口索引集合中接口的数量。
接口索引计数器后边二个字节是接口索引集合,它是按照当前类实现的接口顺序,从左到右依次排列在接口索引集合中。
接口索引集合后边二个字节是字段表计数器,用来表示字段表的容量,字段表计数器后边是字段表。我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static修饰符、final修饰符、volatile修饰符等,所以也可以像类的访问标志那样,使用一些标识来标记字段。字段表作为一个表,同样他也有自己的结构,比如说访问标志,字段名索引,描述符索引,属性计数器,属性集合。在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须要有不一样的名称,但是对于字节码文件来说,如果两个字段的描述符不一致,那这二个字段重名就是合法的。
字段表后边二个字节是方法表计数器,表示方法表的容量,方法表计数器后边紧跟的是方法表。
和字段表类似,方法表里面也有自己的结构,比如说访问标志,方法名索引,描述符索引,属性计数器,属性集合。
方法表后边紧跟的是属性表计数器,属性表计数器后边紧跟的结构为属性表。
属性表的两大特点:一个是限制比较宽松,没有顺序长度要求;一个是开发者可以根据自己的需求,向属性表中添加不重复的属性。
🍊 类加载机制和双亲委派机制
第一步,加载,一个Java源文件进行编译之后,成为一个class字节码文件存储在磁盘上面,这个时候jvm需要读取这个字节码文件,通过通过IO流读取字节码文件,这一步就是加载。
类加载器将.class文件加载到JVM,首先是看当前类是不是使用自定义加载类加载的,如果不是,就委派应用类加载器加载,如果有加载过这个class文件,那就不用再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类的扩展类加载器同理也会先检查自己是不是已经加载过,如果没有再往上,看看启动类加载器。到启动类加载器,已经没有父加载器了,这时候开始考虑自己是否能加载了,如果自己加载不了,就会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException找不到类异常,这就是所谓的双亲委派机制。
这种机制可以避免,同路径下的同文件名的类,比如,自己写了一个java.lang.obejct,这个类和jdk里面的object路径相同,文件名也一样,这个时候,如果不使用双亲委派机制的话,就会出现不知道使用哪个类的情况,而使用了双亲委派机制,它就委派给父类加载器就找这个文件是不是被加载过,从而避免了上面这种情况的发生。
第二步,验证,JVM读到文件也不是直接运行,还需要校验加载进来的字节码文件是不是符合JVM规范
- 验证的第一步就是文件的格式验证,验证class文件里面的魔数和主次版本号,发现它是一个jvm可以支持的class文件并且它的主次版本号符合兼容性要求,所以验证通过。
- 然后又回到了加载,它会将class文件这个二进制静态文件转化到方法区里面,转化为方法区的时候,会有一个结构的调整,将静态的存储文件转化为运行时数据区,这个转化等于说又回到了加载。
- 接着到了方法区的运行时数据区以后,在java堆内存里面生成一个当前类的class对象,作为方法区里面这个类,被各种访问的一个入口。比如说object类,它是所有类都继承它,访问它,所以它也需要一个被各种类访问的入口。object类先加载,加载完成之后,它经过这一系列的操作,把自己java.lang.object放到这个堆里面,要让其他的类进行访问,这个也是加载。
- 接着到了验证里面的第二步元数据验证,它会对字节码描述的信息进行语义分析,比如:这个类是不是有父类,是不是实现了父类的抽象方法,是不是重写了父类的final方法,是不是继承了被final修饰的类等等。
- 第三步,字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,比如:操作数栈的数据类型与指令代码序列是不是可以配合工作,方法中的类型转换是不是有效等等。
- 第四步,符号引用验证:确保解析动作可以正确执行,比如说:通过符号引用是不是可以找到对应的类和方法,符号引用中类、属性、方法的访问性是不是能被当前类访问等,验证完成之后,需要做准备。
准备就是给类的静态变量分配内存,并赋予默认值。我们的类里,可能会包含一些静态变量, 比如说public static int a = 12; 得给a这个变量分配个默认值 0,再比如public static User user = new User(); 给 static的变量User分配内存,并赋默认值null。如果是final修饰的常量,就不需要给默认值了,直接赋值就可以了。
然后就是解析,解析就是将符号引用变为直接引用,该阶段会把一些静态方法替换为指向数据储存在内存中的指针或者句柄,也就是所谓的直接引用,这个就是静态链接过程,是在初始化之前完成。有静态链接就有动态链接,动态链接是在程序运行期间完成将符号引用替换为直接引用,比如静态方法里面有个方法,在运行的时候,方法是存放在常量池中的符号,运行到这个符号,就是找这个符号对应的方法区,因为代码的指令是加载到方法区里面去的,最后把方法对应代码的地址放到栈帧中的动态链接里。
后面就是初始化了,初始化就是对类的静态变量初始化为指定的值并且会执行静态代码块。比如准备阶段的public static final int a = 12;这个变量,就是准备阶段给static变量a赋了默认值0,这一步就该把12赋值给它了。还有static的User public static User user = new User(); 把User进行实例化。
最后就是使用和卸载了,到此整个加载流程就走完了。
🍊 垃圾回收器、垃圾回收算法、空间分配担保
垃圾回收器有多个,先说新生代的三个垃圾回收器,serial,parnew,parallel scavenge,然后再说老年代的serial old,parallel old,cms,最后在说一下新生代和老年代都使用的垃圾回收器G1吧。
🎉 Serial
Serial是新生代下使用复制算法,单线程运行的垃圾回收器,简单高效,没有线程交互开销,专注于GC,这个垃圾回收器工作的时候会将所有应用线程全部冻结,而且是单核cpu,所以基本不会考虑使用它。
🎉 ParNew
ParNew是新生代下使用复制算法,多线程运行的垃圾回收器,可以并行并发GC,和serial对比,除了多核cpu并行gc其他基本相同。
🎉 Parallel scavenge
Parallel scavenge也是新生代下使用复制算法,可以进行吞吐量控制的多线程回收器,主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景。可以发现新生代的垃圾回收器都使用,复制算法进行gc。
🎉 复制算法
新生代中每次垃圾回收都要回收大部分对象,所以为了避免内存碎片化的缺陷,这个算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,当这一块存活区内存满后将gc之后还存活的对象复制到另一块存活区上去,把已使用的内存清掉。
🎉 分代收集算法
按照分代收集算法的思想,把应用程序可用的堆空间分为年轻代,老年代,永久代,然后年轻代有被分为Eden区和二个Survivor存活区,这个比例又可以分为8比1比1。当第一次eden区发生minor gc,会把存活的对象复制到其中的一个Survivor区,然后eden区继续放对象,直到触发gc,会把eden区和之前存放对象的Survivor区一起gc,二个区存活下来的对象,复制到另一个空的Survivor里面,这二个区就清空,然后将二个存活区角色互换。
🎉 进入老年代的几种情况
当对象在Survivor区躲过一次GC 后,年龄就会+1,存活的对象在二个Survivor区不停的移动,默认情况下年龄到达15的对象会被移到老生代中,这是对象进入到老年代的第一种情况。
这里就会有个问题,JVM分代年龄为什么是15次?
一个对象的GC年龄,是存储在对象头里面的,一个Java对象在JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充。而对象头里面有4个bit位来存储GC年龄。
4个bit位能够存储的最大数值是15,所以从这个角度来说,JVM分代年龄之所以设置成15次是因为它最大能够存储的数值就是15。虽然JVM提供了参数来设置分代年龄的大小,但是这个大小不能超过15。从设计角度来看,当一个对象触发了最大值15次gc,还没有办法被回收,就只能移动到old generation了。另外,设计者还引入了动态对象年龄判断的方式来决定把对象转移到old generation,也就是说不管这个对象的gc年龄是否达到了15次,只要满足动态年龄判断的依据,也同样会转移到old generation。
第二种情况就是,创建了一个很大的对象,这个对象的大小超过了jvm里面的一个参数max tenuring thread hold值,这个时候不会创建在eden区,新对象直接进入老年代。
第三种情况,如果在Survivor区里面,同一年龄的所有对象大小的总和大于Survivor区大小的一半,年龄大于等于这个年龄对象的,就可以直接进入老年代,举个例子,存活区只能容纳5个对象,有五个对象,1岁,2岁,2岁,2岁,3岁,3个2岁的对象占了存活区空间的5分之三,大于这个空间的一半了,这个时候大于等于2岁的对象,需要移动到老年代里面,也就是3个2岁的,一个3岁的对象移动到老年代里面。
📝 空间分配担保
第四种情况就是eden区存活的对象,超过了存活区的大小,会直接进入老年代里面。另外在发生minor gc之前,必须检查老年代最大可用连续空间,是不是大于新生代所有对象的总空间,如果大于,这一次的minor gc可以确保是安全的,如果不成立,jvm会检查自己的handlepromotionfailure这个值是true还是false。true表示运行担保失败,false则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minorgc,如果小于或者不允许担保失败,那就直接进行fgc了。
举个例子,在minorgc发生之前,年轻代里面有1g的对象,这个时候,老年代瑟瑟发抖,jvm为了安慰这个老年代,它在minor gc之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2g,jvm就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1g的对象全部给你,你也吃的下,你的空间非常充足,这个时候,老年代就放心了。
但是大部分情况下,在minor gc发生之前,jvm检查完老年代最大可用连续空间以后,发现只有500M,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure
的值是不是允许担保失败,如果允许担保失败,就进行第三次检查。
检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300M,现在老年代最大可用连续空间只有500M,很明显是大于的,那么它会进行一次有风险的minorgc,如果gc之后还是大于500M,那么就会引发fgc了,但是根据以往的一些经验,问题不大,这个就是允许担保失败。
假设历次晋升到老年代平均对象大小是700M,现在老年代最大可用连续空间只有500M,很明显是小于的,minorgc风险太大,这个时候就直接进行fgc了,这就是我们所说的空间分配担保。
🎉 Serial Old
Serial Old就是老年代下使用标记整理算法,单线程运行的垃圾回收器。
🎉 Parallel old
Parallel old也是老年代下使用标记整理算法,可以进行吞吐量控制的多线程回收器,在JDK1.6才开始提供,在JDK1.6之前,新生代使用ParallelScavenge 收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器而出现的。
上面的Serial Old,Parallel Old这二个垃圾回收器使用的是标记整理算法.
🎉 标记整理算法
标记整理算法是标记后将存活对象移向内存的一端,然后清除端边界外的对象。标记整理算法可以弥补标记清除算法当中,内存碎片的缺点,也消除了复制算法当中,内存使用率只有90%的现象,不过也有缺点,就是效率也不高,它不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。
🎉 CMS
CMS是老年代使用标记清除算法,并发收集低停顿的多线程垃圾回收器。这个垃圾回收器可以重点讲一下,CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:
初始标记,只是标记一下GC Roots,能直接关联的对象,速度很快,需要暂停所有的工作线程。
并发标记,进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
重新标记,为了修正在并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程。
并发清除,清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
但是很明显无法处理浮动垃圾,就是已经标记过的对象,开始进行并发清除的时候,这个时候又有垃圾对象产生,这个时候,没办法清除这部分的浮动垃圾了,还有一个问题就是容易产生大量内存碎片,这和它的算法特性相关。
🎉 标记清除算法
标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。
CMS使用标记清除算法看中的就是它的效率高,只不过内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
🎉 G1
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,每个区域又可以根据分代理论分为eden区,Survivor区,只要这个区域里面出现了一个对象,超过了这个区域空间的一半就可以把它当作大对象,g1专门开辟了一块空间用来存储大对象,这个区域的大小,可以通过jvm的参数去设置,取值范围是1~32mb之间,那么如果有一个对象超过了32mb,那么jvm会分配二个连续的区域,用来存储这个大对象。
跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,保证了G1 收集器可以在有限时间获得最高的垃圾收集效率。而且基于标记整理算法,不产生内存碎片。可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。在jdk1.9的时候,被设置成默认的垃圾回收器了。
🍊 安全点
目前Java虚拟机都是采用准确是GC,当执行系统停下来之后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机有办法直接得到哪些地方存放在对象的引用。在HotSpot中,使用了一组OopMap数据结构来实现这个功能。
当一个类加载完之后,HotSpot就把对象是什么类型数据计算出来,在JIT(即时编译)的时候也记录下栈和寄存器哪些位置是引用,这样GC时就可以直接得到有哪些对象的引用。
OopMap不可能为每一条指令都创建一个OopMap只能在特定的位置记录一下,这些位置称为安全点。也就是说程序并非在任何地方都可以进行GC,只有到达安全点之后才可以GC。
安全点的选择不能太少,不能让GC等待的时间太长,也不能太多而影响正常的程序运行速度。所以安全点的选定基本是以程序“是否具有让程序长时间运行的特征”为标准,例如方法调用、循环跳转、异常跳转等地方,具有这些基本功能的指令才产生安全点。
更具体点在HotSpot中,安全点的位置:
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
另外在垃圾收集发生时,多线程的程序要所有的线程都跑到安全点都停下来
如何在垃圾收集发生时让所有线程都跑到最近的安全点?
有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有 用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎 没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个 线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为 了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
程序“不执行”的时候线程如何达到安全点?
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行” 的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续 等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入安全区域来解决。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
🍊 JIT技术
提出疑问:为什么应用刚启动的时候比较卡,过一会就好了?
解答:
JVM 中内置了解释器,在运行时对字节码进行解释翻译成机器码,然后再执行。
解释器的执行方式是一边翻译,一边执行,因此执行效率很低。为了解决这样的低效问题,HotSpot 引入了JIT 技术。
有了 JIT 技术之后,JVM 虽然还是通过解释器进行解释执行,但是,当 JVM 发现某个方法或代码块运行时执行的特别频繁的时候,就会认为这是“热点代码”,然后 JIT 会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用,这也是 HotSpot 虚拟机的名字的由来。
JIT 优化是在运行期进行的,并且也不是 Java 进程刚一启动就能优化的,是需要先执行一段时间的,因为他需要先知道哪些是热点代码。
所以,在 JIT 优化开始之前,我们的所有请求,都是要经过解释执行的,这个过程就会相对慢一些。
如果请求量比较大的的话,这种问题就会更加明显,在应用启动过程中,会有大量的请求过来,这就会导致解释器持续的在努力工作。一旦解释器对 CPU 资源占用比较大的话,就会间接的导致 CPU、LOAD 等飙高,导致应用的性能进一步下降。
这也是为什么很多应用在发布过程中,会出现刚刚重启好的应用会发生大量的超时问题了。而随着请求的不断增多,JIT 优化就会被触发,这就是使得后续的热点请求的执行可能就不需要在通过解释执行了,直接运行 JIT 优化后缓存的机器码就行了。
解决思路:
1、提升 JIT 优化的效率
2、降低瞬时请求量
在提升 JIT 优化效率的设计上,大家可以了解一下阿里研发的 JDK——Dragonwell。
这个相比 OpenJDK 提供了一些专有特性,其中一项叫做JwarmUp的技术就是解决 JIT 优化效率的问题的。
这个技术主要是通过记录 Java 应用上一次运行时候的编译信息到文件中,在下次应用启动时,读取该文件,从而在流量进来之前,提前完成类的加载、初始化和方法编译,从而跳过解释阶段,直接执行编译好的机器码。
除了针对 JDK 做优化之外,还可以采用另外一种方式来解决这个问题,那就是做预热。
很多人都听说过缓存预热,其实思想是类似的。
就是说在应用刚刚启动的时候,通过调节负载均衡,不要很快的把大流量分发给他,而是先分给他一小部分流量,通过这部分流量来触发 JIT 优化,等优化好了之后,再把流量调大。
🍊 可达性分析
在可达性分析法中,可以作为GCRoots节点的,一般内容都会很大(方法区有时候就有数百M),要想检查完所有符合要求的对象,必定很费时间。另外可达性分析应当是对某瞬间的程序快照进行的,不然一边进行可达性分析,一边程序运行,最后出的结果肯定是牛头不对马尾。这个时间点导致GC进行时必须停顿所有Java执行的线程。
通过gc root根节点,从跟节点开始进行引用链的搜索,如果对象搜索不到,就证明这个对象是不可达的,就会在三色标记算法把这个对象标记为白色不可达,最终引发垃圾回收。
gc root是可达性分析的起点,gc root有几种,第一种,虚拟机栈里面引用的对象,也就是栈帧中的本地变量,第二种,本地方法栈里面的引用对象,第三种,方法区里面的静态属性引用的对象,第四种,方法区里面的常量引用对象,第五种,java虚拟机内部也有引用,这个也需要作为gc root,第六种,锁,锁的获取和释放,获取的话会持有对象,这些都是作为gc root的引用点。
🍊 四种引用类型
🎉 强引用
强引用就是最常见的Object a = new Object();这种就是最强的一个引用,只要这个关系还在,就不会被垃圾回收掉。
🎉 软引用
软引用就是描述这个对象还有用,但是它不是一个必须回收的对象,只有系统即将要发送内存溢出的情况下,会把这些对象列入回收的范围里面,进行第二次垃圾回收,如果回收之后,还是没有足够的内存,才会抛出异常。
🎉 弱引用
弱引用,被弱引用引用的对象,只能生存到下一次垃圾回收器进行垃圾收集。
🎉 虚引用
虚引用,它是最弱的一种引用,可以称为幽灵引用,它的存在不会对结构造成任何的影响,没法通过虚引用找到这个对象的实例。
🍊 gc的过程中对象是否能回收
当对象不可达就意味着这个对象要被回收,但是它不会立马就回收,对象不可达会把它放到一个F-Queue的队列里面,这个队列里面会启用一个低优先级的线程,去读取这些不可达的对象,然后一个一个的调用对象的finalize方法,如果对象的finalize方法被覆盖过,被调用过,这个时候虚拟机将这两种情况都视为“没有必要执行”。
这个时候这个不可达对象逃过了垃圾回收,稍后会由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行F-Queue中对象的finalize()方法。
finalize()方法是对象逃脱死亡命运的最后一次机会,收集器将对F-Queue中的对象进行第二次小规模的标记。
如果对象重新与引用链上的任何一个对象建立关联,那在第二次标记时它将被移出“即将回收”的集合。
如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
🍊 三色标记
三色标记,这三色就是白黑灰,白色表示对象不可达,黑色表示已经被访问过了,它关联的对象也扫描了,灰色就是还有一部分对象没有被扫描过。
🍊 跨代引用
跨代引用,年轻代中有一个对象被老年代的对象引用了,这个时候进行minor gc。正常我们的思路是,年轻代里面的对象被老年代里面的对象引用的话,就进行一个遍历,遍历老年代里面的对象。但是老年代里面的对象是很多的,遍历这个是很消耗性能的,这个时候jvm引入了一个记忆集的抽象数据结构。它用于记录从非收集区域指向收集区域的一个指针集合的抽象数据结构。比如说,我们在年轻代里面进行minor gc,它里面有一个记忆集,记录了老年代引用年轻代的对象的指针。如果记忆集里面有当前对象的引用,那么这个对象就不能被回收。
🍊 逃逸分析
逃逸分析原理:
逃逸分析有三种程度,从不逃逸,方法逃逸,线程逃逸,这三个由低到高表示不同逃逸的程度。
方法逃逸:分析对象动态作用域,当一个对象在方法里面定义之后,可能会被外部方法引用,比如作为参数传到其他方法里面去,这个叫方法逃逸。
线程逃逸:一个对象可能被外部线程访问到,比如可以赋值给其他线程能访问的实例变量,这个叫线程逃逸。
优化手段有三种:第一种是栈上分配,标量替换,锁清除(同步清除)。
栈上分配,java堆中的对象,对于各个线程都是共享可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但是回收动作无论是标记筛选出可回收对象, 还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分 配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会 逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着 方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线 程逃逸。
标量替换:一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型 (int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。
一个数据可以继续分解,那它就被称为聚合量,Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。
将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一 步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配), 但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不 会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以 安全地消除掉。
public String concatString(String s1,String s2,String s3){ StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。 虚拟机观察变量sb,经过逃逸分析后会发现它的动态作用域被限 制在concatString()方法内部。也就是sb的所有引用都永远不会逃 逸到concatString()方法之外,其他线程无法访问到它,所以这里 虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会 加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽 略所有的同步措施而直接执行。
🍊 内存泄漏
内存泄漏:是指创建的对象已经没有用处,正常情况下应该会被垃圾收集器回收,但是由于该对象仍然 被其他对象进行了无效引用,导致不能够被垃圾收集器及时清理,这种现象称之为内存泄漏。
🍊 内存堆积
内存泄漏会导致内存堆积,最终发生内存溢出,导致OOM。 发生内存泄漏大部分是由于程序代码导致的,排查方法一般是使用 visualVM 进行heap dump,查看占用 空间比较多的 class 对象,然后检查该对象的instances 以及 reference引用,最终定位到程序代码。 如果堆内存比较大,进行head dump 产生的资源消耗不可接受,可以尝试使用轻量级的jmap生成堆转储快照 分析,思路与使用可视化工具一样。