JVM - 核心知识

2020/07/14

JVM内存结构

下图来自: cyc

分别介绍一下:

  • 程序计数器

线程私有,用于记录当前线程正在执行的字节码指令的地址(如果正在执行的是本地方法则为空)。

  • JVM栈

也叫方法栈、Java虚拟机栈,是线程私有的,线程在执行每个方法时都会创建一个栈帧

其中包含局部变量表返回信息操作数栈常量池引用

  • 本地方法栈

用于保存native方法的相关信息。

JVM所管理的最大的一块内存,被所有线程所共享,主要用于存放对象实例。

堆不需要连续内存,并且可以动态增加内存,若增加失败则会抛出OutOfMemeoryError异常。

可以通过-Xms-Xmx这个两个虚拟机参数来指定一个程序的堆内存大小。

第一个参数设置初始值,第二个参数设置最大值。

  • 方法区

它也是各个线程所共享的内存区域,用于存储已被虚拟机加载的类信息静态变量常量等。

为了更容易管理方法区,从JDK1.8开始,并将方法区移至元空间[metaspace]

它位于本地内存中,而不是虚拟机内存中。

方法区是一个JVM规范,永久代元空间都是它的一种实现方式。

JDK1.8之后,原来永久代的数据被分到堆和元空间中,

元空间存储类的元信息,而堆存储静态变量和常量池。

分代

由于对象存活时间的不同,可以将堆划分为两个区域:

新生代[Young Generation]老年代[Old Generation]

来看下示意图:

新生代

新生代用来存放新生的对象,一般占据堆的1/3空间,

由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。

新生代又分为:EdenSurvivor FromSurvivor To三个区。

  • Eden

Java新对象的出生地(如果新创建的对象占用的内存很大,则直接分配到老年代)。

Eden区内存不足的时候就会触发MinorGC,对新生代区进行一次垃圾回收。

  • ServivorFrom

上一次GC的幸存者,作为这一次GC的被扫描者。

  • ServivorTo

保留了一次MinorGC过程中的幸存者。

那么MinorGC的过程是怎么样的?

主要分为三步: 复制 -> 清空 -> 互换

第一步,复制。

首先,将EdenSurvivorFrom区域内存活的对象复制到SurvivorTo区域,

并将这些对象的年龄+1,如果SurvivorTo区间不够则放入老年代。

如果有对象的年龄已经达到了老年的标准,默认15,也进入老年代。

第二步,清空。

然后,清空EdenSurvivorFrom区域中的对象。

第三步,互换。

最后,互换SurvivorFromSurvivorTo区域,原SurvivorTo称为下一次MinorGC时的SurvivorFrom区。

老年代

老年代主要存放应用程序中生命周期较长的内存对象。该区域的对象比较稳定,所以MajorGC不会频繁执行,

在进行MajorGC前一般会先进行一次MinorGC

如果有新生代的对象需要晋升到老年代,但此时老年代内存不足,才会触发MajorGC

另一种触发场景是:需要创建一个较大的对象,而老年代找不到足够的连续内存空间。

MajorGC采用标记清除算法,首先扫描一次老年代区域,标记存活的对象,然后回收没有标记的对象。

MajorGC的耗时比较长,因为要扫描再回收,并会产生内存碎片。

类加载机制

类的生命周期

主要分为七步:

类的加载过程

主要分为五步,如上图所示。

重点需要掌握: 准备初始化

第一步,加载

.class文件通过二进制流的形式传输并加载到内存。

第二步,验证

校验传入的二进制流是否符合JVM的规范,如:

是否以魔数[caffe babe]开头?版本号是否正确?

第三步,准备

JVM会为类变量分配内存并初始化零值

有一个例外,静态成员变量同时被final关键字修饰,如:

public static final int a = 3

那么,此时a的值就不是0,而是3

因为它被final关键字修饰,一旦初始化,则永远不可能发生改变。

第四步,解析

主要任务就是将常量池中的符号引用替换为直接引用

第五步,初始化

根据执行顺序,分别执行静态代码块,或类成员变量的赋值语句

那么什么时候会触发初始化呢?

主要分为以下四种情况:

第一种,遇到newgetstaticputstaticinvokestatic这四条字节码指令时。

分别对应: 使用new关键字创建对象、读取或设置一个类的成员变量、调用类方法。

第二种,使用java.lang.reflect包中的方法对类进行反射。

第三种,父类优先原则,当初始化一个类时,如果它的父类还没进行初始化,则优先触发其父类的初始化。

第四种,当虚拟机启动时,它会初始化主类,也就是包含main()的类。

来看个例子:

public class Initialization {

    public Initialization() {
        System.out.println("执行构造函数");
    }

    public static void main(String[] args) {
        staticFunction();
    }

