JVM知识体系总结(运行时内存,类加载机制,垃圾回收机制)

JVM分为五大模块: 运行时数据区 、类装载器子系统 、 执行引擎 、 本地方法接口和垃圾收集模块 。

 一、Jvm运行时数据区内存

1.程序计数器

Program Counter Register):也叫PC寄存器,是一块较小的内存空间,它存放的是将要执行的指令的地址。它可以看做是当前线程所执行的字节码的 行号指示器  , 主要用来处理字节码指令的分支、循环、跳转、异常处理、 线程恢复 Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处 理器只会执行一条线程中的指令。 因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数 器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
    特点:
  • 线程私有的, 生命周期与线程相同
  • 此内存区域是jvm里 唯一一个没有内存溢出 (OOM)的内存区域(为什么?因为程序计数器只保存当前执行的字节码的偏移量,当执行下一条指令的时候就会把保存的偏移量改为下一条的,不需要申请新的内存,也就不会产生oom了)。

2.Java虚拟机栈

Java虚拟机栈每个栈帧代表一个方法的信息。每个栈帧存储了局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从入栈到出栈就标志着该方法从开始到结束的过程。

局部变量表

(数组实现,可以通过索引下标访问):存放了编译期各种可知的数据类型(byte,short,int,long,float,double,boolean,char),对象的引用类型(对象在堆中的地址)。

操作数栈

好像也是数组实现,但是只能通过标准的入栈出栈访问数据):操作数栈的深度已经在编译期确定下来了,32bit的数值用一个操作数栈栈帧来表示,64的则需要两个。操作数栈主要是用来保存临时存放需要进行运算的数据。举例:a=b+c,先将b从局部变量表中读取出来然后压入操作数栈中,然后将c从局部变量表中读取出来压入操作数栈中,然后将c和b依次弹出栈计算结果,然后将结果重新压入栈顶中,然后将结果弹出栈保存到a在局部变量表中的索引位置。

动态链接

