编程语言
首页 > 编程语言> > 深入理解java虚拟机第一第二部分(周志明第三版)

深入理解java虚拟机第一第二部分(周志明第三版)

作者:互联网

文章目录

深入理解java虚拟机(周志明第三版)

第一部分、Java的前世今生和未来展望

1.1、概述

java的特点:

1.2、java技术体系

①从广义上讲:

Kotlin、Clojure、JRuby、Groovy等运行在java虚拟机上的编译语言及其相关程序都属于Java技术体系的一员。

②从传统意义上:JCP(Java Community Process:java社区)官方定义的java技术体系:

java程序设计语言

各种硬件平台上的java虚拟机实现

Class文件格式

Java类库API

第三方java类库

JDK:java程序设计语言、Java虚拟机、Java类库;(支持Java开发的最小开发环境)

JRE:JRE支持Java程序运行的标志环境,由Java类库中的java se API子集+Java虚拟机组成

JVM:Java虚拟机(很多种、不同厂商不同)

下面的图来自原书:
在这里插入图片描述

③从技术关注的重点业务上划分:

  • Java Card:支持java小程序,运行在小内存设备(如智能卡)的平台
  • Java ME(Micro Edition):支持Java程序运行在移动终端(手机、Pad)上的平台,注意!!!现在的智能手机流行的Android不属于JME(从JAVA的历史可以发现理由,google利用java开发的安卓,被Oracle告侵权的问题,使得对应的虚拟机不兼容Oracle的JDK);
  • Java SE(Standard Edition):支持面向桌面级(Windows等)Java平台;
  • Java EE(Enterprise Edition):企业使用,JDK里面作了大量的针对性的扩充,如javax这些包都是第三方扩充包,甚至有些还被并入了java.lang包里面,但是命名不变。

1.3、Java发展史(只记录比较重要的)

自己做了一个图:

在这里插入图片描述

1.4、Java虚拟机家族

始祖:Sun Classic/Exact VM(纯解释性执行)------------》Exact VM(使用准确式内存管理);

最强:HotSpotVM :热点代码探测技术、准确式内存管理;

小家碧玉:Mobile/Embedded VM ,主打移动端、嵌入市场;

天下第二:BEA JRockit/IBM J9 VM(两个、其中IBM 模块化OpenJ9适合源码阅读)

Adnrod的虚拟机

微软的JVM及其他小型虚拟机。

1.5、展望Java技术的未来

1.6、自己动手编译JDK、IDE里面调试

保留…更新

第二部分、自动内存管理

2、Java内存区域与内存溢出异常

2.2、虚拟机内存管理

Java虚拟机在执行java程序的过程会把它管理的内存划分为若干个不同的数据区域,这些数据区域有对应自己的用途、创建、销毁时间。
在这里插入图片描述

一、程序计数器——指令位置

内存较小的一块区域,它可以看作当前执行线程所执行的字节码的行号指示器,字节码解释器通过修改该计数器的值选取下一条要执行的字节码指令(如分支、顺序、循环、异常处理、跳转、线程恢复等)。

任何一个时刻,一个CPU只能执行一个线程中的一条指令,因此,为了线程切换之后能够恢复正常执行位置,则每条线程都有一个独立的计数器,也就是说程序计数器是线程私有的,非共享区域。

当线程执行的是一个java方法,计数器记录的是正在执行的虚拟机机器码指令的地址,如果执行的是native方法,该计数器的值为空(Undefined)

该内存区域是唯一没有在Java虚拟机规范中规定任何的OutOfMemoryError的区域。

二、Java 虚拟机栈(VM Stack)——非native方法的执行

一个非本地方法执行——创建一个栈帧(Stack Frame),方法的执行到执行完毕,对应了栈帧从虚拟机栈的入栈和出栈!

栈帧里面有什么呢?

在这里插入图片描述

虚拟机栈也是线程私有的(不共享,它的生命周期同线程一样。)

涉及异常(Error):

Java虚拟机栈涉及的异常有(StackOverflowerError、OutOfMemoryError):

三、本地方法栈(Native Method Stack)——本地方法执行

类似于Java虚拟机栈,不过它是用来给本地方法执行的。可能抛出的异常同虚拟机栈(StackOverflowerError、OutOfMemoryError)。也是私有的。

Native方法——其他的语言实现的、如C\C++编写的方法。

Java虚拟机栈为虚拟机执行的java方法(字节码)服务。而本地方法栈则执行Native方法。

书本有一句话:HotSpot直接把本地方法栈和虚拟机栈合二为一:

在这里插入图片描述

四、Java 堆(heap)——存储对象实例的、也是内存最大的一块

该区是共享区域。并且所有线程共享的Java堆中,可以划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。用来提升对象分配时的效率。

java堆里面存储的都是对象的实例,该堆可以被实现为:

Java虚拟机规定:Java堆可以实现在不连续的内存空间中逻辑上却被视为是连续的。

