关于Java虚拟机,你需要了解的

概述

我们常说的JDK(Java Development Kit)包含了Java语言、Java虚拟机和Java API类库这三部分,是Java程序开发的最小环境。而JRE(Java Runtime Environment)包含了Java API中的Java SE API子集和Java虚拟机这两部分,是Java程序运行的标准环境。可以看出Java虚拟机的重要性,它是整个Java平台的基石,是Java语言编译代码的运行平台。你可以把Java虚拟机看做一个抽象的计算机,它有各种指令集和运行时数据区域。
虽然叫Java虚拟机,但其实它能运行的语言不仅仅是Java,还包括Kotlin、Groovy等。同时需要注意的是,Android中的Dalvik和ART虚拟机并不属于Java虚拟机。

Java虚拟机的执行流程

当我们执行一个Java程序时,它的执行流程如下图所示:


Java程序执行流程

从图中可以发现,java程序的执行流程可以分为四个步骤:编辑源代码、编译生成Class文件、加载Class文件、执行Class文件里的字节码指令。当一个Java'文件经过Java编译器编译后会生成Class文件,这个Class文件会由Java虚拟机处理。Java虚拟机与Java语言并没有什么必然的联系,它只与特定的二进制文件:Class文件有关。因此无论任何语言,只要能编译成Class文件,就可以被Java虚拟机识别并执行。

Java虚拟机结构
Java虚拟机结构

Java虚拟机结构包含运行时数据区域、执行引擎、本地库接口和本地方法库。但上图中所示的类加载子系统并不属于Java虚拟机的内部结构。图中还标出了线程共享区域和线程私有的区域,比如方法区和Java堆就是所有线程共享的数据区域。

Class文件格式

Java(.java)文件被编译后会生成Class(.class)文件,这种二进制格式文件不依赖于特定的硬件和操作系统。每个Class文件中都对应着唯一的类或者接口的定义信息(内部类和匿名内部类编译后也会生成单独的Class文件)。但是类或者接口并不一定定义在文件中,比如类和接口可以通过类加载器直接生成。Class文件的格式如下:


Class文件的格式

ClassFile具有很强的描述能力,包含了很多关键的信息,其中部分字段前面的u4,u2表示"基本数据类型",class文件的基本数据类型如下:

  • u1:1字节,无符号类型
  • u2:2字节,无符号类型
  • u4:4字节,无符号类型
  • u8:8字节,无符号类型
类的生命周期

一个Java类的Class文件被加载到Java虚拟机内存中到从内存中卸载的过程被称为类的生命周期。包括以下几个阶段:加载、链接、初始化、使用和卸载。其中链接又可以分为三个阶段:验证、准备和解析。


类的生命周期

广义上来说,也是经常被面试问到的,类的加载机制是指:加载、验证、准备、解析和初始化这5个阶段。这几个阶段分别完成以下工作:
(1)加载:查找并加载Class文件。加载阶段主要做了三件事:

  • 根据特定名称查找类或接口类型的二进制字节流,这件事是由Java虚拟机外部的类加载子系统来完成的。
  • 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
    (2)链接:包括验证、准备和解析。
  • 验证:验证载入的Class文件数据的正确性。
  • 准备:为类的静态变量分配内存,并用数据类型默认值初始化这些字段。
  • 解析:虚拟机将常量池内的符号引用替换为直接引用。(符号引用是用一组符号描述所引用的目标;直接引用是指向目标的指针)

(3)初始化:将类变量初始化为正确的初始值。这个阶段主要包括两个过程:

  • 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
  • 如果类中存在初始化语句,就依次执行这些初始化语句。
类加载子系统

类加载子系统通过多种类型的类加载器来完成查找和加载Class文件到Java虚拟机中。Java中有三种默认实现的类加载器:
(1)Bootstrap ClassLoader(引导类加载器)
用C/C++代码实现的加载器,用于加载指定的JDK核心库,比如java.lang、java.util等这些系统类。它用来加载以下路径下的类库:

  • $JAVA_HOME/jre/lib目录
  • -Xbootclasspath参数指定的目录
    Java虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的C/C++语言实现的。所以该加载器不能被java代码访问到,但是我们可以查询某个类是否被引导类加载器加载过。