链接分为了静态链接和动态链接,首先要了解这个知识,先了解一下 符号引用直接引用。
       符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可,使用符号引用时,被引用的目标不一定已经加载到内存中。例如全限定类名。 直接引用可以是直接指向目标的指针,相对偏移量,一个能间接定位到目标的句柄,使用直接引用时,引用的目标必定已经存在于虚拟机的内存中了。(个人浅解:例如在常量池中前面带#开头的一般就是符号引用,可以理解为符号引用就是对类,方法的描述。直接引用就是直接定位到目标的指针,内存中的相对偏移量,或者间接定位到目标的句柄)。
为什么在解析阶段要符号引用转直接引用?
       个人理解,如果使用符号引用,虚拟机其实也不知道具体引用的类的内存地址,那么也就无法真正的调用到该类,所以要把符号引用转为直接引用,这样就能够真正定位到类在内存中的地址,如果符号引用转直接引用失败,就说明类还没有被加载到内存中,就会报错。
    静态链接和动态链接的区别: 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变。
       将调用方法的符号引用转换为直接引用的过程称为静态链接; 被调用的目标方法在编译期无法被确定下来 ,只能够在程序运行期将方法的符号引用转换为直接引用,这种引用转换的过程具备动态性,称为 动态链接。
       动态链接的作用(经过一堆前情提要之后作出的正式解释): Java 语言特性多态(需要类加载、运行时才能确定具体的方法),如子类重写父类方法后,具体调用哪个要等到执行时才能确定。在方法区里面有一个运行常量池。 在Java源文件 被编译到字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在class文件的常量池里。当一个方法被调用的时候栈帧中的通过指向被调用的方法在其常量池中的符号引用表示。动态链接就是将这些符号引用转换为直接引用。
非虚方法(私有的,final,类构造器,静态方法)是静态链接,虚方法是动态链接。

方法出口

 方法出口其实就是记录的返回地址。例如:方法1调用了方法2之后,那么方法2的出口地址就是方法1调用方法2的代码位置,因为在方法2执行完了之后,要回到方法1继续执行。
 虚拟机栈特点:线程私有的
Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError( 栈溢出): 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError( 内存溢出): Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

3.本地方法栈

大致如Java虚拟机栈相似,但是它里面是本地方法,不是Java方法。

4.堆:

《java虚拟机规范》规定堆可以处于不连续的内存空间,但是在逻辑上它应该被视为连续的。
堆内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
所有的对象都在堆中分配内存吗?
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
堆分有新生代,老年代
新生代则又分为了一块较大的Eden(伊甸)空间和两块较小的Survivor(幸存者)空间, 默认比例为8:1:1
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
为什么需要分代?
参考以下两个假说:
弱分代假说:绝大多数对象都是朝生夕灭的,生命很短
强分代假说:活得越久也就是熬过越多次垃圾收集过程的对象就越难以消亡
这两个假说不是相互对立的,它们一起共同指导着虚拟机的设计。 一 个区域中大多数对象都是朝生夕灭、难以熬过一次垃圾收集过程的,那么把它们集中放在一起,可以频繁的开启这个空间的GC, 把难以消亡的对象集中放在一个区域,虚拟机就可以不用频繁地对这个区域进行垃圾收集了,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
为啥会有两个 Survivor 区?
因为假设设想一下只有一个 Survibor 区 那么就无法实现对于 S0 区的垃圾收集,以及分代年龄的提升。
新生代对象什么时候会进入到老年代呢?
1.达到晋升年龄:新生代对象在经历每次GC的时候,如果没有被回收,则对象的年龄+1。当年龄超过阈值的时候,便会进入老年代。默认情况下,阈值为15,可通过-XX:MaxTenuringThreshold参数来进行调节。那么,为什么默认是15呢?因为          Mark Word中,每个对象头用一个4bit标志位来记录对象的年龄,而4bit标志位最大只能表示15。
2.如果创建的对象很大的时候,直接进入老年代。可以通过-XX:PretenureSizeThreshold参数来调节可以直接进入老年代的对象大小。
3.动态年龄判断。如果年龄1+年龄2+ ··· +年龄n的对象的大小超过了当前Survivor区的内存的一半,则年龄超过n的对象进入老年代。

5.方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于 存储已被虚拟机加载的类型信息、常量、静态变量、方法信息、即时编译后的代码缓存数据
1.方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
2.方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
3.方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
4.方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类(加载大量第三方jar包、tomcat部署的工程过多、大量动态生成反射类),导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space(jdk7以前)或者xx.xx.OutOfMemoryError: Metaspace(jdk8以后)。
. 在jdk7以前,习惯上把方法区,成为永久代。jdk8开始,使用元空间取代了永久代
本质上,方法区和永久代并不等价。仅是对HotSpot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。事实上除了Hotspot虚拟机,别的虚拟机不存在永久代的概念。
    现在来看,当年使用永久代,不是好的idea。导致java程序更容易OOM。
 5. -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线, 一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
    如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
运行时常量池是属于方法区的,那么运行时常量池存放在哪里?运行时常量池又与常量池又何区别?
首先要清楚一下方法区的演变,在1.6之前,方法区是使用永久代实现的(hotspot虚拟机是这样,别的虚拟机没有永久代的概念),在1.7的时候已经逐渐开始抛弃永久代但还没有完全抛弃,到了1.8的时候,方法区就开始在元空间实现了。
而方法区在没有使用元空间实现之前,永久代使用的是jvm内存,当加载的类过多的时候容易造成虚拟机oom,所以改为了使用本地内存的元空间。运行时常量池和字符串常量池在虚拟机规范里是属于方法区的,但是注意的是,在1.8之后, 运行时常量池和字符串常量池的物理内存是在堆中的,它们在逻辑上属于方法区,但是实际内存是在堆中。(分享一篇讲解字符串常量池等清晰的文章 (15条消息) 一文彻底搞懂字符串、字符串常量池原理_知识分子_的博客-CSDN博客_string常量池原理
当我们写的代码编译之后变成了字节码文件,每个字节码文件中都有属于它的常量池,当程序运行的时候,将字节码加载到虚拟机中,这时候常量池就变为了运行时常量池。所以可以理解为常量池的概念是在编译期的,存在在字节码文件中的,运行时常量池是存在于运行时期的,运行时常量池具有动态性。当在运行的时候,具有新的创建的对象或变量时,可以将新创建的对象的字面量等放入常量池中。
字符串常量池等为什么放入了堆中?
    jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。虽然在jdk8之后没有了永久代,但是字符串常量池的对象回收也是时有发生的。所以在Java 7的时候将其放到堆里,能及时回收内存。

 二、类装载器子系统

1.类加载过程

类加载的原则:延迟加载,能不加载就不加载 。(懒加载)
public class Test {
    public static void main(String[] args) {
        User user = new User();
    }
}
/*编译的时候会产生Test.class和User.class文件,但是一开始只会加载Test类,当用到User类的时候才会加载*/
首先我们了解一下类的生命周期主要有: 加载、验证、准备、解析、初始化、使用、卸载。在这里先不说这个顺序是否是固定的,后文再提。
下面我们了解一下第一步, 类什么时候会被加载?
根据网上的资料,这个问题从两个维度来思考,首先,从jvm的规范,然后,从虚拟机运行的维度。
jvm规范的维度:
jvm没有强制规定类加载的时机,但是jvm规定了有且只有 五种情况需要对类进行"初始化",而对类进行初始化之前,肯定要将初始化之前的步骤完成。
1.遇到 new(使用new关键字初始化对象), getstatic(读取类的静态字段), putstatic(设置类的静态字段), invokestatic(调用一个类的静态方法)这四条字节码指令时,需要立即对类进行初始化。
2.使用java..lang.reflect的包对类进行 反射调用时,如果类没有初始化,需要先将类初始化。
3.如果对一个类初始化时,它的父类还没有初始化,需要先将它的父类进行初始化。
4.当虚拟机启动时,用户指定了一个要执行的主类(例如main()方法那个类),虚拟机会先初始化这个主类。
5. 在JDK7之后的版本中使用动态语言支持,java.lang.invoke.MethodHandle实例解析的结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,而该句柄对应的类还未初始化时,必须先触发其实例化
    虚拟机运行的角度;
6.预加载(虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常用到的,像java.lang.*、java.util. java.io.等等,因此随着虚拟机一起加载。)
7.运行时加载( 虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类
除了上述所讲七种情况,其他使用Java类的方式都被看作是对类的 被动使用 ,都不会导致类的初始化,比如:调用ClassLoader类的 loadClass() 方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
下面说一下,各个步骤都具体干了什么事情?
加载(重点):获取.class的二进制文件流,把类信息,字节码,常量等放入方法区中,然后在内存生成一个该.class的class对象。一般来说,对象是放在堆中,但是hotspot虚拟机比较特殊,它将这个class对象放在方法区中,虚拟机规范对此没有严格要求,所以各个虚拟机实现的灵活度较大。
验证:对输入的流进行验证,例如文件格式验证,元数据验证,字节码验证,符号引用验证等。验证阶段是与加载阶段交叉运行的,不用全部加载完成才开始验证。(如果不对输入的流进行验证,有可能接受了有害的流造成系统崩溃)
准备:准备阶段主要是为类变量(static)分配内存并设置其初始值 这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。被final修饰的静态变量已经在编译期就放入了常量池中了,因为被final修饰的已经不会改变了)。
解析:解析阶段主要是将常量池内的符号引用转换为直接引用  (当静态加载时,解析在初始化之前完成,当是动态加载时,因为要使用的类不确定,解析的结果也是不确定的,所以解析在初始化之后完成)。
初始化:初始化的一个顺序如下:1. 初始化父类中的静态成员变量和静态代码块(执行) ;2. 初始化子类中的静态成员变量和静态代码块(执行) ;3.初始化父类的普通成员变量和代码块,再执行父类的构造方法;4.初始化子类的普通成员变量和代码块,再执行子类的构造方法;
    ( 注意 仔细看 ,在准备和初始化阶段都进行了静态变量的初始操作,那么它们有什么区别 。例如:  public static int value=123;// 在准备阶段 value 初始值为 0 。在初始化阶段才会变为 123 。
使用:使用分为主动使用和被动使用,这部分可参考上文类什么时候被加载提及的内容。类的使用简单来说就是创建一个该类的对象, Java编译器在为它编译的每一个类都至少生成一个实例初始化方法,在Java的class文件中,这个实例初始化方法被称为 <init> 。针对源代码中每一个类的构造方法,Java编译器都产生一个 <init> 方法。)
卸载:当类被加载、连接和初始化后,它的生命周期就开始了,当代表类的Class对象不在被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的声明周期。
由此可见,一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。
注: 由java虚拟机自带的三种类加载加载的类在虚拟机的整个生命周期中是不会被卸载的,由用户自定义的类加载器所加载的类才可以被卸载。
java虚拟机自带的类加载器包含根类加载器、扩展类加载器、系统类加载器,Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用他们所加载类的Class对象,因此这些Class对象始终是可触及的。(可参考下文的垃圾回收机制部分的垃圾判断算法,GC Roots上的对象集合)。

2.类加载器

类加载器主要有两种:系统自带的类加载器,和用户自定义的类加载器
系统类加载器:BootStrap类加载器(由C++实现,加载<JAVA_HOME>/lib下的以java,javax,                                                              sun等开头的类)
                        Extension扩展类加载器(由Java实现,继承了BootStrap类加载器)
                        Application类加载器(由Java实现,继承了扩展类加载器,它负责加载环境变量classpat 指定路径下的类库应用中的类加载器默认为系统类加载器。是用户自定义类加载器的默认父加载器。通过ClassLoader的getSystemClassLoader() 方法可以获取到该类加载器。)
每个Class对象都会包含一个定义它的ClassLoader的一个引用。 也就是每个对象都会包含一个加载它的类加载器的引用。

3.双亲委派机制

先了解一下双亲委派机制的过程:
    如果一个类加载器收到了类加载请求,它首先不会自动去尝试加载这个类,而是把这个类委托给父类加载器去完成,每一层依次这样,因此所有的加载请求都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成该加载请求(找不到所需的类)时,这个时候子加载器才会尝试自己去加载,这个过程就是双亲委派机制。
双亲委派机制有什么作用?
首先先了解一个知识点: 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要它们的类加载器不同,那这两个类必定不相等。
1.防止类的重复加载,确保一个类的全局唯一性,因为父类加载过的类就不会再让子类加载了。
2.防止核心API被篡改,例如 类java.lang.Object,它存在在rt.jar当中,不管是哪一个类加载器要加载这个类,最后都会是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,所以,Object类在程序的各种类加载器环境中都是同一个类。
什么时候需要打破双亲委派机制?
Tomcat 的 webapps 下可以部署多个应用,若多个应用中具有同名类(包结构也一致),但实现方式不同,只加载一份会导致异常,因此需要打破双亲委派机制
怎么打破双亲委派机制?
重写ClassLoader类中的 loadClass()方法
一个类的静态代码块会执行两次吗?
首先我们知道,在加载类的过程中,到了初始化这一阶段会执行类中的静态代码块,因为同一个虚拟机里同一个类只需要加载一次,那么也就只会执行一次。因为一个类的唯一性,是通过这个类和这个类的加载器来共同判断的,所以当使用两个不同的打破双亲委派机制的自定义类加载器都加载这个类的时候,那么就会都执行一次静态代码块。

 三、执行引擎和本地方法接口

我个人对该部分并未深入学习过,且网上和相关书籍对该部分描述也较少,本篇就不对此做过多阐述,只分享一篇个人觉得写的好的文章。 Java基础-执行引擎-即时编译器 - 简书 (jianshu.com)

四、垃圾收集模块

1.垃圾判断算法

要回收堆中的垃圾,首先我们得知道哪些对象是垃圾需要回收的,判断对象是垃圾的方法主要有两种:
  1.引用计数法   在对象中添加一个引用计数器,每当有一个地方引用他时,计数器值就加1,当引用失效时,计数器值就减1,所以任何时刻计数器值为0的对象就是不再被引用的。 引用计数法的缺点就是,如果存在两个对象相互引用,A引用了B,B引用了A,那么它们的引用计数器永远都不会为0,它们也永远不会回收,容易造成内存泄露。
  2.可达性分析法 这个算法的基本思路是通过一系列被成为 “GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径成为引用链(Reference Chain),如果 某个对象到GC Roots间没有任何引用链相连,则证明该对象是不可能再被使用的。也就是以GC Roots为根节点,根据引用关系向下搜索对象,只要不在这棵树上的对象就把它们标记为垃圾。
可达性分析法是目前主流的垃圾判断算法。
那有什么对象会成为GC Roots根对象呢?
  • 虚拟机栈中本地变量表引用的对象(虚拟机栈中每个栈帧代表正在运行的方法,其使用的变量引用的对象也正在被使用,所以肯定不能被回收)
  • 方法区中的静态变量对象(静态变量是类对象的属性,注意,只有使用系统类加载器加载的类对象和它们的静态变量对象会成为GC Roots,使用自定义类加载器的类会有被卸载的可能,所以它们不能成为GC Roots)
  • 存活着的线程对象(Thread)
  • 等等......(此部分可以回顾前文中讲过的由系统类加载器的类不会被卸载,因为系统类加载器是虚拟机自带的类加载器(类加载器也是对象,它们之间还有继承关系呢,Java中万物皆对象),肯定是不能被回收的,所以它们加载的类都会被系统类加载器引用,所以这也就是系统类加载器的类为什么不能被卸载回收)

2.垃圾标记原理

垃圾判断算法讲完了,但是有一点需要注意的是,垃圾并不是在被判断为不可达的时候马上就被标记为垃圾了。
将该对象标记为垃圾有一个过程的。目前主要有两种设计:
  • 第一种, 直接标记,就是将找出来的对象直接标记为垃圾对象,但是如果程序同时在运行的时候,对象的引用可能会瞬息万变,所以采用这种方式的时候需要 STW(Stop The World 也就是将程序停顿)。
  • 第二种  三色标记法
    三色标记法将对象的颜色分为了黑、灰、白,三种颜色。
    黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)。
    灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)。
    白色:该对象没有被标记过。(对象垃圾)。
    标记过程:1.初始时,所有对象都在 【白色集合】中;
                      2.将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
                      3.从灰色集合中获取对象:3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集                          合】中;3.2. 将本对象 挪到 【黑色集合】里面。
                      重复步骤3,直至【灰色集合】为空时结束。               
                      结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收,在【黑                    色集合】的对象即为任需要的对象。
