JVM知识汇总

JVM 的主要组成部分

类加载器,运行时数据区,执行引擎,本地库接口

JVM运行时数据区?

线程私有:程序计数器、Java虚拟机栈、本地方法栈

线程共享:Java堆、方法区

程序计数器

1、程序计数器是一块很小的内存空间,用来存储下一条指令的地址。是唯一在虚拟机规范中没有规定内存溢出情况的区域。

2、任何时间一个线程都只有一个方法在执行,程序计数器会存储当前线程正在执行的Java方的JVM指令地址。如果是在执行native方法,则是undefined。

3、是程序控制流的指示器,分支,循环,跳转,异常处理。线程恢复等基础功能都需要依赖这个计数器来完成。

4、字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令

5、执行线程中的方法,CPU会不停地做任务切换,使用程序计数器存储指令地址可以在CPU切换回来后继续执行,设置成线程私有的可以使各个线程之间相互独立。

Java虚拟机栈

1、在每个线程创建的时候会创建一个虚拟机栈,内部保存的栈帧对应着一次方法的调用,不存在垃圾回收的问题,调用之后栈帧弹出。

2、栈帧的内部结构?

  • 局部变量表:用于存放方法参数和方法内部定义的局部变量。基本数据类型直接存储数值,引用类型存储对象的引用。
  • 操作数栈:计算过程
  • 动态链接:在方法执行的过程中可能会用到类中的常量,用于指向运行时常量
  • 方法返回地址:方法执行完毕后,返回之前调用它的地方
  • 一些附加信息

3、两个栈帧之间的数据共享

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时可以共用一部分数据,无须进行额外的参数赋值传递。

4、线程请求的栈深度大于虚拟机允许的深度会抛出 StackOverflowError错误。

本地方法栈

用于管理本地方法的调用,调用本地方法时虚拟机栈保持不变,动态链接并直接调用指定本地方法

1、在JVM启动的时候即被创建,其空间大小也确定了,是JVM管理的最大一块内存空间。可以设置参数进行调节 (-Xms: 10m 堆起始大小 -Xmx: 20m 堆最大内存的大小) 一般情况下将起始值和最大值设置为一致,这样会减少垃圾回收之后内存重新分配大小的次数。

2、堆内存区域的划分

堆内存分为:新生代和老年代
新生代分为:Eden区和Survivor区(from survivor和 to survivor)

新生代会频繁进行 minor GC,使用copying算法,将Eden区和from Survivor区中的内容进行GC存活的对象复制到 to survivor中。如果存活的对象大小超过 to survivor大小则直接进入老年区,否则进行年龄的积累,达到15岁(默认,可通过-XX:MAxTenuringThreshold设置)就进入到老年区。需要大量连续内存空间的对象也会直接进入到老年代。
为了适应不同内存情况,如果survivor中相同年龄所有对象大小的总和大于survivor的一半,年龄不小于该年龄的对象就可以直接进入老年代。

minor GC前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足,则说明这次GC安全。如果不满足,虚拟机会查看 -XX:HandlePromotionFaliure 参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将进行minor GC,否则进行full GC。

3、新生代与老年代配置比例
默认 -XX:NewRatio = 2 表示新生代占堆的1/3
当发现项目中生长周期长的对象偏多的时候,就可以调整老年代的大小来进行调优。

方法区

在JVM启动的时候被创建,实际物理内存可以不连续,空间大小可以选择固定或是可扩展。方法区的大小决定了可以保存多少的类,如果定义太多会导致溢出,抛出内存溢出错误。加载大量的第三方jar包,或是tomcat部署的工程过多,或是大量动态的生成反射类就会导致溢出。关闭JVM就会释放这个区域的内存。

-XX:MetaspaceSize 设置元空间初始化空间
-XX:MaxMetaspaveSize 设置元空间内存最大空间

对于一个64位的服务器端JVM来说,默认的-XX:MetaspaceSize 值为约20.79M。这是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类,然后这个高水位线将会被重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize值的前提下,适当提高该值。如果释放的空间过多,则适当降低该值。