五、方法区(Method Area)

方法区和Java堆一样,是一个各个线程共享的内存区域,它用于存储已经被虚拟机加载的类的:

Java虚拟机规范:将方法区描述为堆的一个逻辑部分,但是它有一个别名:(Non-Heap)非堆,目的是和堆区分开来。

永生代:JDK8以前,很多程序员愿意称方法区为“永生代(Permanent Generation)”,原因是HotSpot虚拟机设计者为了使用永生代的方法实现方法区的管理(这样做不需要额外写垃圾回收等管理代码)。但是容易出现内存溢出问题。

实际上Java虚拟机规范:不要求具体对方法区如何管理。

为了HotSpot的未来,JDK6的时候,放弃永久代,采用了本地内存(Native Memory)实现了方法区的相关计划,JDK7以后,改用同IBM J9、JRockit一样在本地内存中实现的元空间(Meta Space)。

方法区可能出现的问题:

方法区里面还有一个重要的区域————运行时常量池

运行时常量池

Java虚拟机对Class文件的每一部分(包括常量池)都有严格的规定,如每一个字节用于存储哪种数据都必须符合要求才会被虚拟机认可、加载、执行。但是关于运行时常量池,Java虚拟机规范没有做任何的细节要求。

在这里插入图片描述

运行时常量池的另外一个重要的特性是具备动态性,Java并不要求只有编译期才能产生常量(即并非只有编译期常量才能进入常量池),运行期间也可以将新的常量池放入池中,如String类的intern()方法。

涉及的溢出:OutOfMemoryError(申请内存不够时)

七、直接内存(Direct Memory)——不属于运行时数据区域的一部分

该区域不是Java虚拟机规范中的内存区域,但是它却被频繁使用,也可能出现OutOfMemoryError。

实际使用例子:

JDK1.4加入了NIO(New Input/Output)类,引入了一种基于通道(channel)与缓冲区(buffer)的I/O方式,它使用了Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuff对象作为对这块内存进行操作,某些场景下显著提升性能,避免在Java堆和Native堆中来回复制数据。

2.3、HotSpot虚拟机对象的探究(如何创建、布局、访问一个对象)

(1)、对象的创建

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

如果没有,必须先执行相应的类的加载过程。

类检查通过之后,虚拟机会为新生的对象分配内存(在堆里面划分出和对象大小的区域出来)

具体的分配策略看实现,如果Java堆的内存是绝对规整的,那么利用了空闲区指针移动的方法,指针往空闲区移动一部分(该对象的大小),这种分配方式叫做“指针碰撞(Bump The Pointer)”

如果Java堆并非规整,则利用类似一些操作系统的方法,空闲区列表(FreeList),按照空闲区列表来划分,然后再维护对应的空闲区。相对复杂。

java堆是否规整取决于所采用的垃圾收集器是否带有空间压缩算法(如CMS就采用了Sweep算法)

分配了内存,即修改指针的指向。但是这样却引发了一个问题——并发时创建对象的时候,创建A的时候指针还没来及滑动这个时候线程B抢占,也用了这个指针来创建对象。我们就需要解决方案了:

对象的内存分配完成以后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为0,如果使用了TLAB该项工作也可以提前到TLAB分配时进行。

从虚拟机的角度,这个时候,对象已经诞生了,但是从java程序上,对象才刚刚开始构造函数的运行。

这个时候Class文件里的<init>方法还没有执行,所有的字段均为默认值,对象需要的其他资源和状态信息也没有按照预期构造好。

<init>方法的执行取决于class字节码流中new指令后是否跟着invokespecial决定,Java编译器会遇到了new关键字的地方同时生成这两条字节码指令。之后执行<init>方法,按照我们编写的代码执行对象的初始化。

(2)对象的内存布局

HotSpot虚拟机里,对象在堆内存的存储布局可以被划分为三部分:

在这里插入图片描述

(3)对象的访问与定位

Java程序通过栈上的reference数据来操作堆上的具体对象。Java虚拟机规范只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置。

比较主流的访问方式:

在这里插入图片描述

在这里插入图片描述

2.4、OutOfMemoryError实战

1、heap的OutOfMemoryError

首先配置当前项目的vm参数:

新建一个测试OOM的项目,然后点击Run-》Edit Configuration-》“+”–》Application,填写参数:

在这里插入图片描述

测试代码:

package OutOfMemoryErrorPractice;

import java.util.ArrayList;
import java.util.List;

/**
 * @author 雨夜※繁华
 * @date 2021/3/20 16:18
 *
 * 使用VM arg:
 * -verbose:gc -Xms20M -Xmn20M -XX:+HeapDumpOnOutOfMemoryError -XX:SurvivorRatio=8
 */
public class Heap_OOM {
    static class OOMObject {//空静态内部类

    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true){
            list.add(new OOMObject());
        }
    }
}

