JVM基础

1. JVM体系结构概述

JVM是运行在操作系统之上的,它与硬件没有直接的交互,下图示JVM体系结构图:

JVM体系结构概览

1. 类装载器(ClassLoader)

负责加载class文件,class文件在文件开头有特定的文件标识(cafe babe),将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构;并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

ClassLoader

1.1 类加载器的种类

  • 虚拟机自带的加载器
    • Bootstrap ClassLoader:C++语言写的根平台加载器,系统类加载器
    • Extension ClassLoader:Java语言写的扩展类加载器,JDK9之后为平台类加载器(PlatformClassLoader)
    • Application ClassLoader:应用程序类加载器
  • 用户自定义加载器
    • Java.lang.ClassLoader的子类,用户可以定制类的加载方式

1.2 双亲委派机制

当一个类收到了类加载请求,它首先不会去尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一层次类加载器都是如此,因此所有的加载请求都应该传递到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求时(它自己的加载路径下无法找到所需的类),子类加载器才会尝试自己去加载。

  • 好处:采用双亲委派的一个好处就是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同一个Object对象。

双亲委派机制保证了沙箱安全

2. 执行引擎

Execution Engine执行引擎负责解释命令,提交给操作系统执行。输入的是字节码文件,处理过程是字节码解析,输出的是执行结果

3. 本地接口

Native Interface本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java诞生时正是C/C++横行的时候,要想立足,必须有调用C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是在Native Method Stack中登记native方法,在Execution Engine执行时加载native libraies(本地方法库)。

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备;在现在的企业级应用中已经很少见了。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等。

3.1 Native Method Stack

本地方法栈的具体做法是再其方法栈中登记native方法,在Execution Engine执行时加载本地方法库。

4. 程序计数器

程序计数器是每个线程私有的一块非常小的内存空间,可以把它看做当前线程所执行的字节码的行号指示器或者看做一个指针,指向的是当前正在执行的字节码指令,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,完成诸如分支、循环、跳转、异常处理、线程恢复等基础功能。

4.1 JVM多线程的实现方式

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间(时间片轮转算法)的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

4.1 程序计数器的特点

  1. 线程隔离性:每个线程工作时都拥有自己独立的计数器;
  2. 执行Java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址;
  3. 执行Native方法时,程序计数器的值为空,因为Native方法不是Java执行,故不会产生相应字节码,也就不需要记录和执行;
  4. 程序计数器占用内存非常小,在进行JVM内存计算时,可以忽略不计;
  5. 程序计数器在Java虚拟机规范中没有规定任何OOMError区域,故不会发生内存溢出(OutOfMemory=OOM)错误。

5. 方法区

供各线程共享的运行时内存区域。它存储了每个类的结构信息,就是模板;方法区中存储了例如运行时常量池、类型常量池、类变量、类型信息(方法数据、构造函数和普通方法的字节码内容)等。上面讲的是规范,具体实现不同虚拟机是不同的,最典型的就是永久代(PermGen Space)和元空间(Mate Space)。

5.1 方法区的发展历程

  1. JDK6:开发者团队开始准备放弃永久代,逐步改为采用本地内存来实现方法区;
  2. JDK7:将放在永久代里的字符串常量池、静态变量等移出;
  3. JDK8:完全废除永久代概念,改用本地内存实现元空间,永久代中剩余内容(如运行时常量池)全部移至元空间。

5.2 Java中几种常量池的区分

  1. 全局字符串池:就是我们常说的字符串常量池,存放的内容是类加载完成后在堆中生成字符串对象实例的引用值
  2. class文件常量池:class文件除了包含类的模板信息,还包含类常量池,用于存放编译器生成的各种常量和符号引用(描述引用目标)。
  3. 运行时常量池:当类加载到内存中运行时,JVM就会将class常量池中的内容存放到运行时常量池中,运行时常量池每个类都有一个。