我们刚才讲到第一种直接标记的时候知道,如果直接标记对象,程序在运行中的对象引用会发生变化,所以为了防止标记错误都会让程序终止运行。那么在第二种三色标记法的过程中,我们可以把它分成初始标记和并发标记。
初始标记发生在上述过程的第2步,将GC Roots根对象上直接引用的下一层对象标记为灰色,这一步是要 STW的,因为 GC Root是变动的,比如栈帧中的本地变量表作为GC Root可能时刻在变化,所以必须先STW然后再扫描整个GC Root。
并发标记则发生在上述的第3步,这时候标记过程和程序运行是可以并发执行的,不需要进行STW。
由于并发标记阶段标记过程和程序是同时进行的,所以肯定会有在标记过程中发生对象引用关系发生变化的事情。
主要有两种情况的发生,第一种, 多标:就是把本应该回收的对象标为黑色了,这部分对象称为浮动垃圾, 浮动垃圾并不会影响程序的正确性,这些“垃圾”只有在下次垃圾回收触发的时候被清理。还有在,标记过程中产生的新对象,默认被标记为黑色,但是可能在标记过程中变为“垃圾”。这也算是浮动垃圾的一部分。第二种,漏标 漏标会导致被引用的对象当成垃圾误删除。
多标并不会影响程序的正确性,那些垃圾一般在下一次GC也会被回收,但是漏标把应该还应存活的对象给回收了,这可是严重的bug。
漏标情况的发生在两种情况发生的情况下才会发生。 1.从黑色对象到白色对象增加了新的引用关系。2.灰色对象直接或间接删除了到 这个白色对象的全部引用关系。
只要把这两个条件之一破坏掉就可以防止漏标的情况发生。下面介绍两种方法。
第一种, 增量更新。这是CMS回收器使用的技术。顾名思义,增量更新只关心引用的增加这种情况。当黑色对象插入新的指向白色对象的引用关系后,就将这个新插入的引用 记录下来等并发(标记)扫描结束之后,再将这些 记录过的引用关系中 的 黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
缺点:再这种 再次标记需要进行STW,因为防止引用关系再发生变化,但是这种情况避免了多标的情况发生,避免了浮动垃圾的产生。
第二种: 原始快照。这是G1回收器使用的技术。它只关心第二部分,引用关系的删除。当灰色对象要删除指向白色对象的引用关系时,在它发生之前,就将这个要删除的引用记录下来。
在并发(标记)扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照 刚刚开始扫描那一刻的对象图(Bitmap)快照来进行搜索。
注意:增量更新的记录是发生在引用关系增加之后,原始快照是发生在引用关系删除之前。