在这里插入图片描述

在这里插入图片描述

不知道为什么我这里创建的文件大小是4.84G,该文件书本描述要使用内存映像文件分析工具才能打开。首先对Dump出来的堆转储快照进行分析,然后确认内存中导致OOM的对象是否必要的,也就是要分清楚到底是出现了内存泄漏(Memory Leak)还是OOM。

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎么样的引用链、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们。根据泄漏对象的类型信息,以及它到GC Roots引用链的信息,一般可以比较准确定位到这些对象的创建位置,进而找出产生内存泄漏的代码的具体位置。

如果不是内存泄漏,即内存中的对象确实都是必须存活的,就应当检查java虚拟机的堆参数(-Xms和-Xmm)设置,与机器的内存比对,看看是否还有向上调整的空间。再从代码上检查是否存在这些某些对象的生命周期过长,持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期间的内存消耗。

2、虚拟机栈和本地方法栈的溢出

HotSpot虚拟机中并不分虚拟机栈和本地方法栈,所以-Xoss参数(设置本地方法栈的大小)虽然存在,但是实际上是没有任何效果的。栈的容量只能由-Xss参数来决定。这里可能出现的异常:StackOverflowerError、OutOfMemoryError

HotSpot虚拟机不支持动态扩展栈的内存,所以除非在创建线程时申请内存时就因无法获得足够内存而出现OOM,否则线程运行时不会因为扩展而导致内存溢出,只会因为栈容量无法容纳新的栈帧而导致StackOverflowerError。

(1)实验一:

使用-Xss参数减少栈内存容量

结果抛出StackOverflowerError,异常出现时输出堆栈深度相应缩小。

测试代码:

在这里插入图片描述

package OutOfMemoryErrorPractice;

/**
 * @author 雨夜※繁华
 * @date 2021/3/20 16:57
 *
 * VM arg:
 *
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak(){
        stackLength++;
        stackLeak();//递归
    }
    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF stackOOM = new JavaVMStackSOF();
        try{
            stackOOM.stackLeak();
        }catch (Throwable e){
            System.out.println("stack length: "+stackOOM.stackLength);
            throw e;
        }

    }
}

经过测试,128K是无法创建虚拟机的,我改成了1M,就可以运行了:

在这里插入图片描述

不同操作系统,底层内存分页的大小不一样。64位JDK11至少在180K。Linux可能是228K

(2)实验二:

定义大量的本地变量,增大此方法栈帧中的本地变量表的长度

结果抛出StackOverflowerError,异常出现时输出堆栈深度相应缩小。

在这里插入图片描述

小结:实验结果表明无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配时,HotSpot虚拟机抛出的都是StackOverflowError,如果是运行动态扩展栈容量大小的虚拟机上,相同代码则会导致不一样的情况。譬如远古时期的Classic虚拟机,该虚拟机可以扩展,则调整栈容量使用oos参数,可能就得到了OutOfMemoryError了。

32windows(内存最大支持4G)的单个进程最大内存限制为2G,因为操作系统也是一个进程,它占用了至少2G,剩下给一个进程至多2G。

利用该特点进行的实验三可能会让操作系统死机,这里就不尝试了。(64位太难了。)

在这里插入图片描述

3、方法区和运行时常量池溢出

package OutOfMemoryErrorPractice;

import java.util.HashSet;
import java.util.Set;

/**
 * @author 雨夜※繁华
 * @date 2021/3/20 21:36
 * VM arg:
 *  -XX:PermSize=6M -XX:MaxPermSize=6M
 这个是永久代的参数配置,JDK8移除了
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用Set保存常量池引用,避免Full GC回收常量池行为
        Set<String> set = new HashSet<>();
        //在short范围内足以产生让6MB的PermSize OOM
        short i = 0;
        while(true){
            set.add(String.valueOf(i));
        }
    }
}

在这里插入图片描述

在这里插入图片描述

package OutOfMemoryErrorPractice;

/**
 * @author 雨夜※繁华
 * @date 2021/3/20 21:47
 * <p>
 * 测试运行时常量池的实现
 * JDK7.0之后,字符串常量池移动到了heap区,
 * String.intern()_JDK6时,该方法会把首次遇到的字符串实例复制到永久代字符串的常量池,同时返回的也是该常量池里面的引用
 * 但是JDK7.0后,intern方法不需要拷贝了,因为常量池已经移动到了堆中,只需要机里首次出现了实例的引用,然后返回即可。
 * String str1 = new StringBuilder("aa").append("bb").toString();
 * System.out.println(str1.intern()==str1);
 * String str2 = new StringBuilder("js").append("va").toString();
 * System.out.println(str2.intern()==str2);
 */