6. Java栈

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命周期是跟随线程的生命周期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象引用的变量+实例方法都是在函数的栈内存中分配的。

6.1 栈帧

栈帧中主要保存3类数据:

  • 本地变量(Local Variables):输入参数和输出参数以及方法内的变量;
  • 栈操作(Operand Stack):记录出栈、入栈的操作;
  • 栈帧数据(Frame Data):包括类文件、方法等。

栈帧:在java中的方法,到了栈中就是栈帧;即方法=栈帧。

6.2 栈运行原理

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集;当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3被压入栈,以此类推…;执行完毕后,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧….,遵循“先进后出(Frist In Last Out)”原则。

每个方法执行同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。栈的大小和具体JVM的实现有关,通常在256K~756K之间,约等于1MB左右。

Java Stack

如上图示在一个栈中有两个栈帧:

  • 栈帧2是最先被调用的方法,先入栈;
  • 然后方法2又调用了方法1,栈帧1处于栈顶的位置;
  • 栈帧2处于栈底,执行完毕后,依次弹出栈帧1和栈帧2;
  • 线程结束,栈释放。

每执行一个方法都会产生一个栈帧,保存到栈的顶部,顶部栈就是当前的方法,该方法执行完毕后会自动将此栈帧出栈。

6.3 堆栈溢出

Java默认的栈大小是有限的,当一个程序不停的入栈但是不出栈,当栈被压满后爆栈了,就会产生错误Exception in thread "main" java.lang.StackOverflowError

6.4 堆+栈+方法区的交互关系

堆栈方法区

HotSpot是使用指针的方式来访问对象的:Java堆中会存放访问类元数据(就是访问方法区的模板)的地址,reference存储的就直接是对象的地址(堆中存放的都是new的实际对象数据)。

  • 栈管运行,堆管存储

  • HotSpot是Java目前使用范围最广的Java虚拟机。

2. 堆体系结构概述

Java堆是垃圾收集器管理的内存区域,从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,因此Java堆中常会出现“新生代”“老年代”这些名词。要注意的是这种区域划分仅仅是部分垃圾收集器的共同特性或者是设计风格而已,而非某个JVM具体实现的固有内存布局。将Java堆细分的目的只是为了更好的回收内存,或者更快的分配内存。

2.1 Heap堆结构简介

一个JVM实例只存在一个堆内存,堆内存的大小是可调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存分为三部分:

  • Young Generation Space(新生区),简称Young/New
  • Tenure Generation Space(养老区),简称Old/Tenure
  • Permanent Space(永久区),简称Perm

Heap

上图就是Java7之前的堆结构体系图,Java8之后永久存储区改名为了元空间

2.2 Heap堆new对象流程

新生区是类诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分:伊甸区(Eden Space)和幸存者区(Survivor Space),所有类都是在伊甸园区被new出来的。幸存者区有两个:0区(Survivor 0 Space)和1区(Survivor 1 Space)。

当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC,又称轻GC),将伊甸园区中的无引用对象进行销毁。然后将会将伊甸园中剩余的对象移动到幸存者0区;若幸存者0区也满了,再对该区域进行垃圾回收,然后移动到1区。如果1区也满了,将移动到养老区,若最后养老区也满了,那么这时将产生(Major GC,又叫Full GC),对养老区的内存进行清理。若养老区多次执行Full GC后依然无法腾出空间进行对象保存,就会产生OOM异常:OutOfMemoryError.

如出现java.lang.OutOfMemoryError:Java heap sapce,说明Java虚拟机的堆内存不够,原因有二:

  1. Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用,故无法GC)。

2.3 TransferValue

面试题:str打印什么?

public class TestTransferValue {
    public void changeValue(String str){
        str = "xxx";
    }

    public static void main(String[] args) {
        String str = "abc";
        test.changeValue(str);
        System.out.println("String------"+str);
    }
}

2.4 对象生命周期和GC