3.四种引用类型

刚才介绍了垃圾的标记原理,有一个重要的点,就是垃圾都是通过引用关系来判断的,下面就介绍一下Java中的四种引用关系。 JVM开发团队发现,单一的强引用类型,无法很好的管理对象在JVM里面的生命周期,垃圾回收策略过于简单,无法适用绝大多数场景。为了更好的管理对象的内存,更好的进行垃圾回收,JVM团队扩展了引用类型,从最早的强引用类型增加到强、软、弱、虚四个引用类型。
  • 强引用: 如果JVM垃圾回收器GC Roots可达性分析结果为可达,表示引用类型仍然被引用着,这类对象始终不会被垃圾回收器回收,即使JVM发生OOM(内存溢出)也不会回收。
  • 软引用: 软引用就是当内存充足的时候。它不会被垃圾回收器回收,当内存空间不够用的时候,软引用就会被垃圾回收器回收。适合用于缓存的对象。什么网页缓存,图片缓存之类的。
  • 弱引用: 弱引用就是不管内存中是否有空间,只要遇到垃圾回收器,就会被回收。用途: 是为了解决某些地方的内存泄露的问题。用在 ThreadLocal里面。
  • 虚引用: 简单来说就是对于引用对象来说如同虚设。如果一个对象仅持有虚引用,那么它和没有任何引用一样,在任何时候都有可能被垃圾回收器回收。