(2)Extensions ClassLoader(扩展类加载器)
用于加载Java的扩展库,提供除了系统类之外的额外功能。它用来加载以下路径下的类库:

  • $JAVA_HOME/jre/lib/ext目录
  • 系统属性java.ext.dir所指定的目录

(3)Application ClassLoader(应用类加载器)
又称作System ClassLoader,因为这个类加载器可以通过ClassLoader.getSystemClassLoader()方法获取到。它由来加载以下路径下的类库:

  • 当前应用程序Classpath目录
  • 系统属性java.class.path指定的目录

除了系统自带的类加载器,用户还可以实现自定义加载器,它是通过继承java.lang.ClassLoader类的方式来实现自己的类加载器。

运行时数据区域

把Java的内存简单分为堆内存(Heap)和栈内存(Stack),这种说法不够准确。Java的内存区域划分实际上远比这复杂。Java虚拟机在执行Java程序的过程中,会对它所管理的内存划分为不同的数据区域。

程序计数器

为了保证程序能够连续地执行下去,处理器必须具有某些手段来确定下一条执行的地址,这就是程序计数器的作用。它是一块很小的内存空间。在虚拟机概念模型中,字节码解释器工作时(执行字节码指令)就是通过改变程序计数器里存储的地址,来选取下一条需要的字节码指令。Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式实现的。在一个确定的时刻,只有一个处理器执行一个线程中的指令,为了在线程来回切换后能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,也就是说,程序计数器是每个线程私有的。如果线程执行的方法不是Native方法,则程序计数器保存正在执行的字节码指令地址,如果是Native方法,则程序计数器的值为空(Undefined)。程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的数据区域。
实际上除了恢复线程执行时会用到程序计数器之外,其它一些我们熟悉的判断分支操作、循环操作、跳转和异常处理也都需要依赖程序计数器来完成。

Java虚拟机栈

每个Java虚拟机线程都有一个线程私有的Java虚拟机栈。它的生命周期和线程相同,与线程同时创建。Java虚拟机栈存储的是线程中Java方法的调用状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储一个方法的局部变量表、操作数栈、动态链接、方法出口等信息。当线程执行一个Java方法时,虚拟机会压入一个新的栈帧到该线程的Java虚拟机栈中,该方法执行完成后,这个栈帧就会从Java虚拟机栈中弹出。具体过程可参考此处。我们平时所说的栈内存指的就是Java虚拟机栈,Java虚拟机规范中,定义了两种异常情况:

  • 如果线程请求分配的栈容量超过Java虚拟机所允许的最大容量,Java虚拟机就会抛出StackOverflowError。实际开发中,没有结束条件的递归方法调用,是常见的产生StackOverflowError的场景。
  • 如果Java虚拟机栈可以动态扩展(大部分Java虚拟机的虚拟机栈都可以动态扩展),但是扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存来创建对应的虚拟机栈,则会抛出OutOfMemoryError。

在学习JVM的过程中,经常会看到一句话:
JVM是基于栈的解释器执行的,DVM是基于寄存器解释器执行的
这里说道的“基于栈”,指的就是虚拟机栈。虚拟机栈的初衷是用来描述Java方法执行的内存模型。

关于栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行一个方法时,都会为这个方法创建一个栈帧。每个栈帧内部包含局部变量表,操作数栈,动态链接和返回地址等。
局部变量表——是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在 Java 编译成 class 文件的时候,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。注意:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在静态变量那样的准备阶段。
操作数栈——同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈。
动态链接——它是为了支持方法调用过程中的动态链接(Dynamic Linking)。在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用(方法名)转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接(Dynamic Linking)。
返回地址——在一个方法的执行过程中,无论是执行完毕正常退出还是中途异常退出后,都需要返回到方法被调用的位置,程序才能继续执行,虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

本地方法栈

Java虚拟机实现可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈(Native Method Stack)。它与Java虚拟机栈类似,只不过本地方法栈是用来支持Native方法的。如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无须支持本地方法栈。在Java虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具体的Java虚拟机可以自由实现它。与Java虚拟机栈类似,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。在实际开发中如果涉及JNI,可能接触本地方法栈多一些。

Java堆