Java堆从GC的角度还可细分为新生代(Eden 区、Form Survivor区和To Survivor区)和老年代,堆结构如下图:

Heap

2.4.1 MinorGC的过程:(复制->清空->互换)

  • 复制:Eden、SurvivorFrom复制到SurvivorTo,年龄+1

首先,当Eden区满的时候会触发第一次GC,把还活着的对象拷贝到SurvivorFrom区,当Eden区再次触发GC时会扫描Eden区和From区,对这两个区域进行垃圾回收;经过这次回收后还存活的对象,则直接拷贝到To区(如果有对象年龄达到老年标准,则复制到老年代区),同时把这些对象的年龄+1

  • 清空:清空Eden、SurvivorFrom

复制操作完毕后,会清空Eden和SurvivorFrom中的对象。

  • 互换:SurvivorTo和SurvivorFrom互换

最后,SurvivorTo和SurvivorFrom身份互换,即复制之后有交换,谁空谁是To,原SurvivorTo成为下一次GC时的SurvivorFrom区。部分对象会在From和To区域中来回复制,增加年龄;如此交换15次(由JVM参数MaxTenuringThreshold决定,该参数默认15),最终还存活的,就会进入老年代。

2.4.2 HotSpot内存管理

分代管理

物理

上堆实际只有新生代和老年代,Java中98%的对象是临时对象,在Eden生Eden死,只有极少数才能到老年代。

2.5 永久代(元空间)

方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)”,但严格本质上说两者不同,或者说是使用永久代实现方法区而已,永久代是方法区的一个实现(相当于方法区是一个接口规范,而永久代就是接口实现),JDK7的版本中,已经将原本放在永久代的字符串常量池移走,移动到堆中了。

2.5.1 永久区(Java7之前)

永久存储区是一个常驻内存区域,用于存放JDK自身所携带的Class,Interface的元数据(比如rt.jar包内容、Spring框架必须jar包等等),也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭JVM才会释放此区域所占有的内存。

3. 堆参数调优入门

以下内容以JDK1.8+Hotspot为例。

Heap

JDK1.8后将永久代取消了,由元空间取代,元空间的本质和永久代类似;元空间与永久代之间最大的区别在于:永久代使用的是JVM的堆内存,但Java8以后的元空间并不在虚拟机中而是使用本机物理内存。

因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入Native Memory,字符串池和类的静态变量放入Java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

3.1 堆参数调整

以下是堆的几个重要的参数:

  • -Xms:初始大小,默认为物理内存的1/64;
  • -Xmx:最大分配内存,默认为物理内存的1/4;
  • -XX:+PrintGCDetails:输出详细的GC处理日志。

Java将运行时数据区抽象成了一个类:Runtime,使用这个类的一些API可以查看一些堆参数

public class HeapDemo {
    public static void main(String[] args) {
        System.out.println("CPU核心数:"+Runtime.getRuntime().availableProcessors());
        System.out.println("-Xms:TOTAL_MEMORY = 虚拟机中的内存总量:"+(Runtime.getRuntime().totalMemory())/(double)1024/1024+"MB");
        System.out.println("-Xmx:MAX_MEMORY = 虚拟机试图使用的最大内存量:"+(Runtime.getRuntime().maxMemory()/(double)1024/1024)+"MB");
    }
}

3.2 GC收集日志信息

为了清晰明了的查看堆中的各种GC情况,编写以下测试类测试内存溢出,并查看GC详细日志。

  • 编写测试类
public class HeapDemo {
    public static void main(String[] args) {
        String str = "hello";
        while (true){
            // Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
            str += str + new Random().nextInt(88888888) + new Random().nextInt(99999999);
        }
        //        byte[] bytes = new byte[40 * 1024 * 1024];
    }
}
  • 在IDEA中配置参数:-Xms10m -Xmx10m -XX:+PrintGCDetails,将堆的内存设小。

Heap Parameter

  • 运行程序,查看日志信息。

GCLog

  • GC信息查看模板如下:

GC

  • FGC模板

FGC

4. 垃圾回收算法

4.1 GC是什么(分代收集算法)

GC是Java的垃圾回收机制,主要可划分为“引用计数式垃圾收集”和“追踪式垃圾收集”。

现在主流的商业虚拟机的垃圾收集器,遵循了“分代收集”的理论进行设计,建立在两个分代假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕死的。
  2. 强分代假说:熬过越多次垃圾收集的对象就越难以消亡。

基于这两个假说奠定了多款常用垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据年龄分配到不同的区域中存储。

在Java堆划分出不同的区域之后,垃圾收集器才可根据情况只回收其中部分区域,因此才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型划分;才能根据不同区域的对象存亡特征选择其合适的垃圾收集器,诸如“标记-复制算法”、“标记-清除算法”、“标记-压缩算法”等。

4.2 GC算法概述

GC概述

JVM在进行GC时,并非每次都对上面三个内存区域一起回收,大部分时候回收的都是新生代。因此GC按照回收区域又分了两种类型,一种是普通GC(Minor GC),一种是全局GC(Major GC or Full GC)。

4.2.1 回收类型划分

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中细分如下:
    • 新生代收集(Minor GC/Young GC):指目标仅是新生代的垃圾收集;
    • 老年代收集(Major GC/Old GC):指目标仅是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

4.2.2 Minor GC原理

堆结构图

Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区中放不下,那么剩下的存活对象就被移到Old代中,也即一旦收集后,Eden区就变成空的了。当对象在Eden(包括一个Survivor 区域,假设是From区域)出生后,经过一次Minor GC后,如果对象还存活,且能够被另外一块Survivor区域所容纳(上面假设是from区,那么这里就是to区,即to区域有足够的内存空间来存储Eden和from区中存活的对象),则使用复制算法将这些仍然存活的对象复制到另外一块Survivor区(即to区)中,然后清理所使用过的Eden区以及Survivor区(即from区), 并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,通过-XX:MaxTenuringThreshold来设定参数),这些对象就会成为老年代。

  • -XX:MaxTenuringThreshold :设置对象在新生代中存活的次数

4.3 判断对象是否存活

主要有两种方式,引用计数法和根搜索算法。

4.3.1 引用计数法

引用计数法

该算法给对象添加了一个引用计数器,被引用时,计数器值+1,引用失效时,计数器值-1。

JVM不使用它的原因是很难解决对象之间的循环引用问题。

4.3.2 可达性分析算法

该算法用于判定对象是否存活。这个算法的基本思路就是通过一些的名为“GC Roots”的对象作为起始节点集合,从这些节点开始根据引用关系向下搜索,搜索所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连时,就是从GC Roots到这个对象不可达,则证明此对象是不可用的

在Java语言中,可作为GC Roots的对象包括以下几种:

  1. 虚拟机栈中引用的对象,如线程调用的方法堆栈中使用到的参数,局部变量,临时变量等;
  2. 方法区中的类静态属性引用的对象
  3. 方法区中的常量引用的对象,如字符串常量池里的引用;
  4. 本地方法栈中JNI引用的对象。

4.3.3 Java四大引用

JDK1.2版本后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,引用强度依次递增。

  1. 强引用:最传统的引用定义,就是Object obj = new Object()这样在代码中普遍存在的引用赋值,只要强引用关系存在,被引用对象就不会被GC。
  2. 软引用:用于描述一些还有用,但不必要的对象。在系统将要发生OOM异常前,会将这些对象列入回收范围进行二次回收。JDK提供了SoftReference类来实现软引用。
  3. 弱引用:用于描述一些非必须对象,强度比软引用更弱一点;弱引用关联的对象只能生存到下一次GC发生为止,当GC收集器工作时,弱引用关联的对象都会被回收掉。JDK提供WeakRefererence来实现弱引用。
  4. 虚引用:最弱的一种引用关系,对象是否有虚引用不会产生任何影响,设置虚引用的唯一目的就是为了在该对象被GC时收到一个系统通知。JDK提供了PhantomReference来实现虚引用。