如果初始化的高水位线设置的过低,上述高水位线调整的情况会发生很多次。为了避免频繁GC,会将-XX:MetaspaceSize设置为一个相对较高的值。一般将MetaspaceSize和MaxMetaspaceSize设置成一样的值。

字符串常量池

字符串常量池也叫String Table,String Intern Pool

字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到String pool中,String pool在每个HotSpot VM的实例中只有一份,被所有类共享,为了减少在JVM中创建的字符串的数量。在jdk8后,将字符串常量放到了堆中。jdk9后存储字符串数据的类型从final char[] value 变成了 byte[]

字符串常量池是一个固定大小的Hashtable,如果放进的String非常多会造成Hash冲突严重,从而导致链表很长,链表长了之后会直接造成String.intern时性能大幅下降。

-XX:StringTableSize 可以用来设置StringTable的长度

jdk8后,StringTable的默认值为60013,1009是可设置的最小值。

字符串拼接操作:

1、编译优化,将字符串放到字符串常量池

//字面量+字面量
String str = "a"+"b"+"c";//等同于 String str = "abc"
//常量和字面量的混合
final Stirng str1 = "a";
final Stirng str2 = "b";
String str3 = str1 + "b";
String str4 = str1 + str2; //str3 == str4 == str

2、运行时确定,底层使用StringBuilder拼接,相当于new 了一个对象放到了堆中

//只要出现变量即符合
String str1 = "abc";
String str2 = "def";
String str3 = str1 + "def";
String str4 = "abc" + str2;
String str5 = str1 + str2; //str3 != str4 != str5

inter()方法

intern()会从字符串常量池中查询当前字符串是否存在:
如果存在,直接返回池中对象的地址
如果不存在,把堆中对象的引用地址复制一份,放入到池中,并返回池中的引用地址。

new String("abc") 进行的操作:

在字符串常量池中放入值 abc ,在堆中创建字符串对象

new String("hello")+new String("world")
创建字符串对象 hello,常量池中放入值 hello,创建字符串对象 world,常量池中放入 world,创建字符串对象 helloworld

符号引用

使用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。在编译时,Java类并不知道所引用的类的实际地址,只能使用符号引用来代替。

直接引用

和虚拟机的布局相关,同一符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载到内存中了。
直接引用的不同实现方式:
直接指向目标的指针(指向class对象,类变量,类方法的直接引用可能是指向方法区的指针)
相对偏移量(指向实例变量,实例方法的直接引用都是偏移量)
一个能间接定位到目标的句柄

静态变量和常量

静态变量(类变量):和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。类变量被所有的实例共用即使没有类实例也可以访问它。static修饰。准备阶段设置默认值,初始化阶段显式赋值。

常量(全局常量):被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就被分配了。static final修饰。

静态变量、成员变量、局部变量

静态变量(类变量):

  • 定义在类中方法外,用关键字static修饰,有默认初始值
  • 可以通过对象调用,也可以通过类名调用
  • 生命周期与类共存亡
  • 静态变量存在在堆中,引用在class对象持有

成员变量(实例变量):

  • 定义在类中方法外,有默认值
  • 通过对象的引用来访问实例变量
  • 随着对象的建立而建立,随着对象的消失而消失,存在于对象所在的堆内存中

局部变量:

  • 定义在方法中,或是方法的形参,没有初始值
  • 生命周期与方法共存亡
  • 存放在栈中。局部的对象的引用所指对象在堆中的地址存储在栈中

Class对象

类加载的最终产物是一个java.lang.Class对象,是访问方法区中的类元数据的外部接口,通过这个对象来访问类的字段、方法、运行时常量池

从类的加载和对象创建来说。每写完一个类文件,首先会被编译成.class文件,然后在运行时,这个 .class文件会被加载到jvm中,如果是第一次加载这个类,那么会在Java堆内存中实例化一个java.lang.Class类的对象,用来访问方法区中的类数据。当使用new关键字创建类对象的时候,会先去这个类对应的Class对象获取该类的信息,然后创建对象。同一个类的对象创建都是使用这个Class对象。