Java堆(Java Heap)是被所有线程共享的运行时内存区域,Java堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆内存的对象被垃圾回收器管理,这些受管理的对象无法显式地销毁。从内存回收的角度来分,Java堆可以粗略的分为两部分:新生代和老年代,其中新生代又被分为Eden和Survivor空间。从内存分配的角度,Java堆中可能划分出多个线程私有的分配缓冲区。不管如何划分,Java对存储的内容是不变的,进行划分是为了能更快地回收或者分配内存。Java堆的容量可以是固定的,也可以动态扩展。Java堆所使用的内存在物理上不需要连续,逻辑上连续即可。Java虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展时,则会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)是被所有线程共享的运行时内存区域,用来存储已经被Java虚拟机加载过的类的结构信息,包含运行时常量池、字段和方法信息、静态变量、即时编译器编译后的代码和数据等。方法区是Java堆的逻辑组成部分,他一样在物理上不需要连续,并且可以选择在方法区中不实现垃圾回收。方法区并不等于永久代。只是因为HotSpot VM使用永久代来实现方法区,对于其他的Java虚拟机,比如J9和JRockit等,并不存在永久代概念。

运行时常量池

运行时常量池(Runtime Constant Pool)并不是运行时数据区域的其中一份子,而是方法区的一部分。Class文件不仅包含类的版本、接口、字段和方法等信息,还包含常量池,它用来存放编译时期生成的字面量(int i= 1;就是把1赋值给变量i,这个1就是字面量)和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。运行时常量池可以理解为是类或接口的常量池运行时的表现形式。在Java虚拟机规范中转定义了一种异常情况:当创建类或接口时,如果构造运行时常量池所需要的内存超过方法区所能提供的最大值,Java虚拟机机会抛出OutOfMemoryError。

下图展示了JVM运行时数据区域各部分的结构
image.png
对象的创建

通常是通过new指令来完成一个对象的创建。当虚拟机收到一个new指令时,它会做如下操作:
(1)判断对象所属的类是否被加载、链接和初始化
虚拟机接收到一条new指令时,首先会检查这个指定的参数(类名)是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化。
(2)为对象分配内存
类加载完成后,接着JVM会在Java堆中划分一块内存给对象。内存分配根据Java堆是否规整,有两种方式。

  • 指针碰撞:如果Java堆的内存是规整的,即所有正在使用的内存放在一边,而空闲的内存放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成了分配内存工作。
  • 空闲列表:如果Java堆内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是空闲可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
    Java堆内存是否规整与JVM锁采用的垃圾回收器是否带有压缩整理功能有关。

(3)处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:

  • 对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS算法并配上失败重试的方式保证更新操作的原子性。
  • 每个线程在Java堆中预先分配一小块内存,这块内存成为本地线程分配缓冲(Therad Local Allocation Buffer,TLAB),线程需要为对象分配内存时,就在对应线程的TLAB上分配内存,当线程的TLAB用完并且被分配到了新的TLAB时,这时候才需要同步锁定。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

(4)初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。
(5)设置对象的对象头
将对象的所属类,对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。
(6)执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就创建出来了。

对象在堆内存的布局

以HotSpot虚拟机为例,一个对象在堆内存的分布分为三个区域:对象头、实例数据、对齐填充。

  • 对象头:对象头包括两部分信息,分别是Mark Word和元数据指针。Mark Word用于存放对象运行时的数据、比如HashCode、锁状态标志、GC分代年龄、线程持有的锁(monitor)等。而元数据指针用于指向方法区中的目标类的元数据,通过元数据可以确定对象的具体类型。
  • 实例数据:用于存放对象中的各种类型的字段信息,包括从父类继承来的。
  • 对齐填充:对齐填充不一定存在,起到了占位符的作用,没有特别的含义。
oop-Klass模型

oop-Klass模型是用来描述Java对象实例的一种模型,它分为两个部分,OOP(Ordinary Object Pointer)指的是普通对象指针,用来表示对象的实例信息。klass用来描述元数据。在Java虚拟机内部会分别定义很多oop类型和Klass类型,它们可以看做是oop家族和klass家族
oop家族

其中oopDesc是所有oop的顶级父类,arraryOopDesc是objArrayOopDesc和typeArrayOopDesc的父类。instanceOopdesc和arrayOopDesc都可以用来描述对象头。
klass家族

其中Klass是klass家族的父类(不是顶级父类),可以发现oop家族中的成员和klass家族的成员有着对应的关系,比如instanceOopDesc对应着instanceKlass。
当我们使用new创建一个对象的时候,JVM会在对重创建一个instanceOopDesc对象,这个对象中包含对象头以及实例数据。而我们从oop家族的关系可以看到,instanceOopDesc的父类是oopDesc。它的结构如下:
oopDesc