简言之:

  • 强引用对象引用关系存在,不会被回收
  • 软引用对象系统内存不足时会被GC
  • 弱引用对象发生GC时必然被回收
  • 虚引用用于对象被GC时发送系统通知

4.4 三大算法之:复制算法(Copying)

复制算法是一种适用于年轻代的GC算法

4.4.1 复制算法详细运行步骤

HotSpot JVM将年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1(一种更优化的半区复制分代策略,现称为Appel式回收),一般情况下,新创建的对象都会被分配到Eden区(大对象会直接移到老年代中),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区

对象在Survivor区中每熬过一次Minor GC,年龄就会增加一岁,当它的年龄到一定程度后,就会被移动到老年代。因为年轻代中的对象基本都是朝生夕死(根据IBM的研究表明,新生代98%以上对象熬不过第一轮收集),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面,因此复制算法是不会产生内存碎片的。

复制算法

在GC开始时,对象只会存在于Eden区和名为”From”的Survivor区,Survivor区的”To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到”To”,而在”From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值的对象会被移到到年老代中,没有达到阈值的对象会被复制到”To”区域。

经过这次GC后,Eden区和From区已经被清空。这时,”From”和”To”会交换他们的角色,也就是新的”To”就是上次GC前的”From”,新的”From”就是上次GC前的”To”。

不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到”To”区被填满。当大量对象在Minor GC后仍然存活的情况下导致”To”区被填满,就需要老年代进行分配担保,将Survivor无法容纳的对象直接进入老年代。

分配担保机制:在发生Minor GC之前,JVM会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果大于那么就可以确保这次Minor GC是安全的;如果小于新生代对象总空间,则JVM会查看-XX:HandlerPromotionFailure参数是否允许担保失败;如果允许就会检查老年代剩余可用空间是否大于历次晋升到老年代对象的平均大小,如果大于则说明老年代可能还是放的下年轻代过来的对象,可能可以继续担保,会尝试进行一次Minor GC,尽管是有风险的;如果小于或者参数设置不允许冒险担保,那么就会改为进行一次Full GC,让老年代腾出空间。

复制算法图示

上图:绿色是空闲空间,红色是存活对象,黄色是不可用对象。

因为Eden区对象一般存活率较低,通常使用两块10%的内存作为空闲和活动区间,另外80%的内存用于给新建对象分配内存的。

一旦发生GC,将10%的from活动区间与另外80%中存活的Eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,依次类推。

4.4.2 复制算法的劣势

  1. 它会浪费了一半内存;
  2. 如果对象的存活率过高,假设是100%,那么需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变得不可忽视。

简言之:复制算法优点速度快无内存碎片,缺点耗内存,空间换时间。

4.5 三大算法之:标记清除(Mark-Sweep)

老年代一般是由标记清除或者是标记清除+标记整理的混合实现。

标清+标整就是让JVM平时采用标清,暂时容忍内存碎片的存在,当内存碎片的碎片化程度大到影响对象分配时,再采用标整算法收集一次,以获得规整的内存空间。CMS收集器就是使用的这种。

4.5.1 原理

算法分成标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象

标记清除

用通俗的话解释一下标记清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停(stop the world),随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。

主要进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:从引用根节点开始遍历所有的GC Roots, 先标记出要回收的对象。
  • 清除:遍历整个堆,把标记的对象清除。

4.5.2 标记清除的优缺点

  • 优点:不需要额外的空间,节省内存
  • 缺点:
    • 首先,它的缺点就是执行效率不稳定(递归与全堆对象遍历),标记和清除的执行效率会随着对象数量增长而降低;而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲。
    • 其次,主要的缺点则是这种方式清理出来的空闲内存是不连续的,死亡对象都是随机的出现在内存的各个角落的,现在把它们清除之后,内存的布局会变得乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而在分配数组对象的时候,连续的内存空间寻找会非常麻烦。