静态变量和Class对象存放

jdk7以后将静态变量和Class对象放在堆中,静态变量放在Class对象中。.class文件的信息在方法区,静态变量的信息在方法区

常量池 运行时常量池 字符串常量池

1、常量池(Class常量池),Java文件被编译成Class文件,Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有常量池

用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
字面量:文本字符串 声明为final的常量值 基本数据类型的值
符号引用:类和结构的完全限定名引用 字段名称和描述符 方法名称和描述符

常量池是为了避免频繁的创建和销毁对象而影响系统性能,实现了对象的共享。

2、运行时常量池

当Class文件被加载到内存后,Java虚拟机会将Class文件常量池里的内容转移到运行时常量池里,其中的字符串转移到字符串常量池,运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。

  • 运行时常量池是方法区的一部分
  • 在加载类和接口到虚拟机后,就会创建对应的运行时常量池
  • 运行时常量池每个类都有一个。池中的数据项就像数组项一样,通过索引访问
  • 包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号引用了,而是直接引用。

3、字符串常量池

JVM为了提升性能和减少内存开销,避免字符串的重复创建,维护了一块特殊的内存空间。

常量池:class文件中定义的常量池

运行时常量池:class文件中定义的常量池被加载到虚拟机构成运行时常量池。由字面量和符号引用构成。每个类对应一个运行时常量池

字符串常量池:专门存储字符串的常量池。存放的是字符串的引用或者字符串。JVM中只有一个。

类的生命周期

加载->链接->初始化->使用->卸载

链接包括:验证,准备,解析

1、加载阶段

加载就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型--类模板对象。类模板对象就是Java类在JVM内存中的一个快照,JVM从字节码文件中解析出的常量池,类字段,类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。

加载阶段会查找并加载类的二进制数据,生成Class的实例。完成的事情:

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构
  • 创建Java.lang.Class类的实例,表示该类型,作为方法区这个类的各种数据的访问入口

Class类的构造方法是私有的,只有JVM能够创建。java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据。通过Class类提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等。

2、链接阶段

​ 1.验证

当类加载到系统中,就开始链接操作,验证是链接操作的第一步。目的是保证加载的字节码是合法、合理并符合规范。

大体上的检查:格式检查,语义检查,字节码验证,符号引用验证。

格式检查:魔数检查,版本检查,长度检查
语义检查:是否继承final,是否有父类,抽象方法是否有实现
字节码验证:跳转指令是否指向正确位置,操作数类型是否合理
符号引用验证:符号引用的直接引用是否存在

​ 2.准备

为类的静态变量分配内存,并将其初始化为默认值。

当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机会为这个类分配相应的内存空间,并设置默认初始值。如果使用static final修饰的基本数据类型,则在这个阶段直接赋值,如果修饰的是非基本类型,则在初始化阶段赋值。

不会为实例变量分配初始化,类变量会分配在堆中,而实例变量会随着对象一起分配到Java堆中

在这个阶段不会像初始化阶段那样会有初始化或者代码被执行

​ 3、解析

将类、接口、字段和方法的符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。

在Java代码中直接使用字符串常量的时候,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),保存所有出现过的字符串常量,并且没有重复项。使用Sting.intern()方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是相等的。

3、初始化阶段

为类的静态变量赋予正确的初始值。

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到此系统中,类才会开始执行Java字节码。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

初始化阶段的重要工作是执行类的初始化方法: 方法

  • 该方法仅能由Java编译器生成并由JVM调用,开发者无法自定义一个同名的方法,无法直接在Java程序中调用该方法
  • 由类静态成员的赋值语句以及static语句块合并产生的

在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的总是在子类之前被调用。父类的static优先级高于子类

Java编译器并不会为所有的类都产生初始化方法。以下不会产生方法:

  • 一个类中没有任何的类变量,也没有任何静态代码块
  • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化
  • 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式