下面说一下弱引用的使用场景:ThreadLocal为每一个线程提供了一个私有的变量,简单理解为每个线程使用在共享区域定义的一个变量,但是每个线程对这个变量的值的修改对别的线程是不可见的,形成了一种隔离。(以后会发一篇多线程的文章里面会讲解到它的使用和原理)这个现象是依托于ThreadLocalMap来实现的,map中的每一个Entry代表着一个线程。每个Entry的key可以理解为这个线程指向的是这个线程的threadlocal,这是一个弱引用,每个线程的value指向的是这个线程的threadlocal的值。当外界对指向threadlocal的强引用回收之后,就说明这个threadlocal就没用了,但是此时还有map中的key也指向了它,若是这个key是一个强引用,那么我们就无法对threadlocal进行回收,就有可能造成一个内存泄漏的问题,所以使用了弱引用来解决这个问题,只有弱引用指向的对象,在下次垃圾回收时就会被回收。

4.垃圾回收算法

完成了垃圾的标记之后,下面就到了垃圾的回收了,因为内存区域特点的不同,所以在垃圾回收算法的选择上每片内存区域上的侧重点也不同,下面先介绍常见的几种垃圾回收算法。
标记-清除算法: 标记-清除算法是最早出现也是最基础的垃圾收集算法算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收未标记的对                             象。
                          缺点:标记清除算法会产生大量的不连续的内存碎片,空间碎片太多可能会导致在分配大内存对象时没有足够的内存,然后执行GC操作。