    {
        System.out.println("执行普通代码块");
    }
    int b = 100;

    static int a = 10;
    static {
        System.out.println("更改前,a = " + a);
        System.out.println("执行静态代码块");
        a = 20;
        System.out.println("更改后,a = " + a);
    }

    private static void staticFunction() {
        System.out.println("执行静态方法staticFunction()");
    }
}

返回什么?

更改前a = 10
执行静态代码块
更改后a = 20
执行静态方法staticFunction()

首先,当Java代码编译成字节码后,会有两个初始化方法。

分别为: 类初始化方法对象初始化方法

其中,类初始化方法包含静态代码块类成员变量的赋值语句,并按照它们出现的顺序排列。

那么上面的例子中,对于的类初始化方法如下:

static int a = 10;
static {
    System.out.println("更改前,a = " + a);
    System.out.println("执行静态代码块");
    a = 20;
    System.out.println("更改后,a = " + a);
}

再来看下对象初始化方法,它包含什么?

普通代码块普通成员变量的赋值语句构造函数

注意前两者按照其出现的顺序排列,而构造函数永远在最后。

对于上面的例子:

{
    System.out.println("执行普通代码块");
}
int b = 100;
public Initialization() {
    System.out.println("执行构造函数");
}

然后,我们再来进行分析:

首先,当我们执行main()方法时,JVM会加载Initialization类。

在初始化阶段执行上面的类初始化方法,所以返回:

更改前a = 10
执行静态代码块
更改后a = 20

注意,此时并没有创建对象,所以没有执行对象初始化方法

接着,调用staticFunction()方法,则返回:

执行静态方法staticFunction()

升级一下,下面的代码会返回什么?

public class Initialization {

    public Initialization() {
        System.out.println("执行构造函数");
    }

    public static void main(String[] args) {
        staticFunction();
    }

    {
        System.out.println("执行普通代码块");
    }
    int b = 100;

    static Initialization init = new Initialization();
    static int a = 10;
    static {
        System.out.println("更改前,a = " + a);
        System.out.println("执行静态代码块");
        a = 20;
        System.out.println("更改后,a = " + a);
    }

    private static void staticFunction() {
        System.out.println("执行静态方法staticFunction()");
    }
}

返回:

执行普通代码块
执行构造函数
更改前a = 10
执行静态代码块
更改后a = 20
执行静态方法staticFunction()

因为执行类成员变量init的赋值语句时,需要创建Initialization对象,

所以执行了对象初始化方法,所以先返回:

执行普通代码块
执行构造函数

类加载器

主要分为以下三种:

  • 启动类加载器: 负责加载jre/lib/目录下的Java核心类。
  • 扩展类加载器: 负责加载jre/lib/ext目录下的类。
  • 应用程序类加载器: 负责加载classpath目录下的类。

双亲委派机制的加载流程是怎样的?

当一个类加载器需要加载某个类时,会先把这个请求委托给自己的父类加载器去执行,

并一直向上委托,直至顶层的启动类加载器。

如果父类加载器能够完成类加载,则成功返回,否则子加载器自己尝试加载。

这样加载的好处是什么?

第一,避免类的重复加载。 第二,保证Java核心类的安全稳定。

垃圾回收与算法

如何确定垃圾

  • 引用计数法

Java中,引用和对象是有关联的。 如果要操作对象则必须用引用进行,

因此,一个简单的办法就是通过引用计数来判断一个对象是否可以回收。

但是会存在循环引用的问题。

  • 可达性分析

为了解决引用计数的循环引用问题,Java使用了可达性分析的方法。

通过一系列的"GC roots"对象作为起点搜索。

如果在GC roots和一个对象之间没有可达路径,则称该对象是不可达的。

需要注意,不可达对象不等价于可回收对象,不可达对象至少要经历两次标记过程才会变成可回收对象。

标记清除算法(Mark - Swap)

最基础的垃圾回收算法,分为两个阶段: 标记清除

标记阶段标出所有需要回收的对象,清除阶段则回收被标记的对象所占用的空间,如图:

该算法最大的问题就是内存碎片化严重,后续可能会导致大对象无法找到可用空间的问题。

复制算法(Copying)

为了解决标记-清除算法的内存碎片化问题而提出的算法。

按内存容量将内存划分为大小相等的两块,每次只使用其中一块,

当这一块内存满后,将还存活的对象复制到另一块上去,并把清掉已用内存,如图:

这种算法实现简单,不易产生碎片,但是可空内存被压缩到了原来的一半。

标记整理算法(Mark - Compact)

结合以上两种算法,标记阶段与标记-清除算法相同,标记后不是清理对象,

而是将存活的对象移到内存的一端,然后清除其边界外的空间,如图:

分代收集算法

