JAVA 的内存管理中,将内存分为了运行时数据区域和直接内存区域。运行时数据区域是JAVA需要进行分配和垃圾回收管理的最主要区域。而直接内存是java1.4中才提出的一个NIO的缓冲区域,它会直接调用Native函数库直接进行堆外内存分配,然后通过一个存储在JAVA堆中的对象进行该内存区域的管理。这样,直接内存其实也是会间接的受到gc的影响的,但是细节不清楚,这样做的好处是避免了垃圾回收的时候频繁的复制和移动数据,提高了性能。同样的,直接内存也会出现OOM问题。
具体参考直接内存
以下详细的介绍运行时数据区域。
根据上面的运行时数据区域图可以知道,线程共享的内存区域主要有
同样的,线程不共享的区域主要有:
虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存放着一种叫做“栈帧”的东西,每个方法在执行的时候会创建一个栈帧,存储了局部变量表(基本数据类型和对象引用),操作数栈,动态连接,方法出口等信息。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。通常所说的栈,一般是指虚拟机栈中的局部变量表部分。局部变量表所需的内存在编译期间完成分配。栈的大小可以固定也可以动态扩展,当扩展到无法申请足够的内存,则OutOfMemoryError。
本地方法栈:
线程计数器:
注意的一点:
基本数据类型是放在栈中还是放在堆中,这取决于基本类型声明的位置。在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因。
在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。
在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量。
栈内存是线程私有的,当线程执行一个方法,这个方法作为栈帧入栈,同时方法内部的局部变量也会在栈帧内分配内存,其中基本类型的值就直接保存在栈帧里。而大多数对象初始化会在堆内存开辟空间,然后把这个堆内存的地址给引用类型变量。堆内存非线程私有,几乎所有对象都保存在这个这个区域,对象内的基本类型的成员变量也保存在这个区域。所以堆内存中的对象可以同时被多个线程访问,并非线程安全。所以方法中的基本类型局部变量一定是保存在线程的栈内存,而在方法中构造的对象的基本类型成员变量,如果该对象是分配在堆内存,那么它的成员变量自然是在堆内存保存。上面我没有说对象的基本类型成员变量就一定是在堆内分配内存的,因为虚拟机会对方法做逃逸分析优化,如果一个对象在方法内创建,它的引用不会离开方法,也就是这个对象的生命周期随着栈帧出栈结束,那么虚拟机为了高效回收内存可能就把这个对象分配在栈内了,所以该对象的基本类型成员变量就保存在栈上。
「小知识」网上的很多资料都称 : 基本数据和对象引用存储在栈中。
当然这种说法虽然是正确的,但是很不严谨, 只能说这种说法针对的是局部变量。局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。
但是,如果是成员变量,或者定义在方法外对象的引用,它们存储在堆中。因为在堆中,是线程共享数据的,并且栈帧里的命名就已经清楚的划分了界限 : 局部变量表!
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。如上图所示,我们称这类内存区域为 : 线程私有内存。此内存区域是唯一一个在Java虚拟机中没有规范任何OutOfMemoryError情况的区域。
Java虚拟机栈也是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)。
Java虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时会创建一个 栈帧。 对于我们来说,主要关注的stack栈内存,就是虚拟机栈中 局部变量表部分。
栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧用于存储 局部变量表、操作数栈、动态链接、方法返回等信息。 每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
在活动线程中,只有位于栈顶的栈帧才是有效的,称为 当前栈帧,与这个栈帧相关联的方法称为 当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double) 「String是引用类型」,对象引用(reference类型)和returnAddress类型(它指向了一条字节码指令的地址)
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务(也就是字节码)服务,而本地方法栈为虚拟机使用到的Native方法服务。
Java堆是被所有 线程共享的一块内存区域,在虚拟机启动时创建,是虚拟机所管理的内存中最大的一块。此内存区域的唯一目的就是 【存放对象实例和数组】,几乎所有的对象实例和数组都在这里分配内存。Java堆是垃圾收集器管理的主要区域,也称为GC 垃圾堆。后面会专门分析GC算法。
从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以Java堆可以细分为:新生代、老生代;从内存分配的角度看,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(TLAB);
不论如何划分,都与存放的内容无关,无论哪个区域,存储的仍然是对象实例和数组。
内存泄露 : 指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即 被分配的对象可达但已无用,可用内存越来越少。集合类中,如果一个集合被一个线程所创建,但是这个线程实际上没有使用集合做啥事情,集合中存放的对象就会无法被回收,这样可能会造成内存泄漏。
https://www.jianshu.com/p/54b5da7c6816
内存溢出 : 指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于老年代或永久代垃圾回收后,仍然无内存空间容纳新的Java对象的情况。
内存泄露是内存溢出的一种诱因,不是唯一因素。
方法区又被称为静态区,是程序中永远唯一的元素存储区域。和堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。
关于静态变量,String 类的存放位置
8种基本类型的包装类和常量池
SPI的全名为Service Provider Interface,主要是应用于厂商自定义组件或插件中,在java.util.ServiceLoader的文档里有比较详细的介绍。简单的总结下java SPI机制的思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml解析模块、jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。 有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。
Java SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件,该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader。JDBC SPI mysql的实现如下所示。
一句话就是
定义好规范接口—>通过SPI可以发现存放在classpath下的具体实现类–>加载并实例化依赖
以前直接使用的是
Class.forName(Driver);
//使用这种方式可以直接将Driver 注册到虚拟机一个ConnectManager中,这在实例化中实现。
//而Driver的实例化则是上面的Class.forName实现。
Dirver的实例化过程详细代码如下:
package com.mysql.jdbc;
import com.mysql.jdbc.NonRegisteringDriver;
import java.sql.DriverManager;
import java.sql.SQLException;
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
public Driver() throws SQLException {
}
// 类初始化时完成驱动注册
static {
try {
DriverManager.registerDriver(new Driver());
} catch (SQLException var1) {
throw new RuntimeException("Can\'t register driver!");
}
}
}
import 是告诉编译器类在哪里,让源文件可以编译通过;类的加载是虚拟机在执行的时候动态加载的,并不是一开始就加载。也就是说:一类如果使用了其他类,这个时候,你会import好多类,但是这些类并不是一开始就存在JVM中的,有的可能是执行到某个代码的时候,才把这些类逐步加载进来。但是前提是:这个类用先通过编译,就得告诉编译器关联的其他类的位置。
“主动使用”包括new、对该类型静态变量的读写、对该类型静态方法的调用,还有反射(如Class.forName()或ClassLoader.loadClass())。
加载:将类的class文件读到内存中,之前的编译会将程序编译为二进制的字节码文件class。类加载可以将该字节码文件读取到JVM中,并创建一个对应的Class对象。
连接:将这个二进制数据整合到JRE中。链接就是负责将使用到的函数,引用等的地址整合到内存中。
类初始化:注意,类的加载只会加载,不会进行参数或者变量的初始化。类的初始化时机:
初始化顺序:
静态代码块只会在触发初始化机制的时候,仅仅初始化一次。静态代码块或者静态属性只会被初始化一次,字后放在方法区共享。初始化的机制或者时机由上面可以知道。
非静态初始化块和构造器函数一样,会在每个实例创建的时候执行一次。
静态方法不需要初始化,方法的本质就是一系列的字节码(也就是执行指令),只有类才需要初始化,方法不会被初始化的。
类在加载的过程中并不会触发初始化,累的加载机制和原理可以知道,它就是负责将二进制的字节码文件加载到JVM 中,存在其他的条件来触发类的初始化。注意,静态的东西并不会在类加载的时候进行初始化!!!而且,构造器是最后才初始化的。普通方法调用的时候才初始化。
为了便于描述,我简单的统称:
1)方法本身是指令的操作码部分,保存在stack中;
2)方法内部变量作为指令的操作数部分,跟在指令的操作码之后,保存在stack(简单类型保存在stack中,对象类型在stack中保存地址,在heap中保存值);上述的指令操作码和指令操作数构成了完整的Java指令。
3)对象实例包括其属性值作为数据,保存在数据区heap中。
非静态的对象属性作为对象实例的一部分保存在heap中,而对象实例必须通过stack中保存的地址指针才能访问到。
因此能否访问到对象实例以及它的非静态属性值完全取决于能否获得对象实例在stack中的地址指针。
先分析一下非静态方法和静态方法的区别:非静态方法有一个和静态方法很重大的不同:
非静态方法有一个隐含的传入参数,该参数是JVM给它的,和我们怎么写代码无关,这个隐含的参数就是对象实例在stack中的地址指针。因此非静态方法(在stack中的指令代码)总是可以找到自己的专用数据(在heap中的对象属性值)
当然非静态方法也必须获得该隐含参数,因此非静态方法在调用前,必须先new一个对象实例,获得stack中的地址指针,否则JVM将无法将隐含参数传给非静态方法。
而静态方法无此隐含参数,因此也不需要new对象,只要class文件被ClassLoader load进JVM的stack,该静态方法即可被调用。
当然此时静态方法是存取不到heap中的对象属性的。
总结一下该过程:当一个class文件被ClassLoader load进JVM后,方法指令保存在stack中,此时heap区没有数据。
然后程序技术器开始执行指令,如果是静态方法,直接依次执行指令代码,当然此时指令代码是不能访问heap 数据区的;如果是非静态方法,由于隐含参数没有值,会报错。因此在非静态方法执行前,要先new对象,在heap中分配数据,并把stack中的地址指针交给非静态方法,这样程序技术器依次执行指令,而指令代码此时能够访问到heap数据区了。
再说一下静态属性和动态属性:
前面提到对象实例以及动态属性都是保存在heap中的,而heap必须通过stack中的地址指针才能够被指令(类的方法)访问到。
因此可以推断出:静态属性是保存在stack中的(基本类型保存在stack中,对象类型地址保存在stack,值保存在heap中),而不同于动态属性保存在heap中。正因为都是在stack中,而stack中指令和数据都是定长的,因此很容易算出偏移量,也因此不管什么指令(类的方法),都可以访问到类的静态属性。也正因为静态属性被保存在stack中,所以具有了全局属性。
总结一下:静态属性保存在stack指令内存区,动态属性保存在heap数据内存区。
是这样的,当我们new一个新对象或者引用静态成员变量时,Java虚拟机(JVM)中的类加载器子系统会将对应Class对象加载到JVM中,然后JVM再根据这个类型信息相关的Class对象创建我们需要实例对象或者提供静态变量的引用值。也就是说,Class对象对于类的实例化具有非常重要的意义。没它就没法new新对象和引用静态成员变量。
也就是,Class对象是提供实例化和静态属性和方法调用的时候的重要的参考依据。
Class类也是类的一种,与class关键字是不一样的。
https://juejin.im/post/6844903564804882445
初始化阶段时执行类构造器方法()的过程。
1)类构造器方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序所决定。
2)类构造器方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的类构造器方法执行之前,父类的类构造器方法已经执行完毕,因此在虚拟机中第一个执行的类构造器方法的类一定是java.lang.Object。
3)由于父类的类构造器方法方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
4)类构造器方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块也没有对变量的赋值操作,那么编译器可以不为这个类生成类构造器方法。
5)接口中可能会有变量赋值操作,因此接口也会生成类构造器方法。但是接口与类不同,执行接口的类构造器方法不需要先执行父接口的类构造器方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也不会执行接口的类构造器方法。
6)虚拟机会保证一个类的类构造器方法在多线程环境中被正确地加锁和同步。如果有多个线程去同时初始化一个类,那么只会有一个线程去执行这个类的类构造器方法,其它线程都需要阻塞等待,直到活动线程执行类构造器方法完毕。如果在一个类的类构造器方法中有耗时很长的操作,那么就可能造成多个进程阻塞。
https://juejin.im/post/6844903696996941832
Bootstrap ClassLoader为根类加载器,负责加载java的核心类库。根加载器不是ClassLoader的子类,是有C++实现的。
Extension ClassLoader为扩展类加载器,负责加载%JAVA_HOME%/jre/ext或者java.ext.dirs系统熟悉指定的目录的jar包。大家可以将自己写的工具包放到这个目录下,可以方便自己使用。
AppClassLoader(sun.misc.Launcher AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。
当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也由该类加载器负责载入,除非显示使用另一个类加载器来载入。加载过程遵循「双亲委派」原则。
这是一个很容易忽视的点。
先让父加载器试图加载该Class,只有在父加载器无法加载时该类加载器才会尝试从自己的类路径中加载该类。
双亲委派机制并不是正真的子类继承父类的关系,而是实例之间的父子关系。
双亲委派机制可以保证JVM 中每个类的唯一性。最具体的例子就是自己定义的类如果和系统类同名的话,自定义的类不会被加载。这样就可以保证类的唯一性。
双亲委派”是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。
“双亲委派”机制加载Class的具体过程是:
缓存机制会将已经加载的class缓存起来,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存中不存在该Class时,系统才会读取该类的二进制数据,并将其转换为Class对象,存入缓存中。这就是为什么更改了class后,需要重启JVM才生效的原因。
双亲委派」机制用来保证类的唯一性,那么 JVM 通过什么条件来判断唯一性呢?其实很简单,只要两个类的全路径名称一致,且都是同一个类加载器加载,那么就判断这两个类是相同的。如果同一份字节码被不同的两个类加载器加载,那么它们就不会被 JVM 判断为同一个类。
Class.forName()
方法默认使用当前类的ClassLoader
,JDBC是在DriverManager
类里调用Driver
的,当前类也就是DriverManager
,它的加载器是BootstrapClassLoader
。但是,在SPI中,由于ServiceLoader是存在于核心类库中的,也就是根据加载的机制,其所依赖的类也会使用启动类加载器来加载,而在ServiceLoader中,会自动的扫描得到具体的实现类(路径),同时通过Class.forName()来加载,这里的加载器默认的就是调用者的加载器。因此,这里的SPI 是不可能得到具体的实现类的。这就是逻辑上的破坏双亲委派机制。因此,有一种新的东西,叫做线程上线文类加载器,其默认的类加载器为 ApplicationClassLoader。线程上下文类加载器可以通过set的方式进行类加载器类型的设置等。
public static void main(String[] args) {
Thread th=new Thread(new Runnable() {
public void run() {
System.out.println("hello");
}
});
System.out.println(th.getClass().getClassLoader()); //null null就是boostrap
System.out.println(th.getContextClassLoader()); // Application
}
所谓的破坏这个机制,其实就是在上一级的类加载器中使用到自己无法加载的下一级的类的时候,需要解决的一种情况。例如Bootstrap中可能需要加载Application的类,线程上下文类加载器可以提供一个加载classpath的系统类加载器 AppClassLoader。这个的线程上下文类加载器的有点是可以设置想要的类加载器。
在JDBC4.0之后支持SPI方式加载java.sql.Driver的实现类。SPI实现方式为,通过ServiceLoader.load(Driver.class)方法,去各自实现Driver接口的lib的META-INF/services/java.sql.Driver文件里找到实现类的名字,通过Thread.currentThread().getContextClassLoader()类加载器加载实现类并返回实例。
Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:
对于创建的实例,一般是在堆上进行内存你的分配的,因此,这里主要详细地讲解堆上的内存分配策略。
新生代是新创建的对象优先分配内存的地方,新生代可以分为三个部分。其中的Eden是主要进行新对象分配的地方,为了缓解直接将全部的对象一次性直接搬运到老年代过程中的性能消耗,引入了S0和S1用于管理存活的对象年龄,所谓的年龄,就是minor GC 次数,注意是存活的对象的GC此时。这里的S0和 S1其实是角色互换的类型,也就是每次的GC,存活的对象会在 S0和 S1之间来回复制,再通过设置年龄阈值,进入老年代。
Java新对象的出生地(如果新创建的对象占用内存很大则直接分配给老年代)。当Eden区内存不够的时候就会触发一次minor GC,对新生代区进行一次垃圾回收。
保留了一次MinorGc过程中的幸存者。minor GC采用的是标记-复制的垃圾回收算法。这里的对象就是存活的对象复制得到的。注意,这些存活的对象可能来自 Eden 或者 S1。
minor GC的时候,存活的对象,和S0一致。存活的对象可能来自Eden 或者S0。
老年代的对象比较稳定,所以MajorGC不会频繁执行。对象先分配在年青代,经过多次 Young GC 后,如果对象还活着,晋升到老年代。如果老年代,最终也放满了,就会发生 major GC(即 Full GC),由于老年代的的对象通常会比较多,因为标记 - 清理 - 整理(压缩)的耗时通常会比较长,会让应用出现卡顿的现象,这也是为什么很多应用要优化,尽量避免或减少 Full GC 的原因。
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC 。
所谓的对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别到哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1 。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中的要求的年龄。
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC ,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这次也要改为进行一次 Full GC。
上面提到的“冒险”指的是,由于新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况,把 Survivor 无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
在 JDK 6 Update 24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的控件分配担保策略,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC。
一般来说,针对新生代和老年代的对象存活的特性,进行特异的垃圾回收策略。
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为 0 的对象就是不可能再被使用的。这种算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但它很难解决对象之间相互循环引用的问题。
这个算法的基本思路就是通过一系列额称为“GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连或者说这个对象不可达时,则证明此对象是不可用的。
算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它主要有两个不足的地方:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而得不到提前触发另一次垃圾收集动作。
它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。不足之处是,将内存缩小为原来的一半,代价太高。
例子:新生代中的对象98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。将 Eden 和 Survivor 的大小比例设为 8:1 ,也就是每次新生代中可用内存空间为整个新生代容器的 90%,只有10% 的内存作为保留区域。
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。所以在老年代一般不能直接选用复制收集算法。标记过程仍然与 “标记—清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
根据新生代和老年代的特性,进行回收策略的选取,进而能高效的回收垃圾的过程。也就是上面的两个步骤的术语。
实现了标记-复制算法的程序。有单线程的,也有多线程的。Serial ,ParNew, Parellel Scavenge
Serial 收集器 单线程用标记 - 复制算法,快刀斩乱麻,单线程的好处避免上下文切换,早期的机器,大多是单核,也比较实用。但执行期间,会发生 STW(Stop The World)。
ParNew 收集器 Serial 的多线程版本,同样会 STW,在多核机器上会更适用。
Parallel Scavenge 收集器 ParNew 的升级版本,主要区别在于提供了两个参数:-XX:MaxGCPauseMillis 最大垃圾回收停顿时间;-XX:GCTimeRatio 垃圾回收时间与总时间占比,通过这 2 个参数,可以适当控制回收的节奏,更关注于吞吐率,即总时间与垃圾回收时间的比例。
实现了标记-整理算法的程序。也是有单线程和多线程的实现。
Serial Old 收集器 因为老年代的对象通常比较多,占用的空间通常也会更大,如果采用复制算法,得留 50% 的空间用于复制,相当不划算,而且因为对象多,从 1 个区,复制到另 1 个区,耗时也会比较长,所以老年代的收集,通常会采用“标记 - 整理”法。从名字就可以看出来,这是单线程(串行)的, 依然会有 STW。
Parallel Old 收集器 Serial Old 的多线程版本。
CMS是基于 标记-清除 算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片。CMS是老年代垃圾收集器,在收集过程中可以与用户线程并发操作。它可以与Serial收集器和Parallel New收集器搭配使用。CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。可以通过JVM启动参数:-XX:+UseConcMarkSweepGC来开启CMS。
这是CMS中两次stop-the-world事件中的一次。这一步的作用是标记存活的对象,有两部分:
标记老年代中所有的GC Roots对象
标记年轻代中活着的对象引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;
从“初始标记”阶段标记的对象开始找出所有存活的对象;
因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代;
并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;
如下图所示,也就是节点1、2、3,最终找到了节点4和5。并发标记的特点是和应用程序线程同时运行。并不是老年代的所有存活对象都会被标记,因为标记的同时应用程序会改变一些对象的引用等。
由于这个阶段是和用户线程并发的,可能会导致concurrent mode failure。
这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。
这个阶段,重新标记的内存范围是整个堆,包含_young_gen和_old_gen。为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做cms的“gc root”,来扫描老年代; 因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次ygc,回收掉年轻带的对象无用的对象,并将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间。
由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。
另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled。
通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间;
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:
-XX:+CMSScavengeBeforeRemark。
在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。
从官网的描述中,我们知道G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的: * 像CMS收集器一样,能与应用程序线程并发执行。 * 整理空闲空间更快。 * 需要GC停顿时间更好预测。 * 不希望牺牲大量的吞吐性能。 * 不需要更大的Java Heap。
G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色: * G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。 * G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
G1中每个Region大小是固定相等的,Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
理论上讲,只要有一个 Empty Region(空区域),就可以进行垃圾回收。
如何确保新生代对象被老年代引用的时候不被gc?(查询老年代对象来确认对新生代对象的引用避免误回收)。但是,当老年代中的对象比较多的时候,全盘扫描的话,会消耗巨大的性能。
card table存放了所有老年代对象对新生代对象的引用。
所以每次minor gc通过查询card table来避免查询整个老年代,以此来提高gc性能。
G1收集器
同样的,当Eden空间被占满之后,就会触发YGC。在G1中YGC依然采用复制存活对象到survivor空间的方式,当对象的存活年龄满足晋升条件时,把对象提升到old generation regions(老年代)。
G1控制YGC开销的手段是动态改变young region的个数,YGC的过程中依然会STW(stop the world 应用停顿),并采用多线程并发复制对象,减少GC停顿时间。
RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。例如,对于Old的分区中,我们可以不用去全盘扫描老年代中的对象谁被新生代引用了,直接查表,就可以知道哪个老年代的对象被其他的region中的对象引用了。
infoQ
Java内存溢出(OOM)异常完全指南
排查 Java 的内存问题
JVM OOM 排查
注:文中主要做的是信息的整合工作,如果没有写到的参看博文,请和我说明,我会附上,并真诚地道歉!