虚拟机会保证一个类的()在多线程环境中被正确的加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的(),其他线程都需要阻塞等待,直到活动线程执行方法完毕。如果之前的线程成功加载了类,那么等待队列中的线程就没有机会执行()。当需要使用这个类时,虚拟机会直接返回给它已经准备好的信息。

类的初始化情况:主动使用和被动使用

1、主动使用

Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。初次使用指主动使用。会对类进行初始化操作,而初始化操作之前的加载、验证、准备已经完成。主动使用的情况:

  • 当创建一个类的实例时,比如使用new关键字,或者反射,克隆,序列化
  • 当调用类的静态方法时,即当使用了字节码invokestatic指令
  • 当使用类、接口的静态字段时
  • 当使用Java.lang.reflect包中的方法反射类的方法时
  • 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
  • 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类

虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。在初始化一个类时,并不会先初始化它所实现的接口。在初始化一个接口的时候不会先初始化它的接口。因此一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有程序首次使用特定接口的静态字段时才会导致该接口的初始化。

2、被动使用

除了主动使用的情况,其他情况均属于被动使用。被动使用不会引起类的初始化。

并不是在代码中出现的类就一定会被加载或者初始化,如果不符合主动使用的条件,类就不会初始化。

  • 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化
  • 通过数组定义类引用,不会触发子类初始化
  • 引用常量(赋值不需要调用方法的)不会触发此类或接口的初始化。因为常量在链接阶段就已经被显示赋值了
  • 调用ClassLoader类的loadClass()加载一个类,不是对类的主动使用,不会导致类的初始化

类的生命周期

当类被加载,链接和初始化之后,她的生命周期就开始了。当代表类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的生命周期。类在方法区的二进制数据也会被卸载。

当再次需要时,会检查类的Class对象是否存在,如果存在会直接使用,不再重新加载,如果不在则会重新加载,在堆区新生成一个Class实例。

对象实例化步骤

1、加载类元信息

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在MetaSpace的常量池定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。

如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的 .class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象

2、为对象分配内存

首先计算对象占用空间大小,接着在堆中分配一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量(地址)空间即可,即四个字节大小。对象大小具体算。

  • 如果内存规整,使用指针碰撞

  • 如果内存不规整,使用空闲链表

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。

3、处理并发的问题

在分配内存空间时,另一个问题是及时保证new对象时候的线程安全性,创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:

  • CAS失败重试,区域加锁:保证指针更新操作的原子性
  • TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,虚拟机是否使用TLAB,可以通过-XX:+/-UserTLAB参数来设定

4、属性的默认初始化

内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

5、设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

6、属性的显示初始化、构造代码块中初始化、构造器中初始化

在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行构造代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。 new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

指针碰撞和空闲列表

对象分配内存选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。

  1. 指针碰撞

    如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自占一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将对新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞。

  2. 空闲列表

    虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式为空闲列表。

内存溢出与内存泄漏的区别

内存泄漏:程序在申请内存后,无法释放已申请的内存空间,即使对象不再使用了,但是虚拟机没有释放,一次内存泄漏不会有太大的影响,但是内存泄漏堆积后的后果就是内存溢出

内存溢出:程序申请内存时,没有足够的内存供申请者使用,并且垃圾收集器也无法提供更多的内存。

stop the world

简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用线程都会被暂停,没有任何响应,有点像卡死的感觉。

  • 可达性分析算法中枚举根节点会导致所有Java执行线程停顿

  • 分析工作必须在一个能确保一致性的快照中进行

  • 一致性指整个分析期间整个执行系统看起来像是被冻结在某个时间点上

  • 如果出现分析过程中对象引用关系还在不断变化中,则分析结果的准确性无法保证

  • 所有的GC都有这个事件,只能是回收效率越来越高,尽可能地缩短了暂停时间

安全点与安全区域

1、安全点

  • 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置成为"安全点(SafePoint)"
  • SafePoint的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能会导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据"是否具有让程序长时间执行的特征"为标准。

2、抢断式中断和主动式中断

在发生GC时,检查所有线程都跑到最近的安全点停顿下来

  • 抢断式中断:首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点
  • 主动式中断:设置一个中断标志,各个线程运行到SafePoint的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