oopDesc中包含两个数据成员:_mark和_metadata。其中markOop类型的_mark对象指的就是对象的对象头中的Mark Word。metadata是一个共用体,其中_klass是普通指针,_compressed_klass是压缩类指针,他们就是对象头中的元数据指针。这两个指针数据根据对应关系都会指向instanceKlass,instanceKlass可以用来描述元数据。
instanceKlass

instanceKlass继承自Klass,枚举ClassState用来标识对象的加载进度,Klass中的定义的部分字段如下:
Klass

可以看到Klass描述了Java类的元数据,具体来说就是Java类在JVM中对等的C++类型描述,这样继承自Klass的instanceKlass同样可以用来描述元数据。了解了oop-klass模型,我们就可以分析JVM是如何通过栈帧中的对象引用找到对应的对象实例的。
通过对象引用确定对象具体类型

从图中可以看出,JVM通过栈帧中的对象引用找到Java堆中的instanceOopDesc对象,这样就可以访问到Java对象的实例信息,当需要访问对象的具体类型等信息时,可以通过instanceOopDesc的元数据指针来找到方法区中对应的instanceKlass。
垃圾标记算法

垃圾回收器,简称GC,主要做了两个工作,一个是内存的划分和分配,另一个是对垃圾进行回收。关于内存的划分和分配,目前JVM的内存划分是依赖于GC设计的,比如现在GC都是采用了分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域被划分为新生代和老年代。而新生代又可以细分为Eden空间、FromSurvivor空间和ToSurvivor空间等。这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。关于垃圾回收,被引用的对象是存活的对象,没有被引用的对象就是死亡的对象,也就是垃圾。GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。目前垃圾标记有两种算法分别是引用计数算法和根搜索算法。这两种算法都和引用有些关联,所以我们可以先回顾下引用的相关知识点。

Java中的引用

JDK1.2之后,Java将引用分为强引用,软引用,弱引用,虚引用。

  • 强引用:当我们新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用,GC就绝不会回收它。JVM宁愿抛出OOM异常,使程序异常终止,也不会回收具有强引用的对象来解决内存不足的问题。
  • 软引用:如果一个对象只具有软引用,当内存不够时,GC会回收这些对象的内存,回收后如果还没有足够的内存,就会抛出OOM异常。Java提供了SoftReference类来实现软引用。

软引用隐藏问题 Java虚拟机究竟是如何处理SoftReference的

  • 弱引用:弱引用比软引用的生命周期更短,GC一旦发现对象只具有弱引用,不管当前是否足够,都会回收它的内存。Java提供了WeakReference类来实现弱引用。
  • 虚引用:虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都有可能被GC回收。一个只具有虚引用的对象,被GC回收时会发出一个系统通知,这也是虚引用的主要作用。Java提供了PhantomReference类来实现虚引用。
引用计数算法

引用计数算法的基本思想是,每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1,引用失效时就减1,当引用计数器中的值为0,则该对象就不能被使用,变成了垃圾。
目前主流的JVM没有选择引用计数算法来为垃圾标记的,主要原因是引用计数算法没有解决对象之间的相互循环引用的问题。

根搜索算法

这个算法的基本思想是选定一些对象作GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下探索,如果目标对象到GC Roots是连接着的,我们则称该对象是可达的,如果目标对象不可达则说明目标对象是可以回收的对象。
根对象集合

该算法可以解决引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java中,可以作为GC Roots的对象主要有这么几种:

  • 虚拟机栈中引用的对象
  • 本地方法栈中JNI引用的对象
  • 方法区静态变量引用的对象
  • 方法区运行时常量池引用的对象
  • 正在运行的线程
  • 有Boorstrap ClassLoader加载的对象
  • GC控制的对象

还有一个问题是被标记为不可达的对象会立即被GC回收吗?要回答这个问题我们需要了解Java对象在JVM中的生命周期。