它的核心思想是根据对象存活时间的不同,将堆内存划分为新生代老年代

那么新生代有什么特点?

每次MinorGC都有大量垃圾需要被回收,所以可以使用复制(Copying)算法,因为复制的操作比较少。

老年代的特点是什么?

每次MajorGC只有少量垃圾需要被回收,所以可以采用标记-整理(Mark-Compact)算法。

Java 四种引用类型

强引用

Java中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。

当一个对象被强引用变量引用时,它处于可达状态,因此是不能被垃圾回收机制回收的,

举个例子:

public class StrongReferenceDemo {

    private static final int N = 1024 * 1024;

    public static void main(String[] args) {

        Runtime rt = Runtime.getRuntime();
        System.out.println("初始状态: " + rt.freeMemory() / N + "M(free)");
        byte[] arr = new byte[4 * N];
        System.out.println("创建数组: " + rt.freeMemory() / N + "M(free)");

        System.gc();
        System.out.println("垃圾回收: " + rt.freeMemory() / N + "M(free)");

        arr = null;
        System.gc();
        System.out.println("断开引用: " + rt.freeMemory() / N + "M(free)");
    }
}

// -----------------------------
//    初始状态: 14M(free)
//    创建数组: 10M(free)
//    垃圾回收: 10M(free)
//    断开引用: 14M(free)

软引用

软引用需要使用SoftReference类来实现,对于软引用对象来说,只有系统内存不足时,它才会被回收。

弱引用

弱引用需要用WeakReference类来实现,它比软应用的生存期更短,

一旦进行垃圾回收,不管JVM的内存空间是否足够,总会回收该对象占用的内存。

虚引用

虚引用需要PhantomReference类来实现,它不能单独使用,必须和引用队列联合使用。

虚引用的主要作用是跟踪对象被垃圾回收的状态。

GC垃圾收集器

Java堆内存被划分为新生代老年代两个部分,

新生代主要使用复制垃圾回收算法,而老年代主要使用标记-整理垃圾回收算法。

因此Java虚拟机针对新生代和老年代提供了多种不同的垃圾收集器,

JDK1.6中Sun HotSpot虚拟机的垃圾收集器如下:

Serial(单线程、复制算法)

Serial,英文是连续的意思,是最基本的垃圾收集器,

它的特点是单线程、使用复制算法。

它只会使用一个CPU或一条线程取完成垃圾收集工作,并在进行垃圾收集时,

必须暂停其他所有工作线程,直至垃圾收集结束。

ParNew(Serial+多线程)

ParNew垃圾收集器其实是Serial收集器的多线程版本。

它的特点是多线程,且也使用复制算法。

ParScavenge(多线程、复制算法、高吞吐量)

Parallel Scavenge收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾器。

它的特点在于追求高吞吐量,也就是最高效率地利用CPU时间。

SerialOld(单线程、标记-整理算法)

Serial OldSerial垃圾收集器的老年代版本,

它同样是个单线程的收集器,使用标记-整理算法。

ParOld(多线程、标记-整理算法)

Parallel Old收集器是Parallel Scavenge的老年代版本,

使用多线程标记-整理算法,在JDK1.6才开始提供。

JDK1.6之前,新生代使用Parallel Scavenge收集器只能搭配老年代的Serial Old收集器。

只能保证新生代的吞吐量优先,却无法保证整体的吞吐量,Parallel Old收集器正是解决了这个问题。

CMS(多线程、标记-清除算法、低停顿)

CMS的全称是Concurrent Mark Sweep,它也是一种老年代垃圾收集器。

它的特点是保证在垃圾回收期间的停顿时间最短,并且使用多线程标记-清除算法。

整个过程主要分为四个阶段:

  • 初始标记

标记一下GC Roots能直接关联的对象,速度很快,不过仍然需要暂停所有工作线程。

  • 并发标记

进行GC Roots跟踪的过程,和工作线程一起工作,不需要暂停它们。

  • 重新标记

重新标记在上一步发生标记变化的对象,仍然需要暂停所有工作线程。

  • 并发清除

清除GC Roots不可达对象,和工作线程一起工作,需要暂停它们。

G1(多线程、标记-整理算法、吞吐量与停顿的平衡)

它的特点是:

第一,基于多线程标记-整理算法,不产生内存碎片。

第二,可以非常精确地控制停顿时间,在不牺牲吞吐量的前提下,实现低停顿垃圾回收。

G1收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,

并跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,

每次根据所允许的收集时间,优先回收垃圾最多的区域。

区域的划分和优先级策略确保G1收集器可以在有限的时间内获取最高的垃圾收集效率。

参考


一位喜欢提问、尝试的程序员

(转载本站文章请注明作者和出处 姚屹晨-yaoyichen

Post Directory