简言之:标记清除算法优点节省内存,缺点效率不稳定且会产生内存碎片;时间换空间

4.6 三大算法之:标记压缩(Mark-Compact)

又称为标记整理算法,适合老年代的垃圾回收算法,与标清类似。

4.6.1 原理

标记压缩

在整理压缩阶段,不再对标记的对象做回收,而是通过所有存活对象都向一端移动,然后直接清除边界以外的内存。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

标记-整理算法不仅可以弥补标记-清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价;当然并非没有缺点。

4.6.2 劣势

标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

简言之:优点不会产生内存碎片,内存使用率高,缺点效率不高。

4.7 GC小结

GC算法对比:

  • 内存效率:复制算法>标记清除算法>标记整理算法(只是简单的对比时间复杂度,实际情况不一定)
  • 内存整齐度:复制算法=标记整理算法>标记清除算法
  • 内存利用率:标记整理算法=标记清除算法>复制算法

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程

难道就没有一种最优算法吗? 回答:无,没有最好的算法,只有最合适的算法。==========>分代收集算法。

  • 年轻代(Young Gen)

年轻代特点是区域相对老年代较小,对像存活率低。 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

  • 老年代(Tenure Gen)

老年代的特点是区域较大,对像存活率高。 这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。

Mark阶段的开销与存活对像的数量成正比,这点上说来,对于老年代,标记清除或者标记整理有一些不符,但可以通过多核/线程利用,对并发、并行的形式提标记效率。

Sweep阶段的开销与所管理区域的大小形正相关,但Sweep“就地处决”的特点(原地操作),回收的过程没有对像的移动。使其相对其它有对像移动步骤的回收算法,仍然是效率最好的。但是需要解决内存碎片问题。

Compact阶段的开销与存活对像的数据成开比,如上一条所描述,对于大量对像的移动是很大开销的,做为老年代的第一选择并不合适。

基于上面的考虑,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。以hotspot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对像的回收效率很高,而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器做为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。

5. 垃圾收集器

如果收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的就具体实现。

HotSpot虚拟机所包含的所有收集器如下:

HotSpotJVM1.6的GC收集器

上图展示了7种作用不同的分代收集器,如果两个收集器之间存在连线,说明可以搭配使用。

新生代收集器:Serial、PraNew、Parallel Scavenge

老年代收集器:Serial Old、Parallel Old、CMS

5.1. Serial收集器

最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程收集器,单线程一方面意味着它只会使用一个CPU或者一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。这说明要在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用是难以接受的。

不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大,收集几十兆或几百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿完全是可以接受的。Serial收集器运行过程如下图:

Serial

简言之:Serial是一个采用复制算法的单线程收集器,会产生Stop the World暂停用户线程进行GC操作;是Client模式下默认的新生代收集器,简单而高效。

5.2. ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的算法也是复制算法。

ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,除了Serial收集器外,目前只有它能和CMS收集器配合工作(如图)。

CMS收集器是一款几乎可以认为有划时代意义的垃圾收集器,因为它第一次实现了让垃圾收集线程与用户线程基本同时工作。ParNew收集器在单CPU环境中绝对不会比Serial收集器有更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分百保证可以超越Serial收集器。当然,随着CPU数量的增加,它对于GC时系统资源的有效利用还有很有好处的。它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。ParNew收集器运行过程如下:

ParNew

简言之:ParNew是Serial收集器的多线程版本,会暂停所有用户线程执行GC操作;Server模式下首选的新生代收集器,在CPU核心更多的环境下会有更好的性能表现。

5.3. Parallel Scavenge收集器

Parallel Scavenge收集器也是一个新生代收集器,使用的复制算法,也是并行的多线程收集器,但它的特点是它的关注点和其他收集器不同。这个收集器主要引入了一个吞吐量的概念。

CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量的意思就是CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。另外,Parallel Scavenge收集器是虚拟机运行在Server面模式下的默认垃圾收集器。

停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率利用CPU时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。

虚拟机提供了-XX:MaxGCPauseMillis-XX:GCTimeRatio两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。不过不要以为前者越小越好,GC停顿时间的缩短是以牺牲吞吐量和新生代空间换取的。由于与吞吐量关系密切,Parallel Scavenge收集器也被称为“吞吐量优先收集器”。Parallel Scavenge收集器有一个-XX:+UseAdaptiveSizePolicy参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden区和Survivor参数等细节参数了,虚拟机会根据当前系统的运行情况和性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择

简言之:Parallel Scavenge收集器是一个新生代复制算法并行的多线程收集器,其与ParNew的区别在于吞吐量的概念和自适应调节策略,该收集器的目的是达到一个可控制的吞吐量,故Parallel Scavenge收集器也被称为“吞吐量优先收集器”

吞吐量:吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,越高越好。

5.4. Serial Old收集器

Serial收集器的老年代版本,同样是一个单线程收集器,使用”标记-整理算法“,这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

5.5. Parallel Old收集器

Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在JDK 1.6之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。运行过程如下图所示:

Parallel Old

简言之:Parallel Old是Parallel Scavenge收集器的老年代版本,使用标记整理算法的多线程收集器;适用于吞吐量及CPU资源敏感的场合下与Parallel Scavenge收集器进行组合应用。

5.6. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。使用标记-清除算法,收集过程分如下四步:

  1. 初始标记,标记GCRoots能直接关联到的对象,时间很短。
  2. 并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长。
  3. 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。
  4. 并发清除,回收内存空间,时间很长。

其中,并发标记和并发清除两个阶段耗时最长,但可以与用户线程并发执行。运行过程如下:

CMS

CMS是一款优秀的收集器,优点在于并发收集,低停顿,官方文档也称之为并发低停顿收集器,但它也有一些缺点:

  1. 对CPU资源非常敏感,可能会导致应用程序变慢,吞吐率下降。
  2. 无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾,同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。
  3. 由于采用的标记 - 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC。虚拟机提供了-XX:+UseCMSCompactAtFullCollection参数来进行碎片的合并整理过程,这样会使得停顿时间变长,虚拟机还提供了一个参数配置,-XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,接着来一次带压缩的GC。

5.7. G1(Garbage-First)收集器

G1是目前收集器技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器;G1是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能。

与其他GC收集器相比,G1收集器有以下特点:

  1. 并行和并发:G1能充分利用CPU多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。
  2. 分代收集:虽然可以独立管理整个GC堆,但是仍然保留了分代的概念,能够采用不同的方式去处理新创建对象和已经存活了一段时间,熬过多次GC的旧对象,以获取更好的收集效果。
  3. 空间整合:整体来看基于标记-整理算法实现的收集器;从局部来看是基于复制算法实现的,故无内存碎片产生。
  4. 可预测的停顿:能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。

G1收集器运行大致步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了GF收集器在有限时间内可以尽可能高的收集效率。

使用java -XX:+PrintCommandLineFlags -version命令可以查看当前Java使用的垃圾收集器。

6. JVM小结

6.1 小结&面试题

  1. JVM结构
    1. 有哪几种类加载器?
    2. 双亲委派机制
    3. 沙箱安全
  2. Native
    1. native是一个关键字么?
    2. native方法只有声明,没有实现。
  3. 寄存器
    1. 记录了方法之间的调用和执行情况,类似排班值日表;用来存储指向下一条指令的地址,也即将要执行的指令代码,它是当前线程所执行的字节码的行号指示器
  4. 方法区
    1. 线程共享的运行时内存区域,存储了类的结构信息(模板)
    2. 是一个JVM规范,具体实现不同虚拟机是不同的,例如永久代和元空间
    1. 栈内存主管Java程序运行,生命周期跟随线程生命周期,不存在GC问题。
    2. 栈帧中主要存储3类数据:本地变量、栈操作、栈帧数据。
    3. 栈运行原理?
  5. 堆内存
    1. 堆内存模型:新生区,养老区、永久代
    2. 堆new对象流程?新生区内存划分
    3. 轻GC的过程
    4. 什么是永久代?
    5. -Xms-Xmx是干嘛的?
  6. GC
    1. GC是什么?
    2. Minor GC和Full GC的区别?
    3. Minor GC的工作原理?
    4. GC有哪些算法?
    5. 垃圾收集器
    6. GC使用哪个算法最好?