标记-复制算法: 复制算法,就是把内存分为2块等同大小的内存空间(A和B),使用A进行内存的使用,当A部分的内存不足以分配对象而引起内存回收时,就会把存活的对象从A内存块放到B内存块中,后把A内存块中的对象全部清除,在B内存块中                            使用,当B内存不足以分配内存时,就会把B中存活的对象放到A内存块中,然后把B中对象全部清除,如此循环。
                          缺点:虽然避免了内存碎片,但需要使用额外的内存空间,并且还要执行一次对象的复制操作。
标记-整理算法: 标记-整理算法的标记过程与“标记-清除算法”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
                          缺点:虽然避免了空间内存碎片和使用额外的内存空间,但因为涉及到对象的移动定位等,效率较低。
  • Minor GC过程如下:
    HotSpot将新生区划分为三块,划分的目的是因为HotSpot采用 复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外, 大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC
    GC开始时, 对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生区中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年区中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生区中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年区进行分配担保,将这些对象存放在老年区中。经过研究,99%的对象都是临时对象。
  • MajorGC:因为发生在老年代,对象都较为稳定,所以采用标记复制算法不是很理想,一般是使用标记清除法和标记整理法混合使用。
  • FucllGC:是指回收整个堆和方法区的GC。
这几种GC触发的条件分别是什么?
  • Eden区满了会触发MinorGC,注意是eden区满了才会,survivor区满了并不会。
  • 当老年代满时会触发MajorGC,只有CMS收集器会有单独收集老年代的行为,其他收集器均无此行为。而针对新生代的MinorGC,各个收集器均支持。
  • 调用System.gc时,系统建议执行Full GC,但是不一定会执行 。还有老年代空间不足(非CMS收集器)。方法区空间不足,类卸载。通过 Minor GC 后进入老年代的空间大于老年代的可用内存。
       垃圾回收的算法讲完了,现在结合之前讲的垃圾标记原理来思考一个问题。假如有一个老年代的对象的引用指向了一个新生代的对象。那么因为老年代的对象很难消亡,所以其实这个新生代的对象其实也是很难被回收的, 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。其实这就是 跨代引用假说(有么有很眼熟,对的没错,之前在堆里讲了为什么要分代的时候说了两个假说,这三个假说一起共同指导着堆的设计)的内容,所以假如我们这时候要进行一个仅限于新生代的MinOrGC,这时候还需要把整个老年代加入进来做可达性分析的话,那就太浪费效率了。
      所以为了提高效率,只需在新生代上建立一个全局的数据结构,这个结构被称为记忆集,记忆集把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。这种记忆集最常见的实现方式就是卡表。(记忆集是一种抽象设计,卡表是具体的实现方式,就比如Map是一种抽象设计,HashMap是具体的实现)。卡表可以把它简单的理解为是一种字节数组,每个下标对应的都是对应偏移量的内存块,只要把这个索引对应的数组的值给“弄脏”,就证明在这块内存里有存在跨代引用的,到时候把这块内存加入到可达性分析扫描中就可以了。
好了,至于卡表的赋值可能会出现的伪共享,以及内存屏障等问题,就等我在写jvm的锁机制和JUC的时候再详细描述,到时候我会把这个案例拿出来讲解。本篇JVM笔记就暂时到此了。以后如果有更新的,我会随时修改。
我不认为自己有多厉害,所以这里面的大部分内容都是我参考网上的资料还有书籍自己整理出来的自己的知识体系,也通过一些问题来思考讲解设计者为什么要这样子设计和注意的点。我也只是个菜鸟~也许,只有菜鸟才会懂菜鸟想什么。
           
     

  • 7
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值