Java对象在JVM中的生命周期
  1. 创建阶段
    创建阶段的具体步骤为
    (1)为对象分配内存空间
    (2)构造对象
    (3)从超类到子类对static成员进行初始化
    (4)超类成员变量按顺序初始化,递归调用超类的构造方法
    (5)子类成员按顺序初始化,子类构造方法调用
  2. 应用阶段
    当对象被创建,并分配给变量赋值时,状态就切换到了应用状态。这个阶段的对象至少要具有一个强引用,或者显式地使用软引用,弱引用或者虚引用。
  3. 不可见阶段
    在程序中找不到对象的任何强引用,比如程序的执行已经超过了该对象的作用域。在不可见状态,对象仍可能被特殊的强应用GC Roots持有者,比如对象被本地方法栈中JNI引用或被运行中的线程引用等。
  4. 不可达阶段
    在程序中找不到对象的任何强引用,并且GC发现对象不可达。
  5. 收集阶段
    GC已经发现对象不可达,并且GC已经准备好要对该对象的内存空间进行重新分配,这个时候如果该对象重写了finalize方法,则会调用该方法。
  6. 终结阶段
    对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写finalize方法,则对象会进入终结阶段,等待GC来回收该对象的空间。
  7. 对象空间重新分配阶段
    当GC对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失了。

我们现在已经了解了对象的生命周期,再来思考下先前那个问题,被标记为不可达的对象是否会立即被GC回收?很显然是不会的,被标记为不可达的对象会进入收集阶段,此时会执行该对象重写的finalize方法,如果没有重写finalize方法或者finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收。

垃圾收集算法
标记-清除算法

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾回收算法。它将垃圾回收分为两个阶段:

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占的内存空间

    标记-清除算法之所以是基础的,这是因为后面讲到的其他垃圾回收算法都是在此算法的基础上进行改进的,下图演示了标记-清除算法的执行过程:
    标记-清除算法执行过程

    标记-清除算法主要有两个缺点:一个是标记和清除的效率都不高,另一个就是容易产生大量不连续的内存碎片,碎片太多可能会导致后续没有足够的连续内存分配给较大的对象,从而提前触发新一次的垃圾回收动作。
复制算法

为了解决标记-清除算法效率不高的问题,产生了复制算法。它把内存空间划分了两个相等的区域,每次只使用其中一个区域。在垃圾收集时,遍历当前使用的区域,把存活的对象复制到另一个区域中,最后将当前使用区域的可回收对象进行回收。执行过程如下图。
复制算法的执行过程

这种算法每次都对整个半区进行内存回收,不需要考虑内存碎片问题,代价就是使用内存为原来的一半。复制算法的效率和存活对象的数量多少有很大关系,如果存活对象很少,复制算法的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周期很短的对象都存在于新生代中,所以复制算法被广泛应用于新生代中。

标记-压缩算法

在新生代中可以使用复制算法,但是在老年代中就不能选择复制算法了,因为老年代的对象存活率较高,这样会有较多的复制操作,导致效率较低。因此就出现了一种标记-清除算法的改进版,标记-压缩算法。与标记-清除算法不同的是,在标记可回收对象后,将所有存活的对象压缩到内存空间的一端,使它们紧凑地排列在一起,然后对边界之外的内存空间进行回收,回收后,已使用和未使用的内存就各自一边,标记-压缩算法的执行过程如下图。
标记-压缩算法执行过程

标记-压缩算法解决了标记-清除算法效率低和容易产生大量内存碎片的问题,它被广泛应用于老年代的垃圾回收。

分代收集算法

分代收集算法结合不同的收集算法来处理不同的空间。了解分代收集算法之前,我们先要了解Java堆内存的空间划分。在java虚拟机中,各种对象的生命周期会有较大的差异,大部分对象生命周期很短暂,少部分对象生命周期很长,有的甚至和应用程序以及JVM的运行周期一样长。因此,应该对不同生命周期的对象采用不同的回收策略,根据生命周期长短将它们分别放在Java堆内存的不同划分区域,并且在不同的区域采用不同的垃圾回收算法,这就是分代的概念。现在主流的Java虚拟机的GC都采用了分代收集算法。Java堆内存基于分代的概念,分为新生代和老年代,其中新生代又细分为Eden空间、From Survivor空间和To Survivor空间。新创建的对象通常先进入Eden空间,因为Eden中大多数对象生命周期很短,所以新生代的空间划分不是均分的,比如HotSpot虚拟机默认Eden空间和两个Survivor空间所在的比例是8:1:1。
根据Java堆内存区域的空间划分,垃圾回收的类型有两种:

  • Minor Collection:新生代垃圾回收。
  • Full Collection:又称作Major Collection,对老年代进行垃圾回收。Full Collection通常情况下会伴随至少一次的Minor Collection,它的收集频率较低,耗时较长。