3、安全区域

SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint。但是程序处于Sleep状态或是Blocked状态时,无法响应JVM的中断请求,到安全点去挂起,JVM也不可能等待线程被唤醒。对于这种情况就需要安全区域(Safe Region)。

  • 安全区域是在一段代码片段中,对象的引用关系不会发生变化,在这个区域的任何位置开始GC都是安全的。我们也可以把Safe Region 看做是被扩展了的SafePoint
  • 当线程运行到Safe Region的代码时,首先标识已经进入到Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region 状态的线程
  • 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号位置

引用

引用分为了强引用,软引用,弱引用,虚引用

强引用:

  • 在程序中普遍存在的引用赋值。无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 强引用的对象是可触及的,不会被回收
  • 强引用可能导致内存泄漏

软引用:

  • 在系统将要发生溢出之前,将会把这些对象列入回收规范之中进行第二次回收。如果回收之后还没有足够的内存,才会抛出内存溢出异常。
  • 用来描述一些还有用,但是非必需的对象
  • 用来实现内存敏感的缓存

弱引用:

  • 被弱引用关联的对象只能生存到下一次垃圾回收之前,当垃圾收集器工作时,无论内存空间是否足够都要进行回收。
  • 用来描述那些非必需的对象,在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象

虚引用:

  • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。唯一的目的就是能在这个对象被垃圾收集器回收的时候能收到一个系统的通知。

判断对象存活的方法

在进行GC之前,需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为死亡的对象才会在GC时释放掉占用的内存。

当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。判断对象的存活方式有两种:引用计数算法和可达性分析算法。

1、引用计数算法

对每一个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

优点:

  • 实现简单,垃圾对象便于标识,判定效率高,回收没有延迟性。

缺点:

  • 需要单独的字段存储计数器,增加了空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销
  • 无法处理循环引用的情况,导致在Java垃圾回收器中没有使用这类算法

2、可达性分析算法

以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。内存中存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链。如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。

GC Roots范围

  • 虚拟机栈中引用的对象。 例如:各个线程被调用的方法中使用到的参数、局部变量等。

  • 本地方法栈内JNI引用的对象。

  • 方法区中类静态属性引用的对象。

  • 方法区中常量引用的对象。例如:字符串常量池中的引用

  • 所有被同步锁synchronized持有的对象。

  • Java虚拟机内部的引用。例如:基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器。

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)

  • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

    注:使用可达性分析算法判断内存是否可回收,必须在一个能保障一致性的快照中进行。使得GC时会STW。

finalization机制

Java提供了finalization机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。在垃圾回收此对象之前,总会先调用这个对象的finalize()方法。finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

不要主动去调用finalize(),交给垃圾回收机制调用。在finalize()时可能会导致对象复活。执行的时间没有保障,由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。一个糟糕的finalize()会严重影响GC的性能。

对象的三种状态

如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用。一般来说,此对象需要回收。但事实上,也并非被回收,这时候他们处于缓刑阶段。一个无法触及的对象有可能在某一个条件下"复活"自己,如果这样对他回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。

  • 可触及的:从根节点开始,可以达到这个对象
  • 可复活的:对象的所有引用都被释放,但是对象可能在finalize()中复活
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次

判断对象是否可回收

判断一个对象是否可回收,至少经历两次标记过程:

  1. 如果对象到GC Roots没有引用链,则进行第一次标记。

  2. 进行筛选,判断此对象是否有必要执行finalize()方法

    如果对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为"没有必要执行",对象判定为不可触及
    如果对象重写了finalize()方法,且还未执行过,那么对象将被插入到F-Queue队列中,有一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行

  3. finalize()是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果对象在finalize()中与引用链上的任何一个对象建立了联系,那么在第二次标记时,对象会被移除"即将回收"集合。之后,对象再次出现了没有引用存在的情况,在这个情况下,finalize()不会被再次调用,对象直接变成不可达状态,一个对象的finalize()只会被调用一次。

垃圾收集算法