public class StringInternTest {
    public static void main(String[] args) {
        String str1 = new StringBuilder("aa").append("bb").toString();
        System.out.println(str1.intern() == str1);
        //true,因为在执行toString时,产生的aabb是首次产生的,
        // str1.intern()方法得到了是该字符串在堆的字符串常量池里的引用

        //false,因为虚拟机本身会创建java的常量池字符串,所以str2.intern()方法返回的是该字符串常量池里面的引用,如果没有则会创建再返回
        //而str2则是新创建的java,
        //它们的地址肯定不一样。
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
        //如果把ja改为js则为true
        String str3 = new StringBuilder("js").append("va").toString();
        System.out.println(str3.intern() == str3);
    }
}

在这里插入图片描述

关于其他实验就不做了:

在这里插入图片描述

4、本机直接内存溢出

package OutOfMemoryErrorPractice;

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * @author 雨夜※繁华
 * @date 2021/3/20 22:01
 * 本机直接内存溢出
 * VM args:
 *  -Xms20M -XX:MaxDirectMemorySize=10M
 *  该参数指定直接内存的大小
 *
 *  越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配
 *  (Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,
 *  体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,
 *  在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用) ,
 *  因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,
 *  但它抛出异常时并没有真正向操作系统申请分配内存, 而是通过计算得知内存无法分配就会
 *  在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe::allocateMemory()。
 *
 */
public class DirectMemoryOOM {
    public static final int _1MB = 1024*1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);

        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while(true){
            unsafe.allocateMemory(_1MB);
        }

    }
}

在这里插入图片描述

由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况, 如果读者发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO) , 那就可以考虑重点检查一下直接内存方面的原因了。

3、垃圾收集器与内存分配策略

3.1、概述

垃圾回收诞生得很早,它关注于:

现在的垃圾回收,内存动态分配技术相当成熟,但是我们还需要去了解垃圾收集和内存分配,原因就是当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们必须对这些“自动化”技术实施必要的监控、调节。

垃圾回收的范围(Java堆、方法区):

3.2、如何判断对象已经死了?

Java堆中存放了几乎所有的对象实例,而垃圾回收器对堆进行垃圾回收之前,必须要确定:

(1)引用计数算法

在对象中添加了一个引用计数器,每当有一个地方引用该对象,则计数器加1,当引用失效,则计数器的值减1;任何时刻,计数器的值为0的对象就不再是被使用的。

应用:微软的COM(Component Object Model)、ActionScripts的FlashPlayer、Python语言、游戏力的Squirrel等。

评判:原理很简单,判断高效。但是在java领域,无法处理循环、交叉引用的问题,需要配合大量的额外处理才能保证正确的工作。

证明:

在这里插入图片描述

(2)可达性分析算法——Java采用

可达性分析算法很容易理解:由一系列的GC Roots的根引用对象作为起始节点集,从这些节点开始,由引用关系向下搜索,搜索过程走过的路径称为”引用链“。如果某个对象到GC Roots间没有引用链相连,则表明从GC ROOTs到该对象是不可达的,该对象不再被使用,判断为可以被回收。

在这里插入图片描述

主流的商用语言的内存管理系统都是通过可达性(Reachability Analysis)算法来判断对象是否存活的。

那么GC Roots如何建立?包含了哪些对象?

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

3.3、再谈引用(4种引用类型——强、软、弱、虚)

首先我们必须明确一点——无论是通过引用计数法还是可达性分析法来判断一个对象是否存活,都是离不开引用的。

我就画个图来表示吧,这样清晰一点:

在这里插入图片描述

强度从上往下依次递减。

3.4、生存还是死亡(如何观察一个对象是否被回收)

可达性算法中的细节是怎么样的呢?
在这里插入图片描述

下面实战演示一个对象的自救活动:

package Collection_algorithm;

/**
 * @author 雨夜※繁华
 * @date 2021/3/23 15:20
 * 演示一个对象被回收到F-Queue队列中等待回收的自救过程
 *
 */
public class FinalizeEscapeGC {

    //创建一个静态内部类(它一般作为GC roots的集合)
    public static FinalizeEscapeGC SAVE_HOOK = null;//SAVE_HOOK只是一个引用
    public void isAlive(){
        System.out.println("hello,I am is alive!");
    }
    @Override
    public void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;//自救活动
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();//是对象被回收,对象没有引用链

        //对象第一次拯救自己
        SAVE_HOOK = null;//将引用链断开
        System.gc();//提醒gc回收

        //这个时候内部可能在执行finalize方法,但是优先级很低,这里暂停0.5s,等待它
        Thread.sleep(500);

        //
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no ,i am dead:(");
        }

        //再来一次,看是否能够自救

        SAVE_HOOK = null;//将引用链断开
        System.gc();//提醒gc回收

        //这个时候内部可能在执行finalize方法,但是优先级很低,这里暂停0.5s,等待它
        Thread.sleep(500);

        //
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no ,i am dead:(");
        }
    }
}

在这里插入图片描述

为什么第二次回收的时候逃脱失败,而第一次却成功了呢?