当执行一次Minor Collection时,Eden空间的存活对象会被复制到To Survivor空间,同时,之前经过一次Minor Collection并在From Survivor空间存活的对象也会复制到To Survivor空间。有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor,而是晋升到老年代。一种是存活对象的分代年龄超过了所指定的阈值。另一种是To Survivor空间容量达到阈值。当所有存活对象被复制到To Survivor空间,或晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象。
复制算法在新生代中的应用

这个时候GC执行Minor Collection,Eden空间和FromSurvivor空间都会被清空,新生代中存活的对象都存放在To Survivor空间或晋升到老年代,接下来将From Survivor和To Survivor空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次Minor Collection的执行,Survivor空间互换都保证了To Survivor空间是空的,这就是复制算法在新生代中的应用。而在老年代则会采用标记-压缩算法或者标记-清除算法。

java对象内存申请过程

绝大多数刚创建的对象会存放在Eden空间。如图所示,S0和S1分别指上文中提到的,会相互切换的To Survivor和From Survivor空间。
image.png

当Eden空间第一次满的时候,会触发GC。首先将Eden空间的垃圾对象回收掉,并将存活的对象复制到S0,此时S1,是空的。
image.png
下一次Eden空间再次装满时,再执行一次GC。此次会依次将Eden空间和S0空间的所有垃圾对象回收,并将存活对象复制到S1空间。若S1空间无法存放所有的垃圾对象,则无法存放的对象将直接转移到老年代。
image.png
如此反复在S0和S1之间切换几次(默认15次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代。
image.png

一个对象如果在新生代存活了足够长的时间还没有被回收,则会被复制到老年代。老年代的空间一般比新生代大,能存放更多的对象,如果对象比较大(比如长字符串和大容量数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代。
可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代 GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个 512 byte 的 card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生 GC 时,只需要检查这个 card table 即可,大大提高了性能。

关于对象如何从年轻代到老年代

对象如何从年轻代到老年代

触发Major GC的条件

JVM进行Minor GC的频率很高,而且Minor GC执行的时间很短,所以对系统产生的影响不大。而Major GC的执行,对系统的影响就很明显,值得我们关注,总的来说,有两个条件会触发Major GC:

  • 当应用程序空闲时,即没有应用线程在执行时,Major GC会被调用。因为GC是在优先级最低的线程中进行,所以当应用程序任务繁忙时,GC线程就不会被调用,当下列条件除外。
  • Java堆内存不足时,GC会被调用。当应用线程运行过程需要创建新的对象,若此时内存空间不够,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若一次GC之后仍不能满足内存分配的需求,JVM会再进行两次GC作进一步的尝试,如果还是不能满足要求,没有足够的内存创建新对象,则JVM会抛出OOM异常,应用程序将终止。
减少GC开销的措施

基本思想就是尽量减少Major GC的触发,原则就是尽可能减少垃圾产生和减少GC过程中的开销。

  1. 不要显示地调用System.gc()
  2. 尽量减少临时对象的创建和使用
  3. 将不再使用的对象显式地设为null
  4. 尽量使用StringBUffer和StringBuilder,而不用String来累加字符串
  5. 能用基本类型如int、long,就不要用Integer、Long对象
  6. 尽量少用静态变量。类的静态变量保存在方法区,而在很多java虚拟机中,不会对方法区内存进行GC操作
  7. 不要短时间集中创建和删除对象
垃圾回收机制和调用System.gc()的区别

调用System.gc()方法其实是建议JVM进行一次Major GC:程序员希望进行一次Major GC。但是它不保证Major GC一定会进行,而且具体什么时候进行取决于JVM的实现,不同的JVM有不同的对策。
虽然只是建议而非一定,但是多数情况下,它都会触发Major GC,从而增加了Major GC的频率,也即增加了应用程序间歇性停顿的次数。

本文参考
《Android进阶解密》
https://www.jianshu.com/p/d686e108d15f
https://blog.csdn.net/peerslee/article/details/79166477
https://blog.csdn.net/github_37130188/article/details/102731811
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc?id=1856
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc?id=1855
https://mp.weixin.qq.com/s/XRCq3IDdGJt3Nq9Mu23U5g

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349