常见的三种垃圾收集算法:标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)

  1. 标记-清除算法
    当堆中的有效空间被耗尽的时候,就会STW,然后进行标记和清除。

    标记:从GC Roots开始遍历,标记所有被引用的对象,一般是在对象的Header中记录为可达对象

    清除:对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有被标记为可达对象,则将其回收。

    会产生内存碎片,需要维护一个空闲列表。下次有新对象加载时,判断垃圾的位置空间是否足够,如果够就存放。

  2. 复制算法(Copying)

    为了解决Mark-Sweep算法内存碎片化的缺陷而被提出的算法。

    将可用的内存分为两块,每次只使用其中的一块,在进行垃圾回收时,将使用的内存的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的对象,交换两个内存的角色,最后完成垃圾回收。

    高效性建立在存活对象少,垃圾对象多的前提下。

  3. 标记-压缩算法(Mark-Compact)

    标记被引用的对象,之后将存活的对象压缩到内存的一端,按顺序排放,之后清理边界外所有的空间。

三种算法的比较

指标标记-清除复制标记-整理
速度中等最快最慢
空间开销少(堆积碎片)通常需要活对象的2倍大小(不堆积碎片)少(不堆积碎片)
移动对象

分代收集算法

目前几乎所有的GC都是采用分代收集算法执行垃圾回收的。

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻的和老年代的各自特点。

年轻代(Young Gen)

  • 区域相对老年代较小,对象生命周期短,存活率低,回收频繁
  • 使用复制算法对这一块进行回收,复制算法内存利用率不高的问题通过两个survivor的设计得到解决

老年代(Tenured Gen)

  • 区域较大,对象生命周期长,存活率高,回收不及年轻代频繁
  • 一般由标记-清除或是标记-清除与标记整理的混合实现

增量收集算法

上述现有的算法,在垃圾回收过程中,应用软件处于STW的状态。在该状态下会导致应用程序的所有线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会挂起很久,将严重影响用户体验或者系统的稳定性。

为了解决这个问题,直接导致了增量收集算法的产生。

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

使用这种方式,由于垃圾回收过程中,间断性执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量的下降。

分区算法

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小空间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续不同小区间,每个小区间都独立使用,独立回收。可以控制一次回收多少个小区间。

方法区的垃圾回收

回收常量池中的常量和不再使用的类型。

只要是没有被任何地方引用的常量就可以被回收。

不再使用的类需要满足以下条件:

  • 该类的所有实例都已经被回收。堆中不存在该类以及任何派生子类的实例
  • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi,JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

双亲委派模型

程序在运行的过程中,遇到了一个未知类,使用调用者Class对象的ClassLoader来加载当前未知的类。调用者Class对象就是在遇到这个未知类时,虚拟机正在运行的方法调用(静态方法或是实例方法)所在的类。所有延迟加载的类都会由初始调用main方法的ClassLoader负责,即AppClassLoader。如果一个类加载器收到了类加载的请求,他首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器,只有当父加载无法完成加载请求,子加载器才会尝试去加载类。

类加载器:

启动类加载器(BootStrapClassLoader):负责加载JVM运行时核心类,这些类位于 JAVA_HOME/lib/ext/.jar
应用类加载器(AppClassLoader):加载Classpath环境变量里定义的路径中的jar包和目录。我们自己编写的代码以及使用的第三方jar包通常都是由他来加载。

自定义类加载器 :通过继承抽象类ClassLoader,实现里面的方法。loadClass()方法是加载目标类的入口,它首先会查找当前ClassLoader以及他的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载。如果双亲都加载不了,就会调用findClass()让自定义加载器自己来加载目标类。ClassLoader的findClass()方法需要子类来覆盖,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用defineClass()方法将字节码转换成Class对象。

如何判断两个类是否相等

任意一个类都必须由类加载器和这个类本身共同确立其在虚拟机的唯一性。

两个类只有由同一个类加载器加载才有比较意义,否则即使两个类来源于通过一个Class文件,被同一个JVM加载,只要类加载器不同,这两个类就不同。

JVM记载class文件的原理机制