7. JMM

JMM(Java内存模型Java Memory Model),是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了Java虚拟机在计算机内存中的工作方式。JVM是整个计算机虚拟模型,所以JMM隶属于JVM。抽象角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间共享变量存储在主内存中,每个线程都有自己私有的本地内存,本地内存中存储了该线程读写共享变量的副本。

JMM关于同步规范规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

Java的并发采用的是共享内存模型

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(或称之为栈空间),工作内存是每个线程的私有数据区域。Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完毕后将变量写回主内存。不能直接操作主内存中的变量,各个线程中的工作内存中储存着从主内存中变量的副本拷贝,因此不同的线程无法访问对方的工作内存,线程间的通讯(传值)必须通过主内存来完成,其简要访问过程如下图:

JMM

6.1 JMM的可见性问题

示例代码如下:

class myNumber{
    // int number = 10;
    // 使用volatile关键字解决共享对象可见性问题
    volatile int number = 10;
    public void addNumber(){
        this.number = 2020;
    }
}
public class JMMDemo {
    public static void main(String[] args) {
        myNumber myNumber = new myNumber();

        new Thread(()->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myNumber.addNumber();
            System.out.println(Thread.currentThread().getName()+"\t update number,number value:"+myNumber.number);
        },"AAA").start();

        // 由与JMM的可见性问题,AAA线程修改了number变量,对于其他线程不可见,故main线程while一直处于死循环
        while (myNumber.number==10){
            // 需要一种通知机制告诉main线程,number的值已经修改过了

        }
        System.out.println(Thread.currentThread().getName()+"\t mission is over");

    }
}

上述代码中有两个线程,AAA线程和main线程,AAA线程会在自己的工作内存中操作number变量,修改其值为2020,main线程会一直循环判断number的值是否修改过,修改过则退出循环。执行代码会发现,AAA线程修改完number的值并写回主内存后,main线程的while一直处于死循环状态,这就是JMM的可见性问题

解决这个问题很简单,使用volatile修饰number变量,让其保证可见性;这样AAA线程修改完毕后,主内存会通知main线程number变量值已经被修改过了,需更新变量副本为最新值,此时main线程就会去主内存重新获取最新的变量值,这样while就会结束死循环。

参考与鸣谢

本文参考了以下文章来帮助学习和了解,特此感谢!

  1. 尚硅谷周阳JVM课程,感谢阳哥!
  2. 《深入理解Java虚拟机》—周志明著
  3. Java中几种常量池区分
  4. JMM和底层实现原理
  5. Java垃圾回收机制详解
  6. 程序计数器

  转载请注明: Zero的博客 JVM基础

 上一篇
NIO NIO
NIO相关API1. NIO与IO的区别Java NIO(New IO)是从Java1.4 版本开始引入的一个新的IO API,可以代替标准的Java IO API。 NIO与原来的IO有同样的作用和目的,但是使用方式完全不同,NIO支持面
2020-04-09
下一篇 
MySQL主从复制 MySQL主从复制
主从复制 主从复制(也称 AB 复制)允许将来自一个MySQL数据库服务器(主服务器)的数据复制到一个或多个MySQL数据库服务器(从服务器)。 1. 复制的基本原理slave会从master读取binlog来进行数据同步。 MySQ
2020-03-30
  目录