这是因为任何一个对象的finalize()方法都只会被系统自动调用一次, 如果对象面临下一次回收, 它的finalize()方法不会被再次执行, 因此第二段代码的自救行动失败了。

finalize方法的评价:

因为它并不能等同于C和C++语言中的析构函数, 而是Java刚诞生时为了使传统C、 C++程序员更容易接受Java所做出的一项妥协。 它的运行代价高昂, 不确定性大, 无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。 finalize()能做的所有工作, 使用try-finally或者其他方式都可以做得更好、更及时, 所以笔者建议大家完全可以忘掉Java语言里面的这个方法。

3.5、回收方法区

有人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,Java虚拟机规范也不强制要求(如JDK11的ZGC收集器不支持类的卸载。)

但是如果要回收方法区,主要回收两部分:

判定一个常量是否“废弃”还是相对简单, 而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。 需要同时满足下面三个条件 :

Java虚拟机被允许对满足上述三个条件的无用类进行回收, 仅仅是“被允许”, 而并不是和对象一样, 没有引用了就必然会回收。 关于是否要对类型进行回收, HotSpot虚拟机提供了-Xnoclassgc参数进行控制。

在大量使用反射、 动态代理、 CGLib等字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力, 以保证不会对方法区造成过大的内存压力。

3.6、垃圾收集算法(三种垃圾收集算法——基于分代收集理论)

从如何判断对象消亡的角度,我们有:

(1)分代收集理论

有大佬提出了分代假说(两个法则):

推出:Java收集器应当将java堆内存进行划分不同区域,依据回收对象的年龄(对象熬过垃圾收集的次数)分配到不同的区域中存储。

进而我们有了:Minor GC、Major GC、Full GC三种回收类型(力度和收集的区域不同),也产生了针对不同类型区域的与对象死亡特征想匹配的垃圾收集算法:

分代收集理论存在的问题——新生代对象,如果被年老代引用,这个时候,可达性分析不仅扫描GC Roots,还要扫描整个年老代对象中的所有对象,存在很大的性能负担。

所以添加了第三个经验法则:

结论:我们不应为了少量的跨代引用去扫描整个年老代,只需要在新生代生建立一个全局数据结构(记忆集,Remembered Set,一个map)。这个数据结构可用把年老代分为若干小块,标识出哪一块内存可能存在跨代引用,之后当发生Minor GC时,只有包含跨代引用的小块对象才会加入GC Roots中扫描。(对象改变引用时,要维护该表的正确性,添加了一点运行时开销)

现在统一定义(不同虚拟机可能有差别):

部分收集(Partial GC):目标不再是完整收集整个Java堆的垃圾,划分为:

(2)标记-清除法(Mark Sweep)——最古老最基础

地位:最早出现最为基础的垃圾收集算法 提出者:Lisp之父John Mclarthy

原理:垃圾收集分为两个阶段——标记、清除。

优缺点:

(3)标记——复制算法(Copying算法,从半区复制到优化)

提出的目标:为了解决标记清除算法面对大量可回收对象时执行效率低的问题。 提出者:Feniche提出了“半区复制”;

原理:将整个可用堆空间划分成两半,只有一半是用来使用的,当当前块的内存用完了,便将还存活的对象复制到另外一块,然后对当前的块整块清除。两个内存块角色交换。

在这里插入图片描述

优缺点:

优化:

最早的HotSpot虚拟机的Serial、ParNew等新生代收集器则采用了Appel式回收——新的标记复制算法(不是半区复制),因为新生代对象中98%挨不过第一轮的收集,因此并不需要按照1:1比例来划分新生代的内存空间。

HotSpot等的内置新生代收集器的新生代内存布局:

原理:每次使用Eden和其中一块Survivor,当发生垃圾收集时,将Eden和该Suivivor中仍然存活的对象一次性复制到另外一块Survivor区,然后清除Eden和已使用的Survivor,之后两块Survivor角色交换。

可见:相比于半区复制,新的标记复制算法只浪费了一个Survivor大小的空间。

虽然98%对象挨不过第一次回收,但是却不绝对,我们也没有办法保证,故Appel式回收还有一个充当罕见情况下的“逃生门”安全设计——当Survivor区空间不足以一次容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上是年老代)来做分配担保(Handle Promotion)。

(4)标记-整理法(Mark Compact)

背景:标记复制算法在对象存活率比较高时要进行较多的复制将使得效率降低,更为关键的时,如果不想浪费50%空间,就需要额外提供内存分配担保以应对被使用内存中所有对象都100%存活的情况。所以老年代不能直接采用

针对年老代对象的消亡特征,Edward Lueders提出了针对性的标记整理法。

在这里插入图片描述

标记压缩对标记清除的差异:

是否移动回收后的存活对象是一项优缺点并存的风险决策。分析:

年老代:

所以基于分析有如下情况:

另外, 还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担, 做法是让虚拟机平时多数时间都采用标记-清除算法, 暂时容忍内存碎片的存在, 直到内存空间的碎片化程度已经大到影响对象分配时, 再采用标记-整理算法收集一次, 以获得规整的内存空间。 前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

通常标记-清除算法也是需要停顿用户线程来标记、 清理可回收对象的, 只是停顿时间相对而言要来的短而已。

3.7、HotSpot算法细节实现

(1)根结点枚举——可达性分析中从GC Roots集合找引用链的高效实现

所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的, 枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。 现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发, 但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行。

“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上, 不会出现分析过程中, 根节点集合的对象引用关系还在不断变化的情况, 若这点不能满足的话, 分析结果准确性也就无法保证。 这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因, 即使是号称停顿时间可控, 或者(几乎) 不会发生停顿的CMS、 G1、ZGC等收集器, 枚举根节点时也是必须要停顿的。

当用户线程停顿下来之后, 其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得到哪些地方存放着对象引用的。 在HotSpot的解决方案里, 是使用一组称为OopMap的数据结构来达到这个目的。 一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来, 在即时编译(见第11章) 过程中, 也会在特定的位置记录下栈里和寄存器里哪些位置是引用。 这样收集器在扫描时就可以直接得知这些信息了, 并不需要真正一个不漏地从方法区等GC Roots开始查找。

(2)安全点

在OopMap的协助下, HotSpot可以快速准确地完成GC Roots枚举, 但一个很现实的问题随之而来: 可能导致引用关系变化, 或者说导致OopMap内容变化的指令非常多, 如果为每一条指令都生成对应的OopMap, 那将会需要大量的额外存储空间, 这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上HotSpot也的确没有为每条指令都生成OopMap, 前面已经提到, 只是在“特定的位置”记录了这些信息, 这些位置被称为安全点(Safepoint) 。 有了安全点的设定, 也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集, 而是强制要求必须执行到达安全点后才能够暂停。

安全点的选取——安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的, 因为每条指令执行的时间都非常短暂, 程序不太可能因为指令流长度太长这样的原因而长时间执行, “长时间执行”的最明显特征就是指令序列的复用, 例如方法调用、 循环跳转、 异常跳转等都属于指令序列复用, 所以只有具有这些功能的指令才会产生安全点。

一个需要处理的问题: 如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程) 都跑到最近的安全点, 然后停顿下来。?

这里有两种方案可供选择: 抢先式中断(Preemptive Suspension) 和主动式中断(Voluntary Suspension) , 抢先式中断不需要线程的执行代码主动去配合, 在垃圾收集发生时, 系统首先把所有用户线程全部中断, 如果发现有用户线程中断的地方不在安全点上, 就恢复这条线程执行, 让它一会再重新中断, 直到跑到安全点上。 现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

而主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的, 另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方, 这是为了检查是否即将要发生垃圾收集, 避免没有足够内存分配新
对象。 它采用内存保护陷阱方法来把轮询操作精简至一条汇编指令的程序参考书本第124页:
在这里插入图片描述

(3)安全区域

使用安全点来保证了如何停顿用户线程,但是却存在致命缺陷——如果用户线程陷入了阻塞或者sleep,且不在安全点呢?

这时候线程无法响应虚拟机的中断请求, 不能再走到安全的地方去中断挂起自己, 虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。 对于这种情况, 就必须引入安全区域(Safe Region) 来解决。

安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此, 在这个区域中任意地方开始垃圾收集都是安全的。 我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时, 首先会标识自己已经进入了安全区域, 那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。 当线程要离开安全区域时, 它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段) , 如果完成了, 那线程就当作没事发生过, 继续执行; 否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。

(4)卡表(记忆集的具体实现),解决跨代引用问题

在垃圾收集的场景中, 收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了, 并不需要了解这些跨代指针的全部细节。

那设计者在实现记忆集的时候, 便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本, 下面列举了一些可供选择(当然也可以选择这个范围以外的) 的记录精度:

其中, 第三种“卡精度”所指的是用一种称为**“卡表”(Card Table)** 的方式去实现记忆集, 这也是目前最常用的一种记忆集实现形式, 一些资料中甚至直接把它和记忆集混为一谈。 前面定义中提到记忆集其实是一种“抽象”的数据结构, 抽象的意思是只定义了记忆集的行为意图, 并没有定义其行为的具体实现。 卡表就是记忆集的一种具体实现, 它定义了记忆集的记录精度、 与堆内存的映射关系等。

HotSpot的卡表使用字节数组实现。(以下这行代码是HotSpot默认的卡表标记逻辑 ):

CARD_TABLE (this address>>9) = 0;

在这里插入图片描述

(5)写屏障——卡表是如何维护?

卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时, 其对应的卡表元素就应该变脏, 变脏时间点原则上应该发生在引用类型字段赋值的那一刻。 但问题是如何变脏, 即如何在对象赋值的那一刻去更新维护卡表呢? 假如是解释执行的字节码, 那相对好处理, 虚拟机负责每条字节码指令的执行, 有充分的介入空间; 但在编译执行的场景中呢? 经过即时编译后的代
码已经是纯粹的机器指令流了, 这就必须找到一个在机器码层面的手段, 把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier) 技术维护卡表状态的 。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面, 在引用对象赋值时会产生一个环形(Around) 通知, 供程序执行额外的动作, 也就是说赋值的前后都在写屏障的覆盖范畴内。 在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier) , 在赋值后的则叫作写后屏障(Post-Write Barrier) 。 HotSpot虚拟机的许多收集器中都有使用到写屏障, 但直至G1收集器出现之前, 其他收集器都只用到了写后屏障。

void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障, 在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

应用写屏障后, 虚拟机就会为所有赋值操作生成相应的指令, 一旦收集器在写屏障中增加了更新卡表操作, 无论更新的是不是老年代对新生代对象的引用, 每次只要对引用进行更新, 就会产生额外的开销, 不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

并发下的伪共享问题:伪共享是处理并发底层细节时一种经常需要考虑的问题, 现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的, 当多线程修改互相独立的变量时, 如果这些变量恰好共享同一个缓存行, 就会彼此影响(写回、 无效化或者同步) 而导致性能降低, 这就是伪共享问题。

解决:假设处理器的缓存行大小为64字节, 由于一个卡表元素占1个字节, 64个卡表元素将共享同一个缓存行。 这64个卡表元素对应的卡页总的内存为32KB(64×512字节) , 也就是说如果不同线程更新的对象正好处于这32KB的内存区域内, 就会导致更新卡表时正好写入同一个缓存行而影响性能。 为了避免伪共享问题, 一种简单的解决方案是不采用无条件的写屏障, 而是先检查卡表标记, 只有当该卡表元素未被标记过时才将其标记为变脏, 即将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;  

(6)并发情况下的可达性分析——使用三色标记(白色、黑色、灰色)

曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的, 可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。

在根节点枚举(见3.4.1节) 这个步骤中, 由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数, 且在各种优化技巧(如OopMap) 的加持下, 它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长) 的了。

可从GC Roots再继续往下遍历对象图, 这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了: 堆越大, 存储的对象越多, 对象图结构越复杂, 要标记更多对象而产生的停顿时间自然就更长。

在这里插入图片描述

也就是说如果我们不采用并发标记,垃圾收集时,全程冻结用户线程的执行,该停顿时间随着Java堆容量比例有关。我们采用并发标记——就是为了减少该停顿时延 。

让垃圾回收器和用户线程同时运行,并发工作。也就是我们说的并发标记的阶段。但是并发标记带来了一些问题,我们需要解决的。而首先要描述这个问题,我们需要引入三色标记法

在遍历对象图的过程中,把访问都的对象按照**"是否访问过"这个条件**标记成以下三种颜色:

**白色:表示对象尚未被垃圾回收器访问过。**显然,在可达性分析刚刚开始的阶段,所有的对象都是白色的,在分析结束的阶段,仍然是白色的对象,即代表不可达。

**黑色:表示对象已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过。**黑色的对象代表已经扫描过,它是安全存活的,如果有其它的对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

灰色:表示对象已经被垃圾回收器访问过,但这个对象至少存在一个引用还没有被扫描过。

灰色对象是黑色对象与白色对象之间的中间态。当标记过程结束后,只会有黑色和白色的对象,而白色的对象就是需要被回收的对象。

如果用户线程此时是冻结的, 只有收集器线程在工作, 那不会有任何问题。 但是并发的时候呢?

扫描过程看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,灰色对象是黑色和白色对象的分界线,初始状态只有GC Roots为黑色,然后其下一个结点以灰色推进,灰色结点的下一个结点为白色,所以灰色为分界线。推进过程修改结点颜色。如下gif过程所示:

在这里插入图片描述

如果用户线程与收集器是并发工作, 收集器在对象图上标记颜色, 同时用户线程在修改引用关系——即修改对象图的结构, 这样可能出现两种后果。

具体的场景为:

1、上面的程序扫描到结点6,此时用户线程把它下面的9引用断开了。改为了由5引用。则此时:

在这里插入图片描述

出现了第二种情况,因为我们美好的情况下9是黑色(不用回收),不是垃圾,而现在被当成了垃圾。

2、最上面的程序扫描到8的时候,用户线程把8->11的引用链断开了,同时添加引用链7->11,出现了如下情况:

在这里插入图片描述

出现了对象消失的情况,10和11本来扫描后变成黑色,不用被回收,现在变成了白色,要被回收了。

并发标记出现了——浮动垃圾和对象消失两种情况,浮动垃圾不需要多麻烦,我们专注于解决对象消失的问题。