1.Java语言是一种具有动态性的解释型语言,类(class)被加载到JVM后才能运行。当运行指定程序时,JVM会将编译生成的.class文件按照一定的需求和一定的规则加载到内存中,并组织成为一个完整的Java应用程序。这个加载过程是由类加载器完成的,具体来说,就是由ClassLoader和他的子类来实现的。类加载器本身也是一个类,实质是把类文件从硬盘读取到内存中。

2.类的加载方式分为显示加载和隐式加载。隐式加载是指程序在使用new等方式创建对象时,会隐式地调用类的加载器把对应的类加载到JVM中。显式加载是指通过直接调用class.forName()方法把所需的类加载到JVM中。

3.任何一个工程项目都是由许多类组成的,当程序启动时,只把需要的类加载到JVM中,其他类只有被使用到的时候才会被加载,采用这种方法一方面可以加快速度,另一方面可以节约程序运行时对内存的开销。此外,在Java语言中,每个类或接口都对应一个.class文件,这些文件可以看成是一个个可以被动态加载的单元。因此当只有部分类被修改时,只需要重新编译变化的类即可,而不需要重新编译所有文件,加快了编译速度。

4.在Java语言中,类加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类完全加载到JVM,至于其他类,则需要的时候才会加载

5.类加载的主要步骤:装载、链接、初始化

垃圾收集器

Serial:新生代收集器,使用一个处理器或一条收集线程去完成垃圾收集工作,而且在垃圾收集的时候,必须暂停其他的工作线程,直至它收集结束。是虚拟机运行在客户端模式下的默认新生代收集器。对于内存受限的环境,他是所有收集器里额外内存消耗最少的,对于单核处理器或处理器核心较少的额环境来说,它由于没有线程交互的开销,有最高的的单线程收集效率。

ParNew:实质上是Serial收集器的多线程并行版本,其余行为都与Serial一致。除了Serial收集器,只有他能和CMS收集配合工作。是激活CMS后的默认新生代收集器。自JDK9开始,ParNew加CMS不再是官方推荐的服务端模式下的收集器解决方案了。官方希望C1取代这个组合,甚至取消了ParNew+Serial Old和Serial+CMS这两对组合。并且取消了 -XX:+UseParNewGC参数,意味着ParNew和CMS只能互相搭配使用,再也没有收集器和他们搭配了。

Parallel Scavenge:新生代收集器,能够并行收集的多线程收集器。他的目标是达到可控制的吞吐量。

Serial Old:Serial收集器的老年版本,单线程收集器,基于标记-整理算法。如果在服务端的模式下,有两种用途:一是在JDK5及以前的版本中与Parallel Scavenge搭配使用,二是作为CMS收集器发生失败时的后备预案

Parallel Old:Parallel Scavenge收集器的老年代版本,基于标记-整理算法实现。在注重吞吐量或者处理器资源比较稀缺的场合可以优先考虑Parallel Scavenge+Parallel Old的组合。

CMS:以获取最短回收停顿时间为目标的收集器。基于标记清除算法。处理过程分为四个步骤:初始标记,并发标记,预清理,可被终止的预清理,重新标记,并发清除、并发重置状态等待下次CMS的触发。初始标记和重新标记需要STW,其他步骤均可以和用户线程同时运行。

  • 初始标记:标记老年代中所有的GC Roots直接关联到的对象。标记年轻代中活着的对象引用到的老年代的对象。为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,同时调大并行标记的线程数,线程数不要超过CPU的核数
  • 并发标记:从"初始标记"阶段标记的对象开始找出所有存活的对象。因为是并行运行的,在运行期间会发生新生代的对象晋升到老年代、直接在老年代分配对象、更新老年代对象的引用关系等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代。并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理
  • 预清理阶段:上一个阶段不能标记出老年代全部的存活对象,这个阶段用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象,他会扫描所有标记为dirty的card。
  • 可终止的预处理:为下一段尝试去承担足够多的工作。这个阶段持续的事件依赖好多的因素,由于这个阶段是重复做相同的事情直到发生aboart的条件(比如:重复的次数,多少量的工作,持续的时间等等)之一才会停止。此阶段最大持续时间为5秒,为了期待这5秒内能够发生一次minor GC,清理年轻代的引用,下个阶段扫描年轻代指向老年代的引用时间减少
  • 重新标记:标记整个老年代的所有存活对象。这个阶段重新标记的内存范围是整个堆。对于老年代中的对象,如果被新生代中的对象引用,那么就被视为存活对象、当此阶段耗时较长的时候,可以加入参数 -XX:+CMSScavengeBeforeRemark,在重标记之前,先执行一次minro GC,这样只需扫描to survivor区。之前的预处理阶段是与用户线程并发执行的,可以新生代对老年代的引用发生了很多改变,这个阶段就需要花很多时间去处理,通常这个阶段最好是在新生代足够干净的时候
  • 并发清理:清除那些没有被标记的对象,并且回收空间。由于该阶段用户线程还在运行着,可能还会产生垃圾,无法在当次收集中处理的垃圾,只能留到下一次GC再清理的垃圾称为"浮动垃圾"
  • 并发重置:重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用

G1:将堆进行分区,划分一个个的区域,每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生一次停顿的时间。收集过程的四个阶段,新生代GC、并发标记周期,混合收集、(如果需要)进行 Full GC

  • 新生代GC:回收Eden区和survivor区。一旦Eden区被占满,新生代GC就会启动

  • G1并发标记周期:针对老年期,分为以下几步:

  • 初始标记:标记从根节点直接可达的对象。这个阶段会伴随一次新生代GC,它是会产生STW

  • 根区域扫描:由于初始标记必然会伴随着一次新生代GC,所以在初始化标记后,Eden被清空,并且存活对象被移到survivor区。在这个阶段,将扫描由survivor区直接可达的老年代区域,并标记这些直接可达的对象。这个过程是可以和应用程序并发执行的。但是根区域不能和新生代GC同时发生,如果恰巧此时需要新生代GC,GC就需要等待根区域扫描结束后才能进行,如果发生这种情况,这次新生代GC的时间就会延长

  • 并发标记:扫描并查找整个堆的存活对象,并做好标记。这是一个并发的过程,可以被一次新生代GC打断

  • 重新标记:会产生STW,对上一次标记进行补充。在G1中,这个过程使用SATB算法完成,即G1会在标记之初为存活对象创建一个快照,有助于加速重新标记的速度

  • 独占清理:计算各个区域的存活对象和GC回收比例进行排序,识别可供混合回收的区域。在这个阶段,还会更新记忆集。该阶段给出了需要被混合回收的区域并进行了标记,在混合回收阶段,需要这些信息

  • 并发清理阶段:识别并清理完全空闲的区域。是并发的清理

  • 混合回收:虽然有部分对象被回收,但是回收的比例是非常低的。但是在并发标记周期后,G1已经明确知道哪些区域含有比较多的垃圾对象,在混合回收阶段,就可以专门针对这些区域进行回收。G1会优先回收垃圾比例较高的区域。混合回收会被执行多次,直到回收了足够多的内存空间,然后,他会触发一次新生代的GC。新生代GC之后,又可能会发生一次并发标记周期的处理,最后又会引起混合回收。

  • 必要时的Full GC:并发收集让应用程序和GC线程交替工作,因此在特别繁忙的情况下无可避免的会发生回收过程中内存不足的情况,当遇到这种情况,G1会转入一个Full GC进行回收。以下情况会触发这类的Full GC:

  • 并发模式失效:G1启动标记周期,但在混合回收之前,老年代就被填满,这时候G1会放弃标记周期。这种情况下,需要增加堆大小,或者调整周期

  • 晋升失败:G1完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就被耗尽,触发了GC

  • 疏散失败:进行新生代垃圾收集时,survivor和老年代中没有足够的空间容纳所有的幸存对象

  • Humongous Object分配失败:当Humongous Object(超过region大小的一半)找不到合适的空间进行分配时,就会启动Full GC,来释放空间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值