Wilson于1994年在理论上证明了, 当且仅当以下两个条件同时满足时, 会产生“对象消失”的问题, 即原本应该是黑色的对象被误标为白色:

因为两个条件是要同时满足才出现对象消失,所以只需要破坏两个条件中的任意一个就行。于是产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虚拟机中,CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

增量更新要破坏的是第一个条件, 当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。

原始快照要破坏的是第二个条件, 当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次。 这也可以简化理解为, 无论引用关系删除与否, 都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

两种方法都是通过写屏障实现。

参考博客:https://cloud.tencent.com/developer/article/1591439,图做的很好。

3.8、经典的垃圾收集器(只做简要图视总结)

不存在什么完美的垃圾收集器,只有对某个具体应用最合适的收集器。

首先我们先理解几个概念:

然后总结如下:

在这里插入图片描述

3.9、如何选择垃圾收集器、垃圾收集器的日志

如何选择垃圾收集器?

我们应该如何选择一款适合自己应用的收集器呢? 这个问题的答案主要受以下三个因素影响:

一般来说, 收集器的选择就从以上这几点出发来考虑。 举个例子, 假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器, 一般来说延迟时间是这类应用的主要关注点, 那么:

虚拟机垃圾收集器日志

垃圾收集器日志是一系列人为设定的规则, 多少有点随开发者编码时的心情而定, 没有任何的“业界标准”可言, 换句话说, 每个收集器的日志格式都可能不一样。 除此以外还有一个麻烦, 在JDK 9以前, HotSpot并没有提供统一的日志处理框架, 虚拟机各个功能模块的日志开关分布在不同的参数上, 日志级别、 循环日志大小、 输出格式、 重定向等设置在不同功能上都要单独解决。 直到JDK 9, 这种混乱不堪的局面才终于消失, HotSpot所有功能的日志都收归到了“-Xlog”参数上。

-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

实际测试:

在这里插入图片描述

3.10、内存分配和回收策略——实战

Java技术体系的自动内存管理, 最根本的目标是自动化地解决两个问题: 自动给对象分配内存以及自动回收分配给对象的内存。

书本上使用的是Serial加Serial Old来做的测试,可看出新生代分区等细节。但是我的是JDK11,测试不同于书本。但是可观察到G1收集器的工作过程

(1)对象优先在Eden分配

package MemoryAllocationStrategy;

/**
 * @author 雨夜※繁华
 * @date 2021/3/30 15:27
 * 实战:内存分配与回收策略
 *                  ——对象优先在Eden分配
 *
 *大多数情况下, 对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时, 虚拟机将发起
 * 一次Minor GC。
 *
 * HotSpot虚拟机提供了-XX: +PrintGCDetails这个收集器日志参数, 告诉虚拟机在发生垃圾收集行
 * 为时打印内存回收日志, 并且在进程退出的时候输出当前的内存各区域分配情况。 在实际的问题排查
 * 中, 收集器日志常会打印到文件后通过工具进行分析,
 * 将虚拟机的参数配置:
 *     VM参数:
 *     -verbose:gc
 *     -Xms20M
 *     -Xmx20M
 *     -Xmn10M
 *     -XX:+PrintGCDetails--------这里过时了,使用 -Xlog:gc*
 *     -XX:SurvivorRatio=8
 *
 * 测试思路:
 *  尝试分配三个2MB大小和一个4MB大小的对象, 在运时通过-Xms20M、 -Xmx20M、 -Xmn10M这三个参数限制了Java堆大小为20MB,
 *  不可扩展, 其中10MB分配给新生代, 剩下的10MB分配给老年代。 -XX: Survivor-Ratio=8
 *  决定了新生代中Eden区与一个Survivor区的空间比例是8∶ 1
 */
public class Test1_Eden {
    private static final int _1MB = 1024*1024;
    public static void testAllocation(){
        //4个字节数组
        byte[] A1,A2,A3,A4;
        A1 = new byte[2*_1MB];
        A2 = new byte[2*_1MB];
        A3 = new byte[2*_1MB];
        //分配4MB,因为Eden:survivor:survivor=8:1:1;可以用的只有90%,我们新生代大小10MB,即可用为9MB,这里超出了。
        //则触发一次Minor GC
        A4 = new byte[4*_1MB];
    }

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

输出日志:(很多,我只能摘选一些展示)

在这里插入图片描述

因为自己使用JDK11,难搞,需要安装JDK8才做的了。有点烦。

(2)大对象直接进入老年代

(3)长期存活的对象进入老年代

(4)动态对象年龄判定

(5)空间分配担保

半区复制或者基于复制算法才存在空间分配担保。

4、虚拟机性能监控、故障处理工具

(这些都不是这个时候看的,先跳过)

5、调优案例分析与实战

(这些都不是这个时候看的,先跳过)

标签:周志明,Java,第一第二,虚拟机,对象,线程,内存,引用
来源: https://blog.csdn.net/qq_44861675/article/details/115352954